diff --git a/bun.lock b/bun.lock index e3f6f4b340..707302aa91 100644 --- a/bun.lock +++ b/bun.lock @@ -114,10 +114,12 @@ "effect": "catalog:", "fuzzysort": "catalog:", "jsonc-parser": "3.3.1", + "open": "10.1.2", "opentui-spinner": "catalog:", "semver": "catalog:", "solid-js": "catalog:", "strip-ansi": "7.1.2", + "uqr": "0.1.3", }, "devDependencies": { "@opencode-ai/script": "workspace:*", @@ -1015,6 +1017,7 @@ "remeda": "catalog:", "solid-js": "catalog:", "strip-ansi": "7.1.2", + "uqr": "0.1.3", }, "devDependencies": { "@tsconfig/bun": "catalog:", @@ -5949,6 +5952,8 @@ "update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="], + "uqr": ["uqr@0.1.3", "", {}, "sha512-0rjE8iEJe4YmT9TOhwsZtqCMRLc5DXZUI2UEYUUg63ikBkqqE5EYWaI0etFe/5KUcmcYwLih2RND1kq+hrUJXA=="], + "uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="], "urijs": ["urijs@1.19.11", "", {}, "sha512-HXgFDgDommxn5/bIv0cnQZsPhHDA90NPHD6+c/v21U5+Sx5hoP8+dP9IZXBU1gIfvdRfhG8cel9QNPeionfcCQ=="], diff --git a/github/index.ts b/github/index.ts index 4e1af9cf55..e8acd9a9ce 100644 --- a/github/index.ts +++ b/github/index.ts @@ -495,7 +495,6 @@ async function subscribeSessionEvents() { console.log("Subscribing to session events...") const TOOL: Record = { - todowrite: ["Todo", "\x1b[33m\x1b[1m"], bash: ["Bash", "\x1b[31m\x1b[1m"], edit: ["Edit", "\x1b[32m\x1b[1m"], glob: ["Glob", "\x1b[34m\x1b[1m"], diff --git a/packages/app/e2e/performance/timeline-stability/tools.spec.ts b/packages/app/e2e/performance/timeline-stability/tools.spec.ts index d28fdaa65f..d26968e1cb 100644 --- a/packages/app/e2e/performance/timeline-stability/tools.spec.ts +++ b/packages/app/e2e/performance/timeline-stability/tools.spec.ts @@ -33,11 +33,9 @@ test.describe("timeline tool state stability", () => { } const names = { webfetch: "webfetch", websearch: "websearch", task: "task", skill: "skill", custom: "mcp_probe" } const questionID = "prt_state_question" - const todoID = "prt_state_todo" const initial = [ ...ids.map((id) => toolPart(`prt_state_${id}`, names[id], "pending", inputs[id])), toolPart(questionID, "question", "pending", questionInput()), - toolPart(todoID, "todowrite", "pending", { todos: [{ content: "Hidden", status: "pending" }] }), textPart("prt_state_following", "Following lightweight tools"), ] const childID = "ses_timeline_child" @@ -49,7 +47,6 @@ test.describe("timeline tool state stability", () => { await timeline.send(status("busy"), 120) for (const id of ids) await timeline.waitForPart(`prt_state_${id}`) await expect(page.locator(`[data-timeline-part-id="${questionID}"]`)).toHaveCount(0) - await expect(page.locator(`[data-timeline-part-id="${todoID}"]`)).toHaveCount(0) const regionIDs = [ "prt_state_webfetch", @@ -105,7 +102,6 @@ test.describe("timeline tool state stability", () => { ]), ) await expect(page.locator(`[data-timeline-part-id="${questionID}"]`)).toContainText("Keep it stable") - await expect(page.locator(`[data-timeline-part-id="${todoID}"]`)).toHaveCount(0) await expect( page.locator(`a[href$="/session/${childID}"]`, { has: page.locator('[data-component="task-tool-card"]') }), ).toBeVisible() diff --git a/packages/app/e2e/performance/timeline/session-timeline-stress.fixture.ts b/packages/app/e2e/performance/timeline/session-timeline-stress.fixture.ts index e6fdd5d47b..2e20d98415 100644 --- a/packages/app/e2e/performance/timeline/session-timeline-stress.fixture.ts +++ b/packages/app/e2e/performance/timeline/session-timeline-stress.fixture.ts @@ -269,7 +269,6 @@ const childMessages = Array.from({ length: 4 }, (_, index) => [ ]).flat() function renderable(part: MessagePart) { - if (part.type === "tool" && part.tool === "todowrite") return false if (part.type === "text") return !!part.text.trim() if (part.type === "reasoning") return !!part.text.trim() return part.type !== "step-start" && part.type !== "step-finish" && part.type !== "patch" diff --git a/packages/app/e2e/regression/cross-server-tab-close.spec.ts b/packages/app/e2e/regression/cross-server-tab-close.spec.ts index 159b5a5067..9fd7991437 100644 --- a/packages/app/e2e/regression/cross-server-tab-close.spec.ts +++ b/packages/app/e2e/regression/cross-server-tab-close.spec.ts @@ -90,7 +90,7 @@ async function mockServers(page: Page, requests: string[]) { if (url.pathname === `/session/${current.id}`) return json(route, current) if (/^\/session\/[^/]+$/.test(url.pathname)) return json(route, { name: "NotFoundError" }, 404) if (url.pathname === `/session/${current.id}/message`) return json(route, []) - if (/^\/session\/[^/]+\/(children|todo|diff)$/.test(url.pathname)) return json(route, []) + if (/^\/session\/[^/]+\/(children|diff)$/.test(url.pathname)) return json(route, []) if (["/skill", "/command", "/lsp", "/formatter", "/permission", "/question", "/vcs/diff"].includes(url.pathname)) return json(route, []) if (["/global/config", "/config", "/provider/auth", "/mcp", "/session/status"].includes(url.pathname)) diff --git a/packages/app/e2e/regression/remote-tab-busy.spec.ts b/packages/app/e2e/regression/remote-tab-busy.spec.ts index 119fc7ee2d..ad7e2e1d99 100644 --- a/packages/app/e2e/regression/remote-tab-busy.spec.ts +++ b/packages/app/e2e/regression/remote-tab-busy.spec.ts @@ -65,7 +65,7 @@ async function mockServers(page: Page) { if (url.pathname === `/session/${current.id}`) return json(route, current) if (/^\/session\/[^/]+$/.test(url.pathname)) return json(route, { name: "NotFoundError" }, 404) if (url.pathname === `/session/${current.id}/message`) return json(route, []) - if (/^\/session\/[^/]+\/(children|todo|diff)$/.test(url.pathname)) return json(route, []) + if (/^\/session\/[^/]+\/(children|diff)$/.test(url.pathname)) return json(route, []) if (["/skill", "/command", "/lsp", "/formatter", "/permission", "/question", "/vcs/diff"].includes(url.pathname)) return json(route, []) if (["/global/config", "/config", "/provider/auth", "/mcp"].includes(url.pathname)) return json(route, {}) diff --git a/packages/app/e2e/regression/session-timeline-projection.spec.ts b/packages/app/e2e/regression/session-timeline-projection.spec.ts index b6679ab4ed..45304df365 100644 --- a/packages/app/e2e/regression/session-timeline-projection.spec.ts +++ b/packages/app/e2e/regression/session-timeline-projection.spec.ts @@ -35,7 +35,6 @@ test.describe("session timeline projection", () => { editPart("prt_edit"), toolPart("prt_write", "write", "completed", { filePath: "src/new.ts", content: "export const stable = true\n" }), patchPart("prt_patch"), - toolPart("prt_todo", "todowrite", "completed", { todos: [{ content: "Hidden", status: "pending" }] }), toolPart( "prt_question", "question", @@ -65,7 +64,6 @@ test.describe("session timeline projection", () => { ]) { await expect(page.locator(`[data-timeline-part-id="${id}"]`).first(), id).toBeVisible() } - await expect(page.locator('[data-timeline-part-id="prt_todo"]')).toHaveCount(0) }) test("projects gaps, dividers, assistant parts, and errors together", async ({ page }) => { diff --git a/packages/app/e2e/regression/session-timeline-tool-projection.spec.ts b/packages/app/e2e/regression/session-timeline-tool-projection.spec.ts index 071b030078..4c2c1c4ead 100644 --- a/packages/app/e2e/regression/session-timeline-tool-projection.spec.ts +++ b/packages/app/e2e/regression/session-timeline-tool-projection.spec.ts @@ -17,13 +17,11 @@ test("renders every tool error outcome without leaking hidden tools", async ({ p error: "The user dismissed this question", }), toolPart("prt_question_error", "question", "error", questionInput(), { error: "Question transport failed" }), - toolPart("prt_todo_error", "todowrite", "error", { todos: [] }, { error: "Hidden todo failure" }), ) await setupTimeline(page, { messages: [userMessage(), assistantMessage(parts)] }) await expect(page.locator('[data-kind="tool-error-card"]')).toHaveCount(ordinary.length + 1) await expect(page.getByText(/dismissed/i)).toBeVisible() - await expect(page.locator('[data-timeline-part-id="prt_todo_error"]')).toHaveCount(0) for (let index = 0; index < ordinary.length; index++) { await expect(page.locator(`[data-timeline-part-id="prt_error_${index}"]`)).toBeVisible() } diff --git a/packages/app/e2e/regression/session-todo-dock-navigation.spec.ts b/packages/app/e2e/regression/session-todo-dock-navigation.spec.ts deleted file mode 100644 index 603c411d55..0000000000 --- a/packages/app/e2e/regression/session-todo-dock-navigation.spec.ts +++ /dev/null @@ -1,186 +0,0 @@ -import { base64Encode } from "@opencode-ai/core/util/encode" -import { expect, test, type Page } from "@playwright/test" -import { mockOpenCodeServer } from "../utils/mock-server" -import { expectSessionTitle } from "../utils/waits" - -const directory = "C:/OpenCode/TodoDockNavigation" -const projectID = "proj_todo_dock_navigation" -const sourceID = "ses_todo_dock_source" -const otherID = "ses_todo_dock_other" -const sourceTitle = "Todo dock animation" -const otherTitle = "Separate session" - -const activeTodos = [ - { id: "todo-1", content: "Receive todos in the active session", status: "completed", priority: "high" }, - { id: "todo-2", content: "Keep the dock visible across tabs", status: "completed", priority: "high" }, - { id: "todo-3", content: "Close after the final todo", status: "in_progress", priority: "high" }, -] - -type EventPayload = { - directory: string - payload: Record -} - -test.use({ viewport: { width: 1440, height: 900 }, reducedMotion: "no-preference" }) - -test("animates todo lifecycle without replaying it across session tabs", async ({ page }) => { - test.setTimeout(90_000) - const events: EventPayload[] = [] - const todos: Record = { [sourceID]: [], [otherID]: [] } - - await mockOpenCodeServer(page, { - directory, - project: { - id: projectID, - worktree: directory, - vcs: "git", - name: "todo-dock-navigation", - time: { created: 1700000000000, updated: 1700000000000 }, - sandboxes: [], - }, - provider: { - all: [ - { - id: "opencode", - name: "OpenCode", - models: { - "claude-opus-4-6": { - id: "claude-opus-4-6", - name: "Claude Opus 4.6", - limit: { context: 200_000 }, - }, - }, - }, - ], - connected: ["opencode"], - default: { providerID: "opencode", modelID: "claude-opus-4-6" }, - }, - sessions: [session(sourceID, sourceTitle, 1700000000000), session(otherID, otherTitle, 1700000001000)], - pageMessages: () => ({ items: [] }), - events: () => events.splice(0, 1), - eventRetry: 16, - todos: (sessionID) => todos[sessionID] ?? [], - }) - await configurePage(page) - - await page.goto(sessionHref(sourceID)) - await expectSessionTitle(page, sourceTitle) - const dock = page.locator('[data-component="session-todo-dock"]') - await expect(dock).toHaveCount(0) - - events.push(statusEvent(sourceID, "busy")) - await expect(page.getByRole("button", { name: "Stop" })).toBeVisible() - - await page.waitForTimeout(700) - const opening = sampleDock(page, 1_000) - todos[sourceID] = activeTodos - events.push(todoEvent(sourceID, activeTodos)) - await expect(dock).toBeVisible() - await expect(dock.locator('[data-state="in_progress"]')).toHaveCount(1) - expect((await opening).some((sample) => sample.opacity > 0.05 && sample.opacity < 0.95)).toBe(true) - - await switchSession(page, otherID, otherTitle) - await expect(dock).toHaveCount(0) - - const returningOpen = sampleDock(page, 700) - await switchSession(page, sourceID, sourceTitle) - const openSamples = (await returningOpen).filter((sample) => sample.present) - expect(openSamples.length).toBeGreaterThan(0) - expect(openSamples[0]!.opacity).toBeGreaterThan(0.98) - expect(openSamples[0]!.height).toBeGreaterThan(70) - await expect(dock.locator('[data-state="in_progress"]')).toHaveCount(1) - - const completedTodos = activeTodos.map((todo) => ({ ...todo, status: "completed" })) - const closing = sampleDock(page, 1_000) - todos[sourceID] = completedTodos - events.push(todoEvent(sourceID, completedTodos)) - await expect(dock).toHaveCount(0) - expect((await closing).some((sample) => sample.opacity > 0.05 && sample.opacity < 0.95)).toBe(true) - todos[sourceID] = [] - events.push(todoEvent(sourceID, [])) - - await switchSession(page, otherID, otherTitle) - const returningEmpty = sampleDock(page, 700) - await switchSession(page, sourceID, sourceTitle) - await expect(dock).toHaveCount(0) - expect((await returningEmpty).every((sample) => !sample.present)).toBe(true) -}) - -function session(id: string, title: string, created: number) { - return { - id, - slug: id, - projectID, - directory, - title, - version: "dev", - time: { created, updated: created }, - } -} - -function statusEvent(sessionID: string, type: "busy" | "idle"): EventPayload { - return { - directory, - payload: { type: "session.status", properties: { sessionID, status: { type } } }, - } -} - -function todoEvent(sessionID: string, next: typeof activeTodos): EventPayload { - return { - directory, - payload: { type: "todo.updated", properties: { sessionID, todos: next } }, - } -} - -async function configurePage(page: Page) { - const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}` - await page.addInitScript( - ({ directory, dirBase64, server, sessionIDs }) => { - localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } })) - localStorage.setItem( - "opencode.global.dat:server", - JSON.stringify({ - projects: { local: [{ worktree: directory, expanded: true }] }, - lastProject: { local: directory }, - }), - ) - localStorage.setItem( - "opencode.window.browser.dat:tabs", - JSON.stringify(sessionIDs.map((sessionId) => ({ type: "session", server, dirBase64, sessionId }))), - ) - }, - { directory, dirBase64: base64Encode(directory), server, sessionIDs: [sourceID, otherID] }, - ) -} - -function sessionHref(sessionID: string) { - const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}` - return `/server/${base64Encode(server)}/session/${sessionID}` -} - -async function switchSession(page: Page, sessionID: string, title: string) { - const href = sessionHref(sessionID) - const tab = page.locator(`[data-slot="titlebar-tabs"] a[href="${href}"]`).first() - await expect(tab).toBeVisible() - await tab.click() - await expectSessionTitle(page, title) -} - -function sampleDock(page: Page, duration: number) { - return page.evaluate(async (duration) => { - const samples: { present: boolean; height: number; opacity: number }[] = [] - const start = performance.now() - while (performance.now() - start < duration) { - const dock = document.querySelector('[data-component="session-todo-dock"]') - const clip = dock?.parentElement?.parentElement - const label = dock?.querySelector('[data-action="session-todo-toggle"] span[aria-label]') - samples.push({ - present: !!dock, - height: clip?.getBoundingClientRect().height ?? 0, - opacity: label ? Number.parseFloat(getComputedStyle(label).opacity) : 0, - }) - await new Promise(requestAnimationFrame) - } - return samples - }, duration) -} diff --git a/packages/app/e2e/regression/tab-navigate-mousedown.spec.ts b/packages/app/e2e/regression/tab-navigate-mousedown.spec.ts index 94afbc9a9d..6bc417af80 100644 --- a/packages/app/e2e/regression/tab-navigate-mousedown.spec.ts +++ b/packages/app/e2e/regression/tab-navigate-mousedown.spec.ts @@ -63,7 +63,7 @@ async function mockServer(page: Page) { if (byId) return json(route, byId) if (/^\/session\/[^/]+$/.test(url.pathname)) return json(route, { name: "NotFoundError" }, 404) if (/^\/session\/[^/]+\/message$/.test(url.pathname)) return json(route, []) - if (/^\/session\/[^/]+\/(children|todo|diff)$/.test(url.pathname)) return json(route, []) + if (/^\/session\/[^/]+\/(children|diff)$/.test(url.pathname)) return json(route, []) if (["/skill", "/command", "/lsp", "/formatter", "/permission", "/question", "/vcs/diff"].includes(url.pathname)) return json(route, []) if (["/global/config", "/config", "/provider/auth", "/mcp", "/session/status"].includes(url.pathname)) diff --git a/packages/app/e2e/smoke/session-timeline.fixture.ts b/packages/app/e2e/smoke/session-timeline.fixture.ts index ff857d8179..beb2d7cf75 100644 --- a/packages/app/e2e/smoke/session-timeline.fixture.ts +++ b/packages/app/e2e/smoke/session-timeline.fixture.ts @@ -229,7 +229,6 @@ const sourceMessages = Array.from({ length: 12 }, (_, index) => [ ]).flat() function renderable(part: MessagePart) { - if (part.type === "tool" && part.tool === "todowrite") return false if (part.type === "text") return !!part.text.trim() if (part.type === "reasoning") return !!part.text.trim() return part.type !== "step-start" && part.type !== "step-finish" && part.type !== "patch" diff --git a/packages/app/e2e/utils/mock-server.ts b/packages/app/e2e/utils/mock-server.ts index 53adfd155b..c69382bba3 100644 --- a/packages/app/e2e/utils/mock-server.ts +++ b/packages/app/e2e/utils/mock-server.ts @@ -17,7 +17,6 @@ export interface MockServerConfig { onMessage?: (input: { sessionID: string; messageID: string }) => void events?: () => unknown[] eventRetry?: number - todos?: (sessionID: string) => unknown[] permissions?: unknown[] | (() => unknown[]) questions?: unknown[] | (() => unknown[]) fileList?: (path: string) => unknown | Promise @@ -96,8 +95,6 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) { return json(route, message) } - const todoMatch = path.match(/^\/session\/([^/]+)\/todo$/) - if (todoMatch) return json(route, config.todos?.(todoMatch[1]!) ?? []) if (/^\/session\/[^/]+\/(children|diff)$/.test(path)) return json(route, []) const messagesMatch = path.match(/^\/session\/([^/]+)\/message$/) diff --git a/packages/app/src/components/prompt-input.stories.tsx b/packages/app/src/components/prompt-input.stories.tsx index 362a741401..272079e106 100644 --- a/packages/app/src/components/prompt-input.stories.tsx +++ b/packages/app/src/components/prompt-input.stories.tsx @@ -1,8 +1,6 @@ // @ts-nocheck import { createStore } from "solid-js/store" -import type { Todo } from "@opencode-ai/sdk/v2" import { createPromptState } from "@/context/prompt" -import { SessionComposerRegion, createSessionComposerRegionController } from "@/pages/session/composer" import { createPromptInputHistory, PromptInput } from "./prompt-input" function createPromptInputStoryRuntime() { @@ -104,94 +102,6 @@ function PromptInputExample() { ) } -const todos: Todo[] = [ - { id: "todo-1", content: "Inspect the session composer animation", status: "completed" }, - { id: "todo-2", content: "Keep the dock settled on initial render", status: "in_progress" }, - { id: "todo-3", content: "Verify session navigation behavior", status: "pending" }, -] - -function PromptInputWithOpenDock() { - const input = createPromptInputStoryRuntime() - const [controls, setControls] = createStore({ - agent: "build", - activeTab: undefined as string | undefined, - todoCollapsed: false, - }) - const inputControls = { - agents: { - available: [], - options: ["build"], - get current() { - return controls.agent - }, - loading: false, - visible: true, - select: (agent?: string) => setControls("agent", agent ?? "build"), - }, - model: { - selection: { - current: () => ({ id: "claude-3-7-sonnet", name: "Claude 3.7 Sonnet", provider: { id: "anthropic" } }), - variant: { list: () => [], current: () => undefined, set: () => {} }, - }, - paid: true, - loading: false, - }, - session: { - id: "story-session", - tabs: { - active: () => controls.activeTab, - all: () => [], - open: () => {}, - setActive: (tab: string) => setControls("activeTab", tab), - }, - reviewPanel: { opened: () => false, open: () => {} }, - }, - newLayoutDesigns: true, - } - const state = { - blocked: () => false, - questionRequest: () => undefined, - permissionRequest: () => undefined, - permissionResponding: () => false, - decide: () => {}, - todos: () => todos, - dock: () => true, - closing: () => false, - opening: () => false, - } - return ( - "story-session", - sessionID: () => "story-session", - prompt: input.state, - ready: () => true, - centered: () => false, - todo: { - collapsed: () => controls.todoCollapsed, - onToggle: () => setControls("todoCollapsed", (collapsed) => !collapsed), - }, - followup: () => undefined, - revert: () => undefined, - onResponseSubmit: () => {}, - openParent: () => {}, - setPromptRef: () => {}, - setDockRef: () => {}, - })} - promptInput={ - {}} - newSessionWorktree="" - onNewSessionWorktreeReset={() => {}} - /> - } - /> - ) -} - export default { title: "App/PromptInput", id: "app-prompt-input", @@ -206,12 +116,3 @@ export const Basic = { ), } - -export const DockAlreadyOpen = { - render: () => ( -
-

Prompt Input with open Todo dock

- -
- ), -} diff --git a/packages/app/src/components/prompt-input/submit.ts b/packages/app/src/components/prompt-input/submit.ts index 05d978a947..cba1e4c885 100644 --- a/packages/app/src/components/prompt-input/submit.ts +++ b/packages/app/src/components/prompt-input/submit.ts @@ -221,8 +221,6 @@ export function createPromptSubmit(input: PromptSubmitInput) { const sessionID = params.id if (!sessionID) return Promise.resolve() - serverSync().session.set("todo", sessionID, []) - input.onAbort?.() const key = pendingKey(sessionID) diff --git a/packages/app/src/context/directory-sync.ts b/packages/app/src/context/directory-sync.ts index 68e6b19cef..7ccd6e975e 100644 --- a/packages/app/src/context/directory-sync.ts +++ b/packages/app/src/context/directory-sync.ts @@ -11,7 +11,6 @@ const sessionFields = new Set([ "session_status", "session_working", "session_diff", - "todo", "permission", "question", "message", @@ -115,7 +114,6 @@ export const createDirSyncContext = ( index(sessionID) }, diff: serverSync.session.diff, - todo: serverSync.session.todo, history: serverSync.session.history, evict(sessionID: string) { serverSync.session.evict(sessionID) diff --git a/packages/app/src/context/global-sync/bootstrap.test.ts b/packages/app/src/context/global-sync/bootstrap.test.ts index 40735fb822..872f0929c8 100644 --- a/packages/app/src/context/global-sync/bootstrap.test.ts +++ b/packages/app/src/context/global-sync/bootstrap.test.ts @@ -30,7 +30,6 @@ function directoryState() { return this.session_status[id]?.type !== "idle" }, session_diff: {}, - todo: {}, permission: {}, question: {}, mcp_ready: true, diff --git a/packages/app/src/context/global-sync/child-store.ts b/packages/app/src/context/global-sync/child-store.ts index 53b9536182..66e40f66ec 100644 --- a/packages/app/src/context/global-sync/child-store.ts +++ b/packages/app/src/context/global-sync/child-store.ts @@ -223,7 +223,6 @@ export function createChildStoreManager(input: { return (type ?? "idle") !== "idle" }, session_diff: {}, - todo: {}, permission: {}, question: {}, get mcp_ready() { diff --git a/packages/app/src/context/global-sync/event-reducer.test.ts b/packages/app/src/context/global-sync/event-reducer.test.ts index 2811e6e3c6..8e2113709b 100644 --- a/packages/app/src/context/global-sync/event-reducer.test.ts +++ b/packages/app/src/context/global-sync/event-reducer.test.ts @@ -72,7 +72,6 @@ const baseState = (input: Partial = {}) => sessionTotal: 0, session_status: {}, session_diff: {}, - todo: {}, permission: {}, question: {}, mcp: {}, @@ -216,7 +215,6 @@ describe("applyDirectoryEvent", () => { message: { ses_1: [message] }, part: { [message.id]: [textPart("prt_1", "ses_1", message.id)] }, session_diff: { ses_1: [] }, - todo: { ses_1: [] }, permission: { ses_1: [] }, question: { ses_1: [] }, session_status: { ses_1: { type: "busy" } }, @@ -237,7 +235,6 @@ describe("applyDirectoryEvent", () => { expect(store.message.ses_1).toBeUndefined() expect(store.part[message.id]).toBeUndefined() expect(store.session_diff.ses_1).toBeUndefined() - expect(store.todo.ses_1).toBeUndefined() expect(store.permission.ses_1).toBeUndefined() expect(store.question.ses_1).toBeUndefined() expect(store.session_status.ses_1).toBeUndefined() @@ -262,7 +259,6 @@ describe("applyDirectoryEvent", () => { message: { [item.info.id]: [message] }, part: { [message.id]: [textPart("prt_1", item.info.id, message.id)] }, session_diff: { [item.info.id]: [] }, - todo: { [item.info.id]: [] }, permission: { [item.info.id]: [] }, question: { [item.info.id]: [] }, session_status: { [item.info.id]: { type: "busy" } }, @@ -283,7 +279,6 @@ describe("applyDirectoryEvent", () => { expect(store.message[item.info.id]).toBeUndefined() expect(store.part[message.id]).toBeUndefined() expect(store.session_diff[item.info.id]).toBeUndefined() - expect(store.todo[item.info.id]).toBeUndefined() expect(store.permission[item.info.id]).toBeUndefined() expect(store.question[item.info.id]).toBeUndefined() expect(store.session_status[item.info.id]).toBeUndefined() @@ -294,7 +289,6 @@ describe("applyDirectoryEvent", () => { const dropped = rootSession({ id: "ses_b" }) const kept = rootSession({ id: "ses_a" }) const message = userMessage("msg_1", dropped.id) - const todos: string[] = [] const [store, setStore] = createStore( baseState({ limit: 1, @@ -302,7 +296,6 @@ describe("applyDirectoryEvent", () => { message: { [dropped.id]: [message] }, part: { [message.id]: [textPart("prt_1", dropped.id, message.id)] }, session_diff: { [dropped.id]: [] }, - todo: { [dropped.id]: [] }, permission: { [dropped.id]: [] }, question: { [dropped.id]: [] }, session_status: { [dropped.id]: { type: "busy" } }, @@ -316,21 +309,15 @@ describe("applyDirectoryEvent", () => { push() {}, directory: "/tmp", loadLsp() {}, - setSessionTodo(sessionID, value) { - if (value !== undefined) return - todos.push(sessionID) - }, }) expect(store.session.map((x) => x.id)).toEqual([kept.id]) expect(store.message[dropped.id]).toBeUndefined() expect(store.part[message.id]).toBeUndefined() expect(store.session_diff[dropped.id]).toBeUndefined() - expect(store.todo[dropped.id]).toBeUndefined() expect(store.permission[dropped.id]).toBeUndefined() expect(store.question[dropped.id]).toBeUndefined() expect(store.session_status[dropped.id]).toBeUndefined() - expect(todos).toEqual([dropped.id]) }) test("cleanupDroppedSessionCaches clears part-only orphan state", () => { diff --git a/packages/app/src/context/global-sync/event-reducer.ts b/packages/app/src/context/global-sync/event-reducer.ts index 40b7acbbd3..935fc14e33 100644 --- a/packages/app/src/context/global-sync/event-reducer.ts +++ b/packages/app/src/context/global-sync/event-reducer.ts @@ -9,7 +9,6 @@ import type { Session, SessionStatus, FileDiffInfo, - Todo, } from "@opencode-ai/sdk/v2/client" import type { State, VcsCache } from "./types" import { trimSessions } from "./session-trim" @@ -19,7 +18,6 @@ import { diffs as list, message as clean } from "@/utils/diffs" const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"]) const SESSION_CONTENT_EVENTS = new Set([ "session.diff", - "todo.updated", "session.status", "message.updated", "message.removed", @@ -62,13 +60,8 @@ export function applyGlobalEvent(input: { ) } -function cleanupSessionCaches( - setStore: SetStoreFunction, - sessionID: string, - setSessionTodo?: (sessionID: string, todos: Todo[] | undefined) => void, -) { +function cleanupSessionCaches(setStore: SetStoreFunction, sessionID: string) { if (!sessionID) return - setSessionTodo?.(sessionID, undefined) setStore( produce((draft) => { dropSessionCaches(draft, [sessionID]) @@ -76,17 +69,11 @@ function cleanupSessionCaches( ) } -export function cleanupDroppedSessionCaches( - store: Store, - setStore: SetStoreFunction, - next: Session[], - setSessionTodo?: (sessionID: string, todos: Todo[] | undefined) => void, -) { +export function cleanupDroppedSessionCaches(store: Store, setStore: SetStoreFunction, next: Session[]) { const keep = new Set(next.map((item) => item.id)) const stale = [ ...Object.keys(store.message), ...Object.keys(store.session_diff), - ...Object.keys(store.todo), ...Object.keys(store.permission), ...Object.keys(store.question), ...Object.keys(store.session_status), @@ -95,9 +82,6 @@ export function cleanupDroppedSessionCaches( .filter((sessionID): sessionID is string => !!sessionID), ].filter((sessionID, index, list) => !keep.has(sessionID) && list.indexOf(sessionID) === index) if (stale.length === 0) return - for (const sessionID of stale) { - setSessionTodo?.(sessionID, undefined) - } setStore( produce((draft) => { dropSessionCaches(draft, stale) @@ -114,7 +98,6 @@ export function applyDirectoryEvent(input: { loadLsp: () => void loadReferences?: () => void vcsCache?: VcsCache - setSessionTodo?: (sessionID: string, todos: Todo[] | undefined) => void retainedLimit?: number sessionContent?: boolean permission?: State["permission"] @@ -138,7 +121,7 @@ export function applyDirectoryEvent(input: { next.splice(result.index, 0, info) const trimmed = trimSessions(next, { limit, permission: input.permission ?? input.store.permission }) input.setStore("session", reconcile(trimmed, { key: "id" })) - cleanupDroppedSessionCaches(input.store, input.setStore, trimmed, input.setSessionTodo) + cleanupDroppedSessionCaches(input.store, input.setStore, trimmed) if (!info.parentID) input.setStore("sessionTotal", (value) => value + 1) break } @@ -155,7 +138,7 @@ export function applyDirectoryEvent(input: { }), ) } - cleanupSessionCaches(input.setStore, info.id, input.setSessionTodo) + cleanupSessionCaches(input.setStore, info.id) if (info.parentID) break input.setStore("sessionTotal", (value) => Math.max(0, value - 1)) break @@ -168,7 +151,7 @@ export function applyDirectoryEvent(input: { next.splice(result.index, 0, info) const trimmed = trimSessions(next, { limit, permission: input.permission ?? input.store.permission }) input.setStore("session", reconcile(trimmed, { key: "id" })) - cleanupDroppedSessionCaches(input.store, input.setStore, trimmed, input.setSessionTodo) + cleanupDroppedSessionCaches(input.store, input.setStore, trimmed) break } case "session.deleted": { @@ -182,7 +165,7 @@ export function applyDirectoryEvent(input: { }), ) } - cleanupSessionCaches(input.setStore, info.id, input.setSessionTodo) + cleanupSessionCaches(input.setStore, info.id) if (info.parentID) break input.setStore("sessionTotal", (value) => Math.max(0, value - 1)) break @@ -192,12 +175,6 @@ export function applyDirectoryEvent(input: { input.setStore("session_diff", props.sessionID, reconcile(list(props.diff), { key: "file" })) break } - case "todo.updated": { - const props = event.properties as { sessionID: string; todos: Todo[] } - input.setStore("todo", props.sessionID, reconcile(props.todos, { key: "id" })) - input.setSessionTodo?.(props.sessionID, props.todos) - break - } case "session.status": { const props = event.properties as { sessionID: string; status: SessionStatus } input.setStore("session_status", props.sessionID, reconcile(props.status)) diff --git a/packages/app/src/context/global-sync/session-cache.test.ts b/packages/app/src/context/global-sync/session-cache.test.ts index 3dda5a5429..41034186e6 100644 --- a/packages/app/src/context/global-sync/session-cache.test.ts +++ b/packages/app/src/context/global-sync/session-cache.test.ts @@ -6,7 +6,6 @@ import type { QuestionRequest, SessionStatus, FileDiffInfo, - Todo, } from "@opencode-ai/sdk/v2/client" import { dropSessionCaches, pickSessionCacheEvictions } from "./session-cache" @@ -34,7 +33,6 @@ describe("app session cache", () => { const store: { session_status: Record session_diff: Record - todo: Record message: Record part: Record permission: Record @@ -43,7 +41,6 @@ describe("app session cache", () => { } = { session_status: { ses_1: { type: "busy" } as SessionStatus }, session_diff: { ses_1: [] }, - todo: { ses_1: [] as Todo[] }, message: {}, part: { msg_1: [part("prt_1", "ses_1", "msg_1")] }, permission: { ses_1: [] as PermissionRequest[] }, @@ -56,7 +53,6 @@ describe("app session cache", () => { expect(store.message.ses_1).toBeUndefined() expect(store.part.msg_1).toBeUndefined() expect(store.part_text_accum_delta.prt_1).toBeUndefined() - expect(store.todo.ses_1).toBeUndefined() expect(store.session_diff.ses_1).toBeUndefined() expect(store.session_status.ses_1).toBeUndefined() expect(store.permission.ses_1).toBeUndefined() @@ -68,7 +64,6 @@ describe("app session cache", () => { const store: { session_status: Record session_diff: Record - todo: Record message: Record part: Record permission: Record @@ -77,7 +72,6 @@ describe("app session cache", () => { } = { session_status: {}, session_diff: {}, - todo: {}, message: { ses_1: [m] }, part: { [m.id]: [part("prt_1", "ses_1", m.id)] }, permission: {}, diff --git a/packages/app/src/context/global-sync/session-cache.ts b/packages/app/src/context/global-sync/session-cache.ts index 39535abe2f..6f2c3dc540 100644 --- a/packages/app/src/context/global-sync/session-cache.ts +++ b/packages/app/src/context/global-sync/session-cache.ts @@ -5,7 +5,6 @@ import type { QuestionRequest, SessionStatus, FileDiffInfo, - Todo, } from "@opencode-ai/sdk/v2/client" export const SESSION_CACHE_LIMIT = 40 @@ -13,7 +12,6 @@ export const SESSION_CACHE_LIMIT = 40 type SessionCache = { session_status: Record session_diff: Record - todo: Record message: Record part: Record permission: Record @@ -36,7 +34,6 @@ export function dropSessionCaches(store: SessionCache, sessionIDs: Iterable s().todoCollapsed ?? false, - set(collapsed: boolean) { - const session = key() - const current = store.sessionView[session] - if (!current) { - setStore("sessionView", session, { scroll: {}, todoCollapsed: collapsed }) - } else { - setStore("sessionView", session, "todoCollapsed", collapsed) - } - }, - }, terminal: { opened: terminalOpened, open() { diff --git a/packages/app/src/context/server-session.test.ts b/packages/app/src/context/server-session.test.ts index 46a0dc8266..fb4443a3ef 100644 --- a/packages/app/src/context/server-session.test.ts +++ b/packages/app/src/context/server-session.test.ts @@ -151,7 +151,6 @@ function setup(sessions: Record) { return response() }, diff: async () => ({ data: [] }), - todo: async () => ({ data: [] }), }, } as unknown as OpencodeClient return { get, messages, store: createServerSession(client) } diff --git a/packages/app/src/context/server-session.ts b/packages/app/src/context/server-session.ts index a46eb744d1..12c3d8abb8 100644 --- a/packages/app/src/context/server-session.ts +++ b/packages/app/src/context/server-session.ts @@ -9,7 +9,6 @@ import type { Session, SessionStatus, FileDiffInfo, - Todo, } from "@opencode-ai/sdk/v2/client" import { batch } from "solid-js" import { createStore, produce, reconcile } from "solid-js/store" @@ -140,7 +139,6 @@ export function createServerSession(client: OpencodeClient, options?: { retry?: info: {} as Record, session_status: {} as Record, session_diff: {} as Record, - todo: {} as Record, permission: {} as Record, question: {} as Record, message: {} as Record, @@ -153,7 +151,6 @@ export function createServerSession(client: OpencodeClient, options?: { retry?: const requests = new Map>() const inflight = new Map>() const inflightDiff = new Map>() - const inflightTodo = new Map>() const optimistic = new Map>() const messageLoads = new Map() const pendingParts = new Map>>() @@ -199,7 +196,6 @@ export function createServerSession(client: OpencodeClient, options?: { retry?: ...requests.keys(), ...inflight.keys(), ...inflightDiff.keys(), - ...inflightTodo.keys(), ...messageLoads.keys(), ...optimistic.keys(), ...Object.entries(data.permission) @@ -254,8 +250,7 @@ export function createServerSession(client: OpencodeClient, options?: { retry?: !requests.has(sessionID) && !messageLoads.has(sessionID) && !inflight.has(sessionID) && - !inflightDiff.has(sessionID) && - !inflightTodo.has(sessionID) + !inflightDiff.has(sessionID) ) generations.delete(sessionID) } @@ -418,7 +413,6 @@ export function createServerSession(client: OpencodeClient, options?: { retry?: requests.delete(sessionID) inflight.delete(sessionID) inflightDiff.delete(sessionID) - inflightTodo.delete(sessionID) messageLoads.delete(sessionID) pendingParts.delete(sessionID) orphanParts.delete(sessionID) @@ -448,7 +442,6 @@ export function createServerSession(client: OpencodeClient, options?: { retry?: ...requests.keys(), ...inflight.keys(), ...inflightDiff.keys(), - ...inflightTodo.keys(), ...messageLoads.keys(), ...optimistic.keys(), ...Object.entries(data.permission) @@ -773,11 +766,6 @@ export function createServerSession(client: OpencodeClient, options?: { retry?: setData("session_diff", props.sessionID, reconcile(cleanDiffs(props.diff), { key: "file" })) return } - case "todo.updated": { - const props = event.properties as { sessionID: string; todos: Todo[] } - setData("todo", props.sessionID, reconcile(props.todos, { key: "id" })) - return - } case "session.status": { const props = event.properties as { sessionID: string; status: SessionStatus } setData("session_status", props.sessionID, reconcile(props.status)) @@ -1140,17 +1128,6 @@ export function createServerSession(client: OpencodeClient, options?: { retry?: }) }) }, - todo(sessionID: string, options?: { force?: boolean }) { - touch(sessionID) - if (data.todo[sessionID] !== undefined && !options?.force) return Promise.resolve() - return runInflight(inflightTodo, sessionID, () => { - const active = generation(sessionID) - return retry(() => client.session.todo({ sessionID })).then((result) => { - if (generations.get(sessionID) !== active) return - setData("todo", sessionID, reconcile(result.data ?? [], { key: "id" })) - }) - }) - }, history: { more: (sessionID: string) => data.message[sessionID] !== undefined && diff --git a/packages/app/src/i18n/ar.ts b/packages/app/src/i18n/ar.ts index c60920d107..b824354401 100644 --- a/packages/app/src/i18n/ar.ts +++ b/packages/app/src/i18n/ar.ts @@ -577,9 +577,6 @@ export const dict = { "session.messages.loading": "جارٍ تحميل الرسائل...", "session.messages.jumpToLatest": "الانتقال إلى الأحدث", "session.context.addToContext": "إضافة {{selection}} إلى السياق", - "session.todo.title": "المهام", - "session.todo.collapse": "طي", - "session.todo.expand": "توسيع", "session.question.minimize": "تصغير السؤال", "session.question.restore": "استعادة السؤال", "session.question.pending.one": "{{count}} سؤال معلق", @@ -867,8 +864,6 @@ export const dict = { "settings.permissions.tool.skill.description": "تحميل مهارة بالاسم", "settings.permissions.tool.lsp.title": "LSP", "settings.permissions.tool.lsp.description": "تشغيل استعلامات خادم اللغة", - "settings.permissions.tool.todowrite.title": "كتابة المهام", - "settings.permissions.tool.todowrite.description": "تحديث قائمة المهام", "settings.permissions.tool.webfetch.title": "جلب الويب", "settings.permissions.tool.webfetch.description": "جلب محتوى من عنوان URL", "settings.permissions.tool.websearch.title": "بحث الويب", @@ -931,7 +926,6 @@ export const dict = { "session.review.noVcs.createGit.description": "تتبع ومراجعة والتراجع عن التغييرات في هذا المشروع", "session.review.noVcs.createGit.actionLoading": "جاري إنشاء مستودع Git...", "session.review.noVcs.createGit.action": "إنشاء مستودع Git", - "session.todo.progress": "تم إكمال {{done}} من {{total}} مهام", "session.question.progress": "{{current}} من {{total}} أسئلة", "session.header.open.finder": "Finder", "session.header.open.fileExplorer": "مستكشف الملفات", diff --git a/packages/app/src/i18n/br.ts b/packages/app/src/i18n/br.ts index 12911744e2..f431dbc8fc 100644 --- a/packages/app/src/i18n/br.ts +++ b/packages/app/src/i18n/br.ts @@ -582,9 +582,6 @@ export const dict = { "session.messages.loading": "Carregando mensagens...", "session.messages.jumpToLatest": "Ir para a mais recente", "session.context.addToContext": "Adicionar {{selection}} ao contexto", - "session.todo.title": "Tarefas", - "session.todo.collapse": "Recolher", - "session.todo.expand": "Expandir", "session.question.minimize": "Minimizar pergunta", "session.question.restore": "Restaurar pergunta", "session.question.pending.one": "{{count}} pergunta pendente", @@ -880,8 +877,6 @@ export const dict = { "settings.permissions.tool.skill.description": "Carregar uma habilidade por nome", "settings.permissions.tool.lsp.title": "LSP", "settings.permissions.tool.lsp.description": "Executar consultas de servidor de linguagem", - "settings.permissions.tool.todowrite.title": "Escrever Tarefas", - "settings.permissions.tool.todowrite.description": "Atualizar a lista de tarefas", "settings.permissions.tool.webfetch.title": "Buscar Web", "settings.permissions.tool.webfetch.description": "Buscar conteúdo de uma URL", "settings.permissions.tool.websearch.title": "Pesquisa Web", @@ -944,7 +939,6 @@ export const dict = { "session.review.noVcs.createGit.description": "Rastreie, revise e desfaça alterações neste projeto", "session.review.noVcs.createGit.actionLoading": "Criando repositório Git...", "session.review.noVcs.createGit.action": "Criar repositório Git", - "session.todo.progress": "{{done}} de {{total}} tarefas concluídas", "session.question.progress": "{{current}} de {{total}} perguntas", "session.header.open.finder": "Finder", "session.header.open.fileExplorer": "Explorador de Arquivos", diff --git a/packages/app/src/i18n/bs.ts b/packages/app/src/i18n/bs.ts index 086ecc1b4c..e5d48e5d19 100644 --- a/packages/app/src/i18n/bs.ts +++ b/packages/app/src/i18n/bs.ts @@ -637,9 +637,6 @@ export const dict = { "session.messages.jumpToLatest": "Idi na najnovije", "session.context.addToContext": "Dodaj {{selection}} u kontekst", - "session.todo.title": "Zadaci", - "session.todo.collapse": "Sažmi", - "session.todo.expand": "Proširi", "session.question.minimize": "Minimiziraj pitanje", "session.question.restore": "Vrati pitanje", "session.question.pending.one": "{{count}} pitanje na čekanju", @@ -954,8 +951,6 @@ export const dict = { "settings.permissions.tool.skill.description": "Učitaj vještinu po nazivu", "settings.permissions.tool.lsp.title": "LSP", "settings.permissions.tool.lsp.description": "Pokreni upite jezičnog servera", - "settings.permissions.tool.todowrite.title": "Ažuriranje liste zadataka", - "settings.permissions.tool.todowrite.description": "Ažuriraj listu zadataka", "settings.permissions.tool.webfetch.title": "Web preuzimanje", "settings.permissions.tool.webfetch.description": "Preuzmi sadržaj sa URL-a", "settings.permissions.tool.websearch.title": "Web pretraga", @@ -1020,7 +1015,6 @@ export const dict = { "session.review.noVcs.createGit.description": "Pratite, pregledajte i poništite promjene u ovom projektu", "session.review.noVcs.createGit.actionLoading": "Kreiranje Git repozitorija...", "session.review.noVcs.createGit.action": "Kreiraj Git repozitorij", - "session.todo.progress": "{{done}} od {{total}} zadataka završeno", "session.question.progress": "{{current}} od {{total}} pitanja", "session.header.open.finder": "Finder", "session.header.open.fileExplorer": "File Explorer", diff --git a/packages/app/src/i18n/da.ts b/packages/app/src/i18n/da.ts index 859bea230b..8435d9e378 100644 --- a/packages/app/src/i18n/da.ts +++ b/packages/app/src/i18n/da.ts @@ -632,9 +632,6 @@ export const dict = { "session.messages.jumpToLatest": "Gå til seneste", "session.context.addToContext": "Tilføj {{selection}} til kontekst", - "session.todo.title": "Opgaver", - "session.todo.collapse": "Skjul", - "session.todo.expand": "Udvid", "session.question.minimize": "Minimer spørgsmål", "session.question.restore": "Gendan spørgsmål", "session.question.pending.one": "{{count}} afventende spørgsmål", @@ -946,8 +943,6 @@ export const dict = { "settings.permissions.tool.skill.description": "Indlæs en færdighed efter navn", "settings.permissions.tool.lsp.title": "LSP", "settings.permissions.tool.lsp.description": "Kør sprogserverforespørgsler", - "settings.permissions.tool.todowrite.title": "Skriv To-do", - "settings.permissions.tool.todowrite.description": "Opdater to-do listen", "settings.permissions.tool.webfetch.title": "Webhentning", "settings.permissions.tool.webfetch.description": "Hent indhold fra en URL", "settings.permissions.tool.websearch.title": "Websøgning", @@ -1012,7 +1007,6 @@ export const dict = { "session.review.noVcs.createGit.description": "Spor, gennemgå og fortryd ændringer i dette projekt", "session.review.noVcs.createGit.actionLoading": "Opretter Git-repository...", "session.review.noVcs.createGit.action": "Opret Git-repository", - "session.todo.progress": "{{done}} af {{total}} opgaver fuldført", "session.question.progress": "{{current}} af {{total}} spørgsmål", "session.header.open.finder": "Finder", "session.header.open.fileExplorer": "Stifinder", diff --git a/packages/app/src/i18n/de.ts b/packages/app/src/i18n/de.ts index c96dc232a5..2895f57837 100644 --- a/packages/app/src/i18n/de.ts +++ b/packages/app/src/i18n/de.ts @@ -591,9 +591,6 @@ export const dict = { "session.messages.loading": "Lade Nachrichten...", "session.messages.jumpToLatest": "Zum neuesten springen", "session.context.addToContext": "{{selection}} zum Kontext hinzufügen", - "session.todo.title": "Aufgaben", - "session.todo.collapse": "Einklappen", - "session.todo.expand": "Ausklappen", "session.question.minimize": "Frage minimieren", "session.question.restore": "Frage wiederherstellen", "session.question.pending.one": "{{count}} ausstehende Frage", @@ -892,8 +889,6 @@ export const dict = { "settings.permissions.tool.skill.description": "Eine Fähigkeit nach Namen laden", "settings.permissions.tool.lsp.title": "LSP", "settings.permissions.tool.lsp.description": "Language-Server-Abfragen ausführen", - "settings.permissions.tool.todowrite.title": "Todo schreiben", - "settings.permissions.tool.todowrite.description": "Die Todo-Liste aktualisieren", "settings.permissions.tool.webfetch.title": "Web-Abruf", "settings.permissions.tool.webfetch.description": "Inhalt von einer URL abrufen", "settings.permissions.tool.websearch.title": "Web-Suche", @@ -957,7 +952,6 @@ export const dict = { "Änderungen in diesem Projekt verfolgen, überprüfen und rückgängig machen", "session.review.noVcs.createGit.actionLoading": "Git-Repository wird erstellt...", "session.review.noVcs.createGit.action": "Git-Repository erstellen", - "session.todo.progress": "{{done}} von {{total}} Aufgaben erledigt", "session.question.progress": "{{current}} von {{total}} Fragen", "session.header.open.finder": "Finder", "session.header.open.fileExplorer": "Datei-Explorer", diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index 3da8183bb7..559c6ba17b 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -659,10 +659,6 @@ export const dict = { "session.messages.jumpToLatest": "Jump to latest", "session.context.addToContext": "Add {{selection}} to context", - "session.todo.title": "Todos", - "session.todo.collapse": "Collapse", - "session.todo.expand": "Expand", - "session.todo.progress": "{{done}} of {{total}} todos completed", "session.question.progress": "{{current}} of {{total}} questions", "session.question.minimize": "Minimize question", "session.question.restore": "Restore question", @@ -1040,8 +1036,6 @@ export const dict = { "settings.permissions.tool.skill.description": "Load a skill by name", "settings.permissions.tool.lsp.title": "LSP", "settings.permissions.tool.lsp.description": "Run language server queries", - "settings.permissions.tool.todowrite.title": "Todo Write", - "settings.permissions.tool.todowrite.description": "Update the todo list", "settings.permissions.tool.webfetch.title": "Web Fetch", "settings.permissions.tool.webfetch.description": "Fetch content from a URL", "settings.permissions.tool.websearch.title": "Web Search", diff --git a/packages/app/src/i18n/es.ts b/packages/app/src/i18n/es.ts index 5ef7862cd6..ec6205cf99 100644 --- a/packages/app/src/i18n/es.ts +++ b/packages/app/src/i18n/es.ts @@ -638,9 +638,6 @@ export const dict = { "session.messages.jumpToLatest": "Ir al último", "session.context.addToContext": "Añadir {{selection}} al contexto", - "session.todo.title": "Tareas", - "session.todo.collapse": "Contraer", - "session.todo.expand": "Expandir", "session.question.minimize": "Minimizar pregunta", "session.question.restore": "Restaurar pregunta", "session.question.pending.one": "{{count}} pregunta pendiente", @@ -962,8 +959,6 @@ export const dict = { "settings.permissions.tool.skill.description": "Cargar una habilidad por nombre", "settings.permissions.tool.lsp.title": "LSP", "settings.permissions.tool.lsp.description": "Ejecutar consultas de servidor de lenguaje", - "settings.permissions.tool.todowrite.title": "Escribir Todo", - "settings.permissions.tool.todowrite.description": "Actualizar la lista de tareas", "settings.permissions.tool.webfetch.title": "Web Fetch", "settings.permissions.tool.webfetch.description": "Obtener contenido de una URL", "settings.permissions.tool.websearch.title": "Búsqueda Web", @@ -1028,7 +1023,6 @@ export const dict = { "session.review.noVcs.createGit.description": "Rastrea, revisa y deshaz cambios en este proyecto", "session.review.noVcs.createGit.actionLoading": "Creando repositorio Git...", "session.review.noVcs.createGit.action": "Crear repositorio Git", - "session.todo.progress": "{{done}} de {{total}} tareas completadas", "session.question.progress": "{{current}} de {{total}} preguntas", "session.header.open.finder": "Finder", "session.header.open.fileExplorer": "Explorador de archivos", diff --git a/packages/app/src/i18n/fr.ts b/packages/app/src/i18n/fr.ts index 77a543180d..c5da265344 100644 --- a/packages/app/src/i18n/fr.ts +++ b/packages/app/src/i18n/fr.ts @@ -587,9 +587,6 @@ export const dict = { "session.messages.loading": "Chargement des messages...", "session.messages.jumpToLatest": "Aller au dernier", "session.context.addToContext": "Ajouter {{selection}} au contexte", - "session.todo.title": "Tâches", - "session.todo.collapse": "Réduire", - "session.todo.expand": "Développer", "session.question.minimize": "Réduire la question", "session.question.restore": "Restaurer la question", "session.question.pending.one": "{{count}} question en attente", @@ -891,8 +888,6 @@ export const dict = { "settings.permissions.tool.skill.description": "Charger une compétence par son nom", "settings.permissions.tool.lsp.title": "LSP", "settings.permissions.tool.lsp.description": "Exécuter des requêtes de serveur de langage", - "settings.permissions.tool.todowrite.title": "Écrire Todo", - "settings.permissions.tool.todowrite.description": "Mettre à jour la liste de tâches", "settings.permissions.tool.webfetch.title": "Récupération Web", "settings.permissions.tool.webfetch.description": "Récupérer le contenu d'une URL", "settings.permissions.tool.websearch.title": "Recherche Web", @@ -955,7 +950,6 @@ export const dict = { "session.review.noVcs.createGit.description": "Suivre, examiner et annuler les modifications dans ce projet", "session.review.noVcs.createGit.actionLoading": "Création du dépôt Git...", "session.review.noVcs.createGit.action": "Créer un dépôt Git", - "session.todo.progress": "{{done}} tâches sur {{total}} terminées", "session.question.progress": "{{current}} questions sur {{total}}", "session.header.open.finder": "Finder", "session.header.open.fileExplorer": "Explorateur de fichiers", diff --git a/packages/app/src/i18n/ja.ts b/packages/app/src/i18n/ja.ts index 24d1a29e8a..435def9b19 100644 --- a/packages/app/src/i18n/ja.ts +++ b/packages/app/src/i18n/ja.ts @@ -579,9 +579,6 @@ export const dict = { "session.messages.loading": "メッセージを読み込み中...", "session.messages.jumpToLatest": "最新へジャンプ", "session.context.addToContext": "{{selection}}をコンテキストに追加", - "session.todo.title": "ToDo", - "session.todo.collapse": "折りたたむ", - "session.todo.expand": "展開", "session.question.minimize": "質問を最小化", "session.question.restore": "質問を復元", "session.question.pending.one": "{{count}}件の保留中の質問", @@ -874,8 +871,6 @@ export const dict = { "settings.permissions.tool.skill.description": "名前によるスキルの読み込み", "settings.permissions.tool.lsp.title": "LSP", "settings.permissions.tool.lsp.description": "言語サーバークエリの実行", - "settings.permissions.tool.todowrite.title": "Todo書き込み", - "settings.permissions.tool.todowrite.description": "Todoリストの更新", "settings.permissions.tool.webfetch.title": "Web取得", "settings.permissions.tool.webfetch.description": "URLからコンテンツを取得", "settings.permissions.tool.websearch.title": "Web検索", @@ -938,7 +933,6 @@ export const dict = { "session.review.noVcs.createGit.description": "このプロジェクトの変更を追跡、レビュー、元に戻す", "session.review.noVcs.createGit.actionLoading": "Git リポジトリを作成中...", "session.review.noVcs.createGit.action": "Git リポジトリを作成", - "session.todo.progress": "{{done}} 個中 {{total}} 個の Todo が完了", "session.question.progress": "{{total}} 問中 {{current}} 問", "session.header.open.finder": "Finder", "session.header.open.fileExplorer": "エクスプローラー", diff --git a/packages/app/src/i18n/ko.ts b/packages/app/src/i18n/ko.ts index cf789f5644..c6e7f142bc 100644 --- a/packages/app/src/i18n/ko.ts +++ b/packages/app/src/i18n/ko.ts @@ -471,9 +471,6 @@ export const dict = { "session.messages.loading": "메시지 로드 중...", "session.messages.jumpToLatest": "최신으로 이동", "session.context.addToContext": "컨텍스트에 {{selection}} 추가", - "session.todo.title": "할 일", - "session.todo.collapse": "접기", - "session.todo.expand": "펼치기", "session.followupDock.summary.one": "{{count}}개의 대기 중인 메시지", "session.followupDock.summary.other": "{{count}}개의 대기 중인 메시지", "session.followupDock.sendNow": "지금 전송", @@ -721,8 +718,6 @@ export const dict = { "settings.permissions.tool.skill.description": "이름으로 기술 로드", "settings.permissions.tool.lsp.title": "LSP", "settings.permissions.tool.lsp.description": "언어 서버 쿼리 실행", - "settings.permissions.tool.todowrite.title": "할 일 쓰기", - "settings.permissions.tool.todowrite.description": "할 일 목록 업데이트", "settings.permissions.tool.webfetch.title": "웹 가져오기", "settings.permissions.tool.webfetch.description": "URL에서 콘텐츠 가져오기", "settings.permissions.tool.websearch.title": "웹 검색", @@ -785,7 +780,6 @@ export const dict = { "session.review.noVcs.createGit.description": "이 프로젝트의 변경 사항을 추적, 검토 및 실행 취소", "session.review.noVcs.createGit.actionLoading": "Git 저장소 생성 중...", "session.review.noVcs.createGit.action": "Git 저장소 생성", - "session.todo.progress": "{{total}}개의 할 일 중 {{done}}개 완료", "session.question.progress": "{{total}}개의 질문 중 {{current}}개", "session.header.open.finder": "Finder", "session.header.open.fileExplorer": "파일 탐색기", diff --git a/packages/app/src/i18n/no.ts b/packages/app/src/i18n/no.ts index f3faa31382..e066ff379b 100644 --- a/packages/app/src/i18n/no.ts +++ b/packages/app/src/i18n/no.ts @@ -532,9 +532,6 @@ export const dict = { "session.messages.jumpToLatest": "Hopp til nyeste", "session.context.addToContext": "Legg til {{selection}} i kontekst", - "session.todo.title": "Oppgaver", - "session.todo.collapse": "Skjul", - "session.todo.expand": "Utvid", "session.followupDock.summary.one": "{{count}} melding i kø", "session.followupDock.summary.other": "{{count}} meldinger i kø", "session.followupDock.sendNow": "Send nå", @@ -806,8 +803,6 @@ export const dict = { "settings.permissions.tool.skill.description": "Last en ferdighet etter navn", "settings.permissions.tool.lsp.title": "LSP", "settings.permissions.tool.lsp.description": "Kjør språkserverforespørsler", - "settings.permissions.tool.todowrite.title": "Skriv gjøremål", - "settings.permissions.tool.todowrite.description": "Oppdater gjøremålslisten", "settings.permissions.tool.webfetch.title": "Webhenting", "settings.permissions.tool.webfetch.description": "Hent innhold fra en URL", "settings.permissions.tool.websearch.title": "Websøk", @@ -872,7 +867,6 @@ export const dict = { "session.review.noVcs.createGit.description": "Spor, gjennomgå og angre endringer i dette prosjektet", "session.review.noVcs.createGit.actionLoading": "Oppretter Git-depot...", "session.review.noVcs.createGit.action": "Opprett Git-depot", - "session.todo.progress": "{{done}} av {{total}} oppgaver fullført", "session.question.progress": "{{current}} av {{total}} spørsmål", "session.header.open.finder": "Finder", "session.header.open.fileExplorer": "Filutforsker", diff --git a/packages/app/src/i18n/pl.ts b/packages/app/src/i18n/pl.ts index 295d8e615a..9bc10269fa 100644 --- a/packages/app/src/i18n/pl.ts +++ b/packages/app/src/i18n/pl.ts @@ -582,9 +582,6 @@ export const dict = { "session.messages.loading": "Ładowanie wiadomości...", "session.messages.jumpToLatest": "Przejdź do najnowszych", "session.context.addToContext": "Dodaj {{selection}} do kontekstu", - "session.todo.title": "Zadania", - "session.todo.collapse": "Zwiń", - "session.todo.expand": "Rozwiń", "session.question.minimize": "Zminimalizuj pytanie", "session.question.restore": "Przywróć pytanie", "session.question.pending.one": "{{count}} oczekujące pytanie", @@ -879,8 +876,6 @@ export const dict = { "settings.permissions.tool.skill.description": "Ładowanie umiejętności według nazwy", "settings.permissions.tool.lsp.title": "LSP", "settings.permissions.tool.lsp.description": "Uruchamianie zapytań serwera językowego", - "settings.permissions.tool.todowrite.title": "Zapis Todo", - "settings.permissions.tool.todowrite.description": "Aktualizacja listy zadań", "settings.permissions.tool.webfetch.title": "Pobieranie z sieci", "settings.permissions.tool.webfetch.description": "Pobieranie zawartości z adresu URL", "settings.permissions.tool.websearch.title": "Wyszukiwanie w sieci", @@ -943,7 +938,6 @@ export const dict = { "session.review.noVcs.createGit.description": "Śledź, przeglądaj i cofaj zmiany w tym projekcie", "session.review.noVcs.createGit.actionLoading": "Tworzenie repozytorium Git...", "session.review.noVcs.createGit.action": "Utwórz repozytorium Git", - "session.todo.progress": "Ukończono {{done}} z {{total}} zadań", "session.question.progress": "{{current}} z {{total}} pytań", "session.header.open.finder": "Finder", "session.header.open.fileExplorer": "Eksplorator plików", diff --git a/packages/app/src/i18n/ru.ts b/packages/app/src/i18n/ru.ts index 7ef78bb480..466f18b1c0 100644 --- a/packages/app/src/i18n/ru.ts +++ b/packages/app/src/i18n/ru.ts @@ -635,9 +635,6 @@ export const dict = { "session.messages.jumpToLatest": "Перейти к последнему", "session.context.addToContext": "Добавить {{selection}} в контекст", - "session.todo.title": "Задачи", - "session.todo.collapse": "Свернуть", - "session.todo.expand": "Развернуть", "session.question.minimize": "Свернуть вопрос", "session.question.restore": "Восстановить вопрос", "session.question.pending.one": "{{count}} вопрос без ответа", @@ -956,8 +953,6 @@ export const dict = { "settings.permissions.tool.skill.description": "Загрузка навыка по имени", "settings.permissions.tool.lsp.title": "LSP", "settings.permissions.tool.lsp.description": "Запросы к языковому серверу", - "settings.permissions.tool.todowrite.title": "Todo Write", - "settings.permissions.tool.todowrite.description": "Обновление списка задач", "settings.permissions.tool.webfetch.title": "Web Fetch", "settings.permissions.tool.webfetch.description": "Получение контента по URL", "settings.permissions.tool.websearch.title": "Web Search", @@ -1023,7 +1018,6 @@ export const dict = { "session.review.noVcs.createGit.description": "Отслеживайте, просматривайте и отменяйте изменения в этом проекте", "session.review.noVcs.createGit.actionLoading": "Создание репозитория Git...", "session.review.noVcs.createGit.action": "Создать репозиторий Git", - "session.todo.progress": "Выполнено {{done}} из {{total}} задач", "session.question.progress": "{{current}} из {{total}} вопросов", "session.header.open.finder": "Finder", "session.header.open.fileExplorer": "Проводник", diff --git a/packages/app/src/i18n/th.ts b/packages/app/src/i18n/th.ts index 686db7407c..cd42cc46d3 100644 --- a/packages/app/src/i18n/th.ts +++ b/packages/app/src/i18n/th.ts @@ -632,9 +632,6 @@ export const dict = { "session.messages.jumpToLatest": "ไปที่ล่าสุด", "session.context.addToContext": "เพิ่ม {{selection}} ไปยังบริบท", - "session.todo.title": "สิ่งที่ต้องทำ", - "session.todo.collapse": "ย่อ", - "session.todo.expand": "ขยาย", "session.question.minimize": "ย่อคำถาม", "session.question.restore": "คืนค่าคำถาม", "session.question.pending.one": "คำถามที่รอดำเนินการ {{count}} ข้อ", @@ -942,8 +939,6 @@ export const dict = { "settings.permissions.tool.skill.description": "โหลดทักษะตามชื่อ", "settings.permissions.tool.lsp.title": "LSP", "settings.permissions.tool.lsp.description": "เรียกใช้การสืบค้นเซิร์ฟเวอร์ภาษา", - "settings.permissions.tool.todowrite.title": "เขียนรายการงาน", - "settings.permissions.tool.todowrite.description": "อัปเดตรายการงาน", "settings.permissions.tool.webfetch.title": "ดึงข้อมูลจากเว็บ", "settings.permissions.tool.webfetch.description": "ดึงเนื้อหาจาก URL", "settings.permissions.tool.websearch.title": "ค้นหาเว็บ", @@ -1008,7 +1003,6 @@ export const dict = { "session.review.noVcs.createGit.description": "ติดตาม ตรวจสอบ และเลิกทำสิ่งเปลี่ยนแปลงในโปรเจกต์นี้", "session.review.noVcs.createGit.actionLoading": "กำลังสร้าง Git รีโพซิทอรี...", "session.review.noVcs.createGit.action": "สร้าง Git รีโพซิทอรี", - "session.todo.progress": "เสร็จสิ้น {{done}} จาก {{total}} รายการ", "session.question.progress": "{{current}} จาก {{total}} คำถาม", "session.header.open.finder": "Finder", "session.header.open.fileExplorer": "File Explorer", diff --git a/packages/app/src/i18n/tr.ts b/packages/app/src/i18n/tr.ts index b578f89cca..c3cc62c942 100644 --- a/packages/app/src/i18n/tr.ts +++ b/packages/app/src/i18n/tr.ts @@ -642,9 +642,6 @@ export const dict = { "session.messages.jumpToLatest": "En sona atla", "session.context.addToContext": "{{selection}} bağlama ekle", - "session.todo.title": "Görevler", - "session.todo.collapse": "Daralt", - "session.todo.expand": "Genişlet", "session.question.minimize": "Soruyu küçült", "session.question.restore": "Soruyu geri yükle", "session.question.pending.one": "{{count}} bekleyen soru", @@ -962,8 +959,6 @@ export const dict = { "settings.permissions.tool.skill.description": "Ada göre bir beceri yükle", "settings.permissions.tool.lsp.title": "LSP", "settings.permissions.tool.lsp.description": "Dil sunucusu sorguları çalıştır", - "settings.permissions.tool.todowrite.title": "Görev Yaz", - "settings.permissions.tool.todowrite.description": "Görev listesini güncelle", "settings.permissions.tool.webfetch.title": "Web Getir", "settings.permissions.tool.webfetch.description": "Bir URL'den içerik getir", "settings.permissions.tool.websearch.title": "Web Ara", @@ -1028,7 +1023,6 @@ export const dict = { "session.review.noVcs.createGit.description": "Bu projedeki değişiklikleri takip et, incele ve geri al", "session.review.noVcs.createGit.actionLoading": "Git deposu oluşturuluyor...", "session.review.noVcs.createGit.action": "Git deposu oluştur", - "session.todo.progress": "{{total}} görevin {{done}} tanesi tamamlandı", "session.question.progress": "{{total}} sorunun {{current}} tanesi", "session.header.open.finder": "Finder", "session.header.open.fileExplorer": "Dosya Gezgini", diff --git a/packages/app/src/i18n/uk.ts b/packages/app/src/i18n/uk.ts index ff18c8e342..f4bc44eb23 100644 --- a/packages/app/src/i18n/uk.ts +++ b/packages/app/src/i18n/uk.ts @@ -663,10 +663,6 @@ export const dict = { "session.messages.jumpToLatest": "Перейти до останніх", "session.context.addToContext": "Додати {{selection}} до контексту", - "session.todo.title": "Завдання", - "session.todo.collapse": "Згорнути", - "session.todo.expand": "Розгорнути", - "session.todo.progress": "Виконано {{done}} з {{total}} завдань", "session.question.progress": "{{current}} з {{total}} запитань", "session.question.minimize": "Згорнути запитання", "session.question.restore": "Відновити запитання", @@ -1047,8 +1043,6 @@ export const dict = { "settings.permissions.tool.skill.description": "Завантаження навички за назвою", "settings.permissions.tool.lsp.title": "LSP", "settings.permissions.tool.lsp.description": "Виконання запитів мовного сервера", - "settings.permissions.tool.todowrite.title": "Todo Write", - "settings.permissions.tool.todowrite.description": "Оновлення списку завдань", "settings.permissions.tool.webfetch.title": "Web Fetch", "settings.permissions.tool.webfetch.description": "Отримання вмісту з URL", "settings.permissions.tool.websearch.title": "Web Search", diff --git a/packages/app/src/i18n/zh.ts b/packages/app/src/i18n/zh.ts index 3cccc0c82d..bd12a9dba2 100644 --- a/packages/app/src/i18n/zh.ts +++ b/packages/app/src/i18n/zh.ts @@ -630,9 +630,6 @@ export const dict = { "session.messages.loading": "正在加载消息...", "session.messages.jumpToLatest": "跳转到最新", "session.context.addToContext": "将 {{selection}} 添加到上下文", - "session.todo.title": "待办事项", - "session.todo.collapse": "折叠", - "session.todo.expand": "展开", "session.question.minimize": "最小化问题", "session.question.restore": "恢复问题", "session.question.pending.one": "{{count}} 个待处理问题", @@ -935,8 +932,6 @@ export const dict = { "settings.permissions.tool.skill.description": "按名称加载技能", "settings.permissions.tool.lsp.title": "LSP", "settings.permissions.tool.lsp.description": "运行语言服务器查询", - "settings.permissions.tool.todowrite.title": "更新待办", - "settings.permissions.tool.todowrite.description": "更新待办列表", "settings.permissions.tool.webfetch.title": "网页获取", "settings.permissions.tool.webfetch.description": "从 URL 获取内容", "settings.permissions.tool.websearch.title": "网页搜索", @@ -1001,7 +996,6 @@ export const dict = { "session.review.noVcs.createGit.description": "在此项目中跟踪、审查和撤消更改", "session.review.noVcs.createGit.actionLoading": "正在创建 Git 仓库...", "session.review.noVcs.createGit.action": "创建 Git 仓库", - "session.todo.progress": "已完成 {{done}} 个任务(共 {{total}} 个)", "session.question.progress": "{{current}}/{{total}} 个问题", "session.header.open.finder": "访达", "session.header.open.fileExplorer": "文件资源管理器", diff --git a/packages/app/src/i18n/zht.ts b/packages/app/src/i18n/zht.ts index afe285f6e5..b2a1a6f3ad 100644 --- a/packages/app/src/i18n/zht.ts +++ b/packages/app/src/i18n/zht.ts @@ -626,9 +626,6 @@ export const dict = { "session.messages.jumpToLatest": "跳到最新", "session.context.addToContext": "將 {{selection}} 新增到上下文", - "session.todo.title": "待辦事項", - "session.todo.collapse": "折疊", - "session.todo.expand": "展開", "session.question.minimize": "最小化問題", "session.question.restore": "還原問題", "session.question.pending.one": "{{count}} 個待處理問題", @@ -931,8 +928,6 @@ export const dict = { "settings.permissions.tool.skill.description": "按名稱載入技能", "settings.permissions.tool.lsp.title": "LSP", "settings.permissions.tool.lsp.description": "執行語言伺服器查詢", - "settings.permissions.tool.todowrite.title": "更新待辦", - "settings.permissions.tool.todowrite.description": "更新待辦清單", "settings.permissions.tool.webfetch.title": "Web Fetch", "settings.permissions.tool.webfetch.description": "從 URL 取得內容", "settings.permissions.tool.websearch.title": "Web Search", @@ -997,7 +992,6 @@ export const dict = { "session.review.noVcs.createGit.description": "追蹤、檢閱及復原此專案中的變更", "session.review.noVcs.createGit.actionLoading": "正在建立 Git 儲存庫...", "session.review.noVcs.createGit.action": "建立 Git 儲存庫", - "session.todo.progress": "已完成 {{done}} 個待辦事項(共 {{total}} 個)", "session.question.progress": "{{current}}/{{total}} 個問題", "session.header.open.finder": "Finder", "session.header.open.fileExplorer": "檔案總管", diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index 2d06109bdf..bbbadb7567 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -565,8 +565,6 @@ export default function Page() { }) let reviewFrame: number | undefined - let todoFrame: number | undefined - let todoTimer: number | undefined let diffFrame: number | undefined let diffTimer: number | undefined @@ -773,41 +771,6 @@ export default function Page() { const hasScrollGesture = () => Date.now() - ui.scrollGesture < scrollGestureWindowMs - createEffect( - on( - () => { - const id = params.id - return [ - sdk().directory, - id, - id ? (sync().data.session_status[id]?.type ?? "idle") : "idle", - id ? composer.blocked() : false, - ] as const - }, - ([dir, id, status, blocked]) => { - if (todoFrame !== undefined) cancelAnimationFrame(todoFrame) - if (todoTimer !== undefined) window.clearTimeout(todoTimer) - todoFrame = undefined - todoTimer = undefined - if (!id) return - if (status === "idle" && !blocked) return - const cached = untrack(() => sync().data.todo[id] !== undefined) - - todoFrame = requestAnimationFrame(() => { - todoFrame = undefined - todoTimer = window.setTimeout(() => { - todoTimer = undefined - if (sdk().directory !== dir || params.id !== id) return - untrack(() => { - void sync().session.todo(id, cached ? { force: true } : undefined) - }) - }, 0) - }) - }, - { defer: true }, - ), - ) - createEffect( on( () => visibleUserMessages().at(-1)?.id, @@ -1882,8 +1845,6 @@ export default function Page() { onCleanup(() => { if (reviewFrame !== undefined) cancelAnimationFrame(reviewFrame) - if (todoFrame !== undefined) cancelAnimationFrame(todoFrame) - if (todoTimer !== undefined) window.clearTimeout(todoTimer) if (diffFrame !== undefined) cancelAnimationFrame(diffFrame) if (diffTimer !== undefined) window.clearTimeout(diffTimer) if (scrollStateFrame !== undefined) cancelAnimationFrame(scrollStateFrame) @@ -1898,12 +1859,7 @@ export default function Page() { sessionKey, sessionID: () => params.id, prompt, - ready: () => !store.deferRender && messagesReady(), centered, - todo: { - collapsed: () => view().todoCollapsed.get(), - onToggle: () => view().todoCollapsed.set(!view().todoCollapsed.get()), - }, followup: () => params.id && !isChildSession() ? { diff --git a/packages/app/src/pages/session/composer/session-composer-region-controller.ts b/packages/app/src/pages/session/composer/session-composer-region-controller.ts index 4073f1c191..ff425b4c20 100644 --- a/packages/app/src/pages/session/composer/session-composer-region-controller.ts +++ b/packages/app/src/pages/session/composer/session-composer-region-controller.ts @@ -1,7 +1,4 @@ -import { createResizeObserver } from "@solid-primitives/resize-observer" -import { useSpring } from "@opencode-ai/ui/motion-spring" -import { type Accessor, createEffect, createMemo, createResource, onCleanup } from "solid-js" -import { createStore } from "solid-js/store" +import { type Accessor, createEffect, createMemo, createResource } from "solid-js" import type { PromptInputState } from "@/components/prompt-input" import { useSync } from "@/context/sync" import { getSessionHandoff, setSessionHandoff } from "@/pages/session/handoff" @@ -26,12 +23,7 @@ export function createSessionComposerRegionController(input: { sessionKey: Accessor sessionID: Accessor prompt: PromptInputState - ready: Accessor centered: Accessor - todo: { - collapsed: Accessor - onToggle: () => void - } followup: Accessor revert: Accessor onResponseSubmit: () => void @@ -40,41 +32,6 @@ export function createSessionComposerRegionController(input: { setDockRef: (el: HTMLDivElement) => void }) { const sync = useSync() - const [store, setStore] = createStore({ - ready: input.ready() || input.state.dock(), - height: 320, - body: undefined as HTMLDivElement | undefined, - }) - let timer: number | undefined - let frame: number | undefined - - const clear = () => { - if (timer !== undefined) window.clearTimeout(timer) - if (frame !== undefined) cancelAnimationFrame(frame) - timer = undefined - frame = undefined - } - - createEffect(() => { - input.sessionKey() - const ready = input.ready() - const dock = input.state.dock() - - clear() - if (store.ready || (!ready && !dock)) return - if (dock) { - setStore("ready", true) - return - } - - frame = requestAnimationFrame(() => { - frame = undefined - timer = window.setTimeout(() => { - setStore("ready", true) - timer = undefined - }, 140) - }) - }) createEffect(() => { if (!input.prompt.ready()) return @@ -92,27 +49,10 @@ export function createSessionComposerRegionController(input: { }) }) - createEffect(() => { - const el = store.body - if (!el) return - const update = () => setStore("height", el.getBoundingClientRect().height) - createResizeObserver(el, update) - update() - }) - - onCleanup(clear) - const parentID = createMemo(() => { const id = input.sessionID() return id ? sync().session.get(id)?.parentID : undefined }) - const open = createMemo(() => store.ready && input.state.dock() && !input.state.closing()) - const progress = useSpring( - () => (open() ? 1 : 0), - { visualDuration: 0.3, bounce: 0 }, - () => `${input.sessionKey()}\0${store.ready}`, - ) - const value = createMemo(() => Math.max(0, Math.min(1, progress()))) const ready = Promise.resolve() const [promptReady] = createResource( () => input.prompt.ready.promise ?? ready, @@ -122,7 +62,6 @@ export function createSessionComposerRegionController(input: { return { state: input.state, centered: input.centered, - todo: input.todo, followup: input.followup, revert: input.revert, onResponseSubmit: input.onResponseSubmit, @@ -134,11 +73,7 @@ export function createSessionComposerRegionController(input: { showComposer: () => !input.state.blocked() || !!parentID(), handoffPrompt: () => getSessionHandoff(input.sessionKey())?.prompt, promptReady: () => input.prompt.ready() || promptReady(), - dock: () => (store.ready && input.state.dock()) || value() > 0.001, - dockProgress: value, - dockHeight: () => Math.max(78, store.height), - lift: () => (input.revert()?.items.length ? 18 : 36 * value()), - setDockBodyRef: (el: HTMLDivElement) => setStore("body", el), + lift: () => (input.revert()?.items.length ? 18 : 0), } } diff --git a/packages/app/src/pages/session/composer/session-composer-region.tsx b/packages/app/src/pages/session/composer/session-composer-region.tsx index 600ff41e3d..feb2b0aee4 100644 --- a/packages/app/src/pages/session/composer/session-composer-region.tsx +++ b/packages/app/src/pages/session/composer/session-composer-region.tsx @@ -5,7 +5,6 @@ import { SessionPermissionDock } from "@/pages/session/composer/session-permissi import { SessionQuestionDock } from "@/pages/session/composer/session-question-dock" import { SessionFollowupDock } from "@/pages/session/composer/session-followup-dock" import { SessionRevertDock } from "@/pages/session/composer/session-revert-dock" -import { SessionTodoDock } from "@/pages/session/composer/session-todo-dock" import type { SessionComposerRegionController } from "./session-composer-region-controller" export function SessionComposerRegion(props: { @@ -60,28 +59,6 @@ export function SessionComposerRegion(props: { - -
-
- -
-
-
)} -
+
{controller.handoffPrompt() || language.t("prompt.loading")}
@@ -109,11 +83,7 @@ export function SessionComposerRegion(props: { > {(revert) => ( -
+
@@ -104,35 +103,3 @@ describe("sessionQuestionRequest", () => { expect(sessionQuestionRequest(sessions, questions, "root")?.id).toBe("q-grand") }) }) - -describe("todoState", () => { - test("hides when there are no todos", () => { - expect(todoState({ count: 0, done: false, live: true })).toBe("hide") - }) - - test("opens while the session is still working", () => { - expect(todoState({ count: 2, done: false, live: true })).toBe("open") - }) - - test("closes completed todos after a running turn", () => { - expect(todoState({ count: 2, done: true, live: true })).toBe("close") - }) - - test("clears stale todos when the turn ends", () => { - expect(todoState({ count: 2, done: false, live: false })).toBe("clear") - }) - - test("clears completed todos when the session is no longer live", () => { - expect(todoState({ count: 2, done: true, live: false })).toBe("clear") - }) -}) - -describe("todoDockAtBoundary", () => { - test("shows active todos when entering a session", () => { - expect(todoDockAtBoundary("open")).toBe(true) - }) - - test("hides completed todos when entering a session", () => { - expect(todoDockAtBoundary("close")).toBe(false) - }) -}) diff --git a/packages/app/src/pages/session/composer/session-composer-state.ts b/packages/app/src/pages/session/composer/session-composer-state.ts index 45f5e4cb26..eb282c15fc 100644 --- a/packages/app/src/pages/session/composer/session-composer-state.ts +++ b/packages/app/src/pages/session/composer/session-composer-state.ts @@ -1,35 +1,20 @@ -import { createEffect, createMemo, on, onCleanup } from "solid-js" +import { createMemo } from "solid-js" import { createStore } from "solid-js/store" -import type { PermissionRequest, QuestionRequest, Todo } from "@opencode-ai/sdk/v2" +import type { PermissionRequest, QuestionRequest } from "@opencode-ai/sdk/v2" import { useParams } from "@solidjs/router" import { showToast } from "@/utils/toast" -import { useServerSync } from "@/context/server-sync" import { useLanguage } from "@/context/language" import { usePermission } from "@/context/permission" import { useSDK } from "@/context/sdk" import { useSync } from "@/context/sync" import { sessionPermissionRequest, sessionQuestionRequest } from "./session-request-tree" -export const todoState = (input: { - count: number - done: boolean - live: boolean -}): "hide" | "clear" | "open" | "close" => { - if (input.count === 0) return "hide" - if (!input.live) return "clear" - if (!input.done) return "open" - return "close" -} - -export const todoDockAtBoundary = (state: ReturnType) => state === "open" - const idle = { type: "idle" as const } -export function createSessionComposerController(options?: { closeMs?: number | (() => number) }) { +export function createSessionComposerController() { const params = useParams() const sdk = useSDK() const sync = useSync() - const serverSync = useServerSync() const language = useLanguage() const permission = usePermission() @@ -49,24 +34,8 @@ export function createSessionComposerController(options?: { closeMs?: number | ( return !!permissionRequest() || !!questionRequest() }) - const todos = createMemo((): Todo[] => { - const id = params.id - if (!id) return [] - return serverSync().session.data.todo[id] ?? [] - }) - - const done = createMemo( - () => todos().length > 0 && todos().every((todo) => todo.status === "completed" || todo.status === "cancelled"), - ) - - const live = createMemo(() => sync().data.session_working(params.id ?? "") || blocked()) - const [store, setStore] = createStore({ - sessionID: params.id, responding: undefined as string | undefined, - dock: todos().length > 0 && !done() && live(), - closing: false, - opening: false, }) const permissionResponding = createMemo(() => { @@ -92,112 +61,12 @@ export function createSessionComposerController(options?: { closeMs?: number | ( }) } - let timer: number | undefined - let raf: number | undefined - - const closeMs = () => { - const value = options?.closeMs - if (typeof value === "function") return Math.max(0, value()) - if (typeof value === "number") return Math.max(0, value) - return 400 - } - - const scheduleClose = () => { - if (timer) window.clearTimeout(timer) - timer = window.setTimeout(() => { - setStore({ dock: false, closing: false }) - timer = undefined - }, closeMs()) - } - - // Keep stale turn todos from reopening if the model never clears them. - const clear = () => { - const id = params.id - if (!id) return - sync().set("todo", id, []) - } - - createEffect( - on( - () => [params.id, todos().length, done(), live()] as const, - ([id, count, complete, active], previous) => { - if (raf) cancelAnimationFrame(raf) - raf = undefined - - const next = todoState({ - count, - done: complete, - live: active, - }) - - if (!previous || previous[0] !== id) { - if (timer) window.clearTimeout(timer) - timer = undefined - setStore({ sessionID: id, dock: todoDockAtBoundary(next), closing: false, opening: false }) - if (next === "clear") clear() - return - } - - if (next === "hide") { - if (timer) window.clearTimeout(timer) - timer = undefined - setStore({ dock: false, closing: false, opening: false }) - return - } - - if (next === "clear") { - if (timer) window.clearTimeout(timer) - timer = undefined - clear() - return - } - - if (next === "open") { - if (timer) window.clearTimeout(timer) - timer = undefined - const hidden = !store.dock || store.closing - setStore({ dock: true, closing: false }) - if (hidden) { - setStore("opening", true) - raf = requestAnimationFrame(() => { - setStore("opening", false) - raf = undefined - }) - return - } - setStore("opening", false) - return - } - - setStore({ dock: true, opening: false, closing: true }) - if (!timer) scheduleClose() - }, - ), - ) - - onCleanup(() => { - if (!timer) return - window.clearTimeout(timer) - }) - - onCleanup(() => { - if (!raf) return - cancelAnimationFrame(raf) - }) - return { blocked, questionRequest, permissionRequest, permissionResponding, decide, - todos, - dock: () => - store.sessionID === params.id - ? store.dock - : todoDockAtBoundary(todoState({ count: todos().length, done: done(), live: live() })), - closing: () => store.sessionID === params.id && store.closing, - opening: () => store.sessionID === params.id && store.opening, } } diff --git a/packages/app/src/pages/session/composer/session-todo-dock.tsx b/packages/app/src/pages/session/composer/session-todo-dock.tsx deleted file mode 100644 index f68428fcda..0000000000 --- a/packages/app/src/pages/session/composer/session-todo-dock.tsx +++ /dev/null @@ -1,256 +0,0 @@ -import type { Todo } from "@opencode-ai/sdk/v2" -import { AnimatedNumber } from "@opencode-ai/ui/animated-number" -import { Checkbox } from "@opencode-ai/ui/checkbox" -import { DockTray } from "@opencode-ai/ui/dock-surface" -import { IconButton } from "@opencode-ai/ui/icon-button" -import { useSpring } from "@opencode-ai/ui/motion-spring" -import { TextReveal } from "@opencode-ai/ui/text-reveal" -import { TextStrikethrough } from "@opencode-ai/ui/text-strikethrough" -import { createResizeObserver } from "@solid-primitives/resize-observer" -import { Index, createEffect, createMemo } from "solid-js" -import { createStore } from "solid-js/store" -import { useLanguage } from "@/context/language" - -const doneToken = "\u0000done\u0000" -const totalToken = "\u0000total\u0000" - -function dot(status: Todo["status"]) { - if (status !== "in_progress") return undefined - return ( - - - - ) -} - -export function SessionTodoDock(props: { - todos: Todo[] - collapsed: boolean - onToggle: () => void - collapseLabel: string - expandLabel: string - dockProgress: number -}) { - const language = useLanguage() - const [store, setStore] = createStore({ - height: 78, - }) - - const total = createMemo(() => props.todos.length) - const done = createMemo(() => props.todos.filter((todo) => todo.status === "completed").length) - const label = createMemo(() => language.t("session.todo.progress", { done: done(), total: total() })) - const progress = createMemo(() => - language - .t("session.todo.progress", { done: doneToken, total: totalToken }) - .split(/(\u0000done\u0000|\u0000total\u0000)/), - ) - - const active = createMemo( - () => - props.todos.find((todo) => todo.status === "in_progress") ?? - props.todos.find((todo) => todo.status === "pending") ?? - props.todos.filter((todo) => todo.status === "completed").at(-1) ?? - props.todos[0], - ) - - const preview = createMemo(() => active()?.content ?? "") - const collapse = useSpring(() => (props.collapsed ? 1 : 0), { visualDuration: 0.3, bounce: 0 }) - const dock = createMemo(() => Math.max(0, Math.min(1, props.dockProgress))) - const shut = createMemo(() => 1 - dock()) - const value = createMemo(() => Math.max(0, Math.min(1, collapse()))) - const hide = createMemo(() => Math.max(value(), shut())) - const off = createMemo(() => hide() > 0.98) - const turn = createMemo(() => Math.max(0, Math.min(1, value()))) - const full = createMemo(() => Math.max(78, store.height)) - let contentRef: HTMLDivElement | undefined - - createEffect(() => { - const el = contentRef - if (!el) return - const update = () => { - setStore("height", (height) => Math.max(height, el.scrollHeight)) - } - update() - createResizeObserver(el, update) - }) - - return ( - -
-
{ - if (event.key !== "Enter" && event.key !== " ") return - event.preventDefault() - props.onToggle() - }} - > - - - {(item) => - item() === doneToken ? ( - - ) : item() === totalToken ? ( - - ) : ( - {item()} - ) - } - - -
- -
-
- { - event.preventDefault() - event.stopPropagation() - }} - onClick={(event) => { - event.stopPropagation() - props.onToggle() - }} - aria-label={props.collapsed ? props.expandLabel : props.collapseLabel} - /> -
-
- -
0.1, - }} - style={{ - visibility: off() ? "hidden" : "visible", - opacity: `${Math.max(0, Math.min(1, 1 - hide()))}`, - }} - > - -
-
-
- ) -} - -function TodoList(props: { todos: Todo[] }) { - const [store, setStore] = createStore({ - stuck: false, - }) - - return ( -
-
{ - setStore("stuck", e.currentTarget.scrollTop > 0) - }} - > - - {(todo) => ( - - - - )} - -
-
-
- ) -} diff --git a/packages/app/src/pages/session/composer/todo-panel-motion.stories.tsx b/packages/app/src/pages/session/composer/todo-panel-motion.stories.tsx deleted file mode 100644 index 61cfaf931b..0000000000 --- a/packages/app/src/pages/session/composer/todo-panel-motion.stories.tsx +++ /dev/null @@ -1,623 +0,0 @@ -// @ts-nocheck -import { createEffect, createMemo, onCleanup } from "solid-js" -import { createStore } from "solid-js/store" -import type { Todo } from "@opencode-ai/sdk/v2" -import { useServerSync } from "@/context/global-sync" -import { PromptInput } from "@/components/prompt-input" -import { usePrompt } from "@/context/prompt" -import { - SessionComposerRegion, - createSessionComposerController, - createSessionComposerRegionController, -} from "@/pages/session/composer" - -export default { - title: "UI/Todo Panel Motion", - id: "components-todo-panel-motion", - tags: ["autodocs"], - parameters: { - docs: { - description: { - component: `### Overview -This playground renders the real session composer region from app code. - -### Source path -- \`packages/app/src/pages/session/composer/session-composer-region.tsx\` - -### Includes -- \`SessionTodoDock\` (real) -- \`PromptInput\` (real) - -No visual reimplementation layer is used for the dock/input stack.`, - }, - }, - }, -} - -const pool = [ - "Refactor ToolStatusTitle DOM measurement to offscreen global measurer (unconstrained by timeline layout)", - "Remove inline measure nodes/CSS hooks and keep width morph behavior intact", - "Run typechecks/tests and report what changed", - "Verify reduced-motion behavior in timeline", - "Review diff for animation edge cases", - "Document rollout notes in PR description", - "Check keyboard and screen reader semantics", - "Add storybook controls for iteration speed", -] - -const btn = (accent?: boolean) => - ({ - padding: "6px 14px", - "border-radius": "6px", - border: "1px solid var(--color-divider, #333)", - background: accent ? "var(--color-accent, #58f)" : "var(--color-fill-element, #222)", - color: "var(--color-text, #eee)", - cursor: "pointer", - "font-size": "13px", - }) as const - -const controls = { - agents: { available: [], options: ["build"], current: "build", loading: false, visible: true, select: () => {} }, - model: { - selection: { - current: () => ({ id: "claude-3-7-sonnet", name: "Claude 3.7 Sonnet", provider: { id: "anthropic" } }), - variant: { list: () => [], current: () => undefined, set: () => {} }, - }, - paid: true, - loading: false, - }, - session: { - id: "story-session", - tabs: { active: () => undefined, all: () => [], open: () => {}, setActive: () => {} }, - reviewPanel: { opened: () => false, open: () => {} }, - }, - newLayoutDesigns: true, -} - -const css = ` -[data-component="todo-stage"] { - display: grid; - gap: 20px; - padding: 20px; -} - -[data-component="todo-preview"] { - height: 560px; - min-height: 0; -} - -[data-component="todo-session-root"] { - position: relative; - width: 100%; - height: 100%; - overflow: hidden; - display: flex; - flex-direction: column; - background: var(--background-base); - border: 1px solid var(--border-weak-base); - border-radius: 12px; -} - -[data-component="todo-session-frame"] { - flex: 1 1 auto; - min-height: 0; - display: flex; - flex-direction: column; -} - -[data-component="todo-session-panel"] { - position: relative; - flex: 1 1 auto; - min-height: 0; - height: 100%; - display: flex; - flex-direction: column; - background: var(--background-stronger); -} - -[data-slot="todo-preview-content"] { - flex: 1 1 auto; - min-height: 0; - overflow: hidden; -} - -[data-slot="todo-preview-scroll"] { - height: 100%; - overflow: auto; - min-height: 0; - padding: 14px 16px; - display: flex; - flex-direction: column; - gap: 10px; -} - -[data-slot="todo-preview-spacer"] { - flex: 1 1 auto; - min-height: 0; -} - -[data-slot="todo-preview-msg"] { - border-radius: 8px; - border: 1px solid var(--border-weak-base); - background: var(--surface-base); - color: var(--text-weak); - padding: 8px 10px; - font-size: 13px; - line-height: 1.35; -} - -[data-slot="todo-preview-msg"][data-strong="true"] { - color: var(--text-strong); -} -` - -export const Playground = { - render: () => { - const global = useServerSync() - const prompt = usePrompt() - const [cfg, setCfg] = createStore({ - open: true, - collapsed: false, - step: 1, - dockOpenDuration: 0.3, - dockOpenBounce: 0, - dockCloseDuration: 0.3, - dockCloseBounce: 0, - drawerExpandDuration: 0.3, - drawerExpandBounce: 0, - drawerCollapseDuration: 0.3, - drawerCollapseBounce: 0, - subtitleDuration: 600, - subtitleAuto: true, - subtitleTravel: 25, - subtitleEdge: 17, - countDuration: 600, - countMask: 18, - countMaskHeight: 0, - countWidthDuration: 560, - }) - const open = () => cfg.open - const step = () => cfg.step - const dockOpenDuration = () => cfg.dockOpenDuration - const dockOpenBounce = () => cfg.dockOpenBounce - const dockCloseDuration = () => cfg.dockCloseDuration - const dockCloseBounce = () => cfg.dockCloseBounce - const drawerExpandDuration = () => cfg.drawerExpandDuration - const drawerExpandBounce = () => cfg.drawerExpandBounce - const drawerCollapseDuration = () => cfg.drawerCollapseDuration - const drawerCollapseBounce = () => cfg.drawerCollapseBounce - const subtitleDuration = () => cfg.subtitleDuration - const subtitleAuto = () => cfg.subtitleAuto - const subtitleTravel = () => cfg.subtitleTravel - const subtitleEdge = () => cfg.subtitleEdge - const countDuration = () => cfg.countDuration - const countMask = () => cfg.countMask - const countMaskHeight = () => cfg.countMaskHeight - const countWidthDuration = () => cfg.countWidthDuration - const state = createSessionComposerController({ closeMs: () => Math.round(dockCloseDuration() * 1000) }) - let frame - let scrollRef - - const todos = createMemo(() => { - const done = Math.max(0, Math.min(3, step())) - return pool.slice(0, 3).map((content, i) => ({ - id: `todo-${i + 1}`, - content, - status: i < done ? "completed" : i === done && done < 3 ? "in_progress" : "pending", - })) - }) - - createEffect(() => { - global.todo.set("story-session", todos()) - }) - - const clear = () => { - if (frame) cancelAnimationFrame(frame) - frame = undefined - } - - const pin = () => { - if (!scrollRef) return - scrollRef.scrollTop = scrollRef.scrollHeight - } - - const collapsed = () => cfg.collapsed - const setCollapsed = (value: boolean) => setCfg("collapsed", value) - const openDock = () => { - clear() - setCfg("open", true) - frame = requestAnimationFrame(() => { - pin() - frame = undefined - }) - } - - const closeDock = () => { - clear() - setCfg("open", false) - } - - const dockOpen = () => open() - - const toggleDock = () => { - if (dockOpen()) { - closeDock() - return - } - openDock() - } - - const toggleDrawer = () => { - if (!dockOpen()) { - openDock() - frame = requestAnimationFrame(() => { - pin() - setCollapsed(true) - frame = undefined - }) - return - } - setCollapsed(!collapsed()) - } - - const cycle = () => { - setCfg("step", (value) => (value + 1) % 4) - } - - onCleanup(clear) - - return ( -
- - -
-
-
-
-
-
-
-
- Thinking Checking type safety -
-
Shell Prints five topic blocks between timed commands
-
-
- -
- "story-session", - sessionID: () => "story-session", - prompt, - ready: () => true, - centered: () => false, - todo: { collapsed, onToggle: () => setCollapsed(!collapsed()) }, - followup: () => undefined, - revert: () => undefined, - onResponseSubmit: pin, - openParent: () => {}, - setPromptRef: () => {}, - setDockRef: () => {}, - })} - promptInput={ - {}, handleSubmit: (event) => event.preventDefault() }} - ref={() => {}} - newSessionWorktree="" - onNewSessionWorktreeReset={() => {}} - /> - } - /> -
-
-
-
-
- -
- - - - {[0, 1, 2, 3].map((value) => ( - - ))} -
- -
-
Dock open
- - - -
- Dock close -
- - - -
- Drawer expand -
- - - -
- Drawer collapse -
- - - -
- Subtitle odometer -
- - - - - -
- Count odometer -
- - - - -
-
- ) - }, -} diff --git a/packages/cli/package.json b/packages/cli/package.json index e0d03fc48f..8ecaf9f9ce 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -46,10 +46,12 @@ "effect": "catalog:", "fuzzysort": "catalog:", "jsonc-parser": "3.3.1", + "open": "10.1.2", "opentui-spinner": "catalog:", "semver": "catalog:", "solid-js": "catalog:", - "strip-ansi": "7.1.2" + "strip-ansi": "7.1.2", + "uqr": "0.1.3" }, "devDependencies": { "@opencode-ai/script": "workspace:*", diff --git a/packages/cli/src/commands/commands.ts b/packages/cli/src/commands/commands.ts index a3b49b3d06..a80c5ba108 100644 --- a/packages/cli/src/commands/commands.ts +++ b/packages/cli/src/commands/commands.ts @@ -3,27 +3,13 @@ import { Spec } from "../framework/spec" declare const OPENCODE_CLI_NAME: string | undefined -const MiniParams = { - continue: Flag.boolean("continue").pipe( - Flag.withAlias("c"), - Flag.withDescription("Continue the last session"), +const ServerParams = { + standalone: Flag.boolean("standalone").pipe( + Flag.withDescription("Run with a private server instead of the background service"), Flag.withDefault(false), ), - session: Flag.string("session").pipe( - Flag.withAlias("s"), - Flag.withDescription("Session ID to continue"), - Flag.optional, - ), - fork: Flag.boolean("fork").pipe( - Flag.withDescription("Fork the session when continuing"), - Flag.withDefault(false), - ), - replay: Flag.boolean("replay").pipe( - Flag.withDescription("Replay session history on resume and after resize"), - Flag.withDefault(true), - ), - replayLimit: Flag.integer("replay-limit").pipe( - Flag.withDescription("Cap visible replay to the newest N messages"), + server: Flag.string("server").pipe( + Flag.withDescription("Connect to a server URL instead of the background service"), Flag.optional, ), } @@ -31,18 +17,11 @@ const MiniParams = { export const Commands = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME : "opencode", { description: "OpenCode 2.0 preview command line interface", params: { + ...ServerParams, directory: Argument.string("directory").pipe( Argument.withDescription("Directory to start OpenCode in"), Argument.optional, ), - standalone: Flag.boolean("standalone").pipe( - Flag.withDescription("Run with a private server instead of the background service"), - Flag.withDefault(false), - ), - server: Flag.string("server").pipe( - Flag.withDescription("Connect to a server URL instead of the background service"), - Flag.optional, - ), continue: Flag.boolean("continue").pipe( Flag.withAlias("c"), Flag.withDescription("Continue the last session"), @@ -58,6 +37,7 @@ export const Commands = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCO Spec.make("api", { description: "Make a request to the running server", params: { + ...ServerParams, request: Argument.string("operation | method path").pipe( Argument.withDescription("OpenAPI operation ID, or an HTTP method followed by a path"), Argument.variadic({ min: 1, max: 2 }), @@ -75,6 +55,17 @@ export const Commands = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCO description: "Debugging and troubleshooting tools", commands: [Spec.make("agents", { description: "List all agents" })], }), + Spec.make("console", { + description: "Manage OpenCode Console access", + commands: [ + Spec.make("login", { + description: "Log in to OpenCode Console", + params: { + url: Argument.string("url").pipe(Argument.withDescription("Console server URL"), Argument.optional), + }, + }), + ], + }), Spec.make("mcp", { description: "Manage MCP (Model Context Protocol) servers", commands: [ @@ -116,10 +107,28 @@ export const Commands = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCO Spec.make("mini", { description: "Start the minimal interactive interface", params: { - ...MiniParams, - project: Argument.string("project").pipe( - Argument.withDescription("Path to start OpenCode in"), - Argument.optional, + ...ServerParams, + continue: Flag.boolean("continue").pipe( + Flag.withAlias("c"), + Flag.withDescription("Continue the last session"), + Flag.withDefault(false), + ), + session: Flag.string("session").pipe( + Flag.withAlias("s"), + Flag.withDescription("Session ID to continue"), + Flag.optional, + ), + fork: Flag.boolean("fork").pipe( + Flag.withDescription("Fork the session when continuing"), + Flag.withDefault(false), + ), + replay: Flag.boolean("replay").pipe( + Flag.withDescription("Replay session history on resume and after resize"), + Flag.withDefault(true), + ), + replayLimit: Flag.integer("replay-limit").pipe( + Flag.withDescription("Cap visible replay to the newest N messages"), + Flag.optional, ), model: Flag.string("model").pipe( Flag.withAlias("m"), @@ -128,16 +137,13 @@ export const Commands = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCO ), agent: Flag.string("agent").pipe(Flag.withDescription("Agent to use"), Flag.optional), prompt: Flag.string("prompt").pipe(Flag.withDescription("Prompt to use"), Flag.optional), - server: Flag.string("server").pipe( - Flag.withDescription("Connect to a server URL instead of the background service"), - Flag.optional, - ), demo: Flag.boolean("demo").pipe(Flag.withDefault(false), Flag.withHidden), }, }), Spec.make("run", { description: "Run OpenCode with a message", params: { + ...ServerParams, message: Argument.string("message").pipe( Argument.withDescription("Message to send"), Argument.variadic({ min: 0 }), @@ -158,7 +164,7 @@ export const Commands = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCO ), model: Flag.string("model").pipe( Flag.withAlias("m"), - Flag.withDescription("Model to use in the format provider/model"), + Flag.withDescription("Model to use in the format provider/model#variant"), Flag.optional, ), agent: Flag.string("agent").pipe(Flag.withDescription("Agent to use"), Flag.optional), @@ -172,12 +178,6 @@ export const Commands = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCO Flag.atMost(100), ), title: Flag.string("title").pipe(Flag.withDescription("Session title"), Flag.optional), - server: Flag.string("server").pipe( - Flag.withDescription("Connect to a server URL instead of the background service"), - Flag.optional, - ), - dir: Flag.string("dir").pipe(Flag.withDescription("Directory to run in"), Flag.optional), - variant: Flag.string("variant").pipe(Flag.withDescription("Model variant"), Flag.optional), thinking: Flag.boolean("thinking").pipe( Flag.withDescription("Show thinking blocks"), Flag.withDefault(false), @@ -187,10 +187,6 @@ export const Commands = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCO Flag.withDefault(false), ), yolo: Flag.boolean("yolo").pipe(Flag.withDefault(false), Flag.withHidden), - dangerouslySkipPermissions: Flag.boolean("dangerously-skip-permissions").pipe( - Flag.withDefault(false), - Flag.withHidden, - ), }, }), Spec.make("service", { @@ -214,6 +210,7 @@ export const Commands = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCO }), ], }), + Spec.make("pair", { description: "Show server pairing information" }), Spec.make("serve", { description: "Start the v2 API server", params: { diff --git a/packages/cli/src/commands/handlers/api.ts b/packages/cli/src/commands/handlers/api.ts index de4d1a2b87..49e367d48e 100644 --- a/packages/cli/src/commands/handlers/api.ts +++ b/packages/cli/src/commands/handlers/api.ts @@ -3,7 +3,7 @@ import { Effect, Option } from "effect" import { Commands } from "../commands" import { Runtime } from "../../framework/runtime" import { Service } from "@opencode-ai/client/effect" -import { ServiceConfig } from "../../services/service-config" +import { Server } from "../../services/server" const methods = new Set(["delete", "get", "head", "options", "patch", "post", "put"]) @@ -18,12 +18,15 @@ type OpenApi = { export default Runtime.handler( Commands.commands.api, Effect.fn("cli.api")(function* (input) { - const options = yield* ServiceConfig.options() - const found = yield* Service.discover(options) - const transport = found ?? (yield* Service.start(options)) + const server = yield* Server.resolve({ + server: Option.getOrUndefined(input.server), + standalone: input.standalone, + mismatch: "ignore", + }) + const endpoint = server.endpoint const params = Option.getOrElse(input.param, () => ({})) - const request = yield* resolveRequest(transport, input.request, params) - const headers = new Headers(transport.headers) + const request = yield* resolveRequest(endpoint, input.request, params) + const headers = new Headers(Service.headers(endpoint)) for (const header of input.header) { const index = header.indexOf(":") if (index < 1) return yield* Effect.fail(new Error(`Invalid header, expected name:value: ${header}`)) @@ -33,7 +36,7 @@ export default Runtime.handler( if (body !== undefined && !headers.has("content-type")) headers.set("content-type", "application/json") const response = yield* Effect.tryPromise(() => - fetch(new URL(request.path, transport.url), { + fetch(new URL(request.path, endpoint.url), { method: request.method, headers, body, @@ -60,7 +63,7 @@ export function rawRequest(input: readonly string[]) { } function resolveRequest( - transport: Service.Transport, + endpoint: Service.Endpoint, input: readonly string[], params: Record, ) { @@ -68,7 +71,7 @@ function resolveRequest( if (raw) return Effect.succeed(raw) if (input.length !== 1) return Effect.fail(new Error("Expected an operation name or an HTTP method and path")) return Effect.tryPromise(async () => { - const response = await fetch(new URL("/openapi.json", transport.url), { headers: transport.headers }) + const response = await fetch(new URL("/openapi.json", endpoint.url), { headers: Service.headers(endpoint) }) if (!response.ok) throw new Error(`Failed to load OpenAPI document: HTTP ${response.status}`) return resolveOperation((await response.json()) as OpenApi, input[0], params) }) diff --git a/packages/cli/src/commands/handlers/console/login.ts b/packages/cli/src/commands/handlers/console/login.ts new file mode 100644 index 0000000000..973fe1a326 --- /dev/null +++ b/packages/cli/src/commands/handlers/console/login.ts @@ -0,0 +1,117 @@ +import { Cause, Effect, Exit, Option } from "effect" +import { Service } from "@opencode-ai/client/effect" +import { OpenCode, type OpenCodeClient } from "@opencode-ai/client/promise" +import { AppProcess } from "@opencode-ai/core/process" +import { Commands } from "../../commands" +import { Runtime } from "../../../framework/runtime" +import { ServiceConfig } from "../../../services/service-config" +import { createTimelineHost, type TimelineHost } from "../../../ui/timeline" + +const integrationID = "opencode" +const location = { directory: process.cwd() } + +export default Runtime.handler( + Commands.commands.console.commands.login, + Effect.fn("cli.console.login")(function* (input) { + const timeline = yield* Effect.acquireRelease( + Effect.promise(() => createTimelineHost()), + (value) => request(() => value.close()).pipe(Effect.ignore), + ) + const exit = yield* login(timeline, Option.getOrUndefined(input.url)).pipe( + Effect.raceFirst(AppProcess.waitForAbort(timeline.signal)), + Effect.exit, + ) + if (Exit.isSuccess(exit)) return + + const cancelled = timeline.signal.aborted + yield* request(() => timeline.failure(cancelled ? "Authorization cancelled" : errorMessage(exit.cause))).pipe( + Effect.ignore, + ) + process.exitCode = cancelled ? 130 : 1 + }), +) + +const login = Effect.fn("cli.console.login.run")(function* (timeline: TimelineHost, server?: string) { + yield* request(() => timeline.intro("Log in")) + yield* request(() => timeline.pending("Connecting to OpenCode...")) + + const endpoint = yield* Service.start(yield* ServiceConfig.options()) + const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) }) + const found = yield* request((signal) => client.integration.get({ integrationID, location }, { signal })) + const integration = yield* required(found.data, "OpenCode Console integration is unavailable") + const method = yield* required( + integration.methods.find((candidate) => candidate.type === "oauth"), + "OpenCode Console login is unavailable", + ) + + yield* request(() => timeline.pending("Starting authorization...")) + const started = yield* request((signal) => + client.integration.connect.oauth( + { + integrationID, + methodID: method.id, + inputs: server ? { server } : {}, + location, + }, + { signal }, + ), + ) + const attempt = started.data + yield* Effect.addFinalizer(() => + request(() => + client.integration.attempt.cancel( + { attemptID: attempt.attemptID, location }, + { signal: AbortSignal.timeout(5_000) }, + ), + ).pipe(Effect.ignore), + ) + if (attempt.mode !== "auto") yield* Effect.fail(new Error("OpenCode Console requires a device login")) + + yield* request(() => timeline.item(`Go to: ${attempt.url}`)) + yield* request(() => timeline.item(attempt.instructions)) + yield* request(async () => { + const { default: open } = await import("open") + await open(attempt.url) + }).pipe(Effect.ignore) + yield* request(() => timeline.pending("Waiting for authorization...")) + + const status = yield* waitForConsoleLogin(client, attempt.attemptID) + if (status.status === "failed") yield* Effect.fail(new Error(status.message)) + if (status.status === "expired") yield* Effect.fail(new Error("Device code expired")) + + yield* request(() => timeline.success("Connected to OpenCode Console")) + yield* request(() => timeline.outro("Done")) +}) + +const waitForConsoleLogin = Effect.fn("cli.console.login.wait")(function* ( + client: OpenCodeClient, + attemptID: string, +) { + while (true) { + const response = yield* request((signal) => + client.integration.attempt.status({ attemptID, location }, { signal }), + ) + if (response.data.status !== "pending") return response.data + yield* Effect.sleep(500) + } +}) + +function request(task: (signal: AbortSignal) => Promise) { + return Effect.tryPromise({ + try: task, + catch: (cause) => cause, + }) +} + +function required(value: A | null | undefined, message: string) { + return value === null || value === undefined ? Effect.fail(new Error(message)) : Effect.succeed(value) +} + +function errorMessage(cause: Cause.Cause) { + const error = Cause.squash(cause) + if (error instanceof Error) return error.message + if (typeof error === "object" && error !== null && "message" in error && typeof error.message === "string") { + return error.message + } + return String(error) +} diff --git a/packages/cli/src/commands/handlers/debug/agents.ts b/packages/cli/src/commands/handlers/debug/agents.ts index 1dbaf4d801..ec7925fc44 100644 --- a/packages/cli/src/commands/handlers/debug/agents.ts +++ b/packages/cli/src/commands/handlers/debug/agents.ts @@ -11,8 +11,8 @@ export default Runtime.handler( Effect.fn("cli.debug.agents")(function* () { const options = yield* ServiceConfig.options() const found = yield* Service.discover(options) - const transport = found ?? (yield* Service.start(options)) - const client = createOpencodeClient({ baseUrl: transport.url, headers: transport.headers }) + const endpoint = found ?? (yield* Service.start(options)) + const client = createOpencodeClient({ baseUrl: endpoint.url, headers: Service.headers(endpoint) }) const response = yield* Effect.promise(() => client.v2.agent.list({ location: { directory: process.cwd() } })) process.stdout.write( JSON.stringify( diff --git a/packages/cli/src/commands/handlers/default.ts b/packages/cli/src/commands/handlers/default.ts index 62c3ace63f..78e50369f7 100644 --- a/packages/cli/src/commands/handlers/default.ts +++ b/packages/cli/src/commands/handlers/default.ts @@ -1,89 +1,50 @@ -import { NodeFileSystem } from "@effect/platform-node" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { Global } from "@opencode-ai/core/global" +import { run } from "@opencode-ai/tui" +import { loadBuiltinPlugins } from "@opencode-ai/tui/builtins" +import { TuiConfig } from "@opencode-ai/tui/config" import { Commands } from "../commands" import { Runtime } from "../../framework/runtime" -import { Effect, Option, Redacted } from "effect" -import { Service } from "@opencode-ai/client/effect" -import { Env } from "../../env" -import { ServiceConfig } from "../../services/service-config" -import { Standalone } from "../../services/standalone" +import { Effect, Option } from "effect" +import { Server } from "../../services/server" import { Updater } from "../../services/updater" export default Runtime.handler(Commands, (input) => Effect.gen(function* () { - const directory = Option.getOrUndefined(input.directory) - if (directory !== undefined) process.chdir(directory) + const requestedDirectory = Option.getOrUndefined(input.directory) + if (requestedDirectory !== undefined) process.chdir(requestedDirectory) const updater = yield* Updater.Service yield* updater.check().pipe(Effect.forkScoped) - const server = Option.getOrUndefined(input.server) - if (server !== undefined && input.standalone) - return yield* Effect.fail(new Error("--server and --standalone cannot be combined")) - const transport = yield* Effect.gen(function* () { - if (server !== undefined) { - const password = yield* Env.password - const explicit = { - url: server, - headers: password - ? { authorization: "Basic " + btoa("opencode:" + Redacted.value(password)) } - : undefined, - } satisfies Service.Transport - // Fail loudly before entering the TUI: an explicit server that is - // unreachable or rejects auth should not present as reconnect churn. - const response = yield* Effect.tryPromise(() => - fetch(new URL("/api/health", server), { headers: explicit.headers, signal: AbortSignal.timeout(5_000) }), - ).pipe(Effect.mapError((cause) => new Error(`Could not reach server at ${server}`, { cause }))) - if (response.status === 401) - return yield* Effect.fail( - new Error( - password - ? `Server at ${server} rejected the password` - : `Server at ${server} requires a password; set OPENCODE_PASSWORD`, - ), - ) - if (!response.ok) - return yield* Effect.fail(new Error(`Server at ${server} responded with status ${response.status}`)) - return explicit - } - if (input.standalone) return yield* Standalone.transport() - const options = yield* ServiceConfig.options() - const found = yield* Service.discover(options) - return found ?? (yield* Service.start(options)) + const server = yield* Server.resolve({ + server: Option.getOrUndefined(input.server), + standalone: input.standalone, }) - const { runTui } = yield* Effect.promise(() => import("../../tui")) - // The TUI re-runs discover whenever its event stream drops. For an explicit - // --server or a standalone child the transport is fixed, so reconnects - // retry the same address; for the managed service discovery re-reads the - // registration and may start a replacement. - const serviceOptions = server === undefined && !input.standalone ? yield* ServiceConfig.options() : undefined - // Only startup enforces the CLI version. A reconnect must accept a server - // replaced by another client or the two clients will restart it forever. - const reconnectOptions = serviceOptions ? { ...serviceOptions, version: undefined } : undefined - const discover = reconnectOptions - ? () => - Effect.runPromise( - Effect.gen(function* () { - const found = yield* Service.discover(reconnectOptions) - return found ?? (yield* Service.start(reconnectOptions)) - }).pipe(Effect.provide(NodeFileSystem.layer)), - ) - : () => Promise.resolve(transport) - // Restart the managed service in place; start() resolves once the - // replacement is healthy and the reconnect loop reattaches on its own. - // Only meaningful in service mode: --server is not ours to restart and a - // standalone child cannot be respawned. - const reload = serviceOptions - ? () => - Effect.runPromise( - Effect.gen(function* () { - yield* Service.stop(serviceOptions) - yield* Service.start(serviceOptions) - }).pipe(Effect.provide(NodeFileSystem.layer)), - ) - : undefined - yield* runTui( - transport, - { continue: input.continue, sessionID: Option.getOrUndefined(input.session) }, - discover, - reload, - ) + const config = TuiConfig.resolve({}, { terminalSuspend: false }) + let disposeSlots: (() => void) | undefined + const runFork = Effect.runForkWith(yield* Effect.context()) + yield* run({ + server, + args: { continue: input.continue, sessionID: Option.getOrUndefined(input.session) }, + config, + log: (level, message, tags) => { + const effect = + level === "debug" + ? Effect.logDebug(message, tags) + : level === "warn" + ? Effect.logWarning(message, tags) + : level === "error" + ? Effect.logError(message, tags) + : Effect.logInfo(message, tags) + runFork(effect) + }, + pluginHost: { + async start(pluginInput) { + disposeSlots = await loadBuiltinPlugins(pluginInput.api, pluginInput.runtime) + }, + async dispose() { + disposeSlots?.() + }, + }, + }).pipe(Effect.provide(AppNodeBuilder.build(Global.node))) }), ) diff --git a/packages/cli/src/commands/handlers/mcp/auth.ts b/packages/cli/src/commands/handlers/mcp/auth.ts index 967f24e28f..03a5e687fd 100644 --- a/packages/cli/src/commands/handlers/mcp/auth.ts +++ b/packages/cli/src/commands/handlers/mcp/auth.ts @@ -19,8 +19,8 @@ export default Runtime.handler( Effect.fn("cli.mcp.auth")(function* (input) { const options = yield* ServiceConfig.options() const found = yield* Service.discover(options) - const transport = found ?? (yield* Service.start(options)) - const client = createOpencodeClient({ baseUrl: transport.url, headers: transport.headers }) + const endpoint = found ?? (yield* Service.start(options)) + const client = createOpencodeClient({ baseUrl: endpoint.url, headers: Service.headers(endpoint) }) const integration = yield* resolveIntegration(client, input.name, location) if (!integration) diff --git a/packages/cli/src/commands/handlers/mcp/list.ts b/packages/cli/src/commands/handlers/mcp/list.ts index 2d38a6aca0..3c44a839e3 100644 --- a/packages/cli/src/commands/handlers/mcp/list.ts +++ b/packages/cli/src/commands/handlers/mcp/list.ts @@ -11,8 +11,8 @@ export default Runtime.handler( Effect.fn("cli.mcp.list")(function* () { const options = yield* ServiceConfig.options() const found = yield* Service.discover(options) - const transport = found ?? (yield* Service.start(options)) - const client = createOpencodeClient({ baseUrl: transport.url, headers: transport.headers }) + const endpoint = found ?? (yield* Service.start(options)) + const client = createOpencodeClient({ baseUrl: endpoint.url, headers: Service.headers(endpoint) }) const response = yield* Effect.promise(() => client.v2.mcp.list({ location: { directory: process.cwd() } })) const servers = (response.data?.data ?? []).toSorted((a, b) => a.name.localeCompare(b.name)) if (servers.length === 0) { diff --git a/packages/cli/src/commands/handlers/mcp/logout.ts b/packages/cli/src/commands/handlers/mcp/logout.ts index 94a3114cfa..271953c306 100644 --- a/packages/cli/src/commands/handlers/mcp/logout.ts +++ b/packages/cli/src/commands/handlers/mcp/logout.ts @@ -14,8 +14,8 @@ export default Runtime.handler( Effect.fn("cli.mcp.logout")(function* (input) { const options = yield* ServiceConfig.options() const found = yield* Service.discover(options) - const transport = found ?? (yield* Service.start(options)) - const client = createOpencodeClient({ baseUrl: transport.url, headers: transport.headers }) + const endpoint = found ?? (yield* Service.start(options)) + const client = createOpencodeClient({ baseUrl: endpoint.url, headers: Service.headers(endpoint) }) const integration = yield* resolveIntegration(client, input.name, location) if (!integration) { diff --git a/packages/cli/src/commands/handlers/mini.ts b/packages/cli/src/commands/handlers/mini.ts index 410757967f..7919b2bc8e 100644 --- a/packages/cli/src/commands/handlers/mini.ts +++ b/packages/cli/src/commands/handlers/mini.ts @@ -1,25 +1,17 @@ -import { Effect, Option, Redacted } from "effect" -import path from "node:path" +import { Effect, Option } from "effect" import { Commands } from "../commands" -import { Env } from "../../env" import { Runtime } from "../../framework/runtime" +import { Server } from "../../services/server" export default Runtime.handler(Commands.commands.mini, (input) => Effect.gen(function* () { - const { runMini } = yield* Effect.promise(() => import("../../mini")) - const project = Option.getOrUndefined(input.project) - const server = Option.getOrUndefined(input.server) - const password = yield* Env.password + const { runMini, validateMiniTerminal } = yield* Effect.promise(() => import("../../mini")) + yield* Effect.promise(async () => validateMiniTerminal()) + const serverURL = Option.getOrUndefined(input.server) + const server = yield* Server.resolve({ server: serverURL, standalone: input.standalone }) yield* Effect.promise(() => runMini({ - attach: server, - password: password ? Redacted.value(password) : undefined, - directory: - server !== undefined - ? project - : project === undefined - ? process.cwd() - : path.resolve(process.env.PWD ?? process.cwd(), project), + server, continue: input.continue, session: Option.getOrUndefined(input.session), fork: input.fork, diff --git a/packages/cli/src/commands/handlers/pair.ts b/packages/cli/src/commands/handlers/pair.ts new file mode 100644 index 0000000000..5c40a20444 --- /dev/null +++ b/packages/cli/src/commands/handlers/pair.ts @@ -0,0 +1,43 @@ +import { EOL } from "os" +import { Effect } from "effect" +import { Service } from "@opencode-ai/client/effect" +import { OpenCode } from "@opencode-ai/client/promise" +import { renderUnicodeCompact } from "uqr" +import { Commands } from "../commands" +import { Runtime } from "../../framework/runtime" +import { ServiceConfig } from "../../services/service-config" + +export default Runtime.handler( + Commands.commands.pair, + Effect.fn("cli.pair")(function* () { + const endpoint = yield* Service.start(yield* ServiceConfig.options()) + const password = yield* ServiceConfig.password() + const server = yield* Effect.tryPromise(() => + OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) }).server.get(), + ) + const info = { urls: server.urls, username: "opencode", password } + process.stdout.write( + [ + "", + ` URLs ${info.urls[0] ?? "(none)"}`, + ...info.urls.slice(1).map((url) => ` ${url}`), + ` Username ${info.username}`, + ` Password ${info.password}`, + "", + " Scan to pair", + "", + renderUnicodeCompact(JSON.stringify(info), { border: 2 }) + .split(EOL) + .map((line) => " " + line) + .join(EOL), + "", + ].join(EOL) + EOL, + ) + + const hostname = new URL(endpoint.url).hostname + if (!["localhost", "127.0.0.1", "[::1]"].includes(hostname)) return + process.stderr.write( + ` Run \`opencode service set hostname 0.0.0.0\` to access the service remotely.${EOL}${EOL}`, + ) + }), +) diff --git a/packages/cli/src/commands/handlers/run.ts b/packages/cli/src/commands/handlers/run.ts index aa09a32ea9..e8abf0cb1e 100644 --- a/packages/cli/src/commands/handlers/run.ts +++ b/packages/cli/src/commands/handlers/run.ts @@ -1,15 +1,19 @@ -import { Effect, Option, Redacted } from "effect" +import { Effect, Option } from "effect" import { Commands } from "../commands" -import { Env } from "../../env" import { Runtime } from "../../framework/runtime" +import { Server } from "../../services/server" export default Runtime.handler(Commands.commands.run, (input) => Effect.gen(function* () { const { runNonInteractive } = yield* Effect.promise(() => import("../../mini")) - const password = yield* Env.password const separator = process.argv.indexOf("--", 2) + const server = yield* Server.resolve({ + server: Option.getOrUndefined(input.server), + standalone: input.standalone, + }) yield* Effect.promise(() => runNonInteractive({ + server, message: [...input.message, ...(separator === -1 ? [] : process.argv.slice(separator + 1))], continue: input.continue, session: Option.getOrUndefined(input.session), @@ -19,12 +23,8 @@ export default Runtime.handler(Commands.commands.run, (input) => format: input.format, file: [...input.file], title: Option.getOrUndefined(input.title), - server: Option.getOrUndefined(input.server), - password: password ? Redacted.value(password) : undefined, - directory: Option.getOrUndefined(input.dir), - variant: Option.getOrUndefined(input.variant), thinking: input.thinking, - dangerouslySkipPermissions: input.auto || input.yolo || input.dangerouslySkipPermissions, + auto: input.auto || input.yolo, }), ) }), diff --git a/packages/cli/src/daemon.ts b/packages/cli/src/daemon.ts deleted file mode 100644 index 1e30a35941..0000000000 --- a/packages/cli/src/daemon.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { Service } from "@opencode-ai/client/effect" -import { ClientError, isUnauthorizedError, OpenCode } from "@opencode-ai/client/promise" -import { InstallationVersion } from "@opencode-ai/core/installation/version" -import { ServerAuth } from "@opencode-ai/server/auth" -import { Effect } from "effect" -import { ServiceConfig } from "./services/service-config" - -export type SharedOptions = { - readonly mode: "shared" - readonly command?: ReadonlyArray -} -export type AttachOptions = { - readonly mode: "attach" - readonly url: string - readonly username?: string - readonly password?: string -} -export type Options = SharedOptions | AttachOptions - -const attach = Effect.fn("cli.daemon.attach")(function* (options: AttachOptions) { - const transport = { - url: options.url, - headers: - options.password === undefined - ? undefined - : ServerAuth.headers({ password: options.password, username: options.username }), - } satisfies Service.Transport - const client = OpenCode.make({ baseUrl: transport.url, headers: transport.headers }) - const health = yield* Effect.tryPromise({ - try: () => client.health.get({ signal: AbortSignal.timeout(5_000) }), - catch: (cause) => attachError(options, cause), - }) - if (health.version !== InstallationVersion) - process.stderr.write( - `Warning: Server at ${options.url} has version ${health.version}; this client is ${InstallationVersion}. Continuing anyway.\n`, - ) - return transport -}) - -const shared = Effect.fn("cli.daemon.shared")(function* (options: SharedOptions) { - const config = yield* ServiceConfig.options() - const service = options.command === undefined ? config : { ...config, command: options.command } - const found = yield* Service.discover(service) - if (found) return found - return yield* Service.start(service) -}) - -export function transport(options: AttachOptions): ReturnType -export function transport(options: SharedOptions): ReturnType -export function transport(options: Options): ReturnType | ReturnType -export function transport(options: Options) { - if (options.mode === "attach") return attach(options) - return shared(options) -} - -function attachError(options: AttachOptions, cause: unknown) { - if (isUnauthorizedError(cause)) { - return new Error( - options.password === undefined - ? `Server at ${options.url} requires authentication; provide a password` - : `Server at ${options.url} rejected the supplied credentials`, - { cause }, - ) - } - if (cause instanceof ClientError && cause.reason === "Transport") - return new Error(`Could not reach server at ${options.url}`, { cause }) - return new Error(`Server at ${options.url} did not provide a compatible V2 health response`, { cause }) -} - -export * as Daemon from "./daemon" diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 5b851d7f49..e358b181c4 100755 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -24,6 +24,9 @@ const Handlers = Runtime.handlers(Commands, { debug: { agents: () => import("./commands/handlers/debug/agents"), }, + console: { + login: () => import("./commands/handlers/console/login"), + }, mcp: { list: () => import("./commands/handlers/mcp/list"), add: () => import("./commands/handlers/mcp/add"), @@ -33,6 +36,7 @@ const Handlers = Runtime.handlers(Commands, { migrate: () => import("./commands/handlers/migrate"), mini: () => import("./commands/handlers/mini"), run: () => import("./commands/handlers/run"), + pair: () => import("./commands/handlers/pair"), service: { start: () => import("./commands/handlers/service/start"), restart: () => import("./commands/handlers/service/restart"), diff --git a/packages/cli/src/mini/demo.ts b/packages/cli/src/mini/demo.ts index ed0b6878f5..93790ce653 100644 --- a/packages/cli/src/mini/demo.ts +++ b/packages/cli/src/mini/demo.ts @@ -10,7 +10,7 @@ // /permission [kind] → triggers a permission request variant // /question [kind] → triggers a question request variant // /fmt → emits a specific tool/text type (text, reasoning, bash, -// write, edit, patch, task, todo, question, error, mix) +// write, edit, patch, task, question, error, mix) // // Demo mode also handles permission and question replies locally, completing // or failing the synthetic tool parts as appropriate. @@ -30,7 +30,6 @@ const KINDS = [ "edit", "patch", "task", - "todo", "question", "error", "mix", @@ -733,30 +732,6 @@ function emitTask(state: State): void { }) } -function emitTodo(state: State): void { - const ref = make(state, "todowrite", { - todos: [ - { - content: "Trigger permission UI", - status: "completed", - }, - { - content: "Trigger question UI", - status: "in_progress", - }, - { - content: "Tune tool formatting", - status: "pending", - }, - ], - }) - doneTool(state, ref, { - title: "todowrite", - output: "", - metadata: {}, - }) -} - function emitQuestionTool(state: State): void { const ref = make(state, "question", { questions: [ @@ -952,7 +927,6 @@ function emitQuestion(state: State, kind: QuestionKind = "multi"): void { options: [ { label: "Diff", description: "Show an edit diff in the footer" }, { label: "Task", description: "Show a structured task summary" }, - { label: "Todo", description: "Show a todo snapshot" }, { label: "Error", description: "Show an error transcript row" }, ], multiple: true, @@ -992,7 +966,6 @@ function emitQuestion(state: State, kind: QuestionKind = "multi"): void { options: [ { label: "Diff", description: "Emit edit diff" }, { label: "Task", description: "Emit task card" }, - { label: "Todo", description: "Emit todo card" }, ], multiple: true, custom: true, @@ -1066,11 +1039,6 @@ async function emitFmt(state: State, kind: string, body: string, signal?: AbortS return true } - if (kind === "todo") { - emitTodo(state) - return true - } - if (kind === "question") { emitQuestionTool(state) return true @@ -1091,7 +1059,6 @@ async function emitFmt(state: State, kind: string, body: string, signal?: AbortS emitEdit(state) emitPatch(state) emitTask(state) - emitTodo(state) emitQuestionTool(state) emitError(state, "demo mixed scenario error") return true diff --git a/packages/cli/src/mini/index.ts b/packages/cli/src/mini/index.ts index 62decb77a5..4a6ef986fa 100644 --- a/packages/cli/src/mini/index.ts +++ b/packages/cli/src/mini/index.ts @@ -1,7 +1,8 @@ -export { runMini, mergeInput as mergeInteractiveInput, type MiniCommandInput } from "./mini" +export { runMini, validateMiniTerminal, mergeInput as mergeInteractiveInput, type MiniCommandInput } from "./mini" export { runNonInteractive, mergeInput as mergeNonInteractiveInput, pickRunModel, + parseRunModel, type RunCommandInput, } from "./run" diff --git a/packages/cli/src/mini/mini.ts b/packages/cli/src/mini/mini.ts index 8a47529816..521995b45c 100644 --- a/packages/cli/src/mini/mini.ts +++ b/packages/cli/src/mini/mini.ts @@ -1,18 +1,12 @@ -import { NodeFileSystem } from "@effect/platform-node" +import { Service } from "@opencode-ai/client/effect" import { OpenCode, type OpenCodeClient } from "@opencode-ai/client/promise" -import { Global } from "@opencode-ai/core/global" -import { Effect } from "effect" -import path from "node:path" -import { Daemon } from "../daemon" +import { Server } from "../services/server" import { waitForCatalogReady } from "./catalog.shared" import { INTERACTIVE_INPUT_ERROR, resolveInteractiveStdin } from "./runtime.stdin" import type { RunInput, RunTuiConfig } from "./types" export type MiniCommandInput = { - directory?: string - attach?: string - password?: string - username?: string + server: Server.Resolved continue?: boolean session?: string fork?: boolean @@ -22,58 +16,46 @@ export type MiniCommandInput = { replay?: boolean replayLimit?: number demo?: boolean - serverCommand?: ReadonlyArray tuiConfig?: RunTuiConfig | Promise } type Session = Awaited> -type Transport = { readonly url: string; readonly headers?: HeadersInit } - export async function runMini(input: MiniCommandInput) { validate(input) const initialInput = mergeInput(process.stdin.isTTY ? undefined : await Bun.stdin.text(), input.prompt) const runtimeTask = import("./runtime") - const directory = input.attach ? input.directory : localDirectory(input.directory) - const transportTask = startTransport(input) - void transportTask.catch(() => {}) + const directory = localDirectory() try { - if (input.attach) await transportTask const sdk = OpenCode.make({ - baseUrl: "http://opencode.pending", - fetch: deferredFetch(transportTask), + baseUrl: input.server.endpoint.url, + headers: Service.headers(input.server.endpoint), }) - const attachedSession = - input.attach && input.session && !input.directory - ? await sdk.session.get({ sessionID: input.session }).catch(() => fail("Session not found")) - : undefined - const resolvedDirectory = - directory ?? attachedSession?.location.directory ?? (await remoteDirectory(await transportTask, sdk)) const model = parseModel(input.model) let agentTask: Promise | undefined const resolveAgent = () => { - agentTask ??= validateAgent(sdk, resolvedDirectory, input.agent, input.attach) + agentTask ??= validateAgent(sdk, directory, input.agent) return agentTask } const resolveSession = async () => { const [agent, selected] = await Promise.all([ resolveAgent(), - selectSession(sdk, resolvedDirectory, input, attachedSession), + selectSession(sdk, directory, input), ]) const readyModel = model ?? (selected?.model ? { providerID: selected.model.providerID, modelID: selected.model.id } : undefined) - if (readyModel) await waitForCatalogReady({ sdk, directory: resolvedDirectory, model: readyModel }) - const session = selected ?? (await createSession(sdk, resolvedDirectory, agent, model)) + if (readyModel) await waitForCatalogReady({ sdk, directory, model: readyModel }) + const session = selected ?? (await createSession(sdk, directory, agent, model)) return { id: session.id, title: session.title, resume: selected !== undefined } } const create = ( _sdk: OpenCodeClient, next: { agent: string | undefined; model: RunInput["model"]; variant: string | undefined }, - ) => createSession(sdk, resolvedDirectory, next.agent, next.model, next.variant) + ) => createSession(sdk, directory, next.agent, next.model, next.variant) const runtime = await runtimeTask await runtime.runInteractiveDeferredMode({ sdk, - directory: resolvedDirectory, + directory, resolveAgent, session: resolveSession, createSession: create, @@ -94,6 +76,10 @@ export async function runMini(input: MiniCommandInput) { } } +export function validateMiniTerminal() { + if (!process.stdout.isTTY) fail("opencode mini requires a TTY stdout") +} + /** @internal Exported for testing. */ export function mergeInput(piped: string | undefined, prompt: string | undefined) { if (!prompt) return piped || undefined @@ -102,7 +88,7 @@ export function mergeInput(piped: string | undefined, prompt: string | undefined } function validate(input: MiniCommandInput) { - if (!process.stdout.isTTY) fail("opencode mini requires a TTY stdout") + validateMiniTerminal() if (input.replayLimit !== undefined && (!Number.isInteger(input.replayLimit) || input.replayLimit <= 0)) { fail("--replay-limit must be a positive integer") } @@ -110,56 +96,16 @@ function validate(input: MiniCommandInput) { resolveInteractiveStdin().cleanup?.() } -function localDirectory(directory?: string): string { +function localDirectory(): string { const root = process.env.PWD ?? process.cwd() try { - process.chdir(directory ? (path.isAbsolute(directory) ? directory : path.join(root, directory)) : root) + process.chdir(root) return process.cwd() } catch { - fail(`Failed to change directory to ${directory}`) + fail(`Failed to change directory to ${root}`) } } -function startTransport(input: MiniCommandInput): Promise { - if (input.attach) { - return Effect.runPromise( - Daemon.transport({ - mode: "attach", - url: input.attach, - password: input.password ?? process.env.OPENCODE_SERVER_PASSWORD, - username: input.username ?? process.env.OPENCODE_SERVER_USERNAME, - }), - ) - } - return Effect.runPromise( - Daemon.transport({ mode: "shared", command: input.serverCommand }).pipe( - Effect.provide(NodeFileSystem.layer), - Effect.provide(Global.layerWith({})), - ), - ) -} - -function deferredFetch(transportTask: Promise<{ url: string; headers?: HeadersInit }>): typeof globalThis.fetch { - const fetch = async (input: RequestInfo | URL, init?: RequestInit) => { - const transport = await transportTask - const request = new Request(input, init) - const source = new URL(request.url) - const headers = new Headers(request.headers) - for (const [key, value] of new Headers(transport.headers)) headers.set(key, value) - return globalThis.fetch(new Request(new URL(source.pathname + source.search, transport.url), request), { headers }) - } - return fetch as typeof globalThis.fetch -} - -async function remoteDirectory( - transport: { url: string; headers?: HeadersInit }, - sdk: OpenCodeClient, -): Promise { - const location = await sdk.location.get() - if (!location.directory) throw new Error(`Failed to resolve remote directory from ${transport.url}`) - return location.directory -} - function parseModel(value?: string): RunInput["model"] { if (!value) return const [providerID, ...rest] = value.split("/") @@ -168,7 +114,7 @@ function parseModel(value?: string): RunInput["model"] { return { providerID, modelID } } -async function validateAgent(sdk: OpenCodeClient, directory: string, name?: string, attach?: string) { +async function validateAgent(sdk: OpenCodeClient, directory: string, name?: string) { if (!name) return const deadline = Date.now() + 5_000 let agents: Awaited> | undefined @@ -183,7 +129,7 @@ async function validateAgent(sdk: OpenCodeClient, directory: string, name?: stri await Bun.sleep(25) } if (!agents) { - warning(`failed to list agents${attach ? ` from ${attach}` : ""}. Falling back to default agent`) + warning("failed to list agents. Falling back to default agent") return } warning(`agent "${name}" not found. Falling back to default agent`) diff --git a/packages/cli/src/mini/noninteractive.ts b/packages/cli/src/mini/noninteractive.ts index bc356eb743..978bacacc5 100644 --- a/packages/cli/src/mini/noninteractive.ts +++ b/packages/cli/src/mini/noninteractive.ts @@ -25,7 +25,7 @@ type Input = { variant?: string thinking: boolean format: "default" | "json" - dangerouslySkipPermissions: boolean + auto: boolean /** True when the client is attached to a shared server rather than an exclusive in-process one. */ attached: boolean renderTool: (part: ToolPart) => Promise @@ -91,7 +91,7 @@ export async function runNonInteractivePrompt(input: Input) { } const replyPermission = async (request: { id: string; action: string; resources: ReadonlyArray }) => { - if (!input.dangerouslySkipPermissions) { + if (!input.auto) { permissionRejected = true UI.println( UI.Style.TEXT_WARNING_BOLD + "!", @@ -103,10 +103,10 @@ export async function runNonInteractivePrompt(input: Input) { .reply({ sessionID: input.sessionID, requestID: request.id, - reply: input.dangerouslySkipPermissions ? "once" : "reject", + reply: input.auto ? "once" : "reject", }) .catch(() => {}) - if (!input.dangerouslySkipPermissions) { + if (!input.auto) { await input.client.session.interrupt({ sessionID: input.sessionID }).catch(() => {}) } } @@ -153,7 +153,7 @@ export async function runNonInteractivePrompt(input: Input) { if (!("sessionID" in event.data) || event.data.sessionID !== input.sessionID) continue const time = toMillis("created" in event ? event.created : undefined) - if (event.type === "session.prompt.promoted") { + if (event.type === "session.input.promoted") { if (event.data.inputID === messageID) { promoted = true continue @@ -409,10 +409,8 @@ export async function runNonInteractivePrompt(input: Input) { { sessionID: input.sessionID, id: messageID, - prompt: { - text: [input.message, ...prepared.flatMap((file) => (file.text ? [file.text] : []))].join("\n\n"), - files: prepared.flatMap((file) => (file.attachment ? [file.attachment] : [])), - }, + text: [input.message, ...prepared.flatMap((file) => (file.text ? [file.text] : []))].join("\n\n"), + files: prepared.flatMap((file) => (file.attachment ? [file.attachment] : [])), delivery: "steer", }, { signal: admission.signal }, diff --git a/packages/cli/src/mini/run.ts b/packages/cli/src/mini/run.ts index a6aedbb97d..6f7e461a8c 100644 --- a/packages/cli/src/mini/run.ts +++ b/packages/cli/src/mini/run.ts @@ -1,19 +1,18 @@ -import { NodeFileSystem } from "@effect/platform-node" +import { Service } from "@opencode-ai/client/effect" import { OpenCode, type OpenCodeClient } from "@opencode-ai/client/promise" -import { Global } from "@opencode-ai/core/global" import { FSUtil } from "@opencode-ai/core/fs-util" +import { Model } from "@opencode-ai/schema/model" import type { ToolPart } from "@opencode-ai/sdk/v2" -import { Effect } from "effect" import { open } from "node:fs/promises" import path from "node:path" -import { Daemon } from "../daemon" -import { Standalone } from "../services/standalone" +import { Server } from "../services/server" import { loadRunAgents, waitForCatalogReady } from "./catalog.shared" import { runNonInteractivePrompt } from "./noninteractive" import { toolInlineInfo } from "./tool" import { UI } from "./ui" export type RunCommandInput = { + server: Server.Resolved message: string[] continue?: boolean session?: string @@ -23,14 +22,8 @@ export type RunCommandInput = { format: "default" | "json" file: string[] title?: string - server?: string - password?: string - username?: string - directory?: string - variant?: string thinking?: boolean - dangerouslySkipPermissions?: boolean - standaloneCommand?: ReadonlyArray + auto?: boolean } type FilePart = { @@ -39,8 +32,6 @@ type FilePart = { mime: string } -type Transport = { readonly url: string; readonly headers?: HeadersInit } - type Prepared = { directory?: string message: string @@ -56,34 +47,24 @@ export function runNonInteractive(input: RunCommandInput) { async function run(input: RunCommandInput) { if (input.fork && !input.continue && !input.session) fail("--fork requires --continue or --session") const root = process.env.PWD ?? process.cwd() - const directory = input.server ? input.directory : localDirectory(input.directory, root) + const directory = localDirectory(root) const message = mergeInput(formatMessage(input.message), process.stdin.isTTY ? undefined : await Bun.stdin.text()) if (!message?.trim()) fail("You must provide a message") - const files = await Promise.all( - input.file.map((file) => prepareFile(file, input.server ? root : (directory ?? root), input.server !== undefined)), - ) + const files = await Promise.all(input.file.map((file) => prepareFile(file, root))) const prepared = { directory, message, files } - if (input.standaloneCommand) - return Effect.runPromise( - Effect.scoped( - Effect.gen(function* () { - const transport = yield* Standalone.transport({ command: input.standaloneCommand }) - yield* Effect.promise(() => execute(input, prepared, transport)) - }), - ), - ) - const transport = await startTransport(input) - return execute(input, prepared, transport) + return execute(input, prepared, input.server.endpoint) } -async function execute(input: RunCommandInput, prepared: Prepared, transport: Transport) { - const client = OpenCode.make({ baseUrl: transport.url, headers: transport.headers }) +async function execute(input: RunCommandInput, prepared: Prepared, endpoint: Service.Endpoint) { + const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) }) const requestedDirectory = prepared.directory ?? (await client.location.get()).directory if (!requestedDirectory) fail("Failed to resolve server directory") const session = await selectSession(client, requestedDirectory, input) const cwd = session?.location.directory ?? requestedDirectory const workspace = session?.location.workspaceID - const explicitModel = parseModel(input.model) + const explicit = parseRunModel(input.model) + const explicitModel = explicit?.model + const variant = explicit?.variant const sessionModel = session?.model ? { providerID: session.model.providerID, modelID: session.model.id } : undefined const defaultModel = !explicitModel && !sessionModel @@ -91,8 +72,8 @@ async function execute(input: RunCommandInput, prepared: Prepared, transport: Tr .default({ location: { directory: cwd, workspace } }) .then((result) => (result.data ? { providerID: result.data.providerID, modelID: result.data.id } : undefined)) : undefined - const model = pickRunModel(explicitModel, input.variant, sessionModel, defaultModel) - if (input.variant && !model) + const model = pickRunModel(explicitModel, variant, sessionModel, defaultModel) + if (variant && !model) return reportError(input, "Cannot select a variant before selecting a model", session?.id) if (model) { await waitForCatalogReady({ sdk: client, directory: cwd, workspace, model }) @@ -100,12 +81,12 @@ async function execute(input: RunCommandInput, prepared: Prepared, transport: Tr if (!available.data.some((item) => item.providerID === model.providerID && item.id === model.modelID)) return reportError(input, `Model unavailable: ${model.providerID}/${model.modelID}`, session?.id) } - const agent = await validateAgent(client, cwd, input.agent, input.server) + const agent = await validateAgent(client, cwd, input.agent) const selected = session ?? (await client.session.create({ agent, - model: model ? { providerID: model.providerID, id: model.modelID, variant: input.variant } : undefined, + model: model ? { providerID: model.providerID, id: model.modelID, variant } : undefined, location: { directory: cwd }, })) if (!session && input.title !== undefined) { @@ -122,11 +103,11 @@ async function execute(input: RunCommandInput, prepared: Prepared, transport: Tr files: prepared.files, agent, model, - variant: input.variant, + variant, thinking: input.thinking ?? false, format: input.format, - dangerouslySkipPermissions: input.dangerouslySkipPermissions ?? false, - attached: !input.standaloneCommand, + auto: input.auto ?? false, + attached: true, renderTool, renderToolError, }).catch((error) => reportError(input, error instanceof Error ? error.message : String(error), selected.id)) @@ -154,47 +135,29 @@ function formatMessage(message: string[]) { return value || undefined } -function localDirectory(directory: string | undefined, root: string) { +function localDirectory(root: string) { try { - process.chdir(directory ? (path.isAbsolute(directory) ? directory : path.join(root, directory)) : root) + process.chdir(root) return process.cwd() } catch { - fail(`Failed to change directory to ${directory}`) + fail(`Failed to change directory to ${root}`) } } -function startTransport(input: RunCommandInput) { - if (input.server) { - return Effect.runPromise( - Daemon.transport({ - mode: "attach", - url: input.server, - password: input.password, - username: input.username, - }), - ) - } - return Effect.runPromise( - Daemon.transport({ mode: "shared" }).pipe( - Effect.provide(NodeFileSystem.layer), - Effect.provide(Global.layerWith({})), - ), - ) -} - -function parseModel(value?: string) { +export function parseRunModel(value?: string) { if (!value) return - const [providerID, ...rest] = value.split("/") - const modelID = rest.join("/") - if (!providerID || !modelID) fail("--model must use the format provider/model") - return { providerID, modelID } + const ref = Model.Ref.parse(value) + return { + model: { providerID: ref.providerID, modelID: ref.id }, + variant: ref.variant, + } } -async function validateAgent(client: OpenCodeClient, directory: string, name?: string, server?: string) { +async function validateAgent(client: OpenCodeClient, directory: string, name?: string) { if (!name) return const agents = await loadRunAgents(client, directory).catch(() => undefined) if (!agents) { - warning(`failed to list agents${server ? ` from ${server}` : ""}. Falling back to default agent`) + warning("failed to list agents. Falling back to default agent") return } const agent = agents.find((item) => item.id === name) @@ -222,12 +185,11 @@ async function selectSession(client: OpenCodeClient, directory: string, input: R return client.session.fork({ sessionID: selected.id }) } -async function prepareFile(input: string, directory: string, remote: boolean): Promise { +async function prepareFile(input: string, directory: string): Promise { const file = path.resolve(directory, input) const handle = await open(file, "r").catch(() => fail(`File not found: ${input}`)) try { const stat = await handle.stat() - if (remote && stat.isDirectory()) fail(`Cannot attach local directory without a shared filesystem: ${input}`) if (!stat.isFile() || stat.size > ATTACH_FILE_MAX_BYTES) fail(`Cannot attach a directory, special file, or file larger than 10 MiB: ${input}`) const content = Buffer.alloc(Number(stat.size)) diff --git a/packages/cli/src/mini/scrollback.writer.tsx b/packages/cli/src/mini/scrollback.writer.tsx index be814dbbfa..74f4a97adf 100644 --- a/packages/cli/src/mini/scrollback.writer.tsx +++ b/packages/cli/src/mini/scrollback.writer.tsx @@ -7,26 +7,6 @@ import { toolFiletype, toolStructuredFinal } from "./tool" import { RUN_THEME_FALLBACK, transparent, type RunTheme } from "./theme" import type { EntryLayout, RunEntryBody, ScrollbackOptions, StreamCommit } from "./types" -function todoText(item: { status: string; content: string }): string { - if (item.status === "completed") { - return `[✓] ${item.content}` - } - - if (item.status === "cancelled") { - return `~[ ] ${item.content}~` - } - - if (item.status === "in_progress") { - return `[•] ${item.content}` - } - - return `[ ] ${item.content}` -} - -function todoColor(theme: RunTheme, status: string) { - return status === "in_progress" ? theme.block.warning : theme.block.muted -} - export function entryGroupKey(commit: StreamCommit): string | undefined { if (!commit.partID) { return undefined @@ -137,10 +117,6 @@ export function RunEntryContent(props: { const next = structured() return next?.kind === "task" ? next : undefined }) - const todo_snapshot = createMemo(() => { - const next = structured() - return next?.kind === "todo" ? next : undefined - }) const question_snapshot = createMemo(() => { const next = structured() return next?.kind === "question" ? next : undefined @@ -242,25 +218,6 @@ export function RunEntryContent(props: { - - - - # Todos - - - {todo_snapshot()!.items.map((item) => ( - - {todoText(item)} - - ))} - {todo_snapshot()!.tail ? ( - - {todo_snapshot()!.tail} - - ) : null} - - - diff --git a/packages/cli/src/mini/stream-v2.subagent.ts b/packages/cli/src/mini/stream-v2.subagent.ts index 091ff296ee..911d2d0d4e 100644 --- a/packages/cli/src/mini/stream-v2.subagent.ts +++ b/packages/cli/src/mini/stream-v2.subagent.ts @@ -457,12 +457,13 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac } const reduce = (child: ChildState, event: V2Event) => { - if (event.type === "session.prompt.admitted") { - child.prompts.set(event.data.inputID, event.data.prompt.text) + if (event.type === "session.input.admitted") { + if (event.data.input.type === "user") child.prompts.set(event.data.inputID, event.data.input.data.text) return } - if (event.type === "session.prompt.promoted") { - const prompt = child.prompts.get(event.data.inputID) ?? "" + if (event.type === "session.input.promoted") { + const prompt = child.prompts.get(event.data.inputID) + if (prompt === undefined) return child.prompts.delete(event.data.inputID) if (userFrame(child, event.data.inputID, prompt)) { touch(child, event.created) diff --git a/packages/cli/src/mini/stream-v2.transport.ts b/packages/cli/src/mini/stream-v2.transport.ts index ae854ef2e3..26b8266849 100644 --- a/packages/cli/src/mini/stream-v2.transport.ts +++ b/packages/cli/src/mini/stream-v2.transport.ts @@ -562,7 +562,7 @@ export async function createSessionTransport(input: StreamInput): Promise (file.text ? [file.text] : []))].join("\n\n"), - files: attachments.length ? attachments : undefined, - agents: agents.length ? agents : undefined, - }, + text: [next.prompt.text, ...prepared.flatMap((file) => (file.text ? [file.text] : []))].join("\n\n"), + files: attachments.length ? attachments : undefined, + agents: agents.length ? agents : undefined, delivery: "steer", }, { signal: next.signal }, diff --git a/packages/cli/src/mini/tool.ts b/packages/cli/src/mini/tool.ts index 9eb8782a17..9c13d1ee29 100644 --- a/packages/cli/src/mini/tool.ts +++ b/packages/cli/src/mini/tool.ts @@ -55,7 +55,6 @@ type ToolInput = ToolDict & { content?: string command?: string workdir?: string - todos?: Array<{ status?: string; content?: string }> questions?: Array<{ question?: string }> diff?: string } @@ -122,7 +121,6 @@ type ToolName = | "patch" | "batch" | "task" - | "todowrite" | "question" | "read" | "glob" @@ -398,25 +396,6 @@ function runTask(p: ToolProps): ToolInline { } } -function runTodo(p: ToolProps): ToolInline { - return { - icon: "#", - title: "Todos", - mode: "block", - body: list<{ status?: string; content?: string }>(p.frame.input.todos) - .flatMap((item) => { - const body = typeof item?.content === "string" ? item.content : "" - if (!body) { - return [] - } - - const mark = item.status === "completed" ? "[✓]" : item.status === "in_progress" ? "[•]" : "[ ]" - return [`${mark} ${body}`] - }) - .join("\n"), - } -} - function runSkill(p: ToolProps): ToolInline { return { icon: "→", @@ -604,28 +583,6 @@ function snapTask(p: ToolProps): ToolSnapshot { } } -function snapTodo(p: ToolProps): ToolSnapshot { - const items = list<{ status?: string; content?: string }>(p.frame.input.todos).flatMap((item) => { - const content = typeof item?.content === "string" ? item.content : "" - if (!content) { - return [] - } - - return [ - { - status: typeof item.status === "string" ? item.status : "", - content, - }, - ] - }) - - return { - kind: "todo", - items, - tail: "", - } -} - function snapQuestion(p: ToolProps): ToolSnapshot { const answers = list(p.frame.meta.answers) const items = list<{ question?: string }>(p.frame.input.questions).map((item, i) => { @@ -814,42 +771,6 @@ function scrollTaskFinal(p: ToolProps): string { return `# ${kind} Task\n${row}` } -function scrollTodoStart(_: ToolProps): string { - return "" -} - -function scrollTodoFinal(p: ToolProps): string { - const items = list<{ status?: string }>(p.input.todos) - const time = span(p.frame.state) - if (items.length === 0) { - if (!time) { - return "0 todos" - } - - return `0 todos · ${time}` - } - - const doneN = items.filter((item) => item.status === "completed").length - const runN = items.filter((item) => item.status === "in_progress").length - const left = items.length - doneN - runN - const tail = [`${items.length} total`] - if (doneN > 0) { - tail.push(`${doneN} done`) - } - if (runN > 0) { - tail.push(`${runN} active`) - } - if (left > 0) { - tail.push(`${left} pending`) - } - - if (time) { - tail.push(time) - } - - return tail.join(" · ") -} - function scrollQuestionStart(_: ToolProps): string { return "" } @@ -1131,19 +1052,6 @@ const TOOL_RULES = { }, permission: permTask, }, - todowrite: { - view: { - output: false, - final: true, - snap: "structured", - }, - run: runTodo, - snap: snapTodo, - scroll: { - start: scrollTodoStart, - final: scrollTodoFinal, - }, - }, question: { view: { output: false, diff --git a/packages/cli/src/mini/types.ts b/packages/cli/src/mini/types.ts index 00f8d8daed..0e3b4bea3b 100644 --- a/packages/cli/src/mini/types.ts +++ b/packages/cli/src/mini/types.ts @@ -199,15 +199,6 @@ export type ToolTaskSnapshot = { tail: string } -export type ToolTodoSnapshot = { - kind: "todo" - items: Array<{ - status: string - content: string - }> - tail: string -} - export type ToolQuestionSnapshot = { kind: "question" items: Array<{ @@ -217,12 +208,7 @@ export type ToolQuestionSnapshot = { tail: string } -export type ToolSnapshot = - | ToolCodeSnapshot - | ToolDiffSnapshot - | ToolTaskSnapshot - | ToolTodoSnapshot - | ToolQuestionSnapshot +export type ToolSnapshot = ToolCodeSnapshot | ToolDiffSnapshot | ToolTaskSnapshot | ToolQuestionSnapshot export type EntryLayout = "inline" | "block" diff --git a/packages/cli/src/server-process.ts b/packages/cli/src/server-process.ts index 39c1e256d3..9f688fb16e 100644 --- a/packages/cli/src/server-process.ts +++ b/packages/cli/src/server-process.ts @@ -65,6 +65,7 @@ const processEffect = Effect.fnUntraced(function* (options: Options) { hostname: options.hostname ?? config.hostname ?? "127.0.0.1", port: Option.fromNullishOr(options.port ?? config.port), password, + restartContinuity: options.mode === "service", }).pipe(Effect.provide(Logger.layer([], { mergeWithExisting: false }))) if (lockScope !== undefined) { yield* register(address, password) diff --git a/packages/cli/src/services/server.ts b/packages/cli/src/services/server.ts new file mode 100644 index 0000000000..5ea8434792 --- /dev/null +++ b/packages/cli/src/services/server.ts @@ -0,0 +1,100 @@ +import { NodeFileSystem } from "@effect/platform-node" +import { Service } from "@opencode-ai/client/effect" +import { ClientError, isUnauthorizedError, OpenCode } from "@opencode-ai/client/promise" +import { InstallationVersion } from "@opencode-ai/core/installation/version" +import { Effect, Redacted } from "effect" +import { Env } from "../env" +import { ServiceConfig } from "./service-config" +import { Standalone } from "./standalone" + +export type Args = { + readonly server?: string + readonly standalone?: boolean + readonly mismatch?: "replace" | "ignore" | "error" +} + +export type Resolved = { + readonly endpoint: Service.Endpoint + readonly reconnect?: (attempt: number) => Promise + readonly reload?: () => Promise +} + +export const resolve = Effect.fn("cli.server.resolve")(function* (args: Args) { + if (args.server !== undefined && args.standalone) + return yield* Effect.fail(new Error("--server and --standalone cannot be combined")) + if (args.server !== undefined) { + const password = yield* Env.password + const endpoint = { + url: args.server, + auth: password + ? { type: "basic" as const, username: "opencode", password: Redacted.value(password) } + : undefined, + } satisfies Service.Endpoint + const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) }) + const health = yield* Effect.tryPromise({ + try: () => client.health.get({ signal: AbortSignal.timeout(5_000) }), + catch: (cause) => connectError(endpoint, cause), + }) + if (health.version !== InstallationVersion) + process.stderr.write( + `Warning: Server at ${endpoint.url} has version ${health.version}; this client is ${InstallationVersion}. Continuing anyway.\n`, + ) + return { endpoint } satisfies Resolved + } + if (args.standalone) { + return { endpoint: yield* Standalone.start() } satisfies Resolved + } + + const options = yield* ServiceConfig.options() + const endpoint = yield* resolveManaged(options, args.mismatch ?? "replace") + const reconnectOptions = { ...options, version: undefined } + return { + endpoint, + reconnect: (attempt) => + Effect.runPromise( + Effect.gen(function* () { + if (attempt > 3) return yield* Service.start(reconnectOptions) + const endpoint = yield* Service.discover(reconnectOptions) + if (endpoint !== undefined) return endpoint + return yield* Effect.fail(new Error("Background server is unavailable")) + }).pipe(Effect.provide(NodeFileSystem.layer)), + ), + reload: () => + Effect.runPromise( + Effect.gen(function* () { + yield* Service.stop(options) + yield* Service.start(options) + }).pipe(Effect.provide(NodeFileSystem.layer)), + ), + } satisfies Resolved +}) + +const resolveManaged = Effect.fnUntraced(function* ( + options: Service.Options, + mismatch: NonNullable, +) { + if (mismatch === "replace") return yield* Service.start(options) + if (mismatch === "ignore") return yield* Service.start({ ...options, version: undefined }) + + const compatible = yield* Service.discover(options) + if (compatible !== undefined) return compatible + const existing = yield* Service.discover({ ...options, version: undefined }) + if (existing !== undefined) return yield* Effect.fail(new Error("Background server version does not match this client")) + return yield* Service.start(options) +}) + +function connectError(endpoint: Service.Endpoint, cause: unknown) { + if (isUnauthorizedError(cause)) { + return new Error( + endpoint.auth === undefined + ? `Server at ${endpoint.url} requires a password; set OPENCODE_PASSWORD` + : `Server at ${endpoint.url} rejected the password`, + { cause }, + ) + } + if (cause instanceof ClientError && cause.reason === "Transport") + return new Error(`Could not reach server at ${endpoint.url}`, { cause }) + return new Error(`Server at ${endpoint.url} did not provide a compatible V2 health response`, { cause }) +} + +export * as Server from "./server" diff --git a/packages/cli/src/services/standalone.ts b/packages/cli/src/services/standalone.ts index c0a8d8b71d..099b1c9df1 100644 --- a/packages/cli/src/services/standalone.ts +++ b/packages/cli/src/services/standalone.ts @@ -1,4 +1,4 @@ -import { ServerAuth } from "@opencode-ai/server/auth" +import { Service } from "@opencode-ai/client/effect" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { Effect, Schema, Stream } from "effect" @@ -34,7 +34,7 @@ function command(password: string, options: Options) { }) } -const makeTransport = Effect.fn("cli.standalone.transport")( +const makeEndpoint = Effect.fn("cli.standalone.endpoint")( function* (options: Options) { const password = randomBytes(32).toString("base64url") const spawner = yield* ChildProcessSpawner.ChildProcessSpawner @@ -42,13 +42,17 @@ const makeTransport = Effect.fn("cli.standalone.transport")( const output = yield* proc.stdout.pipe(Stream.decodeText(), Stream.splitLines, Stream.take(1), Stream.mkString) if (!output) return yield* Effect.fail(new Error("Standalone server exited before reporting readiness")) const ready = yield* Effect.tryPromise(() => decodeReady(output)) - return { url: ready.url, headers: ServerAuth.headers({ password, username: "opencode" }), pid: proc.pid } + return { + url: ready.url, + auth: { type: "basic" as const, username: "opencode", password }, + pid: proc.pid, + } satisfies Service.Endpoint & { readonly pid: number } }, Effect.provide(AppNodeBuilder.build(CrossSpawnSpawner.node)), ) -export function transport(options: Options = {}) { - return makeTransport(options) +export function start(options: Options = {}) { + return makeEndpoint(options) } export * as Standalone from "./standalone" diff --git a/packages/cli/src/tui.ts b/packages/cli/src/tui.ts deleted file mode 100644 index e9d25911ac..0000000000 --- a/packages/cli/src/tui.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { run } from "@opencode-ai/tui" -import { TuiConfig } from "@opencode-ai/tui/config" -import { Effect } from "effect" -import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" -import { Global } from "@opencode-ai/core/global" -import { loadBuiltinPlugins } from "@opencode-ai/tui/builtins" -import { OpenCode } from "@opencode-ai/client/promise" -import type { Service } from "@opencode-ai/client/effect" -import { createOpencodeClient } from "@opencode-ai/sdk/v2/client" -import type { Args } from "@opencode-ai/tui/context/args" - -export function runTui( - transport: Service.Transport, - args: Args, - discover?: () => Promise, - reload?: () => Promise, -) { - const config = TuiConfig.resolve({}, { terminalSuspend: false }) - let disposeSlots: (() => void) | undefined - return Effect.gen(function* () { - const runFork = Effect.runForkWith(yield* Effect.context()) - const options = { baseUrl: transport.url, headers: transport.headers } - const api = OpenCode.make(options) - const directory = yield* Effect.tryPromise(() => api.file.list({ location: { directory: process.cwd() } })).pipe( - Effect.map((response) => response.location.directory), - Effect.catch(() => - Effect.tryPromise(() => api.location.get()).pipe(Effect.map((response) => response.directory)), - ), - ) - return yield* run({ - client: createOpencodeClient({ ...options, directory }), - api, - discover: discover - ? async () => { - const next = await discover() - return { - client: createOpencodeClient({ baseUrl: next.url, headers: next.headers, directory }), - api: OpenCode.make({ baseUrl: next.url, headers: next.headers }), - } - } - : undefined, - reload, - args, - config, - log: (level, message, tags) => { - const effect = - level === "debug" - ? Effect.logDebug(message, tags) - : level === "warn" - ? Effect.logWarning(message, tags) - : level === "error" - ? Effect.logError(message, tags) - : Effect.logInfo(message, tags) - runFork(effect) - }, - pluginHost: { - async start(input) { - disposeSlots = await loadBuiltinPlugins(input.api, input.runtime) - }, - async dispose() { - disposeSlots?.() - }, - }, - }) - }).pipe(Effect.provide(AppNodeBuilder.build(Global.node))) -} diff --git a/packages/cli/src/ui/timeline.tsx b/packages/cli/src/ui/timeline.tsx new file mode 100644 index 0000000000..8c7e6612c6 --- /dev/null +++ b/packages/cli/src/ui/timeline.tsx @@ -0,0 +1,242 @@ +/** @jsxImportSource @opentui/solid */ +import { createCliRenderer, RGBA, type CliRenderer, type ColorInput, type ScrollbackWriter } from "@opentui/core" +import { createScrollbackWriter, render, useKeyboard } from "@opentui/solid" +import { registerOpencodeSpinner } from "@opencode-ai/tui/component/register-spinner" +import { Show, createSignal } from "solid-js" + +registerOpencodeSpinner() + +export type TimelineHost = { + readonly signal: AbortSignal + intro(text: string): Promise + item(text: string): Promise + pending(text: string): Promise + success(text: string): Promise + failure(text: string): Promise + outro(text: string): Promise + close(): Promise +} + +type RowKind = "intro" | "item" | "success" | "failure" | "outro" + +const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"] +const IDLE_TIMEOUT = 1_000 +const COLORS = { + accent: RGBA.fromIndex(6), + error: RGBA.fromIndex(1), + foreground: RGBA.defaultForeground(), + muted: RGBA.fromIndex(8), + success: RGBA.fromIndex(2), +} +const ROWS: Record = { + intro: { marker: "┌", color: COLORS.muted, connector: true }, + item: { marker: "●", color: COLORS.accent, connector: true }, + success: { marker: "◇", color: COLORS.success, connector: true }, + failure: { marker: "■", color: COLORS.error, connector: false }, + outro: { marker: "└", color: COLORS.muted, connector: false }, +} + +function row(kind: RowKind, value: string): ScrollbackWriter { + const style = ROWS[kind] + return createScrollbackWriter( + () => ( + + + + {style.marker} + + + {value} + + + + + + + ), + { startOnNewLine: true, trailingNewline: !style.connector }, + ) +} + +function TimelineFooter(props: { pending: () => string | undefined; cancel: () => void }) { + useKeyboard((event) => { + if (event.name !== "escape" && !(event.ctrl && event.name === "c")) return + event.preventDefault() + props.cancel() + }) + + return ( + + + {(text) => ( + <> + + + {text()} + + + )} + + + ) +} + +function bounded(task: Promise) { + return new Promise((resolve) => { + const timer = setTimeout(resolve, IDLE_TIMEOUT) + timer.unref() + const finish = () => { + clearTimeout(timer) + resolve() + } + void task.then(finish, finish) + }) +} + +async function shutdown(renderer: CliRenderer): Promise { + await bounded(renderer.idle()) + try { + renderer.externalOutputMode = "passthrough" + } finally { + try { + renderer.screenMode = "main-screen" + } finally { + if (!renderer.isDestroyed) renderer.destroy() + } + } +} + +export async function createTimelineHost(): Promise { + const stdout = process.stdout + const controller = new AbortController() + const signals: NodeJS.Signals[] = ["SIGINT", "SIGHUP", "SIGQUIT"] + const cancel = () => { + if (!controller.signal.aborted) controller.abort() + } + signals.forEach((signal) => process.on(signal, cancel)) + + if (!stdout.isTTY || !process.stdin.isTTY) { + let closed = false + let writing = false + let active: Promise | undefined + let closeTask: Promise | undefined + const write = async (kind: RowKind | "pending", text: string) => { + if (closed) throw new Error("timeline closed") + if (writing) throw new Error("timeline write already in progress") + writing = true + try { + const style = kind === "pending" ? undefined : ROWS[kind] + const marker = kind === "pending" ? "." : ROWS[kind].marker + const connector = style?.connector ? "│\n" : "" + active = new Promise((resolve, reject) => { + stdout.write(`${marker} ${text}\n${connector}`, (error) => (error ? reject(error) : resolve())) + }) + await active + } finally { + writing = false + active = undefined + } + } + const close = () => { + if (closeTask) return closeTask + closed = true + closeTask = (async () => { + await active?.catch(() => { }) + signals.forEach((signal) => process.off(signal, cancel)) + })() + return closeTask + } + return { + signal: controller.signal, + intro: (text) => write("intro", text), + item: (text) => write("item", text), + pending: (text) => write("pending", text), + success: (text) => write("success", text), + failure: (text) => write("failure", text), + outro: (text) => write("outro", text), + close, + } + } + + let renderer: CliRenderer | undefined + + try { + // Start on a fresh row so delayed SSH cursor reports cannot make + // split-footer overwrite the shell command. + process.stdout.write("\n") + renderer = await createCliRenderer({ + stdin: process.stdin, + useMouse: false, + autoFocus: false, + openConsoleOnError: false, + exitOnCtrlC: false, + exitSignals: [], + screenMode: "split-footer", + footerHeight: 1, + externalOutputMode: "capture-stdout", + consoleMode: "disabled", + clearOnShutdown: false, + }) + const activeRenderer = renderer + const [pending, setPending] = createSignal() + const renderTask = render(() => , activeRenderer) + void renderTask.catch(cancel) + await bounded(activeRenderer.idle()) + + let closed = false + let writing = false + let active: Promise | undefined + let closeTask: Promise | undefined + const write = (kind: RowKind | "pending", text: string) => { + if (closed) return Promise.reject(new Error("timeline closed")) + if (writing) return Promise.reject(new Error("timeline write already in progress")) + writing = true + active = (async () => { + if (kind === "pending") { + setPending(text) + activeRenderer.requestRender() + } else { + if (kind === "success" || kind === "failure" || kind === "outro") setPending(undefined) + activeRenderer.writeToScrollback(row(kind, text)) + activeRenderer.requestRender() + } + await bounded(activeRenderer.idle()) + })().finally(() => { + writing = false + active = undefined + }) + return active + } + const close = () => { + if (closeTask) return closeTask + closed = true + closeTask = (async () => { + await active?.catch(() => { }) + try { + await shutdown(activeRenderer) + await bounded(renderTask) + } finally { + signals.forEach((signal) => process.off(signal, cancel)) + } + })() + return closeTask + } + return { + signal: controller.signal, + intro: (text) => write("intro", text), + item: (text) => write("item", text), + pending: (text) => write("pending", text), + success: (text) => write("success", text), + failure: (text) => write("failure", text), + outro: (text) => write("outro", text), + close, + } + } catch (error) { + try { + if (renderer) await shutdown(renderer) + } finally { + signals.forEach((signal) => process.off(signal, cancel)) + } + throw error + } +} diff --git a/packages/cli/test/fixture/standalone-owner.ts b/packages/cli/test/fixture/standalone-owner.ts index 1ad311890a..7f92ac0636 100644 --- a/packages/cli/test/fixture/standalone-owner.ts +++ b/packages/cli/test/fixture/standalone-owner.ts @@ -1,4 +1,5 @@ import { Effect } from "effect" +import { Service } from "@opencode-ai/client/effect" import path from "node:path" import { Standalone } from "../../src/services/standalone" @@ -7,9 +8,11 @@ process.argv[1] = path.join(import.meta.dir, "../../src/index.ts") await Effect.runPromise( Effect.scoped( Effect.gen(function* () { - const transport = yield* Standalone.transport() - const response = yield* Effect.promise(() => fetch(new URL("/api/health", transport.url), { headers: transport.headers })) - console.log(`${transport.pid} ${transport.url} ${response.status}`) + const endpoint = yield* Standalone.start() + const response = yield* Effect.promise(() => + fetch(new URL("/api/health", endpoint.url), { headers: Service.headers(endpoint) }), + ) + console.log(`${endpoint.pid} ${endpoint.url} ${response.status}`) return yield* Effect.never }), ), diff --git a/packages/cli/test/mini.test.ts b/packages/cli/test/mini.test.ts index 5673ff14a7..17ff0177c7 100644 --- a/packages/cli/test/mini.test.ts +++ b/packages/cli/test/mini.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test } from "bun:test" import { InstallationVersion } from "@opencode-ai/core/installation/version" import path from "node:path" -import { mergeInteractiveInput, mergeNonInteractiveInput, pickRunModel } from "../src/mini" +import { mergeInteractiveInput, mergeNonInteractiveInput, parseRunModel, pickRunModel } from "../src/mini" async function cli(args: string[]) { const child = Bun.spawn([process.execPath, "run", "src/index.ts", ...args], { @@ -39,6 +39,12 @@ describe("mini command", () => { ).toEqual({ providerID: "session-provider", modelID: "session-model" }) }) + test("parses model variants from the model reference", () => { + expect(JSON.stringify(parseRunModel("openrouter/openai/gpt-5#high"))).toBe( + JSON.stringify({ model: { providerID: "openrouter", modelID: "openai/gpt-5" }, variant: "high" }), + ) + }) + test("is registered in the preview CLI", async () => { const result = await cli(["--help"]) @@ -52,6 +58,7 @@ describe("mini command", () => { expect(result.exitCode).toBe(0) expect(result.stdout).toContain("--server string") + expect(result.stdout).not.toContain("--variant") expect(result.stdout).not.toContain("--attach") expect(result.stdout).not.toContain("--command") }) @@ -71,6 +78,8 @@ describe("mini command", () => { const url = new URL(request.url) if (url.pathname === "/api/health") return Response.json({ healthy: true, version: InstallationVersion, pid: process.pid }) + if (url.pathname === "/api/location") + return Response.json({ directory: process.cwd(), project: { id: "global", directory: process.cwd() } }) if (url.pathname === "/api/model") { modelRequests++ return Response.json({ @@ -87,8 +96,6 @@ describe("mini command", () => { "run", "--server", server.url.toString(), - "--dir", - process.cwd(), "--model", "definitely/missing", "hi", @@ -104,7 +111,8 @@ describe("mini command", () => { test("reports pre-admission errors as JSON", async () => { const server = Bun.serve({ port: 0, - fetch() { + fetch(request) { + if (new URL(request.url).pathname === "/api/session") return new Response("boom", { status: 500 }) return Response.json({ healthy: true, version: "incompatible", pid: process.pid }) }, }) @@ -116,7 +124,7 @@ describe("mini command", () => { expect(JSON.parse(result.stdout)).toMatchObject({ type: "error", sessionID: "", - error: { type: "unknown", message: "Failed to resolve server directory" }, + error: { type: "unknown", message: "UnexpectedStatus" }, }) } finally { server.stop(true) diff --git a/packages/cli/test/service.test.ts b/packages/cli/test/service.test.ts index ac1f9ac1cf..a793ce0257 100644 --- a/packages/cli/test/service.test.ts +++ b/packages/cli/test/service.test.ts @@ -1,8 +1,17 @@ import { NodeFileSystem } from "@effect/platform-node" import { Service } from "@opencode-ai/client/effect" +import { Database } from "@opencode-ai/core/database/database" +import { EventV2 } from "@opencode-ai/core/event" +import { EventTable } from "@opencode-ai/core/event/sql" import { Global } from "@opencode-ai/core/global" +import { Project } from "@opencode-ai/core/project" +import { ProjectTable } from "@opencode-ai/core/project/sql" +import { AbsolutePath } from "@opencode-ai/core/schema" +import { SessionV2 } from "@opencode-ai/core/session" +import { SessionEvent } from "@opencode-ai/core/session/event" +import { SessionTable } from "@opencode-ai/core/session/sql" import { expect, test } from "bun:test" -import { Effect, Schema } from "effect" +import { Effect, Schedule, Schema } from "effect" import fs from "node:fs/promises" import os from "node:os" import path from "node:path" @@ -28,16 +37,42 @@ test("local channel stores service config with the local service filename", asyn test("concurrent service processes elect one server", async () => { const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-service-election-")) + const database = path.join(root, "opencode.db") const env = { ...process.env, HOME: root, - OPENCODE_DB: path.join(root, "opencode.db"), + OPENCODE_DB: database, OPENCODE_TEST_HOME: root, XDG_CACHE_HOME: path.join(root, "cache"), XDG_CONFIG_HOME: path.join(root, "config"), XDG_DATA_HOME: path.join(root, "data"), XDG_STATE_HOME: path.join(root, "state"), } + const sessionID = SessionV2.ID.make("ses_service_recovery") + await withDatabase( + database, + Effect.gen(function* () { + const { db } = yield* Database.Service + yield* db + .insert(ProjectTable) + .values({ id: Project.ID.global, worktree: AbsolutePath.make(root), sandboxes: [] }) + .run() + .pipe(Effect.orDie) + yield* db + .insert(SessionTable) + .values({ + id: sessionID, + project_id: Project.ID.global, + slug: "recovery", + directory: root, + title: "recovery", + version: "test", + time_suspended: Date.now(), + }) + .run() + .pipe(Effect.orDie) + }), + ) const command = [process.execPath, path.join(import.meta.dir, "../src/index.ts"), "serve", "--service"] const first = Bun.spawn(command, { env, stderr: "pipe", stdout: "ignore" }) const second = Bun.spawn(command, { env, stderr: "pipe", stdout: "ignore" }) @@ -51,6 +86,20 @@ test("concurrent service processes elect one server", async () => { expect(exited).toBe(true) expect(winner.exitCode).toBe(null) + expect( + await withDatabase( + database, + Effect.gen(function* () { + const { db } = yield* Database.Service + return yield* db + .select({ timeSuspended: SessionTable.time_suspended }) + .from(SessionTable) + .get() + .pipe(Effect.orDie) + }), + ), + ).toEqual({ timeSuspended: null }) + expect(await waitForExecutionStart(database, sessionID)).toBe(1) } finally { first.kill("SIGTERM") second.kill("SIGTERM") @@ -59,6 +108,40 @@ test("concurrent service processes elect one server", async () => { } }) +function withDatabase(file: string, effect: Effect.Effect) { + return Effect.runPromise(effect.pipe(Effect.provide(Database.layerFromPath(file)), Effect.scoped)) +} + +function waitForExecutionStart(file: string, sessionID: SessionV2.ID) { + return withDatabase( + file, + Effect.gen(function* () { + const { db } = yield* Database.Service + return yield* db + .select({ id: EventTable.id, sessionID: EventTable.aggregate_id, type: EventTable.type }) + .from(EventTable) + .all() + .pipe( + Effect.orDie, + Effect.map((rows) => + rows.filter( + (row) => + row.sessionID === sessionID && + row.type === + EventV2.versionedType( + SessionEvent.Execution.Started.type, + SessionEvent.Execution.Started.durable.version, + ), + ), + ), + Effect.filterOrFail((rows) => rows.length > 0), + Effect.map((rows) => rows.length), + Effect.retry(Schedule.spaced("50 millis").pipe(Schedule.both(Schedule.recurs(200)))), + ) + }), + ) +} + async function waitForInfo(file: string) { for (let attempt = 0; attempt < 200; attempt++) { const value = await Bun.file(file) diff --git a/packages/client/src/effect/api/api.ts b/packages/client/src/effect/api/api.ts index 0c7b7a3af3..d1f726d077 100644 --- a/packages/client/src/effect/api/api.ts +++ b/packages/client/src/effect/api/api.ts @@ -14,264 +14,276 @@ export interface HealthApi { readonly get: HealthGetOperation } -type Endpoint1_0Request = Parameters[0] -export type Endpoint1_0Input = { readonly location?: Endpoint1_0Request["query"]["location"] } -export type Endpoint1_0Output = EffectValue> -export type LocationGetOperation = (input?: Endpoint1_0Input) => Effect.Effect +export type Endpoint1_0Output = EffectValue> +export type ServerGetOperation = () => Effect.Effect + +export interface ServerApi { + readonly get: ServerGetOperation +} + +type Endpoint2_0Request = Parameters[0] +export type Endpoint2_0Input = { readonly location?: Endpoint2_0Request["query"]["location"] } +export type Endpoint2_0Output = EffectValue> +export type LocationGetOperation = (input?: Endpoint2_0Input) => Effect.Effect export interface LocationApi { readonly get: LocationGetOperation } -type Endpoint2_0Request = Parameters[0] -export type Endpoint2_0Input = { readonly location?: Endpoint2_0Request["query"]["location"] } -export type Endpoint2_0Output = EffectValue> -export type AgentListOperation = (input?: Endpoint2_0Input) => Effect.Effect +type Endpoint3_0Request = Parameters[0] +export type Endpoint3_0Input = { readonly location?: Endpoint3_0Request["query"]["location"] } +export type Endpoint3_0Output = EffectValue> +export type AgentListOperation = (input?: Endpoint3_0Input) => Effect.Effect export interface AgentApi { readonly list: AgentListOperation } -type Endpoint3_0Request = Parameters[0] -export type Endpoint3_0Input = { readonly location?: Endpoint3_0Request["query"]["location"] } -export type Endpoint3_0Output = EffectValue> -export type PluginListOperation = (input?: Endpoint3_0Input) => Effect.Effect +type Endpoint4_0Request = Parameters[0] +export type Endpoint4_0Input = { readonly location?: Endpoint4_0Request["query"]["location"] } +export type Endpoint4_0Output = EffectValue> +export type PluginListOperation = (input?: Endpoint4_0Input) => Effect.Effect export interface PluginApi { readonly list: PluginListOperation } -type Endpoint4_0Request = Parameters[0] -export type Endpoint4_0Input = { - readonly workspace?: Endpoint4_0Request["query"]["workspace"] - readonly limit?: Endpoint4_0Request["query"]["limit"] - readonly order?: Endpoint4_0Request["query"]["order"] - readonly search?: Endpoint4_0Request["query"]["search"] - readonly parentID?: Endpoint4_0Request["query"]["parentID"] - readonly directory?: Endpoint4_0Request["query"]["directory"] - readonly project?: Endpoint4_0Request["query"]["project"] - readonly subpath?: Endpoint4_0Request["query"]["subpath"] - readonly cursor?: Endpoint4_0Request["query"]["cursor"] +type Endpoint5_0Request = Parameters[0] +export type Endpoint5_0Input = { + readonly workspace?: Endpoint5_0Request["query"]["workspace"] + readonly limit?: Endpoint5_0Request["query"]["limit"] + readonly order?: Endpoint5_0Request["query"]["order"] + readonly search?: Endpoint5_0Request["query"]["search"] + readonly parentID?: Endpoint5_0Request["query"]["parentID"] + readonly directory?: Endpoint5_0Request["query"]["directory"] + readonly project?: Endpoint5_0Request["query"]["project"] + readonly subpath?: Endpoint5_0Request["query"]["subpath"] + readonly cursor?: Endpoint5_0Request["query"]["cursor"] } -export type Endpoint4_0Output = EffectValue> -export type SessionListOperation = (input?: Endpoint4_0Input) => Effect.Effect +export type Endpoint5_0Output = EffectValue> +export type SessionListOperation = (input?: Endpoint5_0Input) => Effect.Effect -type Endpoint4_1Request = Parameters[0] -export type Endpoint4_1Input = { - readonly id?: Endpoint4_1Request["payload"]["id"] - readonly agent?: Endpoint4_1Request["payload"]["agent"] - readonly model?: Endpoint4_1Request["payload"]["model"] - readonly location?: Endpoint4_1Request["payload"]["location"] +type Endpoint5_1Request = Parameters[0] +export type Endpoint5_1Input = { + readonly id?: Endpoint5_1Request["payload"]["id"] + readonly agent?: Endpoint5_1Request["payload"]["agent"] + readonly model?: Endpoint5_1Request["payload"]["model"] + readonly location?: Endpoint5_1Request["payload"]["location"] } -export type Endpoint4_1Output = EffectValue>["data"] -export type SessionCreateOperation = (input?: Endpoint4_1Input) => Effect.Effect +export type Endpoint5_1Output = EffectValue>["data"] +export type SessionCreateOperation = (input?: Endpoint5_1Input) => Effect.Effect -export type Endpoint4_2Output = EffectValue>["data"] -export type SessionActiveOperation = () => Effect.Effect +export type Endpoint5_2Output = EffectValue>["data"] +export type SessionActiveOperation = () => Effect.Effect -type Endpoint4_3Request = Parameters[0] -export type Endpoint4_3Input = { readonly sessionID: Endpoint4_3Request["params"]["sessionID"] } -export type Endpoint4_3Output = EffectValue>["data"] -export type SessionGetOperation = (input: Endpoint4_3Input) => Effect.Effect +type Endpoint5_3Request = Parameters[0] +export type Endpoint5_3Input = { readonly sessionID: Endpoint5_3Request["params"]["sessionID"] } +export type Endpoint5_3Output = EffectValue>["data"] +export type SessionGetOperation = (input: Endpoint5_3Input) => Effect.Effect -type Endpoint4_4Request = Parameters[0] -export type Endpoint4_4Input = { readonly sessionID: Endpoint4_4Request["params"]["sessionID"] } -export type Endpoint4_4Output = EffectValue> -export type SessionRemoveOperation = (input: Endpoint4_4Input) => Effect.Effect +type Endpoint5_4Request = Parameters[0] +export type Endpoint5_4Input = { readonly sessionID: Endpoint5_4Request["params"]["sessionID"] } +export type Endpoint5_4Output = EffectValue> +export type SessionRemoveOperation = (input: Endpoint5_4Input) => Effect.Effect -type Endpoint4_5Request = Parameters[0] -export type Endpoint4_5Input = { - readonly sessionID: Endpoint4_5Request["params"]["sessionID"] - readonly messageID?: Endpoint4_5Request["payload"]["messageID"] +type Endpoint5_5Request = Parameters[0] +export type Endpoint5_5Input = { + readonly sessionID: Endpoint5_5Request["params"]["sessionID"] + readonly messageID?: Endpoint5_5Request["payload"]["messageID"] } -export type Endpoint4_5Output = EffectValue>["data"] -export type SessionForkOperation = (input: Endpoint4_5Input) => Effect.Effect +export type Endpoint5_5Output = EffectValue>["data"] +export type SessionForkOperation = (input: Endpoint5_5Input) => Effect.Effect -type Endpoint4_6Request = Parameters[0] -export type Endpoint4_6Input = { - readonly sessionID: Endpoint4_6Request["params"]["sessionID"] - readonly agent: Endpoint4_6Request["payload"]["agent"] +type Endpoint5_6Request = Parameters[0] +export type Endpoint5_6Input = { + readonly sessionID: Endpoint5_6Request["params"]["sessionID"] + readonly agent: Endpoint5_6Request["payload"]["agent"] } -export type Endpoint4_6Output = EffectValue> -export type SessionSwitchAgentOperation = (input: Endpoint4_6Input) => Effect.Effect +export type Endpoint5_6Output = EffectValue> +export type SessionSwitchAgentOperation = (input: Endpoint5_6Input) => Effect.Effect -type Endpoint4_7Request = Parameters[0] -export type Endpoint4_7Input = { - readonly sessionID: Endpoint4_7Request["params"]["sessionID"] - readonly model: Endpoint4_7Request["payload"]["model"] +type Endpoint5_7Request = Parameters[0] +export type Endpoint5_7Input = { + readonly sessionID: Endpoint5_7Request["params"]["sessionID"] + readonly model: Endpoint5_7Request["payload"]["model"] } -export type Endpoint4_7Output = EffectValue> -export type SessionSwitchModelOperation = (input: Endpoint4_7Input) => Effect.Effect +export type Endpoint5_7Output = EffectValue> +export type SessionSwitchModelOperation = (input: Endpoint5_7Input) => Effect.Effect -type Endpoint4_8Request = Parameters[0] -export type Endpoint4_8Input = { - readonly sessionID: Endpoint4_8Request["params"]["sessionID"] - readonly title: Endpoint4_8Request["payload"]["title"] +type Endpoint5_8Request = Parameters[0] +export type Endpoint5_8Input = { + readonly sessionID: Endpoint5_8Request["params"]["sessionID"] + readonly title: Endpoint5_8Request["payload"]["title"] } -export type Endpoint4_8Output = EffectValue> -export type SessionRenameOperation = (input: Endpoint4_8Input) => Effect.Effect +export type Endpoint5_8Output = EffectValue> +export type SessionRenameOperation = (input: Endpoint5_8Input) => Effect.Effect -type Endpoint4_9Request = Parameters[0] -export type Endpoint4_9Input = { - readonly sessionID: Endpoint4_9Request["params"]["sessionID"] - readonly destination: Endpoint4_9Request["payload"]["destination"] - readonly moveChanges?: Endpoint4_9Request["payload"]["moveChanges"] +type Endpoint5_9Request = Parameters[0] +export type Endpoint5_9Input = { + readonly sessionID: Endpoint5_9Request["params"]["sessionID"] + readonly destination: Endpoint5_9Request["payload"]["destination"] + readonly moveChanges?: Endpoint5_9Request["payload"]["moveChanges"] } -export type Endpoint4_9Output = EffectValue> -export type SessionMoveOperation = (input: Endpoint4_9Input) => Effect.Effect +export type Endpoint5_9Output = EffectValue> +export type SessionMoveOperation = (input: Endpoint5_9Input) => Effect.Effect -type Endpoint4_10Request = Parameters[0] -export type Endpoint4_10Input = { - readonly sessionID: Endpoint4_10Request["params"]["sessionID"] - readonly id?: Endpoint4_10Request["payload"]["id"] - readonly prompt: Endpoint4_10Request["payload"]["prompt"] - readonly delivery?: Endpoint4_10Request["payload"]["delivery"] - readonly resume?: Endpoint4_10Request["payload"]["resume"] +type Endpoint5_10Request = Parameters[0] +export type Endpoint5_10Input = { + readonly sessionID: Endpoint5_10Request["params"]["sessionID"] + readonly id?: Endpoint5_10Request["payload"]["id"] + readonly text: Endpoint5_10Request["payload"]["text"] + readonly files?: Endpoint5_10Request["payload"]["files"] + readonly agents?: Endpoint5_10Request["payload"]["agents"] + readonly metadata?: Endpoint5_10Request["payload"]["metadata"] + readonly delivery?: Endpoint5_10Request["payload"]["delivery"] + readonly resume?: Endpoint5_10Request["payload"]["resume"] } -export type Endpoint4_10Output = EffectValue>["data"] -export type SessionPromptOperation = (input: Endpoint4_10Input) => Effect.Effect +export type Endpoint5_10Output = EffectValue>["data"] +export type SessionPromptOperation = (input: Endpoint5_10Input) => Effect.Effect -type Endpoint4_11Request = Parameters[0] -export type Endpoint4_11Input = { - readonly sessionID: Endpoint4_11Request["params"]["sessionID"] - readonly id?: Endpoint4_11Request["payload"]["id"] - readonly command: Endpoint4_11Request["payload"]["command"] - readonly arguments?: Endpoint4_11Request["payload"]["arguments"] - readonly agent?: Endpoint4_11Request["payload"]["agent"] - readonly model?: Endpoint4_11Request["payload"]["model"] - readonly files?: Endpoint4_11Request["payload"]["files"] - readonly agents?: Endpoint4_11Request["payload"]["agents"] - readonly delivery?: Endpoint4_11Request["payload"]["delivery"] - readonly resume?: Endpoint4_11Request["payload"]["resume"] +type Endpoint5_11Request = Parameters[0] +export type Endpoint5_11Input = { + readonly sessionID: Endpoint5_11Request["params"]["sessionID"] + readonly id?: Endpoint5_11Request["payload"]["id"] + readonly command: Endpoint5_11Request["payload"]["command"] + readonly arguments?: Endpoint5_11Request["payload"]["arguments"] + readonly agent?: Endpoint5_11Request["payload"]["agent"] + readonly model?: Endpoint5_11Request["payload"]["model"] + readonly files?: Endpoint5_11Request["payload"]["files"] + readonly agents?: Endpoint5_11Request["payload"]["agents"] + readonly delivery?: Endpoint5_11Request["payload"]["delivery"] + readonly resume?: Endpoint5_11Request["payload"]["resume"] } -export type Endpoint4_11Output = EffectValue>["data"] -export type SessionCommandOperation = (input: Endpoint4_11Input) => Effect.Effect +export type Endpoint5_11Output = EffectValue>["data"] +export type SessionCommandOperation = (input: Endpoint5_11Input) => Effect.Effect -type Endpoint4_12Request = Parameters[0] -export type Endpoint4_12Input = { - readonly sessionID: Endpoint4_12Request["params"]["sessionID"] - readonly id?: Endpoint4_12Request["payload"]["id"] - readonly skill: Endpoint4_12Request["payload"]["skill"] - readonly resume?: Endpoint4_12Request["payload"]["resume"] +type Endpoint5_12Request = Parameters[0] +export type Endpoint5_12Input = { + readonly sessionID: Endpoint5_12Request["params"]["sessionID"] + readonly id?: Endpoint5_12Request["payload"]["id"] + readonly skill: Endpoint5_12Request["payload"]["skill"] + readonly resume?: Endpoint5_12Request["payload"]["resume"] } -export type Endpoint4_12Output = EffectValue> -export type SessionSkillOperation = (input: Endpoint4_12Input) => Effect.Effect +export type Endpoint5_12Output = EffectValue> +export type SessionSkillOperation = (input: Endpoint5_12Input) => Effect.Effect -type Endpoint4_13Request = Parameters[0] -export type Endpoint4_13Input = { - readonly sessionID: Endpoint4_13Request["params"]["sessionID"] - readonly text: Endpoint4_13Request["payload"]["text"] - readonly description?: Endpoint4_13Request["payload"]["description"] - readonly metadata?: Endpoint4_13Request["payload"]["metadata"] - readonly resume?: Endpoint4_13Request["payload"]["resume"] +type Endpoint5_13Request = Parameters[0] +export type Endpoint5_13Input = { + readonly sessionID: Endpoint5_13Request["params"]["sessionID"] + readonly id?: Endpoint5_13Request["payload"]["id"] + readonly text: Endpoint5_13Request["payload"]["text"] + readonly description?: Endpoint5_13Request["payload"]["description"] + readonly metadata?: Endpoint5_13Request["payload"]["metadata"] + readonly delivery?: Endpoint5_13Request["payload"]["delivery"] + readonly resume?: Endpoint5_13Request["payload"]["resume"] } -export type Endpoint4_13Output = EffectValue> -export type SessionSyntheticOperation = (input: Endpoint4_13Input) => Effect.Effect +export type Endpoint5_13Output = EffectValue>["data"] +export type SessionSyntheticOperation = (input: Endpoint5_13Input) => Effect.Effect -type Endpoint4_14Request = Parameters[0] -export type Endpoint4_14Input = { - readonly sessionID: Endpoint4_14Request["params"]["sessionID"] - readonly id?: Endpoint4_14Request["payload"]["id"] - readonly command: Endpoint4_14Request["payload"]["command"] +type Endpoint5_14Request = Parameters[0] +export type Endpoint5_14Input = { + readonly sessionID: Endpoint5_14Request["params"]["sessionID"] + readonly id?: Endpoint5_14Request["payload"]["id"] + readonly command: Endpoint5_14Request["payload"]["command"] } -export type Endpoint4_14Output = EffectValue> -export type SessionShellOperation = (input: Endpoint4_14Input) => Effect.Effect +export type Endpoint5_14Output = EffectValue> +export type SessionShellOperation = (input: Endpoint5_14Input) => Effect.Effect -type Endpoint4_15Request = Parameters[0] -export type Endpoint4_15Input = { - readonly sessionID: Endpoint4_15Request["params"]["sessionID"] - readonly id?: Endpoint4_15Request["payload"]["id"] +type Endpoint5_15Request = Parameters[0] +export type Endpoint5_15Input = { + readonly sessionID: Endpoint5_15Request["params"]["sessionID"] + readonly id?: Endpoint5_15Request["payload"]["id"] } -export type Endpoint4_15Output = EffectValue>["data"] -export type SessionCompactOperation = (input: Endpoint4_15Input) => Effect.Effect +export type Endpoint5_15Output = EffectValue>["data"] +export type SessionCompactOperation = (input: Endpoint5_15Input) => Effect.Effect -type Endpoint4_16Request = Parameters[0] -export type Endpoint4_16Input = { readonly sessionID: Endpoint4_16Request["params"]["sessionID"] } -export type Endpoint4_16Output = EffectValue> -export type SessionWaitOperation = (input: Endpoint4_16Input) => Effect.Effect +type Endpoint5_16Request = Parameters[0] +export type Endpoint5_16Input = { readonly sessionID: Endpoint5_16Request["params"]["sessionID"] } +export type Endpoint5_16Output = EffectValue> +export type SessionWaitOperation = (input: Endpoint5_16Input) => Effect.Effect -type Endpoint4_17Request = Parameters[0] -export type Endpoint4_17Input = { - readonly sessionID: Endpoint4_17Request["params"]["sessionID"] - readonly messageID: Endpoint4_17Request["payload"]["messageID"] - readonly files?: Endpoint4_17Request["payload"]["files"] +type Endpoint5_17Request = Parameters[0] +export type Endpoint5_17Input = { + readonly sessionID: Endpoint5_17Request["params"]["sessionID"] + readonly messageID: Endpoint5_17Request["payload"]["messageID"] + readonly files?: Endpoint5_17Request["payload"]["files"] } -export type Endpoint4_17Output = EffectValue>["data"] -export type SessionRevertStageOperation = (input: Endpoint4_17Input) => Effect.Effect +export type Endpoint5_17Output = EffectValue>["data"] +export type SessionRevertStageOperation = (input: Endpoint5_17Input) => Effect.Effect -type Endpoint4_18Request = Parameters[0] -export type Endpoint4_18Input = { readonly sessionID: Endpoint4_18Request["params"]["sessionID"] } -export type Endpoint4_18Output = EffectValue> -export type SessionRevertClearOperation = (input: Endpoint4_18Input) => Effect.Effect +type Endpoint5_18Request = Parameters[0] +export type Endpoint5_18Input = { readonly sessionID: Endpoint5_18Request["params"]["sessionID"] } +export type Endpoint5_18Output = EffectValue> +export type SessionRevertClearOperation = (input: Endpoint5_18Input) => Effect.Effect -type Endpoint4_19Request = Parameters[0] -export type Endpoint4_19Input = { readonly sessionID: Endpoint4_19Request["params"]["sessionID"] } -export type Endpoint4_19Output = EffectValue> -export type SessionRevertCommitOperation = (input: Endpoint4_19Input) => Effect.Effect +type Endpoint5_19Request = Parameters[0] +export type Endpoint5_19Input = { readonly sessionID: Endpoint5_19Request["params"]["sessionID"] } +export type Endpoint5_19Output = EffectValue> +export type SessionRevertCommitOperation = (input: Endpoint5_19Input) => Effect.Effect -type Endpoint4_20Request = Parameters[0] -export type Endpoint4_20Input = { readonly sessionID: Endpoint4_20Request["params"]["sessionID"] } -export type Endpoint4_20Output = EffectValue>["data"] -export type SessionContextOperation = (input: Endpoint4_20Input) => Effect.Effect +type Endpoint5_20Request = Parameters[0] +export type Endpoint5_20Input = { readonly sessionID: Endpoint5_20Request["params"]["sessionID"] } +export type Endpoint5_20Output = EffectValue>["data"] +export type SessionContextOperation = (input: Endpoint5_20Input) => Effect.Effect -type Endpoint4_21Request = Parameters[0] -export type Endpoint4_21Input = { readonly sessionID: Endpoint4_21Request["params"]["sessionID"] } -export type Endpoint4_21Output = EffectValue< +type Endpoint5_21Request = Parameters[0] +export type Endpoint5_21Input = { readonly sessionID: Endpoint5_21Request["params"]["sessionID"] } +export type Endpoint5_21Output = EffectValue< ReturnType >["data"] export type SessionInstructionsEntryListOperation = ( - input: Endpoint4_21Input, -) => Effect.Effect + input: Endpoint5_21Input, +) => Effect.Effect -type Endpoint4_22Request = Parameters[0] -export type Endpoint4_22Input = { - readonly sessionID: Endpoint4_22Request["params"]["sessionID"] - readonly key: Endpoint4_22Request["params"]["key"] - readonly value: Endpoint4_22Request["payload"]["value"] +type Endpoint5_22Request = Parameters[0] +export type Endpoint5_22Input = { + readonly sessionID: Endpoint5_22Request["params"]["sessionID"] + readonly key: Endpoint5_22Request["params"]["key"] + readonly value: Endpoint5_22Request["payload"]["value"] } -export type Endpoint4_22Output = EffectValue> +export type Endpoint5_22Output = EffectValue> export type SessionInstructionsEntryPutOperation = ( - input: Endpoint4_22Input, -) => Effect.Effect + input: Endpoint5_22Input, +) => Effect.Effect -type Endpoint4_23Request = Parameters[0] -export type Endpoint4_23Input = { - readonly sessionID: Endpoint4_23Request["params"]["sessionID"] - readonly key: Endpoint4_23Request["params"]["key"] +type Endpoint5_23Request = Parameters[0] +export type Endpoint5_23Input = { + readonly sessionID: Endpoint5_23Request["params"]["sessionID"] + readonly key: Endpoint5_23Request["params"]["key"] } -export type Endpoint4_23Output = EffectValue< +export type Endpoint5_23Output = EffectValue< ReturnType > export type SessionInstructionsEntryRemoveOperation = ( - input: Endpoint4_23Input, -) => Effect.Effect + input: Endpoint5_23Input, +) => Effect.Effect -type Endpoint4_24Request = Parameters[0] -export type Endpoint4_24Input = { - readonly sessionID: Endpoint4_24Request["params"]["sessionID"] - readonly after?: Endpoint4_24Request["query"]["after"] - readonly follow?: Endpoint4_24Request["query"]["follow"] +type Endpoint5_24Request = Parameters[0] +export type Endpoint5_24Input = { + readonly sessionID: Endpoint5_24Request["params"]["sessionID"] + readonly after?: Endpoint5_24Request["query"]["after"] + readonly follow?: Endpoint5_24Request["query"]["follow"] } -export type Endpoint4_24Output = StreamValue>> -export type SessionLogOperation = (input: Endpoint4_24Input) => Stream.Stream +export type Endpoint5_24Output = StreamValue>> +export type SessionLogOperation = (input: Endpoint5_24Input) => Stream.Stream -type Endpoint4_25Request = Parameters[0] -export type Endpoint4_25Input = { readonly sessionID: Endpoint4_25Request["params"]["sessionID"] } -export type Endpoint4_25Output = EffectValue> -export type SessionInterruptOperation = (input: Endpoint4_25Input) => Effect.Effect +type Endpoint5_25Request = Parameters[0] +export type Endpoint5_25Input = { readonly sessionID: Endpoint5_25Request["params"]["sessionID"] } +export type Endpoint5_25Output = EffectValue> +export type SessionInterruptOperation = (input: Endpoint5_25Input) => Effect.Effect -type Endpoint4_26Request = Parameters[0] -export type Endpoint4_26Input = { readonly sessionID: Endpoint4_26Request["params"]["sessionID"] } -export type Endpoint4_26Output = EffectValue> -export type SessionBackgroundOperation = (input: Endpoint4_26Input) => Effect.Effect +type Endpoint5_26Request = Parameters[0] +export type Endpoint5_26Input = { readonly sessionID: Endpoint5_26Request["params"]["sessionID"] } +export type Endpoint5_26Output = EffectValue> +export type SessionBackgroundOperation = (input: Endpoint5_26Input) => Effect.Effect -type Endpoint4_27Request = Parameters[0] -export type Endpoint4_27Input = { - readonly sessionID: Endpoint4_27Request["params"]["sessionID"] - readonly messageID: Endpoint4_27Request["params"]["messageID"] +type Endpoint5_27Request = Parameters[0] +export type Endpoint5_27Input = { + readonly sessionID: Endpoint5_27Request["params"]["sessionID"] + readonly messageID: Endpoint5_27Request["params"]["messageID"] } -export type Endpoint4_27Output = EffectValue>["data"] -export type SessionMessageOperation = (input: Endpoint4_27Input) => Effect.Effect +export type Endpoint5_27Output = EffectValue>["data"] +export type SessionMessageOperation = (input: Endpoint5_27Input) => Effect.Effect export interface SessionApi { readonly list: SessionListOperation @@ -310,132 +322,136 @@ export interface SessionApi { readonly message: SessionMessageOperation } -type Endpoint5_0Request = Parameters[0] -export type Endpoint5_0Input = { - readonly sessionID: Endpoint5_0Request["params"]["sessionID"] - readonly limit?: Endpoint5_0Request["query"]["limit"] - readonly order?: Endpoint5_0Request["query"]["order"] - readonly cursor?: Endpoint5_0Request["query"]["cursor"] +type Endpoint6_0Request = Parameters[0] +export type Endpoint6_0Input = { + readonly sessionID: Endpoint6_0Request["params"]["sessionID"] + readonly limit?: Endpoint6_0Request["query"]["limit"] + readonly order?: Endpoint6_0Request["query"]["order"] + readonly cursor?: Endpoint6_0Request["query"]["cursor"] } -export type Endpoint5_0Output = EffectValue> -export type MessageListOperation = (input: Endpoint5_0Input) => Effect.Effect +export type Endpoint6_0Output = EffectValue> +export type MessageListOperation = (input: Endpoint6_0Input) => Effect.Effect export interface MessageApi { readonly list: MessageListOperation } -type Endpoint6_0Request = Parameters[0] -export type Endpoint6_0Input = { readonly location?: Endpoint6_0Request["query"]["location"] } -export type Endpoint6_0Output = EffectValue> -export type ModelListOperation = (input?: Endpoint6_0Input) => Effect.Effect +type Endpoint7_0Request = Parameters[0] +export type Endpoint7_0Input = { readonly location?: Endpoint7_0Request["query"]["location"] } +export type Endpoint7_0Output = EffectValue> +export type ModelListOperation = (input?: Endpoint7_0Input) => Effect.Effect -type Endpoint6_1Request = Parameters[0] -export type Endpoint6_1Input = { readonly location?: Endpoint6_1Request["query"]["location"] } -export type Endpoint6_1Output = EffectValue> -export type ModelDefaultOperation = (input?: Endpoint6_1Input) => Effect.Effect +type Endpoint7_1Request = Parameters[0] +export type Endpoint7_1Input = { readonly location?: Endpoint7_1Request["query"]["location"] } +export type Endpoint7_1Output = EffectValue> +export type ModelDefaultOperation = (input?: Endpoint7_1Input) => Effect.Effect export interface ModelApi { readonly list: ModelListOperation readonly default: ModelDefaultOperation } -type Endpoint7_0Request = Parameters[0] -export type Endpoint7_0Input = { - readonly location?: Endpoint7_0Request["query"]["location"] - readonly prompt: Endpoint7_0Request["payload"]["prompt"] - readonly model?: Endpoint7_0Request["payload"]["model"] +type Endpoint8_0Request = Parameters[0] +export type Endpoint8_0Input = { + readonly location?: Endpoint8_0Request["query"]["location"] + readonly prompt: Endpoint8_0Request["payload"]["prompt"] + readonly model?: Endpoint8_0Request["payload"]["model"] } -export type Endpoint7_0Output = EffectValue>["data"] -export type GenerateTextOperation = (input: Endpoint7_0Input) => Effect.Effect +export type Endpoint8_0Output = EffectValue>["data"] +export type GenerateTextOperation = (input: Endpoint8_0Input) => Effect.Effect export interface GenerateApi { readonly text: GenerateTextOperation } -type Endpoint8_0Request = Parameters[0] -export type Endpoint8_0Input = { readonly location?: Endpoint8_0Request["query"]["location"] } -export type Endpoint8_0Output = EffectValue> -export type ProviderListOperation = (input?: Endpoint8_0Input) => Effect.Effect +type Endpoint9_0Request = Parameters[0] +export type Endpoint9_0Input = { readonly location?: Endpoint9_0Request["query"]["location"] } +export type Endpoint9_0Output = EffectValue> +export type ProviderListOperation = (input?: Endpoint9_0Input) => Effect.Effect -type Endpoint8_1Request = Parameters[0] -export type Endpoint8_1Input = { - readonly providerID: Endpoint8_1Request["params"]["providerID"] - readonly location?: Endpoint8_1Request["query"]["location"] +type Endpoint9_1Request = Parameters[0] +export type Endpoint9_1Input = { + readonly providerID: Endpoint9_1Request["params"]["providerID"] + readonly location?: Endpoint9_1Request["query"]["location"] } -export type Endpoint8_1Output = EffectValue> -export type ProviderGetOperation = (input: Endpoint8_1Input) => Effect.Effect +export type Endpoint9_1Output = EffectValue> +export type ProviderGetOperation = (input: Endpoint9_1Input) => Effect.Effect export interface ProviderApi { readonly list: ProviderListOperation readonly get: ProviderGetOperation } -type Endpoint9_0Request = Parameters[0] -export type Endpoint9_0Input = { readonly location?: Endpoint9_0Request["query"]["location"] } -export type Endpoint9_0Output = EffectValue> -export type IntegrationListOperation = (input?: Endpoint9_0Input) => Effect.Effect +type Endpoint10_0Request = Parameters[0] +export type Endpoint10_0Input = { readonly location?: Endpoint10_0Request["query"]["location"] } +export type Endpoint10_0Output = EffectValue> +export type IntegrationListOperation = (input?: Endpoint10_0Input) => Effect.Effect -type Endpoint9_1Request = Parameters[0] -export type Endpoint9_1Input = { - readonly integrationID: Endpoint9_1Request["params"]["integrationID"] - readonly location?: Endpoint9_1Request["query"]["location"] +type Endpoint10_1Request = Parameters[0] +export type Endpoint10_1Input = { + readonly integrationID: Endpoint10_1Request["params"]["integrationID"] + readonly location?: Endpoint10_1Request["query"]["location"] } -export type Endpoint9_1Output = EffectValue> -export type IntegrationGetOperation = (input: Endpoint9_1Input) => Effect.Effect +export type Endpoint10_1Output = EffectValue> +export type IntegrationGetOperation = (input: Endpoint10_1Input) => Effect.Effect -type Endpoint9_2Request = Parameters[0] -export type Endpoint9_2Input = { - readonly integrationID: Endpoint9_2Request["params"]["integrationID"] - readonly location?: Endpoint9_2Request["query"]["location"] - readonly key: Endpoint9_2Request["payload"]["key"] - readonly label?: Endpoint9_2Request["payload"]["label"] +type Endpoint10_2Request = Parameters[0] +export type Endpoint10_2Input = { + readonly integrationID: Endpoint10_2Request["params"]["integrationID"] + readonly location?: Endpoint10_2Request["query"]["location"] + readonly key: Endpoint10_2Request["payload"]["key"] + readonly label?: Endpoint10_2Request["payload"]["label"] } -export type Endpoint9_2Output = EffectValue> -export type IntegrationConnectKeyOperation = (input: Endpoint9_2Input) => Effect.Effect +export type Endpoint10_2Output = EffectValue> +export type IntegrationConnectKeyOperation = ( + input: Endpoint10_2Input, +) => Effect.Effect -type Endpoint9_3Request = Parameters[0] -export type Endpoint9_3Input = { - readonly integrationID: Endpoint9_3Request["params"]["integrationID"] - readonly location?: Endpoint9_3Request["query"]["location"] - readonly methodID: Endpoint9_3Request["payload"]["methodID"] - readonly inputs: Endpoint9_3Request["payload"]["inputs"] - readonly label?: Endpoint9_3Request["payload"]["label"] +type Endpoint10_3Request = Parameters[0] +export type Endpoint10_3Input = { + readonly integrationID: Endpoint10_3Request["params"]["integrationID"] + readonly location?: Endpoint10_3Request["query"]["location"] + readonly methodID: Endpoint10_3Request["payload"]["methodID"] + readonly inputs: Endpoint10_3Request["payload"]["inputs"] + readonly label?: Endpoint10_3Request["payload"]["label"] } -export type Endpoint9_3Output = EffectValue> +export type Endpoint10_3Output = EffectValue> export type IntegrationConnectOauthOperation = ( - input: Endpoint9_3Input, -) => Effect.Effect + input: Endpoint10_3Input, +) => Effect.Effect -type Endpoint9_4Request = Parameters[0] -export type Endpoint9_4Input = { - readonly attemptID: Endpoint9_4Request["params"]["attemptID"] - readonly location?: Endpoint9_4Request["query"]["location"] +type Endpoint10_4Request = Parameters[0] +export type Endpoint10_4Input = { + readonly attemptID: Endpoint10_4Request["params"]["attemptID"] + readonly location?: Endpoint10_4Request["query"]["location"] } -export type Endpoint9_4Output = EffectValue> +export type Endpoint10_4Output = EffectValue> export type IntegrationAttemptStatusOperation = ( - input: Endpoint9_4Input, -) => Effect.Effect + input: Endpoint10_4Input, +) => Effect.Effect -type Endpoint9_5Request = Parameters[0] -export type Endpoint9_5Input = { - readonly attemptID: Endpoint9_5Request["params"]["attemptID"] - readonly location?: Endpoint9_5Request["query"]["location"] - readonly code?: Endpoint9_5Request["payload"]["code"] +type Endpoint10_5Request = Parameters[0] +export type Endpoint10_5Input = { + readonly attemptID: Endpoint10_5Request["params"]["attemptID"] + readonly location?: Endpoint10_5Request["query"]["location"] + readonly code?: Endpoint10_5Request["payload"]["code"] } -export type Endpoint9_5Output = EffectValue> +export type Endpoint10_5Output = EffectValue< + ReturnType +> export type IntegrationAttemptCompleteOperation = ( - input: Endpoint9_5Input, -) => Effect.Effect + input: Endpoint10_5Input, +) => Effect.Effect -type Endpoint9_6Request = Parameters[0] -export type Endpoint9_6Input = { - readonly attemptID: Endpoint9_6Request["params"]["attemptID"] - readonly location?: Endpoint9_6Request["query"]["location"] +type Endpoint10_6Request = Parameters[0] +export type Endpoint10_6Input = { + readonly attemptID: Endpoint10_6Request["params"]["attemptID"] + readonly location?: Endpoint10_6Request["query"]["location"] } -export type Endpoint9_6Output = EffectValue> +export type Endpoint10_6Output = EffectValue> export type IntegrationAttemptCancelOperation = ( - input: Endpoint9_6Input, -) => Effect.Effect + input: Endpoint10_6Input, +) => Effect.Effect export interface IntegrationApi { readonly list: IntegrationListOperation @@ -451,60 +467,60 @@ export interface IntegrationApi { } } -type Endpoint10_0Request = Parameters[0] -export type Endpoint10_0Input = { readonly location?: Endpoint10_0Request["query"]["location"] } -export type Endpoint10_0Output = EffectValue> -export type ServerMcpListOperation = (input?: Endpoint10_0Input) => Effect.Effect +type Endpoint11_0Request = Parameters[0] +export type Endpoint11_0Input = { readonly location?: Endpoint11_0Request["query"]["location"] } +export type Endpoint11_0Output = EffectValue> +export type ServerMcpListOperation = (input?: Endpoint11_0Input) => Effect.Effect -type Endpoint10_1Request = Parameters[0] -export type Endpoint10_1Input = { readonly location?: Endpoint10_1Request["query"]["location"] } -export type Endpoint10_1Output = EffectValue> +type Endpoint11_1Request = Parameters[0] +export type Endpoint11_1Input = { readonly location?: Endpoint11_1Request["query"]["location"] } +export type Endpoint11_1Output = EffectValue> export type ServerMcpResourceCatalogOperation = ( - input?: Endpoint10_1Input, -) => Effect.Effect + input?: Endpoint11_1Input, +) => Effect.Effect export interface ServerMcpApi { readonly list: ServerMcpListOperation readonly resource: { readonly catalog: ServerMcpResourceCatalogOperation } } -type Endpoint11_0Request = Parameters[0] -export type Endpoint11_0Input = { - readonly credentialID: Endpoint11_0Request["params"]["credentialID"] - readonly location?: Endpoint11_0Request["query"]["location"] - readonly label: Endpoint11_0Request["payload"]["label"] +type Endpoint12_0Request = Parameters[0] +export type Endpoint12_0Input = { + readonly credentialID: Endpoint12_0Request["params"]["credentialID"] + readonly location?: Endpoint12_0Request["query"]["location"] + readonly label: Endpoint12_0Request["payload"]["label"] } -export type Endpoint11_0Output = EffectValue> -export type CredentialUpdateOperation = (input: Endpoint11_0Input) => Effect.Effect +export type Endpoint12_0Output = EffectValue> +export type CredentialUpdateOperation = (input: Endpoint12_0Input) => Effect.Effect -type Endpoint11_1Request = Parameters[0] -export type Endpoint11_1Input = { - readonly credentialID: Endpoint11_1Request["params"]["credentialID"] - readonly location?: Endpoint11_1Request["query"]["location"] +type Endpoint12_1Request = Parameters[0] +export type Endpoint12_1Input = { + readonly credentialID: Endpoint12_1Request["params"]["credentialID"] + readonly location?: Endpoint12_1Request["query"]["location"] } -export type Endpoint11_1Output = EffectValue> -export type CredentialRemoveOperation = (input: Endpoint11_1Input) => Effect.Effect +export type Endpoint12_1Output = EffectValue> +export type CredentialRemoveOperation = (input: Endpoint12_1Input) => Effect.Effect export interface CredentialApi { readonly update: CredentialUpdateOperation readonly remove: CredentialRemoveOperation } -export type Endpoint12_0Output = EffectValue> -export type ProjectListOperation = () => Effect.Effect +export type Endpoint13_0Output = EffectValue> +export type ProjectListOperation = () => Effect.Effect -type Endpoint12_1Request = Parameters[0] -export type Endpoint12_1Input = { readonly location?: Endpoint12_1Request["query"]["location"] } -export type Endpoint12_1Output = EffectValue> -export type ProjectCurrentOperation = (input?: Endpoint12_1Input) => Effect.Effect +type Endpoint13_1Request = Parameters[0] +export type Endpoint13_1Input = { readonly location?: Endpoint13_1Request["query"]["location"] } +export type Endpoint13_1Output = EffectValue> +export type ProjectCurrentOperation = (input?: Endpoint13_1Input) => Effect.Effect -type Endpoint12_2Request = Parameters[0] -export type Endpoint12_2Input = { - readonly projectID: Endpoint12_2Request["params"]["projectID"] - readonly location?: Endpoint12_2Request["query"]["location"] +type Endpoint13_2Request = Parameters[0] +export type Endpoint13_2Input = { + readonly projectID: Endpoint13_2Request["params"]["projectID"] + readonly location?: Endpoint13_2Request["query"]["location"] } -export type Endpoint12_2Output = EffectValue> -export type ProjectDirectoriesOperation = (input: Endpoint12_2Input) => Effect.Effect +export type Endpoint13_2Output = EffectValue> +export type ProjectDirectoriesOperation = (input: Endpoint13_2Input) => Effect.Effect export interface ProjectApi { readonly list: ProjectListOperation @@ -512,61 +528,61 @@ export interface ProjectApi { readonly directories: ProjectDirectoriesOperation } -type Endpoint13_0Request = Parameters[0] -export type Endpoint13_0Input = { readonly location?: Endpoint13_0Request["query"]["location"] } -export type Endpoint13_0Output = EffectValue> -export type FormRequestListOperation = (input?: Endpoint13_0Input) => Effect.Effect +type Endpoint14_0Request = Parameters[0] +export type Endpoint14_0Input = { readonly location?: Endpoint14_0Request["query"]["location"] } +export type Endpoint14_0Output = EffectValue> +export type FormRequestListOperation = (input?: Endpoint14_0Input) => Effect.Effect -type Endpoint13_1Request = Parameters[0] -export type Endpoint13_1Input = { readonly sessionID: Endpoint13_1Request["params"]["sessionID"] } -export type Endpoint13_1Output = EffectValue>["data"] -export type FormListOperation = (input: Endpoint13_1Input) => Effect.Effect +type Endpoint14_1Request = Parameters[0] +export type Endpoint14_1Input = { readonly sessionID: Endpoint14_1Request["params"]["sessionID"] } +export type Endpoint14_1Output = EffectValue>["data"] +export type FormListOperation = (input: Endpoint14_1Input) => Effect.Effect -type Endpoint13_2Request = Parameters[0] -export type Endpoint13_2Input = { - readonly sessionID: Endpoint13_2Request["params"]["sessionID"] - readonly id?: Endpoint13_2Request["payload"]["id"] - readonly title?: Endpoint13_2Request["payload"]["title"] - readonly metadata?: Endpoint13_2Request["payload"]["metadata"] - readonly mode: Endpoint13_2Request["payload"]["mode"] - readonly fields?: Endpoint13_2Request["payload"]["fields"] - readonly url?: Endpoint13_2Request["payload"]["url"] +type Endpoint14_2Request = Parameters[0] +export type Endpoint14_2Input = { + readonly sessionID: Endpoint14_2Request["params"]["sessionID"] + readonly id?: Endpoint14_2Request["payload"]["id"] + readonly title: Endpoint14_2Request["payload"]["title"] + readonly metadata?: Endpoint14_2Request["payload"]["metadata"] + readonly mode: Endpoint14_2Request["payload"]["mode"] + readonly fields?: Endpoint14_2Request["payload"]["fields"] + readonly url?: Endpoint14_2Request["payload"]["url"] } -export type Endpoint13_2Output = EffectValue>["data"] -export type FormCreateOperation = (input: Endpoint13_2Input) => Effect.Effect +export type Endpoint14_2Output = EffectValue>["data"] +export type FormCreateOperation = (input: Endpoint14_2Input) => Effect.Effect -type Endpoint13_3Request = Parameters[0] -export type Endpoint13_3Input = { - readonly sessionID: Endpoint13_3Request["params"]["sessionID"] - readonly formID: Endpoint13_3Request["params"]["formID"] +type Endpoint14_3Request = Parameters[0] +export type Endpoint14_3Input = { + readonly sessionID: Endpoint14_3Request["params"]["sessionID"] + readonly formID: Endpoint14_3Request["params"]["formID"] } -export type Endpoint13_3Output = EffectValue>["data"] -export type FormGetOperation = (input: Endpoint13_3Input) => Effect.Effect +export type Endpoint14_3Output = EffectValue>["data"] +export type FormGetOperation = (input: Endpoint14_3Input) => Effect.Effect -type Endpoint13_4Request = Parameters[0] -export type Endpoint13_4Input = { - readonly sessionID: Endpoint13_4Request["params"]["sessionID"] - readonly formID: Endpoint13_4Request["params"]["formID"] +type Endpoint14_4Request = Parameters[0] +export type Endpoint14_4Input = { + readonly sessionID: Endpoint14_4Request["params"]["sessionID"] + readonly formID: Endpoint14_4Request["params"]["formID"] } -export type Endpoint13_4Output = EffectValue>["data"] -export type FormStateOperation = (input: Endpoint13_4Input) => Effect.Effect +export type Endpoint14_4Output = EffectValue>["data"] +export type FormStateOperation = (input: Endpoint14_4Input) => Effect.Effect -type Endpoint13_5Request = Parameters[0] -export type Endpoint13_5Input = { - readonly sessionID: Endpoint13_5Request["params"]["sessionID"] - readonly formID: Endpoint13_5Request["params"]["formID"] - readonly answer: Endpoint13_5Request["payload"]["answer"] +type Endpoint14_5Request = Parameters[0] +export type Endpoint14_5Input = { + readonly sessionID: Endpoint14_5Request["params"]["sessionID"] + readonly formID: Endpoint14_5Request["params"]["formID"] + readonly answer: Endpoint14_5Request["payload"]["answer"] } -export type Endpoint13_5Output = EffectValue> -export type FormReplyOperation = (input: Endpoint13_5Input) => Effect.Effect +export type Endpoint14_5Output = EffectValue> +export type FormReplyOperation = (input: Endpoint14_5Input) => Effect.Effect -type Endpoint13_6Request = Parameters[0] -export type Endpoint13_6Input = { - readonly sessionID: Endpoint13_6Request["params"]["sessionID"] - readonly formID: Endpoint13_6Request["params"]["formID"] +type Endpoint14_6Request = Parameters[0] +export type Endpoint14_6Input = { + readonly sessionID: Endpoint14_6Request["params"]["sessionID"] + readonly formID: Endpoint14_6Request["params"]["formID"] } -export type Endpoint13_6Output = EffectValue> -export type FormCancelOperation = (input: Endpoint13_6Input) => Effect.Effect +export type Endpoint14_6Output = EffectValue> +export type FormCancelOperation = (input: Endpoint14_6Input) => Effect.Effect export interface FormApi { readonly request: { readonly list: FormRequestListOperation } @@ -578,71 +594,71 @@ export interface FormApi { readonly cancel: FormCancelOperation } -type Endpoint14_0Request = Parameters[0] -export type Endpoint14_0Input = { readonly location?: Endpoint14_0Request["query"]["location"] } -export type Endpoint14_0Output = EffectValue> +type Endpoint15_0Request = Parameters[0] +export type Endpoint15_0Input = { readonly location?: Endpoint15_0Request["query"]["location"] } +export type Endpoint15_0Output = EffectValue> export type PermissionRequestListOperation = ( - input?: Endpoint14_0Input, -) => Effect.Effect + input?: Endpoint15_0Input, +) => Effect.Effect -type Endpoint14_1Request = Parameters[0] -export type Endpoint14_1Input = { readonly projectID?: Endpoint14_1Request["query"]["projectID"] } -export type Endpoint14_1Output = EffectValue< +type Endpoint15_1Request = Parameters[0] +export type Endpoint15_1Input = { readonly projectID?: Endpoint15_1Request["query"]["projectID"] } +export type Endpoint15_1Output = EffectValue< ReturnType >["data"] export type PermissionSavedListOperation = ( - input?: Endpoint14_1Input, -) => Effect.Effect + input?: Endpoint15_1Input, +) => Effect.Effect -type Endpoint14_2Request = Parameters[0] -export type Endpoint14_2Input = { readonly id: Endpoint14_2Request["params"]["id"] } -export type Endpoint14_2Output = EffectValue> +type Endpoint15_2Request = Parameters[0] +export type Endpoint15_2Input = { readonly id: Endpoint15_2Request["params"]["id"] } +export type Endpoint15_2Output = EffectValue> export type PermissionSavedRemoveOperation = ( - input: Endpoint14_2Input, -) => Effect.Effect + input: Endpoint15_2Input, +) => Effect.Effect -type Endpoint14_3Request = Parameters[0] -export type Endpoint14_3Input = { - readonly sessionID: Endpoint14_3Request["params"]["sessionID"] - readonly id?: Endpoint14_3Request["payload"]["id"] - readonly action: Endpoint14_3Request["payload"]["action"] - readonly resources: Endpoint14_3Request["payload"]["resources"] - readonly save?: Endpoint14_3Request["payload"]["save"] - readonly metadata?: Endpoint14_3Request["payload"]["metadata"] - readonly source?: Endpoint14_3Request["payload"]["source"] - readonly agent?: Endpoint14_3Request["payload"]["agent"] +type Endpoint15_3Request = Parameters[0] +export type Endpoint15_3Input = { + readonly sessionID: Endpoint15_3Request["params"]["sessionID"] + readonly id?: Endpoint15_3Request["payload"]["id"] + readonly action: Endpoint15_3Request["payload"]["action"] + readonly resources: Endpoint15_3Request["payload"]["resources"] + readonly save?: Endpoint15_3Request["payload"]["save"] + readonly metadata?: Endpoint15_3Request["payload"]["metadata"] + readonly source?: Endpoint15_3Request["payload"]["source"] + readonly agent?: Endpoint15_3Request["payload"]["agent"] } -export type Endpoint14_3Output = EffectValue< +export type Endpoint15_3Output = EffectValue< ReturnType >["data"] -export type PermissionCreateOperation = (input: Endpoint14_3Input) => Effect.Effect +export type PermissionCreateOperation = (input: Endpoint15_3Input) => Effect.Effect -type Endpoint14_4Request = Parameters[0] -export type Endpoint14_4Input = { readonly sessionID: Endpoint14_4Request["params"]["sessionID"] } -export type Endpoint14_4Output = EffectValue< +type Endpoint15_4Request = Parameters[0] +export type Endpoint15_4Input = { readonly sessionID: Endpoint15_4Request["params"]["sessionID"] } +export type Endpoint15_4Output = EffectValue< ReturnType >["data"] -export type PermissionListOperation = (input: Endpoint14_4Input) => Effect.Effect +export type PermissionListOperation = (input: Endpoint15_4Input) => Effect.Effect -type Endpoint14_5Request = Parameters[0] -export type Endpoint14_5Input = { - readonly sessionID: Endpoint14_5Request["params"]["sessionID"] - readonly requestID: Endpoint14_5Request["params"]["requestID"] +type Endpoint15_5Request = Parameters[0] +export type Endpoint15_5Input = { + readonly sessionID: Endpoint15_5Request["params"]["sessionID"] + readonly requestID: Endpoint15_5Request["params"]["requestID"] } -export type Endpoint14_5Output = EffectValue< +export type Endpoint15_5Output = EffectValue< ReturnType >["data"] -export type PermissionGetOperation = (input: Endpoint14_5Input) => Effect.Effect +export type PermissionGetOperation = (input: Endpoint15_5Input) => Effect.Effect -type Endpoint14_6Request = Parameters[0] -export type Endpoint14_6Input = { - readonly sessionID: Endpoint14_6Request["params"]["sessionID"] - readonly requestID: Endpoint14_6Request["params"]["requestID"] - readonly reply: Endpoint14_6Request["payload"]["reply"] - readonly message?: Endpoint14_6Request["payload"]["message"] +type Endpoint15_6Request = Parameters[0] +export type Endpoint15_6Input = { + readonly sessionID: Endpoint15_6Request["params"]["sessionID"] + readonly requestID: Endpoint15_6Request["params"]["requestID"] + readonly reply: Endpoint15_6Request["payload"]["reply"] + readonly message?: Endpoint15_6Request["payload"]["message"] } -export type Endpoint14_6Output = EffectValue> -export type PermissionReplyOperation = (input: Endpoint14_6Input) => Effect.Effect +export type Endpoint15_6Output = EffectValue> +export type PermissionReplyOperation = (input: Endpoint15_6Input) => Effect.Effect export interface PermissionApi { readonly request: { readonly list: PermissionRequestListOperation } @@ -653,96 +669,96 @@ export interface PermissionApi { readonly reply: PermissionReplyOperation } -type Endpoint15_0Request = Parameters[0] -export type Endpoint15_0Input = { - readonly location?: Endpoint15_0Request["query"]["location"] - readonly path?: Endpoint15_0Request["query"]["path"] +type Endpoint16_0Request = Parameters[0] +export type Endpoint16_0Input = { + readonly location?: Endpoint16_0Request["query"]["location"] + readonly path?: Endpoint16_0Request["query"]["path"] } -export type Endpoint15_0Output = EffectValue> -export type FileListOperation = (input?: Endpoint15_0Input) => Effect.Effect +export type Endpoint16_0Output = EffectValue> +export type FileListOperation = (input?: Endpoint16_0Input) => Effect.Effect -type Endpoint15_1Request = Parameters[0] -export type Endpoint15_1Input = { - readonly location?: Endpoint15_1Request["query"]["location"] - readonly query: Endpoint15_1Request["query"]["query"] - readonly type?: Endpoint15_1Request["query"]["type"] - readonly limit?: Endpoint15_1Request["query"]["limit"] +type Endpoint16_1Request = Parameters[0] +export type Endpoint16_1Input = { + readonly location?: Endpoint16_1Request["query"]["location"] + readonly query: Endpoint16_1Request["query"]["query"] + readonly type?: Endpoint16_1Request["query"]["type"] + readonly limit?: Endpoint16_1Request["query"]["limit"] } -export type Endpoint15_1Output = EffectValue> -export type FileFindOperation = (input: Endpoint15_1Input) => Effect.Effect +export type Endpoint16_1Output = EffectValue> +export type FileFindOperation = (input: Endpoint16_1Input) => Effect.Effect export interface FileApi { readonly list: FileListOperation readonly find: FileFindOperation } -type Endpoint16_0Request = Parameters[0] -export type Endpoint16_0Input = { readonly location?: Endpoint16_0Request["query"]["location"] } -export type Endpoint16_0Output = EffectValue> -export type CommandListOperation = (input?: Endpoint16_0Input) => Effect.Effect +type Endpoint17_0Request = Parameters[0] +export type Endpoint17_0Input = { readonly location?: Endpoint17_0Request["query"]["location"] } +export type Endpoint17_0Output = EffectValue> +export type CommandListOperation = (input?: Endpoint17_0Input) => Effect.Effect export interface CommandApi { readonly list: CommandListOperation } -type Endpoint17_0Request = Parameters[0] -export type Endpoint17_0Input = { readonly location?: Endpoint17_0Request["query"]["location"] } -export type Endpoint17_0Output = EffectValue> -export type SkillListOperation = (input?: Endpoint17_0Input) => Effect.Effect +type Endpoint18_0Request = Parameters[0] +export type Endpoint18_0Input = { readonly location?: Endpoint18_0Request["query"]["location"] } +export type Endpoint18_0Output = EffectValue> +export type SkillListOperation = (input?: Endpoint18_0Input) => Effect.Effect export interface SkillApi { readonly list: SkillListOperation } -export type Endpoint18_0Output = StreamValue>> -export type EventSubscribeOperation = () => Stream.Stream +export type Endpoint19_0Output = StreamValue>> +export type EventSubscribeOperation = () => Stream.Stream export interface EventApi { readonly subscribe: EventSubscribeOperation } -type Endpoint19_0Request = Parameters[0] -export type Endpoint19_0Input = { readonly location?: Endpoint19_0Request["query"]["location"] } -export type Endpoint19_0Output = EffectValue> -export type PtyListOperation = (input?: Endpoint19_0Input) => Effect.Effect +type Endpoint20_0Request = Parameters[0] +export type Endpoint20_0Input = { readonly location?: Endpoint20_0Request["query"]["location"] } +export type Endpoint20_0Output = EffectValue> +export type PtyListOperation = (input?: Endpoint20_0Input) => Effect.Effect -type Endpoint19_1Request = Parameters[0] -export type Endpoint19_1Input = { - readonly location?: Endpoint19_1Request["query"]["location"] - readonly command?: Endpoint19_1Request["payload"]["command"] - readonly args?: Endpoint19_1Request["payload"]["args"] - readonly cwd?: Endpoint19_1Request["payload"]["cwd"] - readonly title?: Endpoint19_1Request["payload"]["title"] - readonly env?: Endpoint19_1Request["payload"]["env"] +type Endpoint20_1Request = Parameters[0] +export type Endpoint20_1Input = { + readonly location?: Endpoint20_1Request["query"]["location"] + readonly command?: Endpoint20_1Request["payload"]["command"] + readonly args?: Endpoint20_1Request["payload"]["args"] + readonly cwd?: Endpoint20_1Request["payload"]["cwd"] + readonly title?: Endpoint20_1Request["payload"]["title"] + readonly env?: Endpoint20_1Request["payload"]["env"] } -export type Endpoint19_1Output = EffectValue> -export type PtyCreateOperation = (input?: Endpoint19_1Input) => Effect.Effect +export type Endpoint20_1Output = EffectValue> +export type PtyCreateOperation = (input?: Endpoint20_1Input) => Effect.Effect -type Endpoint19_2Request = Parameters[0] -export type Endpoint19_2Input = { - readonly ptyID: Endpoint19_2Request["params"]["ptyID"] - readonly location?: Endpoint19_2Request["query"]["location"] +type Endpoint20_2Request = Parameters[0] +export type Endpoint20_2Input = { + readonly ptyID: Endpoint20_2Request["params"]["ptyID"] + readonly location?: Endpoint20_2Request["query"]["location"] } -export type Endpoint19_2Output = EffectValue> -export type PtyGetOperation = (input: Endpoint19_2Input) => Effect.Effect +export type Endpoint20_2Output = EffectValue> +export type PtyGetOperation = (input: Endpoint20_2Input) => Effect.Effect -type Endpoint19_3Request = Parameters[0] -export type Endpoint19_3Input = { - readonly ptyID: Endpoint19_3Request["params"]["ptyID"] - readonly location?: Endpoint19_3Request["query"]["location"] - readonly title?: Endpoint19_3Request["payload"]["title"] - readonly size?: Endpoint19_3Request["payload"]["size"] +type Endpoint20_3Request = Parameters[0] +export type Endpoint20_3Input = { + readonly ptyID: Endpoint20_3Request["params"]["ptyID"] + readonly location?: Endpoint20_3Request["query"]["location"] + readonly title?: Endpoint20_3Request["payload"]["title"] + readonly size?: Endpoint20_3Request["payload"]["size"] } -export type Endpoint19_3Output = EffectValue> -export type PtyUpdateOperation = (input: Endpoint19_3Input) => Effect.Effect +export type Endpoint20_3Output = EffectValue> +export type PtyUpdateOperation = (input: Endpoint20_3Input) => Effect.Effect -type Endpoint19_4Request = Parameters[0] -export type Endpoint19_4Input = { - readonly ptyID: Endpoint19_4Request["params"]["ptyID"] - readonly location?: Endpoint19_4Request["query"]["location"] +type Endpoint20_4Request = Parameters[0] +export type Endpoint20_4Input = { + readonly ptyID: Endpoint20_4Request["params"]["ptyID"] + readonly location?: Endpoint20_4Request["query"]["location"] } -export type Endpoint19_4Output = EffectValue> -export type PtyRemoveOperation = (input: Endpoint19_4Input) => Effect.Effect +export type Endpoint20_4Output = EffectValue> +export type PtyRemoveOperation = (input: Endpoint20_4Input) => Effect.Effect export interface PtyApi { readonly list: PtyListOperation @@ -752,56 +768,56 @@ export interface PtyApi { readonly remove: PtyRemoveOperation } -type Endpoint20_0Request = Parameters[0] -export type Endpoint20_0Input = { readonly location?: Endpoint20_0Request["query"]["location"] } -export type Endpoint20_0Output = EffectValue> -export type ShellListOperation = (input?: Endpoint20_0Input) => Effect.Effect +type Endpoint21_0Request = Parameters[0] +export type Endpoint21_0Input = { readonly location?: Endpoint21_0Request["query"]["location"] } +export type Endpoint21_0Output = EffectValue> +export type ShellListOperation = (input?: Endpoint21_0Input) => Effect.Effect -type Endpoint20_1Request = Parameters[0] -export type Endpoint20_1Input = { - readonly location?: Endpoint20_1Request["query"]["location"] - readonly command: Endpoint20_1Request["payload"]["command"] - readonly cwd?: Endpoint20_1Request["payload"]["cwd"] - readonly timeout: Endpoint20_1Request["payload"]["timeout"] - readonly metadata?: Endpoint20_1Request["payload"]["metadata"] +type Endpoint21_1Request = Parameters[0] +export type Endpoint21_1Input = { + readonly location?: Endpoint21_1Request["query"]["location"] + readonly command: Endpoint21_1Request["payload"]["command"] + readonly cwd?: Endpoint21_1Request["payload"]["cwd"] + readonly timeout: Endpoint21_1Request["payload"]["timeout"] + readonly metadata?: Endpoint21_1Request["payload"]["metadata"] } -export type Endpoint20_1Output = EffectValue> -export type ShellCreateOperation = (input: Endpoint20_1Input) => Effect.Effect +export type Endpoint21_1Output = EffectValue> +export type ShellCreateOperation = (input: Endpoint21_1Input) => Effect.Effect -type Endpoint20_2Request = Parameters[0] -export type Endpoint20_2Input = { - readonly id: Endpoint20_2Request["params"]["id"] - readonly location?: Endpoint20_2Request["query"]["location"] +type Endpoint21_2Request = Parameters[0] +export type Endpoint21_2Input = { + readonly id: Endpoint21_2Request["params"]["id"] + readonly location?: Endpoint21_2Request["query"]["location"] } -export type Endpoint20_2Output = EffectValue> -export type ShellGetOperation = (input: Endpoint20_2Input) => Effect.Effect +export type Endpoint21_2Output = EffectValue> +export type ShellGetOperation = (input: Endpoint21_2Input) => Effect.Effect -type Endpoint20_3Request = Parameters[0] -export type Endpoint20_3Input = { - readonly id: Endpoint20_3Request["params"]["id"] - readonly location?: Endpoint20_3Request["query"]["location"] - readonly timeout: Endpoint20_3Request["payload"]["timeout"] +type Endpoint21_3Request = Parameters[0] +export type Endpoint21_3Input = { + readonly id: Endpoint21_3Request["params"]["id"] + readonly location?: Endpoint21_3Request["query"]["location"] + readonly timeout: Endpoint21_3Request["payload"]["timeout"] } -export type Endpoint20_3Output = EffectValue> -export type ShellTimeoutOperation = (input: Endpoint20_3Input) => Effect.Effect +export type Endpoint21_3Output = EffectValue> +export type ShellTimeoutOperation = (input: Endpoint21_3Input) => Effect.Effect -type Endpoint20_4Request = Parameters[0] -export type Endpoint20_4Input = { - readonly id: Endpoint20_4Request["params"]["id"] - readonly location?: Endpoint20_4Request["query"]["location"] - readonly cursor?: Endpoint20_4Request["query"]["cursor"] - readonly limit?: Endpoint20_4Request["query"]["limit"] +type Endpoint21_4Request = Parameters[0] +export type Endpoint21_4Input = { + readonly id: Endpoint21_4Request["params"]["id"] + readonly location?: Endpoint21_4Request["query"]["location"] + readonly cursor?: Endpoint21_4Request["query"]["cursor"] + readonly limit?: Endpoint21_4Request["query"]["limit"] } -export type Endpoint20_4Output = EffectValue> -export type ShellOutputOperation = (input: Endpoint20_4Input) => Effect.Effect +export type Endpoint21_4Output = EffectValue> +export type ShellOutputOperation = (input: Endpoint21_4Input) => Effect.Effect -type Endpoint20_5Request = Parameters[0] -export type Endpoint20_5Input = { - readonly id: Endpoint20_5Request["params"]["id"] - readonly location?: Endpoint20_5Request["query"]["location"] +type Endpoint21_5Request = Parameters[0] +export type Endpoint21_5Input = { + readonly id: Endpoint21_5Request["params"]["id"] + readonly location?: Endpoint21_5Request["query"]["location"] } -export type Endpoint20_5Output = EffectValue> -export type ShellRemoveOperation = (input: Endpoint20_5Input) => Effect.Effect +export type Endpoint21_5Output = EffectValue> +export type ShellRemoveOperation = (input: Endpoint21_5Input) => Effect.Effect export interface ShellApi { readonly list: ShellListOperation @@ -812,34 +828,34 @@ export interface ShellApi { readonly remove: ShellRemoveOperation } -type Endpoint21_0Request = Parameters[0] -export type Endpoint21_0Input = { readonly location?: Endpoint21_0Request["query"]["location"] } -export type Endpoint21_0Output = EffectValue> +type Endpoint22_0Request = Parameters[0] +export type Endpoint22_0Input = { readonly location?: Endpoint22_0Request["query"]["location"] } +export type Endpoint22_0Output = EffectValue> export type QuestionRequestListOperation = ( - input?: Endpoint21_0Input, -) => Effect.Effect + input?: Endpoint22_0Input, +) => Effect.Effect -type Endpoint21_1Request = Parameters[0] -export type Endpoint21_1Input = { readonly sessionID: Endpoint21_1Request["params"]["sessionID"] } -export type Endpoint21_1Output = EffectValue>["data"] -export type QuestionListOperation = (input: Endpoint21_1Input) => Effect.Effect +type Endpoint22_1Request = Parameters[0] +export type Endpoint22_1Input = { readonly sessionID: Endpoint22_1Request["params"]["sessionID"] } +export type Endpoint22_1Output = EffectValue>["data"] +export type QuestionListOperation = (input: Endpoint22_1Input) => Effect.Effect -type Endpoint21_2Request = Parameters[0] -export type Endpoint21_2Input = { - readonly sessionID: Endpoint21_2Request["params"]["sessionID"] - readonly requestID: Endpoint21_2Request["params"]["requestID"] - readonly answers: Endpoint21_2Request["payload"]["answers"] +type Endpoint22_2Request = Parameters[0] +export type Endpoint22_2Input = { + readonly sessionID: Endpoint22_2Request["params"]["sessionID"] + readonly requestID: Endpoint22_2Request["params"]["requestID"] + readonly answers: Endpoint22_2Request["payload"]["answers"] } -export type Endpoint21_2Output = EffectValue> -export type QuestionReplyOperation = (input: Endpoint21_2Input) => Effect.Effect +export type Endpoint22_2Output = EffectValue> +export type QuestionReplyOperation = (input: Endpoint22_2Input) => Effect.Effect -type Endpoint21_3Request = Parameters[0] -export type Endpoint21_3Input = { - readonly sessionID: Endpoint21_3Request["params"]["sessionID"] - readonly requestID: Endpoint21_3Request["params"]["requestID"] +type Endpoint22_3Request = Parameters[0] +export type Endpoint22_3Input = { + readonly sessionID: Endpoint22_3Request["params"]["sessionID"] + readonly requestID: Endpoint22_3Request["params"]["requestID"] } -export type Endpoint21_3Output = EffectValue> -export type QuestionRejectOperation = (input: Endpoint21_3Input) => Effect.Effect +export type Endpoint22_3Output = EffectValue> +export type QuestionRejectOperation = (input: Endpoint22_3Input) => Effect.Effect export interface QuestionApi { readonly request: { readonly list: QuestionRequestListOperation } @@ -848,43 +864,43 @@ export interface QuestionApi { readonly reject: QuestionRejectOperation } -type Endpoint22_0Request = Parameters[0] -export type Endpoint22_0Input = { readonly location?: Endpoint22_0Request["query"]["location"] } -export type Endpoint22_0Output = EffectValue> -export type ReferenceListOperation = (input?: Endpoint22_0Input) => Effect.Effect +type Endpoint23_0Request = Parameters[0] +export type Endpoint23_0Input = { readonly location?: Endpoint23_0Request["query"]["location"] } +export type Endpoint23_0Output = EffectValue> +export type ReferenceListOperation = (input?: Endpoint23_0Input) => Effect.Effect export interface ReferenceApi { readonly list: ReferenceListOperation } -type Endpoint23_0Request = Parameters[0] -export type Endpoint23_0Input = { - readonly projectID: Endpoint23_0Request["params"]["projectID"] - readonly location?: Endpoint23_0Request["query"]["location"] - readonly strategy: Endpoint23_0Request["payload"]["strategy"] - readonly directory: Endpoint23_0Request["payload"]["directory"] - readonly name?: Endpoint23_0Request["payload"]["name"] +type Endpoint24_0Request = Parameters[0] +export type Endpoint24_0Input = { + readonly projectID: Endpoint24_0Request["params"]["projectID"] + readonly location?: Endpoint24_0Request["query"]["location"] + readonly strategy: Endpoint24_0Request["payload"]["strategy"] + readonly directory: Endpoint24_0Request["payload"]["directory"] + readonly name?: Endpoint24_0Request["payload"]["name"] } -export type Endpoint23_0Output = EffectValue> -export type ProjectCopyCreateOperation = (input: Endpoint23_0Input) => Effect.Effect +export type Endpoint24_0Output = EffectValue> +export type ProjectCopyCreateOperation = (input: Endpoint24_0Input) => Effect.Effect -type Endpoint23_1Request = Parameters[0] -export type Endpoint23_1Input = { - readonly projectID: Endpoint23_1Request["params"]["projectID"] - readonly location?: Endpoint23_1Request["query"]["location"] - readonly directory: Endpoint23_1Request["payload"]["directory"] - readonly force: Endpoint23_1Request["payload"]["force"] +type Endpoint24_1Request = Parameters[0] +export type Endpoint24_1Input = { + readonly projectID: Endpoint24_1Request["params"]["projectID"] + readonly location?: Endpoint24_1Request["query"]["location"] + readonly directory: Endpoint24_1Request["payload"]["directory"] + readonly force: Endpoint24_1Request["payload"]["force"] } -export type Endpoint23_1Output = EffectValue> -export type ProjectCopyRemoveOperation = (input: Endpoint23_1Input) => Effect.Effect +export type Endpoint24_1Output = EffectValue> +export type ProjectCopyRemoveOperation = (input: Endpoint24_1Input) => Effect.Effect -type Endpoint23_2Request = Parameters[0] -export type Endpoint23_2Input = { - readonly projectID: Endpoint23_2Request["params"]["projectID"] - readonly location?: Endpoint23_2Request["query"]["location"] +type Endpoint24_2Request = Parameters[0] +export type Endpoint24_2Input = { + readonly projectID: Endpoint24_2Request["params"]["projectID"] + readonly location?: Endpoint24_2Request["query"]["location"] } -export type Endpoint23_2Output = EffectValue> -export type ProjectCopyRefreshOperation = (input: Endpoint23_2Input) => Effect.Effect +export type Endpoint24_2Output = EffectValue> +export type ProjectCopyRefreshOperation = (input: Endpoint24_2Input) => Effect.Effect export interface ProjectCopyApi { readonly create: ProjectCopyCreateOperation @@ -892,32 +908,32 @@ export interface ProjectCopyApi { readonly refresh: ProjectCopyRefreshOperation } -type Endpoint24_0Request = Parameters[0] -export type Endpoint24_0Input = { readonly location?: Endpoint24_0Request["query"]["location"] } -export type Endpoint24_0Output = EffectValue> -export type VcsStatusOperation = (input?: Endpoint24_0Input) => Effect.Effect +type Endpoint25_0Request = Parameters[0] +export type Endpoint25_0Input = { readonly location?: Endpoint25_0Request["query"]["location"] } +export type Endpoint25_0Output = EffectValue> +export type VcsStatusOperation = (input?: Endpoint25_0Input) => Effect.Effect -type Endpoint24_1Request = Parameters[0] -export type Endpoint24_1Input = { - readonly location?: Endpoint24_1Request["query"]["location"] - readonly mode: Endpoint24_1Request["query"]["mode"] - readonly context?: Endpoint24_1Request["query"]["context"] +type Endpoint25_1Request = Parameters[0] +export type Endpoint25_1Input = { + readonly location?: Endpoint25_1Request["query"]["location"] + readonly mode: Endpoint25_1Request["query"]["mode"] + readonly context?: Endpoint25_1Request["query"]["context"] } -export type Endpoint24_1Output = EffectValue> -export type VcsDiffOperation = (input: Endpoint24_1Input) => Effect.Effect +export type Endpoint25_1Output = EffectValue> +export type VcsDiffOperation = (input: Endpoint25_1Input) => Effect.Effect export interface VcsApi { readonly status: VcsStatusOperation readonly diff: VcsDiffOperation } -export type Endpoint25_0Output = EffectValue> -export type DebugLocationListOperation = () => Effect.Effect +export type Endpoint26_0Output = EffectValue> +export type DebugLocationListOperation = () => Effect.Effect -type Endpoint25_1Request = Parameters[0] -export type Endpoint25_1Input = { readonly location?: Endpoint25_1Request["query"]["location"] } -export type Endpoint25_1Output = EffectValue> -export type DebugLocationEvictOperation = (input?: Endpoint25_1Input) => Effect.Effect +type Endpoint26_1Request = Parameters[0] +export type Endpoint26_1Input = { readonly location?: Endpoint26_1Request["query"]["location"] } +export type Endpoint26_1Output = EffectValue> +export type DebugLocationEvictOperation = (input?: Endpoint26_1Input) => Effect.Effect export interface DebugApi { readonly location: { readonly list: DebugLocationListOperation; readonly evict: DebugLocationEvictOperation } @@ -925,6 +941,7 @@ export interface DebugApi { export interface AppApi { readonly health: HealthApi + readonly server: ServerApi readonly location: LocationApi readonly agent: AgentApi readonly plugin: PluginApi diff --git a/packages/client/src/effect/generated/client.ts b/packages/client/src/effect/generated/client.ts index 5175567f45..3b39da28a0 100644 --- a/packages/client/src/effect/generated/client.ts +++ b/packages/client/src/effect/generated/client.ts @@ -18,40 +18,45 @@ const Endpoint0_0 = (raw: RawClient["server.health"]) => () => const adaptGroup0 = (raw: RawClient["server.health"]) => ({ get: Endpoint0_0(raw) }) -type Endpoint1_0Request = Parameters[0] -type Endpoint1_0Input = { readonly location?: Endpoint1_0Request["query"]["location"] } -const Endpoint1_0 = (raw: RawClient["server.location"]) => (input?: Endpoint1_0Input) => +const Endpoint1_0 = (raw: RawClient["server.server"]) => () => + raw["server.get"]({}).pipe(Effect.mapError(mapClientError)) + +const adaptGroup1 = (raw: RawClient["server.server"]) => ({ get: Endpoint1_0(raw) }) + +type Endpoint2_0Request = Parameters[0] +type Endpoint2_0Input = { readonly location?: Endpoint2_0Request["query"]["location"] } +const Endpoint2_0 = (raw: RawClient["server.location"]) => (input?: Endpoint2_0Input) => raw["location.get"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)) -const adaptGroup1 = (raw: RawClient["server.location"]) => ({ get: Endpoint1_0(raw) }) +const adaptGroup2 = (raw: RawClient["server.location"]) => ({ get: Endpoint2_0(raw) }) -type Endpoint2_0Request = Parameters[0] -type Endpoint2_0Input = { readonly location?: Endpoint2_0Request["query"]["location"] } -const Endpoint2_0 = (raw: RawClient["server.agent"]) => (input?: Endpoint2_0Input) => +type Endpoint3_0Request = Parameters[0] +type Endpoint3_0Input = { readonly location?: Endpoint3_0Request["query"]["location"] } +const Endpoint3_0 = (raw: RawClient["server.agent"]) => (input?: Endpoint3_0Input) => raw["agent.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)) -const adaptGroup2 = (raw: RawClient["server.agent"]) => ({ list: Endpoint2_0(raw) }) +const adaptGroup3 = (raw: RawClient["server.agent"]) => ({ list: Endpoint3_0(raw) }) -type Endpoint3_0Request = Parameters[0] -type Endpoint3_0Input = { readonly location?: Endpoint3_0Request["query"]["location"] } -const Endpoint3_0 = (raw: RawClient["server.plugin"]) => (input?: Endpoint3_0Input) => +type Endpoint4_0Request = Parameters[0] +type Endpoint4_0Input = { readonly location?: Endpoint4_0Request["query"]["location"] } +const Endpoint4_0 = (raw: RawClient["server.plugin"]) => (input?: Endpoint4_0Input) => raw["plugin.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)) -const adaptGroup3 = (raw: RawClient["server.plugin"]) => ({ list: Endpoint3_0(raw) }) +const adaptGroup4 = (raw: RawClient["server.plugin"]) => ({ list: Endpoint4_0(raw) }) -type Endpoint4_0Request = Parameters[0] -type Endpoint4_0Input = { - readonly workspace?: Endpoint4_0Request["query"]["workspace"] - readonly limit?: Endpoint4_0Request["query"]["limit"] - readonly order?: Endpoint4_0Request["query"]["order"] - readonly search?: Endpoint4_0Request["query"]["search"] - readonly parentID?: Endpoint4_0Request["query"]["parentID"] - readonly directory?: Endpoint4_0Request["query"]["directory"] - readonly project?: Endpoint4_0Request["query"]["project"] - readonly subpath?: Endpoint4_0Request["query"]["subpath"] - readonly cursor?: Endpoint4_0Request["query"]["cursor"] +type Endpoint5_0Request = Parameters[0] +type Endpoint5_0Input = { + readonly workspace?: Endpoint5_0Request["query"]["workspace"] + readonly limit?: Endpoint5_0Request["query"]["limit"] + readonly order?: Endpoint5_0Request["query"]["order"] + readonly search?: Endpoint5_0Request["query"]["search"] + readonly parentID?: Endpoint5_0Request["query"]["parentID"] + readonly directory?: Endpoint5_0Request["query"]["directory"] + readonly project?: Endpoint5_0Request["query"]["project"] + readonly subpath?: Endpoint5_0Request["query"]["subpath"] + readonly cursor?: Endpoint5_0Request["query"]["cursor"] } -const Endpoint4_0 = (raw: RawClient["server.session"]) => (input?: Endpoint4_0Input) => +const Endpoint5_0 = (raw: RawClient["server.session"]) => (input?: Endpoint5_0Input) => raw["session.list"]({ query: { workspace: input?.["workspace"], @@ -66,14 +71,14 @@ const Endpoint4_0 = (raw: RawClient["server.session"]) => (input?: Endpoint4_0In }, }).pipe(Effect.mapError(mapClientError)) -type Endpoint4_1Request = Parameters[0] -type Endpoint4_1Input = { - readonly id?: Endpoint4_1Request["payload"]["id"] - readonly agent?: Endpoint4_1Request["payload"]["agent"] - readonly model?: Endpoint4_1Request["payload"]["model"] - readonly location?: Endpoint4_1Request["payload"]["location"] +type Endpoint5_1Request = Parameters[0] +type Endpoint5_1Input = { + readonly id?: Endpoint5_1Request["payload"]["id"] + readonly agent?: Endpoint5_1Request["payload"]["agent"] + readonly model?: Endpoint5_1Request["payload"]["model"] + readonly location?: Endpoint5_1Request["payload"]["location"] } -const Endpoint4_1 = (raw: RawClient["server.session"]) => (input?: Endpoint4_1Input) => +const Endpoint5_1 = (raw: RawClient["server.session"]) => (input?: Endpoint5_1Input) => raw["session.create"]({ payload: { id: input?.["id"], agent: input?.["agent"], model: input?.["model"], location: input?.["location"] }, }).pipe( @@ -81,109 +86,120 @@ const Endpoint4_1 = (raw: RawClient["server.session"]) => (input?: Endpoint4_1In Effect.map((value) => value.data), ) -const Endpoint4_2 = (raw: RawClient["server.session"]) => () => +const Endpoint5_2 = (raw: RawClient["server.session"]) => () => raw["session.active"]({}).pipe( Effect.mapError(mapClientError), Effect.map((value) => value.data), ) -type Endpoint4_3Request = Parameters[0] -type Endpoint4_3Input = { readonly sessionID: Endpoint4_3Request["params"]["sessionID"] } -const Endpoint4_3 = (raw: RawClient["server.session"]) => (input: Endpoint4_3Input) => +type Endpoint5_3Request = Parameters[0] +type Endpoint5_3Input = { readonly sessionID: Endpoint5_3Request["params"]["sessionID"] } +const Endpoint5_3 = (raw: RawClient["server.session"]) => (input: Endpoint5_3Input) => raw["session.get"]({ params: { sessionID: input["sessionID"] } }).pipe( Effect.mapError(mapClientError), Effect.map((value) => value.data), ) -type Endpoint4_4Request = Parameters[0] -type Endpoint4_4Input = { readonly sessionID: Endpoint4_4Request["params"]["sessionID"] } -const Endpoint4_4 = (raw: RawClient["server.session"]) => (input: Endpoint4_4Input) => +type Endpoint5_4Request = Parameters[0] +type Endpoint5_4Input = { readonly sessionID: Endpoint5_4Request["params"]["sessionID"] } +const Endpoint5_4 = (raw: RawClient["server.session"]) => (input: Endpoint5_4Input) => raw["session.remove"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)) -type Endpoint4_5Request = Parameters[0] -type Endpoint4_5Input = { - readonly sessionID: Endpoint4_5Request["params"]["sessionID"] - readonly messageID?: Endpoint4_5Request["payload"]["messageID"] +type Endpoint5_5Request = Parameters[0] +type Endpoint5_5Input = { + readonly sessionID: Endpoint5_5Request["params"]["sessionID"] + readonly messageID?: Endpoint5_5Request["payload"]["messageID"] } -const Endpoint4_5 = (raw: RawClient["server.session"]) => (input: Endpoint4_5Input) => +const Endpoint5_5 = (raw: RawClient["server.session"]) => (input: Endpoint5_5Input) => raw["session.fork"]({ params: { sessionID: input["sessionID"] }, payload: { messageID: input["messageID"] } }).pipe( Effect.mapError(mapClientError), Effect.map((value) => value.data), ) -type Endpoint4_6Request = Parameters[0] -type Endpoint4_6Input = { - readonly sessionID: Endpoint4_6Request["params"]["sessionID"] - readonly agent: Endpoint4_6Request["payload"]["agent"] +type Endpoint5_6Request = Parameters[0] +type Endpoint5_6Input = { + readonly sessionID: Endpoint5_6Request["params"]["sessionID"] + readonly agent: Endpoint5_6Request["payload"]["agent"] } -const Endpoint4_6 = (raw: RawClient["server.session"]) => (input: Endpoint4_6Input) => +const Endpoint5_6 = (raw: RawClient["server.session"]) => (input: Endpoint5_6Input) => raw["session.switchAgent"]({ params: { sessionID: input["sessionID"] }, payload: { agent: input["agent"] } }).pipe( Effect.mapError(mapClientError), ) -type Endpoint4_7Request = Parameters[0] -type Endpoint4_7Input = { - readonly sessionID: Endpoint4_7Request["params"]["sessionID"] - readonly model: Endpoint4_7Request["payload"]["model"] +type Endpoint5_7Request = Parameters[0] +type Endpoint5_7Input = { + readonly sessionID: Endpoint5_7Request["params"]["sessionID"] + readonly model: Endpoint5_7Request["payload"]["model"] } -const Endpoint4_7 = (raw: RawClient["server.session"]) => (input: Endpoint4_7Input) => +const Endpoint5_7 = (raw: RawClient["server.session"]) => (input: Endpoint5_7Input) => raw["session.switchModel"]({ params: { sessionID: input["sessionID"] }, payload: { model: input["model"] } }).pipe( Effect.mapError(mapClientError), ) -type Endpoint4_8Request = Parameters[0] -type Endpoint4_8Input = { - readonly sessionID: Endpoint4_8Request["params"]["sessionID"] - readonly title: Endpoint4_8Request["payload"]["title"] +type Endpoint5_8Request = Parameters[0] +type Endpoint5_8Input = { + readonly sessionID: Endpoint5_8Request["params"]["sessionID"] + readonly title: Endpoint5_8Request["payload"]["title"] } -const Endpoint4_8 = (raw: RawClient["server.session"]) => (input: Endpoint4_8Input) => +const Endpoint5_8 = (raw: RawClient["server.session"]) => (input: Endpoint5_8Input) => raw["session.rename"]({ params: { sessionID: input["sessionID"] }, payload: { title: input["title"] } }).pipe( Effect.mapError(mapClientError), ) -type Endpoint4_9Request = Parameters[0] -type Endpoint4_9Input = { - readonly sessionID: Endpoint4_9Request["params"]["sessionID"] - readonly destination: Endpoint4_9Request["payload"]["destination"] - readonly moveChanges?: Endpoint4_9Request["payload"]["moveChanges"] +type Endpoint5_9Request = Parameters[0] +type Endpoint5_9Input = { + readonly sessionID: Endpoint5_9Request["params"]["sessionID"] + readonly destination: Endpoint5_9Request["payload"]["destination"] + readonly moveChanges?: Endpoint5_9Request["payload"]["moveChanges"] } -const Endpoint4_9 = (raw: RawClient["server.session"]) => (input: Endpoint4_9Input) => +const Endpoint5_9 = (raw: RawClient["server.session"]) => (input: Endpoint5_9Input) => raw["session.move"]({ params: { sessionID: input["sessionID"] }, payload: { destination: input["destination"], moveChanges: input["moveChanges"] }, }).pipe(Effect.mapError(mapClientError)) -type Endpoint4_10Request = Parameters[0] -type Endpoint4_10Input = { - readonly sessionID: Endpoint4_10Request["params"]["sessionID"] - readonly id?: Endpoint4_10Request["payload"]["id"] - readonly prompt: Endpoint4_10Request["payload"]["prompt"] - readonly delivery?: Endpoint4_10Request["payload"]["delivery"] - readonly resume?: Endpoint4_10Request["payload"]["resume"] +type Endpoint5_10Request = Parameters[0] +type Endpoint5_10Input = { + readonly sessionID: Endpoint5_10Request["params"]["sessionID"] + readonly id?: Endpoint5_10Request["payload"]["id"] + readonly text: Endpoint5_10Request["payload"]["text"] + readonly files?: Endpoint5_10Request["payload"]["files"] + readonly agents?: Endpoint5_10Request["payload"]["agents"] + readonly metadata?: Endpoint5_10Request["payload"]["metadata"] + readonly delivery?: Endpoint5_10Request["payload"]["delivery"] + readonly resume?: Endpoint5_10Request["payload"]["resume"] } -const Endpoint4_10 = (raw: RawClient["server.session"]) => (input: Endpoint4_10Input) => +const Endpoint5_10 = (raw: RawClient["server.session"]) => (input: Endpoint5_10Input) => raw["session.prompt"]({ params: { sessionID: input["sessionID"] }, - payload: { id: input["id"], prompt: input["prompt"], delivery: input["delivery"], resume: input["resume"] }, + payload: { + id: input["id"], + text: input["text"], + files: input["files"], + agents: input["agents"], + metadata: input["metadata"], + delivery: input["delivery"], + resume: input["resume"], + }, }).pipe( Effect.mapError(mapClientError), Effect.map((value) => value.data), ) -type Endpoint4_11Request = Parameters[0] -type Endpoint4_11Input = { - readonly sessionID: Endpoint4_11Request["params"]["sessionID"] - readonly id?: Endpoint4_11Request["payload"]["id"] - readonly command: Endpoint4_11Request["payload"]["command"] - readonly arguments?: Endpoint4_11Request["payload"]["arguments"] - readonly agent?: Endpoint4_11Request["payload"]["agent"] - readonly model?: Endpoint4_11Request["payload"]["model"] - readonly files?: Endpoint4_11Request["payload"]["files"] - readonly agents?: Endpoint4_11Request["payload"]["agents"] - readonly delivery?: Endpoint4_11Request["payload"]["delivery"] - readonly resume?: Endpoint4_11Request["payload"]["resume"] +type Endpoint5_11Request = Parameters[0] +type Endpoint5_11Input = { + readonly sessionID: Endpoint5_11Request["params"]["sessionID"] + readonly id?: Endpoint5_11Request["payload"]["id"] + readonly command: Endpoint5_11Request["payload"]["command"] + readonly arguments?: Endpoint5_11Request["payload"]["arguments"] + readonly agent?: Endpoint5_11Request["payload"]["agent"] + readonly model?: Endpoint5_11Request["payload"]["model"] + readonly files?: Endpoint5_11Request["payload"]["files"] + readonly agents?: Endpoint5_11Request["payload"]["agents"] + readonly delivery?: Endpoint5_11Request["payload"]["delivery"] + readonly resume?: Endpoint5_11Request["payload"]["resume"] } -const Endpoint4_11 = (raw: RawClient["server.session"]) => (input: Endpoint4_11Input) => +const Endpoint5_11 = (raw: RawClient["server.session"]) => (input: Endpoint5_11Input) => raw["session.command"]({ params: { sessionID: input["sessionID"] }, payload: { @@ -202,73 +218,80 @@ const Endpoint4_11 = (raw: RawClient["server.session"]) => (input: Endpoint4_11I Effect.map((value) => value.data), ) -type Endpoint4_12Request = Parameters[0] -type Endpoint4_12Input = { - readonly sessionID: Endpoint4_12Request["params"]["sessionID"] - readonly id?: Endpoint4_12Request["payload"]["id"] - readonly skill: Endpoint4_12Request["payload"]["skill"] - readonly resume?: Endpoint4_12Request["payload"]["resume"] +type Endpoint5_12Request = Parameters[0] +type Endpoint5_12Input = { + readonly sessionID: Endpoint5_12Request["params"]["sessionID"] + readonly id?: Endpoint5_12Request["payload"]["id"] + readonly skill: Endpoint5_12Request["payload"]["skill"] + readonly resume?: Endpoint5_12Request["payload"]["resume"] } -const Endpoint4_12 = (raw: RawClient["server.session"]) => (input: Endpoint4_12Input) => +const Endpoint5_12 = (raw: RawClient["server.session"]) => (input: Endpoint5_12Input) => raw["session.skill"]({ params: { sessionID: input["sessionID"] }, payload: { id: input["id"], skill: input["skill"], resume: input["resume"] }, }).pipe(Effect.mapError(mapClientError)) -type Endpoint4_13Request = Parameters[0] -type Endpoint4_13Input = { - readonly sessionID: Endpoint4_13Request["params"]["sessionID"] - readonly text: Endpoint4_13Request["payload"]["text"] - readonly description?: Endpoint4_13Request["payload"]["description"] - readonly metadata?: Endpoint4_13Request["payload"]["metadata"] - readonly resume?: Endpoint4_13Request["payload"]["resume"] +type Endpoint5_13Request = Parameters[0] +type Endpoint5_13Input = { + readonly sessionID: Endpoint5_13Request["params"]["sessionID"] + readonly id?: Endpoint5_13Request["payload"]["id"] + readonly text: Endpoint5_13Request["payload"]["text"] + readonly description?: Endpoint5_13Request["payload"]["description"] + readonly metadata?: Endpoint5_13Request["payload"]["metadata"] + readonly delivery?: Endpoint5_13Request["payload"]["delivery"] + readonly resume?: Endpoint5_13Request["payload"]["resume"] } -const Endpoint4_13 = (raw: RawClient["server.session"]) => (input: Endpoint4_13Input) => +const Endpoint5_13 = (raw: RawClient["server.session"]) => (input: Endpoint5_13Input) => raw["session.synthetic"]({ params: { sessionID: input["sessionID"] }, payload: { + id: input["id"], text: input["text"], description: input["description"], metadata: input["metadata"], + delivery: input["delivery"], resume: input["resume"], }, - }).pipe(Effect.mapError(mapClientError)) + }).pipe( + Effect.mapError(mapClientError), + Effect.map((value) => value.data), + ) -type Endpoint4_14Request = Parameters[0] -type Endpoint4_14Input = { - readonly sessionID: Endpoint4_14Request["params"]["sessionID"] - readonly id?: Endpoint4_14Request["payload"]["id"] - readonly command: Endpoint4_14Request["payload"]["command"] +type Endpoint5_14Request = Parameters[0] +type Endpoint5_14Input = { + readonly sessionID: Endpoint5_14Request["params"]["sessionID"] + readonly id?: Endpoint5_14Request["payload"]["id"] + readonly command: Endpoint5_14Request["payload"]["command"] } -const Endpoint4_14 = (raw: RawClient["server.session"]) => (input: Endpoint4_14Input) => +const Endpoint5_14 = (raw: RawClient["server.session"]) => (input: Endpoint5_14Input) => raw["session.shell"]({ params: { sessionID: input["sessionID"] }, payload: { id: input["id"], command: input["command"] }, }).pipe(Effect.mapError(mapClientError)) -type Endpoint4_15Request = Parameters[0] -type Endpoint4_15Input = { - readonly sessionID: Endpoint4_15Request["params"]["sessionID"] - readonly id?: Endpoint4_15Request["payload"]["id"] +type Endpoint5_15Request = Parameters[0] +type Endpoint5_15Input = { + readonly sessionID: Endpoint5_15Request["params"]["sessionID"] + readonly id?: Endpoint5_15Request["payload"]["id"] } -const Endpoint4_15 = (raw: RawClient["server.session"]) => (input: Endpoint4_15Input) => +const Endpoint5_15 = (raw: RawClient["server.session"]) => (input: Endpoint5_15Input) => raw["session.compact"]({ params: { sessionID: input["sessionID"] }, payload: { id: input["id"] } }).pipe( Effect.mapError(mapClientError), Effect.map((value) => value.data), ) -type Endpoint4_16Request = Parameters[0] -type Endpoint4_16Input = { readonly sessionID: Endpoint4_16Request["params"]["sessionID"] } -const Endpoint4_16 = (raw: RawClient["server.session"]) => (input: Endpoint4_16Input) => +type Endpoint5_16Request = Parameters[0] +type Endpoint5_16Input = { readonly sessionID: Endpoint5_16Request["params"]["sessionID"] } +const Endpoint5_16 = (raw: RawClient["server.session"]) => (input: Endpoint5_16Input) => raw["session.wait"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)) -type Endpoint4_17Request = Parameters[0] -type Endpoint4_17Input = { - readonly sessionID: Endpoint4_17Request["params"]["sessionID"] - readonly messageID: Endpoint4_17Request["payload"]["messageID"] - readonly files?: Endpoint4_17Request["payload"]["files"] +type Endpoint5_17Request = Parameters[0] +type Endpoint5_17Input = { + readonly sessionID: Endpoint5_17Request["params"]["sessionID"] + readonly messageID: Endpoint5_17Request["payload"]["messageID"] + readonly files?: Endpoint5_17Request["payload"]["files"] } -const Endpoint4_17 = (raw: RawClient["server.session"]) => (input: Endpoint4_17Input) => +const Endpoint5_17 = (raw: RawClient["server.session"]) => (input: Endpoint5_17Input) => raw["session.revert.stage"]({ params: { sessionID: input["sessionID"] }, payload: { messageID: input["messageID"], files: input["files"] }, @@ -277,61 +300,61 @@ const Endpoint4_17 = (raw: RawClient["server.session"]) => (input: Endpoint4_17I Effect.map((value) => value.data), ) -type Endpoint4_18Request = Parameters[0] -type Endpoint4_18Input = { readonly sessionID: Endpoint4_18Request["params"]["sessionID"] } -const Endpoint4_18 = (raw: RawClient["server.session"]) => (input: Endpoint4_18Input) => +type Endpoint5_18Request = Parameters[0] +type Endpoint5_18Input = { readonly sessionID: Endpoint5_18Request["params"]["sessionID"] } +const Endpoint5_18 = (raw: RawClient["server.session"]) => (input: Endpoint5_18Input) => raw["session.revert.clear"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)) -type Endpoint4_19Request = Parameters[0] -type Endpoint4_19Input = { readonly sessionID: Endpoint4_19Request["params"]["sessionID"] } -const Endpoint4_19 = (raw: RawClient["server.session"]) => (input: Endpoint4_19Input) => +type Endpoint5_19Request = Parameters[0] +type Endpoint5_19Input = { readonly sessionID: Endpoint5_19Request["params"]["sessionID"] } +const Endpoint5_19 = (raw: RawClient["server.session"]) => (input: Endpoint5_19Input) => raw["session.revert.commit"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)) -type Endpoint4_20Request = Parameters[0] -type Endpoint4_20Input = { readonly sessionID: Endpoint4_20Request["params"]["sessionID"] } -const Endpoint4_20 = (raw: RawClient["server.session"]) => (input: Endpoint4_20Input) => +type Endpoint5_20Request = Parameters[0] +type Endpoint5_20Input = { readonly sessionID: Endpoint5_20Request["params"]["sessionID"] } +const Endpoint5_20 = (raw: RawClient["server.session"]) => (input: Endpoint5_20Input) => raw["session.context"]({ params: { sessionID: input["sessionID"] } }).pipe( Effect.mapError(mapClientError), Effect.map((value) => value.data), ) -type Endpoint4_21Request = Parameters[0] -type Endpoint4_21Input = { readonly sessionID: Endpoint4_21Request["params"]["sessionID"] } -const Endpoint4_21 = (raw: RawClient["server.session"]) => (input: Endpoint4_21Input) => +type Endpoint5_21Request = Parameters[0] +type Endpoint5_21Input = { readonly sessionID: Endpoint5_21Request["params"]["sessionID"] } +const Endpoint5_21 = (raw: RawClient["server.session"]) => (input: Endpoint5_21Input) => raw["session.instructions.entry.list"]({ params: { sessionID: input["sessionID"] } }).pipe( Effect.mapError(mapClientError), Effect.map((value) => value.data), ) -type Endpoint4_22Request = Parameters[0] -type Endpoint4_22Input = { - readonly sessionID: Endpoint4_22Request["params"]["sessionID"] - readonly key: Endpoint4_22Request["params"]["key"] - readonly value: Endpoint4_22Request["payload"]["value"] +type Endpoint5_22Request = Parameters[0] +type Endpoint5_22Input = { + readonly sessionID: Endpoint5_22Request["params"]["sessionID"] + readonly key: Endpoint5_22Request["params"]["key"] + readonly value: Endpoint5_22Request["payload"]["value"] } -const Endpoint4_22 = (raw: RawClient["server.session"]) => (input: Endpoint4_22Input) => +const Endpoint5_22 = (raw: RawClient["server.session"]) => (input: Endpoint5_22Input) => raw["session.instructions.entry.put"]({ params: { sessionID: input["sessionID"], key: input["key"] }, payload: { value: input["value"] }, }).pipe(Effect.mapError(mapClientError)) -type Endpoint4_23Request = Parameters[0] -type Endpoint4_23Input = { - readonly sessionID: Endpoint4_23Request["params"]["sessionID"] - readonly key: Endpoint4_23Request["params"]["key"] +type Endpoint5_23Request = Parameters[0] +type Endpoint5_23Input = { + readonly sessionID: Endpoint5_23Request["params"]["sessionID"] + readonly key: Endpoint5_23Request["params"]["key"] } -const Endpoint4_23 = (raw: RawClient["server.session"]) => (input: Endpoint4_23Input) => +const Endpoint5_23 = (raw: RawClient["server.session"]) => (input: Endpoint5_23Input) => raw["session.instructions.entry.remove"]({ params: { sessionID: input["sessionID"], key: input["key"] } }).pipe( Effect.mapError(mapClientError), ) -type Endpoint4_24Request = Parameters[0] -type Endpoint4_24Input = { - readonly sessionID: Endpoint4_24Request["params"]["sessionID"] - readonly after?: Endpoint4_24Request["query"]["after"] - readonly follow?: Endpoint4_24Request["query"]["follow"] +type Endpoint5_24Request = Parameters[0] +type Endpoint5_24Input = { + readonly sessionID: Endpoint5_24Request["params"]["sessionID"] + readonly after?: Endpoint5_24Request["query"]["after"] + readonly follow?: Endpoint5_24Request["query"]["follow"] } -const Endpoint4_24 = (raw: RawClient["server.session"]) => (input: Endpoint4_24Input) => +const Endpoint5_24 = (raw: RawClient["server.session"]) => (input: Endpoint5_24Input) => Stream.unwrap( raw["session.log"]({ params: { sessionID: input["sessionID"] }, @@ -342,88 +365,88 @@ const Endpoint4_24 = (raw: RawClient["server.session"]) => (input: Endpoint4_24I ), ) -type Endpoint4_25Request = Parameters[0] -type Endpoint4_25Input = { readonly sessionID: Endpoint4_25Request["params"]["sessionID"] } -const Endpoint4_25 = (raw: RawClient["server.session"]) => (input: Endpoint4_25Input) => +type Endpoint5_25Request = Parameters[0] +type Endpoint5_25Input = { readonly sessionID: Endpoint5_25Request["params"]["sessionID"] } +const Endpoint5_25 = (raw: RawClient["server.session"]) => (input: Endpoint5_25Input) => raw["session.interrupt"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)) -type Endpoint4_26Request = Parameters[0] -type Endpoint4_26Input = { readonly sessionID: Endpoint4_26Request["params"]["sessionID"] } -const Endpoint4_26 = (raw: RawClient["server.session"]) => (input: Endpoint4_26Input) => +type Endpoint5_26Request = Parameters[0] +type Endpoint5_26Input = { readonly sessionID: Endpoint5_26Request["params"]["sessionID"] } +const Endpoint5_26 = (raw: RawClient["server.session"]) => (input: Endpoint5_26Input) => raw["session.background"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)) -type Endpoint4_27Request = Parameters[0] -type Endpoint4_27Input = { - readonly sessionID: Endpoint4_27Request["params"]["sessionID"] - readonly messageID: Endpoint4_27Request["params"]["messageID"] +type Endpoint5_27Request = Parameters[0] +type Endpoint5_27Input = { + readonly sessionID: Endpoint5_27Request["params"]["sessionID"] + readonly messageID: Endpoint5_27Request["params"]["messageID"] } -const Endpoint4_27 = (raw: RawClient["server.session"]) => (input: Endpoint4_27Input) => +const Endpoint5_27 = (raw: RawClient["server.session"]) => (input: Endpoint5_27Input) => raw["session.message"]({ params: { sessionID: input["sessionID"], messageID: input["messageID"] } }).pipe( Effect.mapError(mapClientError), Effect.map((value) => value.data), ) -const adaptGroup4 = (raw: RawClient["server.session"]) => ({ - list: Endpoint4_0(raw), - create: Endpoint4_1(raw), - active: Endpoint4_2(raw), - get: Endpoint4_3(raw), - remove: Endpoint4_4(raw), - fork: Endpoint4_5(raw), - switchAgent: Endpoint4_6(raw), - switchModel: Endpoint4_7(raw), - rename: Endpoint4_8(raw), - move: Endpoint4_9(raw), - prompt: Endpoint4_10(raw), - command: Endpoint4_11(raw), - skill: Endpoint4_12(raw), - synthetic: Endpoint4_13(raw), - shell: Endpoint4_14(raw), - compact: Endpoint4_15(raw), - wait: Endpoint4_16(raw), - revert: { stage: Endpoint4_17(raw), clear: Endpoint4_18(raw), commit: Endpoint4_19(raw) }, - context: Endpoint4_20(raw), - instructions: { entry: { list: Endpoint4_21(raw), put: Endpoint4_22(raw), remove: Endpoint4_23(raw) } }, - log: Endpoint4_24(raw), - interrupt: Endpoint4_25(raw), - background: Endpoint4_26(raw), - message: Endpoint4_27(raw), +const adaptGroup5 = (raw: RawClient["server.session"]) => ({ + list: Endpoint5_0(raw), + create: Endpoint5_1(raw), + active: Endpoint5_2(raw), + get: Endpoint5_3(raw), + remove: Endpoint5_4(raw), + fork: Endpoint5_5(raw), + switchAgent: Endpoint5_6(raw), + switchModel: Endpoint5_7(raw), + rename: Endpoint5_8(raw), + move: Endpoint5_9(raw), + prompt: Endpoint5_10(raw), + command: Endpoint5_11(raw), + skill: Endpoint5_12(raw), + synthetic: Endpoint5_13(raw), + shell: Endpoint5_14(raw), + compact: Endpoint5_15(raw), + wait: Endpoint5_16(raw), + revert: { stage: Endpoint5_17(raw), clear: Endpoint5_18(raw), commit: Endpoint5_19(raw) }, + context: Endpoint5_20(raw), + instructions: { entry: { list: Endpoint5_21(raw), put: Endpoint5_22(raw), remove: Endpoint5_23(raw) } }, + log: Endpoint5_24(raw), + interrupt: Endpoint5_25(raw), + background: Endpoint5_26(raw), + message: Endpoint5_27(raw), }) -type Endpoint5_0Request = Parameters[0] -type Endpoint5_0Input = { - readonly sessionID: Endpoint5_0Request["params"]["sessionID"] - readonly limit?: Endpoint5_0Request["query"]["limit"] - readonly order?: Endpoint5_0Request["query"]["order"] - readonly cursor?: Endpoint5_0Request["query"]["cursor"] +type Endpoint6_0Request = Parameters[0] +type Endpoint6_0Input = { + readonly sessionID: Endpoint6_0Request["params"]["sessionID"] + readonly limit?: Endpoint6_0Request["query"]["limit"] + readonly order?: Endpoint6_0Request["query"]["order"] + readonly cursor?: Endpoint6_0Request["query"]["cursor"] } -const Endpoint5_0 = (raw: RawClient["server.message"]) => (input: Endpoint5_0Input) => +const Endpoint6_0 = (raw: RawClient["server.message"]) => (input: Endpoint6_0Input) => raw["session.messages"]({ params: { sessionID: input["sessionID"] }, query: { limit: input["limit"], order: input["order"], cursor: input["cursor"] }, }).pipe(Effect.mapError(mapClientError)) -const adaptGroup5 = (raw: RawClient["server.message"]) => ({ list: Endpoint5_0(raw) }) +const adaptGroup6 = (raw: RawClient["server.message"]) => ({ list: Endpoint6_0(raw) }) -type Endpoint6_0Request = Parameters[0] -type Endpoint6_0Input = { readonly location?: Endpoint6_0Request["query"]["location"] } -const Endpoint6_0 = (raw: RawClient["server.model"]) => (input?: Endpoint6_0Input) => +type Endpoint7_0Request = Parameters[0] +type Endpoint7_0Input = { readonly location?: Endpoint7_0Request["query"]["location"] } +const Endpoint7_0 = (raw: RawClient["server.model"]) => (input?: Endpoint7_0Input) => raw["model.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)) -type Endpoint6_1Request = Parameters[0] -type Endpoint6_1Input = { readonly location?: Endpoint6_1Request["query"]["location"] } -const Endpoint6_1 = (raw: RawClient["server.model"]) => (input?: Endpoint6_1Input) => +type Endpoint7_1Request = Parameters[0] +type Endpoint7_1Input = { readonly location?: Endpoint7_1Request["query"]["location"] } +const Endpoint7_1 = (raw: RawClient["server.model"]) => (input?: Endpoint7_1Input) => raw["model.default"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)) -const adaptGroup6 = (raw: RawClient["server.model"]) => ({ list: Endpoint6_0(raw), default: Endpoint6_1(raw) }) +const adaptGroup7 = (raw: RawClient["server.model"]) => ({ list: Endpoint7_0(raw), default: Endpoint7_1(raw) }) -type Endpoint7_0Request = Parameters[0] -type Endpoint7_0Input = { - readonly location?: Endpoint7_0Request["query"]["location"] - readonly prompt: Endpoint7_0Request["payload"]["prompt"] - readonly model?: Endpoint7_0Request["payload"]["model"] +type Endpoint8_0Request = Parameters[0] +type Endpoint8_0Input = { + readonly location?: Endpoint8_0Request["query"]["location"] + readonly prompt: Endpoint8_0Request["payload"]["prompt"] + readonly model?: Endpoint8_0Request["payload"]["model"] } -const Endpoint7_0 = (raw: RawClient["server.generate"]) => (input: Endpoint7_0Input) => +const Endpoint8_0 = (raw: RawClient["server.generate"]) => (input: Endpoint8_0Input) => raw["generate.text"]({ query: { location: input["location"] }, payload: { prompt: input["prompt"], model: input["model"] }, @@ -432,202 +455,202 @@ const Endpoint7_0 = (raw: RawClient["server.generate"]) => (input: Endpoint7_0In Effect.map((value) => value.data), ) -const adaptGroup7 = (raw: RawClient["server.generate"]) => ({ text: Endpoint7_0(raw) }) +const adaptGroup8 = (raw: RawClient["server.generate"]) => ({ text: Endpoint8_0(raw) }) -type Endpoint8_0Request = Parameters[0] -type Endpoint8_0Input = { readonly location?: Endpoint8_0Request["query"]["location"] } -const Endpoint8_0 = (raw: RawClient["server.provider"]) => (input?: Endpoint8_0Input) => +type Endpoint9_0Request = Parameters[0] +type Endpoint9_0Input = { readonly location?: Endpoint9_0Request["query"]["location"] } +const Endpoint9_0 = (raw: RawClient["server.provider"]) => (input?: Endpoint9_0Input) => raw["provider.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)) -type Endpoint8_1Request = Parameters[0] -type Endpoint8_1Input = { - readonly providerID: Endpoint8_1Request["params"]["providerID"] - readonly location?: Endpoint8_1Request["query"]["location"] +type Endpoint9_1Request = Parameters[0] +type Endpoint9_1Input = { + readonly providerID: Endpoint9_1Request["params"]["providerID"] + readonly location?: Endpoint9_1Request["query"]["location"] } -const Endpoint8_1 = (raw: RawClient["server.provider"]) => (input: Endpoint8_1Input) => +const Endpoint9_1 = (raw: RawClient["server.provider"]) => (input: Endpoint9_1Input) => raw["provider.get"]({ params: { providerID: input["providerID"] }, query: { location: input["location"] } }).pipe( Effect.mapError(mapClientError), ) -const adaptGroup8 = (raw: RawClient["server.provider"]) => ({ list: Endpoint8_0(raw), get: Endpoint8_1(raw) }) +const adaptGroup9 = (raw: RawClient["server.provider"]) => ({ list: Endpoint9_0(raw), get: Endpoint9_1(raw) }) -type Endpoint9_0Request = Parameters[0] -type Endpoint9_0Input = { readonly location?: Endpoint9_0Request["query"]["location"] } -const Endpoint9_0 = (raw: RawClient["server.integration"]) => (input?: Endpoint9_0Input) => +type Endpoint10_0Request = Parameters[0] +type Endpoint10_0Input = { readonly location?: Endpoint10_0Request["query"]["location"] } +const Endpoint10_0 = (raw: RawClient["server.integration"]) => (input?: Endpoint10_0Input) => raw["integration.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)) -type Endpoint9_1Request = Parameters[0] -type Endpoint9_1Input = { - readonly integrationID: Endpoint9_1Request["params"]["integrationID"] - readonly location?: Endpoint9_1Request["query"]["location"] +type Endpoint10_1Request = Parameters[0] +type Endpoint10_1Input = { + readonly integrationID: Endpoint10_1Request["params"]["integrationID"] + readonly location?: Endpoint10_1Request["query"]["location"] } -const Endpoint9_1 = (raw: RawClient["server.integration"]) => (input: Endpoint9_1Input) => +const Endpoint10_1 = (raw: RawClient["server.integration"]) => (input: Endpoint10_1Input) => raw["integration.get"]({ params: { integrationID: input["integrationID"] }, query: { location: input["location"] }, }).pipe(Effect.mapError(mapClientError)) -type Endpoint9_2Request = Parameters[0] -type Endpoint9_2Input = { - readonly integrationID: Endpoint9_2Request["params"]["integrationID"] - readonly location?: Endpoint9_2Request["query"]["location"] - readonly key: Endpoint9_2Request["payload"]["key"] - readonly label?: Endpoint9_2Request["payload"]["label"] +type Endpoint10_2Request = Parameters[0] +type Endpoint10_2Input = { + readonly integrationID: Endpoint10_2Request["params"]["integrationID"] + readonly location?: Endpoint10_2Request["query"]["location"] + readonly key: Endpoint10_2Request["payload"]["key"] + readonly label?: Endpoint10_2Request["payload"]["label"] } -const Endpoint9_2 = (raw: RawClient["server.integration"]) => (input: Endpoint9_2Input) => +const Endpoint10_2 = (raw: RawClient["server.integration"]) => (input: Endpoint10_2Input) => raw["integration.connect.key"]({ params: { integrationID: input["integrationID"] }, query: { location: input["location"] }, payload: { key: input["key"], label: input["label"] }, }).pipe(Effect.mapError(mapClientError)) -type Endpoint9_3Request = Parameters[0] -type Endpoint9_3Input = { - readonly integrationID: Endpoint9_3Request["params"]["integrationID"] - readonly location?: Endpoint9_3Request["query"]["location"] - readonly methodID: Endpoint9_3Request["payload"]["methodID"] - readonly inputs: Endpoint9_3Request["payload"]["inputs"] - readonly label?: Endpoint9_3Request["payload"]["label"] +type Endpoint10_3Request = Parameters[0] +type Endpoint10_3Input = { + readonly integrationID: Endpoint10_3Request["params"]["integrationID"] + readonly location?: Endpoint10_3Request["query"]["location"] + readonly methodID: Endpoint10_3Request["payload"]["methodID"] + readonly inputs: Endpoint10_3Request["payload"]["inputs"] + readonly label?: Endpoint10_3Request["payload"]["label"] } -const Endpoint9_3 = (raw: RawClient["server.integration"]) => (input: Endpoint9_3Input) => +const Endpoint10_3 = (raw: RawClient["server.integration"]) => (input: Endpoint10_3Input) => raw["integration.connect.oauth"]({ params: { integrationID: input["integrationID"] }, query: { location: input["location"] }, payload: { methodID: input["methodID"], inputs: input["inputs"], label: input["label"] }, }).pipe(Effect.mapError(mapClientError)) -type Endpoint9_4Request = Parameters[0] -type Endpoint9_4Input = { - readonly attemptID: Endpoint9_4Request["params"]["attemptID"] - readonly location?: Endpoint9_4Request["query"]["location"] +type Endpoint10_4Request = Parameters[0] +type Endpoint10_4Input = { + readonly attemptID: Endpoint10_4Request["params"]["attemptID"] + readonly location?: Endpoint10_4Request["query"]["location"] } -const Endpoint9_4 = (raw: RawClient["server.integration"]) => (input: Endpoint9_4Input) => +const Endpoint10_4 = (raw: RawClient["server.integration"]) => (input: Endpoint10_4Input) => raw["integration.attempt.status"]({ params: { attemptID: input["attemptID"] }, query: { location: input["location"] }, }).pipe(Effect.mapError(mapClientError)) -type Endpoint9_5Request = Parameters[0] -type Endpoint9_5Input = { - readonly attemptID: Endpoint9_5Request["params"]["attemptID"] - readonly location?: Endpoint9_5Request["query"]["location"] - readonly code?: Endpoint9_5Request["payload"]["code"] +type Endpoint10_5Request = Parameters[0] +type Endpoint10_5Input = { + readonly attemptID: Endpoint10_5Request["params"]["attemptID"] + readonly location?: Endpoint10_5Request["query"]["location"] + readonly code?: Endpoint10_5Request["payload"]["code"] } -const Endpoint9_5 = (raw: RawClient["server.integration"]) => (input: Endpoint9_5Input) => +const Endpoint10_5 = (raw: RawClient["server.integration"]) => (input: Endpoint10_5Input) => raw["integration.attempt.complete"]({ params: { attemptID: input["attemptID"] }, query: { location: input["location"] }, payload: { code: input["code"] }, }).pipe(Effect.mapError(mapClientError)) -type Endpoint9_6Request = Parameters[0] -type Endpoint9_6Input = { - readonly attemptID: Endpoint9_6Request["params"]["attemptID"] - readonly location?: Endpoint9_6Request["query"]["location"] +type Endpoint10_6Request = Parameters[0] +type Endpoint10_6Input = { + readonly attemptID: Endpoint10_6Request["params"]["attemptID"] + readonly location?: Endpoint10_6Request["query"]["location"] } -const Endpoint9_6 = (raw: RawClient["server.integration"]) => (input: Endpoint9_6Input) => +const Endpoint10_6 = (raw: RawClient["server.integration"]) => (input: Endpoint10_6Input) => raw["integration.attempt.cancel"]({ params: { attemptID: input["attemptID"] }, query: { location: input["location"] }, }).pipe(Effect.mapError(mapClientError)) -const adaptGroup9 = (raw: RawClient["server.integration"]) => ({ - list: Endpoint9_0(raw), - get: Endpoint9_1(raw), - connect: { key: Endpoint9_2(raw), oauth: Endpoint9_3(raw) }, - attempt: { status: Endpoint9_4(raw), complete: Endpoint9_5(raw), cancel: Endpoint9_6(raw) }, +const adaptGroup10 = (raw: RawClient["server.integration"]) => ({ + list: Endpoint10_0(raw), + get: Endpoint10_1(raw), + connect: { key: Endpoint10_2(raw), oauth: Endpoint10_3(raw) }, + attempt: { status: Endpoint10_4(raw), complete: Endpoint10_5(raw), cancel: Endpoint10_6(raw) }, }) -type Endpoint10_0Request = Parameters[0] -type Endpoint10_0Input = { readonly location?: Endpoint10_0Request["query"]["location"] } -const Endpoint10_0 = (raw: RawClient["server.mcp"]) => (input?: Endpoint10_0Input) => +type Endpoint11_0Request = Parameters[0] +type Endpoint11_0Input = { readonly location?: Endpoint11_0Request["query"]["location"] } +const Endpoint11_0 = (raw: RawClient["server.mcp"]) => (input?: Endpoint11_0Input) => raw["mcp.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)) -type Endpoint10_1Request = Parameters[0] -type Endpoint10_1Input = { readonly location?: Endpoint10_1Request["query"]["location"] } -const Endpoint10_1 = (raw: RawClient["server.mcp"]) => (input?: Endpoint10_1Input) => +type Endpoint11_1Request = Parameters[0] +type Endpoint11_1Input = { readonly location?: Endpoint11_1Request["query"]["location"] } +const Endpoint11_1 = (raw: RawClient["server.mcp"]) => (input?: Endpoint11_1Input) => raw["mcp.resource.catalog"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)) -const adaptGroup10 = (raw: RawClient["server.mcp"]) => ({ - list: Endpoint10_0(raw), - resource: { catalog: Endpoint10_1(raw) }, +const adaptGroup11 = (raw: RawClient["server.mcp"]) => ({ + list: Endpoint11_0(raw), + resource: { catalog: Endpoint11_1(raw) }, }) -type Endpoint11_0Request = Parameters[0] -type Endpoint11_0Input = { - readonly credentialID: Endpoint11_0Request["params"]["credentialID"] - readonly location?: Endpoint11_0Request["query"]["location"] - readonly label: Endpoint11_0Request["payload"]["label"] +type Endpoint12_0Request = Parameters[0] +type Endpoint12_0Input = { + readonly credentialID: Endpoint12_0Request["params"]["credentialID"] + readonly location?: Endpoint12_0Request["query"]["location"] + readonly label: Endpoint12_0Request["payload"]["label"] } -const Endpoint11_0 = (raw: RawClient["server.credential"]) => (input: Endpoint11_0Input) => +const Endpoint12_0 = (raw: RawClient["server.credential"]) => (input: Endpoint12_0Input) => raw["credential.update"]({ params: { credentialID: input["credentialID"] }, query: { location: input["location"] }, payload: { label: input["label"] }, }).pipe(Effect.mapError(mapClientError)) -type Endpoint11_1Request = Parameters[0] -type Endpoint11_1Input = { - readonly credentialID: Endpoint11_1Request["params"]["credentialID"] - readonly location?: Endpoint11_1Request["query"]["location"] +type Endpoint12_1Request = Parameters[0] +type Endpoint12_1Input = { + readonly credentialID: Endpoint12_1Request["params"]["credentialID"] + readonly location?: Endpoint12_1Request["query"]["location"] } -const Endpoint11_1 = (raw: RawClient["server.credential"]) => (input: Endpoint11_1Input) => +const Endpoint12_1 = (raw: RawClient["server.credential"]) => (input: Endpoint12_1Input) => raw["credential.remove"]({ params: { credentialID: input["credentialID"] }, query: { location: input["location"] }, }).pipe(Effect.mapError(mapClientError)) -const adaptGroup11 = (raw: RawClient["server.credential"]) => ({ update: Endpoint11_0(raw), remove: Endpoint11_1(raw) }) +const adaptGroup12 = (raw: RawClient["server.credential"]) => ({ update: Endpoint12_0(raw), remove: Endpoint12_1(raw) }) -const Endpoint12_0 = (raw: RawClient["server.project"]) => () => +const Endpoint13_0 = (raw: RawClient["server.project"]) => () => raw["project.list"]({}).pipe(Effect.mapError(mapClientError)) -type Endpoint12_1Request = Parameters[0] -type Endpoint12_1Input = { readonly location?: Endpoint12_1Request["query"]["location"] } -const Endpoint12_1 = (raw: RawClient["server.project"]) => (input?: Endpoint12_1Input) => +type Endpoint13_1Request = Parameters[0] +type Endpoint13_1Input = { readonly location?: Endpoint13_1Request["query"]["location"] } +const Endpoint13_1 = (raw: RawClient["server.project"]) => (input?: Endpoint13_1Input) => raw["project.current"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)) -type Endpoint12_2Request = Parameters[0] -type Endpoint12_2Input = { - readonly projectID: Endpoint12_2Request["params"]["projectID"] - readonly location?: Endpoint12_2Request["query"]["location"] +type Endpoint13_2Request = Parameters[0] +type Endpoint13_2Input = { + readonly projectID: Endpoint13_2Request["params"]["projectID"] + readonly location?: Endpoint13_2Request["query"]["location"] } -const Endpoint12_2 = (raw: RawClient["server.project"]) => (input: Endpoint12_2Input) => +const Endpoint13_2 = (raw: RawClient["server.project"]) => (input: Endpoint13_2Input) => raw["project.directories"]({ params: { projectID: input["projectID"] }, query: { location: input["location"] }, }).pipe(Effect.mapError(mapClientError)) -const adaptGroup12 = (raw: RawClient["server.project"]) => ({ - list: Endpoint12_0(raw), - current: Endpoint12_1(raw), - directories: Endpoint12_2(raw), +const adaptGroup13 = (raw: RawClient["server.project"]) => ({ + list: Endpoint13_0(raw), + current: Endpoint13_1(raw), + directories: Endpoint13_2(raw), }) -type Endpoint13_0Request = Parameters[0] -type Endpoint13_0Input = { readonly location?: Endpoint13_0Request["query"]["location"] } -const Endpoint13_0 = (raw: RawClient["server.form"]) => (input?: Endpoint13_0Input) => +type Endpoint14_0Request = Parameters[0] +type Endpoint14_0Input = { readonly location?: Endpoint14_0Request["query"]["location"] } +const Endpoint14_0 = (raw: RawClient["server.form"]) => (input?: Endpoint14_0Input) => raw["form.request.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)) -type Endpoint13_1Request = Parameters[0] -type Endpoint13_1Input = { readonly sessionID: Endpoint13_1Request["params"]["sessionID"] } -const Endpoint13_1 = (raw: RawClient["server.form"]) => (input: Endpoint13_1Input) => +type Endpoint14_1Request = Parameters[0] +type Endpoint14_1Input = { readonly sessionID: Endpoint14_1Request["params"]["sessionID"] } +const Endpoint14_1 = (raw: RawClient["server.form"]) => (input: Endpoint14_1Input) => raw["session.form.list"]({ params: { sessionID: input["sessionID"] } }).pipe( Effect.mapError(mapClientError), Effect.map((value) => value.data), ) -type Endpoint13_2Request = Parameters[0] -type Endpoint13_2Input = { - readonly sessionID: Endpoint13_2Request["params"]["sessionID"] - readonly id?: Endpoint13_2Request["payload"]["id"] - readonly title?: Endpoint13_2Request["payload"]["title"] - readonly metadata?: Endpoint13_2Request["payload"]["metadata"] - readonly mode: Endpoint13_2Request["payload"]["mode"] - readonly fields?: Endpoint13_2Request["payload"]["fields"] - readonly url?: Endpoint13_2Request["payload"]["url"] +type Endpoint14_2Request = Parameters[0] +type Endpoint14_2Input = { + readonly sessionID: Endpoint14_2Request["params"]["sessionID"] + readonly id?: Endpoint14_2Request["payload"]["id"] + readonly title: Endpoint14_2Request["payload"]["title"] + readonly metadata?: Endpoint14_2Request["payload"]["metadata"] + readonly mode: Endpoint14_2Request["payload"]["mode"] + readonly fields?: Endpoint14_2Request["payload"]["fields"] + readonly url?: Endpoint14_2Request["payload"]["url"] } -const Endpoint13_2 = (raw: RawClient["server.form"]) => (input: Endpoint13_2Input) => +const Endpoint14_2 = (raw: RawClient["server.form"]) => (input: Endpoint14_2Input) => raw["session.form.create"]({ params: { sessionID: input["sessionID"] }, payload: { @@ -643,90 +666,90 @@ const Endpoint13_2 = (raw: RawClient["server.form"]) => (input: Endpoint13_2Inpu Effect.map((value) => value.data), ) -type Endpoint13_3Request = Parameters[0] -type Endpoint13_3Input = { - readonly sessionID: Endpoint13_3Request["params"]["sessionID"] - readonly formID: Endpoint13_3Request["params"]["formID"] +type Endpoint14_3Request = Parameters[0] +type Endpoint14_3Input = { + readonly sessionID: Endpoint14_3Request["params"]["sessionID"] + readonly formID: Endpoint14_3Request["params"]["formID"] } -const Endpoint13_3 = (raw: RawClient["server.form"]) => (input: Endpoint13_3Input) => +const Endpoint14_3 = (raw: RawClient["server.form"]) => (input: Endpoint14_3Input) => raw["session.form.get"]({ params: { sessionID: input["sessionID"], formID: input["formID"] } }).pipe( Effect.mapError(mapClientError), Effect.map((value) => value.data), ) -type Endpoint13_4Request = Parameters[0] -type Endpoint13_4Input = { - readonly sessionID: Endpoint13_4Request["params"]["sessionID"] - readonly formID: Endpoint13_4Request["params"]["formID"] +type Endpoint14_4Request = Parameters[0] +type Endpoint14_4Input = { + readonly sessionID: Endpoint14_4Request["params"]["sessionID"] + readonly formID: Endpoint14_4Request["params"]["formID"] } -const Endpoint13_4 = (raw: RawClient["server.form"]) => (input: Endpoint13_4Input) => +const Endpoint14_4 = (raw: RawClient["server.form"]) => (input: Endpoint14_4Input) => raw["session.form.state"]({ params: { sessionID: input["sessionID"], formID: input["formID"] } }).pipe( Effect.mapError(mapClientError), Effect.map((value) => value.data), ) -type Endpoint13_5Request = Parameters[0] -type Endpoint13_5Input = { - readonly sessionID: Endpoint13_5Request["params"]["sessionID"] - readonly formID: Endpoint13_5Request["params"]["formID"] - readonly answer: Endpoint13_5Request["payload"]["answer"] +type Endpoint14_5Request = Parameters[0] +type Endpoint14_5Input = { + readonly sessionID: Endpoint14_5Request["params"]["sessionID"] + readonly formID: Endpoint14_5Request["params"]["formID"] + readonly answer: Endpoint14_5Request["payload"]["answer"] } -const Endpoint13_5 = (raw: RawClient["server.form"]) => (input: Endpoint13_5Input) => +const Endpoint14_5 = (raw: RawClient["server.form"]) => (input: Endpoint14_5Input) => raw["session.form.reply"]({ params: { sessionID: input["sessionID"], formID: input["formID"] }, payload: { answer: input["answer"] }, }).pipe(Effect.mapError(mapClientError)) -type Endpoint13_6Request = Parameters[0] -type Endpoint13_6Input = { - readonly sessionID: Endpoint13_6Request["params"]["sessionID"] - readonly formID: Endpoint13_6Request["params"]["formID"] +type Endpoint14_6Request = Parameters[0] +type Endpoint14_6Input = { + readonly sessionID: Endpoint14_6Request["params"]["sessionID"] + readonly formID: Endpoint14_6Request["params"]["formID"] } -const Endpoint13_6 = (raw: RawClient["server.form"]) => (input: Endpoint13_6Input) => +const Endpoint14_6 = (raw: RawClient["server.form"]) => (input: Endpoint14_6Input) => raw["session.form.cancel"]({ params: { sessionID: input["sessionID"], formID: input["formID"] } }).pipe( Effect.mapError(mapClientError), ) -const adaptGroup13 = (raw: RawClient["server.form"]) => ({ - request: { list: Endpoint13_0(raw) }, - list: Endpoint13_1(raw), - create: Endpoint13_2(raw), - get: Endpoint13_3(raw), - state: Endpoint13_4(raw), - reply: Endpoint13_5(raw), - cancel: Endpoint13_6(raw), +const adaptGroup14 = (raw: RawClient["server.form"]) => ({ + request: { list: Endpoint14_0(raw) }, + list: Endpoint14_1(raw), + create: Endpoint14_2(raw), + get: Endpoint14_3(raw), + state: Endpoint14_4(raw), + reply: Endpoint14_5(raw), + cancel: Endpoint14_6(raw), }) -type Endpoint14_0Request = Parameters[0] -type Endpoint14_0Input = { readonly location?: Endpoint14_0Request["query"]["location"] } -const Endpoint14_0 = (raw: RawClient["server.permission"]) => (input?: Endpoint14_0Input) => +type Endpoint15_0Request = Parameters[0] +type Endpoint15_0Input = { readonly location?: Endpoint15_0Request["query"]["location"] } +const Endpoint15_0 = (raw: RawClient["server.permission"]) => (input?: Endpoint15_0Input) => raw["permission.request.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)) -type Endpoint14_1Request = Parameters[0] -type Endpoint14_1Input = { readonly projectID?: Endpoint14_1Request["query"]["projectID"] } -const Endpoint14_1 = (raw: RawClient["server.permission"]) => (input?: Endpoint14_1Input) => +type Endpoint15_1Request = Parameters[0] +type Endpoint15_1Input = { readonly projectID?: Endpoint15_1Request["query"]["projectID"] } +const Endpoint15_1 = (raw: RawClient["server.permission"]) => (input?: Endpoint15_1Input) => raw["permission.saved.list"]({ query: { projectID: input?.["projectID"] } }).pipe( Effect.mapError(mapClientError), Effect.map((value) => value.data), ) -type Endpoint14_2Request = Parameters[0] -type Endpoint14_2Input = { readonly id: Endpoint14_2Request["params"]["id"] } -const Endpoint14_2 = (raw: RawClient["server.permission"]) => (input: Endpoint14_2Input) => +type Endpoint15_2Request = Parameters[0] +type Endpoint15_2Input = { readonly id: Endpoint15_2Request["params"]["id"] } +const Endpoint15_2 = (raw: RawClient["server.permission"]) => (input: Endpoint15_2Input) => raw["permission.saved.remove"]({ params: { id: input["id"] } }).pipe(Effect.mapError(mapClientError)) -type Endpoint14_3Request = Parameters[0] -type Endpoint14_3Input = { - readonly sessionID: Endpoint14_3Request["params"]["sessionID"] - readonly id?: Endpoint14_3Request["payload"]["id"] - readonly action: Endpoint14_3Request["payload"]["action"] - readonly resources: Endpoint14_3Request["payload"]["resources"] - readonly save?: Endpoint14_3Request["payload"]["save"] - readonly metadata?: Endpoint14_3Request["payload"]["metadata"] - readonly source?: Endpoint14_3Request["payload"]["source"] - readonly agent?: Endpoint14_3Request["payload"]["agent"] +type Endpoint15_3Request = Parameters[0] +type Endpoint15_3Input = { + readonly sessionID: Endpoint15_3Request["params"]["sessionID"] + readonly id?: Endpoint15_3Request["payload"]["id"] + readonly action: Endpoint15_3Request["payload"]["action"] + readonly resources: Endpoint15_3Request["payload"]["resources"] + readonly save?: Endpoint15_3Request["payload"]["save"] + readonly metadata?: Endpoint15_3Request["payload"]["metadata"] + readonly source?: Endpoint15_3Request["payload"]["source"] + readonly agent?: Endpoint15_3Request["payload"]["agent"] } -const Endpoint14_3 = (raw: RawClient["server.permission"]) => (input: Endpoint14_3Input) => +const Endpoint15_3 = (raw: RawClient["server.permission"]) => (input: Endpoint15_3Input) => raw["session.permission.create"]({ params: { sessionID: input["sessionID"] }, payload: { @@ -743,86 +766,86 @@ const Endpoint14_3 = (raw: RawClient["server.permission"]) => (input: Endpoint14 Effect.map((value) => value.data), ) -type Endpoint14_4Request = Parameters[0] -type Endpoint14_4Input = { readonly sessionID: Endpoint14_4Request["params"]["sessionID"] } -const Endpoint14_4 = (raw: RawClient["server.permission"]) => (input: Endpoint14_4Input) => +type Endpoint15_4Request = Parameters[0] +type Endpoint15_4Input = { readonly sessionID: Endpoint15_4Request["params"]["sessionID"] } +const Endpoint15_4 = (raw: RawClient["server.permission"]) => (input: Endpoint15_4Input) => raw["session.permission.list"]({ params: { sessionID: input["sessionID"] } }).pipe( Effect.mapError(mapClientError), Effect.map((value) => value.data), ) -type Endpoint14_5Request = Parameters[0] -type Endpoint14_5Input = { - readonly sessionID: Endpoint14_5Request["params"]["sessionID"] - readonly requestID: Endpoint14_5Request["params"]["requestID"] +type Endpoint15_5Request = Parameters[0] +type Endpoint15_5Input = { + readonly sessionID: Endpoint15_5Request["params"]["sessionID"] + readonly requestID: Endpoint15_5Request["params"]["requestID"] } -const Endpoint14_5 = (raw: RawClient["server.permission"]) => (input: Endpoint14_5Input) => +const Endpoint15_5 = (raw: RawClient["server.permission"]) => (input: Endpoint15_5Input) => raw["session.permission.get"]({ params: { sessionID: input["sessionID"], requestID: input["requestID"] } }).pipe( Effect.mapError(mapClientError), Effect.map((value) => value.data), ) -type Endpoint14_6Request = Parameters[0] -type Endpoint14_6Input = { - readonly sessionID: Endpoint14_6Request["params"]["sessionID"] - readonly requestID: Endpoint14_6Request["params"]["requestID"] - readonly reply: Endpoint14_6Request["payload"]["reply"] - readonly message?: Endpoint14_6Request["payload"]["message"] +type Endpoint15_6Request = Parameters[0] +type Endpoint15_6Input = { + readonly sessionID: Endpoint15_6Request["params"]["sessionID"] + readonly requestID: Endpoint15_6Request["params"]["requestID"] + readonly reply: Endpoint15_6Request["payload"]["reply"] + readonly message?: Endpoint15_6Request["payload"]["message"] } -const Endpoint14_6 = (raw: RawClient["server.permission"]) => (input: Endpoint14_6Input) => +const Endpoint15_6 = (raw: RawClient["server.permission"]) => (input: Endpoint15_6Input) => raw["session.permission.reply"]({ params: { sessionID: input["sessionID"], requestID: input["requestID"] }, payload: { reply: input["reply"], message: input["message"] }, }).pipe(Effect.mapError(mapClientError)) -const adaptGroup14 = (raw: RawClient["server.permission"]) => ({ - request: { list: Endpoint14_0(raw) }, - saved: { list: Endpoint14_1(raw), remove: Endpoint14_2(raw) }, - create: Endpoint14_3(raw), - list: Endpoint14_4(raw), - get: Endpoint14_5(raw), - reply: Endpoint14_6(raw), +const adaptGroup15 = (raw: RawClient["server.permission"]) => ({ + request: { list: Endpoint15_0(raw) }, + saved: { list: Endpoint15_1(raw), remove: Endpoint15_2(raw) }, + create: Endpoint15_3(raw), + list: Endpoint15_4(raw), + get: Endpoint15_5(raw), + reply: Endpoint15_6(raw), }) -type Endpoint15_0Request = Parameters[0] -type Endpoint15_0Input = { - readonly location?: Endpoint15_0Request["query"]["location"] - readonly path?: Endpoint15_0Request["query"]["path"] +type Endpoint16_0Request = Parameters[0] +type Endpoint16_0Input = { + readonly location?: Endpoint16_0Request["query"]["location"] + readonly path?: Endpoint16_0Request["query"]["path"] } -const Endpoint15_0 = (raw: RawClient["server.fs"]) => (input?: Endpoint15_0Input) => +const Endpoint16_0 = (raw: RawClient["server.fs"]) => (input?: Endpoint16_0Input) => raw["fs.list"]({ query: { location: input?.["location"], path: input?.["path"] } }).pipe( Effect.mapError(mapClientError), ) -type Endpoint15_1Request = Parameters[0] -type Endpoint15_1Input = { - readonly location?: Endpoint15_1Request["query"]["location"] - readonly query: Endpoint15_1Request["query"]["query"] - readonly type?: Endpoint15_1Request["query"]["type"] - readonly limit?: Endpoint15_1Request["query"]["limit"] +type Endpoint16_1Request = Parameters[0] +type Endpoint16_1Input = { + readonly location?: Endpoint16_1Request["query"]["location"] + readonly query: Endpoint16_1Request["query"]["query"] + readonly type?: Endpoint16_1Request["query"]["type"] + readonly limit?: Endpoint16_1Request["query"]["limit"] } -const Endpoint15_1 = (raw: RawClient["server.fs"]) => (input: Endpoint15_1Input) => +const Endpoint16_1 = (raw: RawClient["server.fs"]) => (input: Endpoint16_1Input) => raw["fs.find"]({ query: { location: input["location"], query: input["query"], type: input["type"], limit: input["limit"] }, }).pipe(Effect.mapError(mapClientError)) -const adaptGroup15 = (raw: RawClient["server.fs"]) => ({ list: Endpoint15_0(raw), find: Endpoint15_1(raw) }) +const adaptGroup16 = (raw: RawClient["server.fs"]) => ({ list: Endpoint16_0(raw), find: Endpoint16_1(raw) }) -type Endpoint16_0Request = Parameters[0] -type Endpoint16_0Input = { readonly location?: Endpoint16_0Request["query"]["location"] } -const Endpoint16_0 = (raw: RawClient["server.command"]) => (input?: Endpoint16_0Input) => +type Endpoint17_0Request = Parameters[0] +type Endpoint17_0Input = { readonly location?: Endpoint17_0Request["query"]["location"] } +const Endpoint17_0 = (raw: RawClient["server.command"]) => (input?: Endpoint17_0Input) => raw["command.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)) -const adaptGroup16 = (raw: RawClient["server.command"]) => ({ list: Endpoint16_0(raw) }) +const adaptGroup17 = (raw: RawClient["server.command"]) => ({ list: Endpoint17_0(raw) }) -type Endpoint17_0Request = Parameters[0] -type Endpoint17_0Input = { readonly location?: Endpoint17_0Request["query"]["location"] } -const Endpoint17_0 = (raw: RawClient["server.skill"]) => (input?: Endpoint17_0Input) => +type Endpoint18_0Request = Parameters[0] +type Endpoint18_0Input = { readonly location?: Endpoint18_0Request["query"]["location"] } +const Endpoint18_0 = (raw: RawClient["server.skill"]) => (input?: Endpoint18_0Input) => raw["skill.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)) -const adaptGroup17 = (raw: RawClient["server.skill"]) => ({ list: Endpoint17_0(raw) }) +const adaptGroup18 = (raw: RawClient["server.skill"]) => ({ list: Endpoint18_0(raw) }) -const Endpoint18_0 = (raw: RawClient["server.event"]) => () => +const Endpoint19_0 = (raw: RawClient["server.event"]) => () => Stream.unwrap( raw["event.subscribe"]({}).pipe( Effect.mapError(mapClientError), @@ -830,23 +853,23 @@ const Endpoint18_0 = (raw: RawClient["server.event"]) => () => ), ) -const adaptGroup18 = (raw: RawClient["server.event"]) => ({ subscribe: Endpoint18_0(raw) }) +const adaptGroup19 = (raw: RawClient["server.event"]) => ({ subscribe: Endpoint19_0(raw) }) -type Endpoint19_0Request = Parameters[0] -type Endpoint19_0Input = { readonly location?: Endpoint19_0Request["query"]["location"] } -const Endpoint19_0 = (raw: RawClient["server.pty"]) => (input?: Endpoint19_0Input) => +type Endpoint20_0Request = Parameters[0] +type Endpoint20_0Input = { readonly location?: Endpoint20_0Request["query"]["location"] } +const Endpoint20_0 = (raw: RawClient["server.pty"]) => (input?: Endpoint20_0Input) => raw["pty.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)) -type Endpoint19_1Request = Parameters[0] -type Endpoint19_1Input = { - readonly location?: Endpoint19_1Request["query"]["location"] - readonly command?: Endpoint19_1Request["payload"]["command"] - readonly args?: Endpoint19_1Request["payload"]["args"] - readonly cwd?: Endpoint19_1Request["payload"]["cwd"] - readonly title?: Endpoint19_1Request["payload"]["title"] - readonly env?: Endpoint19_1Request["payload"]["env"] +type Endpoint20_1Request = Parameters[0] +type Endpoint20_1Input = { + readonly location?: Endpoint20_1Request["query"]["location"] + readonly command?: Endpoint20_1Request["payload"]["command"] + readonly args?: Endpoint20_1Request["payload"]["args"] + readonly cwd?: Endpoint20_1Request["payload"]["cwd"] + readonly title?: Endpoint20_1Request["payload"]["title"] + readonly env?: Endpoint20_1Request["payload"]["env"] } -const Endpoint19_1 = (raw: RawClient["server.pty"]) => (input?: Endpoint19_1Input) => +const Endpoint20_1 = (raw: RawClient["server.pty"]) => (input?: Endpoint20_1Input) => raw["pty.create"]({ query: { location: input?.["location"] }, payload: { @@ -858,274 +881,275 @@ const Endpoint19_1 = (raw: RawClient["server.pty"]) => (input?: Endpoint19_1Inpu }, }).pipe(Effect.mapError(mapClientError)) -type Endpoint19_2Request = Parameters[0] -type Endpoint19_2Input = { - readonly ptyID: Endpoint19_2Request["params"]["ptyID"] - readonly location?: Endpoint19_2Request["query"]["location"] +type Endpoint20_2Request = Parameters[0] +type Endpoint20_2Input = { + readonly ptyID: Endpoint20_2Request["params"]["ptyID"] + readonly location?: Endpoint20_2Request["query"]["location"] } -const Endpoint19_2 = (raw: RawClient["server.pty"]) => (input: Endpoint19_2Input) => +const Endpoint20_2 = (raw: RawClient["server.pty"]) => (input: Endpoint20_2Input) => raw["pty.get"]({ params: { ptyID: input["ptyID"] }, query: { location: input["location"] } }).pipe( Effect.mapError(mapClientError), ) -type Endpoint19_3Request = Parameters[0] -type Endpoint19_3Input = { - readonly ptyID: Endpoint19_3Request["params"]["ptyID"] - readonly location?: Endpoint19_3Request["query"]["location"] - readonly title?: Endpoint19_3Request["payload"]["title"] - readonly size?: Endpoint19_3Request["payload"]["size"] +type Endpoint20_3Request = Parameters[0] +type Endpoint20_3Input = { + readonly ptyID: Endpoint20_3Request["params"]["ptyID"] + readonly location?: Endpoint20_3Request["query"]["location"] + readonly title?: Endpoint20_3Request["payload"]["title"] + readonly size?: Endpoint20_3Request["payload"]["size"] } -const Endpoint19_3 = (raw: RawClient["server.pty"]) => (input: Endpoint19_3Input) => +const Endpoint20_3 = (raw: RawClient["server.pty"]) => (input: Endpoint20_3Input) => raw["pty.update"]({ params: { ptyID: input["ptyID"] }, query: { location: input["location"] }, payload: { title: input["title"], size: input["size"] }, }).pipe(Effect.mapError(mapClientError)) -type Endpoint19_4Request = Parameters[0] -type Endpoint19_4Input = { - readonly ptyID: Endpoint19_4Request["params"]["ptyID"] - readonly location?: Endpoint19_4Request["query"]["location"] +type Endpoint20_4Request = Parameters[0] +type Endpoint20_4Input = { + readonly ptyID: Endpoint20_4Request["params"]["ptyID"] + readonly location?: Endpoint20_4Request["query"]["location"] } -const Endpoint19_4 = (raw: RawClient["server.pty"]) => (input: Endpoint19_4Input) => +const Endpoint20_4 = (raw: RawClient["server.pty"]) => (input: Endpoint20_4Input) => raw["pty.remove"]({ params: { ptyID: input["ptyID"] }, query: { location: input["location"] } }).pipe( Effect.mapError(mapClientError), ) -const adaptGroup19 = (raw: RawClient["server.pty"]) => ({ - list: Endpoint19_0(raw), - create: Endpoint19_1(raw), - get: Endpoint19_2(raw), - update: Endpoint19_3(raw), - remove: Endpoint19_4(raw), +const adaptGroup20 = (raw: RawClient["server.pty"]) => ({ + list: Endpoint20_0(raw), + create: Endpoint20_1(raw), + get: Endpoint20_2(raw), + update: Endpoint20_3(raw), + remove: Endpoint20_4(raw), }) -type Endpoint20_0Request = Parameters[0] -type Endpoint20_0Input = { readonly location?: Endpoint20_0Request["query"]["location"] } -const Endpoint20_0 = (raw: RawClient["server.shell"]) => (input?: Endpoint20_0Input) => +type Endpoint21_0Request = Parameters[0] +type Endpoint21_0Input = { readonly location?: Endpoint21_0Request["query"]["location"] } +const Endpoint21_0 = (raw: RawClient["server.shell"]) => (input?: Endpoint21_0Input) => raw["shell.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)) -type Endpoint20_1Request = Parameters[0] -type Endpoint20_1Input = { - readonly location?: Endpoint20_1Request["query"]["location"] - readonly command: Endpoint20_1Request["payload"]["command"] - readonly cwd?: Endpoint20_1Request["payload"]["cwd"] - readonly timeout: Endpoint20_1Request["payload"]["timeout"] - readonly metadata?: Endpoint20_1Request["payload"]["metadata"] +type Endpoint21_1Request = Parameters[0] +type Endpoint21_1Input = { + readonly location?: Endpoint21_1Request["query"]["location"] + readonly command: Endpoint21_1Request["payload"]["command"] + readonly cwd?: Endpoint21_1Request["payload"]["cwd"] + readonly timeout: Endpoint21_1Request["payload"]["timeout"] + readonly metadata?: Endpoint21_1Request["payload"]["metadata"] } -const Endpoint20_1 = (raw: RawClient["server.shell"]) => (input: Endpoint20_1Input) => +const Endpoint21_1 = (raw: RawClient["server.shell"]) => (input: Endpoint21_1Input) => raw["shell.create"]({ query: { location: input["location"] }, payload: { command: input["command"], cwd: input["cwd"], timeout: input["timeout"], metadata: input["metadata"] }, }).pipe(Effect.mapError(mapClientError)) -type Endpoint20_2Request = Parameters[0] -type Endpoint20_2Input = { - readonly id: Endpoint20_2Request["params"]["id"] - readonly location?: Endpoint20_2Request["query"]["location"] +type Endpoint21_2Request = Parameters[0] +type Endpoint21_2Input = { + readonly id: Endpoint21_2Request["params"]["id"] + readonly location?: Endpoint21_2Request["query"]["location"] } -const Endpoint20_2 = (raw: RawClient["server.shell"]) => (input: Endpoint20_2Input) => +const Endpoint21_2 = (raw: RawClient["server.shell"]) => (input: Endpoint21_2Input) => raw["shell.get"]({ params: { id: input["id"] }, query: { location: input["location"] } }).pipe( Effect.mapError(mapClientError), ) -type Endpoint20_3Request = Parameters[0] -type Endpoint20_3Input = { - readonly id: Endpoint20_3Request["params"]["id"] - readonly location?: Endpoint20_3Request["query"]["location"] - readonly timeout: Endpoint20_3Request["payload"]["timeout"] +type Endpoint21_3Request = Parameters[0] +type Endpoint21_3Input = { + readonly id: Endpoint21_3Request["params"]["id"] + readonly location?: Endpoint21_3Request["query"]["location"] + readonly timeout: Endpoint21_3Request["payload"]["timeout"] } -const Endpoint20_3 = (raw: RawClient["server.shell"]) => (input: Endpoint20_3Input) => +const Endpoint21_3 = (raw: RawClient["server.shell"]) => (input: Endpoint21_3Input) => raw["shell.timeout"]({ params: { id: input["id"] }, query: { location: input["location"] }, payload: { timeout: input["timeout"] }, }).pipe(Effect.mapError(mapClientError)) -type Endpoint20_4Request = Parameters[0] -type Endpoint20_4Input = { - readonly id: Endpoint20_4Request["params"]["id"] - readonly location?: Endpoint20_4Request["query"]["location"] - readonly cursor?: Endpoint20_4Request["query"]["cursor"] - readonly limit?: Endpoint20_4Request["query"]["limit"] +type Endpoint21_4Request = Parameters[0] +type Endpoint21_4Input = { + readonly id: Endpoint21_4Request["params"]["id"] + readonly location?: Endpoint21_4Request["query"]["location"] + readonly cursor?: Endpoint21_4Request["query"]["cursor"] + readonly limit?: Endpoint21_4Request["query"]["limit"] } -const Endpoint20_4 = (raw: RawClient["server.shell"]) => (input: Endpoint20_4Input) => +const Endpoint21_4 = (raw: RawClient["server.shell"]) => (input: Endpoint21_4Input) => raw["shell.output"]({ params: { id: input["id"] }, query: { location: input["location"], cursor: input["cursor"], limit: input["limit"] }, }).pipe(Effect.mapError(mapClientError)) -type Endpoint20_5Request = Parameters[0] -type Endpoint20_5Input = { - readonly id: Endpoint20_5Request["params"]["id"] - readonly location?: Endpoint20_5Request["query"]["location"] +type Endpoint21_5Request = Parameters[0] +type Endpoint21_5Input = { + readonly id: Endpoint21_5Request["params"]["id"] + readonly location?: Endpoint21_5Request["query"]["location"] } -const Endpoint20_5 = (raw: RawClient["server.shell"]) => (input: Endpoint20_5Input) => +const Endpoint21_5 = (raw: RawClient["server.shell"]) => (input: Endpoint21_5Input) => raw["shell.remove"]({ params: { id: input["id"] }, query: { location: input["location"] } }).pipe( Effect.mapError(mapClientError), ) -const adaptGroup20 = (raw: RawClient["server.shell"]) => ({ - list: Endpoint20_0(raw), - create: Endpoint20_1(raw), - get: Endpoint20_2(raw), - timeout: Endpoint20_3(raw), - output: Endpoint20_4(raw), - remove: Endpoint20_5(raw), +const adaptGroup21 = (raw: RawClient["server.shell"]) => ({ + list: Endpoint21_0(raw), + create: Endpoint21_1(raw), + get: Endpoint21_2(raw), + timeout: Endpoint21_3(raw), + output: Endpoint21_4(raw), + remove: Endpoint21_5(raw), }) -type Endpoint21_0Request = Parameters[0] -type Endpoint21_0Input = { readonly location?: Endpoint21_0Request["query"]["location"] } -const Endpoint21_0 = (raw: RawClient["server.question"]) => (input?: Endpoint21_0Input) => +type Endpoint22_0Request = Parameters[0] +type Endpoint22_0Input = { readonly location?: Endpoint22_0Request["query"]["location"] } +const Endpoint22_0 = (raw: RawClient["server.question"]) => (input?: Endpoint22_0Input) => raw["question.request.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)) -type Endpoint21_1Request = Parameters[0] -type Endpoint21_1Input = { readonly sessionID: Endpoint21_1Request["params"]["sessionID"] } -const Endpoint21_1 = (raw: RawClient["server.question"]) => (input: Endpoint21_1Input) => +type Endpoint22_1Request = Parameters[0] +type Endpoint22_1Input = { readonly sessionID: Endpoint22_1Request["params"]["sessionID"] } +const Endpoint22_1 = (raw: RawClient["server.question"]) => (input: Endpoint22_1Input) => raw["session.question.list"]({ params: { sessionID: input["sessionID"] } }).pipe( Effect.mapError(mapClientError), Effect.map((value) => value.data), ) -type Endpoint21_2Request = Parameters[0] -type Endpoint21_2Input = { - readonly sessionID: Endpoint21_2Request["params"]["sessionID"] - readonly requestID: Endpoint21_2Request["params"]["requestID"] - readonly answers: Endpoint21_2Request["payload"]["answers"] +type Endpoint22_2Request = Parameters[0] +type Endpoint22_2Input = { + readonly sessionID: Endpoint22_2Request["params"]["sessionID"] + readonly requestID: Endpoint22_2Request["params"]["requestID"] + readonly answers: Endpoint22_2Request["payload"]["answers"] } -const Endpoint21_2 = (raw: RawClient["server.question"]) => (input: Endpoint21_2Input) => +const Endpoint22_2 = (raw: RawClient["server.question"]) => (input: Endpoint22_2Input) => raw["session.question.reply"]({ params: { sessionID: input["sessionID"], requestID: input["requestID"] }, payload: { answers: input["answers"] }, }).pipe(Effect.mapError(mapClientError)) -type Endpoint21_3Request = Parameters[0] -type Endpoint21_3Input = { - readonly sessionID: Endpoint21_3Request["params"]["sessionID"] - readonly requestID: Endpoint21_3Request["params"]["requestID"] +type Endpoint22_3Request = Parameters[0] +type Endpoint22_3Input = { + readonly sessionID: Endpoint22_3Request["params"]["sessionID"] + readonly requestID: Endpoint22_3Request["params"]["requestID"] } -const Endpoint21_3 = (raw: RawClient["server.question"]) => (input: Endpoint21_3Input) => +const Endpoint22_3 = (raw: RawClient["server.question"]) => (input: Endpoint22_3Input) => raw["session.question.reject"]({ params: { sessionID: input["sessionID"], requestID: input["requestID"] } }).pipe( Effect.mapError(mapClientError), ) -const adaptGroup21 = (raw: RawClient["server.question"]) => ({ - request: { list: Endpoint21_0(raw) }, - list: Endpoint21_1(raw), - reply: Endpoint21_2(raw), - reject: Endpoint21_3(raw), +const adaptGroup22 = (raw: RawClient["server.question"]) => ({ + request: { list: Endpoint22_0(raw) }, + list: Endpoint22_1(raw), + reply: Endpoint22_2(raw), + reject: Endpoint22_3(raw), }) -type Endpoint22_0Request = Parameters[0] -type Endpoint22_0Input = { readonly location?: Endpoint22_0Request["query"]["location"] } -const Endpoint22_0 = (raw: RawClient["server.reference"]) => (input?: Endpoint22_0Input) => +type Endpoint23_0Request = Parameters[0] +type Endpoint23_0Input = { readonly location?: Endpoint23_0Request["query"]["location"] } +const Endpoint23_0 = (raw: RawClient["server.reference"]) => (input?: Endpoint23_0Input) => raw["reference.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)) -const adaptGroup22 = (raw: RawClient["server.reference"]) => ({ list: Endpoint22_0(raw) }) +const adaptGroup23 = (raw: RawClient["server.reference"]) => ({ list: Endpoint23_0(raw) }) -type Endpoint23_0Request = Parameters[0] -type Endpoint23_0Input = { - readonly projectID: Endpoint23_0Request["params"]["projectID"] - readonly location?: Endpoint23_0Request["query"]["location"] - readonly strategy: Endpoint23_0Request["payload"]["strategy"] - readonly directory: Endpoint23_0Request["payload"]["directory"] - readonly name?: Endpoint23_0Request["payload"]["name"] +type Endpoint24_0Request = Parameters[0] +type Endpoint24_0Input = { + readonly projectID: Endpoint24_0Request["params"]["projectID"] + readonly location?: Endpoint24_0Request["query"]["location"] + readonly strategy: Endpoint24_0Request["payload"]["strategy"] + readonly directory: Endpoint24_0Request["payload"]["directory"] + readonly name?: Endpoint24_0Request["payload"]["name"] } -const Endpoint23_0 = (raw: RawClient["server.projectCopy"]) => (input: Endpoint23_0Input) => +const Endpoint24_0 = (raw: RawClient["server.projectCopy"]) => (input: Endpoint24_0Input) => raw["projectCopy.create"]({ params: { projectID: input["projectID"] }, query: { location: input["location"] }, payload: { strategy: input["strategy"], directory: input["directory"], name: input["name"] }, }).pipe(Effect.mapError(mapClientError)) -type Endpoint23_1Request = Parameters[0] -type Endpoint23_1Input = { - readonly projectID: Endpoint23_1Request["params"]["projectID"] - readonly location?: Endpoint23_1Request["query"]["location"] - readonly directory: Endpoint23_1Request["payload"]["directory"] - readonly force: Endpoint23_1Request["payload"]["force"] +type Endpoint24_1Request = Parameters[0] +type Endpoint24_1Input = { + readonly projectID: Endpoint24_1Request["params"]["projectID"] + readonly location?: Endpoint24_1Request["query"]["location"] + readonly directory: Endpoint24_1Request["payload"]["directory"] + readonly force: Endpoint24_1Request["payload"]["force"] } -const Endpoint23_1 = (raw: RawClient["server.projectCopy"]) => (input: Endpoint23_1Input) => +const Endpoint24_1 = (raw: RawClient["server.projectCopy"]) => (input: Endpoint24_1Input) => raw["projectCopy.remove"]({ params: { projectID: input["projectID"] }, query: { location: input["location"] }, payload: { directory: input["directory"], force: input["force"] }, }).pipe(Effect.mapError(mapClientError)) -type Endpoint23_2Request = Parameters[0] -type Endpoint23_2Input = { - readonly projectID: Endpoint23_2Request["params"]["projectID"] - readonly location?: Endpoint23_2Request["query"]["location"] +type Endpoint24_2Request = Parameters[0] +type Endpoint24_2Input = { + readonly projectID: Endpoint24_2Request["params"]["projectID"] + readonly location?: Endpoint24_2Request["query"]["location"] } -const Endpoint23_2 = (raw: RawClient["server.projectCopy"]) => (input: Endpoint23_2Input) => +const Endpoint24_2 = (raw: RawClient["server.projectCopy"]) => (input: Endpoint24_2Input) => raw["projectCopy.refresh"]({ params: { projectID: input["projectID"] }, query: { location: input["location"] }, }).pipe(Effect.mapError(mapClientError)) -const adaptGroup23 = (raw: RawClient["server.projectCopy"]) => ({ - create: Endpoint23_0(raw), - remove: Endpoint23_1(raw), - refresh: Endpoint23_2(raw), +const adaptGroup24 = (raw: RawClient["server.projectCopy"]) => ({ + create: Endpoint24_0(raw), + remove: Endpoint24_1(raw), + refresh: Endpoint24_2(raw), }) -type Endpoint24_0Request = Parameters[0] -type Endpoint24_0Input = { readonly location?: Endpoint24_0Request["query"]["location"] } -const Endpoint24_0 = (raw: RawClient["server.vcs"]) => (input?: Endpoint24_0Input) => +type Endpoint25_0Request = Parameters[0] +type Endpoint25_0Input = { readonly location?: Endpoint25_0Request["query"]["location"] } +const Endpoint25_0 = (raw: RawClient["server.vcs"]) => (input?: Endpoint25_0Input) => raw["vcs.status"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)) -type Endpoint24_1Request = Parameters[0] -type Endpoint24_1Input = { - readonly location?: Endpoint24_1Request["query"]["location"] - readonly mode: Endpoint24_1Request["query"]["mode"] - readonly context?: Endpoint24_1Request["query"]["context"] +type Endpoint25_1Request = Parameters[0] +type Endpoint25_1Input = { + readonly location?: Endpoint25_1Request["query"]["location"] + readonly mode: Endpoint25_1Request["query"]["mode"] + readonly context?: Endpoint25_1Request["query"]["context"] } -const Endpoint24_1 = (raw: RawClient["server.vcs"]) => (input: Endpoint24_1Input) => +const Endpoint25_1 = (raw: RawClient["server.vcs"]) => (input: Endpoint25_1Input) => raw["vcs.diff"]({ query: { location: input["location"], mode: input["mode"], context: input["context"] } }).pipe( Effect.mapError(mapClientError), ) -const adaptGroup24 = (raw: RawClient["server.vcs"]) => ({ status: Endpoint24_0(raw), diff: Endpoint24_1(raw) }) +const adaptGroup25 = (raw: RawClient["server.vcs"]) => ({ status: Endpoint25_0(raw), diff: Endpoint25_1(raw) }) -const Endpoint25_0 = (raw: RawClient["server.debug"]) => () => +const Endpoint26_0 = (raw: RawClient["server.debug"]) => () => raw["debug.location"]({}).pipe(Effect.mapError(mapClientError)) -type Endpoint25_1Request = Parameters[0] -type Endpoint25_1Input = { readonly location?: Endpoint25_1Request["query"]["location"] } -const Endpoint25_1 = (raw: RawClient["server.debug"]) => (input?: Endpoint25_1Input) => +type Endpoint26_1Request = Parameters[0] +type Endpoint26_1Input = { readonly location?: Endpoint26_1Request["query"]["location"] } +const Endpoint26_1 = (raw: RawClient["server.debug"]) => (input?: Endpoint26_1Input) => raw["debug.location.evict"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)) -const adaptGroup25 = (raw: RawClient["server.debug"]) => ({ - location: { list: Endpoint25_0(raw), evict: Endpoint25_1(raw) }, +const adaptGroup26 = (raw: RawClient["server.debug"]) => ({ + location: { list: Endpoint26_0(raw), evict: Endpoint26_1(raw) }, }) const adaptClient = (raw: RawClient) => ({ health: adaptGroup0(raw["server.health"]), - location: adaptGroup1(raw["server.location"]), - agent: adaptGroup2(raw["server.agent"]), - plugin: adaptGroup3(raw["server.plugin"]), - session: adaptGroup4(raw["server.session"]), - message: adaptGroup5(raw["server.message"]), - model: adaptGroup6(raw["server.model"]), - generate: adaptGroup7(raw["server.generate"]), - provider: adaptGroup8(raw["server.provider"]), - integration: adaptGroup9(raw["server.integration"]), - "server.mcp": adaptGroup10(raw["server.mcp"]), - credential: adaptGroup11(raw["server.credential"]), - project: adaptGroup12(raw["server.project"]), - form: adaptGroup13(raw["server.form"]), - permission: adaptGroup14(raw["server.permission"]), - file: adaptGroup15(raw["server.fs"]), - command: adaptGroup16(raw["server.command"]), - skill: adaptGroup17(raw["server.skill"]), - event: adaptGroup18(raw["server.event"]), - pty: adaptGroup19(raw["server.pty"]), - shell: adaptGroup20(raw["server.shell"]), - question: adaptGroup21(raw["server.question"]), - reference: adaptGroup22(raw["server.reference"]), - projectCopy: adaptGroup23(raw["server.projectCopy"]), - vcs: adaptGroup24(raw["server.vcs"]), - debug: adaptGroup25(raw["server.debug"]), + server: adaptGroup1(raw["server.server"]), + location: adaptGroup2(raw["server.location"]), + agent: adaptGroup3(raw["server.agent"]), + plugin: adaptGroup4(raw["server.plugin"]), + session: adaptGroup5(raw["server.session"]), + message: adaptGroup6(raw["server.message"]), + model: adaptGroup7(raw["server.model"]), + generate: adaptGroup8(raw["server.generate"]), + provider: adaptGroup9(raw["server.provider"]), + integration: adaptGroup10(raw["server.integration"]), + "server.mcp": adaptGroup11(raw["server.mcp"]), + credential: adaptGroup12(raw["server.credential"]), + project: adaptGroup13(raw["server.project"]), + form: adaptGroup14(raw["server.form"]), + permission: adaptGroup15(raw["server.permission"]), + file: adaptGroup16(raw["server.fs"]), + command: adaptGroup17(raw["server.command"]), + skill: adaptGroup18(raw["server.skill"]), + event: adaptGroup19(raw["server.event"]), + pty: adaptGroup20(raw["server.pty"]), + shell: adaptGroup21(raw["server.shell"]), + question: adaptGroup22(raw["server.question"]), + reference: adaptGroup23(raw["server.reference"]), + projectCopy: adaptGroup24(raw["server.projectCopy"]), + vcs: adaptGroup25(raw["server.vcs"]), + debug: adaptGroup26(raw["server.debug"]), }) export const make = (options?: { readonly baseUrl?: URL | string }) => diff --git a/packages/client/src/effect/service.ts b/packages/client/src/effect/service.ts index 9c5e833ee1..3e5d3ec0f2 100644 --- a/packages/client/src/effect/service.ts +++ b/packages/client/src/effect/service.ts @@ -11,9 +11,13 @@ import { join } from "node:path" // is all a client needs to connect. The daemon's own configuration (port, // persisted password) is CLI-owned and never read here. -export type Transport = { +export type Endpoint = { readonly url: string - readonly headers?: RequestInit["headers"] + readonly auth?: { + readonly type: "basic" + readonly username: string + readonly password: string + } } export type Options = { @@ -31,7 +35,7 @@ export type Options = { // Read-only lookup: registration file plus health check and version gate. // Never spawns; escalation to start() is the caller's policy. export const discover = Effect.fn("service.discover")(function* (options: Options = {}) { - return (yield* discoverLocal(options))?.transport + return (yield* discoverLocal(options))?.endpoint }) const discoverLocal = Effect.fnUntraced(function* (options: Options) { @@ -72,7 +76,7 @@ export const start = Effect.fn("service.start")(function* (options: Options = {} child.kill("SIGTERM") }), ), - Effect.map((found) => found.transport), + Effect.map((found) => found.endpoint), Effect.tapError(() => Effect.try({ try: () => child.kill("SIGTERM"), catch: () => undefined }).pipe(Effect.ignore)), Effect.mapError(() => new Error("Failed to start server")), ) @@ -90,8 +94,9 @@ function fallback() { return join(state, "opencode", "service.json") } -function auth(password: string): RequestInit["headers"] { - return { authorization: "Basic " + btoa("opencode:" + password) } +export function headers(endpoint: Endpoint): RequestInit["headers"] { + if (endpoint.auth === undefined) return undefined + return { authorization: "Basic " + btoa(endpoint.auth.username + ":" + endpoint.auth.password) } } export const Info = Schema.Struct({ @@ -120,14 +125,20 @@ const read = Effect.fnUntraced(function* (file?: string) { type LocalService = { readonly info: Info - readonly transport: Transport + readonly endpoint: Endpoint } const probe = Effect.fnUntraced(function* (info: Info, version?: string, allowLegacy = false) { - const headers = info.password === undefined ? undefined : auth(info.password) + const endpoint = { + url: info.url, + auth: + info.password === undefined + ? undefined + : { type: "basic" as const, username: "opencode", password: info.password }, + } satisfies Endpoint const response = yield* Effect.tryPromise(() => fetch(new URL("/api/health", info.url), { - headers, + headers: headers(endpoint), signal: AbortSignal.timeout(2_000), }), ).pipe(Effect.option, Effect.map(Option.getOrUndefined)) @@ -138,7 +149,7 @@ const probe = Effect.fnUntraced(function* (info: Info, version?: string, allowLe if (health.value.pid !== info.pid) return undefined if (info.version !== undefined && health.value.version !== info.version) return undefined if (version !== undefined && health.value.version !== version) return undefined - return { info, transport: { url: info.url, headers } } satisfies LocalService + return { info, endpoint } satisfies LocalService } if ( !allowLegacy || @@ -146,7 +157,7 @@ const probe = Effect.fnUntraced(function* (info: Info, version?: string, allowLe (typeof body === "object" && body !== null && ("version" in body || "pid" in body)) ) return undefined - return { info, transport: { url: info.url, headers } } satisfies LocalService + return { info, endpoint } satisfies LocalService }) // Health-checked lookup without the version gate: lifecycle operations must be diff --git a/packages/client/src/promise/generated/client.ts b/packages/client/src/promise/generated/client.ts index cc1b6b1854..606673944c 100644 --- a/packages/client/src/promise/generated/client.ts +++ b/packages/client/src/promise/generated/client.ts @@ -1,5 +1,6 @@ import type { HealthGetOutput, + ServerGetOutput, LocationGetInput, LocationGetOutput, AgentListInput, @@ -328,6 +329,13 @@ export function make(options: ClientOptions) { requestOptions, ), }, + server: { + get: (requestOptions?: RequestOptions) => + request( + { method: "GET", path: `/api/server`, successStatus: 200, declaredStatuses: [401, 400], empty: false }, + requestOptions, + ), + }, location: { get: (input?: LocationGetInput, requestOptions?: RequestOptions) => request( @@ -508,7 +516,15 @@ export function make(options: ClientOptions) { { method: "POST", path: `/api/session/${encodeURIComponent(input.sessionID)}/prompt`, - body: { id: input["id"], prompt: input["prompt"], delivery: input["delivery"], resume: input["resume"] }, + body: { + id: input["id"], + text: input["text"], + files: input["files"], + agents: input["agents"], + metadata: input["metadata"], + delivery: input["delivery"], + resume: input["resume"], + }, successStatus: 200, declaredStatuses: [409, 400, 404, 401], empty: false, @@ -550,22 +566,24 @@ export function make(options: ClientOptions) { requestOptions, ), synthetic: (input: SessionSyntheticInput, requestOptions?: RequestOptions) => - request( + request<{ readonly data: SessionSyntheticOutput }>( { method: "POST", path: `/api/session/${encodeURIComponent(input.sessionID)}/synthetic`, body: { + id: input["id"], text: input["text"], description: input["description"], metadata: input["metadata"], + delivery: input["delivery"], resume: input["resume"], }, - successStatus: 204, - declaredStatuses: [404, 400, 401], - empty: true, + successStatus: 200, + declaredStatuses: [409, 404, 400, 401], + empty: false, }, requestOptions, - ), + ).then((value) => value.data), shell: (input: SessionShellInput, requestOptions?: RequestOptions) => request( { diff --git a/packages/client/src/promise/generated/types.ts b/packages/client/src/promise/generated/types.ts index 043f2caa3d..c61fdb5eea 100644 --- a/packages/client/src/promise/generated/types.ts +++ b/packages/client/src/promise/generated/types.ts @@ -159,6 +159,8 @@ export const isProjectCopyError = (value: unknown): value is ProjectCopyError => export type HealthGetOutput = { readonly healthy: true; readonly version: string; readonly pid: number } +export type ServerGetOutput = { readonly urls: ReadonlyArray } + export type LocationGetInput = { readonly location?: { readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined @@ -541,73 +543,120 @@ export type SessionPromptInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] readonly id?: { readonly id?: string | null - readonly prompt: { - readonly text: string - readonly files?: ReadonlyArray<{ - readonly uri: string - readonly name?: string - readonly description?: string - readonly mention?: { readonly start: number; readonly end: number; readonly text: string } - }> - readonly agents?: ReadonlyArray<{ - readonly name: string - readonly mention?: { readonly start: number; readonly end: number; readonly text: string } - }> - } + readonly text: string + readonly files?: ReadonlyArray<{ + readonly uri: string + readonly name?: string + readonly description?: string + readonly mention?: { readonly start: number; readonly end: number; readonly text: string } + }> + readonly agents?: ReadonlyArray<{ + readonly name: string + readonly mention?: { readonly start: number; readonly end: number; readonly text: string } + }> + readonly metadata?: { readonly [x: string]: JsonValue } readonly delivery?: "steer" | "queue" | null readonly resume?: boolean | null }["id"] - readonly prompt: { + readonly text: { readonly id?: string | null - readonly prompt: { - readonly text: string - readonly files?: ReadonlyArray<{ - readonly uri: string - readonly name?: string - readonly description?: string - readonly mention?: { readonly start: number; readonly end: number; readonly text: string } - }> - readonly agents?: ReadonlyArray<{ - readonly name: string - readonly mention?: { readonly start: number; readonly end: number; readonly text: string } - }> - } + readonly text: string + readonly files?: ReadonlyArray<{ + readonly uri: string + readonly name?: string + readonly description?: string + readonly mention?: { readonly start: number; readonly end: number; readonly text: string } + }> + readonly agents?: ReadonlyArray<{ + readonly name: string + readonly mention?: { readonly start: number; readonly end: number; readonly text: string } + }> + readonly metadata?: { readonly [x: string]: JsonValue } readonly delivery?: "steer" | "queue" | null readonly resume?: boolean | null - }["prompt"] + }["text"] + readonly files?: { + readonly id?: string | null + readonly text: string + readonly files?: ReadonlyArray<{ + readonly uri: string + readonly name?: string + readonly description?: string + readonly mention?: { readonly start: number; readonly end: number; readonly text: string } + }> + readonly agents?: ReadonlyArray<{ + readonly name: string + readonly mention?: { readonly start: number; readonly end: number; readonly text: string } + }> + readonly metadata?: { readonly [x: string]: JsonValue } + readonly delivery?: "steer" | "queue" | null + readonly resume?: boolean | null + }["files"] + readonly agents?: { + readonly id?: string | null + readonly text: string + readonly files?: ReadonlyArray<{ + readonly uri: string + readonly name?: string + readonly description?: string + readonly mention?: { readonly start: number; readonly end: number; readonly text: string } + }> + readonly agents?: ReadonlyArray<{ + readonly name: string + readonly mention?: { readonly start: number; readonly end: number; readonly text: string } + }> + readonly metadata?: { readonly [x: string]: JsonValue } + readonly delivery?: "steer" | "queue" | null + readonly resume?: boolean | null + }["agents"] + readonly metadata?: { + readonly id?: string | null + readonly text: string + readonly files?: ReadonlyArray<{ + readonly uri: string + readonly name?: string + readonly description?: string + readonly mention?: { readonly start: number; readonly end: number; readonly text: string } + }> + readonly agents?: ReadonlyArray<{ + readonly name: string + readonly mention?: { readonly start: number; readonly end: number; readonly text: string } + }> + readonly metadata?: { readonly [x: string]: JsonValue } + readonly delivery?: "steer" | "queue" | null + readonly resume?: boolean | null + }["metadata"] readonly delivery?: { readonly id?: string | null - readonly prompt: { - readonly text: string - readonly files?: ReadonlyArray<{ - readonly uri: string - readonly name?: string - readonly description?: string - readonly mention?: { readonly start: number; readonly end: number; readonly text: string } - }> - readonly agents?: ReadonlyArray<{ - readonly name: string - readonly mention?: { readonly start: number; readonly end: number; readonly text: string } - }> - } + readonly text: string + readonly files?: ReadonlyArray<{ + readonly uri: string + readonly name?: string + readonly description?: string + readonly mention?: { readonly start: number; readonly end: number; readonly text: string } + }> + readonly agents?: ReadonlyArray<{ + readonly name: string + readonly mention?: { readonly start: number; readonly end: number; readonly text: string } + }> + readonly metadata?: { readonly [x: string]: JsonValue } readonly delivery?: "steer" | "queue" | null readonly resume?: boolean | null }["delivery"] readonly resume?: { readonly id?: string | null - readonly prompt: { - readonly text: string - readonly files?: ReadonlyArray<{ - readonly uri: string - readonly name?: string - readonly description?: string - readonly mention?: { readonly start: number; readonly end: number; readonly text: string } - }> - readonly agents?: ReadonlyArray<{ - readonly name: string - readonly mention?: { readonly start: number; readonly end: number; readonly text: string } - }> - } + readonly text: string + readonly files?: ReadonlyArray<{ + readonly uri: string + readonly name?: string + readonly description?: string + readonly mention?: { readonly start: number; readonly end: number; readonly text: string } + }> + readonly agents?: ReadonlyArray<{ + readonly name: string + readonly mention?: { readonly start: number; readonly end: number; readonly text: string } + }> + readonly metadata?: { readonly [x: string]: JsonValue } readonly delivery?: "steer" | "queue" | null readonly resume?: boolean | null }["resume"] @@ -618,7 +667,10 @@ export type SessionPromptOutput = { readonly admittedSeq: number readonly id: string readonly sessionID: string - readonly prompt: { + readonly timeCreated: number + readonly promotedSeq?: number + readonly type: "user" + readonly data: { readonly text: string readonly files?: ReadonlyArray<{ readonly data: string @@ -632,10 +684,9 @@ export type SessionPromptOutput = { readonly name: string readonly mention?: { readonly start: number; readonly end: number; readonly text: string } }> + readonly metadata?: { readonly [x: string]: JsonValue } } readonly delivery: "steer" | "queue" - readonly timeCreated: number - readonly promotedSeq?: number } }["data"] @@ -819,7 +870,10 @@ export type SessionCommandOutput = { readonly admittedSeq: number readonly id: string readonly sessionID: string - readonly prompt: { + readonly timeCreated: number + readonly promotedSeq?: number + readonly type: "user" + readonly data: { readonly text: string readonly files?: ReadonlyArray<{ readonly data: string @@ -833,10 +887,9 @@ export type SessionCommandOutput = { readonly name: string readonly mention?: { readonly start: number; readonly end: number; readonly text: string } }> + readonly metadata?: { readonly [x: string]: JsonValue } } readonly delivery: "steer" | "queue" - readonly timeCreated: number - readonly promotedSeq?: number } }["data"] @@ -863,33 +916,72 @@ export type SessionSkillOutput = void export type SessionSyntheticInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] - readonly text: { + readonly id?: { + readonly id?: string | null readonly text: string readonly description?: string | null readonly metadata?: { readonly [x: string]: JsonValue } + readonly delivery?: "steer" | "queue" | null + readonly resume?: boolean | null + }["id"] + readonly text: { + readonly id?: string | null + readonly text: string + readonly description?: string | null + readonly metadata?: { readonly [x: string]: JsonValue } + readonly delivery?: "steer" | "queue" | null readonly resume?: boolean | null }["text"] readonly description?: { + readonly id?: string | null readonly text: string readonly description?: string | null readonly metadata?: { readonly [x: string]: JsonValue } + readonly delivery?: "steer" | "queue" | null readonly resume?: boolean | null }["description"] readonly metadata?: { + readonly id?: string | null readonly text: string readonly description?: string | null readonly metadata?: { readonly [x: string]: JsonValue } + readonly delivery?: "steer" | "queue" | null readonly resume?: boolean | null }["metadata"] - readonly resume?: { + readonly delivery?: { + readonly id?: string | null readonly text: string readonly description?: string | null readonly metadata?: { readonly [x: string]: JsonValue } + readonly delivery?: "steer" | "queue" | null + readonly resume?: boolean | null + }["delivery"] + readonly resume?: { + readonly id?: string | null + readonly text: string + readonly description?: string | null + readonly metadata?: { readonly [x: string]: JsonValue } + readonly delivery?: "steer" | "queue" | null readonly resume?: boolean | null }["resume"] } -export type SessionSyntheticOutput = void +export type SessionSyntheticOutput = { + readonly data: { + readonly admittedSeq: number + readonly id: string + readonly sessionID: string + readonly timeCreated: number + readonly promotedSeq?: number + readonly type: "synthetic" + readonly data: { + readonly text: string + readonly description?: string + readonly metadata?: { readonly [x: string]: JsonValue } + } + readonly delivery: "steer" | "queue" + } +}["data"] export type SessionShellInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] @@ -906,11 +998,11 @@ export type SessionCompactInput = { export type SessionCompactOutput = { readonly data: { - readonly type: "compaction" readonly admittedSeq: number readonly id: string readonly sessionID: string readonly timeCreated: number + readonly type: "compaction" readonly handledSeq?: number } }["data"] @@ -1227,7 +1319,7 @@ export type SessionLogOutput = readonly id: string readonly created: number readonly metadata?: { readonly [x: string]: unknown } - readonly type: "session.prompt.promoted" + readonly type: "session.input.promoted" readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 1 } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID: string; readonly inputID: string } @@ -1236,28 +1328,42 @@ export type SessionLogOutput = readonly id: string readonly created: number readonly metadata?: { readonly [x: string]: unknown } - readonly type: "session.prompt.admitted" + readonly type: "session.input.admitted" readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 1 } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID: string readonly inputID: string - readonly prompt: { - readonly text: string - readonly files?: ReadonlyArray<{ - readonly data: string - readonly mime: string - readonly source: { readonly type: "inline" } | { readonly type: "uri"; readonly uri: string } - readonly name?: string - readonly description?: string - readonly mention?: { readonly start: number; readonly end: number; readonly text: string } - }> - readonly agents?: ReadonlyArray<{ - readonly name: string - readonly mention?: { readonly start: number; readonly end: number; readonly text: string } - }> - } - readonly delivery: "steer" | "queue" + readonly input: + | { + readonly type: "user" + readonly data: { + readonly text: string + readonly files?: ReadonlyArray<{ + readonly data: string + readonly mime: string + readonly source: { readonly type: "inline" } | { readonly type: "uri"; readonly uri: string } + readonly name?: string + readonly description?: string + readonly mention?: { readonly start: number; readonly end: number; readonly text: string } + }> + readonly agents?: ReadonlyArray<{ + readonly name: string + readonly mention?: { readonly start: number; readonly end: number; readonly text: string } + }> + readonly metadata?: { readonly [x: string]: unknown } + } + readonly delivery: "steer" | "queue" + } + | { + readonly type: "synthetic" + readonly data: { + readonly text: string + readonly description?: string + readonly metadata?: { readonly [x: string]: unknown } + } + readonly delivery: "steer" | "queue" + } } } | { @@ -2598,7 +2704,7 @@ export type FormRequestListOutput = { | { readonly id: string readonly sessionID: string - readonly title?: string + readonly title: string readonly metadata?: { readonly [x: string]: JsonValue } readonly mode: "form" readonly fields: ReadonlyArray< @@ -2695,7 +2801,7 @@ export type FormRequestListOutput = { | { readonly id: string readonly sessionID: string - readonly title?: string + readonly title: string readonly metadata?: { readonly [x: string]: JsonValue } readonly mode: "url" readonly url: string @@ -2710,7 +2816,7 @@ export type FormListOutput = { | { readonly id: string readonly sessionID: string - readonly title?: string + readonly title: string readonly metadata?: { readonly [x: string]: JsonValue } readonly mode: "form" readonly fields: ReadonlyArray< @@ -2807,7 +2913,7 @@ export type FormListOutput = { | { readonly id: string readonly sessionID: string - readonly title?: string + readonly title: string readonly metadata?: { readonly [x: string]: JsonValue } readonly mode: "url" readonly url: string @@ -2819,7 +2925,7 @@ export type FormCreateInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] readonly id?: { readonly id?: string | null - readonly title?: string + readonly title: string readonly metadata?: { readonly [x: string]: JsonValue } readonly mode: "form" | "url" readonly fields?: ReadonlyArray< @@ -2914,9 +3020,9 @@ export type FormCreateInput = { > | null readonly url?: string | null }["id"] - readonly title?: { + readonly title: { readonly id?: string | null - readonly title?: string + readonly title: string readonly metadata?: { readonly [x: string]: JsonValue } readonly mode: "form" | "url" readonly fields?: ReadonlyArray< @@ -3013,7 +3119,7 @@ export type FormCreateInput = { }["title"] readonly metadata?: { readonly id?: string | null - readonly title?: string + readonly title: string readonly metadata?: { readonly [x: string]: JsonValue } readonly mode: "form" | "url" readonly fields?: ReadonlyArray< @@ -3110,7 +3216,7 @@ export type FormCreateInput = { }["metadata"] readonly mode: { readonly id?: string | null - readonly title?: string + readonly title: string readonly metadata?: { readonly [x: string]: JsonValue } readonly mode: "form" | "url" readonly fields?: ReadonlyArray< @@ -3207,7 +3313,7 @@ export type FormCreateInput = { }["mode"] readonly fields?: { readonly id?: string | null - readonly title?: string + readonly title: string readonly metadata?: { readonly [x: string]: JsonValue } readonly mode: "form" | "url" readonly fields?: ReadonlyArray< @@ -3304,7 +3410,7 @@ export type FormCreateInput = { }["fields"] readonly url?: { readonly id?: string | null - readonly title?: string + readonly title: string readonly metadata?: { readonly [x: string]: JsonValue } readonly mode: "form" | "url" readonly fields?: ReadonlyArray< @@ -3406,7 +3512,7 @@ export type FormCreateOutput = { | { readonly id: string readonly sessionID: string - readonly title?: string + readonly title: string readonly metadata?: { readonly [x: string]: JsonValue } readonly mode: "form" readonly fields: ReadonlyArray< @@ -3503,7 +3609,7 @@ export type FormCreateOutput = { | { readonly id: string readonly sessionID: string - readonly title?: string + readonly title: string readonly metadata?: { readonly [x: string]: JsonValue } readonly mode: "url" readonly url: string @@ -3520,7 +3626,7 @@ export type FormGetOutput = { | { readonly id: string readonly sessionID: string - readonly title?: string + readonly title: string readonly metadata?: { readonly [x: string]: JsonValue } readonly mode: "form" readonly fields: ReadonlyArray< @@ -3617,7 +3723,7 @@ export type FormGetOutput = { | { readonly id: string readonly sessionID: string - readonly title?: string + readonly title: string readonly metadata?: { readonly [x: string]: JsonValue } readonly mode: "url" readonly url: string @@ -4586,7 +4692,7 @@ export type EventSubscribeOutput = readonly id: string readonly created: number readonly metadata?: { readonly [x: string]: unknown } - readonly type: "session.prompt.promoted" + readonly type: "session.input.promoted" readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 1 } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID: string; readonly inputID: string } @@ -4595,28 +4701,42 @@ export type EventSubscribeOutput = readonly id: string readonly created: number readonly metadata?: { readonly [x: string]: unknown } - readonly type: "session.prompt.admitted" + readonly type: "session.input.admitted" readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 1 } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID: string readonly inputID: string - readonly prompt: { - readonly text: string - readonly files?: ReadonlyArray<{ - readonly data: string - readonly mime: string - readonly source: { readonly type: "inline" } | { readonly type: "uri"; readonly uri: string } - readonly name?: string - readonly description?: string - readonly mention?: { readonly start: number; readonly end: number; readonly text: string } - }> - readonly agents?: ReadonlyArray<{ - readonly name: string - readonly mention?: { readonly start: number; readonly end: number; readonly text: string } - }> - } - readonly delivery: "steer" | "queue" + readonly input: + | { + readonly type: "user" + readonly data: { + readonly text: string + readonly files?: ReadonlyArray<{ + readonly data: string + readonly mime: string + readonly source: { readonly type: "inline" } | { readonly type: "uri"; readonly uri: string } + readonly name?: string + readonly description?: string + readonly mention?: { readonly start: number; readonly end: number; readonly text: string } + }> + readonly agents?: ReadonlyArray<{ + readonly name: string + readonly mention?: { readonly start: number; readonly end: number; readonly text: string } + }> + readonly metadata?: { readonly [x: string]: unknown } + } + readonly delivery: "steer" | "queue" + } + | { + readonly type: "synthetic" + readonly data: { + readonly text: string + readonly description?: string + readonly metadata?: { readonly [x: string]: unknown } + } + readonly delivery: "steer" | "queue" + } } } | { @@ -5339,7 +5459,7 @@ export type EventSubscribeOutput = | { readonly id: string readonly sessionID: string - readonly title?: string + readonly title: string readonly metadata?: { readonly [x: string]: unknown } readonly mode: "form" readonly fields: ReadonlyArray< @@ -5436,7 +5556,7 @@ export type EventSubscribeOutput = | { readonly id: string readonly sessionID: string - readonly title?: string + readonly title: string readonly metadata?: { readonly [x: string]: unknown } readonly mode: "url" readonly url: string @@ -5463,17 +5583,6 @@ export type EventSubscribeOutput = readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly id: string; readonly sessionID: string } } - | { - readonly id: string - readonly created: number - readonly metadata?: { readonly [x: string]: unknown } - readonly type: "todo.updated" - readonly location?: { readonly directory: string; readonly workspaceID?: string } - readonly data: { - readonly sessionID: string - readonly todos: ReadonlyArray<{ readonly content: string; readonly status: string; readonly priority: string }> - } - } | { readonly id: string readonly created: number diff --git a/packages/client/test/contract-identity.test.ts b/packages/client/test/contract-identity.test.ts index 553c120f7a..3b6c1b82b7 100644 --- a/packages/client/test/contract-identity.test.ts +++ b/packages/client/test/contract-identity.test.ts @@ -37,7 +37,9 @@ test("Core and Server reuse the authoritative Schema and Protocol values", () => expect(ProjectV2.Current).toBe(Project.Current) expect(ProjectV2.Directory).toBe(Project.Directory) expect(ProjectV2.Directories).toBe(Project.Directories) - expect(CoreSessionInput.Admitted).toBe(SessionInput.Admitted) + expect(CoreSessionInput.Message).toBe(SessionInput.Message) + expect(CoreSessionInput.User).toBe(SessionInput.User) + expect(CoreSessionInput.Synthetic).toBe(SessionInput.Synthetic) expect(CoreSessionMessage.Info).toBe(SessionMessage.Info) expect(Api.groups["server.session"].identifier).toBe("server.session") expect(Api.groups["server.project"].identifier).toBe("server.project") diff --git a/packages/client/test/effect.test.ts b/packages/client/test/effect.test.ts index 6247d25610..c1254ab7d3 100644 --- a/packages/client/test/effect.test.ts +++ b/packages/client/test/effect.test.ts @@ -178,7 +178,7 @@ test("session methods retain decoded Effect inputs and outputs", async () => { }) const admitted = yield* client.session.prompt({ sessionID: Session.ID.make("ses_test"), - prompt: Prompt.make({ text: "Hello" }), + text: "Hello", resume: false, }) yield* client.session.compact({ sessionID: Session.ID.make("ses_test") }) @@ -201,7 +201,7 @@ test("session methods retain decoded Effect inputs and outputs", async () => { expect(Object.getPrototypeOf(result.created)).toBe(Object.prototype) expect(result.created.id).toBe("ses_test") expect(Object.getPrototypeOf(result.admitted)).toBe(Object.prototype) - expect(Object.getPrototypeOf(result.admitted.prompt)).toBe(Object.prototype) + expect(Object.getPrototypeOf(result.admitted.data)).toBe(Object.prototype) expect(DateTime.toEpochMillis(result.admitted.timeCreated)).toBe(1_717_171_717_000) expect(result.context).toEqual([]) expect(logQueries[0]).toEqual({ after: "0" }) @@ -259,7 +259,8 @@ const admission = { admittedSeq: 0, id: "msg_test", sessionID: "ses_test", - prompt: { text: "Hello" }, + type: "user", + data: { text: "Hello" }, delivery: "steer", timeCreated: 1_717_171_717_000, }, diff --git a/packages/client/test/promise.test.ts b/packages/client/test/promise.test.ts index e67d737942..015e9ebf5e 100644 --- a/packages/client/test/promise.test.ts +++ b/packages/client/test/promise.test.ts @@ -6,6 +6,7 @@ test("exposes every standard HTTP API group", () => { expect(Object.keys(client)).toEqual([ "health", + "server", "location", "agent", "plugin", @@ -45,6 +46,21 @@ test("exposes every standard HTTP API group", () => { expect(Object.keys(client.project)).toEqual(["list", "current", "directories"]) }) +test("server.get uses the public HTTP contract", async () => { + let request: Request | undefined + const client = OpenCode.make({ + baseUrl: "http://localhost:3000", + fetch: async (input) => { + request = input instanceof Request ? input : new Request(input) + return Response.json({ urls: ["http://192.168.1.10:4096"] }) + }, + }) + + expect(await client.server.get()).toEqual({ urls: ["http://192.168.1.10:4096"] }) + expect(request?.method).toBe("GET") + expect(request?.url).toBe("http://localhost:3000/api/server") +}) + test("MCP resource catalog uses the public HTTP contract", async () => { let request: Request | undefined const client = OpenCode.make({ @@ -258,6 +274,7 @@ test("session methods use the public HTTP contract", async () => { }) } if (url.includes("/prompt")) return Response.json(admission) + if (url.includes("/synthetic")) return Response.json(syntheticAdmission) if (url.endsWith("/compact")) return Response.json(compactionAdmission) if (url.includes("/context")) return Response.json({ data: [] }) if (url.includes("/message/")) return Response.json({ data: modelSwitchedMessage }) @@ -278,7 +295,13 @@ test("session methods use the public HTTP contract", async () => { }) const admitted = await client.session.prompt({ sessionID: "ses_test", - prompt: { text: "Hello" }, + text: "Hello", + resume: false, + }) + const synthetic = await client.session.synthetic({ + sessionID: "ses_test", + text: "Completed", + delivery: "queue", resume: false, }) await client.session.compact({ sessionID: "ses_test" }) @@ -293,6 +316,7 @@ test("session methods use the public HTTP contract", async () => { expect(active).toEqual({ ses_test: { type: "running" } }) expect(created.id).toBe("ses_test") expect(admitted.id).toBe("msg_test") + expect(synthetic).toMatchObject({ type: "synthetic", data: { text: "Completed" }, delivery: "queue" }) expect(context).toEqual([]) expect(log).toEqual([modelSwitchedEvent, synced]) expect(message).toEqual(modelSwitchedMessage) @@ -303,6 +327,7 @@ test("session methods use the public HTTP contract", async () => { ["POST", "http://localhost:3000/api/session/ses_test/agent"], ["POST", "http://localhost:3000/api/session/ses_test/model"], ["POST", "http://localhost:3000/api/session/ses_test/prompt"], + ["POST", "http://localhost:3000/api/session/ses_test/synthetic"], ["POST", "http://localhost:3000/api/session/ses_test/compact"], ["POST", "http://localhost:3000/api/session/ses_test/wait"], ["GET", "http://localhost:3000/api/session/ses_test/context"], @@ -313,7 +338,14 @@ test("session methods use the public HTTP contract", async () => { const body = requests.find((request) => request.url.endsWith("/api/session/ses_test/prompt"))?.init?.body if (typeof body !== "string") throw new Error("Expected JSON request body") expect(JSON.parse(body)).toEqual({ - prompt: { text: "Hello" }, + text: "Hello", + resume: false, + }) + const syntheticBody = requests.find((request) => request.url.endsWith("/synthetic"))?.init?.body + if (typeof syntheticBody !== "string") throw new Error("Expected JSON synthetic request body") + expect(JSON.parse(syntheticBody)).toEqual({ + text: "Completed", + delivery: "queue", resume: false, }) }) @@ -376,12 +408,25 @@ const admission = { admittedSeq: 0, id: "msg_test", sessionID: "ses_test", - prompt: { text: "Hello" }, + type: "user", + data: { text: "Hello" }, delivery: "steer", timeCreated: 1_717_171_717_000, }, } +const syntheticAdmission = { + data: { + admittedSeq: 1, + id: "msg_synthetic", + sessionID: "ses_test", + type: "synthetic", + data: { text: "Completed" }, + delivery: "queue", + timeCreated: 1_717_171_717_000, + }, +} + const compactionAdmission = { data: { type: "compaction", diff --git a/packages/codemode/AGENTS.md b/packages/codemode/AGENTS.md index df812c6d86..bc72c815ea 100644 --- a/packages/codemode/AGENTS.md +++ b/packages/codemode/AGENTS.md @@ -4,6 +4,7 @@ - Do not add a speculative generic permission or approval policy. A host omits tools it does not expose and enforces domain authorization inside each provided tool. - Keep Code Mode unaware of host session, channel, and conversation models. The hosting application supplies trusted execution scope around it. - Tool schemas are the model-facing Interface. Keep arguments minimal and natural to the operation; never add unrelated IDs as ambient capability tokens. +- When interpreter behavior or support changes, update `interpreter-support.md` and direct tests in the same PR. Update `codemode.md` when the package design, integration status, or rationale changes. ## OpenAPI diff --git a/packages/codemode/README.md b/packages/codemode/README.md index e298ccfc57..ee2c4162e6 100644 --- a/packages/codemode/README.md +++ b/packages/codemode/README.md @@ -235,23 +235,21 @@ A host cannot define its own `$codemode` top-level namespace. ## Supported Programs -CodeMode executes a deliberately bounded JavaScript subset. It supports: +CodeMode executes a deliberately bounded JavaScript subset. See the +[interpreter support checklist](./interpreter-support.md) for the complete, checkable language and standard-library +matrix, known semantic gaps, and intentional exclusions. -- Plain data literals, property access, assignment, destructuring, and sequence expressions (the comma operator, evaluated left to right with the final value returned). -- `if`, conditional expressions, `switch`, `for`, `for...of` (arrays, strings, Maps, Sets, including assignment-form destructuring such as `for ([key, value] of entries)`), `for...in` (own keys of plain objects, index strings of arrays, and namespace/tool names of `tools` references - anything else is an error suggesting `for...of` or `Object.keys`, rather than real JS's surprising behavior of indices for strings and zero iterations for Maps/Sets), `while`, and `do...while`. -- Arrow functions and function declarations with closures, defaults, rest parameters, and destructuring. -- Optional chaining, nullish coalescing, templates, spread (arrays, strings, Maps, Sets), and `try`/`catch`. -- Common array, string, number, `Object`, `Math`, and `JSON` operations, including primitive-number `valueOf`, the standard non-finite `Number` constants, and host-backed `Math.random`. Mutating array methods include `push`/`pop`/`shift`/`unshift`/`splice` (removes in place and returns the removed elements)/`fill`/`copyWithin`; array `keys`/`values`/`entries` return **arrays** (matching the Map/Set convention) and work with `for...of` and spread. String methods include `localeCompare` (locale/options arguments ignored), `normalize`, and the `trimLeft`/`trimRight` aliases. `Object.keys` also accepts arrays (index strings, as in JS) and tool references: `Object.keys(tools)` lists the top-level namespaces, including `$codemode`, and `Object.keys(tools.ns)` lists the names at that node (a callable tool enumerates as `[]`; an unknown path is an `UnknownTool` diagnostic). `Object.values`/`Object.entries` on a tool reference fail with a pointer at `Object.keys(tools)` and `tools.$codemode.search`. -- `Date` - `Date.now()`/`Date.parse()`/`Date.UTC()`, `new Date(...)`, the getter methods, and date arithmetic/comparison via the time value. Dates stringify as ISO (`toString` included, for determinism across host timezones). -- Regular expressions - `/literals/` and `new RegExp(...)` with `test`/`exec` (stateful `lastIndex` for `g`), plus string `match`/`matchAll`/`replace`/`replaceAll`/`split`/`search` with patterns. Match results are arrays carrying `index` and named `groups` as own properties (`input` is omitted). `replace` and `replaceAll` accept function replacers with captures, offset, input, and named groups; callbacks run sequentially, may await tool calls, and have their results coerced to strings. Invalid patterns, invalid flags, and missing-`g` calls fail with catchable errors that say what was wrong and how to fix it (escaping hints, the exact `/pattern/g` to write). Patterns run on the host engine, so pathological backtracking is bounded only by the execution timeout. -- `Map` and `Set` - construction from entries/arrays/strings, `get`/`set`/`add`/`has`/`delete`/`clear`/`size`/`forEach`, and `keys`/`values`/`entries` returning **arrays** (not iterators). -- URL helpers - `URL` resolution and mutation, linked `URLSearchParams`, `URL.canParse`/`URL.parse`, URI and URI-component encoding/decoding, and query parameter construction, lookup, mutation, sorting, callbacks, and materialization. URLSearchParams iteration methods return arrays, matching the Map/Set convention. -- First-class promises - an un-awaited `tools.ns.tool(...)` is a promise value whose call starts immediately on a supervised fiber; `await` resolves it (awaiting a non-promise value is a no-op, and `return tools.ns.tool(...)` resolves like an async-function return). `Promise.all`, `Promise.allSettled`, and `Promise.race` accept any array mixing promises and plain values (built inline, beforehand, or via spread); `Promise.resolve`/`Promise.reject` construct settled promises. `Promise.allSettled` rejection reasons are the same plain `{ name?, message }` data a `catch` binding sees, and `Promise.race` interrupts its losing in-flight calls. At most 8 tool calls run concurrently. When a program completes, still-running un-awaited calls are awaited before the execution ends; a failure from a call that was never awaited surfaces as an unhandled-rejection diagnostic. -- `throw value` and `throw new Error(message)` for explicit program failure. `Error` (and `TypeError`/`RangeError`/`SyntaxError`/`ReferenceError`/`EvalError`/`URIError`) are real constructors, callable with or without `new`; error values are plain `{ name, message }` data that additionally satisfy `instanceof Error` (a specific type matches itself and `Error`, as in JS). Every caught failure - thrown errors, interpreter runtime errors, and tool failures - is `instanceof Error` in a `catch` block; a thrown non-error value (`throw "text"`) is not, matching JS. Caught failures carry the `name` the equivalent real-JS failure would have - `JSON.parse` and invalid regex patterns produce a `SyntaxError` (satisfying `instanceof SyntaxError`), an unknown identifier a `ReferenceError`, assigning to a constant a `TypeError`, a bad `normalize` form a `RangeError`; failures with no specific analogue (including tool failures) are named `"Error"`. `instanceof` also recognizes `Date`, `RegExp`, `Map`, `Set`, `URL`, `URLSearchParams`, `Array`, `Object`, and `Promise`; any other right-hand side is a catchable error. +At a high level, it supports: -Inside a program, standard-library values stay live everywhere: the internal data checkpoints (`Object.*` helpers, spread, coercion inputs) preserve the instances, so `Object.values({ d: date })[0].getTime()` and a spread copy of an object holding a Map keep working. Only at the host boundary (final result, tool arguments, `JSON.stringify`) do they serialize exactly as `JSON.stringify` would: Date and URL become strings (an invalid Date becomes `null`), while RegExp, Map, Set, and URLSearchParams become `{}`. Promise values never cross a data boundary: an un-awaited promise in a result or tool argument produces a diagnostic that says to await it, instead of serializing to `{}`. +- Plain data, property access and assignment, destructuring, functions, conditionals, loops, spread, optional chaining, + and structured error handling. +- Allowlisted Array, String, Number, Object, Math, JSON, console, Date, RegExp, Map, Set, URL, and URLSearchParams APIs. +- Eager supervised tool promises, direct `await`, and the supported `Promise` combinators for concurrent work. +- Live standard-library values inside the sandbox and predictable JSON-like serialization at tool/result boundaries. +- Actionable diagnostics for unsupported syntax, invalid data, tool failures, limits, and execution failures. -It does not expose `eval`, dynamic imports, modules, classes, generators, timers, host globals, prototype mutation, custom promise constructors (`new Promise`), promise chaining (`.then`/`.catch`/`.finally` - `await` with `try`/`catch` is the supported style), or arbitrary method calls. Unsupported syntax returns an `UnsupportedSyntax` diagnostic with a source location when available. +It does not expose ambient host authority or arbitrary JavaScript execution. Unsupported syntax returns an +`UnsupportedSyntax` diagnostic with a source location when available. CodeMode is an orchestration language, not a general JavaScript runtime. diff --git a/packages/codemode/codemode.md b/packages/codemode/codemode.md index c388cca1bd..440271dc06 100644 --- a/packages/codemode/codemode.md +++ b/packages/codemode/codemode.md @@ -6,7 +6,8 @@ It records current behavior, intentional boundaries, durable rationale, and mate Completed implementation history, branch names, test counts, and closed findings belong in git, not here. Remove completed work instead of preserving checked-off chronology. -Detailed package API documentation lives in [README.md](./README.md). OpenAPI-specific follow-ups live in +Detailed package API documentation lives in [README.md](./README.md), and the checkable language/runtime matrix lives +in [interpreter-support.md](./interpreter-support.md). OpenAPI-specific follow-ups live in [src/openapi/TODO.md](./src/openapi/TODO.md). ## How CodeMode Works @@ -140,31 +141,5 @@ represent accurately rather than guessing semantics. ## Remaining Work -Keep only material unresolved work here. Small isolated defects should be GitHub issues; adapter-only work belongs in -the adapter TODO. Delete entries when completed. - -### DSL expansion - -The supported JavaScript subset should grow when common model-generated code improves tool orchestration. These are -current omissions to implement, not intentional product boundaries. - -- [ ] Design proper multi-stage promise pipelines. Supporting `.then`, `.catch`, and `.finally` should preserve promise - assimilation, cancellation, failure handling, and concurrent per-item pipelines rather than adding syntax-only - shims. Consider `Promise.any` in the same pass. -- [ ] Support async iteration and `for await...of`. Define behavior first for the runtime's supported promise and - collection values, then extend it to bounded host streams when a stream boundary exists. -- [ ] Support callback-bearing standard-library variants that models commonly generate: the mapper argument to - `Array.from(...)` and replacers for `JSON.stringify(...)`, including Effect-aware callbacks where needed. -- [ ] Add `Object.is` after runtime method and tool references have stable identity semantics. -- [ ] Add deterministic modern collection conveniences where they improve orchestration: `Object.groupBy`, Set - composition methods, and `Array.prototype.toSpliced`. -- [ ] Decide whether iterable `Math.sumPrecise` belongs in the runtime. -- [ ] Refine diagnostics so user throws, expected tool failures, unexpected host/tool defects, and genuine interpreter - defects are distinguishable without leaking private causes. - -### Tool and result contracts - -- [ ] Design explicit tagged representations and size rules before allowing Blob, File, ArrayBuffer, typed arrays, or - host streams to cross the sandbox boundary. -- [ ] Define one consistent policy for tool path segments named `__proto__`, `constructor`, or `prototype`. They must - either be safely callable, rejected before catalog generation, or use one documented escaping rule. +The [interpreter support checklist](./interpreter-support.md) owns concrete DSL, standard-library, semantic-correctness, +diagnostic, and data-boundary work. OpenAPI adapter work remains in [src/openapi/TODO.md](./src/openapi/TODO.md). diff --git a/packages/codemode/interpreter-support.md b/packages/codemode/interpreter-support.md new file mode 100644 index 0000000000..aac4ac938c --- /dev/null +++ b/packages/codemode/interpreter-support.md @@ -0,0 +1,296 @@ +# CodeMode Interpreter Support + +This is the checkable support matrix for CodeMode's confined JavaScript interpreter. It tracks the language and +standard-library surface that programs can use today, plus concrete gaps that may be implemented later. + +- `[x]` means the feature is implemented at the scope described here. +- `[ ]` means the feature is unavailable, incomplete, or intentionally divergent as described. +- A checked item does not promise complete ECMAScript edge-case parity. Known differences are listed next to the + supported surface or under [Known semantic gaps](#known-semantic-gaps). +- [Intentional exclusions](#intentional-exclusions) are boundaries, not backlog. + +When behavior changes, update this file and the tests in the same change. The implementation and tests remain the +ultimate source of truth. + +## Source and execution model + +- [x] JavaScript parsed with the latest syntax accepted by Acorn, then restricted by the interpreter allowlist. +- [x] Erasable TypeScript syntax, including type annotations, type declarations, assertions, and non-null assertions. + TypeScript is transpiled first; the emitted JavaScript must still use the supported subset. +- [x] Top-level `await` and `return` through the program's implicit async-function scope. +- [x] Explicit `return`, final top-level expression as a REPL-style result, and `null` when no value is produced. +- [x] JSON-like host boundaries with `undefined` and non-finite numbers normalized to `null`. +- [x] Live Date, RegExp, Map, Set, URL, and URLSearchParams values inside the sandbox. +- [x] Tool calls through the host-provided `tools` tree only. +- [x] Cooperative timeout, tool-call accounting, output bounding, and a maximum of eight concurrent tool calls. +- [ ] Full JavaScript or TypeScript compatibility. CodeMode is a bounded orchestration language. + +## Values and literals + +- [x] `null`, `undefined`, booleans, finite and non-finite numbers, and strings. +- [x] Array literals, including holes and spread from arrays, strings, Maps, Sets, and URLSearchParams. +- [x] Object literals with shorthand, computed string/number keys, and object spread. +- [x] Template literals with interpolation. +- [x] Regular-expression literals. +- [x] `NaN` and `Infinity` globals. +- [ ] BigInt literals and values. +- [ ] Symbols. +- [ ] Tagged template literals. +- [ ] Getters and setters in object literals. + +## Bindings and destructuring + +- [x] `const`, `let`, and accepted `var` declarations. +- [x] Object and array destructuring in declarations, parameters, assignment expressions, and `for...of` bindings. +- [x] Nested patterns, defaults, elisions, and rest elements. +- [x] Assignment to identifiers, object fields, array indexes, and writable URL fields. +- [x] Function declarations are hoisted within their interpreted scope. +- [x] Parameter defaults observe a temporal dead zone for later parameters. +- [ ] JavaScript-correct `var` function scope, hoisting, and redeclaration. Accepted `var` currently behaves like a + lexical declaration; prefer `let` or `const`. +- [ ] Complete `let`/`const` temporal-dead-zone and declaration-hoisting semantics. +- [ ] Computed object destructuring keys such as `const { [field]: value } = record`. +- [ ] Object destructuring from arrays, such as `const { length } = values`. +- [ ] Iterable array destructuring from Map, Set, string, or URLSearchParams values. +- [ ] Dynamic property deletion with `delete object[key]`. + +## Statements and control flow + +- [x] Blocks and empty statements. +- [x] `if`/`else` and conditional expressions. +- [x] `switch`, including default clauses and fallthrough. +- [x] `for`, `while`, and `do...while`. +- [x] `for...of` over arrays, strings, Maps, Sets, and URLSearchParams. +- [x] `for...in` over own keys of plain objects, arrays, and tool references. +- [x] Unlabeled `break` and `continue`. +- [x] `try`, `catch`, optional catch bindings, and `finally`. +- [x] `throw` with arbitrary values. +- [ ] Labeled statements, labeled `break`, and labeled `continue`. +- [ ] `for await...of` and async iteration. +- [ ] `with` and `debugger` statements. + +## Functions and callbacks + +- [x] Function declarations, function expressions, and arrow functions. +- [x] Synchronous and `async` functions. +- [x] Closures, recursion, default parameters, rest parameters, and destructured parameters. +- [x] Expression and block function bodies. +- [x] User callbacks for the supported Array, Map, Set, URLSearchParams, sort, and string-replacement APIs. +- [x] `Boolean`, `Number`, `String`, `parseInt`, `parseFloat`, and URI helpers as callbacks where applicable. +- [x] Async string replacement callbacks; replacements are evaluated sequentially. +- [ ] `this`, `super`, constructor functions, or function prototype methods such as `call`, `apply`, and `bind`. +- [ ] Classes and private fields. +- [ ] Generator functions and `yield`. +- [ ] Async predicates, reducers, and comparators with automatic awaiting. Async mapping can be joined explicitly with + `Promise.all`, but a promise is not a meaningful predicate or sort result. +- [ ] General built-in callable references as callbacks, such as `values.map(Math.abs)` or + `records.map(JSON.stringify)`. + +## Expressions and operators + +- [x] Property access with dot or computed bracket syntax. +- [x] Optional property access and optional calls. +- [x] Function/tool calls and spread arguments. +- [x] Sequence expressions (the comma operator). +- [x] `await` for sandbox promises; awaiting a plain value is a no-op. +- [x] `new` for Error types, Date, RegExp, Map, Set, URL, and URLSearchParams. +- [x] Arithmetic operators: `+`, `-`, `*`, `/`, `%`, and `**`. +- [x] Equality and ordering: `==`, `!=`, `===`, `!==`, `<`, `<=`, `>`, and `>=`. +- [x] Bitwise operators: `&`, `|`, `^`, `~`, `<<`, `>>`, and `>>>`. +- [x] Logical operators: `&&`, `||`, `??`, and `!`, with short-circuiting. +- [x] Unary `+`, unary `-`, `typeof`, `instanceof`, and own-property-only `in`. +- [x] Prefix and postfix `++` and `--`. +- [x] Plain, arithmetic, bitwise, and logical assignment operators. +- [ ] Unary `void` and `delete`. +- [ ] Arbitrary constructors and `new Promise(...)`. + +## Promises and tools + +- [x] Tool calls start eagerly and return supervised, run-once sandbox promises. +- [x] Direct `await`, repeated awaits, and implicit resolution when a promise is returned from a function/program. +- [x] `Promise.resolve` and `Promise.reject`. +- [x] `Promise.all`, `Promise.allSettled`, and `Promise.race` over supported collections containing promises and plain + values. +- [x] `Promise.all` preserves result order and rejects on the first observed failure. +- [x] `Promise.allSettled` returns plain fulfilled/rejected outcome records. +- [x] `Promise.race` interrupts losing in-flight tool calls. +- [x] Un-awaited calls are drained before execution ends; unhandled failures become diagnostics. +- [x] `try`/`catch` can handle awaited tool and promise failures. +- [ ] Real promise values from `Promise.all`, `Promise.allSettled`, and `Promise.race`. These calls currently settle + before returning, so separately constructed combinator batches do not overlap as normal JavaScript promises do. +- [ ] `Promise.any`. +- [ ] Promise chaining with `.then`, `.catch`, and `.finally`. +- [ ] Custom promise construction with `new Promise(...)`. +- [ ] Async iterables, host streams, and stream consumption. + +## Objects and properties + +- [x] Own-field reads and writes on plain data objects. +- [x] Computed property names and object spread. +- [x] `Object.keys`, `Object.values`, `Object.entries`, `Object.hasOwn`, `Object.assign`, and `Object.fromEntries`. +- [x] `Object.keys` over arrays and tool references. +- [x] Object identity is preserved by in-sandbox Object helpers. +- [x] Blocked access to `__proto__`, `constructor`, and `prototype`. +- [ ] `Object.is`; runtime and tool-reference identity semantics need to be defined first. +- [ ] `Object.groupBy`. +- [ ] Object creation, descriptors, freezing/sealing, prototype APIs, and reflection APIs. +- [ ] A final policy for legal data/tool keys named `__proto__`, `constructor`, or `prototype`. + +## Arrays + +- [x] Static methods: `Array.isArray`, `Array.of`, and `Array.from`. +- [x] Iteration/transformation: `map`, `filter`, `flatMap`, and `forEach`. +- [x] Searching/tests: `find`, `findIndex`, `findLast`, `findLastIndex`, `some`, `every`, `includes`, `indexOf`, and + `lastIndexOf`. +- [x] Aggregation: `reduce` and `reduceRight`. +- [x] Ordering: `sort`, `toSorted`, `reverse`, and `toReversed`. +- [x] Access/copying: `at`, `slice`, `concat`, `flat`, `with`, and `join`. +- [x] Mutation: `push`, `pop`, `shift`, `unshift`, `splice`, `fill`, and `copyWithin`. +- [x] Materialized iteration helpers: `keys`, `values`, and `entries` return arrays rather than iterators. +- [x] `length`, numeric indexing, index assignment, spread, and `for...of`. +- [ ] The mapper and `thisArg` forms of `Array.from`. +- [ ] `Array.prototype.toSpliced`. +- [ ] Canonical index handling: a key such as `"01"` must not alias index `1`. +- [ ] Complete sparse-array parity. +- [ ] Correct `findLast` return behavior when its predicate mutates the examined element. + +## Strings + +- [x] Case/normalization: `toLowerCase`, `toUpperCase`, `normalize`. +- [x] Trimming: `trim`, `trimStart`, `trimEnd`, `trimLeft`, and `trimRight`. +- [x] Searching/tests: `includes`, `startsWith`, `endsWith`, `indexOf`, `lastIndexOf`, and `search`. +- [x] Slicing/access: `slice`, `substring`, `substr`, `at`, `charAt`, `charCodeAt`, and `codePointAt`. +- [x] Construction/transformation: `split`, `concat`, `repeat`, `padStart`, `padEnd`, `replace`, and `replaceAll`. +- [x] Regular-expression integration: `match`, materialized `matchAll`, `replace`, `replaceAll`, `split`, and `search`. +- [x] `localeCompare`; locale and options arguments are currently ignored. +- [x] `toString`, `length`, numeric indexing, spread, and `for...of` by Unicode code point. +- [x] Static `String.fromCharCode` and `String.fromCodePoint`. +- [ ] Locale/options-aware `localeCompare` and locale formatting APIs. +- [ ] Exact native coercion across every string method; CodeMode often requires explicit strings/numbers. +- [ ] Native no-argument parity for `match()` and `search()`. + +## Numbers and Math + +- [x] Coercion functions: `Number`, `parseInt`, and `parseFloat`. +- [x] Number predicates/parsers: `Number.isInteger`, `Number.isFinite`, `Number.isNaN`, `Number.isSafeInteger`, + `Number.parseInt`, and `Number.parseFloat`. +- [x] Number formatting: `toFixed`, `toPrecision`, `toExponential`, `toString`, and `valueOf`. +- [x] Number constants: `MAX_SAFE_INTEGER`, `MIN_SAFE_INTEGER`, `MAX_VALUE`, `MIN_VALUE`, `EPSILON`, `NaN`, + `POSITIVE_INFINITY`, and `NEGATIVE_INFINITY`. +- [x] Math constants: `PI`, `E`, `LN2`, `LN10`, `LOG2E`, `LOG10E`, `SQRT2`, and `SQRT1_2`. +- [x] Math methods: `random`, `max`, `min`, `abs`, `acos`, `acosh`, `asin`, `asinh`, `atan`, `atan2`, `atanh`, + `floor`, `ceil`, `round`, `trunc`, `sign`, `sqrt`, `cbrt`, `pow`, `hypot`, `cos`, `cosh`, `sin`, `sinh`, + `tan`, `tanh`, `log`, `log2`, `log10`, `log1p`, `exp`, `expm1`, `f16round`, `fround`, `clz32`, and `imul`. +- [ ] Native zero-argument behavior for `Number()` and `String()`; they currently do not produce `0` and `""`. +- [ ] Safe interpreter coercion for `++` and `--` rather than host `Number(...)` coercion. +- [ ] Reliable feature detection for unknown static members. +- [ ] `Math.sumPrecise`. +- [ ] Global coercing `isFinite` and `isNaN`. + +## JSON and console + +- [x] `JSON.parse` and `JSON.stringify`. +- [x] Numeric/string indentation for `JSON.stringify`. +- [x] Captured `console.log`, `console.info`, `console.debug`, `console.warn`, and `console.error`. +- [x] Captured `console.dir` and `console.table`. +- [ ] `JSON.parse` reviver callbacks. +- [ ] `JSON.stringify` function/array replacers. +- [ ] Other console methods, timers, counters, groups, and host console access. + +## Date + +- [x] `Date.now`, `Date.parse`, and `Date.UTC`. +- [x] `new Date()` from the current time, epoch milliseconds, a date string, another Date, or local components. +- [x] `getTime`, `valueOf`, `toISOString`, `toJSON`, and deterministic ISO `toString`. +- [x] Local getters: `getFullYear`, `getMonth`, `getDate`, `getDay`, `getHours`, `getMinutes`, `getSeconds`, and + `getMilliseconds`. +- [x] UTC getters: `getUTCFullYear`, `getUTCMonth`, `getUTCDate`, `getUTCDay`, `getUTCHours`, `getUTCMinutes`, + `getUTCSeconds`, and `getUTCMilliseconds`. +- [x] `getTimezoneOffset`, arithmetic, relational comparison, and `instanceof Date`. +- [x] Date values serialize to ISO strings; invalid dates serialize to `null`. +- [ ] Date setters. +- [ ] `toUTCString`, locale methods, and other Date formatting methods. +- [ ] Exact native constructor coercion, local-time, and loose-equality semantics. +- [ ] Native `RangeError` branding for invalid `toISOString()` calls. +- [ ] Temporal and Intl date/time APIs. + +## Regular expressions + +- [x] Literal and `new RegExp(pattern, flags)` construction. +- [x] `test`, `exec`, and `toString`. +- [x] Readable `source`, `flags`, `lastIndex`, `global`, `ignoreCase`, `multiline`, `sticky`, `unicode`, and `dotAll`. +- [x] Captures, named groups, match indexes, and stateful global matching. +- [x] Integration with supported String methods, including async function replacers. +- [ ] Writable `lastIndex`. +- [ ] Exposed metadata for the `d` and `v` flags. +- [ ] `RegExp.escape`. +- [ ] Protection from pathological host-regex backtracking beyond the cooperative execution timeout. + +## Map and Set + +- [x] `new Map()` from entry arrays or another Map. +- [x] Map `get`, `set`, `has`, `delete`, `clear`, `size`, and `forEach`. +- [x] `new Set()` from arrays, strings, or another Set. +- [x] Set `add`, `has`, `delete`, `clear`, `size`, and `forEach`. +- [x] Materialized `keys`, `values`, and `entries` arrays for Map and Set. +- [x] Spread, `for...of`, `Array.from`, and `Object.fromEntries` integration. +- [x] Map and Set values serialize to `{}` at host/JSON boundaries. +- [ ] Set composition methods such as `union`, `intersection`, `difference`, and relation predicates. +- [ ] WeakMap and WeakSet. +- [ ] Native iterator objects and custom iterators. + +## URL and URI helpers + +- [x] `encodeURI`, `encodeURIComponent`, `decodeURI`, and `decodeURIComponent`. +- [x] `new URL(input, base)`, `URL.canParse`, and `URL.parse`. +- [x] URL `toString`, `toJSON`, and linked `searchParams`. +- [x] Readable URL fields: `href`, `origin`, `protocol`, `username`, `password`, `host`, `hostname`, `port`, + `pathname`, `search`, and `hash`. +- [x] Writable URL fields except `origin`. +- [x] `new URLSearchParams()` from query strings, data objects, pairs, Maps, and URLSearchParams. +- [x] URLSearchParams `append`, `delete`, `get`, `getAll`, `has`, `set`, `sort`, `forEach`, `keys`, `values`, + `entries`, `toString`, and `size`. +- [x] URL values serialize to their href; URLSearchParams serialize to `{}`. + +## Errors and diagnostics + +- [x] `Error`, `TypeError`, `RangeError`, `SyntaxError`, `ReferenceError`, `EvalError`, and `URIError`, callable with + or without `new`. +- [x] Error `name`/`message`, error inheritance through `instanceof`, and plain-data serialization. +- [x] `instanceof` for Date, RegExp, Map, Set, URL, URLSearchParams, Array, Object, Promise, and Error types. +- [x] Catchable interpreter failures and awaited tool failures. +- [x] Source locations on unsupported-syntax diagnostics when available. +- [x] Sanitized model-visible diagnostics and explicit safe `ToolError` messages. +- [ ] Distinct public categories for user throws, tool refusal, tool internal failure, invalid returned data, compile + failures, and genuine interpreter defects. +- [ ] Preservation of detailed recoverable failure categories inside `catch` and `Promise.allSettled`. + +## Known semantic gaps + +These are actionable implementation items. Check them off only when behavior and direct tests land. + +- [ ] Return real promises from `Promise.all`, `Promise.allSettled`, and `Promise.race`. +- [ ] Bound pending tool-call admission/allocation in addition to execution concurrency. +- [ ] Guarantee every advertised tool path is executable, including dotted and blocked path segments. +- [ ] Define safe outbound handling for non-finite numbers and `undefined` so invalid values cannot silently become + `null` in render-only or OpenAPI tool calls. +- [ ] Make regular-expression execution genuinely timeout-safe, or narrow the timeout guarantee explicitly. +- [ ] Complete lexical declaration and destructuring semantics listed above. +- [ ] Make callback acceptance and async callback behavior consistent across built-ins. +- [ ] Reject every unsupported callback argument explicitly rather than silently ignoring it. +- [ ] Resolve the built-in correctness gaps listed in the Array, String, Number, Date, and RegExp sections. +- [ ] Make tool search tokenization Unicode-aware. +- [ ] Design explicit tagged representations and size limits before adding binary values or streams. + +## Intentional exclusions + +These constraints preserve CodeMode's confinement and host-neutral scope. They are not TODO items. + +- Ambient filesystem, process, environment, credential, network, or application access. +- `fetch`, timers, crypto, or other host globals unless a future host explicitly supplies a bounded capability. +- Static imports, dynamic imports, modules, npm packages, and module loading. +- `eval`, `Function(...)`, arbitrary host execution, and prototype mutation. +- Generic permission prompts, authorization policy, persistence, replay, or exactly-once side effects. +- Arbitrary method dispatch outside the documented allowlists. +- Automatic parsing of text tool results as JSON. +- Full browser, Node.js, Bun, or ECMAScript runtime compatibility. diff --git a/packages/codemode/test/array-callbacks-test262.test.ts b/packages/codemode/test/array-callbacks-test262.test.ts new file mode 100644 index 0000000000..3de3a94a4c --- /dev/null +++ b/packages/codemode/test/array-callbacks-test262.test.ts @@ -0,0 +1,325 @@ +/* + * Portions adapted from Test262 at revision 250f204f23a9249ff204be2baec29600faae7b75: + * - test/built-ins/Array/prototype/map/15.4.4.19-8-1.js + * - test/built-ins/Array/prototype/map/15.4.4.19-8-2.js + * - test/built-ins/Array/prototype/map/15.4.4.19-8-b-1.js + * - test/built-ins/Array/prototype/filter/15.4.4.20-9-1.js + * - test/built-ins/Array/prototype/filter/15.4.4.20-9-2.js + * - test/built-ins/Array/prototype/filter/15.4.4.20-9-b-1.js + * - test/built-ins/Array/prototype/find/predicate-call-parameters.js + * - test/built-ins/Array/prototype/find/predicate-not-called-on-empty-array.js + * - test/built-ins/Array/prototype/find/return-found-value-predicate-result-is-true.js + * - test/built-ins/Array/prototype/find/return-undefined-if-predicate-returns-false-value.js + * - test/built-ins/Array/prototype/findIndex/predicate-call-parameters.js + * - test/built-ins/Array/prototype/findIndex/return-index-predicate-result-is-true.js + * - test/built-ins/Array/prototype/findIndex/return-negative-one-if-predicate-returns-false-value.js + * - test/built-ins/Array/prototype/findLast/predicate-call-parameters.js + * - test/built-ins/Array/prototype/findLast/return-found-value-predicate-result-is-true.js + * - test/built-ins/Array/prototype/findLast/return-undefined-if-predicate-returns-false-value.js + * - test/built-ins/Array/prototype/findLastIndex/predicate-call-parameters.js + * - test/built-ins/Array/prototype/findLastIndex/return-index-predicate-result-is-true.js + * - test/built-ins/Array/prototype/findLastIndex/return-negative-one-if-predicate-returns-false-value.js + * - test/built-ins/Array/prototype/some/15.4.4.17-7-1.js + * - test/built-ins/Array/prototype/some/15.4.4.17-8-1.js + * - test/built-ins/Array/prototype/every/15.4.4.16-7-1.js + * - test/built-ins/Array/prototype/every/15.4.4.16-8-1.js + * - test/built-ins/Array/prototype/forEach/15.4.4.18-7-1.js + * - test/built-ins/Array/prototype/forEach/15.4.4.18-7-2.js + * - test/built-ins/Array/prototype/reduce/15.4.4.21-9-5.js + * - test/built-ins/Array/prototype/reduce/15.4.4.21-9-1.js + * - test/built-ins/Array/prototype/reduce/15.4.4.21-10-1.js + * - test/built-ins/Array/prototype/reduceRight/15.4.4.22-9-5.js + * - test/built-ins/Array/prototype/reduceRight/15.4.4.22-9-1.js + * - test/built-ins/Array/prototype/reduceRight/15.4.4.22-10-1.js + * - test/built-ins/Array/prototype/flatMap/depth-always-one.js + * - test/built-ins/Array/prototype/flatMap/mapperfunction-throws.js + * - test/built-ins/Array/prototype/sort/S15.4.4.11_A1.1_T1.js + * - test/built-ins/Array/prototype/sort/S15.4.4.11_A2.1_T1.js + * - test/built-ins/Array/prototype/sort/stability-5-elements.js + * - test/built-ins/Array/prototype/toSorted/comparefn-controls-sort.js + * - test/built-ins/Array/prototype/toSorted/comparefn-default.js + * - test/built-ins/Array/prototype/toSorted/immutable.js + * - test/built-ins/Array/prototype/toSorted/zero-or-one-element.js + * + * Copyright (C) 2015 the V8 project authors. All rights reserved. + * Copyright (C) 2018 Mathias Bynens. All rights reserved. + * Copyright (C) 2018 Shilpi Jain and Michael Ficarra. All rights reserved. + * Copyright (C) 2021 Igalia, S.L. All rights reserved. + * Copyright (C) 2021 Microsoft. All rights reserved. + * Copyright (C) 2025 Google. All rights reserved. + * Copyright (C) 2026 Garham Lee. All rights reserved. + * Copyright (c) 2012 Ecma International. All rights reserved. + * Copyright 2009 the Sputnik authors. All rights reserved. + * Test262 portions are governed by the BSD license in LICENSE.test262. + */ +import { describe, expect, test } from "bun:test" +import { Effect } from "effect" +import { CodeMode } from "../src/index.js" + +const value = async (code: string) => { + const result = await Effect.runPromise(CodeMode.execute({ code, tools: {} })) + if (!result.ok) throw new Error(`expected success, got ${result.error.kind}: ${result.error.message}`) + return result.value +} + +const cases = [ + { + path: "test/built-ins/Array/prototype/map/15.4.4.19-8-1.js", + code: `const input = [1, 2]; input[3] = 4; input[4] = 5; const result = input.map((value) => { input[2] = 3; input[5] = 6; return 1 }); return [result.length, result[5] === undefined]`, + expected: [5, true], + }, + { + path: "test/built-ins/Array/prototype/map/15.4.4.19-8-2.js", + code: `const input = [1, 2, 3, 4, 5]; const result = input.map((value) => { input[4] = -1; return value > 0 ? 1 : 0 }); return [result.length, result[4]]`, + expected: [5, 0], + }, + { + path: "test/built-ins/Array/prototype/map/15.4.4.19-8-b-1.js", + code: `const input = []; input[10] = 0; input.pop(); input[1] = undefined; let calls = 0; const result = input.map(() => { calls += 1; return 1 }); return [result.length, calls, 0 in result, 1 in result]`, + expected: [10, 1, false, true], + }, + { + path: "test/built-ins/Array/prototype/filter/15.4.4.20-9-1.js", + code: `const input = [1, 2]; input[3] = 4; input[4] = 5; const result = input.filter(() => { input[2] = 3; input[5] = 6; return true }); return result`, + expected: [1, 2, 3, 4, 5], + }, + { + path: "test/built-ins/Array/prototype/filter/15.4.4.20-9-2.js", + code: `const input = [1, 2, 3, 4, 5]; return input.filter((value) => { input[2] = -1; input[4] = -1; return value > 0 })`, + expected: [1, 2, 4], + }, + { + path: "test/built-ins/Array/prototype/filter/15.4.4.20-9-b-1.js", + code: `const input = []; input[9] = 0; input.pop(); input[1] = undefined; let calls = 0; const result = input.filter(() => { calls += 1; return false }); return [result, calls]`, + expected: [[], 1], + }, + { + path: "test/built-ins/Array/prototype/find/predicate-call-parameters.js", + code: `const input = [10, 20]; const seen = []; input.find((value, index, receiver) => { seen.push([value, index, receiver === input]); return false }); return seen`, + expected: [ + [10, 0, true], + [20, 1, true], + ], + }, + { + path: "test/built-ins/Array/prototype/find/return-found-value-predicate-result-is-true.js", + code: `return [1, 2, 3].find((value) => value > 1)`, + expected: 2, + }, + { + path: "test/built-ins/Array/prototype/find/return-undefined-if-predicate-returns-false-value.js", + code: `return [1, 2, 3].find((value) => value > 4) === undefined`, + expected: true, + }, + { + path: "test/built-ins/Array/prototype/find/predicate-not-called-on-empty-array.js", + code: `let calls = 0; const result = [].find(() => { calls += 1; return true }); return [result === undefined, calls]`, + expected: [true, 0], + }, + { + path: "test/built-ins/Array/prototype/findIndex/predicate-call-parameters.js", + code: `const input = [10, 20]; const seen = []; input.findIndex((value, index, receiver) => { seen.push([value, index, receiver === input]); return false }); return seen`, + expected: [ + [10, 0, true], + [20, 1, true], + ], + }, + { + path: "test/built-ins/Array/prototype/findIndex/return-index-predicate-result-is-true.js", + code: `return [1, 2, 3].findIndex((value) => value > 1)`, + expected: 1, + }, + { + path: "test/built-ins/Array/prototype/findIndex/return-negative-one-if-predicate-returns-false-value.js", + code: `return [1, 2, 3].findIndex((value) => value > 4)`, + expected: -1, + }, + { + path: "test/built-ins/Array/prototype/findLast/predicate-call-parameters.js", + code: `const input = [10, 20]; const seen = []; input.findLast((value, index, receiver) => { seen.push([value, index, receiver === input]); return false }); return seen`, + expected: [ + [20, 1, true], + [10, 0, true], + ], + }, + { + path: "test/built-ins/Array/prototype/findLast/return-found-value-predicate-result-is-true.js", + code: `return [1, 2, 3].findLast((value) => value < 3)`, + expected: 2, + }, + { + path: "test/built-ins/Array/prototype/findLast/return-undefined-if-predicate-returns-false-value.js", + code: `return [1, 2, 3].findLast((value) => value > 4) === undefined`, + expected: true, + }, + { + path: "test/built-ins/Array/prototype/findLastIndex/predicate-call-parameters.js", + code: `const input = [10, 20]; const seen = []; input.findLastIndex((value, index, receiver) => { seen.push([value, index, receiver === input]); return false }); return seen`, + expected: [ + [20, 1, true], + [10, 0, true], + ], + }, + { + path: "test/built-ins/Array/prototype/findLastIndex/return-index-predicate-result-is-true.js", + code: `return [1, 2, 3].findLastIndex((value) => value < 3)`, + expected: 1, + }, + { + path: "test/built-ins/Array/prototype/findLastIndex/return-negative-one-if-predicate-returns-false-value.js", + code: `return [1, 2, 3].findLastIndex((value) => value > 4)`, + expected: -1, + }, + { + path: "test/built-ins/Array/prototype/some/15.4.4.17-7-1.js", + code: `const input = [1, 2]; input[3] = 4; input[4] = 5; const seen = []; const result = input.some((value) => { input[2] = 3; seen.push(value); return false }); return [result, seen.includes(3)]`, + expected: [false, true], + }, + { + path: "test/built-ins/Array/prototype/some/15.4.4.17-8-1.js", + code: `return [].some(() => true)`, + expected: false, + }, + { + path: "test/built-ins/Array/prototype/every/15.4.4.16-7-1.js", + code: `const input = [1, 2]; input[3] = 4; input[4] = 5; const seen = []; const result = input.every((value) => { input[2] = 3; seen.push(value); return true }); return [result, seen.includes(3)]`, + expected: [true, true], + }, + { + path: "test/built-ins/Array/prototype/every/15.4.4.16-8-1.js", + code: `return [].every(() => false)`, + expected: true, + }, + { + path: "test/built-ins/Array/prototype/forEach/15.4.4.18-7-1.js", + code: `const input = [1, 2]; input[3] = 4; input[4] = 5; let calls = 0; input.forEach(() => { calls += 1; input[2] = 3; input[5] = 6 }); return calls`, + expected: 5, + }, + { + path: "test/built-ins/Array/prototype/forEach/15.4.4.18-7-2.js", + code: `const input = [1, 2, 3]; const seen = []; input.forEach((value, index) => { seen.push(value); if (index === 0) input.pop() }); return seen`, + expected: [1, 2], + }, + { + path: "test/built-ins/Array/prototype/reduce/15.4.4.21-9-1.js", + code: `const input = [1, 2]; input[3] = 4; input[4] = "5"; return input.reduce((accumulator, value) => { input[2] = 3; input[5] = 6; return accumulator + value })`, + expected: "105", + }, + { + path: "test/built-ins/Array/prototype/reduce/15.4.4.21-9-5.js", + code: `let calls = 0; const result = [1].reduce(() => { calls += 1; return 2 }); return [result, calls]`, + expected: [1, 0], + }, + { + path: "test/built-ins/Array/prototype/reduce/15.4.4.21-10-1.js", + code: `const input = [1, 2, 3, 4, 5]; input.reduce(() => 1); return input`, + expected: [1, 2, 3, 4, 5], + }, + { + path: "test/built-ins/Array/prototype/reduceRight/15.4.4.22-9-1.js", + code: `const input = ["1", 2]; input[3] = 4; input[4] = "5"; return input.reduceRight((accumulator, value) => { input[2] = 3; input[5] = 6; return accumulator + value })`, + expected: "54321", + }, + { + path: "test/built-ins/Array/prototype/reduceRight/15.4.4.22-9-5.js", + code: `let calls = 0; const result = [1].reduceRight(() => { calls += 1; return 2 }); return [result, calls]`, + expected: [1, 0], + }, + { + path: "test/built-ins/Array/prototype/reduceRight/15.4.4.22-10-1.js", + code: `const input = [1, 2, 3, 4, 5]; input.reduceRight(() => 1); return input`, + expected: [1, 2, 3, 4, 5], + }, + { + path: "test/built-ins/Array/prototype/flatMap/depth-always-one.js", + code: `return [1, 2, 3].flatMap((value) => [[value * 2]])`, + expected: [[2], [4], [6]], + }, + { + path: "test/built-ins/Array/prototype/flatMap/mapperfunction-throws.js", + code: `try { [1, 2].flatMap(() => { throw "stop" }) } catch (error) { return error === "stop" } return false`, + expected: true, + }, + { + path: "test/built-ins/Array/prototype/sort/S15.4.4.11_A1.1_T1.js", + code: `const input = []; input[2] = 0; input.pop(); input.sort(); return [input.length, input[0] === undefined, input[1] === undefined]`, + expected: [2, true, true], + }, + { + path: "test/built-ins/Array/prototype/sort/S15.4.4.11_A2.1_T1.js", + code: `return ["z", "y", "x", "w", "v", "u", "t", "s", "r", "q", "p", "o", "n", "M", "L", "K", "J", "I", "H", "G", "F", "E", "D", "C", "B", "A"].sort()`, + expected: [ + "A", + "B", + "C", + "D", + "E", + "F", + "G", + "H", + "I", + "J", + "K", + "L", + "M", + "n", + "o", + "p", + "q", + "r", + "s", + "t", + "u", + "v", + "w", + "x", + "y", + "z", + ], + }, + { + path: "test/built-ins/Array/prototype/sort/stability-5-elements.js", + code: `const input = [{ n: "A", r: 2 }, { n: "B", r: 3 }, { n: "C", r: 2 }, { n: "D", r: 3 }, { n: "E", r: 3 }]; return input.sort((left, right) => right.r - left.r).map((item) => item.n).join("")`, + expected: "BDEAC", + }, + { + path: "test/built-ins/Array/prototype/toSorted/comparefn-controls-sort.js", + code: `const mixed = [333, 33, 3, 222, 22, 2, 111, 11, 1]; return [[1, 2, 3, 4].toSorted((a, b) => a - b), [4, 3, 2, 1].toSorted((a, b) => a - b), mixed.toSorted((a, b) => a - b), [1, 2, 3, 4].toSorted((a, b) => b - a), [4, 3, 2, 1].toSorted((a, b) => b - a), mixed.toSorted((a, b) => b - a)]`, + expected: [ + [1, 2, 3, 4], + [1, 2, 3, 4], + [1, 2, 3, 11, 22, 33, 111, 222, 333], + [4, 3, 2, 1], + [4, 3, 2, 1], + [333, 222, 111, 33, 22, 11, 3, 2, 1], + ], + }, + { + path: "test/built-ins/Array/prototype/toSorted/comparefn-default.js", + code: `return [[1, 2, 3, 4].toSorted(), [4, 3, 2, 1].toSorted(), ["a", 2, 1, "z"].toSorted(), [333, 33, 3, 222, 22, 2, 111, 11, 1].toSorted()]`, + expected: [ + [1, 2, 3, 4], + [1, 2, 3, 4], + [1, 2, "a", "z"], + [1, 11, 111, 2, 22, 222, 3, 33, 333], + ], + }, + { + path: "test/built-ins/Array/prototype/toSorted/immutable.js", + code: `const input = [2, 0, 1]; const result = input.toSorted(); return [input, result !== input]`, + expected: [[2, 0, 1], true], + }, + { + path: "test/built-ins/Array/prototype/toSorted/zero-or-one-element.js", + code: `const zero = []; const one = [1]; const zeroResult = zero.toSorted(); const oneResult = one.toSorted(); return [zeroResult, oneResult, zeroResult !== zero, oneResult !== one]`, + expected: [[], [1], true, true], + }, +] as const + +describe("Test262 Array callback adaptations", () => { + for (const item of cases) { + test(item.path, async () => { + expect(await value(item.code)).toEqual(item.expected) + }) + } +}) diff --git a/packages/codemode/test/array-core-test262.test.ts b/packages/codemode/test/array-core-test262.test.ts new file mode 100644 index 0000000000..a04e1c2566 --- /dev/null +++ b/packages/codemode/test/array-core-test262.test.ts @@ -0,0 +1,323 @@ +/* + * Portions adapted from Test262 at revision 250f204f23a9249ff204be2baec29600faae7b75: + * - test/built-ins/Array/prototype/includes/samevaluezero.js + * - test/built-ins/Array/prototype/includes/using-fromindex.js + * - test/built-ins/Array/prototype/join/S15.4.4.5_A3.1_T1.js + * - test/built-ins/Array/prototype/join/S15.4.4.5_A3.2_T1.js + * - test/built-ins/Array/prototype/slice/S15.4.4.10_A1.2_T2.js + * - test/built-ins/Array/prototype/concat/S15.4.4.4_A1_T1.js + * - test/built-ins/Array/prototype/concat/S15.4.4.4_A1_T2.js + * - test/built-ins/Array/prototype/concat/S15.4.4.4_A1_T3.js + * - test/built-ins/Array/prototype/indexOf/fromindex-zero-conversion.js + * - test/built-ins/Array/prototype/indexOf/length-zero-returns-minus-one.js + * - test/built-ins/Array/prototype/lastIndexOf/fromindex-zero-conversion.js + * - test/built-ins/Array/prototype/lastIndexOf/length-zero-returns-minus-one.js + * - test/built-ins/Array/prototype/at/returns-item.js + * - test/built-ins/Array/prototype/at/returns-item-relative-index.js + * - test/built-ins/Array/prototype/at/returns-undefined-for-out-of-range-index.js + * - test/built-ins/Array/prototype/flat/null-undefined-elements.js + * - test/built-ins/Array/prototype/flat/positive-infinity.js + * - test/built-ins/Array/prototype/reverse/S15.4.4.8_A1_T1.js + * - test/built-ins/Array/prototype/toReversed/immutable.js + * - test/built-ins/Array/prototype/toReversed/zero-or-one-element.js + * - test/built-ins/Array/prototype/with/immutable.js + * - test/built-ins/Array/prototype/with/index-negative.js + * - test/built-ins/Array/prototype/push/S15.4.4.7_A1_T1.js + * - test/built-ins/Array/prototype/pop/S15.4.4.6_A1.1_T1.js + * - test/built-ins/Array/prototype/shift/S15.4.4.9_A1.1_T1.js + * - test/built-ins/Array/prototype/unshift/S15.4.4.13_A1_T1.js + * - test/built-ins/Array/prototype/splice/S15.4.4.12_A1.1_T1.js + * - test/built-ins/Array/prototype/splice/S15.4.4.12_A1.2_T1.js + * - test/built-ins/Array/prototype/splice/called_with_one_argument.js + * - test/built-ins/Array/prototype/fill/fill-values.js + * - test/built-ins/Array/prototype/fill/fill-values-custom-start-and-end.js + * - test/built-ins/Array/prototype/fill/return-this.js + * - test/built-ins/Array/prototype/copyWithin/non-negative-target-start-and-end.js + * - test/built-ins/Array/prototype/copyWithin/return-this.js + * - test/built-ins/Array/prototype/keys/iteration.js + * - test/built-ins/Array/prototype/values/iteration.js + * - test/built-ins/Array/prototype/entries/iteration.js + * - test/built-ins/Array/isArray/15.4.3.2-0-3.js + * - test/built-ins/Array/isArray/15.4.3.2-0-4.js + * - test/built-ins/Array/from/from-array.js + * - test/built-ins/Array/from/from-string.js + * - test/built-ins/Array/from/array-like-has-length-but-no-indexes-with-values.js + * - test/built-ins/Array/of/creates-a-new-array-from-arguments.js + * + * Copyright (C) 2015 André Bargull. All rights reserved. + * Copyright (C) 2015 the V8 project authors. All rights reserved. + * Copyright (C) 2016 the V8 project authors. All rights reserved. + * Copyright (C) 2018 Shilpi Jain and Michael Ficarra. All rights reserved. + * Copyright (C) 2020 Alexey Shvayka. All rights reserved. + * Copyright (C) 2020 Rick Waldron. All rights reserved. + * Copyright (C) 2021 Igalia, S.L. All rights reserved. + * Copyright (c) 2012 Ecma International. All rights reserved. + * Copyright (c) 2014 Hank Yates. All rights reserved. + * Copyright (c) 2015 the V8 project authors. All rights reserved. + * Copyright (c) 2021 Rick Waldron. All rights reserved. + * Copyright 2009 the Sputnik authors. All rights reserved. + * Copyright 2015 Microsoft Corporation. All rights reserved. + * Copyright 2016 The V8 project authors. All rights reserved. + * Test262 portions are governed by the BSD license in LICENSE.test262. + */ +import { describe, expect, test } from "bun:test" +import { Effect } from "effect" +import { CodeMode } from "../src/index.js" + +const value = async (code: string) => { + const result = await Effect.runPromise(CodeMode.execute({ code, tools: {} })) + if (!result.ok) throw new Error(`expected success, got ${result.error.kind}: ${result.error.message}`) + return result.value +} + +const cases = [ + { + path: "test/built-ins/Array/prototype/includes/samevaluezero.js", + code: `const input = [42, 0, 1, NaN]; return [input.includes(42), input.includes("42"), input.includes([42]), input.includes(true), input.includes(NaN), input.includes(0), input.includes(-0), input.includes(null), input.includes("")]`, + expected: [true, false, false, false, true, true, true, false, false], + }, + { + path: "test/built-ins/Array/prototype/includes/using-fromindex.js", + code: `const input = ["a", "b", "c"]; return [input.includes("a", 0), input.includes("a", 1), input.includes("a", -4), input.includes("a", -3), input.includes("a", -2), input.includes("b", 0), input.includes("b", 1), input.includes("b", 2), input.includes("b", -3), input.includes("b", -2), input.includes("b", -1), input.includes("c", 0), input.includes("c", 2), input.includes("c", 3), input.includes("c", -3), input.includes("c", -1)]`, + expected: [true, false, true, true, false, true, true, false, true, true, false, true, true, false, true, true], + }, + { + path: "test/built-ins/Array/prototype/join/S15.4.4.5_A3.1_T1.js", + code: `return [[0, 1, 2, 3].join("&"), [0, 1, 2, 3].join("")]`, + expected: ["0&1&2&3", "0123"], + }, + { + path: "test/built-ins/Array/prototype/join/S15.4.4.5_A3.2_T1.js", + code: `return [ + ["", "", ""].join(""), + ["&", "&", "&"].join("&"), + [true, true, true].join(), + [null, null, null].join(), + [undefined, undefined, undefined].join(), + [Infinity, Infinity, Infinity].join(), + [NaN, NaN, NaN].join(), + ]`, + expected: ["", "&&&&&", "true,true,true", ",,", ",,", "Infinity,Infinity,Infinity", "NaN,NaN,NaN"], + }, + { + path: "test/built-ins/Array/prototype/slice/S15.4.4.10_A1.2_T2.js", + code: `return [0, 1, 2, 3, 4].slice(-1, 5)`, + expected: [4], + }, + { + path: "test/built-ins/Array/prototype/concat/S15.4.4.4_A1_T1.js", + code: `return [].concat([0, 1], [2, 3, 4])`, + expected: [0, 1, 2, 3, 4], + }, + { + path: "test/built-ins/Array/prototype/concat/S15.4.4.4_A1_T2.js", + code: `const object = { value: 1 }; const result = [0].concat(object, [1, 2], -1, true, "NaN"); return [result, result[1] === object]`, + expected: [[0, { value: 1 }, 1, 2, -1, true, "NaN"], true], + }, + { + path: "test/built-ins/Array/prototype/concat/S15.4.4.4_A1_T3.js", + code: `const input = [0, 1]; const result = input.concat(); return [result, result !== input]`, + expected: [[0, 1], true], + }, + { + path: "test/built-ins/Array/prototype/indexOf/fromindex-zero-conversion.js", + code: `const result = [true].indexOf(true, -0); return [result, 1 / result === Infinity]`, + expected: [0, true], + }, + { + path: "test/built-ins/Array/prototype/indexOf/length-zero-returns-minus-one.js", + code: `return [].indexOf(1)`, + expected: -1, + }, + { + path: "test/built-ins/Array/prototype/lastIndexOf/fromindex-zero-conversion.js", + code: `const result = [true].lastIndexOf(true, -0); return [result, 1 / result === Infinity]`, + expected: [0, true], + }, + { + path: "test/built-ins/Array/prototype/lastIndexOf/length-zero-returns-minus-one.js", + code: `return [].lastIndexOf(1)`, + expected: -1, + }, + { + path: "test/built-ins/Array/prototype/at/returns-item.js", + code: `const input = [1, 2, 3, 4, undefined, 5]; return [input.at(0), input.at(1), input.at(2), input.at(3), input.at(4) === undefined, input.at(5)]`, + expected: [1, 2, 3, 4, true, 5], + }, + { + path: "test/built-ins/Array/prototype/at/returns-item-relative-index.js", + code: `const input = [1, 2, 3, 4, undefined, 5]; return [input.at(0), input.at(-1), input.at(-2) === undefined, input.at(-3), input.at(-4)]`, + expected: [1, 5, true, 4, 3], + }, + { + path: "test/built-ins/Array/prototype/at/returns-undefined-for-out-of-range-index.js", + code: `const input = []; return [input.at(-2) === undefined, input.at(0) === undefined, input.at(1) === undefined]`, + expected: [true, true, true], + }, + { + path: "test/built-ins/Array/prototype/flat/null-undefined-elements.js", + code: `const result = [1, [null, [undefined]]].flat(2); return [result.length, result[0], result[1] === null, result[2] === undefined]`, + expected: [3, 1, true, true], + }, + { + path: "test/built-ins/Array/prototype/flat/positive-infinity.js", + code: `return [1, [2, [3, [4]]]].flat(Infinity)`, + expected: [1, 2, 3, 4], + }, + { + path: "test/built-ins/Array/prototype/reverse/S15.4.4.8_A1_T1.js", + code: `const empty = []; const one = [1]; const input = [1, 2]; const emptyResult = empty.reverse(); const oneResult = one.reverse(); const result = input.reverse(); return [emptyResult === empty, oneResult === one, result === input, input]`, + expected: [true, true, true, [2, 1]], + }, + { + path: "test/built-ins/Array/prototype/toReversed/immutable.js", + code: `const input = [0, 1, 2]; const result = input.toReversed(); return [input, result !== input]`, + expected: [[0, 1, 2], true], + }, + { + path: "test/built-ins/Array/prototype/toReversed/zero-or-one-element.js", + code: `const zero = []; const one = [1]; const zeroResult = zero.toReversed(); const oneResult = one.toReversed(); return [zeroResult, oneResult, zeroResult !== zero, oneResult !== one]`, + expected: [[], [1], true, true], + }, + { + path: "test/built-ins/Array/prototype/with/immutable.js", + code: `const input = [0, 1, 2]; const result = input.with(1, 3); return [input, result !== input, input.with(1, 1) !== input]`, + expected: [[0, 1, 2], true, true], + }, + { + path: "test/built-ins/Array/prototype/with/index-negative.js", + code: `const input = [0, 1, 2]; return [input.with(-1, 4), input.with(-3, 4)]`, + expected: [ + [0, 1, 4], + [4, 1, 2], + ], + }, + { + path: "test/built-ins/Array/prototype/push/S15.4.4.7_A1_T1.js", + code: `const input = []; return [input.push(1), input.push(), input.push(-1), input]`, + expected: [1, 1, 2, [1, -1]], + }, + { + path: "test/built-ins/Array/prototype/pop/S15.4.4.6_A1.1_T1.js", + code: `const input = []; return [input.pop() === undefined, input.length]`, + expected: [true, 0], + }, + { + path: "test/built-ins/Array/prototype/shift/S15.4.4.9_A1.1_T1.js", + code: `const input = []; return [input.shift() === undefined, input.length]`, + expected: [true, 0], + }, + { + path: "test/built-ins/Array/prototype/unshift/S15.4.4.13_A1_T1.js", + code: `const input = []; return [input.unshift(1), input[0], input.unshift(), input.unshift(-1), input]`, + expected: [1, 1, 1, 2, [-1, 1]], + }, + { + path: "test/built-ins/Array/prototype/splice/S15.4.4.12_A1.1_T1.js", + code: `const input = [0, 1, 2, 3]; const removed = input.splice(0, 3); return [input, removed]`, + expected: [[3], [0, 1, 2]], + }, + { + path: "test/built-ins/Array/prototype/splice/S15.4.4.12_A1.2_T1.js", + code: `const input = [0, 1]; const removed = input.splice(-2, -1); return [input, removed]`, + expected: [[0, 1], []], + }, + { + path: "test/built-ins/Array/prototype/splice/called_with_one_argument.js", + code: `const input = ["first", "second", "third"]; const removed = input.splice(1); return [input, removed]`, + expected: [["first"], ["second", "third"]], + }, + { + path: "test/built-ins/Array/prototype/fill/fill-values-custom-start-and-end.js", + code: `const input = [0, 0, 0, 0, 0]; input.fill(8, -3, 4); const sparse = []; sparse[4] = 0; sparse.fill(8, 1, 3); return [[0, 0, 0].fill(8, 1, 2), input, [0, 0, 0, 0, 0].fill(8, -2, -1), [0, 0, 0, 0, 0].fill(8, -1, -3), [0 in sparse, sparse[1], sparse[2], 3 in sparse, sparse[4]]]`, + expected: [ + [0, 8, 0], + [0, 0, 8, 8, 0], + [0, 0, 0, 8, 0], + [0, 0, 0, 0, 0], + [false, 8, 8, false, 0], + ], + }, + { + path: "test/built-ins/Array/prototype/fill/return-this.js", + code: `const input = []; return input.fill(1) === input`, + expected: true, + }, + { + path: "test/built-ins/Array/prototype/fill/fill-values.js", + code: `const omitted = [0, 0].fill(); return [[].fill(8), omitted.map((value) => value === undefined), [0, 0, 0].fill(8)]`, + expected: [[], [true, true], [8, 8, 8]], + }, + { + path: "test/built-ins/Array/prototype/copyWithin/non-negative-target-start-and-end.js", + code: `return [[0, 1, 2, 3].copyWithin(0, 0, 0), [0, 1, 2, 3].copyWithin(0, 0, 2), [0, 1, 2, 3].copyWithin(0, 1, 2), [0, 1, 2, 3].copyWithin(1, 0, 2), [0, 1, 2, 3, 4, 5].copyWithin(1, 3, 5)]`, + expected: [ + [0, 1, 2, 3], + [0, 1, 2, 3], + [1, 1, 2, 3], + [0, 0, 1, 3], + [0, 3, 4, 3, 4, 5], + ], + }, + { + path: "test/built-ins/Array/prototype/copyWithin/return-this.js", + code: `const input = [0, 1, 2, 3]; const result = input.copyWithin(1, 0, 2); return [input, result === input]`, + expected: [[0, 0, 1, 3], true], + }, + { + path: "test/built-ins/Array/prototype/keys/iteration.js", + code: `return ["a", "b", "c"].keys()`, + expected: [0, 1, 2], + }, + { + path: "test/built-ins/Array/prototype/values/iteration.js", + code: `return ["a", "b", "c"].values()`, + expected: ["a", "b", "c"], + }, + { + path: "test/built-ins/Array/prototype/entries/iteration.js", + code: `return ["a", "b"].entries()`, + expected: [ + [0, "a"], + [1, "b"], + ], + }, + { + path: "test/built-ins/Array/isArray/15.4.3.2-0-3.js", + code: `return [Array.isArray([]), Array.isArray([1]), Array.isArray(Array.of(1))]`, + expected: [true, true, true], + }, + { + path: "test/built-ins/Array/isArray/15.4.3.2-0-4.js", + code: `return [Array.isArray(42), Array.isArray({}), Array.isArray(null), Array.isArray("array")]`, + expected: [false, false, false, false], + }, + { + path: "test/built-ins/Array/from/from-array.js", + code: `const input = [0, "foo", undefined, Infinity]; const result = Array.from(input); return [result.length, result[0], result[1], result[2] === undefined, result[3] === Infinity, result !== input, result instanceof Array]`, + expected: [4, 0, "foo", true, true, true, true], + }, + { + path: "test/built-ins/Array/from/from-string.js", + code: `return Array.from("Test")`, + expected: ["T", "e", "s", "t"], + }, + { + path: "test/built-ins/Array/from/array-like-has-length-but-no-indexes-with-values.js", + code: `const result = Array.from({ length: 5 }); const mapped = result.map(() => 1); return [result.length, result.map((value) => value === undefined), mapped.length, mapped]`, + expected: [5, [true, true, true, true, true], 5, [1, 1, 1, 1, 1]], + }, + { + path: "test/built-ins/Array/of/creates-a-new-array-from-arguments.js", + code: `const mixed = Array.of(undefined, false, null, undefined); return [Array.of("Mike", "Rick", "Leo"), mixed.length, mixed[0] === undefined, mixed[1], mixed[2], mixed[3] === undefined, Array.of()]`, + expected: [["Mike", "Rick", "Leo"], 4, true, false, null, true, []], + }, +] as const + +describe("Test262 Array core adaptations", () => { + for (const item of cases) { + test(item.path, async () => { + expect(await value(item.code)).toEqual(item.expected) + }) + } +}) diff --git a/packages/codemode/test/fixtures/opencode-v2-openapi.json b/packages/codemode/test/fixtures/opencode-v2-openapi.json index c2cd5a1d21..543a418173 100644 --- a/packages/codemode/test/fixtures/opencode-v2-openapi.json +++ b/packages/codemode/test/fixtures/opencode-v2-openapi.json @@ -75,6 +75,62 @@ "summary": "Check server health" } }, + "/api/server": { + "get": { + "tags": [ + "server" + ], + "operationId": "v2.server.get", + "parameters": [], + "security": [], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "urls": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "urls" + ], + "additionalProperties": false + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + } + }, + "description": "Return the URLs that can be used to connect to this server.", + "summary": "Get server information" + } + }, "/api/location": { "get": { "tags": [ @@ -18676,6 +18732,7 @@ "required": [ "id", "sessionID", + "title", "mode", "fields" ], @@ -18714,6 +18771,7 @@ "required": [ "id", "sessionID", + "title", "mode", "url" ], @@ -18791,6 +18849,7 @@ } }, "required": [ + "title", "mode" ], "additionalProperties": false @@ -24584,6 +24643,7 @@ "required": [ "id", "sessionID", + "title", "mode", "fields" ], @@ -24622,6 +24682,7 @@ "required": [ "id", "sessionID", + "title", "mode", "url" ], @@ -24844,88 +24905,6 @@ ], "additionalProperties": false }, - "Todo": { - "type": "object", - "properties": { - "content": { - "type": "string", - "description": "Brief description of the task" - }, - "status": { - "type": "string", - "description": "Current status of the task: pending, in_progress, completed, cancelled" - }, - "priority": { - "type": "string", - "description": "Priority level of the task: high, medium, low" - } - }, - "required": [ - "content", - "status", - "priority" - ], - "additionalProperties": false - }, - "todo.updated": { - "type": "object", - "properties": { - "id": { - "type": "string", - "allOf": [ - { - "pattern": "^evt_" - } - ] - }, - "created": { - "type": "number" - }, - "metadata": { - "type": "object" - }, - "type": { - "type": "string", - "enum": [ - "todo.updated" - ] - }, - "location": { - "$ref": "#/components/schemas/Location.Ref" - }, - "data": { - "type": "object", - "properties": { - "sessionID": { - "type": "string", - "allOf": [ - { - "pattern": "^ses" - } - ] - }, - "todos": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Todo" - } - } - }, - "required": [ - "sessionID", - "todos" - ], - "additionalProperties": false - } - }, - "required": [ - "id", - "created", - "type", - "data" - ], - "additionalProperties": false - }, "SessionStatus": { "anyOf": [ { @@ -26475,9 +26454,6 @@ { "$ref": "#/components/schemas/form.cancelled" }, - { - "$ref": "#/components/schemas/todo.updated" - }, { "$ref": "#/components/schemas/session.status" }, @@ -27072,6 +27048,9 @@ { "name": "health" }, + { + "name": "server" + }, { "name": "location" }, diff --git a/packages/codemode/test/parity.test.ts b/packages/codemode/test/parity.test.ts index 5593831127..c8046a6967 100644 --- a/packages/codemode/test/parity.test.ts +++ b/packages/codemode/test/parity.test.ts @@ -42,11 +42,6 @@ describe("H2: string property access reads as undefined (not a throw)", () => { test("unknown property on a number is undefined", async () => { expect(await value(`return (5).foo ?? "n"`)).toBe("n") }) - - test("supported string methods still work", async () => { - expect(await value(`return "AB".toLowerCase()`)).toBe("ab") - expect(await value(`return "hello".length`)).toBe(5) - }) }) describe("H3: array property access reads as undefined (not a throw)", () => { @@ -63,8 +58,7 @@ describe("H3: array property access reads as undefined (not a throw)", () => { expect(await value(`return [1,2,3].toSpliced === undefined`)).toBe(true) }) - test("supported array methods and indexing still work", async () => { - expect(await value(`return [1,2,3].map(x => x + 1)`)).toEqual([2, 3, 4]) + test("array indexing still works", async () => { expect(await value(`return [1,2,3][9] === undefined`)).toBe(true) expect(await value(`return [1,2,3][9]`)).toBeNull() }) @@ -202,9 +196,6 @@ describe("Error values and instanceof", () => { "TypeError", true, ]) - expect(await value(`try { "a".normalize("NOPE") } catch (e) { return [e.name, e instanceof RangeError] }`)).toEqual( - ["RangeError", true], - ) expect(await value(`try { "a".match("(") } catch (e) { return [e.name, e instanceof SyntaxError] }`)).toEqual([ "SyntaxError", true, @@ -263,55 +254,18 @@ describe("Error values and instanceof", () => { }) }) -describe("array methods: splice, fill, copyWithin, keys/values/entries", () => { - test("sort and reverse mutate and return the receiver", async () => { +describe("CodeMode-specific array behavior", () => { + test("sort with a comparator mutates and returns the receiver", async () => { expect( await value(` - const sorted = [3, 1, 2] - const sortResult = sorted.sort((a, b) => a - b) - const reversed = [1, 2, 3] - const reverseResult = reversed.reverse() - return { sorted, sameSort: sorted === sortResult, reversed, sameReverse: reversed === reverseResult } + const input = [3, 1, 2] + const result = input.sort((a, b) => a - b) + return { input, same: input === result } `), - ).toEqual({ sorted: [1, 2, 3], sameSort: true, reversed: [3, 2, 1], sameReverse: true }) + ).toEqual({ input: [1, 2, 3], same: true }) }) - test("array callbacks receive the receiver and observe later mutations", async () => { - expect( - await value(` - const values = [1, 2, 3] - const seen = values.map((value, index, receiver) => { - if (index === 0) values[1] = 9 - return [value, receiver === values] - }) - return seen - `), - ).toEqual([ - [1, true], - [9, true], - [3, true], - ]) - expect( - await value(` - const values = [1, 2, 3] - const seen = [] - values.forEach((value, index) => { - seen.push(value) - if (index === 0) values.pop() - }) - return seen - `), - ).toEqual([1, 2]) - }) - - test("splice removes in place and returns the removed elements", async () => { - expect(await value(`const a = [1,2,3,4]; const removed = a.splice(1, 2); return { removed, a }`)).toEqual({ - removed: [2, 3], - a: [1, 4], - }) - }) - - test("splice inserts new elements at the cut", async () => { + test("splice can replace and insert elements", async () => { expect(await value(`const a = ["a","d"]; a.splice(1, 0, "b", "c"); return a`)).toEqual(["a", "b", "c", "d"]) expect(await value(`const a = [1,2,3]; const removed = a.splice(1, 1, "x"); return { removed, a }`)).toEqual({ removed: [2], @@ -319,32 +273,12 @@ describe("array methods: splice, fill, copyWithin, keys/values/entries", () => { }) }) - test("splice with one argument removes to the end; negative start counts back", async () => { - expect(await value(`const a = [1,2,3]; const removed = a.splice(1); return { removed, a }`)).toEqual({ - removed: [2, 3], - a: [1], - }) - expect(await value(`const a = [1,2,3]; const removed = a.splice(-1); return { removed, a }`)).toEqual({ - removed: [3], - a: [1, 2], - }) - }) - test("splice rejects inserting a container into itself", async () => { const err = await error(`const a = [1]; a.splice(0, 0, [a]); return a`) expect(err.kind).toBe("InvalidDataValue") expect(err.message).toContain("circular") }) - test("fill overwrites a range and returns the mutated array", async () => { - expect(await value(`const a = [1,2,3,4]; return a.fill(0, 1, 3)`)).toEqual([1, 0, 0, 4]) - expect(await value(`return [1,2,3].fill("z")`)).toEqual(["z", "z", "z"]) - }) - - test("copyWithin copies a range in place", async () => { - expect(await value(`return [1,2,3,4,5].copyWithin(0, 3)`)).toEqual([4, 5, 3, 4, 5]) - }) - test("keys/values/entries return arrays usable with for...of and spread", async () => { expect(await value(`return [...["x","y","z"].keys()]`)).toEqual([0, 1, 2]) expect(await value(`return ["x","y"].values()`)).toEqual(["x", "y"]) @@ -359,16 +293,9 @@ describe("array methods: splice, fill, copyWithin, keys/values/entries", () => { }) }) -describe("string methods: localeCompare, normalize, trim aliases", () => { +describe("CodeMode-specific string behavior", () => { test("localeCompare orders strings for sorting", async () => { expect(await value(`return ["b","a","c"].sort((x, y) => x.localeCompare(y))`)).toEqual(["a", "b", "c"]) - expect(await value(`return "a".localeCompare("a")`)).toBe(0) - }) - - test("normalize applies unicode normalization forms", async () => { - expect(await value(`return "\\u0065\\u0301".normalize("NFC").length`)).toBe(1) - expect(await value(`return "\\u00e9".normalize("NFD").length`)).toBe(2) - expect(await value(`return "x".normalize() === "x"`)).toBe(true) }) test("an invalid normalize form is a clear catchable error", async () => { @@ -453,11 +380,6 @@ describe("H5: builtin coercion functions work as array callbacks", () => { expect(await value(`return [1, 2, 3].map(String)`)).toEqual(["1", "2", "3"]) }) - test("arrow callbacks still work (no regression)", async () => { - expect(await value(`return [1, 2, 3, 4].filter(x => x % 2 === 0)`)).toEqual([2, 4]) - expect(await value(`return [1, 2, 3].reduce((a, b) => a + b, 0)`)).toBe(6) - }) - test("a non-callable callback is still rejected", async () => { const err = await error(`return [1,2,3].map(42)`) expect(err.message).toContain("callback") diff --git a/packages/codemode/test/stdlib.test.ts b/packages/codemode/test/stdlib.test.ts index d5870dab8d..dc9710bd2f 100644 --- a/packages/codemode/test/stdlib.test.ts +++ b/packages/codemode/test/stdlib.test.ts @@ -154,9 +154,7 @@ describe("RegExp", () => { ).toEqual(["1", "22"]) }) - test("string match: non-global carries index, global lists all matches", async () => { - expect(await value(`const m = "a1b22".match(/\\d+/); return [m[0], m.index]`)).toEqual(["1", 1]) - expect(await value(`return "a1b22".match(/\\d+/g)`)).toEqual(["1", "22"]) + test("an unmatched string pattern returns null", async () => { expect(await value(`return "abc".match(/\\d/)`)).toBeNull() }) @@ -164,13 +162,6 @@ describe("RegExp", () => { expect(await value(`return "a1b22".matchAll(/(\\d+)/g).map((m) => m[1])`)).toEqual(["1", "22"]) }) - test("replace and replaceAll with patterns and $1 substitution", async () => { - expect(await value(`return "a1b2".replace(/\\d/, "#")`)).toBe("a#b2") - expect(await value(`return "a1b2".replace(/\\d/g, "#")`)).toBe("a#b#") - expect(await value(`return "a1b2".replaceAll(/\\d/g, "#")`)).toBe("a#b#") - expect(await value(`return "hi bob".replace(/b(o)b/, "[$1]")`)).toBe("hi [o]") - }) - test("function replacers receive captures, offsets, input, and named groups", async () => { expect( await value(` @@ -236,12 +227,6 @@ describe("RegExp", () => { expect(await value(`try { "a".replaceAll(/a/, "b"); return "no" } catch { return "caught" }`)).toBe("caught") }) - test("split and search accept patterns", async () => { - expect(await value(`return "a1b22c".split(/\\d+/)`)).toEqual(["a", "b", "c"]) - expect(await value(`return "ab42".search(/\\d/)`)).toBe(2) - expect(await value(`return "ab".search(/\\d/)`)).toBe(-1) - }) - test("new RegExp constructs from strings; invalid patterns are catchable", async () => { expect(await value(`return new RegExp("a+", "i").test("AAA")`)).toBe(true) expect(await value(`try { new RegExp("("); return "no" } catch { return "caught" }`)).toBe("caught") @@ -650,9 +635,7 @@ describe("stdlib integration", () => { true, ) expect( - await value( - `try { Object.fromEntries(new Map([["fn", Math.max]])); return false } catch { return true }`, - ), + await value(`try { Object.fromEntries(new Map([["fn", Math.max]])); return false } catch { return true }`), ).toBe(true) }) diff --git a/packages/codemode/test/test262-array.md b/packages/codemode/test/test262-array.md new file mode 100644 index 0000000000..b8e7e07a89 --- /dev/null +++ b/packages/codemode/test/test262-array.md @@ -0,0 +1,77 @@ +# Test262 Array Coverage + +The Array tests adapt Test262 at revision `250f204f23a9249ff204be2baec29600faae7b75`. They cover CodeMode's 35 +exposed instance methods and three static methods using actual arrays, accepted argument types, deterministic behavior, +and CodeMode's materialized collection conventions. Each executable case names its exact upstream source path. +`LICENSE.test262` contains the upstream BSD terms. + +This is coverage of CodeMode's bounded Array surface, not a claim of ECMAScript or Test262 conformance. One upstream +file may contain both adapted and inapplicable assertions, so a cited source means only that the represented assertions +were adapted. + +## Inventory + +The 38 relevant upstream API directories contain 2,837 files. The executable suite adapts assertions from 83 distinct +sources. + +| API | Upstream files | Adapted sources | +| ------------------------------- | -------------: | --------------: | +| `Array.prototype.map` | 216 | 3 | +| `Array.prototype.filter` | 242 | 3 | +| `Array.prototype.find` | 23 | 4 | +| `Array.prototype.findIndex` | 23 | 3 | +| `Array.prototype.findLast` | 24 | 3 | +| `Array.prototype.findLastIndex` | 24 | 3 | +| `Array.prototype.some` | 219 | 2 | +| `Array.prototype.every` | 218 | 2 | +| `Array.prototype.includes` | 30 | 2 | +| `Array.prototype.join` | 23 | 2 | +| `Array.prototype.reduce` | 260 | 3 | +| `Array.prototype.reduceRight` | 260 | 3 | +| `Array.prototype.flatMap` | 24 | 2 | +| `Array.prototype.forEach` | 190 | 2 | +| `Array.prototype.sort` | 54 | 3 | +| `Array.prototype.toSorted` | 21 | 4 | +| `Array.prototype.slice` | 71 | 1 | +| `Array.prototype.concat` | 69 | 3 | +| `Array.prototype.indexOf` | 201 | 2 | +| `Array.prototype.lastIndexOf` | 198 | 2 | +| `Array.prototype.at` | 13 | 3 | +| `Array.prototype.flat` | 19 | 2 | +| `Array.prototype.reverse` | 18 | 1 | +| `Array.prototype.toReversed` | 17 | 2 | +| `Array.prototype.with` | 21 | 2 | +| `Array.prototype.push` | 24 | 1 | +| `Array.prototype.pop` | 23 | 1 | +| `Array.prototype.shift` | 20 | 1 | +| `Array.prototype.unshift` | 22 | 1 | +| `Array.prototype.splice` | 81 | 3 | +| `Array.prototype.fill` | 22 | 3 | +| `Array.prototype.copyWithin` | 39 | 2 | +| `Array.prototype.keys` | 12 | 1 | +| `Array.prototype.values` | 12 | 1 | +| `Array.prototype.entries` | 12 | 1 | +| `Array.from` | 47 | 3 | +| `Array.isArray` | 29 | 2 | +| `Array.of` | 16 | 1 | + +## Exclusions + +Assertions are not adapted when they test behavior outside CodeMode's documented Array surface: + +- Function metadata, property descriptors, constructibility, prototype mutation, species constructors, or cross-realm + identity. +- Generic receivers, detached methods, `.call`, `.apply`, boxed values, custom coercion objects, Symbols, BigInts, + proxies, accessors, frozen arrays, typed arrays, or ArrayBuffers. +- `Array.from` mappers, custom iterables, constructor substitution, and iterator-closing behavior. +- Native iterator identity, `.next()`, completion records, or live iterator mutation. CodeMode deliberately materializes + `keys`, `values`, and `entries` as arrays. +- Sparse-array assertions that depend on literal elisions or inherited indexed properties. CodeMode's confined data + model does not preserve those prototype and hole semantics at every boundary. +- Argument coercions outside the accepted schema-like surface. Numeric positions must be numbers and `join` separators + must be strings. +- Exact native error brands where CodeMode exposes a safe runtime error instead. +- Async/effectful callbacks, circular-data rejection, sandbox-value identity, diagnostics, and host-boundary behavior. + Those remain covered by CodeMode-specific tests. + +Handwritten tests remain where they specify CodeMode behavior rather than ordinary ECMAScript Array semantics. diff --git a/packages/core/schema.json b/packages/core/schema.json index d419909444..06cec4eaed 100644 --- a/packages/core/schema.json +++ b/packages/core/schema.json @@ -1,8 +1,8 @@ { "version": "7", "dialect": "sqlite", - "id": "b0355fd9-bf41-42e3-9dca-76107de27ecd", - "prevIds": ["95328a41-789d-44de-9643-6ac6ecd6b4ec"], + "id": "01451b27-1e51-4657-b2d0-4b457dffa3ec", + "prevIds": ["8c1748cc-f978-4df4-879d-fe7fda5c4c34"], "ddl": [ { "name": "workspace", @@ -76,10 +76,6 @@ "name": "session", "entityType": "tables" }, - { - "name": "todo", - "entityType": "tables" - }, { "name": "session_share", "entityType": "tables" @@ -1016,11 +1012,11 @@ }, { "type": "text", - "notNull": false, + "notNull": true, "autoincrement": false, "default": null, "generated": null, - "name": "prompt", + "name": "data", "entityType": "columns", "table": "session_input" }, @@ -1444,75 +1440,15 @@ "entityType": "columns", "table": "session" }, - { - "type": "text", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "session_id", - "entityType": "columns", - "table": "todo" - }, - { - "type": "text", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "content", - "entityType": "columns", - "table": "todo" - }, - { - "type": "text", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "status", - "entityType": "columns", - "table": "todo" - }, - { - "type": "text", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "priority", - "entityType": "columns", - "table": "todo" - }, { "type": "integer", - "notNull": true, + "notNull": false, "autoincrement": false, "default": null, "generated": null, - "name": "position", + "name": "time_suspended", "entityType": "columns", - "table": "todo" - }, - { - "type": "integer", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "time_created", - "entityType": "columns", - "table": "todo" - }, - { - "type": "integer", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "time_updated", - "entityType": "columns", - "table": "todo" + "table": "session" }, { "type": "text", @@ -1706,17 +1642,6 @@ "entityType": "fks", "table": "session" }, - { - "columns": ["session_id"], - "tableTo": "session", - "columnsTo": ["id"], - "onUpdate": "NO ACTION", - "onDelete": "CASCADE", - "nameExplicit": false, - "name": "fk_todo_session_id_session_id_fk", - "entityType": "fks", - "table": "todo" - }, { "columns": ["session_id"], "tableTo": "session", @@ -1749,13 +1674,6 @@ "entityType": "pks", "table": "instruction_entry" }, - { - "columns": ["session_id", "position"], - "nameExplicit": false, - "name": "todo_pk", - "entityType": "pks", - "table": "todo" - }, { "columns": ["id"], "nameExplicit": false, @@ -1994,10 +1912,6 @@ "value": "promoted_seq", "isExpression": false }, - { - "value": "type", - "isExpression": false - }, { "value": "delivery", "isExpression": false @@ -2010,7 +1924,7 @@ "isUnique": false, "where": null, "origin": "manual", - "name": "session_input_session_pending_type_delivery_seq_idx", + "name": "session_input_session_pending_delivery_seq_idx", "entityType": "indexes", "table": "session_input" }, @@ -2185,16 +2099,16 @@ { "columns": [ { - "value": "session_id", + "value": "time_suspended", "isExpression": false } ], "isUnique": false, - "where": null, + "where": "\"session\".\"time_suspended\" is not null", "origin": "manual", - "name": "todo_session_idx", + "name": "session_time_suspended_idx", "entityType": "indexes", - "table": "todo" + "table": "session" } ], "renames": [] diff --git a/packages/core/src/agent.ts b/packages/core/src/agent.ts index 8ba976399d..822eab9a4a 100644 --- a/packages/core/src/agent.ts +++ b/packages/core/src/agent.ts @@ -52,6 +52,7 @@ const layer = Layer.effect( Effect.gen(function* () { const events = yield* EventV2.Service const state = State.create({ + name: "agent", initial: () => ({ agents: new Map() }), draft: (draft) => ({ list: () => Array.fromIterable(draft.agents.values()) as Info[], diff --git a/packages/core/src/catalog.ts b/packages/core/src/catalog.ts index 2441f03789..c12793b358 100644 --- a/packages/core/src/catalog.ts +++ b/packages/core/src/catalog.ts @@ -72,16 +72,17 @@ const layer = Layer.effect( } const projectModel = (model: ModelV2.Info, provider: ProviderV2.Info) => { - return ModelV2.Info.make({ + return { ...model, package: model.package ?? provider.package, settings: ProviderV2.mergeOverlay(provider.settings, model.settings), headers: ProviderV2.mergeHeaders(provider.headers, model.headers), body: ProviderV2.mergeOverlay(provider.body, model.body), - }) + } satisfies ModelV2.Info } const state = State.create({ + name: "catalog", initial: () => ({ providers: new Map() }), draft: (draft) => { const result: Draft = { @@ -179,7 +180,18 @@ const layer = Layer.effect( available: Effect.fn("CatalogV2.model.available")(function* () { const providers = new Set((yield* result.provider.available()).map((provider) => provider.id)) - return (yield* result.model.all()).filter((model) => providers.has(model.providerID) && model.enabled) + const models: ModelV2.Info[] = [] + for (const record of state.get().providers.values()) { + if (!providers.has(record.provider.id)) continue + for (const model of record.models.values()) { + if (!model.enabled) continue + models.push(projectModel(model, record.provider)) + } + } + return pipe( + models, + Array.sortWith((item) => item.time.released, Order.flip(Order.Number)), + ) }), default: Effect.fn("CatalogV2.model.default")(function* () { @@ -192,13 +204,7 @@ const layer = Layer.effect( } } - return Option.getOrUndefined( - pipe( - yield* result.model.available(), - Array.sortWith((item) => item.time.released, Order.flip(Order.Number)), - Array.head, - ), - ) + return (yield* result.model.available())[0] }), small: Effect.fn("CatalogV2.model.small")(function* (providerID) { diff --git a/packages/core/src/command.ts b/packages/core/src/command.ts index 44f3138ba7..ff3ffe353e 100644 --- a/packages/core/src/command.ts +++ b/packages/core/src/command.ts @@ -61,6 +61,7 @@ const layer = Layer.effect( const config = yield* Config.Service const location = yield* Location.Service const state = State.create({ + name: "command", initial: () => ({ commands: new Map() }), draft: (draft) => ({ list: () => Array.from(draft.commands.values()) as Info[], diff --git a/packages/core/src/config/model.ts b/packages/core/src/config/model.ts index a1ee46a5f1..e3e1d94e84 100644 --- a/packages/core/src/config/model.ts +++ b/packages/core/src/config/model.ts @@ -27,12 +27,10 @@ export const Selection = Schema.Union([Short, Explicit]) .annotate({ identifier: "Config.ModelSelection" }) function parse(input: string): Selection { - const providerEnd = input.indexOf("/") - const variantStart = input.lastIndexOf("#") - const hasVariant = variantStart > providerEnd + const ref = Model.Ref.parse(input) return { - providerID: Provider.ID.make(input.slice(0, providerEnd)), - model: Model.ID.make(input.slice(providerEnd + 1, hasVariant ? variantStart : undefined)), - ...(hasVariant ? { variant: Model.VariantID.make(input.slice(variantStart + 1)) } : {}), + providerID: ref.providerID, + model: ref.id, + ...(ref.variant ? { variant: ref.variant } : {}), } } diff --git a/packages/core/src/database/migration.gen.ts b/packages/core/src/database/migration.gen.ts index 5b1735573d..9f5c5b40a3 100644 --- a/packages/core/src/database/migration.gen.ts +++ b/packages/core/src/database/migration.gen.ts @@ -49,5 +49,8 @@ export const migrations = ( import("./migration/20260706223930_add-session-fork"), import("./migration/20260707010146_durable_session_inbox"), import("./migration/20260707120000_migrate_prelaunch_v2_state"), + import("./migration/20260709013000_generic_session_input"), + import("./migration/20260709025533_drop-todo"), + import("./migration/20260709163752_time_suspended"), ]) ).map((module) => module.default) satisfies DatabaseMigration.Migration[] diff --git a/packages/core/src/database/migration/20260709013000_generic_session_input.ts b/packages/core/src/database/migration/20260709013000_generic_session_input.ts new file mode 100644 index 0000000000..660830bf1e --- /dev/null +++ b/packages/core/src/database/migration/20260709013000_generic_session_input.ts @@ -0,0 +1,77 @@ +import { Effect } from "effect" +import type { DatabaseMigration } from "../migration" + +export default { + id: "20260709013000_generic_session_input", + up(tx) { + return Effect.gen(function* () { + yield* tx.run(`PRAGMA foreign_keys=OFF;`) + yield* tx.run(` + DELETE FROM \`event\` + WHERE \`type\` IN ('session.prompt.admitted.1', 'session.prompt.promoted.1') + AND json_extract(\`data\`, '$.inputID') IN ( + SELECT \`id\` FROM \`session_input\` WHERE \`type\` = 'prompt' AND \`prompt\` IS NULL + ); + `) + yield* tx.run(` + CREATE TABLE \`__new_session_input\` ( + \`id\` text PRIMARY KEY, + \`session_id\` text NOT NULL, + \`type\` text NOT NULL, + \`data\` text NOT NULL, + \`delivery\` text, + \`admitted_seq\` integer NOT NULL, + \`promoted_seq\` integer, + \`time_created\` integer NOT NULL, + CONSTRAINT \`fk_session_input_session_id_session_id_fk\` FOREIGN KEY (\`session_id\`) REFERENCES \`session\`(\`id\`) ON DELETE CASCADE + ); + `) + yield* tx.run(` + INSERT INTO \`__new_session_input\`( + \`id\`, \`session_id\`, \`type\`, \`data\`, \`delivery\`, \`admitted_seq\`, \`promoted_seq\`, \`time_created\` + ) + SELECT + \`id\`, \`session_id\`, CASE WHEN \`type\` = 'prompt' THEN 'user' ELSE \`type\` END, + CASE WHEN \`type\` = 'prompt' THEN \`prompt\` ELSE '{}' END, + \`delivery\`, \`admitted_seq\`, \`promoted_seq\`, \`time_created\` + FROM \`session_input\` + WHERE \`type\` != 'prompt' OR \`prompt\` IS NOT NULL; + `) + yield* tx.run(`DROP TABLE \`session_input\`;`) + yield* tx.run(`ALTER TABLE \`__new_session_input\` RENAME TO \`session_input\`;`) + yield* tx.run(`PRAGMA foreign_keys=ON;`) + yield* tx.run( + `CREATE INDEX \`session_input_session_pending_delivery_seq_idx\` ON \`session_input\` (\`session_id\`,\`promoted_seq\`,\`delivery\`,\`admitted_seq\`);`, + ) + yield* tx.run( + `CREATE UNIQUE INDEX \`session_input_session_pending_compaction_idx\` ON \`session_input\` (\`session_id\`) WHERE \`type\` = 'compaction' and \`promoted_seq\` is null;`, + ) + yield* tx.run( + `CREATE UNIQUE INDEX \`session_input_session_admitted_seq_idx\` ON \`session_input\` (\`session_id\`,\`admitted_seq\`);`, + ) + yield* tx.run( + `CREATE UNIQUE INDEX \`session_input_session_promoted_seq_idx\` ON \`session_input\` (\`session_id\`,\`promoted_seq\`);`, + ) + yield* tx.run(` + UPDATE \`event\` + SET + \`type\` = 'session.input.admitted.1', + \`data\` = json_object( + 'sessionID', json_extract(\`data\`, '$.sessionID'), + 'inputID', json_extract(\`data\`, '$.inputID'), + 'input', json_object( + 'type', 'user', + 'data', json_extract(\`data\`, '$.prompt'), + 'delivery', json_extract(\`data\`, '$.delivery') + ) + ) + WHERE \`type\` = 'session.prompt.admitted.1'; + `) + yield* tx.run(` + UPDATE \`event\` + SET \`type\` = 'session.input.promoted.1' + WHERE \`type\` = 'session.prompt.promoted.1'; + `) + }) + }, +} satisfies DatabaseMigration.Migration diff --git a/packages/core/src/database/migration/20260709025533_drop-todo.ts b/packages/core/src/database/migration/20260709025533_drop-todo.ts new file mode 100644 index 0000000000..1557f889b1 --- /dev/null +++ b/packages/core/src/database/migration/20260709025533_drop-todo.ts @@ -0,0 +1,12 @@ +import { Effect } from "effect" +import type { DatabaseMigration } from "../migration" + +export default { + id: "20260709025533_drop-todo", + up(tx) { + return Effect.gen(function* () { + yield* tx.run(`DROP INDEX IF EXISTS \`todo_session_idx\`;`) + yield* tx.run(`DROP TABLE \`todo\`;`) + }) + }, +} satisfies DatabaseMigration.Migration diff --git a/packages/core/src/database/migration/20260709163752_time_suspended.ts b/packages/core/src/database/migration/20260709163752_time_suspended.ts new file mode 100644 index 0000000000..70c8f136a8 --- /dev/null +++ b/packages/core/src/database/migration/20260709163752_time_suspended.ts @@ -0,0 +1,14 @@ +import { Effect } from "effect" +import type { DatabaseMigration } from "../migration" + +export default { + id: "20260709163752_time_suspended", + up(tx) { + return Effect.gen(function* () { + yield* tx.run(`ALTER TABLE \`session\` ADD \`time_suspended\` integer;`) + yield* tx.run( + `CREATE INDEX \`session_time_suspended_idx\` ON \`session\` (\`time_suspended\`) WHERE "session"."time_suspended" is not null;`, + ) + }) + }, +} satisfies DatabaseMigration.Migration diff --git a/packages/core/src/database/schema.gen.ts b/packages/core/src/database/schema.gen.ts index 0bb6050bf6..27651d0c13 100644 --- a/packages/core/src/database/schema.gen.ts +++ b/packages/core/src/database/schema.gen.ts @@ -171,7 +171,7 @@ export default { \`id\` text PRIMARY KEY, \`session_id\` text NOT NULL, \`type\` text NOT NULL, - \`prompt\` text, + \`data\` text NOT NULL, \`delivery\` text, \`admitted_seq\` integer NOT NULL, \`promoted_seq\` integer, @@ -224,22 +224,10 @@ export default { \`time_updated\` integer NOT NULL, \`time_compacting\` integer, \`time_archived\` integer, + \`time_suspended\` integer, CONSTRAINT \`fk_session_project_id_project_id_fk\` FOREIGN KEY (\`project_id\`) REFERENCES \`project\`(\`id\`) ON DELETE CASCADE ); `) - yield* tx.run(` - CREATE TABLE \`todo\` ( - \`session_id\` text NOT NULL, - \`content\` text NOT NULL, - \`status\` text NOT NULL, - \`priority\` text NOT NULL, - \`position\` integer NOT NULL, - \`time_created\` integer NOT NULL, - \`time_updated\` integer NOT NULL, - CONSTRAINT \`todo_pk\` PRIMARY KEY(\`session_id\`, \`position\`), - CONSTRAINT \`fk_todo_session_id_session_id_fk\` FOREIGN KEY (\`session_id\`) REFERENCES \`session\`(\`id\`) ON DELETE CASCADE - ); - `) yield* tx.run(` CREATE TABLE \`session_share\` ( \`session_id\` text PRIMARY KEY, @@ -262,7 +250,7 @@ export default { yield* tx.run(`CREATE INDEX \`part_message_id_id_idx\` ON \`part\` (\`message_id\`,\`id\`);`) yield* tx.run(`CREATE INDEX \`part_session_idx\` ON \`part\` (\`session_id\`);`) yield* tx.run( - `CREATE INDEX \`session_input_session_pending_type_delivery_seq_idx\` ON \`session_input\` (\`session_id\`,\`promoted_seq\`,\`type\`,\`delivery\`,\`admitted_seq\`);`, + `CREATE INDEX \`session_input_session_pending_delivery_seq_idx\` ON \`session_input\` (\`session_id\`,\`promoted_seq\`,\`delivery\`,\`admitted_seq\`);`, ) yield* tx.run( `CREATE UNIQUE INDEX \`session_input_session_pending_compaction_idx\` ON \`session_input\` (\`session_id\`) WHERE "session_input"."type" = 'compaction' and "session_input"."promoted_seq" is null;`, @@ -286,7 +274,9 @@ export default { yield* tx.run(`CREATE INDEX \`session_project_idx\` ON \`session\` (\`project_id\`);`) yield* tx.run(`CREATE INDEX \`session_workspace_idx\` ON \`session\` (\`workspace_id\`);`) yield* tx.run(`CREATE INDEX \`session_parent_idx\` ON \`session\` (\`parent_id\`);`) - yield* tx.run(`CREATE INDEX \`todo_session_idx\` ON \`todo\` (\`session_id\`);`) + yield* tx.run( + `CREATE INDEX \`session_time_suspended_idx\` ON \`session\` (\`time_suspended\`) WHERE "session"."time_suspended" is not null;`, + ) }) }, } satisfies Omit diff --git a/packages/core/src/integration.ts b/packages/core/src/integration.ts index 12dad07dd3..4e57d390d1 100644 --- a/packages/core/src/integration.ts +++ b/packages/core/src/integration.ts @@ -203,6 +203,7 @@ type AttemptTime = { created: number; expires: number } type PendingAttempt = { status: "pending" completing: boolean + persisting: boolean authorization: OAuthAuthorization integrationID: ID methodID: MethodID @@ -226,6 +227,7 @@ const layer = Layer.effect( const scope = yield* Scope.Scope const attempts = SynchronizedRef.makeUnsafe(new Map()) const state = State.create({ + name: "integration", initial: () => ({ integrations: new Map() }), draft: (draft) => ({ list: () => Array.from(draft.integrations.values(), (entry) => entry.ref) as Ref[], @@ -320,27 +322,61 @@ const layer = Layer.effect( } const settle = Effect.fnUntraced(function* (attemptID: AttemptID, exit: Exit.Exit) { - const now = yield* Clock.currentTimeMillis - const result = yield* SynchronizedRef.modify(attempts, (current) => { - const attempt = current.get(attemptID) - if (!attempt || attempt.status !== "pending") return [undefined, current] - const terminal: TerminalAttempt = Exit.isSuccess(exit) - ? { status: "complete", time: attempt.time, removeAt: now + terminalRetention } - : { status: "failed", message: message(exit.cause), time: attempt.time, removeAt: now + terminalRetention } - return [attempt, new Map(current).set(attemptID, terminal)] - }) - if (!result) return - if (Exit.isSuccess(exit)) { - const implementation = state.get().integrations.get(result.integrationID)?.implementations.get(result.methodID) - yield* credentials.create({ - integrationID: result.integrationID, - label: result.label ?? implementation?.label?.(exit.value), - value: exit.value, - }) - yield* events.publish(Event.ConnectionUpdated, { integrationID: result.integrationID }) - yield* events.publish(Event.Updated, {}) - } - yield* close(result.scope) + return yield* Effect.uninterruptible( + Effect.gen(function* () { + const now = yield* Clock.currentTimeMillis + const attempt = yield* SynchronizedRef.modify(attempts, (current) => { + const match = current.get(attemptID) + if (!match || match.status !== "pending" || match.persisting) return [undefined, current] + const next = Exit.isSuccess(exit) + ? { ...match, persisting: true } + : { + status: "failed" as const, + message: message(exit.cause), + time: match.time, + removeAt: now + terminalRetention, + } + return [match, new Map(current).set(attemptID, next)] + }) + if (!attempt) return + if (Exit.isFailure(exit)) { + yield* close(attempt.scope) + return + } + + yield* Effect.gen(function* () { + const implementation = state + .get() + .integrations.get(attempt.integrationID) + ?.implementations.get(attempt.methodID) + const persistence = yield* Effect.sync(() => attempt.label ?? implementation?.label?.(exit.value)).pipe( + Effect.flatMap((label) => + credentials.create({ + integrationID: attempt.integrationID, + label, + value: exit.value, + }), + ), + Effect.asVoid, + Effect.exit, + ) + const settledAt = yield* Clock.currentTimeMillis + const terminal: TerminalAttempt = Exit.isSuccess(persistence) + ? { status: "complete", time: attempt.time, removeAt: settledAt + terminalRetention } + : { + status: "failed", + message: message(persistence.cause), + time: attempt.time, + removeAt: settledAt + terminalRetention, + } + // Persisting attempts cannot be cancelled, expired, or claimed again. + yield* SynchronizedRef.update(attempts, (current) => new Map(current).set(attemptID, terminal)) + if (Exit.isFailure(persistence)) yield* Effect.failCause(persistence.cause) + yield* events.publish(Event.ConnectionUpdated, { integrationID: attempt.integrationID }) + yield* events.publish(Event.Updated, {}) + }).pipe(Effect.ensuring(close(attempt.scope))) + }), + ) }) const scrub = Effect.fnUntraced(function* () { @@ -349,7 +385,7 @@ const layer = Layer.effect( const next = new Map(current) const scopes: Scope.Closeable[] = [] for (const [id, attempt] of current) { - if (attempt.status === "pending" && attempt.time.expires <= now) { + if (attempt.status === "pending" && !attempt.persisting && attempt.time.expires <= now) { scopes.push(attempt.scope) next.set(id, { status: "expired", time: attempt.time, removeAt: now + terminalRetention }) continue @@ -432,6 +468,7 @@ const layer = Layer.effect( new Map(current).set(id, { status: "pending", completing: authorization.mode === "auto", + persisting: false, authorization, integrationID: input.integrationID, methodID: input.methodID, @@ -506,7 +543,7 @@ const layer = Layer.effect( cancel: Effect.fn("Integration.attempt.cancel")(function* (attemptID) { const attempt = yield* SynchronizedRef.modify(attempts, (current) => { const match = current.get(attemptID) - if (!match || match.status !== "pending") return [undefined, current] + if (!match || match.status !== "pending" || match.persisting) return [undefined, current] const next = new Map(current) next.delete(attemptID) return [match, next] diff --git a/packages/core/src/location-services.ts b/packages/core/src/location-services.ts index 7e52aea140..2da72df1dd 100644 --- a/packages/core/src/location-services.ts +++ b/packages/core/src/location-services.ts @@ -32,7 +32,6 @@ import { SessionRunnerLLM } from "./session/runner/llm" import { SessionRunnerModel } from "./session/runner/model" import { SessionCompaction } from "./session/compaction" import { SessionTitle } from "./session/title" -import { SessionTodo } from "./session/todo" import { SkillV2 } from "./skill" import { SkillGuidance } from "./skill/guidance" import { Snapshot } from "./snapshot" @@ -78,7 +77,6 @@ const locationServiceNodes = [ Image.node, SkillGuidance.node, ReferenceGuidance.node, - SessionTodo.node, InstructionEntry.node, Form.node, QuestionV2.node, diff --git a/packages/core/src/models-dev.ts b/packages/core/src/models-dev.ts index faeee213fb..9cbc3fa73c 100644 --- a/packages/core/src/models-dev.ts +++ b/packages/core/src/models-dev.ts @@ -12,130 +12,285 @@ import { InstallationChannel, InstallationVersion } from "./installation/version import { EventV2 } from "./event" import { makeGlobalNode } from "./effect/app-node" import { httpClient } from "./effect/app-node-platform" +import { ModelV2 } from "./model" +import { ProviderV2 } from "./provider" export const CatalogModelStatus = Schema.Literals(["alpha", "beta", "deprecated"]) export type CatalogModelStatus = typeof CatalogModelStatus.Type const USER_AGENT = `opencode/${InstallationChannel}/${InstallationVersion}/${Flag.OPENCODE_CLIENT}` -const CostTier = Schema.Struct({ - input: Money.USDPerMillionTokens, - output: Money.USDPerMillionTokens, - cache_read: Schema.optional(Money.USDPerMillionTokens), - cache_write: Schema.optional(Money.USDPerMillionTokens), - tier: Schema.Struct({ - type: Schema.Literal("context"), - size: Schema.Finite, - }), -}) +type Cost = { + readonly input: Money.USDPerMillionTokens + readonly output: Money.USDPerMillionTokens + readonly cache_read?: Money.USDPerMillionTokens + readonly cache_write?: Money.USDPerMillionTokens + readonly tiers?: readonly (Cost & { readonly tier: { readonly type: "context"; readonly size: number } })[] + readonly context_over_200k?: Omit +} -const Cost = Schema.Struct({ - input: Money.USDPerMillionTokens, - output: Money.USDPerMillionTokens, - cache_read: Schema.optional(Money.USDPerMillionTokens), - cache_write: Schema.optional(Money.USDPerMillionTokens), - tiers: Schema.optional(Schema.Array(CostTier)), - context_over_200k: Schema.optional( - Schema.Struct({ - input: Money.USDPerMillionTokens, - output: Money.USDPerMillionTokens, - cache_read: Schema.optional(Money.USDPerMillionTokens), - cache_write: Schema.optional(Money.USDPerMillionTokens), - }), - ), -}) +type ReasoningOption = + | { readonly type: "effort"; readonly values: readonly (string | null)[] } + | { readonly type: "toggle" } + | { readonly type: "budget_tokens"; readonly min?: number; readonly max?: number } -const ReasoningOption = Schema.Union([ - Schema.Struct({ - type: Schema.Literal("effort"), - values: Schema.Array(Schema.Union([Schema.String, Schema.Null])), - }), - Schema.Struct({ - type: Schema.Literal("toggle"), - }), - Schema.Struct({ - type: Schema.Literal("budget_tokens"), - min: Schema.optional(Schema.Finite), - max: Schema.optional(Schema.Finite), - }), -]) +type Modality = "text" | "audio" | "image" | "video" | "pdf" -export const Model = Schema.Struct({ - id: Schema.String, - name: Schema.String, - family: Schema.optional(Schema.String), - release_date: Schema.String, - attachment: Schema.Boolean, - reasoning: Schema.Boolean, - reasoning_options: Schema.optional(Schema.Array(ReasoningOption)), - temperature: Schema.optional(Schema.Boolean), - tool_call: Schema.Boolean, - interleaved: Schema.optional( - Schema.Union([ - Schema.Literal(true), - Schema.Struct({ - field: Schema.Literals(["reasoning", "reasoning_content", "reasoning_details"]), - }), - ]), - ), - cost: Schema.optional(Cost), - limit: Schema.Struct({ - context: Schema.Finite, - input: Schema.optional(Schema.Finite), - output: Schema.Finite, - }), - modalities: Schema.optional( - Schema.Struct({ - input: Schema.Array(Schema.Literals(["text", "audio", "image", "video", "pdf"])), - output: Schema.Array(Schema.Literals(["text", "audio", "image", "video", "pdf"])), - }), - ), - experimental: Schema.optional( - Schema.Struct({ - modes: Schema.optional( - Schema.Record( - Schema.String, - Schema.Struct({ - cost: Schema.optional(Cost), - provider: Schema.optional( - Schema.Struct({ - body: Schema.optional(Schema.Record(Schema.String, Schema.MutableJson)), - headers: Schema.optional(Schema.Record(Schema.String, Schema.String)), - }), - ), +type SourceModel = { + readonly id: string + readonly name: string + readonly family?: string + readonly release_date: string + readonly attachment: boolean + readonly reasoning: boolean + readonly reasoning_options?: readonly ReasoningOption[] + readonly temperature?: boolean + readonly tool_call: boolean + readonly interleaved?: true | { readonly field: "reasoning" | "reasoning_content" | "reasoning_details" } + readonly cost?: Cost + readonly limit: { readonly context: number; readonly input?: number; readonly output: number } + readonly modalities?: { readonly input: readonly Modality[]; readonly output: readonly Modality[] } + readonly experimental?: { + readonly modes?: Readonly< + Record< + string, + { + readonly cost?: Cost + readonly provider?: { readonly body?: ProviderV2.Settings; readonly headers?: Readonly> } + } + > + > + } + readonly status?: CatalogModelStatus + readonly provider?: { readonly npm?: string; readonly api?: string } +} + +type SourceProvider = { + readonly api?: string + readonly name: string + readonly env: readonly string[] + readonly id: string + readonly npm?: string + readonly models: Readonly> +} + +export type Snapshot = { + readonly info: ProviderV2.Info + readonly models: readonly ModelV2.Info[] + readonly environment: readonly string[] +} + +function normalize(input: Record): readonly Snapshot[] { + const providers: Snapshot[] = [] + for (const item of Object.values(input)) { + const providerID = ProviderV2.ID.make(item.id) + const info = { + id: providerID, + name: item.name, + package: item.npm ? ProviderV2.aisdk(item.npm) : "", + ...(item.api ? { settings: { baseURL: item.api } } : {}), + } satisfies ProviderV2.Info + const models: ModelV2.Info[] = [] + for (const model of Object.values(item.models)) { + const baseCost = cost(model.cost) + const variants = reasoningVariants(item, model) + const id = ModelV2.ID.make(model.id) + models.push(modelInfo(providerID, id, model, { cost: baseCost, variants })) + for (const [mode, options] of Object.entries(model.experimental?.modes ?? {})) { + const modeID = ModelV2.ID.make(`${model.id}-${mode}`) + models.push( + modelInfo(providerID, modeID, model, { + name: modeName(model, mode), + cost: mergeCost(baseCost, options.cost), + request: options.provider, + variants, }), - ), - ), - }), - ), - status: Schema.optional(CatalogModelStatus), - provider: Schema.optional( - Schema.Struct({ npm: Schema.optional(Schema.String), api: Schema.optional(Schema.String) }), - ), -}) -export type Model = Schema.Schema.Type + ) + } + } + providers.push({ info, models, environment: [...item.env] }) + } + return providers +} -export const Provider = Schema.Struct({ - api: Schema.optional(Schema.String), - name: Schema.String, - env: Schema.Array(Schema.String), - id: Schema.String, - npm: Schema.optional(Schema.String), - models: Schema.Record(Schema.String, Model), -}) +function released(date: string) { + const time = Date.parse(date) + return Number.isFinite(time) ? time : 0 +} -export type Provider = Schema.Schema.Type +function cost(input: SourceModel["cost"]): ModelV2.Info["cost"] { + const base = { + input: input?.input ?? Money.USDPerMillionTokens.zero, + output: input?.output ?? Money.USDPerMillionTokens.zero, + cache: { + read: input?.cache_read ?? Money.USDPerMillionTokens.zero, + write: input?.cache_write ?? Money.USDPerMillionTokens.zero, + }, + } + return [ + base, + ...(input?.tiers?.map((item) => ({ + tier: item.tier, + input: item.input, + output: item.output, + cache: { + read: item.cache_read ?? Money.USDPerMillionTokens.zero, + write: item.cache_write ?? Money.USDPerMillionTokens.zero, + }, + })) ?? []), + ...(input?.context_over_200k + ? [ + { + tier: { type: "context" as const, size: 200_000 }, + input: input.context_over_200k.input, + output: input.context_over_200k.output, + cache: { + read: input.context_over_200k.cache_read ?? Money.USDPerMillionTokens.zero, + write: input.context_over_200k.cache_write ?? Money.USDPerMillionTokens.zero, + }, + }, + ] + : []), + ] +} -const Providers = Schema.Record(Schema.String, Provider) -const decodeProviders = Schema.decodeUnknownEffect(Schema.fromJsonString(Providers)) -const decodeProvidersUnknown = Schema.decodeUnknownEffect(Providers) +function mergeCost(base: ModelV2.Info["cost"], override: SourceModel["cost"] | undefined) { + if (!override) return base + const next = cost(override) + const [baseDefault, ...baseTiers] = base + const [nextDefault, ...nextTiers] = next + const tierKey = (item: ModelV2.Info["cost"][number]) => `${item.tier?.type ?? "base"}:${item.tier?.size ?? 0}` + const merge = (left: ModelV2.Info["cost"][number], right: ModelV2.Info["cost"][number]) => ({ + ...left, + ...right, + tier: right.tier ?? left.tier, + cache: { ...left.cache, ...right.cache }, + }) + const tiers = new Map(baseTiers.map((item) => [tierKey(item), item])) + for (const item of nextTiers) { + const current = tiers.get(tierKey(item)) + tiers.set(tierKey(item), current ? merge(current, item) : item) + } + return [ + merge( + baseDefault ?? { + input: Money.USDPerMillionTokens.zero, + output: Money.USDPerMillionTokens.zero, + cache: { read: Money.USDPerMillionTokens.zero, write: Money.USDPerMillionTokens.zero }, + }, + nextDefault, + ), + ...tiers.values(), + ] +} + +const OPENAI_INCLUDE_ENCRYPTED_REASONING = ["reasoning.encrypted_content"] + +function reasoningVariants(provider: SourceProvider, model: SourceModel): NonNullable { + const npm = model.provider?.npm ?? provider.npm + const options = model.reasoning_options ?? [] + const effort = options.find((option) => option.type === "effort") + if (effort?.type === "effort") { + return effort.values.flatMap((value) => { + const raw: unknown = value + const id = raw === null ? "none" : typeof raw === "string" ? raw : undefined + if (id === undefined) return [] + const settings = settingsForEffort(npm, id) + return settings ? [{ id: ModelV2.VariantID.make(id), settings }] : [] + }) + } + const budget = options.find((option) => option.type === "budget_tokens") + if (budget?.type === "budget_tokens") return budgetVariants(npm, budget) + return [] +} + +function settingsForEffort(npm: string | undefined, effort: string): ProviderV2.Settings | undefined { + if (npm === "@openrouter/ai-sdk-provider") return { reasoning: { effort } } + if (npm === "@ai-sdk/anthropic" || npm === "@ai-sdk/google-vertex/anthropic") + return { thinking: { type: "adaptive", display: "summarized" }, effort } + if (npm === "@ai-sdk/google" || npm === "@ai-sdk/google-vertex") + return { thinkingConfig: { includeThoughts: true, thinkingLevel: effort } } + if (npm === "@ai-sdk/azure") return { reasoningEffort: effort } + if (npm === "@ai-sdk/openai") + return { reasoningEffort: effort, reasoningSummary: "auto", include: OPENAI_INCLUDE_ENCRYPTED_REASONING } + if (npm === "@ai-sdk/openai-compatible") return { reasoningEffort: effort } +} + +function budgetVariants( + npm: string | undefined, + option: Extract[number], { type: "budget_tokens" }>, +): NonNullable { + const max = option.max + const high = + option.max === undefined + ? Math.max(option.min ?? 0, 16_000) + : Math.min(Math.max(option.min ?? 0, 16_000), option.max) + return [ + { id: "high", budget: high }, + ...(max === undefined || max === high ? [] : [{ id: "max", budget: max }]), + ].flatMap((item) => { + const settings = settingsForBudget(npm, item.budget) + return settings ? [{ id: ModelV2.VariantID.make(item.id), settings }] : [] + }) +} + +function settingsForBudget(npm: string | undefined, budget: number): ProviderV2.Settings | undefined { + if (npm === "@openrouter/ai-sdk-provider") return { reasoning: { max_tokens: budget } } + if (npm === "@ai-sdk/anthropic" || npm === "@ai-sdk/google-vertex/anthropic") + return { thinking: { type: "enabled", budgetTokens: budget } } + if (npm === "@ai-sdk/google" || npm === "@ai-sdk/google-vertex") + return { thinkingConfig: { includeThoughts: true, thinkingBudget: budget } } +} + +function modeName(model: SourceModel, mode: string) { + return `${model.name} ${mode.charAt(0).toUpperCase()}${mode.slice(1)}` +} + +function modelInfo( + providerID: ProviderV2.ID, + id: ModelV2.ID, + model: SourceModel, + input: { + readonly name?: string + readonly cost?: ModelV2.Info["cost"] + readonly request?: NonNullable["modes"]>[string]["provider"] + readonly variants?: NonNullable + } = {}, +): ModelV2.Info { + return { + id, + modelID: ModelV2.ID.make(model.id), + providerID, + name: input.name ?? model.name, + family: model.family ? ModelV2.Family.make(model.family) : undefined, + package: model.provider?.npm ? ProviderV2.aisdk(model.provider.npm) : undefined, + settings: model.provider?.api ? { baseURL: model.provider.api } : undefined, + capabilities: { + tools: model.tool_call, + input: [...(model.modalities?.input ?? [])], + output: [...(model.modalities?.output ?? [])], + }, + variants: [...(input.variants ?? [])], + time: { released: released(model.release_date) }, + cost: (input.cost ?? cost(model.cost)).map((item) => ({ + ...item, + tier: item.tier && { ...item.tier }, + cache: { ...item.cache }, + })), + status: model.status ?? "active", + enabled: true, + limit: { context: model.limit.context, input: model.limit.input, output: model.limit.output }, + headers: input.request?.headers ? { ...input.request.headers } : undefined, + body: input.request?.body ? { ...input.request.body } : undefined, + } +} export const Event = ModelsDev.Event -declare const OPENCODE_MODELS_DEV: Record | undefined +declare const OPENCODE_MODELS_DEV: Record | undefined export interface Interface { - readonly get: () => Effect.Effect> + readonly get: () => Effect.Effect readonly refresh: (force?: boolean) => Effect.Effect } @@ -181,7 +336,7 @@ const layer = Layer.effect( }) const loadFromDisk = fs.readJson(Flag.OPENCODE_MODELS_PATH ?? filepath).pipe( - Effect.flatMap(decodeProvidersUnknown), + Effect.map((input) => input as Record), Effect.catch((error) => { if ( Flag.OPENCODE_MODELS_PATH === undefined && @@ -196,13 +351,6 @@ const layer = Layer.effect( const loadSnapshot = Effect.sync(() => typeof OPENCODE_MODELS_DEV === "undefined" ? undefined : OPENCODE_MODELS_DEV, - ).pipe( - Effect.flatMap((snapshot) => - snapshot === undefined ? Effect.succeed(undefined) : decodeProvidersUnknown(snapshot), - ), - Effect.catch((cause) => - Effect.logWarning("bundled models snapshot failed schema decode", { cause }).pipe(Effect.as(undefined)), - ), ) const fetchAndWrite = Effect.fn("ModelsDev.fetchAndWrite")(function* () { @@ -222,10 +370,10 @@ const layer = Layer.effect( const populate = Effect.gen(function* () { const fromDisk = yield* loadFromDisk - if (fromDisk) return fromDisk - const snapshot = yield* loadSnapshot - if (snapshot) return snapshot - if (Flag.OPENCODE_DISABLE_MODELS_FETCH) return {} + if (fromDisk) return normalize(fromDisk) + const bundled = yield* loadSnapshot + if (bundled) return normalize(bundled) + if (Flag.OPENCODE_DISABLE_MODELS_FETCH) return [] // Flock is cross-process: concurrent opencode CLIs can race on this cache file. const text = yield* Effect.scoped( Effect.gen(function* () { @@ -233,12 +381,12 @@ const layer = Layer.effect( return yield* fetchAndWrite() }), ) - return yield* decodeProviders(text) + return normalize(JSON.parse(text) as Record) }).pipe(Effect.withSpan("ModelsDev.populate"), Effect.orDie) const [cachedGet, invalidate] = yield* Effect.cachedInvalidateWithTTL(populate, Duration.infinity) - const get = (): Effect.Effect> => cachedGet + const get = (): Effect.Effect => cachedGet const refresh = Effect.fn("ModelsDev.refresh")(function* (force = false) { if (!force && (yield* fresh())) return diff --git a/packages/core/src/plugin/agent.ts b/packages/core/src/plugin/agent.ts index e6e03a4f15..8b94e219ae 100644 --- a/packages/core/src/plugin/agent.ts +++ b/packages/core/src/plugin/agent.ts @@ -161,12 +161,7 @@ export const Plugin = define({ item.description = "General-purpose agent for researching complex questions and executing multi-step tasks. Use this agent to execute multiple units of work in parallel." item.mode = "subagent" - item.permissions.push( - ...PermissionV2.merge(defaults, [ - { action: "subagent", resource: "*", effect: "deny" }, - { action: "todowrite", resource: "*", effect: "deny" }, - ]), - ) + item.permissions.push(...PermissionV2.merge(defaults, [{ action: "subagent", resource: "*", effect: "deny" }])) }) draft.update(AgentV2.ID.make("explore"), (item) => { diff --git a/packages/core/src/plugin/internal.ts b/packages/core/src/plugin/internal.ts index ad5553928a..9b75645db4 100644 --- a/packages/core/src/plugin/internal.ts +++ b/packages/core/src/plugin/internal.ts @@ -28,7 +28,6 @@ import { PermissionV2 } from "../permission" import { Reference } from "../reference" import { Ripgrep } from "../ripgrep" import { SessionInstructions } from "../session/instructions" -import { SessionTodo } from "../session/todo" import { Shell } from "../shell" import { SkillV2 } from "../skill" import { PatchTool } from "../tool/patch" @@ -41,7 +40,6 @@ import { ReadTool } from "../tool/read" import { ShellTool } from "../tool/shell" import { SkillTool } from "../tool/skill" import { SubagentTool } from "../tool/subagent" -import { TodoWriteTool } from "../tool/todowrite" import { Tools } from "../tool/tools" import { WebFetchTool } from "../tool/webfetch" import { WebSearchTool } from "../tool/websearch" @@ -78,7 +76,6 @@ const services = Effect.fn("PluginInternal.services")(function* () { const reference = yield* Reference.Service const ripgrep = yield* Ripgrep.Service const instructions = yield* SessionInstructions.Service - const todo = yield* SessionTodo.Service const shell = yield* Shell.Service const skill = yield* SkillV2.Service const tools = yield* Tools.Service @@ -107,7 +104,6 @@ const services = Effect.fn("PluginInternal.services")(function* () { Context.make(Reference.Service, reference), Context.make(Ripgrep.Service, ripgrep), Context.make(SessionInstructions.Service, instructions), - Context.make(SessionTodo.Service, todo), Context.make(Shell.Service, shell), Context.make(SkillV2.Service, skill), Context.make(Tools.Service, tools), @@ -136,7 +132,6 @@ const pre = [ ShellTool.Plugin, SkillTool.Plugin, SubagentTool.Plugin, - TodoWriteTool.Plugin, WebFetchTool.Plugin, WebSearchTool.Plugin, WriteTool.Plugin, diff --git a/packages/core/src/plugin/models-dev.ts b/packages/core/src/plugin/models-dev.ts index ef59535e81..3d2957e1ba 100644 --- a/packages/core/src/plugin/models-dev.ts +++ b/packages/core/src/plugin/models-dev.ts @@ -1,266 +1,41 @@ import { define } from "@opencode-ai/plugin/v2/effect/plugin" -import { Money } from "@opencode-ai/schema/money" -import type { ModelInfo } from "@opencode-ai/sdk/v2/types" import { Effect, Stream } from "effect" import { EventV2 } from "../event" -import { ModelV2 } from "../model" import { ModelsDev } from "../models-dev" -import { ProviderV2 } from "../provider" - -function released(date: string) { - const time = Date.parse(date) - return Number.isFinite(time) ? time : 0 -} - -function cost(input: ModelsDev.Model["cost"]): ModelInfo["cost"] { - const base = { - input: input?.input ?? Money.USDPerMillionTokens.zero, - output: input?.output ?? Money.USDPerMillionTokens.zero, - cache: { - read: input?.cache_read ?? Money.USDPerMillionTokens.zero, - write: input?.cache_write ?? Money.USDPerMillionTokens.zero, - }, - } - return [ - base, - ...(input?.tiers?.map((item) => ({ - tier: item.tier, - input: item.input, - output: item.output, - cache: { - read: item.cache_read ?? Money.USDPerMillionTokens.zero, - write: item.cache_write ?? Money.USDPerMillionTokens.zero, - }, - })) ?? []), - ...(input?.context_over_200k - ? [ - { - tier: { - type: "context" as const, - size: 200_000, - }, - input: input.context_over_200k.input, - output: input.context_over_200k.output, - cache: { - read: input.context_over_200k.cache_read ?? Money.USDPerMillionTokens.zero, - write: input.context_over_200k.cache_write ?? Money.USDPerMillionTokens.zero, - }, - }, - ] - : []), - ] -} - -function mergeCost(base: ModelInfo["cost"], override: ModelsDev.Model["cost"] | undefined) { - if (!override) return base - const next = cost(override) - const [baseDefault, ...baseTiers] = base - const [nextDefault, ...nextTiers] = next - const tierKey = (item: ModelInfo["cost"][number]) => `${item.tier?.type ?? "base"}:${item.tier?.size ?? 0}` - const merge = (left: ModelInfo["cost"][number], right: ModelInfo["cost"][number]) => ({ - ...left, - ...right, - tier: right.tier ?? left.tier, - cache: { ...left.cache, ...right.cache }, - }) - const tiers = new Map(baseTiers.map((item) => [tierKey(item), item])) - for (const item of nextTiers) { - const current = tiers.get(tierKey(item)) - tiers.set(tierKey(item), current ? merge(current, item) : item) - } - return [ - merge( - baseDefault ?? { - input: Money.USDPerMillionTokens.zero, - output: Money.USDPerMillionTokens.zero, - cache: { - read: Money.USDPerMillionTokens.zero, - write: Money.USDPerMillionTokens.zero, - }, - }, - nextDefault, - ), - ...tiers.values(), - ] -} - -const OPENAI_INCLUDE_ENCRYPTED_REASONING = ["reasoning.encrypted_content"] - -function reasoningVariants(provider: ModelsDev.Provider, model: ModelsDev.Model): NonNullable { - const npm = model.provider?.npm ?? provider.npm - const options = model.reasoning_options ?? [] - const effort = options.find((option) => option.type === "effort") - if (effort?.type === "effort") { - return effort.values.flatMap((value) => { - const raw: unknown = value - const id = raw === null ? "none" : typeof raw === "string" ? raw : undefined - if (id === undefined) return [] - const settings = settingsForEffort(npm, id) - return settings ? [{ id: ModelV2.VariantID.make(id), settings }] : [] - }) - } - - const budget = options.find((option) => option.type === "budget_tokens") - if (budget?.type === "budget_tokens") return budgetVariants(npm, budget) - - // Toggle-only reasoning is intentionally left for a follow-up because V1 has - // provider/model-specific behavior like MiniMax M3 adaptive thinking and - // Qwen/GLM enable_thinking request shapes in packages/opencode. - return [] -} - -function settingsForEffort(npm: string | undefined, effort: string): ProviderV2.Settings | undefined { - if (npm === "@openrouter/ai-sdk-provider") return { reasoning: { effort } } - if (npm === "@ai-sdk/anthropic" || npm === "@ai-sdk/google-vertex/anthropic") { - return { thinking: { type: "adaptive", display: "summarized" }, effort } - } - if (npm === "@ai-sdk/google" || npm === "@ai-sdk/google-vertex") { - return { thinkingConfig: { includeThoughts: true, thinkingLevel: effort } } - } - if (npm === "@ai-sdk/azure") return { reasoningEffort: effort } - if (npm === "@ai-sdk/openai") { - return { - reasoningEffort: effort, - reasoningSummary: "auto", - include: OPENAI_INCLUDE_ENCRYPTED_REASONING, - } - } - if (npm === "@ai-sdk/openai-compatible") return { reasoningEffort: effort } -} - -function budgetVariants( - npm: string | undefined, - option: Extract[number], { type: "budget_tokens" }>, -): NonNullable { - const max = option.max - const high = - option.max === undefined - ? Math.max(option.min ?? 0, 16_000) - : Math.min(Math.max(option.min ?? 0, 16_000), option.max) - return [ - { id: "high", budget: high }, - ...(max === undefined || max === high ? [] : [{ id: "max", budget: max }]), - ].flatMap((item) => { - const settings = settingsForBudget(npm, item.budget) - return settings ? [{ id: ModelV2.VariantID.make(item.id), settings }] : [] - }) -} - -function settingsForBudget(npm: string | undefined, budget: number): ProviderV2.Settings | undefined { - if (npm === "@openrouter/ai-sdk-provider") return { reasoning: { max_tokens: budget } } - if (npm === "@ai-sdk/anthropic" || npm === "@ai-sdk/google-vertex/anthropic") { - return { thinking: { type: "enabled", budgetTokens: budget } } - } - if (npm === "@ai-sdk/google" || npm === "@ai-sdk/google-vertex") { - return { thinkingConfig: { includeThoughts: true, thinkingBudget: budget } } - } -} - -function modeName(model: ModelsDev.Model, mode: string) { - return `${model.name} ${mode.charAt(0).toUpperCase()}${mode.slice(1)}` -} - -function mergeVariants(model: ModelInfo, next: NonNullable) { - const variants = model.variants ?? [] - const existing = new Map(variants.map((variant) => [variant.id, variant])) - const nextIDs = new Set(next.map((variant) => variant.id)) - model.variants = [ - ...next.map((variant) => existing.get(variant.id) ?? variant), - ...variants.filter((variant) => !nextIDs.has(variant.id)), - ] -} - -function applyModel( - draft: ModelInfo, - model: ModelsDev.Model, - input: { - readonly name?: string - readonly cost?: ModelInfo["cost"] - readonly request?: NonNullable["modes"]>[string]["provider"] - readonly variants?: NonNullable - } = {}, -) { - draft.name = input.name ?? model.name - draft.modelID = model.id - draft.family = model.family ? ModelV2.Family.make(model.family) : undefined - draft.package = model.provider?.npm ? ProviderV2.aisdk(model.provider.npm) : undefined - draft.settings = model.provider?.api ? { ...draft.settings, baseURL: model.provider.api } : draft.settings - draft.capabilities = { - tools: model.tool_call, - input: [...(model.modalities?.input ?? [])], - output: [...(model.modalities?.output ?? [])], - } - mergeVariants(draft, input.variants ?? []) - draft.time.released = released(model.release_date) - draft.cost = (input.cost ?? cost(model.cost)).map((item) => ({ - ...item, - tier: item.tier && { ...item.tier }, - cache: { ...item.cache }, - })) - draft.status = model.status ?? "active" - draft.enabled = true - draft.limit = { - context: model.limit.context, - input: model.limit.input, - output: model.limit.output, - } - draft.headers = { ...draft.headers, ...input.request?.headers } - draft.body = { ...draft.body, ...input.request?.body } -} export const ModelsDevPlugin = define({ id: "opencode.models-dev", effect: Effect.fn(function* (ctx) { const modelsDev = yield* ModelsDev.Service const events = yield* EventV2.Service - const loaded = { data: yield* modelsDev.get() } + const loaded = { data: structuredClone(yield* modelsDev.get()) } yield* ctx.integration.transform((integrations) => { - for (const item of Object.values(loaded.data)) { - if (item.env.length === 0) continue - const integrationID = item.id - integrations.update(integrationID, (integration) => (integration.name = item.name)) + for (const provider of loaded.data) { + if (provider.environment.length === 0) continue + const integrationID = provider.info.id + integrations.update(integrationID, (integration) => (integration.name = provider.info.name)) integrations.method.update({ integrationID, method: { type: "key" }, }) integrations.method.update({ integrationID, - method: { type: "env", names: [...item.env] }, + method: { type: "env", names: [...provider.environment] }, }) } }) yield* ctx.catalog.transform((catalog) => { - for (const item of Object.values(loaded.data)) { - const providerID = ProviderV2.ID.make(item.id) - catalog.provider.update(providerID, (provider) => { - provider.name = item.name - provider.package = item.npm ? ProviderV2.aisdk(item.npm) : "" - provider.settings = item.api ? { ...provider.settings, baseURL: item.api } : provider.settings - }) - - for (const model of Object.values(item.models)) { - const baseCost = cost(model.cost) - const variants = reasoningVariants(item, model) - catalog.model.update(providerID, ModelV2.ID.make(model.id), (draft) => - applyModel(draft, model, { cost: baseCost, variants }), - ) - for (const [mode, options] of Object.entries(model.experimental?.modes ?? {})) { - catalog.model.update(providerID, ModelV2.ID.make(`${model.id}-${mode}`), (draft) => - applyModel(draft, model, { - name: modeName(model, mode), - cost: mergeCost(baseCost, options.cost), - request: options.provider, - variants, - }), - ) - } + for (const provider of loaded.data) { + catalog.provider.update(provider.info.id, (draft) => Object.assign(draft, provider.info)) + for (const model of provider.models) { + catalog.model.update(provider.info.id, model.id, (draft) => Object.assign(draft, model)) } } }) yield* events.subscribe(ModelsDev.Event.Refreshed).pipe( Stream.runForEach(() => modelsDev.get().pipe( - Effect.tap((data) => Effect.sync(() => (loaded.data = data))), + Effect.tap((data) => Effect.sync(() => (loaded.data = structuredClone(data)))), Effect.andThen(ctx.integration.reload()), Effect.andThen(ctx.catalog.reload()), ), diff --git a/packages/core/src/plugin/provider/opencode.ts b/packages/core/src/plugin/provider/opencode.ts index cca6a2dfa7..1d0488bdad 100644 --- a/packages/core/src/plugin/provider/opencode.ts +++ b/packages/core/src/plugin/provider/opencode.ts @@ -43,14 +43,21 @@ function oauth(http: HttpClient.HttpClient) { type: "oauth", label: "OpenCode Console account", }, - authorize: () => + authorize: (inputs) => Effect.gen(function* () { - const device = yield* post(http, `${defaultServer}/auth/device/code`, { client_id: clientID }, Device) + const server = yield* normalizeServer(inputs.server ?? defaultServer) + const device = yield* post(http, `${server}/auth/device/code`, { client_id: clientID }, Device) + const verification = URL.canParse(device.verification_uri_complete) + ? new URL(device.verification_uri_complete) + : undefined + if (verification && verification.protocol !== "http:" && verification.protocol !== "https:") { + return yield* Effect.fail(new Error("Invalid device verification URL: expected HTTP(S)")) + } return { mode: "auto" as const, - url: `${defaultServer}${device.verification_uri_complete}`, + url: verification?.href ?? `${server}/${device.verification_uri_complete.replace(/^\/+/, "")}`, instructions: `Enter code: ${device.user_code}`, - callback: poll(http, defaultServer, device.device_code, Duration.seconds(device.interval)), + callback: poll(http, server, device.device_code, Duration.seconds(device.interval)), } }), refresh: (credential) => @@ -187,7 +194,7 @@ export const OpencodePlugin = define> | undefined) return Object.fromEntries(Object.entries(body ?? {}).filter(([key]) => key !== "apiKey" && key !== "headers")) } +function normalizeServer(input: string) { + return Effect.try({ + try: () => { + const url = new URL(input) + if (url.protocol !== "http:" && url.protocol !== "https:") throw new Error("expected HTTP(S)") + return `${url.origin}${url.pathname.replace(/\/+$/, "")}` + }, + catch: (cause) => + new Error(`Invalid OpenCode server URL: ${cause instanceof Error ? cause.message : String(cause)}`), + }) +} + function remoteCost(input: NonNullable<(typeof ConfigProviderV1.Model.Type)["cost"]>) { const base = { input: Money.USDPerMillionTokens.make(input.input), diff --git a/packages/core/src/plugin/supervisor.ts b/packages/core/src/plugin/supervisor.ts index 5b29d643ab..0dd3a31d72 100644 --- a/packages/core/src/plugin/supervisor.ts +++ b/packages/core/src/plugin/supervisor.ts @@ -30,7 +30,6 @@ import { PluginPromise } from "../plugin/promise" import { Reference } from "../reference" import { Ripgrep } from "../ripgrep" import { SessionInstructions } from "../session/instructions" -import { SessionTodo } from "../session/todo" import { Shell } from "../shell" import { SkillV2 } from "../skill" import { ReadToolFileSystem } from "../tool/read-filesystem" @@ -368,7 +367,6 @@ export const node = makeLocationNode({ Reference.node, Ripgrep.node, SessionInstructions.node, - SessionTodo.node, Shell.node, SkillV2.node, ToolRegistry.toolsNode, diff --git a/packages/core/src/reference.ts b/packages/core/src/reference.ts index b45db31b50..fe9e48e1da 100644 --- a/packages/core/src/reference.ts +++ b/packages/core/src/reference.ts @@ -49,6 +49,7 @@ const layer = Layer.effect( const scope = yield* Scope.Scope const materialized = new Map() const state = State.create({ + name: "reference", initial: () => ({ sources: new Map() }), draft: (draft) => ({ add: (name, source) => draft.sources.set(name, source as Types.DeepMutable), diff --git a/packages/core/src/session.ts b/packages/core/src/session.ts index ad53940971..3d3a3a5e45 100644 --- a/packages/core/src/session.ts +++ b/packages/core/src/session.ts @@ -122,6 +122,13 @@ export class PromptConflictError extends Schema.TaggedErrorClass()( + "Session.SyntheticConflictError", + { + sessionID: SessionSchema.ID, + inputID: SessionMessage.ID, + }, +) {} export class AttachmentError extends Schema.TaggedErrorClass()("Session.AttachmentError", { uri: Schema.String, message: Schema.String, @@ -147,6 +154,7 @@ export type Error = | MessageDecodeError | OperationUnavailableError | PromptConflictError + | SyntheticConflictError | AttachmentError | CompactionConflictError | BusyError @@ -204,10 +212,13 @@ export interface Interface { readonly prompt: (input: { id?: SessionMessage.ID sessionID: SessionSchema.ID - prompt: PromptInput.Prompt + text: string + files?: PromptInput.Prompt["files"] + agents?: PromptInput.Prompt["agents"] + metadata?: Record delivery?: SessionInput.Delivery resume?: boolean - }) => Effect.Effect + }) => Effect.Effect readonly command: (input: { id?: SessionMessage.ID sessionID: SessionSchema.ID @@ -220,7 +231,7 @@ export interface Interface { delivery?: SessionInput.Delivery resume?: boolean }) => Effect.Effect< - SessionInput.Admitted, + SessionInput.User, NotFoundError | PromptConflictError | AttachmentError | CommandV2.NotFoundError | CommandV2.EvaluationError > readonly shell: (input: { @@ -243,12 +254,14 @@ export interface Interface { readonly resume: (sessionID: SessionSchema.ID) => Effect.Effect readonly interrupt: (sessionID: SessionSchema.ID) => Effect.Effect readonly synthetic: (input: { + id?: SessionMessage.ID sessionID: SessionSchema.ID text: string description?: string metadata?: Record + delivery?: SessionInput.Delivery resume?: boolean - }) => Effect.Effect + }) => Effect.Effect readonly revert: { readonly stage: (input: { sessionID: SessionSchema.ID @@ -487,15 +500,19 @@ const layer = Layer.effect( // continues from the reverted boundary rather than stale post-boundary history. if (session.revert) yield* SessionRevert.commit(session).pipe(Effect.provideService(EventV2.Service, events)) - const prompt = yield* resolvePrompt(input.prompt).pipe(Effect.provideService(FSUtil.Service, fs)) + const prompt = yield* resolvePrompt({ text: input.text, files: input.files, agents: input.agents }).pipe( + Effect.provideService(FSUtil.Service, fs), + ) const messageID = input.id ?? SessionMessage.ID.create() - const delivery = input.delivery ?? "steer" - const expected = { sessionID: input.sessionID, messageID, prompt, delivery } + const admittedInput = SessionInput.Message.make({ + type: "user", + data: { ...prompt, metadata: input.metadata }, + delivery: input.delivery ?? "steer", + }) const admitted = yield* SessionInput.admit(db, events, { id: messageID, sessionID: input.sessionID, - prompt, - delivery, + input: admittedInput, }).pipe( Effect.catchDefect((defect) => defect instanceof SessionInput.LifecycleConflict @@ -503,7 +520,10 @@ const layer = Layer.effect( : Effect.die(defect), ), ) - if (!SessionInput.equivalent(admitted, expected)) + if ( + admitted.type !== "user" || + !SessionInput.equivalent(admitted, { sessionID: input.sessionID, input: admittedInput }) + ) return yield* new PromptConflictError({ sessionID: input.sessionID, messageID }) if (input.resume !== false) { if (activeShells.has(admitted.sessionID)) return admitted @@ -539,7 +559,9 @@ const layer = Layer.effect( return yield* result.prompt({ id: input.id, sessionID: input.sessionID, - prompt: { text: evaluated.text, files: input.files, agents: input.agents }, + text: evaluated.text, + files: input.files, + agents: input.agents, delivery: input.delivery, resume: input.resume, }) @@ -664,35 +686,60 @@ const layer = Layer.effect( yield* result.get(sessionID) const backgrounded = yield* jobs.backgroundAll({ sessionID }) if (backgrounded.length === 0) return - yield* result.synthetic({ - sessionID, - text: [ - "User requested that active blocking work be moved to the background.", - "", - "Backgrounded work:", - ...backgrounded.map((job) => `- ${job.type}: ${job.title && job.title.length > 0 ? job.title : job.id}`), - "", - "The backgrounded work is still unfinished. Move on to other work if you can. If there is nothing else useful to do, finish your response. Do not wait, sleep, poll, or report the backgrounded work as complete until a later completion notification is added to the conversation.", - ].join("\n"), - }) + yield* result + .synthetic({ + sessionID, + text: [ + "User requested that active blocking work be moved to the background.", + "", + "Backgrounded work:", + ...backgrounded.map((job) => `- ${job.type}: ${job.title && job.title.length > 0 ? job.title : job.id}`), + "", + "The backgrounded work is still unfinished. Move on to other work if you can. If there is nothing else useful to do, finish your response. Do not wait, sleep, poll, or report the backgrounded work as complete until a later completion notification is added to the conversation.", + ].join("\n"), + }) + .pipe(Effect.catchTag("Session.SyntheticConflictError", Effect.die)) }), resume: Effect.fn("V2Session.resume")(function* (sessionID) { yield* result.get(sessionID) yield* execution.resume(sessionID) }), - synthetic: Effect.fn("V2Session.synthetic")(function* (input) { - yield* result.get(input.sessionID) - yield* events.publish(SessionEvent.Synthetic, { - sessionID: input.sessionID, - text: input.text, - description: input.description, - metadata: input.metadata, - }) - if (input.resume === false) return - yield* execution - .resume(input.sessionID) - .pipe(Effect.ignore, Effect.forkIn(scope, { startImmediately: true }), Effect.asVoid) - }), + synthetic: Effect.fn("V2Session.synthetic")((input) => + Effect.uninterruptible( + Effect.gen(function* () { + yield* result.get(input.sessionID) + const inputID = input.id ?? SessionMessage.ID.create() + const admittedInput = SessionInput.Message.make({ + type: "synthetic", + data: { + text: input.text, + description: input.description, + metadata: input.metadata, + }, + delivery: input.delivery ?? "steer", + }) + const admitted = yield* SessionInput.admit(db, events, { + id: inputID, + sessionID: input.sessionID, + input: admittedInput, + }).pipe( + Effect.catchDefect((defect) => + defect instanceof SessionInput.LifecycleConflict + ? new SyntheticConflictError({ sessionID: input.sessionID, inputID }) + : Effect.die(defect), + ), + ) + if ( + admitted.type !== "synthetic" || + !SessionInput.equivalent(admitted, { sessionID: input.sessionID, input: admittedInput }) + ) + return yield* new SyntheticConflictError({ sessionID: input.sessionID, inputID }) + if (input.resume !== false && !(yield* result.get(input.sessionID)).revert) + yield* execution.wake(input.sessionID) + return admitted + }), + ), + ), interrupt: Effect.fn("V2Session.interrupt")((sessionID) => Effect.uninterruptible(execution.interrupt(sessionID)), ), @@ -710,10 +757,12 @@ const layer = Layer.effect( clear: Effect.fn("V2Session.revert.clear")(function* (sessionID) { const session = yield* result.get(sessionID) if ((yield* execution.active).has(sessionID)) return yield* new BusyError({ sessionID }) - return yield* SessionRevert.clear(session).pipe( + const revert = yield* SessionRevert.clear(session).pipe( Effect.provideService(EventV2.Service, events), Effect.provide(locations.get(session.location)), ) + yield* execution.wake(sessionID) + return revert }), commit: Effect.fn("V2Session.revert.commit")(function* (sessionID) { const session = yield* result.get(sessionID) diff --git a/packages/core/src/session/execution.ts b/packages/core/src/session/execution.ts index 5dbba30776..3b45ba25c9 100644 --- a/packages/core/src/session/execution.ts +++ b/packages/core/src/session/execution.ts @@ -1,10 +1,16 @@ export * as SessionExecution from "./execution" -import { Context, Effect, Layer } from "effect" -import { LayerNode } from "../effect/layer-node" -import { Node } from "../effect/app-node" +import { Cause, Context, Effect, Exit, Layer } from "effect" +import { EventV2 } from "../event" +import { LocationServiceMap } from "../location-service-map" +import { makeGlobalNode } from "../effect/app-node" +import { SessionEvent } from "./event" +import { SessionRunCoordinator } from "./run-coordinator" import { SessionRunner } from "./runner/index" import { SessionSchema } from "./schema" +import { SessionStore } from "./store" +import { toSessionError } from "./to-session-error" +import { UserInterruptedError } from "./error" export interface Interface { /** Snapshots active execution owned by this process. */ @@ -22,7 +28,98 @@ export interface Interface { /** Routes execution from a Session ID to the runner owned by that Session's Location. */ export class Service extends Context.Service()("@opencode/v2/SessionExecution") {} -export const node = LayerNode.unbound(Service, Node.tags.values.global) +type InterruptReason = "user" | "shutdown" | "superseded" + +export function terminal(exit: Exit.Exit, reason?: InterruptReason) { + if (Exit.isSuccess(exit)) return { type: "succeeded" as const } + if (Cause.hasInterrupts(exit.cause)) return { type: "interrupted" as const, reason: reason ?? "shutdown" } + const failure = Cause.squash(exit.cause) + if (failure instanceof UserInterruptedError) return { type: "interrupted" as const, reason: "user" as const } + return { type: "failed" as const, error: toSessionError(failure) } +} + +/** Process-local execution: drains run in this process, routed through the Session's Location graph. */ +export const layer = Layer.effect( + Service, + Effect.gen(function* () { + const store = yield* SessionStore.Service + const locations = yield* LocationServiceMap.Service + const events = yield* EventV2.Service + const reportLifecycle = (sessionID: SessionSchema.ID, effect: Effect.Effect) => + effect.pipe( + Effect.tapCause((cause) => + Cause.hasInterruptsOnly(cause) + ? Effect.void + : Effect.logError("Failed to publish Session execution lifecycle", cause).pipe( + Effect.annotateLogs({ sessionID }), + ), + ), + Effect.asVoid, + ) + // Starting or finishing on its own clears stale suspension; interruption preserves it because + // managed-server teardown suspends active Sessions immediately before interrupting their drains. + const clearSuspensionOnCommit = (sessionID: SessionSchema.ID) => ({ + commit: () => Effect.asVoid(store.consumeSuspended(sessionID)), + }) + const coordinator = yield* SessionRunCoordinator.make({ + started: (sessionID) => + reportLifecycle( + sessionID, + events.publish(SessionEvent.Execution.Started, { sessionID }, clearSuspensionOnCommit(sessionID)), + ), + drain: Effect.fnUntraced(function* (sessionID: SessionSchema.ID, force) { + const session = yield* store.get(sessionID) + if (!session) return yield* Effect.die(new Error(`Session not found: ${sessionID}`)) + return yield* SessionRunner.Service.use((runner) => runner.drain({ sessionID, force })).pipe( + Effect.provide(locations.get(session.location)), + Effect.tapCause((cause) => + Cause.hasInterruptsOnly(cause) + ? Effect.void + : Effect.logError("Failed to drain Session", cause).pipe(Effect.annotateLogs({ sessionID })), + ), + ) + }), + // One terminal observation per busy period, covering every coalesced drain. + settled: (sessionID, exit, reason) => + reportLifecycle( + sessionID, + Effect.gen(function* () { + const outcome = terminal(exit, reason) + if (outcome.type === "succeeded") { + yield* events.publish(SessionEvent.Execution.Succeeded, { sessionID }, clearSuspensionOnCommit(sessionID)) + return + } + if (outcome.type === "interrupted") { + yield* events.publish(SessionEvent.Execution.Interrupted, { sessionID, reason: outcome.reason }) + return + } + yield* events.publish( + SessionEvent.Execution.Failed, + { + sessionID, + error: outcome.error, + }, + clearSuspensionOnCommit(sessionID), + ) + }), + ), + }) + + return Service.of({ + active: coordinator.active, + interrupt: (sessionID) => coordinator.interrupt(sessionID, "user"), + resume: coordinator.run, + wake: coordinator.wake, + awaitIdle: coordinator.awaitIdle, + }) + }), +) + +export const node = makeGlobalNode({ + service: Service, + layer, + deps: [SessionStore.node, LocationServiceMap.node, EventV2.node], +}) /** Low-level compatibility layer for callers that only need durable Session recording. */ export const noopLayer = Layer.succeed( diff --git a/packages/core/src/session/execution/local.ts b/packages/core/src/session/execution/local.ts deleted file mode 100644 index c925ee655c..0000000000 --- a/packages/core/src/session/execution/local.ts +++ /dev/null @@ -1,96 +0,0 @@ -import { Cause, Effect, Exit, Layer } from "effect" -import { EventV2 } from "../../event" -import { LocationServiceMap } from "../../location-service-map" -import { makeGlobalNode } from "../../effect/app-node" -import { SessionEvent } from "../event" -import { SessionRunCoordinator } from "../run-coordinator" -import { SessionRunner } from "../runner" -import { SessionSchema } from "../schema" -import { SessionStore } from "../store" -import { SessionExecution } from "../execution" -import { toSessionError } from "../to-session-error" -import { UserInterruptedError } from "../error" - -export function terminal(exit: Exit.Exit, reason?: "user" | "shutdown" | "superseded") { - if (Exit.isSuccess(exit)) return { type: "succeeded" as const } - if (Cause.hasInterrupts(exit.cause)) return { type: "interrupted" as const, reason: reason ?? "shutdown" } - const failure = Cause.squash(exit.cause) - if (failure instanceof UserInterruptedError) return { type: "interrupted" as const, reason: "user" as const } - return { type: "failed" as const, error: toSessionError(failure) } -} - -/** Current-process routing for implicit-local Locations. Future remote placement belongs here. */ -const layer = Layer.effect( - SessionExecution.Service, - Effect.gen(function* () { - const store = yield* SessionStore.Service - const locations = yield* LocationServiceMap.Service - const events = yield* EventV2.Service - const reportLifecycle = (sessionID: SessionSchema.ID, effect: Effect.Effect) => - effect.pipe( - Effect.tapCause((cause) => - Cause.hasInterruptsOnly(cause) - ? Effect.void - : Effect.logError("Failed to publish Session execution lifecycle", cause).pipe( - Effect.annotateLogs({ sessionID }), - ), - ), - Effect.asVoid, - ) - const coordinator = yield* SessionRunCoordinator.make< - SessionSchema.ID, - SessionRunner.RunError, - "user" | "shutdown" | "superseded" - >({ - started: (sessionID) => reportLifecycle(sessionID, events.publish(SessionEvent.Execution.Started, { sessionID })), - drain: Effect.fnUntraced(function* (sessionID: SessionSchema.ID, force) { - const session = yield* store.get(sessionID) - if (!session) return yield* Effect.die(new Error(`Session not found: ${sessionID}`)) - return yield* SessionRunner.Service.use((runner) => runner.drain({ sessionID, force })).pipe( - Effect.provide(locations.get(session.location)), - Effect.tapCause((cause) => - Cause.hasInterruptsOnly(cause) - ? Effect.void - : Effect.logError("Failed to drain Session", cause).pipe(Effect.annotateLogs({ sessionID })), - ), - ) - }), - // One terminal observation per busy period, covering every coalesced drain. - settled: (sessionID, exit, reason) => - reportLifecycle( - sessionID, - Effect.gen(function* () { - const outcome = terminal(exit, reason) - if (outcome.type === "succeeded") { - yield* events.publish(SessionEvent.Execution.Succeeded, { sessionID }) - return - } - if (outcome.type === "interrupted") { - yield* events.publish(SessionEvent.Execution.Interrupted, { sessionID, reason: outcome.reason }) - return - } - yield* events.publish(SessionEvent.Execution.Failed, { - sessionID, - error: outcome.error, - }) - }), - ), - }) - - return SessionExecution.Service.of({ - active: coordinator.active, - interrupt: (sessionID) => coordinator.interrupt(sessionID, "user"), - resume: coordinator.run, - wake: coordinator.wake, - awaitIdle: coordinator.awaitIdle, - }) - }), -) - -export const node = makeGlobalNode({ - service: SessionExecution.Service, - layer, - deps: [SessionStore.node, LocationServiceMap.node, EventV2.node], -}) - -export * as SessionExecutionLocal from "./local" diff --git a/packages/core/src/session/execution/restart.ts b/packages/core/src/session/execution/restart.ts new file mode 100644 index 0000000000..eb75a7bf42 --- /dev/null +++ b/packages/core/src/session/execution/restart.ts @@ -0,0 +1,51 @@ +export * as SessionRestart from "./restart" + +import { Context, Effect, Layer } from "effect" +import { makeGlobalNode } from "../../effect/app-node" +import { SessionExecution } from "../execution" +import { SessionStore } from "../store" + +export interface Interface { + /** + * Marks every execution active in this process for resumption by the next server start. + * Call once new work has stopped arriving and before teardown interrupts the drains. + */ + readonly suspendActiveSessions: Effect.Effect + /** Resumes suspended Sessions. Each suspension is consumed atomically, so a Session resumes at most once. */ + readonly resumeSuspendedSessions: Effect.Effect +} + +/** + * Restart continuity actions for the managed server. The service is inert until called: only the + * managed server invokes it, so default, embedded, and stdio servers never suspend or auto-resume. + */ +export class Service extends Context.Service()("@opencode/v2/SessionRestart") {} + +export const layer = Layer.effect( + Service, + Effect.gen(function* () { + const store = yield* SessionStore.Service + const execution = yield* SessionExecution.Service + return Service.of({ + suspendActiveSessions: Effect.gen(function* () { + yield* store.suspend(yield* execution.active) + }), + resumeSuspendedSessions: Effect.gen(function* () { + const sessions = yield* store.listSuspended() + yield* Effect.forEach( + sessions, + (sessionID) => + Effect.gen(function* () { + if (!(yield* store.consumeSuspended(sessionID))) return + // Drain failures are already logged and durably recorded by the execution layer. + yield* Effect.ignore(execution.resume(sessionID)) + }), + // Each suspension is consumed atomically right before its drain; at most four drains run at once. + { concurrency: 4, discard: true }, + ) + }), + }) + }), +) + +export const node = makeGlobalNode({ service: Service, layer, deps: [SessionStore.node, SessionExecution.node] }) diff --git a/packages/core/src/session/input.ts b/packages/core/src/session/input.ts index 41eb36ea5b..e3e62ac139 100644 --- a/packages/core/src/session/input.ts +++ b/packages/core/src/session/input.ts @@ -2,22 +2,32 @@ export * as SessionInput from "./input" import { and, asc, eq, isNull } from "drizzle-orm" import { DateTime, Effect, Schema } from "effect" -import { Admitted, Compaction, Delivery, Info, PromptEntry } from "@opencode-ai/schema/session-input" +import { + Compaction, + Delivery, + Info, + Message, + Synthetic, + SyntheticData, + User, + UserData, +} from "@opencode-ai/schema/session-input" import type { Database } from "../database/database" import type { EventV2 } from "../event" import { KeyedMutex } from "../effect/keyed-mutex" import { SessionEvent } from "./event" import { SessionMessage } from "./message" -import { Prompt } from "@opencode-ai/schema/prompt" import { SessionSchema } from "./schema" import { SessionInputTable, SessionMessageTable } from "./sql" type DatabaseService = Database.Interface["db"] -export { Admitted, Compaction, Delivery, Info, PromptEntry } +export { Compaction, Delivery, Info, Message, Synthetic, SyntheticData, User, UserData } -const decodePrompt = Schema.decodeUnknownSync(Prompt) -const encodePrompt = Schema.encodeSync(Prompt) +const decodeUser = Schema.decodeUnknownSync(UserData) +const encodeUser = Schema.encodeSync(UserData) +const decodeSynthetic = Schema.decodeUnknownSync(SyntheticData) +const encodeSynthetic = Schema.encodeSync(SyntheticData) const inboxLocks = KeyedMutex.makeUnsafe() export class LifecycleConflict extends Schema.TaggedErrorClass()("SessionInput.LifecycleConflict", { @@ -37,27 +47,26 @@ const fromRow = (row: typeof SessionInputTable.$inferSelect): Info => { type: "compaction", ...(row.promoted_seq === null ? {} : { handledSeq: row.promoted_seq }), }) - if (!row.prompt || !row.delivery) throw new LifecycleConflict({ id: base.id }) - return PromptEntry.make({ - ...base, - type: "prompt", - prompt: decodePrompt(row.prompt), - delivery: row.delivery, - ...(row.promoted_seq === null ? {} : { promotedSeq: row.promoted_seq }), - }) + if (!row.delivery) throw new LifecycleConflict({ id: base.id }) + if (row.type === "user") + return User.make({ + ...base, + type: "user", + data: decodeUser(row.data), + delivery: row.delivery, + ...(row.promoted_seq === null ? {} : { promotedSeq: row.promoted_seq }), + }) + if (row.type === "synthetic") + return Synthetic.make({ + ...base, + type: "synthetic", + data: decodeSynthetic(row.data), + delivery: row.delivery, + ...(row.promoted_seq === null ? {} : { promotedSeq: row.promoted_seq }), + }) + throw new LifecycleConflict({ id: base.id }) } -const toAdmitted = (entry: PromptEntry): Admitted => - Admitted.make({ - admittedSeq: entry.admittedSeq, - id: entry.id, - sessionID: entry.sessionID, - prompt: entry.prompt, - delivery: entry.delivery, - timeCreated: entry.timeCreated, - ...(entry.promotedSeq === undefined ? {} : { promotedSeq: entry.promotedSeq }), - }) - export const find = Effect.fn("SessionInput.find")(function* (db: DatabaseService, id: SessionMessage.ID) { const row = yield* db.select().from(SessionInputTable).where(eq(SessionInputTable.id, id)).get().pipe(Effect.orDie) return row === undefined ? undefined : fromRow(row) @@ -89,44 +98,43 @@ export const pendingCompaction = Effect.fn("SessionInput.pendingCompaction")(fun export const admit = Effect.fn("SessionInput.admit")(function* ( db: DatabaseService, events: EventV2.Interface, - input: { + request: { readonly id: SessionMessage.ID readonly sessionID: SessionSchema.ID - readonly prompt: Prompt - readonly delivery: Delivery + readonly input: Message }, ) { - const existing = yield* find(db, input.id) + const existing = yield* find(db, request.id) if (existing !== undefined) { - if (existing.type !== "prompt") return yield* Effect.die(new LifecycleConflict({ id: input.id })) - return toAdmitted(existing) + if (existing.type === "compaction") return yield* Effect.die(new LifecycleConflict({ id: request.id })) + return existing } return yield* events - .publish(SessionEvent.PromptAdmitted, { - inputID: input.id, - sessionID: input.sessionID, - prompt: input.prompt, - delivery: input.delivery, + .publish(SessionEvent.InputAdmitted, { + inputID: request.id, + sessionID: request.sessionID, + input: request.input, }) .pipe( - Effect.flatMap((event) => - event.durable === undefined - ? Effect.die(new Error("Prompt admission event is missing aggregate sequence")) - : Effect.succeed( - Admitted.make({ - admittedSeq: event.durable.seq, - id: input.id, - sessionID: input.sessionID, - prompt: input.prompt, - delivery: input.delivery, - timeCreated: event.created, - }), - ), - ), + Effect.flatMap((event) => { + if (event.durable === undefined) + return Effect.die(new Error("Session input admission event is missing aggregate sequence")) + const base = { + admittedSeq: event.durable.seq, + id: request.id, + sessionID: request.sessionID, + timeCreated: event.created, + } + return Effect.succeed( + request.input.type === "user" + ? User.make({ ...base, ...request.input }) + : Synthetic.make({ ...base, ...request.input }), + ) + }), Effect.catchDefect((defect) => - find(db, input.id).pipe( + find(db, request.id).pipe( Effect.flatMap((stored) => - stored?.type === "prompt" ? Effect.succeed(toAdmitted(stored)) : Effect.die(defect), + stored?.type === request.input.type ? Effect.succeed(stored) : Effect.die(defect), ), ), ), @@ -174,38 +182,37 @@ export const admitCompaction = Effect.fn("SessionInput.admitCompaction")(functio export const projectAdmitted = Effect.fn("SessionInput.projectAdmitted")(function* ( db: DatabaseService, - input: { + request: { readonly admittedSeq: number readonly id: SessionMessage.ID readonly sessionID: SessionSchema.ID - readonly prompt: Prompt - readonly delivery: Delivery + readonly input: Message readonly timeCreated: DateTime.Utc }, ) { const message = yield* db .select({ id: SessionMessageTable.id }) .from(SessionMessageTable) - .where(eq(SessionMessageTable.id, input.id)) + .where(eq(SessionMessageTable.id, request.id)) .get() .pipe(Effect.orDie) - if (message !== undefined) return yield* Effect.die(new LifecycleConflict({ id: input.id })) + if (message !== undefined) return yield* Effect.die(new LifecycleConflict({ id: request.id })) const stored = yield* db .insert(SessionInputTable) .values({ - id: input.id, - session_id: input.sessionID, - type: "prompt", - admitted_seq: input.admittedSeq, - prompt: encodePrompt(input.prompt), - delivery: input.delivery, - time_created: DateTime.toEpochMillis(input.timeCreated), + id: request.id, + session_id: request.sessionID, + type: request.input.type, + data: request.input.type === "user" ? encodeUser(request.input.data) : encodeSynthetic(request.input.data), + delivery: request.input.delivery, + admitted_seq: request.admittedSeq, + time_created: DateTime.toEpochMillis(request.timeCreated), }) .onConflictDoNothing() .returning({ id: SessionInputTable.id }) .get() .pipe(Effect.orDie) - if (!stored) return yield* Effect.die(new LifecycleConflict({ id: input.id })) + if (!stored) return yield* Effect.die(new LifecycleConflict({ id: request.id })) }) export const projectCompactionAdmitted = Effect.fn("SessionInput.projectCompactionAdmitted")(function* ( @@ -230,6 +237,7 @@ export const projectCompactionAdmitted = Effect.fn("SessionInput.projectCompacti id: input.id, session_id: input.sessionID, type: "compaction", + data: {}, admitted_seq: input.admittedSeq, time_created: DateTime.toEpochMillis(input.timeCreated), }) @@ -246,7 +254,7 @@ export const projectCompactionAdmitted = Effect.fn("SessionInput.projectCompacti return yield* Effect.die(new LifecycleConflict({ id: input.id })) }) -export const projectPromptPromoted = Effect.fn("SessionInput.projectPromptPromoted")(function* ( +export const projectPromoted = Effect.fn("SessionInput.projectPromoted")(function* ( db: DatabaseService, input: { readonly id: SessionMessage.ID @@ -262,23 +270,16 @@ export const projectPromptPromoted = Effect.fn("SessionInput.projectPromptPromot and( eq(SessionInputTable.id, input.id), eq(SessionInputTable.session_id, input.sessionID), - eq(SessionInputTable.type, "prompt"), isNull(SessionInputTable.promoted_seq), ), ) .returning() .get() .pipe(Effect.orDie) - if (updated) { - const stored = fromRow(updated) - if (stored.type !== "prompt" || stored.sessionID !== input.sessionID) - return yield* Effect.die(new LifecycleConflict({ id: input.id })) - return stored - } - const stored = yield* find(db, input.id) + const stored = updated ? fromRow(updated) : yield* find(db, input.id) if ( !stored || - stored.type !== "prompt" || + stored.type === "compaction" || stored.sessionID !== input.sessionID || stored.promotedSeq !== input.promotedSeq ) @@ -322,7 +323,6 @@ export const hasPending = Effect.fn("SessionInput.hasPending")(function* ( .where( and( eq(SessionInputTable.session_id, sessionID), - eq(SessionInputTable.type, "prompt"), isNull(SessionInputTable.promoted_seq), eq(SessionInputTable.delivery, delivery), ), @@ -334,16 +334,21 @@ export const hasPending = Effect.fn("SessionInput.hasPending")(function* ( }) export const equivalent = ( - input: Admitted, - expected: { - readonly sessionID: SessionSchema.ID - readonly prompt: Prompt - readonly delivery: Delivery - }, -) => - input.delivery === expected.delivery && - input.sessionID === expected.sessionID && - JSON.stringify(encodePrompt(input.prompt)) === JSON.stringify(encodePrompt(expected.prompt)) + input: User | Synthetic, + expected: { readonly sessionID: SessionSchema.ID; readonly input: Message }, +) => { + if ( + input.type !== expected.input.type || + input.delivery !== expected.input.delivery || + input.sessionID !== expected.sessionID + ) + return false + if (input.type === "user" && expected.input.type === "user") + return JSON.stringify(encodeUser(input.data)) === JSON.stringify(encodeUser(expected.input.data)) + if (input.type === "synthetic" && expected.input.type === "synthetic") + return JSON.stringify(encodeSynthetic(input.data)) === JSON.stringify(encodeSynthetic(expected.input.data)) + return false +} const publish = Effect.fn("SessionInput.publish")(function* ( db: DatabaseService, @@ -358,9 +363,9 @@ const publish = Effect.fn("SessionInput.publish")(function* ( rows, (row) => { const entry = fromRow(row) - if (entry.type !== "prompt") return Effect.die(new LifecycleConflict({ id: entry.id })) + if (entry.type === "compaction") return Effect.die(new LifecycleConflict({ id: entry.id })) return events - .publish(SessionEvent.PromptPromoted, { + .publish(SessionEvent.InputPromoted, { sessionID, inputID: entry.id, }) @@ -369,7 +374,7 @@ const publish = Effect.fn("SessionInput.publish")(function* ( defect instanceof LifecycleConflict ? find(db, entry.id).pipe( Effect.flatMap((stored) => - stored?.type === "prompt" && stored.promotedSeq !== undefined + stored?.type !== "compaction" && stored?.promotedSeq !== undefined ? Effect.void : Effect.die(defect), ), @@ -397,7 +402,6 @@ export const promoteSteers = Effect.fn("SessionInput.promoteSteers")(function* ( .where( and( eq(SessionInputTable.session_id, sessionID), - eq(SessionInputTable.type, "prompt"), isNull(SessionInputTable.promoted_seq), eq(SessionInputTable.delivery, "steer"), ), @@ -420,7 +424,6 @@ export const promoteNextQueued = Effect.fn("SessionInput.promoteNextQueued")(fun .where( and( eq(SessionInputTable.session_id, sessionID), - eq(SessionInputTable.type, "prompt"), isNull(SessionInputTable.promoted_seq), eq(SessionInputTable.delivery, "queue"), ), diff --git a/packages/core/src/session/message-updater.ts b/packages/core/src/session/message-updater.ts index 01e045ce5f..5d5ac25668 100644 --- a/packages/core/src/session/message-updater.ts +++ b/packages/core/src/session/message-updater.ts @@ -172,8 +172,8 @@ export function update(adapter: Adapter, event: SessionEvent.Event) { "session.renamed": () => Effect.void, "session.deleted": () => Effect.void, "session.forked": () => Effect.void, - "session.prompt.promoted": () => Effect.void, - "session.prompt.admitted": () => Effect.void, + "session.input.promoted": () => Effect.void, + "session.input.admitted": () => Effect.void, "session.execution.started": () => Effect.void, "session.execution.succeeded": () => clearCurrentRetry, "session.execution.failed": () => clearCurrentRetry, diff --git a/packages/core/src/session/projector.ts b/packages/core/src/session/projector.ts index dcf79ff3a1..a3c2b53ae4 100644 --- a/packages/core/src/session/projector.ts +++ b/packages/core/src/session/projector.ts @@ -313,13 +313,13 @@ const projectFork = Effect.fn("SessionProjector.projectFork")(function* ( .values( inputRows.flatMap((row) => { const id = idMap.get(row.id) - return id && row.type === "prompt" + return id && row.type !== "compaction" ? [ { id, session_id: event.data.sessionID, - type: "prompt" as const, - prompt: row.prompt, + type: row.type, + data: row.data, delivery: row.delivery, admitted_seq: row.admitted_seq, promoted_seq: row.promoted_seq, @@ -629,27 +629,40 @@ const layer = Layer.effectDiscard( .pipe(Effect.orDie), ) yield* events.project(SessionEvent.Forked, (event) => projectFork(db, event)) - yield* events.project(SessionEvent.PromptPromoted, (event) => + yield* events.project(SessionEvent.InputPromoted, (event) => Effect.gen(function* () { if (event.durable === undefined) return yield* Effect.die(new Error("Durable Session event is missing aggregate sequence")) - const input = yield* SessionInput.projectPromptPromoted(db, { + const input = yield* SessionInput.projectPromoted(db, { id: event.data.inputID, sessionID: event.data.sessionID, promotedSeq: event.durable.seq, }) - yield* insertMessage(db, event, { - id: input.id, - type: "user", - metadata: event.metadata, - text: input.prompt.text, - files: input.prompt.files, - agents: input.prompt.agents, - time: { created: event.created }, - }) + yield* insertMessage( + db, + event, + input.type === "user" + ? { + id: input.id, + type: "user", + metadata: input.data.metadata, + text: input.data.text, + files: input.data.files, + agents: input.data.agents, + time: { created: event.created }, + } + : { + id: input.id, + type: "synthetic", + text: input.data.text, + description: input.data.description, + metadata: input.data.metadata, + time: { created: event.created }, + }, + ) }), ) - yield* events.project(SessionEvent.PromptAdmitted, (event) => + yield* events.project(SessionEvent.InputAdmitted, (event) => Effect.gen(function* () { if (event.durable === undefined) return yield* Effect.die(new Error("Durable Session event is missing aggregate sequence")) @@ -657,8 +670,7 @@ const layer = Layer.effectDiscard( admittedSeq: event.durable.seq, id: event.data.inputID, sessionID: event.data.sessionID, - prompt: event.data.prompt, - delivery: event.data.delivery, + input: event.data.input, timeCreated: event.created, }) }), diff --git a/packages/core/src/session/runner/llm.ts b/packages/core/src/session/runner/llm.ts index 261760568f..7570585fc1 100644 --- a/packages/core/src/session/runner/llm.ts +++ b/packages/core/src/session/runner/llm.ts @@ -256,8 +256,8 @@ const layer = Layer.effect( tools: toolMaterialization?.definitions ?? [], toolChoice: isLastStep ? "none" : undefined, }) - const toolFibers = yield* FiberSet.make() - const ownedToolFibers: Array> = [] + const toolFibers = yield* FiberSet.make() + const ownedToolFibers: Array> = [] let needsContinuation = false const availableTools = new Map(request.tools.map((tool) => [tool.name, tool])) const requestEvent: SessionHooks["request"] = { @@ -364,11 +364,10 @@ const layer = Layer.effect( }), settlement.error, ).pipe( + // Terminal cleanup must own failAssistant because the provider may still be streaming another tool input. Effect.andThen( settlement.error?.type === "permission.rejected" - ? serialized(publisher.failAssistant(settlement.error)).pipe( - Effect.andThen(Effect.fail(new UserInterruptedError())), - ) + ? Effect.fail(new StepFailedError({ error: settlement.error })) : Effect.void, ), ), @@ -464,14 +463,19 @@ const layer = Layer.effect( : settled.value.flatMap((exit) => (exit._tag === "Failure" ? [exit.cause] : [])) const toolsInterrupted = settledCauses.some(Cause.hasInterrupts) const userDeclined = settledCauses.some(isUserDeclined) - const permissionRejected = settledCauses.some( - (cause) => Option.getOrUndefined(Cause.findErrorOption(cause)) instanceof UserInterruptedError, - ) + const permissionRejected = settledCauses + .map((cause) => Option.getOrUndefined(Cause.findErrorOption(cause))) + .find( + (error): error is StepFailedError => + error instanceof StepFailedError && error.error.type === "permission.rejected", + ) if (userDeclined || permissionRejected || streamInterrupted || toolsInterrupted) { yield* FiberSet.clear(toolFibers) yield* serialized(publisher.failUnsettledTools({ type: "aborted", message: "Tool execution interrupted" })) - yield* serialized(publisher.failAssistant({ type: "aborted", message: "Step interrupted" })) + yield* serialized( + publisher.failAssistant(permissionRejected?.error ?? { type: "aborted", message: "Step interrupted" }), + ) } // A settled tool fiber failure is one of two things. A defect from a tool // implementation becomes a failed tool call the model can read, and the step still diff --git a/packages/core/src/session/runner/prompt/anthropic.txt b/packages/core/src/session/runner/prompt/anthropic.txt index 0fdf700d5f..1df6f4d693 100644 --- a/packages/core/src/session/runner/prompt/anthropic.txt +++ b/packages/core/src/session/runner/prompt/anthropic.txt @@ -20,58 +20,8 @@ When the user directly asks about OpenCode (eg. "can OpenCode do...", "does Open # Professional objectivity Prioritize technical accuracy and truthfulness over validating the user's beliefs. Focus on facts and problem-solving, providing direct, objective technical info without any unnecessary superlatives, praise, or emotional validation. It is best for the user if OpenCode honestly applies the same rigorous standards to all ideas and disagrees when necessary, even if it may not be what the user wants to hear. Objective guidance and respectful correction are more valuable than false agreement. Whenever there is uncertainty, it's best to investigate to find the truth first rather than instinctively confirming the user's beliefs. -# Task Management -You have access to the todowrite tool to help you manage and plan tasks. Use it VERY frequently to ensure that you are tracking your tasks and giving the user visibility into your progress. -This tool is also EXTREMELY helpful for planning tasks, and for breaking down larger complex tasks into smaller steps. If you do not use this tool when planning, you may forget to do important tasks - and that is unacceptable. - -It is critical that you mark todos as completed as soon as you are done with a task. Do not batch up multiple tasks before marking them as completed. - -Examples: - - -user: Run the build and fix any type errors -assistant: I'm going to use the todowrite tool to write the following items to the todo list: -- Run the build -- Fix any type errors - -I'm now going to run the build using the shell tool. - -Looks like I found 10 type errors. I'm going to use the todowrite tool to write 10 items to the todo list. - -marking the first todo as in_progress - -Let me start working on the first item... - -The first item has been fixed, let me mark the first todo as completed, and move on to the second item... -.. -.. - -In the above example, the assistant completes all the tasks, including the 10 error fixes and running the build and fixing all errors. - - -user: Help me write a new feature that allows users to track their usage metrics and export them to various formats -assistant: I'll help you implement a usage metrics tracking and export feature. Let me first use the todowrite tool to plan this task. -Adding the following todos to the todo list: -1. Research existing metrics tracking in the codebase -2. Design the metrics collection system -3. Implement core metrics tracking functionality -4. Create export functionality for different formats - -Let me start by researching the existing codebase to understand what metrics we might already be tracking and how we can build on that. - -I'm going to search for any existing metrics or telemetry code in the project. - -I've found some existing telemetry code. Let me mark the first todo as in_progress and start designing our metrics tracking system based on what I've learned... - -[Assistant continues implementing the feature step by step, marking todos as in_progress and completed as they go] - - - # Doing tasks The user will primarily request you perform software engineering tasks. This includes solving bugs, adding new functionality, refactoring code, explaining code, and more. For these tasks the following steps are recommended: -- -- Use the todowrite tool to plan the task if required - - Tool results and user messages may include tags. tags contain useful information and reminders. They are automatically added by the system, and bear no direct relation to the specific tool results or user messages in which they appear. @@ -93,8 +43,6 @@ user: What is the codebase structure? assistant: [Uses the subagent tool] -IMPORTANT: Always use the todowrite tool to plan and track tasks throughout the conversation. - # Code References When referencing specific functions or pieces of code include the pattern `file_path:line_number` to allow the user to easily navigate to the source code location. diff --git a/packages/core/src/session/runner/prompt/beast.txt b/packages/core/src/session/runner/prompt/beast.txt index e92e4d020f..a0a927a6d0 100644 --- a/packages/core/src/session/runner/prompt/beast.txt +++ b/packages/core/src/session/runner/prompt/beast.txt @@ -6,7 +6,7 @@ You MUST iterate and keep going until the problem is solved. You have everything you need to resolve this problem. I want you to fully solve this autonomously before coming back to me. -Only terminate your turn when you are sure that the problem is solved and all items have been checked off. Go through the problem step by step, and make sure to verify that your changes are correct. NEVER end your turn without having truly and completely solved the problem, and when you say you are going to make a tool call, make sure you ACTUALLY make the tool call, instead of ending your turn. +Only terminate your turn when you are sure that the problem is solved. Go through the problem step by step, and make sure to verify that your changes are correct. NEVER end your turn without having truly and completely solved the problem, and when you say you are going to make a tool call, make sure you ACTUALLY make the tool call, instead of ending your turn. THE PROBLEM CAN NOT BE SOLVED WITHOUT EXTENSIVE INTERNET RESEARCH. @@ -19,13 +19,13 @@ understanding of third party packages and dependencies is up to date. You must u Always tell the user what you are going to do before making a tool call with a single concise sentence. This will help them understand what you are doing and why. -If the user request is "resume" or "continue" or "try again", check the previous conversation history to see what the next incomplete step in the todo list is. Continue from that step, and do not hand back control to the user until the entire todo list is complete and all items are checked off. Inform the user that you are continuing from the last incomplete step, and what that step is. +If the user request is "resume" or "continue" or "try again", check the previous conversation history to identify the next incomplete step. Continue from that step, and do not hand back control to the user until the request is complete. Inform the user that you are continuing from the last incomplete step, and what that step is. Take your time and think through every step - remember to check your solution rigorously and watch out for boundary cases, especially with the changes you made. Use the sequential thinking tool if available. Your solution must be perfect. If not, continue working on it. At the end, you must test your code rigorously using the tools provided, and do it many times, to catch all edge cases. If it is not robust, iterate more and make it perfect. Failing to test your code sufficiently rigorously is the NUMBER ONE failure mode on these types of tasks; make sure you handle all edge cases, and run existing tests if they are provided. You MUST plan extensively before each function call, and reflect extensively on the outcomes of the previous function calls. DO NOT do this entire process by making function calls only, as this can impair your ability to solve the problem and think insightfully. -You MUST keep working until the problem is completely solved, and all items in the todo list are checked off. Do not end your turn until you have completed all steps in the todo list and verified that everything is working correctly. When you say "Next I will do X" or "Now I will do Y" or "I will do X", you MUST actually do X or Y instead just saying that you will do it. +You MUST keep working until the problem is completely solved. Do not end your turn until you have completed the necessary work and verified that everything is working correctly. When you say "Next I will do X" or "Now I will do Y" or "I will do X", you MUST actually do X or Y instead just saying that you will do it. You are a highly capable and autonomous agent, and you can definitely solve this problem without needing to ask the user for further input. @@ -39,7 +39,7 @@ You are a highly capable and autonomous agent, and you can definitely solve this - What are the dependencies and interactions with other parts of the code? 3. Investigate the codebase. Explore relevant files, search for key functions, and gather context. 4. Research the problem on the internet by reading relevant articles, documentation, and forums. -5. Develop a clear, step-by-step plan. Break down the fix into manageable, incremental steps. Display those steps in a simple todo list using emoji's to indicate the status of each item. +5. Develop a clear, step-by-step plan. Break down the fix into manageable, incremental steps. 6. Implement the fix incrementally. Make small, testable code changes. 7. Debug as needed. Use debugging techniques to isolate and resolve issues. 8. Test frequently. Run tests after each change to verify correctness. @@ -73,10 +73,7 @@ Carefully read the issue and think hard about a plan to solve it before coding. ## 5. Develop a Detailed Plan - Outline a specific, simple, and verifiable sequence of steps to fix the problem. -- Create a todo list in markdown format to track your progress. -- Each time you complete a step, check it off using `[x]` syntax. -- Each time you check off a step, display the updated todo list to the user. -- Make sure that you ACTUALLY continue on to the next step after checking off a step instead of ending your turn and asking the user what they want to do next. +- Continue through the planned steps instead of ending your turn and asking the user what they want to do next. ## 6. Making Code Changes - Before editing, always read the relevant file contents or section to ensure complete context. @@ -139,8 +136,6 @@ If you are asked to write a prompt, you should always generate the prompt in ma If you are not writing the prompt in a file, you should always wrap the prompt in triple backticks so that it is formatted correctly and can be easily copied from the chat. -Remember that todo lists must always be written in markdown format and must always be wrapped in triple backticks. - # Git If the user tells you to stage and commit, you may do so. diff --git a/packages/core/src/session/sql.ts b/packages/core/src/session/sql.ts index 6e09505e3c..f5e97aad76 100644 --- a/packages/core/src/session/sql.ts +++ b/packages/core/src/session/sql.ts @@ -3,7 +3,6 @@ import { sql } from "drizzle-orm" import { directoryColumn, pathColumn } from "../database/path" import { ProjectTable } from "../project/sql" import type { SessionMessage } from "./message" -import type { Prompt } from "@opencode-ai/schema/prompt" import type { SessionInput } from "./input" import type { FileDiff } from "@opencode-ai/schema/file-diff" import { PermissionV1 } from "../v1/permission" @@ -14,6 +13,7 @@ import { WorkspaceV2 } from "../workspace" import { Timestamps } from "../database/schema.sql" import type { Instructions } from "../instructions/index" import type { Session } from "@opencode-ai/schema/session" +import type { SyntheticData, UserData } from "@opencode-ai/schema/session-input" import type { RevertV1 } from "@opencode-ai/schema/session-revert" import type { Schema } from "effect" @@ -61,11 +61,15 @@ export const SessionTable = sqliteTable( ...Timestamps, time_compacting: integer(), time_archived: integer(), + time_suspended: integer(), }, (table) => [ index("session_project_idx").on(table.project_id), index("session_workspace_idx").on(table.workspace_id), index("session_parent_idx").on(table.parent_id), + index("session_time_suspended_idx") + .on(table.time_suspended) + .where(sql`${table.time_suspended} is not null`), ], ) @@ -101,25 +105,6 @@ export const PartTable = sqliteTable( ], ) -export const TodoTable = sqliteTable( - "todo", - { - session_id: text() - .$type() - .notNull() - .references(() => SessionTable.id, { onDelete: "cascade" }), - content: text().notNull(), - status: text().notNull(), - priority: text().notNull(), - position: integer().notNull(), - ...Timestamps, - }, - (table) => [ - primaryKey({ columns: [table.session_id, table.position] }), - index("todo_session_idx").on(table.session_id), - ], -) - export const SessionMessageTable = sqliteTable( "session_message", { @@ -150,7 +135,7 @@ export const SessionInputTable = sqliteTable( .notNull() .references(() => SessionTable.id, { onDelete: "cascade" }), type: text().$type().notNull(), - prompt: text({ mode: "json" }).$type(), + data: text({ mode: "json" }).$type>().notNull(), delivery: text().$type(), admitted_seq: integer().notNull(), promoted_seq: integer(), @@ -159,10 +144,9 @@ export const SessionInputTable = sqliteTable( .$default(() => Date.now()), }, (table) => [ - index("session_input_session_pending_type_delivery_seq_idx").on( + index("session_input_session_pending_delivery_seq_idx").on( table.session_id, table.promoted_seq, - table.type, table.delivery, table.admitted_seq, ), diff --git a/packages/core/src/session/store.ts b/packages/core/src/session/store.ts index 24c99cf515..516159c1e4 100644 --- a/packages/core/src/session/store.ts +++ b/packages/core/src/session/store.ts @@ -1,6 +1,6 @@ export * as SessionStore from "./store" -import { eq } from "drizzle-orm" +import { and, eq, inArray, isNotNull, isNull } from "drizzle-orm" import { Context, Effect, Layer, Schema } from "effect" import { Database } from "../database/database" import { makeGlobalNode } from "../effect/app-node" @@ -17,6 +17,10 @@ export interface Interface { readonly message: ( messageID: SessionMessage.ID, ) => Effect.Effect<{ readonly sessionID: Session.ID; readonly message: SessionMessage.Info } | undefined> + readonly listSuspended: () => Effect.Effect> + /** Clears suspension, reporting whether this caller consumed it. At most one concurrent caller receives true. */ + readonly consumeSuspended: (sessionID: Session.ID) => Effect.Effect + readonly suspend: (sessionIDs: Iterable) => Effect.Effect } export class Service extends Context.Service()("@opencode/v2/SessionStore") {} @@ -49,6 +53,39 @@ const layer = Layer.effect( } : undefined }), + listSuspended: Effect.fn("SessionStore.listSuspended")(function* () { + return yield* db + .select({ sessionID: SessionTable.id }) + .from(SessionTable) + .where(isNotNull(SessionTable.time_suspended)) + .all() + .pipe( + Effect.orDie, + Effect.map((rows) => rows.map((row) => row.sessionID)), + ) + }), + consumeSuspended: Effect.fn("SessionStore.consumeSuspended")(function* (sessionID) { + return ( + (yield* db + .update(SessionTable) + .set({ time_suspended: null }) + .where(and(eq(SessionTable.id, sessionID), isNotNull(SessionTable.time_suspended))) + .returning({ sessionID: SessionTable.id }) + .get() + .pipe(Effect.orDie)) !== undefined + ) + }), + suspend: Effect.fn("SessionStore.suspend")(function* (sessionIDs) { + const ids = Array.from(sessionIDs) + if (ids.length === 0) return + // The null guard preserves the original suspension time if a Session is somehow suspended twice. + yield* db + .update(SessionTable) + .set({ time_suspended: Date.now() }) + .where(and(inArray(SessionTable.id, ids), isNull(SessionTable.time_suspended))) + .run() + .pipe(Effect.orDie) + }), }) }), ) diff --git a/packages/core/src/session/todo.ts b/packages/core/src/session/todo.ts deleted file mode 100644 index 56c73042c4..0000000000 --- a/packages/core/src/session/todo.ts +++ /dev/null @@ -1,78 +0,0 @@ -export * as SessionTodo from "./todo" - -import { asc, eq } from "drizzle-orm" -import { Context, Effect, Layer } from "effect" -import { SessionTodo } from "@opencode-ai/schema/session-todo" -import { Database } from "../database/database" -import { makeLocationNode } from "../effect/app-node" -import { EventV2 } from "../event" -import { SessionSchema } from "./schema" -import { TodoTable } from "./sql" - -export const Info = SessionTodo.Info -export type Info = typeof Info.Type -export const Event = SessionTodo.Event - -export interface Interface { - readonly update: (input: { - readonly sessionID: SessionSchema.ID - readonly todos: ReadonlyArray - }) => Effect.Effect - readonly get: (sessionID: SessionSchema.ID) => Effect.Effect> -} - -export class Service extends Context.Service()("@opencode/v2/SessionTodo") {} - -const layer = Layer.effect( - Service, - Effect.gen(function* () { - const { db } = yield* Database.Service - const events = yield* EventV2.Service - - const update = Effect.fn("SessionTodo.update")(function* (input: { - readonly sessionID: SessionSchema.ID - readonly todos: ReadonlyArray - }) { - yield* db - .transaction((tx) => - Effect.gen(function* () { - yield* tx.delete(TodoTable).where(eq(TodoTable.session_id, input.sessionID)).run() - if (input.todos.length === 0) return - yield* tx - .insert(TodoTable) - .values( - input.todos.map((todo, position) => ({ - session_id: input.sessionID, - content: todo.content, - status: todo.status, - priority: todo.priority, - position, - })), - ) - .run() - }), - ) - .pipe(Effect.orDie) - yield* events.publish(Event.Updated, input) - }) - - const get = Effect.fn("SessionTodo.get")(function* (sessionID: SessionSchema.ID) { - const rows = yield* db - .select() - .from(TodoTable) - .where(eq(TodoTable.session_id, sessionID)) - .orderBy(asc(TodoTable.position)) - .all() - .pipe(Effect.orDie) - return rows.map((row) => ({ - content: row.content, - status: row.status, - priority: row.priority, - })) - }) - - return Service.of({ update, get }) - }), -) - -export const node = makeLocationNode({ service: Service, layer, deps: [EventV2.node, Database.node] }) diff --git a/packages/core/src/skill.ts b/packages/core/src/skill.ts index 13b93004e4..d325ceb599 100644 --- a/packages/core/src/skill.ts +++ b/packages/core/src/skill.ts @@ -83,6 +83,7 @@ const layer = Layer.effect( const events = yield* EventV2.Service const state = State.create({ + name: "skill", initial: () => ({ sources: [] }), draft: (draft) => ({ source: (source) => { diff --git a/packages/core/src/state.ts b/packages/core/src/state.ts index 93dcdb4e5d..88e9984d4a 100644 --- a/packages/core/src/state.ts +++ b/packages/core/src/state.ts @@ -53,6 +53,7 @@ export const inherit = Effect.fnUntraced(function* () { }) export interface Options { + readonly name?: string /** Creates the base value for initial state and every scoped-transform reload. */ readonly initial: () => State /** Wraps mutable state in a domain-specific draft API. */ @@ -89,7 +90,10 @@ export function create(options: Options): Inte const materialize = Effect.fnUntraced(function* () { const next = options.initial() const api = options.draft(next) - for (const transform of transforms) yield* apply(transform.run, api).pipe(Effect.withSpan("State.reload.update")) + for (const transform of transforms) + yield* apply(transform.run, api).pipe( + Effect.withSpan("State.reload.update", { attributes: { state: options.name ?? "anonymous" } }), + ) yield* commit(next) }) @@ -98,6 +102,7 @@ export function create(options: Options): Inte const result: Interface = { get: () => state, transform: Effect.fn("State.transform")(function* (update) { + yield* Effect.annotateCurrentSpan("state", options.name ?? "anonymous") const scope = yield* Scope.Scope return yield* Effect.uninterruptible( Effect.gen(function* () { diff --git a/packages/core/src/tool/question.ts b/packages/core/src/tool/question.ts index d5217f8505..646efd4b3b 100644 --- a/packages/core/src/tool/question.ts +++ b/packages/core/src/tool/question.ts @@ -81,6 +81,7 @@ export const Plugin = { forms .ask({ sessionID: context.sessionID, + title: "Questions", metadata: { kind: "question", tool: { messageID: context.assistantMessageID, callID: context.toolCallID }, diff --git a/packages/core/src/tool/subagent.ts b/packages/core/src/tool/subagent.ts index e6c994c216..b287d74234 100644 --- a/packages/core/src/tool/subagent.ts +++ b/packages/core/src/tool/subagent.ts @@ -152,7 +152,7 @@ export const Plugin = { const run = Effect.gen(function* () { // The child session owns its agent/model (set at create); prompt only admits input. - yield* runtime.session.prompt({ sessionID: child.id, prompt: { text: input.prompt }, resume: false }) + yield* runtime.session.prompt({ sessionID: child.id, text: input.prompt, resume: false }) yield* runtime.session.resume(child.id) return yield* latestAssistantText(child.id) }).pipe(Effect.onInterrupt(() => runtime.session.interrupt(child.id))) diff --git a/packages/core/src/tool/todowrite.ts b/packages/core/src/tool/todowrite.ts deleted file mode 100644 index 96c0f10f1a..0000000000 --- a/packages/core/src/tool/todowrite.ts +++ /dev/null @@ -1,57 +0,0 @@ -export * as TodoWriteTool from "./todowrite" - -import type { Context as PluginContext } from "@opencode-ai/plugin/v2/effect/plugin" -import { ToolFailure } from "@opencode-ai/llm" -import { Effect, Schema } from "effect" -import { PermissionV2 } from "../permission" -import { SessionTodo } from "../session/todo" -import { Tool } from "./tool" - -export const name = "todowrite" - -export const Input = Schema.Struct({ - todos: Schema.Array(SessionTodo.Info).annotate({ description: "The updated todo list" }), -}) - -export const Output = Schema.Struct({ - todos: Schema.Array(SessionTodo.Info), -}) -export type Output = typeof Output.Type - -export const toModelOutput = (output: Output) => JSON.stringify(output.todos, null, 2) - -export const Plugin = { - id: "opencode.tool.todowrite", - effect: Effect.fn("TodoWriteTool.Plugin")(function* (ctx: PluginContext) { - const todos = yield* SessionTodo.Service - const permission = yield* PermissionV2.Service - - yield* ctx.tool - .transform((draft) => - draft.add( - name, - Tool.make({ - description: - "Create and maintain a structured task list for the current coding session. Use it to track progress during multi-step work and keep todo statuses current.", - input: Input, - output: Output, - toModelOutput: ({ output }) => [{ type: "text", text: toModelOutput(output) }], - execute: (input, context) => - Effect.gen(function* () { - yield* permission.assert({ - action: name, - resources: ["*"], - save: ["*"], - sessionID: context.sessionID, - agent: context.agent, - source: { type: "tool", messageID: context.assistantMessageID, callID: context.toolCallID }, - }) - yield* todos.update({ sessionID: context.sessionID, todos: input.todos }) - return { todos: input.todos } - }).pipe(Effect.mapError((error) => new ToolFailure({ message: "Unable to update todos", error }))), - }), - ), - ) - .pipe(Effect.orDie) - }), -} diff --git a/packages/core/src/v1/config/permission.ts b/packages/core/src/v1/config/permission.ts index 475dc7bbf3..ef2da34e99 100644 --- a/packages/core/src/v1/config/permission.ts +++ b/packages/core/src/v1/config/permission.ts @@ -24,7 +24,6 @@ const InputObject = Schema.StructWithRest( bash: Schema.optional(Rule), task: Schema.optional(Rule), external_directory: Schema.optional(Rule), - todowrite: Schema.optional(Action), question: Schema.optional(Action), webfetch: Schema.optional(Action), websearch: Schema.optional(Action), diff --git a/packages/core/test/database-migration.test.ts b/packages/core/test/database-migration.test.ts index 10f02a7342..22a13a1c04 100644 --- a/packages/core/test/database-migration.test.ts +++ b/packages/core/test/database-migration.test.ts @@ -18,8 +18,10 @@ import simplifySessionInputMigration from "@opencode-ai/core/database/migration/ import resetSessionEventsMigration from "@opencode-ai/core/database/migration/20260703200000_reset_v2_session_events" import durableSessionInboxMigration from "@opencode-ai/core/database/migration/20260707010146_durable_session_inbox" import migratePrelaunchV2StateMigration from "@opencode-ai/core/database/migration/20260707120000_migrate_prelaunch_v2_state" +import genericSessionInputMigration from "@opencode-ai/core/database/migration/20260709013000_generic_session_input" import renameInstructionsMigration from "@opencode-ai/core/database/migration/20260705180000_rename_instructions" import addSessionForkMigration from "@opencode-ai/core/database/migration/20260706223930_add-session-fork" +import timeSuspendedMigration from "@opencode-ai/core/database/migration/20260709163752_time_suspended" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { EventV2 } from "@opencode-ai/core/event" @@ -369,7 +371,7 @@ describe("DatabaseMigration", () => { { name: "event_aggregate_type_seq_idx" }, { name: "session_input_session_admitted_seq_idx" }, { name: "session_input_session_pending_compaction_idx" }, - { name: "session_input_session_pending_type_delivery_seq_idx" }, + { name: "session_input_session_pending_delivery_seq_idx" }, { name: "session_input_session_promoted_seq_idx" }, { name: "session_message_session_seq_idx" }, { name: "session_message_session_time_created_id_idx" }, @@ -444,6 +446,28 @@ describe("DatabaseMigration", () => { ) }) + test("does not infer restart continuation from historical shutdown events", async () => { + await run( + Effect.gen(function* () { + const db = yield* makeDb + yield* db.run(sql`CREATE TABLE session (id text PRIMARY KEY)`) + yield* db.run( + sql`CREATE TABLE event (aggregate_id text NOT NULL, seq integer NOT NULL, type text NOT NULL, data text NOT NULL)`, + ) + yield* db.run(sql`INSERT INTO session VALUES ('ses_shutdown')`) + yield* db.run( + sql`INSERT INTO event VALUES ('ses_shutdown', 0, 'session.execution.interrupted.1', '{"reason":"shutdown"}')`, + ) + + yield* DatabaseMigration.applyOnly(db, [timeSuspendedMigration]) + + expect(yield* db.get(sql`SELECT time_suspended FROM session WHERE id = 'ses_shutdown'`)).toEqual({ + time_suspended: null, + }) + }), + ) + }) + test("renames instruction state without losing rows or durable updates", async () => { await run( Effect.gen(function* () { @@ -607,7 +631,7 @@ describe("DatabaseMigration", () => { sql`INSERT INTO event (id, aggregate_id, seq, type, data, created) VALUES ('event', 'session', 9, 'session.updated.1', '{}', 1)`, ) yield* db.run( - sql`INSERT INTO session_input (id, session_id, type, prompt, delivery, admitted_seq, time_created) VALUES ('input', 'session', 'prompt', '{}', 'steer', 9, 1)`, + sql`INSERT INTO session_input (id, session_id, type, data, delivery, admitted_seq, time_created) VALUES ('input', 'session', 'user', '{}', 'steer', 9, 1)`, ) yield* db.run( sql`INSERT INTO session_message (id, session_id, type, seq, time_created, time_updated, data) VALUES ('projected', 'session', 'user', 9, 1, 1, '{}')`, @@ -701,6 +725,56 @@ describe("DatabaseMigration", () => { ) }) + test("migrates prompt inbox rows and lifecycle events to generic user input", async () => { + await run( + Effect.gen(function* () { + const db = yield* makeDb + yield* db.run(sql`CREATE TABLE session (id text PRIMARY KEY)`) + yield* db.run(sql`INSERT INTO session (id) VALUES ('session')`) + yield* db.run( + sql`CREATE TABLE session_input (id text PRIMARY KEY, session_id text NOT NULL, type text NOT NULL, prompt text, delivery text, admitted_seq integer NOT NULL, promoted_seq integer, time_created integer NOT NULL)`, + ) + yield* db.run( + sql`INSERT INTO session_input (id, session_id, type, prompt, delivery, admitted_seq, promoted_seq, time_created) VALUES ('input', 'session', 'prompt', '{"text":"hello"}', 'queue', 4, NULL, 1)`, + ) + yield* db.run( + sql`INSERT INTO session_input (id, session_id, type, prompt, delivery, admitted_seq, promoted_seq, time_created) VALUES ('empty', 'session', 'prompt', NULL, 'steer', 6, NULL, 2)`, + ) + yield* db.run( + sql`CREATE TABLE event (id text PRIMARY KEY, aggregate_id text NOT NULL, seq integer NOT NULL, created integer NOT NULL, type text NOT NULL, data text NOT NULL)`, + ) + yield* db.run( + sql`INSERT INTO event (id, aggregate_id, seq, created, type, data) VALUES ('admitted', 'session', 4, 1, 'session.prompt.admitted.1', '{"sessionID":"session","inputID":"input","prompt":{"text":"hello"},"delivery":"queue"}')`, + ) + yield* db.run( + sql`INSERT INTO event (id, aggregate_id, seq, created, type, data) VALUES ('promoted', 'session', 5, 2, 'session.prompt.promoted.1', '{"sessionID":"session","inputID":"input"}')`, + ) + yield* db.run( + sql`INSERT INTO event (id, aggregate_id, seq, created, type, data) VALUES ('empty-admitted', 'session', 6, 2, 'session.prompt.admitted.1', '{"sessionID":"session","inputID":"empty","prompt":null,"delivery":"steer"}')`, + ) + yield* db.run( + sql`INSERT INTO event (id, aggregate_id, seq, created, type, data) VALUES ('empty-promoted', 'session', 7, 2, 'session.prompt.promoted.1', '{"sessionID":"session","inputID":"empty"}')`, + ) + + yield* DatabaseMigration.applyOnly(db, [genericSessionInputMigration]) + + expect(yield* db.all(sql`SELECT id, type, data, delivery FROM session_input ORDER BY admitted_seq`)).toEqual([ + { id: "input", type: "user", data: '{"text":"hello"}', delivery: "queue" }, + ]) + expect(yield* db.all(sql`SELECT type, data FROM event ORDER BY seq`)).toEqual([ + { + type: "session.input.admitted.1", + data: '{"sessionID":"session","inputID":"input","input":{"type":"user","data":{"text":"hello"},"delivery":"queue"}}', + }, + { + type: "session.input.promoted.1", + data: '{"sessionID":"session","inputID":"input"}', + }, + ]) + }), + ) + }) + test("resets incompatible projected Session messages before adding sequence order", async () => { await run( Effect.gen(function* () { diff --git a/packages/core/test/fixtures/recordings/session-runner/openai-chat-streams-text.json b/packages/core/test/fixtures/recordings/session-runner/openai-chat-streams-text.json index ff501af21f..90154112d2 100644 --- a/packages/core/test/fixtures/recordings/session-runner/openai-chat-streams-text.json +++ b/packages/core/test/fixtures/recordings/session-runner/openai-chat-streams-text.json @@ -13,7 +13,7 @@ "headers": { "content-type": "application/json" }, - "body": "{\"model\":\"gpt-4o-mini\",\"messages\":[{\"role\":\"system\",\"content\":\"You are opencode, an agent - please keep going until the user’s query is completely resolved, before ending your turn and yielding back to the user.\\n\\nYour thinking should be thorough and so it's fine if it's very long. However, avoid unnecessary repetition and verbosity. You should be concise, but thorough.\\n\\nYou MUST iterate and keep going until the problem is solved.\\n\\nYou have everything you need to resolve this problem. I want you to fully solve this autonomously before coming back to me.\\n\\nOnly terminate your turn when you are sure that the problem is solved and all items have been checked off. Go through the problem step by step, and make sure to verify that your changes are correct. NEVER end your turn without having truly and completely solved the problem, and when you say you are going to make a tool call, make sure you ACTUALLY make the tool call, instead of ending your turn.\\n\\nTHE PROBLEM CAN NOT BE SOLVED WITHOUT EXTENSIVE INTERNET RESEARCH.\\n\\nYou must use the webfetch tool to recursively gather all information from URL's provided to you by the user, as well as any links you find in the content of those pages.\\n\\nYour knowledge on everything is out of date because your training date is in the past. \\n\\nYou CANNOT successfully complete this task without using Google to verify your\\nunderstanding of third party packages and dependencies is up to date. You must use the webfetch tool to search google for how to properly use libraries, packages, frameworks, dependencies, etc. every single time you install or implement one. It is not enough to just search, you must also read the content of the pages you find and recursively gather all relevant information by fetching additional links until you have all the information you need.\\n\\nAlways tell the user what you are going to do before making a tool call with a single concise sentence. This will help them understand what you are doing and why.\\n\\nIf the user request is \\\"resume\\\" or \\\"continue\\\" or \\\"try again\\\", check the previous conversation history to see what the next incomplete step in the todo list is. Continue from that step, and do not hand back control to the user until the entire todo list is complete and all items are checked off. Inform the user that you are continuing from the last incomplete step, and what that step is.\\n\\nTake your time and think through every step - remember to check your solution rigorously and watch out for boundary cases, especially with the changes you made. Use the sequential thinking tool if available. Your solution must be perfect. If not, continue working on it. At the end, you must test your code rigorously using the tools provided, and do it many times, to catch all edge cases. If it is not robust, iterate more and make it perfect. Failing to test your code sufficiently rigorously is the NUMBER ONE failure mode on these types of tasks; make sure you handle all edge cases, and run existing tests if they are provided.\\n\\nYou MUST plan extensively before each function call, and reflect extensively on the outcomes of the previous function calls. DO NOT do this entire process by making function calls only, as this can impair your ability to solve the problem and think insightfully.\\n\\nYou MUST keep working until the problem is completely solved, and all items in the todo list are checked off. Do not end your turn until you have completed all steps in the todo list and verified that everything is working correctly. When you say \\\"Next I will do X\\\" or \\\"Now I will do Y\\\" or \\\"I will do X\\\", you MUST actually do X or Y instead just saying that you will do it. \\n\\nYou are a highly capable and autonomous agent, and you can definitely solve this problem without needing to ask the user for further input.\\n\\n# Workflow\\n1. Fetch any URL's provided by the user using the `webfetch` tool.\\n2. Understand the problem deeply. Carefully read the issue and think critically about what is required. Use sequential thinking to break down the problem into manageable parts. Consider the following:\\n - What is the expected behavior?\\n - What are the edge cases?\\n - What are the potential pitfalls?\\n - How does this fit into the larger context of the codebase?\\n - What are the dependencies and interactions with other parts of the code?\\n3. Investigate the codebase. Explore relevant files, search for key functions, and gather context.\\n4. Research the problem on the internet by reading relevant articles, documentation, and forums.\\n5. Develop a clear, step-by-step plan. Break down the fix into manageable, incremental steps. Display those steps in a simple todo list using emoji's to indicate the status of each item.\\n6. Implement the fix incrementally. Make small, testable code changes.\\n7. Debug as needed. Use debugging techniques to isolate and resolve issues.\\n8. Test frequently. Run tests after each change to verify correctness.\\n9. Iterate until the root cause is fixed and all tests pass.\\n10. Reflect and validate comprehensively. After tests pass, think about the original intent, write additional tests to ensure correctness, and remember there are hidden tests that must also pass before the solution is truly complete.\\n\\nRefer to the detailed sections below for more information on each step.\\n\\n## 1. Fetch Provided URLs\\n- If the user provides a URL, use the `webfetch` tool to retrieve the content of the provided URL.\\n- After fetching, review the content returned by the webfetch tool.\\n- If you find any additional URLs or links that are relevant, use the `webfetch` tool again to retrieve those links.\\n- Recursively gather all relevant information by fetching additional links until you have all the information you need.\\n\\n## 2. Deeply Understand the Problem\\nCarefully read the issue and think hard about a plan to solve it before coding.\\n\\n## 3. Codebase Investigation\\n- Explore relevant files and directories.\\n- Search for key functions, classes, or variables related to the issue.\\n- Read and understand relevant code snippets.\\n- Identify the root cause of the problem.\\n- Validate and update your understanding continuously as you gather more context.\\n\\n## 4. Internet Research\\n- Use the `webfetch` tool to search google by fetching the URL `https://www.google.com/search?q=your+search+query`.\\n- After fetching, review the content returned by the fetch tool.\\n- You MUST fetch the contents of the most relevant links to gather information. Do not rely on the summary that you find in the search results.\\n- As you fetch each link, read the content thoroughly and fetch any additional links that you find within the content that are relevant to the problem.\\n- Recursively gather all relevant information by fetching links until you have all the information you need.\\n\\n## 5. Develop a Detailed Plan \\n- Outline a specific, simple, and verifiable sequence of steps to fix the problem.\\n- Create a todo list in markdown format to track your progress.\\n- Each time you complete a step, check it off using `[x]` syntax.\\n- Each time you check off a step, display the updated todo list to the user.\\n- Make sure that you ACTUALLY continue on to the next step after checking off a step instead of ending your turn and asking the user what they want to do next.\\n\\n## 6. Making Code Changes\\n- Before editing, always read the relevant file contents or section to ensure complete context.\\n- Always read 2000 lines of code at a time to ensure you have enough context.\\n- If a patch is not applied correctly, attempt to reapply it.\\n- Make small, testable, incremental changes that logically follow from your investigation and plan.\\n- Whenever you detect that a project requires an environment variable (such as an API key or secret), always check if a .env file exists in the project root. If it does not exist, automatically create a .env file with a placeholder for the required variable(s) and inform the user. Do this proactively, without waiting for the user to request it.\\n\\n## 7. Debugging\\n- Make code changes only if you have high confidence they can solve the problem\\n- When debugging, try to determine the root cause rather than addressing symptoms\\n- Debug for as long as needed to identify the root cause and identify a fix\\n- Use print statements, logs, or temporary code to inspect program state, including descriptive statements or error messages to understand what's happening\\n- To test hypotheses, you can also add test statements or functions\\n- Revisit your assumptions if unexpected behavior occurs.\\n\\n\\n# Communication Guidelines\\nAlways communicate clearly and concisely in a casual, friendly yet professional tone. \\n\\n\\\"Let me fetch the URL you provided to gather more information.\\\"\\n\\\"Ok, I've got all of the information I need on the LIFX API and I know how to use it.\\\"\\n\\\"Now, I will search the codebase for the function that handles the LIFX API requests.\\\"\\n\\\"I need to update several files here - stand by\\\"\\n\\\"OK! Now let's run the tests to make sure everything is working correctly.\\\"\\n\\\"Whelp - I see we have some problems. Let's fix those up.\\\"\\n\\n\\n- Respond with clear, direct answers. Use bullet points and code blocks for structure. - Avoid unnecessary explanations, repetition, and filler. \\n- Always write code directly to the correct files.\\n- Do not display code to the user unless they specifically ask for it.\\n- Only elaborate when clarification is essential for accuracy or user understanding.\\n\\n# Memory\\nYou have a memory that stores information about the user and their preferences. This memory is used to provide a more personalized experience. You can access and update this memory as needed. The memory is stored in a file called `.github/instructions/memory.instruction.md`. If the file is empty, you'll need to create it. \\n\\nWhen creating a new memory file, you MUST include the following front matter at the top of the file:\\n```yaml\\n---\\napplyTo: '**'\\n---\\n```\\n\\nIf the user asks you to remember something or add something to your memory, you can do so by updating the memory file.\\n\\n# Reading Files and Folders\\n\\n**Always check if you have already read a file, folder, or workspace structure before reading it again.**\\n\\n- If you have already read the content and it has not changed, do NOT re-read it.\\n- Only re-read files or folders if:\\n - You suspect the content has changed since your last read.\\n - You have made edits to the file or folder.\\n - You encounter an error that suggests the context may be stale or incomplete.\\n- Use your internal memory and previous context to avoid redundant reads.\\n- This will save time, reduce unnecessary operations, and make your workflow more efficient.\\n\\n# Writing Prompts\\nIf you are asked to write a prompt, you should always generate the prompt in markdown format.\\n\\nIf you are not writing the prompt in a file, you should always wrap the prompt in triple backticks so that it is formatted correctly and can be easily copied from the chat.\\n\\nRemember that todo lists must always be written in markdown format and must always be wrapped in triple backticks.\\n\\n# Git \\nIf the user tells you to stage and commit, you may do so. \\n\\nYou are NEVER allowed to stage and commit files automatically.\\n\"},{\"role\":\"user\",\"content\":\"Say hello in one short sentence.\"}],\"stream\":true,\"stream_options\":{\"include_usage\":true},\"max_tokens\":20,\"temperature\":0}" + "body": "{\"model\":\"gpt-4o-mini\",\"messages\":[{\"role\":\"system\",\"content\":\"You are opencode, an agent - please keep going until the user’s query is completely resolved, before ending your turn and yielding back to the user.\\n\\nYour thinking should be thorough and so it's fine if it's very long. However, avoid unnecessary repetition and verbosity. You should be concise, but thorough.\\n\\nYou MUST iterate and keep going until the problem is solved.\\n\\nYou have everything you need to resolve this problem. I want you to fully solve this autonomously before coming back to me.\\n\\nOnly terminate your turn when you are sure that the problem is solved. Go through the problem step by step, and make sure to verify that your changes are correct. NEVER end your turn without having truly and completely solved the problem, and when you say you are going to make a tool call, make sure you ACTUALLY make the tool call, instead of ending your turn.\\n\\nTHE PROBLEM CAN NOT BE SOLVED WITHOUT EXTENSIVE INTERNET RESEARCH.\\n\\nYou must use the webfetch tool to recursively gather all information from URL's provided to you by the user, as well as any links you find in the content of those pages.\\n\\nYour knowledge on everything is out of date because your training date is in the past. \\n\\nYou CANNOT successfully complete this task without using Google to verify your\\nunderstanding of third party packages and dependencies is up to date. You must use the webfetch tool to search google for how to properly use libraries, packages, frameworks, dependencies, etc. every single time you install or implement one. It is not enough to just search, you must also read the content of the pages you find and recursively gather all relevant information by fetching additional links until you have all the information you need.\\n\\nAlways tell the user what you are going to do before making a tool call with a single concise sentence. This will help them understand what you are doing and why.\\n\\nIf the user request is \\\"resume\\\" or \\\"continue\\\" or \\\"try again\\\", check the previous conversation history to identify the next incomplete step. Continue from that step, and do not hand back control to the user until the request is complete. Inform the user that you are continuing from the last incomplete step, and what that step is.\\n\\nTake your time and think through every step - remember to check your solution rigorously and watch out for boundary cases, especially with the changes you made. Use the sequential thinking tool if available. Your solution must be perfect. If not, continue working on it. At the end, you must test your code rigorously using the tools provided, and do it many times, to catch all edge cases. If it is not robust, iterate more and make it perfect. Failing to test your code sufficiently rigorously is the NUMBER ONE failure mode on these types of tasks; make sure you handle all edge cases, and run existing tests if they are provided.\\n\\nYou MUST plan extensively before each function call, and reflect extensively on the outcomes of the previous function calls. DO NOT do this entire process by making function calls only, as this can impair your ability to solve the problem and think insightfully.\\n\\nYou MUST keep working until the problem is completely solved. Do not end your turn until you have completed the necessary work and verified that everything is working correctly. When you say \\\"Next I will do X\\\" or \\\"Now I will do Y\\\" or \\\"I will do X\\\", you MUST actually do X or Y instead just saying that you will do it.\\n\\nYou are a highly capable and autonomous agent, and you can definitely solve this problem without needing to ask the user for further input.\\n\\n# Workflow\\n1. Fetch any URL's provided by the user using the `webfetch` tool.\\n2. Understand the problem deeply. Carefully read the issue and think critically about what is required. Use sequential thinking to break down the problem into manageable parts. Consider the following:\\n - What is the expected behavior?\\n - What are the edge cases?\\n - What are the potential pitfalls?\\n - How does this fit into the larger context of the codebase?\\n - What are the dependencies and interactions with other parts of the code?\\n3. Investigate the codebase. Explore relevant files, search for key functions, and gather context.\\n4. Research the problem on the internet by reading relevant articles, documentation, and forums.\\n5. Develop a clear, step-by-step plan. Break down the fix into manageable, incremental steps.\\n6. Implement the fix incrementally. Make small, testable code changes.\\n7. Debug as needed. Use debugging techniques to isolate and resolve issues.\\n8. Test frequently. Run tests after each change to verify correctness.\\n9. Iterate until the root cause is fixed and all tests pass.\\n10. Reflect and validate comprehensively. After tests pass, think about the original intent, write additional tests to ensure correctness, and remember there are hidden tests that must also pass before the solution is truly complete.\\n\\nRefer to the detailed sections below for more information on each step.\\n\\n## 1. Fetch Provided URLs\\n- If the user provides a URL, use the `webfetch` tool to retrieve the content of the provided URL.\\n- After fetching, review the content returned by the webfetch tool.\\n- If you find any additional URLs or links that are relevant, use the `webfetch` tool again to retrieve those links.\\n- Recursively gather all relevant information by fetching additional links until you have all the information you need.\\n\\n## 2. Deeply Understand the Problem\\nCarefully read the issue and think hard about a plan to solve it before coding.\\n\\n## 3. Codebase Investigation\\n- Explore relevant files and directories.\\n- Search for key functions, classes, or variables related to the issue.\\n- Read and understand relevant code snippets.\\n- Identify the root cause of the problem.\\n- Validate and update your understanding continuously as you gather more context.\\n\\n## 4. Internet Research\\n- Use the `webfetch` tool to search google by fetching the URL `https://www.google.com/search?q=your+search+query`.\\n- After fetching, review the content returned by the fetch tool.\\n- You MUST fetch the contents of the most relevant links to gather information. Do not rely on the summary that you find in the search results.\\n- As you fetch each link, read the content thoroughly and fetch any additional links that you find within the content that are relevant to the problem.\\n- Recursively gather all relevant information by fetching links until you have all the information you need.\\n\\n## 5. Develop a Detailed Plan \\n- Outline a specific, simple, and verifiable sequence of steps to fix the problem.\\n- Continue through the planned steps instead of ending your turn and asking the user what they want to do next.\\n\\n## 6. Making Code Changes\\n- Before editing, always read the relevant file contents or section to ensure complete context.\\n- Always read 2000 lines of code at a time to ensure you have enough context.\\n- If a patch is not applied correctly, attempt to reapply it.\\n- Make small, testable, incremental changes that logically follow from your investigation and plan.\\n- Whenever you detect that a project requires an environment variable (such as an API key or secret), always check if a .env file exists in the project root. If it does not exist, automatically create a .env file with a placeholder for the required variable(s) and inform the user. Do this proactively, without waiting for the user to request it.\\n\\n## 7. Debugging\\n- Make code changes only if you have high confidence they can solve the problem\\n- When debugging, try to determine the root cause rather than addressing symptoms\\n- Debug for as long as needed to identify the root cause and identify a fix\\n- Use print statements, logs, or temporary code to inspect program state, including descriptive statements or error messages to understand what's happening\\n- To test hypotheses, you can also add test statements or functions\\n- Revisit your assumptions if unexpected behavior occurs.\\n\\n\\n# Communication Guidelines\\nAlways communicate clearly and concisely in a casual, friendly yet professional tone. \\n\\n\\\"Let me fetch the URL you provided to gather more information.\\\"\\n\\\"Ok, I've got all of the information I need on the LIFX API and I know how to use it.\\\"\\n\\\"Now, I will search the codebase for the function that handles the LIFX API requests.\\\"\\n\\\"I need to update several files here - stand by\\\"\\n\\\"OK! Now let's run the tests to make sure everything is working correctly.\\\"\\n\\\"Whelp - I see we have some problems. Let's fix those up.\\\"\\n\\n\\n- Respond with clear, direct answers. Use bullet points and code blocks for structure. - Avoid unnecessary explanations, repetition, and filler. \\n- Always write code directly to the correct files.\\n- Do not display code to the user unless they specifically ask for it.\\n- Only elaborate when clarification is essential for accuracy or user understanding.\\n\\n# Memory\\nYou have a memory that stores information about the user and their preferences. This memory is used to provide a more personalized experience. You can access and update this memory as needed. The memory is stored in a file called `.github/instructions/memory.instruction.md`. If the file is empty, you'll need to create it. \\n\\nWhen creating a new memory file, you MUST include the following front matter at the top of the file:\\n```yaml\\n---\\napplyTo: '**'\\n---\\n```\\n\\nIf the user asks you to remember something or add something to your memory, you can do so by updating the memory file.\\n\\n# Reading Files and Folders\\n\\n**Always check if you have already read a file, folder, or workspace structure before reading it again.**\\n\\n- If you have already read the content and it has not changed, do NOT re-read it.\\n- Only re-read files or folders if:\\n - You suspect the content has changed since your last read.\\n - You have made edits to the file or folder.\\n - You encounter an error that suggests the context may be stale or incomplete.\\n- Use your internal memory and previous context to avoid redundant reads.\\n- This will save time, reduce unnecessary operations, and make your workflow more efficient.\\n\\n# Writing Prompts\\nIf you are asked to write a prompt, you should always generate the prompt in markdown format.\\n\\nIf you are not writing the prompt in a file, you should always wrap the prompt in triple backticks so that it is formatted correctly and can be easily copied from the chat.\\n\\n# Git \\nIf the user tells you to stage and commit, you may do so. \\n\\nYou are NEVER allowed to stage and commit files automatically.\\n\"},{\"role\":\"user\",\"content\":\"Say hello in one short sentence.\"}],\"stream\":true,\"stream_options\":{\"include_usage\":true},\"max_tokens\":20,\"temperature\":0}" }, "response": { "status": 200, diff --git a/packages/core/test/form.test.ts b/packages/core/test/form.test.ts index 8defff611f..f5cf16e159 100644 --- a/packages/core/test/form.test.ts +++ b/packages/core/test/form.test.ts @@ -14,6 +14,7 @@ const formID = Form.ID.create("frm_test") const input = { id: formID, sessionID: SessionSchema.ID.make("ses_test"), + title: "Test form", mode: "form", fields: [{ key: "name", type: "string", required: true }], } satisfies Form.CreateInput @@ -45,10 +46,12 @@ describe("Form", () => { const service = yield* Form.Service const created = yield* service.create({ sessionID: "global", + title: "MCP input", mode: "form", fields: [{ key: "name", type: "string", required: true }], }) expect(created.sessionID).toBe("global") + expect(created.title).toBe("MCP input") const owned = yield* service.list({ sessionID: "global" }) expect(owned.map((form) => form.id)).toEqual([created.id]) @@ -64,6 +67,7 @@ describe("Form", () => { const service = yield* Form.Service const created = yield* service.create({ sessionID: "global", + title: "Conditional form", mode: "form", fields: [ { key: "confirm", type: "boolean", required: true }, @@ -96,6 +100,7 @@ describe("Form", () => { ] const created = yield* service.create({ sessionID: "global", + title: "Multiselect form", mode: "form", fields: [ { key: "langs", type: "multiselect", options }, @@ -118,6 +123,7 @@ describe("Form", () => { const service = yield* Form.Service const created = yield* service.create({ sessionID: "global", + title: "Dependent form", mode: "form", fields: [ { key: "a", type: "boolean" }, @@ -160,6 +166,7 @@ describe("Form", () => { ] const created = yield* service.create({ sessionID: "global", + title: "Selection form", mode: "form", fields: [ { key: "langs", type: "multiselect", options }, @@ -191,6 +198,7 @@ describe("Form", () => { const service = yield* Form.Service const created = yield* service.create({ sessionID: "global", + title: "Cascading form", mode: "form", fields: [ { key: "a", type: "boolean" }, @@ -213,7 +221,7 @@ describe("Form", () => { Effect.gen(function* () { const service = yield* Form.Service const flipCreate = (fields: ReadonlyArray) => - service.create({ sessionID: "global", mode: "form", fields }).pipe(Effect.flip) + service.create({ sessionID: "global", title: "Invalid form", mode: "form", fields }).pipe(Effect.flip) expect( yield* flipCreate([ diff --git a/packages/core/test/integration.test.ts b/packages/core/test/integration.test.ts index 376d2f9523..dc20303f00 100644 --- a/packages/core/test/integration.test.ts +++ b/packages/core/test/integration.test.ts @@ -1,14 +1,33 @@ import { describe, expect } from "bun:test" -import { Duration, Effect, Exit, Fiber, Scope, Stream } from "effect" +import { Cause, Duration, Effect, Exit, Fiber, Layer, Scope, Stream } from "effect" import * as TestClock from "effect/testing/TestClock" import { Credential } from "@opencode-ai/core/credential" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { makeGlobalNode } from "@opencode-ai/core/effect/app-node" import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { EventV2 } from "@opencode-ai/core/event" import { Integration } from "@opencode-ai/core/integration" import { testEffect } from "./lib/effect" const it = testEffect(AppNodeBuilder.build(LayerNode.group([Integration.node, Credential.node, EventV2.node]))) +const failingCredentialNode = makeGlobalNode({ + service: Credential.Service, + layer: Layer.succeed( + Credential.Service, + Credential.Service.of({ + all: () => Effect.succeed([]), + list: () => Effect.succeed([]), + get: () => Effect.succeed(undefined), + create: () => Effect.die(new Error("credential persistence failed")), + update: () => Effect.void, + remove: () => Effect.void, + }), + ), + deps: [], +}) +const failingIt = testEffect( + AppNodeBuilder.build(LayerNode.group([Integration.node, EventV2.node]), [[Credential.node, failingCredentialNode]]), +) describe("Integration", () => { it.effect("registers integrations through the editor", () => @@ -254,6 +273,47 @@ describe("Integration", () => { }), ) + failingIt.effect("fails the attempt when credential persistence fails", () => + Effect.gen(function* () { + const integrations = yield* Integration.Service + const integrationID = Integration.ID.make("openai") + const methodID = Integration.MethodID.make("chatgpt") + yield* integrations.transform((editor) => + editor.method.update({ + integrationID, + method: { id: methodID, type: "oauth", label: "ChatGPT" }, + authorize: () => + Effect.succeed({ + mode: "code" as const, + url: "https://example.com/authorize", + instructions: "Paste the code", + callback: () => + Effect.succeed( + Credential.OAuth.make({ + type: "oauth", + methodID, + access: "access", + refresh: "refresh", + expires: 1, + }), + ), + }), + }), + ) + + const attempt = yield* integrations.connection.oauth({ integrationID, methodID, inputs: {} }) + const exit = yield* integrations.attempt + .complete({ attemptID: attempt.attemptID, code: "1234" }) + .pipe(Effect.exit) + expect(Exit.isFailure(exit) && Cause.hasDies(exit.cause)).toBe(true) + expect(yield* integrations.attempt.status(attempt.attemptID)).toEqual({ + status: "failed", + message: "credential persistence failed", + time: attempt.time, + }) + }), + ) + it.effect("expires abandoned OAuth attempts", () => Effect.gen(function* () { const integrations = yield* Integration.Service diff --git a/packages/core/test/location-layer.test.ts b/packages/core/test/location-layer.test.ts index 60e788f7b1..34288c042c 100644 --- a/packages/core/test/location-layer.test.ts +++ b/packages/core/test/location-layer.test.ts @@ -526,7 +526,6 @@ describe("LocationServiceMap", () => { "shell", "skill", "subagent", - "todowrite", "webfetch", "websearch", "write", @@ -559,7 +558,6 @@ describe("LocationServiceMap", () => { "shell", "skill", "subagent", - "todowrite", "webfetch", "websearch", "write", @@ -577,7 +575,6 @@ describe("LocationServiceMap", () => { "shell", "skill", "subagent", - "todowrite", "webfetch", "websearch", "write", diff --git a/packages/core/test/models.test.ts b/packages/core/test/models.test.ts index 9aa1cedc4d..f3d32c04db 100644 --- a/packages/core/test/models.test.ts +++ b/packages/core/test/models.test.ts @@ -1,12 +1,15 @@ import { describe, expect, beforeAll, beforeEach, afterAll } from "bun:test" -import { Effect, Layer, Ref, Schema } from "effect" +import { Money } from "@opencode-ai/schema/money" +import { Effect, Layer, Ref } from "effect" import { HttpClient, HttpClientResponse } from "effect/unstable/http" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { LayerNodePlatform } from "@opencode-ai/core/effect/app-node-platform" import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { Flag } from "@opencode-ai/core/flag/flag" import { Global } from "@opencode-ai/core/global" +import { ModelV2 } from "@opencode-ai/core/model" import { ModelsDev } from "@opencode-ai/core/models-dev" +import { ProviderV2 } from "@opencode-ai/core/provider" import { it } from "./lib/effect" import { readFile, rm, writeFile, utimes, mkdir } from "fs/promises" import path from "path" @@ -29,7 +32,7 @@ afterAll(() => { const cacheFile = path.join(Global.Path.cache, "models.json") -const fixture: Record = { +const fixture = { acme: { id: "acme", name: "Acme", @@ -49,7 +52,47 @@ const fixture: Record = { }, } -const fixture2: Record = { +const fixtureSnapshot = [ + { + info: { + id: ProviderV2.ID.make("acme"), + name: "Acme", + package: "", + }, + models: [ + { + id: ModelV2.ID.make("acme-1"), + modelID: ModelV2.ID.make("acme-1"), + providerID: ProviderV2.ID.make("acme"), + name: "Acme One", + family: undefined, + package: undefined, + settings: undefined, + capabilities: { tools: true, input: [], output: [] }, + variants: [], + time: { released: Date.parse("2026-01-01") }, + cost: [ + { + input: Money.USDPerMillionTokens.zero, + output: Money.USDPerMillionTokens.zero, + cache: { + read: Money.USDPerMillionTokens.zero, + write: Money.USDPerMillionTokens.zero, + }, + }, + ], + status: "active", + enabled: true, + limit: { context: 128000, input: undefined, output: 8192 }, + headers: undefined, + body: undefined, + }, + ], + environment: ["ACME_API_KEY"], + }, +] satisfies readonly ModelsDev.Snapshot[] + +const fixture2 = { beta: { id: "beta", name: "Beta", @@ -69,6 +112,46 @@ const fixture2: Record = { }, } +const fixture2Snapshot = [ + { + info: { + id: ProviderV2.ID.make("beta"), + name: "Beta", + package: "", + }, + models: [ + { + id: ModelV2.ID.make("beta-1"), + modelID: ModelV2.ID.make("beta-1"), + providerID: ProviderV2.ID.make("beta"), + name: "Beta One", + family: undefined, + package: undefined, + settings: undefined, + capabilities: { tools: false, input: [], output: [] }, + variants: [], + time: { released: Date.parse("2026-02-01") }, + cost: [ + { + input: Money.USDPerMillionTokens.zero, + output: Money.USDPerMillionTokens.zero, + cache: { + read: Money.USDPerMillionTokens.zero, + write: Money.USDPerMillionTokens.zero, + }, + }, + ], + status: "active", + enabled: true, + limit: { context: 64000, input: undefined, output: 4096 }, + headers: undefined, + body: undefined, + }, + ], + environment: ["BETA_API_KEY"], + }, +] satisfies readonly ModelsDev.Snapshot[] + interface MockState { body: string status: number @@ -127,45 +210,7 @@ const initialState: MockState = { } describe("ModelsDev Service", () => { - it.effect("decodes known reasoning options", () => - Effect.sync(() => { - const result = Schema.decodeUnknownSync(ModelsDev.Model)({ - id: "reasoning-model", - name: "Reasoning Model", - release_date: "2026-01-01", - attachment: false, - reasoning: true, - reasoning_options: [ - { type: "effort", values: ["low", "high"] }, - { type: "budget_tokens", min: 1024, max: 8192 }, - { type: "toggle" }, - ], - temperature: true, - tool_call: true, - limit: { context: 128000, output: 8192 }, - }) - - expect(result.reasoning_options?.map((item) => item.type)).toEqual(["effort", "budget_tokens", "toggle"]) - }), - ) - - it.effect("allows models.dev entries without temperature metadata", () => - Effect.sync(() => { - const result = Schema.decodeUnknownSync(ModelsDev.Model)({ - id: "no-temperature-model", - name: "No Temperature Model", - release_date: "2026-01-01", - attachment: false, - reasoning: false, - tool_call: true, - limit: { context: 128000, output: 8192 }, - }) - - expect(result.temperature).toBeUndefined() - }), - ) - - it.live("get() returns providers from disk when cache file exists", () => + it.live("get() returns normalized snapshots from disk when cache file exists", () => Effect.gen(function* () { yield* writeCache(fixture) const state = yield* Ref.make(initialState) @@ -173,7 +218,7 @@ describe("ModelsDev Service", () => { state, ModelsDev.Service.use((s) => s.get()), ) - expect(result).toEqual(fixture) + expect(result).toEqual(fixtureSnapshot) const final = yield* Ref.get(state) expect(final.calls).toEqual([]) }), @@ -186,7 +231,7 @@ describe("ModelsDev Service", () => { state, ModelsDev.Service.use((s) => s.get()), ) - expect(result).toEqual({}) + expect(result).toEqual([]) const final = yield* Ref.get(state) expect(final.calls).toEqual([]) }), @@ -207,7 +252,7 @@ describe("ModelsDev Service", () => { Flag.OPENCODE_DISABLE_MODELS_FETCH = true }), ) - expect(result).toEqual(fixture2) + expect(result).toEqual(fixture2Snapshot) expect(yield* Effect.promise(() => readFile(cacheFile, "utf8"))).toBe(JSON.stringify(fixture2)) const final = yield* Ref.get(state) expect(final.calls.length).toBe(1) @@ -227,7 +272,7 @@ describe("ModelsDev Service", () => { }) }), ) - for (const result of results) expect(result).toEqual(fixture) + for (const result of results) expect(result).toEqual(fixtureSnapshot) }), ) @@ -246,8 +291,8 @@ describe("ModelsDev Service", () => { return { a, b } }), ) - expect(first.a).toEqual(fixture) - expect(first.b).toEqual(fixture) + expect(first.a).toEqual(fixtureSnapshot) + expect(first.b).toEqual(fixtureSnapshot) }), ) @@ -265,8 +310,8 @@ describe("ModelsDev Service", () => { return { before, after } }), ) - expect(result.before).toEqual(fixture) - expect(result.after).toEqual(fixture2) + expect(result.before).toEqual(fixtureSnapshot) + expect(result.after).toEqual(fixture2Snapshot) const final = yield* Ref.get(state) expect(final.calls.length).toBe(1) expect(final.calls[0].url).toContain("/api.json") @@ -303,7 +348,7 @@ describe("ModelsDev Service", () => { ) const final = yield* Ref.get(state) expect(final.calls.length).toBe(1) - expect(after).toEqual(fixture2) + expect(after).toEqual(fixture2Snapshot) }), ) @@ -319,7 +364,7 @@ describe("ModelsDev Service", () => { return yield* svc.get() }), ) - expect(result).toEqual(fixture) + expect(result).toEqual(fixtureSnapshot) // retryTransient retries 5xx, so calls may be > 1. const final = yield* Ref.get(state) expect(final.calls.length).toBeGreaterThanOrEqual(1) diff --git a/packages/core/test/permission.test.ts b/packages/core/test/permission.test.ts index 9bc0453298..669fb4d969 100644 --- a/packages/core/test/permission.test.ts +++ b/packages/core/test/permission.test.ts @@ -182,12 +182,12 @@ describe("PermissionV2", () => { const agents = yield* AgentV2.Service yield* agents.transform((editor) => editor.update(AgentV2.ID.make("build"), (agent) => { - agent.permissions = [{ action: "todowrite", resource: "*", effect: "allow" }] + agent.permissions = [{ action: "custom", resource: "*", effect: "allow" }] }), ) const service = yield* PermissionV2.Service - expect(yield* service.ask(assertion({ action: "todowrite", resources: ["*"] }))).toEqual({ + expect(yield* service.ask(assertion({ action: "custom", resources: ["*"] }))).toEqual({ id: PermissionV2.ID.create("per_test"), effect: "allow", }) diff --git a/packages/core/test/plugin/models-dev.test.ts b/packages/core/test/plugin/models-dev.test.ts index c336666c86..bb8e460d73 100644 --- a/packages/core/test/plugin/models-dev.test.ts +++ b/packages/core/test/plugin/models-dev.test.ts @@ -28,66 +28,113 @@ const layer = AppNodeBuilder.build(LayerNode.group([Catalog.node, Integration.no const it = testEffect(layer) describe("ModelsDevPlugin", () => { - it.effect("projects models.dev modes as separate models instead of variants", () => + it.effect("projects normalized models.dev snapshots into the catalog", () => Effect.gen(function* () { const integrations = yield* Integration.Service const catalog = yield* Catalog.Service + const providerID = ProviderV2.ID.make("acme") + const modelID = ModelV2.ID.make("gpt-5.4") const models = ModelsDev.Service.of({ get: () => - Effect.succeed({ - acme: { - id: "acme", - name: "Acme", - env: [], - npm: "@ai-sdk/openai-compatible", - api: "https://api.acme.test/v1", - models: { - "gpt-5.4": { - id: "gpt-5.4", + Effect.succeed([ + { + info: { + id: providerID, + name: "Acme", + package: ProviderV2.aisdk("@ai-sdk/openai-compatible"), + settings: { baseURL: "https://api.acme.test/v1" }, + }, + environment: [], + models: [ + { + id: modelID, + modelID, + providerID, name: "GPT-5.4", - family: "gpt", - release_date: "2026-01-01", - attachment: false, - reasoning: true, - temperature: true, - tool_call: true, - cost: { - input: Money.USDPerMillionTokens.make(2.5), - output: Money.USDPerMillionTokens.make(15), - tiers: [ - { - tier: { type: "context", size: 272_000 }, - input: Money.USDPerMillionTokens.make(3), - output: Money.USDPerMillionTokens.make(18), - cache_read: Money.USDPerMillionTokens.make(0.25), + family: ModelV2.Family.make("gpt"), + capabilities: { tools: true, input: [], output: [] }, + variants: [], + time: { released: Date.parse("2026-01-01") }, + cost: [ + { + input: Money.USDPerMillionTokens.make(2.5), + output: Money.USDPerMillionTokens.make(15), + cache: { + read: Money.USDPerMillionTokens.zero, + write: Money.USDPerMillionTokens.zero, }, - ], - context_over_200k: { + }, + { + tier: { type: "context", size: 272_000 }, + input: Money.USDPerMillionTokens.make(3), + output: Money.USDPerMillionTokens.make(18), + cache: { + read: Money.USDPerMillionTokens.make(0.25), + write: Money.USDPerMillionTokens.zero, + }, + }, + { + tier: { type: "context", size: 200_000 }, input: Money.USDPerMillionTokens.make(5), output: Money.USDPerMillionTokens.make(22.5), - cache_read: Money.USDPerMillionTokens.make(0.5), - }, - }, - limit: { context: 1_050_000, input: 922_000, output: 128_000 }, - experimental: { - modes: { - fast: { - cost: { - input: Money.USDPerMillionTokens.make(5), - output: Money.USDPerMillionTokens.make(30), - cache_read: Money.USDPerMillionTokens.make(0.5), - }, - provider: { - headers: { "x-mode": "fast" }, - body: { service_tier: "priority" }, - }, + cache: { + read: Money.USDPerMillionTokens.make(0.5), + write: Money.USDPerMillionTokens.zero, }, }, - }, + ], + status: "active", + enabled: true, + limit: { context: 1_050_000, input: 922_000, output: 128_000 }, }, - }, + { + id: ModelV2.ID.make("gpt-5.4-fast"), + modelID, + providerID, + name: "GPT-5.4 Fast", + family: ModelV2.Family.make("gpt"), + package: ProviderV2.aisdk("@ai-sdk/openai-compatible"), + settings: { baseURL: "https://api.acme.test/v1" }, + headers: { "x-mode": "fast" }, + body: { service_tier: "priority" }, + capabilities: { tools: true, input: [], output: [] }, + variants: [], + time: { released: Date.parse("2026-01-01") }, + cost: [ + { + input: Money.USDPerMillionTokens.make(5), + output: Money.USDPerMillionTokens.make(30), + cache: { + read: Money.USDPerMillionTokens.make(0.5), + write: Money.USDPerMillionTokens.zero, + }, + }, + { + tier: { type: "context", size: 272_000 }, + input: Money.USDPerMillionTokens.make(3), + output: Money.USDPerMillionTokens.make(18), + cache: { + read: Money.USDPerMillionTokens.make(0.25), + write: Money.USDPerMillionTokens.zero, + }, + }, + { + tier: { type: "context", size: 200_000 }, + input: Money.USDPerMillionTokens.make(5), + output: Money.USDPerMillionTokens.make(22.5), + cache: { + read: Money.USDPerMillionTokens.make(0.5), + write: Money.USDPerMillionTokens.zero, + }, + }, + ], + status: "active", + enabled: true, + limit: { context: 1_050_000, input: 922_000, output: 128_000 }, + }, + ], }, - } satisfies Record), + ] satisfies readonly ModelsDev.Snapshot[]), refresh: () => Effect.void, }) @@ -98,12 +145,11 @@ describe("ModelsDevPlugin", () => { }), ).pipe(Effect.provideService(ModelsDev.Service, models)) - const providerID = ProviderV2.ID.make("acme") const base = yield* catalog.model.get(providerID, ModelV2.ID.make("gpt-5.4")) const fast = yield* catalog.model.get(providerID, ModelV2.ID.make("gpt-5.4-fast")) expect(base?.variants).toEqual([]) - expect(base?.body).toEqual({}) + expect(base?.body).toBeUndefined() expect(fast).toMatchObject({ id: "gpt-5.4-fast", modelID: "gpt-5.4", diff --git a/packages/core/test/plugin/provider-opencode.test.ts b/packages/core/test/plugin/provider-opencode.test.ts index f8334026f9..dfb0e82d0a 100644 --- a/packages/core/test/plugin/provider-opencode.test.ts +++ b/packages/core/test/plugin/provider-opencode.test.ts @@ -92,18 +92,82 @@ describe("OpencodePlugin", () => { }), ) + it.live("uses a canonical custom server throughout device authorization", () => + Effect.acquireUseRelease( + Effect.sync(() => { + const requests: string[] = [] + const server = Bun.serve({ + port: 0, + fetch: (request) => { + const url = new URL(request.url) + requests.push(`${request.method} ${url.pathname}`) + if (url.pathname.endsWith("/auth/device/code")) { + return Response.json({ + device_code: "device", + user_code: "user", + verification_uri_complete: `${url.origin}/verify`, + expires_in: 60, + interval: 0, + }) + } + if (url.pathname.endsWith("/auth/device/token")) { + return Response.json({ access_token: "access", refresh_token: "refresh", expires_in: 600 }) + } + if (url.pathname.endsWith("/api/user")) return Response.json({ id: "user", email: "user@example.com" }) + if (url.pathname.endsWith("/api/orgs")) return Response.json([{ id: "org", name: "Org" }]) + return new Response("Not found", { status: 404 }) + }, + }) + return { requests, server } + }), + ({ requests, server }) => + Effect.gen(function* () { + yield* addPlugin() + const integrations = yield* Integration.Service + const attempt = yield* integrations.connection.oauth({ + integrationID: Integration.ID.make("opencode"), + methodID: Integration.MethodID.make("device"), + inputs: { server: `${server.url.origin}/console///?ignored=true#ignored` }, + }) + expect(attempt.url).toBe(`${server.url.origin}/verify`) + yield* eventually(integrations.attempt.status(attempt.attemptID), (status) => status.status === "complete") + + expect(requests).toContain("POST /console/auth/device/code") + expect(requests).toContain("POST /console/auth/device/token") + expect(requests).toContain("GET /console/api/user") + expect(requests).toContain("GET /console/api/orgs") + expect((yield* (yield* Credential.Service).list(Integration.ID.make("opencode")))[0]?.value).toMatchObject({ + metadata: { server: `${server.url.origin}/console` }, + }) + }), + ({ server }) => Effect.promise(() => server.stop(true)), + ), + ) + + it.effect("rejects non-HTTP OpenCode servers", () => + Effect.gen(function* () { + yield* addPlugin() + const error = yield* (yield* Integration.Service).connection + .oauth({ + integrationID: Integration.ID.make("opencode"), + methodID: Integration.MethodID.make("device"), + inputs: { server: "ftp://console.example.com" }, + }) + .pipe(Effect.flip) + expect(error).toBeInstanceOf(Integration.AuthorizationError) + expect(String(error.cause)).toContain("Invalid OpenCode server URL: expected HTTP(S)") + }), + ) + it.live("loads providers and models from the connected OpenCode server", () => Effect.acquireUseRelease( Effect.sync(() => { const authorization: Array = [] - const gate = Promise.withResolvers() return { authorization, - release: gate.resolve, server: Bun.serve({ port: 0, - fetch: async (request) => { - await gate.promise + fetch: (request) => { authorization.push(request.headers.get("authorization")) const origin = new URL(request.url).origin return Response.json({ @@ -142,7 +206,7 @@ describe("OpencodePlugin", () => { }), } }), - ({ authorization, release, server }) => + ({ authorization, server }) => Effect.gen(function* () { const credentials = yield* Credential.Service const catalog = yield* Catalog.Service @@ -170,15 +234,9 @@ describe("OpencodePlugin", () => { }) yield* addPlugin() - expect(authorization).toEqual([]) - release() + expect(authorization).toEqual(["Bearer secret"]) - const provider = required( - yield* eventually( - catalog.provider.get(ProviderV2.ID.make("remote")), - (item) => item?.integrationID === Integration.ID.make("opencode"), - ), - ) + const provider = required(yield* catalog.provider.get(ProviderV2.ID.make("remote"))) expect(provider).toMatchObject({ name: "Remote", integrationID: "opencode", @@ -216,7 +274,6 @@ describe("OpencodePlugin", () => { required(yield* catalog.model.get(ProviderV2.ID.make("remote"), ModelV2.ID.make("disabled"))).enabled, ).toBe(false) expect(yield* catalog.model.get(ProviderV2.ID.make("remote"), ModelV2.ID.make("stale"))).toBeDefined() - expect(authorization).toContain("Bearer secret") }), ({ server }) => Effect.promise(() => server.stop(true)), ), diff --git a/packages/core/test/session-compact.test.ts b/packages/core/test/session-compact.test.ts index 703b098f63..814e8c1872 100644 --- a/packages/core/test/session-compact.test.ts +++ b/packages/core/test/session-compact.test.ts @@ -17,7 +17,6 @@ import { SessionCompaction } from "@opencode-ai/core/session/compaction" import { SessionEvent } from "@opencode-ai/core/session/event" import { SessionInput } from "@opencode-ai/core/session/input" import { SessionMessage } from "@opencode-ai/core/session/message" -import { Prompt } from "@opencode-ai/schema/prompt" import { SessionProjector } from "@opencode-ai/core/session/projector" import { SessionExecution } from "@opencode-ai/core/session/execution" import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model" @@ -84,14 +83,16 @@ describe("SessionV2.compact", () => { const created = yield* session.create({ location }) const messageID = SessionMessage.ID.create() - const prompt = Prompt.make({ text: "Please compact this session history." }) - yield* events.publish(SessionEvent.PromptAdmitted, { + yield* events.publish(SessionEvent.InputAdmitted, { sessionID: created.id, inputID: messageID, - prompt, - delivery: "steer", + input: { + type: "user", + data: { text: "Please compact this session history." }, + delivery: "steer", + }, }) - yield* events.publish(SessionEvent.PromptPromoted, { + yield* events.publish(SessionEvent.InputPromoted, { sessionID: created.id, inputID: messageID, }) diff --git a/packages/core/test/session-create.test.ts b/packages/core/test/session-create.test.ts index 3c4bb65a44..e1b83b4f50 100644 --- a/packages/core/test/session-create.test.ts +++ b/packages/core/test/session-create.test.ts @@ -17,7 +17,6 @@ import { ProviderV2 } from "@opencode-ai/core/provider" import { AbsolutePath } from "@opencode-ai/core/schema" import { SessionV2 } from "@opencode-ai/core/session" import { SessionV1 } from "@opencode-ai/core/v1/session" -import { PromptInput } from "@opencode-ai/schema/prompt-input" import { SessionMessage } from "@opencode-ai/core/session/message" import { SessionProjector } from "@opencode-ai/core/session/projector" import { SessionExecution } from "@opencode-ai/core/session/execution" @@ -193,14 +192,12 @@ describe("SessionV2.create", () => { const parent = yield* session.create({ location, title: "Parent" }) const admitted = yield* session.prompt({ sessionID: parent.id, - prompt: PromptInput.Prompt.make({ text: "First" }), + text: "First", resume: false, }) yield* SessionInput.promoteSteers(db, events, parent.id) - yield* events.publish(SessionEvent.Synthetic, { - sessionID: parent.id, - text: "parent note", - }) + yield* session.synthetic({ sessionID: parent.id, text: "parent note", resume: false }) + yield* SessionInput.promoteSteers(db, events, parent.id) const forked = yield* session.fork({ sessionID: parent.id }) const parentContext = yield* session.context(parent.id) @@ -222,19 +219,25 @@ describe("SessionV2.create", () => { }) expect(yield* SessionInput.find(db, forkContext[0].id)).toMatchObject({ sessionID: forked.id, - prompt: { text: "First" }, + type: "user", + data: { text: "First" }, promotedSeq: 2, }) + expect(yield* SessionInput.find(db, forkContext[1].id)).toMatchObject({ + sessionID: forked.id, + type: "synthetic", + data: { text: "parent note" }, + }) yield* session.prompt({ sessionID: parent.id, - prompt: PromptInput.Prompt.make({ text: "Parent changed" }), + text: "Parent changed", resume: false, }) yield* SessionInput.promoteSteers(db, events, parent.id) yield* session.prompt({ sessionID: forked.id, - prompt: PromptInput.Prompt.make({ text: "Child continues" }), + text: "Child continues", resume: false, }) yield* SessionInput.promoteSteers(db, events, forked.id) @@ -246,7 +249,7 @@ describe("SessionV2.create", () => { Array.from(yield* Stream.runCollect(logEvents(session, forked.id))).map( (event): number | undefined => event.durable?.seq, ), - ).toEqual([0, 4, 5]) + ).toEqual([0, 5, 6]) expect(yield* SessionInput.find(db, admitted.id)).toMatchObject({ sessionID: parent.id }) }), ) @@ -259,13 +262,13 @@ describe("SessionV2.create", () => { const parent = yield* session.create({ location }) const first = yield* session.prompt({ sessionID: parent.id, - prompt: PromptInput.Prompt.make({ text: "First" }), + text: "First", resume: false, }) yield* SessionInput.promoteSteers(db, events, parent.id) const second = yield* session.prompt({ sessionID: parent.id, - prompt: PromptInput.Prompt.make({ text: "Second" }), + text: "Second", resume: false, }) yield* SessionInput.promoteSteers(db, events, parent.id) @@ -410,7 +413,7 @@ describe("SessionV2.create", () => { const created = yield* session.create({ location }) yield* session.prompt({ sessionID: created.id, - prompt: PromptInput.Prompt.make({ text: "Hello" }), + text: "Hello", resume: false, }) yield* SessionInput.promoteSteers(db, events, created.id) @@ -418,8 +421,12 @@ describe("SessionV2.create", () => { expect( Array.from(yield* logEvents(session, created.id, true).pipe(Stream.take(2), Stream.runCollect)), ).toMatchObject([ - { durable: { seq: 1 }, type: "session.prompt.admitted", data: { prompt: { text: "Hello" } } }, - { durable: { seq: 2 }, type: "session.prompt.promoted" }, + { + durable: { seq: 1 }, + type: "session.input.admitted", + data: { input: { type: "user", data: { text: "Hello" }, delivery: "steer" } }, + }, + { durable: { seq: 2 }, type: "session.input.promoted" }, ]) }), ) @@ -432,7 +439,7 @@ describe("SessionV2.create", () => { const created = yield* session.create({ id: SessionV2.ID.make("ses_fresh_target_replay"), location }) const admitted = yield* session.prompt({ sessionID: created.id, - prompt: PromptInput.Prompt.make({ text: "Replay lifecycle" }), + text: "Replay lifecycle", resume: false, }) yield* SessionInput.promoteSteers(sourceDb, sourceEvents, created.id) @@ -476,7 +483,8 @@ describe("SessionV2.create", () => { expect(yield* SessionInput.find(db, admitted.id)).toMatchObject({ id: admitted.id, sessionID: created.id, - prompt: { text: "Replay lifecycle" }, + type: "user", + data: { text: "Replay lifecycle" }, delivery: "steer", admittedSeq: 1, }) @@ -486,7 +494,8 @@ describe("SessionV2.create", () => { expect(yield* SessionInput.find(db, admitted.id)).toMatchObject({ id: admitted.id, sessionID: created.id, - prompt: { text: "Replay lifecycle" }, + type: "user", + data: { text: "Replay lifecycle" }, delivery: "steer", admittedSeq: 1, promotedSeq: 2, @@ -504,8 +513,8 @@ describe("SessionV2.create", () => { .pipe(Effect.orDie)).map((event) => [event.seq, event.type]), ).toEqual([ [0, EventV2.versionedType(SessionV1.Event.Created.type, 1)], - [1, EventV2.versionedType(SessionEvent.PromptAdmitted.type, 1)], - [2, EventV2.versionedType(SessionEvent.PromptPromoted.type, 1)], + [1, EventV2.versionedType(SessionEvent.InputAdmitted.type, 1)], + [2, EventV2.versionedType(SessionEvent.InputPromoted.type, 1)], ]) }).pipe(Effect.provide(Layer.fresh(targetLayer))) }), diff --git a/packages/core/test/session-execution-local.test.ts b/packages/core/test/session-execution-local.test.ts deleted file mode 100644 index be1c4aebea..0000000000 --- a/packages/core/test/session-execution-local.test.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { describe, expect, test } from "bun:test" -import { LLMError, TransportReason } from "@opencode-ai/llm" -import { terminal } from "@opencode-ai/core/session/execution/local" -import { UserInterruptedError } from "@opencode-ai/core/session/error" -import { ToolOutputStore } from "@opencode-ai/core/tool-output-store" -import { Effect, Exit } from "effect" - -describe("SessionExecutionLocal lifecycle", () => { - test("classifies success and typed failure terminals", () => { - expect(terminal(Exit.succeed(undefined))).toEqual({ type: "succeeded" }) - expect( - terminal( - Exit.fail( - new LLMError({ - module: "test", - method: "stream", - reason: new TransportReason({ message: "Disconnected" }), - }), - ), - ), - ).toEqual({ type: "failed", error: { type: "provider.transport", message: "Disconnected" } }) - const storage = new ToolOutputStore.StorageError({ operation: "encode", cause: new Error("invalid output") }) - expect(terminal(Exit.fail(storage))).toEqual({ - type: "failed", - error: { type: "unknown", message: storage.message }, - }) - }) - - test("defaults owner-scope interruption to shutdown and preserves explicit reasons", () => { - const interrupted = Effect.runSyncExit(Effect.interrupt) - expect(terminal(interrupted)).toEqual({ type: "interrupted", reason: "shutdown" }) - expect(terminal(interrupted, "user")).toEqual({ type: "interrupted", reason: "user" }) - expect(terminal(interrupted, "superseded")).toEqual({ type: "interrupted", reason: "superseded" }) - expect(terminal(Exit.fail(new UserInterruptedError()))).toEqual({ type: "interrupted", reason: "user" }) - }) -}) diff --git a/packages/core/test/session-execution.test.ts b/packages/core/test/session-execution.test.ts new file mode 100644 index 0000000000..2775df3564 --- /dev/null +++ b/packages/core/test/session-execution.test.ts @@ -0,0 +1,198 @@ +import { describe, expect, test } from "bun:test" +import { LLMError, TransportReason } from "@opencode-ai/llm" +import { Database } from "@opencode-ai/core/database/database" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { EventV2 } from "@opencode-ai/core/event" +import { LocationServiceMap } from "@opencode-ai/core/location-service-map" +import type { LocationServices } from "@opencode-ai/core/location-services" +import { Project } from "@opencode-ai/core/project" +import { ProjectTable } from "@opencode-ai/core/project/sql" +import { AbsolutePath } from "@opencode-ai/core/schema" +import { SessionV2 } from "@opencode-ai/core/session" +import { SessionExecution } from "@opencode-ai/core/session/execution" +import { SessionRestart } from "@opencode-ai/core/session/execution/restart" +import { UserInterruptedError } from "@opencode-ai/core/session/error" +import { SessionRunner } from "@opencode-ai/core/session/runner" +import { SessionTable } from "@opencode-ai/core/session/sql" +import { SessionStore } from "@opencode-ai/core/session/store" +import { ToolOutputStore } from "@opencode-ai/core/tool-output-store" +import { Context, Deferred, Effect, Exit, Fiber, Layer, LayerMap, Scope } from "effect" +import { testEffect } from "./lib/effect" + +const it = testEffect(AppNodeBuilder.build(LayerNode.group([Database.node, EventV2.node, SessionStore.node]))) + +describe("SessionExecution lifecycle", () => { + test("classifies success and typed failure terminals", () => { + expect(SessionExecution.terminal(Exit.succeed(undefined))).toEqual({ type: "succeeded" }) + expect( + SessionExecution.terminal( + Exit.fail( + new LLMError({ + module: "test", + method: "stream", + reason: new TransportReason({ message: "Disconnected" }), + }), + ), + ), + ).toEqual({ type: "failed", error: { type: "provider.transport", message: "Disconnected" } }) + const storage = new ToolOutputStore.StorageError({ operation: "encode", cause: new Error("invalid output") }) + expect(SessionExecution.terminal(Exit.fail(storage))).toEqual({ + type: "failed", + error: { type: "unknown", message: storage.message }, + }) + }) + + test("defaults owner-scope interruption to shutdown and preserves explicit reasons", () => { + const interrupted = Effect.runSyncExit(Effect.interrupt) + expect(SessionExecution.terminal(interrupted)).toEqual({ type: "interrupted", reason: "shutdown" }) + expect(SessionExecution.terminal(interrupted, "user")).toEqual({ type: "interrupted", reason: "user" }) + expect(SessionExecution.terminal(interrupted, "superseded")).toEqual({ type: "interrupted", reason: "superseded" }) + expect(SessionExecution.terminal(Exit.fail(new UserInterruptedError()))).toEqual({ + type: "interrupted", + reason: "user", + }) + }) + + it.effect("atomically consumes each suspension at most once", () => + Effect.gen(function* () { + const database = yield* Database.Service + const store = yield* SessionStore.Service + const first = SessionV2.ID.make("ses_recover_first") + const second = SessionV2.ID.make("ses_recover_second") + yield* seedSessions(database, [first, second], { time_suspended: Date.now() }) + + expect(yield* store.consumeSuspended(first)).toBe(true) + expect(yield* store.consumeSuspended(first)).toBe(false) + expect(yield* store.consumeSuspended(second)).toBe(true) + expect(yield* suspensions(database)).toEqual({ [first]: false, [second]: false }) + }), + ) + + it.effect("suspension survives teardown interruption and clears when a drain finishes on its own", () => + Effect.gen(function* () { + const database = yield* Database.Service + const interrupted = SessionV2.ID.make("ses_suspend_interrupted") + const completed = SessionV2.ID.make("ses_suspend_completed") + yield* seedSessions(database, [interrupted, completed]) + + const draining = yield* Deferred.make() + const release = yield* Deferred.make() + const scope = yield* Scope.make() + const context = yield* buildExecution(scope, ({ sessionID }) => + sessionID === completed + ? Deferred.await(release) + : Deferred.succeed(draining, undefined).pipe(Effect.andThen(Effect.never)), + ) + const execution = Context.get(context, SessionExecution.Service) + const restart = Context.get(context, SessionRestart.Service) + yield* execution.resume(interrupted).pipe(Effect.forkScoped) + const completing = yield* execution.resume(completed).pipe(Effect.forkIn(scope)) + yield* Deferred.await(draining) + + yield* restart.suspendActiveSessions + expect(yield* suspensions(database)).toEqual({ [interrupted]: true, [completed]: true }) + + // A drain that finishes on its own after suspension clears its stale suspension. + yield* Deferred.succeed(release, undefined) + yield* Fiber.join(completing) + yield* execution.awaitIdle(completed) + expect((yield* suspensions(database))[completed]).toBe(false) + + // Teardown interruption preserves suspension for the next server start. + yield* Scope.close(scope, Exit.void) + expect((yield* suspensions(database))[interrupted]).toBe(true) + }), + ) + + it.effect("resumes each suspended Session at most once", () => + Effect.gen(function* () { + const database = yield* Database.Service + const first = SessionV2.ID.make("ses_resume_first") + const second = SessionV2.ID.make("ses_resume_second") + yield* seedSessions(database, [first, second], { time_suspended: Date.now() }) + + const drained: string[] = [] + const scope = yield* Scope.make() + const context = yield* buildExecution(scope, ({ sessionID }) => Effect.sync(() => void drained.push(sessionID))) + const restart = Context.get(context, SessionRestart.Service) + + yield* restart.resumeSuspendedSessions + expect(drained.toSorted()).toEqual([first, second]) + expect(yield* suspensions(database)).toEqual({ [first]: false, [second]: false }) + + yield* restart.resumeSuspendedSessions + expect(drained.length).toBe(2) + yield* Scope.close(scope, Exit.void) + }), + ) +}) + +function seedSessions( + database: Database.Service["Service"], + sessionIDs: ReadonlyArray, + values: { time_suspended?: number } = {}, +) { + return Effect.gen(function* () { + yield* database.db + .insert(ProjectTable) + .values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] }) + .run() + .pipe(Effect.orDie) + yield* database.db + .insert(SessionTable) + .values( + sessionIDs.map((id) => ({ + id, + project_id: Project.ID.global, + slug: id, + directory: "/project", + title: id, + version: "test", + ...values, + })), + ) + .run() + .pipe(Effect.orDie) + }) +} + +function suspensions(database: Database.Service["Service"]) { + return database.db + .select({ id: SessionTable.id, suspended: SessionTable.time_suspended }) + .from(SessionTable) + .all() + .pipe( + Effect.orDie, + Effect.map((rows) => Object.fromEntries(rows.map((row) => [row.id, row.suspended !== null]))), + ) +} + +/** Builds the local execution layer plus the restart actions against the test harness services. */ +function buildExecution(scope: Scope.Closeable, drain: SessionRunner.Interface["drain"]) { + return Effect.gen(function* () { + const database = yield* Database.Service + const events = yield* EventV2.Service + const store = yield* SessionStore.Service + const runner = Layer.succeed(SessionRunner.Service, SessionRunner.Service.of({ drain })) + const locations = Layer.effect( + LocationServiceMap.Service, + LayerMap.make( + () => + // The local execution test only needs the Session runner from the Location graph. + // oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion + runner as unknown as Layer.Layer, + ), + ) + return yield* Layer.buildWithScope( + SessionRestart.layer.pipe( + Layer.provideMerge(SessionExecution.layer), + Layer.provide(Layer.succeed(Database.Service, database)), + Layer.provide(Layer.succeed(EventV2.Service, events)), + Layer.provide(Layer.succeed(SessionStore.Service, store)), + Layer.provide(locations), + ), + scope, + ) + }) +} diff --git a/packages/core/test/session-projector.test.ts b/packages/core/test/session-projector.test.ts index 1eb7f6b6fd..7ddfa9b94b 100644 --- a/packages/core/test/session-projector.test.ts +++ b/packages/core/test/session-projector.test.ts @@ -15,7 +15,6 @@ import { AbsolutePath } from "@opencode-ai/core/schema" import { SessionV2 } from "@opencode-ai/core/session" import { SessionEvent } from "@opencode-ai/core/session/event" import { SessionMessage } from "@opencode-ai/core/session/message" -import { Prompt } from "@opencode-ai/schema/prompt" import { Money } from "@opencode-ai/schema/money" import { SessionMessageUpdater } from "@opencode-ai/core/session/message-updater" import { SessionProjector } from "@opencode-ai/core/session/projector" @@ -266,28 +265,26 @@ describe("SessionProjector", () => { .pipe(Effect.orDie) const events = yield* EventV2.Service - yield* events.publish(SessionEvent.PromptAdmitted, { + yield* events.publish(SessionEvent.InputAdmitted, { sessionID, inputID: SessionMessage.ID.make("msg_first"), - prompt: Prompt.make({ text: "first" }), - delivery: "steer", + input: { type: "user", data: { text: "first" }, delivery: "steer" }, }) yield* events.publish( - SessionEvent.PromptPromoted, + SessionEvent.InputPromoted, { sessionID, inputID: SessionMessage.ID.make("msg_first"), }, { id: EventV2.ID.make("evt_z") }, ) - yield* events.publish(SessionEvent.PromptAdmitted, { + yield* events.publish(SessionEvent.InputAdmitted, { sessionID, inputID: SessionMessage.ID.make("msg_second"), - prompt: Prompt.make({ text: "second" }), - delivery: "steer", + input: { type: "user", data: { text: "second" }, delivery: "steer" }, }) yield* events.publish( - SessionEvent.PromptPromoted, + SessionEvent.InputPromoted, { sessionID, inputID: SessionMessage.ID.make("msg_second"), @@ -344,12 +341,11 @@ describe("SessionProjector", () => { const admitted = yield* SessionInput.admit(db, events, { id, sessionID, - prompt: Prompt.make({ text: "promote me" }), - delivery: "steer", + input: { type: "user", data: { text: "promote me" }, delivery: "steer" }, }) if (!admitted) return yield* Effect.die("Prompt admission failed") - const event = yield* events.publish(SessionEvent.PromptPromoted, { + const event = yield* events.publish(SessionEvent.InputPromoted, { sessionID, inputID: id, }) @@ -636,6 +632,43 @@ describe("SessionProjector", () => { }), ) + it.effect("does not infer restart continuation from lifecycle history", () => + Effect.gen(function* () { + const { db } = yield* Database.Service + yield* db + .insert(ProjectTable) + .values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] }) + .run() + .pipe(Effect.orDie) + yield* db + .insert(SessionTable) + .values({ + id: sessionID, + project_id: Project.ID.global, + slug: "test", + directory: "/project", + title: "test", + version: "test", + }) + .run() + .pipe(Effect.orDie) + const events = yield* EventV2.Service + const suspended = () => + db + .select({ timeSuspended: SessionTable.time_suspended }) + .from(SessionTable) + .where(eq(SessionTable.id, sessionID)) + .get() + .pipe(Effect.orDie) + + yield* events.publish(SessionEvent.Execution.Interrupted, { sessionID, reason: "shutdown" }) + expect((yield* suspended())?.timeSuspended).toBeNull() + + yield* events.publish(SessionEvent.Execution.Started, { sessionID }) + expect((yield* suspended())?.timeSuspended).toBeNull() + }), + ) + it.effect("updates only the newest incomplete assistant projection", () => Effect.gen(function* () { const { db } = yield* Database.Service diff --git a/packages/core/test/session-prompt.test.ts b/packages/core/test/session-prompt.test.ts index 34b1ca1487..f9782170f4 100644 --- a/packages/core/test/session-prompt.test.ts +++ b/packages/core/test/session-prompt.test.ts @@ -18,7 +18,6 @@ import { Project } from "@opencode-ai/core/project" import { ProjectTable } from "@opencode-ai/core/project/sql" import { AbsolutePath } from "@opencode-ai/core/schema" import { SessionV2 } from "@opencode-ai/core/session" -import { PromptInput } from "@opencode-ai/schema/prompt-input" import { SessionMessage } from "@opencode-ai/core/session/message" import { SessionProjector } from "@opencode-ai/core/session/projector" import { SessionExecution } from "@opencode-ai/core/session/execution" @@ -174,16 +173,17 @@ describe("SessionV2.prompt", () => { const message = yield* session.prompt({ sessionID, - prompt: PromptInput.Prompt.make({ text: "Fix the failing tests" }), + text: "Fix the failing tests", resume: false, }) - expect(message.prompt.text).toBe("Fix the failing tests") + expect(message.data.text).toBe("Fix the failing tests") expect(yield* session.messages({ sessionID })).toEqual([]) expect(yield* admitted(message.id)).toMatchObject({ id: message.id, sessionID, - prompt: { text: "Fix the failing tests" }, + type: "user", + data: { text: "Fix the failing tests" }, delivery: "steer", }) }), @@ -198,7 +198,7 @@ describe("SessionV2.prompt", () => { const boundary = yield* session.prompt({ sessionID, - prompt: PromptInput.Prompt.make({ text: "boundary" }), + text: "boundary", resume: false, }) yield* SessionInput.promoteSteers(db, events, sessionID) @@ -210,7 +210,7 @@ describe("SessionV2.prompt", () => { }) expect((yield* session.get(sessionID)).revert?.messageID).toBe(boundary.id) - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "after revert" }), resume: false }) + yield* session.prompt({ sessionID, text: "after revert", resume: false }) expect((yield* session.get(sessionID)).revert).toBeUndefined() expect( @@ -222,6 +222,35 @@ describe("SessionV2.prompt", () => { }), ) + it.effect("holds synthetic input behind a staged revert and discards it when committed", () => + Effect.gen(function* () { + yield* setup + const session = yield* SessionV2.Service + const events = yield* EventV2.Service + const { db } = yield* Database.Service + const boundary = yield* session.prompt({ + sessionID, + text: "boundary", + resume: false, + }) + yield* SessionInput.promoteSteers(db, events, sessionID) + yield* events.publish(SessionEvent.RevertEvent.Staged, { + sessionID, + revert: { messageID: boundary.id, files: [] }, + }) + wakeCalls.length = 0 + + const completion = yield* session.synthetic({ sessionID, text: "stale completion" }) + + expect(wakeCalls).toEqual([]) + expect(yield* SessionInput.find(db, completion.id)).toMatchObject({ type: "synthetic" }) + + yield* session.revert.commit(sessionID) + + expect(yield* SessionInput.find(db, completion.id)).toBeUndefined() + }), + ) + it.effect("resolves attachment MIME before admission", () => Effect.gen(function* () { yield* setup @@ -231,14 +260,12 @@ describe("SessionV2.prompt", () => { const message = yield* session.prompt({ sessionID, - prompt: { - text: "Inspect this image", - files: [{ uri, name: "image.png", mention: { start: 8, end: 17, text: "[Image 1]" } }], - }, + text: "Inspect this image", + files: [{ uri, name: "image.png", mention: { start: 8, end: 17, text: "[Image 1]" } }], resume: false, }) - expect(message.prompt.files).toEqual([ + expect(message.data.files).toEqual([ { data: uri.slice(uri.indexOf(",") + 1), mime: "image/png", @@ -248,8 +275,8 @@ describe("SessionV2.prompt", () => { }, ]) const stored = yield* admitted(message.id) - expect(stored?.type).toBe("prompt") - if (stored?.type === "prompt") expect(stored.prompt.files).toEqual(message.prompt.files) + expect(stored?.type).toBe("user") + if (stored?.type === "user") expect(stored.data.files).toEqual(message.data.files) }), ) @@ -265,21 +292,19 @@ describe("SessionV2.prompt", () => { const message = yield* session.prompt({ sessionID, - prompt: { - text: "Inspect this", - files: [{ uri: sourceUri.href, name: "main.ts" }], - }, + text: "Inspect this", + files: [{ uri: sourceUri.href, name: "main.ts" }], resume: false, }) - expect(message.prompt.files).toHaveLength(1) - expect(message.prompt.files?.[0]).toMatchObject({ + expect(message.data.files).toHaveLength(1) + expect(message.data.files?.[0]).toMatchObject({ mime: "text/plain", source: { type: "uri", uri: sourceUri.href }, name: "main.ts", }) expect( - Buffer.from(message.prompt.files?.[0]?.data ?? "", "base64") + Buffer.from(message.data.files?.[0]?.data ?? "", "base64") .toString("utf8") .replace(/\r$/, ""), ).toBe('import { describe, expect } from "bun:test"') @@ -294,17 +319,18 @@ describe("SessionV2.prompt", () => { const message = yield* session.prompt({ sessionID, - prompt: { text: "Inspect this", files: [{ uri, name: "source" }] }, + text: "Inspect this", + files: [{ uri, name: "source" }], resume: false, }) - expect(message.prompt.files).toHaveLength(1) - expect(message.prompt.files?.[0]).toMatchObject({ + expect(message.data.files).toHaveLength(1) + expect(message.data.files?.[0]).toMatchObject({ mime: "application/x-directory", source: { type: "uri", uri }, name: "source", }) - expect(Buffer.from(message.prompt.files?.[0]?.data ?? "", "base64").toString("utf8")).toContain( + expect(Buffer.from(message.data.files?.[0]?.data ?? "", "base64").toString("utf8")).toContain( "session-prompt.test.ts", ) }), @@ -327,11 +353,12 @@ describe("SessionV2.prompt", () => { const message = yield* session.prompt({ sessionID, - prompt: { text: "Inspect this image", files: [{ uri: pathToFileURL(source).href }] }, + text: "Inspect this image", + files: [{ uri: pathToFileURL(source).href }], resume: false, }) - expect(message.prompt.files).toEqual([ + expect(message.data.files).toEqual([ { data: bytes.toString("base64"), mime: "image/png", @@ -340,7 +367,7 @@ describe("SessionV2.prompt", () => { }, ]) const stored = yield* admitted(message.id) - expect(stored?.type === "prompt" ? stored.prompt.files : undefined).toEqual(message.prompt.files) + expect(stored?.type === "user" ? stored.data.files : undefined).toEqual(message.data.files) }), ) @@ -352,11 +379,12 @@ describe("SessionV2.prompt", () => { const message = yield* session.prompt({ sessionID, - prompt: { text: "Inspect this", files: [{ uri, name: "main.ts" }] }, + text: "Inspect this", + files: [{ uri, name: "main.ts" }], resume: false, }) - expect(message.prompt.files).toEqual([ + expect(message.data.files).toEqual([ { data: Buffer.from("export const value = 1\n").toString("base64"), mime: "text/plain", @@ -376,7 +404,8 @@ describe("SessionV2.prompt", () => { const error = yield* session .prompt({ sessionID, - prompt: { text: "Inspect this", files: [{ uri, name: "image.png" }] }, + text: "Inspect this", + files: [{ uri, name: "image.png" }], resume: false, }) .pipe(Effect.flip) @@ -402,22 +431,22 @@ describe("SessionV2.prompt", () => { const fiber = yield* publicEvents({ sessionID }).pipe(Stream.take(4), Stream.runCollect, Effect.forkScoped) yield* Effect.yieldNow - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "First" }), resume: false }) - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Second" }), resume: false }) + yield* session.prompt({ sessionID, text: "First", resume: false }) + yield* session.prompt({ sessionID, text: "Second", resume: false }) yield* SessionInput.promoteSteers(db, events, sessionID) const streamed = Array.from(yield* Fiber.join(fiber)) expect(streamed.map((event): [number | undefined, string] => [event.durable?.seq, event.type])).toEqual([ - [0, "session.prompt.admitted"], - [1, "session.prompt.admitted"], - [2, "session.prompt.promoted"], - [3, "session.prompt.promoted"], + [0, "session.input.admitted"], + [1, "session.input.admitted"], + [2, "session.input.promoted"], + [3, "session.input.promoted"], ]) expect( Array.from( yield* publicEvents({ sessionID, after: streamed[0].durable?.seq }).pipe(Stream.take(1), Stream.runCollect), ).map((event): [number | undefined, string] => [event.durable?.seq, event.type]), - ).toEqual([[1, "session.prompt.admitted"]]) + ).toEqual([[1, "session.input.admitted"]]) }), ) @@ -427,7 +456,7 @@ describe("SessionV2.prompt", () => { const session = yield* SessionV2.Service const message = yield* session.prompt({ sessionID, - prompt: PromptInput.Prompt.make({ text: "Fix the failing tests" }), + text: "Fix the failing tests", resume: false, }) @@ -446,7 +475,7 @@ describe("SessionV2.prompt", () => { Effect.gen(function* () { yield* setup const session = yield* SessionV2.Service - const input = { sessionID, prompt: PromptInput.Prompt.make({ text: "Fix the failing tests" }), resume: false } + const input = { sessionID, text: "Fix the failing tests", resume: false } const first = yield* session.prompt(input) const second = yield* session.prompt(input) @@ -464,7 +493,7 @@ describe("SessionV2.prompt", () => { const input = { sessionID, id: messageID, - prompt: PromptInput.Prompt.make({ text: "Fix the failing tests" }), + text: "Fix the failing tests", resume: false, } @@ -484,7 +513,7 @@ describe("SessionV2.prompt", () => { const input = { sessionID, id: messageID, - prompt: PromptInput.Prompt.make({ text: "Recover committed prompt" }), + text: "Recover committed prompt", resume: false, } const first = yield* session.prompt(input) @@ -505,13 +534,13 @@ describe("SessionV2.prompt", () => { yield* session.prompt({ sessionID, id: messageID, - prompt: PromptInput.Prompt.make({ text: "Fix the failing tests" }), + text: "Fix the failing tests", }) const failure = yield* session .prompt({ sessionID, id: messageID, - prompt: PromptInput.Prompt.make({ text: "Delete the failing tests" }), + text: "Delete the failing tests", resume: false, }) .pipe(Effect.flip) @@ -530,14 +559,14 @@ describe("SessionV2.prompt", () => { yield* session.prompt({ id: messageID, sessionID, - prompt: PromptInput.Prompt.make({ text: "Fix the failing tests" }), + text: "Fix the failing tests", resume: false, }) const failure = yield* session .prompt({ id: messageID, sessionID, - prompt: PromptInput.Prompt.make({ text: "Fix the failing tests" }), + text: "Fix the failing tests", delivery: "queue", resume: false, }) @@ -554,7 +583,7 @@ describe("SessionV2.prompt", () => { const input = { sessionID, id: messageID, - prompt: PromptInput.Prompt.make({ text: "Fix the failing tests" }), + text: "Fix the failing tests", resume: false, } @@ -563,7 +592,7 @@ describe("SessionV2.prompt", () => { expect(messages[1]).toEqual(messages[0]) expect(yield* session.messages({ sessionID })).toEqual([]) expect(yield* admittedCount).toBe(1) - expect(yield* eventCount(EventV2.versionedType(SessionEvent.PromptAdmitted.type, 1))).toBe(1) + expect(yield* eventCount(EventV2.versionedType(SessionEvent.InputAdmitted.type, 1))).toBe(1) }), ) @@ -576,7 +605,7 @@ describe("SessionV2.prompt", () => { yield* session.prompt({ id: messageID, sessionID, - prompt: PromptInput.Prompt.make({ text: "Promote once" }), + text: "Promote once", resume: false, }) @@ -585,7 +614,7 @@ describe("SessionV2.prompt", () => { { concurrency: "unbounded" }, ) - expect(yield* eventCount(EventV2.versionedType(SessionEvent.PromptPromoted.type, 1))).toBe(1) + expect(yield* eventCount(EventV2.versionedType(SessionEvent.InputPromoted.type, 1))).toBe(1) expect(yield* admitted(messageID)).toMatchObject({ promotedSeq: 1 }) expect(yield* session.messages({ sessionID })).toMatchObject([ { id: messageID, type: "user", text: "Promote once" }, @@ -603,9 +632,11 @@ describe("SessionV2.prompt", () => { yield* session.prompt({ id: messageID, sessionID, - prompt: PromptInput.Prompt.make({ text: "Replay pending" }), + text: "Replay pending", resume: false, }) + const syntheticID = SessionMessage.ID.create() + yield* session.synthetic({ id: syntheticID, sessionID, text: "Replay synthetic", resume: false }) const recorded = yield* db .select() .from(EventTable) @@ -631,7 +662,16 @@ describe("SessionV2.prompt", () => { })), ) - expect(yield* admitted(messageID)).toMatchObject({ id: messageID, prompt: { text: "Replay pending" } }) + expect(yield* admitted(messageID)).toMatchObject({ + id: messageID, + type: "user", + data: { text: "Replay pending" }, + }) + expect(yield* admitted(syntheticID)).toMatchObject({ + id: syntheticID, + type: "synthetic", + data: { text: "Replay synthetic" }, + }) expect(yield* session.messages({ sessionID })).toEqual([]) expect(wakeCalls).toEqual([]) }), @@ -656,11 +696,9 @@ describe("SessionV2.prompt", () => { .onConflictDoNothing() .run() .pipe(Effect.orDie) - const prompt = PromptInput.Prompt.make({ text: "Fix the failing tests" }) - - yield* session.prompt({ id: messageID, sessionID, prompt, resume: false }) + yield* session.prompt({ id: messageID, sessionID, text: "Fix the failing tests", resume: false }) const failure = yield* session - .prompt({ id: messageID, sessionID: other, prompt, resume: false }) + .prompt({ id: messageID, sessionID: other, text: "Fix the failing tests", resume: false }) .pipe(Effect.flip) expect(failure).toMatchObject({ _tag: "Session.PromptConflictError", sessionID: other, messageID }) @@ -692,7 +730,7 @@ describe("SessionV2.prompt", () => { .prompt({ id: messageID, sessionID, - prompt: PromptInput.Prompt.make({ text: "Conflicting prompt" }), + text: "Conflicting prompt", resume: false, }) .pipe(Effect.flip) @@ -709,7 +747,7 @@ describe("SessionV2.prompt", () => { executionCalls.length = 0 wakeCalls.length = 0 - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Run by default" }) }) + yield* session.prompt({ sessionID, text: "Run by default" }) expect(executionCalls).toEqual([]) expect(wakeCalls).toEqual([sessionID]) @@ -725,7 +763,7 @@ describe("SessionV2.prompt", () => { yield* session.prompt({ sessionID, - prompt: PromptInput.Prompt.make({ text: "Run explicitly" }), + text: "Run explicitly", resume: true, }) @@ -741,10 +779,150 @@ describe("SessionV2.prompt", () => { executionCalls.length = 0 wakeCalls.length = 0 - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Do not run" }), resume: false }) + yield* session.prompt({ sessionID, text: "Do not run", resume: false }) expect(executionCalls).toEqual([]) expect(wakeCalls).toEqual([]) }), ) + + it.effect("treats prompt metadata as durable retry identity", () => + Effect.gen(function* () { + yield* setup + const session = yield* SessionV2.Service + const input = { + id: messageID, + sessionID, + text: "Deploy", + metadata: { source: "api" }, + resume: false, + } + + const first = yield* session.prompt(input) + const retried = yield* session.prompt(input) + const failure = yield* session.prompt({ ...input, metadata: { source: "plugin" } }).pipe(Effect.flip) + + expect(retried).toEqual(first) + expect(first.data.metadata).toEqual({ source: "api" }) + expect(failure._tag).toBe("Session.PromptConflictError") + }), + ) + + it.effect("durably admits synthetic input before transcript promotion", () => + Effect.gen(function* () { + yield* setup + const session = yield* SessionV2.Service + const events = yield* EventV2.Service + const { db } = yield* Database.Service + + const input = yield* session.synthetic({ + id: messageID, + sessionID, + text: "Background work completed", + description: "shell completion", + metadata: { job: "shell" }, + resume: false, + }) + + expect(yield* session.messages({ sessionID })).toEqual([]) + expect(yield* admitted(input.id)).toMatchObject({ + type: "synthetic", + sessionID, + delivery: "steer", + data: { + text: "Background work completed", + description: "shell completion", + metadata: { job: "shell" }, + }, + }) + + yield* SessionInput.promoteSteers(db, events, sessionID) + + expect(yield* session.messages({ sessionID })).toMatchObject([ + { + id: messageID, + type: "synthetic", + text: "Background work completed", + description: "shell completion", + metadata: { job: "shell" }, + }, + ]) + }), + ) + + it.effect("reconciles exact synthetic retries and rejects conflicting reuse", () => + Effect.gen(function* () { + yield* setup + const session = yield* SessionV2.Service + const events = yield* EventV2.Service + const database = yield* Database.Service + const input = { id: messageID, sessionID, text: "Completed", resume: false } + + const entries = yield* Effect.all([session.synthetic(input), session.synthetic(input)], { + concurrency: "unbounded", + }) + yield* SessionInput.promoteSteers(database.db, events, sessionID) + const promotedRetry = yield* session.synthetic(input) + const failure = yield* session.synthetic({ ...input, text: "Different completion" }).pipe(Effect.flip) + + expect(entries[1]).toEqual(entries[0]) + expect(promotedRetry).toMatchObject({ id: messageID, type: "synthetic", promotedSeq: expect.any(Number) }) + expect(failure).toMatchObject({ _tag: "Session.SyntheticConflictError", sessionID, inputID: messageID }) + expect(yield* admittedCount).toBe(1) + expect(yield* eventCount(EventV2.versionedType(SessionEvent.InputAdmitted.type, 1))).toBe(1) + }), + ) + + it.effect("keeps synthetic queue input pending until the queue boundary", () => + Effect.gen(function* () { + yield* setup + const session = yield* SessionV2.Service + const events = yield* EventV2.Service + const { db } = yield* Database.Service + + const input = yield* session.synthetic({ + sessionID, + text: "Queued completion", + delivery: "queue", + resume: false, + }) + + expect(input.delivery).toBe("queue") + expect(yield* SessionInput.promoteSteers(db, events, sessionID)).toBe(0) + expect(yield* session.messages({ sessionID })).toEqual([]) + expect(yield* SessionInput.promoteNextQueued(db, events, sessionID)).toBe(true) + expect(yield* session.messages({ sessionID })).toMatchObject([ + { id: input.id, type: "synthetic", text: "Queued completion" }, + ]) + }), + ) + + it.effect("promotes prompt and synthetic steers in admission order", () => + Effect.gen(function* () { + yield* setup + const session = yield* SessionV2.Service + const events = yield* EventV2.Service + const { db } = yield* Database.Service + + yield* session.prompt({ + sessionID, + text: "First prompt", + resume: false, + }) + yield* session.synthetic({ sessionID, text: "Background completion", resume: false }) + yield* session.prompt({ + sessionID, + text: "Second prompt", + resume: false, + }) + + yield* SessionInput.promoteSteers(db, events, sessionID) + + expect( + (yield* session.messages({ sessionID, order: "asc" })).map((message) => + message.type === "user" || message.type === "synthetic" ? message.text : message.type, + ), + ).toEqual(["First prompt", "Background completion", "Second prompt"]) + }), + ) }) diff --git a/packages/core/test/session-runner-recorded.test.ts b/packages/core/test/session-runner-recorded.test.ts index 36b8f85061..7fa33da84a 100644 --- a/packages/core/test/session-runner-recorded.test.ts +++ b/packages/core/test/session-runner-recorded.test.ts @@ -18,7 +18,6 @@ import { SessionV2 } from "@opencode-ai/core/session" import { Snapshot } from "@opencode-ai/core/snapshot" import { SessionCompaction } from "@opencode-ai/core/session/compaction" import { SessionTitle } from "@opencode-ai/core/session/title" -import { PromptInput } from "@opencode-ai/schema/prompt-input" import { SessionProjector } from "@opencode-ai/core/session/projector" import { SessionExecution } from "@opencode-ai/core/session/execution" import { SessionRunCoordinator } from "@opencode-ai/core/session/run-coordinator" @@ -179,7 +178,7 @@ describe("SessionRunnerLLM recorded", () => { const session = yield* SessionV2.Service const prompt = yield* session.prompt({ sessionID, - prompt: PromptInput.Prompt.make({ text: "Say hello in one short sentence." }), + text: "Say hello in one short sentence.", resume: false, }) @@ -200,8 +199,8 @@ describe("SessionRunnerLLM recorded", () => { .orderBy(EventTable.seq) .all()).map((event) => event.type), ).toEqual([ - "session.prompt.admitted.1", - "session.prompt.promoted.1", + "session.input.admitted.1", + "session.input.promoted.1", "session.step.started.1", "session.text.started.1", "session.text.ended.1", diff --git a/packages/core/test/session-runner.test.ts b/packages/core/test/session-runner.test.ts index 35091f37b9..4465b9e350 100644 --- a/packages/core/test/session-runner.test.ts +++ b/packages/core/test/session-runner.test.ts @@ -29,7 +29,6 @@ import { Snapshot } from "@opencode-ai/core/snapshot" import { SessionEvent } from "@opencode-ai/core/session/event" import { SessionInput } from "@opencode-ai/core/session/input" import { SessionMessage } from "@opencode-ai/core/session/message" -import { PromptInput } from "@opencode-ai/schema/prompt-input" import { Money } from "@opencode-ai/schema/money" import { SessionProjector } from "@opencode-ai/core/session/projector" import { SessionExecution } from "@opencode-ai/core/session/execution" @@ -65,13 +64,14 @@ import { Location } from "@opencode-ai/core/location" import { ProviderV2 } from "@opencode-ai/core/provider" import { Cause, DateTime, Deferred, Effect, Exit, Fiber, Layer, Schema, Stream } from "effect" import { TestClock } from "effect/testing" -import { asc, eq } from "drizzle-orm" +import { and, asc, eq } from "drizzle-orm" import { testEffect } from "./lib/effect" const requests: LLMRequest[] = [] let response: LLMEvent[] = [] let responses: LLMEvent[][] | undefined let responseStream: Stream.Stream | undefined +let responseStreams: Stream.Stream[] | undefined let streamGate: Deferred.Deferred | undefined let streamStarted: Deferred.Deferred | undefined let streamFailure: LLMError | undefined @@ -86,6 +86,7 @@ const client = Layer.succeed( prepare: () => Effect.die("unused"), stream: ((request: LLMRequest) => { requests.push(request) + if (responseStreams) return responseStreams.shift() ?? Stream.empty if (responseStream) { const stream = responseStream responseStream = undefined @@ -181,6 +182,20 @@ test("does not apply an ineligible tier without base pricing", () => { const authorizations: Tool.Context[] = [] const executions: string[] = [] +const permissionFail = Tool.make({ + description: "Reject a permission", + input: Schema.Struct({}), + output: Schema.Struct({}), + execute: () => + new ToolFailure({ + message: "Permission denied: edit", + error: new PermissionV2.BlockedError({ + rules: [], + permission: "edit", + resources: ["src/index.ts"], + }), + }), +}) const permission = Layer.succeed( PermissionV2.Service, PermissionV2.Service.of({ @@ -389,8 +404,7 @@ const it = testEffect( ) const sessionID = SessionV2.ID.make("ses_runner_test") const otherSessionID = SessionV2.ID.make("ses_runner_other") -const admit = (session: SessionV2.Interface, text: string) => - session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text }), resume: false }) +const admit = (session: SessionV2.Interface, text: string) => session.prompt({ sessionID, text, resume: false }) const insertSession = (id: SessionV2.ID) => Effect.gen(function* () { @@ -427,6 +441,7 @@ const setup = Effect.gen(function* () { responses = undefined streamFailure = undefined responseStream = undefined + responseStreams = undefined streamGate = undefined streamStarted = undefined toolExecutionGate = undefined @@ -503,6 +518,22 @@ const recordedEventTypes = (id: SessionV2.ID) => ) }) +const recordedToolInputEnds = (id: SessionV2.ID, callID: string) => + Effect.gen(function* () { + const { db } = yield* Database.Service + return (yield* db + .select({ data: EventTable.data }) + .from(EventTable) + .where( + and( + eq(EventTable.aggregate_id, id), + eq(EventTable.type, EventV2.versionedType(SessionEvent.Tool.Input.Ended.type, 1)), + ), + ) + .all() + .pipe(Effect.orDie)).filter((event) => event.data.callID === callID) + }) + const recordedStepSettlementEvents = (id: SessionV2.ID, assistantMessageID: SessionMessage.ID) => Effect.gen(function* () { const { db } = yield* Database.Service @@ -774,7 +805,7 @@ describe("SessionRunnerLLM", () => { const message = yield* session.prompt({ sessionID, - prompt: PromptInput.Prompt.make({ text: "Run automatically" }), + text: "Run automatically", }) yield* session.wait(sessionID) @@ -785,6 +816,34 @@ describe("SessionRunnerLLM", () => { }), ) + it.effect("runs a follow-up when synthetic input arrives during an active continuation", () => + Effect.gen(function* () { + const session = yield* setup + const secondStarted = yield* Deferred.make() + const releaseSecond = yield* Deferred.make() + responseStreams = [ + Stream.fromIterable(reply.tool("call-echo", "echo", { text: "background started" })), + Stream.unwrap( + Deferred.succeed(secondStarted, undefined).pipe( + Effect.andThen(Deferred.await(releaseSecond)), + Effect.as(Stream.fromIterable(reply.stop())), + ), + ), + Stream.fromIterable(reply.text("Handled completion", "text-completion")), + ] + yield* admit(session, "Start background work") + const running = yield* session.resume(sessionID).pipe(Effect.forkChild({ startImmediately: true })) + yield* Deferred.await(secondStarted) + + yield* session.synthetic({ sessionID, text: "Background work completed" }) + yield* Deferred.succeed(releaseSecond, undefined) + yield* Fiber.join(running) + + expect(requests).toHaveLength(3) + expect(userTexts(requests[2]!)).toContain("Background work completed") + }), + ) + it.effect("streams one request with registry definitions from chronological V2 user history", () => Effect.gen(function* () { const session = yield* setup @@ -813,7 +872,7 @@ describe("SessionRunnerLLM", () => { yield* session.prompt({ id: messageID, sessionID, - prompt: PromptInput.Prompt.make({ text: "First" }), + text: "First", resume: false, }) @@ -832,7 +891,7 @@ describe("SessionRunnerLLM", () => { ).toBeUndefined() systemUnavailable = false - yield* session.prompt({ id: messageID, sessionID, prompt: PromptInput.Prompt.make({ text: "First" }) }) + yield* session.prompt({ id: messageID, sessionID, text: "First" }) yield* session.wait(sessionID) expect(requests).toHaveLength(1) @@ -1099,7 +1158,7 @@ describe("SessionRunnerLLM", () => { .run() .pipe(Effect.orDie) const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Inspect files" }), resume: false }) + yield* session.prompt({ sessionID, text: "Inspect files", resume: false }) requests.length = 0 response = [] @@ -1120,7 +1179,7 @@ describe("SessionRunnerLLM", () => { const release = yield* Deferred.make() pluginFlushHook = Deferred.await(release) const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Wait for plugins" }), resume: false }) + yield* session.prompt({ sessionID, text: "Wait for plugins", resume: false }) requests.length = 0 response = [] @@ -1409,9 +1468,10 @@ describe("SessionRunnerLLM", () => { expect((yield* session.messages({ sessionID })).find((message) => message.id === first.id)).toBeUndefined() yield* admit(session, "Steer after compaction") + yield* session.synthetic({ sessionID, text: "Completion after compaction", resume: false }) yield* session.prompt({ sessionID, - prompt: PromptInput.Prompt.make({ text: "Queue after compaction" }), + text: "Queue after compaction", delivery: "queue", resume: false, }) @@ -1423,6 +1483,7 @@ describe("SessionRunnerLLM", () => { expect(requests).toHaveLength(4) expect(userTexts(requests[1])[0]).toContain("Create a new anchored summary") expect(userTexts(requests[2])).toContain("Steer after compaction") + expect(userTexts(requests[2])).toContain("Completion after compaction") expect(userTexts(requests[3])).toContain("Queue after compaction") expect(yield* SessionInput.pendingCompaction((yield* Database.Service).db, sessionID)).toBeUndefined() expect((yield* session.messages({ sessionID })).find((message) => message.id === first.id)).toMatchObject({ @@ -1451,7 +1512,7 @@ describe("SessionRunnerLLM", () => { const compaction = yield* session.compact({ sessionID }) yield* session.prompt({ sessionID, - prompt: PromptInput.Prompt.make({ text: "Continue after failure" }), + text: "Continue after failure", delivery: "queue", resume: false, }) @@ -2189,7 +2250,7 @@ describe("SessionRunnerLLM", () => { const first = yield* session.resume(sessionID).pipe(Effect.forkChild) yield* Deferred.await(streamStarted) - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Change direction" }) }) + yield* session.prompt({ sessionID, text: "Change direction" }) yield* Deferred.succeed(streamGate, undefined) yield* Fiber.join(first) streamGate = undefined @@ -2221,7 +2282,7 @@ describe("SessionRunnerLLM", () => { yield* Deferred.await(streamStarted) yield* session.prompt({ sessionID, - prompt: PromptInput.Prompt.make({ text: "Wait until continuation ends" }), + text: "Wait until continuation ends", delivery: "queue", }) yield* Deferred.succeed(streamGate, undefined) @@ -2250,7 +2311,7 @@ describe("SessionRunnerLLM", () => { yield* Deferred.await(streamStarted) yield* session.prompt({ sessionID, - prompt: PromptInput.Prompt.make({ text: "Run after interrupt" }), + text: "Run after interrupt", delivery: "queue", }) yield* session.interrupt(sessionID) @@ -2284,7 +2345,7 @@ describe("SessionRunnerLLM", () => { yield* Deferred.await(streamStarted) yield* session.prompt({ sessionID, - prompt: PromptInput.Prompt.make({ text: "Steer after interrupt" }), + text: "Steer after interrupt", }) yield* session.interrupt(sessionID) expect(yield* Fiber.await(run)).toMatchObject({ _tag: "Failure" }) @@ -2315,8 +2376,8 @@ describe("SessionRunnerLLM", () => { const first = yield* session.resume(sessionID).pipe(Effect.forkChild) yield* Deferred.await(streamStarted) - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Queue first" }), delivery: "queue" }) - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Queue second" }), delivery: "queue" }) + yield* session.prompt({ sessionID, text: "Queue first", delivery: "queue" }) + yield* session.prompt({ sessionID, text: "Queue second", delivery: "queue" }) yield* Deferred.succeed(streamGate, undefined) yield* Fiber.join(first) streamGate = undefined @@ -2335,7 +2396,7 @@ describe("SessionRunnerLLM", () => { yield* admit(session, "Start steering") yield* session.prompt({ sessionID, - prompt: PromptInput.Prompt.make({ text: "Queue for later" }), + text: "Queue for later", delivery: "queue", resume: false, }) @@ -2362,16 +2423,17 @@ describe("SessionRunnerLLM", () => { const first = yield* session.resume(sessionID).pipe(Effect.forkChild) while (requests.length < 1) yield* Effect.yieldNow - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Queue first" }), delivery: "queue" }) - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Queue second" }), delivery: "queue" }) + yield* session.prompt({ sessionID, text: "Queue first", delivery: "queue" }) + yield* session.prompt({ sessionID, text: "Queue second", delivery: "queue" }) streamGate = secondGate yield* Deferred.succeed(firstGate, undefined) while (requests.length < 2) yield* Effect.yieldNow - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Steer before next queued input" }) }) + yield* session.prompt({ sessionID, text: "Steer before next queued input" }) yield* session.prompt({ sessionID, - prompt: PromptInput.Prompt.make({ text: "Also steer before next queued input" }), + text: "Also steer before next queued input", }) + yield* session.synthetic({ sessionID, text: "Background completion before next queued input" }) yield* Deferred.succeed(secondGate, undefined) yield* Fiber.join(first) streamGate = undefined @@ -2384,12 +2446,14 @@ describe("SessionRunnerLLM", () => { "Queue first", "Steer before next queued input", "Also steer before next queued input", + "Background completion before next queued input", ]) expect(userTexts(requests[3]!)).toEqual([ "Start working", "Queue first", "Steer before next queued input", "Also steer before next queued input", + "Background completion before next queued input", "Queue second", ]) }), @@ -2406,8 +2470,8 @@ describe("SessionRunnerLLM", () => { const first = yield* session.resume(sessionID).pipe(Effect.forkChild) yield* Deferred.await(streamStarted) - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "First steer" }) }) - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Second steer" }) }) + yield* session.prompt({ sessionID, text: "First steer" }) + yield* session.prompt({ sessionID, text: "Second steer" }) yield* Deferred.succeed(streamGate, undefined) yield* Fiber.join(first) streamGate = undefined @@ -2433,7 +2497,7 @@ describe("SessionRunnerLLM", () => { const first = yield* session.resume(sessionID).pipe(Effect.forkChild) yield* Deferred.await(streamStarted) - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Recover with this" }) }) + yield* session.prompt({ sessionID, text: "Recover with this" }) yield* Deferred.succeed(streamGate, undefined) expect(yield* Fiber.join(first).pipe(Effect.flip)).toBe(streamFailure) @@ -2592,7 +2656,7 @@ describe("SessionRunnerLLM", () => { const session = yield* setup yield* session.prompt({ sessionID, - prompt: PromptInput.Prompt.make({ text: "Wait in queue" }), + text: "Wait in queue", delivery: "queue", resume: false, }) @@ -2611,7 +2675,7 @@ describe("SessionRunnerLLM", () => { const events = yield* EventV2.Service const defect = new Error("fail after prompt promotion") let fail = true - yield* events.project(SessionEvent.PromptPromoted, () => (fail ? Effect.die(defect) : Effect.void)) + yield* events.project(SessionEvent.InputPromoted, () => (fail ? Effect.die(defect) : Effect.void)) yield* admit(session, "Recover promoted input") expect(yield* session.resume(sessionID).pipe(Effect.catchDefect(Effect.succeed))).toBe(defect) @@ -2631,7 +2695,7 @@ describe("SessionRunnerLLM", () => { const session = yield* setup const events = yield* EventV2.Service yield* events.listen((event) => - event.type === SessionEvent.PromptPromoted.type + event.type === SessionEvent.InputPromoted.type ? Effect.die("fail after prompt promotion commits") : Effect.void, ) @@ -2651,7 +2715,7 @@ describe("SessionRunnerLLM", () => { yield* admit(session, "Run first") yield* session.prompt({ sessionID: otherSessionID, - prompt: PromptInput.Prompt.make({ text: "Run second" }), + text: "Run second", resume: false, }) @@ -2686,12 +2750,12 @@ describe("SessionRunnerLLM", () => { yield* insertSession(otherLongSessionID) yield* session.prompt({ sessionID: longSessionID, - prompt: PromptInput.Prompt.make({ text: "Run long session" }), + text: "Run long session", resume: false, }) yield* session.prompt({ sessionID: otherLongSessionID, - prompt: PromptInput.Prompt.make({ text: "Run other long session" }), + text: "Run other long session", resume: false, }) @@ -2946,22 +3010,7 @@ describe("SessionRunnerLLM", () => { Effect.gen(function* () { const session = yield* setup const registry = yield* ToolRegistry.Service - yield* registry.register({ - permissionfail: Tool.make({ - description: "Reject a permission", - input: Schema.Struct({}), - output: Schema.Struct({}), - execute: () => - new ToolFailure({ - message: "Permission denied: edit", - error: new PermissionV2.BlockedError({ - rules: [], - permission: "edit", - resources: ["src/index.ts"], - }), - }), - }), - }) + yield* registry.register({ permissionfail: permissionFail }) yield* admit(session, "Reject permission") responses = [ reply.tool("call-permission", "permissionfail", {}), @@ -3000,6 +3049,90 @@ describe("SessionRunnerLLM", () => { }), ) + const rejectPermissionWhileToolInputStreams = (lateEvent: LLMEvent) => + Effect.gen(function* () { + const session = yield* setup + const registry = yield* ToolRegistry.Service + const releaseLateEvent = yield* Deferred.make() + yield* registry.register({ permissionfail: permissionFail }) + const events = yield* EventV2.Service + const permissionFailed = yield* events + .subscribe(SessionEvent.Tool.Failed) + .pipe( + Stream.filter((event) => event.data.sessionID === sessionID && event.data.callID === "call-permission"), + Stream.runHead, + Effect.forkScoped({ startImmediately: true }), + ) + yield* admit(session, "Reject permission while another tool input streams") + responseStream = Stream.concat( + Stream.fromIterable([ + LLMEvent.stepStart({ index: 0 }), + LLMEvent.toolInputStart({ id: "call-streaming", name: "echo" }), + LLMEvent.toolCall({ id: "call-permission", name: "permissionfail", input: {} }), + ]), + Stream.fromEffect(Deferred.await(releaseLateEvent)).pipe(Stream.flatMap(() => Stream.make(lateEvent))), + ) + + const run = yield* session.resume(sessionID).pipe(Effect.forkChild) + yield* Fiber.join(permissionFailed).pipe(Effect.timeout("1 second")) + yield* Effect.yieldNow + const inputEndsBeforeRelease = yield* recordedToolInputEnds(sessionID, "call-streaming") + yield* Deferred.succeed(releaseLateEvent, undefined) + const exit = yield* Fiber.await(run) + return { + exit, + inputEndsBeforeRelease, + context: yield* session.context(sessionID), + inputEnds: yield* recordedToolInputEnds(sessionID, "call-streaming"), + } + }) + + for (const testCase of [ + { + name: "does not end concurrent tool input when permission is rejected", + event: LLMEvent.toolInputDelta({ + id: "call-streaming", + name: "echo", + text: '{"text":"still streaming"}', + }), + defect: "Tool input delta after end: call-streaming", + }, + { + name: "does not duplicate concurrent tool input end when permission is rejected", + event: LLMEvent.toolInputEnd({ id: "call-streaming", name: "echo" }), + defect: "Duplicate tool input end: call-streaming", + }, + ]) { + it.effect(testCase.name, () => + Effect.gen(function* () { + const result = yield* rejectPermissionWhileToolInputStreams(testCase.event) + + expect(result.inputEndsBeforeRelease).toHaveLength(0) + expect(Exit.isFailure(result.exit)).toBe(true) + if (Exit.isFailure(result.exit)) { + expect(Cause.pretty(result.exit.cause)).not.toContain(testCase.defect) + expect(Cause.hasDies(result.exit.cause)).toBe(false) + } + expect(result.context).toMatchObject([ + { type: "user" }, + { + type: "assistant", + error: { type: "permission.rejected", message: "Permission denied: edit" }, + content: [ + { + type: "tool", + id: "call-streaming", + state: { status: "error", error: { type: "aborted", message: "Tool execution interrupted" } }, + }, + { type: "tool", id: "call-permission", state: { status: "error" } }, + ], + }, + ]) + expect(result.inputEnds).toHaveLength(1) + }), + ) + } + it.effect("interrupts runner continuation when a question is cancelled", () => Effect.gen(function* () { const session = yield* setup @@ -3253,7 +3386,7 @@ describe("SessionRunnerLLM", () => { const run = yield* session.resume(sessionID).pipe(Effect.forkChild) yield* Deferred.await(streamStarted) - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Change direction" }) }) + yield* session.prompt({ sessionID, text: "Change direction" }) yield* Deferred.succeed(streamGate, undefined) yield* Fiber.join(run) streamGate = undefined diff --git a/packages/core/test/session-title.test.ts b/packages/core/test/session-title.test.ts index f962672494..aeb0963660 100644 --- a/packages/core/test/session-title.test.ts +++ b/packages/core/test/session-title.test.ts @@ -9,7 +9,6 @@ import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { EventV2 } from "@opencode-ai/core/event" import { SessionEvent } from "@opencode-ai/core/session/event" import { SessionMessage } from "@opencode-ai/core/session/message" -import { Prompt } from "@opencode-ai/schema/prompt" import { SessionProjector } from "@opencode-ai/core/session/projector" import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model" import { SessionTable } from "@opencode-ai/core/session/sql" @@ -84,13 +83,12 @@ const prompt = (sessionID: SessionV2.ID, text: string) => Effect.gen(function* () { const events = yield* EventV2.Service const messageID = SessionMessage.ID.create() - yield* events.publish(SessionEvent.PromptAdmitted, { + yield* events.publish(SessionEvent.InputAdmitted, { sessionID, inputID: messageID, - prompt: Prompt.make({ text }), - delivery: "steer", + input: { type: "user", data: { text }, delivery: "steer" }, }) - yield* events.publish(SessionEvent.PromptPromoted, { + yield* events.publish(SessionEvent.InputPromoted, { sessionID, inputID: messageID, }) diff --git a/packages/core/test/session-todo.test.ts b/packages/core/test/session-todo.test.ts deleted file mode 100644 index 9b848f8270..0000000000 --- a/packages/core/test/session-todo.test.ts +++ /dev/null @@ -1,94 +0,0 @@ -import { describe, expect } from "bun:test" -import { asc } from "drizzle-orm" -import { Effect } from "effect" -import { Database } from "@opencode-ai/core/database/database" -import { LayerNode } from "@opencode-ai/core/effect/layer-node" -import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" -import { EventV2 } from "@opencode-ai/core/event" -import { Project } from "@opencode-ai/core/project" -import { ProjectTable } from "@opencode-ai/core/project/sql" -import { AbsolutePath } from "@opencode-ai/core/schema" -import { SessionV2 } from "@opencode-ai/core/session" -import { SessionTable, TodoTable } from "@opencode-ai/core/session/sql" -import { SessionTodo } from "@opencode-ai/core/session/todo" -import { testEffect } from "./lib/effect" - -const it = testEffect(AppNodeBuilder.build(LayerNode.group([Database.node, EventV2.node, SessionTodo.node]))) -const sessionID = SessionV2.ID.make("ses_todo_test") - -const setup = Effect.gen(function* () { - const { db } = yield* Database.Service - yield* db - .insert(ProjectTable) - .values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] }) - .run() - .pipe(Effect.orDie) - yield* db - .insert(SessionTable) - .values({ - id: sessionID, - project_id: Project.ID.global, - slug: "todo", - directory: "/project", - title: "todo", - version: "test", - }) - .run() - .pipe(Effect.orDie) -}) - -describe("SessionTodo", () => { - it.effect("replaces persisted todos in order and publishes updates", () => - Effect.gen(function* () { - yield* setup - const { db } = yield* Database.Service - const events = yield* EventV2.Service - const todos = yield* SessionTodo.Service - const published = new Array() - const unsubscribe = yield* events.listen((event) => - Effect.sync(() => { - if (event.type === SessionTodo.Event.Updated.type) published.push(event) - }), - ) - yield* Effect.addFinalizer(() => unsubscribe) - - yield* todos.update({ - sessionID, - todos: [ - { content: "second", status: "pending", priority: "low" }, - { content: "first", status: "in_progress", priority: "high" }, - ], - }) - expect(yield* todos.get(sessionID)).toEqual([ - { content: "second", status: "pending", priority: "low" }, - { content: "first", status: "in_progress", priority: "high" }, - ]) - expect( - (yield* db.select().from(TodoTable).orderBy(asc(TodoTable.position)).all().pipe(Effect.orDie)).map((row) => ({ - content: row.content, - position: row.position, - })), - ).toEqual([ - { content: "second", position: 0 }, - { content: "first", position: 1 }, - ]) - - yield* todos.update({ sessionID, todos: [{ content: "replacement", status: "completed", priority: "medium" }] }) - expect(yield* todos.get(sessionID)).toEqual([{ content: "replacement", status: "completed", priority: "medium" }]) - - yield* todos.update({ sessionID, todos: [] }) - expect(yield* todos.get(sessionID)).toEqual([]) - expect(published.map((event) => event.data)).toEqual([ - { - sessionID, - todos: [ - { content: "second", status: "pending", priority: "low" }, - { content: "first", status: "in_progress", priority: "high" }, - ], - }, - { sessionID, todos: [{ content: "replacement", status: "completed", priority: "medium" }] }, - { sessionID, todos: [] }, - ]) - }), - ) -}) diff --git a/packages/core/test/shared-schema.test.ts b/packages/core/test/shared-schema.test.ts index 100d44cfe2..8cbe9f9e2f 100644 --- a/packages/core/test/shared-schema.test.ts +++ b/packages/core/test/shared-schema.test.ts @@ -24,7 +24,6 @@ import { LLM } from "@opencode-ai/schema/llm" import { Permission } from "@opencode-ai/schema/permission" import { Pty } from "@opencode-ai/schema/pty" import { Reference } from "@opencode-ai/schema/reference" -import { SessionTodo } from "@opencode-ai/schema/session-todo" import { Skill } from "@opencode-ai/schema/skill" import { AbsolutePath, DateTimeUtcFromMillis, optional, statics } from "@opencode-ai/schema/schema" import { ProviderV2 } from "@opencode-ai/core/provider" @@ -46,7 +45,6 @@ test("Core reuses the canonical shared schemas", async () => { coreReference, coreSessionInput, coreSessionMessage, - coreSessionTodo, coreSkill, coreV2Schema, coreSchema, @@ -67,7 +65,6 @@ test("Core reuses the canonical shared schemas", async () => { import("@opencode-ai/core/reference"), import("@opencode-ai/core/session/input"), import("@opencode-ai/core/session/message"), - import("@opencode-ai/core/session/todo"), import("@opencode-ai/core/skill"), import("@opencode-ai/core/v2-schema"), import("@opencode-ai/core/schema"), @@ -137,7 +134,9 @@ test("Core reuses the canonical shared schemas", async () => { [SessionV2.Info, Session.Info], [SessionV2.ListAnchor, Session.ListAnchor], [coreSessionInput.Delivery, SessionInput.Delivery], - [coreSessionInput.Admitted, SessionInput.Admitted], + [coreSessionInput.Message, SessionInput.Message], + [coreSessionInput.User, SessionInput.User], + [coreSessionInput.Synthetic, SessionInput.Synthetic], [coreSessionMessage.ID, SessionMessage.ID], [coreSessionMessage.AssistantRetry, SessionMessage.AssistantRetry], [coreSessionMessage.AgentSelected, SessionMessage.AgentSelected], @@ -158,8 +157,6 @@ test("Core reuses the canonical shared schemas", async () => { [coreSessionMessage.Assistant, SessionMessage.Assistant], [coreSessionMessage.Compaction, SessionMessage.Compaction], [coreSessionMessage.Info, SessionMessage.Info], - [coreSessionTodo.Info, SessionTodo.Info], - [coreSessionTodo.Event, SessionTodo.Event], [coreSkill.DirectorySource, Skill.DirectorySource], [coreSkill.UrlSource, Skill.UrlSource], [coreSkill.EmbeddedSource, Skill.EmbeddedSource], diff --git a/packages/core/test/tool-question.test.ts b/packages/core/test/tool-question.test.ts index 7afb2158c8..63b6f3a912 100644 --- a/packages/core/test/tool-question.test.ts +++ b/packages/core/test/tool-question.test.ts @@ -156,6 +156,7 @@ describe("QuestionTool", () => { expect(assertions).toMatchObject([{ sessionID, action: "question", resources: ["*"] }]) expect(capturedInput()).toEqual({ sessionID, + title: "Questions", metadata: { kind: "question", tool: { messageID: toolIdentity.assistantMessageID, callID: "call-question" } }, mode: "form", fields: [ @@ -202,6 +203,7 @@ describe("QuestionTool", () => { }) expect(capturedInput()).toEqual({ sessionID, + title: "Questions", metadata: { kind: "question", tool: { messageID: toolIdentity.assistantMessageID, callID: "call-question" } }, mode: "form", fields: [], diff --git a/packages/core/test/tool-subagent.test.ts b/packages/core/test/tool-subagent.test.ts index 55223a9daa..e8d770d00a 100644 --- a/packages/core/test/tool-subagent.test.ts +++ b/packages/core/test/tool-subagent.test.ts @@ -1,5 +1,5 @@ import { describe, expect } from "bun:test" -import { DateTime, Effect, Layer, Schema } from "effect" +import { DateTime, Effect, Fiber, Layer, Schema, Stream } from "effect" import { Money } from "@opencode-ai/schema/money" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { LayerNode } from "@opencode-ai/core/effect/layer-node" @@ -16,6 +16,7 @@ import { LocationServiceMap } from "@opencode-ai/core/location-service-map" import { SessionV2 } from "@opencode-ai/core/session" import { SessionEvent } from "@opencode-ai/core/session/event" import { SessionExecution } from "@opencode-ai/core/session/execution" +import { SessionInput } from "@opencode-ai/core/session/input" import { SessionMessage } from "@opencode-ai/core/session/message" import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model" import { SessionStore } from "@opencode-ai/core/session/store" @@ -263,6 +264,13 @@ describe("SubagentTool", () => { const locations = yield* LocationServiceMap.Service const registry = yield* ToolRegistry.Service.pipe(Effect.provide(locations.get(parent.location))) yield* waitForTool(registry, SubagentTool.name) + const events = yield* EventV2.Service + const admitted = yield* events.subscribe(SessionEvent.InputAdmitted).pipe( + Stream.filter((event) => event.data.sessionID === parent.id && event.data.input.type === "synthetic"), + Stream.take(1), + Stream.runCollect, + Effect.forkScoped({ startImmediately: true }), + ) const settled = yield* settleTool(registry, { sessionID: parent.id, @@ -277,7 +285,10 @@ describe("SubagentTool", () => { const childID = outputSessionID(settled.output?.structured) expect(settled.output?.structured).toMatchObject({ status: "running" }) - yield* Effect.yieldNow + const admission = Array.from(yield* Fiber.join(admitted))[0] + expect(admission?.data.input.data.text).toContain(` message.type === "synthetic") expect(synthetic).toHaveLength(1) expect(synthetic[0]?.text).toContain(` - Effect.sync(() => assertions.push(input)).pipe( - Effect.andThen( - deny - ? Effect.fail( - new PermissionV2.BlockedError({ - rules: [], - permission: input.action, - resources: input.resources, - }), - ) - : Effect.void, - ), - ), - ask: () => Effect.die("unused"), - reply: () => Effect.die("unused"), - get: () => Effect.die("unused"), - forSession: () => Effect.die("unused"), - list: () => Effect.die("unused"), - }), -) -const it = testEffect( - AppNodeBuilder.build( - LayerNode.group([ - Database.node, - EventV2.node, - SessionTodo.node, - ToolRegistry.node, - ToolRegistry.toolsNode, - todoWriteToolNode, - ]), - [ - [PermissionV2.node, permission], - [ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig], - ], - ), -) - -const setup = Effect.gen(function* () { - assertions.length = 0 - deny = false - const { db } = yield* Database.Service - yield* db - .insert(ProjectTable) - .values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] }) - .run() - .pipe(Effect.orDie) - yield* db - .insert(SessionTable) - .values({ - id: sessionID, - project_id: Project.ID.global, - slug: "todowrite", - directory: "/project", - title: "todowrite", - version: "test", - }) - .run() - .pipe(Effect.orDie) -}) - -const call = (todos: ReadonlyArray, id = "call-todowrite") => ({ - sessionID, - ...toolIdentity, - call: { type: "tool-call" as const, id, name: TodoWriteTool.name, input: { todos } }, -}) - -describe("TodoWriteTool", () => { - it.effect("registers, approves the wildcard resource, persists todos, and returns typed output", () => - Effect.gen(function* () { - yield* setup - const registry = yield* ToolRegistry.Service - const service = yield* SessionTodo.Service - const todoList: ReadonlyArray = [ - { content: "Implement slice", status: "in_progress", priority: "high" }, - ] - - expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual([TodoWriteTool.name]) - expect(yield* settleTool(registry, call(todoList))).toEqual({ - result: { type: "text", value: JSON.stringify(todoList, null, 2) }, - output: { - structured: { todos: todoList }, - content: [{ type: "text", text: JSON.stringify(todoList, null, 2) }], - }, - }) - expect(assertions).toMatchObject([{ sessionID, action: "todowrite", resources: ["*"], save: ["*"] }]) - expect(yield* service.get(sessionID)).toEqual(todoList) - }), - ) - - it.effect("does not update persisted todos when permission is denied", () => - Effect.gen(function* () { - yield* setup - const registry = yield* ToolRegistry.Service - const service = yield* SessionTodo.Service - yield* service.update({ sessionID, todos: [{ content: "keep", status: "pending", priority: "low" }] }) - deny = true - - expect( - yield* executeTool(registry, call([{ content: "blocked", status: "completed", priority: "high" }])), - ).toEqual({ - type: "error", - value: "Unable to update todos", - }) - expect(yield* service.get(sessionID)).toEqual([{ content: "keep", status: "pending", priority: "low" }]) - expect(assertions).toMatchObject([{ sessionID, action: "todowrite", resources: ["*"], save: ["*"] }]) - }), - ) -}) diff --git a/packages/docs/openapi.json b/packages/docs/openapi.json index c2cd5a1d21..543a418173 100644 --- a/packages/docs/openapi.json +++ b/packages/docs/openapi.json @@ -75,6 +75,62 @@ "summary": "Check server health" } }, + "/api/server": { + "get": { + "tags": [ + "server" + ], + "operationId": "v2.server.get", + "parameters": [], + "security": [], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "urls": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "urls" + ], + "additionalProperties": false + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + } + }, + "description": "Return the URLs that can be used to connect to this server.", + "summary": "Get server information" + } + }, "/api/location": { "get": { "tags": [ @@ -18676,6 +18732,7 @@ "required": [ "id", "sessionID", + "title", "mode", "fields" ], @@ -18714,6 +18771,7 @@ "required": [ "id", "sessionID", + "title", "mode", "url" ], @@ -18791,6 +18849,7 @@ } }, "required": [ + "title", "mode" ], "additionalProperties": false @@ -24584,6 +24643,7 @@ "required": [ "id", "sessionID", + "title", "mode", "fields" ], @@ -24622,6 +24682,7 @@ "required": [ "id", "sessionID", + "title", "mode", "url" ], @@ -24844,88 +24905,6 @@ ], "additionalProperties": false }, - "Todo": { - "type": "object", - "properties": { - "content": { - "type": "string", - "description": "Brief description of the task" - }, - "status": { - "type": "string", - "description": "Current status of the task: pending, in_progress, completed, cancelled" - }, - "priority": { - "type": "string", - "description": "Priority level of the task: high, medium, low" - } - }, - "required": [ - "content", - "status", - "priority" - ], - "additionalProperties": false - }, - "todo.updated": { - "type": "object", - "properties": { - "id": { - "type": "string", - "allOf": [ - { - "pattern": "^evt_" - } - ] - }, - "created": { - "type": "number" - }, - "metadata": { - "type": "object" - }, - "type": { - "type": "string", - "enum": [ - "todo.updated" - ] - }, - "location": { - "$ref": "#/components/schemas/Location.Ref" - }, - "data": { - "type": "object", - "properties": { - "sessionID": { - "type": "string", - "allOf": [ - { - "pattern": "^ses" - } - ] - }, - "todos": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Todo" - } - } - }, - "required": [ - "sessionID", - "todos" - ], - "additionalProperties": false - } - }, - "required": [ - "id", - "created", - "type", - "data" - ], - "additionalProperties": false - }, "SessionStatus": { "anyOf": [ { @@ -26475,9 +26454,6 @@ { "$ref": "#/components/schemas/form.cancelled" }, - { - "$ref": "#/components/schemas/todo.updated" - }, { "$ref": "#/components/schemas/session.status" }, @@ -27072,6 +27048,9 @@ { "name": "health" }, + { + "name": "server" + }, { "name": "location" }, diff --git a/packages/llm/src/protocols/openai-responses.ts b/packages/llm/src/protocols/openai-responses.ts index e2889d5b6d..c6d621f152 100644 --- a/packages/llm/src/protocols/openai-responses.ts +++ b/packages/llm/src/protocols/openai-responses.ts @@ -198,11 +198,11 @@ const OpenAIResponsesStreamItem = Schema.Struct({ }) type OpenAIResponsesStreamItem = Schema.Schema.Type -// OpenAI Responses surfaces provider failures in two related shapes. The -// streaming `error` event carries the details at the top level -// (`{ type: "error", code, message, param, sequence_number }`), while -// `response.failed` carries them under `response.error`. We capture both so -// the parser can surface a useful provider-error message in either path. +// The Responses schema puts streaming error details at the top level and +// response failures under `response.error`. The official SDK also recognizes +// an event-level HTTP-style `error` envelope, so accept all three shapes here. +// https://github.com/openai/openai-openapi/blob/5162af98d3147432c14680df789e8e12d4891e6b/openapi.yaml#L67234-L67382 +// https://github.com/openai/openai-node/blob/61539248cbe04665de68a71e6fd878127ae4db87/src/core/streaming.ts#L58-L85 const OpenAIResponsesErrorPayload = Schema.Struct({ code: optionalNull(Schema.String), message: optionalNull(Schema.String), @@ -227,9 +227,10 @@ const OpenAIResponsesEvent = Schema.Struct({ [Schema.Record(Schema.String, Schema.Unknown)], ), ), - code: Schema.optional(Schema.String), + code: optionalNull(Schema.String), message: Schema.optional(Schema.String), - param: Schema.optional(Schema.String), + param: optionalNull(Schema.String), + error: optionalNull(OpenAIResponsesErrorPayload), }) type OpenAIResponsesEvent = Schema.Schema.Type @@ -899,7 +900,7 @@ const onResponseFinish = (state: ParserState, event: OpenAIResponsesEvent): Step // the bare message — production rate limits and context-length failures used // to be indistinguishable from generic stream drops. const providerErrorMessage = (event: OpenAIResponsesEvent, fallback: string): string => { - const nested = event.response?.error ?? undefined + const nested = event.error ?? event.response?.error ?? undefined const message = event.message || nested?.message || undefined const code = event.code || nested?.code || undefined if (message && code) return `${code}: ${message}` @@ -907,7 +908,7 @@ const providerErrorMessage = (event: OpenAIResponsesEvent, fallback: string): st } const providerError = (event: OpenAIResponsesEvent, fallback: string) => { - const code = event.code || event.response?.error?.code || undefined + const code = event.code || event.error?.code || event.response?.error?.code || undefined const message = providerErrorMessage(event, fallback) return LLMEvent.providerError({ message, diff --git a/packages/llm/test/provider/openai-responses.test.ts b/packages/llm/test/provider/openai-responses.test.ts index 3d048d4eee..421617b6db 100644 --- a/packages/llm/test/provider/openai-responses.test.ts +++ b/packages/llm/test/provider/openai-responses.test.ts @@ -1443,7 +1443,7 @@ describe("OpenAI Responses route", () => { }), ) - it.effect("surfaces error event details even when they arrive nested under response.error", () => + it.effect("surfaces error event details nested under response.error", () => Effect.gen(function* () { // Some OpenAI-compatible proxies and older SDK versions wrap the // top-level error fields into a nested `response.error` payload @@ -1471,6 +1471,65 @@ describe("OpenAI Responses route", () => { }), ) + it.effect("surfaces error event details nested under error", () => + Effect.gen(function* () { + const response = yield* LLMClient.generate(request).pipe( + Effect.provide( + fixedResponse( + sseEvents({ + type: "error", + sequence_number: 2, + error: { + type: "invalid_request_error", + code: "context_length_exceeded", + message: "prompt too long", + param: "input", + }, + }), + ), + ), + ) + + expect(response.events).toEqual([ + { + type: "provider-error", + message: "context_length_exceeded: prompt too long", + classification: "context-overflow", + }, + ]) + }), + ) + + it.effect("accepts nullable fields in spec-compliant error events", () => + Effect.gen(function* () { + const response = yield* LLMClient.generate(request).pipe( + Effect.provide( + fixedResponse( + sseEvents({ + type: "error", + code: null, + message: "Something went wrong", + param: null, + sequence_number: 1, + }), + ), + ), + ) + + expect(response.events).toEqual([{ type: "provider-error", message: "Something went wrong" }]) + }), + ) + + it.effect("falls back to a stable default when error is null", () => + Effect.gen(function* () { + const response = yield* LLMClient.generate(request).pipe( + Effect.provide(fixedResponse(sseEvents({ type: "error", error: null }))), + ) + + expect(response.events).toEqual([{ type: "provider-error", message: "OpenAI Responses stream error" }]) + }), + ) + it.effect("falls back to a stable default when both error and response are absent", () => Effect.gen(function* () { const response = yield* LLMClient.generate(request).pipe( diff --git a/packages/opencode/specs/effect/tools.md b/packages/opencode/specs/effect/tools.md index 61b8aa40dd..862b582701 100644 --- a/packages/opencode/specs/effect/tools.md +++ b/packages/opencode/specs/effect/tools.md @@ -50,7 +50,6 @@ These exported tool definitions currently use `Tool.define(...)` in `src/tool`: - [x] `read.ts` - [x] `skill.ts` - [x] `task.ts` -- [x] `todo.ts` - [x] `webfetch.ts` - [x] `websearch.ts` - [x] `write.ts` diff --git a/packages/opencode/specs/tui-plugins.md b/packages/opencode/specs/tui-plugins.md index 06f7757d51..9dd5932420 100644 --- a/packages/opencode/specs/tui-plugins.md +++ b/packages/opencode/specs/tui-plugins.md @@ -381,7 +381,6 @@ Mode pushes are automatically tracked by the plugin runtime. If a plugin is disa - `vcs?.branch` - `session.count()` - `session.diff(sessionID)` - - `session.todo(sessionID)` - `session.messages(sessionID)` - `session.status(sessionID)` - `session.permission(sessionID)` @@ -511,12 +510,11 @@ Metadata is persisted by plugin id. - `internal:sidebar-context` - `internal:sidebar-mcp` - `internal:sidebar-lsp` -- `internal:sidebar-todo` - `internal:sidebar-files` - `internal:sidebar-footer` - `internal:plugin-manager` -Sidebar content order is currently: context `100`, mcp `200`, lsp `300`, todo `400`, files `500`. +Sidebar content order is currently: context `100`, mcp `200`, lsp `300`, files `500`. The plugin manager is exposed as a command with title `Plugins` and value `plugins.list`. diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts index 5c819e521d..6d4a7599d9 100644 --- a/packages/opencode/src/agent/agent.ts +++ b/packages/opencode/src/agent/agent.ts @@ -182,13 +182,7 @@ const layer = Layer.effect( general: { name: "general", description: `General-purpose agent for researching complex questions and executing multi-step tasks. Use this agent to execute multiple units of work in parallel.`, - permission: Permission.merge( - defaults, - Permission.fromConfig({ - todowrite: "deny", - }), - user, - ), + permission: Permission.merge(defaults, user), options: {}, mode: "subagent", native: true, diff --git a/packages/opencode/src/agent/subagent-permissions.ts b/packages/opencode/src/agent/subagent-permissions.ts index b1b99b484e..596b30a175 100644 --- a/packages/opencode/src/agent/subagent-permissions.ts +++ b/packages/opencode/src/agent/subagent-permissions.ts @@ -8,20 +8,18 @@ import type { Agent } from "./agent" * 1. The parent session's deny rules and external_directory rules. * Parent agent restrictions only govern that agent; the subagent's own * permissions determine its capabilities. - * 2. Default `todowrite` and `task` denies if the subagent's own ruleset - * doesn't already permit them. + * 2. A default `task` deny if the subagent's own ruleset doesn't already + * permit it. */ export function deriveSubagentSessionPermission(input: { parentSessionPermission: PermissionV1.Ruleset subagent: Agent.Info }): PermissionV1.Ruleset { const canTask = input.subagent.permission.some((rule) => rule.permission === "task") - const canTodo = input.subagent.permission.some((rule) => rule.permission === "todowrite") return [ ...input.parentSessionPermission.filter( (rule) => rule.permission === "external_directory" || rule.action === "deny", ), - ...(canTodo ? [] : [{ permission: "todowrite" as const, pattern: "*" as const, action: "deny" as const }]), ...(canTask ? [] : [{ permission: "task" as const, pattern: "*" as const, action: "deny" as const }]), ] } diff --git a/packages/opencode/src/cli/cmd/agent.ts b/packages/opencode/src/cli/cmd/agent.ts index c9c1d2c167..076160b41f 100644 --- a/packages/opencode/src/cli/cmd/agent.ts +++ b/packages/opencode/src/cli/cmd/agent.ts @@ -16,19 +16,7 @@ type AgentMode = "all" | "primary" | "subagent" // Permission keys (not raw tool names). Multiple tools can map to a single // permission — e.g. write/edit/apply_patch all gate on `edit` — so we configure // agents at the permission level to match how the runtime actually enforces it. -const AVAILABLE_PERMISSIONS = [ - "bash", - "read", - "edit", - "glob", - "grep", - "webfetch", - "task", - "todowrite", - "websearch", - "lsp", - "skill", -] +const AVAILABLE_PERMISSIONS = ["bash", "read", "edit", "glob", "grep", "webfetch", "task", "websearch", "lsp", "skill"] const AgentCreateCommand = effectCmd({ command: "create", diff --git a/packages/opencode/src/cli/cmd/attach.ts b/packages/opencode/src/cli/cmd/attach.ts index 4339892cf9..0d06bd7c41 100644 --- a/packages/opencode/src/cli/cmd/attach.ts +++ b/packages/opencode/src/cli/cmd/attach.ts @@ -84,6 +84,7 @@ export const AttachCommand = cmd({ const { createLegacyTuiPluginHost } = await import("@/plugin/tui/runtime") await Effect.runPromise( run({ + // @ts-expect-error V1 does not consume the V2-only server input. client: createOpencodeClient({ baseUrl: args.url, headers, directory }), api: OpenCode.make({ baseUrl: args.url, headers }), config, diff --git a/packages/opencode/src/cli/cmd/github.handler.ts b/packages/opencode/src/cli/cmd/github.handler.ts index d55c0bf3fb..a36d4743ff 100644 --- a/packages/opencode/src/cli/cmd/github.handler.ts +++ b/packages/opencode/src/cli/cmd/github.handler.ts @@ -167,6 +167,7 @@ export const githubInstall = Effect.fn("Cli.github.install")(function* () { const providers = await Effect.runPromise(modelsDev.get()).then((p) => { // TODO: add guide for copilot, for now just hide it + // @ts-expect-error dead V1 expects the pre-normalized ModelsDev provider record. delete p["github-copilot"] return p }) @@ -187,6 +188,7 @@ export const githubInstall = Effect.fn("Cli.github.install")(function* () { step2 = [ ` 2. Add the following secrets in org or repo (${app.owner}/${app.repo}) settings`, "", + // @ts-expect-error dead V1 expects provider env fields instead of normalized snapshots. ...providers[provider].env.map((e) => ` - ${e}`), ].join("\n") } @@ -238,12 +240,17 @@ export const githubInstall = Effect.fn("Cli.github.install")(function* () { providers, values(), sortBy( + // @ts-expect-error dead V1 expects provider IDs on the ModelsDev record values. (x) => priority[x.id] ?? 99, + // @ts-expect-error dead V1 expects provider names on the ModelsDev record values. (x) => x.name ?? x.id, ), map((x) => ({ + // @ts-expect-error dead V1 expects provider names on the ModelsDev record values. label: x.name, + // @ts-expect-error dead V1 expects provider IDs on the ModelsDev record values. value: x.id, + // @ts-expect-error dead V1 expects provider IDs on the ModelsDev record values. hint: priority[x.id] === 0 ? "recommended" : undefined, })), ), @@ -330,6 +337,7 @@ export const githubInstall = Effect.fn("Cli.github.install")(function* () { const envStr = provider === "amazon-bedrock" ? "" + // @ts-expect-error dead V1 expects provider env fields instead of normalized snapshots. : `\n env:${providers[provider].env.map((e) => `\n ${e}: \${{ secrets.${e} }}`).join("")}` await Filesystem.write( @@ -820,7 +828,6 @@ export const githubRun = Effect.fn("Cli.github.run")(function* (args: { event?: async function subscribeSessionEvents() { const TOOL: Record = { - todowrite: ["Todo", UI.Style.TEXT_WARNING_BOLD], bash: ["Shell", UI.Style.TEXT_DANGER_BOLD], edit: ["Edit", UI.Style.TEXT_SUCCESS_BOLD], glob: ["Glob", UI.Style.TEXT_INFO_BOLD], diff --git a/packages/opencode/src/cli/cmd/providers.ts b/packages/opencode/src/cli/cmd/providers.ts index 3775123d83..2d5a0ceaf7 100644 --- a/packages/opencode/src/cli/cmd/providers.ts +++ b/packages/opencode/src/cli/cmd/providers.ts @@ -264,6 +264,7 @@ export const ProvidersListCommand = effectCmd({ const database = yield* modelsDev.get() for (const [providerID, result] of results) { + // @ts-expect-error dead V1 indexes the normalized ModelsDev snapshot array by provider ID. const name = database[providerID]?.name || providerID yield* Prompt.log.info(`${name} ${UI.Style.TEXT_DIM}${result.type}`) } @@ -273,9 +274,11 @@ export const ProvidersListCommand = effectCmd({ const activeEnvVars: Array<{ provider: string; envVar: string }> = [] for (const [providerID, provider] of Object.entries(database)) { + // @ts-expect-error dead V1 expects provider env fields instead of normalized snapshots. for (const envVar of provider.env) { if (process.env[envVar]) { activeEnvVars.push({ + // @ts-expect-error dead V1 expects provider names instead of normalized snapshot info. provider: provider.name || providerID, envVar, }) @@ -362,6 +365,7 @@ export const ProvidersLoginCommand = effectCmd({ const enabled = config.enabled_providers ? new Set(config.enabled_providers) : undefined const allProviders = yield* modelsDev.get() + // @ts-expect-error dead V1 expects a provider-keyed record, not normalized ModelsDev snapshots. const providers: Record = {} for (const [key, value] of Object.entries(allProviders)) { if ((enabled ? enabled.has(key) : true) && !disabled.has(key)) providers[key] = value @@ -395,6 +399,7 @@ export const ProvidersLoginCommand = effectCmd({ map((x) => ({ label: x.name, value: x.id, + // @ts-expect-error dead V1 provider IDs come from pre-normalized ModelsDev records. hint: { opencode: "recommended", openai: "ChatGPT Plus/Pro or API key", @@ -419,6 +424,7 @@ export const ProvidersLoginCommand = effectCmd({ } provider = match.value } else { + // @ts-expect-error dead V1 selection values come from pre-normalized ModelsDev records. provider = yield* promptValue( yield* Prompt.autocomplete({ message: "Select provider", @@ -511,6 +517,7 @@ export const ProvidersLogoutCommand = effectCmd({ } const database = yield* modelsDev.get() const options = credentials.map(([key, value]) => ({ + // @ts-expect-error dead V1 indexes the normalized ModelsDev snapshot array by provider ID. label: (database[key]?.name || key) + UI.Style.TEXT_DIM + " (" + value.type + ")", value: key, })) @@ -518,6 +525,7 @@ export const ProvidersLogoutCommand = effectCmd({ ? options.find( (option) => option.value === args.provider || + // @ts-expect-error dead V1 indexes the normalized ModelsDev snapshot array by provider ID. database[option.value]?.name?.toLowerCase() === args.provider?.toLowerCase(), )?.value : yield* promptValue( diff --git a/packages/opencode/src/cli/cmd/run.ts b/packages/opencode/src/cli/cmd/run.ts index 2021aadadd..9478357a2d 100644 --- a/packages/opencode/src/cli/cmd/run.ts +++ b/packages/opencode/src/cli/cmd/run.ts @@ -112,6 +112,7 @@ export const RunCommand = effectCmd({ file: args.file ?? [], title: args.title, server: args.server ?? args.attach, + // @ts-expect-error V1 does not consume the V2-only resolved server input. password: args.password ?? process.env.OPENCODE_PASSWORD ?? process.env.OPENCODE_SERVER_PASSWORD, username: args.username ?? process.env.OPENCODE_SERVER_USERNAME, directory: args.dir, diff --git a/packages/opencode/src/cli/cmd/tui.ts b/packages/opencode/src/cli/cmd/tui.ts index f4d1649a14..79e21ed6bf 100644 --- a/packages/opencode/src/cli/cmd/tui.ts +++ b/packages/opencode/src/cli/cmd/tui.ts @@ -159,6 +159,7 @@ export const TuiThreadCommand = cmd({ const { createLegacyTuiPluginHost } = await import("@/plugin/tui/runtime") await Effect.runPromise( run({ + // @ts-expect-error V1 does not consume the V2-only server input. client: createOpencodeClient({ baseUrl: url, headers, directory: cwd }), api: OpenCode.make({ baseUrl: url, headers }), async onSnapshot() { diff --git a/packages/opencode/src/effect/app-runtime.ts b/packages/opencode/src/effect/app-runtime.ts index 19328e73cb..1ebed3b472 100644 --- a/packages/opencode/src/effect/app-runtime.ts +++ b/packages/opencode/src/effect/app-runtime.ts @@ -20,7 +20,6 @@ import { Skill } from "@/skill" import { Discovery } from "@/skill/discovery" import { Question } from "@/question" import { Permission } from "@/permission" -import { Todo } from "@/session/todo" import { Session } from "@/session/session" import { SessionStatus } from "@/session/status" import { SessionRunState } from "@/session/run-state" @@ -75,7 +74,6 @@ export const AppLayer = AppNodeBuilderV1.build( Discovery.node, Question.node, Permission.node, - Todo.node, Session.node, SessionProjector.node, SessionStatus.node, diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index c979fc4465..dae82870f8 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -1156,6 +1156,7 @@ export class Service extends Context.Service()("@opencode/Pr export const use = serviceUse(Service) +// @ts-expect-error dead V1 consumes the removed pre-normalized ModelsDev model type. function cost(c: ModelsDev.Model["cost"]): Model["cost"] { const result: Model["cost"] = { input: c?.input ?? 0, @@ -1166,6 +1167,7 @@ function cost(c: ModelsDev.Model["cost"]): Model["cost"] { }, } if (c?.tiers) { + // @ts-expect-error dead V1 relies on the removed ModelsDev cost tier context. result.tiers = c.tiers.map((item) => ({ input: item.input, output: item.output, @@ -1189,6 +1191,7 @@ function cost(c: ModelsDev.Model["cost"]): Model["cost"] { return result } +// @ts-expect-error dead V1 consumes removed provider and model types instead of snapshots. function fromModelsDevModel(provider: ModelsDev.Provider, model: ModelsDev.Model): Model { const base: Model = { id: ModelV2.ID.make(model.id), @@ -1240,26 +1243,34 @@ function fromModelsDevModel(provider: ModelsDev.Provider, model: ModelsDev.Model } } +// @ts-expect-error dead V1 consumes the removed pre-normalized ModelsDev provider type. export function fromModelsDevProvider(provider: ModelsDev.Provider): Info { const models: Record = {} for (const [key, model] of Object.entries(provider.models)) { models[key] = fromModelsDevModel(provider, model) + // @ts-expect-error dead V1 expects raw model metadata inside a provider instead of normalized snapshots. for (const [mode, opts] of Object.entries(model.experimental?.modes ?? {})) { + // @ts-expect-error dead V1 expects raw model IDs inside normalized ModelsDev data. const id = `${model.id}-${mode}` const base = fromModelsDevModel(provider, model) models[id] = { ...base, id: ModelV2.ID.make(id), + // @ts-expect-error dead V1 expects raw model names and mode options. name: `${model.name} ${mode[0].toUpperCase()}${mode.slice(1)}`, + // @ts-expect-error dead V1 expects raw mode costs inside normalized ModelsDev data. cost: opts.cost ? mergeDeep(base.cost, cost(opts.cost)) : base.cost, + // @ts-expect-error dead V1 expects raw mode provider bodies inside normalized ModelsDev data. options: opts.provider?.body ? Object.fromEntries( + // @ts-expect-error dead V1 expects raw mode provider bodies inside normalized ModelsDev data. Object.entries(opts.provider.body).map(([k, v]) => [ k.replace(/_([a-z])/g, (_, c) => c.toUpperCase()), v, ]), ) : base.options, + // @ts-expect-error dead V1 expects raw mode headers inside normalized ModelsDev data. headers: opts.provider?.headers ?? base.headers, } } @@ -1344,13 +1355,13 @@ const layer = Layer.effect( function mergeProvider(providerID: ProviderV2.ID, provider: Partial) { const existing = providers[providerID] if (existing) { - // @ts-expect-error + // @ts-expect-error dead V1 merges partial provider records with legacy semantics. providers[providerID] = mergeDeep(existing, provider) return } + // @ts-expect-error dead V1 indexes the normalized ModelsDev snapshot array by provider ID. const match = database[providerID] if (!match) return - // @ts-expect-error providers[providerID] = mergeDeep(match, provider) } @@ -1376,6 +1387,7 @@ const layer = Layer.effect( const providerID = ProviderV2.ID.make(p.id) if (disabled.has(providerID)) continue + // @ts-expect-error dead V1 indexes the normalized ModelsDev snapshot array by provider ID. const provider = database[providerID] if (!provider) continue const pluginAuth = yield* auth.get(providerID).pipe(Effect.orDie) @@ -1397,6 +1409,7 @@ const layer = Layer.effect( // extend database from config for (const [providerID, provider] of configProviders) { + // @ts-expect-error dead V1 indexes the normalized ModelsDev snapshot array by provider ID. const existing = database[providerID] const parsed: Info = { id: ProviderV2.ID.make(providerID), @@ -1414,6 +1427,7 @@ const layer = Layer.effect( model.provider?.npm ?? provider.npm ?? existingModel?.api.npm ?? + // @ts-expect-error dead V1 indexes normalized ModelsDev snapshots for raw provider fields. modelsDev[providerID]?.npm ?? "@ai-sdk/openai-compatible" const name = iife(() => { @@ -1426,6 +1440,7 @@ const layer = Layer.effect( api: { id: apiID, npm: apiNpm, + // @ts-expect-error dead V1 indexes normalized ModelsDev snapshots for raw provider fields. url: model.provider?.api ?? provider?.api ?? existingModel?.api.url ?? modelsDev[providerID]?.api ?? "", }, status: model.status ?? existingModel?.status ?? "active", @@ -1486,6 +1501,7 @@ const layer = Layer.effect( ) parsed.models[modelID] = parsedModel } + // @ts-expect-error dead V1 mutates a provider-keyed record, not normalized snapshots. database[providerID] = parsed } @@ -1528,6 +1544,7 @@ const layer = Layer.effect( const options = yield* Effect.promise(() => plugin.auth!.loader!( () => bridge.promise(auth.get(providerID).pipe(Effect.orDie)) as any, + // @ts-expect-error dead V1 indexes the normalized ModelsDev snapshot array by provider ID. toPublicInfo(database[plugin.auth!.provider]), ), ) @@ -1539,6 +1556,7 @@ const layer = Layer.effect( for (const [id, fn] of Object.entries(custom(dep))) { const providerID = ProviderV2.ID.make(id) if (disabled.has(providerID)) continue + // @ts-expect-error dead V1 indexes the normalized ModelsDev snapshot array by provider ID. const data = database[providerID] if (!data) { continue diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index f42e14139f..bf65a8a2e6 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -5,6 +5,7 @@ import type * as Provider from "./provider" import type * as ModelsDev from "@opencode-ai/core/models-dev" import { iife } from "@/util/iife" +// @ts-expect-error dead V1 consumes the removed pre-normalized ModelsDev model type. type Modality = NonNullable["input"][number] function mimeToModality(mime: string): Modality | undefined { @@ -396,6 +397,7 @@ function unsupportedParts(msgs: ModelMessage[], model: Provider.Model): ModelMes const filename = part.type === "file" ? part.filename : undefined const modality = mimeToModality(mime) if (!modality) return part + // @ts-expect-error dead V1 modality typing came from the removed ModelsDev model schema. if (model.capabilities.input[modality]) return part const name = filename ? `"${filename}"` : modality diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/session.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/session.ts index 959a303dc9..4fb08281ca 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/session.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/session.ts @@ -8,7 +8,6 @@ import { SessionPrompt } from "@/session/prompt" import { SessionRevert } from "@/session/revert" import { SessionStatus } from "@/session/status" import { SessionSummary } from "@/session/summary" -import { Todo } from "@/session/todo" import { MessageID, PartID, SessionID } from "@/session/schema" import { Snapshot } from "@/snapshot" import { Schema, Struct } from "effect" @@ -80,7 +79,6 @@ export const SessionPaths = { status: `${root}/status`, get: `${root}/:sessionID`, children: `${root}/:sessionID/children`, - todo: `${root}/:sessionID/todo`, diff: `${root}/:sessionID/diff`, messages: `${root}/:sessionID/message`, message: `${root}/:sessionID/message/:messageID`, @@ -153,18 +151,6 @@ export const SessionApi = HttpApi.make("session") description: "Retrieve all child sessions that were forked from the specified parent session.", }), ), - HttpApiEndpoint.get("todo", SessionPaths.todo, { - params: { sessionID: SessionID }, - query: WorkspaceRoutingQuery, - success: described(Schema.Array(Todo.Info), "Todo list"), - error: [HttpApiError.BadRequest, ApiNotFoundError], - }).annotateMerge( - OpenApi.annotations({ - identifier: "session.todo", - summary: "Get session todos", - description: "Retrieve the todo list associated with a specific session, showing tasks and action items.", - }), - ), HttpApiEndpoint.get("diff", SessionPaths.diff, { params: { sessionID: SessionID }, query: DiffQuery, diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/provider.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/provider.ts index e1377b6f75..19087c01f6 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/provider.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/provider.ts @@ -42,6 +42,7 @@ export const providerHandlers = HttpApiBuilder.group(InstanceHttpApi, "provider" const all = yield* ModelsDev.Service.use((s) => s.get()) const disabled = new Set(config.disabled_providers ?? []) const enabled = config.enabled_providers ? new Set(config.enabled_providers) : undefined + // @ts-expect-error dead V1 expects a provider-keyed record, not normalized ModelsDev snapshots. const filtered: Record = {} for (const [key, value] of Object.entries(all)) { if ((enabled ? enabled.has(key) : true) && !disabled.has(key)) filtered[key] = value diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts index 662585020a..f0478b6fbb 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts @@ -13,7 +13,6 @@ import { SessionRevert } from "@/session/revert" import { SessionRunState } from "@/session/run-state" import { SessionStatus } from "@/session/status" import { SessionSummary } from "@/session/summary" -import { Todo } from "@/session/todo" import { MessageID, PartID, SessionID } from "@/session/schema" import { NamedError } from "@opencode-ai/core/util/error" import { Cause, Effect, Option, Schema, Scope } from "effect" @@ -56,7 +55,6 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session", const agentSvc = yield* Agent.Service const permissionSvc = yield* Permission.Service const statusSvc = yield* SessionStatus.Service - const todoSvc = yield* Todo.Service const summary = yield* SessionSummary.Service const events = yield* EventV2Bridge.Service const scope = yield* Scope.Scope @@ -91,11 +89,6 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session", return yield* session.children(ctx.params.sessionID) }) - const todo = Effect.fn("SessionHttpApi.todo")(function* (ctx: { params: { sessionID: SessionID } }) { - yield* requireSession(ctx.params.sessionID) - return yield* todoSvc.get(ctx.params.sessionID) - }) - const diff = Effect.fn("SessionHttpApi.diff")(function* (ctx: { params: { sessionID: SessionID } query: typeof DiffQuery.Type @@ -415,7 +408,6 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session", .handle("status", status) .handle("get", get) .handle("children", children) - .handle("todo", todo) .handle("diff", diff) .handle("messages", messages) .handle("message", message) diff --git a/packages/opencode/src/server/routes/instance/httpapi/server.ts b/packages/opencode/src/server/routes/instance/httpapi/server.ts index 108888d4f6..fad59961b1 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/server.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/server.ts @@ -38,7 +38,6 @@ import { SessionRunState } from "@/session/run-state" import { Session } from "@/session/session" import { SessionStatus } from "@/session/status" import { SessionSummary } from "@/session/summary" -import { Todo } from "@/session/todo" import { SessionShare } from "@/share/session" import { ShareNext } from "@/share/share-next" import { Skill } from "@/skill" @@ -64,8 +63,6 @@ import { PtyTicket } from "@opencode-ai/core/pty/ticket" import { Ripgrep } from "@opencode-ai/core/ripgrep" import { SessionProjector } from "@opencode-ai/core/session/projector" import { SessionV2 } from "@opencode-ai/core/session" -import { SessionExecution } from "@opencode-ai/core/session/execution" -import * as SessionExecutionLocal from "@opencode-ai/core/session/execution/local" import { PluginRuntime } from "@opencode-ai/core/plugin/runtime" import { lazy } from "@/util/lazy" import { CorsConfig, isAllowedCorsOrigin, type CorsOptions } from "@opencode-ai/server/cors" @@ -233,7 +230,6 @@ const app = LayerNode.group([ Question.node, Permission.node, PermissionSaved.node, - Todo.node, Session.node, SessionProjector.node, SessionStatus.node, @@ -304,7 +300,6 @@ export function createRoutes( Layer.provide( AppNodeBuilderV1.build(LayerNode.group([SessionV2.node, PluginRuntime.providerNode]), [ [LocationServiceMap.node, locationServiceMapV2], - [SessionExecution.node, SessionExecutionLocal.node], ]), ), Layer.provide(locationServiceMapV2), diff --git a/packages/opencode/src/session/prompt/anthropic.txt b/packages/opencode/src/session/prompt/anthropic.txt index 21d9c0e9f2..a816861b26 100644 --- a/packages/opencode/src/session/prompt/anthropic.txt +++ b/packages/opencode/src/session/prompt/anthropic.txt @@ -20,58 +20,7 @@ When the user directly asks about OpenCode (eg. "can OpenCode do...", "does Open # Professional objectivity Prioritize technical accuracy and truthfulness over validating the user's beliefs. Focus on facts and problem-solving, providing direct, objective technical info without any unnecessary superlatives, praise, or emotional validation. It is best for the user if OpenCode honestly applies the same rigorous standards to all ideas and disagrees when necessary, even if it may not be what the user wants to hear. Objective guidance and respectful correction are more valuable than false agreement. Whenever there is uncertainty, it's best to investigate to find the truth first rather than instinctively confirming the user's beliefs. -# Task Management -You have access to the TodoWrite tools to help you manage and plan tasks. Use these tools VERY frequently to ensure that you are tracking your tasks and giving the user visibility into your progress. -These tools are also EXTREMELY helpful for planning tasks, and for breaking down larger complex tasks into smaller steps. If you do not use this tool when planning, you may forget to do important tasks - and that is unacceptable. - -It is critical that you mark todos as completed as soon as you are done with a task. Do not batch up multiple tasks before marking them as completed. - -Examples: - - -user: Run the build and fix any type errors -assistant: I'm going to use the TodoWrite tool to write the following items to the todo list: -- Run the build -- Fix any type errors - -I'm now going to run the build using Bash. - -Looks like I found 10 type errors. I'm going to use the TodoWrite tool to write 10 items to the todo list. - -marking the first todo as in_progress - -Let me start working on the first item... - -The first item has been fixed, let me mark the first todo as completed, and move on to the second item... -.. -.. - -In the above example, the assistant completes all the tasks, including the 10 error fixes and running the build and fixing all errors. - - -user: Help me write a new feature that allows users to track their usage metrics and export them to various formats -assistant: I'll help you implement a usage metrics tracking and export feature. Let me first use the TodoWrite tool to plan this task. -Adding the following todos to the todo list: -1. Research existing metrics tracking in the codebase -2. Design the metrics collection system -3. Implement core metrics tracking functionality -4. Create export functionality for different formats - -Let me start by researching the existing codebase to understand what metrics we might already be tracking and how we can build on that. - -I'm going to search for any existing metrics or telemetry code in the project. - -I've found some existing telemetry code. Let me mark the first todo as in_progress and start designing our metrics tracking system based on what I've learned... - -[Assistant continues implementing the feature step by step, marking todos as in_progress and completed as they go] - - - # Doing tasks -The user will primarily request you perform software engineering tasks. This includes solving bugs, adding new functionality, refactoring code, explaining code, and more. For these tasks the following steps are recommended: -- -- Use the TodoWrite tool to plan the task if required - - Tool results and user messages may include tags. tags contain useful information and reminders. They are automatically added by the system, and bear no direct relation to the specific tool results or user messages in which they appear. @@ -93,8 +42,6 @@ user: What is the codebase structure? assistant: [Uses the Task tool] -IMPORTANT: Always use the TodoWrite tool to plan and track tasks throughout the conversation. - # Code References When referencing specific functions or pieces of code include the pattern `file_path:line_number` to allow the user to easily navigate to the source code location. diff --git a/packages/opencode/src/session/prompt/beast.txt b/packages/opencode/src/session/prompt/beast.txt index e92e4d020f..193bd05c52 100644 --- a/packages/opencode/src/session/prompt/beast.txt +++ b/packages/opencode/src/session/prompt/beast.txt @@ -6,7 +6,7 @@ You MUST iterate and keep going until the problem is solved. You have everything you need to resolve this problem. I want you to fully solve this autonomously before coming back to me. -Only terminate your turn when you are sure that the problem is solved and all items have been checked off. Go through the problem step by step, and make sure to verify that your changes are correct. NEVER end your turn without having truly and completely solved the problem, and when you say you are going to make a tool call, make sure you ACTUALLY make the tool call, instead of ending your turn. +Only terminate your turn when you are sure that the problem is solved. Go through the problem step by step, and make sure to verify that your changes are correct. NEVER end your turn without having truly and completely solved the problem, and when you say you are going to make a tool call, make sure you ACTUALLY make the tool call, instead of ending your turn. THE PROBLEM CAN NOT BE SOLVED WITHOUT EXTENSIVE INTERNET RESEARCH. @@ -19,13 +19,13 @@ understanding of third party packages and dependencies is up to date. You must u Always tell the user what you are going to do before making a tool call with a single concise sentence. This will help them understand what you are doing and why. -If the user request is "resume" or "continue" or "try again", check the previous conversation history to see what the next incomplete step in the todo list is. Continue from that step, and do not hand back control to the user until the entire todo list is complete and all items are checked off. Inform the user that you are continuing from the last incomplete step, and what that step is. +If the user request is "resume" or "continue" or "try again", check the previous conversation history to identify the next incomplete step. Continue from that step, and do not hand back control to the user until the problem is solved. Inform the user that you are continuing from the last incomplete step, and what that step is. Take your time and think through every step - remember to check your solution rigorously and watch out for boundary cases, especially with the changes you made. Use the sequential thinking tool if available. Your solution must be perfect. If not, continue working on it. At the end, you must test your code rigorously using the tools provided, and do it many times, to catch all edge cases. If it is not robust, iterate more and make it perfect. Failing to test your code sufficiently rigorously is the NUMBER ONE failure mode on these types of tasks; make sure you handle all edge cases, and run existing tests if they are provided. You MUST plan extensively before each function call, and reflect extensively on the outcomes of the previous function calls. DO NOT do this entire process by making function calls only, as this can impair your ability to solve the problem and think insightfully. -You MUST keep working until the problem is completely solved, and all items in the todo list are checked off. Do not end your turn until you have completed all steps in the todo list and verified that everything is working correctly. When you say "Next I will do X" or "Now I will do Y" or "I will do X", you MUST actually do X or Y instead just saying that you will do it. +You MUST keep working until the problem is completely solved. Do not end your turn until you have completed all necessary steps and verified that everything is working correctly. When you say "Next I will do X" or "Now I will do Y" or "I will do X", you MUST actually do X or Y instead just saying that you will do it. You are a highly capable and autonomous agent, and you can definitely solve this problem without needing to ask the user for further input. @@ -39,7 +39,7 @@ You are a highly capable and autonomous agent, and you can definitely solve this - What are the dependencies and interactions with other parts of the code? 3. Investigate the codebase. Explore relevant files, search for key functions, and gather context. 4. Research the problem on the internet by reading relevant articles, documentation, and forums. -5. Develop a clear, step-by-step plan. Break down the fix into manageable, incremental steps. Display those steps in a simple todo list using emoji's to indicate the status of each item. +5. Develop a clear, step-by-step plan. Break down the fix into manageable, incremental steps. 6. Implement the fix incrementally. Make small, testable code changes. 7. Debug as needed. Use debugging techniques to isolate and resolve issues. 8. Test frequently. Run tests after each change to verify correctness. @@ -73,9 +73,6 @@ Carefully read the issue and think hard about a plan to solve it before coding. ## 5. Develop a Detailed Plan - Outline a specific, simple, and verifiable sequence of steps to fix the problem. -- Create a todo list in markdown format to track your progress. -- Each time you complete a step, check it off using `[x]` syntax. -- Each time you check off a step, display the updated todo list to the user. - Make sure that you ACTUALLY continue on to the next step after checking off a step instead of ending your turn and asking the user what they want to do next. ## 6. Making Code Changes @@ -139,7 +136,6 @@ If you are asked to write a prompt, you should always generate the prompt in ma If you are not writing the prompt in a file, you should always wrap the prompt in triple backticks so that it is formatted correctly and can be easily copied from the chat. -Remember that todo lists must always be written in markdown format and must always be wrapped in triple backticks. # Git If the user tells you to stage and commit, you may do so. diff --git a/packages/opencode/src/session/prompt/copilot-gpt-5.txt b/packages/opencode/src/session/prompt/copilot-gpt-5.txt index d8da6d2017..d7a7776789 100644 --- a/packages/opencode/src/session/prompt/copilot-gpt-5.txt +++ b/packages/opencode/src/session/prompt/copilot-gpt-5.txt @@ -23,14 +23,14 @@ You must use webfetch tool to recursively gather all information from URL's prov 1. Understand the problem deeply. Carefully read the issue and think critically about what is required. 2. Investigate the codebase. Explore relevant files, search for key functions, and gather context. 3. Develop a clear, step-by-step plan. Break down the fix into manageable, -incremental steps - use the todo tool to track your progress. +incremental steps and track your progress. 4. Implement the fix incrementally. Make small, testable code changes. 5. Debug as needed. Use debugging techniques to isolate and resolve issues. 6. Test frequently. Run tests after each change to verify correctness. 7. Iterate until the root cause is fixed and all tests pass. 8. Reflect and validate comprehensively. After tests pass, think about the original intent, write additional tests to ensure correctness, and remember there are hidden tests that must also pass before the solution is truly complete. **CRITICAL - Before ending your turn:** -- Review and update the todo list, marking completed, skipped (with explanations), or blocked items. +- Review the plan, noting completed, skipped (with explanations), or blocked items. ## 1. Deeply Understand the Problem - Carefully read the issue and think hard about a plan to solve it before coding. @@ -50,8 +50,8 @@ incremental steps - use the todo tool to track your progress. ## 3. Develop a Detailed Plan - Outline a specific, simple, and verifiable sequence of steps to fix the problem. -- Create a todo list to track your progress. -- Each time you check off a step, update the todo list. +- Create a plan to track your progress. +- Keep the plan current as you complete each step. - Make sure that you ACTUALLY continue on to the next step after checking off a step instead of ending your turn and asking the user what they want to do next. ## 4. Making Code Changes diff --git a/packages/opencode/src/session/todo.ts b/packages/opencode/src/session/todo.ts deleted file mode 100644 index cd80828de6..0000000000 --- a/packages/opencode/src/session/todo.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { LayerNode } from "@opencode-ai/core/effect/layer-node" -import { SessionID } from "./schema" -import { Effect, Layer, Context } from "effect" -import { Database } from "@opencode-ai/core/database/database" -import { eq } from "drizzle-orm" -import { asc } from "drizzle-orm" -import { TodoTable } from "@opencode-ai/core/session/sql" -import { EventV2Bridge } from "@/event-v2-bridge" -import { SessionTodo } from "@opencode-ai/schema/session-todo" - -export const Info = SessionTodo.Info -export type Info = SessionTodo.Info - -export const Event = SessionTodo.Event - -export interface Interface { - readonly update: (input: { sessionID: SessionID; todos: ReadonlyArray }) => Effect.Effect - readonly get: (sessionID: SessionID) => Effect.Effect -} - -export class Service extends Context.Service()("@opencode/SessionTodo") {} - -const layer = Layer.effect( - Service, - Effect.gen(function* () { - const events = yield* EventV2Bridge.Service - const { db } = yield* Database.Service - - const update = Effect.fn("Todo.update")(function* (input: { sessionID: SessionID; todos: ReadonlyArray }) { - yield* db - .transaction((tx) => - Effect.gen(function* () { - yield* tx.delete(TodoTable).where(eq(TodoTable.session_id, input.sessionID)).run() - if (input.todos.length === 0) return - yield* tx - .insert(TodoTable) - .values( - input.todos.map((todo, position) => ({ - session_id: input.sessionID, - content: todo.content, - status: todo.status, - priority: todo.priority, - position, - })), - ) - .run() - }), - ) - .pipe(Effect.orDie) - yield* events.publish(Event.Updated, input) - }) - - const get = Effect.fn("Todo.get")(function* (sessionID: SessionID) { - const rows = yield* db - .select() - .from(TodoTable) - .where(eq(TodoTable.session_id, sessionID)) - .orderBy(asc(TodoTable.position)) - .all() - .pipe(Effect.orDie) - return rows.map((row) => ({ - content: row.content, - status: row.status, - priority: row.priority, - })) - }) - - return Service.of({ update, get }) - }), -) - -export const node = LayerNode.make({ service: Service, layer: layer, deps: [EventV2Bridge.node, Database.node] }) - -export * as Todo from "./todo" diff --git a/packages/opencode/src/storage/schema.ts b/packages/opencode/src/storage/schema.ts index 06d095f06d..450ccb43d1 100644 --- a/packages/opencode/src/storage/schema.ts +++ b/packages/opencode/src/storage/schema.ts @@ -1,5 +1,5 @@ export { AccountTable, AccountStateTable, ControlAccountTable } from "@opencode-ai/core/account/sql" export { ProjectTable } from "@opencode-ai/core/project/sql" -export { SessionTable, MessageTable, PartTable, TodoTable } from "@opencode-ai/core/session/sql" +export { SessionTable, MessageTable, PartTable } from "@opencode-ai/core/session/sql" export { SessionShareTable } from "@opencode-ai/core/share/sql" export { WorkspaceTable } from "@opencode-ai/core/control-plane/workspace.sql" diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index 037cf236dc..4576bd7a88 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -11,7 +11,6 @@ import { GrepTool } from "./grep" import { ReadTool } from "./read" import { TaskTool } from "./task" import { Database } from "@opencode-ai/core/database/database" -import { TodoWriteTool } from "./todo" import { WebFetchTool } from "./webfetch" import { WriteTool } from "./write" import { InvalidTool } from "./invalid" @@ -39,7 +38,6 @@ import { Format } from "../format" import { InstanceState } from "@/effect/instance-state" import { EffectBridge } from "@/effect/bridge" import { Question } from "../question" -import { Todo } from "../session/todo" import { LSP } from "@/lsp/lsp" import { Instruction } from "../session/instruction" import { FSUtil } from "@opencode-ai/core/fs-util" @@ -97,7 +95,6 @@ const layer = Layer.effect( const task = yield* TaskTool const read = yield* ReadTool const question = yield* QuestionTool - const todo = yield* TodoWriteTool const lsptool = yield* LspTool const plan = yield* PlanExitTool const webfetch = yield* WebFetchTool @@ -211,7 +208,6 @@ const layer = Layer.effect( write: Tool.init(writetool), task: Tool.init(task), fetch: Tool.init(webfetch), - todo: Tool.init(todo), search: Tool.init(websearch), skill: Tool.init(skilltool), patch: Tool.init(patchtool), @@ -234,7 +230,6 @@ const layer = Layer.effect( tool.write, tool.task, tool.fetch, - tool.todo, tool.search, tool.skill, tool.patch, @@ -426,7 +421,6 @@ export const node = LayerNode.make({ Config.node, Plugin.node, Question.node, - Todo.node, Agent.node, Skill.node, Session.node, diff --git a/packages/opencode/src/tool/task.ts b/packages/opencode/src/tool/task.ts index 4039fd36fa..c753d17715 100644 --- a/packages/opencode/src/tool/task.ts +++ b/packages/opencode/src/tool/task.ts @@ -117,9 +117,6 @@ export const TaskTool = Tool.define( subagent: next, }) const childToolDenies = [ - ...(next.permission.some((rule) => rule.permission === "todowrite") - ? [] - : [{ permission: "todowrite" as const, pattern: "*" as const, action: "deny" as const }]), ...(next.permission.some((rule) => rule.permission === id) ? [] : [{ permission: id, pattern: "*" as const, action: "deny" as const }]), diff --git a/packages/opencode/src/tool/todo.ts b/packages/opencode/src/tool/todo.ts deleted file mode 100644 index 69fb0550e8..0000000000 --- a/packages/opencode/src/tool/todo.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { Effect, Schema } from "effect" -import * as Tool from "./tool" -import DESCRIPTION_WRITE from "./todowrite.txt" -import { Todo } from "../session/todo" - -export const Parameters = Schema.Struct({ - todos: Schema.mutable(Schema.Array(Todo.Info)).annotate({ description: "The updated todo list" }), -}) - -type Metadata = { - todos: Todo.Info[] -} - -export const TodoWriteTool = Tool.define( - "todowrite", - Effect.gen(function* () { - const todo = yield* Todo.Service - - return { - description: DESCRIPTION_WRITE, - parameters: Parameters, - execute: (params: Schema.Schema.Type, ctx: Tool.Context) => - Effect.gen(function* () { - yield* ctx.ask({ - permission: "todowrite", - patterns: ["*"], - always: ["*"], - metadata: {}, - }) - - yield* todo.update({ - sessionID: ctx.sessionID, - todos: params.todos, - }) - - return { - title: `${params.todos.filter((x) => x.status !== "completed").length} todos`, - output: JSON.stringify(params.todos, null, 2), - metadata: { - todos: params.todos, - }, - } - }), - } satisfies Tool.DefWithoutID - }), -) diff --git a/packages/opencode/src/tool/todowrite.txt b/packages/opencode/src/tool/todowrite.txt deleted file mode 100644 index a21630c158..0000000000 --- a/packages/opencode/src/tool/todowrite.txt +++ /dev/null @@ -1,44 +0,0 @@ -Create and maintain a structured task list for the current coding session. Tracks progress, organizes multi-step work, and surfaces status to the user. - -## When to use -Use proactively when: -- The task requires 3+ distinct steps or actions (not just 3 tool calls for a single conceptual step) -- The work is non-trivial and benefits from planning -- The user provides multiple tasks (numbered or comma-separated) or explicitly asks for a todo list -- New instructions arrive - capture them as todos -- You start a task - mark it `in_progress` (only one at a time) before working -- You finish a task - mark it `completed` and add any follow-ups discovered during the work - -## When NOT to use -Skip when: -- The work is a single, straightforward task (or <3 trivial steps) -- The request is purely informational or conversational -- Tracking adds no organizational value - -## States -- `pending` - not started -- `in_progress` - actively working (exactly ONE at a time) -- `completed` - finished successfully -- `cancelled` - no longer needed - -## Rules -- Update status in real time; don't batch completions -- Mark `completed` only after the required work is actually done, including any required verification. Never based on intent. -- Keep exactly one `in_progress` while work remains -- If blocked or partial, keep it `in_progress` and add a follow-up todo describing the blocker -- Preserve user-provided commands verbatim (flags, args, order) -- Items should be specific and actionable; break large work into smaller steps - -## Examples - -Use it: -- "Add a dark mode toggle and run the tests" -> multi-step feature + explicit verification -- "Rename getCwd -> getCurrentWorkingDirectory across the repo" -> grep reveals 15 occurrences in 8 files -- "Implement registration, catalog, cart, checkout" -> multiple complex features - -Skip it: -- "How do I print Hello World in Python?" -> informational -- "Add a comment to calculateTotal" -> single edit -- "Run npm install and tell me what happened" -> one command - -When in doubt, use it. diff --git a/packages/opencode/test/agent/agent.test.ts b/packages/opencode/test/agent/agent.test.ts index efeaf408a2..2e13e26a95 100644 --- a/packages/opencode/test/agent/agent.test.ts +++ b/packages/opencode/test/agent/agent.test.ts @@ -116,7 +116,6 @@ it.instance("explore agent denies edit and write", () => expect(explore?.mode).toBe("subagent") expect(evalPerm(explore, "edit")).toBe("deny") expect(evalPerm(explore, "write")).toBe("deny") - expect(evalPerm(explore, "todowrite")).toBe("deny") }), ) @@ -160,16 +159,6 @@ it.instance( }, ) -it.instance("general agent denies todo tools", () => - Effect.gen(function* () { - const general = yield* load((svc) => svc.get("general")) - expect(general).toBeDefined() - expect(general?.mode).toBe("subagent") - expect(general?.hidden).toBeUndefined() - expect(evalPerm(general, "todowrite")).toBe("deny") - }), -) - it.instance("compaction agent denies all permissions", () => Effect.gen(function* () { const compaction = yield* load((svc) => svc.get("compaction")) diff --git a/packages/opencode/test/cli/github-action.test.ts b/packages/opencode/test/cli/github-action.test.ts index 57567d8c9b..2cf914ed78 100644 --- a/packages/opencode/test/cli/github-action.test.ts +++ b/packages/opencode/test/cli/github-action.test.ts @@ -93,7 +93,7 @@ describe("extractResponseText", () => { }) test("returns text even when tool parts follow", () => { - const parts = [createTextPart("I'll help with that."), createToolPart("todowrite", "3 todos")] + const parts = [createTextPart("I'll help with that."), createToolPart("read", "src/index.ts")] expect(extractResponseText(parts)).toBe("I'll help with that.") }) @@ -103,8 +103,7 @@ describe("extractResponseText", () => { }) test("returns null for tool-only response (signals summary needed)", () => { - // This is the exact scenario from the bug report - todowrite with no text - const parts = [createToolPart("todowrite", "8 todos")] + const parts = [createToolPart("read", "src/index.ts")] expect(extractResponseText(parts)).toBeNull() }) diff --git a/packages/opencode/test/cli/help/__snapshots__/help-snapshots.test.ts.snap b/packages/opencode/test/cli/help/__snapshots__/help-snapshots.test.ts.snap index 5c84f0d389..90da1632d5 100644 --- a/packages/opencode/test/cli/help/__snapshots__/help-snapshots.test.ts.snap +++ b/packages/opencode/test/cli/help/__snapshots__/help-snapshots.test.ts.snap @@ -519,8 +519,8 @@ Options: --description what the agent should do [string] --mode agent mode [string] [choices: "all", "primary", "subagent"] --permissions, --tools comma-separated list of permissions to allow (default: all). - Available: "bash, read, edit, glob, grep, webfetch, task, todowrite, - websearch, lsp, skill" [string] + Available: "bash, read, edit, glob, grep, webfetch, task, websearch, + lsp, skill" [string] -m, --model model to use in the format of provider/model [string]" `; diff --git a/packages/opencode/test/cli/run/noninteractive.test.ts b/packages/opencode/test/cli/run/noninteractive.test.ts index 377d6c582b..599d9daced 100644 --- a/packages/opencode/test/cli/run/noninteractive.test.ts +++ b/packages/opencode/test/cli/run/noninteractive.test.ts @@ -10,7 +10,7 @@ function ok(data: T) { } function form(id: string, sessionID: string): FormInfo { - return { id, sessionID, mode: "form", fields: [] } + return { id, sessionID, title: "Input requested", mode: "form", fields: [] } } function formCreated(info: FormInfo): V2Event { @@ -21,7 +21,7 @@ function prompted(inputID: string): V2Event { return { id: "evt_prompted", created: 0, - type: "session.prompt.promoted", + type: "session.input.promoted", durable: { aggregateID: "ses_1", seq: 0, version: 1 }, data: { sessionID: "ses_1", inputID }, } @@ -84,7 +84,7 @@ async function run(input: { turn: (inputID: string) => V2Event[]; pendingForms?: files: [], thinking: false, format: "default", - dangerouslySkipPermissions: false, + auto: false, attached: input.attached ?? false, renderTool: () => Promise.resolve(), renderToolError: () => Promise.resolve(), diff --git a/packages/opencode/test/cli/run/scrollback.surface.test.ts b/packages/opencode/test/cli/run/scrollback.surface.test.ts index abe40559ed..039ab156fd 100644 --- a/packages/opencode/test/cli/run/scrollback.surface.test.ts +++ b/packages/opencode/test/cli/run/scrollback.surface.test.ts @@ -321,50 +321,8 @@ test("holds markdown code blocks until final commit and keeps newline ownership" } }) -test("renders todo and question summaries without boilerplate footer copy", async () => { +test("renders question summaries without boilerplate footer copy", async () => { const cases = [ - { - title: "# Todos", - include: [ - "[✓] List files under `run/`", - "[•] Count functions in each `run/` file", - "[ ] Mark each tracking item complete", - ], - exclude: ["Updating", "todos completed"], - start: toolCommit({ - tool: "todowrite", - phase: "start", - toolState: "running", - state: { - status: "running", - input: { - todos: [ - { status: "completed", content: "List files under `run/`" }, - { status: "in_progress", content: "Count functions in each `run/` file" }, - { status: "pending", content: "Mark each tracking item complete" }, - ], - }, - time: { start: 1 }, - }, - }), - final: toolCommit({ - tool: "todowrite", - phase: "final", - toolState: "completed", - state: { - status: "completed", - input: { - todos: [ - { status: "completed", content: "List files under `run/`" }, - { status: "in_progress", content: "Count functions in each `run/` file" }, - { status: "pending", content: "Mark each tracking item complete" }, - ], - }, - metadata: {}, - time: { start: 1, end: 4 }, - }, - }), - }, { title: "# Questions", include: ["What should I work on in the codebase next?", "Bug fix"], diff --git a/packages/opencode/test/cli/run/stream-v2.transport.test.ts b/packages/opencode/test/cli/run/stream-v2.transport.test.ts index 5d0894c0d7..78485db16c 100644 --- a/packages/opencode/test/cli/run/stream-v2.transport.test.ts +++ b/packages/opencode/test/cli/run/stream-v2.transport.test.ts @@ -63,6 +63,23 @@ function durable(sessionID: string, seq = 0, version: 1 | 2 = 1) { return { aggregateID: sessionID, seq, version } } +function promptAdmission(input: Parameters[0], sessionID = "ses_1") { + return { + admittedSeq: 1, + id: input.id ?? "msg_prompt", + sessionID, + type: "user" as const, + data: { + text: input.text, + files: input.files, + agents: input.agents, + metadata: input.metadata, + }, + delivery: input.delivery ?? ("steer" as const), + timeCreated: 2, + } +} + function footer() { const commits: StreamCommit[] = [] const events: FooterEvent[] = [] @@ -170,19 +187,8 @@ describe("V2 mini transport", () => { let admitted = false spyOn(client.session, "prompt").mockImplementation((request) => { - const messageID = request.id ?? "msg_prompt" - const prompt = request.prompt ?? { text: "" } admitted = true - return ok({ - data: { - admittedSeq: 1, - id: messageID, - sessionID: "ses_1", - prompt, - delivery: "steer" as const, - timeCreated: 2, - }, - }) as never + return ok({ data: promptAdmission(request) }) as never }) const turn = transport.runPromptTurn({ @@ -197,7 +203,7 @@ describe("V2 mini transport", () => { events.push({ id: "evt_prompted", created: 0, - type: "session.prompt.promoted", + type: "session.input.promoted", durable: durable("ses_1"), data: { sessionID: "ses_1", @@ -255,7 +261,7 @@ describe("V2 mini transport", () => { events.push({ id: "evt_prompted", created: 0, - type: "session.prompt.promoted", + type: "session.input.promoted", durable: durable("ses_1"), data: { sessionID: "ses_1", @@ -270,16 +276,7 @@ describe("V2 mini transport", () => { data: { sessionID: "ses_1" }, }) }) - return ok({ - data: { - admittedSeq: 1, - id: input.id ?? "msg_prompt", - sessionID: "ses_1", - prompt: input.prompt ?? { text: "" }, - delivery: "steer" as const, - timeCreated: 2, - }, - }) as never + return ok({ data: promptAdmission(input) }) as never }) await transport.runPromptTurn({ @@ -310,8 +307,8 @@ describe("V2 mini transport", () => { includeFiles: true, }) - expect(request?.prompt?.text).toBe("Review @note.ts and @docs") - expect(request?.prompt?.files).toEqual([ + expect(request?.text).toBe("Review @note.ts and @docs") + expect(request?.files).toEqual([ { uri: pathToFileURL(filePath).href, name: "note.ts", @@ -350,7 +347,7 @@ describe("V2 mini transport", () => { events.push({ id: "evt_prompted", created: 0, - type: "session.prompt.promoted", + type: "session.input.promoted", durable: durable("ses_1"), data: { sessionID: "ses_1", @@ -365,16 +362,7 @@ describe("V2 mini transport", () => { data: { sessionID: "ses_1" }, }) }) - return ok({ - data: { - admittedSeq: 1, - id: input.id ?? "msg_prompt", - sessionID: "ses_1", - prompt: input.prompt ?? { text: "" }, - delivery: "steer" as const, - timeCreated: 2, - }, - }) + return ok({ data: promptAdmission(input) }) }) await transport.runPromptTurn({ @@ -407,8 +395,8 @@ describe("V2 mini transport", () => { expect(remoteRead).not.toHaveBeenCalled() expect(remoteList).not.toHaveBeenCalled() - expect(request?.prompt?.text).toBe("Review @note.ts and @docs") - expect(request?.prompt?.files).toEqual([ + expect(request?.text).toBe("Review @note.ts and @docs") + expect(request?.files).toEqual([ { uri: "file:///remote/project/note.ts", name: "note.ts", @@ -448,7 +436,7 @@ describe("V2 mini transport", () => { events.push({ id: "evt_prompted", created: 0, - type: "session.prompt.promoted", + type: "session.input.promoted", durable: durable("ses_1"), data: { sessionID: "ses_1", @@ -463,16 +451,7 @@ describe("V2 mini transport", () => { data: { sessionID: "ses_1" }, }) }) - return ok({ - data: { - admittedSeq: 1, - id: input.id ?? "msg_prompt", - sessionID: "ses_1", - prompt: input.prompt ?? { text: "" }, - delivery: "steer" as const, - timeCreated: 2, - }, - }) + return ok({ data: promptAdmission(input) }) }) await transport.runPromptTurn({ @@ -496,8 +475,8 @@ describe("V2 mini transport", () => { includeFiles: true, }) - expect(request?.prompt?.text).toBe("Review @diagram.png") - expect(request?.prompt?.files).toEqual([ + expect(request?.text).toBe("Review @diagram.png") + expect(request?.files).toEqual([ { name: "diagram.png", uri: pathToFileURL(filePath).href, @@ -589,12 +568,8 @@ describe("V2 mini transport", () => { // The generated method has conditional return types for throwOnError; this mock represents the successful branch. // @ts-expect-error successful SDK response is valid for both modes at runtime spyOn(client.session, "prompt").mockImplementation((request) => { - const messageID = request.id ?? "msg_prompt" - const prompt = request.prompt ?? { text: "" } admitted = true - return ok({ - data: { admittedSeq: 1, id: messageID, sessionID: "ses_1", prompt, delivery: "steer" as const, timeCreated: 2 }, - }) + return ok({ data: promptAdmission(request) }) }) const turn = transport.runPromptTurn({ @@ -661,12 +636,8 @@ describe("V2 mini transport", () => { // The generated method has conditional return types for throwOnError; this mock represents the successful branch. // @ts-expect-error successful SDK response is valid for both modes at runtime spyOn(client.session, "prompt").mockImplementation((request) => { - const messageID = request.id ?? "msg_prompt" - const prompt = request.prompt ?? { text: "" } admitted = true - return ok({ - data: { admittedSeq: 1, id: messageID, sessionID: "ses_1", prompt, delivery: "steer" as const, timeCreated: 2 }, - }) + return ok({ data: promptAdmission(request) }) }) const turn = transport.runPromptTurn({ @@ -899,12 +870,8 @@ describe("V2 mini transport", () => { // The generated method has conditional return types for throwOnError; this mock represents the successful branch. // @ts-expect-error successful SDK response is valid for both modes at runtime spyOn(client.session, "prompt").mockImplementation((request) => { - const messageID = request.id ?? "msg_prompt" - const prompt = request.prompt ?? { text: "" } admitted = true - return ok({ - data: { admittedSeq: 1, id: messageID, sessionID: "ses_1", prompt, delivery: "steer" as const, timeCreated: 2 }, - }) + return ok({ data: promptAdmission(request) }) }) const interrupted = spyOn(client.session, "interrupt").mockImplementation(() => ok(undefined)) @@ -956,12 +923,8 @@ describe("V2 mini transport", () => { // The generated method has conditional return types for throwOnError; this mock represents the successful branch. // @ts-expect-error successful SDK response is valid for both modes at runtime spyOn(client.session, "prompt").mockImplementation((request) => { - const messageID = request.id ?? "msg_prompt" - const prompt = request.prompt ?? { text: "" } admitted = true - return ok({ - data: { admittedSeq: 1, id: messageID, sessionID: "ses_1", prompt, delivery: "steer" as const, timeCreated: 2 }, - }) + return ok({ data: promptAdmission(request) }) }) const turn = transport.runPromptTurn({ @@ -976,7 +939,7 @@ describe("V2 mini transport", () => { events.push({ id: "evt_prompted", created: 0, - type: "session.prompt.promoted", + type: "session.input.promoted", durable: durable("ses_1"), data: { sessionID: "ses_1", @@ -1015,12 +978,8 @@ describe("V2 mini transport", () => { // The generated method has conditional return types for throwOnError; this mock represents the successful branch. // @ts-expect-error successful SDK response is valid for both modes at runtime spyOn(client.session, "prompt").mockImplementation((request) => { - const messageID = request.id ?? "msg_prompt" - const prompt = request.prompt ?? { text: "" } admitted = true - return ok({ - data: { admittedSeq: 1, id: messageID, sessionID: "ses_1", prompt, delivery: "steer" as const, timeCreated: 2 }, - }) + return ok({ data: promptAdmission(request) }) }) const interrupted = spyOn(client.session, "interrupt").mockImplementation(() => ok(undefined)) const controller = new AbortController() @@ -1037,7 +996,7 @@ describe("V2 mini transport", () => { events.push({ id: "evt_prompted", created: 0, - type: "session.prompt.promoted", + type: "session.input.promoted", durable: durable("ses_1"), data: { sessionID: "ses_1", @@ -1464,7 +1423,7 @@ describe("V2 mini transport", () => { events.push({ id: "evt_prompted", created: 0, - type: "session.prompt.promoted", + type: "session.input.promoted", durable: durable("ses_1"), data: { sessionID: "ses_1", @@ -1483,7 +1442,8 @@ describe("V2 mini transport", () => { admittedSeq: 1, id: input.id ?? "msg_cmd", sessionID: "ses_1", - prompt: { text: "evaluated template" }, + type: "user" as const, + data: { text: "evaluated template" }, delivery: "steer" as const, timeCreated: 2, }) @@ -1862,13 +1822,12 @@ describe("V2 mini transport", () => { events.push({ id: "evt_child_admitted", created: 1, - type: "session.prompt.admitted", + type: "session.input.admitted", durable: durable("ses_child"), data: { sessionID: "ses_child", inputID: "msg_child_prompt", - prompt: { text: "actual child prompt" }, - delivery: "steer", + input: { type: "user", data: { text: "actual child prompt" }, delivery: "steer" }, }, }) await Bun.sleep(0) @@ -1881,7 +1840,7 @@ describe("V2 mini transport", () => { events.push({ id: "evt_child_promoted", created: 2, - type: "session.prompt.promoted", + type: "session.input.promoted", durable: durable("ses_child", 1), data: { sessionID: "ses_child", inputID: "msg_child_prompt" }, }) @@ -1928,13 +1887,12 @@ describe("V2 mini transport", () => { events.push({ id: "evt_child_admitted_race", created: 1, - type: "session.prompt.admitted", + type: "session.input.admitted", durable: durable("ses_child"), data: { sessionID: "ses_child", inputID: "msg_child_race", - prompt: { text: "prompt admitted before hydration" }, - delivery: "steer", + input: { type: "user", data: { text: "prompt admitted before hydration" }, delivery: "steer" }, }, }) await Bun.sleep(0) @@ -1943,7 +1901,7 @@ describe("V2 mini transport", () => { events.push({ id: "evt_child_promoted_race", created: 2, - type: "session.prompt.promoted", + type: "session.input.promoted", durable: durable("ses_child", 1), data: { sessionID: "ses_child", inputID: "msg_child_race" }, }) diff --git a/packages/opencode/test/cli/tui/plugin-loader.test.ts b/packages/opencode/test/cli/tui/plugin-loader.test.ts index 49ea06affc..c542914cad 100644 --- a/packages/opencode/test/cli/tui/plugin-loader.test.ts +++ b/packages/opencode/test/cli/tui/plugin-loader.test.ts @@ -220,7 +220,6 @@ export default { api.kv.set(options.kv_key, "stored") const kv_after = api.kv.get(options.kv_key, "missing") const diff = api.state.session.diff(options.session_id) - const todo = api.state.session.todo(options.session_id) const lsp = api.state.lsp() const mcp = api.state.mcp() const depth_before = api.ui.dialog.depth @@ -263,8 +262,6 @@ export default { kv_ready: api.kv.ready, diff_count: diff.length, diff_file: diff[0]?.file, - todo_count: todo.length, - todo_first: todo[0]?.content, lsp_count: lsp.length, mcp_count: mcp.length, mcp_first: mcp[0]?.name, @@ -513,10 +510,6 @@ export default { if (sessionID !== "ses_test") return [] return [{ file: "src/app.ts", additions: 3, deletions: 1 }] }, - todo(sessionID) { - if (sessionID !== "ses_test") return [] - return [{ content: "ship it", status: "pending" }] - }, }, lsp() { return [{ id: "ts", root: "/tmp/project", status: "connected" }] @@ -867,8 +860,6 @@ describe("tui.plugin.loader", () => { expect(data.local.kv_ready).toBe(true) expect(data.local.diff_count).toBe(1) expect(data.local.diff_file).toBe("src/app.ts") - expect(data.local.todo_count).toBe(1) - expect(data.local.todo_first).toBe("ship it") expect(data.local.lsp_count).toBe(1) expect(data.local.mcp_count).toBe(1) expect(data.local.mcp_first).toBe("github") diff --git a/packages/opencode/test/config/config.test.ts b/packages/opencode/test/config/config.test.ts index e281831ddf..ac408ea812 100644 --- a/packages/opencode/test/config/config.test.ts +++ b/packages/opencode/test/config/config.test.ts @@ -1307,7 +1307,6 @@ it.instance("permission config preserves user key order", () => write: "ask", external_directory: "ask", read: "allow", - todowrite: "allow", "thoughts_*": "allow", "reasoning_model_*": "allow", "tools_*": "allow", @@ -1322,7 +1321,6 @@ it.instance("permission config preserves user key order", () => "write", "external_directory", "read", - "todowrite", "thoughts_*", "reasoning_model_*", "tools_*", diff --git a/packages/opencode/test/event-manifest.test.ts b/packages/opencode/test/event-manifest.test.ts index 64755ad44f..a54a0c6ac8 100644 --- a/packages/opencode/test/event-manifest.test.ts +++ b/packages/opencode/test/event-manifest.test.ts @@ -2,7 +2,6 @@ import { describe, expect, test } from "bun:test" import { SessionEvent } from "@opencode-ai/core/session/event" import { Agent } from "@opencode-ai/schema" import { EventManifest as SchemaEventManifest } from "@opencode-ai/schema/event-manifest" -import { Todo } from "@/session/todo" import { EventManifest } from "@/event-manifest" describe("public event manifest", () => { @@ -10,10 +9,9 @@ describe("public event manifest", () => { expect(EventManifest.Definitions).toBe(SchemaEventManifest.Definitions) expect(EventManifest.Latest).toBe(SchemaEventManifest.Latest) expect(EventManifest.Durable).toBe(SchemaEventManifest.Durable) - expect(Array.from(EventManifest.Latest.keys()).filter((type) => type !== "agent.updated").length).toBe(101) + expect(Array.from(EventManifest.Latest.keys()).filter((type) => type !== "agent.updated").length).toBe(108) expect(EventManifest.Latest.get("session.next.step.ended")).toBe(SessionEvent.Step.Ended) expect(EventManifest.Latest.get("agent.updated")).toBe(Agent.Event.Updated) - expect(EventManifest.Latest.get("todo.updated")).toBe(Todo.Event.Updated) expect(EventManifest.Latest.has("ide.installed")).toBe(false) expect(EventManifest.Latest.has("server.connected")).toBe(true) expect(EventManifest.Latest.has("global.disposed")).toBe(true) diff --git a/packages/opencode/test/fixture/tui-plugin.ts b/packages/opencode/test/fixture/tui-plugin.ts index 5d93139f00..d94b02e7ca 100644 --- a/packages/opencode/test/fixture/tui-plugin.ts +++ b/packages/opencode/test/fixture/tui-plugin.ts @@ -316,7 +316,6 @@ export function createTuiPluginApi(opts: Opts = {}): HostPluginApi { count: opts.state?.session?.count ?? (() => 0), get: opts.state?.session?.get ?? (() => undefined), diff: opts.state?.session?.diff ?? (() => []), - todo: opts.state?.session?.todo ?? (() => []), messages: opts.state?.session?.messages ?? (() => []), status: opts.state?.session?.status ?? (() => undefined), permission: opts.state?.session?.permission ?? (() => []), diff --git a/packages/opencode/test/provider/model-status.test.ts b/packages/opencode/test/provider/model-status.test.ts index 35a859120b..3682ebe5a8 100644 --- a/packages/opencode/test/provider/model-status.test.ts +++ b/packages/opencode/test/provider/model-status.test.ts @@ -15,6 +15,7 @@ describe("provider model status schemas", () => { test("accepts active status across public provider schemas", () => { expect(Schema.decodeUnknownSync(ConfigProviderV1.Model)({ status: "active" }).status).toBe("active") expect( + // @ts-expect-error dead V1 test consumes the removed ModelsDev model schema value. Schema.decodeUnknownSync(ModelsDev.Model)({ id: "test-model", name: "Test Model", diff --git a/packages/opencode/test/provider/provider.test.ts b/packages/opencode/test/provider/provider.test.ts index 18ec8f9fbe..53fe8bac20 100644 --- a/packages/opencode/test/provider/provider.test.ts +++ b/packages/opencode/test/provider/provider.test.ts @@ -1386,6 +1386,7 @@ test("mode cost preserves over-200k pricing from base model", () => { }, }, }, + // @ts-expect-error dead V1 fixture uses the removed pre-normalized ModelsDev provider type. } as unknown as ModelsDev.Provider const model = Provider.fromModelsDevProvider(provider).models["gpt-5.4-fast"] @@ -1415,6 +1416,7 @@ test("models.dev normalization fills required response fields", () => { limit: { context: 1_050_000, input: 922_000, output: 128_000 }, }, }, + // @ts-expect-error dead V1 fixture uses the removed pre-normalized ModelsDev provider type. } as unknown as ModelsDev.Provider const model = Provider.fromModelsDevProvider(provider).models["gpt-5.4"] diff --git a/packages/opencode/test/server/httpapi-exercise/index.ts b/packages/opencode/test/server/httpapi-exercise/index.ts index 195699f9c9..b815f5f7da 100644 --- a/packages/opencode/test/server/httpapi-exercise/index.ts +++ b/packages/opencode/test/server/httpapi-exercise/index.ts @@ -1326,23 +1326,6 @@ const scenarios: Scenario[] = [ "children should include seeded child", ) }), - http.protected - .get("/session/{sessionID}/todo", "session.todo") - .seeded((ctx) => - Effect.gen(function* () { - const session = yield* ctx.session({ title: "Todo session" }) - const todos = [{ content: "cover session todo", status: "pending" as const, priority: "high" as const }] - yield* ctx.todos(session.id, todos) - return { session, todos } - }), - ) - .at((ctx) => ({ - path: route("/session/{sessionID}/todo", { sessionID: ctx.state.session.id }), - headers: ctx.headers(), - })) - .json(200, (body, ctx) => { - check(stable(body) === stable(ctx.state.todos), "todos should match seeded state") - }), http.protected .get("/session/{sessionID}/diff", "session.diff") .seeded((ctx) => ctx.session({ title: "Diff session" })) diff --git a/packages/opencode/test/server/httpapi-exercise/runner.ts b/packages/opencode/test/server/httpapi-exercise/runner.ts index b7ad6d6208..37e24b790b 100644 --- a/packages/opencode/test/server/httpapi-exercise/runner.ts +++ b/packages/opencode/test/server/httpapi-exercise/runner.ts @@ -176,7 +176,6 @@ function withContext( }), messages: (sessionID) => run(modules.Session.Service.use((svc) => svc.messages({ sessionID }).pipe(Effect.orDie))), - todos: (sessionID, todos) => run(modules.Todo.Service.use((svc) => svc.update({ sessionID, todos }))), worktree: (input) => run(modules.Worktree.Service.use((svc) => svc.create(input).pipe(Effect.orDie))), worktreeRemove: (directory) => run(modules.Worktree.Service.use((svc) => svc.remove({ directory })).pipe(Effect.ignore)), diff --git a/packages/opencode/test/server/httpapi-exercise/runtime.ts b/packages/opencode/test/server/httpapi-exercise/runtime.ts index 81bd302a1a..b7a3952bde 100644 --- a/packages/opencode/test/server/httpapi-exercise/runtime.ts +++ b/packages/opencode/test/server/httpapi-exercise/runtime.ts @@ -6,7 +6,6 @@ export type Runtime = { InstanceRef: (typeof import("../../../src/effect/instance-ref"))["InstanceRef"] InstanceStore: (typeof import("../../../src/project/instance-store"))["InstanceStore"] Session: (typeof import("../../../src/session/session"))["Session"] - Todo: (typeof import("../../../src/session/todo"))["Todo"] Worktree: (typeof import("../../../src/worktree"))["Worktree"] Project: (typeof import("../../../src/project/project"))["Project"] Tui: typeof import("../../../src/server/shared/tui-control") @@ -26,7 +25,6 @@ export function runtime() { const instanceRef = await import("../../../src/effect/instance-ref") const instanceStore = await import("../../../src/project/instance-store") const session = await import("../../../src/session/session") - const todo = await import("../../../src/session/todo") const worktree = await import("../../../src/worktree") const project = await import("../../../src/project/project") const tui = await import("../../../src/server/shared/tui-control") @@ -40,7 +38,6 @@ export function runtime() { InstanceRef: instanceRef.InstanceRef, InstanceStore: instanceStore.InstanceStore, Session: session.Session, - Todo: todo.Todo, Worktree: worktree.Worktree, Project: project.Project, Tui: tui, diff --git a/packages/opencode/test/server/httpapi-exercise/types.ts b/packages/opencode/test/server/httpapi-exercise/types.ts index acc1898e32..0895216505 100644 --- a/packages/opencode/test/server/httpapi-exercise/types.ts +++ b/packages/opencode/test/server/httpapi-exercise/types.ts @@ -60,7 +60,6 @@ export type ScenarioContext = { project: () => Effect.Effect message: (sessionID: SessionID, input?: { text?: string }) => Effect.Effect messages: (sessionID: SessionID) => Effect.Effect - todos: (sessionID: SessionID, todos: TodoInfo[]) => Effect.Effect worktree: (input?: { name?: string }) => Effect.Effect worktreeRemove: (directory: string) => Effect.Effect llmText: (value: string) => Effect.Effect @@ -119,9 +118,4 @@ export type Result = | { status: "skip"; scenario: TodoScenario } export type SessionInfo = { id: SessionID; title: string; parentID?: SessionID } -export type TodoInfo = { - content: string - status: "pending" | "in_progress" | "completed" | "cancelled" - priority: "high" | "medium" | "low" -} export type MessageSeed = { info: SessionV1.User; part: SessionV1.TextPart } diff --git a/packages/opencode/test/server/httpapi-sdk.test.ts b/packages/opencode/test/server/httpapi-sdk.test.ts index 0af92ae382..a6f308f032 100644 --- a/packages/opencode/test/server/httpapi-sdk.test.ts +++ b/packages/opencode/test/server/httpapi-sdk.test.ts @@ -577,7 +577,6 @@ describe("HttpApi SDK", () => { const roots = yield* capture(() => sdk.session.list({ roots: true, limit: 10 })) const all = yield* capture(() => sdk.session.list({ roots: false, limit: 10 })) const children = yield* capture(() => sdk.session.children({ sessionID: parentID })) - const todo = yield* capture(() => sdk.session.todo({ sessionID: parentID })) const status = yield* capture(() => sdk.session.status()) const messages = yield* capture(() => sdk.session.messages({ sessionID: parentID })) const missingGet = yield* capture(() => sdk.session.get({ sessionID: "ses_missing" })) @@ -597,7 +596,6 @@ describe("HttpApi SDK", () => { roots, all, children, - todo, status, messages, missingGet, @@ -611,7 +609,6 @@ describe("HttpApi SDK", () => { rootTitles: sessionTitles(roots.data), allTitles: sessionTitles(all.data), childCount: array(children.data).length, - todoCount: array(todo.data).length, messageCount: array(messages.data).length, } }), diff --git a/packages/opencode/test/server/httpapi-session.test.ts b/packages/opencode/test/server/httpapi-session.test.ts index a5677d5274..4b3e7e3f92 100644 --- a/packages/opencode/test/server/httpapi-session.test.ts +++ b/packages/opencode/test/server/httpapi-session.test.ts @@ -272,10 +272,6 @@ describe("session HttpApi", () => { expect(children.status).toBe(404) expect(yield* responseJson(children)).toEqual(missingSessionBody) - const todo = yield* request(pathFor(SessionPaths.todo, { sessionID: missingSession }), { headers }) - expect(todo.status).toBe(404) - expect(yield* responseJson(todo)).toEqual(missingSessionBody) - const messages = yield* request(pathFor(SessionPaths.messages, { sessionID: missingSession }), { headers }) expect(messages.status).toBe(404) expect(yield* responseJson(messages)).toEqual(missingSessionBody) @@ -344,10 +340,6 @@ describe("session HttpApi", () => { })).map((item) => item.id), ).toEqual([child.id]) - expect( - yield* requestJson(pathFor(SessionPaths.todo, { sessionID: parent.id }), { headers }), - ).toEqual([]) - expect( yield* requestJson(pathFor(SessionPaths.diff, { sessionID: parent.id }), { headers }), ).toEqual([]) diff --git a/packages/opencode/test/session/llm-native-recorded.test.ts b/packages/opencode/test/session/llm-native-recorded.test.ts index 36853a6d26..b4864d14ac 100644 --- a/packages/opencode/test/session/llm-native-recorded.test.ts +++ b/packages/opencode/test/session/llm-native-recorded.test.ts @@ -49,9 +49,11 @@ type RecordedScenario = { readonly recordAuth?: () => Auth.Info | undefined readonly replayAuth?: Auth.Info readonly stableID?: string + // @ts-expect-error dead V1 fixture uses the removed pre-normalized ModelsDev provider type. readonly config: (model: ModelsDev.Provider["models"][string]) => Partial } +// @ts-expect-error dead V1 fixture uses the removed pre-normalized ModelsDev provider type. const cloneModel = (model: ModelsDev.Provider["models"][string]) => { const cloned = structuredClone(model) const { experimental, ...rest } = cloned @@ -94,6 +96,7 @@ const providerConfig = (input: { readonly env: string[] readonly npm: string readonly api: string + // @ts-expect-error dead V1 fixture uses the removed pre-normalized ModelsDev provider type. readonly model: ModelsDev.Provider["models"][string] readonly options: Record }): Partial => ({ @@ -254,6 +257,7 @@ async function loadFixture(providerID: string, modelID: string) { return model } +// @ts-expect-error dead V1 fixture uses the removed pre-normalized ModelsDev provider type. const modelsFixture = Filesystem.readJson>( path.join(import.meta.dir, "../tool/fixtures/models-api.json"), ) @@ -283,6 +287,7 @@ function recordedNativeLLMLayer(scenario: RecordedScenario) { ]) } +// @ts-expect-error dead V1 fixture uses the removed pre-normalized ModelsDev provider type. const writeConfig = (directory: string, scenario: RecordedScenario, model: ModelsDev.Provider["models"][string]) => Effect.promise(() => Bun.write( diff --git a/packages/opencode/test/session/llm.test.ts b/packages/opencode/test/session/llm.test.ts index 5574d29356..15e2251b75 100644 --- a/packages/opencode/test/session/llm.test.ts +++ b/packages/opencode/test/session/llm.test.ts @@ -30,6 +30,7 @@ import { LayerNodePlatform } from "@opencode-ai/core/effect/app-node-platform" type ConfigModel = NonNullable[string]["models"]>[string] +// @ts-expect-error dead V1 fixture uses the removed pre-normalized ModelsDev provider type. const openAIConfig = (model: ModelsDev.Provider["models"][string], baseURL: string): Partial => { const { experimental: _experimental, ...configModel } = model return { @@ -700,6 +701,7 @@ function createChatStream(text: string) { const MODELS_FIXTURE = JSON.parse( await Bun.file(path.join(import.meta.dir, "../tool/fixtures/models-api.json")).text(), + // @ts-expect-error dead V1 fixture uses the removed pre-normalized ModelsDev provider type. ) as Record function loadFixture(providerID: string, modelID: string) { @@ -710,6 +712,7 @@ function loadFixture(providerID: string, modelID: string) { return { provider, model } } +// @ts-expect-error dead V1 fixture uses the removed pre-normalized ModelsDev model type. function configModel(model: ModelsDev.Model) { return { id: model.id, diff --git a/packages/opencode/test/session/prompt.test.ts b/packages/opencode/test/session/prompt.test.ts index 9ca8669e84..420bd49324 100644 --- a/packages/opencode/test/session/prompt.test.ts +++ b/packages/opencode/test/session/prompt.test.ts @@ -24,7 +24,6 @@ import { Git } from "../../src/git" import { Image } from "../../src/image/image" import { Question } from "../../src/question" -import { Todo } from "../../src/session/todo" import { Session } from "@/session/session" import { SessionMessageTable } from "@opencode-ai/core/session/sql" import { LLM } from "../../src/session/llm" @@ -191,7 +190,6 @@ const promptRoot = LayerNode.group([ Database.node, EventV2Bridge.node, Question.node, - Todo.node, ToolRegistry.node, Skill.node, Git.node, diff --git a/packages/opencode/test/session/schema-decoding.test.ts b/packages/opencode/test/session/schema-decoding.test.ts index 3bcbd55d4b..379494822a 100644 --- a/packages/opencode/test/session/schema-decoding.test.ts +++ b/packages/opencode/test/session/schema-decoding.test.ts @@ -6,7 +6,6 @@ import { SessionPrompt } from "../../src/session/prompt" import { SessionRevert } from "../../src/session/revert" import { SessionStatus } from "../../src/session/status" import { SessionSummary } from "../../src/session/summary" -import { Todo } from "../../src/session/todo" import { SessionID, MessageID, PartID } from "../../src/session/schema" import { ProjectV2 } from "@opencode-ai/core/project" import { WorkspaceV2 } from "@opencode-ai/core/workspace" @@ -252,15 +251,6 @@ describe("SessionStatus.Info", () => { }) }) -describe("Todo.Info", () => { - const decode = decodeUnknown(Todo.Info) - - test("three-field round-trip", () => { - const input = Todo.Info.make({ content: "do a thing", status: "pending", priority: "high" }) - expect(decode(input)).toEqual(input) - }) -}) - describe("SessionPrompt input schemas", () => { test("LoopInput is just sessionID", () => { const decode = decodeUnknown(SessionPrompt.LoopInput) diff --git a/packages/opencode/test/tool/__snapshots__/parameters.test.ts.snap b/packages/opencode/test/tool/__snapshots__/parameters.test.ts.snap index 51ff867ea4..48c6b55585 100644 --- a/packages/opencode/test/tool/__snapshots__/parameters.test.ts.snap +++ b/packages/opencode/test/tool/__snapshots__/parameters.test.ts.snap @@ -335,44 +335,6 @@ exports[`tool parameters JSON Schema (wire shape) task 1`] = ` } `; -exports[`tool parameters JSON Schema (wire shape) todo 1`] = ` -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "properties": { - "todos": { - "description": "The updated todo list", - "items": { - "properties": { - "content": { - "description": "Brief description of the task", - "type": "string", - }, - "priority": { - "description": "Priority level of the task: high, medium, low", - "type": "string", - }, - "status": { - "description": "Current status of the task: pending, in_progress, completed, cancelled", - "type": "string", - }, - }, - "required": [ - "content", - "status", - "priority", - ], - "type": "object", - }, - "type": "array", - }, - }, - "required": [ - "todos", - ], - "type": "object", -} -`; - exports[`tool parameters JSON Schema (wire shape) webfetch 1`] = ` { "$schema": "https://json-schema.org/draft/2020-12/schema", diff --git a/packages/opencode/test/tool/parameters.test.ts b/packages/opencode/test/tool/parameters.test.ts index 9c540daad0..11db271b92 100644 --- a/packages/opencode/test/tool/parameters.test.ts +++ b/packages/opencode/test/tool/parameters.test.ts @@ -21,7 +21,6 @@ import { Parameters as Read } from "../../src/tool/read" import { Parameters as Shell } from "../../src/tool/shell" import { Parameters as Skill } from "../../src/tool/skill" import { Parameters as Task } from "../../src/tool/task" -import { Parameters as Todo } from "../../src/tool/todo" import { Parameters as WebFetch } from "../../src/tool/webfetch" import { Parameters as WebSearch } from "../../src/tool/websearch" import { Parameters as Write } from "../../src/tool/write" @@ -48,7 +47,6 @@ describe("tool parameters", () => { test("read", () => expect(toJsonSchema(Read)).toMatchSnapshot()) test("skill", () => expect(toJsonSchema(Skill)).toMatchSnapshot()) test("task", () => expect(toJsonSchema(Task)).toMatchSnapshot()) - test("todo", () => expect(toJsonSchema(Todo)).toMatchSnapshot()) test("webfetch", () => expect(toJsonSchema(WebFetch)).toMatchSnapshot()) test("websearch", () => expect(toJsonSchema(WebSearch)).toMatchSnapshot()) test("write", () => expect(toJsonSchema(Write)).toMatchSnapshot()) @@ -248,18 +246,6 @@ describe("tool parameters", () => { }) }) - describe("todo", () => { - test("accepts todos array", () => { - const parsed = parse(Todo, { - todos: [{ id: "t1", content: "do x", status: "pending", priority: "medium" }], - }) - expect(parsed.todos.length).toBe(1) - }) - test("rejects missing todos", () => { - expect(accepts(Todo, {})).toBe(false) - }) - }) - describe("webfetch", () => { test("defaults omitted format to markdown", () => { expect(parse(WebFetch, { url: "https://example.com" })).toEqual({ diff --git a/packages/opencode/test/tool/task.test.ts b/packages/opencode/test/tool/task.test.ts index 32171789c8..fc75c3d72f 100644 --- a/packages/opencode/test/tool/task.test.ts +++ b/packages/opencode/test/tool/task.test.ts @@ -388,7 +388,7 @@ describe("tool.task", () => { ) it.instance( - "execute shapes child permissions for task, todowrite, and primary tools", + "execute shapes child permissions for task and primary tools", () => Effect.gen(function* () { const sessions = yield* Session.Service @@ -420,11 +420,6 @@ describe("tool.task", () => { expect(child.parentID).toBe(chat.id) expect(child.agent).toBe("reviewer") expect(child.permission).toEqual([ - { - permission: "todowrite", - pattern: "*", - action: "deny", - }, { permission: "bash", pattern: "*", diff --git a/packages/plugin/src/tui.ts b/packages/plugin/src/tui.ts index 8e2fee5908..7c1d321edb 100644 --- a/packages/plugin/src/tui.ts +++ b/packages/plugin/src/tui.ts @@ -3,7 +3,6 @@ import type { V2Event, LspStatus, McpStatus, - Todo, Message, Part, Provider, @@ -380,7 +379,6 @@ export type TuiState = { count: () => number get: (sessionID: string) => Session | undefined diff: (sessionID: string) => ReadonlyArray - todo: (sessionID: string) => ReadonlyArray messages: (sessionID: string) => ReadonlyArray status: (sessionID: string) => SessionStatus | undefined permission: (sessionID: string) => ReadonlyArray @@ -437,8 +435,6 @@ export type TuiSidebarMcpItem = { export type TuiSidebarLspItem = Pick -export type TuiSidebarTodoItem = Pick - export type TuiSidebarFileItem = { file: string additions: number diff --git a/packages/protocol/src/api.ts b/packages/protocol/src/api.ts index 2f91fae332..a7a6ef9fd2 100644 --- a/packages/protocol/src/api.ts +++ b/packages/protocol/src/api.ts @@ -16,6 +16,7 @@ import type { Definition } from "@opencode-ai/schema/event" import { AgentGroup } from "./groups/agent.js" import { PluginGroup } from "./groups/plugin.js" import { HealthGroup } from "./groups/health.js" +import { ServerGroup } from "./groups/server.js" import { DebugGroup } from "./groups/debug.js" import { PtyGroup } from "./groups/pty.js" import { ShellGroup } from "./groups/shell.js" @@ -82,6 +83,7 @@ type ApiGroups< Event extends HttpApiGroup.Any, > = | typeof HealthGroup + | typeof ServerGroup | typeof DebugGroup | LocationGroups | FormGroups @@ -143,6 +145,7 @@ const makeApiFromGroup = < > => HttpApi.make("server") .add(HealthGroup) + .add(ServerGroup) .add(LocationGroup.middleware(locationMiddleware)) .add(AgentGroup.middleware(locationMiddleware)) .add(PluginGroup.middleware(locationMiddleware)) diff --git a/packages/protocol/src/client.ts b/packages/protocol/src/client.ts index 03f9a34015..27201783cd 100644 --- a/packages/protocol/src/client.ts +++ b/packages/protocol/src/client.ts @@ -34,6 +34,7 @@ export const ClientApi: ClientApiShape = makeDefaultApi({ export const groupNames = { "server.health": "health", + "server.server": "server", "server.debug": "debug", "server.location": "location", "server.agent": "agent", diff --git a/packages/protocol/src/groups/server.ts b/packages/protocol/src/groups/server.ts new file mode 100644 index 0000000000..1ca59921ad --- /dev/null +++ b/packages/protocol/src/groups/server.ts @@ -0,0 +1,16 @@ +import { Schema } from "effect" +import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" + +export const ServerGroup = HttpApiGroup.make("server.server") + .add( + HttpApiEndpoint.get("server.get", "/api/server", { + success: Schema.Struct({ urls: Schema.Array(Schema.String) }), + }).annotateMerge( + OpenApi.annotations({ + identifier: "v2.server.get", + summary: "Get server information", + description: "Return the URLs that can be used to connect to this server.", + }), + ), + ) + .annotateMerge(OpenApi.annotations({ title: "server" })) diff --git a/packages/protocol/src/groups/session.ts b/packages/protocol/src/groups/session.ts index e595716873..255ee420ab 100644 --- a/packages/protocol/src/groups/session.ts +++ b/packages/protocol/src/groups/session.ts @@ -293,11 +293,12 @@ export const makeSessionGroup = (sessionLo params: { sessionID: Session.ID }, payload: Schema.Struct({ id: SessionMessage.ID.pipe(Schema.optional), - prompt: PromptInput.Prompt, + ...PromptInput.Prompt.fields, + metadata: SessionInput.UserData.fields.metadata, delivery: SessionInput.Delivery.pipe(Schema.optional), resume: Schema.Boolean.pipe(Schema.optional), }), - success: Schema.Struct({ data: SessionInput.Admitted }), + success: Schema.Struct({ data: SessionInput.User }), error: [ConflictError, InvalidRequestError, SessionNotFoundError], }) .middleware(sessionLocationMiddleware) @@ -323,7 +324,7 @@ export const makeSessionGroup = (sessionLo delivery: SessionInput.Delivery.pipe(Schema.optional), resume: Schema.Boolean.pipe(Schema.optional), }), - success: Schema.Struct({ data: SessionInput.Admitted }), + success: Schema.Struct({ data: SessionInput.User }), error: [ConflictError, InvalidRequestError, SessionNotFoundError, CommandNotFoundError, CommandEvaluationError], }) .middleware(sessionLocationMiddleware) @@ -360,20 +361,22 @@ export const makeSessionGroup = (sessionLo HttpApiEndpoint.post("session.synthetic", "/api/session/:sessionID/synthetic", { params: { sessionID: Session.ID }, payload: Schema.Struct({ + id: SessionMessage.ID.pipe(Schema.optional), text: Schema.String, description: Schema.String.pipe(Schema.optional), metadata: SessionMessage.Synthetic.fields.metadata, + delivery: SessionInput.Delivery.pipe(Schema.optional), resume: Schema.Boolean.pipe(Schema.optional), }), - success: HttpApiSchema.NoContent, - error: SessionNotFoundError, + success: Schema.Struct({ data: SessionInput.Synthetic }), + error: [ConflictError, SessionNotFoundError], }) .middleware(sessionLocationMiddleware) .annotateMerge( OpenApi.annotations({ identifier: "v2.session.synthetic", summary: "Add synthetic message", - description: "Append a synthetic message to a session and resume execution.", + description: "Durably admit synthetic session input and schedule execution unless resume is false.", }), ), ) diff --git a/packages/schema/AGENTS.md b/packages/schema/AGENTS.md index 400b4b312b..f079b42a26 100644 --- a/packages/schema/AGENTS.md +++ b/packages/schema/AGENTS.md @@ -28,8 +28,8 @@ ## Module Shape -- Use one canonical exported value for each contract. Avoid bridge aliases such as `PluginID`, `PluginEvent`, `PtyInfo`, `PtyEvent`, and `SessionTodoInfo`. -- Prefer importing the schema module namespace and reading canonical members, for example `Plugin.ID` or `SessionTodo.Info`. +- Use one canonical exported value for each contract. Avoid bridge aliases such as `PluginID`, `PluginEvent`, `PtyInfo`, and `PtyEvent`. +- Prefer importing the schema module namespace and reading canonical members, for example `Plugin.ID` or `Question.Info`. - Core may compose Schema contracts with runtime behavior into a deliberate domain facade, but the facade must re-export the exact canonical Schema value. Do not create a second schema identity. - Use flat top-level exports plus the package's existing namespace projection pattern, for example `export * as SessionMessage from "./session-message"`. - Keep standalone ID modules only when they prevent real cycles or heavy dependency edges. Inline one-off IDs into their owning contract module when no cycle exists. diff --git a/packages/schema/src/agent.ts b/packages/schema/src/agent.ts index 81bdfe8c98..8806a9c469 100644 --- a/packages/schema/src/agent.ts +++ b/packages/schema/src/agent.ts @@ -38,9 +38,9 @@ export const Info = Schema.Struct({ }) .annotate({ identifier: "Agent.Info" }) .pipe( - statics((schema) => ({ + statics(() => ({ empty: (id: ID) => - schema.make({ + ({ id, name: Name.make(id), request: { settings: {}, headers: {}, body: {} }, @@ -50,7 +50,7 @@ export const Info = Schema.Struct({ { action: "*", resource: "*", effect: "allow" }, { action: "external_directory", resource: "*", effect: "ask" }, ], - }), + }) satisfies Info, })), ) diff --git a/packages/schema/src/event-manifest.ts b/packages/schema/src/event-manifest.ts index 035f72568e..386d750340 100644 --- a/packages/schema/src/event-manifest.ts +++ b/packages/schema/src/event-manifest.ts @@ -31,7 +31,6 @@ import { Skill } from "./skill.js" import { SessionCompactionEvent } from "./session-compaction-event.js" import { SessionEvent } from "./session-event.js" import { SessionStatusEvent } from "./session-status-event.js" -import { SessionTodo } from "./session-todo.js" import { SessionV1 } from "./session-v1.js" import { TuiEvent } from "./tui-event.js" import { VcsEvent } from "./vcs-event.js" @@ -73,7 +72,6 @@ const featureDefinitions = Event.inventory( export const ServerDefinitions = Event.inventory( ...foundationDefinitions, ...featureDefinitions, - ...SessionTodo.Event.Definitions, // Current events the TUI consumes from the public stream. ...SessionStatusEvent.Definitions, ...TuiEvent.Definitions, @@ -97,7 +95,6 @@ export const Definitions = Event.inventory( ...sessionV1LiveDefinitions, ...InstallationEvent.Definitions, ...featureDefinitions, - ...SessionTodo.Event.Definitions, ...LspEvent.Definitions, ...PermissionV1.Event.Definitions, ...TuiEvent.Definitions, diff --git a/packages/schema/src/form.ts b/packages/schema/src/form.ts index df9d455f13..98f863f7fa 100644 --- a/packages/schema/src/form.ts +++ b/packages/schema/src/form.ts @@ -106,7 +106,7 @@ const InfoBase = { // elicitations can be attributed to real sessions, revert this to SessionID. Do not rely // on non-session owners anywhere else. sessionID: Schema.String, - title: Schema.String.pipe(optional), + title: Schema.String, metadata: Metadata.pipe(optional), } diff --git a/packages/schema/src/model.ts b/packages/schema/src/model.ts index 3539c87f77..623b80a60a 100644 --- a/packages/schema/src/model.ts +++ b/packages/schema/src/model.ts @@ -15,7 +15,27 @@ export const Ref = Schema.Struct({ id: ID, providerID: Provider.ID, variant: VariantID.pipe(optional), -}).annotate({ identifier: "Model.Ref" }) +}) + .annotate({ identifier: "Model.Ref" }) + .pipe( + statics((schema) => ({ + parse: (input: string) => { + const providerEnd = input.indexOf("/") + if (providerEnd <= 0) throw new Error(`Invalid model reference: ${input}`) + const providerID = input.slice(0, providerEnd) + const variantStart = input.indexOf("#", providerEnd + 1) + const id = input.slice(providerEnd + 1, variantStart === -1 ? undefined : variantStart) + const variant = variantStart === -1 ? undefined : input.slice(variantStart + 1) + if (!id || providerID.includes("#") || (variant !== undefined && (!variant || variant.includes("#")))) + throw new Error(`Invalid model reference: ${input}`) + return schema.make({ + providerID: Provider.ID.make(providerID), + id: ID.make(id), + ...(variant ? { variant: VariantID.make(variant) } : {}), + }) + }, + })), + ) export interface Ref extends Schema.Schema.Type {} export const Family = Schema.String.pipe(Schema.brand("Model.Family")) @@ -73,9 +93,9 @@ export const Info = Schema.Struct({ }) .annotate({ identifier: "Model.Info" }) .pipe( - statics((schema) => ({ + statics(() => ({ empty: (providerID: Provider.ID, id: ID) => - schema.make({ + ({ id, modelID: id, providerID, @@ -87,6 +107,6 @@ export const Info = Schema.Struct({ status: "active", enabled: true, limit: { context: 0, output: 0 }, - }), + }) satisfies Info, })), ) diff --git a/packages/schema/src/provider.ts b/packages/schema/src/provider.ts index f426a19798..3e98b16990 100644 --- a/packages/schema/src/provider.ts +++ b/packages/schema/src/provider.ts @@ -52,7 +52,7 @@ export const Info = Schema.Struct({ }) .annotate({ identifier: "ProviderV2.Info" }) .pipe( - statics((schema) => ({ - empty: (id: ID) => schema.make({ id, name: id, package: "" }), + statics(() => ({ + empty: (id: ID): Info => ({ id, name: id, package: "" }), })), ) diff --git a/packages/schema/src/session-event.ts b/packages/schema/src/session-event.ts index 9c2417e053..7d6e17e315 100644 --- a/packages/schema/src/session-event.ts +++ b/packages/schema/src/session-event.ts @@ -5,10 +5,9 @@ import { optional } from "./schema.js" import { Event } from "./event.js" import { ToolContent } from "./llm.js" import { FinishReason } from "./llm.js" -import { Delivery } from "./session-delivery.js" import { Model } from "./model.js" import { NonNegativeInt, PositiveInt, RelativePath } from "./schema.js" -import { FileAttachment, Prompt } from "./prompt.js" +import { FileAttachment } from "./prompt.js" import { SessionID } from "./session-id.js" import { Location } from "./location.js" import { SessionMessage } from "./session-message.js" @@ -20,6 +19,7 @@ import { Skill as SkillSchema } from "./skill.js" import { Money } from "./money.js" import { Snapshot } from "./snapshot.js" import { TokenUsage } from "./token-usage.js" +import { SessionInput } from "./session-input.js" export { FileAttachment } @@ -35,12 +35,6 @@ export interface Source extends Schema.Schema.Type {} const Base = { sessionID: SessionID, } -const PromptFields = { - ...Base, - inputID: SessionMessage.ID, - prompt: Prompt, - delivery: Delivery, -} const options = { durable: { @@ -120,22 +114,26 @@ export const Forked = Event.durable({ }) export type Forked = typeof Forked.Type -export const PromptPromoted = Event.durable({ - type: "session.prompt.promoted", +export const InputPromoted = Event.durable({ + type: "session.input.promoted", ...options, schema: { sessionID: SessionID, inputID: SessionMessage.ID, }, }) -export type PromptPromoted = typeof PromptPromoted.Type +export type InputPromoted = typeof InputPromoted.Type -export const PromptAdmitted = Event.durable({ - type: "session.prompt.admitted", +export const InputAdmitted = Event.durable({ + type: "session.input.admitted", ...options, - schema: PromptFields, + schema: { + ...Base, + inputID: SessionMessage.ID, + input: SessionInput.Message, + }, }) -export type PromptAdmitted = typeof PromptAdmitted.Type +export type InputAdmitted = typeof InputAdmitted.Type export namespace Execution { export const Started = Event.durable({ type: "session.execution.started", ...options, schema: Base }) @@ -523,8 +521,8 @@ export const Definitions = Event.inventory( UsageUpdated, Deleted, Forked, - PromptPromoted, - PromptAdmitted, + InputPromoted, + InputAdmitted, Execution.Started, Execution.Succeeded, Execution.Failed, diff --git a/packages/schema/src/session-input.ts b/packages/schema/src/session-input.ts index d1837ba08f..0b76d661e5 100644 --- a/packages/schema/src/session-input.ts +++ b/packages/schema/src/session-input.ts @@ -11,34 +11,70 @@ import { SessionMessage } from "./session-message.js" export const Delivery = SessionDelivery.Delivery export type Delivery = SessionDelivery.Delivery -export interface Admitted extends Schema.Schema.Type {} -export const Admitted = Schema.Struct({ +export interface UserData extends Schema.Schema.Type {} +export const UserData = Schema.Struct({ + ...Prompt.fields, + metadata: Schema.Record(Schema.String, Schema.Unknown).pipe(optional), +}).annotate({ identifier: "SessionInput.UserData" }) + +export interface SyntheticData extends Schema.Schema.Type {} +export const SyntheticData = Schema.Struct({ + text: Schema.String, + description: Schema.String.pipe(optional), + metadata: Schema.Record(Schema.String, Schema.Unknown).pipe(optional), +}).annotate({ identifier: "SessionInput.SyntheticData" }) + +export interface UserMessage extends Schema.Schema.Type {} +export const UserMessage = Schema.Struct({ + type: Schema.tag("user"), + data: UserData, + delivery: Delivery, +}).annotate({ identifier: "SessionInput.UserMessage" }) + +export interface SyntheticMessage extends Schema.Schema.Type {} +export const SyntheticMessage = Schema.Struct({ + type: Schema.tag("synthetic"), + data: SyntheticData, + delivery: Delivery, +}).annotate({ identifier: "SessionInput.SyntheticMessage" }) + +export const Message = Schema.Union([UserMessage, SyntheticMessage]).pipe( + Schema.toTaggedUnion("type"), + Schema.annotate({ identifier: "SessionInput.Message" }), +) +export type Message = typeof Message.Type + +const Admitted = { admittedSeq: NonNegativeInt, id: SessionMessage.ID, sessionID: SessionID, - prompt: Prompt, - delivery: Delivery, timeCreated: DateTimeUtcFromMillis, +} +const MessageLifecycle = { + ...Admitted, promotedSeq: NonNegativeInt.pipe(optional), -}).annotate({ identifier: "SessionInput.Admitted" }) +} -export interface PromptEntry extends Schema.Schema.Type {} -export const PromptEntry = Schema.Struct({ - type: Schema.tag("prompt"), - ...Admitted.fields, -}).annotate({ identifier: "SessionInput.PromptEntry" }) +export interface User extends Schema.Schema.Type {} +export const User = Schema.Struct({ + ...MessageLifecycle, + ...UserMessage.fields, +}).annotate({ identifier: "SessionInput.User" }) + +export interface Synthetic extends Schema.Schema.Type {} +export const Synthetic = Schema.Struct({ + ...MessageLifecycle, + ...SyntheticMessage.fields, +}).annotate({ identifier: "SessionInput.Synthetic" }) export interface Compaction extends Schema.Schema.Type {} export const Compaction = Schema.Struct({ + ...Admitted, type: Schema.tag("compaction"), - admittedSeq: NonNegativeInt, - id: SessionMessage.ID, - sessionID: SessionID, - timeCreated: DateTimeUtcFromMillis, handledSeq: NonNegativeInt.pipe(optional), }).annotate({ identifier: "SessionInput.Compaction" }) -export const Info = Schema.Union([PromptEntry, Compaction]).pipe( +export const Info = Schema.Union([User, Synthetic, Compaction]).pipe( Schema.toTaggedUnion("type"), Schema.annotate({ identifier: "SessionInput.Info" }), ) diff --git a/packages/schema/src/session-todo.ts b/packages/schema/src/session-todo.ts deleted file mode 100644 index f4d68268ff..0000000000 --- a/packages/schema/src/session-todo.ts +++ /dev/null @@ -1,25 +0,0 @@ -export * as SessionTodo from "./session-todo.js" - -import { Schema } from "effect" -import { ephemeral, inventory } from "./event.js" -import { SessionID } from "./session-id.js" - -export const Info = Schema.Struct({ - content: Schema.String.annotate({ description: "Brief description of the task" }), - status: Schema.String.annotate({ - description: "Current status of the task: pending, in_progress, completed, cancelled", - }), - priority: Schema.String.annotate({ - description: "Priority level of the task: high, medium, low", - }), -}).annotate({ identifier: "Todo" }) -export interface Info extends Schema.Schema.Type {} - -const Updated = ephemeral({ - type: "todo.updated", - schema: { - sessionID: SessionID, - todos: Schema.Array(Info), - }, -}) -export const Event = { Updated, Definitions: inventory(Updated) } diff --git a/packages/schema/test/contract-hygiene.test.ts b/packages/schema/test/contract-hygiene.test.ts index f5f310d8d7..140be20cba 100644 --- a/packages/schema/test/contract-hygiene.test.ts +++ b/packages/schema/test/contract-hygiene.test.ts @@ -16,7 +16,6 @@ import { Money } from "../src/money.js" import { Skill } from "../src/skill.js" import { Shell } from "../src/shell.js" import { PersistedRevert } from "../src/session-revert.js" -import { SessionTodo } from "../src/session-todo.js" import { optional } from "../src/schema.js" describe("contract hygiene", () => { @@ -39,6 +38,13 @@ describe("contract hygiene", () => { expect(Schema.decodeUnknownSync(Value)({ value: "1" })).toEqual({ value: 1 }) expect(Schema.encodeSync(Value)({ value: 1 })).toEqual({ value: "1" }) expect(Schema.encodeSync(Value)({ value: undefined })).toEqual({}) + expect( + Schema.encodeSync(SessionInput.SyntheticData)({ + text: "completed", + description: undefined, + metadata: undefined, + }), + ).toEqual({ text: "completed" }) }) test("model defaults and provider overlays preserve public invariants", () => { @@ -54,15 +60,6 @@ describe("contract hygiene", () => { ).toThrow() }) - test("todo status and priority preserve arbitrary strings", () => { - const decode = Schema.decodeUnknownSync(SessionTodo.Info) - expect(decode({ content: "ship", status: "waiting", priority: "urgent" })).toEqual({ - content: "ship", - status: "waiting", - priority: "urgent", - }) - }) - test("current ID constructors expose create", () => { expect(Question.ID.create()).toStartWith("que_") expect(Pty.ID.create()).toStartWith("pty_") @@ -92,6 +89,10 @@ describe("contract hygiene", () => { Pty.Info, Session.ListAnchor, Session.Revert, + SessionInput.UserData, + SessionInput.SyntheticData, + SessionInput.User, + SessionInput.Synthetic, ].map((schema) => schema.ast.annotations?.identifier) expect(identifiers.every((identifier) => typeof identifier === "string")).toBe(true) diff --git a/packages/schema/test/event-manifest.test.ts b/packages/schema/test/event-manifest.test.ts index 971ac68f24..80d179ae25 100644 --- a/packages/schema/test/event-manifest.test.ts +++ b/packages/schema/test/event-manifest.test.ts @@ -19,7 +19,6 @@ import { Plugin } from "../src/plugin.js" import { SessionEvent } from "../src/session-event.js" import { SessionID } from "../src/session-id.js" import { SessionMessage } from "../src/session-message.js" -import { SessionTodo } from "../src/session-todo.js" import { SessionV1 } from "../src/session-v1.js" import { WorkspaceEvent } from "../src/workspace-event.js" @@ -64,7 +63,6 @@ describe("public event manifest", () => { expect(Workspace.Event).toBe(WorkspaceEvent) expect(Workspace.Event.Definitions).toBe(WorkspaceEvent.Definitions) expect(EventManifest.Latest.get("session.step.ended")).toBe(SessionEvent.Step.Ended) - expect(EventManifest.Latest.get("todo.updated")).toBe(SessionTodo.Event.Updated) expect(EventManifest.Latest.get("agent.updated")).toBe(Agent.Event.Updated) expect(EventManifest.Latest.get("project.updated")).toBe(Project.Event.Updated) expect(Agent.Event.Definitions).toEqual([Agent.Event.Updated]) @@ -107,8 +105,8 @@ describe("public event manifest", () => { "session.moved.1", "session.renamed.1", "session.forked.1", - "session.prompt.promoted.1", - "session.prompt.admitted.1", + "session.input.promoted.1", + "session.input.admitted.1", "session.execution.started.1", "session.execution.succeeded.1", "session.execution.failed.1", diff --git a/packages/schema/test/model.test.ts b/packages/schema/test/model.test.ts new file mode 100644 index 0000000000..1dedf08698 --- /dev/null +++ b/packages/schema/test/model.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, test } from "bun:test" +import { Model } from "../src/model.js" + +describe("Model.Ref", () => { + test("parses model references with optional variants", () => { + const variant = Model.Ref.parse("openrouter/openai/gpt-5#high") + expect(String(variant.providerID)).toBe("openrouter") + expect(String(variant.id)).toBe("openai/gpt-5") + expect(String(variant.variant)).toBe("high") + + const standard = Model.Ref.parse("anthropic/claude-sonnet") + expect(String(standard.providerID)).toBe("anthropic") + expect(String(standard.id)).toBe("claude-sonnet") + expect(standard.variant).toBeUndefined() + }) + + test("rejects malformed model references", () => { + expect(() => Model.Ref.parse("gpt-5")).toThrow() + expect(() => Model.Ref.parse("openai/gpt-5#")).toThrow() + expect(() => Model.Ref.parse("openai/gpt-5#high#extra")).toThrow() + }) +}) diff --git a/packages/sdk-next/test/embedded.test.ts b/packages/sdk-next/test/embedded.test.ts index 9dfe7e2a14..482f8c9501 100644 --- a/packages/sdk-next/test/embedded.test.ts +++ b/packages/sdk-next/test/embedded.test.ts @@ -225,7 +225,7 @@ it.live( const active = yield* opencode.sessions.active() const admitted = yield* opencode.sessions.prompt({ sessionID: id, - prompt: fixture.sdk.PromptInput.Prompt.make({ text: "Do not run" }), + text: "Do not run", resume: false, }) const context = yield* opencode.sessions.context({ sessionID: id }) @@ -236,10 +236,10 @@ it.live( const remainingContextEntries = yield* opencode.sessions.instructions.entry.list({ sessionID: id }) const wake = yield* opencode.sessions.prompt({ sessionID: id, - prompt: fixture.sdk.PromptInput.Prompt.make({ text: "Promote this input" }), + text: "Promote this input", }) const prompted = yield* opencode.sessions.log({ sessionID: id, follow: true }).pipe( - Stream.filter((event) => event.type === "session.prompt.promoted" && event.data.inputID === wake.id), + Stream.filter((event) => event.type === "session.input.promoted" && event.data.inputID === wake.id), Stream.runHead, Effect.timeout("10 seconds"), Effect.map(Option.getOrThrow), @@ -280,7 +280,7 @@ it.live( expect(page.data.some((session) => session.id === id)).toBe(true) expect(active).toEqual({}) expect(admitted.sessionID).toBe(id) - expect(prompted.type).toBe("session.prompt.promoted") + expect(prompted.type).toBe("session.input.promoted") expect(wakeContext).toContainEqual(expect.objectContaining({ id: wake.id, type: "user" })) expect(contextEntries).toEqual([ { key: "deploy-target", value: "production" }, @@ -310,13 +310,13 @@ it.live( const opencode = yield* fixture.sdk.OpenCode.create() const id = sessionID(fixture) const connected = yield* Latch.make(false) - const prompted = yield* Deferred.make>() + const prompted = yield* Deferred.make>() yield* opencode.events.subscribe().pipe( Stream.runForEach((event) => event.type === "server.connected" ? connected.open - : event.type === "session.prompt.promoted" && event.data.sessionID === id + : event.type === "session.input.promoted" && event.data.sessionID === id ? Deferred.succeed(prompted, event).pipe(Effect.asVoid) : Effect.void, ), @@ -326,7 +326,7 @@ it.live( yield* opencode.sessions.create({ id, location: location(fixture) }) yield* opencode.sessions.prompt({ sessionID: id, - prompt: fixture.sdk.PromptInput.Prompt.make({ text: "Observe this input" }), + text: "Observe this input", }) const event = yield* Deferred.await(prompted).pipe(Effect.timeout("4 seconds")) diff --git a/packages/sdk/js/script/build.ts b/packages/sdk/js/script/build.ts index 20a4fcd803..8999db0590 100755 --- a/packages/sdk/js/script/build.ts +++ b/packages/sdk/js/script/build.ts @@ -62,7 +62,7 @@ if (schemas) { visit({ ...document, components: { ...document.components, schemas: undefined } }) for (const name of Object.keys(schemas)) { if ( - /^(SessionAgentSelected|SessionModelSelected|SessionMoved|SessionRenamed|SessionForked|SessionPromptPromoted|SessionPromptAdmitted|SessionExecutionStarted|SessionExecutionSucceeded|SessionExecutionFailed|SessionExecutionInterrupted|SessionInstructionsUpdated|SessionSynthetic|SessionSkillActivated|SessionShellStarted|SessionShellEnded|SessionStepStarted|SessionStepEnded|SessionStepFailed|SessionTextStarted|SessionTextDelta|SessionTextEnded|SessionToolInputStarted|SessionToolInputDelta|SessionToolInputEnded|SessionToolCalled|SessionToolProgress|SessionToolSuccess|SessionToolFailed|SessionRetryScheduled|SessionCompactionStarted|SessionCompactionDelta|SessionCompactionEnded|SessionRevertStaged|SessionRevertCleared|SessionRevertCommitted)\d+$/.test( + /^(SessionAgentSelected|SessionModelSelected|SessionMoved|SessionRenamed|SessionForked|SessionInputPromoted|SessionInputAdmitted|SessionExecutionStarted|SessionExecutionSucceeded|SessionExecutionFailed|SessionExecutionInterrupted|SessionInstructionsUpdated|SessionSynthetic|SessionSkillActivated|SessionShellStarted|SessionShellEnded|SessionStepStarted|SessionStepEnded|SessionStepFailed|SessionTextStarted|SessionTextDelta|SessionTextEnded|SessionToolInputStarted|SessionToolInputDelta|SessionToolInputEnded|SessionToolCalled|SessionToolProgress|SessionToolSuccess|SessionToolFailed|SessionRetryScheduled|SessionCompactionStarted|SessionCompactionDelta|SessionCompactionEnded|SessionRevertStaged|SessionRevertCleared|SessionRevertCommitted)\d+$/.test( name, ) && !reachable.has(name) @@ -102,7 +102,7 @@ await createClient({ const generatedTypesPath = "./src/v2/gen/types.gen.ts" const generatedTypes = await Bun.file(generatedTypesPath).text() if ( - /export type (SessionAgentSelected|SessionModelSelected|SessionMoved|SessionRenamed|SessionForked|SessionPromptPromoted|SessionPromptAdmitted|SessionExecutionStarted|SessionExecutionSucceeded|SessionExecutionFailed|SessionExecutionInterrupted|SessionInstructionsUpdated|SessionSynthetic|SessionSkillActivated|SessionShellStarted|SessionShellEnded|SessionStepStarted|SessionStepEnded|SessionStepFailed|SessionTextStarted|SessionTextDelta|SessionTextEnded|SessionToolInputStarted|SessionToolInputDelta|SessionToolInputEnded|SessionToolCalled|SessionToolProgress|SessionToolSuccess|SessionToolFailed|SessionRetryScheduled|SessionCompactionStarted|SessionCompactionDelta|SessionCompactionEnded|SessionRevertStaged|SessionRevertCleared|SessionRevertCommitted)\d+ =/.test( + /export type (SessionAgentSelected|SessionModelSelected|SessionMoved|SessionRenamed|SessionForked|SessionInputPromoted|SessionInputAdmitted|SessionExecutionStarted|SessionExecutionSucceeded|SessionExecutionFailed|SessionExecutionInterrupted|SessionInstructionsUpdated|SessionSynthetic|SessionSkillActivated|SessionShellStarted|SessionShellEnded|SessionStepStarted|SessionStepEnded|SessionStepFailed|SessionTextStarted|SessionTextDelta|SessionTextEnded|SessionToolInputStarted|SessionToolInputDelta|SessionToolInputEnded|SessionToolCalled|SessionToolProgress|SessionToolSuccess|SessionToolFailed|SessionRetryScheduled|SessionCompactionStarted|SessionCompactionDelta|SessionCompactionEnded|SessionRevertStaged|SessionRevertCleared|SessionRevertCommitted)\d+ =/.test( generatedTypes, ) ) { diff --git a/packages/sdk/js/src/gen/sdk.gen.ts b/packages/sdk/js/src/gen/sdk.gen.ts index 5e3e67e1c0..4b5d9284c6 100644 --- a/packages/sdk/js/src/gen/sdk.gen.ts +++ b/packages/sdk/js/src/gen/sdk.gen.ts @@ -62,9 +62,6 @@ import type { SessionChildrenData, SessionChildrenResponses, SessionChildrenErrors, - SessionTodoData, - SessionTodoResponses, - SessionTodoErrors, SessionInitData, SessionInitResponses, SessionInitErrors, @@ -507,16 +504,6 @@ class Session extends _HeyApiClient { }) } - /** - * Get the todo list for a session - */ - public todo(options: Options) { - return (options.client ?? this._client).get({ - url: "/session/{id}/todo", - ...options, - }) - } - /** * Analyze the app and create an AGENTS.md file */ diff --git a/packages/sdk/js/src/gen/types.gen.ts b/packages/sdk/js/src/gen/types.gen.ts index 1055d82ce2..e9b704b468 100644 --- a/packages/sdk/js/src/gen/types.gen.ts +++ b/packages/sdk/js/src/gen/types.gen.ts @@ -493,33 +493,6 @@ export type EventFileEdited = { } } -export type Todo = { - /** - * Brief description of the task - */ - content: string - /** - * Current status of the task: pending, in_progress, completed, cancelled - */ - status: string - /** - * Priority level of the task: high, medium, low - */ - priority: string - /** - * Unique identifier for the todo item - */ - id: string -} - -export type EventTodoUpdated = { - type: "todo.updated" - properties: { - sessionID: string - todos: Array - } -} - export type EventCommandExecuted = { type: "command.executed" properties: { @@ -717,7 +690,6 @@ export type Event = | EventSessionIdle | EventSessionCompacted | EventFileEdited - | EventTodoUpdated | EventCommandExecuted | EventSessionCreated | EventSessionUpdated @@ -2272,42 +2244,6 @@ export type SessionChildrenResponses = { export type SessionChildrenResponse = SessionChildrenResponses[keyof SessionChildrenResponses] -export type SessionTodoData = { - body?: never - path: { - /** - * Session ID - */ - id: string - } - query?: { - directory?: string - } - url: "/session/{id}/todo" -} - -export type SessionTodoErrors = { - /** - * Bad request - */ - 400: BadRequestError - /** - * Not found - */ - 404: NotFoundError -} - -export type SessionTodoError = SessionTodoErrors[keyof SessionTodoErrors] - -export type SessionTodoResponses = { - /** - * Todo list - */ - 200: Array -} - -export type SessionTodoResponse = SessionTodoResponses[keyof SessionTodoResponses] - export type SessionInitData = { body?: { modelID: string diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index 03d76cc5de..c1ecda15ea 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -146,7 +146,6 @@ import type { ProjectUpdateErrors, ProjectUpdateResponses, PromptAgentAttachment, - PromptInput, PromptInputFileAttachment, ProviderAuthErrors, ProviderAuthResponses, @@ -220,8 +219,6 @@ import type { SessionStatusResponses, SessionSummarizeErrors, SessionSummarizeResponses, - SessionTodoErrors, - SessionTodoResponses, SessionUnrevertErrors, SessionUnrevertResponses, SessionUnshareErrors, @@ -362,6 +359,8 @@ import type { V2QuestionRequestListResponses, V2ReferenceListErrors, V2ReferenceListResponses, + V2ServerGetErrors, + V2ServerGetResponses, V2SessionActiveErrors, V2SessionActiveResponses, V2SessionBackgroundErrors, @@ -3710,38 +3709,6 @@ export class Session2 extends HeyApiClient { }) } - /** - * Get session todos - * - * Retrieve the todo list associated with a specific session, showing tasks and action items. - */ - public todo( - parameters: { - sessionID: string - directory?: string - workspace?: string - }, - options?: Options, - ) { - const params = buildClientParams( - [parameters], - [ - { - args: [ - { in: "path", key: "sessionID" }, - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - ], - }, - ], - ) - return (options?.client ?? this.client).get({ - url: "/session/{sessionID}/todo", - ...options, - ...params, - }) - } - /** * Get message diff * @@ -5113,6 +5080,20 @@ export class Health extends HeyApiClient { } } +export class Server extends HeyApiClient { + /** + * Get server information + * + * Return the URLs that can be used to connect to this server. + */ + public get(options?: Options) { + return (options?.client ?? this.client).get({ + url: "/api/server", + ...options, + }) + } +} + export class Location extends HeyApiClient { /** * Get location @@ -6149,7 +6130,12 @@ export class Session3 extends HeyApiClient { parameters: { sessionID: string id?: string | null - prompt?: PromptInput + text?: string + files?: Array + agents?: Array + metadata?: { + [key: string]: unknown + } delivery?: "steer" | "queue" | null resume?: boolean | null }, @@ -6162,7 +6148,10 @@ export class Session3 extends HeyApiClient { args: [ { in: "path", key: "sessionID" }, { in: "body", key: "id" }, - { in: "body", key: "prompt" }, + { in: "body", key: "text" }, + { in: "body", key: "files" }, + { in: "body", key: "agents" }, + { in: "body", key: "metadata" }, { in: "body", key: "delivery" }, { in: "body", key: "resume" }, ], @@ -6274,16 +6263,18 @@ export class Session3 extends HeyApiClient { /** * Add synthetic message * - * Append a synthetic message to a session and resume execution. + * Durably admit synthetic session input and schedule execution unless resume is false. */ public synthetic( parameters: { sessionID: string + id?: string | null text?: string description?: string | null metadata?: { [key: string]: unknown } + delivery?: "steer" | "queue" | null resume?: boolean | null }, options?: Options, @@ -6294,9 +6285,11 @@ export class Session3 extends HeyApiClient { { args: [ { in: "path", key: "sessionID" }, + { in: "body", key: "id" }, { in: "body", key: "text" }, { in: "body", key: "description" }, { in: "body", key: "metadata" }, + { in: "body", key: "delivery" }, { in: "body", key: "resume" }, ], }, @@ -8259,6 +8252,11 @@ export class V2 extends HeyApiClient { return (this._health ??= new Health({ client: this.client })) } + private _server?: Server + get server(): Server { + return (this._server ??= new Server({ client: this.client })) + } + private _location?: Location get location(): Location { return (this._location ??= new Location({ client: this.client })) diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 0edaac8a7c..92b9514db5 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -23,8 +23,8 @@ export type Event = | EventSessionRenamed | EventSessionUsageUpdated | EventSessionForked - | EventSessionPromptPromoted - | EventSessionPromptAdmitted + | EventSessionInputPromoted + | EventSessionInputAdmitted | EventSessionExecutionStarted | EventSessionExecutionSucceeded | EventSessionExecutionFailed @@ -87,7 +87,6 @@ export type Event = | EventFormCreated | EventFormReplied | EventFormCancelled - | EventTodoUpdated | EventLspUpdated | EventPermissionAsked | EventPermissionReplied @@ -599,12 +598,6 @@ export type Part = | RetryPart | CompactionPart -export type Prompt = { - text: string - files?: Array - agents?: Array -} - export type Shell = { id: string status: "running" | "exited" | "timeout" | "killed" @@ -634,21 +627,6 @@ export type Pty = { exitCode?: number } -export type Todo = { - /** - * Brief description of the task - */ - content: string - /** - * Current status of the task: pending, in_progress, completed, cancelled - */ - status: string - /** - * Priority level of the task: high, medium, low - */ - priority: string -} - export type SessionStatus = | { type: "idle" @@ -856,7 +834,7 @@ export type GlobalEvent = { } | { id: string - type: "session.prompt.promoted" + type: "session.input.promoted" properties: { sessionID: string inputID: string @@ -864,12 +842,11 @@ export type GlobalEvent = { } | { id: string - type: "session.prompt.admitted" + type: "session.input.admitted" properties: { sessionID: string inputID: string - prompt: Prompt - delivery: "steer" | "queue" + input: SessionInputMessage } } | { @@ -1451,14 +1428,6 @@ export type GlobalEvent = { sessionID: string } } - | { - id: string - type: "todo.updated" - properties: { - sessionID: string - todos: Array - } - } | { id: string type: "lsp.updated" @@ -1720,8 +1689,8 @@ export type GlobalEvent = { | SyncEventSessionMoved | SyncEventSessionRenamed | SyncEventSessionForked - | SyncEventSessionPromptPromoted - | SyncEventSessionPromptAdmitted + | SyncEventSessionInputPromoted + | SyncEventSessionInputAdmitted | SyncEventSessionExecutionStarted | SyncEventSessionExecutionSucceeded | SyncEventSessionExecutionFailed @@ -1789,7 +1758,6 @@ export type PermissionConfig = bash?: PermissionRuleConfig task?: PermissionRuleConfig external_directory?: PermissionRuleConfig - todowrite?: PermissionActionConfig question?: PermissionActionConfig webfetch?: PermissionActionConfig websearch?: PermissionActionConfig @@ -2876,12 +2844,6 @@ export type MessageNotFoundError = { message: string } -export type PromptInput = { - text: string - files?: Array - agents?: Array -} - export type ConflictError = { _tag: "ConflictError" message: string @@ -3058,8 +3020,8 @@ export type V2Event = | SessionRenamed | SessionUsageUpdated | SessionForked - | SessionPromptPromoted - | SessionPromptAdmitted + | SessionInputPromoted + | SessionInputAdmitted | SessionExecutionStarted | SessionExecutionSucceeded | SessionExecutionFailed @@ -3122,7 +3084,6 @@ export type V2Event = | FormCreated | FormReplied | FormCancelled - | TodoUpdated | LspUpdated | PermissionAsked | PermissionReplied @@ -3391,6 +3352,37 @@ export type PromptAgentAttachment = { mention?: PromptMention } +export type SessionInputUserData = { + text: string + files?: Array + agents?: Array + metadata?: { + [key: string]: unknown + } +} + +export type SessionInputUserMessage = { + type: "user" + data: SessionInputUserData + delivery: "steer" | "queue" +} + +export type SessionInputSyntheticData = { + text: string + description?: string + metadata?: { + [key: string]: unknown + } +} + +export type SessionInputSyntheticMessage = { + type: "synthetic" + data: SessionInputSyntheticData + delivery: "steer" | "queue" +} + +export type SessionInputMessage = SessionInputUserMessage | SessionInputSyntheticMessage + export type SessionStructuredError = { type: string message: string @@ -3556,7 +3548,7 @@ export type FormMultiselectField = { export type FormFormInfo = { id: string sessionID: string - title?: string + title: string metadata?: FormMetadata mode: "form" fields: Array @@ -3565,7 +3557,7 @@ export type FormFormInfo = { export type FormUrlInfo = { id: string sessionID: string - title?: string + title: string metadata?: FormMetadata mode: "url" url: string @@ -3799,11 +3791,11 @@ export type SyncEventSessionForked = { } } -export type SyncEventSessionPromptPromoted = { +export type SyncEventSessionInputPromoted = { type: "sync" id: string syncEvent: { - type: "session.prompt.promoted.1" + type: "session.input.promoted.1" id: string seq: number aggregateID: string @@ -3814,19 +3806,18 @@ export type SyncEventSessionPromptPromoted = { } } -export type SyncEventSessionPromptAdmitted = { +export type SyncEventSessionInputAdmitted = { type: "sync" id: string syncEvent: { - type: "session.prompt.admitted.1" + type: "session.input.admitted.1" id: string seq: number aggregateID: string data: { sessionID: string inputID: string - prompt: Prompt - delivery: "steer" | "queue" + input: SessionInputMessage } } } @@ -4459,22 +4450,34 @@ export type PromptInputFileAttachment = { mention?: PromptMention } -export type SessionInputAdmitted = { +export type SessionInputUser = { admittedSeq: number id: string sessionID: string - prompt: Prompt - delivery: "steer" | "queue" timeCreated: number promotedSeq?: number + type: "user" + data: SessionInputUserData + delivery: "steer" | "queue" +} + +export type SessionInputSynthetic = { + admittedSeq: number + id: string + sessionID: string + timeCreated: number + promotedSeq?: number + type: "synthetic" + data: SessionInputSyntheticData + delivery: "steer" | "queue" } export type SessionInputCompaction = { - type: "compaction" admittedSeq: number id: string sessionID: string timeCreated: number + type: "compaction" handledSeq?: number } @@ -4866,13 +4869,13 @@ export type SessionForked = { } } -export type SessionPromptPromoted = { +export type SessionInputPromoted = { id: string created: number metadata?: { [key: string]: unknown } - type: "session.prompt.promoted" + type: "session.input.promoted" durable: { aggregateID: string seq: number @@ -4885,13 +4888,13 @@ export type SessionPromptPromoted = { } } -export type SessionPromptAdmitted = { +export type SessionInputAdmitted = { id: string created: number metadata?: { [key: string]: unknown } - type: "session.prompt.admitted" + type: "session.input.admitted" durable: { aggregateID: string seq: number @@ -4901,8 +4904,7 @@ export type SessionPromptAdmitted = { data: { sessionID: string inputID: string - prompt: Prompt - delivery: "steer" | "queue" + input: SessionInputMessage } } @@ -5548,8 +5550,8 @@ export type SessionEventDurable = | SessionRenamed | SessionDeleted | SessionForked - | SessionPromptPromoted - | SessionPromptAdmitted + | SessionInputPromoted + | SessionInputAdmitted | SessionExecutionStarted | SessionExecutionSucceeded | SessionExecutionFailed @@ -5832,7 +5834,7 @@ export type ProjectCurrent = { export type FormCreatePayload = { id?: string - title?: string + title: string metadata?: FormMetadata mode: "form" | "url" fields?: Array @@ -6592,20 +6594,6 @@ export type FormCancelled = { } } -export type TodoUpdated = { - id: string - created: number - metadata?: { - [key: string]: unknown - } - type: "todo.updated" - location?: LocationRef - data: { - sessionID: string - todos: Array - } -} - export type LspUpdated = { id: string created: number @@ -7182,23 +7170,22 @@ export type EventSessionForked = { } } -export type EventSessionPromptPromoted = { +export type EventSessionInputPromoted = { id: string - type: "session.prompt.promoted" + type: "session.input.promoted" properties: { sessionID: string inputID: string } } -export type EventSessionPromptAdmitted = { +export type EventSessionInputAdmitted = { id: string - type: "session.prompt.admitted" + type: "session.input.admitted" properties: { sessionID: string inputID: string - prompt: Prompt - delivery: "steer" | "queue" + input: SessionInputMessage } } @@ -7867,15 +7854,6 @@ export type EventFormCancelled = { } } -export type EventTodoUpdated = { - id: string - type: "todo.updated" - properties: { - sessionID: string - todos: Array - } -} - export type EventLspUpdated = { id: string type: "lsp.updated" @@ -8644,8 +8622,8 @@ export type V2EventV2 = | SessionUsageUpdatedV2 | SessionDeletedV2 | SessionForkedV2 - | SessionPromptPromotedV2 - | SessionPromptAdmittedV2 + | SessionInputPromotedV2 + | SessionInputAdmittedV2 | SessionExecutionStartedV2 | SessionExecutionSucceededV2 | SessionExecutionFailedV2 @@ -8703,7 +8681,6 @@ export type V2EventV2 = | FormCreatedV2 | FormRepliedV2 | FormCancelledV2 - | TodoUpdatedV2 | SessionStatusV22 | SessionIdleV2 | TuiPromptAppendV2 @@ -8801,22 +8778,34 @@ export type SessionInfoV2 = { export type PromptBase64V2 = string -export type SessionInputAdmittedV2 = { +export type SessionInputUserV2 = { admittedSeq: number id: string sessionID: string - prompt: Prompt - delivery: "steer" | "queue" timeCreated: number promotedSeq?: number + type: "user" + data: SessionInputUserData + delivery: "steer" | "queue" +} + +export type SessionInputSyntheticV2 = { + admittedSeq: number + id: string + sessionID: string + timeCreated: number + promotedSeq?: number + type: "synthetic" + data: SessionInputSyntheticData + delivery: "steer" | "queue" } export type SessionInputCompactionV2 = { - type: "compaction" admittedSeq: number id: string sessionID: string timeCreated: number + type: "compaction" handledSeq?: number } @@ -9115,13 +9104,13 @@ export type SessionForkedV2 = { } } -export type SessionPromptPromotedV2 = { +export type SessionInputPromotedV2 = { id: string created: number metadata?: { [key: string]: unknown } - type: "session.prompt.promoted" + type: "session.input.promoted" durable: { aggregateID: string seq: number @@ -9134,13 +9123,42 @@ export type SessionPromptPromotedV2 = { } } -export type SessionPromptAdmittedV2 = { +export type SessionInputUserData1 = { + text: string + files?: Array + agents?: Array + metadata?: { + [key: string]: unknown + } +} + +export type SessionInputUserMessageV2 = { + type: "user" + data: SessionInputUserData1 + delivery: "steer" | "queue" +} + +export type SessionInputSyntheticData1 = { + text: string + description?: string + metadata?: { + [key: string]: unknown + } +} + +export type SessionInputSyntheticMessageV2 = { + type: "synthetic" + data: SessionInputSyntheticData1 + delivery: "steer" | "queue" +} + +export type SessionInputAdmittedV2 = { id: string created: number metadata?: { [key: string]: unknown } - type: "session.prompt.admitted" + type: "session.input.admitted" durable: { aggregateID: string seq: number @@ -9150,8 +9168,7 @@ export type SessionPromptAdmittedV2 = { data: { sessionID: string inputID: string - prompt: Prompt - delivery: "steer" | "queue" + input: SessionInputMessage } } @@ -9878,7 +9895,7 @@ export type FormMultiselectFieldV2 = { export type FormFormInfoV2 = { id: string sessionID: string - title?: string + title: string metadata?: FormMetadata mode: "form" fields: Array @@ -9887,7 +9904,7 @@ export type FormFormInfoV2 = { export type FormUrlInfoV2 = { id: string sessionID: string - title?: string + title: string metadata?: FormMetadata mode: "url" url: string @@ -9895,7 +9912,7 @@ export type FormUrlInfoV2 = { export type FormCreatePayloadV2 = { id?: string | null - title?: string + title: string metadata?: FormMetadata mode: "form" | "url" fields?: Array< @@ -10613,7 +10630,7 @@ export type FormMultiselectField1 = { export type FormFormInfo1 = { id: string sessionID: string - title?: string + title: string metadata?: FormMetadata1 mode: "form" fields: Array @@ -10622,7 +10639,7 @@ export type FormFormInfo1 = { export type FormUrlInfo1 = { id: string sessionID: string - title?: string + title: string metadata?: FormMetadata1 mode: "url" url: string @@ -10674,20 +10691,6 @@ export type FormCancelledV2 = { } } -export type TodoUpdatedV2 = { - id: string - created: number - metadata?: { - [key: string]: unknown - } - type: "todo.updated" - location?: LocationRefV2 - data: { - sessionID: string - todos: Array - } -} - export type SessionIdleV2 = { id: string created: number @@ -13581,40 +13584,6 @@ export type SessionChildrenResponses = { export type SessionChildrenResponse = SessionChildrenResponses[keyof SessionChildrenResponses] -export type SessionTodoData = { - body?: never - path: { - sessionID: string - } - query?: { - directory?: string - workspace?: string - } - url: "/session/{sessionID}/todo" -} - -export type SessionTodoErrors = { - /** - * BadRequest | InvalidRequestError - */ - 400: EffectHttpApiErrorBadRequest | InvalidRequestError - /** - * NotFoundError - */ - 404: NotFoundError -} - -export type SessionTodoError = SessionTodoErrors[keyof SessionTodoErrors] - -export type SessionTodoResponses = { - /** - * Todo list - */ - 200: Array -} - -export type SessionTodoResponse = SessionTodoResponses[keyof SessionTodoResponses] - export type SessionDiffData = { body?: never path: { @@ -15154,6 +15123,37 @@ export type V2HealthGetResponses = { export type V2HealthGetResponse = V2HealthGetResponses[keyof V2HealthGetResponses] +export type V2ServerGetData = { + body?: never + path?: never + query?: never + url: "/api/server" +} + +export type V2ServerGetErrors = { + /** + * InvalidRequestError + */ + 400: InvalidRequestErrorV2 + /** + * UnauthorizedError + */ + 401: UnauthorizedError +} + +export type V2ServerGetError = V2ServerGetErrors[keyof V2ServerGetErrors] + +export type V2ServerGetResponses = { + /** + * Success + */ + 200: { + urls: Array + } +} + +export type V2ServerGetResponse = V2ServerGetResponses[keyof V2ServerGetResponses] + export type V2LocationGetData = { body?: never path?: never @@ -15641,7 +15641,12 @@ export type V2SessionMoveResponse = V2SessionMoveResponses[keyof V2SessionMoveRe export type V2SessionPromptData = { body: { id?: string | null - prompt: PromptInput + text: string + files?: Array + agents?: Array + metadata?: { + [key: string]: unknown + } delivery?: "steer" | "queue" | null resume?: boolean | null } @@ -15678,7 +15683,7 @@ export type V2SessionPromptResponses = { * Success */ 200: { - data: SessionInputAdmittedV2 + data: SessionInputUserV2 } } @@ -15733,7 +15738,7 @@ export type V2SessionCommandResponses = { * Success */ 200: { - data: SessionInputAdmittedV2 + data: SessionInputUserV2 } } @@ -15780,11 +15785,13 @@ export type V2SessionSkillResponse = V2SessionSkillResponses[keyof V2SessionSkil export type V2SessionSyntheticData = { body: { + id?: string | null text: string description?: string | null metadata?: { [key: string]: unknown } + delivery?: "steer" | "queue" | null resume?: boolean | null } path: { @@ -15807,15 +15814,21 @@ export type V2SessionSyntheticErrors = { * SessionNotFoundError */ 404: SessionNotFoundError + /** + * ConflictError + */ + 409: ConflictErrorV2 } export type V2SessionSyntheticError = V2SessionSyntheticErrors[keyof V2SessionSyntheticErrors] export type V2SessionSyntheticResponses = { /** - * + * Success */ - 204: void + 200: { + data: SessionInputSyntheticV2 + } } export type V2SessionSyntheticResponse = V2SessionSyntheticResponses[keyof V2SessionSyntheticResponses] diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index a3886cc0c9..d5a273edea 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -5925,90 +5925,6 @@ ] } }, - "/session/{sessionID}/todo": { - "get": { - "tags": ["session"], - "operationId": "session.todo", - "parameters": [ - { - "name": "sessionID", - "in": "path", - "schema": { - "type": "string", - "pattern": "^ses" - }, - "required": true - }, - { - "name": "directory", - "in": "query", - "schema": { - "type": "string" - }, - "required": false - }, - { - "name": "workspace", - "in": "query", - "schema": { - "type": "string" - }, - "required": false - } - ], - "responses": { - "200": { - "description": "Todo list", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Todo" - }, - "description": "Todo list" - } - } - } - }, - "400": { - "description": "BadRequest | InvalidRequestError", - "content": { - "application/json": { - "schema": { - "anyOf": [ - { - "$ref": "#/components/schemas/effect_HttpApiError_BadRequest" - }, - { - "$ref": "#/components/schemas/InvalidRequestError" - } - ] - } - } - } - }, - "404": { - "description": "NotFoundError", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/NotFoundError" - } - } - } - } - }, - "description": "Retrieve the todo list associated with a specific session, showing tasks and action items.", - "summary": "Get session todos", - "x-codeSamples": [ - { - "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.session.todo({\n ...\n})" - } - ] - } - }, "/session/{sessionID}/diff": { "get": { "tags": ["session"], @@ -15445,9 +15361,6 @@ { "$ref": "#/components/schemas/EventQuestionV2Rejected" }, - { - "$ref": "#/components/schemas/EventTodoUpdated" - }, { "$ref": "#/components/schemas/EventLspUpdated" }, @@ -17244,25 +17157,6 @@ "required": ["id", "title", "command", "args", "cwd", "status", "pid"], "additionalProperties": false }, - "Todo": { - "type": "object", - "properties": { - "content": { - "type": "string", - "description": "Brief description of the task" - }, - "status": { - "type": "string", - "description": "Current status of the task: pending, in_progress, completed, cancelled" - }, - "priority": { - "type": "string", - "description": "Priority level of the task: high, medium, low" - } - }, - "required": ["content", "status", "priority"], - "additionalProperties": false - }, "SessionStatus": { "anyOf": [ { @@ -19611,38 +19505,6 @@ "required": ["id", "type", "properties"], "additionalProperties": false }, - { - "type": "object", - "properties": { - "id": { - "type": "string", - "pattern": "^evt_" - }, - "type": { - "type": "string", - "enum": ["todo.updated"] - }, - "properties": { - "type": "object", - "properties": { - "sessionID": { - "type": "string", - "pattern": "^ses" - }, - "todos": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Todo" - } - } - }, - "required": ["sessionID", "todos"], - "additionalProperties": false - } - }, - "required": ["id", "type", "properties"], - "additionalProperties": false - }, { "type": "object", "properties": { @@ -20612,9 +20474,6 @@ "external_directory": { "$ref": "#/components/schemas/PermissionRuleConfig" }, - "todowrite": { - "$ref": "#/components/schemas/PermissionActionConfig" - }, "question": { "$ref": "#/components/schemas/PermissionActionConfig" }, @@ -24164,9 +24023,6 @@ { "$ref": "#/components/schemas/QuestionV2Rejected" }, - { - "$ref": "#/components/schemas/TodoUpdated" - }, { "$ref": "#/components/schemas/LspUpdated" }, @@ -32461,60 +32317,6 @@ "required": ["id", "type", "data"], "additionalProperties": false }, - "TodoUpdated": { - "type": "object", - "properties": { - "id": { - "type": "string", - "pattern": "^evt_" - }, - "metadata": { - "type": "object" - }, - "type": { - "type": "string", - "enum": ["todo.updated"] - }, - "durable": { - "type": "object", - "properties": { - "aggregateID": { - "type": "string" - }, - "seq": { - "type": "integer" - }, - "version": { - "type": "integer" - } - }, - "required": ["aggregateID", "seq", "version"], - "additionalProperties": false - }, - "location": { - "$ref": "#/components/schemas/LocationRef" - }, - "data": { - "type": "object", - "properties": { - "sessionID": { - "type": "string", - "pattern": "^ses" - }, - "todos": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Todo" - } - } - }, - "required": ["sessionID", "todos"], - "additionalProperties": false - } - }, - "required": ["id", "type", "data"], - "additionalProperties": false - }, "LspUpdated": { "type": "object", "properties": { @@ -35990,38 +35792,6 @@ "required": ["id", "type", "properties"], "additionalProperties": false }, - "EventTodoUpdated": { - "type": "object", - "properties": { - "id": { - "type": "string", - "pattern": "^evt_" - }, - "type": { - "type": "string", - "enum": ["todo.updated"] - }, - "properties": { - "type": "object", - "properties": { - "sessionID": { - "type": "string", - "pattern": "^ses" - }, - "todos": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Todo" - } - } - }, - "required": ["sessionID", "todos"], - "additionalProperties": false - } - }, - "required": ["id", "type", "properties"], - "additionalProperties": false - }, "EventLspUpdated": { "type": "object", "properties": { diff --git a/packages/server/src/handlers.ts b/packages/server/src/handlers.ts index 896cbbc592..5dfbefe11c 100644 --- a/packages/server/src/handlers.ts +++ b/packages/server/src/handlers.ts @@ -13,6 +13,7 @@ import { EventHandler } from "./handlers/event" import { AgentHandler } from "./handlers/agent" import { PluginHandler } from "./handlers/plugin" import { HealthHandler } from "./handlers/health" +import { ServerHandler } from "./handlers/server" import { DebugHandler } from "./handlers/debug" import { PtyHandler } from "./handlers/pty" import { ShellHandler } from "./handlers/shell" @@ -28,6 +29,7 @@ import { VcsHandler } from "./handlers/vcs" export const handlers = Layer.mergeAll( HealthHandler, + ServerHandler, DebugHandler, LocationHandler, AgentHandler, diff --git a/packages/server/src/handlers/event.ts b/packages/server/src/handlers/event.ts index e17c815cb8..524746acbf 100644 --- a/packages/server/src/handlers/event.ts +++ b/packages/server/src/handlers/event.ts @@ -6,7 +6,9 @@ import { HttpServerResponse } from "effect/unstable/http" import { HttpApiBuilder } from "effect/unstable/httpapi" import { Api } from "../api" -const subscriberCapacity = 256 +// Session execution emits dense event bursts; allow healthy SSE clients enough +// time to absorb one without weakening the bounded slow-subscriber failure. +const subscriberCapacity = 4_096 function eventData(data: unknown): Sse.Event { return { diff --git a/packages/server/src/handlers/server.ts b/packages/server/src/handlers/server.ts new file mode 100644 index 0000000000..0eec917cd4 --- /dev/null +++ b/packages/server/src/handlers/server.ts @@ -0,0 +1,13 @@ +import { Effect } from "effect" +import { HttpApiBuilder } from "effect/unstable/httpapi" +import { Api } from "../api" +import { ServerInfo } from "../server-info" + +export const ServerHandler = HttpApiBuilder.group(Api, "server.server", (handlers) => + handlers.handle("server.get", () => + Effect.gen(function* () { + const info = yield* ServerInfo.Service + return { urls: info.urls() } + }), + ), +) diff --git a/packages/server/src/handlers/session.ts b/packages/server/src/handlers/session.ts index e3160cc231..8bb4694363 100644 --- a/packages/server/src/handlers/session.ts +++ b/packages/server/src/handlers/session.ts @@ -206,37 +206,39 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl .handle( "session.move", Effect.fn(function* (ctx) { - yield* moveSession.moveSession({ - sessionID: ctx.params.sessionID, - destination: ctx.payload.destination, - moveChanges: ctx.payload.moveChanges, - }).pipe( - Effect.catchTag("Session.NotFoundError", (error) => - Effect.fail( - new SessionNotFoundError({ - sessionID: error.sessionID, - message: `Session not found: ${error.sessionID}`, - }), + yield* moveSession + .moveSession({ + sessionID: ctx.params.sessionID, + destination: ctx.payload.destination, + moveChanges: ctx.payload.moveChanges, + }) + .pipe( + Effect.catchTag("Session.NotFoundError", (error) => + Effect.fail( + new SessionNotFoundError({ + sessionID: error.sessionID, + message: `Session not found: ${error.sessionID}`, + }), + ), ), - ), - Effect.catchTag("MoveSession.DestinationProjectMismatchError", () => - Effect.fail(new InvalidRequestError({ message: "Destination directory belongs to another project" })), - ), - Effect.catchTag("MoveSession.ApplyChangesError", () => - Effect.fail( - new InvalidRequestError({ - message: - "Unable to apply your changes in the destination directory. The files may conflict with existing changes.", - }), + Effect.catchTag("MoveSession.DestinationProjectMismatchError", () => + Effect.fail(new InvalidRequestError({ message: "Destination directory belongs to another project" })), ), - ), - Effect.catchTag("MoveSession.CaptureChangesError", (error) => - Effect.fail(new InvalidRequestError({ message: error.message })), - ), - Effect.catchTag("MoveSession.ResetSourceChangesError", (error) => - Effect.fail(new InvalidRequestError({ message: error.message })), - ), - ) + Effect.catchTag("MoveSession.ApplyChangesError", () => + Effect.fail( + new InvalidRequestError({ + message: + "Unable to apply your changes in the destination directory. The files may conflict with existing changes.", + }), + ), + ), + Effect.catchTag("MoveSession.CaptureChangesError", (error) => + Effect.fail(new InvalidRequestError({ message: error.message })), + ), + Effect.catchTag("MoveSession.ResetSourceChangesError", (error) => + Effect.fail(new InvalidRequestError({ message: error.message })), + ), + ) return HttpApiSchema.NoContent.make() }), ) @@ -248,7 +250,10 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl .prompt({ sessionID: ctx.params.sessionID, id: ctx.payload.id, - prompt: ctx.payload.prompt, + text: ctx.payload.text, + files: ctx.payload.files, + agents: ctx.payload.agents, + metadata: ctx.payload.metadata, delivery: ctx.payload.delivery, resume: ctx.payload.resume, }) @@ -270,7 +275,7 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl ), ), Effect.catchTag("Session.AttachmentError", (error) => - Effect.fail(new InvalidRequestError({ message: error.message, field: "prompt.files" })), + Effect.fail(new InvalidRequestError({ message: error.message, field: "files" })), ), ), } @@ -362,12 +367,14 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl .handle( "session.synthetic", Effect.fn(function* (ctx) { - yield* session + const data = yield* session .synthetic({ + id: ctx.payload.id, sessionID: ctx.params.sessionID, text: ctx.payload.text, description: ctx.payload.description, metadata: ctx.payload.metadata, + delivery: ctx.payload.delivery, resume: ctx.payload.resume, }) .pipe( @@ -379,8 +386,16 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl }), ), ), + Effect.catchTag("Session.SyntheticConflictError", (error) => + Effect.fail( + new ConflictError({ + message: `Synthetic input ID conflicts with an existing durable record: ${error.inputID}`, + resource: error.inputID, + }), + ), + ), ) - return HttpApiSchema.NoContent.make() + return { data } }), ) .handle( diff --git a/packages/server/src/process.ts b/packages/server/src/process.ts index 971d77734b..d9bef69e10 100644 --- a/packages/server/src/process.ts +++ b/packages/server/src/process.ts @@ -6,13 +6,16 @@ import { Context, Effect, Layer, Option } from "effect" import { HttpClient, HttpClientRequest, HttpMiddleware, HttpRouter, HttpServer } from "effect/unstable/http" import { HttpApi, HttpApiClient } from "effect/unstable/httpapi" import { createServer } from "node:http" +import { SessionRestart } from "@opencode-ai/core/session/execution/restart" import { ServerAuth } from "./auth" import { createRoutes } from "./routes" +import { ServerInfo } from "./server-info" export type Options = { readonly hostname: string readonly port: Option.Option readonly password: string + readonly restartContinuity?: boolean } const ReadinessApi = HttpApi.make("readiness").add(HealthGroup) @@ -37,25 +40,49 @@ export const start = Effect.fn("ServerProcess.start")(function* (options: Option }) function listen(options: Options) { - if (Option.isSome(options.port)) return bind(options.hostname, options.port.value, options.password) + if (Option.isSome(options.port)) return bind(options, options.port.value) const next = (port: number): ReturnType => - bind(options.hostname, port, options.password).pipe( - Effect.catch((error) => (port === 65_535 ? Effect.fail(error) : next(port + 1))), - ) + bind(options, port).pipe(Effect.catch((error) => (port === 65_535 ? Effect.fail(error) : next(port + 1)))) return next(4096) } -function bind(hostname: string, port: number, password: string) { +function bind(options: Options, port: number) { const server = createServer() return Layer.build( - createRoutes(password).pipe( - Layer.flatMap((context) => - HttpServer.serve(Context.get(context, HttpRouter.HttpRouter).asHttpEffect(), HttpMiddleware.logger), - ), - Layer.provideMerge(NodeHttpServer.layer(() => server, { port, host: hostname })), + createRoutes(options.password, () => { + const address = server.address() + if (address === null || typeof address === "string") return [] + const host = address.family === "IPv6" ? `[${address.address}]` : address.address + return ServerInfo.connectionURLs(`http://${host}:${address.port}`, options.hostname) + }).pipe( + Layer.flatMap((context) => { + const serve = HttpServer.serve( + Context.get(context, HttpRouter.HttpRouter).asHttpEffect(), + HttpMiddleware.logger, + ) + if (!options.restartContinuity) return serve + const restart = Context.get(context, SessionRestart.Service) + return Layer.merge(serve, restartContinuity(restart)) + }), + Layer.provideMerge(NodeHttpServer.layer(() => server, { port, host: options.hostname })), ), ).pipe( Effect.tap(() => Effect.addFinalizer(() => Effect.sync(() => server.closeAllConnections()))), Effect.map((context) => Context.get(context, HttpServer.HttpServer).address), ) } + +/** + * The managed server owns restart continuity: it resumes Sessions the previous server suspended and + * suspends its own active Sessions on graceful shutdown. Suspension runs while the drains are still + * alive: connections close first, this finalizer runs next, and Session execution teardown follows. + */ +function restartContinuity(restart: SessionRestart.Interface) { + return Layer.effectDiscard( + Effect.gen(function* () { + yield* Effect.forkScoped(restart.resumeSuspendedSessions) + // Registered after the fork so suspension observes still-running resumed drains during teardown. + yield* Effect.addFinalizer(() => restart.suspendActiveSessions) + }), + ) +} diff --git a/packages/server/src/routes.ts b/packages/server/src/routes.ts index 773b01ad2f..3f44ec3bf1 100644 --- a/packages/server/src/routes.ts +++ b/packages/server/src/routes.ts @@ -11,10 +11,9 @@ import { PtyTicket } from "@opencode-ai/core/pty/ticket" import { MoveSession } from "@opencode-ai/core/control-plane/move-session" import { Project } from "@opencode-ai/core/project" import { SessionV2 } from "@opencode-ai/core/session" -import { SessionExecution } from "@opencode-ai/core/session/execution" import { Job } from "@opencode-ai/core/job" import { LocationServiceMap } from "@opencode-ai/core/location-service-map" -import { SessionExecutionLocal } from "@opencode-ai/core/session/execution/local" +import { SessionRestart } from "@opencode-ai/core/session/execution/restart" import { PluginRuntime } from "@opencode-ai/core/plugin/runtime" import { SdkPlugins } from "@opencode-ai/core/plugin/sdk" import { ToolOutputStore } from "@opencode-ai/core/tool-output-store" @@ -30,6 +29,7 @@ import { PtyEnvironment } from "./pty-environment" import { layer } from "./location" import { formLocationLayer } from "./middleware/form-location" import { sessionLocationLayer } from "./middleware/session-location" +import { ServerInfo } from "./server-info" const applicationServices = LayerNode.group([ Database.node, @@ -48,13 +48,15 @@ const applicationServices = LayerNode.group([ Credential.node, PtyEnvironment.node, LocationServiceMap.node, + SessionRestart.node, ]) -export function createRoutes(password?: string) { +export function createRoutes(password?: string, serviceURLs: () => ReadonlyArray = () => []) { return makeRoutes( password ? ServerAuth.Config.configLayer({ username: "opencode", password: Option.some(password) }) : ServerAuth.Config.layer, + serviceURLs, ) } @@ -62,10 +64,12 @@ export function createEmbeddedRoutes() { return makeRoutes(ServerAuth.Config.configLayer({ username: "opencode", password: Option.none() })) } -function makeRoutes(auth: Layer.Layer) { +function makeRoutes( + auth: Layer.Layer, + serviceURLs: () => ReadonlyArray = () => [], +) { const pluginRuntimeCell = PluginRuntime.makeCell() const replacements: LayerNode.Replacements = [ - [SessionExecution.node, SessionExecutionLocal.node], [PluginRuntime.node, PluginRuntime.layerWithCell(pluginRuntimeCell)], [PluginRuntime.providerNode, PluginRuntime.providerNodeWithCell(pluginRuntimeCell)], ] @@ -87,7 +91,10 @@ function makeRoutes(auth: Layer.Layer { const services = Layer.succeedContext(context) - const requestServices = Layer.succeedContext(Context.pick(PermissionSaved.Service, Project.Service)(context)) + const requestServices = Layer.merge( + Layer.succeedContext(Context.pick(PermissionSaved.Service, Project.Service)(context)), + ServerInfo.layer(serviceURLs), + ) return HttpApiBuilder.layer(Api, { openapiPath: "/openapi.json" }).pipe( Layer.provide(handlers.pipe(Layer.provide(services))), Layer.provide(formLocationLayer), diff --git a/packages/server/src/server-info.ts b/packages/server/src/server-info.ts new file mode 100644 index 0000000000..7d1ba4b094 --- /dev/null +++ b/packages/server/src/server-info.ts @@ -0,0 +1,32 @@ +import { Context, Layer } from "effect" +import { networkInterfaces } from "node:os" + +export class Service extends Context.Service ReadonlyArray }>()( + "@opencode-ai/server/ServerInfo", +) {} + +export function layer(urls: () => ReadonlyArray) { + return Layer.succeed(Service, Service.of({ urls })) +} + +export function connectionURLs(value: string, requestedHostname?: string) { + const url = new URL(value) + const hostname = requestedHostname ?? url.hostname + const family = hostname === "0.0.0.0" ? "IPv4" : hostname === "::" || hostname === "[::]" ? "IPv6" : undefined + if (family === undefined) return [value] + + return [ + ...new Set( + Object.values(networkInterfaces()) + .flatMap((entries) => entries ?? []) + .filter((entry) => !entry.internal && entry.family === family) + .map((entry) => { + const result = new URL(value) + result.hostname = family === "IPv6" ? `[${entry.address}]` : entry.address + return result.toString().replace(/\/$/, "") + }), + ), + ] +} + +export * as ServerInfo from "./server-info" diff --git a/packages/session-ui/src/components/message-part.css b/packages/session-ui/src/components/message-part.css index 88779a3c6c..b50f0902d0 100644 --- a/packages/session-ui/src/components/message-part.css +++ b/packages/session-ui/src/components/message-part.css @@ -590,27 +590,6 @@ } } -[data-component="todos"] { - padding: 10px 0 24px 0; - display: flex; - flex-direction: column; - gap: 8px; - - [data-component="checkbox"] { - --checkbox-align: flex-start; - --checkbox-offset: 0.5px; - } - - [data-slot="message-part-todo-content"] { - line-height: var(--line-height-normal); - - &[data-completed="completed"] { - text-decoration: line-through; - color: var(--v2-text-text-faint); - } - } -} - [data-component="context-tool-group-trigger"] { width: 100%; min-height: 24px; @@ -694,7 +673,6 @@ [data-component="bash-output"], [data-component="edit-content"], [data-component="write-content"], -[data-component="todos"], [data-component="diagnostics"], .error-card { -webkit-user-select: text; @@ -1389,10 +1367,6 @@ body:not([data-new-layout]) { color: var(--text-interactive-base); } - [data-slot="message-part-todo-content"][data-completed="completed"] { - color: var(--text-weaker); - } - [data-component="diagnostics"] { background-color: var(--surface-critical-weak); border-top: 1px solid var(--border-critical-base); diff --git a/packages/session-ui/src/components/message-part.tsx b/packages/session-ui/src/components/message-part.tsx index f147cd7388..f78ba5f30a 100644 --- a/packages/session-ui/src/components/message-part.tsx +++ b/packages/session-ui/src/components/message-part.tsx @@ -27,7 +27,6 @@ import { TextPart, ToolPart, UserMessage, - Todo, QuestionAnswer, QuestionInfo, } from "@opencode-ai/sdk/v2" @@ -42,7 +41,6 @@ import { Collapsible } from "@opencode-ai/ui/collapsible" import { FileIcon } from "@opencode-ai/ui/file-icon" import { Icon } from "@opencode-ai/ui/icon" import { ToolErrorCard } from "./tool-error-card" -import { Checkbox } from "@opencode-ai/ui/checkbox" import { DiffChanges } from "@opencode-ai/ui/diff-changes" import { Markdown } from "./markdown" import { ImagePreview } from "@opencode-ai/ui/image-preview" @@ -545,11 +543,6 @@ export function getToolInfo( ? `${input.files.length} ${i18n.t(input.files.length > 1 ? "ui.common.file.other" : "ui.common.file.one")}` : undefined, } - case "todowrite": - return { - icon: "checklist", - title: i18n.t("ui.tool.todos"), - } case "question": return { icon: "bubble-5", @@ -613,8 +606,6 @@ function taskSession( } const CONTEXT_GROUP_TOOLS = new Set(["read", "glob", "grep", "list"]) -const HIDDEN_TOOLS = new Set(["todowrite"]) - function list(value: T[] | undefined | null, fallback: T[]) { if (Array.isArray(value)) return value return fallback @@ -718,7 +709,6 @@ function index(items: readonly T[]) { export function renderable(part: PartType, showReasoningSummaries = true) { if (part.type === "tool") { - if (HIDDEN_TOOLS.has(part.tool)) return false if (part.tool === "question") return part.state.status !== "pending" && part.state.status !== "running" return true } @@ -1498,8 +1488,6 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) { const data = useData() const i18n = useI18n() const part = () => props.part as ToolPart - if (part().tool === "todowrite") return null - const hideQuestion = createMemo( () => part().tool === "question" && (part().state.status === "pending" || part().state.status === "running"), ) @@ -2476,57 +2464,6 @@ ToolRegistry.register({ }, }) -ToolRegistry.register({ - name: "todowrite", - render(props) { - const i18n = useI18n() - const todos = createMemo(() => { - const meta = props.metadata?.todos - if (Array.isArray(meta)) return meta - - const input = props.input.todos - if (Array.isArray(input)) return input - - return [] - }) - - const subtitle = createMemo(() => { - const list = todos() - if (list.length === 0) return "" - return `${list.filter((t: Todo) => t.status === "completed").length}/${list.length}` - }) - - return ( - - -
- - {(todo: Todo) => ( - - - {todo.content} - - - )} - -
-
-
- ) - }, -}) - ToolRegistry.register({ name: "question", render(props) { diff --git a/packages/session-ui/src/components/session-turn.tsx b/packages/session-ui/src/components/session-turn.tsx index 21884c42b1..a6d0f11efb 100644 --- a/packages/session-ui/src/components/session-turn.tsx +++ b/packages/session-ui/src/components/session-turn.tsx @@ -104,11 +104,8 @@ function summaryDiff(value: SummaryDiffInput): value is SummaryDiff { ) } -const hidden = new Set(["todowrite"]) - function partState(part: PartType, showReasoningSummaries: boolean) { if (part.type === "tool") { - if (hidden.has(part.tool)) return if (part.tool === "question" && (part.state.status === "pending" || part.state.status === "running")) return return "visible" as const } diff --git a/packages/session-ui/src/components/timeline-playground.stories.tsx b/packages/session-ui/src/components/timeline-playground.stories.tsx index 53699094ac..091e4f655e 100644 --- a/packages/session-ui/src/components/timeline-playground.stories.tsx +++ b/packages/session-ui/src/components/timeline-playground.stories.tsx @@ -399,25 +399,6 @@ const TOOL_SAMPLES = { title: "playwriter", metadata: {}, }, - todowrite: { - tool: "todowrite", - input: { - todos: [ - { content: "Create data generators", status: "completed", priority: "high" }, - { content: "Build UI controls", status: "in_progress", priority: "high" }, - { content: "Add CSS export", status: "pending", priority: "medium" }, - ], - }, - output: "", - title: "Todos", - metadata: { - todos: [ - { content: "Create data generators", status: "completed", priority: "high" }, - { content: "Build UI controls", status: "in_progress", priority: "high" }, - { content: "Add CSS export", status: "pending", priority: "medium" }, - ], - }, - }, } // --------------------------------------------------------------------------- diff --git a/packages/simulation/src/frontend/actions.ts b/packages/simulation/src/frontend/actions.ts index 5819d94035..202b1707f6 100644 --- a/packages/simulation/src/frontend/actions.ts +++ b/packages/simulation/src/frontend/actions.ts @@ -104,6 +104,10 @@ export function state(harness: Harness) { } } +export function matches(harness: Pick, text: string) { + return harness.screen().includes(text) +} + export async function screenshot(harness: Harness, name?: string) { await harness.renderOnce() const image = SimulationPng.screenshot(harness.renderer) diff --git a/packages/simulation/src/frontend/server.ts b/packages/simulation/src/frontend/server.ts index b878bdd761..a70cfc30cb 100644 --- a/packages/simulation/src/frontend/server.ts +++ b/packages/simulation/src/frontend/server.ts @@ -21,6 +21,8 @@ async function handle( case "ui.state": { return SimulationActions.state(harness) } + case "ui.matches": + return SimulationActions.matches(harness, request.params.text) case "ui.recording.finish": if (!finishRecording) throw new Error("UI recording is not available") return finishRecording() diff --git a/packages/simulation/src/protocol/index.ts b/packages/simulation/src/protocol/index.ts index a2b9fa3c29..1414c72cd6 100644 --- a/packages/simulation/src/protocol/index.ts +++ b/packages/simulation/src/protocol/index.ts @@ -97,12 +97,18 @@ export namespace Frontend { export const RecordingFinish = Schema.String export type RecordingFinish = Schema.Schema.Type + export const Matches = Schema.Boolean + export type Matches = Schema.Schema.Type + export const ScreenshotParams = Schema.Struct({ name: Schema.optional(Schema.String) }) export interface ScreenshotParams extends Schema.Schema.Type {} export const TypeParams = Schema.Struct({ text: Schema.String }) export interface TypeParams extends Schema.Schema.Type {} + export const MatchesParams = Schema.Struct({ text: Schema.String }) + export interface MatchesParams extends Schema.Schema.Type {} + export const PressParams = Schema.Struct({ key: Schema.String, modifiers: Schema.optional(KeyModifiers) }) export interface PressParams extends Schema.Schema.Type {} @@ -121,6 +127,7 @@ export namespace Frontend { Schema.Struct({ ...JsonRpc.RequestFields, method: Schema.Literal("ui.arrow"), params: ArrowParams }), Schema.Struct({ ...JsonRpc.RequestFields, method: Schema.Literal("ui.focus"), params: FocusParams }), Schema.Struct({ ...JsonRpc.RequestFields, method: Schema.Literal("ui.click"), params: ClickParams }), + Schema.Struct({ ...JsonRpc.RequestFields, method: Schema.Literal("ui.matches"), params: MatchesParams }), Schema.Struct({ ...JsonRpc.RequestFields, method: Schema.Literal("ui.screenshot"), diff --git a/packages/simulation/test/actions.test.ts b/packages/simulation/test/actions.test.ts new file mode 100644 index 0000000000..de777cd730 --- /dev/null +++ b/packages/simulation/test/actions.test.ts @@ -0,0 +1,11 @@ +import { expect, test } from "bun:test" +import { matches } from "../src/frontend/actions" + +test("matches literal screen text", () => { + const harness = { screen: () => "OpenCode [ready].*" } + + expect(matches(harness, "OpenCode")).toBe(true) + expect(matches(harness, "[ready].*")).toBe(true) + expect(matches(harness, "OpenCode.*ready")).toBe(false) + expect(matches(harness, "opencode")).toBe(false) +}) diff --git a/packages/simulation/test/protocol.test.ts b/packages/simulation/test/protocol.test.ts new file mode 100644 index 0000000000..5d0ea4c970 --- /dev/null +++ b/packages/simulation/test/protocol.test.ts @@ -0,0 +1,21 @@ +import { expect, test } from "bun:test" +import { Frontend } from "../src/protocol" + +test("decodes ui.matches text params", () => { + expect( + Frontend.decodeRequest({ + jsonrpc: "2.0", + id: 1, + method: "ui.matches", + params: { text: "OpenCode [ready].*" }, + }), + ).toMatchObject({ method: "ui.matches", params: { text: "OpenCode [ready].*" } }) + expect(() => + Frontend.decodeRequest({ + jsonrpc: "2.0", + id: 1, + method: "ui.matches", + params: { pattern: "OpenCode.*" }, + }), + ).toThrow() +}) diff --git a/packages/storybook/.storybook/mocks/app/context/file.ts b/packages/storybook/.storybook/mocks/app/context/file.ts index db2158a5cd..550d32f5b8 100644 --- a/packages/storybook/.storybook/mocks/app/context/file.ts +++ b/packages/storybook/.storybook/mocks/app/context/file.ts @@ -20,13 +20,7 @@ export function selectionFromLines(selection?: SelectedLineRange): FileSelection } } -const pool = [ - "src/session/timeline.tsx", - "src/session/composer.tsx", - "src/components/prompt-input.tsx", - "src/components/session-todo-dock.tsx", - "README.md", -] +const pool = ["src/session/timeline.tsx", "src/session/composer.tsx", "src/components/prompt-input.tsx", "README.md"] export function useFile() { return { diff --git a/packages/storybook/.storybook/mocks/app/context/global-sync.ts b/packages/storybook/.storybook/mocks/app/context/global-sync.ts index c538168bed..2d7b990ec7 100644 --- a/packages/storybook/.storybook/mocks/app/context/global-sync.ts +++ b/packages/storybook/.storybook/mocks/app/context/global-sync.ts @@ -18,7 +18,6 @@ const provider = { } const [store, setStore] = createStore({ - todo: {} as Record, provider, session: [] as any[], config: { permission: {} }, @@ -28,16 +27,10 @@ export function useServerSync() { return { data: { provider, - session_todo: store.todo, }, child() { return [store, setStore] as const }, - todo: { - set(sessionID: string, todos: any[]) { - setStore("todo", sessionID, todos) - }, - }, } } diff --git a/packages/storybook/.storybook/mocks/app/context/language.ts b/packages/storybook/.storybook/mocks/app/context/language.ts index df28d79fbd..731e3f931b 100644 --- a/packages/storybook/.storybook/mocks/app/context/language.ts +++ b/packages/storybook/.storybook/mocks/app/context/language.ts @@ -1,7 +1,4 @@ const dict: Record = { - "session.todo.title": "Todos", - "session.todo.collapse": "Collapse todos", - "session.todo.expand": "Expand todos", "prompt.loading": "Loading prompt...", "prompt.placeholder.normal": "Ask anything...", "prompt.placeholder.simple": "Ask anything...", diff --git a/packages/tui/package.json b/packages/tui/package.json index 0924b5cfef..a690ed2202 100644 --- a/packages/tui/package.json +++ b/packages/tui/package.json @@ -69,8 +69,9 @@ "open": "10.1.2", "opentui-spinner": "catalog:", "remeda": "catalog:", + "solid-js": "catalog:", "strip-ansi": "7.1.2", - "solid-js": "catalog:" + "uqr": "0.1.3" }, "devDependencies": { "@tsconfig/bun": "catalog:", diff --git a/packages/tui/src/app.tsx b/packages/tui/src/app.tsx index c5535ad32d..40303f30bb 100644 --- a/packages/tui/src/app.tsx +++ b/packages/tui/src/app.tsx @@ -2,6 +2,8 @@ import { render, TimeToFirstDraw, useRenderer, useTerminalDimensions } from "@op import { registerOpencodeSpinner } from "./component/register-spinner" import { createDefaultOpenTuiKeymap } from "@opentui/keymap/opentui" import { Deferred, Effect } from "effect" +import { Service } from "@opencode-ai/client/effect" +import { OpenCode } from "@opencode-ai/client/promise" import { Global } from "@opencode-ai/core/global" import { Flag } from "@opencode-ai/core/flag/flag" import { InstallationVersion } from "@opencode-ai/core/installation/version" @@ -45,6 +47,8 @@ import { useConnected } from "./component/use-connected" import { DialogMcp } from "./component/dialog-mcp" import { DialogStatus } from "./component/dialog-status" import { DialogDebug } from "./component/dialog-debug" +import { DialogPair, type DialogPairCredentials } from "./component/dialog-pair" +import { createOpencodeClient } from "@opencode-ai/sdk/v2/client" import { DialogThemeList } from "./component/dialog-theme-list" import { DialogHelp } from "./ui/dialog-help" import { DialogAgent } from "./component/dialog-agent" @@ -80,8 +84,6 @@ import { useOpencodeKeymap, } from "./keymap" -import type { OpenCodeClient } from "@opencode-ai/client/promise" -import type { OpencodeClient } from "@opencode-ai/sdk/v2" import { DialogVariant } from "./component/dialog-variant" import { createTuiAttention } from "./attention" import * as TuiAudio from "./audio" @@ -121,6 +123,7 @@ const appBindingCommands = [ "provider.connect", "console.org.switch", "opencode.status", + "server.pair", "opencode.debug", "theme.switch", "theme.switch_mode", @@ -142,10 +145,11 @@ const appBindingCommands = [ ] as const export type TuiInput = { - client: OpencodeClient - api: OpenCodeClient - discover?: () => Promise<{ client: OpencodeClient; api: OpenCodeClient }> - reload?: () => Promise + server: { + endpoint: Service.Endpoint + reconnect?: (attempt: number) => Promise + reload?: () => Promise + } args: Args config: TuiConfig.Resolved onSnapshot?: () => Promise @@ -188,6 +192,25 @@ function isVersionGreater(left: string, right: string) { export const run = Effect.fn("Tui.run")(function* (input: TuiInput) { const log = input.log ?? (() => {}) const global = yield* Global.Service + const options = { baseUrl: input.server.endpoint.url, headers: Service.headers(input.server.endpoint) } + const api = OpenCode.make(options) + const directory = yield* Effect.tryPromise(() => api.file.list({ location: { directory: process.cwd() } })).pipe( + Effect.map((response) => response.location.directory), + Effect.catch(() => + Effect.tryPromise(() => api.location.get()).pipe(Effect.map((response) => response.directory)), + ), + ) + const reconnectEndpoint = input.server.reconnect + const reconnect = reconnectEndpoint + ? async (attempt: number) => { + const endpoint = await reconnectEndpoint(attempt) + const next = { baseUrl: endpoint.url, headers: Service.headers(endpoint) } + return { + client: createOpencodeClient({ ...next, directory }), + api: OpenCode.make(next), + } + } + : undefined const exit = { epilogue: undefined as string | undefined, reason: undefined as unknown } const result = yield* Effect.scoped( Effect.gen(function* () { @@ -314,10 +337,10 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) { @@ -335,6 +358,14 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) { @@ -380,7 +411,11 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) { }) }) -function App(props: { onSnapshot?: () => Promise; pluginHost: TuiPluginHost }) { +function App(props: { + onSnapshot?: () => Promise + pluginHost: TuiPluginHost + pair?: DialogPairCredentials +}) { const log = useLog({ component: "app" }) const startup = useTuiStartup() const tuiConfig = useTuiConfig() @@ -813,6 +848,15 @@ function App(props: { onSnapshot?: () => Promise; pluginHost: TuiPlugi }, category: "System", }, + { + name: "server.pair", + title: "Pair device", + slashName: "pair", + run: () => { + dialog.replace(() => ) + }, + category: "System", + }, ...(sdk.reload ? [ { diff --git a/packages/tui/src/component/dialog-pair.tsx b/packages/tui/src/component/dialog-pair.tsx new file mode 100644 index 0000000000..ffa531890f --- /dev/null +++ b/packages/tui/src/component/dialog-pair.tsx @@ -0,0 +1,126 @@ +import { TextAttributes } from "@opentui/core" +import { useTerminalDimensions } from "@opentui/solid" +import { createMemo, createResource, createSignal, For, Show } from "solid-js" +import { renderUnicodeCompact } from "uqr" +import { useSDK } from "../context/sdk" +import { useTheme } from "../context/theme" +import { useDialog } from "../ui/dialog" +import { errorMessage } from "../util/error" + +export type DialogPairCredentials = { + readonly username: string + readonly password: string +} + +export function DialogPair(props: { credentials?: DialogPairCredentials }) { + const sdk = useSDK() + const dialog = useDialog() + const dimensions = useTerminalDimensions() + const { theme } = useTheme() + const [loadError, setLoadError] = createSignal() + const [showPassword, setShowPassword] = createSignal(false) + const [passwordHover, setPasswordHover] = createSignal(false) + + dialog.setSize("large") + dialog.setCentered(true) + + const [server] = createResource(() => + sdk.client.v2.server + .get({ throwOnError: true }) + .then((result) => result.data) + .catch((error) => { + setLoadError(error) + return undefined + }), + ) + const info = createMemo(() => { + const current = server() + if (!current) return + return { + urls: current.urls, + username: props.credentials?.username ?? "opencode", + password: props.credentials?.password ?? "", + } + }) + const horizontal = createMemo(() => dimensions().width >= 96) + const content = () => { + const value = info() + if (!value) return + return ( + + + + URLs + {(url) => {url}} + + + Username + {value.username} + + + Password + setPasswordHover(true)} + onMouseOut={() => setPasswordHover(false)} + onMouseUp={() => setShowPassword((current) => !current)} + > + {showPassword() ? value.password : "************"} + + + ["localhost", "127.0.0.1", "[::1]"].includes(new URL(url).hostname))} + > + + Run `opencode service set hostname 0.0.0.0` to access the service remotely. + + + + + {renderUnicodeCompact(JSON.stringify(value), { border: 1 })} + + + ) + } + + return ( + + + + Pair + + dialog.clear()}> + esc + + + + {(error) => {errorMessage(error())}} + + Loading server information...}> + = 36} + fallback={ + + {content()} + + } + > + {content()} + + + + ) +} diff --git a/packages/tui/src/component/prompt/index.tsx b/packages/tui/src/component/prompt/index.tsx index 0fcbfa96d5..b256e6ac6b 100644 --- a/packages/tui/src/component/prompt/index.tsx +++ b/packages/tui/src/component/prompt/index.tsx @@ -286,8 +286,8 @@ export function Prompt(props: PromptProps) { last.tokens.input + last.tokens.output + last.tokens.reasoning + last.tokens.cache.read + last.tokens.cache.write if (tokens <= 0) return - const model = data.location - .model.list(session.location) + const model = data.location.model + .list(session.location) ?.find((model) => model.providerID === last.model.providerID && model.id === last.model.id) const pct = model?.limit.context ? `${Math.round((tokens / model.limit.context) * 100)}%` : undefined const cost = session.cost @@ -629,7 +629,7 @@ export function Prompt(props: PromptProps) { createEffect(() => { if (!input || input.isDestroyed) return - if (props.visible === false || dialog.stack.length > 0) { + if (props.visible === false || props.disabled || dialog.stack.length > 0) { if (input.focused) input.blur() return } @@ -1139,11 +1139,9 @@ export function Prompt(props: PromptProps) { const error = await sdk.api.session .prompt({ sessionID, - prompt: { - text: [...editorParts.map((part) => part.text), inputText].filter(Boolean).join("\n\n"), - files: store.prompt.files, - agents: store.prompt.agents, - }, + text: [...editorParts.map((part) => part.text), inputText].filter(Boolean).join("\n\n"), + files: store.prompt.files, + agents: store.prompt.agents, }) .then( () => undefined, @@ -1452,7 +1450,10 @@ export function Prompt(props: PromptProps) { input.cursorColor = theme.text }, 0) }} - onMouseDown={(r: MouseEvent) => r.target?.focus()} + onMouseDown={(r: MouseEvent) => { + if (props.disabled) return + r.target?.focus() + }} focusedBackgroundColor={theme.backgroundElement} cursorColor={props.disabled ? theme.backgroundElement : theme.text} syntaxStyle={syntax()} diff --git a/packages/tui/src/component/todo-item.tsx b/packages/tui/src/component/todo-item.tsx deleted file mode 100644 index b54cc46334..0000000000 --- a/packages/tui/src/component/todo-item.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import { useTheme } from "../context/theme" - -export interface TodoItemProps { - status: string - content: string -} - -export function TodoItem(props: TodoItemProps) { - const { theme } = useTheme() - - return ( - - - [{props.status === "completed" ? "✓" : props.status === "in_progress" ? "•" : " "}]{" "} - - - {props.content} - - - ) -} diff --git a/packages/tui/src/context/data.tsx b/packages/tui/src/context/data.tsx index 8ac669492a..76d24a796d 100644 --- a/packages/tui/src/context/data.tsx +++ b/packages/tui/src/context/data.tsx @@ -35,7 +35,10 @@ export type DataSessionStatus = "idle" | "running" const messageIDFromEvent = (eventID: string) => eventID.replace(/^evt_/, "msg_") -export type FormInfo = FormFormInfo | FormUrlInfo +// Global MCP elicitations temporarily use "global" instead of a real session ID, so the +// server cannot recover their Location when settling them. Preserve the event Location +// until MCP elicitations carry session ownership. +export type FormInfo = (FormFormInfo | FormUrlInfo) & { readonly location?: LocationRef } type LocationData = { agent?: AgentInfo[] @@ -62,7 +65,7 @@ type Data = { message: Record input: Record permission: Record - // Pending forms keyed by session ID. + // Pending forms keyed by owner: a session ID or the temporary "global" elicitation sentinel. form: Record } project: { @@ -112,6 +115,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ }) const messageIndex = new Map>() let bootstrapping: Promise | undefined + let connected = false function setSessionStatus(sessionID: string, status: DataSessionStatus) { setStore("session", "status", sessionID, status) @@ -312,19 +316,17 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ setStore("session", "info", event.data.sessionID, "subpath", event.data.subpath) } break - case "session.prompt.promoted": { + case "session.input.promoted": { message.update(event.data.sessionID, (draft, index) => { const position = index.get(event.data.inputID) if (position === undefined) return const existing = draft[position] - if (existing?.type === "user" && store.session.input[event.data.sessionID]?.includes(event.data.inputID)) { - existing.time.created = event.created - draft.splice(position, 1) - draft.push(existing) - index.clear() - draft.forEach((message, indexValue) => index.set(message.id, indexValue)) - return - } + if (!existing || !store.session.input[event.data.sessionID]?.includes(event.data.inputID)) return + existing.time.created = event.created + draft.splice(position, 1) + draft.push(existing) + index.clear() + draft.forEach((message, indexValue) => index.set(message.id, indexValue)) }) setStore( "session", @@ -334,21 +336,30 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ ) break } - case "session.prompt.admitted": + case "session.input.admitted": if (!store.session.input[event.data.sessionID]?.includes(event.data.inputID)) setStore("session", "input", event.data.sessionID, [ ...(store.session.input[event.data.sessionID] ?? []), event.data.inputID, ]) message.update(event.data.sessionID, (draft, index) => { - message.append(draft, index, { - id: event.data.inputID, - type: "user", - text: event.data.prompt.text, - files: event.data.prompt.files, - agents: event.data.prompt.agents, - time: { created: event.created }, - }) + message.append( + draft, + index, + event.data.input.type === "user" + ? { + id: event.data.inputID, + type: "user", + ...event.data.input.data, + time: { created: event.created }, + } + : { + id: event.data.inputID, + type: "synthetic", + ...event.data.input.data, + time: { created: event.created }, + }, + ) }) break case "session.instructions.updated": @@ -734,7 +745,11 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ if (store.session.form[event.data.form.sessionID]?.some((form) => form.id === event.data.form.id)) break setStore("session", "form", event.data.form.sessionID, [ ...(store.session.form[event.data.form.sessionID] ?? []), - mutable(event.data.form), + mutable( + event.data.form.sessionID === "global" + ? { ...event.data.form, location: event.location } + : event.data.form, + ), ]) break case "form.replied": @@ -831,13 +846,11 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ return position === undefined ? undefined : messages?.[position] }, async refresh(sessionID: string) { - setStore("session", "message", sessionID, []) - messageIndex.set(sessionID, new Map()) const messages = mutable( (await sdk.api.message.list({ sessionID, limit: 200, order: "desc" })).data, ).toReversed() messageIndex.set(sessionID, new Map(messages.map((message, index) => [message.id, index]))) - setStore("session", "message", sessionID, messages) + setStore("session", "message", sessionID, reconcile(messages)) }, }, permission: { @@ -849,10 +862,31 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ }, }, form: { - list(sessionID: string) { - return store.session.form[sessionID] + list(sessionID: string, ref?: LocationRef) { + const forms = store.session.form[sessionID] + if (sessionID !== "global") return forms + if (!ref) return + const key = locationKey(ref) + return forms?.filter((form) => form.location && locationKey(form.location) === key) }, - async refresh(sessionID: string) { + async refresh(sessionID: string, ref?: LocationRef) { + if (sessionID === "global") { + const response = await sdk.api.form.request.list({ location: locationQuery(ref ?? defaultLocation()) }) + const location = { + directory: response.location.directory, + workspaceID: response.location.workspaceID, + } + const key = locationKey(location) + setStore("session", "form", sessionID, [ + ...(store.session.form[sessionID] ?? []).filter( + (form) => form.location && locationKey(form.location) !== key, + ), + ...mutable( + response.data.filter((form) => form.sessionID === "global").map((form) => ({ ...form, location })), + ), + ]) + return + } setStore("session", "form", sessionID, mutable(await sdk.api.form.list({ sessionID }))) }, }, @@ -1009,10 +1043,17 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ setStore("session", "permission", reconcile(permissions)) }), sdk.api.form.request.list({ location: locationQuery(defaultLocation()) }).then((response) => { + const location = { + directory: response.location.directory, + workspaceID: response.location.workspaceID, + } const forms = mutable(response.data).reduce>( (result, form) => ({ ...result, - [form.sessionID]: [...(result[form.sessionID] ?? []), form], + [form.sessionID]: [ + ...(result[form.sessionID] ?? []), + form.sessionID === "global" ? { ...form, location } : form, + ], }), {}, ) @@ -1029,9 +1070,22 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ result.location.skill.refresh(), result.shell.refresh(), ]) - .then((settled) => { + .then(async (settled) => { for (const failure of settled.filter((item) => item.status === "rejected")) console.error("Failed to refresh default location data", failure.reason) + const key = locationKey(defaultLocation()) + const locations = new Map( + Object.values(store.session.info).map( + (session) => [locationKey(session.location), session.location] as const, + ), + ) + const refreshed = await Promise.allSettled( + Array.from(locations) + .filter(([location]) => location !== key) + .map(([, location]) => result.session.form.refresh("global", location)), + ) + for (const failure of refreshed.filter((item) => item.status === "rejected")) + console.error("Failed to refresh global forms", failure.reason) }) .finally(() => { bootstrapping = undefined @@ -1055,8 +1109,10 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ onCleanup( sdk.event.listen(({ details }) => { if (details.type === "server.connected") { + const messages = connected ? Object.keys(store.session.message) : [] + connected = true refreshActive() - void bootstrap() + void Promise.allSettled([bootstrap(), ...messages.map(result.session.message.refresh)]) return } handleEvent(details) diff --git a/packages/tui/src/context/sdk.tsx b/packages/tui/src/context/sdk.tsx index d481ab3f85..1e0afb0b61 100644 --- a/packages/tui/src/context/sdk.tsx +++ b/packages/tui/src/context/sdk.tsx @@ -7,21 +7,32 @@ import { createSimpleContext } from "./helper" import { useLog } from "./log" export type SDKConnectionStatus = "connected" | "connecting" | "reconnecting" +export type SDKConnectionEvent = { + readonly type: "client.connection" + readonly created: number + readonly data: { + readonly status: "connecting" | "connected" | "disconnected" | "reconnecting" + readonly attempt: number + readonly error?: string + } +} type SDKEventMap = { [Type in V2Event["type"]]: Extract } const connectTimeout = 2_000 +const connectionHistoryLimit = 50 export const { use: useSDK, provider: SDKProvider } = createSimpleContext({ name: "SDK", init: (props: { client: OpencodeClient api: OpenCodeClient - discover?: () => Promise<{ client: OpencodeClient; api: OpenCodeClient }> + reconnect?: (attempt: number) => Promise<{ client: OpencodeClient; api: OpenCodeClient }> // Stops and starts the managed service; present only in service mode. reload?: () => Promise }) => { - const log = useLog() + const log = useLog({ component: "sdk" }) const abort = new AbortController() + const history: SDKConnectionEvent[] = [] let client = props.client let api = props.api const events = createGlobalEmitter() @@ -35,6 +46,11 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({ }) let stream: AbortController | undefined + function record(status: SDKConnectionEvent["data"]["status"], attempt: number, error?: string) { + history.push({ type: "client.connection", created: Date.now(), data: { status, attempt, error } }) + if (history.length > connectionHistoryLimit) history.shift() + } + function start() { stream?.abort() const controller = new AbortController() @@ -54,6 +70,8 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({ ) controller.signal.addEventListener("abort", cancel, { once: true }) const error = await (async () => { + record(attempt === 0 ? "connecting" : "reconnecting", attempt) + log.info("event stream connecting", { attempt }) const response = await client.v2.event.subscribe({ signal: connection.signal, sseMaxRetryAttempts: 0, @@ -69,7 +87,9 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({ if (first.value.type !== "server.connected") return new Error("Event stream did not start with server.connected") clearTimeout(timeout) + record("connected", attempt) attempt = 0 + log.info("event stream connected") events.emit(first.value.type, first.value) setConnection({ status: "connected", attempt: 0, error: undefined }) connected() @@ -93,11 +113,17 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({ }) if (abort.signal.aborted || controller.signal.aborted) return attempt += 1 + const message = error instanceof Error ? error.message : String(error) + record("disconnected", attempt, message) + log.info("event stream disconnected", { + attempt, + error: message, + }) // Re-resolve the transport before retrying: the server may have // moved (service restarted on a new port) or need starting. Static // transports (--server, standalone) resolve to the same address. - if (props.discover) { - const next = await props.discover().catch(() => undefined) + if (props.reconnect) { + const next = await props.reconnect(attempt).catch(() => undefined) if (abort.signal.aborted || controller.signal.aborted) return if (next) { client = next.client @@ -107,9 +133,9 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({ setConnection({ status: "reconnecting", attempt, - error: error instanceof Error ? error.message : String(error), + error: message, }) - await wait(250, controller.signal) + await wait(1_000, controller.signal) } })() return ready @@ -143,6 +169,11 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({ error() { return connection.error }, + internal: { + history() { + return history.slice() + }, + }, }, reload: props.reload, } diff --git a/packages/tui/src/context/sync.tsx b/packages/tui/src/context/sync.tsx index 40eac011ae..c35927d6f0 100644 --- a/packages/tui/src/context/sync.tsx +++ b/packages/tui/src/context/sync.tsx @@ -16,7 +16,6 @@ import type { QuestionRequest, Session, FileDiffInfo, - Todo, VcsInfo, } from "@opencode-ai/sdk/v2" import { createStore } from "solid-js/store" @@ -50,7 +49,6 @@ export const { config: Config session: Session[] session_diff: Record - todo: Record message: Record part: Record lsp: LspStatus[] @@ -76,7 +74,6 @@ export const { config: {}, session: [], session_diff: {}, - todo: {}, message: {}, part: {}, lsp: [], diff --git a/packages/tui/src/feature-plugins/builtins.ts b/packages/tui/src/feature-plugins/builtins.ts index 569f3db020..0069d56937 100644 --- a/packages/tui/src/feature-plugins/builtins.ts +++ b/packages/tui/src/feature-plugins/builtins.ts @@ -7,7 +7,6 @@ import SidebarFiles from "./sidebar/files" import SidebarFooter from "./sidebar/footer" import SidebarLsp from "./sidebar/lsp" import SidebarMcp from "./sidebar/mcp" -import SidebarTodo from "./sidebar/todo" import DiffViewer from "./system/diff-viewer" import Notifications from "./system/notifications" import PluginManager from "./system/plugins" @@ -27,7 +26,6 @@ export function createBuiltinPlugins(): BuiltinTuiPlugin[] { SidebarContext, SidebarMcp, SidebarLsp, - SidebarTodo, SidebarFiles, SidebarFooter, Notifications, diff --git a/packages/tui/src/feature-plugins/sidebar/todo.tsx b/packages/tui/src/feature-plugins/sidebar/todo.tsx deleted file mode 100644 index 15a78b0ebb..0000000000 --- a/packages/tui/src/feature-plugins/sidebar/todo.tsx +++ /dev/null @@ -1,49 +0,0 @@ -import type { TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui" -import type { BuiltinTuiPlugin } from "../builtins" -import { createMemo, For, Show, createSignal } from "solid-js" -import { TodoItem } from "../../component/todo-item" - -const id = "internal:sidebar-todo" - -function View(props: { api: TuiPluginApi; session_id: string }) { - const [open, setOpen] = createSignal(true) - const theme = () => props.api.theme.current - const list = createMemo(() => props.api.state.session.todo(props.session_id)) - const show = createMemo(() => list().length > 0 && list().some((item) => item.status !== "completed")) - - return ( - - - list().length > 2 && setOpen((x) => !x)}> - 2}> - {open() ? "▼" : "▶"} - - - Todo - - - - {(item) => } - - - - ) -} - -const tui: TuiPlugin = async (api) => { - api.slots.register({ - order: 400, - slots: { - sidebar_content(_ctx, props) { - return - }, - }, - }) -} - -const plugin: BuiltinTuiPlugin = { - id, - tui, -} - -export default plugin diff --git a/packages/tui/src/feature-plugins/system/notifications.ts b/packages/tui/src/feature-plugins/system/notifications.ts index b8f2727bae..3933b20c54 100644 --- a/packages/tui/src/feature-plugins/system/notifications.ts +++ b/packages/tui/src/feature-plugins/system/notifications.ts @@ -6,11 +6,17 @@ const id = "internal:notifications" type SessionError = Extract["data"]["error"] -function notify(api: TuiPluginApi, sessionID: string | undefined, message: string, sound: TuiAttentionSoundName) { +function notify( + api: TuiPluginApi, + sessionID: string | undefined, + message: string, + sound: TuiAttentionSoundName, + title?: string, +) { const session = sessionID ? api.state.session.get(sessionID) : undefined const isSubagent = session?.parentID !== undefined void api.attention.notify({ - title: session?.title, + title: title ?? session?.title, message, notification: isSubagent ? false : { when: "blurred" }, sound: { name: sound, when: "always" }, @@ -34,10 +40,15 @@ const tui: TuiPlugin = async (api) => { const permissions = new Set() api.event.on("form.created", (event) => { - if (event.data.form.sessionID === "global") return if (forms.has(event.data.form.id)) return forms.add(event.data.form.id) - notify(api, event.data.form.sessionID, "Input needs response", "question") + notify( + api, + event.data.form.sessionID, + "Input needs response", + "question", + event.data.form.title, + ) }) api.event.on("form.replied", (event) => { diff --git a/packages/tui/src/plugin/adapters.tsx b/packages/tui/src/plugin/adapters.tsx index 068b51c732..7da68b8e86 100644 --- a/packages/tui/src/plugin/adapters.tsx +++ b/packages/tui/src/plugin/adapters.tsx @@ -130,9 +130,6 @@ function stateApi(sync: ReturnType, data: ReturnType & { +export type PromptInfo = Types.DeepMutable> & { pasted: PastedText[] mode?: "normal" | "shell" } diff --git a/packages/tui/src/routes/home.tsx b/packages/tui/src/routes/home.tsx index 1d5ba75c97..459dbf944e 100644 --- a/packages/tui/src/routes/home.tsx +++ b/packages/tui/src/routes/home.tsx @@ -1,5 +1,5 @@ import { Prompt, type PromptRef } from "../component/prompt" -import { createEffect, createMemo, createSignal, onMount } from "solid-js" +import { createEffect, createMemo, createSignal, onMount, Show } from "solid-js" import { Logo } from "../component/logo" import { useSync } from "../context/sync" import { Toast } from "../ui/toast" @@ -14,6 +14,7 @@ import { useTuiConfig } from "../config" import { HomeSessionDestinationProvider } from "./home/session-destination" import { useData } from "../context/data" import { LocationProvider } from "../context/location" +import { FormPrompt } from "./session/form" let once = false const placeholder = { @@ -33,6 +34,8 @@ export function Home() { const dimensions = useTerminalDimensions() const tuiConfig = useTuiConfig() const data = useData() + // Global MCP elicitations can arrive without a session route, so keep them reachable from Home. + const forms = createMemo(() => data.session.form.list("global", data.location.default()) ?? []) const promptMaxWidth = createMemo(() => { const configured = tuiConfig.prompt?.max_width if (configured === "auto") return Math.max(75, Math.floor(dimensions().width * 0.7)) @@ -84,7 +87,12 @@ export function Home() { - } placeholders={placeholder} /> + } + placeholders={placeholder} + disabled={forms().length > 0} + /> @@ -94,6 +102,26 @@ export function Home() { + + {(_) => { + const form = forms()[0] + return form ? ( + + + + + + ) : null + }} + ) diff --git a/packages/tui/src/routes/session/form.tsx b/packages/tui/src/routes/session/form.tsx index e6f7a4818c..5f143370ce 100644 --- a/packages/tui/src/routes/session/form.tsx +++ b/packages/tui/src/routes/session/form.tsx @@ -130,6 +130,16 @@ function display(field: Field, value: FormValue | undefined) { return label(value) } +function requestOptions(form: FormInfo) { + if (form.sessionID !== "global" || !form.location) return undefined + return { + headers: { + "x-opencode-directory": encodeURIComponent(form.location.directory), + ...(form.location.workspaceID ? { "x-opencode-workspace": form.location.workspaceID } : {}), + }, + } +} + export function FormPrompt(props: { form: FormInfo }) { return props.form.mode === "url" ? : } @@ -154,7 +164,10 @@ function UrlPrompt(props: { form: FormInfo & { mode: "url" } }) { title: "Dismiss form", category: "Form", run() { - void sdk.api.form.cancel({ sessionID: props.form.sessionID, formID: props.form.id }) + void sdk.api.form.cancel( + { sessionID: props.form.sessionID, formID: props.form.id }, + requestOptions(props.form), + ) }, }, ], @@ -172,7 +185,10 @@ function UrlPrompt(props: { form: FormInfo & { mode: "url" } }) { desc: "Dismiss form", group: "Form", cmd: () => { - void sdk.api.form.cancel({ sessionID: props.form.sessionID, formID: props.form.id }) + void sdk.api.form.cancel( + { sessionID: props.form.sessionID, formID: props.form.id }, + requestOptions(props.form), + ) }, }, ], @@ -186,7 +202,7 @@ function UrlPrompt(props: { form: FormInfo & { mode: "url" } }) { customBorderChars={SplitBorder.customBorderChars} > - {props.form.title ?? "Input requested"} + {props.form.title} {message()} @@ -328,11 +344,14 @@ function FieldsPrompt(props: { form: FormInfo & { mode: "form" } }) { function replySingle(field: Field, value: FormValue) { sdk.api.form - .reply({ - sessionID: props.form.sessionID, - formID: props.form.id, - answer: { [field.key]: value }, - }) + .reply( + { + sessionID: props.form.sessionID, + formID: props.form.id, + answer: { [field.key]: value }, + }, + requestOptions(props.form), + ) .catch((error: unknown) => { setStore( "error", @@ -532,7 +551,10 @@ function FieldsPrompt(props: { form: FormInfo & { mode: "form" } }) { group: "Form", cmd: () => { if (textual()) { - void sdk.api.form.cancel({ sessionID: props.form.sessionID, formID: props.form.id }) + void sdk.api.form.cancel( + { sessionID: props.form.sessionID, formID: props.form.id }, + requestOptions(props.form), + ) return } setStore("editing", false) @@ -594,7 +616,10 @@ function FieldsPrompt(props: { form: FormInfo & { mode: "form" } }) { title: "Dismiss form", category: "Form", run() { - void sdk.api.form.cancel({ sessionID: props.form.sessionID, formID: props.form.id }) + void sdk.api.form.cancel( + { sessionID: props.form.sessionID, formID: props.form.id }, + requestOptions(props.form), + ) }, }, ], @@ -638,16 +663,19 @@ function FieldsPrompt(props: { form: FormInfo & { mode: "form" } }) { return } sdk.api.form - .reply({ - sessionID: props.form.sessionID, - formID: props.form.id, - answer: Object.fromEntries( - fields().flatMap((field) => { - const value = store.answers[field.key] - return value === undefined ? [] : [[field.key, value] as const] - }), - ), - }) + .reply( + { + sessionID: props.form.sessionID, + formID: props.form.id, + answer: Object.fromEntries( + fields().flatMap((field) => { + const value = store.answers[field.key] + return value === undefined ? [] : [[field.key, value] as const] + }), + ), + }, + requestOptions(props.form), + ) .catch((error: unknown) => { setStore( "error", @@ -666,7 +694,10 @@ function FieldsPrompt(props: { form: FormInfo & { mode: "form" } }) { desc: "Dismiss form", group: "Form", cmd: () => { - void sdk.api.form.cancel({ sessionID: props.form.sessionID, formID: props.form.id }) + void sdk.api.form.cancel( + { sessionID: props.form.sessionID, formID: props.form.id }, + requestOptions(props.form), + ) }, }, { key: "up", desc: "Scroll review", group: "Form", cmd: () => review?.scrollBy(-1) }, @@ -715,7 +746,10 @@ function FieldsPrompt(props: { form: FormInfo & { mode: "form" } }) { desc: "Dismiss form", group: "Form", cmd: () => { - void sdk.api.form.cancel({ sessionID: props.form.sessionID, formID: props.form.id }) + void sdk.api.form.cancel( + { sessionID: props.form.sessionID, formID: props.form.id }, + requestOptions(props.form), + ) }, }, ...tuiConfig.keybinds.get("app.exit"), @@ -732,11 +766,9 @@ function FieldsPrompt(props: { form: FormInfo & { mode: "form" } }) { customBorderChars={SplitBorder.customBorderChars} > - - - {props.form.title} - - + + {props.form.title} + diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index 1dc2baa2ba..917e605605 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -14,6 +14,7 @@ import { useContext, } from "solid-js" import path from "node:path" +import { EOL, tmpdir } from "node:os" import { mkdir, writeFile } from "node:fs/promises" import { useRoute, useRouteData } from "../../context/route" import { createStore } from "solid-js/store" @@ -44,7 +45,6 @@ import { useEditorContext } from "../../context/editor" import { openEditor } from "../../editor" import { useDialog } from "../../ui/dialog" import { DialogSessionRename } from "../../component/dialog-session-rename" -import { TodoItem } from "../../component/todo-item" import { DialogMessage } from "./dialog-message" import { DialogFork } from "./dialog-fork" import { Sidebar } from "./sidebar" @@ -61,6 +61,7 @@ import { normalizePath } from "../../util/path" import { PermissionPrompt } from "./permission" import { FormPrompt } from "./form" import { DialogExportOptions } from "../../ui/dialog-export-options" +import { DialogExportResult } from "../../ui/dialog-export-result" import { sessionEpilogue } from "../../util/presentation" import { useTuiConfig } from "../../config" import { useClipboard } from "../../context/clipboard" @@ -183,8 +184,11 @@ export function Session() { ) }) const forms = createMemo(() => { - if (session()?.parentID) return [] - return data.session.form.list(route.sessionID) ?? [] + const global = data.session.form.list("global", location()) ?? [] + if (session()?.parentID) return global + return [route.sessionID, ...descendantSessionIDs()] + .flatMap((sessionID) => data.session.form.list(sessionID) ?? []) + .concat(global) }) const [composer, setComposer] = createStore({ open: false, @@ -237,7 +241,12 @@ export function Session() { createEffect( on(descendantSessionIDs, (sessionIDs) => { - void Promise.all(sessionIDs.map((sessionID) => data.session.permission.refresh(sessionID))) + void Promise.all( + sessionIDs.flatMap((sessionID) => [ + data.session.permission.refresh(sessionID), + data.session.form.refresh(sessionID), + ]), + ) }), ) @@ -259,6 +268,15 @@ export function Session() { navigate({ type: "home" }) return } + void data.session.form + .refresh("global", info.location) + .catch((error) => + toast.show({ + message: `Failed to refresh global forms: ${errorMessage(error)}`, + variant: "error", + duration: 5000, + }), + ) project.workspace.set(info.location.workspaceID) editor.reconnect(info.location.directory) if (route.sessionID === sessionID && scroll) scroll.scrollBy(100_000) @@ -411,29 +429,34 @@ export function Session() { category: "Session", slash: { name: "undo" }, run: () => { - void (async () => { - const boundary = session()?.revert?.messageID - const list = messages() - let target: string | undefined - for (let i = list.length - 1; i >= 0; i--) { - const message = list[i] - if (message.type !== "user" || !message.text.trim()) continue - if (boundary && message.id >= boundary) continue - target = message.id - break - } - if (!target) { - toast.show({ message: "Nothing to undo", variant: "error", duration: 3000 }) - dialog.clear() - return - } - const error = await sdk.api.session.revert.stage({ sessionID: route.sessionID, messageID: target }).then( - () => undefined, - (error) => error, - ) - if (error) toast.show({ message: errorMessage(error), variant: "error", duration: 5000 }) + const boundary = session()?.revert?.messageID + const message = messages().findLast( + (message): message is SessionMessageUser => + message.type === "user" && !!message.text.trim() && (!boundary || message.id < boundary), + ) + if (!message) { + toast.show({ message: "Nothing to undo", variant: "error", duration: 3000 }) dialog.clear() - })() + return + } + void sdk.api.session.revert + .stage({ sessionID: route.sessionID, messageID: message.id }) + .catch((error) => toast.show({ message: errorMessage(error), variant: "error", duration: 5000 })) + prompt?.set({ + text: message.text, + files: message.files?.map((file) => ({ + uri: file.source.type === "uri" ? file.source.uri : `data:${file.mime};base64,${file.data}`, + name: file.name, + description: file.description, + mention: file.mention ? { ...file.mention } : undefined, + })), + agents: message.agents?.map((agent) => ({ + name: agent.name, + mention: agent.mention ? { ...agent.mention } : undefined, + })), + pasted: [], + }) + dialog.clear() }, }, { @@ -711,7 +734,13 @@ export function Session() { try { const sessionData = session() if (!sessionData) return - const transcript = formatSessionTranscript(sessionData, messages(), showThinking(), showDetails()) + const transcript = formatSessionTranscript( + sessionData, + messages(), + showThinking(), + showDetails(), + showAssistantMetadata(), + ) await clipboard.write?.(transcript) toast.show({ message: "Session transcript copied to clipboard!", variant: "success" }) } catch { @@ -731,53 +760,70 @@ export function Session() { try { const sessionData = session() if (!sessionData) return - const defaultFilename = `session-${sessionData.id.slice(0, 8)}.md` const options = await DialogExportOptions.show( dialog, - defaultFilename, showThinking(), showDetails(), showAssistantMetadata(), - false, ) if (options === null) return - const transcript = formatSessionTranscript(sessionData, messages(), options.thinking, options.toolDetails) + const content = + options.format === "markdown" + ? formatSessionTranscript( + sessionData, + messages(), + options.thinking, + options.toolDetails, + options.assistantMetadata, + ) + : await (async () => { + if (options.debug) { + const events: { readonly created: number }[] = [] + for await (const event of sdk.api.session.log({ sessionID: sessionData.id, follow: false })) { + if (event.type !== "log.synced") events.push(event) + } + // Durable events stay in aggregate order even when their wall-clock timestamps differ. + sdk.connection.internal.history().forEach((event) => { + const index = events.findIndex((item) => item.created > event.created) + if (index === -1) { + events.push(event) + return + } + events.splice(index, 0, event) + }) + return JSON.stringify({ info: sessionData, events }, null, 2) + EOL + } - if (options.openWithoutSaving) { - // Just open in editor without saving - await openEditor({ - renderer, - value: transcript, - cwd: - (project.instance.path().worktree === "/" ? undefined : project.instance.path().worktree) || - project.instance.directory() || - paths.cwd, - }) - } else { - const exportDir = paths.cwd - const filename = options.filename.trim() - const filepath = path.join(exportDir, filename) + const messages: unknown[] = [] + let cursor: string | undefined + do { + const page = await sdk.api.message.list( + cursor + ? { sessionID: sessionData.id, limit: 200, cursor } + : { sessionID: sessionData.id, limit: 200, order: "asc" }, + ) + messages.push(...page.data) + cursor = page.data.length ? (page.cursor.next ?? undefined) : undefined + } while (cursor) + return JSON.stringify({ info: sessionData, messages }, null, 2) + EOL + })() - await writeExport(filepath, transcript) - - // Open with EDITOR if available - const result = await openEditor({ - renderer, - value: transcript, - cwd: - (project.instance.path().worktree === "/" ? undefined : project.instance.path().worktree) || - project.instance.directory() || - paths.cwd, - }) - if (result !== undefined) { - await writeExport(filepath, result) - } - - toast.show({ message: `Session exported to ${filename}`, variant: "success" }) + if (options.action === "copy") { + await clipboard.write?.(content) + dialog.clear() + toast.show({ message: "Copied to clipboard", variant: "success" }) + return } + + const filepath = path.join( + tmpdir(), + `session-${crypto.randomUUID()}.${options.format === "markdown" ? "md" : "json"}`, + ) + await writeExport(filepath, content) + await DialogExportResult.show(dialog, filepath) } catch { toast.show({ message: "Failed to export session", variant: "error" }) } @@ -941,12 +987,12 @@ export function Session() { setComposer("open", false)} /> - {null} + {null} 0}> @@ -1896,9 +1942,6 @@ function ToolPart(props: { part: SessionMessageAssistantTool }) { - - - @@ -2598,32 +2641,6 @@ function ApplyPatch(props: ToolProps) { ) } -function TodoWrite(props: ToolProps) { - const todos = createMemo(() => parseTodos(props.input.todos)) - return ( - - - - - {(todo) => } - - - - - - Updating todos... - - - - ) -} - function Question(props: ToolProps) { const { theme } = useTheme() const questions = createMemo(() => parseQuestions(props.input.questions)) @@ -2724,7 +2741,6 @@ const toolDisplays = new Set([ "subagent", "execute", "patch", - "todowrite", "question", "skill", ]) @@ -2746,6 +2762,7 @@ function formatSessionTranscript( messages: SessionMessageInfo[], thinking: boolean, toolDetails: boolean, + assistantMetadata: boolean, ) { const body = messages.flatMap((message) => { if (message.type === "user") return [`## User\n\n${message.text}`] @@ -2767,7 +2784,13 @@ function formatSessionTranscript( .join("\n") return [`**Tool: ${item.name}**\n\n**Input:**\n\`\`\`json\n${input}\n\`\`\`\n\n${output}`] }) - return [`## Assistant\n\n${content.join("\n\n")}`] + const duration = message.time.completed + ? ` · ${((message.time.completed - message.time.created) / 1000).toFixed(1)}s` + : "" + const heading = assistantMetadata + ? `## Assistant (${message.agent} · ${message.model.providerID}/${message.model.id}${duration})` + : "## Assistant" + return [`${heading}\n\n${content.join("\n\n")}`] }) return `# ${session.title}\n\n**Session ID:** ${session.id}\n**Created:** ${new Date(session.time.created).toLocaleString()}\n**Updated:** ${new Date(session.time.updated).toLocaleString()}\n\n---\n\n${body.join("\n\n---\n\n")}\n` } @@ -2799,16 +2822,6 @@ export function parseApplyPatchFiles(value: unknown) { }) } -export function parseTodos(value: unknown) { - if (!Array.isArray(value)) return [] - return value.flatMap((item) => { - const todo = recordValue(item) - const status = stringValue(todo?.status) - const content = stringValue(todo?.content) - return status && content ? [{ status, content }] : [] - }) -} - export function parseQuestions(value: unknown) { if (!Array.isArray(value)) return [] return value.flatMap((item) => { diff --git a/packages/tui/src/routes/session/rows.ts b/packages/tui/src/routes/session/rows.ts index c8764f3030..a148f9fd20 100644 --- a/packages/tui/src/routes/session/rows.ts +++ b/packages/tui/src/routes/session/rows.ts @@ -75,7 +75,7 @@ export function createSessionRows(sessionID: Accessor) { on( () => data.session.message.list(sessionID()).flatMap((message) => - message.type === "user" + message.type === "user" || message.type === "synthetic" ? [ { id: message.id, @@ -156,7 +156,7 @@ export function createSessionRows(sessionID: Accessor) { const isPending = (messageID: string) => { const message = data.session.message.get(sessionID(), messageID) - if (message?.type === "user") return data.session.input.has(sessionID(), messageID) + if (message?.type === "user" || message?.type === "synthetic") return data.session.input.has(sessionID(), messageID) return message?.type === "compaction" && message.status === "running" } @@ -168,11 +168,21 @@ export function createSessionRows(sessionID: Accessor) { const message = (event: { id: string; data: { sessionID: string } }) => { if (event.data.sessionID === sessionID()) appendMessage(event.id.replace(/^evt_/, "msg_")) } - const input = (event: { data: { sessionID: string; inputID: string } }) => { - if (event.data.sessionID === sessionID()) appendMessage(event.data.inputID) + const input = (event: { + data: { + sessionID: string + inputID: string + input: { type: "user" } | { type: "synthetic"; data: { description?: string } } + } + }) => { + if ( + event.data.sessionID === sessionID() && + (event.data.input.type === "user" || event.data.input.data.description?.trim()) + ) + appendMessage(event.data.inputID) } const subscriptions = [ - data.on("session.prompt.admitted", input), + data.on("session.input.admitted", input), data.on("session.compaction.started", message), data.on("session.instructions.updated", message), data.on("session.synthetic", (event) => { diff --git a/packages/tui/src/ui/dialog-export-options.tsx b/packages/tui/src/ui/dialog-export-options.tsx index cab853ff2a..914d0960c8 100644 --- a/packages/tui/src/ui/dialog-export-options.tsx +++ b/packages/tui/src/ui/dialog-export-options.tsx @@ -1,38 +1,63 @@ -import { TextareaRenderable, TextAttributes } from "@opentui/core" +import { TextAttributes } from "@opentui/core" import { useTheme } from "../context/theme" import { useDialog, type DialogContext } from "./dialog" import { createStore } from "solid-js/store" -import { onMount, Show } from "solid-js" +import { For, Show } from "solid-js" import { useBindings } from "../keymap" +export type ExportFormat = "markdown" | "json" + export type DialogExportOptionsProps = { - defaultFilename: string defaultThinking: boolean defaultToolDetails: boolean defaultAssistantMetadata: boolean - defaultOpenWithoutSaving: boolean onConfirm?: (options: { - filename: string + action: "copy" | "export" + format: ExportFormat + debug: boolean thinking: boolean toolDetails: boolean assistantMetadata: boolean - openWithoutSaving: boolean }) => void onCancel?: () => void } +type Active = ExportFormat | "debug" | "thinking" | "toolDetails" | "assistantMetadata" | "copy" | "export" + export function DialogExportOptions(props: DialogExportOptionsProps) { const dialog = useDialog() const { theme } = useTheme() - let textarea: TextareaRenderable const [store, setStore] = createStore({ + format: "markdown" as ExportFormat, + debug: false, thinking: props.defaultThinking, toolDetails: props.defaultToolDetails, assistantMetadata: props.defaultAssistantMetadata, - openWithoutSaving: props.defaultOpenWithoutSaving, - active: "filename" as "filename" | "thinking" | "toolDetails" | "assistantMetadata" | "openWithoutSaving", + active: "markdown" as Active, }) + const confirm = (action: "copy" | "export") => + props.onConfirm?.({ + action, + format: store.format, + debug: store.debug, + thinking: store.thinking, + toolDetails: store.toolDetails, + assistantMetadata: store.assistantMetadata, + }) + + const activate = () => { + if (store.active === "markdown" || store.active === "json") { + setStore("format", store.active) + return + } + if (store.active === "debug") setStore("debug", !store.debug) + if (store.active === "thinking") setStore("thinking", !store.thinking) + if (store.active === "toolDetails") setStore("toolDetails", !store.toolDetails) + if (store.active === "assistantMetadata") setStore("assistantMetadata", !store.assistantMetadata) + if (store.active === "copy" || store.active === "export") confirm(store.active) + } + useBindings(() => ({ bindings: [ { @@ -40,173 +65,144 @@ export function DialogExportOptions(props: DialogExportOptionsProps) { desc: "Next export option", group: "Dialog", cmd: () => { - const order: Array<"filename" | "thinking" | "toolDetails" | "assistantMetadata" | "openWithoutSaving"> = [ - "filename", - "thinking", - "toolDetails", - "assistantMetadata", - "openWithoutSaving", - ] - const currentIndex = order.indexOf(store.active) - const nextIndex = (currentIndex + 1) % order.length - setStore("active", order[nextIndex]) + const order: Active[] = + store.format === "markdown" + ? ["markdown", "json", "thinking", "toolDetails", "assistantMetadata", "copy", "export"] + : ["markdown", "json", "debug", "copy", "export"] + setStore("active", order[(order.indexOf(store.active) + 1) % order.length]) }, }, - ], - })) - - useBindings(() => ({ - enabled: store.active !== "filename", - bindings: [ { - key: "space", - desc: "Toggle export option", + key: "return", + desc: "Select export option", group: "Dialog", - cmd: () => { - if (store.active === "thinking") setStore("thinking", !store.thinking) - if (store.active === "toolDetails") setStore("toolDetails", !store.toolDetails) - if (store.active === "assistantMetadata") setStore("assistantMetadata", !store.assistantMetadata) - if (store.active === "openWithoutSaving") setStore("openWithoutSaving", !store.openWithoutSaving) - }, + cmd: activate, }, ], })) - onMount(() => { - dialog.setSize("medium") - setTimeout(() => { - if (!textarea || textarea.isDestroyed) return - textarea.focus() - }, 1) - textarea.gotoLineEnd() - }) + const selectFormat = (format: ExportFormat) => { + setStore("format", format) + setStore("active", format) + } + + const toggle = (option: "thinking" | "toolDetails" | "assistantMetadata") => { + setStore("active", option) + setStore(option, !store[option]) + } return ( - Export Options + Export session dialog.clear()}> esc - - - Filename: - -