Compare commits

..

18 Commits

Author SHA1 Message Date
Kit Langton
6b5dff1779 refactor(session): type not found errors 2026-05-05 00:28:54 -04:00
opencode-agent[bot]
2d0a757eb2 chore: generate 2026-05-05 02:37:07 +00:00
Kit Langton
75d141b574 fix(session): cancel subtask child sessions (#25798) 2026-05-04 22:36:06 -04:00
Dax
39c88f9afb Improve v2 session message rendering (#25634) 2026-05-05 02:35:21 +00:00
Dax Raad
0df2bb0f3b docs: restore v2 todo 2026-05-04 22:22:39 -04:00
Brendan Allan
f6a3615f59 fix(console): remove Cloudflare cache config from download fetch (#25804) 2026-05-05 10:15:00 +08:00
James Long
edd480f56b fix(tui): fix type error for calling workspace.warp (#25801) 2026-05-04 22:06:33 -04:00
Luke Parker
2740d398fa devex: Enable Electron MCP servers with DevTools debug port (#25795) 2026-05-05 11:37:18 +10:00
opencode-agent[bot]
f33b17e8ac chore: generate 2026-05-05 01:29:49 +00:00
James Long
22a4a9df8b feat(core): session warping (#25768) 2026-05-04 21:28:38 -04:00
Brendan Allan
84afd2bef8 update: normalize download asset names to match new naming convention (#25796) 2026-05-05 09:19:13 +08:00
Luke Parker
ca2411d332 Run UI unit tests in CI (#25792) 2026-05-05 11:05:53 +10:00
opencode
6b852774e1 sync release versions for v1.14.35 2026-05-05 01:01:47 +00:00
opencode-agent[bot]
f14784d531 chore: generate 2026-05-05 00:35:18 +00:00
Luke Parker
6a5e329427 fix(vcs): preserve batched patch boundaries (#25787) 2026-05-05 00:34:06 +00:00
opencode
4b65b1e053 sync release versions for v1.14.34 2026-05-04 23:26:02 +00:00
Aiden Cline
d431a0e4b4 fix: ensure effect server middleware properly parses errors (#25717) 2026-05-04 18:29:00 -04:00
Frank
5720883d5d sync 2026-05-04 15:51:29 -04:00
84 changed files with 4567 additions and 1942 deletions

View File

@@ -29,7 +29,7 @@
},
"packages/app": {
"name": "@opencode-ai/app",
"version": "1.14.33",
"version": "1.14.35",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/core": "workspace:*",
@@ -85,7 +85,7 @@
},
"packages/console/app": {
"name": "@opencode-ai/console-app",
"version": "1.14.33",
"version": "1.14.35",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@ibm/plex": "6.4.1",
@@ -119,7 +119,7 @@
},
"packages/console/core": {
"name": "@opencode-ai/console-core",
"version": "1.14.33",
"version": "1.14.35",
"dependencies": {
"@aws-sdk/client-sts": "3.782.0",
"@jsx-email/render": "1.1.1",
@@ -146,7 +146,7 @@
},
"packages/console/function": {
"name": "@opencode-ai/console-function",
"version": "1.14.33",
"version": "1.14.35",
"dependencies": {
"@ai-sdk/anthropic": "3.0.64",
"@ai-sdk/openai": "3.0.48",
@@ -170,7 +170,7 @@
},
"packages/console/mail": {
"name": "@opencode-ai/console-mail",
"version": "1.14.33",
"version": "1.14.35",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
@@ -194,7 +194,7 @@
},
"packages/core": {
"name": "@opencode-ai/core",
"version": "1.14.33",
"version": "1.14.35",
"bin": {
"opencode": "./bin/opencode",
},
@@ -228,7 +228,7 @@
},
"packages/desktop": {
"name": "@opencode-ai/desktop",
"version": "1.14.33",
"version": "1.14.35",
"dependencies": {
"@opencode-ai/app": "workspace:*",
"@opencode-ai/ui": "workspace:*",
@@ -263,7 +263,7 @@
},
"packages/desktop-electron": {
"name": "@opencode-ai/desktop-electron",
"version": "1.14.33",
"version": "1.14.35",
"dependencies": {
"drizzle-orm": "catalog:",
"effect": "catalog:",
@@ -309,7 +309,7 @@
},
"packages/enterprise": {
"name": "@opencode-ai/enterprise",
"version": "1.14.33",
"version": "1.14.35",
"dependencies": {
"@opencode-ai/core": "workspace:*",
"@opencode-ai/ui": "workspace:*",
@@ -338,7 +338,7 @@
},
"packages/function": {
"name": "@opencode-ai/function",
"version": "1.14.33",
"version": "1.14.35",
"dependencies": {
"@octokit/auth-app": "8.0.1",
"@octokit/rest": "catalog:",
@@ -354,14 +354,14 @@
},
"packages/opencode": {
"name": "opencode",
"version": "1.14.33",
"version": "1.14.35",
"bin": {
"opencode": "./bin/opencode",
},
"dependencies": {
"@actions/core": "1.11.1",
"@actions/github": "6.0.1",
"@agentclientprotocol/sdk": "0.21.0",
"@agentclientprotocol/sdk": "0.16.1",
"@ai-sdk/alibaba": "1.0.17",
"@ai-sdk/amazon-bedrock": "4.0.96",
"@ai-sdk/anthropic": "3.0.71",
@@ -496,7 +496,7 @@
},
"packages/plugin": {
"name": "@opencode-ai/plugin",
"version": "1.14.33",
"version": "1.14.35",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"effect": "catalog:",
@@ -531,7 +531,7 @@
},
"packages/sdk/js": {
"name": "@opencode-ai/sdk",
"version": "1.14.33",
"version": "1.14.35",
"dependencies": {
"cross-spawn": "catalog:",
},
@@ -546,7 +546,7 @@
},
"packages/slack": {
"name": "@opencode-ai/slack",
"version": "1.14.33",
"version": "1.14.35",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"@slack/bolt": "^3.17.1",
@@ -581,7 +581,7 @@
},
"packages/ui": {
"name": "@opencode-ai/ui",
"version": "1.14.33",
"version": "1.14.35",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/core": "workspace:*",
@@ -630,7 +630,7 @@
},
"packages/web": {
"name": "@opencode-ai/web",
"version": "1.14.33",
"version": "1.14.35",
"dependencies": {
"@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1",
@@ -754,7 +754,7 @@
"@adobe/css-tools": ["@adobe/css-tools@4.4.4", "", {}, "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg=="],
"@agentclientprotocol/sdk": ["@agentclientprotocol/sdk@0.21.0", "", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-ONj+Q8qOdNQp5XbH5jnMwzT9IKZJsSN0p0lkceS4GtUtNOPVLpNzSS8gqQdGMKfBvA0ESbkL8BTaSN1Rc9miEw=="],
"@agentclientprotocol/sdk": ["@agentclientprotocol/sdk@0.16.1", "", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-1ad+Sc/0sCtZGHthxxvgEUo5Wsbw16I+aF+YwdiLnPwkZG8KAGUEAPK6LM6Pf69lCyJPt1Aomk1d+8oE3C4ZEw=="],
"@ai-sdk/alibaba": ["@ai-sdk/alibaba@1.0.17", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.41", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ZbE+U5bWz2JBc5DERLowx5+TKbjGBE93LqKZAWvuEn7HOSQMraxFMZuc0ST335QZJAyfBOzh7m1mPQ+y7EaaoA=="],

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/app",
"version": "1.14.33",
"version": "1.14.35",
"description": "",
"type": "module",
"exports": {

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-app",
"version": "1.14.33",
"version": "1.14.35",
"type": "module",
"license": "MIT",
"scripts": {

View File

@@ -2,11 +2,11 @@ import type { APIEvent } from "@solidjs/start"
import type { DownloadPlatform } from "../types"
const prodAssetNames: Record<string, string> = {
"darwin-aarch64-dmg": "opencode-desktop-darwin-aarch64.dmg",
"darwin-x64-dmg": "opencode-desktop-darwin-x64.dmg",
"windows-x64-nsis": "opencode-desktop-windows-x64.exe",
"darwin-aarch64-dmg": "opencode-desktop-mac-arm64.dmg",
"darwin-x64-dmg": "opencode-desktop-mac-x64.dmg",
"windows-x64-nsis": "opencode-desktop-win-x64.exe",
"linux-x64-deb": "opencode-desktop-linux-amd64.deb",
"linux-x64-appimage": "opencode-desktop-linux-amd64.AppImage",
"linux-x64-appimage": "opencode-desktop-linux-x86_64.AppImage",
"linux-x64-rpm": "opencode-desktop-linux-x86_64.rpm",
} satisfies Record<DownloadPlatform, string>
@@ -32,13 +32,6 @@ export async function GET({ params: { platform, channel } }: APIEvent) {
const resp = await fetch(
`https://github.com/anomalyco/${channel === "stable" ? "opencode" : "opencode-beta"}/releases/latest/download/${assetName}`,
{
cf: {
// in case gh releases has rate limits
cacheTtl: 60 * 5,
cacheEverything: true,
},
} as any,
)
const downloadName = downloadNames[platform]

View File

@@ -919,6 +919,13 @@ export async function handler(
"tokens.cache_read": cacheReadTokens,
"tokens.cache_write_5m": cacheWrite5mTokens,
"tokens.cache_write_1h": cacheWrite1hTokens,
"cost.input.microcents": centsToMicroCents(inputCost),
"cost.output.microcents": centsToMicroCents(outputCost),
"cost.reasoning.microcents": reasoningCost ? centsToMicroCents(reasoningCost) : undefined,
"cost.cache_read.microcents": cacheReadCost ? centsToMicroCents(cacheReadCost) : undefined,
"cost.cache_write.microcents": cacheWrite5mCost ? centsToMicroCents(cacheWrite5mCost) : undefined,
"cost.total.microcents": centsToMicroCents(totalCostInCent),
// deprecated - remove after May 20, 2026
"cost.input": Math.round(inputCost),
"cost.output": Math.round(outputCost),
"cost.reasoning": reasoningCost ? Math.round(reasoningCost) : undefined,

View File

@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/console-core",
"version": "1.14.33",
"version": "1.14.35",
"private": true,
"type": "module",
"license": "MIT",

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-function",
"version": "1.14.33",
"version": "1.14.35",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-mail",
"version": "1.14.33",
"version": "1.14.35",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",

View File

@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.14.33",
"version": "1.14.35",
"name": "@opencode-ai/core",
"type": "module",
"license": "MIT",

View File

@@ -71,6 +71,8 @@ export const layer = Layer.effect(
Effect.sync(() => Service.of(make())),
)
export const defaultLayer = layer
export const layerWith = (input: Partial<Interface>) =>
Layer.effect(
Service,

View File

@@ -1,7 +1,7 @@
{
"name": "@opencode-ai/desktop-electron",
"private": true,
"version": "1.14.33",
"version": "1.14.35",
"type": "module",
"license": "MIT",
"homepage": "https://opencode.ai",

View File

@@ -74,6 +74,7 @@ setupApp()
function setupApp() {
ensureLoopbackNoProxy()
app.commandLine.appendSwitch("proxy-bypass-list", "<-loopback>")
if (!app.isPackaged) app.commandLine.appendSwitch("remote-debugging-port", "9222")
if (!app.requestSingleInstanceLock()) {
app.quit()

View File

@@ -1,7 +1,7 @@
{
"name": "@opencode-ai/desktop",
"private": true,
"version": "1.14.33",
"version": "1.14.35",
"type": "module",
"license": "MIT",
"scripts": {

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/enterprise",
"version": "1.14.33",
"version": "1.14.35",
"private": true,
"type": "module",
"license": "MIT",

View File

@@ -1,7 +1,7 @@
id = "opencode"
name = "OpenCode"
description = "The open source coding agent."
version = "1.14.33"
version = "1.14.35"
schema_version = 1
authors = ["Anomaly"]
repository = "https://github.com/anomalyco/opencode"
@@ -11,26 +11,26 @@ name = "OpenCode"
icon = "./icons/opencode.svg"
[agent_servers.opencode.targets.darwin-aarch64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.33/opencode-darwin-arm64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.35/opencode-darwin-arm64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.darwin-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.33/opencode-darwin-x64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.35/opencode-darwin-x64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.linux-aarch64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.33/opencode-linux-arm64.tar.gz"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.35/opencode-linux-arm64.tar.gz"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.linux-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.33/opencode-linux-x64.tar.gz"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.35/opencode-linux-x64.tar.gz"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.windows-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.33/opencode-windows-x64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.35/opencode-windows-x64.zip"
cmd = "./opencode.exe"
args = ["acp"]

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/function",
"version": "1.14.33",
"version": "1.14.35",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",

View File

@@ -0,0 +1 @@
ALTER TABLE `event_sequence` ADD `owner_id` text;

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.14.33",
"version": "1.14.35",
"name": "opencode",
"type": "module",
"license": "MIT",
@@ -80,7 +80,7 @@
"dependencies": {
"@actions/core": "1.11.1",
"@actions/github": "6.0.1",
"@agentclientprotocol/sdk": "0.21.0",
"@agentclientprotocol/sdk": "0.16.1",
"@ai-sdk/alibaba": "1.0.17",
"@ai-sdk/amazon-bedrock": "4.0.96",
"@ai-sdk/anthropic": "3.0.71",

View File

@@ -776,9 +776,9 @@ const scenarios: Scenario[] = [
}))
.status(200),
http
.post("/experimental/workspace/{id}/session-restore", "experimental.workspace.sessionRestore")
.post("/experimental/workspace/warp", "experimental.workspace.warp")
.at((ctx) => ({
path: route("/experimental/workspace/{id}/session-restore", { id: "wrk_httpapi_missing" }),
path: "/experimental/workspace/warp",
headers: ctx.headers(),
body: {},
}))

View File

@@ -0,0 +1,329 @@
# Typed error migration
Plan for moving `packages/opencode` from temporary defect/`NamedError`
compatibility toward typed Effect service errors and explicit HTTP error
contracts.
## Goal
- Expected service failures live on the Effect error channel.
- Service interfaces expose those failures in their return types.
- Domain errors are authored with Effect Schema so they are reusable by services,
tests, HTTP routes, tools, and OpenAPI generation.
- HTTP status codes and wire compatibility are handled at the HTTP boundary, not
inside service modules.
- `Effect.die`, `throw`, `catchDefect`, and global cause inspection are reserved
for defects, compatibility bridges, or final fallback behavior.
## Current State
- Many migrated services use Effect internally, but expected failures are still a
mix of `NamedError.create(...)`, `namedSchemaError(...)`, `class extends Error`,
`throw`, and `Effect.die(...)`.
- Some services already use `Schema.TaggedErrorClass`, for example `Account`,
`Auth`, `Permission`, `Question`, `Installation`, and parts of
`Workspace`.
- Legacy Hono error handling recognizes `NamedError`, `Session.BusyError`, and a
few name-based cases, then emits the legacy `{ name, data }` JSON body.
- Effect `HttpApi` only knows how to encode errors that are declared on the
endpoint, group, or middleware. Undeclared expected errors become defects and
eventually fall through to generic HTTP handling.
- The temporary HttpApi error middleware catches defect-wrapped legacy errors to
preserve runtime behavior, but it is intentionally a bridge rather than the
final model.
## End State
Service modules own domain failures.
```ts
export class SessionBusyError extends Schema.TaggedErrorClass<SessionBusyError>()("SessionBusyError", {
sessionID: SessionID,
message: Schema.String,
}) {}
export type Error = Storage.Error | SessionBusyError
export interface Interface {
readonly get: (id: SessionID) => Effect.Effect<Info, Error>
}
```
HTTP modules own transport mapping.
```ts
const get = Effect.fn("SessionHttpApi.get")(function* (ctx: { params: { sessionID: SessionID } }) {
return yield* session
.get(ctx.params.sessionID)
.pipe(
Effect.catchTag("StorageNotFoundError", () => new SessionNotFoundHttpError({ sessionID: ctx.params.sessionID })),
)
})
```
HTTP-visible error schemas carry their own response status through Effect
HttpApi's `httpApiStatus` annotation. Prefer `HttpApiSchema.status(...)`, or the
equivalent declaration annotation, instead of maintaining a parallel status map.
```ts
export class SessionNotFoundHttpError extends Schema.TaggedErrorClass<SessionNotFoundHttpError>()(
"SessionNotFoundHttpError",
{
sessionID: SessionID,
message: Schema.String,
},
{ httpApiStatus: 404 },
) {}
```
Endpoint definitions still declare which HTTP-visible error schemas can be
emitted. The status annotation is only used if the error is part of the endpoint,
group, or middleware error schema and the handler fails with that error on the
typed error channel.
```ts
HttpApiEndpoint.get("get", SessionPaths.get, {
success: Session.Info,
error: [SessionNotFoundHttpError, SessionBusyHttpError],
})
```
The service error and HTTP error may be the same class when the wire shape is a
deliberate public contract. They should be different classes when the service
error contains internals, low-level causes, retry hints, or anything that should
not be exposed to API clients.
## Rules
- Use `Schema.TaggedErrorClass` for new expected domain errors.
- Include `cause: Schema.optional(Schema.Defect)` only when preserving an
underlying unknown failure is useful for logs or callers.
- Export a domain-level error union from each service module, for example
`export type Error = NotFoundError | BusyError | Storage.Error`.
- Put expected errors in service method signatures, for example
`Effect.Effect<Result, Service.Error, R>`.
- Use `yield* new DomainError(...)` for direct early failures inside
`Effect.gen` / `Effect.fn`.
- Use `Effect.try({ try, catch })`, `Effect.mapError`, or `Effect.catchTag` to
convert external exceptions into domain errors.
- Use `HttpApiSchema.status(...)` or `{ httpApiStatus: code }` on HTTP-visible
error schemas so Effect `HttpApiBuilder` and OpenAPI generation get the status
from the schema itself.
- Do not use `Effect.die(...)` for user, IO, validation, missing-resource, auth,
provider, worktree, or busy-state failures.
- Do not use `catchDefect` to recover expected domain errors. If recovery is
needed, the upstream effect should fail with a typed error instead.
- Do not make service modules import `HttpApiError`, `HttpServerResponse`, HTTP
status codes, or route-specific error schemas.
- Keep raw `HttpRouter` routes free to use `HttpServerRespondable` when that is
the right transport abstraction, but prefer declared `HttpApi` errors for
normal JSON API endpoints.
## HTTP Boundary Shape
Create an HttpApi-local error module, likely
`src/server/routes/instance/httpapi/errors.ts`.
That module should provide:
- Legacy-compatible public schemas for `{ name, data }` error bodies that must
remain SDK-compatible during the Hono migration.
- Small constructors or mapping helpers for common API errors such as not found,
bad request, conflict, and unknown internal errors.
- Route-group-specific adapters only when they encode domain-specific public
data.
- A single place to document which public error shape is legacy-compatible and
which shape is new Effect-native API surface.
Avoid one giant `unknown -> status` mapper. Prefer small, explicit mappers close
to the handler or route group.
```ts
const mapSessionError = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
effect.pipe(
Effect.catchTag("StorageNotFoundError", (error) => new SessionNotFoundHttpError({ message: error.message })),
Effect.catchTag("SessionBusyError", (error) => new SessionBusyHttpError({ message: error.message })),
)
```
Use built-in `HttpApiError.BadRequest`, `HttpApiError.NotFound`, and related
types only when their generated response body and SDK surface are intentionally
acceptable. Use a custom schema-backed error when clients need the legacy
`{ name, data }` body or a domain-specific error payload.
## Migration Phases
### 1. Stabilize The Bridge
Keep the temporary HttpApi error middleware only as a compatibility bridge while
typed errors are introduced.
- Add tests that prove the bridge catches legacy `NamedError` defects.
- Add tests that prove declared HttpApi errors still use the declared endpoint
contract.
- Stop returning stack traces in unknown HTTP `500` responses; log the full
`Cause.pretty(cause)` server-side instead.
- Add a comment or TODO that names this plan and states the bridge must shrink
as route groups migrate.
### 2. Define The Shared HTTP Error Helpers
Add the `httpapi/errors.ts` module before converting route groups.
- Define a legacy `{ name, data }` body helper for SDK-compatible errors.
- Define `UnknownError` for generic internal failures with a safe public message.
- Define `BadRequestError` and `NotFoundError` equivalents only if the actual
wire body must match the legacy Hono SDK surface.
- Put the HTTP status on the public schema with `HttpApiSchema.status(...)` or
`{ httpApiStatus: code }`; do not keep a separate name-to-status table.
- Keep conversion helpers pure and small. They should not inspect `Cause` or
accept `unknown` unless they are final fallback helpers.
### 3. Convert One Vertical Slice
Start with session read routes because they already have local `mapNotFound`
logic and are heavily covered by existing HttpApi tests.
- Convert `Session.BusyError` from a plain `Error` to a typed service error, or
add a typed wrapper while preserving the old constructor until callers are
migrated.
- Replace `catchDefect` in `httpapi/handlers/session.ts` with typed error
mapping.
- Add endpoint error schemas for the affected session endpoints.
- Prove behavior with focused tests in `test/server/httpapi-session.test.ts`.
- Remove the migrated cases from the global compatibility middleware.
### 4. Convert Legacy NamedError Domains
Move legacy `NamedError.create(...)` services to Effect Schema-backed errors in
small domain PRs.
Priority order:
1. `storage/storage.ts` and `storage/db.ts` not-found errors.
2. `worktree/index.ts` `Worktree*` errors.
3. `provider/auth.ts` validation failures and `provider/provider.ts` model-not-found errors.
4. `mcp/index.ts`, `skill/index.ts`, `lsp/client.ts`, and `ide/index.ts` service errors.
5. Config and CLI-only errors after HTTP-facing domains are stable.
For each domain:
- Replace `NamedError.create(...)` with `Schema.TaggedErrorClass` when the error
is primarily a service error.
- Keep or add a separate HTTP error schema when the legacy `{ name, data }` wire
shape must remain stable.
- Update service interface return types to include the new error union.
- Replace `throw new X(...)` inside `Effect.fn` with `yield* new X(...)`.
- Replace async exceptions with `Effect.try({ catch })` or explicit `mapError`.
- Add service-level tests that assert the error tag and data, not just the HTTP
status.
### 5. Declare HttpApi Errors Group By Group
For each HttpApi group:
- Inventory every service call and the typed errors it can return.
- Add only the public error schemas that endpoint can actually emit.
- Map service errors to HTTP errors in the handler file.
- Keep built-in `HttpApiError` only for generic request/validation failures where
the generated contract is accepted.
- Update `httpapi/public.ts` compatibility transforms only when the generated
spec cannot represent the desired source shape directly.
- Regenerate the SDK after OpenAPI-visible changes and verify the diff is
intentional.
Suggested route order:
1. `session` not-found and busy-state reads.
2. `experimental` worktree mutations.
3. `provider` auth and model selection errors.
4. `mcp` OAuth and connection errors.
5. Remaining route groups as Hono deletion work progresses.
### 6. Remove Defect Recovery
After enough route groups declare their expected errors:
- Delete `catchDefect` recovery for domain errors.
- Delete name-prefix checks such as `error.name.startsWith("Worktree")` from
HTTP middleware.
- Delete `NamedError` branches from the Effect HttpApi compatibility middleware
once no Effect route depends on them.
- Leave one final unknown-defect fallback that logs server-side and returns a
safe generic `500` body.
## Inventory Checklist
Use this checklist when touching a service or route group.
- [ ] Does the service interface expose every expected failure in the Effect
error type?
- [ ] Are user-caused, provider-caused, IO, auth, missing-resource, and busy-state
failures modeled as typed errors instead of defects?
- [ ] Does the service avoid importing HTTP status, `HttpApiError`, or response
classes?
- [ ] Does the handler map each service error into a declared endpoint error?
- [ ] Does the endpoint `error` field include every public error the handler can
emit?
- [ ] Does OpenAPI/SDK output either stay byte-identical or have an explicitly
reviewed diff?
- [ ] Do tests cover both service-level error typing and HTTP-level status/body?
- [ ] Did the PR remove any now-unneeded case from the temporary compatibility
middleware?
## Testing Requirements
For service conversions:
- Test the service method directly with `testEffect(...)`.
- Assert on `_tag` or class identity and the structured fields.
- Avoid testing by string-matching `Cause.pretty(...)`.
For HttpApi conversions:
- Add or update the focused `test/server/httpapi-*.test.ts` file.
- Assert status code, content type, and exact JSON body for declared public
errors.
- Add a regression test that the temporary middleware is no longer needed for the
migrated route.
- Keep bridge/parity tests aligned with legacy Hono behavior until Hono is
deleted or the SDK contract intentionally changes.
## Verification Commands
Run from `packages/opencode` unless noted otherwise.
```bash
bun run prettier --write <changed files>
bunx oxlint <changed files>
bun typecheck
bun run test -- test/server/httpapi-session.test.ts
```
Run SDK generation from the repo root when schemas or OpenAPI-visible errors
change.
```bash
./packages/sdk/js/script/build.ts
```
## Open Questions
- Should legacy V1 routes keep `{ name, data }` forever while V2 routes expose a
more Effect-native tagged error body?
- Should storage not-found remain generic, or should callers map it to
domain-specific not-found errors before crossing service boundaries?
- Should `namedSchemaError(...)` stay as a long-term public-wire helper, or only
as a migration bridge for old `NamedError` contracts?
- Which SDK version boundary lets us stop remapping built-in Effect HttpApi error
schemas in `httpapi/public.ts`?
## Success Criteria
- New service code no longer uses `die` for expected failures.
- A route reviewer can read an endpoint definition and see every public error it
can return.
- The temporary HttpApi error middleware shrinks over time instead of gaining new
name-based cases.
- Service tests prove domain error types without going through HTTP.
- HTTP tests prove status/body contracts without relying on defect recovery.

View File

@@ -5,8 +5,6 @@ import {
type AuthenticateRequest,
type AuthMethod,
type CancelNotification,
type CloseSessionRequest,
type CloseSessionResponse,
type ForkSessionRequest,
type ForkSessionResponse,
type InitializeRequest,
@@ -567,7 +565,6 @@ export class Agent implements ACPAgent {
image: true,
},
sessionCapabilities: {
close: {},
fork: {},
list: {},
resume: {},
@@ -800,7 +797,7 @@ export class Agent implements ACPAgent {
}
}
async resumeSession(params: ResumeSessionRequest): Promise<ResumeSessionResponse> {
async unstable_resumeSession(params: ResumeSessionRequest): Promise<ResumeSessionResponse> {
const directory = params.cwd
const sessionId = params.sessionId
const mcpServers = params.mcpServers ?? []
@@ -831,27 +828,6 @@ export class Agent implements ACPAgent {
}
}
async closeSession(params: CloseSessionRequest): Promise<CloseSessionResponse> {
const session = this.sessionManager.remove(params.sessionId)
if (!session) return {}
await this.sdk.session
.abort(
{
sessionID: params.sessionId,
directory: session.cwd,
},
{ throwOnError: true },
)
.catch((error) => {
log.error("failed to abort session while closing ACP session", { error, sessionID: params.sessionId })
})
this.permissionQueues.delete(params.sessionId)
log.info("close_session", { sessionId: params.sessionId })
return {}
}
private async processMessage(message: SessionMessageResponse) {
log.debug("process message", message)
if (message.info.role !== "assistant" && message.info.role !== "user") return
@@ -1208,7 +1184,7 @@ export class Agent implements ACPAgent {
if (currentVariant && !availableVariants.includes(currentVariant)) {
this.sessionManager.setVariant(sessionId, undefined)
}
const availableModels = buildAvailableModels(entries)
const availableModels = buildAvailableModels(entries, { includeVariants: true })
const modeState = await this.resolveModeState(directory, sessionId)
const currentModeId = modeState.currentModeId
const modes = currentModeId
@@ -1291,15 +1267,13 @@ export class Agent implements ACPAgent {
return {
sessionId,
models: {
currentModelId: formatModelIdWithVariant(model, currentVariant, availableVariants, false),
currentModelId: formatModelIdWithVariant(model, currentVariant, availableVariants, true),
availableModels,
},
modes,
configOptions: buildConfigOptions({
currentModelId: formatModelIdWithVariant(model, currentVariant, availableVariants, false),
currentModelId: formatModelIdWithVariant(model, currentVariant, availableVariants, true),
availableModels,
currentVariant,
availableVariants,
modes,
}),
_meta: buildVariantMeta({
@@ -1322,24 +1296,6 @@ export class Agent implements ACPAgent {
const entries = sortProvidersByName(providers)
const availableVariants = modelVariantsFromProviders(entries, selection.model)
const modeState = await this.resolveModeState(session.cwd, session.id)
const modes = modeState.currentModeId
? { availableModes: modeState.availableModes, currentModeId: modeState.currentModeId }
: undefined
await this.connection.sessionUpdate({
sessionId: session.id,
update: {
sessionUpdate: "config_option_update",
configOptions: buildConfigOptions({
currentModelId: formatModelIdWithVariant(selection.model, selection.variant, availableVariants, false),
availableModels: buildAvailableModels(entries),
currentVariant: selection.variant,
availableVariants,
modes,
}),
},
})
return {
_meta: buildVariantMeta({
@@ -1371,14 +1327,6 @@ export class Agent implements ACPAgent {
const selection = parseModelSelection(params.value, providers)
this.sessionManager.setModel(session.id, selection.model)
this.sessionManager.setVariant(session.id, selection.variant)
} else if (params.configId === "effort") {
if (typeof params.value !== "string") throw RequestError.invalidParams("effort value must be a string")
const current = session.model ?? (await defaultModel(this.config, session.cwd))
const availableVariants = modelVariantsFromProviders(entries, current)
if (!availableVariants.includes(params.value)) {
throw RequestError.invalidParams(JSON.stringify({ error: `Effort not found: ${params.value}` }))
}
this.sessionManager.setVariant(session.id, params.value)
} else if (params.configId === "mode") {
if (typeof params.value !== "string") throw RequestError.invalidParams("mode value must be a string")
const availableModes = await this.loadAvailableModes(session.cwd)
@@ -1393,21 +1341,15 @@ export class Agent implements ACPAgent {
const updatedSession = this.sessionManager.get(session.id)
const model = updatedSession.model ?? (await defaultModel(this.config, session.cwd))
const availableVariants = modelVariantsFromProviders(entries, model)
const currentModelId = formatModelIdWithVariant(model, updatedSession.variant, availableVariants, false)
const availableModels = buildAvailableModels(entries)
const currentModelId = formatModelIdWithVariant(model, updatedSession.variant, availableVariants, true)
const availableModels = buildAvailableModels(entries, { includeVariants: true })
const modeState = await this.resolveModeState(session.cwd, session.id)
const modes = modeState.currentModeId
? { availableModes: modeState.availableModes, currentModeId: modeState.currentModeId }
: undefined
return {
configOptions: buildConfigOptions({
currentModelId,
availableModels,
currentVariant: updatedSession.variant,
availableVariants,
modes,
}),
configOptions: buildConfigOptions({ currentModelId, availableModels, modes }),
}
}
@@ -1689,9 +1631,6 @@ async function defaultModel(config: ACPConfig, cwd?: string): Promise<{ provider
const opencodeProvider = providers.find((p) => p.id === "opencode")
if (opencodeProvider) {
// if (opencodeProvider.models["claude-haiku-4-5"]) {
// return { providerID: ProviderID.opencode, modelID: ModelID.make("claude-haiku-4-5") }
// }
if (opencodeProvider.models["big-pickle"]) {
return { providerID: ProviderID.opencode, modelID: ModelID.make("big-pickle") }
}
@@ -1818,14 +1757,8 @@ function formatModelIdWithVariant(
includeVariant: boolean,
) {
const base = `${model.providerID}/${model.modelID}`
if (!includeVariant || availableVariants.length === 0) return base
const selectedVariant =
variant && availableVariants.includes(variant)
? variant
: availableVariants.includes(DEFAULT_VARIANT_VALUE)
? DEFAULT_VARIANT_VALUE
: availableVariants[0]
return `${base}/${selectedVariant}`
if (!includeVariant || !variant || !availableVariants.includes(variant)) return base
return `${base}/${variant}`
}
function buildVariantMeta(input: {
@@ -1877,8 +1810,6 @@ function parseModelSelection(
function buildConfigOptions(input: {
currentModelId: string
availableModels: ModelOption[]
currentVariant?: string
availableVariants?: string[]
modes?: { availableModes: ModeOption[]; currentModeId: string } | undefined
}): SessionConfigOption[] {
const options: SessionConfigOption[] = [
@@ -1891,22 +1822,6 @@ function buildConfigOptions(input: {
options: input.availableModels.map((m) => ({ value: m.modelId, name: m.name })),
},
]
if (input.availableVariants?.length) {
options.push({
id: "effort",
name: "Effort",
description: "Available effort levels for this model",
category: "thought_level",
type: "select",
currentValue:
input.currentVariant && input.availableVariants.includes(input.currentVariant)
? input.currentVariant
: input.availableVariants.includes(DEFAULT_VARIANT_VALUE)
? DEFAULT_VARIANT_VALUE
: input.availableVariants[0],
options: input.availableVariants.map((variant) => ({ value: variant, name: formatVariantName(variant) })),
})
}
if (input.modes) {
options.push({
id: "mode",
@@ -1924,11 +1839,4 @@ function buildConfigOptions(input: {
return options
}
function formatVariantName(variant: string) {
return variant
.split(/[_-]/)
.map((part) => (part ? part.charAt(0).toUpperCase() + part.slice(1) : part))
.join(" ")
}
export * as ACP from "./agent"

View File

@@ -113,10 +113,4 @@ export class ACPSessionManager {
this.sessions.set(sessionId, session)
return session
}
remove(sessionId: string): ACPSessionState | undefined {
const session = this.sessions.get(sessionId)
this.sessions.delete(sessionId)
return session
}
}

View File

@@ -9,6 +9,7 @@ import { Locale } from "@/util/locale"
import { Flag } from "@opencode-ai/core/flag/flag"
import { Filesystem } from "@/util/filesystem"
import { Process } from "@/util/process"
import { NotFoundError } from "@/storage/storage"
import { EOL } from "os"
import path from "path"
import { which } from "../../util/which"
@@ -59,9 +60,12 @@ export const SessionDeleteCommand = effectCmd({
handler: Effect.fn("Cli.session.delete")(function* (args) {
const svc = yield* Session.Service
const sessionID = SessionID.make(args.sessionID)
// Match legacy try/catch — Session.get surfaces NotFoundError as a defect.
yield* svc.get(sessionID).pipe(Effect.catchCause(() => fail(`Session not found: ${args.sessionID}`)))
yield* svc.remove(sessionID)
yield* svc.remove(sessionID).pipe(
Effect.catchIf(
(error): error is InstanceType<typeof NotFoundError> => NotFoundError.isInstance(error),
() => fail(`Session not found: ${args.sessionID}`),
),
)
UI.println(UI.Style.TEXT_SUCCESS_BOLD + `Session ${args.sessionID} deleted` + UI.Style.TEXT_NORMAL)
}),
})

View File

@@ -2,7 +2,7 @@ import { useDialog } from "@tui/ui/dialog"
import { DialogSelect } from "@tui/ui/dialog-select"
import { useRoute } from "@tui/context/route"
import { useSync } from "@tui/context/sync"
import { createMemo, createResource, createSignal, onMount } from "solid-js"
import { createMemo, createResource, createSignal, onMount, type JSX } from "solid-js"
import { Locale } from "@/util/locale"
import { useProject } from "@tui/context/project"
import { useKeybind } from "../context/keybind"
@@ -10,15 +10,13 @@ import { useTheme } from "../context/theme"
import { useSDK } from "../context/sdk"
import { Flag } from "@opencode-ai/core/flag/flag"
import { DialogSessionRename } from "./dialog-session-rename"
import { Keybind } from "@/util/keybind"
import { createDebouncedSignal } from "../util/signal"
import { useToast } from "../ui/toast"
import { DialogWorkspaceCreate, openWorkspaceSession, restoreWorkspaceSession } from "./dialog-workspace-create"
import { openWorkspaceSelect, type WorkspaceSelection, warpWorkspaceSession } from "./dialog-workspace-create"
import { Spinner } from "./spinner"
import { errorMessage } from "@/util/error"
import { DialogSessionDeleteFailed } from "./dialog-session-delete-failed"
type WorkspaceStatus = "connected" | "connecting" | "disconnected" | "error"
import { WorkspaceLabel } from "./workspace-label"
export function DialogSessionList() {
const dialog = useDialog()
@@ -44,26 +42,39 @@ export function DialogSessionList() {
const currentSessionID = createMemo(() => (route.data.type === "session" ? route.data.sessionID : undefined))
const sessions = createMemo(() => searchResults() ?? sync.data.session)
function createWorkspace() {
dialog.replace(() => (
<DialogWorkspaceCreate
onSelect={(workspaceID) =>
openWorkspaceSession({
dialog,
route,
sdk,
sync,
toast,
workspaceID,
})
}
/>
))
}
function recover(session: NonNullable<ReturnType<typeof sessions>[number]>) {
const workspace = project.workspace.get(session.workspaceID!)
const list = () => dialog.replace(() => <DialogSessionList />)
const warp = async (selection: WorkspaceSelection) => {
const workspaceID = await (async () => {
if (selection.type === "none") return null
if (selection.type === "existing") return selection.workspaceID
const result = await sdk.client.experimental.workspace
.create({ type: selection.workspaceType, branch: null })
.catch(() => undefined)
const workspace = result?.data
if (!workspace) {
toast.show({
message: `Failed to create workspace: ${errorMessage(result?.error ?? "no response")}`,
variant: "error",
})
return
}
await project.workspace.sync()
return workspace.id
})()
if (workspaceID === undefined) return
await warpWorkspaceSession({
dialog,
sdk,
sync,
project,
toast,
workspaceID,
sessionID: session.id,
done: list,
})
}
dialog.replace(() => (
<DialogSessionDeleteFailed
session={session.title}
@@ -90,22 +101,15 @@ export function DialogSessionList() {
return true
}}
onRestore={() => {
dialog.replace(() => (
<DialogWorkspaceCreate
onSelect={(workspaceID) =>
restoreWorkspaceSession({
dialog,
sdk,
sync,
project,
toast,
workspaceID,
sessionID: session.id,
done: list,
})
}
/>
))
void openWorkspaceSelect({
dialog,
sdk,
sync,
toast,
onSelect: (selection) => {
void warp(selection)
},
})
return false
}}
/>
@@ -124,30 +128,17 @@ export function DialogSessionList() {
.map((x) => {
const workspace = x.workspaceID ? project.workspace.get(x.workspaceID) : undefined
let workspaceStatus: WorkspaceStatus | null = null
if (x.workspaceID) {
workspaceStatus = project.workspace.status(x.workspaceID) || "error"
}
let footer = ""
let footer: JSX.Element | string = ""
if (Flag.OPENCODE_EXPERIMENTAL_WORKSPACES) {
if (x.workspaceID) {
let desc = "unknown"
if (workspace) {
desc = `${workspace.type}: ${workspace.name}`
}
footer = (
<>
{desc}{" "}
<span
style={{
fg: workspaceStatus === "connected" ? theme.success : theme.error,
}}
>
</span>
</>
footer = workspace ? (
<WorkspaceLabel
type={workspace.type}
name={workspace.name}
status={project.workspace.status(x.workspaceID) ?? "error"}
/>
) : (
<WorkspaceLabel type="unknown" name={x.workspaceID} status="error" />
)
}
} else {
@@ -250,15 +241,6 @@ export function DialogSessionList() {
dialog.replace(() => <DialogSessionRename session={option.value} />)
},
},
{
keybind: Keybind.parse("ctrl+w")[0],
title: "new workspace",
side: "right",
disabled: !Flag.OPENCODE_EXPERIMENTAL_WORKSPACES,
onTrigger: () => {
createWorkspace()
},
},
]}
/>
)

View File

@@ -1,11 +1,9 @@
import { createOpencodeClient } from "@opencode-ai/sdk/v2"
import type { Workspace } from "@opencode-ai/sdk/v2"
import { useDialog } from "@tui/ui/dialog"
import { DialogSelect } from "@tui/ui/dialog-select"
import { useRoute } from "@tui/context/route"
import { DialogSelect, type DialogSelectOption } from "@tui/ui/dialog-select"
import { useSync } from "@tui/context/sync"
import { useProject } from "@tui/context/project"
import { createMemo, createSignal, onMount } from "solid-js"
import { setTimeout as sleep } from "node:timers/promises"
import { errorMessage } from "@/util/error"
import { useSDK } from "../context/sdk"
import { useToast } from "../ui/toast"
@@ -16,184 +14,212 @@ type Adapter = {
description: string
}
function scoped(sdk: ReturnType<typeof useSDK>, sync: ReturnType<typeof useSync>, workspaceID: string) {
return createOpencodeClient({
baseUrl: sdk.url,
fetch: sdk.fetch,
directory: sync.path.directory || sdk.directory,
experimental_workspaceID: workspaceID,
})
}
export type WorkspaceSelection =
| {
type: "none"
}
| {
type: "new"
workspaceType: string
workspaceName: string
}
| {
type: "existing"
workspaceID: string
workspaceType: string
workspaceName: string
}
export async function openWorkspaceSession(input: {
dialog: ReturnType<typeof useDialog>
route: ReturnType<typeof useRoute>
type WorkspaceSelectValue = WorkspaceSelection | { type: "existing-list" }
type ExistingWorkspaceSelectValue = { workspace: Workspace }
async function loadWorkspaceAdapters(input: {
sdk: ReturnType<typeof useSDK>
sync: ReturnType<typeof useSync>
toast: ReturnType<typeof useToast>
workspaceID: string
}) {
const client = scoped(input.sdk, input.sync, input.workspaceID)
while (true) {
const result = await client.session.create({ workspace: input.workspaceID }).catch(() => undefined)
if (!result) {
input.toast.show({
message: "Failed to create workspace session",
variant: "error",
})
return
}
if (result.response?.status && result.response.status >= 500 && result.response.status < 600) {
await sleep(1000)
continue
}
if (!result.data) {
input.toast.show({
message: "Failed to create workspace session",
variant: "error",
})
return
}
input.route.navigate({
type: "session",
sessionID: result.data.id,
})
input.dialog.clear()
return
}
const dir = input.sync.path.directory || input.sdk.directory
const url = new URL("/experimental/workspace/adapter", input.sdk.url)
if (dir) url.searchParams.set("directory", dir)
const res = await input.sdk
.fetch(url)
.then((x) => x.json() as Promise<Adapter[]>)
.catch(() => undefined)
if (res) return res
input.toast.show({
message: "Failed to load workspace adapters",
variant: "error",
})
}
export async function restoreWorkspaceSession(input: {
export async function openWorkspaceSelect(input: {
dialog: ReturnType<typeof useDialog>
sdk: ReturnType<typeof useSDK>
sync: ReturnType<typeof useSync>
toast: ReturnType<typeof useToast>
onSelect: (selection: WorkspaceSelection) => Promise<void> | void
}) {
input.dialog.clear()
const adapters = await loadWorkspaceAdapters(input)
if (!adapters) return
input.dialog.replace(() => <DialogWorkspaceSelect adapters={adapters} onSelect={input.onSelect} />)
}
export async function warpWorkspaceSession(input: {
dialog: ReturnType<typeof useDialog>
sdk: ReturnType<typeof useSDK>
sync: ReturnType<typeof useSync>
project: ReturnType<typeof useProject>
toast: ReturnType<typeof useToast>
workspaceID: string
workspaceID: string | null
sessionID: string
done?: () => void
}) {
}): Promise<boolean> {
const result = await input.sdk.client.experimental.workspace
.sessionRestore({ id: input.workspaceID, sessionID: input.sessionID })
.warp({
id: input.workspaceID ?? undefined,
sessionID: input.sessionID,
})
.catch(() => undefined)
if (!result?.data) {
input.toast.show({
message: `Failed to restore session: ${errorMessage(result?.error ?? "no response")}`,
message: `Failed to warp session: ${errorMessage(result?.error ?? "no response")}`,
variant: "error",
})
return
return false
}
input.project.workspace.set(input.workspaceID)
await input.sync.bootstrap({ fatal: false }).catch(() => undefined)
await Promise.all([input.project.workspace.sync(), input.sync.session.sync(input.sessionID)])
await Promise.all([input.project.workspace.sync(), input.sync.session.refresh()])
input.toast.show({
message: "Session restored into the new workspace",
variant: "success",
})
input.done?.()
if (input.done) return
if (input.done) return true
input.dialog.clear()
return true
}
export function DialogWorkspaceCreate(props: { onSelect: (workspaceID: string) => Promise<void> | void }) {
export function DialogWorkspaceSelect(props: {
adapters?: Adapter[]
onSelect: (selection: WorkspaceSelection) => Promise<void> | void
}) {
const dialog = useDialog()
const sync = useSync()
const project = useProject()
const sync = useSync()
const sdk = useSDK()
const toast = useToast()
const [creating, setCreating] = createSignal<string>()
const [adapters, setAdapters] = createSignal<Adapter[]>()
const [adapters, setAdapters] = createSignal<Adapter[] | undefined>(props.adapters)
onMount(() => {
dialog.setSize("medium")
void (async () => {
const dir = sync.path.directory || sdk.directory
const url = new URL("/experimental/workspace/adapter", sdk.url)
if (dir) url.searchParams.set("directory", dir)
const res = await sdk
.fetch(url)
.then((x) => x.json() as Promise<Adapter[]>)
.catch(() => undefined)
if (!res) {
toast.show({
message: "Failed to load workspace adapters",
variant: "error",
})
return
}
if (adapters()) return
const res = await loadWorkspaceAdapters({ sdk, sync, toast })
if (!res) return
setAdapters(res)
})()
})
const options = createMemo(() => {
const type = creating()
if (type) {
return [
{
title: `Creating ${type} workspace...`,
value: "creating" as const,
description: "This can take a while for remote environments",
},
]
}
const options = createMemo<DialogSelectOption<WorkspaceSelectValue>[]>(() => {
const list = adapters()
if (!list) {
return [
{
title: "Loading workspaces...",
value: "loading" as const,
description: "Fetching available workspace adapters",
if (!list) return []
const recent = sync.data.session
.toSorted((a, b) => b.time.updated - a.time.updated)
.flatMap((session) => (session.workspaceID ? [session.workspaceID] : []))
.filter((workspaceID, index, list) => list.indexOf(workspaceID) === index)
.slice(0, 3)
.flatMap((workspaceID) => {
const workspace = project.workspace.get(workspaceID)
return workspace ? [workspace] : []
})
return [
...list.map((adapter) => ({
title: adapter.name,
value: { type: "new" as const, workspaceType: adapter.type, workspaceName: adapter.name },
description: adapter.description,
category: "New workspace",
})),
{
title: "None",
value: { type: "none" as const },
description: "Use the local project",
category: "Choose workspace",
},
...recent.map((workspace: Workspace) => ({
title: workspace.name,
description: `(${workspace.type})`,
value: {
type: "existing" as const,
workspaceID: workspace.id,
workspaceType: workspace.type,
workspaceName: workspace.name,
},
]
}
return list.map((item) => ({
title: item.name,
value: item.type,
description: item.description,
}))
category: "Choose workspace",
})),
{
title: "View all workspaces",
value: { type: "existing-list" as const },
description: "Choose from all workspaces",
category: "Choose workspace",
},
]
})
const create = async (type: string) => {
if (creating()) return
setCreating(type)
const result = await sdk.client.experimental.workspace.create({ type, branch: null }).catch(() => {
toast.show({
message: "Creating workspace failed",
variant: "error",
})
return undefined
})
const workspace = result?.data
if (!workspace) {
setCreating(undefined)
toast.show({
message: `Failed to create workspace: ${errorMessage(result?.error ?? "no response")}`,
variant: "error",
})
return
}
await project.workspace.sync()
await props.onSelect(workspace.id)
setCreating(undefined)
}
if (!adapters()) return null
return (
<DialogSelect
title={creating() ? "Creating Workspace" : "New Workspace"}
<DialogSelect<WorkspaceSelectValue>
title="Warp"
skipFilter={true}
renderFilter={false}
options={options()}
onSelect={(option) => {
if (option.value === "creating" || option.value === "loading") return
void create(option.value)
if (!option.value) return
if (option.value.type === "none") {
void props.onSelect(option.value)
return
}
if (option.value.type === "new") {
void props.onSelect(option.value)
return
}
if (option.value.type === "existing") {
void props.onSelect(option.value)
return
}
dialog.replace(() => <DialogExistingWorkspaceSelect onSelect={props.onSelect} />)
}}
/>
)
}
function DialogExistingWorkspaceSelect(props: { onSelect: (selection: WorkspaceSelection) => Promise<void> | void }) {
const project = useProject()
const options = createMemo<DialogSelectOption<ExistingWorkspaceSelectValue>[]>(() =>
project.workspace
.list()
.filter((workspace) => project.workspace.status(workspace.id) === "connected")
.map((workspace: Workspace) => ({
title: workspace.name,
description: `(${workspace.type})`,
value: { workspace },
})),
)
return (
<DialogSelect<ExistingWorkspaceSelectValue>
title="Existing Workspace"
options={options()}
onSelect={(option) => {
void props.onSelect({
type: "existing",
workspaceID: option.value.workspace.id,
workspaceType: option.value.workspace.type,
workspaceName: option.value.workspace.name,
})
}}
/>
)

View File

@@ -7,6 +7,7 @@ import { Filesystem } from "@/util/filesystem"
import { useLocal } from "@tui/context/local"
import { tint, useTheme } from "@tui/context/theme"
import { EmptyBorder, SplitBorder } from "@tui/component/border"
import { Spinner } from "@tui/component/spinner"
import { useSDK } from "@tui/context/sdk"
import { useRoute } from "@tui/context/route"
import { useProject } from "@tui/context/project"
@@ -41,9 +42,11 @@ import { useKV } from "../../context/kv"
import { createFadeIn } from "../../util/signal"
import { useTextareaKeybindings } from "../textarea-keybindings"
import { DialogSkill } from "../dialog-skill"
import { DialogWorkspaceCreate, restoreWorkspaceSession } from "../dialog-workspace-create"
import { openWorkspaceSelect, warpWorkspaceSession, type WorkspaceSelection } from "../dialog-workspace-create"
import { DialogWorkspaceUnavailable } from "../dialog-workspace-unavailable"
import { useArgs } from "@tui/context/args"
import { Flag } from "@opencode-ai/core/flag/flag"
import { WorkspaceLabel, type WorkspaceStatus } from "../workspace-label"
export type PromptProps = {
sessionID?: string
@@ -173,9 +176,92 @@ export function Prompt(props: PromptProps) {
const [editorContextHover, setEditorContextHover] = createSignal(false)
let lastSubmittedEditorSelectionKey: string | undefined
const [auto, setAuto] = createSignal<AutocompleteRef>()
const [workspaceSelection, setWorkspaceSelection] = createSignal<WorkspaceSelection>()
const [workspaceCreating, setWorkspaceCreating] = createSignal(false)
const [workspaceCreatingDots, setWorkspaceCreatingDots] = createSignal(3)
const [warpNotice, setWarpNotice] = createSignal<string>()
const currentProviderLabel = createMemo(() => local.model.parsed().provider)
const hasRightContent = createMemo(() => Boolean(props.right))
function selectWorkspace(selection: WorkspaceSelection | undefined) {
setWorkspaceSelection(selection)
}
function setCreatingWorkspace(creating: boolean) {
setWorkspaceCreating(creating)
}
function showWarpNotice(name: string) {
setWarpNotice(`Warped to ${name}`)
setTimeout(() => setWarpNotice(undefined), 4000)
}
async function createWorkspace(selection: Extract<WorkspaceSelection, { type: "new" }>) {
setCreatingWorkspace(true)
const result = await sdk.client.experimental.workspace
.create({ type: selection.workspaceType, branch: null })
.catch(() => undefined)
if (result == undefined || result.error || !result.data) {
selectWorkspace(undefined)
setCreatingWorkspace(false)
toast.show({
message: "Creating workspace failed",
variant: "error",
})
return
}
await project.workspace.sync()
const workspace = result.data
selectWorkspace({
type: "existing",
workspaceID: workspace.id,
workspaceType: workspace.type,
workspaceName: workspace.name,
})
setCreatingWorkspace(false)
return workspace
}
async function warpSession(selection: WorkspaceSelection) {
if (!props.sessionID) {
selectWorkspace(selection)
dialog.clear()
if (selection.type === "new") void createWorkspace(selection)
return
}
selectWorkspace(selection)
dialog.clear()
const workspace =
selection.type === "none"
? { id: null, name: "local project" }
: selection.type === "existing"
? { id: selection.workspaceID, name: selection.workspaceName }
: await createWorkspace(selection)
if (!workspace) return
const warped = await warpWorkspaceSession({
dialog,
sdk,
sync,
project,
toast,
workspaceID: workspace.id,
sessionID: props.sessionID,
})
if (warped) showWarpNotice(workspace.name)
}
createEffect(() => {
if (!workspaceCreating()) {
setWorkspaceCreatingDots(3)
return
}
const timer = setInterval(() => setWorkspaceCreatingDots((dots) => (dots % 3) + 1), 1000)
onCleanup(() => clearInterval(timer))
})
function promptModelWarning() {
toast.show({
variant: "warning",
@@ -213,6 +299,7 @@ export function Prompt(props: PromptProps) {
})
createEffect(() => {
if (!input || input.isDestroyed) return
if (props.disabled) input.cursorColor = theme.backgroundElement
if (!props.disabled) input.cursorColor = theme.text
})
@@ -489,6 +576,27 @@ export function Prompt(props: PromptProps) {
))
},
},
{
title: "Warp",
description: "Change the workspace for the session",
value: "workspace.set",
category: "Session",
enabled: Flag.OPENCODE_EXPERIMENTAL_WORKSPACES,
slash: {
name: "warp",
},
onSelect: (dialog) => {
void openWorkspaceSelect({
dialog,
sdk,
sync,
toast,
onSelect: (selection) => {
void warpSession(selection)
},
})
},
},
]
})
@@ -699,6 +807,8 @@ export function Prompt(props: PromptProps) {
])
async function submit() {
setWarpNotice(undefined)
// IME: double-defer may fire before onContentChange flushes the last
// composed character (e.g. Korean hangul) to the store, so read
// plainText directly and sync before any downstream reads.
@@ -707,6 +817,7 @@ export function Prompt(props: PromptProps) {
syncExtmarksWithPromptParts()
}
if (props.disabled) return false
if (workspaceCreating()) return false
if (autocomplete?.visible) return false
if (!store.prompt.input) return false
const agent = local.agent.current()
@@ -729,21 +840,16 @@ export function Prompt(props: PromptProps) {
dialog.replace(() => (
<DialogWorkspaceUnavailable
onRestore={() => {
dialog.replace(() => (
<DialogWorkspaceCreate
onSelect={(nextWorkspaceID) =>
restoreWorkspaceSession({
dialog,
sdk,
sync,
project,
toast,
workspaceID: nextWorkspaceID,
sessionID: props.sessionID!,
})
}
/>
))
void openWorkspaceSelect({
dialog,
sdk,
sync,
toast,
onSelect: (selection) => {
void warpSession(selection)
},
})
return false
}}
/>
))
@@ -753,6 +859,14 @@ export function Prompt(props: PromptProps) {
const variant = local.model.variant.current()
let sessionID = props.sessionID
if (sessionID == null) {
const workspace = workspaceSelection()
const workspaceID = iife(() => {
if (!workspace) return undefined
if (workspace.type === "none") return undefined
if (workspace.type === "existing") return workspace.workspaceID
return undefined
})
const res = await sdk.client.session.create({
workspace: props.workspaceID,
agent: agent.name,
@@ -1025,6 +1139,29 @@ export function Prompt(props: PromptProps) {
return `Ask anything... "${list()[store.placeholder % list().length]}"`
})
const workspaceLabel = createMemo<
| { type: "new"; workspaceType: string }
| { type: "existing"; workspaceType: string; workspaceName: string; status?: WorkspaceStatus }
| undefined
>(() => {
const selected = workspaceSelection()
if (!selected) return
if (selected.type === "none") return
if (props.sessionID && !workspaceCreating()) return
if (selected.type === "new") {
return {
type: "new",
workspaceType: selected.workspaceType,
}
}
return {
type: "existing",
workspaceType: selected.workspaceType,
workspaceName: selected.workspaceName,
status: selected.type === "existing" ? "connected" : undefined,
}
})
const spinnerDef = createMemo(() => {
const agent = local.agent.current()
const color = agent ? local.agent.color(agent.name) : theme.border
@@ -1281,7 +1418,7 @@ export function Prompt(props: PromptProps) {
}}
onMouseDown={(r: MouseEvent) => r.target?.focus()}
focusedBackgroundColor={theme.backgroundElement}
cursorColor={theme.text}
cursorColor={props.disabled ? theme.backgroundElement : theme.text}
syntaxStyle={syntax()}
/>
<box flexDirection="row" flexShrink={0} paddingTop={1} gap={1} justifyContent="space-between">
@@ -1351,86 +1488,124 @@ export function Prompt(props: PromptProps) {
/>
</box>
<box width="100%" flexDirection="row" justifyContent="space-between">
<Show when={status().type !== "idle"} fallback={props.hint ?? <text />}>
<box
flexDirection="row"
gap={1}
flexGrow={1}
justifyContent={status().type === "retry" ? "space-between" : "flex-start"}
>
<box flexShrink={0} flexDirection="row" gap={1}>
<box marginLeft={1}>
<Show when={kv.get("animations_enabled", true)} fallback={<text fg={theme.textMuted}>[]</text>}>
<spinner color={spinnerDef().color} frames={spinnerDef().frames} interval={40} />
</Show>
</box>
<box flexDirection="row" gap={1} flexShrink={0}>
{(() => {
const retry = createMemo(() => {
const s = status()
if (s.type !== "retry") return
return s
})
const message = createMemo(() => {
const r = retry()
if (!r) return
if (r.message.includes("exceeded your current quota") && r.message.includes("gemini"))
return "gemini is way too hot right now"
if (r.message.length > 80) return r.message.slice(0, 80) + "..."
return r.message
})
const isTruncated = createMemo(() => {
const r = retry()
if (!r) return false
return r.message.length > 120
})
const [seconds, setSeconds] = createSignal(0)
onMount(() => {
const timer = setInterval(() => {
const next = retry()?.next
if (next) setSeconds(Math.round((next - Date.now()) / 1000))
}, 1000)
onCleanup(() => {
clearInterval(timer)
<Switch>
<Match when={status().type !== "idle"}>
<box
flexDirection="row"
gap={1}
flexGrow={1}
justifyContent={status().type === "retry" ? "space-between" : "flex-start"}
>
<box flexShrink={0} flexDirection="row" gap={1}>
<box marginLeft={1}>
<Show when={kv.get("animations_enabled", true)} fallback={<text fg={theme.textMuted}>[]</text>}>
<spinner color={spinnerDef().color} frames={spinnerDef().frames} interval={40} />
</Show>
</box>
<box flexDirection="row" gap={1} flexShrink={0}>
{(() => {
const retry = createMemo(() => {
const s = status()
if (s.type !== "retry") return
return s
})
})
const handleMessageClick = () => {
const r = retry()
if (!r) return
if (isTruncated()) {
void DialogAlert.show(dialog, "Retry Error", r.message)
const message = createMemo(() => {
const r = retry()
if (!r) return
if (r.message.includes("exceeded your current quota") && r.message.includes("gemini"))
return "gemini is way too hot right now"
if (r.message.length > 80) return r.message.slice(0, 80) + "..."
return r.message
})
const isTruncated = createMemo(() => {
const r = retry()
if (!r) return false
return r.message.length > 120
})
const [seconds, setSeconds] = createSignal(0)
onMount(() => {
const timer = setInterval(() => {
const next = retry()?.next
if (next) setSeconds(Math.round((next - Date.now()) / 1000))
}, 1000)
onCleanup(() => {
clearInterval(timer)
})
})
const handleMessageClick = () => {
const r = retry()
if (!r) return
if (isTruncated()) {
void DialogAlert.show(dialog, "Retry Error", r.message)
}
}
}
const retryText = () => {
const r = retry()
if (!r) return ""
const baseMessage = message()
const truncatedHint = isTruncated() ? " (click to expand)" : ""
const duration = formatDuration(seconds())
const retryInfo = ` [retrying ${duration ? `in ${duration} ` : ""}attempt #${r.attempt}]`
return baseMessage + truncatedHint + retryInfo
}
const retryText = () => {
const r = retry()
if (!r) return ""
const baseMessage = message()
const truncatedHint = isTruncated() ? " (click to expand)" : ""
const duration = formatDuration(seconds())
const retryInfo = ` [retrying ${duration ? `in ${duration} ` : ""}attempt #${r.attempt}]`
return baseMessage + truncatedHint + retryInfo
}
return (
<Show when={retry()}>
<box onMouseUp={handleMessageClick}>
<text fg={theme.error}>{retryText()}</text>
</box>
</Show>
)
})()}
return (
<Show when={retry()}>
<box onMouseUp={handleMessageClick}>
<text fg={theme.error}>{retryText()}</text>
</box>
</Show>
)
})()}
</box>
</box>
<text fg={store.interrupt > 0 ? theme.primary : theme.text}>
esc{" "}
<span style={{ fg: store.interrupt > 0 ? theme.primary : theme.textMuted }}>
{store.interrupt > 0 ? "again to interrupt" : "interrupt"}
</span>
</text>
</box>
<text fg={store.interrupt > 0 ? theme.primary : theme.text}>
esc{" "}
<span style={{ fg: store.interrupt > 0 ? theme.primary : theme.textMuted }}>
{store.interrupt > 0 ? "again to interrupt" : "interrupt"}
</span>
</text>
</box>
</Show>
</Match>
<Match when={warpNotice()}>
{(notice) => (
<box paddingLeft={3}>
<text fg={theme.accent}>{notice()}</text>
</box>
)}
</Match>
<Match when={workspaceLabel()}>
{(workspace) => (
<box paddingLeft={3} flexDirection="row" gap={1}>
<Show when={workspaceCreating()}>
<Spinner color={theme.accent} />
</Show>
<text fg={workspaceCreating() ? theme.accent : theme.text}>
{(() => {
const item = workspace()
if (item.type === "new") {
if (workspaceCreating())
return `Creating ${item.workspaceType}${".".repeat(workspaceCreatingDots())}`
return (
<>
Workspace <span style={{ fg: theme.textMuted }}>(new {item.workspaceType})</span>
</>
)
}
return (
<>
Workspace <span style={{ fg: theme.textMuted }}>{item.workspaceName}</span>
</>
)
})()}
</text>
</box>
)}
</Match>
<Match when={true}>{props.hint ?? <text />}</Match>
</Switch>
<Show when={status().type !== "retry"}>
<box gap={2} flexDirection="row">
<Show when={editorFileLabelDisplay()}>

View File

@@ -0,0 +1,19 @@
import { useTheme } from "@tui/context/theme"
export type WorkspaceStatus = "connected" | "connecting" | "disconnected" | "error"
export function WorkspaceLabel(props: { type: string; name: string; status?: WorkspaceStatus; icon?: boolean }) {
const { theme } = useTheme()
const color = () => {
if (props.status === "connected") return theme.success
if (props.status === "error") return theme.error
return theme.textMuted
}
return (
<>
{props.icon ? <span style={{ fg: color() }}> </span> : undefined}
<span style={{ fg: theme.text }}>{props.name}</span> <span style={{ fg: theme.textMuted }}>({props.type})</span>
</>
)
}

View File

@@ -11,21 +11,21 @@ import { createSimpleContext } from "./helper"
import { useSDK } from "./sdk"
function activeAssistant(messages: SessionMessage[]) {
const index = messages.findLastIndex((message) => message.type === "assistant" && !message.time.completed)
const index = messages.findIndex((message) => message.type === "assistant" && !message.time.completed)
if (index < 0) return
const assistant = messages[index]
return assistant?.type === "assistant" ? assistant : undefined
}
function activeCompaction(messages: SessionMessage[]) {
const index = messages.findLastIndex((message) => message.type === "compaction")
const index = messages.findIndex((message) => message.type === "compaction")
if (index < 0) return
const compaction = messages[index]
return compaction?.type === "compaction" ? compaction : undefined
}
function activeShell(messages: SessionMessage[], callID: string) {
const index = messages.findLastIndex((message) => message.type === "shell" && message.callID === callID)
const index = messages.findIndex((message) => message.type === "shell" && message.callID === callID)
if (index < 0) return
const shell = messages[index]
return shell?.type === "shell" ? shell : undefined
@@ -74,7 +74,7 @@ export const { use: useSyncV2, provider: SyncProviderV2 } = createSimpleContext(
switch (event.type) {
case "session.next.prompted": {
update(event.properties.sessionID, (draft) => {
draft.push({
draft.unshift({
id: event.id,
type: "user",
text: event.properties.prompt.text,
@@ -87,7 +87,7 @@ export const { use: useSyncV2, provider: SyncProviderV2 } = createSimpleContext(
}
case "session.next.synthetic":
update(event.properties.sessionID, (draft) => {
draft.push({
draft.unshift({
id: event.id,
type: "synthetic",
sessionID: event.properties.sessionID,
@@ -98,7 +98,7 @@ export const { use: useSyncV2, provider: SyncProviderV2 } = createSimpleContext(
break
case "session.next.shell.started":
update(event.properties.sessionID, (draft) => {
draft.push({
draft.unshift({
id: event.id,
type: "shell",
callID: event.properties.callID,
@@ -120,7 +120,7 @@ export const { use: useSyncV2, provider: SyncProviderV2 } = createSimpleContext(
update(event.properties.sessionID, (draft) => {
const currentAssistant = activeAssistant(draft)
if (currentAssistant) currentAssistant.time.completed = event.properties.timestamp
draft.push({
draft.unshift({
id: event.id,
type: "assistant",
agent: event.properties.agent,
@@ -259,7 +259,7 @@ export const { use: useSyncV2, provider: SyncProviderV2 } = createSimpleContext(
break
case "session.next.compaction.started":
update(event.properties.sessionID, (draft) => {
draft.push({
draft.unshift({
id: event.id,
type: "compaction",
reason: event.properties.reason,

View File

@@ -5,7 +5,7 @@ import { Spinner } from "@tui/component/spinner"
import { useTheme } from "@tui/context/theme"
import { useLocal } from "@tui/context/local"
import { useKeyboard, useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid"
import type { SyntaxStyle } from "@opentui/core"
import { TextAttributes, type BoxRenderable, type SyntaxStyle } from "@opentui/core"
import { Locale } from "@/util/locale"
import { LANGUAGE_EXTENSIONS } from "@/lsp/language"
import path from "path"
@@ -44,6 +44,10 @@ function View(props: { api: TuiPluginApi; sessionID: string }) {
const messages = createMemo(() => sync.data.messages[props.sessionID] ?? [])
const renderedMessages = createMemo(() => messages().toReversed())
const lastAssistant = createMemo(() => renderedMessages().findLast((message) => message.type === "assistant"))
const lastUserCreated = (index: number) =>
renderedMessages()
.slice(0, index)
.findLast((message) => message.type === "user")?.time.created
createEffect(() => {
void sync.session.message.sync(props.sessionID)
@@ -83,10 +87,11 @@ function View(props: { api: TuiPluginApi; sessionID: string }) {
last={lastAssistant()?.id === message.id}
syntax={syntax()}
subtleSyntax={subtleSyntax()}
start={lastUserCreated(index())}
/>
</Match>
<Match when={message.type === "synthetic"}>
<SyntheticMessage message={message as SessionMessageSynthetic} index={index()} />
<></>
</Match>
<Match when={message.type === "shell"}>
<ShellMessage message={message as SessionMessageShell} />
@@ -146,63 +151,36 @@ function UserMessage(props: { message: SessionMessageUser; index: number }) {
<box
id={props.message.id}
border={["left"]}
borderColor={theme.primary}
borderColor={theme.secondary}
customBorderChars={SplitBorder.customBorderChars}
marginTop={props.index === 0 ? 0 : 1}
flexShrink={0}
>
<box paddingTop={1} paddingBottom={1} paddingLeft={2} backgroundColor={theme.backgroundPanel}>
<Show
when={props.message.text.trim()}
fallback={
<MissingData label="User message text" detail={`Message ${props.message.id} has no text field content.`} />
}
>
<text fg={theme.text}>{props.message.text}</text>
</Show>
<Show when={attachments().length}>
<box flexDirection="row" paddingTop={1} gap={1} flexWrap="wrap">
<For each={props.message.files ?? []}>
{(file) => (
<text fg={theme.text}>
<span style={{ bg: theme.secondary, fg: theme.background }}> {file.mime} </span>
<span style={{ bg: theme.backgroundElement, fg: theme.textMuted }}> {file.name ?? file.uri} </span>
</text>
)}
</For>
<For each={props.message.agents ?? []}>
{(agent) => (
<text fg={theme.text}>
<span style={{ bg: theme.accent, fg: theme.background }}> agent </span>
<span style={{ bg: theme.backgroundElement, fg: theme.textMuted }}> {agent.name} </span>
</text>
)}
</For>
</box>
</Show>
<text fg={theme.textMuted}>{Locale.todayTimeOrDateTime(props.message.time.created)}</text>
</box>
</box>
)
}
function SyntheticMessage(props: { message: SessionMessageSynthetic; index: number }) {
const { theme } = useTheme()
return (
<box
id={props.message.id}
border={["left"]}
borderColor={theme.backgroundElement}
customBorderChars={SplitBorder.customBorderChars}
marginTop={props.index === 0 ? 0 : 1}
paddingLeft={2}
paddingTop={1}
paddingBottom={1}
paddingLeft={2}
backgroundColor={theme.backgroundPanel}
flexShrink={0}
>
<text fg={theme.textMuted}>Synthetic</text>
<text fg={theme.text}>{props.message.text}</text>
<Show when={attachments().length}>
<box flexDirection="row" paddingTop={1} gap={1} flexWrap="wrap">
<For each={props.message.files ?? []}>
{(file) => (
<text fg={theme.text}>
<span style={{ bg: theme.secondary, fg: theme.background }}> {file.mime} </span>
<span style={{ bg: theme.backgroundElement, fg: theme.textMuted }}> {file.name ?? file.uri} </span>
</text>
)}
</For>
<For each={props.message.agents ?? []}>
{(agent) => (
<text fg={theme.text}>
<span style={{ bg: theme.accent, fg: theme.background }}> agent </span>
<span style={{ bg: theme.backgroundElement, fg: theme.textMuted }}> {agent.name} </span>
</text>
)}
</For>
</box>
</Show>
</box>
)
}
@@ -237,7 +215,7 @@ function ShellMessage(props: { message: SessionMessageShell }) {
}
function CompactionMessage(props: { message: SessionMessageCompaction }) {
const { theme } = useTheme()
const { theme, syntax } = useTheme()
return (
<box
marginTop={1}
@@ -248,7 +226,19 @@ function CompactionMessage(props: { message: SessionMessageCompaction }) {
flexShrink={0}
>
<Show when={props.message.summary}>
<text fg={theme.textMuted}>{props.message.summary}</text>
{(summary) => (
<box paddingLeft={3} paddingTop={1}>
<code
filetype="markdown"
drawUnstyledText={false}
streaming={false}
syntaxStyle={syntax()}
content={summary().trim()}
conceal={true}
fg={theme.text}
/>
</box>
)}
</Show>
</box>
)
@@ -294,12 +284,13 @@ function AssistantMessage(props: {
last: boolean
syntax: SyntaxStyle
subtleSyntax: SyntaxStyle
start?: number
}) {
const { theme } = useTheme()
const local = useLocal()
const duration = createMemo(() => {
if (!props.message.time.completed) return 0
return props.message.time.completed - props.message.time.created
return props.message.time.completed - (props.start ?? props.message.time.created)
})
const model = createMemo(() => {
const variant = props.message.model.variant ? `/${props.message.model.variant}` : ""
@@ -361,7 +352,7 @@ function AssistantText(props: { part: SessionMessageAssistantText; syntax: Synta
const { theme } = useTheme()
return (
<Show when={props.part.text.trim()}>
<box paddingLeft={3} marginTop={1} flexShrink={0}>
<box paddingLeft={3} marginTop={1} flexShrink={0} id="text">
<code
filetype="markdown"
drawUnstyledText={false}
@@ -521,33 +512,93 @@ function InlineTool(props: {
part: SessionMessageAssistantTool
}) {
const { theme } = useTheme()
const renderer = useRenderer()
const [margin, setMargin] = createSignal(0)
const [hover, setHover] = createSignal(false)
const [showError, setShowError] = createSignal(false)
const error = createMemo(() => (props.part.state.status === "error" ? props.part.state.error.message : undefined))
const complete = createMemo(() => !!props.complete)
const denied = createMemo(() => {
const message = error()
if (!message) return false
return (
message.includes("QuestionRejectedError") ||
message.includes("rejected permission") ||
message.includes("specified a rule") ||
message.includes("user dismissed")
)
})
const fg = createMemo(() => {
if (error()) return theme.error
if (complete()) return theme.textMuted
return theme.text
})
const attributes = createMemo(() => (denied() ? TextAttributes.STRIKETHROUGH : undefined))
return (
<box marginTop={1} paddingLeft={3} flexShrink={0}>
<Switch>
<Match when={props.spinner}>
<Spinner color={theme.text}>{props.children}</Spinner>
</Match>
<Match when={true}>
<text paddingLeft={3} fg={props.complete ? theme.textMuted : theme.text}>
<Show fallback={<>~ {props.pending}</>} when={props.complete}>
{props.icon} {props.children}
</Show>
</text>
</Match>
</Switch>
<Show when={error() && !denied()}>
<text fg={theme.error}>{error()}</text>
</Show>
<box
marginTop={margin()}
paddingLeft={3}
flexShrink={0}
flexDirection="row"
gap={1}
backgroundColor={hover() && error() ? theme.backgroundMenu : undefined}
onMouseOver={() => error() && setHover(true)}
onMouseOut={() => setHover(false)}
onMouseUp={() => {
if (!error()) return
if (renderer.getSelection()?.getSelectedText()) return
setShowError((prev) => !prev)
}}
renderBefore={function () {
const el = this as BoxRenderable
const parent = el.parent
if (!parent) return
const previous = parent.getChildren()[parent.getChildren().indexOf(el) - 1]
if (!previous) {
setMargin(0)
return
}
if (previous.id.startsWith("text")) setMargin(1)
}}
>
<box flexShrink={0}>
<Switch>
<Match when={props.spinner}>
<Spinner color={theme.text} />
</Match>
<Match when={complete()}>
<text fg={fg()} attributes={attributes()}>
{props.icon}
</text>
</Match>
<Match when={true}>
<text fg={fg()} attributes={attributes()}>
~
</text>
</Match>
</Switch>
</box>
<box flexGrow={1}>
<box>
<Switch>
<Match when={complete()}>
<text fg={fg()} attributes={attributes()}>
{props.children}
</text>
</Match>
<Match when={true}>
<text fg={fg()} attributes={attributes()}>
{props.pending}
</text>
</Match>
</Switch>
</box>
<Show when={showError() && error()}>
<box>
<text fg={theme.error}>{error()}</text>
</box>
</Show>
</box>
</box>
)
}

View File

@@ -7,6 +7,7 @@ import { InstallationChannel, InstallationVersion } from "@opencode-ai/core/inst
import { TuiPluginRuntime } from "@/cli/cmd/tui/plugin/runtime"
import { getScrollAcceleration } from "../../util/scroll"
import { WorkspaceLabel } from "../../component/workspace-label"
export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
const project = useProject()
@@ -14,17 +15,10 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
const { theme } = useTheme()
const tuiConfig = useTuiConfig()
const session = createMemo(() => sync.session.get(props.sessionID))
const workspaceStatus = () => {
const workspace = () => {
const workspaceID = session()?.workspaceID
if (!workspaceID) return "error"
return project.workspace.status(workspaceID) ?? "error"
}
const workspaceLabel = () => {
const workspaceID = session()?.workspaceID
if (!workspaceID) return "unknown"
const info = project.workspace.get(workspaceID)
if (!info) return "unknown"
return `${info.type}: ${info.name}`
if (!workspaceID) return
return project.workspace.get(workspaceID)
}
const scrollAcceleration = createMemo(() => getScrollAcceleration(tuiConfig))
@@ -67,8 +61,19 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
</Show>
<Show when={session()!.workspaceID}>
<text fg={theme.textMuted}>
<span style={{ fg: workspaceStatus() === "connected" ? theme.success : theme.error }}></span>{" "}
{workspaceLabel()}
<Show
when={workspace()}
fallback={<WorkspaceLabel type="unknown" name={session()!.workspaceID!} status="error" icon />}
>
{(item) => (
<WorkspaceLabel
type={item().type}
name={item().name}
status={project.workspace.status(item().id) ?? "error"}
icon
/>
)}
</Show>
</text>
</Show>
<Show when={session()!.share?.url}>

View File

@@ -23,6 +23,7 @@ export interface DialogSelectProps<T> {
onFilter?: (query: string) => void
onSelect?: (option: DialogSelectOption<T>) => void
skipFilter?: boolean
renderFilter?: boolean
keybind?: {
keybind?: Keybind.Info
title: string
@@ -81,7 +82,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
let input: InputRenderable
const filtered = createMemo(() => {
if (props.skipFilter) return props.options.filter((x) => x.disabled !== true)
if (props.skipFilter || props.renderFilter === false) return props.options.filter((x) => x.disabled !== true)
const needle = store.filter.toLowerCase()
const options = pipe(
props.options,
@@ -250,30 +251,32 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
esc
</text>
</box>
<box paddingTop={1}>
<input
onInput={(e) => {
batch(() => {
setStore("filter", e)
props.onFilter?.(e)
})
}}
focusedBackgroundColor={theme.backgroundPanel}
cursorColor={theme.primary}
focusedTextColor={theme.textMuted}
ref={(r) => {
input = r
input.traits = { status: "FILTER" }
setTimeout(() => {
if (!input) return
if (input.isDestroyed) return
input.focus()
}, 1)
}}
placeholder={props.placeholder ?? "Search"}
placeholderColor={theme.textMuted}
/>
</box>
<Show when={props.renderFilter !== false}>
<box paddingTop={1}>
<input
onInput={(e) => {
batch(() => {
setStore("filter", e)
props.onFilter?.(e)
})
}}
focusedBackgroundColor={theme.backgroundPanel}
cursorColor={theme.primary}
focusedTextColor={theme.textMuted}
ref={(r) => {
input = r
input.traits = { status: "FILTER" }
setTimeout(() => {
if (!input) return
if (input.isDestroyed) return
input.focus()
}, 1)
}}
placeholder={props.placeholder ?? "Search"}
placeholderColor={theme.textMuted}
/>
</box>
</Show>
</box>
<Show
when={grouped().length > 0}

View File

@@ -1,10 +1,11 @@
import { Context, Effect, FiberMap, Layer, Schema, Stream } from "effect"
import { Context, Effect, FiberMap, Iterable, Layer, Schema, Stream } from "effect"
import { FetchHttpClient, HttpBody, HttpClient, HttpClientError, HttpClientRequest } from "effect/unstable/http"
import { Database } from "@/storage/db"
import { asc } from "drizzle-orm"
import { eq } from "drizzle-orm"
import { inArray } from "drizzle-orm"
import { Project } from "@/project/project"
import { Instance } from "@/project/instance"
import { BusEvent } from "@/bus/bus-event"
import { GlobalBus } from "@/bus/global"
import { Auth } from "@/auth"
@@ -20,13 +21,14 @@ import { getAdapter } from "./adapters"
import { type WorkspaceInfo, WorkspaceInfo as WorkspaceInfoSchema } from "./types"
import { WorkspaceID } from "./schema"
import { Session } from "@/session/session"
import { SessionPrompt } from "@/session/prompt"
import { SessionTable } from "@/session/session.sql"
import { SessionID } from "@/session/schema"
import { errorData } from "@/util/error"
import { waitEvent } from "./util"
import { WorkspaceContext } from "./workspace-context"
import { EffectBridge } from "@/effect/bridge"
import { NonNegativeInt, withStatics } from "@/util/schema"
import { withStatics } from "@/util/schema"
import { zod as effectZod, zodObject } from "@/util/effect-zod"
export const Info = WorkspaceInfoSchema
@@ -38,13 +40,6 @@ export const ConnectionStatus = Schema.Struct({
})
export type ConnectionStatus = Schema.Schema.Type<typeof ConnectionStatus>
const Restore = Schema.Struct({
workspaceID: WorkspaceID,
sessionID: SessionID,
total: NonNegativeInt,
step: NonNegativeInt,
})
export const Event = {
Ready: BusEvent.define(
"workspace.ready",
@@ -58,7 +53,6 @@ export const Event = {
message: Schema.String,
}),
),
Restore: BusEvent.define("workspace.restore", Restore),
Status: BusEvent.define("workspace.status", ConnectionStatus),
}
@@ -84,15 +78,15 @@ export const CreateInput = Schema.Struct({
type: Info.fields.type,
branch: Info.fields.branch,
projectID: ProjectID,
extra: Info.fields.extra,
extra: Schema.optional(Info.fields.extra),
}).pipe(withStatics((s) => ({ zod: effectZod(s), zodObject: zodObject(s) })))
export type CreateInput = Schema.Schema.Type<typeof CreateInput>
export const SessionRestoreInput = Schema.Struct({
workspaceID: WorkspaceID,
export const SessionWarpInput = Schema.Struct({
workspaceID: Schema.NullOr(WorkspaceID),
sessionID: SessionID,
}).pipe(withStatics((s) => ({ zod: effectZod(s), zodObject: zodObject(s) })))
export type SessionRestoreInput = Schema.Schema.Type<typeof SessionRestoreInput>
export type SessionWarpInput = Schema.Schema.Type<typeof SessionWarpInput>
export class SyncHttpError extends Schema.TaggedErrorClass<SyncHttpError>()("WorkspaceSyncHttpError", {
message: Schema.String,
@@ -116,8 +110,8 @@ export class SessionEventsNotFoundError extends Schema.TaggedErrorClass<SessionE
},
) {}
export class SessionRestoreHttpError extends Schema.TaggedErrorClass<SessionRestoreHttpError>()(
"WorkspaceSessionRestoreHttpError",
export class SessionWarpHttpError extends Schema.TaggedErrorClass<SessionWarpHttpError>()(
"WorkspaceSessionWarpHttpError",
{
message: Schema.String,
workspaceID: WorkspaceID,
@@ -138,17 +132,17 @@ export class SyncAbortedError extends Schema.TaggedErrorClass<SyncAbortedError>(
}) {}
type CreateError = Auth.AuthError
type SessionRestoreError =
type SessionWarpError =
| WorkspaceNotFoundError
| SessionEventsNotFoundError
| SessionRestoreHttpError
| SessionWarpHttpError
| HttpClientError.HttpClientError
type WaitForSyncError = SyncTimeoutError | SyncAbortedError
type SyncLoopError = SyncHttpError | HttpClientError.HttpClientError
export interface Interface {
readonly create: (input: CreateInput) => Effect.Effect<Info, CreateError>
readonly sessionRestore: (input: SessionRestoreInput) => Effect.Effect<{ total: number }, SessionRestoreError>
readonly sessionWarp: (input: SessionWarpInput) => Effect.Effect<void, SessionWarpError>
readonly list: (project: Project.Info) => Effect.Effect<Info[]>
readonly get: (id: WorkspaceID) => Effect.Effect<Info | undefined>
readonly remove: (id: WorkspaceID) => Effect.Effect<Info | undefined>
@@ -169,6 +163,7 @@ export const layer = Layer.effect(
Effect.gen(function* () {
const auth = yield* Auth.Service
const session = yield* Session.Service
const prompt = yield* SessionPrompt.Service
const http = yield* HttpClient.HttpClient
const sync = yield* SyncEvent.Service
const connections = new Map<WorkspaceID, ConnectionStatus>()
@@ -461,7 +456,7 @@ export const layer = Layer.effect(
const id = WorkspaceID.ascending(input.id)
const adapter = getAdapter(input.projectID, input.type)
const config = yield* EffectBridge.fromPromise(() =>
adapter.configure({ ...input, id, name: Slug.create(), directory: null }),
adapter.configure({ ...input, id, name: Slug.create(), directory: null, extra: input.extra ?? null }),
)
const info: Info = {
@@ -518,29 +513,93 @@ export const layer = Layer.effect(
return info
})
const sessionRestore = Effect.fn("Workspace.sessionRestore")(function* (input: SessionRestoreInput) {
const sessionWarp = Effect.fn("Workspace.sessionWarp")(function* (input: SessionWarpInput) {
return yield* Effect.gen(function* () {
log.info("session restore requested", {
log.info("session warp requested", {
workspaceID: input.workspaceID,
sessionID: input.sessionID,
})
const space = yield* get(input.workspaceID)
const current = yield* db((db) =>
db
.select({ workspaceID: SessionTable.workspace_id })
.from(SessionTable)
.where(eq(SessionTable.id, input.sessionID))
.get(),
)
if (current?.workspaceID) {
const previous = yield* get(current.workspaceID)
if (previous) {
const adapter = getAdapter(previous.projectID, previous.type)
const target = yield* EffectBridge.fromPromise(() => adapter.target(previous))
if (target.type === "remote") {
yield* syncHistory(previous, target.url, target.headers).pipe(
Effect.catch((error) =>
Effect.sync(() => {
log.warn("session warp final source sync failed", {
workspaceID: previous.id,
sessionID: input.sessionID,
error: errorData(error),
})
}),
),
)
} else {
yield* prompt.cancel(input.sessionID)
}
// "claim" this session so any future events coming from
// the old workspace are ignored
SyncEvent.claim(input.sessionID, input.workspaceID ?? Instance.project.id)
}
}
if (input.workspaceID === null) {
yield* Effect.sync(() =>
SyncEvent.run(Session.Event.Updated, {
sessionID: input.sessionID,
info: {
workspaceID: null,
},
}),
)
log.info("session warp complete", {
workspaceID: input.workspaceID,
sessionID: input.sessionID,
target: "local",
})
return
}
const workspaceID = input.workspaceID
const space = yield* get(workspaceID)
if (!space)
return yield* new WorkspaceNotFoundError({
message: `Workspace not found: ${input.workspaceID}`,
workspaceID: input.workspaceID,
message: `Workspace not found: ${workspaceID}`,
workspaceID,
})
const adapter = getAdapter(space.projectID, space.type)
const target = yield* EffectBridge.fromPromise(() => adapter.target(space))
yield* sync.run(Session.Event.Updated, {
sessionID: input.sessionID,
info: {
if (target.type === "local") {
yield* sync.run(Session.Event.Updated, {
sessionID: input.sessionID,
info: {
workspaceID: input.workspaceID,
},
})
log.info("session warp complete", {
workspaceID: input.workspaceID,
},
})
sessionID: input.sessionID,
target: target.directory,
})
return
}
const rows = yield* db((db) =>
db
@@ -562,130 +621,95 @@ export const layer = Layer.effect(
sessionID: input.sessionID,
})
const size = 10
// TODO: look into using effect APIs to process this in chunks
const sets = Array.from({ length: Math.ceil(rows.length / size) }, (_, i) =>
rows.slice(i * size, (i + 1) * size),
)
const total = sets.length
const batches = Iterable.chunksOf(rows, 10)
const total = Iterable.size(batches)
log.info("session restore prepared", {
log.info("session warp prepared", {
workspaceID: input.workspaceID,
sessionID: input.sessionID,
workspaceType: space.type,
directory: space.directory,
target: target.type === "remote" ? String(route(target.url, "/sync/replay")) : target.directory,
target: String(route(target.url, "/sync/replay")),
events: rows.length,
batches: total,
first: rows[0]?.seq,
last: rows.at(-1)?.seq,
})
yield* Effect.sync(() =>
GlobalBus.emit("event", {
directory: "global",
workspace: input.workspaceID,
payload: {
type: Event.Restore.type,
properties: {
workspaceID: input.workspaceID,
sessionID: input.sessionID,
total,
step: 0,
},
},
}),
)
for (const [i, events] of sets.entries()) {
log.info("session restore batch starting", {
workspaceID: input.workspaceID,
sessionID: input.sessionID,
step: i + 1,
total,
events: events.length,
first: events[0]?.seq,
last: events.at(-1)?.seq,
target: target.type === "remote" ? String(route(target.url, "/sync/replay")) : target.directory,
})
if (target.type === "local") {
yield* sync.replayAll(events)
log.info("session restore batch replayed locally", {
workspaceID: input.workspaceID,
sessionID: input.sessionID,
step: i + 1,
total,
events: events.length,
})
} else {
const url = route(target.url, "/sync/replay")
const res = yield* http.execute(
HttpClientRequest.post(url, {
headers: new Headers(target.headers),
body: HttpBody.jsonUnsafe({
directory: space.directory ?? "",
events,
yield* Effect.forEach(
batches,
(events, i) =>
Effect.gen(function* () {
const response = yield* http.execute(
HttpClientRequest.post(route(target.url, "/sync/replay"), {
headers: new Headers(target.headers),
body: HttpBody.jsonUnsafe({
directory: space.directory ?? "",
events,
}),
}),
}),
)
)
if (res.status < 200 || res.status >= 300) {
const body = yield* res.text
log.error("session restore batch failed", {
if (response.status < 200 || response.status >= 300) {
const body = yield* response.text
log.error("session warp batch failed", {
workspaceID: input.workspaceID,
sessionID: input.sessionID,
step: i + 1,
total,
status: response.status,
body,
})
return yield* new SessionWarpHttpError({
message: `Failed to warp session ${input.sessionID} into workspace ${workspaceID}: HTTP ${response.status} ${body}`,
workspaceID,
sessionID: input.sessionID,
status: response.status,
body,
})
}
log.info("session warp batch posted", {
workspaceID: input.workspaceID,
sessionID: input.sessionID,
step: i + 1,
total,
status: res.status,
body,
status: response.status,
})
return yield* new SessionRestoreHttpError({
message: `Failed to replay session ${input.sessionID} into workspace ${input.workspaceID}: HTTP ${res.status} ${body}`,
workspaceID: input.workspaceID,
sessionID: input.sessionID,
status: res.status,
body,
})
}
log.info("session restore batch posted", {
workspaceID: input.workspaceID,
sessionID: input.sessionID,
step: i + 1,
total,
status: res.status,
})
}
yield* Effect.sync(() =>
GlobalBus.emit("event", {
directory: "global",
workspace: input.workspaceID,
payload: {
type: Event.Restore.type,
properties: {
workspaceID: input.workspaceID,
sessionID: input.sessionID,
total,
step: i + 1,
},
},
}),
)
{ discard: true },
)
const response = yield* http.execute(
HttpClientRequest.post(route(target.url, "/sync/steal"), {
headers: new Headers(target.headers),
body: HttpBody.jsonUnsafe({ sessionID: input.sessionID }),
}),
)
if (response.status < 200 || response.status >= 300) {
const body = yield* response.text
log.error("session warp steal failed", {
workspaceID: input.workspaceID,
sessionID: input.sessionID,
status: response.status,
body,
})
return yield* new SessionWarpHttpError({
message: `Failed to steal session ${input.sessionID} into workspace ${workspaceID}: HTTP ${response.status} ${body}`,
workspaceID,
sessionID: input.sessionID,
status: response.status,
body,
})
}
log.info("session restore complete", {
log.info("session warp complete", {
workspaceID: input.workspaceID,
sessionID: input.sessionID,
batches: total,
})
return { total }
}).pipe(
Effect.tapError((err) =>
Effect.sync(() =>
log.error("session restore failed", {
log.error("session warp failed", {
workspaceID: input.workspaceID,
sessionID: input.sessionID,
error: errorData(err),
@@ -715,9 +739,18 @@ export const layer = Layer.effect(
const remove = Effect.fn("Workspace.remove")(function* (id: WorkspaceID) {
const sessions = yield* db((db) =>
db.select({ id: SessionTable.id }).from(SessionTable).where(eq(SessionTable.workspace_id, id)).all(),
db
.select({ id: SessionTable.id, parentID: SessionTable.parent_id })
.from(SessionTable)
.where(eq(SessionTable.workspace_id, id))
.all(),
)
const sessionIDs = new Set(sessions.map((sessionInfo) => sessionInfo.id))
yield* Effect.forEach(
sessions.filter((sessionInfo) => !sessionInfo.parentID || !sessionIDs.has(sessionInfo.parentID)),
(sessionInfo) => session.remove(sessionInfo.id).pipe(Effect.orDie),
{ discard: true },
)
yield* Effect.forEach(sessions, (sessionInfo) => session.remove(sessionInfo.id), { discard: true })
const row = yield* db((db) => db.select().from(WorkspaceTable).where(eq(WorkspaceTable.id, id)).get())
if (!row) return
@@ -814,7 +847,7 @@ export const layer = Layer.effect(
return Service.of({
create,
sessionRestore,
sessionWarp,
list,
get,
remove,
@@ -830,6 +863,7 @@ export const defaultLayer = layer.pipe(
Layer.provide(Auth.defaultLayer),
Layer.provide(Session.defaultLayer),
Layer.provide(SyncEvent.defaultLayer),
Layer.provide(SessionPrompt.defaultLayer),
Layer.provide(FetchHttpClient.layer),
)

View File

@@ -13,6 +13,7 @@ const prefixes = {
tool: "tool",
workspace: "wrk",
entry: "ent",
account: "act",
} as const
export function schema(prefix: keyof typeof prefixes) {

View File

@@ -85,7 +85,9 @@ const fileFromPatchChunk = (chunk: string) => {
}
const splitGitPatch = (patch: Git.Patch) => {
const starts = [...patch.text.matchAll(/^diff --git /gm)].map((match) => match.index)
const starts = [...patch.text.matchAll(/(?:^|\n)diff --git /g)].map((match) =>
match[0].startsWith("\n") ? match.index + 1 : match.index,
)
const chunks = starts.map((start, index) => patch.text.slice(start, starts[index + 1] ?? patch.text.length))
if (!patch.truncated) return chunks
return chunks.slice(0, -1)

View File

@@ -10,10 +10,6 @@ import { zodObject } from "@/util/effect-zod"
import { Instance } from "@/project/instance"
import { errors } from "../../error"
import { lazy } from "@/util/lazy"
import * as Log from "@opencode-ai/core/util/log"
import { errorData } from "@/util/error"
const log = Log.create({ service: "server.workspace" })
export const WorkspaceRoutes = lazy(() =>
new Hono()
@@ -151,60 +147,36 @@ export const WorkspaceRoutes = lazy(() =>
},
)
.post(
"/:id/session-restore",
"/warp",
describeRoute({
summary: "Restore session into workspace",
description: "Replay a session's sync events into the target workspace in batches.",
operationId: "experimental.workspace.sessionRestore",
summary: "Warp session into workspace",
description: "Move a session's sync history into the target workspace, or detach it to the local project.",
operationId: "experimental.workspace.warp",
responses: {
200: {
description: "Session replay started",
content: {
"application/json": {
schema: resolver(
z.object({
total: z.number().int().min(0),
}),
),
},
},
204: {
description: "Session warped",
},
...errors(400),
},
}),
validator("param", z.object({ id: zodObject(Workspace.Info).shape.id })),
validator("json", Workspace.SessionRestoreInput.zodObject.omit({ workspaceID: true })),
validator(
"json",
z.object({
id: zodObject(Workspace.Info).shape.id.nullable(),
sessionID: Workspace.SessionWarpInput.zodObject.shape.sessionID,
}),
),
async (c) => {
const { id } = c.req.valid("param")
const body = c.req.valid("json") as Omit<Workspace.SessionRestoreInput, "workspaceID">
log.info("session restore route requested", {
workspaceID: id,
sessionID: body.sessionID,
directory: Instance.directory,
})
try {
const result = await AppRuntime.runPromise(
Workspace.Service.use((svc) =>
svc.sessionRestore({
workspaceID: id,
...body,
}),
),
)
log.info("session restore route complete", {
workspaceID: id,
sessionID: body.sessionID,
total: result.total,
})
return c.json(result)
} catch (err) {
log.error("session restore route failed", {
workspaceID: id,
sessionID: body.sessionID,
error: errorData(err),
})
throw err
}
const body = c.req.valid("json")
await AppRuntime.runPromise(
Workspace.Service.use((workspace) =>
workspace.sessionWarp({
workspaceID: body.id,
sessionID: body.sessionID,
}),
),
)
return c.body(null, 204)
},
),
)

View File

@@ -67,6 +67,16 @@ export const PermissionResponsePayload = Schema.Struct({
response: Permission.Reply,
})
export class SessionNotFoundError extends Schema.ErrorClass<SessionNotFoundError>("NotFoundError")(
{
name: Schema.Literal("NotFoundError"),
data: Schema.Struct({
message: Schema.String,
}),
},
{ httpApiStatus: 404 },
) {}
export const SessionPaths = {
list: root,
status: `${root}/status`,
@@ -123,7 +133,7 @@ export const SessionApi = HttpApi.make("session")
HttpApiEndpoint.get("get", SessionPaths.get, {
params: { sessionID: SessionID },
success: described(Session.Info, "Get session"),
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
error: [HttpApiError.BadRequest, SessionNotFoundError],
}).annotateMerge(
OpenApi.annotations({
identifier: "session.get",
@@ -168,7 +178,7 @@ export const SessionApi = HttpApi.make("session")
params: { sessionID: SessionID },
query: MessagesQuery,
success: described(Schema.Array(MessageV2.WithParts), "List of messages"),
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
error: [HttpApiError.BadRequest, SessionNotFoundError],
}).annotateMerge(
OpenApi.annotations({
identifier: "session.messages",
@@ -179,7 +189,7 @@ export const SessionApi = HttpApi.make("session")
HttpApiEndpoint.get("message", SessionPaths.message, {
params: { sessionID: SessionID, messageID: MessageID },
success: described(MessageV2.WithParts, "Message"),
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
error: [HttpApiError.BadRequest, SessionNotFoundError],
}).annotateMerge(
OpenApi.annotations({
identifier: "session.message",
@@ -201,7 +211,7 @@ export const SessionApi = HttpApi.make("session")
HttpApiEndpoint.delete("remove", SessionPaths.remove, {
params: { sessionID: SessionID },
success: described(Schema.Boolean, "Successfully deleted session"),
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
error: [HttpApiError.BadRequest, SessionNotFoundError],
}).annotateMerge(
OpenApi.annotations({
identifier: "session.delete",
@@ -213,7 +223,7 @@ export const SessionApi = HttpApi.make("session")
params: { sessionID: SessionID },
payload: UpdatePayload,
success: described(Session.Info, "Successfully updated session"),
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
error: [HttpApiError.BadRequest, SessionNotFoundError],
}).annotateMerge(
OpenApi.annotations({
identifier: "session.update",
@@ -225,6 +235,7 @@ export const SessionApi = HttpApi.make("session")
params: { sessionID: SessionID },
payload: ForkPayload,
success: described(Session.Info, "200"),
error: SessionNotFoundError,
}).annotateMerge(
OpenApi.annotations({
identifier: "session.fork",
@@ -259,7 +270,7 @@ export const SessionApi = HttpApi.make("session")
HttpApiEndpoint.post("share", SessionPaths.share, {
params: { sessionID: SessionID },
success: described(Session.Info, "Successfully shared session"),
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
error: [HttpApiError.BadRequest, SessionNotFoundError],
}).annotateMerge(
OpenApi.annotations({
identifier: "session.share",
@@ -270,7 +281,7 @@ export const SessionApi = HttpApi.make("session")
HttpApiEndpoint.delete("unshare", SessionPaths.share, {
params: { sessionID: SessionID },
success: described(Session.Info, "Successfully unshared session"),
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
error: [HttpApiError.BadRequest, SessionNotFoundError],
}).annotateMerge(
OpenApi.annotations({
identifier: "session.unshare",
@@ -282,7 +293,7 @@ export const SessionApi = HttpApi.make("session")
params: { sessionID: SessionID },
payload: SummarizePayload,
success: described(Schema.Boolean, "Summarized session"),
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
error: [HttpApiError.BadRequest, SessionNotFoundError],
}).annotateMerge(
OpenApi.annotations({
identifier: "session.summarize",

View File

@@ -1,4 +1,5 @@
import { NonNegativeInt } from "@/util/schema"
import { SessionID } from "@/session/schema"
import { Schema } from "effect"
import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { Authorization } from "../middleware/authorization"
@@ -21,6 +22,9 @@ export const ReplayPayload = Schema.Struct({
export const ReplayResponse = Schema.Struct({
sessionID: Schema.String,
})
export const SessionPayload = Schema.Struct({
sessionID: SessionID,
})
export const HistoryPayload = Schema.Record(Schema.String, NonNegativeInt)
export const HistoryEvent = Schema.Struct({
id: Schema.String,
@@ -33,6 +37,7 @@ export const HistoryEvent = Schema.Struct({
export const SyncPaths = {
start: `${root}/start`,
replay: `${root}/replay`,
steal: `${root}/steal`,
history: `${root}/history`,
} as const
@@ -60,6 +65,17 @@ export const SyncApi = HttpApi.make("sync")
description: "Validate and replay a complete sync event history.",
}),
),
HttpApiEndpoint.post("steal", SyncPaths.steal, {
payload: SessionPayload,
success: described(SessionPayload, "Session stolen into workspace"),
error: HttpApiError.BadRequest,
}).annotateMerge(
OpenApi.annotations({
identifier: "sync.steal",
summary: "Steal session into workspace",
description: "Update a session to belong to the current workspace through the sync event system.",
}),
),
HttpApiEndpoint.post("history", SyncPaths.history, {
payload: HistoryPayload,
success: described(Schema.Array(HistoryEvent), "Sync events"),

View File

@@ -1,21 +1,17 @@
import { Workspace } from "@/control-plane/workspace"
import { WorkspaceAdapterEntry } from "@/control-plane/types"
import { NonNegativeInt } from "@/util/schema"
import { Schema, Struct } from "effect"
import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
import { Authorization } from "../middleware/authorization"
import { InstanceContextMiddleware } from "../middleware/instance-context"
import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing"
import { described } from "./metadata"
const root = "/experimental/workspace"
export const CreatePayload = Schema.Struct({
...Struct.omit(Workspace.CreateInput.fields, ["projectID", "extra"]),
extra: Schema.optional(Workspace.CreateInput.fields.extra),
})
export const SessionRestorePayload = Schema.Struct(Struct.omit(Workspace.SessionRestoreInput.fields, ["workspaceID"]))
export const SessionRestoreResponse = Schema.Struct({
total: NonNegativeInt,
export const CreatePayload = Schema.Struct(Struct.omit(Workspace.CreateInput.fields, ["projectID"]))
export const WarpPayload = Schema.Struct({
id: Schema.NullOr(Workspace.Info.fields.id),
sessionID: Workspace.SessionWarpInput.fields.sessionID,
})
export const WorkspacePaths = {
@@ -23,7 +19,7 @@ export const WorkspacePaths = {
list: root,
status: `${root}/status`,
remove: `${root}/:id`,
sessionRestore: `${root}/:id/session-restore`,
warp: `${root}/warp`,
} as const
export const WorkspaceApi = HttpApi.make("workspace")
@@ -79,16 +75,15 @@ export const WorkspaceApi = HttpApi.make("workspace")
description: "Remove an existing workspace.",
}),
),
HttpApiEndpoint.post("sessionRestore", WorkspacePaths.sessionRestore, {
params: { id: Workspace.Info.fields.id },
payload: SessionRestorePayload,
success: described(SessionRestoreResponse, "Session replay started"),
HttpApiEndpoint.post("warp", WorkspacePaths.warp, {
payload: WarpPayload,
success: described(HttpApiSchema.NoContent, "Session warped"),
error: HttpApiError.BadRequest,
}).annotateMerge(
OpenApi.annotations({
identifier: "experimental.workspace.sessionRestore",
summary: "Restore session into workspace",
description: "Replay a session's sync events into the target workspace in batches.",
identifier: "experimental.workspace.warp",
summary: "Warp session into workspace",
description: "Move a session's sync history into the target workspace, or detach it to the local project.",
}),
),
)

View File

@@ -33,18 +33,19 @@ import {
PermissionResponsePayload,
PromptPayload,
RevertPayload,
SessionNotFoundError,
ShellPayload,
SummarizePayload,
UpdatePayload,
} from "../groups/session"
const mapNotFound = <A, E, R>(self: Effect.Effect<A, E, R>) =>
self.pipe(
Effect.catchIf(NotFoundError.isInstance, () => Effect.fail(new HttpApiError.NotFound({}))),
Effect.catchDefect((error) =>
NotFoundError.isInstance(error) ? Effect.fail(new HttpApiError.NotFound({})) : Effect.die(error),
),
)
type StorageNotFound = InstanceType<typeof NotFoundError>
const sessionNotFound = (error: StorageNotFound) =>
new SessionNotFoundError({ name: "NotFoundError", data: error.data })
const mapNotFound = <A, R>(self: Effect.Effect<A, StorageNotFound, R>): Effect.Effect<A, SessionNotFoundError, R> =>
self.pipe(Effect.mapError(sessionNotFound))
export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session", (handlers) =>
Effect.gen(function* () {
@@ -101,51 +102,50 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session",
params: { sessionID: SessionID }
query: typeof MessagesQuery.Type
}) {
return yield* mapNotFound(
Effect.gen(function* () {
if (ctx.query.before && ctx.query.limit === undefined) return yield* new HttpApiError.BadRequest({})
if (ctx.query.before) {
const before = ctx.query.before
yield* Effect.try({
try: () => MessageV2.cursor.decode(before),
catch: () => new HttpApiError.BadRequest({}),
})
}
if (ctx.query.limit === undefined || ctx.query.limit === 0) {
yield* session.get(ctx.params.sessionID)
return yield* session.messages({ sessionID: ctx.params.sessionID })
}
if (ctx.query.before && ctx.query.limit === undefined) return yield* new HttpApiError.BadRequest({})
if (ctx.query.before) {
const before = ctx.query.before
yield* Effect.try({
try: () => MessageV2.cursor.decode(before),
catch: () => new HttpApiError.BadRequest({}),
})
}
if (ctx.query.limit === undefined || ctx.query.limit === 0) {
yield* mapNotFound(session.get(ctx.params.sessionID))
return yield* session.messages({ sessionID: ctx.params.sessionID })
}
yield* session.get(ctx.params.sessionID)
const page = MessageV2.page({
sessionID: ctx.params.sessionID,
limit: ctx.query.limit,
before: ctx.query.before,
})
if (!page.cursor) return page.items
yield* mapNotFound(session.get(ctx.params.sessionID))
const page = MessageV2.page({
sessionID: ctx.params.sessionID,
limit: ctx.query.limit,
before: ctx.query.before,
})
if (!page.cursor) return page.items
const request = yield* HttpServerRequest.HttpServerRequest
// toURL() honors the Host + x-forwarded-proto headers, so the Link
// header echoes the real origin instead of a hard-coded localhost.
const url = Option.getOrElse(HttpServerRequest.toURL(request), () => new URL(request.url, "http://localhost"))
url.searchParams.set("limit", ctx.query.limit.toString())
url.searchParams.set("before", page.cursor)
return HttpServerResponse.jsonUnsafe(page.items, {
headers: {
"Access-Control-Expose-Headers": "Link, X-Next-Cursor",
Link: `<${url.toString()}>; rel="next"`,
"X-Next-Cursor": page.cursor,
},
})
}),
)
const request = yield* HttpServerRequest.HttpServerRequest
// toURL() honors the Host + x-forwarded-proto headers, so the Link
// header echoes the real origin instead of a hard-coded localhost.
const url = Option.getOrElse(HttpServerRequest.toURL(request), () => new URL(request.url, "http://localhost"))
url.searchParams.set("limit", ctx.query.limit.toString())
url.searchParams.set("before", page.cursor)
return HttpServerResponse.jsonUnsafe(page.items, {
headers: {
"Access-Control-Expose-Headers": "Link, X-Next-Cursor",
Link: `<${url.toString()}>; rel="next"`,
"X-Next-Cursor": page.cursor,
},
})
})
const message = Effect.fn("SessionHttpApi.message")(function* (ctx: {
params: { sessionID: SessionID; messageID: MessageID }
}) {
return yield* mapNotFound(
Effect.sync(() => MessageV2.get({ sessionID: ctx.params.sessionID, messageID: ctx.params.messageID })),
Effect.try({
try: () => MessageV2.get({ sessionID: ctx.params.sessionID, messageID: ctx.params.messageID }),
catch: (error) => error,
}).pipe(Effect.catch((error) => (NotFoundError.isInstance(error) ? Effect.fail(error) : Effect.die(error)))),
)
})
@@ -170,7 +170,7 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session",
})
const remove = Effect.fn("SessionHttpApi.remove")(function* (ctx: { params: { sessionID: SessionID } }) {
yield* session.remove(ctx.params.sessionID)
yield* mapNotFound(session.remove(ctx.params.sessionID))
return true
})
@@ -178,7 +178,7 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session",
params: { sessionID: SessionID }
payload: typeof UpdatePayload.Type
}) {
const current = yield* session.get(ctx.params.sessionID)
const current = yield* mapNotFound(session.get(ctx.params.sessionID))
if (ctx.payload.title !== undefined) {
yield* session.setTitle({ sessionID: ctx.params.sessionID, title: ctx.payload.title })
}
@@ -191,14 +191,14 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session",
if (ctx.payload.time?.archived !== undefined) {
yield* session.setArchived({ sessionID: ctx.params.sessionID, time: ctx.payload.time.archived })
}
return yield* session.get(ctx.params.sessionID)
return yield* mapNotFound(session.get(ctx.params.sessionID))
})
const fork = Effect.fn("SessionHttpApi.fork")(function* (ctx: {
params: { sessionID: SessionID }
payload: typeof ForkPayload.Type
}) {
return yield* session.fork({ sessionID: ctx.params.sessionID, messageID: ctx.payload.messageID })
return yield* mapNotFound(session.fork({ sessionID: ctx.params.sessionID, messageID: ctx.payload.messageID }))
})
const abort = Effect.fn("SessionHttpApi.abort")(function* (ctx: { params: { sessionID: SessionID } }) {
@@ -222,19 +222,19 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session",
const share = Effect.fn("SessionHttpApi.share")(function* (ctx: { params: { sessionID: SessionID } }) {
yield* shareSvc.share(ctx.params.sessionID).pipe(Effect.mapError(() => new HttpApiError.BadRequest({})))
return yield* session.get(ctx.params.sessionID)
return yield* mapNotFound(session.get(ctx.params.sessionID))
})
const unshare = Effect.fn("SessionHttpApi.unshare")(function* (ctx: { params: { sessionID: SessionID } }) {
yield* shareSvc.unshare(ctx.params.sessionID).pipe(Effect.mapError(() => new HttpApiError.BadRequest({})))
return yield* session.get(ctx.params.sessionID)
return yield* mapNotFound(session.get(ctx.params.sessionID))
})
const summarize = Effect.fn("SessionHttpApi.summarize")(function* (ctx: {
params: { sessionID: SessionID }
payload: typeof SummarizePayload.Type
}) {
yield* revertSvc.cleanup(yield* session.get(ctx.params.sessionID))
yield* revertSvc.cleanup(yield* mapNotFound(session.get(ctx.params.sessionID)))
const messages = yield* session.messages({ sessionID: ctx.params.sessionID })
const defaultAgent = yield* agentSvc.defaultAgent()
const currentAgent = messages.findLast((message) => message.info.role === "user")?.info.agent ?? defaultAgent

View File

@@ -1,5 +1,6 @@
import { Workspace } from "@/control-plane/workspace"
import * as InstanceState from "@/effect/instance-state"
import { Session } from "@/session/session"
import { Database } from "@/storage/db"
import { SyncEvent } from "@/sync"
import { EventTable } from "@/sync/event.sql"
@@ -12,7 +13,7 @@ import { or } from "drizzle-orm"
import { Effect, Scope } from "effect"
import { HttpApiBuilder } from "effect/unstable/httpapi"
import { InstanceHttpApi } from "../api"
import { HistoryPayload, ReplayPayload } from "../groups/sync"
import { HistoryPayload, ReplayPayload, SessionPayload } from "../groups/sync"
import * as Log from "@opencode-ai/core/util/log"
const log = Log.create({ service: "server.sync" })
@@ -56,6 +57,25 @@ export const syncHandlers = HttpApiBuilder.group(InstanceHttpApi, "sync", (handl
return { sessionID: source }
})
const steal = Effect.fn("SyncHttpApi.steal")(function* (ctx: { payload: typeof SessionPayload.Type }) {
const workspaceID = yield* InstanceState.workspaceID
if (!workspaceID) throw new Error("Cannot steal session without workspace context")
yield* sync.run(Session.Event.Updated, {
sessionID: ctx.payload.sessionID,
info: {
workspaceID,
},
})
log.info("sync session stolen", {
sessionID: ctx.payload.sessionID,
workspaceID,
})
return { sessionID: ctx.payload.sessionID }
})
const history = Effect.fn("SyncHttpApi.history")(function* (ctx: { payload: typeof HistoryPayload.Type }) {
const exclude = Object.entries(ctx.payload)
return Database.use((db) =>
@@ -72,6 +92,6 @@ export const syncHandlers = HttpApiBuilder.group(InstanceHttpApi, "sync", (handl
)
})
return handlers.handle("start", start).handle("replay", replay).handle("history", history)
return handlers.handle("start", start).handle("replay", replay).handle("steal", steal).handle("history", history)
}),
)

View File

@@ -4,7 +4,7 @@ import * as InstanceState from "@/effect/instance-state"
import { Effect } from "effect"
import { HttpApiBuilder, HttpApiError } from "effect/unstable/httpapi"
import { InstanceHttpApi } from "../api"
import { CreatePayload, SessionRestorePayload } from "../groups/workspace"
import { CreatePayload, WarpPayload } from "../groups/workspace"
export const workspaceHandlers = HttpApiBuilder.group(InstanceHttpApi, "workspace", (handlers) =>
Effect.gen(function* () {
@@ -39,13 +39,10 @@ export const workspaceHandlers = HttpApiBuilder.group(InstanceHttpApi, "workspac
return yield* workspace.remove(ctx.params.id)
})
const sessionRestore = Effect.fn("WorkspaceHttpApi.sessionRestore")(function* (ctx: {
params: { id: Workspace.Info["id"] }
payload: typeof SessionRestorePayload.Type
}) {
return yield* workspace
.sessionRestore({
workspaceID: ctx.params.id,
const warp = Effect.fn("WorkspaceHttpApi.warp")(function* (ctx: { payload: typeof WarpPayload.Type }) {
yield* workspace
.sessionWarp({
workspaceID: ctx.payload.id,
sessionID: ctx.payload.sessionID,
})
.pipe(Effect.mapError(() => new HttpApiError.BadRequest({})))
@@ -57,6 +54,6 @@ export const workspaceHandlers = HttpApiBuilder.group(InstanceHttpApi, "workspac
.handle("create", create)
.handle("status", status)
.handle("remove", remove)
.handle("sessionRestore", sessionRestore)
.handle("warp", warp)
}),
)

View File

@@ -0,0 +1,58 @@
import { Provider } from "@/provider/provider"
import { Session } from "@/session/session"
import { NotFoundError } from "@/storage/storage"
import { iife } from "@/util/iife"
import { NamedError } from "@opencode-ai/core/util/error"
import * as Log from "@opencode-ai/core/util/log"
import { Cause, Effect } from "effect"
import { HttpRouter, HttpServerError, HttpServerRespondable, HttpServerResponse } from "effect/unstable/http"
const log = Log.create({ service: "server" })
// Keep typed HttpApi failures on their declared error path; this boundary only replaces defect-only empty 500s.
export const errorLayer = HttpRouter.middleware<{ handles: unknown }>()((effect) =>
effect.pipe(
Effect.catchCause((cause) => {
const defect = cause.reasons.filter(Cause.isDieReason).find((reason) => {
if (HttpServerResponse.isHttpServerResponse(reason.defect)) return false
if (HttpServerError.isHttpServerError(reason.defect)) return false
if (HttpServerRespondable.isRespondable(reason.defect)) return false
return true
})
if (!defect) return Effect.failCause(cause)
const error = defect.defect
log.error("failed", { error, cause: Cause.pretty(cause) })
if (error instanceof NamedError) {
return Effect.succeed(
HttpServerResponse.jsonUnsafe(error.toObject(), {
status: iife(() => {
if (error instanceof NotFoundError) return 404
if (error instanceof Provider.ModelNotFoundError) return 400
if (error.name === "ProviderAuthValidationFailed") return 400
if (error.name.startsWith("Worktree")) return 400
return 500
}),
}),
)
}
if (error instanceof Session.BusyError) {
return Effect.succeed(
HttpServerResponse.jsonUnsafe(new NamedError.Unknown({ message: error.message }).toObject(), {
status: 400,
}),
)
}
return Effect.succeed(
HttpServerResponse.jsonUnsafe(
new NamedError.Unknown({
message: error instanceof Error && error.stack ? error.stack : String(error),
}).toObject(),
{ status: 500 },
),
)
}),
),
).layer

View File

@@ -7,6 +7,7 @@ import { Session } from "@/session/session"
import { HttpApiProxy } from "./proxy"
import * as Fence from "@/server/shared/fence"
import { getWorkspaceRouteSessionID, isLocalWorkspaceRoute, workspaceProxyURL } from "@/server/shared/workspace-routing"
import { NotFoundError } from "@/storage/storage"
import { Flag } from "@opencode-ai/core/flag/flag"
import { Context, Data, Effect, Layer } from "effect"
import { HttpClient, HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
@@ -178,7 +179,13 @@ function routeHttpApiWorkspace<E>(
const request = yield* HttpServerRequest.HttpServerRequest
const sessionID = getWorkspaceRouteSessionID(requestURL(request))
const session = sessionID
? yield* Session.Service.use((svc) => svc.get(sessionID)).pipe(Effect.catchDefect(() => Effect.void))
? yield* Session.Service.use((svc) => svc.get(sessionID)).pipe(
Effect.catchIf(
(error): error is InstanceType<typeof NotFoundError> => NotFoundError.isInstance(error),
() => Effect.succeed(undefined),
),
Effect.catchDefect(() => Effect.succeed(undefined)),
)
: undefined
const plan = yield* planRequest(request, session?.workspaceID)
return yield* routeWorkspace(client, effect, plan)

View File

@@ -73,6 +73,7 @@ import { workspaceRouterMiddleware, workspaceRoutingLayer } from "./middleware/w
import { disposeMiddleware } from "./lifecycle"
import { memoMap } from "@opencode-ai/core/effect/memo-map"
import * as ServerBackend from "@/server/backend"
import { errorLayer } from "./middleware/error"
export const context = Context.makeUnsafe<unknown>(new Map())
@@ -144,6 +145,7 @@ const uiRoute = HttpRouter.use((router) =>
export function createRoutes(corsOptions?: CorsOptions) {
return Layer.mergeAll(rootApiRoutes, eventApiRoutes, instanceRoutes, uiRoute).pipe(
Layer.provide([
errorLayer,
cors(corsOptions),
runtime,
Account.defaultLayer,

View File

@@ -155,7 +155,7 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket, opts?: CorsOptions): H
app.get(WorkspacePaths.list, (c) => handler(c.req.raw, context))
app.get(WorkspacePaths.status, (c) => handler(c.req.raw, context))
app.delete(WorkspacePaths.remove, (c) => handler(c.req.raw, context))
app.post(WorkspacePaths.sessionRestore, (c) => handler(c.req.raw, context))
app.post(WorkspacePaths.warp, (c) => handler(c.req.raw, context))
}
return app

View File

@@ -16,6 +16,9 @@ import { Workspace } from "@/control-plane/workspace"
import { AppRuntime } from "@/effect/app-runtime"
import { Instance } from "@/project/instance"
import { errors } from "../../error"
import { Session } from "@/session/session"
import { WorkspaceContext } from "@/control-plane/workspace-context"
import { SessionID } from "@/session/schema"
const ReplayEvent = z.object({
id: z.string(),
@@ -24,6 +27,9 @@ const ReplayEvent = z.object({
type: z.string(),
data: z.record(z.string(), z.unknown()),
})
const SessionPayload = z.object({
sessionID: SessionID.zod,
})
const log = Log.create({ service: "server.sync" })
@@ -108,6 +114,47 @@ export const SyncRoutes = lazy(() =>
})
},
)
.post(
"/steal",
describeRoute({
summary: "Steal session into workspace",
description: "Update a session to belong to the current workspace through the sync event system.",
operationId: "sync.steal",
responses: {
200: {
description: "Session stolen into workspace",
content: {
"application/json": {
schema: resolver(SessionPayload),
},
},
},
...errors(400),
},
}),
validator("json", SessionPayload),
async (c) => {
const body = c.req.valid("json")
const workspaceID = WorkspaceContext.workspaceID
if (!workspaceID) throw new Error("Cannot steal session without workspace context")
SyncEvent.run(Session.Event.Updated, {
sessionID: body.sessionID,
info: {
workspaceID,
},
})
log.info("sync session stolen", {
sessionID: body.sessionID,
workspaceID,
})
return c.json({
sessionID: body.sessionID,
})
},
)
.post(
"/history",
describeRoute({

View File

@@ -22,6 +22,7 @@ import * as Log from "@opencode-ai/core/util/log"
import { isRecord } from "@/util/record"
import { EventV2 } from "@/v2/event"
import { SessionEvent } from "@/v2/session-event"
import { Modelv2 } from "@/v2/model"
import * as DateTime from "effect/DateTime"
const DOOM_LOOP_THRESHOLD = 3
@@ -432,9 +433,9 @@ export const layer: Layer.Layer<
sessionID: ctx.sessionID,
agent: input.assistantMessage.agent,
model: {
id: ctx.model.id,
providerID: ctx.model.providerID,
variant: input.assistantMessage.variant,
id: Modelv2.ID.make(ctx.model.id),
providerID: Modelv2.ProviderID.make(ctx.model.providerID),
variant: Modelv2.VariantID.make(input.assistantMessage.variant ?? "default"),
},
snapshot: ctx.snapshot,
timestamp: DateTime.makeUnsafe(Date.now()),
@@ -655,7 +656,7 @@ export const layer: Layer.Layer<
EventV2.run(SessionEvent.Step.Failed.Sync, {
sessionID: ctx.sessionID,
error: {
type: error.name,
type: "unknown",
message: errorMessage(e),
},
timestamp: DateTime.makeUnsafe(Date.now()),

View File

@@ -132,11 +132,7 @@ export default [
SyncEvent.project(SessionEvent.ModelSwitched.Sync, (db, data, event) => {
db.update(SessionTable)
.set({
model: {
id: data.id,
providerID: data.providerID,
variant: data.variant,
},
model: data.model,
time_updated: DateTime.toEpochMillis(data.timestamp),
})
.where(eq(SessionTable.id, data.sessionID))

View File

@@ -1,6 +1,5 @@
import path from "path"
import os from "os"
import z from "zod"
import * as EffectZod from "@/util/effect-zod"
import { SessionID, MessageID, PartID } from "./schema"
import { MessageV2 } from "./message-v2"
@@ -56,6 +55,7 @@ import { SessionRunState } from "./run-state"
import { EffectBridge } from "@/effect/bridge"
import { EventV2 } from "@/v2/event"
import { SessionEvent } from "@/v2/session-event"
import { Modelv2 } from "@/v2/model"
import { AgentAttachment, FileAttachment, Source } from "@/v2/session-prompt"
import * as DateTime from "effect/DateTime"
import { eq } from "@/storage/db"
@@ -120,9 +120,8 @@ export const layer = Layer.effect(
return yield* EffectBridge.make()
})
const ops = Effect.fn("SessionPrompt.ops")(function* () {
const run = yield* runner()
return {
cancel: (sessionID: SessionID) => run.fork(cancel(sessionID)),
cancel: (sessionID: SessionID) => cancel(sessionID),
resolvePromptParts: (template: string) => resolvePromptParts(template),
prompt: (input: PromptInput) => prompt(input),
} satisfies TaskPromptOps
@@ -745,7 +744,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
const markReady = ready ? ready.open.pipe(Effect.asVoid) : Effect.void
const { msg, part, cwd } = yield* Effect.gen(function* () {
const ctx = yield* InstanceState.context
const session = yield* sessions.get(input.sessionID)
const session = yield* sessions.get(input.sessionID).pipe(Effect.orDie)
if (session.revert) {
yield* revert.cleanup(session)
}
@@ -978,9 +977,11 @@ NOTE: At any point in time through this workflow you should feel free to ask the
EventV2.run(SessionEvent.ModelSwitched.Sync, {
sessionID: input.sessionID,
timestamp: DateTime.makeUnsafe(info.time.created),
id: info.model.modelID,
providerID: info.model.providerID,
variant: info.model.variant,
model: {
id: Modelv2.ID.make(info.model.modelID),
providerID: Modelv2.ProviderID.make(info.model.providerID),
variant: Modelv2.VariantID.make(info.model.variant ?? "default"),
},
})
}
@@ -1369,7 +1370,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
const prompt: (input: PromptInput) => Effect.Effect<MessageV2.WithParts> = Effect.fn("SessionPrompt.prompt")(
function* (input: PromptInput) {
const session = yield* sessions.get(input.sessionID)
const session = yield* sessions.get(input.sessionID).pipe(Effect.orDie)
yield* revert.cleanup(session)
const message = yield* createUserMessage(input)
yield* sessions.touch(input.sessionID)
@@ -1400,9 +1401,9 @@ NOTE: At any point in time through this workflow you should feel free to ask the
function* (sessionID: SessionID) {
const ctx = yield* InstanceState.context
const slog = elog.with({ sessionID })
let structured: unknown | undefined
let structured: unknown
let step = 0
const session = yield* sessions.get(sessionID)
const session = yield* sessions.get(sessionID).pipe(Effect.orDie)
while (true) {
yield* status.set(sessionID, { type: "busy" })

View File

@@ -44,7 +44,7 @@ export const layer = Layer.effect(
yield* state.assertNotBusy(input.sessionID)
const all = yield* sessions.messages({ sessionID: input.sessionID })
let lastUser: MessageV2.User | undefined
const session = yield* sessions.get(input.sessionID)
const session = yield* sessions.get(input.sessionID).pipe(Effect.orDie)
let rev: Session.Info["revert"]
const patches: Snapshot.Patch[] = []
@@ -75,8 +75,8 @@ export const layer = Layer.effect(
rev.snapshot = session.revert?.snapshot ?? (yield* snap.track())
if (session.revert?.snapshot) yield* snap.restore(session.revert.snapshot)
yield* snap.revert(patches)
if (rev.snapshot) rev.diff = yield* snap.diff(rev.snapshot as string)
const range = all.filter((msg) => msg.info.id >= rev!.messageID)
if (rev.snapshot) rev.diff = yield* snap.diff(rev.snapshot)
const range = all.filter((msg) => msg.info.id >= rev.messageID)
const diffs = yield* summary.computeDiff({ messages: range })
yield* storage.write(["session_diff", input.sessionID], diffs).pipe(Effect.ignore)
yield* bus.publish(Session.Event.Diff, { sessionID: input.sessionID, diff: diffs })
@@ -89,17 +89,17 @@ export const layer = Layer.effect(
files: diffs.length,
},
})
return yield* sessions.get(input.sessionID)
return yield* sessions.get(input.sessionID).pipe(Effect.orDie)
})
const unrevert = Effect.fn("SessionRevert.unrevert")(function* (input: { sessionID: SessionID }) {
log.info("unreverting", input)
yield* state.assertNotBusy(input.sessionID)
const session = yield* sessions.get(input.sessionID)
const session = yield* sessions.get(input.sessionID).pipe(Effect.orDie)
if (!session.revert) return session
if (session.revert.snapshot) yield* snap.restore(session.revert!.snapshot!)
if (session.revert.snapshot) yield* snap.restore(session.revert.snapshot)
yield* sessions.clearRevert(input.sessionID)
return yield* sessions.get(input.sessionID)
return yield* sessions.get(input.sessionID).pipe(Effect.orDie)
})
const cleanup = Effect.fn("SessionRevert.cleanup")(function* (session: Session.Info) {

View File

@@ -3,7 +3,6 @@ import path from "path"
import { BusEvent } from "@/bus/bus-event"
import { Bus } from "@/bus"
import { Decimal } from "decimal.js"
import z from "zod"
import { type ProviderMetadata, type LanguageModelUsage } from "ai"
import { Flag } from "@opencode-ai/core/flag/flag"
import { InstallationVersion } from "@opencode-ai/core/installation/version"
@@ -422,6 +421,8 @@ export class BusyError extends Error {
}
}
export type NotFound = InstanceType<typeof NotFoundError>
export interface Interface {
readonly list: (input?: ListInput) => Effect.Effect<Info[]>
readonly create: (input?: {
@@ -432,9 +433,9 @@ export interface Interface {
permission?: Permission.Ruleset
workspaceID?: WorkspaceID
}) => Effect.Effect<Info>
readonly fork: (input: { sessionID: SessionID; messageID?: MessageID }) => Effect.Effect<Info>
readonly fork: (input: { sessionID: SessionID; messageID?: MessageID }) => Effect.Effect<Info, NotFound>
readonly touch: (sessionID: SessionID) => Effect.Effect<void>
readonly get: (id: SessionID) => Effect.Effect<Info>
readonly get: (id: SessionID) => Effect.Effect<Info, NotFound>
readonly setTitle: (input: { sessionID: SessionID; title: string }) => Effect.Effect<void>
readonly setArchived: (input: { sessionID: SessionID; time?: number }) => Effect.Effect<void>
readonly setPermission: (input: { sessionID: SessionID; permission: Permission.Ruleset }) => Effect.Effect<void>
@@ -448,7 +449,7 @@ export interface Interface {
readonly diff: (sessionID: SessionID) => Effect.Effect<Snapshot.FileDiff[]>
readonly messages: (input: { sessionID: SessionID; limit?: number }) => Effect.Effect<MessageV2.WithParts[]>
readonly children: (parentID: SessionID) => Effect.Effect<Info[]>
readonly remove: (sessionID: SessionID) => Effect.Effect<void>
readonly remove: (sessionID: SessionID) => Effect.Effect<void, NotFound>
readonly updateMessage: <T extends MessageV2.Info>(msg: T) => Effect.Effect<T>
readonly removeMessage: (input: { sessionID: SessionID; messageID: MessageID }) => Effect.Effect<MessageID>
readonly removePart: (input: { sessionID: SessionID; messageID: MessageID; partID: PartID }) => Effect.Effect<PartID>
@@ -534,13 +535,13 @@ export const layer: Layer.Layer<Service, never, Bus.Service | Storage.Service |
const get = Effect.fn("Session.get")(function* (id: SessionID) {
const row = yield* db((d) => d.select().from(SessionTable).where(eq(SessionTable.id, id)).get())
if (!row) throw new NotFoundError({ message: `Session not found: ${id}` })
if (!row) return yield* Effect.fail(new NotFoundError({ message: `Session not found: ${id}` }))
return fromRow(row)
})
const list = Effect.fn("Session.list")(function* (input?: ListInput) {
const ctx = yield* InstanceState.context
return Array.from(listByProject({ projectID: ctx.project.id, ...(input ?? {}) }))
return Array.from(listByProject({ projectID: ctx.project.id, ...input }))
})
const children = Effect.fn("Session.children")(function* (parentID: SessionID) {
@@ -555,8 +556,8 @@ export const layer: Layer.Layer<Service, never, Bus.Service | Storage.Service |
})
const remove: Interface["remove"] = Effect.fnUntraced(function* (sessionID: SessionID) {
const session = yield* get(sessionID)
try {
const session = yield* get(sessionID)
const kids = yield* children(sessionID)
for (const child of kids) {
yield* remove(child.id)

View File

@@ -3,6 +3,7 @@ import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core"
export const EventSequenceTable = sqliteTable("event_sequence", {
aggregate_id: text().notNull().primaryKey(),
seq: integer().notNull(),
owner_id: text(),
})
export const EventTable = sqliteTable("event", {

View File

@@ -59,8 +59,11 @@ export interface Interface {
data: Event<Def>["data"],
options?: { publish?: boolean },
) => Effect.Effect<void>
readonly replay: (event: SerializedEvent, options?: { publish: boolean }) => Effect.Effect<void>
readonly replayAll: (events: SerializedEvent[], options?: { publish: boolean }) => Effect.Effect<string | undefined>
readonly replay: (event: SerializedEvent, options?: { publish: boolean; ownerID?: string }) => Effect.Effect<void>
readonly replayAll: (
events: SerializedEvent[],
options?: { publish: boolean; ownerID?: string },
) => Effect.Effect<string | undefined>
readonly remove: (aggregateID: string) => Effect.Effect<void>
}
@@ -76,7 +79,7 @@ export const layer = Layer.effect(Service)(
const row = Database.use((db) =>
db
.select({ seq: EventSequenceTable.seq })
.select({ seq: EventSequenceTable.seq, ownerID: EventSequenceTable.owner_id })
.from(EventSequenceTable)
.where(eq(EventSequenceTable.aggregate_id, event.aggregateID))
.get(),
@@ -85,6 +88,10 @@ export const layer = Layer.effect(Service)(
const latest = row?.seq ?? -1
if (event.seq <= latest) return
if (row?.ownerID && row.ownerID !== options?.ownerID) {
return
}
const expected = latest + 1
if (event.seq !== expected) {
throw new Error(
@@ -99,7 +106,7 @@ export const layer = Layer.effect(Service)(
workspace: yield* InstanceState.workspaceID,
}
: undefined
process(def, event, { publish, context })
process(def, event, { publish, context, ownerID: options?.ownerID })
})
const replayAll: Interface["replayAll"] = Effect.fn("SyncEvent.replayAll")(function* (events, options) {
@@ -263,7 +270,7 @@ export function project<Def extends Definition>(
function process<Def extends Definition>(
def: Def,
event: Event<Def>,
options: { publish: boolean; context?: PublishContext },
options: { publish: boolean; context?: PublishContext; ownerID?: string },
) {
if (projectors == null) {
throw new Error("No projectors available. Call `SyncEvent.init` to install projectors")
@@ -274,8 +281,6 @@ function process<Def extends Definition>(
throw new Error(`Projector not found for event: ${def.type}`)
}
// idempotent: need to ignore any events already logged
Database.transaction((tx) => {
projector(tx, event.data, event)
@@ -284,6 +289,7 @@ function process<Def extends Definition>(
.values({
aggregate_id: event.aggregateID,
seq: event.seq,
owner_id: options?.ownerID,
})
.onConflictDoUpdate({
target: EventSequenceTable.aggregate_id,
@@ -332,11 +338,11 @@ function process<Def extends Definition>(
})
}
export function replay(event: SerializedEvent, options?: { publish: boolean }) {
export function replay(event: SerializedEvent, options?: { publish: boolean; ownerID?: string }) {
return runtime.runSync((sync) => sync.replay(event, options))
}
export function replayAll(events: SerializedEvent[], options?: { publish: boolean }) {
export function replayAll(events: SerializedEvent[], options?: { publish: boolean; ownerID?: string }) {
return runtime.runSync((sync) => sync.replayAll(events, options))
}
@@ -348,6 +354,16 @@ export function remove(aggregateID: string) {
return runtime.runSync((sync) => sync.remove(aggregateID))
}
export function claim(aggregateID: string, ownerID: string) {
Database.use((db) =>
db
.update(EventSequenceTable)
.set({ owner_id: ownerID })
.where(eq(EventSequenceTable.aggregate_id, aggregateID))
.run(),
)
}
export function payloads() {
return registry
.entries()

View File

@@ -6,10 +6,11 @@ import { MessageV2 } from "../session/message-v2"
import { Agent } from "../agent/agent"
import type { SessionPrompt } from "../session/prompt"
import { Config } from "@/config/config"
import { Effect, Schema } from "effect"
import { Effect, Exit, Schema } from "effect"
import { EffectBridge } from "@/effect/bridge"
export interface TaskPromptOps {
cancel(sessionID: SessionID): void
cancel(sessionID: SessionID): Effect.Effect<void>
resolvePromptParts(template: string): Effect.Effect<SessionPrompt.PromptInput["parts"]>
prompt(input: SessionPrompt.PromptInput): Effect.Effect<MessageV2.WithParts>
}
@@ -118,16 +119,18 @@ export const TaskTool = Tool.define(
const ops = ctx.extra?.promptOps as TaskPromptOps
if (!ops) return yield* Effect.fail(new Error("TaskTool requires promptOps in ctx.extra"))
const runCancel = yield* EffectBridge.make()
const messageID = MessageID.ascending()
const cancel = ops.cancel(nextSession.id)
function cancel() {
ops.cancel(nextSession.id)
function onAbort() {
runCancel.fork(cancel)
}
return yield* Effect.acquireUseRelease(
Effect.sync(() => {
ctx.abort.addEventListener("abort", cancel)
ctx.abort.addEventListener("abort", onAbort)
}),
() =>
Effect.gen(function* () {
@@ -163,10 +166,16 @@ export const TaskTool = Tool.define(
].join("\n"),
}
}),
() =>
Effect.sync(() => {
ctx.abort.removeEventListener("abort", cancel)
}),
(_, exit) =>
Effect.gen(function* () {
if (Exit.hasInterrupts(exit)) yield* cancel
}).pipe(
Effect.ensuring(
Effect.sync(() => {
ctx.abort.removeEventListener("abort", onAbort)
}),
),
),
)
})

View File

@@ -0,0 +1,246 @@
import path from "path"
import { Effect, Layer, Option, Schema, Context, SynchronizedRef } from "effect"
import { Identifier } from "@opencode-ai/core/util/identifier"
import { NonNegativeInt, withStatics } from "@/util/schema"
import { Global } from "@opencode-ai/core/global"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
export const OAUTH_DUMMY_KEY = "opencode-oauth-dummy-key"
const AccountID = Schema.String.pipe(
Schema.brand("AccountID"),
withStatics((schema) => ({ create: () => schema.make("acc_" + Identifier.ascending()) })),
)
export type AccountID = typeof AccountID.Type
export const ServiceID = Schema.String.pipe(Schema.brand("ServiceID"))
export type ServiceID = typeof ServiceID.Type
export class OAuthCredential extends Schema.Class<OAuthCredential>("AuthV2.OAuthCredential")({
type: Schema.Literal("oauth"),
refresh: Schema.String,
access: Schema.String,
expires: NonNegativeInt,
}) {}
export class ApiKeyCredential extends Schema.Class<ApiKeyCredential>("AuthV2.ApiKeyCredential")({
type: Schema.Literal("api"),
key: Schema.String,
metadata: Schema.optional(Schema.Record(Schema.String, Schema.String)),
}) {}
export const Credential = Schema.Union([OAuthCredential, ApiKeyCredential])
.pipe(Schema.toTaggedUnion("type"))
.annotate({
identifier: "AuthV2.Credential",
})
export type Credential = Schema.Schema.Type<typeof Credential>
export class Account extends Schema.Class<Account>("AuthV2.Account")({
id: AccountID,
serviceID: ServiceID,
description: Schema.String,
credential: Credential,
}) {}
export class AuthFileWriteError extends Schema.TaggedErrorClass<AuthFileWriteError>()("AuthV2.FileWriteError", {
operation: Schema.Union([Schema.Literal("migrate"), Schema.Literal("write")]),
cause: Schema.Defect,
}) {}
export type AuthError = AuthFileWriteError
interface Writable {
version: 2
accounts: Record<string, Account>
active: Record<string, AccountID>
}
const decodeV1 = Schema.decodeUnknownOption(Schema.Record(Schema.String, Credential))
function migrate(old: Record<string, unknown>): Writable {
const accounts: Record<string, Account> = {}
const active: Record<string, AccountID> = {}
for (const [serviceID, value] of Object.entries(old)) {
const decoded = Option.getOrElse(decodeV1({ [serviceID]: value }), () => ({}))
const parsed = (decoded as Record<string, Credential>)[serviceID]
if (!parsed) continue
const id = Identifier.ascending()
const accountID = AccountID.make(id)
const brandedServiceID = ServiceID.make(serviceID)
accounts[id] = new Account({
id: accountID,
serviceID: brandedServiceID,
description: "default",
credential: parsed,
})
active[brandedServiceID] = accountID
}
return { version: 2, accounts, active }
}
export interface Interface {
readonly get: (accountID: AccountID) => Effect.Effect<Account | undefined, AuthError>
readonly all: () => Effect.Effect<Account[], AuthError>
readonly create: (input: {
serviceID: ServiceID
credential: Credential
description?: string
active?: boolean
}) => Effect.Effect<Account, AuthError>
readonly update: (
accountID: AccountID,
updates: Partial<Pick<Account, "description" | "credential">>,
) => Effect.Effect<void, AuthError>
readonly remove: (accountID: AccountID) => Effect.Effect<void, AuthError>
readonly activate: (accountID: AccountID) => Effect.Effect<void, AuthError>
readonly active: (serviceID: ServiceID) => Effect.Effect<Account | undefined, AuthError>
readonly forService: (serviceID: ServiceID) => Effect.Effect<Account[], AuthError>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Auth") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const fsys = yield* AppFileSystem.Service
const global = yield* Global.Service
const file = path.join(global.data, "auth-v2.json")
const load: () => Effect.Effect<Writable, AuthError> = Effect.fnUntraced(function* () {
if (process.env.OPENCODE_AUTH_CONTENT) {
try {
return JSON.parse(process.env.OPENCODE_AUTH_CONTENT)
} catch {}
}
const raw = yield* fsys.readJson(file).pipe(Effect.orElseSucceed(() => null))
if (!raw || typeof raw !== "object") return { version: 2, accounts: {}, active: {} }
if ("version" in raw && raw.version === 2) return raw as Writable
const migrated = migrate(raw as Record<string, unknown>)
yield* fsys
.writeJson(file, migrated, 0o600)
.pipe(Effect.mapError((cause) => new AuthFileWriteError({ operation: "migrate", cause })))
return migrated
})
const write = (data: Writable) =>
fsys
.writeJson(file, data, 0o600)
.pipe(Effect.mapError((cause) => new AuthFileWriteError({ operation: "write", cause })))
const state = SynchronizedRef.makeUnsafe(yield* load())
const result: Interface = {
get: Effect.fn("AuthV2.get")(function* (accountID) {
return (yield* SynchronizedRef.get(state)).accounts[accountID]
}),
all: Effect.fn("AuthV2.all")(function* () {
return Object.values((yield* SynchronizedRef.get(state)).accounts)
}),
active: Effect.fn("AuthV2.active")(function* (serviceID) {
const data = yield* SynchronizedRef.get(state)
return (
data.accounts[data.active[serviceID]] ?? Object.values(data.accounts).find((a) => a.serviceID === serviceID)
)
}),
forService: Effect.fn("AuthV2.list")(function* (serviceID) {
return Object.values((yield* SynchronizedRef.get(state)).accounts).filter((a) => a.serviceID === serviceID)
}),
create: Effect.fn("AuthV2.add")(function* (input) {
return yield* SynchronizedRef.modifyEffect(
state,
Effect.fnUntraced(function* (data) {
const account = new Account({
id: AccountID.make(Identifier.ascending()),
serviceID: input.serviceID,
description: input.description ?? "default",
credential: input.credential,
})
const next = {
...data,
accounts: { ...data.accounts, [account.id]: account },
active:
(input.active ?? Object.values(data.accounts).every((a) => a.serviceID !== input.serviceID))
? { ...data.active, [input.serviceID]: account.id }
: data.active,
}
yield* write(next)
return [account, next] as const
}),
)
}),
update: Effect.fn("AuthV2.update")(function* (accountID, updates) {
yield* SynchronizedRef.modifyEffect(
state,
Effect.fnUntraced(function* (data) {
const existing = data.accounts[accountID]
if (!existing) return [undefined, data] as const
const next = {
...data,
accounts: {
...data.accounts,
[accountID]: new Account({
id: accountID,
serviceID: existing.serviceID,
description: updates.description ?? existing.description,
credential: updates.credential ?? existing.credential,
}),
},
}
yield* write(next)
return [undefined, next] as const
}),
)
}),
remove: Effect.fn("AuthV2.remove")(function* (accountID) {
yield* SynchronizedRef.modifyEffect(
state,
Effect.fnUntraced(function* (data) {
const accounts = { ...data.accounts }
const active = { ...data.active }
if (accounts[accountID] && active[accounts[accountID].serviceID] === accountID)
delete active[accounts[accountID].serviceID]
delete accounts[accountID]
const next = { ...data, accounts, active }
yield* write(next)
return [undefined, next] as const
}),
)
}),
activate: Effect.fn("AuthV2.activate")(function* (accountID) {
yield* SynchronizedRef.modifyEffect(
state,
Effect.fnUntraced(function* (data) {
const account = data.accounts[accountID]
if (!account) return [undefined, data] as const
const next = { ...data, active: { ...data.active, [account.serviceID]: accountID } }
yield* write(next)
return [undefined, next] as const
}),
)
}),
}
return Service.of(result)
}),
)
export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer), Layer.provide(Global.defaultLayer))
export * as AuthV2 from "./auth"

View File

@@ -0,0 +1,192 @@
import { withStatics } from "@/util/schema"
import { Array, Context, Effect, HashMap, Layer, Option, Order, pipe, Schema } from "effect"
import { DateTimeUtcFromMillis } from "effect/Schema"
export const ID = Schema.String.pipe(Schema.brand("Model.ID"))
export type ID = typeof ID.Type
export const ProviderID = Schema.String.pipe(
Schema.brand("Model.ProviderID"),
withStatics((schema) => ({
// Well-known providers
opencode: schema.make("opencode"),
anthropic: schema.make("anthropic"),
openai: schema.make("openai"),
google: schema.make("google"),
googleVertex: schema.make("google-vertex"),
githubCopilot: schema.make("github-copilot"),
amazonBedrock: schema.make("amazon-bedrock"),
azure: schema.make("azure"),
openrouter: schema.make("openrouter"),
mistral: schema.make("mistral"),
gitlab: schema.make("gitlab"),
})),
)
export type ProviderID = typeof ProviderID.Type
export const VariantID = Schema.String.pipe(Schema.brand("VariantID"))
export type VariantID = typeof VariantID.Type
// Grouping of models, eg claude opus, claude sonnet
export const Family = Schema.String.pipe(Schema.brand("Family"))
export type Family = typeof Family.Type
const OpenAIResponses = Schema.Struct({
type: Schema.Literal("openai/responses"),
url: Schema.String,
websocket: Schema.optional(Schema.Boolean),
})
const OpenAICompletions = Schema.Struct({
type: Schema.Literal("openai/completions"),
url: Schema.String,
reasoning: Schema.Union([
Schema.Struct({
type: Schema.Literal("reasoning_content"),
}),
Schema.Struct({
type: Schema.Literal("reasoning_details"),
}),
]).pipe(Schema.optional),
})
export type OpenAICompletions = typeof OpenAICompletions.Type
const AnthropicMessages = Schema.Struct({
type: Schema.Literal("anthropic/messages"),
url: Schema.String,
})
export const Endpoint = Schema.Union([OpenAIResponses, OpenAICompletions, AnthropicMessages]).pipe(
Schema.toTaggedUnion("type"),
)
export type Endpoint = typeof Endpoint.Type
export const Capabilities = Schema.Struct({
tools: Schema.Boolean,
// mime patterns, image, audio, video/*, text/*
input: Schema.String.pipe(Schema.Array),
output: Schema.String.pipe(Schema.Array),
})
export type Capabilities = typeof Capabilities.Type
export const Options = Schema.Struct({
headers: Schema.Record(Schema.String, Schema.String),
body: Schema.Record(Schema.String, Schema.Any),
})
export type Options = typeof Options.Type
export const Cost = Schema.Struct({
tier: Schema.Struct({
type: Schema.Literal("context"),
size: Schema.Int,
}).pipe(Schema.optional),
input: Schema.Finite,
output: Schema.Finite,
cache: Schema.Struct({
read: Schema.Finite,
write: Schema.Finite,
}),
})
export const Ref = Schema.Struct({
id: ID,
providerID: ProviderID,
variant: VariantID,
})
export type Ref = typeof Ref.Type
export class Info extends Schema.Class<Info>("Model.Info")({
id: ID,
providerID: ProviderID,
family: Family.pipe(Schema.optional),
name: Schema.String,
endpoint: Endpoint,
capabilities: Capabilities,
options: Schema.Struct({
...Options.fields,
variant: Schema.String.pipe(Schema.optional),
}),
variants: Schema.Struct({
id: VariantID,
...Options.fields,
}).pipe(Schema.Array),
time: Schema.Struct({
released: DateTimeUtcFromMillis,
}),
cost: Cost.pipe(Schema.Array),
status: Schema.Literals(["alpha", "beta", "deprecated", "active"]),
limit: Schema.Struct({
context: Schema.Int,
input: Schema.Int.pipe(Schema.optional),
output: Schema.Int,
}),
}) {}
export function parse(input: string): { providerID: ProviderID; modelID: ID } {
const [providerID, ...modelID] = input.split("/")
return {
providerID: ProviderID.make(providerID),
modelID: ID.make(modelID.join("/")),
}
}
export interface Interface {
readonly get: (providerID: ProviderID, modelID: ID) => Effect.Effect<Option.Option<Info>>
readonly add: (model: Info) => Effect.Effect<void>
readonly remove: (providerID: ProviderID, modelID: ID) => Effect.Effect<void>
readonly all: () => Effect.Effect<Info[]>
readonly default: () => Effect.Effect<Option.Option<Info>>
readonly small: (provider: ProviderID) => Effect.Effect<Option.Option<Info>>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Model") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
let models = HashMap.empty<string, Info>()
function key(providerID: ProviderID, modelID: ID) {
return `${providerID}/${modelID}`
}
const result: Interface = {
get: Effect.fn("V2Model.get")(function* (providerID, modelID) {
return HashMap.get(models, key(providerID, modelID))
}),
add: Effect.fn("V2Model.add")(function* (model) {
models = HashMap.set(models, key(model.providerID, model.id), model)
}),
remove: Effect.fn("V2Model.remove")(function* (providerID, modelID) {
models = HashMap.remove(models, key(providerID, modelID))
}),
all: Effect.fn("V2Model.all")(function* () {
return pipe(
models,
HashMap.toValues,
Array.sortWith((item) => item.time.released.epochMilliseconds, Order.flip(Order.Number)),
)
}),
default: Effect.fn("V2Model.default")(function* () {
const all = yield* result.all()
return Option.fromUndefinedOr(all[0])
}),
small: Effect.fn("V2Model.small")(function* (providerID) {
const all = yield* result.all()
const match = all.find((model) => model.providerID === providerID && model.id.toLowerCase().includes("small"))
return Option.fromUndefinedOr(match)
}),
}
return Service.of(result)
}),
)
export const defaultLayer = layer
export * as Modelv2 from "./model"

View File

@@ -5,8 +5,8 @@ import { FileAttachment, Prompt } from "./session-prompt"
import { Schema } from "effect"
export { FileAttachment }
import { ToolOutput } from "./tool-output"
import { ModelID, ProviderID } from "@/provider/schema"
import { V2Schema } from "./schema"
import { Modelv2 } from "./model"
export const Source = Schema.Struct({
start: NonNegativeInt,
@@ -22,10 +22,13 @@ const Base = {
sessionID: SessionID,
}
const Error = Schema.Struct({
type: Schema.String,
export const UnknownError = Schema.Struct({
type: Schema.Literal("unknown"),
message: Schema.String,
}).annotate({
identifier: "Session.Error.Unknown",
})
export type UnknownError = Schema.Schema.Type<typeof UnknownError>
export const AgentSwitched = EventV2.define({
type: "session.next.agent.switched",
@@ -44,9 +47,7 @@ export const ModelSwitched = EventV2.define({
version: 1,
schema: {
...Base,
id: ModelID,
providerID: ProviderID,
variant: Schema.String.pipe(Schema.optional),
model: Modelv2.Ref,
},
})
export type ModelSwitched = Schema.Schema.Type<typeof ModelSwitched>
@@ -103,11 +104,7 @@ export namespace Step {
schema: {
...Base,
agent: Schema.String,
model: Schema.Struct({
id: Schema.String,
providerID: Schema.String,
variant: Schema.String.pipe(Schema.optional),
}),
model: Modelv2.Ref,
snapshot: Schema.String.pipe(Schema.optional),
},
})
@@ -139,7 +136,7 @@ export namespace Step {
aggregate: "sessionID",
schema: {
...Base,
error: Error,
error: UnknownError,
},
})
export type Failed = Schema.Schema.Type<typeof Failed>
@@ -296,7 +293,7 @@ export namespace Tool {
schema: {
...Base,
callID: Schema.String,
error: Error,
error: UnknownError,
provider: Schema.Struct({
executed: Schema.Boolean,
metadata: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional),

View File

@@ -109,11 +109,7 @@ export function update<Result>(adapter: Adapter<Result>, event: SessionEvent.Eve
id: event.id,
type: "model-switched",
metadata: event.metadata,
model: {
id: event.data.id,
providerID: event.data.providerID,
variant: event.data.variant,
},
model: event.data.model,
time: { created: event.data.timestamp },
}),
)

View File

@@ -4,6 +4,7 @@ import { SessionEvent } from "./session-event"
import { EventV2 } from "./event"
import { ToolOutput } from "./tool-output"
import { V2Schema } from "./schema"
import { Modelv2 } from "./model"
export const ID = EventV2.ID
export type ID = Schema.Schema.Type<typeof ID>
@@ -25,11 +26,7 @@ export class AgentSwitched extends Schema.Class<AgentSwitched>("Session.Message.
export class ModelSwitched extends Schema.Class<ModelSwitched>("Session.Message.ModelSwitched")({
...Base,
type: Schema.Literal("model-switched"),
model: Schema.Struct({
id: SessionEvent.ModelSwitched.fields.data.fields.id,
providerID: SessionEvent.ModelSwitched.fields.data.fields.providerID,
variant: SessionEvent.ModelSwitched.fields.data.fields.variant,
}),
model: Modelv2.Ref,
}) {}
export class User extends Schema.Class<User>("Session.Message.User")({
@@ -87,10 +84,7 @@ export class ToolStateError extends Schema.Class<ToolStateError>("Session.Messag
input: Schema.Record(Schema.String, Schema.Unknown),
content: ToolOutput.Content.pipe(Schema.Array),
structured: ToolOutput.Structured,
error: Schema.Struct({
type: Schema.String,
message: Schema.String,
}),
error: SessionEvent.UnknownError,
}) {}
export const ToolState = Schema.Union([ToolStatePending, ToolStateRunning, ToolStateCompleted, ToolStateError]).pipe(

View File

@@ -3,17 +3,17 @@ import { SessionID } from "@/session/schema"
import { WorkspaceID } from "@/control-plane/schema"
import { and, asc, desc, eq, gt, gte, isNull, like, lt, or, type SQL } from "@/storage/db"
import * as Database from "@/storage/db"
import { Context, DateTime, Effect, Layer, Schema } from "effect"
import { Context, DateTime, Effect, Layer, Option, Schema } from "effect"
import { SessionMessage } from "./session-message"
import type { Prompt } from "./session-prompt"
import { EventV2 } from "./event"
import { ProjectID } from "@/project/schema"
import { ModelID, ProviderID } from "@/provider/schema"
import { SessionEvent } from "./session-event"
import { V2Schema } from "./schema"
import { optionalOmitUndefined } from "@/util/schema"
import { Modelv2 } from "./model"
export const Delivery = Schema.Union([Schema.Literal("immediate"), Schema.Literal("deferred")]).annotate({
export const Delivery = Schema.Literals(["immediate", "deferred"]).annotate({
identifier: "Session.Delivery",
})
export type Delivery = Schema.Schema.Type<typeof Delivery>
@@ -27,11 +27,7 @@ export class Info extends Schema.Class<Info>("Session.Info")({
workspaceID: optionalOmitUndefined(WorkspaceID),
path: optionalOmitUndefined(Schema.String),
agent: optionalOmitUndefined(Schema.String),
model: Schema.Struct({
id: ModelID,
providerID: ProviderID,
variant: optionalOmitUndefined(Schema.String),
}).pipe(optionalOmitUndefined),
model: Modelv2.Ref.pipe(optionalOmitUndefined),
time: Schema.Struct({
created: V2Schema.DateTimeUtcFromMillis,
updated: V2Schema.DateTimeUtcFromMillis,
@@ -53,7 +49,18 @@ export class Info extends Schema.Class<Info>("Session.Info")({
*/
}) {}
export class NotFoundError extends Schema.TaggedErrorClass<NotFoundError>()("Session.NotFoundError", {
sessionID: SessionID,
}) {}
export interface Interface {
readonly create: (input?: {
agent?: string
model?: Modelv2.Ref
parentID?: SessionID
workspaceID?: WorkspaceID
}) => Effect.Effect<Info>
readonly get: (sessionID: SessionID) => Effect.Effect<Info, NotFoundError>
readonly list: (input: {
limit?: number
order?: "asc" | "desc"
@@ -88,13 +95,15 @@ export interface Interface {
}) => Effect.Effect<SessionMessage.User, never>
readonly shell: (input: { id?: EventV2.ID; sessionID: SessionID; command: string }) => Effect.Effect<void, never>
readonly skill: (input: { id?: EventV2.ID; sessionID: SessionID; skill: string }) => Effect.Effect<void, never>
readonly subagent: (input: {
id?: EventV2.ID
parentID: SessionID
prompt: Prompt
agent: string
model?: Modelv2.Ref
}) => Effect.Effect<void, NotFoundError>
readonly switchAgent: (input: { sessionID: SessionID; agent: string }) => Effect.Effect<void, never>
readonly switchModel: (input: {
sessionID: SessionID
id: ModelID
providerID: ProviderID
variant?: string
}) => Effect.Effect<void, never>
readonly switchModel: (input: { sessionID: SessionID; model: Modelv2.Ref }) => Effect.Effect<void, never>
readonly compact: (sessionID: SessionID) => Effect.Effect<void, never>
readonly wait: (sessionID: SessionID) => Effect.Effect<void, never>
}
@@ -120,9 +129,9 @@ export const layer = Layer.effect(
agent: row.agent ?? undefined,
model: row.model
? {
id: ModelID.make(row.model.id),
providerID: ProviderID.make(row.model.providerID),
variant: row.model.variant,
id: Modelv2.ID.make(row.model.id),
providerID: Modelv2.ProviderID.make(row.model.providerID),
variant: Modelv2.VariantID.make(row.model.variant ?? "default"),
}
: undefined,
time: {
@@ -134,6 +143,14 @@ export const layer = Layer.effect(
}
const result: Interface = {
create: Effect.fn("V2Session.create")(function* (_input) {
return {} as any
}),
get: Effect.fn("V2Session.get")(function* (sessionID) {
const row = Database.use((db) => db.select().from(SessionTable).where(eq(SessionTable.id, sessionID)).get())
if (!row) return yield* new NotFoundError({ sessionID })
return fromRow(row)
}),
list: Effect.fn("V2Session.list")(function* (input) {
const direction = input.cursor?.direction ?? "next"
let order = input.order ?? "desc"
@@ -262,11 +279,30 @@ export const layer = Layer.effect(
EventV2.run(SessionEvent.ModelSwitched.Sync, {
sessionID: input.sessionID,
timestamp: DateTime.makeUnsafe(Date.now()),
id: input.id,
providerID: input.providerID,
variant: input.variant,
model: input.model,
})
}),
subagent: Effect.fn("V2Session.subagent")(function* (input) {
const parent = yield* result.get(input.parentID)
const session = yield* result.create({
agent: input.agent,
model: input.model,
parentID: input.parentID,
workspaceID: parent.workspaceID,
})
yield* result.prompt({
prompt: input.prompt,
sessionID: session.id,
})
yield* Effect.gen(function* () {
yield* result.wait(session.id)
const messages = yield* result.messages({ sessionID: session.id, order: "desc" })
const assistant = messages.find((msg) => msg.type === "assistant")
if (!assistant) return
const text = assistant.content.findLast((part) => part.type === "text")
if (!text) return
}).pipe(Effect.forkChild())
}),
compact: Effect.fn("V2Session.compact")(function* (_sessionID) {}),
wait: Effect.fn("V2Session.wait")(function* (_sessionID) {}),
}

View File

@@ -34,11 +34,10 @@ describe("acp.agent interface compliance", () => {
"loadSession",
"setSessionMode",
"authenticate",
// Capability-gated methods checked by the SDK router
// Unstable - SDK checks these with unstable_ prefix
"listSessions",
"resumeSession",
"closeSession",
"unstable_forkSession",
"unstable_resumeSession",
"unstable_setSessionModel",
]

View File

@@ -6,7 +6,7 @@ import { setTimeout as delay } from "node:timers/promises"
import { NodeHttpServer } from "@effect/platform-node"
import { Effect, Layer } from "effect"
import { HttpServer, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
import { asc, eq } from "drizzle-orm"
import { eq } from "drizzle-orm"
import * as Log from "@opencode-ai/core/util/log"
import { Flag } from "@opencode-ai/core/flag/flag"
import { GlobalBus, type GlobalEvent } from "@/bus/global"
@@ -16,11 +16,10 @@ import { ProjectTable } from "@/project/project.sql"
import { Instance } from "@/project/instance"
import { WithInstance } from "../../src/project/with-instance"
import { Session as SessionNs } from "@/session/session"
import { SessionID, MessageID, PartID } from "@/session/schema"
import { SessionID } from "@/session/schema"
import { SessionTable } from "@/session/session.sql"
import { ModelID, ProviderID } from "@/provider/schema"
import { SyncEvent } from "@/sync"
import { EventSequenceTable, EventTable } from "@/sync/event.sql"
import { EventSequenceTable } from "@/sync/event.sql"
import { resetDatabase } from "../fixture/db"
import { disposeAllInstances, provideTmpdirInstance, tmpdir } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
@@ -111,8 +110,8 @@ async function withInstance<T>(fn: (dir: string) => T | Promise<T>) {
const runWorkspace = <A, E>(effect: Effect.Effect<A, E, WorkspaceOld.Service>) => AppRuntime.runPromise(effect)
const createWorkspace = (input: WorkspaceOld.CreateInput) =>
runWorkspace(WorkspaceOld.Service.use((workspace) => workspace.create(input)))
const restoreWorkspaceSession = (input: WorkspaceOld.SessionRestoreInput) =>
runWorkspace(WorkspaceOld.Service.use((workspace) => workspace.sessionRestore(input)))
const warpWorkspaceSession = (input: WorkspaceOld.SessionWarpInput) =>
runWorkspace(WorkspaceOld.Service.use((workspace) => workspace.sessionWarp(input)))
const listWorkspaces = (project: Parameters<WorkspaceOld.Interface["list"]>[0]) =>
runWorkspace(WorkspaceOld.Service.use((workspace) => workspace.list(project)))
const getWorkspace = (id: WorkspaceID) => runWorkspace(WorkspaceOld.Service.use((workspace) => workspace.get(id)))
@@ -317,48 +316,24 @@ function sessionSequence(sessionID: SessionID) {
)?.seq
}
function eventRows(sessionID: SessionID) {
function sessionSequenceOwner(sessionID: SessionID) {
return Database.use((db) =>
db
.select({ seq: EventTable.seq, type: EventTable.type, data: EventTable.data })
.from(EventTable)
.where(eq(EventTable.aggregate_id, sessionID))
.orderBy(asc(EventTable.seq))
.all(),
)
.select({ ownerID: EventSequenceTable.owner_id })
.from(EventSequenceTable)
.where(eq(EventSequenceTable.aggregate_id, sessionID))
.get(),
)?.ownerID
}
function sessionUpdatedType() {
return SyncEvent.versionedType(SessionNs.Event.Updated.type, SessionNs.Event.Updated.version)
}
function replaceSessionEvents(sessionID: SessionID, count: number) {
Database.use((db) => {
db.delete(EventSequenceTable).where(eq(EventSequenceTable.aggregate_id, sessionID)).run()
if (count === 0) return
db.insert(EventSequenceTable)
.values({ aggregate_id: sessionID, seq: count - 1 })
.run()
db.insert(EventTable)
.values(
Array.from({ length: count }, (_, i) => ({
id: `evt_${unique(`manual-${i}`)}`,
aggregate_id: sessionID,
seq: i,
type: sessionUpdatedType(),
data: { sessionID, info: { title: `manual ${i}` } },
})),
)
.run()
})
}
describe("workspace-old schemas and exports", () => {
test("keeps the historical event type names", () => {
expect(WorkspaceOld.Event.Ready.type).toBe("workspace.ready")
expect(WorkspaceOld.Event.Failed.type).toBe("workspace.failed")
expect(WorkspaceOld.Event.Restore.type).toBe("workspace.restore")
expect(WorkspaceOld.Event.Status.type).toBe("workspace.status")
})
@@ -375,17 +350,6 @@ describe("workspace-old schemas and exports", () => {
expect(() => WorkspaceOld.CreateInput.zod.parse({ ...input, id: "bad" })).toThrow()
expect(() => WorkspaceOld.CreateInput.zod.parse({ ...input, branch: 1 })).toThrow()
})
test("validates session restore input", () => {
const input = {
workspaceID: WorkspaceID.ascending("wrk_schema_restore"),
sessionID: SessionID.descending("ses_schema_restore"),
}
expect(WorkspaceOld.SessionRestoreInput.zod.parse(input)).toEqual(input)
expect(() => WorkspaceOld.SessionRestoreInput.zod.parse({ ...input, workspaceID: "bad" })).toThrow()
expect(() => WorkspaceOld.SessionRestoreInput.zod.parse({ ...input, sessionID: "bad" })).toThrow()
})
})
describe("workspace-old CRUD", () => {
@@ -651,6 +615,144 @@ describe("workspace-old CRUD", () => {
expect(await getWorkspace(info.id)).toBeUndefined()
})
})
test("sessionWarp moves a session into a local workspace and claims ownership", async () => {
await withInstance(async (dir) => {
const previousType = unique("warp-prev-local")
const targetType = unique("warp-target-local")
const previous = workspaceInfo(Instance.project.id, previousType)
const target = workspaceInfo(Instance.project.id, targetType)
insertWorkspace(previous)
insertWorkspace(target)
registerAdapter(Instance.project.id, previousType, localAdapter(path.join(dir, "warp-prev-local")).adapter)
registerAdapter(Instance.project.id, targetType, localAdapter(path.join(dir, "warp-target-local")).adapter)
const session = await AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.create({})))
attachSessionToWorkspace(session.id, previous.id)
await warpWorkspaceSession({ workspaceID: target.id, sessionID: session.id })
expect(
Database.use((db) =>
db
.select({ workspaceID: SessionTable.workspace_id })
.from(SessionTable)
.where(eq(SessionTable.id, session.id))
.get(),
)?.workspaceID,
).toBe(target.id)
expect(sessionSequenceOwner(session.id)).toBe(target.id)
})
})
test("sessionWarp detaches a session to the local project and claims project ownership", async () => {
await withInstance(async (dir) => {
const previousType = unique("warp-detach-local")
const previous = workspaceInfo(Instance.project.id, previousType)
insertWorkspace(previous)
registerAdapter(Instance.project.id, previousType, localAdapter(path.join(dir, "warp-detach-local")).adapter)
const session = await AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.create({})))
attachSessionToWorkspace(session.id, previous.id)
await warpWorkspaceSession({ workspaceID: null, sessionID: session.id })
expect(
Database.use((db) =>
db
.select({ workspaceID: SessionTable.workspace_id })
.from(SessionTable)
.where(eq(SessionTable.id, session.id))
.get(),
)?.workspaceID,
).toBeNull()
expect(sessionSequenceOwner(session.id)).toBe(Instance.project.id)
})
})
it.live("sessionWarp syncs previous remote history, replays it, steals, and claims the sequence", () => {
const calls: FetchCall[] = []
let historySessionID: SessionID | undefined
let historyNextSeq = 0
return Effect.gen(function* () {
yield* HttpServer.serveEffect()(
Effect.gen(function* () {
const req = yield* HttpServerRequest.HttpServerRequest
const bodyText = yield* req.text
const call = {
url: new URL(req.url, "http://localhost"),
method: req.method,
headers: new Headers(req.headers),
bodyText,
json: bodyText ? JSON.parse(bodyText) : undefined,
}
calls.push(call)
if (call.url.pathname === "/warp-source/sync/history") {
return yield* HttpServerResponse.json([
{
id: `evt_${unique("warp-source-history")}`,
aggregate_id: historySessionID!,
seq: historyNextSeq,
type: sessionUpdatedType(),
data: { sessionID: historySessionID!, info: { title: "from source history" } },
},
])
}
if (call.url.pathname === "/warp-target/sync/replay")
return yield* HttpServerResponse.json({ sessionID: "ok" })
if (call.url.pathname === "/warp-target/sync/steal")
return yield* HttpServerResponse.json({ sessionID: "ok" })
return HttpServerResponse.text("unexpected", { status: 500 })
}),
)
const url = yield* serverUrl()
yield* provideTmpdirInstance(
() =>
Effect.gen(function* () {
const workspace = yield* WorkspaceOld.Service
const sessionSvc = yield* SessionNs.Service
const previousType = unique("warp-remote-source")
const targetType = unique("warp-remote-target")
const previous = workspaceInfo(Instance.project.id, previousType)
const target = workspaceInfo(Instance.project.id, targetType, { directory: "remote-target-dir" })
insertWorkspace(previous)
insertWorkspace(target)
registerAdapter(Instance.project.id, previousType, remoteAdapter(`${url}/warp-source`).adapter)
registerAdapter(Instance.project.id, targetType, remoteAdapter(`${url}/warp-target`).adapter)
const session = yield* sessionSvc.create({})
attachSessionToWorkspace(session.id, previous.id)
historySessionID = session.id
historyNextSeq = (sessionSequence(session.id) ?? -1) + 1
yield* workspace.sessionWarp({ workspaceID: target.id, sessionID: session.id })
expect(calls.map((call) => `${call.method} ${call.url.pathname}`)).toEqual([
"POST /warp-source/sync/history",
"POST /warp-target/sync/replay",
"POST /warp-target/sync/steal",
])
expect(calls[0].json).toEqual({ [session.id]: historyNextSeq - 1 })
expect(calls[1].json).toMatchObject({
directory: "remote-target-dir",
events: [
{
aggregateID: session.id,
seq: 0,
type: SyncEvent.versionedType(SessionNs.Event.Created.type, SessionNs.Event.Created.version),
},
{
aggregateID: session.id,
seq: historyNextSeq,
type: sessionUpdatedType(),
},
],
})
expect(calls[2].json).toEqual({ sessionID: session.id })
expect((yield* sessionSvc.get(session.id)).title).toBe("from source history")
expect(sessionSequenceOwner(session.id)).toBe(target.id)
}),
{ git: true },
)
})
})
})
describe("workspace-old sync state", () => {
@@ -958,7 +1060,7 @@ describe("workspace-old sync state", () => {
yield* eventuallyEffect(
Effect.gen(function* () {
expect((yield* sessionSvc.get(session.id)).title).toBe("from history")
expect((yield* sessionSvc.get(session.id).pipe(Effect.orDie)).title).toBe("from history")
}),
)
expect(historyBodies).toEqual([{ [session.id]: historyNextSeq - 1 }])
@@ -1106,7 +1208,7 @@ describe("workspace-old sync state", () => {
yield* eventuallyEffect(
Effect.gen(function* () {
expect((yield* sessionSvc.get(session.id)).title).toBe("from sse")
expect((yield* sessionSvc.get(session.id).pipe(Effect.orDie)).title).toBe("from sse")
}),
)
expect(
@@ -1215,313 +1317,3 @@ describe("workspace-old waitForSync", () => {
})
}, 7000)
})
describe("workspace-old sessionRestore", () => {
test("throws when the workspace is missing", async () => {
await withInstance(async () => {
await expect(
restoreWorkspaceSession({
workspaceID: WorkspaceID.ascending("wrk_restore_missing"),
sessionID: SessionID.descending("ses_restore_missing_workspace"),
}),
).rejects.toThrow("Workspace not found: wrk_restore_missing")
})
})
test("throws when switching a missing session fails", async () => {
await withInstance(async (dir) => {
const type = unique("restore-missing-session")
const info = workspaceInfo(Instance.project.id, type, { directory: dir })
insertWorkspace(info)
registerAdapter(Instance.project.id, type, localAdapter(dir).adapter)
await expect(
restoreWorkspaceSession({ workspaceID: info.id, sessionID: SessionID.descending("ses_missing_restore") }),
).rejects.toThrow("NotFoundError")
await removeWorkspace(info.id)
})
})
it.live("posts remote replay batches of 10, emits progress, and includes the workspace update event", () => {
const replay: FetchCall[] = []
return Effect.gen(function* () {
yield* HttpServer.serveEffect()(
Effect.gen(function* () {
const req = yield* HttpServerRequest.HttpServerRequest
const bodyText = yield* req.text
const call = {
url: new URL(req.url, "http://localhost"),
method: req.method,
headers: new Headers(req.headers),
bodyText,
json: bodyText ? JSON.parse(bodyText) : undefined,
}
if (call.url.pathname === "/restore/sync/replay") {
replay.push(call)
return HttpServerResponse.fromWeb(Response.json({ ok: true }))
}
return HttpServerResponse.text("unexpected", { status: 500 })
}),
)
const url = yield* serverUrl()
yield* provideTmpdirInstance(
(dir) =>
Effect.gen(function* () {
const workspace = yield* WorkspaceOld.Service
const sessionSvc = yield* SessionNs.Service
const captured = captureGlobalEvents()
try {
const type = unique("restore-remote")
const info = workspaceInfo(Instance.project.id, type, { directory: dir })
insertWorkspace(info)
registerAdapter(
Instance.project.id,
type,
remoteAdapter(`${url}/restore/?ignored=1#hash`, {
directory: dir,
headers: { authorization: "Bearer restore" },
}).adapter,
)
const session = yield* sessionSvc.create({ title: "restore remote" })
replaceSessionEvents(session.id, 24)
const result = yield* workspace.sessionRestore({ workspaceID: info.id, sessionID: session.id })
expect(result).toEqual({ total: 3 })
expect(replay).toHaveLength(3)
expect(replay.map((call) => call.url.pathname + call.url.search + call.url.hash)).toEqual([
"/restore/sync/replay",
"/restore/sync/replay",
"/restore/sync/replay",
])
expect(replay.every((call) => call.headers.get("authorization") === "Bearer restore")).toBe(true)
expect(replay.every((call) => call.headers.get("content-type") === "application/json")).toBe(true)
expect(replay.map((call) => (call.json as { events: unknown[] }).events.length)).toEqual([10, 10, 5])
expect(replay.map((call) => (call.json as { directory: string }).directory)).toEqual([dir, dir, dir])
expect(
replay.flatMap((call) =>
(call.json as { events: Array<{ seq: number }> }).events.map((event) => event.seq),
),
).toEqual(Array.from({ length: 25 }, (_, i) => i))
expect(
(replay[2].json as { events: Array<{ seq: number; type: string; data: unknown }> }).events.at(-1),
).toMatchObject({
seq: 24,
type: sessionUpdatedType(),
data: { sessionID: session.id, info: { workspaceID: info.id } },
})
expect((yield* sessionSvc.get(session.id)).workspaceID).toBe(info.id)
expect(
captured.events
.filter(
(event) => event.workspace === info.id && event.payload.type === WorkspaceOld.Event.Restore.type,
)
.map((event) => event.payload.properties.step),
).toEqual([0, 1, 2, 3])
yield* workspace.remove(info.id)
} finally {
captured.dispose()
}
}),
{ git: true },
)
})
})
it.live("remote restore sends an empty directory string when the workspace directory is null", () => {
const replay: FetchCall[] = []
return Effect.gen(function* () {
yield* HttpServer.serveEffect()(
Effect.gen(function* () {
const req = yield* HttpServerRequest.HttpServerRequest
const bodyText = yield* req.text
replay.push({
url: new URL(req.url, "http://localhost"),
method: req.method,
headers: new Headers(req.headers),
bodyText,
json: bodyText ? JSON.parse(bodyText) : undefined,
})
return HttpServerResponse.fromWeb(Response.json({ ok: true }))
}),
)
const url = yield* serverUrl()
yield* provideTmpdirInstance(
() =>
Effect.gen(function* () {
const workspace = yield* WorkspaceOld.Service
const sessionSvc = yield* SessionNs.Service
const type = unique("restore-null-dir")
const info = workspaceInfo(Instance.project.id, type, { directory: null })
insertWorkspace(info)
registerAdapter(Instance.project.id, type, remoteAdapter(`${url}/null-dir`, { directory: null }).adapter)
const session = yield* sessionSvc.create({ title: "null dir" })
replaceSessionEvents(session.id, 0)
expect(yield* workspace.sessionRestore({ workspaceID: info.id, sessionID: session.id })).toEqual({
total: 1,
})
expect((replay[0].json as { directory: string }).directory).toBe("")
expect((replay[0].json as { events: unknown[] }).events).toHaveLength(1)
yield* workspace.remove(info.id)
}),
{ git: true },
)
})
})
it.live("remote restore failures include status and body and do not emit completed batch progress", () => {
const replay: FetchCall[] = []
return Effect.gen(function* () {
yield* HttpServer.serveEffect()(
Effect.gen(function* () {
const req = yield* HttpServerRequest.HttpServerRequest
const bodyText = yield* req.text
replay.push({
url: new URL(req.url, "http://localhost"),
method: req.method,
headers: new Headers(req.headers),
bodyText,
json: bodyText ? JSON.parse(bodyText) : undefined,
})
return HttpServerResponse.text("replay failed", { status: 503 })
}),
)
const url = yield* serverUrl()
yield* provideTmpdirInstance(
(dir) =>
Effect.gen(function* () {
const workspace = yield* WorkspaceOld.Service
const sessionSvc = yield* SessionNs.Service
const captured = captureGlobalEvents()
try {
const type = unique("restore-remote-fail")
const info = workspaceInfo(Instance.project.id, type, { directory: dir })
insertWorkspace(info)
registerAdapter(Instance.project.id, type, remoteAdapter(`${url}/fail`, { directory: dir }).adapter)
const session = yield* sessionSvc.create({ title: "restore fail" })
replaceSessionEvents(session.id, 11)
const error = yield* Effect.flip(
workspace.sessionRestore({ workspaceID: info.id, sessionID: session.id }),
)
expect((error as Error).message).toContain(
`Failed to replay session ${session.id} into workspace ${info.id}: HTTP 503 replay failed`,
)
expect(replay).toHaveLength(1)
expect(
captured.events
.filter(
(event) => event.workspace === info.id && event.payload.type === WorkspaceOld.Event.Restore.type,
)
.map((event) => event.payload.properties.step),
).toEqual([0])
yield* workspace.remove(info.id)
} finally {
captured.dispose()
}
}),
{ git: true },
)
})
})
it.live("local restore replays batches and emits progress", () =>
provideTmpdirInstance(
(dir) =>
Effect.gen(function* () {
const workspace = yield* WorkspaceOld.Service
const sessionSvc = yield* SessionNs.Service
const captured = captureGlobalEvents()
try {
const type = unique("restore-local")
const info = workspaceInfo(Instance.project.id, type, { directory: dir })
insertWorkspace(info)
registerAdapter(Instance.project.id, type, localAdapter(dir).adapter)
const session = yield* sessionSvc.create({ title: "restore local" })
replaceSessionEvents(session.id, 20)
expect(yield* workspace.sessionRestore({ workspaceID: info.id, sessionID: session.id })).toEqual({
total: 3,
})
expect((yield* sessionSvc.get(session.id)).workspaceID).toBe(info.id)
expect(eventRows(session.id).map((row) => row.seq)).toEqual(Array.from({ length: 21 }, (_, i) => i))
expect(
captured.events
.filter(
(event) => event.workspace === info.id && event.payload.type === WorkspaceOld.Event.Restore.type,
)
.map((event) => event.payload.properties.step),
).toEqual([0, 1, 2, 3])
yield* workspace.remove(info.id)
} finally {
captured.dispose()
}
}),
{ git: true },
),
)
it.live("session restore includes real message and part events in sequence order", () => {
const replay: FetchCall[] = []
return Effect.gen(function* () {
yield* HttpServer.serveEffect()(
Effect.gen(function* () {
const req = yield* HttpServerRequest.HttpServerRequest
const bodyText = yield* req.text
replay.push({
url: new URL(req.url, "http://localhost"),
method: req.method,
headers: new Headers(req.headers),
bodyText,
json: bodyText ? JSON.parse(bodyText) : undefined,
})
return HttpServerResponse.fromWeb(Response.json({ ok: true }))
}),
)
const url = yield* serverUrl()
yield* provideTmpdirInstance(
(dir) =>
Effect.gen(function* () {
const workspace = yield* WorkspaceOld.Service
const sessionSvc = yield* SessionNs.Service
const type = unique("restore-real-events")
const info = workspaceInfo(Instance.project.id, type, { directory: dir })
insertWorkspace(info)
registerAdapter(Instance.project.id, type, remoteAdapter(`${url}/real`, { directory: dir }).adapter)
const session = yield* sessionSvc.create({ title: "real events" })
for (let i = 0; i < 3; i++) {
const msg = yield* sessionSvc.updateMessage({
id: MessageID.ascending(),
role: "user",
sessionID: session.id,
agent: "build",
model: { providerID: ProviderID.make("test"), modelID: ModelID.make("test") },
time: { created: Date.now() },
})
yield* sessionSvc.updatePart({
id: PartID.ascending(),
sessionID: session.id,
messageID: msg.id,
type: "text",
text: `message ${i}`,
})
}
const before = eventRows(session.id)
expect(yield* workspace.sessionRestore({ workspaceID: info.id, sessionID: session.id })).toEqual({
total: 1,
})
const posted = (replay[0].json as { events: Array<{ seq: number; type: string }> }).events
expect(posted.map((event) => event.seq)).toEqual([...before.map((row) => row.seq), before.at(-1)!.seq + 1])
expect(posted.map((event) => event.type).slice(0, -1)).toEqual(before.map((row) => row.type))
expect(posted.at(-1)?.type).toBe(sessionUpdatedType())
yield* workspace.remove(info.id)
}),
{ git: true },
)
})
})
})

View File

@@ -1,5 +1,6 @@
import { $ } from "bun"
import { afterEach, describe, expect, test } from "bun:test"
import { parsePatch } from "diff"
import { Effect } from "effect"
import fs from "fs/promises"
import path from "path"
@@ -288,6 +289,28 @@ describe("Vcs diff", () => {
})
})
test("diff('git') keeps carriage returns inside patch hunks", async () => {
await using tmp = await tmpdir({ git: true })
await fs.writeFile(path.join(tmp.path, "file.txt"), "keep\nsame\rdiff --git inside\ndelete\n", "utf-8")
await $`git add .`.cwd(tmp.path).quiet()
await $`git commit --no-gpg-sign -m "add file"`.cwd(tmp.path).quiet()
await fs.writeFile(path.join(tmp.path, "file.txt"), "keep\nadd\nsame\rdiff --git inside\n", "utf-8")
await withVcsOnly(tmp.path, async () => {
const diff = await AppRuntime.runPromise(
Effect.gen(function* () {
const vcs = yield* Vcs.Service
return yield* vcs.diff("git")
}),
)
const file = diff.find((item) => item.file === "file.txt")
expect(file?.patch).toContain(" same\rdiff --git inside")
expect(file?.patch).toContain("-delete")
expect(() => parsePatch(file?.patch ?? "")).not.toThrow()
})
}, 20_000)
test("diff('branch') returns changes against default branch", async () => {
await using tmp = await tmpdir({ git: true })
await $`git branch -M main`.cwd(tmp.path).quiet()

View File

@@ -8,17 +8,17 @@ import type { WorkspaceAdapter } from "../../src/control-plane/types"
import { Workspace } from "../../src/control-plane/workspace"
import { PermissionID } from "../../src/permission/schema"
import { ModelID, ProviderID } from "../../src/provider/schema"
import { Instance } from "../../src/project/instance"
import { WithInstance } from "../../src/project/with-instance"
import { Project } from "../../src/project/project"
import { Server } from "../../src/server/server"
import { SessionPaths } from "../../src/server/routes/instance/httpapi/groups/session"
import { Session } from "@/session/session"
import { MessageID, PartID, type SessionID } from "../../src/session/schema"
import { MessageID, PartID, SessionID, type SessionID as SessionIDType } from "../../src/session/schema"
import { MessageV2 } from "../../src/session/message-v2"
import { Database } from "@/storage/db"
import { SessionMessageTable, SessionTable } from "@/session/session.sql"
import { SessionMessage } from "../../src/v2/session-message"
import { Modelv2 } from "../../src/v2/model"
import * as DateTime from "effect/DateTime"
import * as Log from "@opencode-ai/core/util/log"
import { eq } from "drizzle-orm"
@@ -54,7 +54,7 @@ function createSession(directory: string, input?: Session.CreateInput) {
)
}
function createTextMessage(directory: string, sessionID: SessionID, text: string) {
function createTextMessage(directory: string, sessionID: SessionIDType, text: string) {
return Effect.promise(
async () =>
await WithInstance.provide({
@@ -124,6 +124,10 @@ function json<T>(response: Response) {
})
}
function responseJson(response: Response) {
return Effect.promise(() => response.json())
}
function requestJson<T>(path: string, init?: RequestInit) {
return request(path, init).pipe(Effect.flatMap(json<T>))
}
@@ -146,6 +150,47 @@ afterEach(async () => {
})
describe("session HttpApi", () => {
it.live(
"returns declared not found errors for read routes",
withTmp({ git: true, config: { formatter: false, lsp: false } }, (tmp) =>
Effect.gen(function* () {
const headers = { "x-opencode-directory": tmp.path }
const missingSession = SessionID.descending()
const missingSessionBody = {
name: "NotFoundError",
data: { message: `Session not found: ${missingSession}` },
}
const get = yield* request(pathFor(SessionPaths.get, { sessionID: missingSession }), { headers })
expect(get.status).toBe(404)
expect(yield* responseJson(get)).toEqual(missingSessionBody)
const messages = yield* request(pathFor(SessionPaths.messages, { sessionID: missingSession }), { headers })
expect(messages.status).toBe(404)
expect(yield* responseJson(messages)).toEqual(missingSessionBody)
const remove = yield* request(pathFor(SessionPaths.remove, { sessionID: missingSession }), {
headers,
method: "DELETE",
})
expect(remove.status).toBe(404)
expect(yield* responseJson(remove)).toEqual(missingSessionBody)
const session = yield* createSession(tmp.path, { title: "missing message" })
const missingMessage = MessageID.ascending()
const message = yield* request(
pathFor(SessionPaths.message, { sessionID: session.id, messageID: missingMessage }),
{ headers },
)
expect(message.status).toBe(404)
expect(yield* responseJson(message)).toEqual({
name: "NotFoundError",
data: { message: `Message not found: ${missingMessage}` },
})
}),
),
)
it.live(
"serves read routes through Hono bridge",
withTmp({ git: true, config: { formatter: false, lsp: false } }, (tmp) =>
@@ -214,7 +259,11 @@ describe("session HttpApi", () => {
id: SessionMessage.ID.create(),
type: "assistant",
agent: "build",
model: { id: "model", providerID: "provider" },
model: {
id: Modelv2.ID.make("model"),
providerID: Modelv2.ProviderID.make("provider"),
variant: Modelv2.VariantID.make("default"),
},
time: { created: DateTime.makeUnsafe(1) },
content: [],
})

View File

@@ -168,22 +168,19 @@ describe("workspace HttpApi", () => {
const created = yield* request(WorkspacePaths.list, dir, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ type: "local-test", branch: null, extra: null }),
body: JSON.stringify({ type: "local-test", branch: null }),
})
expect(created.status).toBe(200)
const workspace = (yield* Effect.promise(() => created.json())) as Workspace.Info
expect(workspace).toMatchObject({ type: "local-test", name: "local-test" })
const session = yield* Session.Service.use((svc) => svc.create({})).pipe(provideInstance(dir))
const restored = yield* request(WorkspacePaths.sessionRestore.replace(":id", workspace.id), dir, {
const warped = yield* request(WorkspacePaths.warp, dir, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ sessionID: session.id }),
})
expect(restored.status).toBe(200)
expect((yield* Effect.promise(() => restored.json())) as { total: number }).toMatchObject({
total: expect.any(Number),
body: JSON.stringify({ id: workspace.id, sessionID: session.id }),
})
expect(warped.status).toBe(204)
const removed = yield* request(WorkspacePaths.remove.replace(":id", workspace.id), dir, { method: "DELETE" })
expect(removed.status).toBe(200)
@@ -212,7 +209,6 @@ describe("workspace HttpApi", () => {
expect((yield* Effect.promise(() => created.json())) as Workspace.Info).toMatchObject({
type: "local-test",
name: "local-test",
extra: null,
})
}),
)
@@ -257,7 +253,6 @@ describe("workspace HttpApi", () => {
expect((yield* Effect.promise(() => created.json())) as Workspace.Info).toMatchObject({
type: "local-test",
name: "local-test",
extra: null,
})
}),
)
@@ -272,7 +267,7 @@ describe("workspace HttpApi", () => {
const created = yield* request(WorkspacePaths.list, dir, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ type: "local-target", branch: null, extra: null }),
body: JSON.stringify({ type: "local-target", branch: null }),
})
const workspace = (yield* Effect.promise(() => created.json())) as Workspace.Info
@@ -327,7 +322,7 @@ describe("workspace HttpApi", () => {
const created = yield* request(WorkspacePaths.list, dir, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ type: "remote-target", branch: null, extra: null }),
body: JSON.stringify({ type: "remote-target", branch: null }),
})
const workspace = (yield* Effect.promise(() => created.json())) as Workspace.Info
@@ -394,7 +389,7 @@ describe("workspace HttpApi", () => {
const created = yield* request(WorkspacePaths.list, dir, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ type: "remote-session-target", branch: null, extra: null }),
body: JSON.stringify({ type: "remote-session-target", branch: null }),
})
const workspace = (yield* Effect.promise(() => created.json())) as Workspace.Info
const session = yield* Session.Service.use((svc) => svc.create()).pipe(

View File

@@ -858,6 +858,43 @@ it.live(
30_000,
)
it.live(
"cancel propagates from slash command subtask to child session",
() =>
provideTmpdirServer(
Effect.fnUntraced(function* ({ llm }) {
const prompt = yield* SessionPrompt.Service
const sessions = yield* Session.Service
const status = yield* SessionStatus.Service
const chat = yield* sessions.create({ title: "Pinned" })
yield* llm.hang
const msg = yield* user(chat.id, "hello")
yield* addSubtask(chat.id, msg.id)
const fiber = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild)
yield* llm.wait(1)
const msgs = yield* MessageV2.filterCompactedEffect(chat.id)
const taskMsg = msgs.find((item) => item.info.role === "assistant" && item.info.agent === "general")
const tool = taskMsg ? toolPart(taskMsg.parts) : undefined
const sessionID = tool?.state.status === "running" ? tool.state.metadata?.sessionId : undefined
expect(typeof sessionID).toBe("string")
if (typeof sessionID !== "string") throw new Error("missing child session id")
const childID = SessionID.make(sessionID)
expect((yield* status.get(childID)).type).toBe("busy")
yield* prompt.cancel(chat.id)
const exit = yield* Fiber.await(fiber)
expect(Exit.isSuccess(exit)).toBe(true)
expect((yield* status.get(chat.id)).type).toBe("idle")
expect((yield* status.get(childID)).type).toBe("idle")
}),
{ git: true, config: providerCfg },
),
10_000,
)
it.live(
"cancel with queued callers resolves all cleanly",
() =>

View File

@@ -5,7 +5,7 @@ import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { Bus } from "../../src/bus"
import { SyncEvent } from "../../src/sync"
import { Database } from "@/storage/db"
import { EventTable } from "../../src/sync/event.sql"
import { EventSequenceTable, EventTable } from "../../src/sync/event.sql"
import { MessageID } from "../../src/session/schema"
import { Flag } from "@opencode-ai/core/flag/flag"
import { initProjectors } from "../../src/server/projectors"
@@ -252,5 +252,76 @@ describe("SyncEvent", () => {
}),
),
)
it.live(
"claims unowned event sequence on replay with ownerID",
provideTmpdirInstance(() =>
Effect.gen(function* () {
const { Created } = setup()
const id = MessageID.ascending()
yield* SyncEvent.use.replay(
{
id: "evt_1",
type: SyncEvent.versionedType(Created.type, Created.version),
seq: 0,
aggregateID: id,
data: { id, name: "owned" },
},
{ publish: false, ownerID: "owner-1" },
)
const row = Database.use((db) =>
db
.select({ seq: EventSequenceTable.seq, ownerID: EventSequenceTable.owner_id })
.from(EventSequenceTable)
.get(),
)
expect(row).toEqual({ seq: 0, ownerID: "owner-1" })
}),
),
)
it.live(
"ignores replay from a different owner after sequence is claimed",
provideTmpdirInstance(() =>
Effect.gen(function* () {
const { Created } = setup()
const id = MessageID.ascending()
yield* SyncEvent.use.replay(
{
id: "evt_1",
type: SyncEvent.versionedType(Created.type, Created.version),
seq: 0,
aggregateID: id,
data: { id, name: "first" },
},
{ publish: false, ownerID: "owner-1" },
)
yield* SyncEvent.use.replay(
{
id: "evt_2",
type: SyncEvent.versionedType(Created.type, Created.version),
seq: 1,
aggregateID: id,
data: { id, name: "ignored" },
},
{ publish: false, ownerID: "owner-2" },
)
const events = Database.use((db) => db.select().from(EventTable).all())
const sequence = Database.use((db) =>
db
.select({ seq: EventSequenceTable.seq, ownerID: EventSequenceTable.owner_id })
.from(EventSequenceTable)
.get(),
)
expect(events).toHaveLength(1)
expect(events[0].id).toBe("evt_1")
expect(sequence).toEqual({ seq: 0, ownerID: "owner-1" })
}),
),
)
})
})

View File

@@ -1,18 +1,17 @@
import { afterEach, describe, expect } from "bun:test"
import { Effect, Layer } from "effect"
import { Effect, Exit, Fiber, Layer } from "effect"
import { Agent } from "../../src/agent/agent"
import { Config } from "@/config/config"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { Instance } from "../../src/project/instance"
import { Session } from "@/session/session"
import { MessageV2 } from "../../src/session/message-v2"
import type { SessionPrompt } from "../../src/session/prompt"
import { MessageID, PartID } from "../../src/session/schema"
import { MessageID, PartID, SessionID } from "../../src/session/schema"
import { ModelID, ProviderID } from "../../src/provider/schema"
import { TaskTool, type TaskPromptOps } from "../../src/tool/task"
import { Truncate } from "@/tool/truncate"
import { ToolRegistry } from "@/tool/registry"
import { disposeAllInstances, provideTmpdirInstance } from "../fixture/fixture"
import { disposeAllInstances } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
afterEach(async () => {
@@ -35,6 +34,14 @@ const it = testEffect(
),
)
function defer<T>() {
let resolve!: (value: T | PromiseLike<T>) => void
const promise = new Promise<T>((done) => {
resolve = done
})
return { promise, resolve }
}
const seed = Effect.fn("TaskToolTest.seed")(function* (title = "Pinned") {
const session = yield* Session.Service
const chat = yield* session.create({ title })
@@ -66,7 +73,7 @@ const seed = Effect.fn("TaskToolTest.seed")(function* (title = "Pinned") {
function stubOps(opts?: { onPrompt?: (input: SessionPrompt.PromptInput) => void; text?: string }): TaskPromptOps {
return {
cancel() {},
cancel: () => Effect.void,
resolvePromptParts: (template) => Effect.succeed([{ type: "text" as const, text: template }]),
prompt: (input) =>
Effect.sync(() => {
@@ -107,102 +114,270 @@ function reply(input: SessionPrompt.PromptInput, text: string): MessageV2.WithPa
}
describe("tool.task", () => {
it.live("description sorts subagents by name and is stable across calls", () =>
provideTmpdirInstance(
() =>
Effect.gen(function* () {
const agent = yield* Agent.Service
const build = yield* agent.get("build")
const registry = yield* ToolRegistry.Service
const get = Effect.fnUntraced(function* () {
const tools = yield* registry.tools({ ...ref, agent: build })
return tools.find((tool) => tool.id === TaskTool.id)?.description ?? ""
})
const first = yield* get()
const second = yield* get()
it.instance(
"description sorts subagents by name and is stable across calls",
() =>
Effect.gen(function* () {
const agent = yield* Agent.Service
const build = yield* agent.get("build")
const registry = yield* ToolRegistry.Service
const get = Effect.fnUntraced(function* () {
const tools = yield* registry.tools({ ...ref, agent: build })
return tools.find((tool) => tool.id === TaskTool.id)?.description ?? ""
})
const first = yield* get()
const second = yield* get()
expect(first).toBe(second)
expect(first).toBe(second)
const alpha = first.indexOf("- alpha: Alpha agent")
const explore = first.indexOf("- explore:")
const general = first.indexOf("- general:")
const zebra = first.indexOf("- zebra: Zebra agent")
const alpha = first.indexOf("- alpha: Alpha agent")
const explore = first.indexOf("- explore:")
const general = first.indexOf("- general:")
const zebra = first.indexOf("- zebra: Zebra agent")
expect(alpha).toBeGreaterThan(-1)
expect(explore).toBeGreaterThan(alpha)
expect(general).toBeGreaterThan(explore)
expect(zebra).toBeGreaterThan(general)
}),
{
config: {
agent: {
zebra: {
description: "Zebra agent",
mode: "subagent",
},
alpha: {
description: "Alpha agent",
mode: "subagent",
},
expect(alpha).toBeGreaterThan(-1)
expect(explore).toBeGreaterThan(alpha)
expect(general).toBeGreaterThan(explore)
expect(zebra).toBeGreaterThan(general)
}),
{
config: {
agent: {
zebra: {
description: "Zebra agent",
mode: "subagent",
},
alpha: {
description: "Alpha agent",
mode: "subagent",
},
},
},
),
},
)
it.live("description hides denied subagents for the caller", () =>
provideTmpdirInstance(
() =>
Effect.gen(function* () {
const agent = yield* Agent.Service
const build = yield* agent.get("build")
const registry = yield* ToolRegistry.Service
const description =
(yield* registry.tools({ ...ref, agent: build })).find((tool) => tool.id === TaskTool.id)?.description ?? ""
it.instance(
"description hides denied subagents for the caller",
() =>
Effect.gen(function* () {
const agent = yield* Agent.Service
const build = yield* agent.get("build")
const registry = yield* ToolRegistry.Service
const description =
(yield* registry.tools({ ...ref, agent: build })).find((tool) => tool.id === TaskTool.id)?.description ?? ""
expect(description).toContain("- alpha: Alpha agent")
expect(description).not.toContain("- zebra: Zebra agent")
}),
{
config: {
permission: {
task: {
"*": "allow",
zebra: "deny",
},
expect(description).toContain("- alpha: Alpha agent")
expect(description).not.toContain("- zebra: Zebra agent")
}),
{
config: {
permission: {
task: {
"*": "allow",
zebra: "deny",
},
agent: {
zebra: {
description: "Zebra agent",
mode: "subagent",
},
alpha: {
description: "Alpha agent",
mode: "subagent",
},
},
agent: {
zebra: {
description: "Zebra agent",
mode: "subagent",
},
alpha: {
description: "Alpha agent",
mode: "subagent",
},
},
},
),
},
)
it.live("execute resumes an existing task session from task_id", () =>
provideTmpdirInstance(() =>
it.instance("execute resumes an existing task session from task_id", () =>
Effect.gen(function* () {
const sessions = yield* Session.Service
const { chat, assistant } = yield* seed()
const child = yield* sessions.create({ parentID: chat.id, title: "Existing child" })
const tool = yield* TaskTool
const def = yield* tool.init()
let seen: SessionPrompt.PromptInput | undefined
const promptOps = stubOps({ text: "resumed", onPrompt: (input) => (seen = input) })
const result = yield* def.execute(
{
description: "inspect bug",
prompt: "look into the cache key path",
subagent_type: "general",
task_id: child.id,
},
{
sessionID: chat.id,
messageID: assistant.id,
agent: "build",
abort: new AbortController().signal,
extra: { promptOps },
messages: [],
metadata: () => Effect.void,
ask: () => Effect.void,
},
)
const kids = yield* sessions.children(chat.id)
expect(kids).toHaveLength(1)
expect(kids[0]?.id).toBe(child.id)
expect(result.metadata.sessionId).toBe(child.id)
expect(result.output).toContain(`task_id: ${child.id}`)
expect(seen?.sessionID).toBe(child.id)
}),
)
it.instance("execute asks by default and skips checks when bypassed", () =>
Effect.gen(function* () {
const { chat, assistant } = yield* seed()
const tool = yield* TaskTool
const def = yield* tool.init()
const calls: unknown[] = []
const promptOps = stubOps()
const exec = (extra?: Record<string, any>) =>
def.execute(
{
description: "inspect bug",
prompt: "look into the cache key path",
subagent_type: "general",
},
{
sessionID: chat.id,
messageID: assistant.id,
agent: "build",
abort: new AbortController().signal,
extra: { promptOps, ...extra },
messages: [],
metadata: () => Effect.void,
ask: (input) =>
Effect.sync(() => {
calls.push(input)
}),
},
)
yield* exec()
yield* exec({ bypassAgentCheck: true })
expect(calls).toHaveLength(1)
expect(calls[0]).toEqual({
permission: "task",
patterns: ["general"],
always: ["*"],
metadata: {
description: "inspect bug",
subagent_type: "general",
},
})
}),
)
it.instance("execute cancels child session when abort signal fires", () =>
Effect.gen(function* () {
const { chat, assistant } = yield* seed()
const tool = yield* TaskTool
const def = yield* tool.init()
const ready = defer<SessionPrompt.PromptInput>()
const cancelled = defer<SessionID>()
const abort = new AbortController()
const promptOps: TaskPromptOps = {
cancel: (sessionID) =>
Effect.sync(() => {
cancelled.resolve(sessionID)
}),
resolvePromptParts: (template) => Effect.succeed([{ type: "text" as const, text: template }]),
prompt: (input) =>
Effect.promise(() => {
ready.resolve(input)
return cancelled.promise
}).pipe(Effect.as(reply(input, "cancelled"))),
}
const fiber = yield* def
.execute(
{
description: "inspect bug",
prompt: "look into the cache key path",
subagent_type: "general",
},
{
sessionID: chat.id,
messageID: assistant.id,
agent: "build",
abort: abort.signal,
extra: { promptOps },
messages: [],
metadata: () => Effect.void,
ask: () => Effect.void,
},
)
.pipe(Effect.forkChild)
const input = yield* Effect.promise(() => ready.promise)
abort.abort()
expect(yield* Effect.promise(() => cancelled.promise)).toBe(input.sessionID)
const exit = yield* Fiber.await(fiber)
expect(Exit.isSuccess(exit)).toBe(true)
}),
)
it.instance("execute creates a child when task_id does not exist", () =>
Effect.gen(function* () {
const sessions = yield* Session.Service
const { chat, assistant } = yield* seed()
const tool = yield* TaskTool
const def = yield* tool.init()
let seen: SessionPrompt.PromptInput | undefined
const promptOps = stubOps({ text: "created", onPrompt: (input) => (seen = input) })
const result = yield* def.execute(
{
description: "inspect bug",
prompt: "look into the cache key path",
subagent_type: "general",
task_id: "ses_missing",
},
{
sessionID: chat.id,
messageID: assistant.id,
agent: "build",
abort: new AbortController().signal,
extra: { promptOps },
messages: [],
metadata: () => Effect.void,
ask: () => Effect.void,
},
)
const kids = yield* sessions.children(chat.id)
expect(kids).toHaveLength(1)
expect(kids[0]?.id).toBe(result.metadata.sessionId)
expect(result.metadata.sessionId).not.toBe("ses_missing")
expect(result.output).toContain(`task_id: ${result.metadata.sessionId}`)
expect(seen?.sessionID).toBe(result.metadata.sessionId)
}),
)
it.instance(
"execute shapes child permissions for task, todowrite, and primary tools",
() =>
Effect.gen(function* () {
const sessions = yield* Session.Service
const { chat, assistant } = yield* seed()
const child = yield* sessions.create({ parentID: chat.id, title: "Existing child" })
const tool = yield* TaskTool
const def = yield* tool.init()
let seen: SessionPrompt.PromptInput | undefined
const promptOps = stubOps({ text: "resumed", onPrompt: (input) => (seen = input) })
const promptOps = stubOps({ onPrompt: (input) => (seen = input) })
const result = yield* def.execute(
{
description: "inspect bug",
prompt: "look into the cache key path",
subagent_type: "general",
task_id: child.id,
subagent_type: "reviewer",
},
{
sessionID: chat.id,
@@ -216,172 +391,45 @@ describe("tool.task", () => {
},
)
const kids = yield* sessions.children(chat.id)
expect(kids).toHaveLength(1)
expect(kids[0]?.id).toBe(child.id)
expect(result.metadata.sessionId).toBe(child.id)
expect(result.output).toContain(`task_id: ${child.id}`)
expect(seen?.sessionID).toBe(child.id)
}),
),
)
it.live("execute asks by default and skips checks when bypassed", () =>
provideTmpdirInstance(() =>
Effect.gen(function* () {
const { chat, assistant } = yield* seed()
const tool = yield* TaskTool
const def = yield* tool.init()
const calls: unknown[] = []
const promptOps = stubOps()
const exec = (extra?: Record<string, any>) =>
def.execute(
{
description: "inspect bug",
prompt: "look into the cache key path",
subagent_type: "general",
},
{
sessionID: chat.id,
messageID: assistant.id,
agent: "build",
abort: new AbortController().signal,
extra: { promptOps, ...extra },
messages: [],
metadata: () => Effect.void,
ask: (input) =>
Effect.sync(() => {
calls.push(input)
}),
},
)
yield* exec()
yield* exec({ bypassAgentCheck: true })
expect(calls).toHaveLength(1)
expect(calls[0]).toEqual({
permission: "task",
patterns: ["general"],
always: ["*"],
metadata: {
description: "inspect bug",
subagent_type: "general",
const child = yield* sessions.get(result.metadata.sessionId)
expect(child.parentID).toBe(chat.id)
expect(child.permission).toEqual([
{
permission: "todowrite",
pattern: "*",
action: "deny",
},
{
permission: "bash",
pattern: "*",
action: "allow",
},
{
permission: "read",
pattern: "*",
action: "allow",
},
])
expect(seen?.tools).toEqual({
todowrite: false,
bash: false,
read: false,
})
}),
),
)
it.live("execute creates a child when task_id does not exist", () =>
provideTmpdirInstance(() =>
Effect.gen(function* () {
const sessions = yield* Session.Service
const { chat, assistant } = yield* seed()
const tool = yield* TaskTool
const def = yield* tool.init()
let seen: SessionPrompt.PromptInput | undefined
const promptOps = stubOps({ text: "created", onPrompt: (input) => (seen = input) })
const result = yield* def.execute(
{
description: "inspect bug",
prompt: "look into the cache key path",
subagent_type: "general",
task_id: "ses_missing",
},
{
sessionID: chat.id,
messageID: assistant.id,
agent: "build",
abort: new AbortController().signal,
extra: { promptOps },
messages: [],
metadata: () => Effect.void,
ask: () => Effect.void,
},
)
const kids = yield* sessions.children(chat.id)
expect(kids).toHaveLength(1)
expect(kids[0]?.id).toBe(result.metadata.sessionId)
expect(result.metadata.sessionId).not.toBe("ses_missing")
expect(result.output).toContain(`task_id: ${result.metadata.sessionId}`)
expect(seen?.sessionID).toBe(result.metadata.sessionId)
}),
),
)
it.live("execute shapes child permissions for task, todowrite, and primary tools", () =>
provideTmpdirInstance(
() =>
Effect.gen(function* () {
const sessions = yield* Session.Service
const { chat, assistant } = yield* seed()
const tool = yield* TaskTool
const def = yield* tool.init()
let seen: SessionPrompt.PromptInput | undefined
const promptOps = stubOps({ onPrompt: (input) => (seen = input) })
const result = yield* def.execute(
{
description: "inspect bug",
prompt: "look into the cache key path",
subagent_type: "reviewer",
{
config: {
agent: {
reviewer: {
mode: "subagent",
permission: {
task: "allow",
},
{
sessionID: chat.id,
messageID: assistant.id,
agent: "build",
abort: new AbortController().signal,
extra: { promptOps },
messages: [],
metadata: () => Effect.void,
ask: () => Effect.void,
},
)
const child = yield* sessions.get(result.metadata.sessionId)
expect(child.parentID).toBe(chat.id)
expect(child.permission).toEqual([
{
permission: "todowrite",
pattern: "*",
action: "deny",
},
{
permission: "bash",
pattern: "*",
action: "allow",
},
{
permission: "read",
pattern: "*",
action: "allow",
},
])
expect(seen?.tools).toEqual({
todowrite: false,
bash: false,
read: false,
})
}),
{
config: {
agent: {
reviewer: {
mode: "subagent",
permission: {
task: "allow",
},
},
},
experimental: {
primary_tools: ["bash", "read"],
},
},
experimental: {
primary_tools: ["bash", "read"],
},
},
),
},
)
})

View File

@@ -2,6 +2,7 @@ import { expect, test } from "bun:test"
import * as DateTime from "effect/DateTime"
import { SessionID } from "../../src/session/schema"
import { EventV2 } from "../../src/v2/event"
import { Modelv2 } from "../../src/v2/model"
import { SessionEvent } from "../../src/v2/session-event"
import { SessionMessageUpdater } from "../../src/v2/session-message-updater"
@@ -16,7 +17,11 @@ test("step snapshots carry over to assistant messages", () => {
sessionID,
timestamp: DateTime.makeUnsafe(1),
agent: "build",
model: { id: "model", providerID: "provider" },
model: {
id: Modelv2.ID.make("model"),
providerID: Modelv2.ProviderID.make("provider"),
variant: Modelv2.VariantID.make("default"),
},
snapshot: "before",
},
} satisfies SessionEvent.Event)
@@ -56,7 +61,11 @@ test("text ended populates assistant text content", () => {
sessionID,
timestamp: DateTime.makeUnsafe(1),
agent: "build",
model: { id: "model", providerID: "provider" },
model: {
id: Modelv2.ID.make("model"),
providerID: Modelv2.ProviderID.make("provider"),
variant: Modelv2.VariantID.make("default"),
},
},
} satisfies SessionEvent.Event)
@@ -96,7 +105,11 @@ test("tool completion stores completed timestamp", () => {
sessionID,
timestamp: DateTime.makeUnsafe(1),
agent: "build",
model: { id: "model", providerID: "provider" },
model: {
id: Modelv2.ID.make("model"),
providerID: Modelv2.ProviderID.make("provider"),
variant: Modelv2.VariantID.make("default"),
},
},
} satisfies SessionEvent.Event)

View File

@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/plugin",
"version": "1.14.33",
"version": "1.14.35",
"type": "module",
"license": "MIT",
"scripts": {

View File

@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/sdk",
"version": "1.14.33",
"version": "1.14.35",
"type": "module",
"license": "MIT",
"scripts": {

View File

@@ -35,9 +35,9 @@ import type {
ExperimentalWorkspaceListResponses,
ExperimentalWorkspaceRemoveErrors,
ExperimentalWorkspaceRemoveResponses,
ExperimentalWorkspaceSessionRestoreErrors,
ExperimentalWorkspaceSessionRestoreResponses,
ExperimentalWorkspaceStatusResponses,
ExperimentalWorkspaceWarpErrors,
ExperimentalWorkspaceWarpResponses,
FileListResponses,
FilePartInput,
FilePartSource,
@@ -169,6 +169,8 @@ import type {
SyncReplayErrors,
SyncReplayResponses,
SyncStartResponses,
SyncStealErrors,
SyncStealResponses,
TextPartInput,
ToolIdsErrors,
ToolIdsResponses,
@@ -1009,15 +1011,15 @@ export class Workspace extends HeyApiClient {
}
/**
* Restore session into workspace
* Warp session into workspace
*
* Replay a session's sync events into the target workspace in batches.
* Move a session's sync history into the target workspace, or detach it to the local project.
*/
public sessionRestore<ThrowOnError extends boolean = false>(
parameters: {
id: string
public warp<ThrowOnError extends boolean = false>(
parameters?: {
directory?: string
workspace?: string
id?: string
sessionID?: string
},
options?: Options<never, ThrowOnError>,
@@ -1027,20 +1029,20 @@ export class Workspace extends HeyApiClient {
[
{
args: [
{ in: "path", key: "id" },
{ in: "query", key: "directory" },
{ in: "query", key: "workspace" },
{ in: "body", key: "id" },
{ in: "body", key: "sessionID" },
],
},
],
)
return (options?.client ?? this.client).post<
ExperimentalWorkspaceSessionRestoreResponses,
ExperimentalWorkspaceSessionRestoreErrors,
ExperimentalWorkspaceWarpResponses,
ExperimentalWorkspaceWarpErrors,
ThrowOnError
>({
url: "/experimental/workspace/{id}/session-restore",
url: "/experimental/workspace/warp",
...options,
...params,
headers: {
@@ -3956,6 +3958,43 @@ export class Sync extends HeyApiClient {
})
}
/**
* Steal session into workspace
*
* Update a session to belong to the current workspace through the sync event system.
*/
public steal<ThrowOnError extends boolean = false>(
parameters?: {
directory?: string
workspace?: string
sessionID?: string
},
options?: Options<never, ThrowOnError>,
) {
const params = buildClientParams(
[parameters],
[
{
args: [
{ in: "query", key: "directory" },
{ in: "query", key: "workspace" },
{ in: "body", key: "sessionID" },
],
},
],
)
return (options?.client ?? this.client).post<SyncStealResponses, SyncStealErrors, ThrowOnError>({
url: "/sync/steal",
...options,
...params,
headers: {
"Content-Type": "application/json",
...options?.headers,
...params.headers,
},
})
}
private _history?: History
get history(): History {
return (this._history ??= new History({ client: this.client }))

View File

@@ -35,7 +35,6 @@ export type Event =
| EventVcsBranchUpdated
| EventWorkspaceReady
| EventWorkspaceFailed
| EventWorkspaceRestore
| EventWorkspaceStatus
| EventWorktreeReady
| EventWorktreeFailed
@@ -801,7 +800,6 @@ export type GlobalEvent = {
| EventVcsBranchUpdated
| EventWorkspaceReady
| EventWorkspaceFailed
| EventWorkspaceRestore
| EventWorkspaceStatus
| EventWorktreeReady
| EventWorktreeFailed
@@ -1877,9 +1875,11 @@ export type SyncEventSessionNextModelSwitched = {
data: {
timestamp: number
sessionID: string
id: string
providerID: string
variant?: string
model: {
id: string
providerID: string
variant: string
}
}
}
@@ -1950,7 +1950,7 @@ export type SyncEventSessionNextStepStarted = {
model: {
id: string
providerID: string
variant?: string
variant: string
}
snapshot?: string
}
@@ -1989,10 +1989,7 @@ export type SyncEventSessionNextStepFailed = {
data: {
timestamp: number
sessionID: string
error: {
type: string
message: string
}
error: SessionErrorUnknown
}
}
@@ -2190,10 +2187,7 @@ export type SyncEventSessionNextToolFailed = {
timestamp: number
sessionID: string
callID: string
error: {
type: string
message: string
}
error: SessionErrorUnknown
provider: {
executed: boolean
metadata?: {
@@ -2478,17 +2472,6 @@ export type EventWorkspaceFailed = {
}
}
export type EventWorkspaceRestore = {
id: string
type: "workspace.restore"
properties: {
workspaceID: string
sessionID: string
total: number
step: number
}
}
export type EventWorkspaceStatus = {
id: string
type: "workspace.status"
@@ -2629,9 +2612,11 @@ export type EventSessionNextModelSwitched = {
properties: {
timestamp: number
sessionID: string
id: string
providerID: string
variant?: string
model: {
id: string
providerID: string
variant: string
}
}
}
@@ -2706,7 +2691,7 @@ export type EventSessionNextStepStarted = {
model: {
id: string
providerID: string
variant?: string
variant: string
}
snapshot?: string
}
@@ -2733,16 +2718,18 @@ export type EventSessionNextStepEnded = {
}
}
export type SessionErrorUnknown = {
type: "unknown"
message: string
}
export type EventSessionNextStepFailed = {
id: string
type: "session.next.step.failed"
properties: {
timestamp: number
sessionID: string
error: {
type: string
message: string
}
error: SessionErrorUnknown
}
}
@@ -2913,10 +2900,7 @@ export type EventSessionNextToolFailed = {
timestamp: number
sessionID: string
callID: string
error: {
type: string
message: string
}
error: SessionErrorUnknown
provider: {
executed: boolean
metadata?: {
@@ -3007,7 +2991,7 @@ export type SessionInfo = {
model?: {
id: string
providerID: string
variant?: string
variant: string
}
time: {
created: number
@@ -3043,7 +3027,7 @@ export type SessionMessageModelSwitched = {
model: {
id: string
providerID: string
variant?: string
variant: string
}
}
@@ -3137,10 +3121,7 @@ export type SessionMessageToolStateError = {
structured: {
[key: string]: unknown
}
error: {
type: string
message: string
}
error: SessionErrorUnknown
}
export type SessionMessageAssistantTool = {
@@ -3180,7 +3161,7 @@ export type SessionMessageAssistant = {
model: {
id: string
providerID: string
variant?: string
variant: string
}
content: Array<SessionMessageAssistantText | SessionMessageAssistantReasoning | SessionMessageAssistantTool>
snapshot?: {
@@ -3198,10 +3179,7 @@ export type SessionMessageAssistant = {
write: number
}
}
error?: {
type: string
message: string
}
error?: SessionErrorUnknown
}
export type SessionMessageCompaction = {
@@ -6023,6 +6001,38 @@ export type SyncReplayResponses = {
export type SyncReplayResponse = SyncReplayResponses[keyof SyncReplayResponses]
export type SyncStealData = {
body?: {
sessionID: string
}
path?: never
query?: {
directory?: string
workspace?: string
}
url: "/sync/steal"
}
export type SyncStealErrors = {
/**
* Bad request
*/
400: BadRequestError
}
export type SyncStealError = SyncStealErrors[keyof SyncStealErrors]
export type SyncStealResponses = {
/**
* Session stolen into workspace
*/
200: {
sessionID: string
}
}
export type SyncStealResponse = SyncStealResponses[keyof SyncStealResponses]
export type SyncHistoryListData = {
body?: {
[key: string]: number
@@ -6644,41 +6654,37 @@ export type ExperimentalWorkspaceRemoveResponses = {
export type ExperimentalWorkspaceRemoveResponse =
ExperimentalWorkspaceRemoveResponses[keyof ExperimentalWorkspaceRemoveResponses]
export type ExperimentalWorkspaceSessionRestoreData = {
export type ExperimentalWorkspaceWarpData = {
body?: {
id: string
sessionID: string
}
path: {
id: string
}
path?: never
query?: {
directory?: string
workspace?: string
}
url: "/experimental/workspace/{id}/session-restore"
url: "/experimental/workspace/warp"
}
export type ExperimentalWorkspaceSessionRestoreErrors = {
export type ExperimentalWorkspaceWarpErrors = {
/**
* Bad request
*/
400: BadRequestError
}
export type ExperimentalWorkspaceSessionRestoreError =
ExperimentalWorkspaceSessionRestoreErrors[keyof ExperimentalWorkspaceSessionRestoreErrors]
export type ExperimentalWorkspaceWarpError = ExperimentalWorkspaceWarpErrors[keyof ExperimentalWorkspaceWarpErrors]
export type ExperimentalWorkspaceSessionRestoreResponses = {
export type ExperimentalWorkspaceWarpResponses = {
/**
* Session replay started
* Session warped
*/
200: {
total: number
}
204: void
}
export type ExperimentalWorkspaceSessionRestoreResponse =
ExperimentalWorkspaceSessionRestoreResponses[keyof ExperimentalWorkspaceSessionRestoreResponses]
export type ExperimentalWorkspaceWarpResponse =
ExperimentalWorkspaceWarpResponses[keyof ExperimentalWorkspaceWarpResponses]
export type PtyConnectData = {
body?: never

View File

@@ -6785,6 +6785,84 @@
]
}
},
"/sync/steal": {
"post": {
"tags": ["sync"],
"operationId": "sync.steal",
"parameters": [
{
"name": "directory",
"in": "query",
"required": false,
"schema": {
"type": "string"
}
},
{
"name": "workspace",
"in": "query",
"required": false,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "Session stolen into workspace",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"sessionID": {
"type": "string"
}
},
"required": ["sessionID"],
"additionalProperties": false,
"description": "Session stolen into workspace"
}
}
}
},
"400": {
"description": "Bad request",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/BadRequestError"
}
}
}
}
},
"description": "Update a session to belong to the current workspace through the sync event system.",
"summary": "Steal session into workspace",
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"sessionID": {
"type": "string"
}
},
"required": ["sessionID"],
"additionalProperties": false
}
}
}
},
"x-codeSamples": [
{
"lang": "js",
"source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.sync.steal({\n ...\n})"
}
]
}
},
"/sync/history": {
"post": {
"tags": ["sync"],
@@ -8281,10 +8359,10 @@
]
}
},
"/experimental/workspace/{id}/session-restore": {
"/experimental/workspace/warp": {
"post": {
"tags": ["workspace"],
"operationId": "experimental.workspace.sessionRestore",
"operationId": "experimental.workspace.warp",
"parameters": [
{
"name": "directory",
@@ -8301,36 +8379,11 @@
"schema": {
"type": "string"
}
},
{
"name": "id",
"in": "path",
"schema": {
"type": "string",
"pattern": "^wrk.*"
},
"required": true
}
],
"responses": {
"200": {
"description": "Session replay started",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"total": {
"type": "integer",
"minimum": 0
}
},
"required": ["total"],
"additionalProperties": false,
"description": "Session replay started"
}
}
}
"204": {
"description": "Session warped"
},
"400": {
"description": "Bad request",
@@ -8343,19 +8396,22 @@
}
}
},
"description": "Replay a session's sync events into the target workspace in batches.",
"summary": "Restore session into workspace",
"description": "Move a session's sync history into the target workspace, or detach it to the local project.",
"summary": "Warp session into workspace",
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"sessionID": {
"type": "string"
}
},
"required": ["sessionID"],
"required": ["id", "sessionID"],
"additionalProperties": false
}
}
@@ -8364,7 +8420,7 @@
"x-codeSamples": [
{
"lang": "js",
"source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.experimental.workspace.sessionRestore({\n ...\n})"
"source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.experimental.workspace.warp({\n ...\n})"
}
]
}
@@ -8538,9 +8594,6 @@
{
"$ref": "#/components/schemas/EventWorkspaceFailed"
},
{
"$ref": "#/components/schemas/EventWorkspaceRestore"
},
{
"$ref": "#/components/schemas/EventWorkspaceStatus"
},
@@ -10737,9 +10790,6 @@
{
"$ref": "#/components/schemas/EventWorkspaceFailed"
},
{
"$ref": "#/components/schemas/EventWorkspaceRestore"
},
{
"$ref": "#/components/schemas/EventWorkspaceStatus"
},
@@ -13948,17 +13998,24 @@
"sessionID": {
"type": "string"
},
"id": {
"type": "string"
},
"providerID": {
"type": "string"
},
"variant": {
"type": "string"
"model": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"providerID": {
"type": "string"
},
"variant": {
"type": "string"
}
},
"required": ["id", "providerID", "variant"],
"additionalProperties": false
}
},
"required": ["timestamp", "sessionID", "id", "providerID"],
"required": ["timestamp", "sessionID", "model"],
"additionalProperties": false
}
},
@@ -14181,7 +14238,7 @@
"type": "string"
}
},
"required": ["id", "providerID"],
"required": ["id", "providerID", "variant"],
"additionalProperties": false
},
"snapshot": {
@@ -14307,17 +14364,7 @@
"type": "string"
},
"error": {
"type": "object",
"properties": {
"type": {
"type": "string"
},
"message": {
"type": "string"
}
},
"required": ["type", "message"],
"additionalProperties": false
"$ref": "#/components/schemas/SessionErrorUnknown"
}
},
"required": ["timestamp", "sessionID", "error"],
@@ -14929,17 +14976,7 @@
"type": "string"
},
"error": {
"type": "object",
"properties": {
"type": {
"type": "string"
},
"message": {
"type": "string"
}
},
"required": ["type", "message"],
"additionalProperties": false
"$ref": "#/components/schemas/SessionErrorUnknown"
},
"provider": {
"type": "object",
@@ -15793,41 +15830,6 @@
"required": ["id", "type", "properties"],
"additionalProperties": false
},
"EventWorkspaceRestore": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"type": {
"type": "string",
"enum": ["workspace.restore"]
},
"properties": {
"type": "object",
"properties": {
"workspaceID": {
"type": "string"
},
"sessionID": {
"type": "string"
},
"total": {
"type": "integer",
"minimum": 0
},
"step": {
"type": "integer",
"minimum": 0
}
},
"required": ["workspaceID", "sessionID", "total", "step"],
"additionalProperties": false
}
},
"required": ["id", "type", "properties"],
"additionalProperties": false
},
"EventWorkspaceStatus": {
"type": "object",
"properties": {
@@ -16252,17 +16254,24 @@
"sessionID": {
"type": "string"
},
"id": {
"type": "string"
},
"providerID": {
"type": "string"
},
"variant": {
"type": "string"
"model": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"providerID": {
"type": "string"
},
"variant": {
"type": "string"
}
},
"required": ["id", "providerID", "variant"],
"additionalProperties": false
}
},
"required": ["timestamp", "sessionID", "id", "providerID"],
"required": ["timestamp", "sessionID", "model"],
"additionalProperties": false
}
},
@@ -16481,7 +16490,7 @@
"type": "string"
}
},
"required": ["id", "providerID"],
"required": ["id", "providerID", "variant"],
"additionalProperties": false
},
"snapshot": {
@@ -16565,6 +16574,20 @@
"required": ["id", "type", "properties"],
"additionalProperties": false
},
"SessionErrorUnknown": {
"type": "object",
"properties": {
"type": {
"type": "string",
"enum": ["unknown"]
},
"message": {
"type": "string"
}
},
"required": ["type", "message"],
"additionalProperties": false
},
"EventSessionNextStepFailed": {
"type": "object",
"properties": {
@@ -16585,17 +16608,7 @@
"type": "string"
},
"error": {
"type": "object",
"properties": {
"type": {
"type": "string"
},
"message": {
"type": "string"
}
},
"required": ["type", "message"],
"additionalProperties": false
"$ref": "#/components/schemas/SessionErrorUnknown"
}
},
"required": ["timestamp", "sessionID", "error"],
@@ -17098,17 +17111,7 @@
"type": "string"
},
"error": {
"type": "object",
"properties": {
"type": {
"type": "string"
},
"message": {
"type": "string"
}
},
"required": ["type", "message"],
"additionalProperties": false
"$ref": "#/components/schemas/SessionErrorUnknown"
},
"provider": {
"type": "object",
@@ -17361,7 +17364,7 @@
"type": "string"
}
},
"required": ["id", "providerID"],
"required": ["id", "providerID", "variant"],
"additionalProperties": false
},
"time": {
@@ -17457,7 +17460,7 @@
"type": "string"
}
},
"required": ["id", "providerID"],
"required": ["id", "providerID", "variant"],
"additionalProperties": false
}
},
@@ -17716,17 +17719,7 @@
"type": "object"
},
"error": {
"type": "object",
"properties": {
"type": {
"type": "string"
},
"message": {
"type": "string"
}
},
"required": ["type", "message"],
"additionalProperties": false
"$ref": "#/components/schemas/SessionErrorUnknown"
}
},
"required": ["status", "input", "content", "structured", "error"],
@@ -17839,7 +17832,7 @@
"type": "string"
}
},
"required": ["id", "providerID"],
"required": ["id", "providerID", "variant"],
"additionalProperties": false
},
"content": {
@@ -17906,17 +17899,7 @@
"additionalProperties": false
},
"error": {
"type": "object",
"properties": {
"type": {
"type": "string"
},
"message": {
"type": "string"
}
},
"required": ["type", "message"],
"additionalProperties": false
"$ref": "#/components/schemas/SessionErrorUnknown"
}
},
"required": ["id", "time", "type", "agent", "model", "content"],

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/slack",
"version": "1.14.33",
"version": "1.14.35",
"type": "module",
"license": "MIT",
"scripts": {

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/ui",
"version": "1.14.33",
"version": "1.14.35",
"type": "module",
"license": "MIT",
"exports": {
@@ -25,6 +25,8 @@
},
"scripts": {
"typecheck": "tsgo --noEmit",
"test": "bun test src",
"test:ci": "mkdir -p .artifacts/unit && bun test src --reporter=junit --reporter-outfile=.artifacts/unit/junit.xml",
"dev": "vite",
"generate:tailwind": "bun run script/tailwind.ts"
},

View File

@@ -19,6 +19,21 @@ describe("session diff", () => {
expect(text(view, "additions")).toBe("one\nthree\n")
})
test("keeps missing final newlines from unified patches", () => {
const diff = {
file: "a.ts",
patch:
"Index: a.ts\n===================================================================\n--- a.ts\t\n+++ a.ts\t\n@@ -1,2 +1,2 @@\n one\n-two\n\\ No newline at end of file\n+three\n\\ No newline at end of file\n",
additions: 1,
deletions: 1,
status: "modified" as const,
}
const view = normalize(diff)
expect(text(view, "deletions")).toBe("one\ntwo")
expect(text(view, "additions")).toBe("one\nthree")
})
test("converts legacy content into a patch", () => {
const diff = {
file: "a.ts",
@@ -34,4 +49,20 @@ describe("session diff", () => {
expect(text(view, "deletions")).toBe("one\n")
expect(text(view, "additions")).toBe("two\n")
})
test("ignores malformed persisted patches", () => {
const diff = {
file: "a.ts",
patch:
"diff --git a/a.ts b/a.ts\nindex ff4ceb2..65a1de0 100644\n--- a/a.ts\n+++ b/a.ts\n@@ -1,3 +1,3 @@\n keep\n+add\n same\r",
additions: 1,
deletions: 1,
status: "modified" as const,
}
const view = normalize(diff)
expect(view.patch).toBe(diff.patch)
expect(text(view, "deletions")).toBe("")
expect(text(view, "additions")).toBe("")
})
})

View File

@@ -27,26 +27,49 @@ const cache = new Map<string, FileDiffMetadata>()
function patch(diff: ReviewDiff) {
if (typeof diff.patch === "string") {
const [patch] = parsePatch(diff.patch)
try {
const [patch] = parsePatch(diff.patch)
const beforeLines: Array<{ text: string; newline: boolean }> = []
const afterLines: Array<{ text: string; newline: boolean }> = []
let previous: "-" | "+" | " " | undefined
const beforeLines = []
const afterLines = []
for (const hunk of patch.hunks) {
for (const line of hunk.lines) {
if (line.startsWith("\\")) {
if (previous === "-" || previous === " ") {
const before = beforeLines.at(-1)
if (before) before.newline = false
}
if (previous === "+" || previous === " ") {
const after = afterLines.at(-1)
if (after) after.newline = false
}
continue
}
for (const hunk of patch.hunks) {
for (const line of hunk.lines) {
if (line.startsWith("-")) {
beforeLines.push(line.slice(1))
} else if (line.startsWith("+")) {
afterLines.push(line.slice(1))
} else {
// context line (starts with ' ')
beforeLines.push(line.slice(1))
afterLines.push(line.slice(1))
if (line.startsWith("-")) {
beforeLines.push({ text: line.slice(1), newline: true })
previous = "-"
} else if (line.startsWith("+")) {
afterLines.push({ text: line.slice(1), newline: true })
previous = "+"
} else {
// context line (starts with ' ')
beforeLines.push({ text: line.slice(1), newline: true })
afterLines.push({ text: line.slice(1), newline: true })
previous = " "
}
}
}
}
return { before: beforeLines.join("\n"), after: afterLines.join("\n"), patch: diff.patch }
return {
before: beforeLines.map((line) => line.text + (line.newline ? "\n" : "")).join(""),
after: afterLines.map((line) => line.text + (line.newline ? "\n" : "")).join(""),
patch: diff.patch,
}
} catch {
return { before: "", after: "", patch: diff.patch }
}
}
return {
before: "before" in diff && typeof diff.before === "string" ? diff.before : "",

View File

@@ -2,7 +2,7 @@
"name": "@opencode-ai/web",
"type": "module",
"license": "MIT",
"version": "1.14.33",
"version": "1.14.35",
"scripts": {
"dev": "astro dev",
"dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev",

View File

@@ -2,7 +2,7 @@
"name": "opencode",
"displayName": "opencode",
"description": "opencode for VS Code",
"version": "1.14.33",
"version": "1.14.35",
"publisher": "sst-dev",
"repository": {
"type": "git",

View File

@@ -1,131 +0,0 @@
# Session V2 Concept Gaps
Compared with `packages/opencode/src/session/message-v2.ts` and `packages/opencode/src/session/processor.ts`, `packages/opencode/src/v2` currently captures the rough event stream for prompts, assistant steps, text, reasoning, tools, retries, and compaction, but it does not yet capture several persisted-message and processor concepts.
## Message Metadata
- User messages are missing selected `agent`, `model`, `system`, enabled `tools`, output `format`, and summary metadata.
- Assistant messages are missing `parentID`, `agent`, `providerID`, `modelID`, `variant`, `path.cwd`, `path.root`, deprecated `mode`, `summary`, `structured`, `finish`, and typed `error`.
## Output Format
- Text output format.
- JSON-schema output format.
- Structured-output retry count.
- Structured assistant result payload.
- Structured-output error classification.
## Errors
- Aborted error.
- Provider auth error.
- API error with status, retryability, headers, body, and metadata.
- Context-overflow error.
- Output-length error.
- Unknown error.
- V2 mostly reduces assistant errors to strings, except retry errors.
## Part Identity
- V1 has stable `MessageID`, `PartID`, `sessionID`, and `messageID` on every part.
- V2 assistant content does not preserve stable per-content IDs.
- Stable content IDs matter for deltas, updates, removals, sync events, and UI reconciliation.
## Part Timing And Metadata
- V1 text, reasoning, and tool states carry timing and provider metadata.
- V2 assistant text and reasoning content only store text.
- V2 events include metadata, but `SessionEntry` currently drops most provider metadata.
## Snapshots And Patches
- Snapshot parts.
- Patch parts.
- Step-start snapshot references.
- Step-finish snapshot references.
- Processor behavior that tracks a snapshot before the stream and emits patches after step finish or cleanup.
## Step Boundaries
- V1 stores `step-start` and `step-finish` as first-class parts.
- V2 has `step.started` and `step.ended` events, but the assistant entry only stores aggregate cost and tokens.
- V2 does not preserve step boundary parts, finish reason, or snapshot details in the entry model.
## Compaction
- V1 compaction parts have `auto`, `overflow`, and `tail_start_id`.
- V2 compacted events have `auto` and optional `overflow`, but no retained-tail marker.
- V1 also has history filtering semantics around completed summary messages and retained tails.
## Files And Sources
- V1 file parts have `mime`, `filename`, `url`, and typed source information.
- V1 source variants include file, symbol, and resource sources.
- Symbol sources include LSP range, name, and kind.
- Resource sources include client name and URI.
- V2 file attachments have `uri`, `mime`, `name`, `description`, and a generic text source, but lose source type, LSP metadata, and resource metadata.
## Agents And Subtasks
- Agent parts.
- Subtask parts.
- Subtask prompt, description, agent, model, and command.
- V2 has agent attachments on prompts, but no assistant/session content equivalent for subtask execution.
## Text Flags
- Synthetic text flag.
- Ignored text flag.
- V2 has a separate synthetic entry, but no ignored text concept.
## Tool Calls
- V1 pending tool state stores parsed input and raw input text separately.
- V2 pending tool state stores a string input but does not preserve a separate raw field.
- V1 completed tool state has `time.start`, `time.end`, and optional `time.compacted`.
- V2 tool time has `created`, `ran`, `completed`, and `pruned`, but the stepper currently does not set `completed` or `pruned`.
- V1 error tool state has `time.start` and `time.end`.
- V1 supports interrupted tool errors with `metadata.interrupted` and preserved partial output.
- V1 tracks provider execution and provider call metadata.
- V2 events include provider info, but `SessionEntryStepper` drops it from entries.
- V1 has tool-output compaction and truncation behavior via `time.compacted`.
## Media Handling
- V1 models tool attachments as file parts and has provider-specific handling for media in tool results.
- V1 can strip media, inject synthetic user messages for unsupported providers, and uses a synthetic attachment prompt.
- V2 has attachments but not these model-message conversion semantics.
## Retries
- V1 stores retries as independently addressable retry parts.
- V2 stores retries as an assistant aggregate.
- V2 captures some retry information, but not the independent part identity/update model.
## Processor Control Flow
- Session status transitions: busy, retry, and idle.
- Retry policy integration.
- Context-overflow-driven compaction.
- Abort and interrupt handling.
- Permission-denied blocking.
- Doom-loop detection.
- Plugin hook for `experimental.text.complete`.
- Background summary generation after steps.
- Cleanup semantics for open text, reasoning, and tool calls.
## Sync And Bus Events
- Message updated.
- Message removed.
- Message part updated.
- Message part delta.
- Message part removed.
- V2 has domain events, but not the sync/bus event model for persisted message and part updates/removals.
## History Retrieval
- Cursor encoding and decoding.
- Paged message retrieval.
- Reverse streaming through history.
- Compaction-aware history filtering.

59
specs/v2/todo.md Normal file
View File

@@ -0,0 +1,59 @@
# TODO
ok we need to work towards a launch of v2 so we can get out of this rebuild phase
## Kill Hono - Kit
Hono needs to go away so zod can go away. this is almost done
## New Data Mode - Dax
This is mostly done. I'm working through modeling subagents, skill invocations
and shell commands.
## Rework agent loop - Kit?
I think this needs to be done so we can take advantage of the simpler data
model. It can stop doing all the
## Rework compaction - Aiden?
The new agent loop needs to trigger compaction properly
## Plugin API design - James?
We need to figure out how we want server plugins to work and what hooks are useful.
Some ideas:
- plugins get immer drafts so bad mutations can be thrown away
- plugins get global "opencode" instance like in that post i showed
- opencode instance has stuff like `opencode.session.prompt()` or
`opencode.tool.register({...})`
## Rework Config - ???
We should do another pass on config to clean up any mistakes we made with it and
simplify as much as possible. Old configs should get auto-converted to new
## Auth - ???
I have a basic auth system that can track any kind of auth, not just providers
## Model Database - ???
I have a basic model service that allows for models to be registered dynamically
## Provider - ???
Providers should register as plugins and autoload based on whatever logic they
want / config. They should register models into model database
## Event - Kit
I have this v2/event.ts but it needs to be self contained instead of using the
old bus system
## Everything is hotreloadable - ???
Instead of needing to tear down things when something changes every service should emit granular events so services can react to them and reconfigure themselves. Allows frontend to receive these too, eg model.added. also prevents startup from blocking

View File

@@ -26,6 +26,15 @@
"dependsOn": ["^build"],
"outputs": [".artifacts/unit/junit.xml"],
"passThroughEnv": ["*"]
},
"@opencode-ai/ui#test": {
"dependsOn": ["^build"],
"outputs": []
},
"@opencode-ai/ui#test:ci": {
"dependsOn": ["^build"],
"outputs": [".artifacts/unit/junit.xml"],
"passThroughEnv": ["*"]
}
}
}