mirror of
https://fastgit.cc/https://github.com/anomalyco/opencode
synced 2026-05-06 00:31:03 +08:00
Compare commits
1 Commits
feature/v2
...
v1.14.34
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b5f433b444 |
32
bun.lock
32
bun.lock
@@ -29,7 +29,7 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@opencode-ai/app",
|
||||
"version": "1.14.35",
|
||||
"version": "1.14.34",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
@@ -85,7 +85,7 @@
|
||||
},
|
||||
"packages/console/app": {
|
||||
"name": "@opencode-ai/console-app",
|
||||
"version": "1.14.35",
|
||||
"version": "1.14.34",
|
||||
"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.35",
|
||||
"version": "1.14.34",
|
||||
"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.35",
|
||||
"version": "1.14.34",
|
||||
"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.35",
|
||||
"version": "1.14.34",
|
||||
"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.35",
|
||||
"version": "1.14.34",
|
||||
"bin": {
|
||||
"opencode": "./bin/opencode",
|
||||
},
|
||||
@@ -228,7 +228,7 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@opencode-ai/desktop",
|
||||
"version": "1.14.35",
|
||||
"version": "1.14.34",
|
||||
"dependencies": {
|
||||
"@opencode-ai/app": "workspace:*",
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
@@ -263,7 +263,7 @@
|
||||
},
|
||||
"packages/desktop-electron": {
|
||||
"name": "@opencode-ai/desktop-electron",
|
||||
"version": "1.14.35",
|
||||
"version": "1.14.34",
|
||||
"dependencies": {
|
||||
"drizzle-orm": "catalog:",
|
||||
"effect": "catalog:",
|
||||
@@ -309,7 +309,7 @@
|
||||
},
|
||||
"packages/enterprise": {
|
||||
"name": "@opencode-ai/enterprise",
|
||||
"version": "1.14.35",
|
||||
"version": "1.14.34",
|
||||
"dependencies": {
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
@@ -338,7 +338,7 @@
|
||||
},
|
||||
"packages/function": {
|
||||
"name": "@opencode-ai/function",
|
||||
"version": "1.14.35",
|
||||
"version": "1.14.34",
|
||||
"dependencies": {
|
||||
"@octokit/auth-app": "8.0.1",
|
||||
"@octokit/rest": "catalog:",
|
||||
@@ -354,7 +354,7 @@
|
||||
},
|
||||
"packages/opencode": {
|
||||
"name": "opencode",
|
||||
"version": "1.14.35",
|
||||
"version": "1.14.34",
|
||||
"bin": {
|
||||
"opencode": "./bin/opencode",
|
||||
},
|
||||
@@ -496,7 +496,7 @@
|
||||
},
|
||||
"packages/plugin": {
|
||||
"name": "@opencode-ai/plugin",
|
||||
"version": "1.14.35",
|
||||
"version": "1.14.34",
|
||||
"dependencies": {
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"effect": "catalog:",
|
||||
@@ -531,7 +531,7 @@
|
||||
},
|
||||
"packages/sdk/js": {
|
||||
"name": "@opencode-ai/sdk",
|
||||
"version": "1.14.35",
|
||||
"version": "1.14.34",
|
||||
"dependencies": {
|
||||
"cross-spawn": "catalog:",
|
||||
},
|
||||
@@ -546,7 +546,7 @@
|
||||
},
|
||||
"packages/slack": {
|
||||
"name": "@opencode-ai/slack",
|
||||
"version": "1.14.35",
|
||||
"version": "1.14.34",
|
||||
"dependencies": {
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"@slack/bolt": "^3.17.1",
|
||||
@@ -581,7 +581,7 @@
|
||||
},
|
||||
"packages/ui": {
|
||||
"name": "@opencode-ai/ui",
|
||||
"version": "1.14.35",
|
||||
"version": "1.14.34",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
@@ -630,7 +630,7 @@
|
||||
},
|
||||
"packages/web": {
|
||||
"name": "@opencode-ai/web",
|
||||
"version": "1.14.35",
|
||||
"version": "1.14.34",
|
||||
"dependencies": {
|
||||
"@astrojs/cloudflare": "12.6.3",
|
||||
"@astrojs/markdown-remark": "6.3.1",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/app",
|
||||
"version": "1.14.35",
|
||||
"version": "1.14.34",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-app",
|
||||
"version": "1.14.35",
|
||||
"version": "1.14.34",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -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-mac-arm64.dmg",
|
||||
"darwin-x64-dmg": "opencode-desktop-mac-x64.dmg",
|
||||
"windows-x64-nsis": "opencode-desktop-win-x64.exe",
|
||||
"darwin-aarch64-dmg": "opencode-desktop-darwin-aarch64.dmg",
|
||||
"darwin-x64-dmg": "opencode-desktop-darwin-x64.dmg",
|
||||
"windows-x64-nsis": "opencode-desktop-windows-x64.exe",
|
||||
"linux-x64-deb": "opencode-desktop-linux-amd64.deb",
|
||||
"linux-x64-appimage": "opencode-desktop-linux-x86_64.AppImage",
|
||||
"linux-x64-appimage": "opencode-desktop-linux-amd64.AppImage",
|
||||
"linux-x64-rpm": "opencode-desktop-linux-x86_64.rpm",
|
||||
} satisfies Record<DownloadPlatform, string>
|
||||
|
||||
@@ -32,6 +32,13 @@ 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]
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/console-core",
|
||||
"version": "1.14.35",
|
||||
"version": "1.14.34",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-function",
|
||||
"version": "1.14.35",
|
||||
"version": "1.14.34",
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-mail",
|
||||
"version": "1.14.35",
|
||||
"version": "1.14.34",
|
||||
"dependencies": {
|
||||
"@jsx-email/all": "2.2.3",
|
||||
"@jsx-email/cli": "1.4.3",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "1.14.35",
|
||||
"version": "1.14.34",
|
||||
"name": "@opencode-ai/core",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -71,8 +71,6 @@ export const layer = Layer.effect(
|
||||
Effect.sync(() => Service.of(make())),
|
||||
)
|
||||
|
||||
export const defaultLayer = layer
|
||||
|
||||
export const layerWith = (input: Partial<Interface>) =>
|
||||
Layer.effect(
|
||||
Service,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@opencode-ai/desktop-electron",
|
||||
"private": true,
|
||||
"version": "1.14.35",
|
||||
"version": "1.14.34",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"homepage": "https://opencode.ai",
|
||||
|
||||
@@ -74,7 +74,6 @@ 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()
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@opencode-ai/desktop",
|
||||
"private": true,
|
||||
"version": "1.14.35",
|
||||
"version": "1.14.34",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/enterprise",
|
||||
"version": "1.14.35",
|
||||
"version": "1.14.34",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
id = "opencode"
|
||||
name = "OpenCode"
|
||||
description = "The open source coding agent."
|
||||
version = "1.14.35"
|
||||
version = "1.14.34"
|
||||
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.35/opencode-darwin-arm64.zip"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.34/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.35/opencode-darwin-x64.zip"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.34/opencode-darwin-x64.zip"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.linux-aarch64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.35/opencode-linux-arm64.tar.gz"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.34/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.35/opencode-linux-x64.tar.gz"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.34/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.35/opencode-windows-x64.zip"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.34/opencode-windows-x64.zip"
|
||||
cmd = "./opencode.exe"
|
||||
args = ["acp"]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/function",
|
||||
"version": "1.14.35",
|
||||
"version": "1.14.34",
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
ALTER TABLE `event_sequence` ADD `owner_id` text;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "1.14.35",
|
||||
"version": "1.14.34",
|
||||
"name": "opencode",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -776,9 +776,9 @@ const scenarios: Scenario[] = [
|
||||
}))
|
||||
.status(200),
|
||||
http
|
||||
.post("/experimental/workspace/warp", "experimental.workspace.warp")
|
||||
.post("/experimental/workspace/{id}/session-restore", "experimental.workspace.sessionRestore")
|
||||
.at((ctx) => ({
|
||||
path: "/experimental/workspace/warp",
|
||||
path: route("/experimental/workspace/{id}/session-restore", { id: "wrk_httpapi_missing" }),
|
||||
headers: ctx.headers(),
|
||||
body: {},
|
||||
}))
|
||||
|
||||
@@ -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, type JSX } from "solid-js"
|
||||
import { createMemo, createResource, createSignal, onMount } from "solid-js"
|
||||
import { Locale } from "@/util/locale"
|
||||
import { useProject } from "@tui/context/project"
|
||||
import { useKeybind } from "../context/keybind"
|
||||
@@ -10,13 +10,15 @@ 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 { openWorkspaceSelect, type WorkspaceSelection, warpWorkspaceSession } from "./dialog-workspace-create"
|
||||
import { DialogWorkspaceCreate, openWorkspaceSession, restoreWorkspaceSession } from "./dialog-workspace-create"
|
||||
import { Spinner } from "./spinner"
|
||||
import { errorMessage } from "@/util/error"
|
||||
import { DialogSessionDeleteFailed } from "./dialog-session-delete-failed"
|
||||
import { WorkspaceLabel } from "./workspace-label"
|
||||
|
||||
type WorkspaceStatus = "connected" | "connecting" | "disconnected" | "error"
|
||||
|
||||
export function DialogSessionList() {
|
||||
const dialog = useDialog()
|
||||
@@ -42,39 +44,26 @@ 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}
|
||||
@@ -101,15 +90,22 @@ export function DialogSessionList() {
|
||||
return true
|
||||
}}
|
||||
onRestore={() => {
|
||||
void openWorkspaceSelect({
|
||||
dialog,
|
||||
sdk,
|
||||
sync,
|
||||
toast,
|
||||
onSelect: (selection) => {
|
||||
void warp(selection)
|
||||
},
|
||||
})
|
||||
dialog.replace(() => (
|
||||
<DialogWorkspaceCreate
|
||||
onSelect={(workspaceID) =>
|
||||
restoreWorkspaceSession({
|
||||
dialog,
|
||||
sdk,
|
||||
sync,
|
||||
project,
|
||||
toast,
|
||||
workspaceID,
|
||||
sessionID: session.id,
|
||||
done: list,
|
||||
})
|
||||
}
|
||||
/>
|
||||
))
|
||||
return false
|
||||
}}
|
||||
/>
|
||||
@@ -128,17 +124,30 @@ export function DialogSessionList() {
|
||||
.map((x) => {
|
||||
const workspace = x.workspaceID ? project.workspace.get(x.workspaceID) : undefined
|
||||
|
||||
let footer: JSX.Element | string = ""
|
||||
let workspaceStatus: WorkspaceStatus | null = null
|
||||
if (x.workspaceID) {
|
||||
workspaceStatus = project.workspace.status(x.workspaceID) || "error"
|
||||
}
|
||||
|
||||
let footer = ""
|
||||
if (Flag.OPENCODE_EXPERIMENTAL_WORKSPACES) {
|
||||
if (x.workspaceID) {
|
||||
footer = workspace ? (
|
||||
<WorkspaceLabel
|
||||
type={workspace.type}
|
||||
name={workspace.name}
|
||||
status={project.workspace.status(x.workspaceID) ?? "error"}
|
||||
/>
|
||||
) : (
|
||||
<WorkspaceLabel type="unknown" name={x.workspaceID} status="error" />
|
||||
let desc = "unknown"
|
||||
if (workspace) {
|
||||
desc = `${workspace.type}: ${workspace.name}`
|
||||
}
|
||||
|
||||
footer = (
|
||||
<>
|
||||
{desc}{" "}
|
||||
<span
|
||||
style={{
|
||||
fg: workspaceStatus === "connected" ? theme.success : theme.error,
|
||||
}}
|
||||
>
|
||||
●
|
||||
</span>
|
||||
</>
|
||||
)
|
||||
}
|
||||
} else {
|
||||
@@ -241,6 +250,15 @@ 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()
|
||||
},
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import type { Workspace } from "@opencode-ai/sdk/v2"
|
||||
import { createOpencodeClient } from "@opencode-ai/sdk/v2"
|
||||
import { useDialog } from "@tui/ui/dialog"
|
||||
import { DialogSelect, type DialogSelectOption } from "@tui/ui/dialog-select"
|
||||
import { DialogSelect } from "@tui/ui/dialog-select"
|
||||
import { useRoute } from "@tui/context/route"
|
||||
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"
|
||||
@@ -14,212 +16,184 @@ type Adapter = {
|
||||
description: string
|
||||
}
|
||||
|
||||
export type WorkspaceSelection =
|
||||
| {
|
||||
type: "none"
|
||||
}
|
||||
| {
|
||||
type: "new"
|
||||
workspaceType: string
|
||||
workspaceName: string
|
||||
}
|
||||
| {
|
||||
type: "existing"
|
||||
workspaceID: string
|
||||
workspaceType: string
|
||||
workspaceName: string
|
||||
}
|
||||
|
||||
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>
|
||||
}) {
|
||||
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",
|
||||
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 async function openWorkspaceSelect(input: {
|
||||
export async function openWorkspaceSession(input: {
|
||||
dialog: ReturnType<typeof useDialog>
|
||||
route: ReturnType<typeof useRoute>
|
||||
sdk: ReturnType<typeof useSDK>
|
||||
sync: ReturnType<typeof useSync>
|
||||
toast: ReturnType<typeof useToast>
|
||||
onSelect: (selection: WorkspaceSelection) => Promise<void> | void
|
||||
workspaceID: string
|
||||
}) {
|
||||
input.dialog.clear()
|
||||
const adapters = await loadWorkspaceAdapters(input)
|
||||
if (!adapters) return
|
||||
input.dialog.replace(() => <DialogWorkspaceSelect adapters={adapters} onSelect={input.onSelect} />)
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
export async function warpWorkspaceSession(input: {
|
||||
export async function restoreWorkspaceSession(input: {
|
||||
dialog: ReturnType<typeof useDialog>
|
||||
sdk: ReturnType<typeof useSDK>
|
||||
sync: ReturnType<typeof useSync>
|
||||
project: ReturnType<typeof useProject>
|
||||
toast: ReturnType<typeof useToast>
|
||||
workspaceID: string | null
|
||||
workspaceID: string
|
||||
sessionID: string
|
||||
done?: () => void
|
||||
}): Promise<boolean> {
|
||||
}) {
|
||||
const result = await input.sdk.client.experimental.workspace
|
||||
.warp({
|
||||
id: input.workspaceID ?? undefined,
|
||||
sessionID: input.sessionID,
|
||||
})
|
||||
.sessionRestore({ id: input.workspaceID, sessionID: input.sessionID })
|
||||
.catch(() => undefined)
|
||||
if (!result?.data) {
|
||||
input.toast.show({
|
||||
message: `Failed to warp session: ${errorMessage(result?.error ?? "no response")}`,
|
||||
message: `Failed to restore session: ${errorMessage(result?.error ?? "no response")}`,
|
||||
variant: "error",
|
||||
})
|
||||
return false
|
||||
return
|
||||
}
|
||||
|
||||
input.project.workspace.set(input.workspaceID)
|
||||
|
||||
await input.sync.bootstrap({ fatal: false }).catch(() => undefined)
|
||||
|
||||
await Promise.all([input.project.workspace.sync(), input.sync.session.refresh()])
|
||||
await Promise.all([input.project.workspace.sync(), input.sync.session.sync(input.sessionID)])
|
||||
|
||||
input.toast.show({
|
||||
message: "Session restored into the new workspace",
|
||||
variant: "success",
|
||||
})
|
||||
input.done?.()
|
||||
if (input.done) return true
|
||||
if (input.done) return
|
||||
input.dialog.clear()
|
||||
return true
|
||||
}
|
||||
|
||||
export function DialogWorkspaceSelect(props: {
|
||||
adapters?: Adapter[]
|
||||
onSelect: (selection: WorkspaceSelection) => Promise<void> | void
|
||||
}) {
|
||||
export function DialogWorkspaceCreate(props: { onSelect: (workspaceID: string) => Promise<void> | void }) {
|
||||
const dialog = useDialog()
|
||||
const project = useProject()
|
||||
const sync = useSync()
|
||||
const project = useProject()
|
||||
const sdk = useSDK()
|
||||
const toast = useToast()
|
||||
const [adapters, setAdapters] = createSignal<Adapter[] | undefined>(props.adapters)
|
||||
const [creating, setCreating] = createSignal<string>()
|
||||
const [adapters, setAdapters] = createSignal<Adapter[]>()
|
||||
|
||||
onMount(() => {
|
||||
dialog.setSize("medium")
|
||||
void (async () => {
|
||||
if (adapters()) return
|
||||
const res = await loadWorkspaceAdapters({ sdk, sync, toast })
|
||||
if (!res) return
|
||||
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
|
||||
}
|
||||
setAdapters(res)
|
||||
})()
|
||||
})
|
||||
|
||||
const options = createMemo<DialogSelectOption<WorkspaceSelectValue>[]>(() => {
|
||||
const list = 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,
|
||||
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",
|
||||
},
|
||||
category: "Choose workspace",
|
||||
})),
|
||||
{
|
||||
title: "View all workspaces",
|
||||
value: { type: "existing-list" as const },
|
||||
description: "Choose from all workspaces",
|
||||
category: "Choose workspace",
|
||||
},
|
||||
]
|
||||
]
|
||||
}
|
||||
const list = adapters()
|
||||
if (!list) {
|
||||
return [
|
||||
{
|
||||
title: "Loading workspaces...",
|
||||
value: "loading" as const,
|
||||
description: "Fetching available workspace adapters",
|
||||
},
|
||||
]
|
||||
}
|
||||
return list.map((item) => ({
|
||||
title: item.name,
|
||||
value: item.type,
|
||||
description: item.description,
|
||||
}))
|
||||
})
|
||||
|
||||
if (!adapters()) return null
|
||||
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)
|
||||
}
|
||||
|
||||
return (
|
||||
<DialogSelect<WorkspaceSelectValue>
|
||||
title="Warp"
|
||||
<DialogSelect
|
||||
title={creating() ? "Creating Workspace" : "New Workspace"}
|
||||
skipFilter={true}
|
||||
renderFilter={false}
|
||||
options={options()}
|
||||
onSelect={(option) => {
|
||||
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,
|
||||
})
|
||||
if (option.value === "creating" || option.value === "loading") return
|
||||
void create(option.value)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -7,7 +7,6 @@ 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"
|
||||
@@ -42,11 +41,9 @@ import { useKV } from "../../context/kv"
|
||||
import { createFadeIn } from "../../util/signal"
|
||||
import { useTextareaKeybindings } from "../textarea-keybindings"
|
||||
import { DialogSkill } from "../dialog-skill"
|
||||
import { openWorkspaceSelect, warpWorkspaceSession, type WorkspaceSelection } from "../dialog-workspace-create"
|
||||
import { DialogWorkspaceCreate, restoreWorkspaceSession } 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
|
||||
@@ -176,92 +173,9 @@ 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",
|
||||
@@ -299,7 +213,6 @@ 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
|
||||
})
|
||||
@@ -576,27 +489,6 @@ 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)
|
||||
},
|
||||
})
|
||||
},
|
||||
},
|
||||
]
|
||||
})
|
||||
|
||||
@@ -807,8 +699,6 @@ 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.
|
||||
@@ -817,7 +707,6 @@ 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()
|
||||
@@ -840,16 +729,21 @@ export function Prompt(props: PromptProps) {
|
||||
dialog.replace(() => (
|
||||
<DialogWorkspaceUnavailable
|
||||
onRestore={() => {
|
||||
void openWorkspaceSelect({
|
||||
dialog,
|
||||
sdk,
|
||||
sync,
|
||||
toast,
|
||||
onSelect: (selection) => {
|
||||
void warpSession(selection)
|
||||
},
|
||||
})
|
||||
return false
|
||||
dialog.replace(() => (
|
||||
<DialogWorkspaceCreate
|
||||
onSelect={(nextWorkspaceID) =>
|
||||
restoreWorkspaceSession({
|
||||
dialog,
|
||||
sdk,
|
||||
sync,
|
||||
project,
|
||||
toast,
|
||||
workspaceID: nextWorkspaceID,
|
||||
sessionID: props.sessionID!,
|
||||
})
|
||||
}
|
||||
/>
|
||||
))
|
||||
}}
|
||||
/>
|
||||
))
|
||||
@@ -859,14 +753,6 @@ 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,
|
||||
@@ -1139,29 +1025,6 @@ 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
|
||||
@@ -1418,7 +1281,7 @@ export function Prompt(props: PromptProps) {
|
||||
}}
|
||||
onMouseDown={(r: MouseEvent) => r.target?.focus()}
|
||||
focusedBackgroundColor={theme.backgroundElement}
|
||||
cursorColor={props.disabled ? theme.backgroundElement : theme.text}
|
||||
cursorColor={theme.text}
|
||||
syntaxStyle={syntax()}
|
||||
/>
|
||||
<box flexDirection="row" flexShrink={0} paddingTop={1} gap={1} justifyContent="space-between">
|
||||
@@ -1488,124 +1351,86 @@ export function Prompt(props: PromptProps) {
|
||||
/>
|
||||
</box>
|
||||
<box width="100%" flexDirection="row" justifyContent="space-between">
|
||||
<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 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
|
||||
}
|
||||
|
||||
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>
|
||||
</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 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>
|
||||
<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>
|
||||
<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)
|
||||
})
|
||||
})
|
||||
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
|
||||
}
|
||||
|
||||
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>
|
||||
</Show>
|
||||
<Show when={status().type !== "retry"}>
|
||||
<box gap={2} flexDirection="row">
|
||||
<Show when={editorFileLabelDisplay()}>
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -11,21 +11,21 @@ import { createSimpleContext } from "./helper"
|
||||
import { useSDK } from "./sdk"
|
||||
|
||||
function activeAssistant(messages: SessionMessage[]) {
|
||||
const index = messages.findIndex((message) => message.type === "assistant" && !message.time.completed)
|
||||
const index = messages.findLastIndex((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.findIndex((message) => message.type === "compaction")
|
||||
const index = messages.findLastIndex((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.findIndex((message) => message.type === "shell" && message.callID === callID)
|
||||
const index = messages.findLastIndex((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.unshift({
|
||||
draft.push({
|
||||
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.unshift({
|
||||
draft.push({
|
||||
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.unshift({
|
||||
draft.push({
|
||||
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.unshift({
|
||||
draft.push({
|
||||
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.unshift({
|
||||
draft.push({
|
||||
id: event.id,
|
||||
type: "compaction",
|
||||
reason: event.properties.reason,
|
||||
|
||||
@@ -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 { TextAttributes, type BoxRenderable, type SyntaxStyle } from "@opentui/core"
|
||||
import type { SyntaxStyle } from "@opentui/core"
|
||||
import { Locale } from "@/util/locale"
|
||||
import { LANGUAGE_EXTENSIONS } from "@/lsp/language"
|
||||
import path from "path"
|
||||
@@ -44,10 +44,6 @@ 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)
|
||||
@@ -87,11 +83,10 @@ 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} />
|
||||
@@ -151,36 +146,63 @@ function UserMessage(props: { message: SessionMessageUser; index: number }) {
|
||||
<box
|
||||
id={props.message.id}
|
||||
border={["left"]}
|
||||
borderColor={theme.secondary}
|
||||
borderColor={theme.primary}
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -215,7 +237,7 @@ function ShellMessage(props: { message: SessionMessageShell }) {
|
||||
}
|
||||
|
||||
function CompactionMessage(props: { message: SessionMessageCompaction }) {
|
||||
const { theme, syntax } = useTheme()
|
||||
const { theme } = useTheme()
|
||||
return (
|
||||
<box
|
||||
marginTop={1}
|
||||
@@ -226,19 +248,7 @@ function CompactionMessage(props: { message: SessionMessageCompaction }) {
|
||||
flexShrink={0}
|
||||
>
|
||||
<Show when={props.message.summary}>
|
||||
{(summary) => (
|
||||
<box paddingLeft={3} paddingTop={1}>
|
||||
<code
|
||||
filetype="markdown"
|
||||
drawUnstyledText={false}
|
||||
streaming={false}
|
||||
syntaxStyle={syntax()}
|
||||
content={summary().trim()}
|
||||
conceal={true}
|
||||
fg={theme.text}
|
||||
/>
|
||||
</box>
|
||||
)}
|
||||
<text fg={theme.textMuted}>{props.message.summary}</text>
|
||||
</Show>
|
||||
</box>
|
||||
)
|
||||
@@ -284,13 +294,12 @@ 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.start ?? props.message.time.created)
|
||||
return props.message.time.completed - props.message.time.created
|
||||
})
|
||||
const model = createMemo(() => {
|
||||
const variant = props.message.model.variant ? `/${props.message.model.variant}` : ""
|
||||
@@ -352,7 +361,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} id="text">
|
||||
<box paddingLeft={3} marginTop={1} flexShrink={0}>
|
||||
<code
|
||||
filetype="markdown"
|
||||
drawUnstyledText={false}
|
||||
@@ -512,93 +521,33 @@ 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={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 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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ 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()
|
||||
@@ -15,10 +14,17 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
|
||||
const { theme } = useTheme()
|
||||
const tuiConfig = useTuiConfig()
|
||||
const session = createMemo(() => sync.session.get(props.sessionID))
|
||||
const workspace = () => {
|
||||
const workspaceStatus = () => {
|
||||
const workspaceID = session()?.workspaceID
|
||||
if (!workspaceID) return
|
||||
return project.workspace.get(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}`
|
||||
}
|
||||
const scrollAcceleration = createMemo(() => getScrollAcceleration(tuiConfig))
|
||||
|
||||
@@ -61,19 +67,8 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
|
||||
</Show>
|
||||
<Show when={session()!.workspaceID}>
|
||||
<text fg={theme.textMuted}>
|
||||
<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>
|
||||
<span style={{ fg: workspaceStatus() === "connected" ? theme.success : theme.error }}>●</span>{" "}
|
||||
{workspaceLabel()}
|
||||
</text>
|
||||
</Show>
|
||||
<Show when={session()!.share?.url}>
|
||||
|
||||
@@ -23,7 +23,6 @@ export interface DialogSelectProps<T> {
|
||||
onFilter?: (query: string) => void
|
||||
onSelect?: (option: DialogSelectOption<T>) => void
|
||||
skipFilter?: boolean
|
||||
renderFilter?: boolean
|
||||
keybind?: {
|
||||
keybind?: Keybind.Info
|
||||
title: string
|
||||
@@ -82,7 +81,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
|
||||
let input: InputRenderable
|
||||
|
||||
const filtered = createMemo(() => {
|
||||
if (props.skipFilter || props.renderFilter === false) return props.options.filter((x) => x.disabled !== true)
|
||||
if (props.skipFilter) return props.options.filter((x) => x.disabled !== true)
|
||||
const needle = store.filter.toLowerCase()
|
||||
const options = pipe(
|
||||
props.options,
|
||||
@@ -251,32 +250,30 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
|
||||
esc
|
||||
</text>
|
||||
</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 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>
|
||||
</box>
|
||||
<Show
|
||||
when={grouped().length > 0}
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { Context, Effect, FiberMap, Iterable, Layer, Schema, Stream } from "effect"
|
||||
import { Context, Effect, FiberMap, 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"
|
||||
@@ -21,7 +20,6 @@ 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"
|
||||
@@ -40,6 +38,13 @@ 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",
|
||||
@@ -53,6 +58,7 @@ export const Event = {
|
||||
message: Schema.String,
|
||||
}),
|
||||
),
|
||||
Restore: BusEvent.define("workspace.restore", Restore),
|
||||
Status: BusEvent.define("workspace.status", ConnectionStatus),
|
||||
}
|
||||
|
||||
@@ -78,15 +84,15 @@ export const CreateInput = Schema.Struct({
|
||||
type: Info.fields.type,
|
||||
branch: Info.fields.branch,
|
||||
projectID: ProjectID,
|
||||
extra: Schema.optional(Info.fields.extra),
|
||||
extra: Info.fields.extra,
|
||||
}).pipe(withStatics((s) => ({ zod: effectZod(s), zodObject: zodObject(s) })))
|
||||
export type CreateInput = Schema.Schema.Type<typeof CreateInput>
|
||||
|
||||
export const SessionWarpInput = Schema.Struct({
|
||||
workspaceID: Schema.NullOr(WorkspaceID),
|
||||
export const SessionRestoreInput = Schema.Struct({
|
||||
workspaceID: WorkspaceID,
|
||||
sessionID: SessionID,
|
||||
}).pipe(withStatics((s) => ({ zod: effectZod(s), zodObject: zodObject(s) })))
|
||||
export type SessionWarpInput = Schema.Schema.Type<typeof SessionWarpInput>
|
||||
export type SessionRestoreInput = Schema.Schema.Type<typeof SessionRestoreInput>
|
||||
|
||||
export class SyncHttpError extends Schema.TaggedErrorClass<SyncHttpError>()("WorkspaceSyncHttpError", {
|
||||
message: Schema.String,
|
||||
@@ -110,8 +116,8 @@ export class SessionEventsNotFoundError extends Schema.TaggedErrorClass<SessionE
|
||||
},
|
||||
) {}
|
||||
|
||||
export class SessionWarpHttpError extends Schema.TaggedErrorClass<SessionWarpHttpError>()(
|
||||
"WorkspaceSessionWarpHttpError",
|
||||
export class SessionRestoreHttpError extends Schema.TaggedErrorClass<SessionRestoreHttpError>()(
|
||||
"WorkspaceSessionRestoreHttpError",
|
||||
{
|
||||
message: Schema.String,
|
||||
workspaceID: WorkspaceID,
|
||||
@@ -132,17 +138,17 @@ export class SyncAbortedError extends Schema.TaggedErrorClass<SyncAbortedError>(
|
||||
}) {}
|
||||
|
||||
type CreateError = Auth.AuthError
|
||||
type SessionWarpError =
|
||||
type SessionRestoreError =
|
||||
| WorkspaceNotFoundError
|
||||
| SessionEventsNotFoundError
|
||||
| SessionWarpHttpError
|
||||
| SessionRestoreHttpError
|
||||
| HttpClientError.HttpClientError
|
||||
type WaitForSyncError = SyncTimeoutError | SyncAbortedError
|
||||
type SyncLoopError = SyncHttpError | HttpClientError.HttpClientError
|
||||
|
||||
export interface Interface {
|
||||
readonly create: (input: CreateInput) => Effect.Effect<Info, CreateError>
|
||||
readonly sessionWarp: (input: SessionWarpInput) => Effect.Effect<void, SessionWarpError>
|
||||
readonly sessionRestore: (input: SessionRestoreInput) => Effect.Effect<{ total: number }, SessionRestoreError>
|
||||
readonly list: (project: Project.Info) => Effect.Effect<Info[]>
|
||||
readonly get: (id: WorkspaceID) => Effect.Effect<Info | undefined>
|
||||
readonly remove: (id: WorkspaceID) => Effect.Effect<Info | undefined>
|
||||
@@ -163,7 +169,6 @@ 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>()
|
||||
@@ -456,7 +461,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, extra: input.extra ?? null }),
|
||||
adapter.configure({ ...input, id, name: Slug.create(), directory: null }),
|
||||
)
|
||||
|
||||
const info: Info = {
|
||||
@@ -513,93 +518,29 @@ export const layer = Layer.effect(
|
||||
return info
|
||||
})
|
||||
|
||||
const sessionWarp = Effect.fn("Workspace.sessionWarp")(function* (input: SessionWarpInput) {
|
||||
const sessionRestore = Effect.fn("Workspace.sessionRestore")(function* (input: SessionRestoreInput) {
|
||||
return yield* Effect.gen(function* () {
|
||||
log.info("session warp requested", {
|
||||
log.info("session restore requested", {
|
||||
workspaceID: input.workspaceID,
|
||||
sessionID: input.sessionID,
|
||||
})
|
||||
|
||||
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)
|
||||
const space = yield* get(input.workspaceID)
|
||||
if (!space)
|
||||
return yield* new WorkspaceNotFoundError({
|
||||
message: `Workspace not found: ${workspaceID}`,
|
||||
workspaceID,
|
||||
message: `Workspace not found: ${input.workspaceID}`,
|
||||
workspaceID: input.workspaceID,
|
||||
})
|
||||
|
||||
const adapter = getAdapter(space.projectID, space.type)
|
||||
const target = yield* EffectBridge.fromPromise(() => adapter.target(space))
|
||||
|
||||
if (target.type === "local") {
|
||||
yield* sync.run(Session.Event.Updated, {
|
||||
sessionID: input.sessionID,
|
||||
info: {
|
||||
workspaceID: input.workspaceID,
|
||||
},
|
||||
})
|
||||
|
||||
log.info("session warp complete", {
|
||||
yield* sync.run(Session.Event.Updated, {
|
||||
sessionID: input.sessionID,
|
||||
info: {
|
||||
workspaceID: input.workspaceID,
|
||||
sessionID: input.sessionID,
|
||||
target: target.directory,
|
||||
})
|
||||
return
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const rows = yield* db((db) =>
|
||||
db
|
||||
@@ -621,95 +562,130 @@ export const layer = Layer.effect(
|
||||
sessionID: input.sessionID,
|
||||
})
|
||||
|
||||
const batches = Iterable.chunksOf(rows, 10)
|
||||
const total = Iterable.size(batches)
|
||||
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
|
||||
|
||||
log.info("session warp prepared", {
|
||||
log.info("session restore prepared", {
|
||||
workspaceID: input.workspaceID,
|
||||
sessionID: input.sessionID,
|
||||
target: String(route(target.url, "/sync/replay")),
|
||||
workspaceType: space.type,
|
||||
directory: space.directory,
|
||||
target: target.type === "remote" ? String(route(target.url, "/sync/replay")) : target.directory,
|
||||
events: rows.length,
|
||||
batches: total,
|
||||
first: rows[0]?.seq,
|
||||
last: rows.at(-1)?.seq,
|
||||
})
|
||||
|
||||
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,
|
||||
}),
|
||||
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,
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
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", {
|
||||
if (res.status < 200 || res.status >= 300) {
|
||||
const body = yield* res.text
|
||||
log.error("session restore batch failed", {
|
||||
workspaceID: input.workspaceID,
|
||||
sessionID: input.sessionID,
|
||||
step: i + 1,
|
||||
total,
|
||||
status: response.status,
|
||||
status: res.status,
|
||||
body,
|
||||
})
|
||||
}),
|
||||
{ discard: true },
|
||||
)
|
||||
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,
|
||||
})
|
||||
}
|
||||
|
||||
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 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,
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
log.info("session warp complete", {
|
||||
log.info("session restore complete", {
|
||||
workspaceID: input.workspaceID,
|
||||
sessionID: input.sessionID,
|
||||
batches: total,
|
||||
})
|
||||
|
||||
return { total }
|
||||
}).pipe(
|
||||
Effect.tapError((err) =>
|
||||
Effect.sync(() =>
|
||||
log.error("session warp failed", {
|
||||
log.error("session restore failed", {
|
||||
workspaceID: input.workspaceID,
|
||||
sessionID: input.sessionID,
|
||||
error: errorData(err),
|
||||
@@ -838,7 +814,7 @@ export const layer = Layer.effect(
|
||||
|
||||
return Service.of({
|
||||
create,
|
||||
sessionWarp,
|
||||
sessionRestore,
|
||||
list,
|
||||
get,
|
||||
remove,
|
||||
@@ -854,7 +830,6 @@ 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),
|
||||
)
|
||||
|
||||
|
||||
@@ -13,7 +13,6 @@ const prefixes = {
|
||||
tool: "tool",
|
||||
workspace: "wrk",
|
||||
entry: "ent",
|
||||
account: "act",
|
||||
} as const
|
||||
|
||||
export function schema(prefix: keyof typeof prefixes) {
|
||||
|
||||
@@ -85,9 +85,7 @@ const fileFromPatchChunk = (chunk: string) => {
|
||||
}
|
||||
|
||||
const splitGitPatch = (patch: Git.Patch) => {
|
||||
const starts = [...patch.text.matchAll(/(?:^|\n)diff --git /g)].map((match) =>
|
||||
match[0].startsWith("\n") ? match.index + 1 : match.index,
|
||||
)
|
||||
const starts = [...patch.text.matchAll(/^diff --git /gm)].map((match) => 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)
|
||||
|
||||
@@ -10,6 +10,10 @@ 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()
|
||||
@@ -147,36 +151,60 @@ export const WorkspaceRoutes = lazy(() =>
|
||||
},
|
||||
)
|
||||
.post(
|
||||
"/warp",
|
||||
"/:id/session-restore",
|
||||
describeRoute({
|
||||
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",
|
||||
summary: "Restore session into workspace",
|
||||
description: "Replay a session's sync events into the target workspace in batches.",
|
||||
operationId: "experimental.workspace.sessionRestore",
|
||||
responses: {
|
||||
204: {
|
||||
description: "Session warped",
|
||||
200: {
|
||||
description: "Session replay started",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(
|
||||
z.object({
|
||||
total: z.number().int().min(0),
|
||||
}),
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
...errors(400),
|
||||
},
|
||||
}),
|
||||
validator(
|
||||
"json",
|
||||
z.object({
|
||||
id: zodObject(Workspace.Info).shape.id.nullable(),
|
||||
sessionID: Workspace.SessionWarpInput.zodObject.shape.sessionID,
|
||||
}),
|
||||
),
|
||||
validator("param", z.object({ id: zodObject(Workspace.Info).shape.id })),
|
||||
validator("json", Workspace.SessionRestoreInput.zodObject.omit({ workspaceID: true })),
|
||||
async (c) => {
|
||||
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)
|
||||
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
|
||||
}
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
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"
|
||||
@@ -22,9 +21,6 @@ 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,
|
||||
@@ -37,7 +33,6 @@ export const HistoryEvent = Schema.Struct({
|
||||
export const SyncPaths = {
|
||||
start: `${root}/start`,
|
||||
replay: `${root}/replay`,
|
||||
steal: `${root}/steal`,
|
||||
history: `${root}/history`,
|
||||
} as const
|
||||
|
||||
@@ -65,17 +60,6 @@ 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"),
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import { HttpApi, OpenApi } from "effect/unstable/httpapi"
|
||||
import { MessageGroup } from "./v2/message"
|
||||
import { ModelGroup } from "./v2/model"
|
||||
import { SessionGroup } from "./v2/session"
|
||||
|
||||
export const V2Api = HttpApi.make("v2")
|
||||
.add(SessionGroup)
|
||||
.add(MessageGroup)
|
||||
.add(ModelGroup)
|
||||
.annotateMerge(
|
||||
OpenApi.annotations({
|
||||
title: "opencode experimental HttpApi",
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
import { ModelV2 } from "@/v2/model"
|
||||
import { Schema } from "effect"
|
||||
import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
||||
import { Authorization } from "../../middleware/authorization"
|
||||
|
||||
export const ModelGroup = HttpApiGroup.make("v2.model")
|
||||
.add(
|
||||
HttpApiEndpoint.get("models", "/api/model", {
|
||||
success: Schema.Array(ModelV2.Info),
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "v2.model.list",
|
||||
summary: "List v2 models",
|
||||
description: "Retrieve available v2 models ordered by release date.",
|
||||
}),
|
||||
),
|
||||
)
|
||||
.annotateMerge(
|
||||
OpenApi.annotations({
|
||||
title: "v2 models",
|
||||
description: "Experimental v2 model routes.",
|
||||
}),
|
||||
)
|
||||
.middleware(Authorization)
|
||||
@@ -1,17 +1,21 @@
|
||||
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, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
|
||||
import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, 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"]))
|
||||
export const WarpPayload = Schema.Struct({
|
||||
id: Schema.NullOr(Workspace.Info.fields.id),
|
||||
sessionID: Workspace.SessionWarpInput.fields.sessionID,
|
||||
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 WorkspacePaths = {
|
||||
@@ -19,7 +23,7 @@ export const WorkspacePaths = {
|
||||
list: root,
|
||||
status: `${root}/status`,
|
||||
remove: `${root}/:id`,
|
||||
warp: `${root}/warp`,
|
||||
sessionRestore: `${root}/:id/session-restore`,
|
||||
} as const
|
||||
|
||||
export const WorkspaceApi = HttpApi.make("workspace")
|
||||
@@ -75,15 +79,16 @@ export const WorkspaceApi = HttpApi.make("workspace")
|
||||
description: "Remove an existing workspace.",
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.post("warp", WorkspacePaths.warp, {
|
||||
payload: WarpPayload,
|
||||
success: described(HttpApiSchema.NoContent, "Session warped"),
|
||||
HttpApiEndpoint.post("sessionRestore", WorkspacePaths.sessionRestore, {
|
||||
params: { id: Workspace.Info.fields.id },
|
||||
payload: SessionRestorePayload,
|
||||
success: described(SessionRestoreResponse, "Session replay started"),
|
||||
error: HttpApiError.BadRequest,
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
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.",
|
||||
identifier: "experimental.workspace.sessionRestore",
|
||||
summary: "Restore session into workspace",
|
||||
description: "Replay a session's sync events into the target workspace in batches.",
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
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"
|
||||
@@ -13,7 +12,7 @@ import { or } from "drizzle-orm"
|
||||
import { Effect, Scope } from "effect"
|
||||
import { HttpApiBuilder } from "effect/unstable/httpapi"
|
||||
import { InstanceHttpApi } from "../api"
|
||||
import { HistoryPayload, ReplayPayload, SessionPayload } from "../groups/sync"
|
||||
import { HistoryPayload, ReplayPayload } from "../groups/sync"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
|
||||
const log = Log.create({ service: "server.sync" })
|
||||
@@ -57,25 +56,6 @@ 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) =>
|
||||
@@ -92,6 +72,6 @@ export const syncHandlers = HttpApiBuilder.group(InstanceHttpApi, "sync", (handl
|
||||
)
|
||||
})
|
||||
|
||||
return handlers.handle("start", start).handle("replay", replay).handle("steal", steal).handle("history", history)
|
||||
return handlers.handle("start", start).handle("replay", replay).handle("history", history)
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -1,11 +1,6 @@
|
||||
import { ModelV2 } from "@/v2/model"
|
||||
import { SessionV2 } from "@/v2/session"
|
||||
import { Layer } from "effect"
|
||||
import { messageHandlers } from "./v2/message"
|
||||
import { modelHandlers } from "./v2/model"
|
||||
import { sessionHandlers } from "./v2/session"
|
||||
|
||||
export const v2Handlers = Layer.mergeAll(sessionHandlers, messageHandlers, modelHandlers).pipe(
|
||||
Layer.provide(ModelV2.defaultLayer),
|
||||
Layer.provide(SessionV2.defaultLayer),
|
||||
)
|
||||
export const v2Handlers = Layer.mergeAll(sessionHandlers, messageHandlers).pipe(Layer.provide(SessionV2.defaultLayer))
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
import { ModelV2 } from "@/v2/model"
|
||||
import { Effect } from "effect"
|
||||
import { HttpApiBuilder } from "effect/unstable/httpapi"
|
||||
import { InstanceHttpApi } from "../../api"
|
||||
|
||||
export const modelHandlers = HttpApiBuilder.group(InstanceHttpApi, "v2.model", (handlers) =>
|
||||
Effect.gen(function* () {
|
||||
const model = yield* ModelV2.Service
|
||||
|
||||
return handlers.handle("models", () => model.all())
|
||||
}),
|
||||
)
|
||||
@@ -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, WarpPayload } from "../groups/workspace"
|
||||
import { CreatePayload, SessionRestorePayload } from "../groups/workspace"
|
||||
|
||||
export const workspaceHandlers = HttpApiBuilder.group(InstanceHttpApi, "workspace", (handlers) =>
|
||||
Effect.gen(function* () {
|
||||
@@ -39,10 +39,13 @@ export const workspaceHandlers = HttpApiBuilder.group(InstanceHttpApi, "workspac
|
||||
return yield* workspace.remove(ctx.params.id)
|
||||
})
|
||||
|
||||
const warp = Effect.fn("WorkspaceHttpApi.warp")(function* (ctx: { payload: typeof WarpPayload.Type }) {
|
||||
yield* workspace
|
||||
.sessionWarp({
|
||||
workspaceID: ctx.payload.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,
|
||||
sessionID: ctx.payload.sessionID,
|
||||
})
|
||||
.pipe(Effect.mapError(() => new HttpApiError.BadRequest({})))
|
||||
@@ -54,6 +57,6 @@ export const workspaceHandlers = HttpApiBuilder.group(InstanceHttpApi, "workspac
|
||||
.handle("create", create)
|
||||
.handle("status", status)
|
||||
.handle("remove", remove)
|
||||
.handle("warp", warp)
|
||||
.handle("sessionRestore", sessionRestore)
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -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.warp, (c) => handler(c.req.raw, context))
|
||||
app.post(WorkspacePaths.sessionRestore, (c) => handler(c.req.raw, context))
|
||||
}
|
||||
|
||||
return app
|
||||
|
||||
@@ -16,9 +16,6 @@ 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(),
|
||||
@@ -27,9 +24,6 @@ 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" })
|
||||
|
||||
@@ -114,47 +108,6 @@ 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({
|
||||
|
||||
@@ -22,7 +22,6 @@ 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
|
||||
@@ -433,9 +432,9 @@ export const layer: Layer.Layer<
|
||||
sessionID: ctx.sessionID,
|
||||
agent: input.assistantMessage.agent,
|
||||
model: {
|
||||
id: ModelV2.ID.make(ctx.model.id),
|
||||
providerID: ModelV2.ProviderID.make(ctx.model.providerID),
|
||||
variant: ModelV2.VariantID.make(input.assistantMessage.variant ?? "default"),
|
||||
id: ctx.model.id,
|
||||
providerID: ctx.model.providerID,
|
||||
variant: input.assistantMessage.variant,
|
||||
},
|
||||
snapshot: ctx.snapshot,
|
||||
timestamp: DateTime.makeUnsafe(Date.now()),
|
||||
@@ -656,7 +655,7 @@ export const layer: Layer.Layer<
|
||||
EventV2.run(SessionEvent.Step.Failed.Sync, {
|
||||
sessionID: ctx.sessionID,
|
||||
error: {
|
||||
type: "unknown",
|
||||
type: error.name,
|
||||
message: errorMessage(e),
|
||||
},
|
||||
timestamp: DateTime.makeUnsafe(Date.now()),
|
||||
|
||||
@@ -132,7 +132,11 @@ export default [
|
||||
SyncEvent.project(SessionEvent.ModelSwitched.Sync, (db, data, event) => {
|
||||
db.update(SessionTable)
|
||||
.set({
|
||||
model: data.model,
|
||||
model: {
|
||||
id: data.id,
|
||||
providerID: data.providerID,
|
||||
variant: data.variant,
|
||||
},
|
||||
time_updated: DateTime.toEpochMillis(data.timestamp),
|
||||
})
|
||||
.where(eq(SessionTable.id, data.sessionID))
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
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"
|
||||
@@ -55,7 +56,6 @@ 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,8 +120,9 @@ export const layer = Layer.effect(
|
||||
return yield* EffectBridge.make()
|
||||
})
|
||||
const ops = Effect.fn("SessionPrompt.ops")(function* () {
|
||||
const run = yield* runner()
|
||||
return {
|
||||
cancel: (sessionID: SessionID) => cancel(sessionID),
|
||||
cancel: (sessionID: SessionID) => run.fork(cancel(sessionID)),
|
||||
resolvePromptParts: (template: string) => resolvePromptParts(template),
|
||||
prompt: (input: PromptInput) => prompt(input),
|
||||
} satisfies TaskPromptOps
|
||||
@@ -977,11 +978,9 @@ 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),
|
||||
model: {
|
||||
id: ModelV2.ID.make(info.model.modelID),
|
||||
providerID: ModelV2.ProviderID.make(info.model.providerID),
|
||||
variant: ModelV2.VariantID.make(info.model.variant ?? "default"),
|
||||
},
|
||||
id: info.model.modelID,
|
||||
providerID: info.model.providerID,
|
||||
variant: info.model.variant,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@ 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", {
|
||||
|
||||
@@ -59,11 +59,8 @@ export interface Interface {
|
||||
data: Event<Def>["data"],
|
||||
options?: { publish?: boolean },
|
||||
) => Effect.Effect<void>
|
||||
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 replay: (event: SerializedEvent, options?: { publish: boolean }) => Effect.Effect<void>
|
||||
readonly replayAll: (events: SerializedEvent[], options?: { publish: boolean }) => Effect.Effect<string | undefined>
|
||||
readonly remove: (aggregateID: string) => Effect.Effect<void>
|
||||
}
|
||||
|
||||
@@ -79,7 +76,7 @@ export const layer = Layer.effect(Service)(
|
||||
|
||||
const row = Database.use((db) =>
|
||||
db
|
||||
.select({ seq: EventSequenceTable.seq, ownerID: EventSequenceTable.owner_id })
|
||||
.select({ seq: EventSequenceTable.seq })
|
||||
.from(EventSequenceTable)
|
||||
.where(eq(EventSequenceTable.aggregate_id, event.aggregateID))
|
||||
.get(),
|
||||
@@ -88,10 +85,6 @@ 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(
|
||||
@@ -106,7 +99,7 @@ export const layer = Layer.effect(Service)(
|
||||
workspace: yield* InstanceState.workspaceID,
|
||||
}
|
||||
: undefined
|
||||
process(def, event, { publish, context, ownerID: options?.ownerID })
|
||||
process(def, event, { publish, context })
|
||||
})
|
||||
|
||||
const replayAll: Interface["replayAll"] = Effect.fn("SyncEvent.replayAll")(function* (events, options) {
|
||||
@@ -270,7 +263,7 @@ export function project<Def extends Definition>(
|
||||
function process<Def extends Definition>(
|
||||
def: Def,
|
||||
event: Event<Def>,
|
||||
options: { publish: boolean; context?: PublishContext; ownerID?: string },
|
||||
options: { publish: boolean; context?: PublishContext },
|
||||
) {
|
||||
if (projectors == null) {
|
||||
throw new Error("No projectors available. Call `SyncEvent.init` to install projectors")
|
||||
@@ -281,6 +274,8 @@ 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)
|
||||
|
||||
@@ -289,7 +284,6 @@ function process<Def extends Definition>(
|
||||
.values({
|
||||
aggregate_id: event.aggregateID,
|
||||
seq: event.seq,
|
||||
owner_id: options?.ownerID,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: EventSequenceTable.aggregate_id,
|
||||
@@ -338,11 +332,11 @@ function process<Def extends Definition>(
|
||||
})
|
||||
}
|
||||
|
||||
export function replay(event: SerializedEvent, options?: { publish: boolean; ownerID?: string }) {
|
||||
export function replay(event: SerializedEvent, options?: { publish: boolean }) {
|
||||
return runtime.runSync((sync) => sync.replay(event, options))
|
||||
}
|
||||
|
||||
export function replayAll(events: SerializedEvent[], options?: { publish: boolean; ownerID?: string }) {
|
||||
export function replayAll(events: SerializedEvent[], options?: { publish: boolean }) {
|
||||
return runtime.runSync((sync) => sync.replayAll(events, options))
|
||||
}
|
||||
|
||||
@@ -354,16 +348,6 @@ 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()
|
||||
|
||||
@@ -6,11 +6,10 @@ import { MessageV2 } from "../session/message-v2"
|
||||
import { Agent } from "../agent/agent"
|
||||
import type { SessionPrompt } from "../session/prompt"
|
||||
import { Config } from "@/config/config"
|
||||
import { Effect, Exit, Schema } from "effect"
|
||||
import { EffectBridge } from "@/effect/bridge"
|
||||
import { Effect, Schema } from "effect"
|
||||
|
||||
export interface TaskPromptOps {
|
||||
cancel(sessionID: SessionID): Effect.Effect<void>
|
||||
cancel(sessionID: SessionID): void
|
||||
resolvePromptParts(template: string): Effect.Effect<SessionPrompt.PromptInput["parts"]>
|
||||
prompt(input: SessionPrompt.PromptInput): Effect.Effect<MessageV2.WithParts>
|
||||
}
|
||||
@@ -119,18 +118,16 @@ 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 onAbort() {
|
||||
runCancel.fork(cancel)
|
||||
function cancel() {
|
||||
ops.cancel(nextSession.id)
|
||||
}
|
||||
|
||||
return yield* Effect.acquireUseRelease(
|
||||
Effect.sync(() => {
|
||||
ctx.abort.addEventListener("abort", onAbort)
|
||||
ctx.abort.addEventListener("abort", cancel)
|
||||
}),
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
@@ -166,16 +163,10 @@ export const TaskTool = Tool.define(
|
||||
].join("\n"),
|
||||
}
|
||||
}),
|
||||
(_, exit) =>
|
||||
Effect.gen(function* () {
|
||||
if (Exit.hasInterrupts(exit)) yield* cancel
|
||||
}).pipe(
|
||||
Effect.ensuring(
|
||||
Effect.sync(() => {
|
||||
ctx.abort.removeEventListener("abort", onAbort)
|
||||
}),
|
||||
),
|
||||
),
|
||||
() =>
|
||||
Effect.sync(() => {
|
||||
ctx.abort.removeEventListener("abort", cancel)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@@ -1,246 +0,0 @@
|
||||
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"
|
||||
@@ -1,192 +0,0 @@
|
||||
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("ModelV2.ID"))
|
||||
export type ID = typeof ID.Type
|
||||
|
||||
export const ProviderID = Schema.String.pipe(
|
||||
Schema.brand("ModelV2.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>("ModelV2.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("ModelV2.get")(function* (providerID, modelID) {
|
||||
return HashMap.get(models, key(providerID, modelID))
|
||||
}),
|
||||
|
||||
add: Effect.fn("ModelV2.add")(function* (model) {
|
||||
models = HashMap.set(models, key(model.providerID, model.id), model)
|
||||
}),
|
||||
|
||||
remove: Effect.fn("ModelV2.remove")(function* (providerID, modelID) {
|
||||
models = HashMap.remove(models, key(providerID, modelID))
|
||||
}),
|
||||
|
||||
all: Effect.fn("ModelV2.all")(function* () {
|
||||
return pipe(
|
||||
models,
|
||||
HashMap.toValues,
|
||||
Array.sortWith((item) => item.time.released.epochMilliseconds, Order.flip(Order.Number)),
|
||||
)
|
||||
}),
|
||||
|
||||
default: Effect.fn("ModelV2.default")(function* () {
|
||||
const all = yield* result.all()
|
||||
return Option.fromUndefinedOr(all[0])
|
||||
}),
|
||||
|
||||
small: Effect.fn("ModelV2.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"
|
||||
@@ -1,39 +0,0 @@
|
||||
import { Context, Effect, HashMap, Layer } from "effect"
|
||||
import { type Plugin } from "./plugin"
|
||||
import { ModelV2 } from "./model"
|
||||
import { AuthV2 } from "./auth"
|
||||
|
||||
export * as PluginRegistry from "./plugin-registry"
|
||||
|
||||
export interface Interface {
|
||||
readonly register: (input: { id: Plugin.ID; definition: Plugin.Definition }) => Effect.Effect<void>
|
||||
readonly trigger: <Name extends keyof Plugin.Hooks>(
|
||||
name: Name,
|
||||
input: Parameters<Plugin.Hooks[Name]>[0],
|
||||
) => Effect.Effect<Parameters<Plugin.Hooks[Name]>[0]>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/PluginRegistry") {}
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
let plugins = HashMap.empty<Plugin.ID, Plugin.Definition>()
|
||||
|
||||
const context: Plugin.Context = {
|
||||
model: yield* ModelV2.Service,
|
||||
auth: yield* AuthV2.Service,
|
||||
}
|
||||
|
||||
const result = Service.of({
|
||||
register: Effect.fn("PluginRegistry.register")(function* (input) {
|
||||
plugins = HashMap.set(plugins, input.id, input.definition)
|
||||
}),
|
||||
trigger: Effect.fn("PluginRegistry.trigger")(function* (name, input) {
|
||||
return input
|
||||
}),
|
||||
})
|
||||
|
||||
return result
|
||||
}),
|
||||
)
|
||||
@@ -1,20 +0,0 @@
|
||||
export * as Plugin from "./plugin"
|
||||
|
||||
import { ModelV2 } from "./model"
|
||||
import type { AuthV2 } from "./auth"
|
||||
import { Effect, Schema } from "effect"
|
||||
import type { Draft } from "immer"
|
||||
|
||||
export const ID = Schema.String.pipe(Schema.brand("Plugin.ID"))
|
||||
export type ID = typeof ID.Type
|
||||
|
||||
export type Context = {
|
||||
model: ModelV2.Interface
|
||||
auth: AuthV2.Interface
|
||||
}
|
||||
|
||||
export type Hooks = {
|
||||
"model.add": (input: { readonly id: ModelV2.ID; model: Draft<ModelV2.Info> }) => Effect.Effect<void>
|
||||
}
|
||||
|
||||
export type Definition = (context: Context) => Effect.Effect<Hooks>
|
||||
@@ -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,13 +22,10 @@ const Base = {
|
||||
sessionID: SessionID,
|
||||
}
|
||||
|
||||
export const UnknownError = Schema.Struct({
|
||||
type: Schema.Literal("unknown"),
|
||||
const Error = Schema.Struct({
|
||||
type: Schema.String,
|
||||
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",
|
||||
@@ -47,7 +44,9 @@ export const ModelSwitched = EventV2.define({
|
||||
version: 1,
|
||||
schema: {
|
||||
...Base,
|
||||
model: ModelV2.Ref,
|
||||
id: ModelID,
|
||||
providerID: ProviderID,
|
||||
variant: Schema.String.pipe(Schema.optional),
|
||||
},
|
||||
})
|
||||
export type ModelSwitched = Schema.Schema.Type<typeof ModelSwitched>
|
||||
@@ -104,7 +103,11 @@ export namespace Step {
|
||||
schema: {
|
||||
...Base,
|
||||
agent: Schema.String,
|
||||
model: ModelV2.Ref,
|
||||
model: Schema.Struct({
|
||||
id: Schema.String,
|
||||
providerID: Schema.String,
|
||||
variant: Schema.String.pipe(Schema.optional),
|
||||
}),
|
||||
snapshot: Schema.String.pipe(Schema.optional),
|
||||
},
|
||||
})
|
||||
@@ -136,7 +139,7 @@ export namespace Step {
|
||||
aggregate: "sessionID",
|
||||
schema: {
|
||||
...Base,
|
||||
error: UnknownError,
|
||||
error: Error,
|
||||
},
|
||||
})
|
||||
export type Failed = Schema.Schema.Type<typeof Failed>
|
||||
@@ -293,7 +296,7 @@ export namespace Tool {
|
||||
schema: {
|
||||
...Base,
|
||||
callID: Schema.String,
|
||||
error: UnknownError,
|
||||
error: Error,
|
||||
provider: Schema.Struct({
|
||||
executed: Schema.Boolean,
|
||||
metadata: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional),
|
||||
|
||||
@@ -109,7 +109,11 @@ export function update<Result>(adapter: Adapter<Result>, event: SessionEvent.Eve
|
||||
id: event.id,
|
||||
type: "model-switched",
|
||||
metadata: event.metadata,
|
||||
model: event.data.model,
|
||||
model: {
|
||||
id: event.data.id,
|
||||
providerID: event.data.providerID,
|
||||
variant: event.data.variant,
|
||||
},
|
||||
time: { created: event.data.timestamp },
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -4,7 +4,6 @@ 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>
|
||||
@@ -26,7 +25,11 @@ 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: ModelV2.Ref,
|
||||
model: Schema.Struct({
|
||||
id: SessionEvent.ModelSwitched.fields.data.fields.id,
|
||||
providerID: SessionEvent.ModelSwitched.fields.data.fields.providerID,
|
||||
variant: SessionEvent.ModelSwitched.fields.data.fields.variant,
|
||||
}),
|
||||
}) {}
|
||||
|
||||
export class User extends Schema.Class<User>("Session.Message.User")({
|
||||
@@ -84,7 +87,10 @@ 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: SessionEvent.UnknownError,
|
||||
error: Schema.Struct({
|
||||
type: Schema.String,
|
||||
message: Schema.String,
|
||||
}),
|
||||
}) {}
|
||||
|
||||
export const ToolState = Schema.Union([ToolStatePending, ToolStateRunning, ToolStateCompleted, ToolStateError]).pipe(
|
||||
|
||||
@@ -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, Option, Schema } from "effect"
|
||||
import { Context, DateTime, Effect, Layer, 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.Literals(["immediate", "deferred"]).annotate({
|
||||
export const Delivery = Schema.Union([Schema.Literal("immediate"), Schema.Literal("deferred")]).annotate({
|
||||
identifier: "Session.Delivery",
|
||||
})
|
||||
export type Delivery = Schema.Schema.Type<typeof Delivery>
|
||||
@@ -27,7 +27,11 @@ export class Info extends Schema.Class<Info>("Session.Info")({
|
||||
workspaceID: optionalOmitUndefined(WorkspaceID),
|
||||
path: optionalOmitUndefined(Schema.String),
|
||||
agent: optionalOmitUndefined(Schema.String),
|
||||
model: ModelV2.Ref.pipe(optionalOmitUndefined),
|
||||
model: Schema.Struct({
|
||||
id: ModelID,
|
||||
providerID: ProviderID,
|
||||
variant: optionalOmitUndefined(Schema.String),
|
||||
}).pipe(optionalOmitUndefined),
|
||||
time: Schema.Struct({
|
||||
created: V2Schema.DateTimeUtcFromMillis,
|
||||
updated: V2Schema.DateTimeUtcFromMillis,
|
||||
@@ -49,18 +53,7 @@ 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"
|
||||
@@ -95,15 +88,13 @@ 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; model: ModelV2.Ref }) => Effect.Effect<void, never>
|
||||
readonly switchModel: (input: {
|
||||
sessionID: SessionID
|
||||
id: ModelID
|
||||
providerID: ProviderID
|
||||
variant?: string
|
||||
}) => Effect.Effect<void, never>
|
||||
readonly compact: (sessionID: SessionID) => Effect.Effect<void, never>
|
||||
readonly wait: (sessionID: SessionID) => Effect.Effect<void, never>
|
||||
}
|
||||
@@ -129,9 +120,9 @@ export const layer = Layer.effect(
|
||||
agent: row.agent ?? undefined,
|
||||
model: row.model
|
||||
? {
|
||||
id: ModelV2.ID.make(row.model.id),
|
||||
providerID: ModelV2.ProviderID.make(row.model.providerID),
|
||||
variant: ModelV2.VariantID.make(row.model.variant ?? "default"),
|
||||
id: ModelID.make(row.model.id),
|
||||
providerID: ProviderID.make(row.model.providerID),
|
||||
variant: row.model.variant,
|
||||
}
|
||||
: undefined,
|
||||
time: {
|
||||
@@ -143,14 +134,6 @@ 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"
|
||||
@@ -279,30 +262,11 @@ export const layer = Layer.effect(
|
||||
EventV2.run(SessionEvent.ModelSwitched.Sync, {
|
||||
sessionID: input.sessionID,
|
||||
timestamp: DateTime.makeUnsafe(Date.now()),
|
||||
model: input.model,
|
||||
id: input.id,
|
||||
providerID: input.providerID,
|
||||
variant: input.variant,
|
||||
})
|
||||
}),
|
||||
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) {}),
|
||||
}
|
||||
|
||||
@@ -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 { eq } from "drizzle-orm"
|
||||
import { asc, 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,10 +16,11 @@ 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 } from "@/session/schema"
|
||||
import { SessionID, MessageID, PartID } from "@/session/schema"
|
||||
import { SessionTable } from "@/session/session.sql"
|
||||
import { ModelID, ProviderID } from "@/provider/schema"
|
||||
import { SyncEvent } from "@/sync"
|
||||
import { EventSequenceTable } from "@/sync/event.sql"
|
||||
import { EventSequenceTable, EventTable } from "@/sync/event.sql"
|
||||
import { resetDatabase } from "../fixture/db"
|
||||
import { disposeAllInstances, provideTmpdirInstance, tmpdir } from "../fixture/fixture"
|
||||
import { testEffect } from "../lib/effect"
|
||||
@@ -110,8 +111,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 warpWorkspaceSession = (input: WorkspaceOld.SessionWarpInput) =>
|
||||
runWorkspace(WorkspaceOld.Service.use((workspace) => workspace.sessionWarp(input)))
|
||||
const restoreWorkspaceSession = (input: WorkspaceOld.SessionRestoreInput) =>
|
||||
runWorkspace(WorkspaceOld.Service.use((workspace) => workspace.sessionRestore(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)))
|
||||
@@ -316,24 +317,48 @@ function sessionSequence(sessionID: SessionID) {
|
||||
)?.seq
|
||||
}
|
||||
|
||||
function sessionSequenceOwner(sessionID: SessionID) {
|
||||
function eventRows(sessionID: SessionID) {
|
||||
return Database.use((db) =>
|
||||
db
|
||||
.select({ ownerID: EventSequenceTable.owner_id })
|
||||
.from(EventSequenceTable)
|
||||
.where(eq(EventSequenceTable.aggregate_id, sessionID))
|
||||
.get(),
|
||||
)?.ownerID
|
||||
.select({ seq: EventTable.seq, type: EventTable.type, data: EventTable.data })
|
||||
.from(EventTable)
|
||||
.where(eq(EventTable.aggregate_id, sessionID))
|
||||
.orderBy(asc(EventTable.seq))
|
||||
.all(),
|
||||
)
|
||||
}
|
||||
|
||||
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")
|
||||
})
|
||||
|
||||
@@ -350,6 +375,17 @@ 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", () => {
|
||||
@@ -615,144 +651,6 @@ 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", () => {
|
||||
@@ -1317,3 +1215,313 @@ 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 },
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
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"
|
||||
@@ -289,28 +288,6 @@ 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()
|
||||
|
||||
@@ -19,7 +19,6 @@ 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"
|
||||
@@ -215,11 +214,7 @@ describe("session HttpApi", () => {
|
||||
id: SessionMessage.ID.create(),
|
||||
type: "assistant",
|
||||
agent: "build",
|
||||
model: {
|
||||
id: ModelV2.ID.make("model"),
|
||||
providerID: ModelV2.ProviderID.make("provider"),
|
||||
variant: ModelV2.VariantID.make("default"),
|
||||
},
|
||||
model: { id: "model", providerID: "provider" },
|
||||
time: { created: DateTime.makeUnsafe(1) },
|
||||
content: [],
|
||||
})
|
||||
|
||||
@@ -168,19 +168,22 @@ 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 }),
|
||||
body: JSON.stringify({ type: "local-test", branch: null, extra: 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 warped = yield* request(WorkspacePaths.warp, dir, {
|
||||
const restored = yield* request(WorkspacePaths.sessionRestore.replace(":id", workspace.id), dir, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ id: workspace.id, sessionID: session.id }),
|
||||
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),
|
||||
})
|
||||
expect(warped.status).toBe(204)
|
||||
|
||||
const removed = yield* request(WorkspacePaths.remove.replace(":id", workspace.id), dir, { method: "DELETE" })
|
||||
expect(removed.status).toBe(200)
|
||||
@@ -209,6 +212,7 @@ describe("workspace HttpApi", () => {
|
||||
expect((yield* Effect.promise(() => created.json())) as Workspace.Info).toMatchObject({
|
||||
type: "local-test",
|
||||
name: "local-test",
|
||||
extra: null,
|
||||
})
|
||||
}),
|
||||
)
|
||||
@@ -253,6 +257,7 @@ describe("workspace HttpApi", () => {
|
||||
expect((yield* Effect.promise(() => created.json())) as Workspace.Info).toMatchObject({
|
||||
type: "local-test",
|
||||
name: "local-test",
|
||||
extra: null,
|
||||
})
|
||||
}),
|
||||
)
|
||||
@@ -267,7 +272,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 }),
|
||||
body: JSON.stringify({ type: "local-target", branch: null, extra: null }),
|
||||
})
|
||||
const workspace = (yield* Effect.promise(() => created.json())) as Workspace.Info
|
||||
|
||||
@@ -322,7 +327,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 }),
|
||||
body: JSON.stringify({ type: "remote-target", branch: null, extra: null }),
|
||||
})
|
||||
const workspace = (yield* Effect.promise(() => created.json())) as Workspace.Info
|
||||
|
||||
@@ -389,7 +394,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 }),
|
||||
body: JSON.stringify({ type: "remote-session-target", branch: null, extra: null }),
|
||||
})
|
||||
const workspace = (yield* Effect.promise(() => created.json())) as Workspace.Info
|
||||
const session = yield* Session.Service.use((svc) => svc.create()).pipe(
|
||||
|
||||
@@ -858,43 +858,6 @@ 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",
|
||||
() =>
|
||||
|
||||
@@ -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 { EventSequenceTable, EventTable } from "../../src/sync/event.sql"
|
||||
import { 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,76 +252,5 @@ 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" })
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
import { afterEach, describe, expect } from "bun:test"
|
||||
import { Effect, Exit, Fiber, Layer } from "effect"
|
||||
import { Effect, 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, SessionID } from "../../src/session/schema"
|
||||
import { MessageID, PartID } 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 } from "../fixture/fixture"
|
||||
import { disposeAllInstances, provideTmpdirInstance } from "../fixture/fixture"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
afterEach(async () => {
|
||||
@@ -34,14 +35,6 @@ 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 })
|
||||
@@ -73,7 +66,7 @@ const seed = Effect.fn("TaskToolTest.seed")(function* (title = "Pinned") {
|
||||
|
||||
function stubOps(opts?: { onPrompt?: (input: SessionPrompt.PromptInput) => void; text?: string }): TaskPromptOps {
|
||||
return {
|
||||
cancel: () => Effect.void,
|
||||
cancel() {},
|
||||
resolvePromptParts: (template) => Effect.succeed([{ type: "text" as const, text: template }]),
|
||||
prompt: (input) =>
|
||||
Effect.sync(() => {
|
||||
@@ -114,270 +107,189 @@ function reply(input: SessionPrompt.PromptInput, text: string): MessageV2.WithPa
|
||||
}
|
||||
|
||||
describe("tool.task", () => {
|
||||
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()
|
||||
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()
|
||||
|
||||
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.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 ?? ""
|
||||
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 ?? ""
|
||||
|
||||
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.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) })
|
||||
it.live("execute resumes an existing task session from task_id", () =>
|
||||
provideTmpdirInstance(() =>
|
||||
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(
|
||||
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, ...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)
|
||||
}),
|
||||
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 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) })
|
||||
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 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 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)
|
||||
}),
|
||||
},
|
||||
)
|
||||
|
||||
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)
|
||||
}),
|
||||
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 shapes child permissions for task, todowrite, and primary tools",
|
||||
() =>
|
||||
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({ onPrompt: (input) => (seen = input) })
|
||||
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: "reviewer",
|
||||
subagent_type: "general",
|
||||
task_id: "ses_missing",
|
||||
},
|
||||
{
|
||||
sessionID: chat.id,
|
||||
@@ -391,45 +303,85 @@ describe("tool.task", () => {
|
||||
},
|
||||
)
|
||||
|
||||
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,
|
||||
})
|
||||
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)
|
||||
}),
|
||||
{
|
||||
config: {
|
||||
agent: {
|
||||
reviewer: {
|
||||
mode: "subagent",
|
||||
permission: {
|
||||
task: "allow",
|
||||
),
|
||||
)
|
||||
|
||||
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",
|
||||
},
|
||||
{
|
||||
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"],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -2,7 +2,6 @@ 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"
|
||||
|
||||
@@ -17,11 +16,7 @@ test("step snapshots carry over to assistant messages", () => {
|
||||
sessionID,
|
||||
timestamp: DateTime.makeUnsafe(1),
|
||||
agent: "build",
|
||||
model: {
|
||||
id: ModelV2.ID.make("model"),
|
||||
providerID: ModelV2.ProviderID.make("provider"),
|
||||
variant: ModelV2.VariantID.make("default"),
|
||||
},
|
||||
model: { id: "model", providerID: "provider" },
|
||||
snapshot: "before",
|
||||
},
|
||||
} satisfies SessionEvent.Event)
|
||||
@@ -61,11 +56,7 @@ test("text ended populates assistant text content", () => {
|
||||
sessionID,
|
||||
timestamp: DateTime.makeUnsafe(1),
|
||||
agent: "build",
|
||||
model: {
|
||||
id: ModelV2.ID.make("model"),
|
||||
providerID: ModelV2.ProviderID.make("provider"),
|
||||
variant: ModelV2.VariantID.make("default"),
|
||||
},
|
||||
model: { id: "model", providerID: "provider" },
|
||||
},
|
||||
} satisfies SessionEvent.Event)
|
||||
|
||||
@@ -105,11 +96,7 @@ test("tool completion stores completed timestamp", () => {
|
||||
sessionID,
|
||||
timestamp: DateTime.makeUnsafe(1),
|
||||
agent: "build",
|
||||
model: {
|
||||
id: ModelV2.ID.make("model"),
|
||||
providerID: ModelV2.ProviderID.make("provider"),
|
||||
variant: ModelV2.VariantID.make("default"),
|
||||
},
|
||||
model: { id: "model", providerID: "provider" },
|
||||
},
|
||||
} satisfies SessionEvent.Event)
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/plugin",
|
||||
"version": "1.14.35",
|
||||
"version": "1.14.34",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/sdk",
|
||||
"version": "1.14.35",
|
||||
"version": "1.14.34",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -35,9 +35,9 @@ import type {
|
||||
ExperimentalWorkspaceListResponses,
|
||||
ExperimentalWorkspaceRemoveErrors,
|
||||
ExperimentalWorkspaceRemoveResponses,
|
||||
ExperimentalWorkspaceSessionRestoreErrors,
|
||||
ExperimentalWorkspaceSessionRestoreResponses,
|
||||
ExperimentalWorkspaceStatusResponses,
|
||||
ExperimentalWorkspaceWarpErrors,
|
||||
ExperimentalWorkspaceWarpResponses,
|
||||
FileListResponses,
|
||||
FilePartInput,
|
||||
FilePartSource,
|
||||
@@ -169,8 +169,6 @@ import type {
|
||||
SyncReplayErrors,
|
||||
SyncReplayResponses,
|
||||
SyncStartResponses,
|
||||
SyncStealErrors,
|
||||
SyncStealResponses,
|
||||
TextPartInput,
|
||||
ToolIdsErrors,
|
||||
ToolIdsResponses,
|
||||
@@ -193,7 +191,6 @@ import type {
|
||||
TuiSelectSessionResponses,
|
||||
TuiShowToastResponses,
|
||||
TuiSubmitPromptResponses,
|
||||
V2ModelListResponses,
|
||||
V2SessionCompactResponses,
|
||||
V2SessionContextResponses,
|
||||
V2SessionListErrors,
|
||||
@@ -1012,15 +1009,15 @@ export class Workspace extends HeyApiClient {
|
||||
}
|
||||
|
||||
/**
|
||||
* Warp session into workspace
|
||||
* Restore session into workspace
|
||||
*
|
||||
* Move a session's sync history into the target workspace, or detach it to the local project.
|
||||
* Replay a session's sync events into the target workspace in batches.
|
||||
*/
|
||||
public warp<ThrowOnError extends boolean = false>(
|
||||
parameters?: {
|
||||
public sessionRestore<ThrowOnError extends boolean = false>(
|
||||
parameters: {
|
||||
id: string
|
||||
directory?: string
|
||||
workspace?: string
|
||||
id?: string
|
||||
sessionID?: string
|
||||
},
|
||||
options?: Options<never, ThrowOnError>,
|
||||
@@ -1030,20 +1027,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<
|
||||
ExperimentalWorkspaceWarpResponses,
|
||||
ExperimentalWorkspaceWarpErrors,
|
||||
ExperimentalWorkspaceSessionRestoreResponses,
|
||||
ExperimentalWorkspaceSessionRestoreErrors,
|
||||
ThrowOnError
|
||||
>({
|
||||
url: "/experimental/workspace/warp",
|
||||
url: "/experimental/workspace/{id}/session-restore",
|
||||
...options,
|
||||
...params,
|
||||
headers: {
|
||||
@@ -3959,43 +3956,6 @@ 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 }))
|
||||
@@ -4203,48 +4163,11 @@ export class Session3 extends HeyApiClient {
|
||||
}
|
||||
}
|
||||
|
||||
export class Model extends HeyApiClient {
|
||||
/**
|
||||
* List v2 models
|
||||
*
|
||||
* Retrieve available v2 models ordered by release date.
|
||||
*/
|
||||
public list<ThrowOnError extends boolean = false>(
|
||||
parameters?: {
|
||||
directory?: string
|
||||
workspace?: string
|
||||
},
|
||||
options?: Options<never, ThrowOnError>,
|
||||
) {
|
||||
const params = buildClientParams(
|
||||
[parameters],
|
||||
[
|
||||
{
|
||||
args: [
|
||||
{ in: "query", key: "directory" },
|
||||
{ in: "query", key: "workspace" },
|
||||
],
|
||||
},
|
||||
],
|
||||
)
|
||||
return (options?.client ?? this.client).get<V2ModelListResponses, unknown, ThrowOnError>({
|
||||
url: "/api/model",
|
||||
...options,
|
||||
...params,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export class V2 extends HeyApiClient {
|
||||
private _session?: Session3
|
||||
get session(): Session3 {
|
||||
return (this._session ??= new Session3({ client: this.client }))
|
||||
}
|
||||
|
||||
private _model?: Model
|
||||
get model(): Model {
|
||||
return (this._model ??= new Model({ client: this.client }))
|
||||
}
|
||||
}
|
||||
|
||||
export class Control extends HeyApiClient {
|
||||
|
||||
@@ -35,6 +35,7 @@ export type Event =
|
||||
| EventVcsBranchUpdated
|
||||
| EventWorkspaceReady
|
||||
| EventWorkspaceFailed
|
||||
| EventWorkspaceRestore
|
||||
| EventWorkspaceStatus
|
||||
| EventWorktreeReady
|
||||
| EventWorktreeFailed
|
||||
@@ -800,6 +801,7 @@ export type GlobalEvent = {
|
||||
| EventVcsBranchUpdated
|
||||
| EventWorkspaceReady
|
||||
| EventWorkspaceFailed
|
||||
| EventWorkspaceRestore
|
||||
| EventWorkspaceStatus
|
||||
| EventWorktreeReady
|
||||
| EventWorktreeFailed
|
||||
@@ -1875,11 +1877,9 @@ export type SyncEventSessionNextModelSwitched = {
|
||||
data: {
|
||||
timestamp: number
|
||||
sessionID: string
|
||||
model: {
|
||||
id: string
|
||||
providerID: string
|
||||
variant: string
|
||||
}
|
||||
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,7 +1989,10 @@ export type SyncEventSessionNextStepFailed = {
|
||||
data: {
|
||||
timestamp: number
|
||||
sessionID: string
|
||||
error: SessionErrorUnknown
|
||||
error: {
|
||||
type: string
|
||||
message: string
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2187,7 +2190,10 @@ export type SyncEventSessionNextToolFailed = {
|
||||
timestamp: number
|
||||
sessionID: string
|
||||
callID: string
|
||||
error: SessionErrorUnknown
|
||||
error: {
|
||||
type: string
|
||||
message: string
|
||||
}
|
||||
provider: {
|
||||
executed: boolean
|
||||
metadata?: {
|
||||
@@ -2472,6 +2478,17 @@ 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"
|
||||
@@ -2612,11 +2629,9 @@ export type EventSessionNextModelSwitched = {
|
||||
properties: {
|
||||
timestamp: number
|
||||
sessionID: string
|
||||
model: {
|
||||
id: string
|
||||
providerID: string
|
||||
variant: string
|
||||
}
|
||||
id: string
|
||||
providerID: string
|
||||
variant?: string
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2691,7 +2706,7 @@ export type EventSessionNextStepStarted = {
|
||||
model: {
|
||||
id: string
|
||||
providerID: string
|
||||
variant: string
|
||||
variant?: string
|
||||
}
|
||||
snapshot?: string
|
||||
}
|
||||
@@ -2718,18 +2733,16 @@ 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: SessionErrorUnknown
|
||||
error: {
|
||||
type: string
|
||||
message: string
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2900,7 +2913,10 @@ export type EventSessionNextToolFailed = {
|
||||
timestamp: number
|
||||
sessionID: string
|
||||
callID: string
|
||||
error: SessionErrorUnknown
|
||||
error: {
|
||||
type: string
|
||||
message: string
|
||||
}
|
||||
provider: {
|
||||
executed: boolean
|
||||
metadata?: {
|
||||
@@ -2991,7 +3007,7 @@ export type SessionInfo = {
|
||||
model?: {
|
||||
id: string
|
||||
providerID: string
|
||||
variant: string
|
||||
variant?: string
|
||||
}
|
||||
time: {
|
||||
created: number
|
||||
@@ -3027,7 +3043,7 @@ export type SessionMessageModelSwitched = {
|
||||
model: {
|
||||
id: string
|
||||
providerID: string
|
||||
variant: string
|
||||
variant?: string
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3121,7 +3137,10 @@ export type SessionMessageToolStateError = {
|
||||
structured: {
|
||||
[key: string]: unknown
|
||||
}
|
||||
error: SessionErrorUnknown
|
||||
error: {
|
||||
type: string
|
||||
message: string
|
||||
}
|
||||
}
|
||||
|
||||
export type SessionMessageAssistantTool = {
|
||||
@@ -3161,7 +3180,7 @@ export type SessionMessageAssistant = {
|
||||
model: {
|
||||
id: string
|
||||
providerID: string
|
||||
variant: string
|
||||
variant?: string
|
||||
}
|
||||
content: Array<SessionMessageAssistantText | SessionMessageAssistantReasoning | SessionMessageAssistantTool>
|
||||
snapshot?: {
|
||||
@@ -3179,7 +3198,10 @@ export type SessionMessageAssistant = {
|
||||
write: number
|
||||
}
|
||||
}
|
||||
error?: SessionErrorUnknown
|
||||
error?: {
|
||||
type: string
|
||||
message: string
|
||||
}
|
||||
}
|
||||
|
||||
export type SessionMessageCompaction = {
|
||||
@@ -3205,78 +3227,6 @@ export type SessionMessage =
|
||||
| SessionMessageAssistant
|
||||
| SessionMessageCompaction
|
||||
|
||||
export type ModelV2Info = {
|
||||
id: string
|
||||
providerID: string
|
||||
family?: string
|
||||
name: string
|
||||
endpoint:
|
||||
| {
|
||||
type: "openai/responses"
|
||||
url: string
|
||||
websocket?: boolean
|
||||
}
|
||||
| {
|
||||
type: "openai/completions"
|
||||
url: string
|
||||
reasoning?:
|
||||
| {
|
||||
type: "reasoning_content"
|
||||
}
|
||||
| {
|
||||
type: "reasoning_details"
|
||||
}
|
||||
}
|
||||
| {
|
||||
type: "anthropic/messages"
|
||||
url: string
|
||||
}
|
||||
capabilities: {
|
||||
tools: boolean
|
||||
input: Array<string>
|
||||
output: Array<string>
|
||||
}
|
||||
options: {
|
||||
headers: {
|
||||
[key: string]: string
|
||||
}
|
||||
body: {
|
||||
[key: string]: unknown
|
||||
}
|
||||
variant?: string
|
||||
}
|
||||
variants: Array<{
|
||||
id: string
|
||||
headers: {
|
||||
[key: string]: string
|
||||
}
|
||||
body: {
|
||||
[key: string]: unknown
|
||||
}
|
||||
}>
|
||||
time: {
|
||||
released: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN"
|
||||
}
|
||||
cost: Array<{
|
||||
tier?: {
|
||||
type: "context"
|
||||
size: number
|
||||
}
|
||||
input: number
|
||||
output: number
|
||||
cache: {
|
||||
read: number
|
||||
write: number
|
||||
}
|
||||
}>
|
||||
status: "alpha" | "beta" | "deprecated" | "active"
|
||||
limit: {
|
||||
context: number
|
||||
input?: number
|
||||
output: number
|
||||
}
|
||||
}
|
||||
|
||||
export type EventTuiToastShow1 = {
|
||||
id: string
|
||||
type: "tui.toast.show"
|
||||
@@ -6073,38 +6023,6 @@ 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
|
||||
@@ -6288,25 +6206,6 @@ export type V2SessionMessagesResponses = {
|
||||
|
||||
export type V2SessionMessagesResponse2 = V2SessionMessagesResponses[keyof V2SessionMessagesResponses]
|
||||
|
||||
export type V2ModelListData = {
|
||||
body?: never
|
||||
path?: never
|
||||
query?: {
|
||||
directory?: string
|
||||
workspace?: string
|
||||
}
|
||||
url: "/api/model"
|
||||
}
|
||||
|
||||
export type V2ModelListResponses = {
|
||||
/**
|
||||
* Success
|
||||
*/
|
||||
200: Array<ModelV2Info>
|
||||
}
|
||||
|
||||
export type V2ModelListResponse = V2ModelListResponses[keyof V2ModelListResponses]
|
||||
|
||||
export type TuiAppendPromptData = {
|
||||
body?: {
|
||||
text: string
|
||||
@@ -6745,37 +6644,41 @@ export type ExperimentalWorkspaceRemoveResponses = {
|
||||
export type ExperimentalWorkspaceRemoveResponse =
|
||||
ExperimentalWorkspaceRemoveResponses[keyof ExperimentalWorkspaceRemoveResponses]
|
||||
|
||||
export type ExperimentalWorkspaceWarpData = {
|
||||
export type ExperimentalWorkspaceSessionRestoreData = {
|
||||
body?: {
|
||||
id: string
|
||||
sessionID: string
|
||||
}
|
||||
path?: never
|
||||
path: {
|
||||
id: string
|
||||
}
|
||||
query?: {
|
||||
directory?: string
|
||||
workspace?: string
|
||||
}
|
||||
url: "/experimental/workspace/warp"
|
||||
url: "/experimental/workspace/{id}/session-restore"
|
||||
}
|
||||
|
||||
export type ExperimentalWorkspaceWarpErrors = {
|
||||
export type ExperimentalWorkspaceSessionRestoreErrors = {
|
||||
/**
|
||||
* Bad request
|
||||
*/
|
||||
400: BadRequestError
|
||||
}
|
||||
|
||||
export type ExperimentalWorkspaceWarpError = ExperimentalWorkspaceWarpErrors[keyof ExperimentalWorkspaceWarpErrors]
|
||||
export type ExperimentalWorkspaceSessionRestoreError =
|
||||
ExperimentalWorkspaceSessionRestoreErrors[keyof ExperimentalWorkspaceSessionRestoreErrors]
|
||||
|
||||
export type ExperimentalWorkspaceWarpResponses = {
|
||||
export type ExperimentalWorkspaceSessionRestoreResponses = {
|
||||
/**
|
||||
* Session warped
|
||||
* Session replay started
|
||||
*/
|
||||
204: void
|
||||
200: {
|
||||
total: number
|
||||
}
|
||||
}
|
||||
|
||||
export type ExperimentalWorkspaceWarpResponse =
|
||||
ExperimentalWorkspaceWarpResponses[keyof ExperimentalWorkspaceWarpResponses]
|
||||
export type ExperimentalWorkspaceSessionRestoreResponse =
|
||||
ExperimentalWorkspaceSessionRestoreResponses[keyof ExperimentalWorkspaceSessionRestoreResponses]
|
||||
|
||||
export type PtyConnectData = {
|
||||
body?: never
|
||||
|
||||
@@ -6785,84 +6785,6 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"/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"],
|
||||
@@ -8359,10 +8281,10 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"/experimental/workspace/warp": {
|
||||
"/experimental/workspace/{id}/session-restore": {
|
||||
"post": {
|
||||
"tags": ["workspace"],
|
||||
"operationId": "experimental.workspace.warp",
|
||||
"operationId": "experimental.workspace.sessionRestore",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "directory",
|
||||
@@ -8379,11 +8301,36 @@
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"pattern": "^wrk.*"
|
||||
},
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"204": {
|
||||
"description": "Session warped"
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad request",
|
||||
@@ -8396,22 +8343,19 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Move a session's sync history into the target workspace, or detach it to the local project.",
|
||||
"summary": "Warp session into workspace",
|
||||
"description": "Replay a session's sync events into the target workspace in batches.",
|
||||
"summary": "Restore session into workspace",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"sessionID": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["id", "sessionID"],
|
||||
"required": ["sessionID"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
@@ -8420,7 +8364,7 @@
|
||||
"x-codeSamples": [
|
||||
{
|
||||
"lang": "js",
|
||||
"source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.experimental.workspace.warp({\n ...\n})"
|
||||
"source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.experimental.workspace.sessionRestore({\n ...\n})"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -8594,6 +8538,9 @@
|
||||
{
|
||||
"$ref": "#/components/schemas/EventWorkspaceFailed"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/EventWorkspaceRestore"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/EventWorkspaceStatus"
|
||||
},
|
||||
@@ -10790,6 +10737,9 @@
|
||||
{
|
||||
"$ref": "#/components/schemas/EventWorkspaceFailed"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/EventWorkspaceRestore"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/EventWorkspaceStatus"
|
||||
},
|
||||
@@ -13998,24 +13948,17 @@
|
||||
"sessionID": {
|
||||
"type": "string"
|
||||
},
|
||||
"model": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"providerID": {
|
||||
"type": "string"
|
||||
},
|
||||
"variant": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["id", "providerID", "variant"],
|
||||
"additionalProperties": false
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"providerID": {
|
||||
"type": "string"
|
||||
},
|
||||
"variant": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["timestamp", "sessionID", "model"],
|
||||
"required": ["timestamp", "sessionID", "id", "providerID"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
@@ -14238,7 +14181,7 @@
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["id", "providerID", "variant"],
|
||||
"required": ["id", "providerID"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"snapshot": {
|
||||
@@ -14364,7 +14307,17 @@
|
||||
"type": "string"
|
||||
},
|
||||
"error": {
|
||||
"$ref": "#/components/schemas/SessionErrorUnknown"
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string"
|
||||
},
|
||||
"message": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["type", "message"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"required": ["timestamp", "sessionID", "error"],
|
||||
@@ -14976,7 +14929,17 @@
|
||||
"type": "string"
|
||||
},
|
||||
"error": {
|
||||
"$ref": "#/components/schemas/SessionErrorUnknown"
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string"
|
||||
},
|
||||
"message": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["type", "message"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"provider": {
|
||||
"type": "object",
|
||||
@@ -15830,6 +15793,41 @@
|
||||
"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": {
|
||||
@@ -16254,24 +16252,17 @@
|
||||
"sessionID": {
|
||||
"type": "string"
|
||||
},
|
||||
"model": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"providerID": {
|
||||
"type": "string"
|
||||
},
|
||||
"variant": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["id", "providerID", "variant"],
|
||||
"additionalProperties": false
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"providerID": {
|
||||
"type": "string"
|
||||
},
|
||||
"variant": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["timestamp", "sessionID", "model"],
|
||||
"required": ["timestamp", "sessionID", "id", "providerID"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
@@ -16490,7 +16481,7 @@
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["id", "providerID", "variant"],
|
||||
"required": ["id", "providerID"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"snapshot": {
|
||||
@@ -16574,20 +16565,6 @@
|
||||
"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": {
|
||||
@@ -16608,7 +16585,17 @@
|
||||
"type": "string"
|
||||
},
|
||||
"error": {
|
||||
"$ref": "#/components/schemas/SessionErrorUnknown"
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string"
|
||||
},
|
||||
"message": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["type", "message"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"required": ["timestamp", "sessionID", "error"],
|
||||
@@ -17111,7 +17098,17 @@
|
||||
"type": "string"
|
||||
},
|
||||
"error": {
|
||||
"$ref": "#/components/schemas/SessionErrorUnknown"
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string"
|
||||
},
|
||||
"message": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["type", "message"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"provider": {
|
||||
"type": "object",
|
||||
@@ -17364,7 +17361,7 @@
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["id", "providerID", "variant"],
|
||||
"required": ["id", "providerID"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"time": {
|
||||
@@ -17460,7 +17457,7 @@
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["id", "providerID", "variant"],
|
||||
"required": ["id", "providerID"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
@@ -17719,7 +17716,17 @@
|
||||
"type": "object"
|
||||
},
|
||||
"error": {
|
||||
"$ref": "#/components/schemas/SessionErrorUnknown"
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string"
|
||||
},
|
||||
"message": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["type", "message"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"required": ["status", "input", "content", "structured", "error"],
|
||||
@@ -17832,7 +17839,7 @@
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["id", "providerID", "variant"],
|
||||
"required": ["id", "providerID"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"content": {
|
||||
@@ -17899,7 +17906,17 @@
|
||||
"additionalProperties": false
|
||||
},
|
||||
"error": {
|
||||
"$ref": "#/components/schemas/SessionErrorUnknown"
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string"
|
||||
},
|
||||
"message": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["type", "message"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"required": ["id", "time", "type", "agent", "model", "content"],
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/slack",
|
||||
"version": "1.14.35",
|
||||
"version": "1.14.34",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/ui",
|
||||
"version": "1.14.35",
|
||||
"version": "1.14.34",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"exports": {
|
||||
@@ -25,8 +25,6 @@
|
||||
},
|
||||
"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"
|
||||
},
|
||||
|
||||
@@ -19,21 +19,6 @@ 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",
|
||||
@@ -49,20 +34,4 @@ 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("")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -27,49 +27,26 @@ const cache = new Map<string, FileDiffMetadata>()
|
||||
|
||||
function patch(diff: ReviewDiff) {
|
||||
if (typeof diff.patch === "string") {
|
||||
try {
|
||||
const [patch] = parsePatch(diff.patch)
|
||||
const beforeLines: Array<{ text: string; newline: boolean }> = []
|
||||
const afterLines: Array<{ text: string; newline: boolean }> = []
|
||||
let previous: "-" | "+" | " " | undefined
|
||||
const [patch] = parsePatch(diff.patch)
|
||||
|
||||
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
|
||||
}
|
||||
const beforeLines = []
|
||||
const afterLines = []
|
||||
|
||||
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 = " "
|
||||
}
|
||||
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))
|
||||
}
|
||||
}
|
||||
|
||||
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: beforeLines.join("\n"), after: afterLines.join("\n"), patch: diff.patch }
|
||||
}
|
||||
return {
|
||||
before: "before" in diff && typeof diff.before === "string" ? diff.before : "",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "@opencode-ai/web",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"version": "1.14.35",
|
||||
"version": "1.14.34",
|
||||
"scripts": {
|
||||
"dev": "astro dev",
|
||||
"dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "opencode",
|
||||
"displayName": "opencode",
|
||||
"description": "opencode for VS Code",
|
||||
"version": "1.14.35",
|
||||
"version": "1.14.34",
|
||||
"publisher": "sst-dev",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
||||
131
specs/v2/session-concepts-gap.md
Normal file
131
specs/v2/session-concepts-gap.md
Normal file
@@ -0,0 +1,131 @@
|
||||
# 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.
|
||||
@@ -1,59 +0,0 @@
|
||||
# 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
|
||||
@@ -26,15 +26,6 @@
|
||||
"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": ["*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user