diff --git a/bun.lock b/bun.lock index d85e40b08..831b0be5b 100644 --- a/bun.lock +++ b/bun.lock @@ -192,6 +192,26 @@ "cloudflare": "5.2.0", }, }, + "packages/console/support": { + "name": "@opencode-ai/console-support", + "version": "0.0.0", + "dependencies": { + "@cloudflare/vite-plugin": "1.15.2", + "@opencode-ai/console-core": "workspace:*", + "@solidjs/meta": "catalog:", + "@solidjs/router": "catalog:", + "@solidjs/start": "catalog:", + "nitro": "3.0.1-alpha.1", + "solid-js": "catalog:", + "vite": "catalog:", + }, + "devDependencies": { + "@types/bun": "catalog:", + "@typescript/native-preview": "catalog:", + "typescript": "catalog:", + "wrangler": "4.50.0", + }, + }, "packages/core": { "name": "@opencode-ai/core", "version": "1.15.10", @@ -207,7 +227,7 @@ "@ai-sdk/cohere": "3.0.27", "@ai-sdk/deepinfra": "2.0.41", "@ai-sdk/gateway": "3.0.104", - "@ai-sdk/google": "3.0.75", + "@ai-sdk/google": "3.0.63", "@ai-sdk/google-vertex": "4.0.131", "@ai-sdk/groq": "3.0.31", "@ai-sdk/mistral": "3.0.27", @@ -434,7 +454,7 @@ "@ai-sdk/cohere": "3.0.27", "@ai-sdk/deepinfra": "2.0.41", "@ai-sdk/gateway": "3.0.104", - "@ai-sdk/google": "3.0.75", + "@ai-sdk/google": "3.0.63", "@ai-sdk/google-vertex": "4.0.131", "@ai-sdk/groq": "3.0.31", "@ai-sdk/mistral": "3.0.27", @@ -911,7 +931,7 @@ "@ai-sdk/gateway": ["@ai-sdk/gateway@3.0.104", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23", "@vercel/oidc": "3.2.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ZKX5n74io8VIRlhIMSLWVlvT3sXC8Z7cZ9GHuWBWZDVi96+62AIsWuLGvMfcBA1STYuSoDrp6rIziZmvrTq0TA=="], - "@ai-sdk/google": ["@ai-sdk/google@3.0.75", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-XAm31ftiOrzlb8NjDzT7kw0xw+4lmgFdGFn1QKM73nXFFKyN1kWLESBV75UGNfjXP8X1YJ0YydnMVqO0jaPghw=="], + "@ai-sdk/google": ["@ai-sdk/google@3.0.63", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-RfOZWVMYSPu2sPRfGajrauWAZ9BSaRopSn+AszkKWQ1MFj8nhaXvCqRHB5pBQUaHTfZKagvOmMpNfa/s3gPLgQ=="], "@ai-sdk/google-vertex": ["@ai-sdk/google-vertex@4.0.131", "", { "dependencies": { "@ai-sdk/anthropic": "3.0.78", "@ai-sdk/google": "3.0.75", "@ai-sdk/openai-compatible": "2.0.47", "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27", "google-auth-library": "^10.5.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Oj1X8p0rVgvEoR5OOSxWi6XgzJ3QDlE/n30MZVtpKkCiToYYDyvlvVDGXz3IqhMyUev2JhlcuUk1brScKT01kA=="], @@ -1647,6 +1667,8 @@ "@opencode-ai/console-resource": ["@opencode-ai/console-resource@workspace:packages/console/resource"], + "@opencode-ai/console-support": ["@opencode-ai/console-support@workspace:packages/console/support"], + "@opencode-ai/core": ["@opencode-ai/core@workspace:packages/core"], "@opencode-ai/desktop": ["@opencode-ai/desktop@workspace:packages/desktop"], @@ -5225,12 +5247,10 @@ "@ai-sdk/fireworks/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.41", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kNAGINk71AlOXx10Dq/PXw4t/9XjdK8uxfpVElRwtSFMdeSiLVt58p9TPx4/FJD+hxZuVhvxYj9r42osxWq79g=="], - "@ai-sdk/google/@ai-sdk/provider": ["@ai-sdk/provider@3.0.10", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-Q3BZ27qfpYqnCYGvE3vt+Qi6LGOF9R5Nmzn+9JoM1lCRsD9mYaIhfJLkSunN48nfGXJ6n+XNV0J/XVpqGQl7Dw=="], - - "@ai-sdk/google/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.27", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ubkAJ+xODouwtmN1tYlvTPphH1hPOBfZaEQe8U7skGvFAnIRs9PPpsq57bC2+Ky/MB4yzhd6YOsxTAx9sGpazw=="], - "@ai-sdk/google-vertex/@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.78", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-0OY12G20cUt6iU6htpEA1491Oz++NVxZxlmWGX4B7rSbeZ5pnDmOu6YtW9BKzdZlNx5Gn23i6WMxyZFoMKNcgA=="], + "@ai-sdk/google-vertex/@ai-sdk/google": ["@ai-sdk/google@3.0.75", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-XAm31ftiOrzlb8NjDzT7kw0xw+4lmgFdGFn1QKM73nXFFKyN1kWLESBV75UGNfjXP8X1YJ0YydnMVqO0jaPghw=="], + "@ai-sdk/google-vertex/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.47", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Enm5UlL0zUCrW3792opk5h7hRWxZOZzDe6eQYVFqX9LUOGGCe1h8MZWAGim765nwzgnjlpeYOsuzZmLtRsTPlg=="], "@ai-sdk/google-vertex/@ai-sdk/provider": ["@ai-sdk/provider@3.0.10", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-Q3BZ27qfpYqnCYGvE3vt+Qi6LGOF9R5Nmzn+9JoM1lCRsD9mYaIhfJLkSunN48nfGXJ6n+XNV0J/XVpqGQl7Dw=="], @@ -6169,10 +6189,6 @@ "@ai-sdk/google-vertex/@ai-sdk/provider-utils/eventsource-parser": ["eventsource-parser@3.0.8", "", {}, "sha512-70QWGkr4snxr0OXLRWsFLeRBIRPuQOvt4s8QYjmUlmlkyTZkRqS7EDVRZtzU3TiyDbXSzaOeF0XUKy8PchzukQ=="], - "@ai-sdk/google/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], - - "@ai-sdk/google/@ai-sdk/provider-utils/eventsource-parser": ["eventsource-parser@3.0.8", "", {}, "sha512-70QWGkr4snxr0OXLRWsFLeRBIRPuQOvt4s8QYjmUlmlkyTZkRqS7EDVRZtzU3TiyDbXSzaOeF0XUKy8PchzukQ=="], - "@ai-sdk/groq/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], "@ai-sdk/mistral/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], diff --git a/infra/stage.ts b/infra/stage.ts index f98867238..beb3f5925 100644 --- a/infra/stage.ts +++ b/infra/stage.ts @@ -7,50 +7,8 @@ export const domain = (() => { export const zoneID = "430ba34c138cfb5360826c4909f99be8" // Dev owns the shared AWS lake/stats infra for all non-production stages. export const awsStage = $app.stage === "production" ? "production" : "dev" -export const deployAws = $app.stage === awsStage - -const githubActionsDeployRole = (() => { - if ($app.stage !== "dev" && $app.stage !== "production") return - - const provider = new aws.iam.OpenIdConnectProvider("GithubActionsOidcProvider", { - url: "https://token.actions.githubusercontent.com", - clientIdLists: ["sts.amazonaws.com"], - }) - const role = new aws.iam.Role("GithubActionsDeployRole", { - name: `opencode-${$app.stage}-github-actions-deploy`, - maxSessionDuration: 3600, - assumeRolePolicy: aws.iam.getPolicyDocumentOutput({ - statements: [ - { - effect: "Allow", - actions: ["sts:AssumeRoleWithWebIdentity"], - principals: [{ type: "Federated", identifiers: [provider.arn] }], - conditions: [ - { - test: "StringEquals", - variable: "token.actions.githubusercontent.com:aud", - values: ["sts.amazonaws.com"], - }, - { - test: "StringEquals", - variable: "token.actions.githubusercontent.com:sub", - values: [`repo:anomalyco/opencode:environment:${$app.stage}`], - }, - ], - }, - ], - }).json, - }) - - new aws.iam.RolePolicyAttachment("GithubActionsDeployRoleAdmin", { - role: role.name, - policyArn: "arn:aws:iam::aws:policy/AdministratorAccess", - }) - - return role -})() - -export const githubActionsDeployRoleArn = githubActionsDeployRole?.arn +// Temporarily omit AWS infra so SST removes the lake/stats resources. +export const deployAws = false new cloudflare.RegionalHostname("RegionalHostname", { hostname: domain, diff --git a/nix/hashes.json b/nix/hashes.json index a4353299a..ce0261833 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -1,8 +1,8 @@ { "nodeModules": { - "x86_64-linux": "sha256-6s5msV+dYMHHt9Gc/CqvCrUj8K7ELjxoAe6ejHSJo4I=", - "aarch64-linux": "sha256-SP94UPy5LePd7ZdC3eENIXiozc67blpg1SN9Ug5yiv8=", - "aarch64-darwin": "sha256-jv3lffiAQ5kxDAbXNavsHD+tjjdgT0dT0JxN0bWMYTE=", - "x86_64-darwin": "sha256-aYNtcarg516ZmqaO62mnUZYiSyWt8rJjUHQslhrhGHM=" + "x86_64-linux": "sha256-p8EPw6YDdAaToGibLAljdtLuuSIqQ3mVJecYOwUWqY0=", + "aarch64-linux": "sha256-G/xOunyVzqJucokje713GkysXgixJmce6jlevRht7Sc=", + "aarch64-darwin": "sha256-zMeGIyWXUb0ofrUGxTluAytZMkTtlyEydIhbu62O2VM=", + "x86_64-darwin": "sha256-ef7+QlnKmtB2fGOw3EFjlyZcgj+Q8XLbNVdPQiNNvh8=" } } diff --git a/packages/app/e2e/regression/session-timeline-collapse-state.spec.ts b/packages/app/e2e/regression/session-timeline-collapse-state.spec.ts index db191d257..88b140a61 100644 --- a/packages/app/e2e/regression/session-timeline-collapse-state.spec.ts +++ b/packages/app/e2e/regression/session-timeline-collapse-state.spec.ts @@ -1,4 +1,5 @@ -import { expect, test, type Locator, type Page, type Route } from "@playwright/test" +import { expect, test, type Locator, type Page } from "@playwright/test" +import { mockOpenCodeServer } from "../utils/mock-server" const directory = "C:/OpenCode/TimelineStateRegression" const projectID = "proj_timeline_state_regression" @@ -299,39 +300,13 @@ function readExpanded(element: Element) { } async function mockServer(page: Page, events: EventPayload[]) { - await page.route("**/*", async (route) => { - const url = new URL(route.request().url()) - const targetPort = process.env.PLAYWRIGHT_SERVER_PORT ?? "4096" - if (url.port !== targetPort) return route.fallback() - - const path = url.pathname - if (path === "/global/event") return sse(route, events.splice(0)) - if ( - path === "/global/config" || - path === "/config" || - path === "/provider/auth" || - path === "/mcp" || - path === "/session/status" - ) - return json(route, {}) - if ( - ["/skill", "/command", "/lsp", "/formatter", "/permission", "/question", "/vcs/status", "/vcs/diff"].includes( - path, - ) - ) - return json(route, []) - if (path === "/provider") return json(route, provider()) - if (path === "/path") - return json(route, { state: directory, config: directory, worktree: directory, directory, home: "C:/OpenCode" }) - if (path === "/project") return json(route, [project()]) - if (path === "/project/current") return json(route, project()) - if (path === "/agent") return json(route, [{ name: "build", mode: "primary" }]) - if (path === "/vcs") return json(route, { branch: "main", default_branch: "main" }) - if (path === "/session") return json(route, [session()]) - if (path === `/session/${sessionID}`) return json(route, session()) - if (/^\/session\/[^/]+\/(children|todo|diff)$/.test(path)) return json(route, []) - if (path === `/session/${sessionID}/message`) return json(route, [userMessage, assistantMessage]) - return json(route, {}) + await mockOpenCodeServer(page, { + directory, + project: project(), + provider: provider(), + sessions: [session()], + pageMessages: () => ({ items: [userMessage, assistantMessage] }), + events: () => events.splice(0), }) } @@ -372,24 +347,6 @@ function provider() { } } -function json(route: Route, body: unknown, headers?: Record) { - return route.fulfill({ - status: 200, - contentType: "application/json", - headers: { "access-control-allow-origin": "*", "access-control-expose-headers": "x-next-cursor", ...headers }, - body: JSON.stringify(body ?? null), - }) -} - -function sse(route: Route, events: EventPayload[]) { - return route.fulfill({ - status: 200, - contentType: "text/event-stream", - headers: { "access-control-allow-origin": "*" }, - body: events.map((event) => `data: ${JSON.stringify(event)}\n\n`).join(""), - }) -} - function base64Encode(value: string) { return Buffer.from(value, "utf8").toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "") } diff --git a/packages/app/e2e/regression/session-timeline-context-resize.spec.ts b/packages/app/e2e/regression/session-timeline-context-resize.spec.ts index 98f34b2b9..dc72e24f0 100644 --- a/packages/app/e2e/regression/session-timeline-context-resize.spec.ts +++ b/packages/app/e2e/regression/session-timeline-context-resize.spec.ts @@ -1,4 +1,5 @@ -import { expect, test, type Page, type Route } from "@playwright/test" +import { expect, test, type Page } from "@playwright/test" +import { mockOpenCodeServer } from "../utils/mock-server" const directory = "C:/OpenCode/ContextResizeRegression" const projectID = "proj_context_resize_regression" @@ -207,33 +208,12 @@ function contextTool(partID: string, messageID: string, tool: string, input: Rec } async function mockServer(page: Page) { - await page.route("**/*", async (route) => { - const url = new URL(route.request().url()) - const targetPort = process.env.PLAYWRIGHT_SERVER_PORT ?? "4096" - if (url.port !== targetPort) return route.fallback() - - const path = url.pathname - if (path === "/global/event" || path === "/event") return sse(route) - if (["/global/config", "/config", "/provider/auth", "/mcp", "/session/status"].includes(path)) - return json(route, {}) - if ( - ["/skill", "/command", "/lsp", "/formatter", "/permission", "/question", "/vcs/status", "/vcs/diff"].includes( - path, - ) - ) - return json(route, []) - if (path === "/provider") return json(route, provider()) - if (path === "/path") - return json(route, { state: directory, config: directory, worktree: directory, directory, home: "C:/OpenCode" }) - if (path === "/project") return json(route, [project()]) - if (path === "/project/current") return json(route, project()) - if (path === "/agent") return json(route, [{ name: "build", mode: "primary" }]) - if (path === "/vcs") return json(route, { branch: "main", default_branch: "main" }) - if (path === "/session") return json(route, [session()]) - if (path === `/session/${sessionID}`) return json(route, session()) - if (/^\/session\/[^/]+\/(children|todo|diff)$/.test(path)) return json(route, []) - if (path === `/session/${sessionID}/message`) return json(route, messages) - return json(route, {}) + await mockOpenCodeServer(page, { + directory, + project: project(), + provider: provider(), + sessions: [session()], + pageMessages: () => ({ items: messages }), }) } @@ -282,19 +262,6 @@ function provider() { } } -function json(route: Route, body: unknown, headers?: Record) { - return route.fulfill({ - status: 200, - contentType: "application/json", - headers: { "access-control-allow-origin": "*", "access-control-expose-headers": "x-next-cursor", ...headers }, - body: JSON.stringify(body ?? null), - }) -} - -function sse(route: Route) { - return route.fulfill({ status: 200, contentType: "text/event-stream", body: ": ok\n\n" }) -} - function base64Encode(value: string) { return Buffer.from(value, "utf8").toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "") } diff --git a/packages/app/e2e/utils/mock-server.ts b/packages/app/e2e/utils/mock-server.ts index b89f46dee..9a03a9d5a 100644 --- a/packages/app/e2e/utils/mock-server.ts +++ b/packages/app/e2e/utils/mock-server.ts @@ -18,6 +18,7 @@ export interface MockServerConfig { project: unknown sessions: ({ id: string } & Record)[] pageMessages: (sessionId: string, limit: number, before?: string) => { items: unknown[]; cursor?: string } + events?: () => unknown[] } export async function mockOpenCodeServer(page: Page, config: MockServerConfig) { @@ -43,7 +44,8 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) { if (url.port !== targetPort) return route.fallback() const path = url.pathname - if (path === "/global/event" || path === "/event") return sse(route) + if (path === "/global/event" || path === "/event") return sse(route, config.events?.()) + if (path === "/global/health") return json(route, { healthy: true }) if (emptyObject.has(path)) return json(route, {}) if (emptyList.has(path)) return json(route, []) if (path in staticRoutes) return json(route, staticRoutes[path]) @@ -81,6 +83,10 @@ function json(route: Route, body: unknown, headers?: Record) { }) } -function sse(route: Route) { - return route.fulfill({ status: 200, contentType: "text/event-stream", body: ": ok\n\n" }) +function sse(route: Route, events?: unknown[]) { + return route.fulfill({ + status: 200, + contentType: "text/event-stream", + body: events?.map((event) => `data: ${JSON.stringify(event)}\n\n`).join("") || ": ok\n\n", + }) } diff --git a/packages/app/public/assets/Inter.ttf b/packages/app/public/assets/Inter.ttf new file mode 100644 index 000000000..e31b51e3e Binary files /dev/null and b/packages/app/public/assets/Inter.ttf differ diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx index 339cda8ed..c4eadbd5f 100644 --- a/packages/app/src/app.tsx +++ b/packages/app/src/app.tsx @@ -46,6 +46,12 @@ import DirectoryLayout from "@/pages/directory-layout" import Layout from "@/pages/layout" import { ErrorPage } from "./pages/error" import { useCheckServerHealth } from "./utils/server-health" +import { ServersProvider } from "./context/servers" + +if (import.meta.env.VITE_OPENCODE_CHANNEL !== "prod") { + document.body.classList.remove("text-12-regular") + document.body.classList.add("font-(family-name:--font-family-text)", "text-[13px]", "font-[440]") +} const HomeRoute = lazy(() => import("@/pages/home")) const Session = lazy(() => import("@/pages/session")) @@ -296,31 +302,29 @@ export function AppInterface(props: { disableHealthCheck?: boolean }) { return ( - - - - - - - {routerProps.children}} - > - - - } /> - - - - - - - - + + + + + + + + {routerProps.children}} + > + + + } /> + + + + + + + + + ) } diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index a087c366e..e26217c90 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -4,6 +4,8 @@ import { createEffect, on, Component, + splitProps, + For, Show, onCleanup, createMemo, @@ -11,7 +13,10 @@ import { createResource, Switch, Match, + type ComponentProps, + type JSX, } from "solid-js" +import { Popover as KobaltePopover } from "@kobalte/core/popover" import { createStore } from "solid-js/store" import { useLocal } from "@/context/local" import { selectionFromLines, type SelectedLineRange, useFile } from "@/context/file" @@ -26,12 +31,14 @@ import { FileAttachmentPart, } from "@/context/prompt" import { useLayout } from "@/context/layout" +import { useNavigate } from "@solidjs/router" import { useSDK } from "@/context/sdk" +import { useServer } from "@/context/server" import { useSync } from "@/context/sync" import { useComments } from "@/context/comments" import { Button } from "@opencode-ai/ui/button" import { DockShellForm, DockTray } from "@opencode-ai/ui/dock-surface" -import { Icon } from "@opencode-ai/ui/icon" +import { Icon, type IconProps } from "@opencode-ai/ui/icon" import { ProviderIcon } from "@opencode-ai/ui/provider-icon" import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip" import { IconButton } from "@opencode-ai/ui/icon-button" @@ -44,6 +51,7 @@ import { Persist, persisted } from "@/utils/persist" import { usePermission } from "@/context/permission" import { useLanguage } from "@/context/language" import { usePlatform } from "@/context/platform" +import { useSettings } from "@/context/settings" import { useSessionLayout } from "@/pages/session/session-layout" import { createSessionTabs } from "@/pages/session/helpers" import { createTextFragment, getCursorPosition, setCursorPosition, setRangeEdge } from "./prompt-input/editor-dom" @@ -68,14 +76,16 @@ import { ImagePreview } from "@opencode-ai/ui/image-preview" import { useQueries } from "@tanstack/solid-query" import { useQueryOptions } from "@/context/server-sync" import { pathKey } from "@/utils/path-key" -import { getFilename } from "@opencode-ai/core/util/path" +import { base64Encode } from "@opencode-ai/core/util/encode" +import { displayName } from "@/pages/layout/helpers" + +const USE_V2_INPUT = import.meta.env.VITE_OPENCODE_CHANNEL !== "prod" interface PromptInputProps { class?: string variant?: "dock" | "new-session" ref?: (el: HTMLDivElement) => void newSessionWorktree?: string - onNewSessionWorktreeChange?: (worktree: string) => void onNewSessionWorktreeReset?: () => void edit?: { id: string; prompt: Prompt; context: FollowupDraft["context"] } onEditLoaded?: () => void @@ -113,11 +123,9 @@ const EXAMPLES = [ "prompt.example.25", ] as const -const MAIN_WORKTREE = "main" -const CREATE_WORKTREE = "create" - export const PromptInput: Component = (props) => { const sdk = useSDK() + const navigate = useNavigate() const queryOptions = useQueryOptions() const sync = useSync() @@ -125,6 +133,7 @@ export const PromptInput: Component = (props) => { const files = useFile() const prompt = usePrompt() const layout = useLayout() + const server = useServer() const comments = useComments() const dialog = useDialog() const providers = useProviders() @@ -132,11 +141,13 @@ export const PromptInput: Component = (props) => { const permission = usePermission() const language = useLanguage() const platform = usePlatform() + const settings = useSettings() const { params, tabs, view } = useSessionLayout() let editorRef!: HTMLDivElement let fileInputRef: HTMLInputElement | undefined let scrollRef!: HTMLDivElement let slashPopoverRef!: HTMLDivElement + let projectSearchRef: HTMLInputElement | undefined const mirror = { input: false } const inset = 56 @@ -277,6 +288,10 @@ export const PromptInput: Component = (props) => { mode: "normal", applyingHistory: false, }) + const [picker, setPicker] = createStore({ + projectOpen: false, + projectSearch: "", + }) const buttonsSpring = useSpring(() => (store.mode === "normal" ? 1 : 0), { visualDuration: 0.2, bounce: 0 }) const motion = (value: number) => ({ @@ -1303,91 +1318,124 @@ export const PromptInput: Component = (props) => { return "Ask anything, / for commands, @ for context..." } - const modelControl = () => ( - - 0} - fallback={ - - - - } - > - - - - - - {local.model.current()?.name ?? language.t("dialog.model.select.title")} - - - - - - ) + const modelControlState = createMemo(() => ({ + loading: providersLoading(), + paid: providers.paid().length > 0, + title: language.t("command.model.choose"), + keybind: command.keybind("model.choose"), + model: local.model, + providerID: local.model.current()?.provider?.id, + modelName: local.model.current()?.name ?? language.t("dialog.model.select.title"), + style: control(), + onClose: restoreFocus, + onUnpaidClick: () => { + void import("@/components/dialog-select-model-unpaid").then((x) => { + dialog.show(() => ) + }) + }, + })) const newSession = () => props.variant === "new-session" - const worktrees = createMemo(() => [MAIN_WORKTREE, ...(sync.project?.sandboxes ?? []), CREATE_WORKTREE]) - const currentWorktree = createMemo(() => { - if (worktrees().includes(props.newSessionWorktree ?? MAIN_WORKTREE)) - return props.newSessionWorktree ?? MAIN_WORKTREE - return MAIN_WORKTREE + const projects = createMemo(() => layout.projects.list()) + const projectForDirectory = (directory: string | undefined) => { + if (!directory) return + const key = pathKey(directory) + return projects().find( + (project) => pathKey(project.worktree) === key || project.sandboxes?.some((sandbox) => pathKey(sandbox) === key), + ) + } + const selectedProject = createMemo(() => projectForDirectory(sdk.directory)) + const projectResults = createMemo(() => { + const search = picker.projectSearch.trim().toLowerCase() + if (!search) return projects() + return projects().filter((project) => displayName(project).toLowerCase().includes(search)) }) - const worktreeLabel = (value: string) => { - if (value === MAIN_WORKTREE) return MAIN_WORKTREE - if (value === CREATE_WORKTREE) return language.t("session.new.worktree.create") - return getFilename(value) + const showAgentControl = createMemo(() => settings.general.showCustomAgents() && agentNames().length > 0) + const selectProject = (worktree: string) => { + setPicker({ + projectOpen: false, + projectSearch: "", + }) + if (pathKey(worktree) === pathKey(selectedProject()?.worktree ?? "")) { + restoreFocus() + return + } + layout.projects.open(worktree) + server.projects.touch(worktree) + navigate(`/${base64Encode(worktree)}/session`) + } + const addProject = async () => { + const select = (result: string | string[] | null) => { + const directory = Array.isArray(result) ? result[0] : result + if (!directory) return + selectProject(directory) + } + if (platform.openDirectoryPickerDialog && server.isLocal()) { + select(await platform.openDirectoryPickerDialog({ title: language.t("command.project.open") })) + return + } + void import("@/components/dialog-select-directory").then((x) => { + dialog.show( + () => , + () => select(null), + ) + }) } - const USE_V2_INPUT = import.meta.env.VITE_OPENCODE_CHANNEL !== "prod" + const projectPickerState = createMemo(() => ({ + open: picker.projectOpen, + trigger: { + action: "prompt-project", + icon: "folder", + label: selectedProject() ? displayName(selectedProject()!) : language.t("session.new.project.new"), + class: "max-w-[203px]", + style: control(), + onPress: () => setPicker("projectOpen", true), + }, + search: picker.projectSearch, + searchPlaceholder: language.t("session.new.project.search"), + clearLabel: language.t("common.clear"), + items: projectResults().map((project) => ({ + icon: "folder", + label: displayName(project), + selected: selectedProject()?.worktree === project.worktree, + onSelect: () => selectProject(project.worktree), + })), + action: { + icon: "plus", + label: language.t("session.new.project.add"), + onSelect: () => { + setPicker("projectOpen", false) + void addProject() + }, + }, + onOpenChange: (open) => { + setPicker("projectOpen", open) + if (open) requestAnimationFrame(() => projectSearchRef?.focus()) + }, + onSearchInput: (value) => setPicker("projectSearch", value), + onSearchClear: () => setPicker("projectSearch", ""), + searchRef: (el) => (projectSearchRef = el), + })) + const agentControlState = createMemo(() => ({ + title: language.t("command.agent.cycle"), + keybind: command.keybind("agent.cycle"), + options: agentNames(), + current: local.agent.current()?.name ?? "", + style: control(), + onSelect: (value) => { + local.agent.set(value) + restoreFocus() + }, + })) + const newProjectTriggerState = createMemo(() => ({ + action: "prompt-project", + icon: "folder-add-left", + label: language.t("session.new.project.new"), + class: "max-w-[160px]", + style: control(), + onPress: () => void addProject(), + })) return (
@@ -1409,154 +1457,145 @@ export const PromptInput: Component = (props) => { /> - - - { - const active = comments.active() - return !!item.commentID && item.commentID === active?.id && item.path === active?.file - }} - openComment={openComment} - remove={(item) => { - if (item.commentID) comments.remove(item.path, item.commentID) - prompt.context.remove(item.key) - }} - t={(key) => language.t(key as Parameters[0])} - /> - - dialog.show(() => ) - } - onRemove={removeAttachment} - removeLabel={language.t("prompt.attachment.remove")} - /> -
{ - const target = e.target - if (!(target instanceof HTMLElement)) return - if (target.closest('[data-action="prompt-attach"], [data-action="prompt-submit"]')) return - editorRef?.focus() +
+ -
(scrollRef = el)}> -
{ - editorRef = el - props.ref?.(el) - }} - role="textbox" - aria-multiline="true" - aria-label={designPlaceholder()} - contenteditable="true" - autocapitalize={store.mode === "normal" ? "sentences" : "off"} - autocorrect={store.mode === "normal" ? "on" : "off"} - spellcheck={store.mode === "normal"} - inputMode="text" - // @ts-expect-error - autocomplete="off" - onInput={handleInput} - onPaste={handlePaste} - onCompositionStart={handleCompositionStart} - onCompositionEnd={handleCompositionEnd} - onBlur={handleBlur} - onKeyDown={handleKeyDown} - classList={{ - "select-text": true, - "min-h-[52px] w-full px-4 pt-4 pb-2 focus:outline-none whitespace-pre-wrap leading-5 text-[13px] font-[440] text-v2-text-text-faint [font-family:Inter,var(--font-family-sans)]": true, - "[&_[data-type=file]]:text-syntax-property": true, - "[&_[data-type=agent]]:text-syntax-type": true, - "font-mono!": store.mode === "shell", - }} - /> -
- {designPlaceholder()} + + { + const active = comments.active() + return !!item.commentID && item.commentID === active?.id && item.path === active?.file + }} + openComment={openComment} + remove={(item) => { + if (item.commentID) comments.remove(item.path, item.commentID) + prompt.context.remove(item.key) + }} + t={(key) => language.t(key as Parameters[0])} + /> + + dialog.show(() => ) + } + onRemove={removeAttachment} + removeLabel={language.t("prompt.attachment.remove")} + /> +
{ + const target = e.target + if (!(target instanceof HTMLElement)) return + if (target.closest('[data-action^="prompt-"]')) return + editorRef?.focus() + }} + > +
(scrollRef = el)}> +
{ + editorRef = el + props.ref?.(el) + }} + role="textbox" + aria-multiline="true" + aria-label={designPlaceholder()} + contenteditable="true" + autocapitalize={store.mode === "normal" ? "sentences" : "off"} + autocorrect={store.mode === "normal" ? "on" : "off"} + spellcheck={store.mode === "normal"} + inputMode="text" + // @ts-expect-error + autocomplete="off" + onInput={handleInput} + onPaste={handlePaste} + onCompositionStart={handleCompositionStart} + onCompositionEnd={handleCompositionEnd} + onBlur={handleBlur} + onKeyDown={handleKeyDown} + classList={{ + "select-text": true, + "min-h-[52px] w-full px-4 pt-4 pb-2 focus:outline-none whitespace-pre-wrap leading-5 text-[13px] font-[440] text-v2-text-text-faint [font-family:Inter,var(--font-family-sans)]": true, + "[&_[data-type=file]]:text-syntax-property": true, + "[&_[data-type=agent]]:text-syntax-type": true, + "font-mono!": store.mode === "shell", + }} + /> +
+ {designPlaceholder()} +
-
-
-
- {fileAttachmentInput()} - - - - -
-
- -
- props.state.onSearchInput(event.currentTarget.value)} + /> + + + +
+ {(item) => } +
+
+
+ +
+ + + + ) +} + +function ComposerAgentControl(props: { state: ComposerAgentControlState }) { + return ( +
+
+ +
+ + -
-
- -
- + + + + ) +} diff --git a/packages/console/support/src/routes/lookup.tsx b/packages/console/support/src/routes/lookup.tsx new file mode 100644 index 000000000..7e977b7f9 --- /dev/null +++ b/packages/console/support/src/routes/lookup.tsx @@ -0,0 +1,39 @@ +import { Title } from "@solidjs/meta" +import { createAsync, query, useSearchParams, type RouteDefinition } from "@solidjs/router" +import { Show } from "solid-js" +import { ErrorBoundary } from "solid-js" +import { Result } from "~/component/result" +import { lookup } from "~/lib/lookup" + +const getLookup = query(async (identifier: string) => { + "use server" + return lookup(identifier) +}, "support.lookup") + +export const route: RouteDefinition = { + preload: ({ location }) => { + const identifier = new URLSearchParams(location.search).get("identifier")?.trim() + if (identifier) void getLookup(identifier) + }, +} + +export default function LookupPage() { + const [params] = useSearchParams() + const identifier = () => String(params.identifier ?? "").trim() + const data = createAsync(() => (identifier() ? getLookup(identifier()) : Promise.resolve(undefined))) + + return ( +
+ opencode support — {identifier() || "lookup"} +

Lookup: {identifier() || "(no identifier)"}

+ + Provide an `identifier` query parameter.
}> +
{(err as Error).message}
}> + Loading...
}> + {(result) => } + + + + + ) +} diff --git a/packages/console/support/sst-env.d.ts b/packages/console/support/sst-env.d.ts new file mode 100644 index 000000000..301538ccb --- /dev/null +++ b/packages/console/support/sst-env.d.ts @@ -0,0 +1,10 @@ +/* This file is auto-generated by SST. Do not edit. */ +/* tslint:disable */ +/* eslint-disable */ +/* deno-fmt-ignore-file */ +/* biome-ignore-all lint: auto-generated */ + +/// + +import "sst" +export {} \ No newline at end of file diff --git a/packages/console/support/tsconfig.json b/packages/console/support/tsconfig.json new file mode 100644 index 000000000..0f96f182c --- /dev/null +++ b/packages/console/support/tsconfig.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowSyntheticDefaultImports": true, + "esModuleInterop": true, + "jsx": "preserve", + "jsxImportSource": "solid-js", + "allowJs": true, + "strict": true, + "noEmit": true, + "types": ["vite/client", "bun"], + "isolatedModules": true, + "paths": { + "~/*": ["./src/*"] + } + } +} diff --git a/packages/console/support/vite.config.ts b/packages/console/support/vite.config.ts new file mode 100644 index 000000000..3b013e990 --- /dev/null +++ b/packages/console/support/vite.config.ts @@ -0,0 +1,25 @@ +import { defineConfig, PluginOption } from "vite" +import { solidStart } from "@solidjs/start/config" +import { nitro } from "nitro/vite" + +export default defineConfig({ + plugins: [ + solidStart() as PluginOption, + nitro({ + compatibilityDate: "2024-09-19", + preset: "cloudflare_module", + cloudflare: { + nodeCompat: true, + }, + }), + ], + server: { + allowedHosts: true, + }, + build: { + rollupOptions: { + external: ["cloudflare:workers"], + }, + minify: false, + }, +}) diff --git a/packages/core/package.json b/packages/core/package.json index 9a13ea50a..f18d0421f 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -43,7 +43,7 @@ "@ai-sdk/cohere": "3.0.27", "@ai-sdk/deepinfra": "2.0.41", "@ai-sdk/gateway": "3.0.104", - "@ai-sdk/google": "3.0.75", + "@ai-sdk/google": "3.0.63", "@ai-sdk/google-vertex": "4.0.131", "@ai-sdk/groq": "3.0.31", "@ai-sdk/mistral": "3.0.27", diff --git a/packages/opencode/package.json b/packages/opencode/package.json index fb5ee5450..c244989e6 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -79,7 +79,7 @@ "@ai-sdk/cohere": "3.0.27", "@ai-sdk/deepinfra": "2.0.41", "@ai-sdk/gateway": "3.0.104", - "@ai-sdk/google": "3.0.75", + "@ai-sdk/google": "3.0.63", "@ai-sdk/google-vertex": "4.0.131", "@ai-sdk/groq": "3.0.31", "@ai-sdk/mistral": "3.0.27", diff --git a/packages/opencode/src/acp-next/agent.ts b/packages/opencode/src/acp-next/agent.ts index a4ae00695..040c1947e 100644 --- a/packages/opencode/src/acp-next/agent.ts +++ b/packages/opencode/src/acp-next/agent.ts @@ -4,10 +4,14 @@ import { type AgentSideConnection, type AuthenticateRequest, type CancelNotification, + type CloseSessionRequest, + type ForkSessionRequest, type InitializeRequest, + type ListSessionsRequest, type LoadSessionRequest, type NewSessionRequest, type PromptRequest, + type ResumeSessionRequest, type SetSessionConfigOptionRequest, type SetSessionModelRequest, type SetSessionModeRequest, @@ -44,6 +48,22 @@ export class Agent implements ACPAgent { return run(this.service.loadSession(params)) } + listSessions(params: ListSessionsRequest) { + return run(this.service.listSessions(params)) + } + + resumeSession(params: ResumeSessionRequest) { + return run(this.service.resumeSession(params)) + } + + closeSession(params: CloseSessionRequest) { + return run(this.service.closeSession(params)) + } + + unstable_forkSession(params: ForkSessionRequest) { + return run(this.service.forkSession(params)) + } + setSessionConfigOption(params: SetSessionConfigOptionRequest) { return run(this.service.setSessionConfigOption(params)) } diff --git a/packages/opencode/src/acp-next/event.ts b/packages/opencode/src/acp-next/event.ts new file mode 100644 index 000000000..25aa5c08e --- /dev/null +++ b/packages/opencode/src/acp-next/event.ts @@ -0,0 +1,311 @@ +import type { AgentSideConnection } from "@agentclientprotocol/sdk" +import * as Log from "@opencode-ai/core/util/log" +import type { + Event, + EventMessagePartDelta, + EventMessagePartUpdated, + OpencodeClient, + Part, + SessionMessageResponse, + ToolPart, +} from "@opencode-ai/sdk/v2" +import { Effect } from "effect" +import { ACPNextSession } from "./session" +import { + duplicateRunningToolUpdate, + errorToolUpdate, + pendingToolCall, + runningToolUpdate, + shellOutputSnapshot, + completedToolUpdate, +} from "./tool" + +const log = Log.create({ service: "acp-next-event" }) + +type Connection = Pick +type GlobalEventEnvelope = { + payload?: Event +} +type GlobalEventStream = { + stream: AsyncIterable +} + +export function start(input: { sdk: OpencodeClient; connection: Connection; session: ACPNextSession.Interface }) { + const subscription = new Subscription(input) + subscription.start() + return subscription +} + +export class Subscription { + private readonly abort = new AbortController() + private readonly shellSnapshots = new Map() + private readonly toolStarts = new Set() + private started = false + + constructor( + private readonly input: { + sdk: OpencodeClient + connection: Connection + session: ACPNextSession.Interface + }, + ) {} + + start() { + if (this.started) return + this.started = true + this.run().catch((error: unknown) => { + if (this.abort.signal.aborted) return + log.error("event subscription failed", { error }) + }) + } + + stop() { + this.abort.abort() + } + + async handle(event: Event) { + switch (event.type) { + case "message.part.updated": + return this.handlePartUpdated(event) + case "message.part.delta": + return this.handlePartDelta(event) + } + } + + async replayMessage(message: SessionMessageResponse) { + if (message.info.role !== "assistant" && message.info.role !== "user") return + + for (const part of message.parts) { + await this.recordFetchedPart(message.info.sessionID, message, part) + if (part.type === "tool") { + await this.handleToolPart(message.info.sessionID, part) + } + } + } + + private async run() { + while (!this.abort.signal.aborted) { + const events = (await this.input.sdk.global.event({ + signal: this.abort.signal, + })) as GlobalEventStream + + for await (const event of events.stream) { + if (this.abort.signal.aborted) return + if (!event.payload) continue + await this.handle(event.payload).catch((error: unknown) => { + log.error("failed to handle event", { error, type: event.payload?.type }) + }) + } + if (!this.abort.signal.aborted) await new Promise((resolve) => setTimeout(resolve, 1000)) + } + } + + private async handlePartUpdated(event: EventMessagePartUpdated) { + const part = event.properties.part + const sessionId = part.sessionID || event.properties.sessionID + const session = await Effect.runPromise(this.input.session.tryGet(sessionId)) + if (!session) return + + await Effect.runPromise( + this.input.session.recordPartMetadata({ + sessionId: session.id, + messageId: part.messageID, + partId: part.id, + partType: part.type, + role: part.type === "reasoning" ? "assistant" : undefined, + ignored: part.type === "text" ? part.ignored : undefined, + toolCallId: part.type === "tool" ? part.callID : undefined, + metadata: "metadata" in part ? part.metadata : undefined, + }), + ) + if (part.type === "tool") { + await this.handleToolPart(session.id, part) + } + } + + private async handlePartDelta(event: EventMessagePartDelta) { + const props = event.properties + const session = await Effect.runPromise(this.input.session.tryGet(props.sessionID)) + if (!session) return + + const known = await Effect.runPromise( + this.input.session.tryGetPartMetadata({ + sessionId: session.id, + messageId: props.messageID, + partId: props.partID, + }), + ) + const metadata = + known?.role && known.partType + ? known + : await this.fetchPartMetadata(session.id, session.cwd, props.messageID, props.partID) + if (metadata?.role !== "assistant") return + if (metadata.partType === "text" && props.field === "text" && metadata.ignored !== true) { + await this.input.connection.sessionUpdate({ + sessionId: session.id, + update: { + sessionUpdate: "agent_message_chunk", + messageId: props.messageID, + content: { + type: "text", + text: props.delta, + }, + }, + }) + return + } + + if (metadata.partType === "reasoning" && props.field === "text") { + await this.input.connection.sessionUpdate({ + sessionId: session.id, + update: { + sessionUpdate: "agent_thought_chunk", + messageId: props.messageID, + content: { + type: "text", + text: props.delta, + }, + }, + }) + } + } + + private async fetchPartMetadata(sessionId: string, cwd: string, messageId: string, partId: string) { + const message = await this.input.sdk.session + .message( + { + sessionID: sessionId, + messageID: messageId, + directory: cwd, + }, + { throwOnError: true }, + ) + .then((response) => response.data) + .catch((error: unknown) => { + log.error("unexpected error when fetching message for delta metadata", { error, messageId, partId }) + return undefined + }) + if (!message) return + + const part = message.parts.find((item) => item.id === partId) + if (!part) return + return await this.recordFetchedPart(sessionId, message, part) + } + + private async recordFetchedPart(sessionId: string, message: SessionMessageResponse, part: Part) { + return await Effect.runPromise( + this.input.session.recordPartMetadata({ + sessionId, + messageId: part.messageID, + partId: part.id, + partType: part.type, + role: message.info.role, + ignored: part.type === "text" ? part.ignored : undefined, + toolCallId: part.type === "tool" ? part.callID : undefined, + metadata: "metadata" in part ? part.metadata : undefined, + }), + ) + } + + private async handleToolPart(sessionId: string, part: ToolPart) { + await this.toolStart(sessionId, part) + + switch (part.state.status) { + case "pending": + this.shellSnapshots.delete(part.callID) + return + + case "running": + await this.runningTool(sessionId, part) + return + + case "completed": + this.clearTool(part.callID) + await this.input.connection.sessionUpdate({ + sessionId, + update: { + sessionUpdate: "tool_call_update", + ...completedToolUpdate({ + toolCallId: part.callID, + toolName: part.tool, + state: part.state, + }), + }, + }) + return + + case "error": + this.clearTool(part.callID) + await this.input.connection.sessionUpdate({ + sessionId, + update: { + sessionUpdate: "tool_call_update", + ...errorToolUpdate({ + toolCallId: part.callID, + toolName: part.tool, + state: part.state, + }), + }, + }) + return + } + } + + private async runningTool(sessionId: string, part: ToolPart) { + if (part.state.status !== "running") return + + const output = part.tool === "bash" ? shellOutputSnapshot(part.state) : undefined + if (output !== undefined) { + if (this.shellSnapshots.get(part.callID) === output) { + await this.input.connection.sessionUpdate({ + sessionId, + update: { + sessionUpdate: "tool_call_update", + ...duplicateRunningToolUpdate({ + toolCallId: part.callID, + toolName: part.tool, + state: part.state, + }), + }, + }) + return + } + this.shellSnapshots.set(part.callID, output) + } + + await this.input.connection.sessionUpdate({ + sessionId, + update: { + sessionUpdate: "tool_call_update", + ...runningToolUpdate({ + toolCallId: part.callID, + toolName: part.tool, + state: part.state, + output, + }), + }, + }) + } + + private async toolStart(sessionId: string, part: ToolPart) { + if (this.toolStarts.has(part.callID)) return + this.toolStarts.add(part.callID) + await this.input.connection.sessionUpdate({ + sessionId, + update: { + sessionUpdate: "tool_call", + ...pendingToolCall({ + toolCallId: part.callID, + toolName: part.tool, + }), + }, + }) + } + + private clearTool(toolCallId: string) { + this.toolStarts.delete(toolCallId) + this.shellSnapshots.delete(toolCallId) + } +} + +export * as ACPNextEvent from "./event" diff --git a/packages/opencode/src/acp-next/service.ts b/packages/opencode/src/acp-next/service.ts index 4516a3047..1ab960b40 100644 --- a/packages/opencode/src/acp-next/service.ts +++ b/packages/opencode/src/acp-next/service.ts @@ -4,8 +4,14 @@ import { type AuthenticateResponse, type AuthMethod, type CancelNotification, + type CloseSessionRequest, + type CloseSessionResponse, + type ForkSessionRequest, + type ForkSessionResponse, type InitializeRequest, type InitializeResponse, + type ListSessionsRequest, + type ListSessionsResponse, type LoadSessionRequest, type LoadSessionResponse, type McpServer, @@ -13,6 +19,9 @@ import { type NewSessionResponse, type PromptRequest, type PromptResponse, + type ResumeSessionRequest, + type ResumeSessionResponse, + type SessionInfo, type SetSessionConfigOptionRequest, type SetSessionConfigOptionResponse, type SetSessionModelRequest, @@ -21,11 +30,13 @@ import { type SetSessionModeResponse, } from "@agentclientprotocol/sdk" import { InstallationVersion } from "@opencode-ai/core/installation/version" -import type { OpencodeClient } from "@opencode-ai/sdk/v2" +import * as Log from "@opencode-ai/core/util/log" +import type { Message, OpencodeClient, SessionMessageResponse } from "@opencode-ai/sdk/v2" import { Context, Effect, Layer, ManagedRuntime } from "effect" import * as ACPNextError from "./error" import { buildConfigOptions, parseModelSelection } from "./config-option" import { Directory } from "./directory" +import { ACPNextEvent } from "./event" import { ACPNextSession } from "./session" import { ProviderV2 } from "@opencode-ai/core/provider" import { Provider } from "@/provider/provider" @@ -33,6 +44,7 @@ import type { Command } from "@/command" export const AuthMethodID = "opencode-login" +const log = Log.create({ service: "acp-next-service" }) export type Error = ACPNextError.Error @@ -41,6 +53,10 @@ export type Interface = { readonly authenticate: (input: AuthenticateRequest) => Effect.Effect readonly newSession: (input: NewSessionRequest) => Effect.Effect readonly loadSession: (input: LoadSessionRequest) => Effect.Effect + readonly listSessions: (input: ListSessionsRequest) => Effect.Effect + readonly resumeSession: (input: ResumeSessionRequest) => Effect.Effect + readonly closeSession: (input: CloseSessionRequest) => Effect.Effect + readonly forkSession: (input: ForkSessionRequest) => Effect.Effect readonly setSessionConfigOption: ( input: SetSessionConfigOptionRequest, ) => Effect.Effect @@ -57,10 +73,15 @@ export function make(input: { connection?: Pick directory?: Directory.Interface session?: ACPNextSession.Interface + eventSubscription?: (subscription: ACPNextEvent.Subscription) => void }): Interface { const session = input.session ?? makeSessionService() const directoryService = input.directory ?? makeDirectoryService(input.sdk) const registeredMcp = new Map>() + const events = input.connection + ? ACPNextEvent.start({ sdk: input.sdk, connection: input.connection, session }) + : undefined + if (events) input.eventSubscription?.(events) const initialize = Effect.fn("ACPNext.initialize")(function* (params: InitializeRequest) { const authMethod: AuthMethod = { @@ -91,6 +112,12 @@ export function make(input: { embeddedContext: true, image: true, }, + sessionCapabilities: { + close: {}, + fork: {}, + list: {}, + resume: {}, + }, }, authMethods: [authMethod], agentInfo: { @@ -181,6 +208,137 @@ export function make(input: { yield* registerMcpServers(input.sdk, registeredMcp, params.cwd, state.id, params.mcpServers) yield* sendAvailableCommands(input.connection, state.id, snapshot) + yield* replayMessages(events, messages) + + return { + configOptions: configOptions(snapshot, { + model: state.model ?? model, + variant: state.variant, + modeId: state.modeId, + }), + } + }) + + const listSessions = Effect.fn("ACPNext.listSessions")(function* (params: ListSessionsRequest) { + const cursor = params.cursor ? Number(params.cursor) : undefined + const limit = 100 + const sessions = yield* request( + () => + input.sdk.session.list( + { + ...(params.cwd ? { directory: params.cwd } : {}), + roots: true, + }, + { throwOnError: true }, + ), + "session", + ) + const sorted = sessions.toSorted((a, b) => b.time.updated - a.time.updated) + const filtered = + cursor === undefined || !Number.isFinite(cursor) ? sorted : sorted.filter((item) => item.time.updated < cursor) + const page = filtered.slice(0, limit) + const last = page.at(-1) + return { + sessions: page.map( + (item): SessionInfo => ({ + sessionId: item.id, + cwd: item.directory, + title: item.title, + updatedAt: new Date(item.time.updated).toISOString(), + }), + ), + ...(filtered.length > limit && last ? { nextCursor: String(last.time.updated) } : {}), + } + }) + + const resumeSession = Effect.fn("ACPNext.resumeSession")(function* (params: ResumeSessionRequest) { + const snapshot = yield* directorySnapshot(params.cwd) + yield* request( + () => input.sdk.session.get({ directory: params.cwd, sessionID: params.sessionId }, { throwOnError: true }), + "session", + ) + const messages = yield* request( + () => + input.sdk.session.messages( + { directory: params.cwd, sessionID: params.sessionId, limit: 20 }, + { throwOnError: true }, + ), + "session", + ) + const restored = restoreFromMessages(messages.map((item) => item.info)) + const model = restored.model ?? selectDefaultModel(snapshot) + const state = yield* session.load({ + id: params.sessionId, + cwd: params.cwd, + mcpServers: params.mcpServers ?? [], + model, + variant: restored.variant ?? selectVariant(snapshot, model), + modeId: restored.modeId ?? (snapshot.availableModes.length > 0 ? snapshot.defaultModeID : undefined), + }) + + yield* registerMcpServers(input.sdk, registeredMcp, params.cwd, state.id, params.mcpServers ?? []) + yield* sendAvailableCommands(input.connection, state.id, snapshot) + yield* replayMessages(events, messages) + + return { + configOptions: configOptions(snapshot, { + model: state.model ?? model, + variant: state.variant, + modeId: state.modeId, + }), + } + }) + + const closeSession = Effect.fn("ACPNext.closeSession")(function* (params: CloseSessionRequest) { + const removed = yield* session.remove(params.sessionId) + registeredMcp.delete(params.sessionId) + if (!removed) return {} + + yield* request( + () => input.sdk.session.abort({ directory: removed.cwd, sessionID: params.sessionId }, { throwOnError: true }), + "session", + ).pipe( + Effect.catch((error) => + Effect.sync(() => { + log.error("failed to abort session while closing ACP session", { error, sessionID: params.sessionId }) + }), + ), + ) + return {} + }) + + const forkSession = Effect.fn("ACPNext.forkSession")(function* (params: ForkSessionRequest) { + const snapshot = yield* directorySnapshot(params.cwd) + const forked = yield* request( + () => + input.sdk.session.fork( + { + directory: params.cwd, + sessionID: params.sessionId, + }, + { throwOnError: true }, + ), + "session", + ) + const messages = yield* request( + () => + input.sdk.session.messages({ directory: params.cwd, sessionID: forked.id, limit: 20 }, { throwOnError: true }), + "session", + ) + const restored = restoreFromMessages(messages.map((item) => item.info)) + const model = restored.model ?? selectDefaultModel(snapshot) + const state = yield* session.load({ + id: forked.id, + cwd: params.cwd, + mcpServers: params.mcpServers ?? [], + model, + variant: restored.variant ?? selectVariant(snapshot, model), + modeId: restored.modeId ?? (snapshot.availableModes.length > 0 ? snapshot.defaultModeID : undefined), + }) + + yield* registerMcpServers(input.sdk, registeredMcp, params.cwd, state.id, params.mcpServers ?? []) + yield* sendAvailableCommands(input.connection, state.id, snapshot) + yield* replayMessages(events, messages) return { sessionId: state.id, @@ -279,6 +437,10 @@ export function make(input: { authenticate, newSession, loadSession, + listSessions, + resumeSession, + closeSession, + forkSession, setSessionConfigOption, setSessionMode, setSessionModel, @@ -312,6 +474,17 @@ function makeDirectoryService(sdk: OpencodeClient) { ).runSync(Directory.Service.use((service) => Effect.succeed(service))) } +function replayMessages(subscription: ACPNextEvent.Subscription | undefined, messages: SessionMessageResponse[]) { + if (!subscription) return Effect.void + return Effect.promise(async () => { + for (const message of messages) { + await subscription.replayMessage(message).catch((error: unknown) => { + log.error("failed to replay ACP message", { error, messageID: message.info.id }) + }) + } + }) +} + type ConfigState = { readonly model: Directory.DefaultModel readonly variant?: string @@ -324,17 +497,13 @@ type SdkResponse = { } type MessageInfo = { - readonly role?: string - readonly model?: { - readonly providerID?: string - readonly modelID?: string - readonly variant?: string - } - readonly providerID?: string - readonly modelID?: string - readonly variant?: string - readonly mode?: string - readonly agent?: string + readonly role?: Message["role"] + readonly model?: Extract["model"] + readonly providerID?: Extract["providerID"] + readonly modelID?: Extract["modelID"] + readonly variant?: Extract["variant"] + readonly mode?: Extract["mode"] + readonly agent?: Message["agent"] } function request(fn: () => Promise>, service?: string) { diff --git a/packages/opencode/src/acp-next/session.ts b/packages/opencode/src/acp-next/session.ts index 99c8542e6..e6cbec6cf 100644 --- a/packages/opencode/src/acp-next/session.ts +++ b/packages/opencode/src/acp-next/session.ts @@ -1,4 +1,5 @@ import type { McpServer } from "@agentclientprotocol/sdk" +import type { Message, Part } from "@opencode-ai/sdk/v2" import { Context, Effect, Layer, Ref } from "effect" import type { ProviderV2 } from "@opencode-ai/core/provider" import * as ACPNextError from "./error" @@ -12,6 +13,9 @@ export type SelectedModel = { export type KnownMessagePartMetadata = { messageId: string partId: string + partType?: Part["type"] + role?: Message["role"] + ignored?: boolean toolCallId?: string metadata?: unknown } @@ -41,6 +45,9 @@ export type RecordPartMetadataInput = { sessionId: string messageId: string partId: string + partType?: Part["type"] + role?: Message["role"] + ignored?: boolean toolCallId?: string metadata?: unknown } @@ -147,6 +154,9 @@ export const layer = Layer.effect( const metadata = { messageId: input.messageId, partId: input.partId, + partType: input.partType, + role: input.role, + ignored: input.ignored, toolCallId: input.toolCallId, metadata: input.metadata, } diff --git a/packages/opencode/src/acp-next/tool.ts b/packages/opencode/src/acp-next/tool.ts index 128c4c9c8..288d24e31 100644 --- a/packages/opencode/src/acp-next/tool.ts +++ b/packages/opencode/src/acp-next/tool.ts @@ -1,4 +1,4 @@ -import type { ToolCallContent, ToolCallLocation, ToolKind } from "@agentclientprotocol/sdk" +import type { ToolCall, ToolCallContent, ToolCallLocation, ToolCallUpdate, ToolKind } from "@agentclientprotocol/sdk" export type ToolInput = Record @@ -16,6 +16,19 @@ export type CompletedToolState = { readonly attachments?: ReadonlyArray } +export type RunningToolState = { + readonly status: "running" + readonly input: ToolInput + readonly title?: string +} + +export type ErrorToolState = { + readonly status: "error" + readonly input: ToolInput + readonly error: string + readonly metadata?: unknown +} + export type ImageAttachment = { readonly mimeType: string readonly data: string @@ -100,6 +113,104 @@ export function completedToolContent(toolName: string, state: CompletedToolState return content } +export function pendingToolCall(input: { readonly toolCallId: string; readonly toolName: string }): ToolCall { + return { + toolCallId: input.toolCallId, + title: input.toolName, + kind: toToolKind(input.toolName), + status: "pending", + locations: [], + rawInput: {}, + } +} + +export function runningToolUpdate(input: { + readonly toolCallId: string + readonly toolName: string + readonly state: RunningToolState + readonly output?: string +}): ToolCallUpdate { + const content = input.output + ? [ + { + type: "content" as const, + content: { + type: "text" as const, + text: input.output, + }, + }, + ] + : undefined + + return { + toolCallId: input.toolCallId, + status: "in_progress", + kind: toToolKind(input.toolName), + title: input.state.title ?? input.toolName, + locations: toLocations(input.toolName, input.state.input), + rawInput: input.state.input, + ...(content ? { content } : {}), + } +} + +export function duplicateRunningToolUpdate(input: { + readonly toolCallId: string + readonly toolName: string + readonly state: RunningToolState +}): ToolCallUpdate { + return { + toolCallId: input.toolCallId, + status: "in_progress", + kind: toToolKind(input.toolName), + title: input.state.title ?? input.toolName, + locations: toLocations(input.toolName, input.state.input), + rawInput: input.state.input, + } +} + +export function completedToolUpdate(input: { + readonly toolCallId: string + readonly toolName: string + readonly state: CompletedToolState & { readonly title: string } +}): ToolCallUpdate { + return { + toolCallId: input.toolCallId, + status: "completed", + kind: toToolKind(input.toolName), + title: input.state.title, + content: completedToolContent(input.toolName, input.state), + rawInput: input.state.input, + rawOutput: completedToolRawOutput(input.state), + } +} + +export function errorToolUpdate(input: { + readonly toolCallId: string + readonly toolName: string + readonly state: ErrorToolState +}): ToolCallUpdate { + return { + toolCallId: input.toolCallId, + status: "failed", + kind: toToolKind(input.toolName), + title: input.toolName, + rawInput: input.state.input, + content: [ + { + type: "content", + content: { + type: "text", + text: input.state.error, + }, + }, + ], + rawOutput: { + error: input.state.error, + metadata: input.state.metadata, + }, + } +} + export function completedToolRawOutput(state: CompletedToolState) { return { output: state.output, @@ -138,6 +249,11 @@ export const extractLocations = toLocations export const buildCompletedToolContent = completedToolContent export const buildCompletedRawOutput = completedToolRawOutput export const extractShellOutputSnapshot = shellOutputSnapshot +export const buildPendingToolCall = pendingToolCall +export const buildRunningToolUpdate = runningToolUpdate +export const buildDuplicateRunningToolUpdate = duplicateRunningToolUpdate +export const buildCompletedToolUpdate = completedToolUpdate +export const buildErrorToolUpdate = errorToolUpdate function locationFrom(value: unknown): ToolCallLocation[] { const path = stringValue(value) diff --git a/packages/opencode/src/acp-next/usage.ts b/packages/opencode/src/acp-next/usage.ts index 54e3b4f9c..9104236f2 100644 --- a/packages/opencode/src/acp-next/usage.ts +++ b/packages/opencode/src/acp-next/usage.ts @@ -1,5 +1,6 @@ import type { AgentSideConnection, Usage } from "@agentclientprotocol/sdk" import * as Log from "@opencode-ai/core/util/log" +import type { AssistantMessage as OpenCodeAssistantMessage, Message } from "@opencode-ai/sdk/v2" import { InstanceRef } from "@/effect/instance-ref" import { InstanceStore } from "@/project/instance-store" import { ProviderV2 } from "@opencode-ai/core/provider" @@ -8,27 +9,14 @@ import { Context, Effect, Layer, SynchronizedRef } from "effect" const log = Log.create({ service: "acp-next-usage" }) -export type AssistantTokenCost = { - readonly cost: number - readonly tokens: { - readonly input: number - readonly output: number - readonly reasoning: number - readonly cache: { - readonly read: number - readonly write: number - } - } -} +export type AssistantTokenCost = Pick -export type AssistantMessage = AssistantTokenCost & { - readonly role: "assistant" - readonly providerID?: string - readonly modelID?: string -} +export type AssistantMessage = AssistantTokenCost & + Pick & + Partial> export type SessionMessage = { - readonly info: { readonly role: string } | AssistantMessage + readonly info: { readonly role: Message["role"] } | AssistantMessage } export type MessagesInput = { diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 307b02ca4..456d6c3ee 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -762,7 +762,14 @@ export const layer = Layer.effect( result.permission = mergeDeep(perms, result.permission ?? {}) } - if (!result.username) result.username = os.userInfo().username + if (!result.username) { + try { + result.username = os.userInfo().username || "user" + } catch (err) { + log.warn("failed to read system username, using fallback", { err }) + result.username = "user" + } + } if (result.autoshare === true && !result.share) { result.share = "auto" diff --git a/packages/opencode/src/config/managed.ts b/packages/opencode/src/config/managed.ts index 5b0488420..c5348afaf 100644 --- a/packages/opencode/src/config/managed.ts +++ b/packages/opencode/src/config/managed.ts @@ -46,7 +46,14 @@ export function parseManagedPlist(json: string): string { export async function readManagedPreferences() { if (process.platform !== "darwin") return - const user = os.userInfo().username + const user = (() => { + try { + return os.userInfo().username || "user" + } catch (err) { + log.warn("failed to read system username, using fallback", { err }) + return "user" + } + })() const paths = [ path.join("/Library/Managed Preferences", user, `${MANAGED_PLIST_DOMAIN}.plist`), path.join("/Library/Managed Preferences", `${MANAGED_PLIST_DOMAIN}.plist`), diff --git a/packages/opencode/test/acp-next/event.test.ts b/packages/opencode/test/acp-next/event.test.ts new file mode 100644 index 000000000..92b65b888 --- /dev/null +++ b/packages/opencode/test/acp-next/event.test.ts @@ -0,0 +1,657 @@ +import { describe, expect, it } from "bun:test" +import type { AgentSideConnection } from "@agentclientprotocol/sdk" +import type { Event, Message, OpencodeClient, Part, SessionMessageResponse, ToolPart } from "@opencode-ai/sdk/v2" +import { Effect, ManagedRuntime } from "effect" +import { ACPNextEvent } from "@/acp-next/event" +import * as ACPNextService from "@/acp-next/service" +import { Directory } from "@/acp-next/directory" +import { ACPNextSession } from "@/acp-next/session" + +type SessionUpdateParams = Parameters[0] +type ToolSessionUpdateParams = SessionUpdateParams & { + update: Extract +} +type GlobalEventEnvelope = { + payload?: Event +} +type DeltaPartType = Extract["type"] + +const pollUntil = async ( + check: () => boolean | Promise, + message: string, + opts?: { timeoutMs?: number; intervalMs?: number }, +) => { + const started = Date.now() + while (true) { + if (await check()) return + if (Date.now() - started > (opts?.timeoutMs ?? 2000)) throw new Error(message) + await new Promise((resolve) => setTimeout(resolve, opts?.intervalMs ?? 5)) + } +} + +function makeSessionService() { + return ManagedRuntime.make(ACPNextSession.defaultLayer).runSync( + ACPNextSession.Service.use((service) => Effect.succeed(service)), + ) +} + +function createEventStream() { + const queue: GlobalEventEnvelope[] = [] + const waiters: Array<(value: GlobalEventEnvelope | undefined) => void> = [] + const state = { closed: false } + + const push = (event: GlobalEventEnvelope) => { + const waiter = waiters.shift() + if (waiter) { + waiter(event) + return + } + queue.push(event) + } + + const close = () => { + state.closed = true + for (const waiter of waiters.splice(0)) { + waiter(undefined) + } + } + + const stream = async function* (signal?: AbortSignal) { + while (true) { + if (signal?.aborted) return + const next = queue.shift() + if (next) { + yield next + continue + } + if (state.closed) return + const value = await new Promise((resolve) => { + waiters.push(resolve) + signal?.addEventListener("abort", () => resolve(undefined), { once: true }) + }) + if (!value) return + yield value + } + } + + return { push, close, stream } +} + +function createHarness(messages: Record = {}) { + const updates: SessionUpdateParams[] = [] + const calls = { + eventSubscribe: 0, + message: 0, + } + const events = createEventStream() + const sdk = { + global: { + event: (options?: { signal?: AbortSignal }) => { + calls.eventSubscribe++ + return Promise.resolve({ stream: events.stream(options?.signal) }) + }, + }, + session: { + message: (input: { messageID: string }) => { + calls.message++ + return Promise.resolve({ data: messages[input.messageID] }) + }, + get: () => Promise.resolve({ data: { id: "ses_loaded" } }), + messages: () => Promise.resolve({ data: [] }), + }, + } as unknown as OpencodeClient + const connection = { + sessionUpdate: (params: SessionUpdateParams) => { + updates.push(params) + return Promise.resolve() + }, + } satisfies Pick + const session = makeSessionService() + const subscription = new ACPNextEvent.Subscription({ sdk, connection, session }) + + return { calls, connection, events, sdk, session, subscription, updates } +} + +function textDelta(sessionID: string, messageID: string, partID: string, delta: string): Event { + return { + id: `evt_${sessionID}_${messageID}_${partID}_${delta}`, + type: "message.part.delta", + properties: { + sessionID, + messageID, + partID, + field: "text", + delta, + }, + } +} + +function partUpdated(sessionID: string, messageID: string, partID: string, type: DeltaPartType): Event { + return { + id: `evt_${sessionID}_${messageID}_${partID}`, + type: "message.part.updated", + properties: { + sessionID, + time: Date.now(), + part: + type === "text" + ? { + id: partID, + sessionID, + messageID, + type: "text", + text: "", + } + : { + id: partID, + sessionID, + messageID, + type: "reasoning", + text: "", + time: { start: Date.now() }, + }, + }, + } +} + +function toolUpdated(part: ToolPart): Event { + return { + id: `evt_${part.sessionID}_${part.messageID}_${part.id}_${part.state.status}`, + type: "message.part.updated", + properties: { + sessionID: part.sessionID, + time: Date.now(), + part, + }, + } +} + +function assistantMessage(sessionID: string, messageID: string, partID: string, type: DeltaPartType) { + return { + info: { + id: messageID, + sessionID, + role: "assistant", + time: { created: Date.now() }, + parentID: "msg_parent", + modelID: "model", + providerID: "provider", + mode: "build", + agent: "build", + path: { cwd: "/workspace", root: "/workspace" }, + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + }, + parts: [ + type === "text" + ? { + id: partID, + sessionID, + messageID, + type: "text", + text: "", + } + : { + id: partID, + sessionID, + messageID, + type: "reasoning", + text: "", + time: { start: Date.now() }, + }, + ], + } satisfies SessionMessageResponse +} + +function assistantToolMessage(part: ToolPart) { + return { + info: { + id: part.messageID, + sessionID: part.sessionID, + role: "assistant", + time: { created: Date.now() }, + parentID: "msg_parent", + modelID: "model", + providerID: "provider", + mode: "build", + agent: "build", + path: { cwd: "/workspace", root: "/workspace" }, + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + }, + parts: [part], + } satisfies SessionMessageResponse +} + +function runningTool( + sessionID: string, + callID: string, + output?: string, + input: Record = { cmd: "printf hello" }, +) { + return { + id: `part_${callID}`, + sessionID, + messageID: `msg_${callID}`, + type: "tool", + callID, + tool: "bash", + state: { + status: "running", + input, + title: "bash", + ...(output !== undefined ? { metadata: { output } } : {}), + time: { start: Date.now() }, + }, + } satisfies ToolPart +} + +function completedTool( + sessionID: string, + callID: string, + output = "done", + attachments: Extract["attachments"] = [], +) { + return { + id: `part_${callID}`, + sessionID, + messageID: `msg_${callID}`, + type: "tool", + callID, + tool: "bash", + state: { + status: "completed", + input: { cmd: "printf done" }, + output, + title: "bash", + metadata: { exit: 0 }, + time: { start: Date.now() - 1, end: Date.now() }, + ...(attachments.length ? { attachments } : {}), + }, + } satisfies ToolPart +} + +function errorTool(sessionID: string, callID: string) { + return { + id: `part_${callID}`, + sessionID, + messageID: `msg_${callID}`, + type: "tool", + callID, + tool: "bash", + state: { + status: "error", + input: { cmd: "exit 1" }, + error: "failed hard", + metadata: { exit: 1 }, + time: { start: Date.now() - 1, end: Date.now() }, + }, + } satisfies ToolPart +} + +function toolUpdates(updates: SessionUpdateParams[]) { + return updates.filter((item): item is ToolSessionUpdateParams => { + return item.update.sessionUpdate === "tool_call" || item.update.sessionUpdate === "tool_call_update" + }) +} + +async function createKnownSession( + session: ACPNextSession.Interface, + sessionId: string, + part: { messageId: string; partId: string; partType: Part["type"]; role?: Message["role"] }, +) { + await Effect.runPromise(session.create({ id: sessionId, cwd: "/workspace" })) + await Effect.runPromise( + session.recordPartMetadata({ + sessionId, + messageId: part.messageId, + partId: part.partId, + partType: part.partType, + role: part.role ?? "assistant", + }), + ) +} + +describe("acp-next event routing", () => { + it("routes message.part.delta by sessionID without cross-session pollution", async () => { + const harness = createHarness() + await createKnownSession(harness.session, "ses_a", { messageId: "msg_a", partId: "part_a", partType: "text" }) + await createKnownSession(harness.session, "ses_b", { messageId: "msg_b", partId: "part_b", partType: "text" }) + + await harness.subscription.handle(textDelta("ses_b", "msg_b", "part_b", "hello")) + + expect(harness.updates.map((update) => update.sessionId)).toEqual(["ses_b"]) + expect(harness.updates[0]?.update.sessionUpdate).toBe("agent_message_chunk") + }) + + it("keeps interleaved sessions isolated for text and reasoning deltas", async () => { + const harness = createHarness() + await createKnownSession(harness.session, "ses_a", { messageId: "msg_a", partId: "part_a", partType: "text" }) + await createKnownSession(harness.session, "ses_b", { + messageId: "msg_b", + partId: "part_b", + partType: "reasoning", + }) + + await harness.subscription.handle(textDelta("ses_a", "msg_a", "part_a", "A1")) + await harness.subscription.handle(textDelta("ses_b", "msg_b", "part_b", "B1")) + await harness.subscription.handle(textDelta("ses_a", "msg_a", "part_a", "A2")) + await harness.subscription.handle(textDelta("ses_b", "msg_b", "part_b", "B2")) + + expect( + harness.updates.filter((update) => update.sessionId === "ses_a").map((update) => update.update.sessionUpdate), + ).toEqual(["agent_message_chunk", "agent_message_chunk"]) + expect( + harness.updates.filter((update) => update.sessionId === "ses_b").map((update) => update.update.sessionUpdate), + ).toEqual(["agent_thought_chunk", "agent_thought_chunk"]) + }) + + it("does not create extra subscriptions on repeated loadSession", async () => { + const harness = createHarness() + let subscription: ACPNextEvent.Subscription | undefined + const service = ACPNextService.make({ + sdk: harness.sdk, + connection: harness.connection, + directory: { + get: () => + Effect.succeed( + Directory.build({ + directory: "/workspace", + providers: {}, + modes: [], + defaultModeID: "build", + commands: [], + }), + ), + refresh: () => + Effect.succeed( + Directory.build({ + directory: "/workspace", + providers: {}, + modes: [], + defaultModeID: "build", + commands: [], + }), + ), + variants: Directory.variants, + }, + session: harness.session, + eventSubscription: (started) => { + subscription = started + }, + }) + + await pollUntil(() => harness.calls.eventSubscribe === 1, "event subscription did not start") + await Effect.runPromise(service.loadSession({ cwd: "/workspace", sessionId: "ses_loaded", mcpServers: [] })) + await Effect.runPromise(service.loadSession({ cwd: "/workspace", sessionId: "ses_loaded", mcpServers: [] })) + await Effect.runPromise(service.loadSession({ cwd: "/workspace", sessionId: "ses_loaded", mcpServers: [] })) + + expect(harness.calls.eventSubscribe).toBe(1) + subscription?.stop() + harness.events.close() + }) + + it("does not call sdk.session.message repeatedly when metadata is known", async () => { + const harness = createHarness() + await createKnownSession(harness.session, "ses_a", { messageId: "msg_a", partId: "part_a", partType: "text" }) + + for (const delta of ["a", "b", "c", "d", "e"]) { + await harness.subscription.handle(textDelta("ses_a", "msg_a", "part_a", delta)) + } + + expect(harness.calls.message).toBe(0) + expect(harness.updates).toHaveLength(5) + }) + + it("fetches unknown part metadata once and reuses it for later deltas", async () => { + const harness = createHarness({ + msg_a: assistantMessage("ses_a", "msg_a", "part_a", "text"), + }) + await Effect.runPromise(harness.session.create({ id: "ses_a", cwd: "/workspace" })) + + await harness.subscription.handle(partUpdated("ses_a", "msg_a", "part_a", "text")) + await harness.subscription.handle(textDelta("ses_a", "msg_a", "part_a", "a")) + await harness.subscription.handle(textDelta("ses_a", "msg_a", "part_a", "b")) + + expect(harness.calls.message).toBe(1) + expect(harness.updates).toHaveLength(2) + }) + + it("replays loaded session messages sequentially and continues after update failures", async () => { + const events = createEventStream() + const updates: SessionUpdateParams[] = [] + const connection = { + sessionUpdate: (params: SessionUpdateParams) => { + if (params.update.sessionUpdate === "tool_call" && params.update.toolCallId === "call_slow") { + return new Promise((resolve) => { + setTimeout(() => { + updates.push(params) + resolve() + }, 20) + }) + } + + if (params.update.sessionUpdate === "tool_call_update" && params.update.toolCallId === "call_slow") { + return Promise.reject(new Error("replay send failed")) + } + + updates.push(params) + return Promise.resolve() + }, + } satisfies Pick + let subscription: ACPNextEvent.Subscription | undefined + const service = ACPNextService.make({ + sdk: { + global: { + event: (options?: { signal?: AbortSignal }) => Promise.resolve({ stream: events.stream(options?.signal) }), + }, + session: { + get: () => Promise.resolve({ data: { id: "ses_loaded" } }), + messages: () => + Promise.resolve({ + data: [ + assistantToolMessage(completedTool("ses_loaded", "call_slow", "slow")), + assistantToolMessage(completedTool("ses_loaded", "call_after", "after")), + ], + }), + }, + } as unknown as OpencodeClient, + connection, + directory: { + get: () => + Effect.succeed( + Directory.build({ + directory: "/workspace", + providers: {}, + modes: [], + defaultModeID: "build", + commands: [], + }), + ), + refresh: () => + Effect.succeed( + Directory.build({ + directory: "/workspace", + providers: {}, + modes: [], + defaultModeID: "build", + commands: [], + }), + ), + variants: Directory.variants, + }, + eventSubscription: (started) => { + subscription = started + }, + }) + + await Effect.runPromise(service.loadSession({ cwd: "/workspace", sessionId: "ses_loaded", mcpServers: [] })) + + expect(toolUpdates(updates).map((item) => item.update.toolCallId)).toEqual([ + "call_slow", + "call_after", + "call_after", + ]) + subscription?.stop() + events.close() + }) + + it("ignores unknown sessions and live user parts without user_message_chunk duplication", async () => { + const harness = createHarness() + await createKnownSession(harness.session, "ses_user", { + messageId: "msg_user", + partId: "part_user", + partType: "text", + role: "user", + }) + + await harness.subscription.handle(textDelta("ses_missing", "msg_missing", "part_missing", "ignored")) + await harness.subscription.handle(partUpdated("ses_user", "msg_user", "part_live", "text")) + await harness.subscription.handle(textDelta("ses_user", "msg_user", "part_user", "hello")) + + expect(harness.updates).toHaveLength(0) + }) + + it("emits synthetic pending before the first running tool update", async () => { + const harness = createHarness() + await Effect.runPromise(harness.session.create({ id: "ses_tool", cwd: "/workspace" })) + + await harness.subscription.handle(toolUpdated(runningTool("ses_tool", "call_1", "hello"))) + + expect(toolUpdates(harness.updates).map((item) => item.update.sessionUpdate)).toEqual([ + "tool_call", + "tool_call_update", + ]) + expect(harness.updates[0]?.update).toMatchObject({ status: "pending", toolCallId: "call_1" }) + expect(harness.updates[1]?.update).toMatchObject({ status: "in_progress", toolCallId: "call_1" }) + }) + + it("does not emit duplicate synthetic pending after a replayed running tool", async () => { + const harness = createHarness() + await Effect.runPromise(harness.session.create({ id: "ses_replay", cwd: "/workspace" })) + + await harness.subscription.replayMessage(assistantToolMessage(runningTool("ses_replay", "call_replay", "first"))) + await harness.subscription.handle(toolUpdated(runningTool("ses_replay", "call_replay", "second"))) + + expect(toolUpdates(harness.updates).filter((item) => item.update.sessionUpdate === "tool_call")).toHaveLength(1) + expect(toolUpdates(harness.updates).map((item) => item.update.sessionUpdate)).toEqual([ + "tool_call", + "tool_call_update", + "tool_call_update", + ]) + }) + + it("dedupes shell output snapshots while still sending status-only running updates", async () => { + const harness = createHarness() + await Effect.runPromise(harness.session.create({ id: "ses_shell", cwd: "/workspace" })) + + await harness.subscription.handle(toolUpdated(runningTool("ses_shell", "call_shell", "same"))) + await harness.subscription.handle(toolUpdated(runningTool("ses_shell", "call_shell", "same"))) + + const updates = toolUpdates(harness.updates) + expect(updates).toHaveLength(3) + expect(updates[1]?.update).toMatchObject({ + sessionUpdate: "tool_call_update", + content: [{ type: "content", content: { type: "text", text: "same" } }], + }) + expect(updates[2]?.update).toMatchObject({ sessionUpdate: "tool_call_update", status: "in_progress" }) + expect("content" in updates[2]!.update).toBe(false) + }) + + it("clears shell snapshot marker when a tool returns to pending", async () => { + const harness = createHarness() + await Effect.runPromise(harness.session.create({ id: "ses_pending", cwd: "/workspace" })) + + await harness.subscription.handle(toolUpdated(runningTool("ses_pending", "call_pending", "repeat"))) + await harness.subscription.handle( + toolUpdated({ + id: "part_call_pending", + sessionID: "ses_pending", + messageID: "msg_call_pending", + type: "tool", + callID: "call_pending", + tool: "bash", + state: { + status: "pending", + input: { cmd: "printf repeat" }, + raw: '{"cmd":"printf repeat"}', + }, + }), + ) + await harness.subscription.handle(toolUpdated(runningTool("ses_pending", "call_pending", "repeat"))) + + expect( + toolUpdates(harness.updates) + .filter((item) => item.update.sessionUpdate === "tool_call_update") + .map((item) => ("content" in item.update ? item.update.content : undefined)), + ).toEqual([ + [{ type: "content", content: { type: "text", text: "repeat" } }], + [{ type: "content", content: { type: "text", text: "repeat" } }], + ]) + }) + + it("emits completed tool output and rawOutput", async () => { + const harness = createHarness() + await Effect.runPromise(harness.session.create({ id: "ses_done", cwd: "/workspace" })) + + await harness.subscription.handle(toolUpdated(completedTool("ses_done", "call_done", "finished"))) + + expect(harness.updates.at(-1)?.update).toMatchObject({ + sessionUpdate: "tool_call_update", + toolCallId: "call_done", + status: "completed", + content: [{ type: "content", content: { type: "text", text: "finished" } }], + rawOutput: { output: "finished", metadata: { exit: 0 } }, + }) + }) + + it("emits error tool output", async () => { + const harness = createHarness() + await Effect.runPromise(harness.session.create({ id: "ses_error", cwd: "/workspace" })) + + await harness.subscription.handle(toolUpdated(errorTool("ses_error", "call_error"))) + + expect(harness.updates.at(-1)?.update).toMatchObject({ + sessionUpdate: "tool_call_update", + toolCallId: "call_error", + status: "failed", + content: [{ type: "content", content: { type: "text", text: "failed hard" } }], + rawOutput: { error: "failed hard", metadata: { exit: 1 } }, + }) + }) + + it("emits image attachments as ACP image content for live and replayed completed tool updates", async () => { + const harness = createHarness() + const image = Buffer.from("image-data").toString("base64") + const attachment = { + id: "file_image", + sessionID: "ses_image", + messageID: "msg_image", + type: "file", + mime: "image/png", + filename: "image.png", + url: `data:image/png;base64,${image}`, + } as const + await Effect.runPromise(harness.session.create({ id: "ses_image", cwd: "/workspace" })) + + await harness.subscription.handle(toolUpdated(completedTool("ses_image", "call_live", "live", [attachment]))) + await harness.subscription.replayMessage( + assistantToolMessage(completedTool("ses_image", "call_replayed", "replayed", [attachment])), + ) + + expect( + toolUpdates(harness.updates) + .filter((item) => item.update.sessionUpdate === "tool_call_update" && item.update.status === "completed") + .map((item) => ("content" in item.update ? item.update.content : [])), + ).toEqual([ + [ + { type: "content", content: { type: "text", text: "live" } }, + { type: "content", content: { type: "image", mimeType: "image/png", data: image } }, + ], + [ + { type: "content", content: { type: "text", text: "replayed" } }, + { type: "content", content: { type: "image", mimeType: "image/png", data: image } }, + ], + ]) + }) +}) diff --git a/packages/opencode/test/acp-next/service-session.test.ts b/packages/opencode/test/acp-next/service-session.test.ts index 5e21b3c4b..c51dae1e2 100644 --- a/packages/opencode/test/acp-next/service-session.test.ts +++ b/packages/opencode/test/acp-next/service-session.test.ts @@ -1,16 +1,19 @@ import { describe, expect, it } from "bun:test" import type { AgentSideConnection, + ForkSessionResponse, LoadSessionResponse, NewSessionResponse, + ResumeSessionResponse, SessionConfigOption, SessionConfigSelectOption, SetSessionConfigOptionResponse, } from "@agentclientprotocol/sdk" import type { OpencodeClient } from "@opencode-ai/sdk/v2" -import { Effect } from "effect" +import { Effect, ManagedRuntime } from "effect" import * as ACPNextService from "@/acp-next/service" import * as ACPNextError from "@/acp-next/error" +import { ACPNextSession } from "@/acp-next/session" import { ProviderV2 } from "@opencode-ai/core/provider" import type { Provider } from "@/provider/provider" @@ -140,6 +143,14 @@ describe("ACP next service sessions", () => { const makeService = (messages: readonly { info: unknown; parts: readonly unknown[] }[] = []) => { const updates: unknown[] = [] const mcpAdds: string[] = [] + const aborts: string[] = [] + const forks: string[] = [] + const sessions = Array.from({ length: 102 }, (_, index) => ({ + id: `ses_${index + 1}`, + directory: index % 2 === 0 ? "/workspace" : "/other", + title: `Session ${index + 1}`, + time: { created: index + 1, updated: index + 1 }, + })) const sdk = { config: { providers: () => Promise.resolve({ data: { providers: [provider], default: { test: modelID } } }), @@ -168,8 +179,19 @@ describe("ACP next service sessions", () => { session: { create: () => Promise.resolve({ data: { id: "ses_new" } }), get: () => Promise.resolve({ data: { id: "ses_loaded" } }), - list: () => Promise.resolve({ data: [] }), + list: (input: { directory?: string }) => + Promise.resolve({ + data: input.directory ? sessions.filter((session) => session.directory === input.directory) : sessions, + }), messages: () => Promise.resolve({ data: messages }), + abort: (input: { sessionID: string }) => { + aborts.push(input.sessionID) + return Promise.resolve({ data: true }) + }, + fork: (input: { sessionID: string }) => { + forks.push(input.sessionID) + return Promise.resolve({ data: { id: `fork_${input.sessionID}` } }) + }, }, mcp: { add: (input: { name?: string }) => { @@ -185,7 +207,7 @@ describe("ACP next service sessions", () => { }, } as Pick - return { service: ACPNextService.make({ sdk, connection }), updates, mcpAdds } + return { service: ACPNextService.make({ sdk, connection }), updates, mcpAdds, aborts, forks } } it("creates a backed session with config options and command update", async () => { @@ -233,6 +255,125 @@ describe("ACP next service sessions", () => { expect(result.configOptions?.find((option) => option.id === "mode")?.currentValue).toBe("plan") }) + it("lists sessions sorted by updated time with cursor support", async () => { + const { service } = makeService() + const first = await Effect.runPromise(service.listSessions({ cwd: "/workspace" })) + const second = await Effect.runPromise(service.listSessions({ cwd: "/workspace", cursor: first.nextCursor })) + + expect(first.sessions).toHaveLength(51) + expect(first.sessions[0]?.sessionId).toBe("ses_101") + expect(first.sessions.at(-1)?.sessionId).toBe("ses_1") + expect(first.nextCursor).toBeUndefined() + expect(second.sessions).toEqual(first.sessions) + }) + + it("lists all sessions with next cursor when the first page is full", async () => { + const { service } = makeService() + const first = await Effect.runPromise(service.listSessions({})) + const second = await Effect.runPromise(service.listSessions({ cursor: first.nextCursor })) + + expect(first.sessions).toHaveLength(100) + expect(first.sessions[0]?.sessionId).toBe("ses_102") + expect(first.sessions.at(-1)?.sessionId).toBe("ses_3") + expect(first.nextCursor).toBe("3") + expect(second.sessions.map((session) => session.sessionId)).toEqual(["ses_2", "ses_1"]) + }) + + it("resumes a session and stores restored state", async () => { + const { service } = makeService([ + { + info: { + role: "user", + model: { providerID: "test", modelID: "test-model", variant: "high" }, + agent: "plan", + }, + parts: [], + }, + ]) + const resumed = await Effect.runPromise( + service.resumeSession({ cwd: "/workspace", sessionId: "ses_resume", mcpServers: [] }), + ) + const updated = await Effect.runPromise( + service.setSessionConfigOption({ sessionId: "ses_resume", configId: "effort", value: "default" }), + ) + + expect(select(resumed, "effort")?.currentValue).toBe("high") + expect(select(updated, "effort")?.currentValue).toBe("default") + }) + + it("closes local ACP state and aborts the backing session best-effort", async () => { + const { service, aborts } = makeService() + const created = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] })) + + expect(await Effect.runPromise(service.closeSession({ sessionId: created.sessionId }))).toEqual({}) + const missing = await Effect.runPromise( + service + .setSessionConfigOption({ sessionId: created.sessionId, configId: "effort", value: "high" }) + .pipe(Effect.mapError(ACPNextError.toRequestError), Effect.flip), + ) + expect(missing.code).toBe(-32602) + expect(aborts).toEqual([created.sessionId]) + expect(await Effect.runPromise(service.closeSession({ sessionId: "missing" }))).toEqual({}) + }) + + it("does not fail close when backing abort fails", async () => { + const sessionService = ManagedRuntime.make(ACPNextSession.defaultLayer).runSync( + ACPNextSession.Service.use((service) => Effect.succeed(service)), + ) + const { service } = makeService() + const sdk = { + config: { + providers: () => Promise.resolve({ data: { providers: [provider], default: { test: modelID } } }), + get: () => Promise.resolve({ data: {} }), + }, + app: { + agents: () => Promise.resolve({ data: [{ name: "build", mode: "primary", permission: [], options: {} }] }), + skills: () => Promise.resolve({ data: [] }), + }, + command: { + list: () => Promise.resolve({ data: [] }), + }, + session: { + abort: () => Promise.reject(new Error("nope")), + }, + mcp: { + add: () => Promise.resolve({ data: {} }), + }, + } as unknown as OpencodeClient + const closing = ACPNextService.make({ sdk, session: sessionService }) + await Effect.runPromise(sessionService.create({ id: "ses_close", cwd: "/workspace" })) + + expect(await Effect.runPromise(closing.closeSession({ sessionId: "ses_close" }))).toEqual({}) + expect(await Effect.runPromise(service.closeSession({ sessionId: "missing" }))).toEqual({}) + }) + + it("forks a session, loads fork state, and returns config options", async () => { + const { service, forks } = makeService([ + { + info: { + role: "assistant", + providerID: "test", + modelID: "second-model", + variant: "medium", + mode: "plan", + }, + parts: [], + }, + ]) + const forked = await Effect.runPromise( + service.forkSession({ cwd: "/workspace", sessionId: "ses_parent", mcpServers: [] }), + ) + const updated = await Effect.runPromise( + service.setSessionConfigOption({ sessionId: forked.sessionId, configId: "effort", value: "low" }), + ) + + expect(forked.sessionId).toBe("fork_ses_parent") + expect(select(forked, "model")?.currentValue).toBe("test/second-model") + expect(select(forked, "effort")?.currentValue).toBe("medium") + expect(select(updated, "effort")?.currentValue).toBe("low") + expect(forks).toEqual(["ses_parent"]) + }) + it("restores model variant and mode from the latest user message", async () => { const { service } = makeService([ { @@ -522,8 +663,11 @@ function categories(result: NewSessionResponse | LoadSessionResponse) { return result.configOptions?.map((option) => option.category) ?? [] } -function select(result: SetSessionConfigOptionResponse, id: string) { - return result.configOptions.find( +function select( + result: SetSessionConfigOptionResponse | ResumeSessionResponse | NewSessionResponse | ForkSessionResponse, + id: string, +) { + return result.configOptions?.find( (option): option is Extract => option.id === id && option.type === "select", ) diff --git a/packages/opencode/test/cli/acp-next/acp-next-process.test.ts b/packages/opencode/test/cli/acp-next/acp-next-process.test.ts index c4e88fb74..95b905543 100644 --- a/packages/opencode/test/cli/acp-next/acp-next-process.test.ts +++ b/packages/opencode/test/cli/acp-next/acp-next-process.test.ts @@ -1,9 +1,11 @@ import { describe, expect } from "bun:test" import type { AuthenticateResponse, + CloseSessionResponse, InitializeResponse, LoadSessionResponse, NewSessionResponse, + ResumeSessionResponse, SessionNotification, SetSessionConfigOptionResponse, } from "@agentclientprotocol/sdk" @@ -31,7 +33,10 @@ describe("opencode acp-next (subprocess)", () => { expect(initialized.agentCapabilities?.mcpCapabilities?.http).toBe(true) expect(initialized.agentCapabilities?.mcpCapabilities?.sse).toBe(true) expect(initialized.agentCapabilities?.loadSession).toBe(true) - expect(initialized.agentCapabilities?.sessionCapabilities).toBeUndefined() + expect(initialized.agentCapabilities?.sessionCapabilities?.close).toEqual({}) + expect(initialized.agentCapabilities?.sessionCapabilities?.fork).toEqual({}) + expect(initialized.agentCapabilities?.sessionCapabilities?.list).toEqual({}) + expect(initialized.agentCapabilities?.sessionCapabilities?.resume).toEqual({}) expect(initialized.agentInfo?.name).toBe("OpenCode") expect(initialized.authMethods?.[0]?.id).toBe("opencode-login") expect(initialized.authMethods?.[0]?._meta?.["terminal-auth"]).toBeDefined() @@ -159,6 +164,55 @@ describe("opencode acp-next (subprocess)", () => { 60_000, ) + cliIt.live( + "advertises and supports close behind OPENCODE_ACP_NEXT", + ({ home, llm, opencode }) => + Effect.gen(function* () { + const acp = createAcpClient( + yield* opencode.acp({ + env: { + OPENCODE_ACP_NEXT: "1", + OPENCODE_CONFIG_CONTENT: JSON.stringify(verifierConfig(llm.url)), + }, + }), + ) + const initialized = expectOk(yield* acp.request("initialize", { protocolVersion: 1 })) + expect(initialized.agentCapabilities?.sessionCapabilities?.close).toEqual({}) + const session = expectOk(yield* acp.request("session/new", { cwd: home, mcpServers: [] })) + + expectOk(yield* acp.request("session/close", { sessionId: session.sessionId })) + }), + 60_000, + ) + + cliIt.live( + "advertises and supports resume behind OPENCODE_ACP_NEXT", + ({ home, llm, opencode }) => + Effect.gen(function* () { + const acp = createAcpClient( + yield* opencode.acp({ + env: { + OPENCODE_ACP_NEXT: "1", + OPENCODE_CONFIG_CONTENT: JSON.stringify(verifierConfig(llm.url)), + }, + }), + ) + const initialized = expectOk(yield* acp.request("initialize", { protocolVersion: 1 })) + expect(initialized.agentCapabilities?.sessionCapabilities?.resume).toEqual({}) + const session = expectOk(yield* acp.request("session/new", { cwd: home, mcpServers: [] })) + const resumed = expectOk( + yield* acp.request("session/resume", { + cwd: home, + sessionId: session.sessionId, + mcpServers: [], + }), + ) + + expect(selectConfigOption(resumed.configOptions, "model")?.category).toBe("model") + }), + 60_000, + ) + cliIt.live( "exits cleanly when flagged stdin is closed", ({ opencode }) => diff --git a/packages/opencode/test/config/config.test.ts b/packages/opencode/test/config/config.test.ts index 307791b07..85cb78a32 100644 --- a/packages/opencode/test/config/config.test.ts +++ b/packages/opencode/test/config/config.test.ts @@ -1,4 +1,4 @@ -import { test, expect, describe, afterEach, beforeEach } from "bun:test" +import { test, expect, describe, afterEach, beforeEach, spyOn } from "bun:test" import { Effect, Exit, Layer, Option } from "effect" import { FetchHttpClient, HttpClient, HttpClientResponse } from "effect/unstable/http" import { NodeFileSystem, NodePath } from "@effect/platform-node" @@ -28,6 +28,7 @@ import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { testEffect } from "../lib/effect" import path from "path" import fs from "fs/promises" +import os from "os" import { pathToFileURL } from "url" import { Global } from "@opencode-ai/core/global" import { ProjectV2 } from "@opencode-ai/core/project" @@ -291,6 +292,20 @@ it.instance("loads config with defaults when no files exist", () => }), ) +it.instance("falls back to generic username when system user info is unavailable", () => + Effect.gen(function* () { + const userInfo = spyOn(os, "userInfo").mockImplementation(() => { + throw Object.assign(new Error("missing passwd entry"), { code: "ENOENT" }) + }) + try { + const config = yield* Config.use.get() + expect(config.username).toBe("user") + } finally { + userInfo.mockRestore() + } + }), +) + it.effect("creates global jsonc config with schema when no global configs exist", () => withGlobalConfig({}, ({ dir }) => Effect.gen(function* () { diff --git a/packages/stats/core/src/stat-sync.ts b/packages/stats/core/src/stat-sync.ts index a64b33493..7f72cd2ca 100644 --- a/packages/stats/core/src/stat-sync.ts +++ b/packages/stats/core/src/stat-sync.ts @@ -52,8 +52,8 @@ export const syncStats: () => Effect.Effect< discard: true, }) - yield* Effect.logInfo("stats sync complete").pipe( - Effect.annotateLogs({ + yield* Effect.logInfo( + `stats sync complete ${JSON.stringify({ startedAt: startedAt.toISOString(), periodStart: periodStart.toISOString(), periodEnd: periodEnd.toISOString(), @@ -61,7 +61,7 @@ export const syncStats: () => Effect.Effect< providerRows: providerRows.length, geoRows: geoRows.length, stage: Resource.App.stage, - }), + })}`, ) return { @@ -74,8 +74,8 @@ export const syncStats: () => Effect.Effect< }) function logRuntimeCheck() { - return Effect.logInfo("athena stats runtime check").pipe( - Effect.annotateLogs({ + return Effect.logInfo( + `athena stats runtime check ${JSON.stringify({ catalog: Resource.InferenceEvent.catalog, database: Resource.InferenceEvent.database, dataset: Resource.StatsSyncConfig.dataset, @@ -83,6 +83,6 @@ function logRuntimeCheck() { workgroup: Resource.InferenceEvent.workgroup, region: Resource.InferenceEvent.region, stage: Resource.App.stage, - }), + })}`, ) } diff --git a/packages/stats/server/src/ingest.ts b/packages/stats/server/src/ingest.ts index f2806204f..ddb766606 100644 --- a/packages/stats/server/src/ingest.ts +++ b/packages/stats/server/src/ingest.ts @@ -34,24 +34,36 @@ export class Ingest extends Context.Service()("@opencode if (events.length === 0) return { records: 0 } const records = events.map(routeEvent).filter((event): event is RoutedEvent => Boolean(event)) if (records.length !== events.length) { + yield* Effect.logWarning( + `lake ingest rejected ${JSON.stringify({ records: events.length, unsupported: events.length - records.length })}`, + ) return yield* new IngestError({ message: "Unsupported lake event type", failed: events.length - records.length, }) } + const batches = chunks( + records.map((event) => ({ Data: Buffer.from(JSON.stringify(event)) })), + MAX_FIREHOSE_BATCH_SIZE, + ) + yield* Effect.logInfo( + `lake ingest batch prepared ${JSON.stringify({ records: records.length, batches: batches.length })}`, + ) + const failed = (yield* Effect.all( - chunks( - records.map((event) => ({ Data: Buffer.from(JSON.stringify(event)) })), - MAX_FIREHOSE_BATCH_SIZE, - ).map((batch) => putRecords(client, Resource.LakeIngestConfig.streamName, batch)), + batches.map((batch) => putRecords(client, Resource.LakeIngestConfig.streamName, batch)), { concurrency: 8 }, )).reduce((sum, item) => sum + item, 0) if (failed > 0) { + yield* Effect.logWarning(`lake ingest incomplete ${JSON.stringify({ records: records.length, failed })}`) return yield* new IngestError({ message: "Failed to ingest all lake records", failed }) } + yield* Effect.logInfo( + `lake ingest complete ${JSON.stringify({ records: records.length, batches: batches.length })}`, + ) return { records: records.length } }) @@ -75,7 +87,11 @@ const putRecords: ( try: () => client.send(new PutRecordBatchCommand({ DeliveryStreamName: streamName, Records: records })), catch: (cause) => new IngestError({ message: "Failed to write lake records to Firehose", failed: records.length, cause }), - }) + }).pipe( + Effect.tapError(() => + Effect.logWarning(`firehose batch write failed ${JSON.stringify({ records: records.length, attempt })}`), + ), + ) const failed = result.RequestResponses?.flatMap((item, index) => { const record = records[index] @@ -83,9 +99,20 @@ const putRecords: ( return [record] }) ?? [] + yield* Effect.logInfo( + `firehose batch written ${JSON.stringify({ records: records.length, failed: failed.length, attempt })}`, + ) if (failed.length === 0) return 0 - if (attempt >= MAX_FIREHOSE_ATTEMPTS) return failed.length + if (attempt >= MAX_FIREHOSE_ATTEMPTS) { + yield* Effect.logWarning( + `firehose batch failed ${JSON.stringify({ records: failed.length, attempts: MAX_FIREHOSE_ATTEMPTS })}`, + ) + return failed.length + } + yield* Effect.logWarning( + `firehose batch retrying ${JSON.stringify({ records: failed.length, attempt: attempt + 1 })}`, + ) yield* Effect.sleep(`${250 * 2 ** (attempt - 1)} millis`) return yield* putRecords(client, streamName, failed, attempt + 1) }) diff --git a/packages/stats/server/src/stat-sync.ts b/packages/stats/server/src/stat-sync.ts index dcbe17948..a5e841f8c 100644 --- a/packages/stats/server/src/stat-sync.ts +++ b/packages/stats/server/src/stat-sync.ts @@ -9,7 +9,7 @@ const SYNC_INTERVAL = "1 hour" const runtimeLayer = Layer.mergeAll(statsLayer, Athena.layer) const syncPass = syncStats().pipe( Effect.catchCause((cause) => - Effect.logWarning("stats sync failed").pipe(Effect.annotateLogs({ cause: Cause.pretty(cause) })), + Effect.logWarning(`stats sync failed ${JSON.stringify({ cause: Cause.pretty(cause) })}`), ), ) const daemon = Effect.logInfo("stats sync daemon started").pipe( diff --git a/packages/ui/src/v2/components/icon-button-v2.css b/packages/ui/src/v2/components/icon-button-v2.css index f5ea604e9..e75e4c7ad 100644 --- a/packages/ui/src/v2/components/icon-button-v2.css +++ b/packages/ui/src/v2/components/icon-button-v2.css @@ -5,7 +5,6 @@ } [data-component="icon-button-v2"] { - position: relative; display: inline-flex; align-items: center; justify-content: center; diff --git a/packages/ui/src/v2/components/icon.tsx b/packages/ui/src/v2/components/icon.tsx index 89190869a..09aaea2e5 100644 --- a/packages/ui/src/v2/components/icon.tsx +++ b/packages/ui/src/v2/components/icon.tsx @@ -17,6 +17,18 @@ const icons = { viewBox: "0 0 20 20", body: ``, }, + "sidebar-right": { + viewBox: "0 0 20 20", + body: ``, + }, + status: { + viewBox: "0 0 20 20", + body: ``, + }, + "status-active": { + viewBox: "0 0 20 20", + body: ``, + }, "magnifying-glass": { viewBox: "0 0 16 16", body: ``, @@ -37,6 +49,14 @@ const icons = { viewBox: "0 0 16 16", body: ``, }, + "outline-chevron-down": { + viewBox: "0 0 16 16", + body: ``, + }, + "outline-dots": { + viewBox: "0 0 16 16", + body: ``, + }, } const spriteID = "opencode-v2-icon-sprite" diff --git a/packages/ui/src/v2/styles/theme.css b/packages/ui/src/v2/styles/theme.css index 2dc795d43..91e90d678 100644 --- a/packages/ui/src/v2/styles/theme.css +++ b/packages/ui/src/v2/styles/theme.css @@ -92,6 +92,8 @@ --v2-illustration-illustration-layer-01: var(--v2-grey-300); --v2-illustration-illustration-layer-02: var(--v2-grey-400); --v2-illustration-illustration-layer-03: var(--v2-grey-500); + + --font-family-text: "Inter", sans-serif; } /* OS preference fallback (no JS needed) */ diff --git a/sst-env.d.ts b/sst-env.d.ts index 9f6a5db31..02ba732ef 100644 --- a/sst-env.d.ts +++ b/sst-env.d.ts @@ -153,7 +153,6 @@ declare module "sst" { } "STRIPE_WEBHOOK_SECRET": { "type": "sst.sst.Linkable" - "value": string } "Stat": import("@cloudflare/workers-types").Service "StatsDatabase": { @@ -345,4 +344,4 @@ declare module "sst" { } import "sst" -export {} \ No newline at end of file +export {} diff --git a/sst.config.ts b/sst.config.ts index cc04bd1a9..550f91c6f 100644 --- a/sst.config.ts +++ b/sst.config.ts @@ -2,28 +2,21 @@ export default $config({ app(input) { - // Dev owns the shared AWS lake/stats infra for all non-production stages. - const awsStage = input.stage === "production" ? "production" : "dev" - const deployAws = input.stage === awsStage return { name: "opencode", removal: input?.stage === "production" ? "retain" : "remove", protect: ["production"].includes(input?.stage), home: "cloudflare", providers: { - ...(deployAws - ? { - aws: { - version: "7.30.0", - region: "us-east-1", - profile: process.env.GITHUB_ACTIONS - ? undefined - : input.stage === "production" - ? "opencode-production" - : "opencode-dev", - }, - } - : {}), + aws: { + version: "7.30.0", + region: "us-east-1", + profile: process.env.GITHUB_ACTIONS + ? undefined + : input.stage === "production" + ? "opencode-production" + : "opencode-dev", + }, stripe: { version: "0.0.28", apiKey: process.env.STRIPE_SECRET_KEY!, @@ -51,7 +44,6 @@ export default $config({ StatWorkerUrl: stat.url, // StatsUrl: stats.app.url, AwsStage: stage.awsStage, - ...(stage.githubActionsDeployRoleArn ? { GithubActionsDeployRoleArn: stage.githubActionsDeployRoleArn } : {}), } }, })