Compare commits
1 commit
dev
...
desktop-v2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
67e232840e |
111 changed files with 5260 additions and 1091 deletions
6
bun.lock
6
bun.lock
|
|
@ -37,6 +37,7 @@
|
||||||
"@dnd-kit/helpers": "0.5.0",
|
"@dnd-kit/helpers": "0.5.0",
|
||||||
"@dnd-kit/solid": "0.5.0",
|
"@dnd-kit/solid": "0.5.0",
|
||||||
"@kobalte/core": "catalog:",
|
"@kobalte/core": "catalog:",
|
||||||
|
"@opencode-ai/client": "file:vendor/opencode-ai-client-1.17.13.tgz",
|
||||||
"@opencode-ai/core": "workspace:*",
|
"@opencode-ai/core": "workspace:*",
|
||||||
"@opencode-ai/schema": "workspace:*",
|
"@opencode-ai/schema": "workspace:*",
|
||||||
"@opencode-ai/sdk": "workspace:*",
|
"@opencode-ai/sdk": "workspace:*",
|
||||||
|
|
@ -798,6 +799,7 @@
|
||||||
"version": "1.18.4",
|
"version": "1.18.4",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@kobalte/core": "catalog:",
|
"@kobalte/core": "catalog:",
|
||||||
|
"@opencode-ai/client": "file:../app/vendor/opencode-ai-client-1.17.13.tgz",
|
||||||
"@opencode-ai/core": "workspace:*",
|
"@opencode-ai/core": "workspace:*",
|
||||||
"@opencode-ai/sdk": "workspace:*",
|
"@opencode-ai/sdk": "workspace:*",
|
||||||
"@opencode-ai/ui": "workspace:*",
|
"@opencode-ai/ui": "workspace:*",
|
||||||
|
|
@ -6028,6 +6030,8 @@
|
||||||
|
|
||||||
"@openauthjs/openauth/jose": ["jose@5.9.6", "", {}, "sha512-AMlnetc9+CV9asI19zHmrgS/WYsWUwCn2R7RzlbJWD7F9eWYUTGyBmU9o6PxngtLGOiDGPRu+Uc4fhKzbpteZQ=="],
|
"@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": ["@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=="],
|
"@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/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/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=="],
|
"@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=="],
|
||||||
|
|
|
||||||
220
packages/app/V1_API_MIGRATION.md
Normal file
220
packages/app/V1_API_MIGRATION.md
Normal file
|
|
@ -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.
|
||||||
|
|
@ -41,7 +41,12 @@ const assistants = Array.from({ length: 14 }, (_, index) => {
|
||||||
const messages = [user, ...assistants]
|
const messages = [user, ...assistants]
|
||||||
const target = fixture.sessions.find((session) => session.id === fixture.targetID)!
|
const target = fixture.sessions.find((session) => session.id === fixture.targetID)!
|
||||||
const lastID = userID
|
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("hydrates an orphaned latest turn after a cold session click", async ({ browser, report }, testInfo) => {
|
||||||
benchmark.setTimeout(180_000)
|
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 }
|
return { items: items.slice(start, end), cursor: start > 0 ? items[start]!.info.id : undefined }
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
await page.route(`**/session/${fixture.targetID}`, (route) =>
|
await page.route(`**/session/${fixture.targetID}`, (route) => {
|
||||||
route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(target) }),
|
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 installStressSessionTabs(page, { sessionIDs: [fixture.sourceID] })
|
||||||
await page.goto(stressSessionHref(fixture.sourceID))
|
await page.goto(stressSessionHref(fixture.sourceID))
|
||||||
await expectSessionTitle(page, fixture.expected.sourceTitle)
|
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,
|
parent: requests.filter((request) => request.type === "parent").length,
|
||||||
}
|
}
|
||||||
if (mode === "candidate") {
|
if (mode === "candidate") {
|
||||||
expect(requestCounts.parent).toBe(1)
|
expect(requestCounts.parent).toBe(0)
|
||||||
expect(historyGates).toBe(1)
|
expect(historyGates).toBe(0)
|
||||||
}
|
}
|
||||||
return { metrics, requestCounts, historyGateCount: historyGates }
|
return { metrics, requestCounts, historyGateCount: historyGates }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { expect, test, type Page, type Route } from "@playwright/test"
|
import { expect, test, type Page, type Route } from "@playwright/test"
|
||||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||||
|
import { currentSession } from "../utils/mock-server"
|
||||||
|
|
||||||
const serverA = "http://127.0.0.1:4096"
|
const serverA = "http://127.0.0.1:4096"
|
||||||
const serverB = "http://127.0.0.1:4097"
|
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 tabA.locator('[data-slot="tab-close"] button').click()
|
||||||
|
|
||||||
await expect(page).toHaveURL(new RegExp(`${hrefB.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`))
|
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()
|
await expect(page.getByText(sessionB.title).first()).toBeVisible()
|
||||||
const sessionBRequests = requests.filter((url) => url.includes(`/session/${sessionB.id}`))
|
const sessionBRequests = requests.filter((url) => url.includes(`/session/${sessionB.id}`))
|
||||||
expect(sessionBRequests.every((url) => url.startsWith(serverB))).toBe(true)
|
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 current = url.origin === serverA ? sessionA : sessionB
|
||||||
const directory = url.searchParams.get("directory")
|
const directory = url.searchParams.get("directory")
|
||||||
if (directory && directory !== current.directory) return json(route, { name: "InvalidDirectory" }, 500)
|
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 === "/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 (url.pathname === `/session/${current.id}`) return json(route, current)
|
||||||
if (/^\/session\/[^/]+$/.test(url.pathname)) return json(route, { name: "NotFoundError" }, 404)
|
if (/^\/session\/[^/]+$/.test(url.pathname)) return json(route, { name: "NotFoundError" }, 404)
|
||||||
if (url.pathname === `/session/${current.id}/message`) return json(route, [])
|
if (url.pathname === `/session/${current.id}/message`) return json(route, [])
|
||||||
if (/^\/session\/[^/]+\/(children|todo|diff)$/.test(url.pathname)) 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))
|
if (["/skill", "/command", "/lsp", "/formatter", "/permission", "/question", "/vcs/diff"].includes(url.pathname))
|
||||||
return json(route, [])
|
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, {})
|
return json(route, {})
|
||||||
if (url.pathname === "/provider")
|
if (url.pathname === "/provider")
|
||||||
return json(route, { all: [], connected: [], default: { providerID: "", modelID: "" } })
|
return json(route, { all: [], connected: [], default: { providerID: "", modelID: "" } })
|
||||||
|
|
@ -116,7 +120,17 @@ async function mockServers(page: Page, requests: string[]) {
|
||||||
directory: current.directory,
|
directory: current.directory,
|
||||||
home: 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 === "/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, {})
|
return json(route, {})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||||
import { expect, test, type Page, type Route } from "@playwright/test"
|
import { expect, test, type Page, type Route } from "@playwright/test"
|
||||||
import { installSseTransport } from "../utils/sse-transport"
|
import { installSseTransport } from "../utils/sse-transport"
|
||||||
|
import { currentSession } from "../utils/mock-server"
|
||||||
|
|
||||||
const serverA = "http://127.0.0.1:4096"
|
const serverA = "http://127.0.0.1:4096"
|
||||||
const serverB = "http://127.0.0.1:4097"
|
const serverB = "http://127.0.0.1:4097"
|
||||||
|
|
@ -180,10 +181,35 @@ async function mockServers(page: Page, permissionRequests: string[], permissionR
|
||||||
return json(route, true)
|
return json(route, true)
|
||||||
}
|
}
|
||||||
if (requestDirectory && requestDirectory !== directory) return json(route, { name: "InvalidDirectory" }, 500)
|
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 === "/global/health") return json(route, { healthy: true })
|
||||||
if (url.pathname === "/session/status") return json(route, {})
|
if (url.pathname === "/api/provider" || url.pathname === "/api/model" || url.pathname === "/api/agent")
|
||||||
if (url.pathname === "/session") return json(route, sessions)
|
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}`)
|
const current = sessions.find((session) => url.pathname === `/session/${session.id}`)
|
||||||
if (current) return json(route, current)
|
if (current) return json(route, current)
|
||||||
if (/^\/session\/[^/]+$/.test(url.pathname)) return json(route, { name: "NotFoundError" }, 404)
|
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())
|
permissionRequests.push(url.toString())
|
||||||
return json(route, [])
|
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, [])
|
return json(route, [])
|
||||||
if (["/global/config", "/config", "/provider/auth", "/mcp"].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"))
|
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,
|
directory,
|
||||||
home: 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 === "/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, {})
|
return json(route, {})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { expect, test, type Page, type Route } from "@playwright/test"
|
import { expect, test, type Page, type Route } from "@playwright/test"
|
||||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||||
|
import { currentSession } from "../utils/mock-server"
|
||||||
|
|
||||||
const serverA = "http://127.0.0.1:4096"
|
const serverA = "http://127.0.0.1:4096"
|
||||||
const serverB = "http://127.0.0.1:4097"
|
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 current = url.origin === serverA ? sessionA : sessionB
|
||||||
const directory = url.searchParams.get("directory")
|
const directory = url.searchParams.get("directory")
|
||||||
if (directory && directory !== current.directory) return json(route, { name: "InvalidDirectory" }, 500)
|
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 === "/global/health") return json(route, { healthy: true })
|
||||||
if (url.pathname === "/session/status")
|
if (url.pathname === "/api/session/active")
|
||||||
return json(route, url.origin === serverB ? { [sessionB.id]: { type: "busy" } } : {})
|
return json(route, { data: url.origin === serverB ? { [sessionB.id]: { type: "running" } } : {} })
|
||||||
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/${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 (url.pathname === `/session/${current.id}`) return json(route, current)
|
||||||
if (/^\/session\/[^/]+$/.test(url.pathname)) return json(route, { name: "NotFoundError" }, 404)
|
if (/^\/session\/[^/]+$/.test(url.pathname)) return json(route, { name: "NotFoundError" }, 404)
|
||||||
if (url.pathname === `/session/${current.id}/message`) return json(route, [])
|
if (url.pathname === `/session/${current.id}/message`) return json(route, [])
|
||||||
|
|
@ -90,7 +94,17 @@ async function mockServers(page: Page) {
|
||||||
directory: current.directory,
|
directory: current.directory,
|
||||||
home: 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 === "/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, {})
|
return json(route, {})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
@ -104,6 +118,10 @@ function json(route: Route, body: unknown, status = 200) {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function sse(route: Route) {
|
function sse(route: Route, current: boolean) {
|
||||||
return route.fulfill({ status: 200, contentType: "text/event-stream", body: ": ok\n\n" })
|
return route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: "text/event-stream",
|
||||||
|
body: current ? 'data: {"id":"evt_connected","type":"server.connected","data":{}}\n\n' : ": ok\n\n",
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -143,9 +143,9 @@ async function openReview(page: Page) {
|
||||||
|
|
||||||
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
|
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
|
||||||
await expectSessionTitle(page, title)
|
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()
|
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"]')
|
const review = page.locator('[data-component="session-review"]')
|
||||||
await expectAppVisible(review)
|
await expectAppVisible(review)
|
||||||
|
|
|
||||||
|
|
@ -92,18 +92,20 @@ async function setup(page: Page) {
|
||||||
route.fulfill({
|
route.fulfill({
|
||||||
status: 200,
|
status: 200,
|
||||||
contentType: "application/json",
|
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) =>
|
await page.route("**/vcs/diff**", (route) =>
|
||||||
route.fulfill({
|
route.fulfill({
|
||||||
status: 200,
|
status: 200,
|
||||||
contentType: "application/json",
|
contentType: "application/json",
|
||||||
body: JSON.stringify(
|
body: JSON.stringify({
|
||||||
new URL(route.request().url()).searchParams.get("mode") === "branch"
|
location: { directory },
|
||||||
? [diff("src/alpha.ts"), diff("src/beta.ts")]
|
data:
|
||||||
: [diff("src/alpha.ts"), diff("src/gamma.ts")],
|
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(
|
await page.addInitScript(
|
||||||
|
|
|
||||||
|
|
@ -64,39 +64,80 @@ test("keeps the review tree and terminal sized when both panels are open", async
|
||||||
route.fulfill({
|
route.fulfill({
|
||||||
status: 200,
|
status: 200,
|
||||||
contentType: "application/json",
|
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 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")
|
const detail = scope?.endsWith("/src/branch/d00027")
|
||||||
if (detail && detailFailures-- > 0) return route.fulfill({ status: 500, body: "retry detail" })
|
if (detail && detailFailures-- > 0) return route.fulfill({ status: 500, body: "retry detail" })
|
||||||
return route.fulfill({
|
return route.fulfill({
|
||||||
status: 200,
|
status: 200,
|
||||||
contentType: "application/json",
|
contentType: "application/json",
|
||||||
body: JSON.stringify(
|
body: JSON.stringify({
|
||||||
url.searchParams.get("mode") === "branch"
|
location: { directory: scope ?? directory, project: { id: projectID, directory } },
|
||||||
? detail
|
data:
|
||||||
? branchDiffs
|
url.searchParams.get("mode") === "branch"
|
||||||
.filter((diff) => diff.file.startsWith("src/branch/d00027/"))
|
? detail
|
||||||
.map((diff) => fileDiff(diff.file, diff.additions, true, detailVersion))
|
? branchDiffs
|
||||||
: branchDiffs
|
.filter((diff) => diff.file.startsWith("src/branch/d00027/"))
|
||||||
: Array.from({ length: 7 }, (_, index) => fileDiff(`src/git-${index}.ts`, 1)),
|
.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({
|
route.fulfill({
|
||||||
status: 200,
|
status: 200,
|
||||||
contentType: "application/json",
|
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) =>
|
await page.route("**/api/pty/pty_review_terminal?*", (route) =>
|
||||||
route.fulfill({ status: 200, contentType: "application/json", body: "{}" }),
|
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.routeWebSocket("**/pty/pty_review_terminal/connect", () => undefined)
|
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("**/api/pty/pty_review_terminal/connect", () => undefined)
|
||||||
await page.addInitScript(() => {
|
await page.addInitScript(() => {
|
||||||
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
|
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
|
||||||
localStorage.setItem(
|
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 lazyDiff = page.waitForRequest((request) => {
|
||||||
const url = new URL(request.url())
|
const url = new URL(request.url())
|
||||||
return (
|
return (
|
||||||
url.pathname === "/vcs/diff" &&
|
url.pathname === "/api/vcs/diff" &&
|
||||||
url.searchParams.get("directory")?.replaceAll("\\", "/").endsWith("/src/branch/d00027") === true
|
url.searchParams.get("location[directory]")?.replaceAll("\\", "/").endsWith("/src/branch/d00027") === true
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
await lastFile.click()
|
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 refreshedDiff = page.waitForRequest((request) => {
|
||||||
const url = new URL(request.url())
|
const url = new URL(request.url())
|
||||||
return (
|
return (
|
||||||
url.pathname === "/vcs/diff" &&
|
url.pathname === "/api/vcs/diff" &&
|
||||||
url.searchParams.get("directory")?.replaceAll("\\", "/").endsWith("/src/branch/d00027") === true
|
url.searchParams.get("location[directory]")?.replaceAll("\\", "/").endsWith("/src/branch/d00027") === true
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
events.push(statusEvent("idle"))
|
events.push(statusEvent("idle"))
|
||||||
|
|
|
||||||
|
|
@ -16,8 +16,8 @@ test("shows loaded sessions before the directory path request resolves", async (
|
||||||
const pathBlocked = new Promise<void>((resolve) => {
|
const pathBlocked = new Promise<void>((resolve) => {
|
||||||
releasePath = resolve
|
releasePath = resolve
|
||||||
})
|
})
|
||||||
await page.route("**/path?*", async (route) => {
|
await page.route("**/api/path?*", async (route) => {
|
||||||
if (!new URL(route.request().url()).searchParams.has("directory")) return route.fallback()
|
if (!new URL(route.request().url()).searchParams.has("location[directory]")) return route.fallback()
|
||||||
await pathBlocked
|
await pathBlocked
|
||||||
return route.fallback()
|
return route.fallback()
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -42,7 +42,8 @@ test("shows a pending question dock", async ({ page }) => {
|
||||||
const rejectRequests: string[] = []
|
const rejectRequests: string[] = []
|
||||||
page.on("request", (request) => {
|
page.on("request", (request) => {
|
||||||
if (request.method() !== "POST") return
|
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()
|
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()
|
await question.getByRole("radio", { name: /Minimal/ }).click()
|
||||||
const reply = page.waitForRequest(
|
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()
|
await question.getByRole("button", { name: "Submit" }).click()
|
||||||
expect((await reply).postDataJSON()).toEqual({ answers: [["Minimal"]] })
|
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")
|
const reply = page.waitForRequest((request) => request.method() === "POST")
|
||||||
await permission.getByRole("button", { name: "Allow once" }).click()
|
await permission.getByRole("button", { name: "Allow once" }).click()
|
||||||
const request = await reply
|
const request = await reply
|
||||||
expect(new URL(request.url()).pathname).toBe(`/session/${sessionID}/permissions/permission-request`)
|
expect(new URL(request.url()).pathname).toBe(`/api/session/${sessionID}/permission/permission-request/reply`)
|
||||||
expect(request.postDataJSON()).toEqual({ response: "once" })
|
expect(request.postDataJSON()).toEqual({ reply: "once" })
|
||||||
})
|
})
|
||||||
|
|
||||||
test("restores the draft caret before typing after a request dock closes", async ({ page }) => {
|
test("restores the draft caret before typing after a request dock closes", async ({ page }) => {
|
||||||
|
|
|
||||||
|
|
@ -89,7 +89,7 @@ test("reconnects after a stream error", async ({ page }) => {
|
||||||
expect((await timeline.transport.connections())[0]?.endedBy).toBe("error")
|
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 timeline = await setupTimeline(page, { eventRetry: 10 })
|
||||||
const first = await timeline.transport.send(partUpdated(textPart("prt_transport_id", "event with id")), {
|
const first = await timeline.transport.send(partUpdated(textPart("prt_transport_id", "event with id")), {
|
||||||
id: "timeline-event-7",
|
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 })
|
const connection = await timeline.transport.waitForConnection({ after: first.connectionID })
|
||||||
|
|
||||||
expect(first.eventID).toBe("timeline-event-7")
|
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 }) => {
|
test("passes through non-event fetches", async ({ page }) => {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||||
import { expect, test, type Page } from "@playwright/test"
|
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"
|
import { expectSessionTitle } from "../utils/waits"
|
||||||
|
|
||||||
const directory = "C:/OpenCode/SubagentNavigation"
|
const directory = "C:/OpenCode/SubagentNavigation"
|
||||||
|
|
@ -72,16 +72,19 @@ async function setup(page: Page, events?: () => EventPayload[]) {
|
||||||
events,
|
events,
|
||||||
eventRetry: events ? 16 : undefined,
|
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.
|
// matching a subagent session that has not been loaded into the list cache yet.
|
||||||
await page.route(
|
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) =>
|
||||||
route.fulfill({
|
route.fulfill({
|
||||||
status: 200,
|
status: 200,
|
||||||
contentType: "application/json",
|
contentType: "application/json",
|
||||||
headers: { "access-control-allow-origin": "*" },
|
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)
|
await configurePage(page)
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { expect, test, type Page, type Route } from "@playwright/test"
|
import { expect, test, type Page, type Route } from "@playwright/test"
|
||||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||||
|
import { currentSession } from "../utils/mock-server"
|
||||||
|
|
||||||
const server = "http://127.0.0.1:4096"
|
const server = "http://127.0.0.1:4096"
|
||||||
const sessionA = session("ses_tab_a", "Tab A session")
|
const sessionA = session("ses_tab_a", "Tab A session")
|
||||||
|
|
@ -56,9 +57,14 @@ async function mockServer(page: Page) {
|
||||||
await page.route("**/*", async (route) => {
|
await page.route("**/*", async (route) => {
|
||||||
const url = new URL(route.request().url())
|
const url = new URL(route.request().url())
|
||||||
if (url.origin !== server) return route.fallback()
|
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 === "/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}`)
|
const byId = sessions.find((item) => url.pathname === `/session/${item.id}`)
|
||||||
if (byId) return json(route, byId)
|
if (byId) return json(route, byId)
|
||||||
if (/^\/session\/[^/]+$/.test(url.pathname)) return json(route, { name: "NotFoundError" }, 404)
|
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 (/^\/session\/[^/]+\/(children|todo|diff)$/.test(url.pathname)) return json(route, [])
|
||||||
if (["/skill", "/command", "/lsp", "/formatter", "/permission", "/question", "/vcs/diff"].includes(url.pathname))
|
if (["/skill", "/command", "/lsp", "/formatter", "/permission", "/question", "/vcs/diff"].includes(url.pathname))
|
||||||
return json(route, [])
|
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, {})
|
return json(route, {})
|
||||||
if (url.pathname === "/provider")
|
if (url.pathname === "/provider")
|
||||||
return json(route, { all: [], connected: [], default: { providerID: "", modelID: "" } })
|
return json(route, { all: [], connected: [], default: { providerID: "", modelID: "" } })
|
||||||
|
|
@ -89,7 +95,20 @@ async function mockServer(page: Page) {
|
||||||
directory: sessionA.directory,
|
directory: sessionA.directory,
|
||||||
home: 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 === "/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, {})
|
return json(route, {})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -46,25 +46,30 @@ test.beforeEach(async ({ page }) => {
|
||||||
],
|
],
|
||||||
pageMessages: () => ({ items: [] }),
|
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({
|
route.fulfill({
|
||||||
status: 200,
|
status: 200,
|
||||||
contentType: "application/json",
|
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) =>
|
await page.route(`**/api/pty/${ptyID}/connect-token*`, (route) =>
|
||||||
route.fulfill({ status: 200, contentType: "application/json", body: "{}" }),
|
|
||||||
)
|
|
||||||
await page.route(`**/pty/${ptyID}/connect-token*`, (route) =>
|
|
||||||
route.fulfill({
|
route.fulfill({
|
||||||
status: 200,
|
status: 200,
|
||||||
contentType: "application/json",
|
contentType: "application/json",
|
||||||
headers: { "access-control-allow-origin": "*" },
|
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(() => {
|
await page.addInitScript(() => {
|
||||||
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
|
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<void>()
|
const ghostty = Promise.withResolvers<void>()
|
||||||
const release = Promise.withResolvers<void>()
|
const release = Promise.withResolvers<void>()
|
||||||
const created = { count: 0 }
|
const created = { count: 0 }
|
||||||
await page.route("**/pty", (route) => {
|
await page.route("**/api/pty?*", (route) => {
|
||||||
created.count += 1
|
created.count += 1
|
||||||
return route.fulfill({
|
return route.fulfill({
|
||||||
status: 200,
|
status: 200,
|
||||||
contentType: "application/json",
|
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) => {
|
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 }) => {
|
test("focuses a terminal created from the new-terminal button", async ({ page }) => {
|
||||||
const created = { count: 0 }
|
const created = { count: 0 }
|
||||||
await page.route("**/pty", (route) => {
|
await page.route("**/api/pty?*", (route) => {
|
||||||
created.count += 1
|
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({
|
return route.fulfill({
|
||||||
status: 200,
|
status: 200,
|
||||||
contentType: "application/json",
|
contentType: "application/json",
|
||||||
body: JSON.stringify(next),
|
body: JSON.stringify({ location: ptyLocation(), data: next }),
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
await page.route(`**/pty/${newPtyID}`, (route) =>
|
await page.route(`**/api/pty/${newPtyID}?*`, (route) =>
|
||||||
route.fulfill({ status: 200, contentType: "application/json", body: "{}" }),
|
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({
|
route.fulfill({
|
||||||
status: 200,
|
status: 200,
|
||||||
contentType: "application/json",
|
contentType: "application/json",
|
||||||
headers: { "access-control-allow-origin": "*" },
|
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 page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
|
||||||
await expectSessionTitle(page, "Terminal composer focus")
|
await expectSessionTitle(page, "Terminal composer focus")
|
||||||
|
|
@ -207,3 +216,11 @@ function seedCachedTerminal(page: Page) {
|
||||||
{ terminalKey: `${base64Encode(directory)}/terminal.v1`, ptyID },
|
{ 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 }
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -43,17 +43,53 @@ test("unmounts the terminal panel while it is hidden", async ({ page }) => {
|
||||||
],
|
],
|
||||||
pageMessages: () => ({ items: [] }),
|
pageMessages: () => ({ items: [] }),
|
||||||
})
|
})
|
||||||
await page.route("**/pty", (route) =>
|
await page.route("**/api/pty?*", (route) =>
|
||||||
route.fulfill({
|
route.fulfill({
|
||||||
status: 200,
|
status: 200,
|
||||||
contentType: "application/json",
|
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) =>
|
await page.route("**/api/pty/pty_hidden_terminal?*", (route) =>
|
||||||
route.fulfill({ status: 200, contentType: "application/json", body: "{}" }),
|
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.routeWebSocket("**/pty/pty_hidden_terminal/connect", () => undefined)
|
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("**/api/pty/pty_hidden_terminal/connect", () => undefined)
|
||||||
|
|
||||||
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
|
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
|
||||||
await expectSessionTitle(page, title)
|
await expectSessionTitle(page, title)
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,10 @@ test("keeps the terminal session alive when switching session tabs in a workspac
|
||||||
const terminal = page.locator('[data-component="terminal"]')
|
const terminal = page.locator('[data-component="terminal"]')
|
||||||
await expect(terminal).toBeVisible()
|
await expect(terminal).toBeVisible()
|
||||||
await expect.poll(() => connections.length).toBe(1)
|
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 writeProbe(page)
|
||||||
|
|
||||||
await switchTab(page, titleB)
|
await switchTab(page, titleB)
|
||||||
|
|
@ -85,26 +89,33 @@ async function setup(page: Page) {
|
||||||
sessions: [session(sessionA, titleA, 1700000000000), session(sessionB, titleB, 1700000001000)],
|
sessions: [session(sessionA, titleA, 1700000000000), session(sessionB, titleB, 1700000001000)],
|
||||||
pageMessages: () => ({ items: [] }),
|
pageMessages: () => ({ items: [] }),
|
||||||
})
|
})
|
||||||
await page.route("**/pty", (route) =>
|
await page.route("**/api/pty?*", (route) =>
|
||||||
route.fulfill({
|
route.fulfill({
|
||||||
status: 200,
|
status: 200,
|
||||||
contentType: "application/json",
|
contentType: "application/json",
|
||||||
body: JSON.stringify({ id: ptyID, title: "Terminal 1" }),
|
body: JSON.stringify({ location: ptyLocation(), data: ptyInfo() }),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
await page.route(`**/pty/${ptyID}`, (route) =>
|
await page.route(`**/api/pty/${ptyID}?*`, (route) =>
|
||||||
route.fulfill({ status: 200, contentType: "application/json", body: "{}" }),
|
|
||||||
)
|
|
||||||
await page.route(`**/pty/${ptyID}/connect-token*`, (route) =>
|
|
||||||
route.fulfill({
|
route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: "application/json",
|
||||||
|
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,
|
status: 200,
|
||||||
contentType: "application/json",
|
contentType: "application/json",
|
||||||
headers: { "access-control-allow-origin": "*" },
|
headers: { "access-control-allow-origin": "*" },
|
||||||
body: JSON.stringify({ ticket: "e2e-ticket" }),
|
body: JSON.stringify({ location: ptyLocation(), data: { ticket: "e2e-ticket", expires_in: 60 } }),
|
||||||
}),
|
})
|
||||||
)
|
})
|
||||||
const connections: string[] = []
|
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())
|
connections.push(ws.url())
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -143,3 +154,11 @@ function session(id: string, title: string, created: number) {
|
||||||
function sessionHref(sessionID: string) {
|
function sessionHref(sessionID: string) {
|
||||||
return `/server/${base64Encode(server)}/session/${sessionID}`
|
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 }
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -54,14 +54,18 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
||||||
if (url.port !== targetPort && url.port !== appPort) return route.fallback()
|
if (url.port !== targetPort && url.port !== appPort) return route.fallback()
|
||||||
|
|
||||||
const path = url.pathname
|
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 === "/global/health") return json(route, { healthy: true })
|
||||||
if (path === "/api/session")
|
if (path === "/experimental/capabilities") return json(route, { backgroundSubagents: true })
|
||||||
return json(route, {
|
|
||||||
data: config.sessions.map((session) => v2Session(session, config.directory)),
|
|
||||||
cursor: {},
|
|
||||||
})
|
|
||||||
if (path === "/experimental/capabilities") return json(route, { backgroundSubagents: false })
|
|
||||||
if (path === "/permission")
|
if (path === "/permission")
|
||||||
return json(route, typeof config.permissions === "function" ? config.permissions() : (config.permissions ?? []))
|
return json(route, typeof config.permissions === "function" ? config.permissions() : (config.permissions ?? []))
|
||||||
if (path === "/question")
|
if (path === "/question")
|
||||||
|
|
@ -89,10 +93,122 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
||||||
},
|
},
|
||||||
data: [],
|
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 (emptyObject.has(path)) return json(route, {})
|
||||||
if (emptyList.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<string, { type?: string }>
|
||||||
|
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])
|
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\/([^/]+)$/)
|
const sessionMatch = path.match(/^\/session\/([^/]+)$/)
|
||||||
if (sessionMatch) {
|
if (sessionMatch) {
|
||||||
const session = config.sessions.find((s) => s.id === sessionMatch[1])
|
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 (todoMatch) return json(route, config.todos?.(todoMatch[1]!) ?? [])
|
||||||
if (/^\/session\/[^/]+\/(children|diff)$/.test(path)) return json(route, [])
|
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$/)
|
const messagesMatch = path.match(/^\/session\/([^/]+)\/message$/)
|
||||||
if (messagesMatch) {
|
if (messagesMatch) {
|
||||||
const token = url.searchParams.get("before") ?? undefined
|
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<string, unknown>, 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<string, unknown>
|
||||||
|
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<string, unknown>, fallbackDirectory?: string) {
|
||||||
const time = session.time && typeof session.time === "object" ? session.time : {}
|
const time = session.time && typeof session.time === "object" ? session.time : {}
|
||||||
return {
|
return {
|
||||||
id: session.id,
|
id: session.id,
|
||||||
parentID: session.parentID,
|
parentID: session.parentID,
|
||||||
projectID: session.projectID ?? "project",
|
projectID: session.projectID ?? "project",
|
||||||
|
agent: session.agent ?? "build",
|
||||||
|
model: session.model ?? { id: "mock-model", providerID: "mock-provider" },
|
||||||
cost: session.cost ?? 0,
|
cost: session.cost ?? 0,
|
||||||
tokens: session.tokens ?? { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
tokens: session.tokens ?? { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||||
time: {
|
time: {
|
||||||
|
|
@ -157,7 +315,67 @@ function v2Session(session: { id: string } & Record<string, unknown>, fallbackDi
|
||||||
directory: typeof session.directory === "string" ? session.directory : fallbackDirectory,
|
directory: typeof session.directory === "string" ? session.directory : fallbackDirectory,
|
||||||
...(typeof session.workspaceID === "string" ? { workspaceID: session.workspaceID } : {}),
|
...(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<string, unknown> & { id: string; role: "user" | "assistant"; time: { created: number } }
|
||||||
|
parts: Array<Record<string, unknown> & { 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<unknown>((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<string, unknown>
|
||||||
|
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"}`,
|
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import type { Page } from "@playwright/test"
|
||||||
export type SseConnectionRecord = {
|
export type SseConnectionRecord = {
|
||||||
id: number
|
id: number
|
||||||
url: string
|
url: string
|
||||||
path: "/global/event" | "/event"
|
path: "/global/event" | "/event" | "/api/event"
|
||||||
headers: Record<string, string>
|
headers: Record<string, string>
|
||||||
openedAt: number
|
openedAt: number
|
||||||
endedAt?: number
|
endedAt?: number
|
||||||
|
|
@ -93,6 +93,20 @@ export async function installSseTransport<T>(
|
||||||
eventOptions.retry === undefined ? "" : `retry: ${eventOptions.retry}\n`,
|
eventOptions.retry === undefined ? "" : `retry: ${eventOptions.retry}\n`,
|
||||||
`data: ${JSON.stringify(payload)}\n\n`,
|
`data: ${JSON.stringify(payload)}\n\n`,
|
||||||
].join("")
|
].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 = (
|
const acknowledge = (
|
||||||
connection: Connection,
|
connection: Connection,
|
||||||
bytes: number,
|
bytes: number,
|
||||||
|
|
@ -140,14 +154,14 @@ export async function installSseTransport<T>(
|
||||||
output.forEach((chunk) => connection.controller.enqueue(chunk))
|
output.forEach((chunk) => connection.controller.enqueue(chunk))
|
||||||
return acknowledge(connection, input.bytes.length, output.length)
|
return acknowledge(connection, input.bytes.length, output.length)
|
||||||
}
|
}
|
||||||
const encoded = input.deliveries.map((delivery) => ({
|
const encoded = input.deliveries.map((delivery) => {
|
||||||
delivery,
|
const payload = connection.path === "/api/event" ? currentEvent(delivery.payload) : delivery.payload
|
||||||
bytes: encoder.encode(frame(delivery.payload, delivery.options)),
|
return { delivery, payload, bytes: encoder.encode(frame(payload, delivery.options)) }
|
||||||
}))
|
})
|
||||||
encoded.forEach((item) => marker(item.delivery.options?.marker))
|
encoded.forEach((item) => marker(item.delivery.options?.marker))
|
||||||
if (input.burst) {
|
if (input.burst) {
|
||||||
const bytes = encoder.encode(
|
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)
|
connection.controller.enqueue(bytes)
|
||||||
return encoded.map((item) => acknowledge(connection, item.bytes.byteLength, 1, item.delivery.options?.id))
|
return encoded.map((item) => acknowledge(connection, item.bytes.byteLength, 1, item.delivery.options?.id))
|
||||||
|
|
@ -161,7 +175,10 @@ export async function installSseTransport<T>(
|
||||||
const fetch = (input: RequestInfo | URL, init?: RequestInit) => {
|
const fetch = (input: RequestInfo | URL, init?: RequestInit) => {
|
||||||
const request = new Request(input, init)
|
const request = new Request(input, init)
|
||||||
const url = new URL(request.url)
|
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)
|
return originalFetch(request)
|
||||||
|
|
||||||
const id = ++nextConnectionID
|
const id = ++nextConnectionID
|
||||||
|
|
@ -177,6 +194,12 @@ export async function installSseTransport<T>(
|
||||||
record.controller = controller
|
record.controller = controller
|
||||||
connections.push(record)
|
connections.push(record)
|
||||||
if (retry !== undefined) controller.enqueue(encoder.encode(`retry: ${retry}\n\n`))
|
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(
|
request.signal.addEventListener(
|
||||||
"abort",
|
"abort",
|
||||||
() => {
|
() => {
|
||||||
|
|
|
||||||
|
|
@ -53,6 +53,7 @@
|
||||||
"@dnd-kit/helpers": "0.5.0",
|
"@dnd-kit/helpers": "0.5.0",
|
||||||
"@dnd-kit/solid": "0.5.0",
|
"@dnd-kit/solid": "0.5.0",
|
||||||
"@kobalte/core": "catalog:",
|
"@kobalte/core": "catalog:",
|
||||||
|
"@opencode-ai/client": "file:vendor/opencode-ai-client-1.17.13.tgz",
|
||||||
"@opencode-ai/core": "workspace:*",
|
"@opencode-ai/core": "workspace:*",
|
||||||
"@opencode-ai/schema": "workspace:*",
|
"@opencode-ai/schema": "workspace:*",
|
||||||
"@opencode-ai/sdk": "workspace:*",
|
"@opencode-ai/sdk": "workspace:*",
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { getFilename } from "@opencode-ai/core/util/path"
|
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 { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||||
import { createMemo, onCleanup } from "solid-js"
|
import { createMemo, onCleanup } from "solid-js"
|
||||||
import { commandPaletteOptions, useCommand, type CommandOption } from "@/context/command"
|
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 { displayName, projectForSession } from "@/pages/layout/helpers"
|
||||||
import { createSessionTabs } from "@/pages/session/helpers"
|
import { createSessionTabs } from "@/pages/session/helpers"
|
||||||
import { useSessionLayout } from "@/pages/session/session-layout"
|
import { useSessionLayout } from "@/pages/session/session-layout"
|
||||||
|
import { normalizeSessionInfo } from "@/utils/session"
|
||||||
|
|
||||||
export type CommandPaletteEntry = {
|
export type CommandPaletteEntry = {
|
||||||
id: string
|
id: string
|
||||||
|
|
@ -145,7 +147,7 @@ export function createCommandPaletteModel(props: { filesOnly?: () => boolean; on
|
||||||
opened: serverCtx.projects.list,
|
opened: serverCtx.projects.list,
|
||||||
stored: () => serverCtx.sync.data.project,
|
stored: () => serverCtx.sync.data.project,
|
||||||
load: (search, signal) =>
|
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"),
|
untitled: () => language.t("command.session.new"),
|
||||||
category: () => language.t("command.category.session"),
|
category: () => language.t("command.category.session"),
|
||||||
})
|
})
|
||||||
|
|
@ -219,7 +221,7 @@ export function createServerSessionEntries(props: {
|
||||||
server: ServerConnection.Key
|
server: ServerConnection.Key
|
||||||
opened: () => LocalProject[]
|
opened: () => LocalProject[]
|
||||||
stored: () => Project[]
|
stored: () => Project[]
|
||||||
load: (search: string, signal: AbortSignal) => Promise<{ data?: GlobalSession[] }>
|
load: (search: string, signal: AbortSignal) => Promise<{ data: SessionInfo[] }>
|
||||||
untitled: () => string
|
untitled: () => string
|
||||||
category: () => string
|
category: () => string
|
||||||
}) {
|
}) {
|
||||||
|
|
@ -255,7 +257,8 @@ export function createServerSessionEntries(props: {
|
||||||
return props
|
return props
|
||||||
.load(search, current.signal)
|
.load(search, current.signal)
|
||||||
.then((result) =>
|
.then((result) =>
|
||||||
(result.data ?? [])
|
result.data
|
||||||
|
.map(normalizeSessionInfo)
|
||||||
.filter((session) => !session.time.archived)
|
.filter((session) => !session.time.archived)
|
||||||
.map((session) => {
|
.map((session) => {
|
||||||
const project =
|
const project =
|
||||||
|
|
@ -264,7 +267,7 @@ export function createServerSessionEntries(props: {
|
||||||
id: `session:${props.server}:${session.id}`,
|
id: `session:${props.server}:${session.id}`,
|
||||||
type: "session" as const,
|
type: "session" as const,
|
||||||
title: session.title || props.untitled(),
|
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(),
|
category: props.category(),
|
||||||
directory: session.directory,
|
directory: session.directory,
|
||||||
sessionID: session.id,
|
sessionID: session.id,
|
||||||
|
|
|
||||||
|
|
@ -80,7 +80,7 @@ export function DialogHomeCommandPaletteV2(props: {
|
||||||
opened: serverCtx.projects.list,
|
opened: serverCtx.projects.list,
|
||||||
stored: () => serverCtx.sync.data.project,
|
stored: () => serverCtx.sync.data.project,
|
||||||
load: (search, signal) =>
|
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"),
|
untitled: () => language.t("command.session.new"),
|
||||||
category: () => language.t("command.category.session"),
|
category: () => language.t("command.category.session"),
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -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 { Button } from "@opencode-ai/ui/button"
|
||||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||||
import { Dialog } from "@opencode-ai/ui/dialog"
|
import { Dialog } from "@opencode-ai/ui/dialog"
|
||||||
|
|
@ -28,6 +31,8 @@ import {
|
||||||
Switch,
|
Switch,
|
||||||
} from "solid-js"
|
} from "solid-js"
|
||||||
import { createStore, produce } from "solid-js/store"
|
import { createStore, produce } from "solid-js/store"
|
||||||
|
import { useQueryClient } from "@tanstack/solid-query"
|
||||||
|
import { useParams } from "@solidjs/router"
|
||||||
import { Link } from "@/components/link"
|
import { Link } from "@/components/link"
|
||||||
import { useServerSDK } from "@/context/server-sdk"
|
import { useServerSDK } from "@/context/server-sdk"
|
||||||
import { useServerSync } from "@/context/server-sync"
|
import { useServerSync } from "@/context/server-sync"
|
||||||
|
|
@ -35,8 +40,11 @@ import { useLanguage } from "@/context/language"
|
||||||
import { useSettings } from "@/context/settings"
|
import { useSettings } from "@/context/settings"
|
||||||
import { popularProviders, useProviders } from "@/hooks/use-providers"
|
import { popularProviders, useProviders } from "@/hooks/use-providers"
|
||||||
import { CustomProviderForm } from "./dialog-custom-provider"
|
import { CustomProviderForm } from "./dialog-custom-provider"
|
||||||
|
import { decode64 } from "@/utils/base64"
|
||||||
|
import { pathKey } from "@/utils/path-key"
|
||||||
|
|
||||||
const CUSTOM_ID = "_custom"
|
const CUSTOM_ID = "_custom"
|
||||||
|
type ConnectMethod = Extract<IntegrationMethod, { type: "key" | "oauth" }>
|
||||||
|
|
||||||
export function useProviderConnectController(options: { onBack?: () => void } = {}) {
|
export function useProviderConnectController(options: { onBack?: () => void } = {}) {
|
||||||
const [store, setStore] = createStore({ selected: undefined as string | undefined })
|
const [store, setStore] = createStore({ selected: undefined as string | undefined })
|
||||||
|
|
@ -228,8 +236,6 @@ function ProviderPickerV2(props: {
|
||||||
}) {
|
}) {
|
||||||
const providers = useProviders(props.directory)
|
const providers = useProviders(props.directory)
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const serverSync = useServerSync()
|
|
||||||
const serverSDK = useServerSDK()
|
|
||||||
const [store, setStore] = createStore({
|
const [store, setStore] = createStore({
|
||||||
filter: "",
|
filter: "",
|
||||||
active: undefined as string | undefined,
|
active: undefined as string | undefined,
|
||||||
|
|
@ -266,19 +272,7 @@ function ProviderPickerV2(props: {
|
||||||
|
|
||||||
const connect = (provider: string) => {
|
const connect = (provider: string) => {
|
||||||
props.onPrepare?.()
|
props.onPrepare?.()
|
||||||
if (provider === CUSTOM_ID || serverSync().data.provider_auth[provider]) {
|
props.onSelect(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))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const move = (event: KeyboardEvent, direction: number) => {
|
const move = (event: KeyboardEvent, direction: number) => {
|
||||||
|
|
@ -395,10 +389,17 @@ function ProviderConnection(props: {
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const serverSync = useServerSync()
|
const serverSync = useServerSync()
|
||||||
const serverSDK = useServerSDK()
|
const serverSDK = useServerSDK()
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const params = useParams()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const settings = useSettings()
|
const settings = useSettings()
|
||||||
const newLayout = settings.general.newLayoutDesigns
|
const newLayout = settings.general.newLayoutDesigns
|
||||||
const providers = useProviders(props.directory)
|
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 alive = { value: true }
|
||||||
const timer = { current: undefined as ReturnType<typeof setTimeout> | undefined }
|
const timer = { current: undefined as ReturnType<typeof setTimeout> | undefined }
|
||||||
|
|
@ -413,38 +414,34 @@ function ProviderConnection(props: {
|
||||||
const provider = createMemo(
|
const provider = createMemo(
|
||||||
() => providers.all().get(props.provider) ?? serverSync().data.provider.all.get(props.provider)!,
|
() => providers.all().get(props.provider) ?? serverSync().data.provider.all.get(props.provider)!,
|
||||||
)
|
)
|
||||||
const fallback = createMemo<ProviderAuthMethod[]>(() => [
|
const fallback = createMemo<ConnectMethod[]>(() => [
|
||||||
{
|
{
|
||||||
type: "api" as const,
|
type: "key" as const,
|
||||||
label: language.t("provider.connect.method.apiKey"),
|
label: language.t("provider.connect.method.apiKey"),
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
const [auth] = createResource(
|
const [integration] = createResource(
|
||||||
() => props.provider,
|
() => ({ provider: props.provider, directory: directory() }),
|
||||||
async () => {
|
(input) =>
|
||||||
const cached = serverSync().data.provider_auth[props.provider]
|
serverSDK()
|
||||||
if (cached) return cached
|
.api.integration.get({
|
||||||
const res = await serverSDK().client.provider.auth()
|
integrationID: input.provider,
|
||||||
if (!alive.value) return fallback()
|
location: input.directory ? { directory: input.directory } : undefined,
|
||||||
serverSync().set("provider_auth", res.data ?? {})
|
})
|
||||||
return res.data?.[props.provider] ?? fallback()
|
.then((result) => result.data),
|
||||||
},
|
|
||||||
)
|
)
|
||||||
const loading = createMemo(() => auth.loading && !serverSync().data.provider_auth[props.provider])
|
const loading = createMemo(() => integration.loading)
|
||||||
const methods = createMemo(() => auth.latest ?? serverSync().data.provider_auth[props.provider] ?? fallback())
|
const methods = createMemo<ConnectMethod[]>(() => {
|
||||||
const cachedMethods = serverSync().data.provider_auth[props.provider]
|
const values = integration.latest?.methods.filter(
|
||||||
const directMethod =
|
(method): method is ConnectMethod => method.type === "key" || method.type === "oauth",
|
||||||
cachedMethods?.length === 1 && cachedMethods[0].type === "api" && !cachedMethods[0].prompts?.length ? 0 : undefined
|
)
|
||||||
|
return values?.length ? values : fallback()
|
||||||
|
})
|
||||||
const [store, setStore] = createStore({
|
const [store, setStore] = createStore({
|
||||||
methodIndex: directMethod as undefined | number,
|
methodIndex: undefined as undefined | number,
|
||||||
authorization: undefined as undefined | ProviderAuthAuthorization,
|
authorization: undefined as undefined | IntegrationOauthConnectOutput["data"],
|
||||||
promptInputs: undefined as undefined | Record<string, string>,
|
promptInputs: undefined as undefined | Record<string, string>,
|
||||||
state: (directMethod === undefined ? "pending" : undefined) as
|
state: "pending" as undefined | "pending" | "complete" | "error" | "prompt",
|
||||||
| undefined
|
|
||||||
| "pending"
|
|
||||||
| "complete"
|
|
||||||
| "error"
|
|
||||||
| "prompt",
|
|
||||||
error: undefined as string | undefined,
|
error: undefined as string | undefined,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -454,7 +451,7 @@ function ProviderConnection(props: {
|
||||||
| { type: "auth.prompt" }
|
| { type: "auth.prompt" }
|
||||||
| { type: "auth.inputs"; inputs: Record<string, string> }
|
| { type: "auth.inputs"; inputs: Record<string, string> }
|
||||||
| { type: "auth.pending" }
|
| { type: "auth.pending" }
|
||||||
| { type: "auth.complete"; authorization: ProviderAuthAuthorization }
|
| { type: "auth.complete"; authorization: IntegrationOauthConnectOutput["data"] }
|
||||||
| { type: "auth.error"; error: string }
|
| { type: "auth.error"; error: string }
|
||||||
|
|
||||||
function dispatch(action: Action) {
|
function dispatch(action: Action) {
|
||||||
|
|
@ -508,7 +505,7 @@ function ProviderConnection(props: {
|
||||||
|
|
||||||
const methodLabel = (value?: { type?: string; label?: string }) => {
|
const methodLabel = (value?: { type?: string; label?: string }) => {
|
||||||
if (!value) return ""
|
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 ?? ""
|
return value.label ?? ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -518,7 +515,7 @@ function ProviderConnection(props: {
|
||||||
const hint = suffix?.[1]
|
const hint = suffix?.[1]
|
||||||
return {
|
return {
|
||||||
label: suffix ? label.slice(0, -suffix[0].length) : label,
|
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]
|
const method = methods()[index]
|
||||||
dispatch({ type: "method.select", 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.type === "oauth") {
|
||||||
if (method.prompts?.length && !inputs) {
|
if (method.prompts?.length && !inputs) {
|
||||||
dispatch({ type: "auth.prompt" })
|
dispatch({ type: "auth.prompt" })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
dispatch({ type: "auth.pending" })
|
dispatch({ type: "auth.pending" })
|
||||||
const start = Date.now()
|
|
||||||
await serverSDK()
|
await serverSDK()
|
||||||
.client.provider.oauth.authorize(
|
.api.integration.oauth.connect({
|
||||||
{
|
integrationID: props.provider,
|
||||||
providerID: props.provider,
|
methodID: method.id,
|
||||||
method: index,
|
inputs: inputs ?? {},
|
||||||
inputs,
|
location: location(),
|
||||||
},
|
})
|
||||||
{ throwOnError: true },
|
|
||||||
)
|
|
||||||
.then((x) => {
|
.then((x) => {
|
||||||
if (!alive.value) return
|
if (!alive.value) return
|
||||||
const elapsed = Date.now() - start
|
dispatch({ type: "auth.complete", authorization: x.data })
|
||||||
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! })
|
|
||||||
})
|
})
|
||||||
.catch((e) => {
|
.catch((e) => {
|
||||||
if (!alive.value) return
|
if (!alive.value) return
|
||||||
|
|
@ -603,9 +576,9 @@ function ProviderConnection(props: {
|
||||||
index: 0,
|
index: 0,
|
||||||
})
|
})
|
||||||
|
|
||||||
const prompts = createMemo<NonNullable<ProviderAuthMethod["prompts"]>>(() => {
|
const prompts = createMemo(() => {
|
||||||
const value = method()
|
const value = method()
|
||||||
return value?.prompts ?? []
|
return value?.type === "oauth" ? (value.prompts ?? []) : []
|
||||||
})
|
})
|
||||||
const matches = (prompt: NonNullable<ReturnType<typeof prompts>[number]>, value: Record<string, string>) => {
|
const matches = (prompt: NonNullable<ReturnType<typeof prompts>[number]>, value: Record<string, string>) => {
|
||||||
if (!prompt.when) return true
|
if (!prompt.when) return true
|
||||||
|
|
@ -636,10 +609,6 @@ function ProviderConnection(props: {
|
||||||
setFormStore("index", next)
|
setFormStore("index", next)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (method()?.type === "api") {
|
|
||||||
dispatch({ type: "auth.inputs", inputs: value })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
await selectMethod(store.methodIndex, value)
|
await selectMethod(store.methodIndex, value)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -741,7 +710,10 @@ function ProviderConnection(props: {
|
||||||
})
|
})
|
||||||
|
|
||||||
async function complete() {
|
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()
|
dialog.close()
|
||||||
showToast({
|
showToast({
|
||||||
variant: "success",
|
variant: "success",
|
||||||
|
|
@ -805,7 +777,7 @@ function ProviderConnection(props: {
|
||||||
listRef = ref
|
listRef = ref
|
||||||
}}
|
}}
|
||||||
items={methods}
|
items={methods}
|
||||||
key={(m) => m?.label}
|
key={(m) => m?.label ?? m?.type}
|
||||||
onSelect={async (selected, index) => {
|
onSelect={async (selected, index) => {
|
||||||
if (!selected) return
|
if (!selected) return
|
||||||
void selectMethod(index)
|
void selectMethod(index)
|
||||||
|
|
@ -851,13 +823,10 @@ function ProviderConnection(props: {
|
||||||
}
|
}
|
||||||
|
|
||||||
setFormStore("error", undefined)
|
setFormStore("error", undefined)
|
||||||
await serverSDK().client.auth.set({
|
await serverSDK().api.integration.connect.key({
|
||||||
providerID: props.provider,
|
integrationID: props.provider,
|
||||||
auth: {
|
location: location(),
|
||||||
type: "api",
|
key: apiKey,
|
||||||
key: apiKey,
|
|
||||||
...(store.promptInputs ? { metadata: store.promptInputs } : {}),
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
await complete()
|
await complete()
|
||||||
}
|
}
|
||||||
|
|
@ -984,12 +953,13 @@ function ProviderConnection(props: {
|
||||||
|
|
||||||
setFormStore("error", undefined)
|
setFormStore("error", undefined)
|
||||||
const result = await serverSDK()
|
const result = await serverSDK()
|
||||||
.client.provider.oauth.callback({
|
.api.integration.oauth.complete({
|
||||||
providerID: props.provider,
|
integrationID: props.provider,
|
||||||
method: store.methodIndex,
|
attemptID: store.authorization!.attemptID,
|
||||||
|
location: location(),
|
||||||
code,
|
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 }))
|
.catch((error) => ({ ok: false as const, error }))
|
||||||
if (result.ok) {
|
if (result.ok) {
|
||||||
await complete()
|
await complete()
|
||||||
|
|
@ -1076,25 +1046,37 @@ function ProviderConnection(props: {
|
||||||
})
|
})
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
void (async () => {
|
const poll = async () => {
|
||||||
|
const authorization = store.authorization
|
||||||
|
if (!authorization || !alive.value) return
|
||||||
const result = await serverSDK()
|
const result = await serverSDK()
|
||||||
.client.provider.oauth.callback({
|
.api.integration.oauth.status({
|
||||||
providerID: props.provider,
|
integrationID: props.provider,
|
||||||
method: store.methodIndex,
|
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 }))
|
.catch((error) => ({ ok: false as const, error }))
|
||||||
|
|
||||||
if (!alive.value) return
|
if (!alive.value) return
|
||||||
|
|
||||||
if (!result.ok) {
|
if (!result.ok) {
|
||||||
const message = formatError(result.error, language.t("common.requestFailed"))
|
dispatch({ type: "auth.error", error: formatError(result.error, language.t("common.requestFailed")) })
|
||||||
dispatch({ type: "auth.error", error: message })
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (result.status.status === "complete") {
|
||||||
await 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 (
|
return (
|
||||||
|
|
@ -1178,15 +1160,15 @@ function ProviderConnection(props: {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Match>
|
</Match>
|
||||||
<Match when={method()?.type === "api"}>
|
<Match when={method()?.type === "key"}>
|
||||||
<ApiAuthView />
|
<ApiAuthView />
|
||||||
</Match>
|
</Match>
|
||||||
<Match when={method()?.type === "oauth"}>
|
<Match when={method()?.type === "oauth"}>
|
||||||
<Switch>
|
<Switch>
|
||||||
<Match when={store.authorization?.method === "code"}>
|
<Match when={store.authorization?.mode === "code"}>
|
||||||
<OAuthCodeView />
|
<OAuthCodeView />
|
||||||
</Match>
|
</Match>
|
||||||
<Match when={store.authorization?.method === "auto"}>
|
<Match when={store.authorization?.mode === "auto"}>
|
||||||
<OAuthAutoView />
|
<OAuthAutoView />
|
||||||
</Match>
|
</Match>
|
||||||
</Switch>
|
</Switch>
|
||||||
|
|
|
||||||
|
|
@ -69,15 +69,11 @@ export const DialogFork: Component = () => {
|
||||||
const dir = base64Encode(sdk().directory)
|
const dir = base64Encode(sdk().directory)
|
||||||
|
|
||||||
sdk()
|
sdk()
|
||||||
.client.session.fork({ sessionID, messageID: item.id })
|
.api.session.fork({ sessionID, messageID: item.id })
|
||||||
.then((forked) => {
|
.then((forked) => {
|
||||||
if (!forked.data) {
|
|
||||||
showToast({ title: language.t("common.requestFailed") })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
dialog.close()
|
dialog.close()
|
||||||
prompt.set(restored, undefined, { dir, id: forked.data.id })
|
prompt.set(restored, undefined, { dir, id: forked.id })
|
||||||
navigate(`/${dir}/session/${forked.data.id}`)
|
navigate(`/${dir}/session/${forked.id}`)
|
||||||
})
|
})
|
||||||
.catch((err: unknown) => {
|
.catch((err: unknown) => {
|
||||||
const message = err instanceof Error ? err.message : String(err)
|
const message = err instanceof Error ? err.message : String(err)
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,7 @@ import {
|
||||||
} from "./directory-picker-domain"
|
} from "./directory-picker-domain"
|
||||||
import "./dialog-select-directory-v2.css"
|
import "./dialog-select-directory-v2.css"
|
||||||
import { DividerV2 } from "@opencode-ai/ui/v2/divider-v2"
|
import { DividerV2 } from "@opencode-ai/ui/v2/divider-v2"
|
||||||
|
import { getFilename } from "@opencode-ai/core/util/path"
|
||||||
|
|
||||||
interface DialogSelectDirectoryV2Props {
|
interface DialogSelectDirectoryV2Props {
|
||||||
title?: string
|
title?: string
|
||||||
|
|
@ -68,9 +69,8 @@ export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) {
|
||||||
const [fallbackPath] = createResource(
|
const [fallbackPath] = createResource(
|
||||||
() => (missingBase() ? true : undefined),
|
() => (missingBase() ? true : undefined),
|
||||||
() =>
|
() =>
|
||||||
sdk.client.path
|
sdk.api.path
|
||||||
.get()
|
.get()
|
||||||
.then((result) => result.data)
|
|
||||||
.catch(() => undefined),
|
.catch(() => undefined),
|
||||||
{ initialValue: undefined },
|
{ initialValue: undefined },
|
||||||
)
|
)
|
||||||
|
|
@ -85,18 +85,26 @@ export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) {
|
||||||
)
|
)
|
||||||
const search = createDirectorySearch({ sdk, home, base: () => root() || start() })
|
const search = createDirectorySearch({ sdk, home, base: () => root() || start() })
|
||||||
const [suggestions] = createResource(input, async (value) => {
|
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(/\/+$/, "")
|
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 }))
|
const directories = (await search(value)).map((absolute) => ({ absolute, type: "directory" as const }))
|
||||||
if (!policy.includeFiles) return { query: value, items: directories.slice(0, 5) }
|
if (!policy.includeFiles) return { query: value, items: directories.slice(0, 5) }
|
||||||
const files = await sdk.client.find
|
const base = pickerRoot(cleaned) || root() || start()
|
||||||
.files({ directory: root(), query: pickerFileSearchQuery(root(), value, home()), type: "file", limit: 20 })
|
if (!base) return { query: value, items: directories.slice(0, 5) }
|
||||||
.then((result) => result.data ?? [])
|
const files = await sdk.api.file
|
||||||
|
.find({
|
||||||
|
location: { directory: base },
|
||||||
|
query: pickerFileSearchQuery(base, value, home()),
|
||||||
|
type: "file",
|
||||||
|
limit: 20,
|
||||||
|
})
|
||||||
|
.then((result) => result.data)
|
||||||
.catch(() => [])
|
.catch(() => [])
|
||||||
const results = [
|
const results = [
|
||||||
...directories,
|
...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 {
|
return {
|
||||||
query: value,
|
query: value,
|
||||||
|
|
@ -115,9 +123,14 @@ export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) {
|
||||||
existing ??
|
existing ??
|
||||||
loads.schedule(`${generation}:${key}`, eager ? "background" : "user", () => {
|
loads.schedule(`${generation}:${key}`, eager ? "background" : "user", () => {
|
||||||
if (!activeTreeNavigation(generation, navigation)) return Promise.resolve(undefined)
|
if (!activeTreeNavigation(generation, navigation)) return Promise.resolve(undefined)
|
||||||
return sdk.client.file
|
return sdk.api.file
|
||||||
.list({ directory: absolute, path: "" })
|
.list({ location: { directory: absolute } })
|
||||||
.then((result) => result.data ?? [])
|
.then((result) =>
|
||||||
|
result.data.map((entry) => ({
|
||||||
|
name: getFilename(entry.path.replace(/[\\/]+$/, "")),
|
||||||
|
type: entry.type,
|
||||||
|
})),
|
||||||
|
)
|
||||||
.catch(() => undefined)
|
.catch(() => undefined)
|
||||||
})
|
})
|
||||||
listings.set(key, request)
|
listings.set(key, request)
|
||||||
|
|
|
||||||
|
|
@ -60,9 +60,8 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
|
||||||
const [fallbackPath] = createResource(
|
const [fallbackPath] = createResource(
|
||||||
() => (missingBase() ? true : undefined),
|
() => (missingBase() ? true : undefined),
|
||||||
async () => {
|
async () => {
|
||||||
return sdk.client.path
|
return sdk.api.path
|
||||||
.get()
|
.get()
|
||||||
.then((x) => x.data)
|
|
||||||
.catch(() => undefined)
|
.catch(() => undefined)
|
||||||
},
|
},
|
||||||
{ initialValue: undefined },
|
{ initialValue: undefined },
|
||||||
|
|
|
||||||
|
|
@ -43,7 +43,7 @@ export const DialogSelectMcp: Component = () => {
|
||||||
filterKeys={["name", "status"]}
|
filterKeys={["name", "status"]}
|
||||||
sortBy={(a, b) => a.name.localeCompare(b.name)}
|
sortBy={(a, b) => a.name.localeCompare(b.name)}
|
||||||
onSelect={(x) => {
|
onSelect={(x) => {
|
||||||
if (!x || toggle.isPending) return
|
if (!x || x.status === "pending" || toggle.isPending) return
|
||||||
toggle.mutate(x.name)
|
toggle.mutate(x.name)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|
@ -76,7 +76,7 @@ export const DialogSelectMcp: Component = () => {
|
||||||
<div onClick={(e) => e.stopPropagation()}>
|
<div onClick={(e) => e.stopPropagation()}>
|
||||||
<Switch
|
<Switch
|
||||||
checked={enabled()}
|
checked={enabled()}
|
||||||
disabled={toggle.isPending && toggle.variables === i.name}
|
disabled={status() === "pending" || (toggle.isPending && toggle.variables === i.name)}
|
||||||
onChange={() => {
|
onChange={() => {
|
||||||
if (toggle.isPending) return
|
if (toggle.isPending) return
|
||||||
toggle.mutate(i.name)
|
toggle.mutate(i.name)
|
||||||
|
|
|
||||||
|
|
@ -133,10 +133,10 @@ test("scopes file autocomplete to the current browser root", () => {
|
||||||
test("resolves directory autocomplete from the current browser root", async () => {
|
test("resolves directory autocomplete from the current browser root", async () => {
|
||||||
const directories: string[] = []
|
const directories: string[] = []
|
||||||
const sdk = {
|
const sdk = {
|
||||||
client: {
|
api: {
|
||||||
find: {
|
file: {
|
||||||
files: (input: { directory: string }) => {
|
find: (input: { location?: { directory?: string } }) => {
|
||||||
directories.push(input.directory)
|
directories.push(input.location?.directory ?? "")
|
||||||
return Promise.resolve({ data: [] })
|
return Promise.resolve({ data: [] })
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
@ -152,6 +152,29 @@ test("resolves directory autocomplete from the current browser root", async () =
|
||||||
expect(directories).toEqual(["/repo", "/repo/src"])
|
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<typeof createDirectorySearch>[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", () => {
|
test("identifies the next directory level to preload", () => {
|
||||||
expect(
|
expect(
|
||||||
preloadTreeDirectories("src/", [
|
preloadTreeDirectories("src/", [
|
||||||
|
|
|
||||||
|
|
@ -326,15 +326,15 @@ export function createDirectorySearch(args: { sdk: ServerSDK; base: () => string
|
||||||
let current = 0
|
let current = 0
|
||||||
|
|
||||||
const scoped = (value: string) => {
|
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()
|
const base = args.base()
|
||||||
if (!base) return
|
if (!base) return
|
||||||
const raw = normalizePickerDrive(value)
|
|
||||||
if (!raw) return { directory: trimPickerPath(base), path: "" }
|
if (!raw) return { directory: trimPickerPath(base), path: "" }
|
||||||
const home = args.home()
|
const home = args.home()
|
||||||
if (raw === "~") return { directory: trimPickerPath(home || base), path: "" }
|
if (raw === "~") return { directory: trimPickerPath(home || base), path: "" }
|
||||||
if (raw.startsWith("~/")) return { directory: trimPickerPath(home || base), path: raw.slice(2) }
|
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 }
|
return { directory: trimPickerPath(base), path: raw }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -342,14 +342,17 @@ export function createDirectorySearch(args: { sdk: ServerSDK; base: () => string
|
||||||
const key = trimPickerPath(directory)
|
const key = trimPickerPath(directory)
|
||||||
const existing = cache.get(key)
|
const existing = cache.get(key)
|
||||||
if (existing) return existing
|
if (existing) return existing
|
||||||
const request = args.sdk.client.file
|
const request = args.sdk.api.file
|
||||||
.list({ directory: key, path: "" })
|
.list({ location: { directory: key } })
|
||||||
.then((result) => result.data ?? [])
|
.then((result) => result.data)
|
||||||
.catch(() => [])
|
.catch(() => [])
|
||||||
.then((nodes) =>
|
.then((nodes) =>
|
||||||
nodes
|
nodes
|
||||||
.filter((node) => node.type === "directory")
|
.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)
|
cache.set(key, request)
|
||||||
return request
|
return request
|
||||||
|
|
@ -371,9 +374,9 @@ export function createDirectorySearch(args: { sdk: ServerSDK; base: () => string
|
||||||
const pathInput = raw.startsWith("~") || !!pickerRoot(raw) || raw.includes("/")
|
const pathInput = raw.startsWith("~") || !!pickerRoot(raw) || raw.includes("/")
|
||||||
const query = normalizePickerDrive(input.path)
|
const query = normalizePickerDrive(input.path)
|
||||||
if (!pathInput) {
|
if (!pathInput) {
|
||||||
const results = await args.sdk.client.find
|
const results = await args.sdk.api.file
|
||||||
.files({ directory: input.directory, query, type: "directory", limit: 50 })
|
.find({ location: { directory: input.directory }, query, type: "directory", limit: 50 })
|
||||||
.then((result) => result.data ?? [])
|
.then((result) => result.data.map((entry) => entry.path))
|
||||||
.catch(() => [])
|
.catch(() => [])
|
||||||
if (!active()) return []
|
if (!active()) return []
|
||||||
return results.map((path) => joinPickerPath(input.directory, path)).slice(0, 50)
|
return results.map((path) => joinPickerPath(input.directory, path)).slice(0, 50)
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { getFilename } from "@opencode-ai/core/util/path"
|
import { getFilename } from "@opencode-ai/core/util/path"
|
||||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||||
import { useMutation } from "@tanstack/solid-query"
|
import { useMutation } from "@tanstack/solid-query"
|
||||||
|
import { normalizeProjectInfo } from "@/context/global-sync/utils"
|
||||||
import { createMemo } from "solid-js"
|
import { createMemo } from "solid-js"
|
||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
import { useGlobal } from "@/context/global"
|
import { useGlobal } from "@/context/global"
|
||||||
|
|
@ -70,13 +71,15 @@ export function createEditProjectModel(props: { project: LocalProject; server: S
|
||||||
const start = store.startup.trim()
|
const start = store.startup.trim()
|
||||||
|
|
||||||
if (props.project.id && props.project.id !== "global") {
|
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,
|
projectID: props.project.id,
|
||||||
directory: props.project.worktree,
|
|
||||||
name,
|
name,
|
||||||
icon: { color: store.color || "", override: store.iconOverride || "" },
|
icon: { color: store.color || "", override: store.iconOverride || "" },
|
||||||
commands: { start },
|
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)
|
serverCtx().sync.project.icon(props.project.worktree, store.iconOverride || undefined)
|
||||||
dialog.close()
|
dialog.close()
|
||||||
return
|
return
|
||||||
|
|
|
||||||
|
|
@ -310,7 +310,7 @@ export function usePromptInputV2Controller(props: PromptInputV2ControllerProps):
|
||||||
)
|
)
|
||||||
const resources = createMemo(() =>
|
const resources = createMemo(() =>
|
||||||
Object.values(sync().data.mcp_resource).map((resource) => ({
|
Object.values(sync().data.mcp_resource).map((resource) => ({
|
||||||
id: `resource:${resource.client}:${resource.uri}`,
|
id: `resource:${resource.server}:${resource.uri}`,
|
||||||
kind: "resource" as const,
|
kind: "resource" as const,
|
||||||
label: `@${resource.name}`,
|
label: `@${resource.name}`,
|
||||||
path: resource.uri,
|
path: resource.uri,
|
||||||
|
|
@ -327,7 +327,7 @@ export function usePromptInputV2Controller(props: PromptInputV2ControllerProps):
|
||||||
source: {
|
source: {
|
||||||
type: "resource" as const,
|
type: "resource" as const,
|
||||||
text: { value: `@${resource.name}`, start: 0, end: resource.name.length + 1 },
|
text: { value: `@${resource.name}`, start: 0, end: resource.name.length + 1 },
|
||||||
clientName: resource.client,
|
clientName: resource.server,
|
||||||
uri: resource.uri,
|
uri: resource.uri,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -591,7 +591,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||||
type: "resource",
|
type: "resource",
|
||||||
name: resource.name,
|
name: resource.name,
|
||||||
uri: resource.uri,
|
uri: resource.uri,
|
||||||
client: resource.client,
|
client: resource.server,
|
||||||
display: resource.name,
|
display: resource.name,
|
||||||
description: resource.description,
|
description: resource.description,
|
||||||
mime: resource.mimeType,
|
mime: resource.mimeType,
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,11 @@ let createPromptSubmit: typeof import("./submit").createPromptSubmit
|
||||||
|
|
||||||
const createdClients: string[] = []
|
const createdClients: string[] = []
|
||||||
const createdSessions: 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 enabledAutoAccept: Array<{ server: string; sessionID: string; directory: string }> = []
|
||||||
const optimistic: Array<{
|
const optimistic: Array<{
|
||||||
directory?: string
|
directory?: string
|
||||||
|
|
@ -20,9 +25,14 @@ const optimistic: Array<{
|
||||||
const optimisticSeeded: boolean[] = []
|
const optimisticSeeded: boolean[] = []
|
||||||
const storedSessions: Record<string, Array<{ id: string; title?: string }>> = {}
|
const storedSessions: Record<string, Array<{ id: string; title?: string }>> = {}
|
||||||
const promoted: Array<{ directory: string; sessionID: string }> = []
|
const promoted: Array<{ directory: string; sessionID: string }> = []
|
||||||
const sentShell: string[] = []
|
const sentShell: Array<{ sessionID: string; id?: string; command: string }> = []
|
||||||
const syncedDirectories: string[] = []
|
const syncedDirectories: string[] = []
|
||||||
const promotedDrafts: Array<{ draftID: string; server: string; sessionId: 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 params: { id?: string } = {}
|
||||||
let search: { draftId?: string } = {}
|
let search: { draftId?: string } = {}
|
||||||
|
|
@ -31,7 +41,7 @@ let variant: string | undefined
|
||||||
let permissionServer = "server-a"
|
let permissionServer = "server-a"
|
||||||
let createSessionGate: Promise<void> | undefined
|
let createSessionGate: Promise<void> | 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<PromptStore>({
|
const [promptStore, setPromptStore] = createStore<PromptStore>({
|
||||||
prompt: promptValue,
|
prompt: promptValue,
|
||||||
cursor: 0,
|
cursor: 0,
|
||||||
|
|
@ -63,23 +73,39 @@ const prompt = {
|
||||||
const clientFor = (directory: string) => {
|
const clientFor = (directory: string) => {
|
||||||
createdClients.push(directory)
|
createdClients.push(directory)
|
||||||
return {
|
return {
|
||||||
session: {
|
api: {
|
||||||
create: async () => {
|
session: {
|
||||||
await createSessionGate
|
create: async (input: (typeof sessionCreateInputs)[number]) => {
|
||||||
createdSessions.push(directory)
|
await createSessionGate
|
||||||
return {
|
const location = input.location?.directory ?? directory
|
||||||
data: {
|
createdSessions.push(location)
|
||||||
|
sessionCreateInputs.push(input)
|
||||||
|
return {
|
||||||
id: `session-${createdSessions.length}`,
|
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}`,
|
title: `New session ${createdSessions.length}`,
|
||||||
},
|
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)
|
||||||
|
},
|
||||||
},
|
},
|
||||||
shell: async () => {
|
},
|
||||||
sentShell.push(directory)
|
session: {
|
||||||
return { data: undefined }
|
|
||||||
},
|
|
||||||
prompt: async () => ({ data: undefined }),
|
|
||||||
promptAsync: async () => ({ data: undefined }),
|
|
||||||
command: async () => ({ data: undefined }),
|
command: async () => ({ data: undefined }),
|
||||||
abort: async () => ({ data: undefined }),
|
abort: async () => ({ data: undefined }),
|
||||||
},
|
},
|
||||||
|
|
@ -172,6 +198,7 @@ beforeAll(async () => {
|
||||||
scope: "local",
|
scope: "local",
|
||||||
directory: "/repo/main",
|
directory: "/repo/main",
|
||||||
client: rootClient,
|
client: rootClient,
|
||||||
|
api: rootClient.api,
|
||||||
url: "http://localhost:4096",
|
url: "http://localhost:4096",
|
||||||
createClient(opts: any) {
|
createClient(opts: any) {
|
||||||
return clientFor(opts.directory)
|
return clientFor(opts.directory)
|
||||||
|
|
@ -183,7 +210,7 @@ beforeAll(async () => {
|
||||||
|
|
||||||
mock.module("@/context/sync", () => ({
|
mock.module("@/context/sync", () => ({
|
||||||
useSync: () => () => ({
|
useSync: () => () => ({
|
||||||
data: { command: [] },
|
data: { command: commands },
|
||||||
session: {
|
session: {
|
||||||
optimistic: {
|
optimistic: {
|
||||||
add: (value: {
|
add: (value: {
|
||||||
|
|
@ -210,6 +237,9 @@ beforeAll(async () => {
|
||||||
session: {
|
session: {
|
||||||
remember: () => undefined,
|
remember: () => undefined,
|
||||||
set: () => undefined,
|
set: () => undefined,
|
||||||
|
sync: async () => {
|
||||||
|
serverSessionSyncs++
|
||||||
|
},
|
||||||
},
|
},
|
||||||
child: (directory: string) => {
|
child: (directory: string) => {
|
||||||
syncedDirectories.push(directory)
|
syncedDirectories.push(directory)
|
||||||
|
|
@ -251,11 +281,17 @@ beforeAll(async () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
createdClients.length = 0
|
createdClients.length = 0
|
||||||
createdSessions.length = 0
|
createdSessions.length = 0
|
||||||
|
sessionCreateInputs.length = 0
|
||||||
enabledAutoAccept.length = 0
|
enabledAutoAccept.length = 0
|
||||||
optimistic.length = 0
|
optimistic.length = 0
|
||||||
optimisticSeeded.length = 0
|
optimisticSeeded.length = 0
|
||||||
promoted.length = 0
|
promoted.length = 0
|
||||||
promotedDrafts.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 = {}
|
params = {}
|
||||||
search = {}
|
search = {}
|
||||||
sentShell.length = 0
|
sentShell.length = 0
|
||||||
|
|
@ -264,6 +300,7 @@ beforeEach(() => {
|
||||||
variant = undefined
|
variant = undefined
|
||||||
permissionServer = "server-a"
|
permissionServer = "server-a"
|
||||||
createSessionGate = undefined
|
createSessionGate = undefined
|
||||||
|
serverSessionSyncs = 0
|
||||||
for (const key of Object.keys(storedSessions)) delete storedSessions[key]
|
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(createdClients).toEqual(["/repo/worktree-a", "/repo/worktree-b"])
|
||||||
expect(createdSessions).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(syncedDirectories).toEqual(["/repo/worktree-a", "/repo/worktree-a", "/repo/worktree-b", "/repo/worktree-b"])
|
||||||
|
expect(serverSessionSyncs).toBe(0)
|
||||||
expect(promoted).toEqual([
|
expect(promoted).toEqual([
|
||||||
{ directory: "/repo/worktree-a", sessionID: "session-1" },
|
{ directory: "/repo/worktree-a", sessionID: "session-1" },
|
||||||
{ directory: "/repo/worktree-b", sessionID: "session-2" },
|
{ directory: "/repo/worktree-b", sessionID: "session-2" },
|
||||||
|
|
@ -419,6 +472,7 @@ describe("prompt submit worktree selection", () => {
|
||||||
const event = { preventDefault: () => undefined } as unknown as Event
|
const event = { preventDefault: () => undefined } as unknown as Event
|
||||||
|
|
||||||
await submit.handleSubmit(event)
|
await submit.handleSubmit(event)
|
||||||
|
await Bun.sleep(0)
|
||||||
|
|
||||||
expect(optimistic).toHaveLength(1)
|
expect(optimistic).toHaveLength(1)
|
||||||
expect(optimistic[0]).toMatchObject({
|
expect(optimistic[0]).toMatchObject({
|
||||||
|
|
@ -427,6 +481,53 @@ describe("prompt submit worktree selection", () => {
|
||||||
model: { providerID: "provider", modelID: "model", variant: "high" },
|
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 () => {
|
test("uses an injected model selection", async () => {
|
||||||
|
|
@ -487,7 +588,8 @@ describe("prompt submit worktree selection", () => {
|
||||||
|
|
||||||
await submit.handleSubmit(event)
|
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])
|
expect(optimisticSeeded).toEqual([true])
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,8 @@ import { setCursorPosition } from "./editor-dom"
|
||||||
import { formatServerError } from "@/utils/server-errors"
|
import { formatServerError } from "@/utils/server-errors"
|
||||||
import { ScopedKey } from "@/utils/server-scope"
|
import { ScopedKey } from "@/utils/server-scope"
|
||||||
import { createPromptSubmissionState } from "./submission-state"
|
import { createPromptSubmissionState } from "./submission-state"
|
||||||
|
import { normalizeSessionInfo } from "@/utils/session"
|
||||||
|
import { Event } from "@opencode-ai/schema/event"
|
||||||
|
|
||||||
type PendingPrompt = {
|
type PendingPrompt = {
|
||||||
abort: AbortController
|
abort: AbortController
|
||||||
|
|
@ -39,7 +41,7 @@ export type FollowupDraft = {
|
||||||
}
|
}
|
||||||
|
|
||||||
type FollowupSendInput = {
|
type FollowupSendInput = {
|
||||||
client: DirectorySDK["client"]
|
api: DirectorySDK["api"]["session"]
|
||||||
serverSync: ServerSync
|
serverSync: ServerSync
|
||||||
sync: DirectorySync
|
sync: DirectorySync
|
||||||
draft: FollowupDraft
|
draft: FollowupDraft
|
||||||
|
|
@ -81,19 +83,21 @@ export async function sendFollowupDraft(input: FollowupSendInput) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
await input.client.session.command({
|
const messageID = Identifier.ascending("message")
|
||||||
|
await input.api.command({
|
||||||
sessionID: input.draft.sessionID,
|
sessionID: input.draft.sessionID,
|
||||||
|
id: messageID,
|
||||||
command: cmd,
|
command: cmd,
|
||||||
arguments: tail.join(" "),
|
arguments: tail.join(" "),
|
||||||
agent: input.draft.agent,
|
agent: input.draft.agent,
|
||||||
model: `${input.draft.model.providerID}/${input.draft.model.modelID}`,
|
model: {
|
||||||
variant: input.draft.variant,
|
id: input.draft.model.modelID,
|
||||||
parts: images.map((attachment) => ({
|
providerID: input.draft.model.providerID,
|
||||||
id: Identifier.ascending("part"),
|
variant: input.draft.variant,
|
||||||
type: "file" as const,
|
},
|
||||||
mime: attachment.mime,
|
files: images.map((attachment) => ({
|
||||||
url: attachment.dataUrl,
|
uri: attachment.dataUrl,
|
||||||
filename: attachment.filename,
|
name: attachment.filename,
|
||||||
})),
|
})),
|
||||||
})
|
})
|
||||||
return true
|
return true
|
||||||
|
|
@ -152,13 +156,36 @@ export async function sendFollowupDraft(input: FollowupSendInput) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
await input.client.session.promptAsync({
|
await input.api.prompt({
|
||||||
sessionID: input.draft.sessionID,
|
sessionID: input.draft.sessionID,
|
||||||
|
id: messageID,
|
||||||
agent: input.draft.agent,
|
agent: input.draft.agent,
|
||||||
model: input.draft.model,
|
model: input.draft.model,
|
||||||
messageID,
|
|
||||||
parts: requestParts,
|
|
||||||
variant: input.draft.variant,
|
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
|
return true
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
@ -210,6 +237,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||||
const pendingKey = (sessionID: string) => ScopedKey.from(sdk().scope, sessionID)
|
const pendingKey = (sessionID: string) => ScopedKey.from(sdk().scope, sessionID)
|
||||||
|
|
||||||
const errorMessage = (err: unknown) => {
|
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) {
|
if (err && typeof err === "object" && "data" in err) {
|
||||||
const data = (err as { data?: { message?: string } }).data
|
const data = (err as { data?: { message?: string } }).data
|
||||||
if (data?.message) return data.message
|
if (data?.message) return data.message
|
||||||
|
|
@ -235,9 +263,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||||
return Promise.resolve()
|
return Promise.resolve()
|
||||||
}
|
}
|
||||||
return sdk()
|
return sdk()
|
||||||
.client.session.abort({
|
.api.session.interrupt({ sessionID })
|
||||||
sessionID,
|
|
||||||
})
|
|
||||||
.catch(() => {})
|
.catch(() => {})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -364,9 +390,13 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||||
|
|
||||||
let session = input.info()
|
let session = input.info()
|
||||||
if (!session && isNewSession) {
|
if (!session && isNewSession) {
|
||||||
const created = await client.session
|
const created = await sdk()
|
||||||
.create()
|
.api.session.create({
|
||||||
.then((x) => x.data ?? undefined)
|
agent: currentAgent.name,
|
||||||
|
model: { id: currentModel.id, providerID: currentModel.provider.id, variant },
|
||||||
|
location: { directory: sessionDirectory },
|
||||||
|
})
|
||||||
|
.then(normalizeSessionInfo)
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
showToast({
|
showToast({
|
||||||
title: language.t("prompt.toast.sessionCreateFailed.title"),
|
title: language.t("prompt.toast.sessionCreateFailed.title"),
|
||||||
|
|
@ -450,12 +480,14 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||||
|
|
||||||
if (mode === "shell") {
|
if (mode === "shell") {
|
||||||
clearInput()
|
clearInput()
|
||||||
client.session
|
const eventID = Event.ID.create()
|
||||||
.shell({
|
sdk()
|
||||||
|
.api.session.shell({
|
||||||
sessionID: session.id,
|
sessionID: session.id,
|
||||||
|
id: eventID,
|
||||||
|
command: text,
|
||||||
agent,
|
agent,
|
||||||
model,
|
model,
|
||||||
command: text,
|
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
showToast({
|
showToast({
|
||||||
|
|
@ -473,23 +505,23 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||||
const customCommand = sync().data.command.find((c) => c.name === commandName)
|
const customCommand = sync().data.command.find((c) => c.name === commandName)
|
||||||
if (customCommand) {
|
if (customCommand) {
|
||||||
clearInput()
|
clearInput()
|
||||||
client.session
|
const messageID = Identifier.ascending("message")
|
||||||
.command({
|
serverSync().session.set("session_status", session.id, { type: "busy" })
|
||||||
|
sdk()
|
||||||
|
.api.session.command({
|
||||||
sessionID: session.id,
|
sessionID: session.id,
|
||||||
|
id: messageID,
|
||||||
command: commandName,
|
command: commandName,
|
||||||
arguments: args.join(" "),
|
arguments: args.join(" "),
|
||||||
agent,
|
agent,
|
||||||
model: `${model.providerID}/${model.modelID}`,
|
model: { id: model.modelID, providerID: model.providerID, variant },
|
||||||
variant,
|
files: images.map((attachment) => ({
|
||||||
parts: images.map((attachment) => ({
|
uri: attachment.dataUrl,
|
||||||
id: Identifier.ascending("part"),
|
name: attachment.filename,
|
||||||
type: "file" as const,
|
|
||||||
mime: attachment.mime,
|
|
||||||
url: attachment.dataUrl,
|
|
||||||
filename: attachment.filename,
|
|
||||||
})),
|
})),
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
|
serverSync().session.set("session_status", session.id, { type: "idle" })
|
||||||
showToast({
|
showToast({
|
||||||
title: language.t("prompt.toast.commandSendFailed.title"),
|
title: language.t("prompt.toast.commandSendFailed.title"),
|
||||||
description: formatServerError(err, language.t, language.t("common.requestFailed")),
|
description: formatServerError(err, language.t, language.t("common.requestFailed")),
|
||||||
|
|
@ -573,7 +605,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||||
}
|
}
|
||||||
|
|
||||||
void sendFollowupDraft({
|
void sendFollowupDraft({
|
||||||
client,
|
api: sdk().api.session,
|
||||||
sync: sync(),
|
sync: sync(),
|
||||||
serverSync: serverSync(),
|
serverSync: serverSync(),
|
||||||
draft,
|
draft,
|
||||||
|
|
|
||||||
|
|
@ -129,8 +129,8 @@ export const SettingsGeneral: Component = () => {
|
||||||
const [shells] = createResource(
|
const [shells] = createResource(
|
||||||
() =>
|
() =>
|
||||||
serverSdk()
|
serverSdk()
|
||||||
.client.pty.shells()
|
.api.pty.shells()
|
||||||
.then((res) => res.data ?? [])
|
.then((res) => res.data)
|
||||||
.catch(() => [] as ShellOption[]),
|
.catch(() => [] as ShellOption[]),
|
||||||
{ initialValue: [] as ShellOption[] },
|
{ initialValue: [] as ShellOption[] },
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -124,8 +124,8 @@ export const SettingsGeneralV2: Component<{
|
||||||
const [shells] = createResource(
|
const [shells] = createResource(
|
||||||
() =>
|
() =>
|
||||||
serverSdk()
|
serverSdk()
|
||||||
.client.pty.shells()
|
.api.pty.shells()
|
||||||
.then((res) => res.data ?? [])
|
.then((res) => res.data)
|
||||||
.catch(() => [] as ShellOption[]),
|
.catch(() => [] as ShellOption[]),
|
||||||
{ initialValue: [] as ShellOption[] },
|
{ initialValue: [] as ShellOption[] },
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,7 @@ describe("hasNonBlockingServiceIssue", () => {
|
||||||
expect(hasNonBlockingServiceIssue({ mcp: ["failed"], lsp: [] })).toBe(true)
|
expect(hasNonBlockingServiceIssue({ mcp: ["failed"], lsp: [] })).toBe(true)
|
||||||
expect(hasNonBlockingServiceIssue({ mcp: ["needs_auth"], lsp: [] })).toBe(true)
|
expect(hasNonBlockingServiceIssue({ mcp: ["needs_auth"], lsp: [] })).toBe(true)
|
||||||
expect(hasNonBlockingServiceIssue({ mcp: ["needs_client_registration"], 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", () => {
|
test("detects LSP failures that do not block chatting", () => {
|
||||||
|
|
|
||||||
|
|
@ -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: {
|
export function hasNonBlockingServiceIssue(input: {
|
||||||
mcp: Array<McpStatus["status"]>
|
mcp: Array<McpServer["status"]["status"]>
|
||||||
lsp: Array<LspStatus["status"]>
|
lsp: Array<LspStatus["status"]>
|
||||||
}) {
|
}) {
|
||||||
return (
|
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")
|
input.lsp.some((status) => status === "error")
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,6 @@ import { matchKeybind, parseKeybind } from "@/context/command"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { usePlatform } from "@/context/platform"
|
import { usePlatform } from "@/context/platform"
|
||||||
import { useSDK } from "@/context/sdk"
|
import { useSDK } from "@/context/sdk"
|
||||||
import { useServerSDK } from "@/context/server-sdk"
|
|
||||||
import { terminalFontFamily, useSettings } from "@/context/settings"
|
import { terminalFontFamily, useSettings } from "@/context/settings"
|
||||||
import type { LocalPTY } from "@/context/terminal"
|
import type { LocalPTY } from "@/context/terminal"
|
||||||
import { disposeIfDisposable, getHoveredLinkText, setOptionIfSupported } from "@/utils/runtime-adapters"
|
import { disposeIfDisposable, getHoveredLinkText, setOptionIfSupported } from "@/utils/runtime-adapters"
|
||||||
|
|
@ -176,15 +175,9 @@ export const Terminal = (props: TerminalProps) => {
|
||||||
const theme = useTheme()
|
const theme = useTheme()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
// Terminal captures its connection for the PTY lifetime, so callers must key it per server/session.
|
// 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 directory = sdk().directory
|
||||||
const client = sdk().client
|
const client = sdk().api.pty
|
||||||
const url = sdk().url
|
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
|
let container!: HTMLDivElement
|
||||||
const [local, others] = splitProps(props, [
|
const [local, others] = splitProps(props, [
|
||||||
"pty",
|
"pty",
|
||||||
|
|
@ -242,9 +235,10 @@ export const Terminal = (props: TerminalProps) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
const pushSize = (cols: number, rows: number) => {
|
const pushSize = (cols: number, rows: number) => {
|
||||||
return client.pty
|
return client
|
||||||
.update({
|
.update({
|
||||||
ptyID: id,
|
ptyID: id,
|
||||||
|
location: { directory },
|
||||||
size: { cols, rows },
|
size: { cols, rows },
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
|
|
@ -523,33 +517,29 @@ export const Terminal = (props: TerminalProps) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
const gone = () =>
|
const gone = () =>
|
||||||
client.pty
|
client
|
||||||
.get({ ptyID: id }, { throwOnError: false })
|
.get({ ptyID: id, location: { directory } })
|
||||||
.then((result) => result.response.status === 404)
|
.then((result) => result.data.status === "exited")
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
|
if (err && typeof err === "object" && "_tag" in err && err._tag === "PtyNotFoundError") return true
|
||||||
debugTerminal("failed to inspect terminal session", err)
|
debugTerminal("failed to inspect terminal session", err)
|
||||||
return false
|
return false
|
||||||
})
|
})
|
||||||
|
|
||||||
const connectToken = async () => {
|
const connectToken = async () => {
|
||||||
const result = await client.pty
|
return client
|
||||||
.connectToken(
|
.connectToken({
|
||||||
{ ptyID: id, directory },
|
ptyID: id,
|
||||||
{
|
location: { directory },
|
||||||
throwOnError: false,
|
"x-opencode-ticket": "1",
|
||||||
headers: { "x-opencode-ticket": "1" },
|
})
|
||||||
},
|
.then((result) => result.data.ticket)
|
||||||
)
|
|
||||||
.catch((err: unknown) => {
|
.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
|
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) => {
|
const retry = (err: unknown) => {
|
||||||
|
|
@ -579,7 +569,7 @@ export const Terminal = (props: TerminalProps) => {
|
||||||
fail(err)
|
fail(err)
|
||||||
return undefined
|
return undefined
|
||||||
})
|
})
|
||||||
if (once.value) return
|
if (!ticket || once.value) return
|
||||||
if (disposed) return
|
if (disposed) return
|
||||||
|
|
||||||
const socket = new WebSocket(
|
const socket = new WebSocket(
|
||||||
|
|
@ -589,10 +579,6 @@ export const Terminal = (props: TerminalProps) => {
|
||||||
directory,
|
directory,
|
||||||
cursor: seek,
|
cursor: seek,
|
||||||
ticket,
|
ticket,
|
||||||
sameOrigin,
|
|
||||||
username,
|
|
||||||
password,
|
|
||||||
authToken,
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
socket.binaryType = "arraybuffer"
|
socket.binaryType = "arraybuffer"
|
||||||
|
|
|
||||||
|
|
@ -120,8 +120,7 @@ export function TabNavItem(props: {
|
||||||
const ctx = serverCtx()
|
const ctx = serverCtx()
|
||||||
const session = props.session()
|
const session = props.session()
|
||||||
if (!ctx || !session) return
|
if (!ctx || !session) return
|
||||||
const client = ctx.sdk.createClient({ directory: session.directory, throwOnError: true })
|
await ctx.sdk.api.session.rename({ sessionID: session.id, title })
|
||||||
await client.session.update({ sessionID: session.id, title })
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const closeRename = async (save: boolean) => {
|
const closeRename = async (save: boolean) => {
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,7 @@ import { tabKey, useTabs } from "@/context/tabs"
|
||||||
import type { PromptSession } from "@/context/prompt"
|
import type { PromptSession } from "@/context/prompt"
|
||||||
import "./titlebar.css"
|
import "./titlebar.css"
|
||||||
import { newTabTooltipKeybind } from "./command-tooltip-keybind"
|
import { newTabTooltipKeybind } from "./command-tooltip-keybind"
|
||||||
|
import { normalizeSessionInfo } from "@/utils/session"
|
||||||
|
|
||||||
type TauriDesktopWindow = {
|
type TauriDesktopWindow = {
|
||||||
startDragging?: () => Promise<void>
|
startDragging?: () => Promise<void>
|
||||||
|
|
@ -267,9 +268,9 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl
|
||||||
return conn ? { route, sdk: global.ensureServerCtx(conn).sdk } : undefined
|
return conn ? { route, sdk: global.ensureServerCtx(conn).sdk } : undefined
|
||||||
},
|
},
|
||||||
({ route, sdk }) =>
|
({ route, sdk }) =>
|
||||||
sdk.client.session
|
sdk.api.session
|
||||||
.get({ sessionID: route.sessionId })
|
.get({ sessionID: route.sessionId })
|
||||||
.then((x) => x.data)
|
.then(normalizeSessionInfo)
|
||||||
.catch(() => {}),
|
.catch(() => {}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import { produce, reconcile, type SetStoreFunction } from "solid-js/store"
|
||||||
import type { createServerSdkContext } from "./server-sdk"
|
import type { createServerSdkContext } from "./server-sdk"
|
||||||
import type { createServerSyncContextInner } from "./server-sync"
|
import type { createServerSyncContextInner } from "./server-sync"
|
||||||
import type { State } from "./global-sync/types"
|
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 cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
|
||||||
const sessionFields = new Set([
|
const sessionFields = new Set([
|
||||||
|
|
@ -15,6 +16,7 @@ const sessionFields = new Set([
|
||||||
"permission",
|
"permission",
|
||||||
"question",
|
"question",
|
||||||
"message",
|
"message",
|
||||||
|
"session_message",
|
||||||
"part",
|
"part",
|
||||||
"part_text_accum_delta",
|
"part_text_accum_delta",
|
||||||
])
|
])
|
||||||
|
|
@ -114,7 +116,6 @@ export const createDirSyncContext = (
|
||||||
await serverSync.session.sync(sessionID, options)
|
await serverSync.session.sync(sessionID, options)
|
||||||
index(sessionID)
|
index(sessionID)
|
||||||
},
|
},
|
||||||
diff: serverSync.session.diff,
|
|
||||||
todo: serverSync.session.todo,
|
todo: serverSync.session.todo,
|
||||||
history: serverSync.session.history,
|
history: serverSync.session.history,
|
||||||
evict(sessionID: string) {
|
evict(sessionID: string) {
|
||||||
|
|
@ -123,9 +124,9 @@ export const createDirSyncContext = (
|
||||||
fetch: async (count = 10) => {
|
fetch: async (count = 10) => {
|
||||||
const [store, setStore] = current()
|
const [store, setStore] = current()
|
||||||
setStore("limit", (value) => value + count)
|
setStore("limit", (value) => value + count)
|
||||||
const response = await client.session.list()
|
const response = await serverSDK.api.session.list({ directory, limit: store.limit, order: "desc" })
|
||||||
const sessions = (response.data ?? [])
|
const sessions = response.data
|
||||||
.filter((session) => !!session?.id)
|
.map(normalizeSessionInfo)
|
||||||
.sort((a, b) => cmp(a.id, b.id))
|
.sort((a, b) => cmp(a.id, b.id))
|
||||||
.slice(0, store.limit)
|
.slice(0, store.limit)
|
||||||
sessions.forEach(serverSync.session.remember)
|
sessions.forEach(serverSync.session.remember)
|
||||||
|
|
@ -133,7 +134,7 @@ export const createDirSyncContext = (
|
||||||
},
|
},
|
||||||
more: createMemo(() => current()[0].session.length >= current()[0].limit),
|
more: createMemo(() => current()[0].session.length >= current()[0].limit),
|
||||||
archive: async (sessionID: string) => {
|
archive: async (sessionID: string) => {
|
||||||
await serverSDK.client.session.update({ sessionID, time: { archived: Date.now() } })
|
await serverSDK.api.session.archive({ sessionID, directory })
|
||||||
current()[1](
|
current()[1](
|
||||||
"session",
|
"session",
|
||||||
produce((draft) => {
|
produce((draft) => {
|
||||||
|
|
|
||||||
|
|
@ -204,10 +204,18 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
|
||||||
}
|
}
|
||||||
|
|
||||||
const search = (query: string, dirs: "true" | "false", options?: { limit?: number; signal?: AbortSignal }) =>
|
const search = (query: string, dirs: "true" | "false", options?: { limit?: number; signal?: AbortSignal }) =>
|
||||||
sdk()
|
serverSDK()
|
||||||
.client.find.files({ query, dirs, limit: options?.limit }, { signal: options?.signal })
|
.api.file.find(
|
||||||
|
{
|
||||||
|
location: { directory: sdk().directory },
|
||||||
|
query,
|
||||||
|
type: dirs === "true" ? "directory" : "file",
|
||||||
|
limit: options?.limit,
|
||||||
|
},
|
||||||
|
{ signal: options?.signal },
|
||||||
|
)
|
||||||
.then(
|
.then(
|
||||||
(x) => (x.data ?? []).map(path.normalize),
|
(x) => x.data.map((entry) => path.normalize(entry.path)),
|
||||||
(error) => {
|
(error) => {
|
||||||
if (options?.signal?.aborted) throw error
|
if (options?.signal?.aborted) throw error
|
||||||
return []
|
return []
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,39 @@
|
||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
import { QueryClient } from "@tanstack/solid-query"
|
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 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 type { State, VcsCache } from "./types"
|
||||||
import { createServerSession } from "../server-session"
|
|
||||||
import { ServerScope } from "@/utils/server-scope"
|
import { ServerScope } from "@/utils/server-scope"
|
||||||
|
import type { ServerApi } from "@/utils/server"
|
||||||
|
|
||||||
const provider = { all: new Map(), connected: [], default: {} } satisfies NormalizedProviderListResponse
|
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() {
|
function directoryState() {
|
||||||
return createStore<State>({
|
return createStore<State>({
|
||||||
|
|
@ -41,6 +66,7 @@ function directoryState() {
|
||||||
vcs: undefined,
|
vcs: undefined,
|
||||||
limit: 5,
|
limit: 5,
|
||||||
message: {},
|
message: {},
|
||||||
|
session_message: {},
|
||||||
part: {},
|
part: {},
|
||||||
part_text_accum_delta: {},
|
part_text_accum_delta: {},
|
||||||
})
|
})
|
||||||
|
|
@ -64,7 +90,6 @@ describe("bootstrapDirectory", () => {
|
||||||
sdk: {
|
sdk: {
|
||||||
app: { agents: async () => ({ data: [{ name: "build", mode: "primary" }] }) },
|
app: { agents: async () => ({ data: [{ name: "build", mode: "primary" }] }) },
|
||||||
config: { get: async () => ({ data: {} }) },
|
config: { get: async () => ({ data: {} }) },
|
||||||
session: { status: async () => ({ data: {} }) },
|
|
||||||
vcs: { get: async () => ({ data: undefined }) },
|
vcs: { get: async () => ({ data: undefined }) },
|
||||||
command: {
|
command: {
|
||||||
list: async () => {
|
list: async () => {
|
||||||
|
|
@ -83,6 +108,7 @@ describe("bootstrapDirectory", () => {
|
||||||
},
|
},
|
||||||
provider: { list: async () => ({ data: { all: [], connected: [], default: {} } }) },
|
provider: { list: async () => ({ data: { all: [], connected: [], default: {} } }) },
|
||||||
} as unknown as OpencodeClient,
|
} as unknown as OpencodeClient,
|
||||||
|
api,
|
||||||
store,
|
store,
|
||||||
setStore,
|
setStore,
|
||||||
vcsCache: { setStore() {} } as unknown as VcsCache,
|
vcsCache: { setStore() {} } as unknown as VcsCache,
|
||||||
|
|
@ -99,78 +125,108 @@ describe("bootstrapDirectory", () => {
|
||||||
expect(mcpReads).toEqual([])
|
expect(mcpReads).toEqual([])
|
||||||
})
|
})
|
||||||
|
|
||||||
test("seeds session status even while warming session info stalls", async () => {
|
|
||||||
const [store, setStore] = directoryState()
|
|
||||||
const stalled = Promise.withResolvers<never>()
|
|
||||||
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", () => {
|
describe("query keys", () => {
|
||||||
test("partitions identical directories by server scope", () => {
|
test("partitions identical directories by server scope", () => {
|
||||||
const client = {} as OpencodeClient
|
const client = {} as Parameters<typeof loadPathQuery>[2]
|
||||||
|
const api = {} as CatalogApi
|
||||||
const remote = "https://debian.example" as typeof ServerScope.local
|
const remote = "https://debian.example" as typeof ServerScope.local
|
||||||
|
|
||||||
expect([...loadPathQuery(ServerScope.local, "/repo", client).queryKey]).toEqual(["local", "/repo", "path"])
|
expect([...loadPathQuery(ServerScope.local, "/repo", client).queryKey]).toEqual(["local", "/repo", "path"])
|
||||||
expect([...loadPathQuery(remote, "/repo", client).queryKey]).toEqual(["https://debian.example", "/repo", "path"])
|
expect([...loadPathQuery(remote, "/repo", client).queryKey]).toEqual(["https://debian.example", "/repo", "path"])
|
||||||
expect([...loadProvidersQuery(remote, null, client).queryKey]).toEqual([
|
expect([...loadProvidersQuery(remote, null, api).queryKey]).toEqual(["https://debian.example", null, "providers"])
|
||||||
"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)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,26 @@ import type {
|
||||||
ReferenceInfo,
|
ReferenceInfo,
|
||||||
Session,
|
Session,
|
||||||
} from "@opencode-ai/sdk/v2/client"
|
} 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 { showToast } from "@/utils/toast"
|
||||||
import { getFilename } from "@opencode-ai/core/util/path"
|
import { getFilename } from "@opencode-ai/core/util/path"
|
||||||
import { retry } from "@opencode-ai/core/util/retry"
|
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 { produce, reconcile, type SetStoreFunction, type Store } from "solid-js/store"
|
||||||
import type { State, VcsCache } from "./types"
|
import type { State, VcsCache } from "./types"
|
||||||
import type { ServerSession } from "../server-session"
|
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 { formatServerError } from "@/utils/server-errors"
|
||||||
import { QueryClient, queryOptions } from "@tanstack/solid-query"
|
import { QueryClient, queryOptions } from "@tanstack/solid-query"
|
||||||
import { loadMcpQuery, loadMcpResourcesQuery } from "../server-sync"
|
import { loadMcpQuery, loadMcpResourcesQuery } from "../server-sync"
|
||||||
import { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
|
import { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
|
||||||
import { ScopedKey, type ServerScope } from "@/utils/server-scope"
|
import { ScopedKey, type ServerScope } from "@/utils/server-scope"
|
||||||
|
import { normalizeSessionInfo } from "@/utils/session"
|
||||||
|
import type { ServerProtocol } from "@/utils/server-protocol"
|
||||||
|
|
||||||
type GlobalStore = {
|
type GlobalStore = {
|
||||||
ready: boolean
|
ready: boolean
|
||||||
|
|
@ -88,15 +116,25 @@ export const loadGlobalConfigQuery = (scope: ServerScope, sdk: OpencodeClient) =
|
||||||
queryFn: () => retry(() => sdk.global.config.get().then((x) => x.data!)),
|
queryFn: () => retry(() => sdk.global.config.get().then((x) => x.data!)),
|
||||||
})
|
})
|
||||||
|
|
||||||
export const loadProjectsQuery = (scope: ServerScope, sdk: OpencodeClient) =>
|
type ProjectApi = {
|
||||||
|
readonly list: () => Promise<ProjectListOutput>
|
||||||
|
readonly current: (input?: ProjectCurrentInput) => Promise<ProjectCurrentOutput>
|
||||||
|
}
|
||||||
|
|
||||||
|
type PathApi = {
|
||||||
|
readonly get: (input?: PathGetInput) => Promise<PathGetOutput>
|
||||||
|
}
|
||||||
|
|
||||||
|
export const loadProjectsQuery = (scope: ServerScope, api: ProjectApi) =>
|
||||||
queryOptions({
|
queryOptions({
|
||||||
queryKey: [scope, "project"],
|
queryKey: [scope, "project"],
|
||||||
queryFn: () =>
|
queryFn: () =>
|
||||||
retry(() =>
|
retry(() =>
|
||||||
sdk.project.list().then((x) => {
|
api.list().then((projects) => {
|
||||||
return (x.data ?? [])
|
return projects
|
||||||
.filter((p) => !!p?.id)
|
.filter((p) => !!p?.id)
|
||||||
.filter((p) => !!p.worktree && !p.worktree.includes("opencode-test"))
|
.filter((p) => !!p.worktree && !p.worktree.includes("opencode-test"))
|
||||||
|
.map(normalizeProjectInfo)
|
||||||
.slice()
|
.slice()
|
||||||
.sort((a, b) => cmp(a.id, b.id))
|
.sort((a, b) => cmp(a.id, b.id))
|
||||||
}),
|
}),
|
||||||
|
|
@ -105,6 +143,8 @@ export const loadProjectsQuery = (scope: ServerScope, sdk: OpencodeClient) =>
|
||||||
|
|
||||||
export async function bootstrapGlobal(input: {
|
export async function bootstrapGlobal(input: {
|
||||||
serverSDK: OpencodeClient
|
serverSDK: OpencodeClient
|
||||||
|
serverAPI: CatalogApi & { readonly path: PathApi; readonly project: ProjectApi }
|
||||||
|
protocol?: Promise<ServerProtocol>
|
||||||
scope: ServerScope
|
scope: ServerScope
|
||||||
requestFailedTitle: string
|
requestFailedTitle: string
|
||||||
translate: (key: string, vars?: Record<string, string | number>) => string
|
translate: (key: string, vars?: Record<string, string | number>) => string
|
||||||
|
|
@ -114,11 +154,14 @@ export async function bootstrapGlobal(input: {
|
||||||
}) {
|
}) {
|
||||||
const slow = [
|
const slow = [
|
||||||
() => input.queryClient.fetchQuery(loadGlobalConfigQuery(input.scope, input.serverSDK)),
|
() => 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
|
input.queryClient
|
||||||
.fetchQuery(loadProjectsQuery(input.scope, input.serverSDK))
|
.fetchQuery(loadProjectsQuery(input.scope, input.serverAPI.project))
|
||||||
.then((data) => input.setGlobalStore("project", data)),
|
.then((data) => input.setGlobalStore("project", data)),
|
||||||
]
|
]
|
||||||
await runAll(slow)
|
await runAll(slow)
|
||||||
|
|
@ -162,44 +205,117 @@ function warmSessions(input: {
|
||||||
ids: string[]
|
ids: string[]
|
||||||
store: Store<State>
|
store: Store<State>
|
||||||
setStore: SetStoreFunction<State>
|
setStore: SetStoreFunction<State>
|
||||||
sdk: OpencodeClient
|
api: SessionApi
|
||||||
}) {
|
}) {
|
||||||
const known = new Set(input.store.session.map((item) => item.id))
|
const known = new Set(input.store.session.map((item) => item.id))
|
||||||
const ids = [...new Set(input.ids)].filter((id) => !!id && !known.has(id))
|
const ids = [...new Set(input.ids)].filter((id) => !!id && !known.has(id))
|
||||||
if (ids.length === 0) return Promise.resolve()
|
if (ids.length === 0) return Promise.resolve()
|
||||||
return Promise.all(
|
return Promise.all(
|
||||||
ids.map((sessionID) =>
|
ids.map((sessionID) =>
|
||||||
retry(() => input.sdk.session.get({ sessionID })).then((x) => {
|
retry(() => input.api.get({ sessionID })).then((session) =>
|
||||||
const session = x.data
|
mergeSession(input.setStore, normalizeSessionInfo(session)),
|
||||||
if (!session?.id) return
|
),
|
||||||
mergeSession(input.setStore, session)
|
|
||||||
}),
|
|
||||||
),
|
),
|
||||||
).then(() => undefined)
|
).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<ServerProtocol>,
|
||||||
|
) =>
|
||||||
queryOptions({
|
queryOptions({
|
||||||
queryKey: [scope, directory, "providers"],
|
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<AgentListOutput>
|
||||||
|
}
|
||||||
|
|
||||||
|
type CommandListApi = {
|
||||||
|
readonly list: (input?: CommandListInput) => Promise<CommandListOutput>
|
||||||
|
}
|
||||||
|
|
||||||
|
type ReferenceListApi = {
|
||||||
|
readonly list: (input?: ReferenceListInput) => Promise<ReferenceListOutput>
|
||||||
|
}
|
||||||
|
|
||||||
|
export const loadAgentsQuery = (
|
||||||
|
scope: ServerScope,
|
||||||
|
directory: string,
|
||||||
|
sdk: AgentListApi,
|
||||||
|
legacy?: OpencodeClient,
|
||||||
|
protocol?: Promise<ServerProtocol>,
|
||||||
|
) =>
|
||||||
queryOptions({
|
queryOptions({
|
||||||
queryKey: [scope, directory, "agents"],
|
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<ServerProtocol>,
|
||||||
|
): Promise<CommandInfo[]> =>
|
||||||
|
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<Path>({
|
queryOptions<Path>({
|
||||||
queryKey: [scope, directory, "path"],
|
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<ServerProtocol>,
|
||||||
|
) =>
|
||||||
queryOptions<ReferenceInfo[]>({
|
queryOptions<ReferenceInfo[]>({
|
||||||
queryKey: [scope, directory, "references"] as const,
|
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: [],
|
placeholderData: [],
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -208,6 +324,18 @@ export async function bootstrapDirectory(input: {
|
||||||
scope: ServerScope
|
scope: ServerScope
|
||||||
mcp: boolean
|
mcp: boolean
|
||||||
sdk: OpencodeClient
|
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<State>
|
store: Store<State>
|
||||||
setStore: SetStoreFunction<State>
|
setStore: SetStoreFunction<State>
|
||||||
vcsCache: VcsCache
|
vcsCache: VcsCache
|
||||||
|
|
@ -221,6 +349,7 @@ export async function bootstrapDirectory(input: {
|
||||||
}
|
}
|
||||||
queryClient: QueryClient
|
queryClient: QueryClient
|
||||||
session?: ServerSession
|
session?: ServerSession
|
||||||
|
protocol?: Promise<ServerProtocol>
|
||||||
}) {
|
}) {
|
||||||
const loading = input.store.status !== "complete"
|
const loading = input.store.status !== "complete"
|
||||||
const seededProject = projectID(input.directory, input.global.project)
|
const seededProject = projectID(input.directory, input.global.project)
|
||||||
|
|
@ -240,66 +369,55 @@ export async function bootstrapDirectory(input: {
|
||||||
() => Promise.resolve(input.loadSessions(input.directory)),
|
() => Promise.resolve(input.loadSessions(input.directory)),
|
||||||
() =>
|
() =>
|
||||||
input.queryClient
|
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)),
|
.then((data) => input.setStore("agent", data)),
|
||||||
() =>
|
() =>
|
||||||
retry(() => input.sdk.config.get().then((x) => input.setStore("config", reconcile(x.data!, { merge: false })))),
|
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 &&
|
!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 &&
|
!seededPath &&
|
||||||
(() =>
|
(() =>
|
||||||
input.queryClient.ensureQueryData(loadPathQuery(input.scope, input.directory, input.sdk)).then((data) => {
|
input.queryClient
|
||||||
const next = projectID(data.directory ?? input.directory, input.global.project)
|
.ensureQueryData(loadPathQuery(input.scope, input.directory, input.api.path))
|
||||||
if (next) input.setStore("project", next)
|
.then((data) => {
|
||||||
})),
|
const next = projectID(data.directory ?? input.directory, input.global.project)
|
||||||
|
if (next) input.setStore("project", next)
|
||||||
|
})),
|
||||||
() =>
|
() =>
|
||||||
retry(() =>
|
retry(() =>
|
||||||
input.sdk.vcs.get().then((x) => {
|
input.api.vcs.get({ location: { directory: input.directory } }).then((result) => {
|
||||||
const next = x.data ?? input.store.vcs
|
const next = { branch: result.data.branch, default_branch: result.data.defaultBranch }
|
||||||
input.setStore("vcs", next)
|
input.setStore("vcs", next)
|
||||||
if (next) input.vcsCache.setStore("value", next)
|
if (next) input.vcsCache.setStore("value", next)
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
input.mcp && (() => retry(() => input.sdk.command.list().then((x) => input.setStore("command", x.data ?? [])))),
|
input.mcp &&
|
||||||
() => input.queryClient.fetchQuery(loadReferencesQuery(input.scope, input.directory, input.sdk)),
|
(() =>
|
||||||
|
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(() =>
|
retry(() =>
|
||||||
input.sdk.permission.list().then((x) => {
|
(async () => {
|
||||||
const ids = (x.data ?? []).map((perm) => perm?.sessionID).filter((id): id is string => !!id)
|
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(
|
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
|
const warm = input.session
|
||||||
? Promise.all(ids.map((sessionID) => input.session!.resolve(sessionID))).then(() => undefined)
|
? 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(() =>
|
return warm.then(() =>
|
||||||
batch(() => {
|
batch(() => {
|
||||||
const current = input.session?.data.permission ?? input.store.permission
|
const current = input.session?.data.permission ?? input.store.permission
|
||||||
|
|
@ -323,12 +441,19 @@ export async function bootstrapDirectory(input: {
|
||||||
),
|
),
|
||||||
() =>
|
() =>
|
||||||
retry(() =>
|
retry(() =>
|
||||||
input.sdk.question.list().then((x) => {
|
(async () => {
|
||||||
const ids = (x.data ?? []).map((question) => question?.sessionID).filter((id): id is string => !!id)
|
if ((await input.protocol) === "v1") return (await input.sdk.question.list()).data ?? []
|
||||||
const grouped = groupBySession((x.data ?? []).filter((q): q is QuestionRequest => !!q?.id && !!q.sessionID))
|
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
|
const warm = input.session
|
||||||
? Promise.all(ids.map((sessionID) => input.session!.resolve(sessionID))).then(() => undefined)
|
? 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(() =>
|
return warm.then(() =>
|
||||||
batch(() => {
|
batch(() => {
|
||||||
const current = input.session?.data.question ?? input.store.question
|
const current = input.session?.data.question ?? input.store.question
|
||||||
|
|
@ -351,17 +476,20 @@ export async function bootstrapDirectory(input: {
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
() => Promise.resolve(input.loadSessions(input.directory)),
|
() => Promise.resolve(input.loadSessions(input.directory)),
|
||||||
input.mcp && (() => input.queryClient.fetchQuery(loadMcpQuery(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.sdk))),
|
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) => {
|
input.queryClient
|
||||||
const project = getFilename(input.directory)
|
.fetchQuery(loadProvidersQuery(input.scope, input.directory, input.api, input.sdk, input.protocol))
|
||||||
showToast({
|
.catch((err) => {
|
||||||
variant: "error",
|
const project = getFilename(input.directory)
|
||||||
title: input.translate("toast.project.reloadFailed.title", { project }),
|
showToast({
|
||||||
description: formatServerError(err, input.translate),
|
variant: "error",
|
||||||
})
|
title: input.translate("toast.project.reloadFailed.title", { project }),
|
||||||
}),
|
description: formatServerError(err, input.translate),
|
||||||
|
})
|
||||||
|
}),
|
||||||
].filter(Boolean) as (() => Promise<any>)[]
|
].filter(Boolean) as (() => Promise<any>)[]
|
||||||
|
|
||||||
await waitForPaint()
|
await waitForPaint()
|
||||||
|
|
|
||||||
|
|
@ -255,6 +255,7 @@ export function createChildStoreManager(input: {
|
||||||
vcs: vcsStore.value,
|
vcs: vcsStore.value,
|
||||||
limit: 5,
|
limit: 5,
|
||||||
message: {},
|
message: {},
|
||||||
|
session_message: {},
|
||||||
part: {},
|
part: {},
|
||||||
part_text_accum_delta: {},
|
part_text_accum_delta: {},
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -80,6 +80,7 @@ const baseState = (input: Partial<State> = {}) =>
|
||||||
vcs: undefined,
|
vcs: undefined,
|
||||||
limit: 10,
|
limit: 10,
|
||||||
message: {},
|
message: {},
|
||||||
|
session_message: {},
|
||||||
part: {},
|
part: {},
|
||||||
part_text_accum_delta: {},
|
part_text_accum_delta: {},
|
||||||
...input,
|
...input,
|
||||||
|
|
@ -261,8 +262,8 @@ describe("applyDirectoryEvent", () => {
|
||||||
|
|
||||||
test("cleans session caches when deleted and decrements only root totals", () => {
|
test("cleans session caches when deleted and decrements only root totals", () => {
|
||||||
const cases = [
|
const cases = [
|
||||||
{ info: rootSession({ id: "ses_1" }), expectedTotal: 1 },
|
{ info: rootSession({ id: "ses_1" }), expectedTotal: 1, current: false },
|
||||||
{ info: rootSession({ id: "ses_2", parentID: "ses_1" }), expectedTotal: 2 },
|
{ info: rootSession({ id: "ses_2", parentID: "ses_1" }), expectedTotal: 2, current: true },
|
||||||
]
|
]
|
||||||
|
|
||||||
for (const item of cases) {
|
for (const item of cases) {
|
||||||
|
|
@ -286,7 +287,10 @@ describe("applyDirectoryEvent", () => {
|
||||||
)
|
)
|
||||||
|
|
||||||
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,
|
store,
|
||||||
setStore,
|
setStore,
|
||||||
push() {},
|
push() {},
|
||||||
|
|
|
||||||
|
|
@ -8,9 +8,9 @@ import type {
|
||||||
QuestionRequest,
|
QuestionRequest,
|
||||||
Session,
|
Session,
|
||||||
SessionStatus,
|
SessionStatus,
|
||||||
SnapshotFileDiff,
|
|
||||||
Todo,
|
Todo,
|
||||||
} from "@opencode-ai/sdk/v2/client"
|
} from "@opencode-ai/sdk/v2/client"
|
||||||
|
import type { FileDiffInfo } from "@opencode-ai/client/promise"
|
||||||
import type { State, VcsCache } from "./types"
|
import type { State, VcsCache } from "./types"
|
||||||
import { trimSessions } from "./session-trim"
|
import { trimSessions } from "./session-trim"
|
||||||
import { dropSessionCaches } from "./session-cache"
|
import { dropSessionCaches } from "./session-cache"
|
||||||
|
|
@ -171,8 +171,11 @@ export function applyDirectoryEvent(input: {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
case "session.deleted": {
|
case "session.deleted": {
|
||||||
const info = (event.properties as { info: Session }).info
|
const properties = event.properties as { sessionID?: string; info?: Session }
|
||||||
const result = Binary.search(input.store.session, info.id, (s) => s.id)
|
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) {
|
if (result.found) {
|
||||||
input.setStore(
|
input.setStore(
|
||||||
"session",
|
"session",
|
||||||
|
|
@ -181,14 +184,77 @@ export function applyDirectoryEvent(input: {
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
cleanupSessionCaches(input.setStore, info.id, input.setSessionTodo)
|
cleanupSessionCaches(input.setStore, sessionID, input.setSessionTodo)
|
||||||
if (info.parentID) break
|
if (info?.parentID) break
|
||||||
input.setStore("sessionTotal", (value) => Math.max(0, value - 1))
|
input.setStore("sessionTotal", (value) => Math.max(0, value - 1))
|
||||||
break
|
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<Session, "cost" | "tokens"> & { 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": {
|
case "session.diff": {
|
||||||
const props = event.properties as { sessionID: string; diff: SnapshotFileDiff[] }
|
const props = event.properties as { sessionID: string; diff: FileDiffInfo[] }
|
||||||
input.setStore("session_diff", props.sessionID, reconcile(list(props.diff), { key: "file" }))
|
input.setStore("session_diff", props.sessionID, reconcile(list(props.diff) as FileDiffInfo[], { key: "file" }))
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
case "todo.updated": {
|
case "todo.updated": {
|
||||||
|
|
|
||||||
|
|
@ -31,4 +31,24 @@ describe("toggleMcp", () => {
|
||||||
await toggleMcp(input("disabled"))
|
await toggleMcp(input("disabled"))
|
||||||
expect(calls).toEqual(["connect", "refresh"])
|
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([])
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -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: {
|
export async function toggleMcp(input: {
|
||||||
status: McpStatus["status"]
|
status: McpServer["status"]["status"]
|
||||||
connect: () => Promise<void>
|
connect: () => Promise<void>
|
||||||
disconnect: () => Promise<void>
|
disconnect: () => Promise<void>
|
||||||
authenticate: () => Promise<void>
|
authenticate: () => Promise<void>
|
||||||
refresh: () => Promise<void>
|
refresh: () => Promise<void>
|
||||||
}) {
|
}) {
|
||||||
|
if (input.status === "pending") return
|
||||||
await {
|
await {
|
||||||
connected: input.disconnect,
|
connected: input.disconnect,
|
||||||
needs_auth: input.authenticate,
|
needs_auth: input.authenticate,
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,6 @@
|
||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
import type {
|
import type { Message, Part, PermissionRequest, QuestionRequest, SessionStatus, Todo } from "@opencode-ai/sdk/v2/client"
|
||||||
Message,
|
import type { FileDiffInfo } from "@opencode-ai/client/promise"
|
||||||
Part,
|
|
||||||
PermissionRequest,
|
|
||||||
QuestionRequest,
|
|
||||||
SessionStatus,
|
|
||||||
SnapshotFileDiff,
|
|
||||||
Todo,
|
|
||||||
} from "@opencode-ai/sdk/v2/client"
|
|
||||||
import { dropSessionCaches, pickSessionCacheEvictions } from "./session-cache"
|
import { dropSessionCaches, pickSessionCacheEvictions } from "./session-cache"
|
||||||
|
|
||||||
const msg = (id: string, sessionID: string) =>
|
const msg = (id: string, sessionID: string) =>
|
||||||
|
|
@ -33,9 +26,10 @@ describe("app session cache", () => {
|
||||||
test("dropSessionCaches clears orphaned parts without message rows", () => {
|
test("dropSessionCaches clears orphaned parts without message rows", () => {
|
||||||
const store: {
|
const store: {
|
||||||
session_status: Record<string, SessionStatus | undefined>
|
session_status: Record<string, SessionStatus | undefined>
|
||||||
session_diff: Record<string, SnapshotFileDiff[] | undefined>
|
session_diff: Record<string, FileDiffInfo[] | undefined>
|
||||||
todo: Record<string, Todo[] | undefined>
|
todo: Record<string, Todo[] | undefined>
|
||||||
message: Record<string, Message[] | undefined>
|
message: Record<string, Message[] | undefined>
|
||||||
|
session_message: Record<string, never[] | undefined>
|
||||||
part: Record<string, Part[] | undefined>
|
part: Record<string, Part[] | undefined>
|
||||||
permission: Record<string, PermissionRequest[] | undefined>
|
permission: Record<string, PermissionRequest[] | undefined>
|
||||||
question: Record<string, QuestionRequest[] | undefined>
|
question: Record<string, QuestionRequest[] | undefined>
|
||||||
|
|
@ -45,6 +39,7 @@ describe("app session cache", () => {
|
||||||
session_diff: { ses_1: [] },
|
session_diff: { ses_1: [] },
|
||||||
todo: { ses_1: [] as Todo[] },
|
todo: { ses_1: [] as Todo[] },
|
||||||
message: {},
|
message: {},
|
||||||
|
session_message: {},
|
||||||
part: { msg_1: [part("prt_1", "ses_1", "msg_1")] },
|
part: { msg_1: [part("prt_1", "ses_1", "msg_1")] },
|
||||||
permission: { ses_1: [] as PermissionRequest[] },
|
permission: { ses_1: [] as PermissionRequest[] },
|
||||||
question: { ses_1: [] as QuestionRequest[] },
|
question: { ses_1: [] as QuestionRequest[] },
|
||||||
|
|
@ -67,9 +62,10 @@ describe("app session cache", () => {
|
||||||
const m = msg("msg_1", "ses_1")
|
const m = msg("msg_1", "ses_1")
|
||||||
const store: {
|
const store: {
|
||||||
session_status: Record<string, SessionStatus | undefined>
|
session_status: Record<string, SessionStatus | undefined>
|
||||||
session_diff: Record<string, SnapshotFileDiff[] | undefined>
|
session_diff: Record<string, FileDiffInfo[] | undefined>
|
||||||
todo: Record<string, Todo[] | undefined>
|
todo: Record<string, Todo[] | undefined>
|
||||||
message: Record<string, Message[] | undefined>
|
message: Record<string, Message[] | undefined>
|
||||||
|
session_message: Record<string, never[] | undefined>
|
||||||
part: Record<string, Part[] | undefined>
|
part: Record<string, Part[] | undefined>
|
||||||
permission: Record<string, PermissionRequest[] | undefined>
|
permission: Record<string, PermissionRequest[] | undefined>
|
||||||
question: Record<string, QuestionRequest[] | undefined>
|
question: Record<string, QuestionRequest[] | undefined>
|
||||||
|
|
@ -79,6 +75,7 @@ describe("app session cache", () => {
|
||||||
session_diff: {},
|
session_diff: {},
|
||||||
todo: {},
|
todo: {},
|
||||||
message: { ses_1: [m] },
|
message: { ses_1: [m] },
|
||||||
|
session_message: {},
|
||||||
part: { [m.id]: [part("prt_1", "ses_1", m.id)] },
|
part: { [m.id]: [part("prt_1", "ses_1", m.id)] },
|
||||||
permission: {},
|
permission: {},
|
||||||
question: {},
|
question: {},
|
||||||
|
|
|
||||||
|
|
@ -1,20 +1,15 @@
|
||||||
import type {
|
import type { Message, Part, PermissionRequest, QuestionRequest, SessionStatus, Todo } from "@opencode-ai/sdk/v2/client"
|
||||||
Message,
|
import type { FileDiffInfo } from "@opencode-ai/client/promise"
|
||||||
Part,
|
import type { SessionMessageInfo } from "@opencode-ai/client/promise"
|
||||||
PermissionRequest,
|
|
||||||
QuestionRequest,
|
|
||||||
SessionStatus,
|
|
||||||
SnapshotFileDiff,
|
|
||||||
Todo,
|
|
||||||
} from "@opencode-ai/sdk/v2/client"
|
|
||||||
|
|
||||||
export const SESSION_CACHE_LIMIT = 40
|
export const SESSION_CACHE_LIMIT = 40
|
||||||
|
|
||||||
type SessionCache = {
|
type SessionCache = {
|
||||||
session_status: Record<string, SessionStatus | undefined>
|
session_status: Record<string, SessionStatus | undefined>
|
||||||
session_diff: Record<string, SnapshotFileDiff[] | undefined>
|
session_diff: Record<string, FileDiffInfo[] | undefined>
|
||||||
todo: Record<string, Todo[] | undefined>
|
todo: Record<string, Todo[] | undefined>
|
||||||
message: Record<string, Message[] | undefined>
|
message: Record<string, Message[] | undefined>
|
||||||
|
session_message: Record<string, SessionMessageInfo[] | undefined>
|
||||||
part: Record<string, Part[] | undefined>
|
part: Record<string, Part[] | undefined>
|
||||||
permission: Record<string, PermissionRequest[] | undefined>
|
permission: Record<string, PermissionRequest[] | undefined>
|
||||||
question: Record<string, QuestionRequest[] | undefined>
|
question: Record<string, QuestionRequest[] | undefined>
|
||||||
|
|
@ -37,6 +32,7 @@ export function dropSessionCaches(store: SessionCache, sessionIDs: Iterable<stri
|
||||||
for (const sessionID of stale) {
|
for (const sessionID of stale) {
|
||||||
delete store.message[sessionID]
|
delete store.message[sessionID]
|
||||||
delete store.todo[sessionID]
|
delete store.todo[sessionID]
|
||||||
|
delete store.session_message[sessionID]
|
||||||
delete store.session_diff[sessionID]
|
delete store.session_diff[sessionID]
|
||||||
delete store.session_status[sessionID]
|
delete store.session_status[sessionID]
|
||||||
delete store.permission[sessionID]
|
delete store.permission[sessionID]
|
||||||
|
|
|
||||||
|
|
@ -1,20 +1,28 @@
|
||||||
import type { RootLoadArgs } from "./types"
|
import type { SessionApi } from "@opencode-ai/client/promise"
|
||||||
|
import { normalizeSessionInfo } from "@/utils/session"
|
||||||
|
import type { OpencodeClient } from "@opencode-ai/sdk/v2/client"
|
||||||
|
|
||||||
export async function loadRootSessionsWithFallback(input: RootLoadArgs) {
|
export async function loadRootSessions(input: { api: Pick<SessionApi, "list">; 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 {
|
try {
|
||||||
const result = await input.list({ directory: input.directory, roots: true, limit: input.limit })
|
const result = await input.client.session.list({ directory: input.directory, roots: true, limit: input.limit })
|
||||||
return {
|
return { data: result.data, limit: input.limit, limited: true } as const
|
||||||
data: result.data,
|
|
||||||
limit: input.limit,
|
|
||||||
limited: true,
|
|
||||||
} as const
|
|
||||||
} catch {
|
} catch {
|
||||||
const result = await input.list({ directory: input.directory, roots: true })
|
const result = await input.client.session.list({ directory: input.directory, roots: true })
|
||||||
return {
|
return { data: result.data, limit: input.limit, limited: false } as const
|
||||||
data: result.data,
|
|
||||||
limit: input.limit,
|
|
||||||
limited: false,
|
|
||||||
} as const
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,7 @@
|
||||||
import type {
|
import type {
|
||||||
Agent,
|
Agent,
|
||||||
Command,
|
|
||||||
Config,
|
Config,
|
||||||
LspStatus,
|
LspStatus,
|
||||||
McpResource,
|
|
||||||
McpStatus,
|
|
||||||
Message,
|
Message,
|
||||||
Part,
|
Part,
|
||||||
Path,
|
Path,
|
||||||
|
|
@ -13,11 +10,12 @@ import type {
|
||||||
ReferenceInfo,
|
ReferenceInfo,
|
||||||
Session,
|
Session,
|
||||||
SessionStatus,
|
SessionStatus,
|
||||||
SnapshotFileDiff,
|
|
||||||
Todo,
|
Todo,
|
||||||
VcsInfo,
|
VcsInfo,
|
||||||
} from "@opencode-ai/sdk/v2/client"
|
} from "@opencode-ai/sdk/v2/client"
|
||||||
|
import type { FileDiffInfo } from "@opencode-ai/client/promise"
|
||||||
import { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
|
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 { Accessor } from "solid-js"
|
||||||
import type { SetStoreFunction, Store } from "solid-js/store"
|
import type { SetStoreFunction, Store } from "solid-js/store"
|
||||||
|
|
||||||
|
|
@ -35,7 +33,7 @@ export type ProjectMeta = {
|
||||||
export type State = {
|
export type State = {
|
||||||
status: "loading" | "partial" | "complete"
|
status: "loading" | "partial" | "complete"
|
||||||
agent: Agent[]
|
agent: Agent[]
|
||||||
command: Command[]
|
command: CommandInfo[]
|
||||||
reference: ReferenceInfo[]
|
reference: ReferenceInfo[]
|
||||||
project: string
|
project: string
|
||||||
projectMeta: ProjectMeta | undefined
|
projectMeta: ProjectMeta | undefined
|
||||||
|
|
@ -51,7 +49,7 @@ export type State = {
|
||||||
}
|
}
|
||||||
session_working(id: string): boolean
|
session_working(id: string): boolean
|
||||||
session_diff: {
|
session_diff: {
|
||||||
[sessionID: string]: SnapshotFileDiff[]
|
[sessionID: string]: FileDiffInfo[]
|
||||||
}
|
}
|
||||||
todo: {
|
todo: {
|
||||||
[sessionID: string]: Todo[]
|
[sessionID: string]: Todo[]
|
||||||
|
|
@ -64,7 +62,7 @@ export type State = {
|
||||||
}
|
}
|
||||||
mcp_ready: boolean
|
mcp_ready: boolean
|
||||||
mcp: {
|
mcp: {
|
||||||
[name: string]: McpStatus
|
[name: string]: McpServer["status"]
|
||||||
}
|
}
|
||||||
mcp_resource: {
|
mcp_resource: {
|
||||||
[key: string]: McpResource
|
[key: string]: McpResource
|
||||||
|
|
@ -76,6 +74,9 @@ export type State = {
|
||||||
message: {
|
message: {
|
||||||
[sessionID: string]: Message[]
|
[sessionID: string]: Message[]
|
||||||
}
|
}
|
||||||
|
session_message: {
|
||||||
|
[sessionID: string]: SessionMessageInfo[]
|
||||||
|
}
|
||||||
part: {
|
part: {
|
||||||
[messageID: string]: Part[]
|
[messageID: string]: Part[]
|
||||||
}
|
}
|
||||||
|
|
@ -128,18 +129,6 @@ export type DisposeCheck = {
|
||||||
loadingSessions: boolean
|
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 MAX_DIR_STORES = 30
|
||||||
export const DIR_IDLE_TTL_MS = 20 * 60 * 1000
|
export const DIR_IDLE_TTL_MS = 20 * 60 * 1000
|
||||||
export const SESSION_RECENT_WINDOW = 4 * 60 * 60 * 1000
|
export const SESSION_RECENT_WINDOW = 4 * 60 * 60 * 1000
|
||||||
|
|
|
||||||
|
|
@ -1,36 +1,112 @@
|
||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
import type { Agent } from "@opencode-ai/sdk/v2/client"
|
import type { AgentListOutput, ModelDefaultOutput, ModelListOutput, ProviderListOutput } from "@opencode-ai/client/promise"
|
||||||
import { directoryKey, normalizeAgentList } from "./utils"
|
import { directoryKey, normalizeAgentList, normalizePermissionRequest, normalizeProviderList } from "./utils"
|
||||||
|
|
||||||
const agent = (name = "build") =>
|
|
||||||
({
|
|
||||||
name,
|
|
||||||
mode: "primary",
|
|
||||||
permission: {},
|
|
||||||
options: {},
|
|
||||||
}) as Agent
|
|
||||||
|
|
||||||
describe("normalizeAgentList", () => {
|
describe("normalizeAgentList", () => {
|
||||||
test("keeps array payloads", () => {
|
test("adapts current agents to the app agent shape", () => {
|
||||||
expect(normalizeAgentList([agent("build"), agent("docs")])).toEqual([agent("build"), agent("docs")])
|
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(result).toEqual([
|
||||||
expect(normalizeAgentList(agent("docs"))).toEqual([agent("docs")])
|
{
|
||||||
|
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(
|
expect(
|
||||||
normalizeAgentList({
|
normalizePermissionRequest({
|
||||||
build: agent("build"),
|
id: "permission-1",
|
||||||
docs: agent("docs"),
|
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" },
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
})
|
||||||
|
|
||||||
test("drops invalid payloads", () => {
|
describe("normalizeProviderList", () => {
|
||||||
expect(normalizeAgentList({ name: "AbortError" })).toEqual([])
|
test("groups current models into the app provider catalog", () => {
|
||||||
expect(normalizeAgentList([{ name: "build" }, agent("docs")])).toEqual([agent("docs")])
|
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"],
|
||||||
|
)
|
||||||
|
|
||||||
|
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: {} },
|
||||||
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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"
|
import { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
|
||||||
export { pathKey as directoryKey, type PathKey as DirectoryKey } from "@/utils/path-key"
|
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)
|
export const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
|
||||||
|
|
||||||
function isAgent(input: unknown): input is Agent {
|
export function normalizeAgentList(input: AgentListOutput["data"] | Agent[]): Agent[] {
|
||||||
if (!input || typeof input !== "object") return false
|
if (input.every((agent) => !("request" in agent))) return input as Agent[]
|
||||||
const item = input as { name?: unknown; mode?: unknown }
|
return (input as AgentListOutput["data"]).map((agent) => ({
|
||||||
if (typeof item.name !== "string") return false
|
name: agent.id,
|
||||||
return item.mode === "subagent" || item.mode === "primary" || item.mode === "all"
|
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[] {
|
export function normalizePermissionRequest(input: PermissionV2Request | PermissionRequest): PermissionRequest {
|
||||||
if (Array.isArray(input)) return input.filter(isAgent)
|
if ("permission" in input) return input
|
||||||
if (isAgent(input)) return [input]
|
|
||||||
if (!input || typeof input !== "object") return []
|
|
||||||
return Object.values(input).filter(isAgent)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function normalizeProviderList(input: ProviderListResponse): NormalizedProviderListResponse {
|
|
||||||
return {
|
return {
|
||||||
...input,
|
id: input.id,
|
||||||
all: new Map(
|
sessionID: input.sessionID,
|
||||||
input.all.map(
|
permission: input.action,
|
||||||
(provider) =>
|
patterns: input.resources,
|
||||||
[
|
always: input.save ?? [],
|
||||||
provider.id,
|
metadata: input.metadata ?? {},
|
||||||
{
|
tool:
|
||||||
...provider,
|
input.source?.type === "tool" ? { messageID: input.source.messageID, callID: input.source.callID } : undefined,
|
||||||
models: Object.fromEntries(
|
}
|
||||||
Object.entries(provider.models).filter(([, info]) => info.status !== "deprecated"),
|
}
|
||||||
),
|
|
||||||
},
|
export function normalizeProviderList(
|
||||||
] as const,
|
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<string, Provider>()
|
||||||
|
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import { useServerSDK } from "./server-sdk"
|
||||||
import { RECENTLY_CLOSED_DISPLAY_LIMIT, ServerConnection, useServer } from "./server"
|
import { RECENTLY_CLOSED_DISPLAY_LIMIT, ServerConnection, useServer } from "./server"
|
||||||
import { usePlatform } from "./platform"
|
import { usePlatform } from "./platform"
|
||||||
import { Project } from "@opencode-ai/sdk/v2"
|
import { Project } from "@opencode-ai/sdk/v2"
|
||||||
|
import { normalizeProjectInfo } from "./global-sync/utils"
|
||||||
import { Persist, persisted, removePersisted } from "@/utils/persist"
|
import { Persist, persisted, removePersisted } from "@/utils/persist"
|
||||||
import { pathKey } from "@/utils/path-key"
|
import { pathKey } from "@/utils/path-key"
|
||||||
import { decode64 } from "@/utils/base64"
|
import { decode64 } from "@/utils/base64"
|
||||||
|
|
@ -570,7 +571,12 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
||||||
}
|
}
|
||||||
|
|
||||||
void serverSdk()
|
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(() => {
|
.catch(() => {
|
||||||
if (colorRequested.get(worktree) === color) colorRequested.delete(worktree)
|
if (colorRequested.get(worktree) === color) colorRequested.delete(worktree)
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ import { type DraftTab, useTabs } from "./tabs"
|
||||||
import { useSettings } from "./settings"
|
import { useSettings } from "./settings"
|
||||||
import { requireServerKey } from "@/utils/session-route"
|
import { requireServerKey } from "@/utils/session-route"
|
||||||
import type { ServerScope } from "@/utils/server-scope"
|
import type { ServerScope } from "@/utils/server-scope"
|
||||||
|
import { normalizePermissionRequest } from "./global-sync/utils"
|
||||||
import {
|
import {
|
||||||
acceptKey,
|
acceptKey,
|
||||||
directoryAcceptKey,
|
directoryAcceptKey,
|
||||||
|
|
@ -243,9 +244,20 @@ function createServerPermissionState(input: { sdk: ServerSDK; sync: ServerSync }
|
||||||
|
|
||||||
const respond: PermissionRespondFn = (request) => {
|
const respond: PermissionRespondFn = (request) => {
|
||||||
if (meta.disposed) return
|
if (meta.disposed) return
|
||||||
input.sdk.client.permission.respond(request).catch(() => {
|
input.sdk.api.permission
|
||||||
responded.delete(request.permissionID)
|
.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) {
|
function respondOnce(permission: PermissionRequest, directory?: string) {
|
||||||
|
|
@ -343,14 +355,12 @@ function createServerPermissionState(input: { sdk: ServerSDK; sync: ServerSync }
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
input.sdk.client.permission
|
list(directory)
|
||||||
.list({ directory })
|
.then((permissions) => {
|
||||||
.then((x) => {
|
|
||||||
if (meta.disposed) return
|
if (meta.disposed) return
|
||||||
if (!isAutoAcceptingDirectory(directory)) return
|
if (!isAutoAcceptingDirectory(directory)) return
|
||||||
for (const perm of x.data ?? []) {
|
for (const permission of permissions) {
|
||||||
if (!perm?.id) continue
|
void respondPending(permission, directory, () => isAutoAcceptingDirectory(directory))
|
||||||
void respondPending(perm, directory, () => isAutoAcceptingDirectory(directory))
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(() => undefined)
|
.catch(() => undefined)
|
||||||
|
|
@ -377,16 +387,14 @@ function createServerPermissionState(input: { sdk: ServerSDK; sync: ServerSync }
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
input.sdk.client.permission
|
list(directory)
|
||||||
.list({ directory })
|
.then((permissions) => {
|
||||||
.then((x) => {
|
|
||||||
if (meta.disposed) return
|
if (meta.disposed) return
|
||||||
if (enableVersion.get(key) !== version) return
|
if (enableVersion.get(key) !== version) return
|
||||||
if (!isAutoAccepting(sessionID, directory)) return
|
if (!isAutoAccepting(sessionID, directory)) return
|
||||||
for (const perm of x.data ?? []) {
|
for (const permission of permissions) {
|
||||||
if (!perm?.id) continue
|
|
||||||
void respondPending(
|
void respondPending(
|
||||||
perm,
|
permission,
|
||||||
directory,
|
directory,
|
||||||
() => enableVersion.get(key) === version && isAutoAccepting(sessionID, directory),
|
() => enableVersion.get(key) === version && isAutoAccepting(sessionID, directory),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { describe, expect, test } from "bun:test"
|
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"
|
import type { Event } from "@opencode-ai/sdk/v2/client"
|
||||||
|
|
||||||
describe("resumeStreamAfterPageShow", () => {
|
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", () => {
|
describe("coalesceServerEvents", () => {
|
||||||
const delta = (value: string, field = "text", partID = "part") => ({
|
const delta = (value: string, field = "text", partID = "part") => ({
|
||||||
directory: "/repo",
|
directory: "/repo",
|
||||||
|
|
@ -34,6 +52,23 @@ describe("coalesceServerEvents", () => {
|
||||||
expect(result[0]?.payload).toMatchObject({ id: "second", properties: { delta: "hello world" } })
|
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", () => {
|
test("preserves event boundaries and distinct fields", () => {
|
||||||
const status = {
|
const status = {
|
||||||
directory: "/repo",
|
directory: "/repo",
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,60 @@
|
||||||
|
import type { OpenCodeEvent } from "@opencode-ai/client/promise"
|
||||||
import type { Event } from "@opencode-ai/sdk/v2/client"
|
import type { Event } from "@opencode-ai/sdk/v2/client"
|
||||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||||
import { createGlobalEmitter } from "@solid-primitives/event-bus"
|
import { createGlobalEmitter } from "@solid-primitives/event-bus"
|
||||||
import { makeEventListener } from "@solid-primitives/event-listener"
|
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||||
import { type Accessor, batch, createMemo, onCleanup, onMount } from "solid-js"
|
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 { useLanguage } from "./language"
|
||||||
import { usePlatform } from "./platform"
|
import { usePlatform } from "./platform"
|
||||||
import { ServerConnection, useServer } from "./server"
|
import { ServerConnection, useServer } from "./server"
|
||||||
import { createRefCountMap } from "@/utils/refcount"
|
import { createRefCountMap } from "@/utils/refcount"
|
||||||
import { useGlobal } from "./global"
|
import { useGlobal } from "./global"
|
||||||
import { ServerScope } from "@/utils/server-scope"
|
import { ServerScope } from "@/utils/server-scope"
|
||||||
|
import { detectServerProtocol } from "@/utils/server-protocol"
|
||||||
|
import { createCompatibleApi } from "@/utils/server-compat"
|
||||||
|
|
||||||
const isAbortError = (error: unknown) =>
|
const isAbortError = (error: unknown) =>
|
||||||
error !== null && typeof error === "object" && "name" in error && error.name === "AbortError"
|
error !== null && typeof error === "object" && "name" in error && error.name === "AbortError"
|
||||||
|
|
||||||
const isStreamClosed = (error: unknown, signal?: AbortSignal) => isAbortError(error) || signal?.aborted === true
|
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) => {
|
const coalescedKey = (event: QueuedServerEvent) => {
|
||||||
if (event.payload.type === "lsp.updated") return `lsp.updated:${event.directory}`
|
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[]) {
|
export function coalesceServerEvents(events: QueuedServerEvent[]) {
|
||||||
const output: QueuedServerEvent[] = []
|
const output: QueuedServerEvent[] = []
|
||||||
events.forEach((event) => {
|
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") {
|
if (event.payload.type !== "message.part.delta") {
|
||||||
output.push(event)
|
output.push(event)
|
||||||
return
|
return
|
||||||
|
|
@ -71,6 +138,27 @@ export function coalesceServerEvents(events: QueuedServerEvent[]) {
|
||||||
return output
|
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) {
|
export function resumeStreamAfterPageShow(event: PageTransitionEvent, start: () => unknown) {
|
||||||
if (!event.persisted) return
|
if (!event.persisted) return
|
||||||
start()
|
start()
|
||||||
|
|
@ -91,13 +179,15 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
|
||||||
}
|
}
|
||||||
})()
|
})()
|
||||||
|
|
||||||
|
const eventApi = createApiForServer({ server: server.http, fetch: eventFetch })
|
||||||
const eventSdk = createSdkForServer({
|
const eventSdk = createSdkForServer({
|
||||||
signal: abort.signal,
|
signal: abort.signal,
|
||||||
fetch: eventFetch,
|
fetch: eventFetch,
|
||||||
server: server.http,
|
server: server.http,
|
||||||
})
|
})
|
||||||
|
const protocol = detectServerProtocol(server.http, platform.fetch ?? globalThis.fetch)
|
||||||
const emitter = createGlobalEmitter<{
|
const emitter = createGlobalEmitter<{
|
||||||
[key: string]: Event
|
[key: string]: ServerEvent
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
type Queued = QueuedServerEvent
|
type Queued = QueuedServerEvent
|
||||||
|
|
@ -142,21 +232,6 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
|
||||||
let run: Promise<void> | undefined
|
let run: Promise<void> | undefined
|
||||||
let started = false
|
let started = false
|
||||||
let generation = 0
|
let generation = 0
|
||||||
const HEARTBEAT_TIMEOUT_MS = 15_000
|
|
||||||
let lastEventAt = Date.now()
|
|
||||||
let heartbeat: ReturnType<typeof setTimeout> | 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 = () => {
|
const start = () => {
|
||||||
if (started) return run
|
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
|
// 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) {
|
while (!abort.signal.aborted && started && generation === active) {
|
||||||
attempt = new AbortController()
|
attempt = new AbortController()
|
||||||
lastEventAt = Date.now()
|
|
||||||
const onAbort = () => {
|
const onAbort = () => {
|
||||||
attempt?.abort()
|
attempt?.abort()
|
||||||
}
|
}
|
||||||
abort.signal.addEventListener("abort", onAbort)
|
abort.signal.addEventListener("abort", onAbort)
|
||||||
try {
|
try {
|
||||||
const events = await eventSdk.global.event({
|
const kind = await protocol
|
||||||
signal: attempt.signal,
|
const events =
|
||||||
onSseError: (error) => {
|
kind === "v1"
|
||||||
if (isStreamClosed(error, attempt?.signal)) return
|
? (await eventSdk.global.event({ signal: attempt.signal })).stream
|
||||||
if (streamErrorLogged) return
|
: eventApi.event.subscribe({ signal: attempt.signal })
|
||||||
streamErrorLogged = true
|
|
||||||
console.error("[global-sdk] event stream error", {
|
|
||||||
url: server.http.url,
|
|
||||||
fetch: eventFetch ? "platform" : "webview",
|
|
||||||
error,
|
|
||||||
})
|
|
||||||
},
|
|
||||||
})
|
|
||||||
let yielded = Date.now()
|
let yielded = Date.now()
|
||||||
resetHeartbeat()
|
for await (const event of events) {
|
||||||
for await (const event of events.stream) {
|
|
||||||
resetHeartbeat()
|
|
||||||
streamErrorLogged = false
|
streamErrorLogged = false
|
||||||
if (event.payload.type !== "sync") {
|
const legacy = "payload" in event
|
||||||
const directory = event.directory ?? "global"
|
if (legacy && event.payload.type === "sync") continue
|
||||||
const payload = event.payload as Event
|
const directory = legacy ? (event.directory ?? "global") : (event.location?.directory ?? "global")
|
||||||
if (enqueueServerEvent(queue, { directory, payload })) schedule()
|
const payload = legacy ? (event.payload as Event) : adaptServerEvent(event)
|
||||||
}
|
if (enqueueServerEvent(queue, { directory, payload })) schedule()
|
||||||
|
|
||||||
if (Date.now() - yielded < STREAM_YIELD_MS) continue
|
if (Date.now() - yielded < STREAM_YIELD_MS) continue
|
||||||
yielded = Date.now()
|
yielded = Date.now()
|
||||||
|
|
@ -214,7 +278,6 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
|
||||||
} finally {
|
} finally {
|
||||||
abort.signal.removeEventListener("abort", onAbort)
|
abort.signal.removeEventListener("abort", onAbort)
|
||||||
attempt = undefined
|
attempt = undefined
|
||||||
clearHeartbeat()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (abort.signal.aborted || !started || generation !== active) return
|
if (abort.signal.aborted || !started || generation !== active) return
|
||||||
|
|
@ -233,18 +296,11 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
|
||||||
started = false
|
started = false
|
||||||
generation++
|
generation++
|
||||||
attempt?.abort()
|
attempt?.abort()
|
||||||
clearHeartbeat()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
makeEventListener(window, "pagehide", stop)
|
makeEventListener(window, "pagehide", stop)
|
||||||
makeEventListener(window, "pageshow", (event) => resumeStreamAfterPageShow(event, start))
|
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(() => {
|
onCleanup(() => {
|
||||||
|
|
@ -258,12 +314,24 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
|
||||||
fetch: platform.fetch,
|
fetch: platform.fetch,
|
||||||
throwOnError: true,
|
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 {
|
return {
|
||||||
server,
|
server,
|
||||||
scope,
|
scope,
|
||||||
|
protocol,
|
||||||
url: server.http.url,
|
url: server.http.url,
|
||||||
client: sdk,
|
client: sdk,
|
||||||
|
api,
|
||||||
|
currentApi,
|
||||||
event: {
|
event: {
|
||||||
on: emitter.on.bind(emitter),
|
on: emitter.on.bind(emitter),
|
||||||
listen: emitter.listen.bind(emitter),
|
listen: emitter.listen.bind(emitter),
|
||||||
|
|
@ -309,7 +377,7 @@ export const { use: useServerSDK, provider: ServerSDKProvider } = createSimpleCo
|
||||||
})
|
})
|
||||||
|
|
||||||
type SDKEventMap = {
|
type SDKEventMap = {
|
||||||
[key in Event["type"]]: Extract<Event, { type: key }>
|
[key in Event["type"]]: Extract<ServerEvent, { type: key }>
|
||||||
}
|
}
|
||||||
|
|
||||||
function createDirSdkContext(directory: string, serverSDK: ServerSDKBase) {
|
function createDirSdkContext(directory: string, serverSDK: ServerSDKBase) {
|
||||||
|
|
@ -329,6 +397,12 @@ function createDirSdkContext(directory: string, serverSDK: ServerSDKBase) {
|
||||||
scope: serverSDK.scope,
|
scope: serverSDK.scope,
|
||||||
directory,
|
directory,
|
||||||
client,
|
client,
|
||||||
|
api: createCompatibleApi({
|
||||||
|
protocol: serverSDK.protocol,
|
||||||
|
current: serverSDK.currentApi,
|
||||||
|
legacy: (next) => serverSDK.createClient({ directory: next ?? directory, throwOnError: true }),
|
||||||
|
directory,
|
||||||
|
}),
|
||||||
event: emitter,
|
event: emitter,
|
||||||
get url() {
|
get url() {
|
||||||
return serverSDK.url
|
return serverSDK.url
|
||||||
|
|
|
||||||
148
packages/app/src/context/server-session-v2-reducer.test.ts
Normal file
148
packages/app/src/context/server-session-v2-reducer.test.ts
Normal file
|
|
@ -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: [] })
|
||||||
|
})
|
||||||
|
})
|
||||||
434
packages/app/src/context/server-session-v2-reducer.ts
Normal file
434
packages/app/src/context/server-session-v2-reducer.ts
Normal file
|
|
@ -0,0 +1,434 @@
|
||||||
|
import type { OpenCodeEvent, SessionMessageInfo, SessionPendingMessage } from "@opencode-ai/client/promise"
|
||||||
|
|
||||||
|
type Assistant = Extract<SessionMessageInfo, { type: "assistant" }>
|
||||||
|
type Compaction = Extract<SessionMessageInfo, { type: "compaction" }>
|
||||||
|
type Shell = Extract<SessionMessageInfo, { type: "shell" }>
|
||||||
|
|
||||||
|
export type V2SessionReduction = {
|
||||||
|
sessionID: string
|
||||||
|
messages: SessionMessageInfo[]
|
||||||
|
touched: string[]
|
||||||
|
missing?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createV2SessionReducer() {
|
||||||
|
const pending = new Map<string, SessionPendingMessage>()
|
||||||
|
|
||||||
|
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<SessionMessageInfo, { type: "model-switched" | "assistant" }> =>
|
||||||
|
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<Shell>(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<Extract<Compaction, { status: "running" }>>(source, (item): item is Extract<Compaction, { status: "running" }> => 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<Compaction, { status: "running" }> => 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<Compaction, { status: "running" }> => item.type === "compaction" && item.status === "running")
|
||||||
|
const failed: Extract<Compaction, { status: "failed" }> = {
|
||||||
|
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<T extends SessionMessageInfo>(
|
||||||
|
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<T extends "text" | "reasoning">(
|
||||||
|
source: readonly SessionMessageInfo[],
|
||||||
|
messageID: string,
|
||||||
|
sessionID: string,
|
||||||
|
type: T,
|
||||||
|
ordinal: number,
|
||||||
|
apply: (item: Extract<Assistant["content"][number], { type: T }>) => Extract<Assistant["content"][number], { type: T }>,
|
||||||
|
) {
|
||||||
|
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<Assistant["content"][number], { type: T }>)
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateTool(
|
||||||
|
source: readonly SessionMessageInfo[],
|
||||||
|
messageID: string,
|
||||||
|
callID: string,
|
||||||
|
sessionID: string,
|
||||||
|
apply: (item: Extract<Assistant["content"][number], { type: "tool" }>) => Extract<Assistant["content"][number], { type: "tool" }>,
|
||||||
|
) {
|
||||||
|
return updateAssistant(source, messageID, sessionID, (assistant) => ({
|
||||||
|
...assistant,
|
||||||
|
content: assistant.content.map((item) => item.type === "tool" && item.id === callID ? apply(item) : item),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
function insertOrdinal<T extends Assistant["content"][number]["type"]>(
|
||||||
|
source: Assistant["content"],
|
||||||
|
type: T,
|
||||||
|
ordinal: number,
|
||||||
|
item: Extract<Assistant["content"][number], { type: T }>,
|
||||||
|
) {
|
||||||
|
const matches = source.filter((content) => content.type === type)
|
||||||
|
if (matches[ordinal]) return source
|
||||||
|
return [...source, item]
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
import type { retry } from "@opencode-ai/core/util/retry"
|
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 type { Message, OpencodeClient, Part, Session } from "@opencode-ai/sdk/v2/client"
|
||||||
import { createServerSession } from "./server-session"
|
import { createServerSession } from "./server-session"
|
||||||
|
|
||||||
|
|
@ -158,6 +159,57 @@ function setup(sessions: Record<string, Session>) {
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("server session", () => {
|
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 () => {
|
test("resolves lineage by session ID without directory", async () => {
|
||||||
const ctx = setup({ child: session("child", "root"), root: session("root") })
|
const ctx = setup({ child: session("child", "root"), root: session("root") })
|
||||||
|
|
||||||
|
|
@ -178,6 +230,111 @@ describe("server session", () => {
|
||||||
expect(ctx.store.data.message.root).toEqual([])
|
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 () => {
|
test("backfills an assistant-only initial page through its user root", async () => {
|
||||||
const user = userMessage("message-1")
|
const user = userMessage("message-1")
|
||||||
const assistants = [assistantMessage("message-2", user.id), assistantMessage("message-3", user.id)]
|
const assistants = [assistantMessage("message-2", user.id), assistantMessage("message-3", user.id)]
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { Binary } from "@opencode-ai/core/util/binary"
|
import { Binary } from "@opencode-ai/core/util/binary"
|
||||||
import { retry } from "@opencode-ai/core/util/retry"
|
import { retry } from "@opencode-ai/core/util/retry"
|
||||||
|
import type { MessageApi, OpenCodeEvent, SessionApi, SessionMessageInfo } from "@opencode-ai/client/promise"
|
||||||
import type {
|
import type {
|
||||||
Message,
|
Message,
|
||||||
OpencodeClient,
|
OpencodeClient,
|
||||||
|
|
@ -8,15 +9,18 @@ import type {
|
||||||
QuestionRequest,
|
QuestionRequest,
|
||||||
Session,
|
Session,
|
||||||
SessionStatus,
|
SessionStatus,
|
||||||
SnapshotFileDiff,
|
|
||||||
Todo,
|
Todo,
|
||||||
} from "@opencode-ai/sdk/v2/client"
|
} from "@opencode-ai/sdk/v2/client"
|
||||||
|
import type { FileDiffInfo } from "@opencode-ai/client/promise"
|
||||||
import { batch } from "solid-js"
|
import { batch } from "solid-js"
|
||||||
import { createStore, produce, reconcile } from "solid-js/store"
|
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 { sessionNotFoundError } from "@/utils/server-errors"
|
||||||
import { rootSession } from "@/utils/session-route"
|
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 { 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 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)
|
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 = {
|
type MessagePage = {
|
||||||
session: Message[]
|
session: Message[]
|
||||||
part: { id: string; part: Part[] }[]
|
part: { id: string; part: Part[] }[]
|
||||||
|
source?: SessionMessageInfo[]
|
||||||
|
sourceMode?: "latest" | "older"
|
||||||
|
projectSource?: boolean
|
||||||
cursor?: string
|
cursor?: string
|
||||||
complete: boolean
|
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.
|
// Most markers describe the current HTTP attempt; deltaParts persists non-durable stream state across retries.
|
||||||
type MessageLoadState = {
|
type MessageLoadState = {
|
||||||
touchedMessages: Set<string>
|
touchedMessages: Set<string>
|
||||||
|
|
@ -52,6 +83,7 @@ type MessageLoadState = {
|
||||||
optimisticParts: Map<string, Set<string>>
|
optimisticParts: Map<string, Set<string>>
|
||||||
orphanParents: Set<string>
|
orphanParents: Set<string>
|
||||||
clearedMessageParts: Set<string>
|
clearedMessageParts: Set<string>
|
||||||
|
touchedSource: Set<string>
|
||||||
}
|
}
|
||||||
|
|
||||||
type MessageLoadBaseline = Pick<
|
type MessageLoadBaseline = Pick<
|
||||||
|
|
@ -137,15 +169,25 @@ function reconcileFetched<T extends { id: string }>(
|
||||||
return [...result.values()].sort((a, b) => cmp(a.id, b.id))
|
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({
|
const [data, setData] = createStore({
|
||||||
info: {} as Record<string, Session | undefined>,
|
info: {} as Record<string, Session | undefined>,
|
||||||
session_status: {} as Record<string, SessionStatus>,
|
session_status: {} as Record<string, SessionStatus>,
|
||||||
session_diff: {} as Record<string, SnapshotFileDiff[]>,
|
session_diff: {} as Record<string, FileDiffInfo[]>,
|
||||||
todo: {} as Record<string, Todo[]>,
|
todo: {} as Record<string, Todo[]>,
|
||||||
permission: {} as Record<string, PermissionRequest[]>,
|
permission: {} as Record<string, PermissionRequest[]>,
|
||||||
question: {} as Record<string, QuestionRequest[]>,
|
question: {} as Record<string, QuestionRequest[]>,
|
||||||
message: {} as Record<string, Message[]>,
|
message: {} as Record<string, Message[]>,
|
||||||
|
session_message: {} as Record<string, SessionMessageInfo[]>,
|
||||||
part: {} as Record<string, Part[]>,
|
part: {} as Record<string, Part[]>,
|
||||||
part_text_accum_delta: {} as Record<string, string>,
|
part_text_accum_delta: {} as Record<string, string>,
|
||||||
session_working(id: string) {
|
session_working(id: string) {
|
||||||
|
|
@ -154,9 +196,9 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
|
||||||
})
|
})
|
||||||
const requests = new Map<string, Promise<Session>>()
|
const requests = new Map<string, Promise<Session>>()
|
||||||
const inflight = new Map<string, Promise<void>>()
|
const inflight = new Map<string, Promise<void>>()
|
||||||
const inflightDiff = new Map<string, Promise<void>>()
|
|
||||||
const inflightTodo = new Map<string, Promise<void>>()
|
const inflightTodo = new Map<string, Promise<void>>()
|
||||||
const optimistic = new Map<string, Map<string, OptimisticItem>>()
|
const optimistic = new Map<string, Map<string, OptimisticItem>>()
|
||||||
|
const v2 = createV2SessionReducer()
|
||||||
const messageLoads = new Map<string, MessageLoadState>()
|
const messageLoads = new Map<string, MessageLoadState>()
|
||||||
const pendingParts = new Map<string, Map<string, Set<string>>>()
|
const pendingParts = new Map<string, Map<string, Set<string>>>()
|
||||||
const orphanParts = new Map<string, Set<string>>()
|
const orphanParts = new Map<string, Set<string>>()
|
||||||
|
|
@ -191,6 +233,16 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
|
||||||
at: {} as Record<string, number | undefined>,
|
at: {} as Record<string, number | undefined>,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
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) => {
|
const remember = (session: Session) => {
|
||||||
setData("info", session.id, reconcile(session))
|
setData("info", session.id, reconcile(session))
|
||||||
infoSeen.delete(session.id)
|
infoSeen.delete(session.id)
|
||||||
|
|
@ -200,7 +252,6 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
|
||||||
...pinned.keys(),
|
...pinned.keys(),
|
||||||
...requests.keys(),
|
...requests.keys(),
|
||||||
...inflight.keys(),
|
...inflight.keys(),
|
||||||
...inflightDiff.keys(),
|
|
||||||
...inflightTodo.keys(),
|
...inflightTodo.keys(),
|
||||||
...messageLoads.keys(),
|
...messageLoads.keys(),
|
||||||
...optimistic.keys(),
|
...optimistic.keys(),
|
||||||
|
|
@ -242,27 +293,31 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
|
||||||
const pending = requests.get(sessionID)
|
const pending = requests.get(sessionID)
|
||||||
if (pending) return pending
|
if (pending) return pending
|
||||||
const active = generation(sessionID)
|
const active = generation(sessionID)
|
||||||
const request = client.session.get({ sessionID }).then((result) => {
|
const request = sessionApi
|
||||||
if (!result.data) throw sessionNotFoundError(sessionID)
|
? sessionApi.get({ sessionID }).then(normalizeSessionInfo)
|
||||||
if (generations.get(sessionID) !== active) return result.data
|
: client.session.get({ sessionID }).then((result) => {
|
||||||
return remember(result.data)
|
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 = () => {
|
const cleanup = () => {
|
||||||
if (requests.get(sessionID) === request) requests.delete(sessionID)
|
if (requests.get(sessionID) === resolved) requests.delete(sessionID)
|
||||||
if (
|
if (
|
||||||
generations.get(sessionID) === active &&
|
generations.get(sessionID) === active &&
|
||||||
!data.info[sessionID] &&
|
!data.info[sessionID] &&
|
||||||
!requests.has(sessionID) &&
|
!requests.has(sessionID) &&
|
||||||
!messageLoads.has(sessionID) &&
|
!messageLoads.has(sessionID) &&
|
||||||
!inflight.has(sessionID) &&
|
!inflight.has(sessionID) &&
|
||||||
!inflightDiff.has(sessionID) &&
|
|
||||||
!inflightTodo.has(sessionID)
|
!inflightTodo.has(sessionID)
|
||||||
)
|
)
|
||||||
generations.delete(sessionID)
|
generations.delete(sessionID)
|
||||||
}
|
}
|
||||||
void request.then(cleanup, cleanup)
|
void resolved.then(cleanup, cleanup)
|
||||||
return request
|
return resolved
|
||||||
}
|
}
|
||||||
|
|
||||||
const peekLineage = (sessionID: string) => {
|
const peekLineage = (sessionID: string) => {
|
||||||
|
|
@ -419,9 +474,9 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
|
||||||
clearOptimistic(sessionID)
|
clearOptimistic(sessionID)
|
||||||
requests.delete(sessionID)
|
requests.delete(sessionID)
|
||||||
inflight.delete(sessionID)
|
inflight.delete(sessionID)
|
||||||
inflightDiff.delete(sessionID)
|
|
||||||
inflightTodo.delete(sessionID)
|
inflightTodo.delete(sessionID)
|
||||||
messageLoads.delete(sessionID)
|
messageLoads.delete(sessionID)
|
||||||
|
v2.clear(sessionID)
|
||||||
pendingParts.delete(sessionID)
|
pendingParts.delete(sessionID)
|
||||||
orphanParts.delete(sessionID)
|
orphanParts.delete(sessionID)
|
||||||
removedMessages.delete(sessionID)
|
removedMessages.delete(sessionID)
|
||||||
|
|
@ -449,7 +504,6 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
|
||||||
...pinned.keys(),
|
...pinned.keys(),
|
||||||
...requests.keys(),
|
...requests.keys(),
|
||||||
...inflight.keys(),
|
...inflight.keys(),
|
||||||
...inflightDiff.keys(),
|
|
||||||
...inflightTodo.keys(),
|
...inflightTodo.keys(),
|
||||||
...messageLoads.keys(),
|
...messageLoads.keys(),
|
||||||
...optimistic.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) => {
|
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)(() => {
|
const response = await (options?.retry ?? retry)(() => {
|
||||||
onAttempt?.()
|
onAttempt?.()
|
||||||
return client.session.messages({ sessionID, limit, before })
|
return client.session.messages({ sessionID, limit, before })
|
||||||
|
|
@ -481,12 +554,24 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
|
||||||
id: item.info.id,
|
id: item.info.id,
|
||||||
part: item.parts.filter((part) => !!part?.id).sort((a, b) => cmp(a.id, b.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,
|
cursor: response.response.headers.get("x-next-cursor") ?? undefined,
|
||||||
complete: !response.response.headers.get("x-next-cursor"),
|
complete: !response.response.headers.get("x-next-cursor"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const fetchMessage = async (sessionID: string, messageID: string, onAttempt?: () => void) => {
|
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)(() => {
|
const response = await (options?.retry ?? retry)(() => {
|
||||||
onAttempt?.()
|
onAttempt?.()
|
||||||
return client.session.message({ sessionID, messageID })
|
return client.session.message({ sessionID, messageID })
|
||||||
|
|
@ -571,7 +656,31 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
|
||||||
preserveUnfetched: boolean | ((message: Message) => boolean),
|
preserveUnfetched: boolean | ((message: Message) => boolean),
|
||||||
cleanupOrphans: 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) => {
|
merged.observed.forEach((item) => {
|
||||||
if (!load?.clearedMessageParts.has(item.messageID)) confirmOptimistic(sessionID, item.messageID, item.parts)
|
if (!load?.clearedMessageParts.has(item.messageID)) confirmOptimistic(sessionID, item.messageID, item.parts)
|
||||||
})
|
})
|
||||||
|
|
@ -583,6 +692,7 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
|
||||||
preserveUnfetched,
|
preserveUnfetched,
|
||||||
})
|
})
|
||||||
batch(() => {
|
batch(() => {
|
||||||
|
if (source) setData("session_message", sessionID, reconcile(source))
|
||||||
const messageIDs = replaceMessages(sessionID, messages)
|
const messageIDs = replaceMessages(sessionID, messages)
|
||||||
replaceParts(sessionID, merged.part, messageIDs, load)
|
replaceParts(sessionID, merged.part, messageIDs, load)
|
||||||
const orphans = orphanParts.get(sessionID)
|
const orphans = orphanParts.get(sessionID)
|
||||||
|
|
@ -613,6 +723,7 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
|
||||||
optimisticParts: new Map(),
|
optimisticParts: new Map(),
|
||||||
orphanParents: new Set(),
|
orphanParents: new Set(),
|
||||||
clearedMessageParts: new Set(),
|
clearedMessageParts: new Set(),
|
||||||
|
touchedSource: new Set(),
|
||||||
}
|
}
|
||||||
messageLoads.set(sessionID, load)
|
messageLoads.set(sessionID, load)
|
||||||
setMeta("loading", sessionID, true)
|
setMeta("loading", sessionID, true)
|
||||||
|
|
@ -747,6 +858,109 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
|
||||||
return properties.part.sessionID
|
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 apply = (event: { type: string; properties?: unknown }) => {
|
||||||
const eventID = eventSessionID(event)
|
const eventID = eventSessionID(event)
|
||||||
if (eventID) {
|
if (eventID) {
|
||||||
|
|
@ -770,7 +984,9 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
case "session.deleted": {
|
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)
|
infoSeen.delete(sessionID)
|
||||||
setData(
|
setData(
|
||||||
"info",
|
"info",
|
||||||
|
|
@ -779,11 +995,6 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
|
||||||
evict([sessionID])
|
evict([sessionID])
|
||||||
return
|
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": {
|
case "todo.updated": {
|
||||||
const props = event.properties as { sessionID: string; todos: Todo[] }
|
const props = event.properties as { sessionID: string; todos: Todo[] }
|
||||||
setData("todo", props.sessionID, reconcile(props.todos, { key: "id" }))
|
setData("todo", props.sessionID, reconcile(props.todos, { key: "id" }))
|
||||||
|
|
@ -796,6 +1007,7 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
|
||||||
}
|
}
|
||||||
case "message.updated": {
|
case "message.updated": {
|
||||||
const info = cleanMessage((event.properties as { info: Message }).info)
|
const info = cleanMessage((event.properties as { info: Message }).info)
|
||||||
|
indexLegacyMessage(info)
|
||||||
const load = messageLoads.get(info.sessionID)
|
const load = messageLoads.get(info.sessionID)
|
||||||
load?.touchedMessages.add(info.id)
|
load?.touchedMessages.add(info.id)
|
||||||
load?.removedMessages.delete(info.id)
|
load?.removedMessages.delete(info.id)
|
||||||
|
|
@ -828,6 +1040,9 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
|
||||||
}
|
}
|
||||||
case "message.removed": {
|
case "message.removed": {
|
||||||
const props = event.properties as { sessionID: string; messageID: string }
|
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)
|
const load = messageLoads.get(props.sessionID)
|
||||||
load?.touchedMessages.add(props.messageID)
|
load?.touchedMessages.add(props.messageID)
|
||||||
load?.removedMessages.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)))
|
setData(produce((draft) => deleteMessageParts(draft, input.messageID)))
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
diff(sessionID: string, options?: { force?: boolean }) {
|
async todo(sessionID: string, request?: { force?: boolean }) {
|
||||||
touch(sessionID)
|
touch(sessionID)
|
||||||
if (data.session_diff[sessionID] !== undefined && !options?.force) return Promise.resolve()
|
if (data.todo[sessionID] !== undefined && !request?.force) return
|
||||||
return runInflight(inflightDiff, sessionID, () => {
|
if ((await options?.protocol) === "v2") {
|
||||||
const active = generation(sessionID)
|
setData("todo", sessionID, [])
|
||||||
return retry(() => client.session.diff({ sessionID })).then((result) => {
|
return
|
||||||
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()
|
|
||||||
return runInflight(inflightTodo, sessionID, () => {
|
return runInflight(inflightTodo, sessionID, () => {
|
||||||
const active = generation(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
|
if (generations.get(sessionID) !== active) return
|
||||||
setData("todo", sessionID, reconcile(result.data ?? [], { key: "id" }))
|
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)
|
if (count && count > 1) pinned.set(sessionID, count - 1)
|
||||||
},
|
},
|
||||||
apply,
|
apply,
|
||||||
|
applyV2,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,108 @@
|
||||||
import { describe, expect, test } from "bun:test"
|
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 { 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", () => {
|
describe("pickDirectoriesToEvict", () => {
|
||||||
test("keeps pinned stores and evicts idle stores", () => {
|
test("keeps pinned stores and evicts idle stores", () => {
|
||||||
|
|
@ -23,46 +125,57 @@ describe("pickDirectoriesToEvict", () => {
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe("loadRootSessionsWithFallback", () => {
|
describe("loadRootSessions", () => {
|
||||||
test("uses limited roots query when supported", async () => {
|
test("loads and normalizes a limited page of root sessions", async () => {
|
||||||
const calls: Array<{ directory: string; roots: true; limit?: number }> = []
|
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<SessionApi, "list">,
|
||||||
directory: "dir",
|
directory: "dir",
|
||||||
limit: 10,
|
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(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 () => {
|
test("propagates list failures", () => {
|
||||||
const calls: Array<{ directory: string; roots: true; limit?: number }> = []
|
expect(
|
||||||
|
loadRootSessions({
|
||||||
const result = await loadRootSessionsWithFallback({
|
api: {
|
||||||
directory: "dir",
|
list: async () => {
|
||||||
limit: 25,
|
throw new Error("failed")
|
||||||
list: async (query) => {
|
},
|
||||||
calls.push(query)
|
} satisfies Pick<SessionApi, "list">,
|
||||||
if (query.limit) throw new Error("unsupported")
|
directory: "dir",
|
||||||
return { data: [] }
|
limit: 25,
|
||||||
},
|
}),
|
||||||
})
|
).rejects.toThrow("failed")
|
||||||
|
|
||||||
expect(result.data).toEqual([])
|
|
||||||
expect(result.limited).toBe(false)
|
|
||||||
expect(calls).toEqual([
|
|
||||||
{ directory: "dir", roots: true, limit: 25 },
|
|
||||||
{ directory: "dir", roots: true },
|
|
||||||
])
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
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", () => {
|
describe("estimateRootSessionTotal", () => {
|
||||||
test("keeps exact total for full fetches", () => {
|
test("keeps exact total for full fetches", () => {
|
||||||
expect(estimateRootSessionTotal({ count: 42, limit: 10, limited: false })).toBe(42)
|
expect(estimateRootSessionTotal({ count: 42, limit: 10, limited: false })).toBe(42)
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,10 @@
|
||||||
import type {
|
import type {
|
||||||
Config,
|
Config,
|
||||||
McpResource,
|
|
||||||
OpencodeClient,
|
OpencodeClient,
|
||||||
Path,
|
Path,
|
||||||
Project,
|
Project,
|
||||||
ProviderAuthResponse,
|
ProviderAuthResponse,
|
||||||
|
SessionStatus,
|
||||||
} from "@opencode-ai/sdk/v2/client"
|
} from "@opencode-ai/sdk/v2/client"
|
||||||
import { showToast } from "@/utils/toast"
|
import { showToast } from "@/utils/toast"
|
||||||
import { getFilename } from "@opencode-ai/core/util/path"
|
import { getFilename } from "@opencode-ai/core/util/path"
|
||||||
|
|
@ -18,6 +18,7 @@ import {
|
||||||
bootstrapGlobal,
|
bootstrapGlobal,
|
||||||
clearProviderRev,
|
clearProviderRev,
|
||||||
loadAgentsQuery,
|
loadAgentsQuery,
|
||||||
|
loadCommands,
|
||||||
loadGlobalConfigQuery,
|
loadGlobalConfigQuery,
|
||||||
loadPathQuery,
|
loadPathQuery,
|
||||||
loadProjectsQuery,
|
loadProjectsQuery,
|
||||||
|
|
@ -26,12 +27,13 @@ import {
|
||||||
} from "./global-sync/bootstrap"
|
} from "./global-sync/bootstrap"
|
||||||
import { createChildStoreManager } from "./global-sync/child-store"
|
import { createChildStoreManager } from "./global-sync/child-store"
|
||||||
import { applyDirectoryEvent, applyGlobalEvent } from "./global-sync/event-reducer"
|
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 { trimSessions } from "./global-sync/session-trim"
|
||||||
import type { ProjectMeta } from "./global-sync/types"
|
import type { ProjectMeta } from "./global-sync/types"
|
||||||
import { SESSION_RECENT_LIMIT } from "./global-sync/types"
|
import { SESSION_RECENT_LIMIT } from "./global-sync/types"
|
||||||
import { formatServerError } from "@/utils/server-errors"
|
import { formatServerError } from "@/utils/server-errors"
|
||||||
import { queryOptions, useMutation, useQueries, useQuery, useQueryClient } from "@tanstack/solid-query"
|
import { queryOptions, useMutation, useQueries, useQuery, useQueryClient } from "@tanstack/solid-query"
|
||||||
|
import type { SolidQueryOptions } from "@tanstack/solid-query"
|
||||||
import { createRefreshQueue } from "./global-sync/queue"
|
import { createRefreshQueue } from "./global-sync/queue"
|
||||||
import { directoryKey } from "./global-sync/utils"
|
import { directoryKey } from "./global-sync/utils"
|
||||||
import { PathKey } from "@/utils/path-key"
|
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 type { ServerScope } from "@/utils/server-scope"
|
||||||
import { createHomeSessionIndexCache } from "./global-sync/home-session-index"
|
import { createHomeSessionIndexCache } from "./global-sync/home-session-index"
|
||||||
import { persisted } from "@/utils/persist"
|
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 { toggleMcp } from "./global-sync/mcp"
|
||||||
import { createServerSession } from "./server-session"
|
import { createServerSession, type ServerSession } from "./server-session"
|
||||||
|
|
||||||
type GlobalStore = {
|
type GlobalStore = {
|
||||||
ready: boolean
|
ready: boolean
|
||||||
|
|
@ -59,16 +71,76 @@ type GlobalStore = {
|
||||||
reload: undefined | "pending" | "complete"
|
reload: undefined | "pending" | "complete"
|
||||||
}
|
}
|
||||||
|
|
||||||
export const loadMcpQuery = (scope: ServerScope, directory: string, sdk: OpencodeClient) =>
|
type McpListApi = {
|
||||||
queryOptions({
|
readonly list: (input?: McpListInput) => Promise<McpListOutput>
|
||||||
|
}
|
||||||
|
|
||||||
|
type McpResourceApi = {
|
||||||
|
readonly resource: {
|
||||||
|
readonly catalog: (input?: McpResourceCatalogInput) => Promise<McpResourceCatalogOutput>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type ApiQueryOptions<T, K extends readonly unknown[]> = SolidQueryOptions<T, Error, T, K> & {
|
||||||
|
initialData?: undefined
|
||||||
|
queryKey: K
|
||||||
|
}
|
||||||
|
|
||||||
|
type SessionActiveApi = {
|
||||||
|
readonly active: () => Promise<SessionActiveOutput>
|
||||||
|
}
|
||||||
|
|
||||||
|
export const loadMcpQuery = (
|
||||||
|
scope: ServerScope,
|
||||||
|
directory: string,
|
||||||
|
api: McpListApi,
|
||||||
|
legacy?: OpencodeClient,
|
||||||
|
protocol?: Promise<"v1" | "v2">,
|
||||||
|
): ApiQueryOptions<Record<string, McpServer["status"]>, readonly [ServerScope, string, "mcp"]> =>
|
||||||
|
queryOptions<
|
||||||
|
Record<string, McpServer["status"]>,
|
||||||
|
Error,
|
||||||
|
Record<string, McpServer["status"]>,
|
||||||
|
readonly [ServerScope, string, "mcp"]
|
||||||
|
>({
|
||||||
queryKey: [scope, directory, "mcp"] as const,
|
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) =>
|
export const loadMcpResourcesQuery = (
|
||||||
queryOptions<Record<string, McpResource>>({
|
scope: ServerScope,
|
||||||
|
directory: string,
|
||||||
|
api: McpResourceApi,
|
||||||
|
legacy?: OpencodeClient,
|
||||||
|
protocol?: Promise<"v1" | "v2">,
|
||||||
|
): ApiQueryOptions<Record<string, McpResource>, readonly [ServerScope, string, "mcpResources"]> =>
|
||||||
|
queryOptions<
|
||||||
|
Record<string, McpResource>,
|
||||||
|
Error,
|
||||||
|
Record<string, McpResource>,
|
||||||
|
readonly [ServerScope, string, "mcpResources"]
|
||||||
|
>({
|
||||||
queryKey: [scope, directory, "mcpResources"] as const,
|
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: {},
|
placeholderData: {},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -78,22 +150,51 @@ export const loadLspQuery = (scope: ServerScope, directory: string, sdk: Opencod
|
||||||
queryFn: () => sdk.lsp.status().then((r) => r.data ?? []),
|
queryFn: () => sdk.lsp.status().then((r) => r.data ?? []),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
export const loadActiveSessionsQuery = (
|
||||||
|
scope: ServerScope,
|
||||||
|
api: SessionActiveApi,
|
||||||
|
): ApiQueryOptions<SessionActiveOutput, readonly [ServerScope, "activeSessions"]> =>
|
||||||
|
queryOptions<SessionActiveOutput, Error, SessionActiveOutput, readonly [ServerScope, "activeSessions"]>({
|
||||||
|
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<ServerSession, "data" | "set">,
|
||||||
|
active: SessionActiveOutput | Record<string, SessionStatus>,
|
||||||
|
) {
|
||||||
|
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(
|
function makeQueryOptionsApi(
|
||||||
scope: ServerScope,
|
scope: ServerScope,
|
||||||
serverSDK: () => OpencodeClient,
|
serverSDK: () => OpencodeClient,
|
||||||
|
serverAPI: ServerApi,
|
||||||
sdkFor: (dir: PathKey) => OpencodeClient,
|
sdkFor: (dir: PathKey) => OpencodeClient,
|
||||||
|
protocol: Promise<"v1" | "v2">,
|
||||||
) {
|
) {
|
||||||
return {
|
return {
|
||||||
globalConfig: () => loadGlobalConfigQuery(scope, serverSDK()),
|
globalConfig: () => loadGlobalConfigQuery(scope, serverSDK()),
|
||||||
projects: () => loadProjectsQuery(scope, serverSDK()),
|
projects: () => loadProjectsQuery(scope, serverAPI.project),
|
||||||
providers: (directory: PathKey | null) =>
|
providers: (directory: PathKey | null) =>
|
||||||
loadProvidersQuery(scope, directory, directory === null ? serverSDK() : sdkFor(directory)),
|
loadProvidersQuery(scope, directory, serverAPI, directory ? sdkFor(directory) : serverSDK(), protocol),
|
||||||
path: (directory: PathKey | null) =>
|
path: (directory: PathKey | null) => loadPathQuery(scope, directory, serverAPI.path),
|
||||||
loadPathQuery(scope, directory, directory === null ? serverSDK() : sdkFor(directory)),
|
agents: (directory: PathKey) => loadAgentsQuery(scope, directory, serverAPI.agent, sdkFor(directory), protocol),
|
||||||
agents: (directory: PathKey) => loadAgentsQuery(scope, directory, sdkFor(directory)),
|
references: (directory: PathKey) =>
|
||||||
references: (directory: PathKey) => loadReferencesQuery(scope, directory, sdkFor(directory)),
|
loadReferencesQuery(scope, directory, serverAPI.reference, sdkFor(directory), protocol),
|
||||||
mcp: (directory: PathKey) => loadMcpQuery(scope, directory, sdkFor(directory)),
|
mcp: (directory: PathKey) => loadMcpQuery(scope, directory, serverAPI.mcp, sdkFor(directory), protocol),
|
||||||
mcpResources: (directory: PathKey) => loadMcpResourcesQuery(scope, directory, sdkFor(directory)),
|
mcpResources: (directory: PathKey) =>
|
||||||
|
loadMcpResourcesQuery(scope, directory, serverAPI.mcp, sdkFor(directory), protocol),
|
||||||
lsp: (directory: PathKey) => loadLspQuery(scope, directory, sdkFor(directory)),
|
lsp: (directory: PathKey) => loadLspQuery(scope, directory, sdkFor(directory)),
|
||||||
sessions: (directory: PathKey) => ({ queryKey: [scope, directory, "loadSessions"] as const }),
|
sessions: (directory: PathKey) => ({ queryKey: [scope, directory, "loadSessions"] as const }),
|
||||||
}
|
}
|
||||||
|
|
@ -122,11 +223,44 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
||||||
return sdk
|
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(() => ({
|
const [configQuery, providerQuery, pathQuery] = useQueries(() => ({
|
||||||
queries: [queryOptionsApi.globalConfig(), queryOptionsApi.providers(null), queryOptionsApi.path(null)],
|
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<GlobalStore>({
|
const [globalStore, setGlobalStore] = createStore<GlobalStore>({
|
||||||
get ready() {
|
get ready() {
|
||||||
|
|
@ -183,6 +317,8 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
await bootstrapGlobal({
|
await bootstrapGlobal({
|
||||||
serverSDK: serverSDK.client,
|
serverSDK: serverSDK.client,
|
||||||
|
serverAPI: serverSDK.api,
|
||||||
|
protocol: serverSDK.protocol,
|
||||||
scope: serverSDK.scope,
|
scope: serverSDK.scope,
|
||||||
requestFailedTitle: language.t("common.requestFailed"),
|
requestFailedTitle: language.t("common.requestFailed"),
|
||||||
translate: language.t,
|
translate: language.t,
|
||||||
|
|
@ -212,8 +348,6 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
||||||
bootstrapInstance,
|
bootstrapInstance,
|
||||||
})
|
})
|
||||||
|
|
||||||
const session = createServerSession(serverSDK.client)
|
|
||||||
|
|
||||||
const children = createChildStoreManager({
|
const children = createChildStoreManager({
|
||||||
owner,
|
owner,
|
||||||
scope: serverSDK.scope,
|
scope: serverSDK.scope,
|
||||||
|
|
@ -224,17 +358,15 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
||||||
void bootstrapInstance(directory)
|
void bootstrapInstance(directory)
|
||||||
},
|
},
|
||||||
onMcp: (directory, setStore) => {
|
onMcp: (directory, setStore) => {
|
||||||
void retry(() =>
|
void loadCommands(directory, serverSDK.api.command, sdkFor(directory), serverSDK.protocol)
|
||||||
sdkFor(directory)
|
.then((commands) => setStore("command", commands))
|
||||||
.command.list()
|
.catch((err) => {
|
||||||
.then((x) => setStore("command", x.data ?? [])),
|
showToast({
|
||||||
).catch((err) => {
|
variant: "error",
|
||||||
showToast({
|
title: language.t("toast.project.reloadFailed.title", { project: getFilename(directory) }),
|
||||||
variant: "error",
|
description: formatServerError(err, language.t),
|
||||||
title: language.t("toast.project.reloadFailed.title", { project: getFilename(directory) }),
|
})
|
||||||
description: formatServerError(err, language.t),
|
|
||||||
})
|
})
|
||||||
})
|
|
||||||
},
|
},
|
||||||
onDispose: (directory) => {
|
onDispose: (directory) => {
|
||||||
const key = directoryKey(directory)
|
const key = directoryKey(directory)
|
||||||
|
|
@ -279,11 +411,12 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
||||||
.fetchQuery({
|
.fetchQuery({
|
||||||
...queryOptionsApi.sessions(key),
|
...queryOptionsApi.sessions(key),
|
||||||
queryFn: () =>
|
queryFn: () =>
|
||||||
loadRootSessionsWithFallback({
|
serverSDK.protocol
|
||||||
directory,
|
.then((protocol) =>
|
||||||
limit,
|
protocol === "v1"
|
||||||
list: (query) => serverSDK.client.session.list(query),
|
? loadRootSessionsV1({ client: sdkFor(directory), directory, limit })
|
||||||
})
|
: loadRootSessions({ api: serverSDK.api.session, directory, limit }),
|
||||||
|
)
|
||||||
.then((x) => {
|
.then((x) => {
|
||||||
const nonArchived = (x.data ?? [])
|
const nonArchived = (x.data ?? [])
|
||||||
.filter((s) => !!s?.id)
|
.filter((s) => !!s?.id)
|
||||||
|
|
@ -353,6 +486,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
||||||
provider: globalStore.provider,
|
provider: globalStore.provider,
|
||||||
},
|
},
|
||||||
sdk,
|
sdk,
|
||||||
|
api: serverSDK.api,
|
||||||
store: child[0],
|
store: child[0],
|
||||||
setStore: child[1],
|
setStore: child[1],
|
||||||
vcsCache: cache,
|
vcsCache: cache,
|
||||||
|
|
@ -360,6 +494,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
||||||
translate: language.t,
|
translate: language.t,
|
||||||
queryClient,
|
queryClient,
|
||||||
session,
|
session,
|
||||||
|
protocol: serverSDK.protocol,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -371,12 +506,31 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
||||||
return promise
|
return promise
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const indexSession = (info: Parameters<typeof session.remember>[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 unsub = serverSDK.event.listen((e) => {
|
||||||
const directory = e.name
|
const directory = e.name
|
||||||
const key = directoryKey(directory)
|
const key = directoryKey(directory)
|
||||||
const event = e.details
|
const event = e.details
|
||||||
|
const eventType: string = event.type
|
||||||
const recent = bootingRoot || Date.now() - bootedAt < 1500
|
const recent = bootingRoot || Date.now() - bootedAt < 1500
|
||||||
|
|
||||||
|
if (event.current) session.applyV2(event.current)
|
||||||
session.apply(event)
|
session.apply(event)
|
||||||
if (event.type === "session.created" || event.type === "session.updated" || event.type === "session.deleted") {
|
if (event.type === "session.created" || event.type === "session.updated" || event.type === "session.deleted") {
|
||||||
homeSessions.apply(event)
|
homeSessions.apply(event)
|
||||||
|
|
@ -384,6 +538,8 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
||||||
homeSessions.refresh(event.type)
|
homeSessions.refresh(event.type)
|
||||||
|
|
||||||
if (directory === "global") {
|
if (directory === "global") {
|
||||||
|
if (eventType === "server.connected" && activeSessionsQuery.data === undefined && !activeSessionsQuery.isFetching)
|
||||||
|
void activeSessionsQuery.refetch()
|
||||||
applyGlobalEvent({
|
applyGlobalEvent({
|
||||||
event,
|
event,
|
||||||
project: globalStore.project,
|
project: globalStore.project,
|
||||||
|
|
@ -393,7 +549,14 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
||||||
},
|
},
|
||||||
setGlobalProject: setProjects,
|
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
|
if (recent) return
|
||||||
for (const directory of Object.keys(children.children)) {
|
for (const directory of Object.keys(children.children)) {
|
||||||
if (!children.active(directory)) continue
|
if (!children.active(directory)) continue
|
||||||
|
|
@ -403,9 +566,30 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
||||||
return
|
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]
|
const existing = children.children[key]
|
||||||
if (!existing) return
|
if (!existing) return
|
||||||
children.mark(key)
|
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
|
const [store, setStore] = existing
|
||||||
applyDirectoryEvent({
|
applyDirectoryEvent({
|
||||||
event,
|
event,
|
||||||
|
|
@ -502,14 +686,23 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
||||||
toggle: async (directory: string, name: string) => {
|
toggle: async (directory: string, name: string) => {
|
||||||
const key = directoryKey(directory)
|
const key = directoryKey(directory)
|
||||||
const sdk = sdkFor(key)
|
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({
|
await toggleMcp({
|
||||||
status,
|
status,
|
||||||
connect: async () => {
|
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 () => {
|
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 () => {
|
authenticate: async () => {
|
||||||
await sdk.mcp.auth.authenticate({ name })
|
await sdk.mcp.auth.authenticate({ name })
|
||||||
|
|
|
||||||
|
|
@ -149,6 +149,7 @@ function createWorkspaceTerminalSession(
|
||||||
scope: ServerScopeValue,
|
scope: ServerScopeValue,
|
||||||
legacySessionID?: string,
|
legacySessionID?: string,
|
||||||
) {
|
) {
|
||||||
|
const location = { directory: sdk.directory }
|
||||||
const legacy = scope === ServerScope.local ? getLegacyTerminalStorageKeys(dir, legacySessionID) : []
|
const legacy = scope === ServerScope.local ? getLegacyTerminalStorageKeys(dir, legacySessionID) : []
|
||||||
|
|
||||||
const [store, setStore, _, ready] = persisted(
|
const [store, setStore, _, ready] = persisted(
|
||||||
|
|
@ -240,15 +241,16 @@ function createWorkspaceTerminalSession(
|
||||||
})
|
})
|
||||||
onCleanup(unsub)
|
onCleanup(unsub)
|
||||||
|
|
||||||
const update = (client: DirectorySDK["client"], pty: Partial<LocalPTY> & { id: string }) => {
|
const update = (pty: Partial<LocalPTY> & { id: string }) => {
|
||||||
const index = store.all.findIndex((x) => x.id === pty.id)
|
const index = store.all.findIndex((x) => x.id === pty.id)
|
||||||
const previous = index >= 0 ? store.all[index] : undefined
|
const previous = index >= 0 ? store.all[index] : undefined
|
||||||
if (index >= 0) {
|
if (index >= 0) {
|
||||||
setStore("all", index, (item) => ({ ...item, ...pty }))
|
setStore("all", index, (item) => ({ ...item, ...pty }))
|
||||||
}
|
}
|
||||||
client.pty
|
sdk.api.pty
|
||||||
.update({
|
.update({
|
||||||
ptyID: pty.id,
|
ptyID: pty.id,
|
||||||
|
location,
|
||||||
title: pty.title,
|
title: pty.title,
|
||||||
size: pty.cols && pty.rows ? { rows: pty.rows, cols: pty.cols } : undefined,
|
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 index = store.all.findIndex((x) => x.id === id)
|
||||||
const pty = store.all[index]
|
const pty = store.all[index]
|
||||||
if (!pty) return
|
if (!pty) return
|
||||||
const next = await client.pty
|
const next = await sdk.api.pty
|
||||||
.create({
|
.create({
|
||||||
|
location,
|
||||||
title: pty.title,
|
title: pty.title,
|
||||||
})
|
})
|
||||||
.catch((error: unknown) => {
|
.catch((error: unknown) => {
|
||||||
|
|
@ -308,17 +311,17 @@ function createWorkspaceTerminalSession(
|
||||||
const nextNumber = pickNextTerminalNumber()
|
const nextNumber = pickNextTerminalNumber()
|
||||||
const focusRequest = options?.focus ? requestFocus(undefined, true) : undefined
|
const focusRequest = options?.focus ? requestFocus(undefined, true) : undefined
|
||||||
|
|
||||||
sdk.client.pty
|
sdk.api.pty
|
||||||
.create({ title: defaultTitle(nextNumber) })
|
.create({ location, title: defaultTitle(nextNumber) })
|
||||||
.then((pty: { data?: { id?: string; title?: string } }) => {
|
.then((pty) => {
|
||||||
const id = pty.data?.id
|
const id = pty.data.id
|
||||||
if (!id) {
|
if (!id) {
|
||||||
if (focusRequest !== undefined) cancelFocus(focusRequest)
|
if (focusRequest !== undefined) cancelFocus(focusRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const newTerminal = {
|
const newTerminal = {
|
||||||
id,
|
id,
|
||||||
title: pty.data?.title ?? defaultTitle(nextNumber),
|
title: pty.data.title ?? defaultTitle(nextNumber),
|
||||||
titleNumber: nextNumber,
|
titleNumber: nextNumber,
|
||||||
}
|
}
|
||||||
batch(() => {
|
batch(() => {
|
||||||
|
|
@ -335,7 +338,7 @@ function createWorkspaceTerminalSession(
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
update(pty: Partial<LocalPTY> & { id: string }) {
|
update(pty: Partial<LocalPTY> & { id: string }) {
|
||||||
update(sdk.client, pty)
|
update(pty)
|
||||||
},
|
},
|
||||||
trim(id: string) {
|
trim(id: string) {
|
||||||
const index = store.all.findIndex((x) => x.id === id)
|
const index = store.all.findIndex((x) => x.id === id)
|
||||||
|
|
@ -350,10 +353,9 @@ function createWorkspaceTerminalSession(
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
async clone(id: string) {
|
async clone(id: string) {
|
||||||
await clone(sdk.client, id)
|
await clone(id)
|
||||||
},
|
},
|
||||||
bind() {
|
bind() {
|
||||||
const client = sdk.client
|
|
||||||
return {
|
return {
|
||||||
trim(id: string) {
|
trim(id: string) {
|
||||||
const index = store.all.findIndex((x) => x.id === id)
|
const index = store.all.findIndex((x) => x.id === id)
|
||||||
|
|
@ -361,10 +363,10 @@ function createWorkspaceTerminalSession(
|
||||||
setStore("all", index, (pty) => trimTerminal(pty))
|
setStore("all", index, (pty) => trimTerminal(pty))
|
||||||
},
|
},
|
||||||
update(pty: Partial<LocalPTY> & { id: string }) {
|
update(pty: Partial<LocalPTY> & { id: string }) {
|
||||||
update(client, pty)
|
update(pty)
|
||||||
},
|
},
|
||||||
async clone(id: string) {
|
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)
|
console.error("Failed to close terminal", error)
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@ test("archiving a Home session removes its open titlebar tab", async () => {
|
||||||
await archiveHomeSession({
|
await archiveHomeSession({
|
||||||
server: remote,
|
server: remote,
|
||||||
session: { id: "ses_1", directory: "/workspace" },
|
session: { id: "ses_1", directory: "/workspace" },
|
||||||
update: async () => undefined,
|
archive: async () => undefined,
|
||||||
remove: () => {
|
remove: () => {
|
||||||
removed = true
|
removed = true
|
||||||
},
|
},
|
||||||
|
|
@ -37,7 +37,7 @@ test("reports archive failures without removing the session", async () => {
|
||||||
await archiveHomeSession({
|
await archiveHomeSession({
|
||||||
server: remote,
|
server: remote,
|
||||||
session: { id: "ses_1", directory: "/workspace" },
|
session: { id: "ses_1", directory: "/workspace" },
|
||||||
update: async () => Promise.reject(failure),
|
archive: async () => Promise.reject(failure),
|
||||||
remove: () => {
|
remove: () => {
|
||||||
removed = true
|
removed = true
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -6,25 +6,15 @@ type HomeSession = {
|
||||||
directory: string
|
directory: string
|
||||||
}
|
}
|
||||||
|
|
||||||
type SessionUpdate = {
|
|
||||||
directory: string
|
|
||||||
sessionID: string
|
|
||||||
time: { archived: number }
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function archiveHomeSession(input: {
|
export async function archiveHomeSession(input: {
|
||||||
server: ServerConnection.Key
|
server: ServerConnection.Key
|
||||||
session: HomeSession
|
session: HomeSession
|
||||||
update: (value: SessionUpdate) => Promise<unknown>
|
archive: (sessionID: string) => Promise<unknown>
|
||||||
remove: () => void
|
remove: () => void
|
||||||
onError?: (error: unknown) => void
|
onError?: (error: unknown) => void
|
||||||
}) {
|
}) {
|
||||||
await input
|
await input
|
||||||
.update({
|
.archive(input.session.id)
|
||||||
directory: input.session.directory,
|
|
||||||
sessionID: input.session.id,
|
|
||||||
time: { archived: Date.now() },
|
|
||||||
})
|
|
||||||
.then(() => {
|
.then(() => {
|
||||||
input.remove()
|
input.remove()
|
||||||
notifySessionTabsRemoved({
|
notifySessionTabsRemoved({
|
||||||
|
|
|
||||||
|
|
@ -606,7 +606,7 @@ export function NewHome() {
|
||||||
await archiveHomeSession({
|
await archiveHomeSession({
|
||||||
server: ServerConnection.key(conn),
|
server: ServerConnection.key(conn),
|
||||||
session,
|
session,
|
||||||
update: (value) => ctx.sdk.client.session.update(value),
|
archive: (sessionID) => ctx.sdk.api.session.archive({ sessionID, directory: session.directory }),
|
||||||
remove: () =>
|
remove: () =>
|
||||||
setStore(
|
setStore(
|
||||||
produce((draft) => {
|
produce((draft) => {
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,7 @@ import { useProviders } from "@/hooks/use-providers"
|
||||||
import { toaster } from "@opencode-ai/ui/toast"
|
import { toaster } from "@opencode-ai/ui/toast"
|
||||||
import { setV2Toast, showToast, ToastRegion } from "@/utils/toast"
|
import { setV2Toast, showToast, ToastRegion } from "@/utils/toast"
|
||||||
import { useServerSDK } from "@/context/server-sdk"
|
import { useServerSDK } from "@/context/server-sdk"
|
||||||
|
import { normalizeProjectInfo } from "@/context/global-sync/utils"
|
||||||
import { clearWorkspaceTerminals } from "@/context/terminal"
|
import { clearWorkspaceTerminals } from "@/context/terminal"
|
||||||
import { pickSessionCacheEvictions } from "@/context/global-sync/session-cache"
|
import { pickSessionCacheEvictions } from "@/context/global-sync/session-cache"
|
||||||
import { useNotification } from "@/context/notification"
|
import { useNotification } from "@/context/notification"
|
||||||
|
|
@ -48,6 +49,7 @@ import { setNavigate } from "@/utils/notification-click"
|
||||||
import { Worktree as WorktreeState } from "@/utils/worktree"
|
import { Worktree as WorktreeState } from "@/utils/worktree"
|
||||||
import { setSessionHandoff } from "@/pages/session/handoff"
|
import { setSessionHandoff } from "@/pages/session/handoff"
|
||||||
import { SessionRouteKey, SessionStateKey } from "@/utils/server-scope"
|
import { SessionRouteKey, SessionStateKey } from "@/utils/server-scope"
|
||||||
|
import { listAllSessions } from "@/utils/session"
|
||||||
|
|
||||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||||
import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme/context"
|
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 index = sessions.findIndex((s) => s.id === session.id)
|
||||||
const nextSession = sessions[index + 1] ?? sessions[index - 1]
|
const nextSession = sessions[index + 1] ?? sessions[index - 1]
|
||||||
|
|
||||||
await serverSDK().client.session.update({
|
await serverSDK().api.session.archive({ sessionID: session.id, directory: session.directory })
|
||||||
directory: session.directory,
|
|
||||||
sessionID: session.id,
|
|
||||||
time: { archived: Date.now() },
|
|
||||||
})
|
|
||||||
setStore(
|
setStore(
|
||||||
produce((draft) => {
|
produce((draft) => {
|
||||||
const match = Binary.search(draft.session, session.id, (s) => s.id)
|
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) => {
|
const refreshDirs = async (target?: string) => {
|
||||||
if (!target || target === root || canOpen(target)) return canOpen(target)
|
if (!target || target === root || canOpen(target)) return canOpen(target)
|
||||||
const listed = await serverSDK()
|
const listed = await Promise.resolve(
|
||||||
.client.worktree.list({ directory: root })
|
project?.id ?? serverSDK().api.project.current({ location: { directory: root } }),
|
||||||
.then((x) => x.data ?? [])
|
)
|
||||||
|
.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[])
|
.catch(() => [] as string[])
|
||||||
dirs = effectiveWorkspaceOrder(root, [root, ...listed], store.workspaceOrder[root])
|
dirs = effectiveWorkspaceOrder(root, [root, ...listed], store.workspaceOrder[root])
|
||||||
return canOpen(target)
|
return canOpen(target)
|
||||||
|
|
@ -1231,10 +1232,11 @@ export default function LegacyLayout(props: ParentProps) {
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
dirs.map(async (item) => ({
|
dirs.map(async (item) => ({
|
||||||
path: { directory: item },
|
path: { directory: item },
|
||||||
session: await serverSDK()
|
session: await listAllSessions(serverSDK().api.session, {
|
||||||
.client.session.list({ directory: item })
|
directory: item,
|
||||||
.then((x) => x.data ?? [])
|
parentID: null,
|
||||||
.catch(() => []),
|
order: "desc",
|
||||||
|
}).catch(() => []),
|
||||||
})),
|
})),
|
||||||
),
|
),
|
||||||
Date.now(),
|
Date.now(),
|
||||||
|
|
@ -1294,7 +1296,10 @@ export default function LegacyLayout(props: ParentProps) {
|
||||||
const name = next === getFilename(project.worktree) ? "" : next
|
const name = next === getFilename(project.worktree) ? "" : next
|
||||||
|
|
||||||
if (project.id && project.id !== "global") {
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1445,10 +1450,7 @@ export default function LegacyLayout(props: ParentProps) {
|
||||||
})
|
})
|
||||||
const dismiss = () => toaster.dismiss(progress)
|
const dismiss = () => toaster.dismiss(progress)
|
||||||
|
|
||||||
const sessions: Session[] = await serverSDK()
|
const sessions = await listAllSessions(serverSDK().api.session, { directory, order: "desc" }).catch(() => [])
|
||||||
.client.session.list({ directory })
|
|
||||||
.then((x) => x.data ?? [])
|
|
||||||
.catch(() => [])
|
|
||||||
|
|
||||||
clearWorkspaceTerminals(
|
clearWorkspaceTerminals(
|
||||||
directory,
|
directory,
|
||||||
|
|
@ -1477,17 +1479,12 @@ export default function LegacyLayout(props: ParentProps) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const archivedAt = Date.now()
|
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
sessions
|
sessions
|
||||||
.filter((session) => session.time.archived === undefined)
|
.filter((session) => session.time.archived === undefined)
|
||||||
.map((session) =>
|
.map((session) =>
|
||||||
serverSDK()
|
serverSDK()
|
||||||
.client.session.update({
|
.api.session.archive({ sessionID: session.id, directory: session.directory })
|
||||||
sessionID: session.id,
|
|
||||||
directory: session.directory,
|
|
||||||
time: { archived: archivedAt },
|
|
||||||
})
|
|
||||||
.catch(() => undefined),
|
.catch(() => undefined),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
@ -1524,9 +1521,9 @@ export default function LegacyLayout(props: ParentProps) {
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
serverSDK()
|
serverSDK()
|
||||||
.client.vcs.status({ directory: props.directory })
|
.api.vcs.status({ location: { directory: props.directory } })
|
||||||
.then((x) => {
|
.then((result) => {
|
||||||
const files = x.data ?? []
|
const files = result.data
|
||||||
const dirty = files.length > 0
|
const dirty = files.length > 0
|
||||||
setData({ status: "ready", dirty })
|
setData({ status: "ready", dirty })
|
||||||
})
|
})
|
||||||
|
|
@ -1582,19 +1579,19 @@ export default function LegacyLayout(props: ParentProps) {
|
||||||
})
|
})
|
||||||
|
|
||||||
const refresh = async () => {
|
const refresh = async () => {
|
||||||
const sessions = await serverSDK()
|
const sessions = await listAllSessions(serverSDK().api.session, {
|
||||||
.client.session.list({ directory: props.directory })
|
directory: props.directory,
|
||||||
.then((x) => x.data ?? [])
|
order: "desc",
|
||||||
.catch(() => [])
|
}).catch(() => [])
|
||||||
const active = sessions.filter((session) => session.time.archived === undefined)
|
const active = sessions.filter((session) => session.time.archived === undefined)
|
||||||
setState({ sessions: active })
|
setState({ sessions: active })
|
||||||
}
|
}
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
serverSDK()
|
serverSDK()
|
||||||
.client.vcs.status({ directory: props.directory })
|
.api.vcs.status({ location: { directory: props.directory } })
|
||||||
.then((x) => {
|
.then((result) => {
|
||||||
const files = x.data ?? []
|
const files = result.data
|
||||||
const dirty = files.length > 0
|
const dirty = files.length > 0
|
||||||
setState({ status: "ready", dirty })
|
setState({ status: "ready", dirty })
|
||||||
void refresh()
|
void refresh()
|
||||||
|
|
|
||||||
|
|
@ -532,7 +532,6 @@ export default function Page() {
|
||||||
|
|
||||||
const info = createMemo(() => (params.id ? sync().session.get(params.id) : undefined))
|
const info = createMemo(() => (params.id ? sync().session.get(params.id) : undefined))
|
||||||
const isChildSession = createMemo(() => !!info()?.parentID)
|
const isChildSession = createMemo(() => !!info()?.parentID)
|
||||||
const diffs = createMemo(() => (params.id ? list(sync().data.session_diff[params.id]) : []))
|
|
||||||
const canReview = createMemo(() => !!sync().project)
|
const canReview = createMemo(() => !!sync().project)
|
||||||
const reviewTab = createMemo(() => isDesktop())
|
const reviewTab = createMemo(() => isDesktop())
|
||||||
const tabState = createSessionTabs({
|
const tabState = createSessionTabs({
|
||||||
|
|
@ -690,8 +689,8 @@ export default function Page() {
|
||||||
queryFn: mode
|
queryFn: mode
|
||||||
? () =>
|
? () =>
|
||||||
sdk()
|
sdk()
|
||||||
.client.vcs.diff({ mode })
|
.api.vcs.diff({ location: { directory: sdk().directory }, mode: mode === "git" ? "working" : mode })
|
||||||
.then((result) => list(result.data))
|
.then((result) => result.data)
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.debug("[session-review] failed to load vcs diff", { mode, error })
|
console.debug("[session-review] failed to load vcs diff", { mode, error })
|
||||||
return []
|
return []
|
||||||
|
|
@ -738,8 +737,12 @@ export default function Page() {
|
||||||
retry: 2,
|
retry: 2,
|
||||||
queryFn: () =>
|
queryFn: () =>
|
||||||
sdk()
|
sdk()
|
||||||
.client.vcs.diff({ mode, directory: scope, context })
|
.api.vcs.diff({
|
||||||
.then((result) => result.data ?? []),
|
location: { directory: scope },
|
||||||
|
mode: mode === "git" ? "working" : mode,
|
||||||
|
context,
|
||||||
|
})
|
||||||
|
.then((result) => result.data),
|
||||||
})
|
})
|
||||||
.then((diffs) => diffs.find((diff) => diff.file === file))
|
.then((diffs) => diffs.find((diff) => diff.file === file))
|
||||||
|
|
||||||
|
|
@ -946,10 +949,11 @@ export default function Page() {
|
||||||
)
|
)
|
||||||
|
|
||||||
const stopVcs = sdk().event.listen((evt) => {
|
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 =
|
const props =
|
||||||
typeof evt.details.properties === "object" && evt.details.properties
|
typeof details.properties === "object" && details.properties
|
||||||
? (evt.details.properties as Record<string, unknown>)
|
? (details.properties as Record<string, unknown>)
|
||||||
: undefined
|
: undefined
|
||||||
const file = typeof props?.file === "string" ? props.file : undefined
|
const file = typeof props?.file === "string" ? props.file : undefined
|
||||||
if (!file || file.startsWith(".git/")) return
|
if (!file || file.startsWith(".git/")) return
|
||||||
|
|
@ -1464,44 +1468,6 @@ export default function Page() {
|
||||||
requestAnimationFrame(() => attempt(0))
|
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
|
let treeDir: string | undefined
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
const dir = sdk().directory
|
const dir = sdk().directory
|
||||||
|
|
@ -1757,7 +1723,7 @@ export default function Page() {
|
||||||
setFollowup("failed", input.sessionID, undefined)
|
setFollowup("failed", input.sessionID, undefined)
|
||||||
|
|
||||||
const ok = await sendFollowupDraft({
|
const ok = await sendFollowupDraft({
|
||||||
client: sdk().client,
|
api: sdk().api.session,
|
||||||
sync: sync(),
|
sync: sync(),
|
||||||
serverSync: serverSync(),
|
serverSync: serverSync(),
|
||||||
draft: item,
|
draft: item,
|
||||||
|
|
@ -1853,13 +1819,13 @@ export default function Page() {
|
||||||
const halt = (sessionID: string) =>
|
const halt = (sessionID: string) =>
|
||||||
busy(sessionID)
|
busy(sessionID)
|
||||||
? sdk()
|
? sdk()
|
||||||
.client.session.abort({ sessionID })
|
.api.session.interrupt({ sessionID })
|
||||||
.catch(() => {})
|
.catch(() => {})
|
||||||
: Promise.resolve()
|
: Promise.resolve()
|
||||||
|
|
||||||
const revertMutation = useMutation(() => ({
|
const revertMutation = useMutation(() => ({
|
||||||
mutationFn: async (input: { sessionID: string; messageID: string }) => {
|
mutationFn: async (input: { sessionID: string; messageID: string }) => {
|
||||||
const client = sdk().client
|
const session = sdk().api.session
|
||||||
const target = sync()
|
const target = sync()
|
||||||
const last = target.session.get(input.sessionID)?.revert
|
const last = target.session.get(input.sessionID)?.revert
|
||||||
const value = draft(input.messageID)
|
const value = draft(input.messageID)
|
||||||
|
|
@ -1869,10 +1835,8 @@ export default function Page() {
|
||||||
roll(input.sessionID, { messageID: input.messageID }, target)
|
roll(input.sessionID, { messageID: input.messageID }, target)
|
||||||
prompt.set(value)
|
prompt.set(value)
|
||||||
},
|
},
|
||||||
request: () => halt(input.sessionID).then(() => client.session.revert(input)),
|
request: () => halt(input.sessionID).then(() => session.revert.stage(input)),
|
||||||
complete: (result) => {
|
complete: () => undefined,
|
||||||
if (result.data) merge(result.data, target)
|
|
||||||
},
|
|
||||||
rollback: () => roll(input.sessionID, last, target),
|
rollback: () => roll(input.sessionID, last, target),
|
||||||
fail,
|
fail,
|
||||||
})
|
})
|
||||||
|
|
@ -1884,7 +1848,7 @@ export default function Page() {
|
||||||
const sessionID = params.id
|
const sessionID = params.id
|
||||||
if (!sessionID) return
|
if (!sessionID) return
|
||||||
|
|
||||||
const client = sdk().client
|
const session = sdk().api.session
|
||||||
const target = sync()
|
const target = sync()
|
||||||
const next = userMessages().find((item) => item.id > id)
|
const next = userMessages().find((item) => item.id > id)
|
||||||
const last = target.session.get(sessionID)?.revert
|
const last = target.session.get(sessionID)?.revert
|
||||||
|
|
@ -1901,11 +1865,9 @@ export default function Page() {
|
||||||
},
|
},
|
||||||
request: () =>
|
request: () =>
|
||||||
!next
|
!next
|
||||||
? halt(sessionID).then(() => client.session.unrevert({ sessionID }))
|
? halt(sessionID).then(() => session.revert.clear({ sessionID }))
|
||||||
: halt(sessionID).then(() => client.session.revert({ sessionID, messageID: next.id })),
|
: halt(sessionID).then(() => session.revert.stage({ sessionID, messageID: next.id }).then(() => undefined)),
|
||||||
complete: (result) => {
|
complete: () => undefined,
|
||||||
if (result.data) merge(result.data, target)
|
|
||||||
},
|
|
||||||
rollback: () => roll(sessionID, last, target),
|
rollback: () => roll(sessionID, last, target),
|
||||||
fail,
|
fail,
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -45,7 +45,8 @@ export function createPromptInputController(input: {
|
||||||
model: {
|
model: {
|
||||||
selection: input.model ?? local.model,
|
selection: input.model ?? local.model,
|
||||||
paid: providers.paid().length > 0,
|
paid: providers.paid().length > 0,
|
||||||
loading: agentsQuery.isLoading || providersQuery.isLoading || globalProvidersQuery.isLoading,
|
loading:
|
||||||
|
(local.agent.visible() && agentsQuery.isLoading) || providersQuery.isLoading || globalProvidersQuery.isLoading,
|
||||||
},
|
},
|
||||||
session: {
|
session: {
|
||||||
id: input.sessionID(),
|
id: input.sessionID(),
|
||||||
|
|
|
||||||
|
|
@ -82,7 +82,7 @@ export function createSessionComposerController(options?: { closeMs?: number | (
|
||||||
|
|
||||||
setStore("responding", perm.id)
|
setStore("responding", perm.id)
|
||||||
sdk()
|
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) => {
|
.catch((err: unknown) => {
|
||||||
const description = err instanceof Error ? err.message : String(err)
|
const description = err instanceof Error ? err.message : String(err)
|
||||||
showToast({ title: language.t("common.requestFailed"), description })
|
showToast({ title: language.t("common.requestFailed"), description })
|
||||||
|
|
|
||||||
|
|
@ -223,7 +223,8 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
||||||
}
|
}
|
||||||
|
|
||||||
const replyMutation = useMutation(() => ({
|
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: () => {
|
onMutate: () => {
|
||||||
props.onSubmit()
|
props.onSubmit()
|
||||||
},
|
},
|
||||||
|
|
@ -235,7 +236,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
||||||
}))
|
}))
|
||||||
|
|
||||||
const rejectMutation = useMutation(() => ({
|
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: () => {
|
onMutate: () => {
|
||||||
props.onSubmit()
|
props.onSubmit()
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -235,7 +235,7 @@ export function SessionFileView(props: SessionFileViewProps) {
|
||||||
const source = props.diff
|
const source = props.diff
|
||||||
if (!source) return
|
if (!source) return
|
||||||
const loaded = loadedDiff()
|
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 (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { createEffect, onCleanup, type JSX } from "solid-js"
|
import { createEffect, onCleanup, type JSX } from "solid-js"
|
||||||
import { makeEventListener } from "@solid-primitives/event-listener"
|
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||||
import type { SnapshotFileDiff, VcsFileDiff } from "@opencode-ai/sdk/v2"
|
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 { SessionReview } from "@opencode-ai/session-ui/session-review"
|
||||||
import type {
|
import type {
|
||||||
SessionReviewCommentActions,
|
SessionReviewCommentActions,
|
||||||
|
|
@ -14,7 +15,7 @@ import type { LineComment } from "@/context/comments"
|
||||||
|
|
||||||
export type DiffStyle = "unified" | "split"
|
export type DiffStyle = "unified" | "split"
|
||||||
|
|
||||||
type ReviewDiff = SnapshotFileDiff | VcsFileDiff
|
type ReviewDiff = FileDiffInfo | SnapshotFileDiff | VcsFileDiff
|
||||||
|
|
||||||
export interface SessionReviewTabProps {
|
export interface SessionReviewTabProps {
|
||||||
title?: JSX.Element
|
title?: JSX.Element
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
|
||||||
import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2"
|
import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2"
|
||||||
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
|
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
|
||||||
import type { SnapshotFileDiff, VcsFileDiff } from "@opencode-ai/sdk/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 { ConstrainDragYAxis, getDraggableId } from "@/utils/solid-dnd"
|
||||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
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 { useSessionLayout } from "@/pages/session/session-layout"
|
||||||
import { SessionFileBrowserTab, type SessionFileBrowserState } from "@/pages/session/v2/session-file-browser-tab"
|
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"
|
return typeof value.file === "string"
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SessionSidePanel(props: {
|
export function SessionSidePanel(props: {
|
||||||
canReview: () => boolean
|
canReview: () => boolean
|
||||||
diffs: () => (SnapshotFileDiff | VcsFileDiff)[]
|
diffs: () => ReviewDiff[]
|
||||||
diffsReady: () => boolean
|
diffsReady: () => boolean
|
||||||
empty: () => string
|
empty: () => string
|
||||||
hasReview: () => boolean
|
hasReview: () => boolean
|
||||||
|
|
|
||||||
|
|
@ -283,6 +283,14 @@ export function MessageTimeline(props: {
|
||||||
return sync().data.session_status[id] ?? idle
|
return sync().data.session_status[id] ?? idle
|
||||||
})
|
})
|
||||||
const sessionMessages = createMemo(() => (sessionID() ? (sync().data.message[sessionID()!] ?? []) : []))
|
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 info = createMemo(() => {
|
||||||
const id = sessionID()
|
const id = sessionID()
|
||||||
if (!id) return
|
if (!id) return
|
||||||
|
|
@ -324,7 +332,7 @@ export function MessageTimeline(props: {
|
||||||
const showHeader = createMemo(() => !!(titleValue() || parentID()))
|
const showHeader = createMemo(() => !!(titleValue() || parentID()))
|
||||||
const projection = createTimelineProjection({
|
const projection = createTimelineProjection({
|
||||||
messages: sessionMessages,
|
messages: sessionMessages,
|
||||||
userMessages: () => props.userMessages,
|
sessionMessages: projectedMessages,
|
||||||
parts: getMsgParts,
|
parts: getMsgParts,
|
||||||
status: sessionStatus,
|
status: sessionStatus,
|
||||||
showReasoningSummaries: settings.general.showReasoningSummaries,
|
showReasoningSummaries: settings.general.showReasoningSummaries,
|
||||||
|
|
@ -664,8 +672,7 @@ export function MessageTimeline(props: {
|
||||||
}))
|
}))
|
||||||
|
|
||||||
const titleMutation = useMutation(() => ({
|
const titleMutation = useMutation(() => ({
|
||||||
mutationFn: (input: { id: string; title: string }) =>
|
mutationFn: (input: { id: string; title: string }) => sdk().api.session.rename({ sessionID: input.id, title: input.title }),
|
||||||
sdk().client.session.update({ sessionID: input.id, title: input.title }),
|
|
||||||
onSuccess: (_, input) => {
|
onSuccess: (_, input) => {
|
||||||
sync().set(
|
sync().set(
|
||||||
produce((draft) => {
|
produce((draft) => {
|
||||||
|
|
@ -809,7 +816,7 @@ export function MessageTimeline(props: {
|
||||||
const nextSession = index === -1 ? undefined : (sessions[index + 1] ?? sessions[index - 1])
|
const nextSession = index === -1 ? undefined : (sessions[index + 1] ?? sessions[index - 1])
|
||||||
|
|
||||||
await sdk()
|
await sdk()
|
||||||
.client.session.update({ sessionID, time: { archived: Date.now() } })
|
.api.session.archive({ sessionID })
|
||||||
.then(() => {
|
.then(() => {
|
||||||
sync().set(
|
sync().set(
|
||||||
produce((draft) => {
|
produce((draft) => {
|
||||||
|
|
@ -838,8 +845,8 @@ export function MessageTimeline(props: {
|
||||||
const nextSession = index === -1 ? undefined : (sessions[index + 1] ?? sessions[index - 1])
|
const nextSession = index === -1 ? undefined : (sessions[index + 1] ?? sessions[index - 1])
|
||||||
|
|
||||||
const result = await sdk()
|
const result = await sdk()
|
||||||
.client.session.delete({ sessionID })
|
.api.session.remove({ sessionID })
|
||||||
.then((x) => x.data)
|
.then(() => true)
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
showToast({
|
showToast({
|
||||||
title: language.t("session.delete.failed.title"),
|
title: language.t("session.delete.failed.title"),
|
||||||
|
|
|
||||||
|
|
@ -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 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 { reuseTimelineRows } from "./row-reconciliation"
|
||||||
import { Timeline, TimelineRow } from "./rows"
|
import { Timeline, TimelineRow } from "./rows"
|
||||||
|
|
||||||
export { reuseTimelineRows } from "./row-reconciliation"
|
export { reuseTimelineRows } from "./row-reconciliation"
|
||||||
|
|
||||||
const emptyAssistantMessages: AssistantMessage[] = []
|
|
||||||
|
|
||||||
export function createTimelineProjection(input: {
|
export function createTimelineProjection(input: {
|
||||||
messages: Accessor<Message[]>
|
messages: Accessor<Message[]>
|
||||||
userMessages: Accessor<UserMessage[]>
|
sessionMessages: Accessor<SessionMessageInfo[]>
|
||||||
parts: (messageID: string) => Part[]
|
parts: (messageID: string) => Part[]
|
||||||
status: Accessor<SessionStatus>
|
status: Accessor<SessionStatus>
|
||||||
showReasoningSummaries: Accessor<boolean>
|
showReasoningSummaries: Accessor<boolean>
|
||||||
|
|
@ -30,47 +28,19 @@ export function createTimelineProjection(input: {
|
||||||
})
|
})
|
||||||
return result
|
return result
|
||||||
})
|
})
|
||||||
const activeMessageID = createMemo(() => {
|
const projection = createMemo(() =>
|
||||||
const parentID = input
|
Timeline.constructSessionMessageRows(
|
||||||
.messages()
|
input.sessionMessages(),
|
||||||
.findLast(
|
(messageID) => messageByID().get(messageID) as UserMessage | AssistantMessage | undefined,
|
||||||
(message): message is AssistantMessage =>
|
input.parts,
|
||||||
message.role === "assistant" && typeof message.time.completed !== "number",
|
input.showReasoningSummaries(),
|
||||||
)?.parentID
|
input.status().type,
|
||||||
if (parentID) {
|
input.inlineComments(),
|
||||||
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 activeMessageID = createMemo(() => projection().activeMessageID)
|
||||||
const rows = createMemo((previous: TimelineRow.TimelineRow[] | undefined) =>
|
const rows = createMemo((previous: TimelineRow.TimelineRow[] | undefined) =>
|
||||||
reuseTimelineRows(
|
reuseTimelineRows(previous, projection().rows),
|
||||||
previous,
|
|
||||||
messageRowMemos().flatMap((memo) => memo()),
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
const rowByKey = createMemo(() => new Map(rows().map((row) => [TimelineRow.key(row), row] as const)))
|
const rowByKey = createMemo(() => new Map(rows().map((row) => [TimelineRow.key(row), row] as const)))
|
||||||
const messageRowIndex = createMemo(() => {
|
const messageRowIndex = createMemo(() => {
|
||||||
|
|
|
||||||
92
packages/app/src/pages/session/timeline/rows-current.test.ts
Normal file
92
packages/app/src/pages/session/timeline/rows-current.test.ts
Normal file
|
|
@ -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",
|
||||||
|
])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { parseCommentNote, readCommentMetadata } from "@/utils/comment-note"
|
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 { AssistantMessage, Part, SessionStatus, UserMessage } from "@opencode-ai/sdk/v2"
|
||||||
import { groupParts, renderable, type PartGroup } from "@opencode-ai/session-ui/message-part"
|
import { groupParts, renderable, type PartGroup } from "@opencode-ai/session-ui/message-part"
|
||||||
import { TimelineRow, type SummaryDiff } from "./timeline-row"
|
import { TimelineRow, type SummaryDiff } from "./timeline-row"
|
||||||
|
|
@ -31,6 +32,47 @@ export type TimelineRowMap = {
|
||||||
}
|
}
|
||||||
|
|
||||||
export namespace Timeline {
|
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(
|
export function constructMessageRows(
|
||||||
userMessage: UserMessage,
|
userMessage: UserMessage,
|
||||||
getMessageParts: (messageID: string) => Part[],
|
getMessageParts: (messageID: string) => Part[],
|
||||||
|
|
|
||||||
|
|
@ -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 { useFile, selectionFromLines, type FileSelection, type SelectedLineRange } from "@/context/file"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { useLayout } from "@/context/layout"
|
import { useLayout } from "@/context/layout"
|
||||||
import { useLocal } from "@/context/local"
|
|
||||||
import { usePermission } from "@/context/permission"
|
import { usePermission } from "@/context/permission"
|
||||||
import { usePrompt } from "@/context/prompt"
|
import { usePrompt } from "@/context/prompt"
|
||||||
import { useSDK } from "@/context/sdk"
|
import { useSDK } from "@/context/sdk"
|
||||||
|
|
@ -19,6 +18,7 @@ import { extractPromptFromParts } from "@/utils/prompt"
|
||||||
import { UserMessage } from "@opencode-ai/sdk/v2"
|
import { UserMessage } from "@opencode-ai/sdk/v2"
|
||||||
import { useSessionLayout } from "@/pages/session/session-layout"
|
import { useSessionLayout } from "@/pages/session/session-layout"
|
||||||
import { createSessionOwnership } from "./session-ownership"
|
import { createSessionOwnership } from "./session-ownership"
|
||||||
|
import { useLocal } from "@/context/local"
|
||||||
|
|
||||||
export type SessionCommandContext = {
|
export type SessionCommandContext = {
|
||||||
navigateMessageByOffset: (offset: number) => void
|
navigateMessageByOffset: (offset: number) => void
|
||||||
|
|
@ -40,7 +40,6 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const file = useFile()
|
const file = useFile()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const local = useLocal()
|
|
||||||
const permission = usePermission()
|
const permission = usePermission()
|
||||||
const prompt = usePrompt()
|
const prompt = usePrompt()
|
||||||
const sdk = useSDK()
|
const sdk = useSDK()
|
||||||
|
|
@ -48,6 +47,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
||||||
const sync = useSync()
|
const sync = useSync()
|
||||||
const terminal = useTerminal()
|
const terminal = useTerminal()
|
||||||
const layout = useLayout()
|
const layout = useLayout()
|
||||||
|
const local = useLocal()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const { params, sessionKey, tabs, view } = useSessionLayout()
|
const { params, sessionKey, tabs, view } = useSessionLayout()
|
||||||
const sessionOwnership = createSessionOwnership(sessionKey)
|
const sessionOwnership = createSessionOwnership(sessionKey)
|
||||||
|
|
@ -306,7 +306,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
||||||
const sessionID = params.id
|
const sessionID = params.id
|
||||||
if (!sessionID) return
|
if (!sessionID) return
|
||||||
const owner = sessionOwnership.capture()
|
const owner = sessionOwnership.capture()
|
||||||
const client = sdk().client
|
const session = sdk().api.session
|
||||||
const directory = sdk().directory
|
const directory = sdk().directory
|
||||||
const promptSession = prompt.capture()
|
const promptSession = prompt.capture()
|
||||||
const revert = info()?.revert?.messageID
|
const revert = info()?.revert?.messageID
|
||||||
|
|
@ -316,13 +316,13 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
||||||
const parts = sync().data.part[message.id]
|
const parts = sync().data.part[message.id]
|
||||||
|
|
||||||
if (sync().data.session_working(sessionID)) {
|
if (sync().data.session_working(sessionID)) {
|
||||||
await client.session.abort({ sessionID }).catch(() => {})
|
await session.interrupt({ sessionID }).catch(() => {})
|
||||||
}
|
}
|
||||||
|
|
||||||
await runCommand({
|
await runCommand({
|
||||||
owner,
|
owner,
|
||||||
prompt: promptSession,
|
prompt: promptSession,
|
||||||
request: () => client.session.revert({ sessionID, messageID: message.id }),
|
request: () => session.revert.stage({ sessionID, messageID: message.id }),
|
||||||
updatePrompt: (promptSession) => {
|
updatePrompt: (promptSession) => {
|
||||||
if (parts) promptSession.set(extractPromptFromParts(parts, { directory }))
|
if (parts) promptSession.set(extractPromptFromParts(parts, { directory }))
|
||||||
},
|
},
|
||||||
|
|
@ -334,7 +334,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
||||||
const sessionID = params.id
|
const sessionID = params.id
|
||||||
if (!sessionID) return
|
if (!sessionID) return
|
||||||
const owner = sessionOwnership.capture()
|
const owner = sessionOwnership.capture()
|
||||||
const client = sdk().client
|
const session = sdk().api.session
|
||||||
const messages = userMessages()
|
const messages = userMessages()
|
||||||
const promptSession = prompt.capture()
|
const promptSession = prompt.capture()
|
||||||
|
|
||||||
|
|
@ -346,7 +346,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
||||||
await runCommand({
|
await runCommand({
|
||||||
owner,
|
owner,
|
||||||
prompt: promptSession,
|
prompt: promptSession,
|
||||||
request: () => client.session.unrevert({ sessionID }),
|
request: () => session.revert.clear({ sessionID }),
|
||||||
updatePrompt: (promptSession) => promptSession.reset(),
|
updatePrompt: (promptSession) => promptSession.reset(),
|
||||||
updateViewport: () => setActiveMessage(findLast(messages, (x) => x.id >= revertMessageID)),
|
updateViewport: () => setActiveMessage(findLast(messages, (x) => x.id >= revertMessageID)),
|
||||||
})
|
})
|
||||||
|
|
@ -356,7 +356,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
||||||
await runCommand({
|
await runCommand({
|
||||||
owner,
|
owner,
|
||||||
prompt: promptSession,
|
prompt: promptSession,
|
||||||
request: () => client.session.revert({ sessionID, messageID: next.id }),
|
request: () => session.revert.stage({ sessionID, messageID: next.id }),
|
||||||
updatePrompt: () => undefined,
|
updatePrompt: () => undefined,
|
||||||
updateViewport: () => setActiveMessage(findLast(messages, (x) => x.id < next.id)),
|
updateViewport: () => setActiveMessage(findLast(messages, (x) => x.id < next.id)),
|
||||||
})
|
})
|
||||||
|
|
@ -375,10 +375,9 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
await sdk().client.session.summarize({
|
await sdk().api.session.compact({
|
||||||
sessionID,
|
sessionID,
|
||||||
modelID: model.id,
|
model: { providerID: model.provider.id, modelID: model.id },
|
||||||
providerID: model.provider.id,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,15 @@
|
||||||
import type { SnapshotFileDiff, VcsFileDiff } from "@opencode-ai/sdk/v2"
|
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 type { Kind } from "@/components/file-tree-v2"
|
||||||
import { normalizeFileTreeV2Path } from "@/components/file-tree-v2-model"
|
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) {
|
export function normalizePath(p: string) {
|
||||||
return normalizeFileTreeV2Path(p)
|
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"
|
return typeof value.file === "string"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { createMemo, createResource, createSignal, Show, type JSX } from "solid-js"
|
import { createMemo, createResource, createSignal, Show, type JSX } from "solid-js"
|
||||||
import type { SnapshotFileDiff, VcsFileDiff } from "@opencode-ai/sdk/v2"
|
import type { SnapshotFileDiff, VcsFileDiff } from "@opencode-ai/sdk/v2"
|
||||||
|
import type { FileDiffInfo } from "@opencode-ai/client/promise"
|
||||||
import {
|
import {
|
||||||
SESSION_REVIEW_V2_SIDEBAR_WIDTH_MAX,
|
SESSION_REVIEW_V2_SIDEBAR_WIDTH_MAX,
|
||||||
SESSION_REVIEW_V2_SIDEBAR_WIDTH_MIN,
|
SESSION_REVIEW_V2_SIDEBAR_WIDTH_MIN,
|
||||||
|
|
@ -30,7 +31,7 @@ import {
|
||||||
import type { ReviewPanelV2State } from "@/pages/session/v2/review-panel-v2-state"
|
import type { ReviewPanelV2State } from "@/pages/session/v2/review-panel-v2-state"
|
||||||
import { applyFileListKeyDown, SessionFileListV2 } from "@/pages/session/v2/session-file-list-v2"
|
import { applyFileListKeyDown, SessionFileListV2 } from "@/pages/session/v2/session-file-list-v2"
|
||||||
|
|
||||||
type ReviewDiff = SnapshotFileDiff | VcsFileDiff
|
type ReviewDiff = FileDiffInfo | SnapshotFileDiff | VcsFileDiff
|
||||||
|
|
||||||
export type ReviewPanelV2Props = {
|
export type ReviewPanelV2Props = {
|
||||||
title?: JSX.Element
|
title?: JSX.Element
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
import type { SnapshotFileDiff } from "@opencode-ai/sdk/v2"
|
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 type { Message } from "@opencode-ai/sdk/v2/client"
|
||||||
import { diffs, message } from "./diffs"
|
import { diffs, message } from "./diffs"
|
||||||
|
|
||||||
|
|
@ -9,7 +10,7 @@ const item = {
|
||||||
additions: 1,
|
additions: 1,
|
||||||
deletions: 1,
|
deletions: 1,
|
||||||
status: "modified",
|
status: "modified",
|
||||||
} satisfies SnapshotFileDiff
|
} satisfies FileDiffInfo & SnapshotFileDiff
|
||||||
|
|
||||||
describe("diffs", () => {
|
describe("diffs", () => {
|
||||||
test("keeps valid arrays", () => {
|
test("keeps valid arrays", () => {
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
import type { SnapshotFileDiff, VcsFileDiff } from "@opencode-ai/sdk/v2"
|
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"
|
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 {
|
function diff(value: unknown): value is Diff {
|
||||||
if (!value || typeof value !== "object" || Array.isArray(value)) return false
|
if (!value || typeof value !== "object" || Array.isArray(value)) return false
|
||||||
|
|
|
||||||
95
packages/app/src/utils/server-compat.test.ts
Normal file
95
packages/app/src/utils/server-compat.test.ts
Normal file
|
|
@ -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")
|
||||||
|
})
|
||||||
|
})
|
||||||
496
packages/app/src/utils/server-compat.ts
Normal file
496
packages/app/src/utils/server-compat.ts
Normal file
|
|
@ -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<SessionPromptOutput>
|
||||||
|
command: (input: SessionCommandInput) => Promise<SessionCommandOutput>
|
||||||
|
shell: (input: SessionShellInput & LegacyPrompt) => Promise<SessionShellOutput>
|
||||||
|
compact: (input: SessionCompactInput & { model?: LegacyPrompt["model"] }) => Promise<SessionCompactOutput>
|
||||||
|
rename: (input: Parameters<SessionApi["rename"]>[0] & LegacyLocation) => ReturnType<SessionApi["rename"]>
|
||||||
|
archive: (input: Parameters<SessionApi["archive"]>[0] & LegacyLocation) => ReturnType<SessionApi["archive"]>
|
||||||
|
remove: (input: Parameters<SessionApi["remove"]>[0] & LegacyLocation) => ReturnType<SessionApi["remove"]>
|
||||||
|
}
|
||||||
|
export type CompatibleApi = Omit<ServerApi, "session"> & { 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<ServerProtocol>
|
||||||
|
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 = <T>(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<ServerApi["session"]["list"]>[0],
|
||||||
|
options?: Parameters<ServerApi["session"]["list"]>[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<ServerApi["session"]["create"]>[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<ServerApi["session"]["get"]>[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<ServerApi["session"]["rename"]>[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<ServerApi["session"]["archive"]>[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<ServerApi["session"]["remove"]>[0] & LegacyLocation) {
|
||||||
|
if (!(await isV1())) return input.current.session.remove(value)
|
||||||
|
await legacy(value).session.delete(value)
|
||||||
|
},
|
||||||
|
async fork(value: Parameters<ServerApi["session"]["fork"]>[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<ServerApi["session"]["interrupt"]>[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<ServerApi["session"]["revert"]["stage"]>[0]) => {
|
||||||
|
if (!(await isV1())) return input.current.session.revert.stage(value)
|
||||||
|
await legacy().session.revert(value)
|
||||||
|
return { messageID: value.messageID }
|
||||||
|
},
|
||||||
|
clear: async (value: Parameters<ServerApi["session"]["revert"]["clear"]>[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<ServerApi["project"]["current"]>[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<ServerApi["project"]["update"]>[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<ServerApi["project"]["directories"]>[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<ServerApi["path"]["get"]>[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<ServerApi["vcs"]["get"]>[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<ServerApi["vcs"]["status"]>[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<ServerApi["vcs"]["diff"]>[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<ServerApi["file"]["list"]>[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<ServerApi["file"]["find"]>[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<ServerApi["integration"]["get"]>[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<ServerApi["integration"]["connect"]["key"]>[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<ServerApi["integration"]["oauth"]["connect"]>[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<ServerApi["integration"]["oauth"]["complete"]>[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<ServerApi["integration"]["oauth"]["status"]>[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<ServerApi["pty"]["shells"]>[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<ServerApi["pty"]["list"]>[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<ServerApi["pty"]["create"]>[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<ServerApi["pty"]["get"]>[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<ServerApi["pty"]["update"]>[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<ServerApi["pty"]["remove"]>[0]) {
|
||||||
|
if (!(await isV1())) return input.current.pty.remove(value)
|
||||||
|
await legacy(value.location).pty.remove({ ptyID: value.ptyID })
|
||||||
|
},
|
||||||
|
async connectToken(value: Parameters<ServerApi["pty"]["connectToken"]>[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<ServerApi["permission"]["reply"]>[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<ServerApi["question"]["reply"]>[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<ServerApi["question"]["reject"]>[0]) {
|
||||||
|
if (!(await isV1())) return input.current.question.reject(value)
|
||||||
|
await legacy().question.reject({ requestID: value.requestID })
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -14,15 +14,32 @@ function abortFromInput(input: RequestInfo | URL, init?: RequestInit) {
|
||||||
|
|
||||||
describe("checkServerHealth", () => {
|
describe("checkServerHealth", () => {
|
||||||
test("returns healthy response with version", async () => {
|
test("returns healthy response with version", async () => {
|
||||||
const fetch = (async () =>
|
let request: URL | undefined
|
||||||
new Response(JSON.stringify({ healthy: true, version: "1.2.3" }), {
|
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,
|
status: 200,
|
||||||
headers: { "content-type": "application/json" },
|
headers: { "content-type": "application/json" },
|
||||||
})) as unknown as typeof globalThis.fetch
|
})
|
||||||
|
}) as unknown as typeof globalThis.fetch
|
||||||
|
|
||||||
const result = await checkServerHealth(server, fetch)
|
const result = await checkServerHealth(server, fetch)
|
||||||
|
|
||||||
expect(result).toEqual({ healthy: true, version: "1.2.3" })
|
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 () => {
|
test("allows slow servers thirty seconds by default", async () => {
|
||||||
|
|
@ -142,7 +159,7 @@ describe("checkServerHealth", () => {
|
||||||
retryDelayMs: 1,
|
retryDelayMs: 1,
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(count).toBe(3)
|
expect(count).toBe(6)
|
||||||
expect(result).toEqual({ healthy: false })
|
expect(result).toEqual({ healthy: false })
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { usePlatform } from "@/context/platform"
|
import { usePlatform } from "@/context/platform"
|
||||||
import { ServerConnection } from "@/context/server"
|
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 { Accessor, createEffect, onCleanup } from "solid-js"
|
||||||
import { createStore, reconcile } from "solid-js/store"
|
import { createStore, reconcile } from "solid-js/store"
|
||||||
|
|
||||||
|
|
@ -61,6 +62,7 @@ function wait(ms: number, signal?: AbortSignal) {
|
||||||
|
|
||||||
function retryable(error: unknown, signal?: AbortSignal) {
|
function retryable(error: unknown, signal?: AbortSignal) {
|
||||||
if (signal?.aborted) return false
|
if (signal?.aborted) return false
|
||||||
|
if (error instanceof ClientError) return error.reason === "Transport"
|
||||||
if (!(error instanceof Error)) return false
|
if (!(error instanceof Error)) return false
|
||||||
if (error.name === "AbortError" || error.name === "TimeoutError") return false
|
if (error.name === "AbortError" || error.name === "TimeoutError") return false
|
||||||
if (error instanceof TypeError) return true
|
if (error instanceof TypeError) return true
|
||||||
|
|
@ -82,15 +84,27 @@ export async function checkServerHealth(
|
||||||
.then(() => attempt(count + 1))
|
.then(() => attempt(count + 1))
|
||||||
.catch(() => ({ healthy: false }))
|
.catch(() => ({ healthy: false }))
|
||||||
}
|
}
|
||||||
const attempt = (count: number): Promise<ServerHealth> =>
|
const attempt = async (count: number): Promise<ServerHealth> => {
|
||||||
createSdkForServer({
|
const current = await OpenCode.make({
|
||||||
server,
|
baseUrl: server.url,
|
||||||
fetch,
|
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()
|
.global.health()
|
||||||
.then((x) => (x.error ? next(count, x.error) : { healthy: x.data?.healthy === true, version: x.data?.version }))
|
.then((x) => (x.error ? next(count, x.error) : { healthy: x.data?.healthy === true, version: x.data?.version }))
|
||||||
.catch((error) => next(count, error))
|
.catch((error) => next(count, error))
|
||||||
|
}
|
||||||
return attempt(0).finally(() => timeout?.clear?.())
|
return attempt(0).finally(() => timeout?.clear?.())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
40
packages/app/src/utils/server-protocol.test.ts
Normal file
40
packages/app/src/utils/server-protocol.test.ts
Normal file
|
|
@ -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<Response>) =>
|
||||||
|
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")
|
||||||
|
})
|
||||||
|
})
|
||||||
35
packages/app/src/utils/server-protocol.ts
Normal file
35
packages/app/src/utils/server-protocol.ts
Normal file
|
|
@ -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<ServerProtocol> {
|
||||||
|
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"
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,23 @@
|
||||||
import { createOpencodeClient } from "@opencode-ai/sdk/v2/client"
|
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 type { ServerConnection } from "@/context/server"
|
||||||
import { decode64 } from "@/utils/base64"
|
import { decode64 } from "@/utils/base64"
|
||||||
|
|
||||||
|
|
@ -39,3 +58,39 @@ export function createSdkForServer({
|
||||||
baseUrl: server.url,
|
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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
|
||||||
200
packages/app/src/utils/session-message.test.ts
Normal file
200
packages/app/src/utils/session-message.test.ts
Normal file
|
|
@ -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,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
})
|
||||||
|
})
|
||||||
348
packages/app/src/utils/session-message.ts
Normal file
348
packages/app/src/utils/session-message.ts
Normal file
|
|
@ -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<string, unknown> {
|
||||||
|
return !!value && typeof value === "object" && !Array.isArray(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeToolInput(name: string, input: Record<string, unknown>) {
|
||||||
|
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<string, unknown>) {
|
||||||
|
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<string, Part[]>()
|
||||||
|
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 },
|
||||||
|
}
|
||||||
|
}
|
||||||
94
packages/app/src/utils/session.test.ts
Normal file
94
packages/app/src/utils/session.test.ts
Normal file
|
|
@ -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<string | undefined, { data: SessionInfo[]; cursor: { next?: string } }>([
|
||||||
|
[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<SessionApi, "list">
|
||||||
|
|
||||||
|
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<string | undefined> = []
|
||||||
|
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<SessionApi, "list">
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
37
packages/app/src/utils/session.ts
Normal file
37
packages/app/src/utils/session.ts
Normal file
|
|
@ -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<SessionApi, "list">, input: Omit<SessionListInput, "cursor">) {
|
||||||
|
const load = async (cursor?: string): Promise<Session[]> => {
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
|
@ -2,51 +2,22 @@ import { describe, expect, test } from "bun:test"
|
||||||
import { terminalWebSocketURL } from "./terminal-websocket-url"
|
import { terminalWebSocketURL } from "./terminal-websocket-url"
|
||||||
|
|
||||||
describe("terminalWebSocketURL", () => {
|
describe("terminalWebSocketURL", () => {
|
||||||
test("uses query auth without embedding credentials in websocket URL", () => {
|
test("uses the current ticketed PTY route", () => {
|
||||||
const url = terminalWebSocketURL({
|
const url = terminalWebSocketURL({
|
||||||
url: "http://127.0.0.1:49365",
|
url: "http://127.0.0.1:49365",
|
||||||
id: "pty_test",
|
id: "pty_test",
|
||||||
directory: "/tmp/project",
|
directory: "/tmp/project",
|
||||||
cursor: 0,
|
cursor: 0,
|
||||||
sameOrigin: false,
|
ticket: "connect-ticket",
|
||||||
username: "opencode",
|
|
||||||
password: "secret",
|
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(url.protocol).toBe("ws:")
|
expect(url.protocol).toBe("ws:")
|
||||||
expect(url.username).toBe("")
|
expect(url.username).toBe("")
|
||||||
expect(url.password).toBe("")
|
expect(url.password).toBe("")
|
||||||
expect(url.searchParams.get("auth_token")).toBe(btoa("opencode:secret"))
|
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")
|
||||||
test("omits query auth for same-origin saved credentials", () => {
|
expect(url.searchParams.get("ticket")).toBe("connect-ticket")
|
||||||
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.searchParams.has("auth_token")).toBe(false)
|
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"))
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -1,28 +1,14 @@
|
||||||
import { authTokenFromCredentials } from "@/utils/server"
|
|
||||||
|
|
||||||
export function terminalWebSocketURL(input: {
|
export function terminalWebSocketURL(input: {
|
||||||
url: string
|
url: string
|
||||||
id: string
|
id: string
|
||||||
directory: string
|
directory: string
|
||||||
cursor: number
|
cursor: number
|
||||||
ticket?: string
|
ticket: string
|
||||||
sameOrigin?: boolean
|
|
||||||
username?: string
|
|
||||||
password?: string
|
|
||||||
authToken?: boolean
|
|
||||||
}) {
|
}) {
|
||||||
const next = new URL(`${input.url}/pty/${input.id}/connect`)
|
const next = new URL(`${input.url}/api/pty/${input.id}/connect`)
|
||||||
next.searchParams.set("directory", input.directory)
|
next.searchParams.set("location[directory]", input.directory)
|
||||||
next.searchParams.set("cursor", String(input.cursor))
|
next.searchParams.set("cursor", String(input.cursor))
|
||||||
next.protocol = next.protocol === "https:" ? "wss:" : "ws:"
|
next.protocol = next.protocol === "https:" ? "wss:" : "ws:"
|
||||||
if (input.ticket) {
|
next.searchParams.set("ticket", 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 }),
|
|
||||||
)
|
|
||||||
return next
|
return next
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue