diff --git a/packages/app/e2e/performance/AGENTS.md b/packages/app/e2e/performance/AGENTS.md new file mode 100644 index 0000000000000000000000000000000000000000..e69c91a98fc168717cfc5663f0dfdf5a01723153 --- /dev/null +++ b/packages/app/e2e/performance/AGENTS.md @@ -0,0 +1,13 @@ +- Prioritize stability, then simplicity, then measurement overhead. +- Use Playwright for scenario control, isolation, and completion checks. +- Use Chrome Performance traces for generic browser profiling. +- Use Electron `contentTracing` for packaged multi-process profiling. +- Keep custom probes only for product-specific measurements. +- Do not duplicate measurements across the harness, probes, and traces. +- Run benchmarks serially to avoid cross-test contention. +- Run benchmarks against production builds. +- Keep detailed profiling opt-in when it changes workload behavior. +- Preserve raw diagnostic data or use lossless representations. +- Do not enforce machine-dependent performance thresholds. +- Assert scenario completion and metric collection only. +- Keep normal test discovery free of manual benchmarks. diff --git a/packages/app/e2e/performance/README.md b/packages/app/e2e/performance/README.md new file mode 100644 index 0000000000000000000000000000000000000000..ce868d573bb7008fb769c199643b5ee9eeab1391 --- /dev/null +++ b/packages/app/e2e/performance/README.md @@ -0,0 +1,79 @@ +# Manual app performance suite + +The app's high-volume performance diagnostics live under `packages/app/e2e/performance` and are excluded from normal local and CI Playwright discovery. The benchmark config builds the app and serves the production bundle before running scenarios serially. + +Run the suite explicitly from `packages/app`: + +```sh +bun run test:bench +``` + +PowerShell: + +```powershell +$env:PLAYWRIGHT_WORKERS = "1" +bun run test:bench +``` + +The suite contains: + +- cold and hot session-tab timing +- home-session click timing split between content and titlebar-tab paint +- single-session tab close timing through stable home restoration +- cached session repaint and mutation tracing +- streaming timeline throughput, RAF-gap, long-task, geometry, and remount diagnostics + +All benchmarks import the shared `benchmark` fixture. Pages created through Playwright's `page` fixture automatically capture main-frame navigation history and emit a Chrome trace when `OPENCODE_PERFORMANCE_TRACE_DIR` is set. Benchmarks that need isolated browser contexts use `withBenchmarkPage`, which owns the context and the same diagnostics lifecycle. + +New benchmarks should look like normal Playwright tests: + +```ts +import { benchmark, expect } from "../benchmark" + +benchmark("measures one interaction", async ({ page, report }) => { + // Only scenario-specific setup and interaction belong here. + report({ durationMs: 42 }) +}) +``` + +The fixture requires every benchmark to call `report()`, automatically names and closes traces, captures navigation history, attaches that history when a test fails, and emits metrics as a consistent `BENCHMARK` JSON line. + +```text +BENCHMARK {"name":"...","context":{"project":"chromium","platform":"darwin"},"metrics":{...}} +``` + +Every observed page also emits `BENCHMARK_PAGE` with the same run ID, navigation history, and optional trace path before the final status-bearing `BENCHMARK` record. Chrome traces are browser-wide page-lifetime diagnostics; scenario metrics use narrower explicitly named observation windows. + +This follows the stack's own guidance: [Electron recommends repeated Chrome DevTools and Chrome Tracing measurement](https://www.electronjs.org/docs/latest/tutorial/performance), [Chrome DevTools recommends Performance recordings for runtime work](https://developer.chrome.com/docs/devtools/performance), and [Playwright uses traces for test debugging rather than renderer profiling](https://playwright.dev/docs/trace-viewer). + +These Playwright benchmarks profile the shared app renderer in Chromium. A future packaged Electron benchmark that needs main-process and multi-process attribution should use Electron's official [`contentTracing`](https://www.electronjs.org/docs/latest/api/content-tracing/) API rather than extending this renderer harness with bespoke process instrumentation. + +CPU and high-volume visual profiling are disabled by default. Set `TIMELINE_CPU_PROFILE=1` to enable both, or additionally set `TIMELINE_VISUAL_PROFILE=0` for CPU-only profiling. + +The streaming scenario's 30x CPU throttle is a deterministic stress profile, not a simulated end-user device. + +Benchmarks do not assert machine-dependent performance budgets. Streaming processes 160 deltas by default and reports renderer-observed completion time, throughput, RAF callback-gap distributions, frame-budget equivalents, and long tasks through final geometry settlement. Delta count and delivery batch are included in result context when overridden. These are main-thread callback diagnostics, not compositor presentation or dropped-frame measurements. Visual-only and geometry metrics are `null` when their probes are disabled. Tab metrics describe sampled DOM observations. Assertions verify scenario and metric collection completion. Repeated repaint states are run-length grouped, but every original observation timestamp is retained alongside raw mutation batches and layout shifts. + +Committed smoke and regression tests continue to own correctness coverage for pagination, tab paint, context resize, collapse state, and composer spacing. + +## Chrome traces + +Set `OPENCODE_PERFORMANCE_TRACE_DIR` to emit a standard Chrome DevTools trace for every benchmark page automatically: + +```sh +OPENCODE_PERFORMANCE_TRACE_DIR=/tmp/opencode-performance-traces \ +bunx playwright test --config e2e/performance/playwright.config.ts \ + timeline/session-tab-switch-benchmark.spec.ts +``` + +The emitted JSON is a standard Chrome trace and can be loaded directly into the Chrome DevTools Performance panel. `devtools-tracing` can optionally inspect it from the command line without adding package scripts or dependencies: + +Trace capture mirrors [Puppeteer's official tracing defaults and lifecycle](https://pptr.dev/api/puppeteer.tracing), using Chrome's `ReturnAsStream` transfer mode and failing when Chromium reports trace data loss. + +```sh +bunx devtools-tracing stats +``` + +INP analysis requires a trace with a supported navigation/interaction insight. Selector statistics require a trace captured with `OPENCODE_PERFORMANCE_SELECTOR_TRACE=1`. + +`e2e/performance/playwright.uncapped.config.ts` disables Chromium frame-rate limiting for explicit uncapped diagnostics. Native product benchmarks should use the default Playwright configuration. diff --git a/packages/app/e2e/performance/benchmark.ts b/packages/app/e2e/performance/benchmark.ts new file mode 100644 index 0000000000000000000000000000000000000000..b9f8ea4341170838596a1e5f1234cec51f872682 --- /dev/null +++ b/packages/app/e2e/performance/benchmark.ts @@ -0,0 +1,144 @@ +import { expect, test as base, type Browser, type Page, type TestInfo } from "@playwright/test" +import { startChromeTrace } from "./chrome-trace" + +type BenchmarkFixtures = { + report: (metrics: Record, context?: Record) => void + reportState: { payload?: { metrics: Record; context: Record } } + benchmarkResult: void +} + +export type PerformancePageDiagnostics = { + navigations: string[] + stop: () => Promise +} + +const pages = new WeakMap() + +export const benchmark = base.extend({ + reportState: async ({}, use) => use({}), + report: async ({ reportState }, use) => { + await use((metrics, context = {}) => { + if (reportState.payload) throw new Error("Benchmark reported metrics more than once") + reportState.payload = { metrics, context } + }) + }, + benchmarkResult: [ + async ({ reportState }, use, testInfo) => { + await use() + const missing = !reportState.payload + console.log( + `BENCHMARK ${JSON.stringify({ + schemaVersion: 2, + runID: process.env.OPENCODE_PERFORMANCE_RUN_ID, + name: benchmarkName(testInfo), + status: missing ? "failed" : testInfo.status, + expectedStatus: testInfo.expectedStatus, + retry: testInfo.retry, + repeatEachIndex: testInfo.repeatEachIndex, + context: { + project: testInfo.project.name, + platform: process.platform, + ...reportState.payload?.context, + }, + metrics: reportState.payload?.metrics ?? null, + error: missing ? "Benchmark did not report metrics" : undefined, + })}`, + ) + if (missing && testInfo.status === testInfo.expectedStatus) + throw new Error(`Benchmark did not report metrics: ${benchmarkName(testInfo)}`) + }, + { auto: true }, + ], + page: async ({ page }, use, testInfo) => { + const name = benchmarkName(testInfo) + const diagnostics = await observePerformancePage(page, name) + try { + await use(page) + } finally { + try { + await reportPerformancePage(name, diagnostics, testInfo) + } finally { + if (testInfo.status !== testInfo.expectedStatus) { + await testInfo.attach("performance-navigations", { + body: JSON.stringify(diagnostics.navigations, null, 2), + contentType: "application/json", + }) + } + } + } + }, +}) + +function benchmarkName(testInfo: TestInfo) { + return testInfo.titlePath.slice(1).join(" > ") +} + +export { expect } + +async function observePerformancePage(page: Page, name: string) { + const navigations: string[] = [] + const onNavigation = (frame: ReturnType) => { + if (frame === page.mainFrame()) navigations.push(frame.url()) + } + page.on("framenavigated", onNavigation) + const stopTrace = await startChromeTrace(page, name).catch((error) => { + page.off("framenavigated", onNavigation) + throw error + }) + let stopping: Promise | undefined + const diagnostics: PerformancePageDiagnostics = { + navigations, + stop() { + page.off("framenavigated", onNavigation) + return (stopping ??= stopTrace?.() ?? Promise.resolve(undefined)) + }, + } + pages.set(page, diagnostics) + return diagnostics +} + +export async function withBenchmarkPage( + browser: Browser, + name: string, + run: (page: Page) => Promise, + testInfo?: TestInfo, +) { + const context = await browser.newContext() + try { + const page = await context.newPage() + const diagnostics = await observePerformancePage(page, name) + try { + return await run(page) + } finally { + await reportPerformancePage(name, diagnostics, testInfo) + } + } finally { + await context.close() + } +} + +async function reportPerformancePage(name: string, diagnostics: PerformancePageDiagnostics, testInfo?: TestInfo) { + const trace = await diagnostics.stop() + console.log( + `BENCHMARK_PAGE ${JSON.stringify({ + schemaVersion: 2, + runID: process.env.OPENCODE_PERFORMANCE_RUN_ID, + name, + test: testInfo ? benchmarkName(testInfo) : undefined, + retry: testInfo?.retry, + repeatEachIndex: testInfo?.repeatEachIndex, + context: { + platform: process.platform, + trace, + selectorTrace: process.env.OPENCODE_PERFORMANCE_SELECTOR_TRACE === "1", + }, + navigations: diagnostics.navigations, + })}`, + ) +} + +export function benchmarkDiagnostics(page: Page) { + const diagnostics = pages.get(page) + if (!diagnostics) throw new Error("Performance diagnostics are not installed for this page") + return diagnostics +} diff --git a/packages/app/e2e/performance/chrome-trace.ts b/packages/app/e2e/performance/chrome-trace.ts new file mode 100644 index 0000000000000000000000000000000000000000..343526e254df0b86f3e951db2c8721efa28d4c8f --- /dev/null +++ b/packages/app/e2e/performance/chrome-trace.ts @@ -0,0 +1,95 @@ +import type { CDPSession, Page } from "@playwright/test" +import path from "node:path" +import { mkdir, open, rename } from "node:fs/promises" +import { Buffer } from "node:buffer" +import { createHash, randomUUID } from "node:crypto" + +const categories = [ + "-*", + "devtools.timeline", + "v8.execute", + "disabled-by-default-devtools.timeline", + "disabled-by-default-devtools.timeline.frame", + "toplevel", + "blink.console", + "blink.user_timing", + "latencyInfo", + "disabled-by-default-devtools.timeline.stack", + "disabled-by-default-v8.cpu_profiler", +] + +export async function startChromeTrace(page: Page, name: string) { + const directory = process.env.OPENCODE_PERFORMANCE_TRACE_DIR + if (!directory) return + + const selectors = process.env.OPENCODE_PERFORMANCE_SELECTOR_TRACE === "1" + const file = await prepareChromeTrace(directory, name, selectors) + const session = await page.context().newCDPSession(page) + try { + await session.send("Tracing.start", { + transferMode: "ReturnAsStream", + traceConfig: { + excludedCategories: categories + .filter((category) => category.startsWith("-")) + .map((category) => category.slice(1)), + includedCategories: [ + ...categories.filter((category) => !category.startsWith("-")), + ...(selectors + ? ["disabled-by-default-blink.debug", "disabled-by-default-devtools.timeline.invalidationTracking"] + : []), + ], + }, + }) + } catch (error) { + await Promise.allSettled([session.detach()]) + throw error + } + let stopping: Promise | undefined + + return () => + (stopping ??= (async () => { + try { + const complete = new Promise<{ stream?: string; dataLossOccurred: boolean }>((resolve) => + session.once("Tracing.tracingComplete", resolve), + ) + await session.send("Tracing.end") + const result = await complete + if (!result.stream) throw new Error(`Chrome trace stream missing: ${file}`) + const partial = `${file}.partial` + await writeProtocolStream(session, result.stream, partial) + if (result.dataLossOccurred) throw new Error(`Chrome trace lost data; partial capture retained: ${partial}`) + await rename(partial, file) + return file + } finally { + await Promise.allSettled([session.detach()]) + } + })()) +} + +export async function prepareChromeTrace( + directory: string, + name: string, + selectors: boolean, + nonce = randomUUID().slice(0, 8), +) { + await mkdir(directory, { recursive: true }) + const run = process.env.OPENCODE_PERFORMANCE_RUN_ID ?? "manual" + const hash = createHash("sha256").update(name).digest("hex").slice(0, 8) + return path.join( + directory, + `${run}-${name.replace(/[^a-zA-Z0-9_-]/g, "-")}-${hash}-${nonce}${selectors ? "-selectors" : ""}.json`, + ) +} + +async function writeProtocolStream(session: CDPSession, handle: string, file: string) { + const output = await open(file, "wx") + try { + while (true) { + const chunk = await session.send("IO.read", { handle }) + await output.write(chunk.base64Encoded ? Buffer.from(chunk.data, "base64") : chunk.data) + if (chunk.eof) break + } + } finally { + await Promise.allSettled([output.close(), session.send("IO.close", { handle })]) + } +} diff --git a/packages/app/e2e/performance/playwright.config.ts b/packages/app/e2e/performance/playwright.config.ts new file mode 100644 index 0000000000000000000000000000000000000000..d4793daee58d3071475266abf2b22549a8e14324 --- /dev/null +++ b/packages/app/e2e/performance/playwright.config.ts @@ -0,0 +1,20 @@ +import config from "../../playwright.config" + +const port = Number(process.env.PLAYWRIGHT_PORT ?? 3000) +process.env.PLAYWRIGHT_SERVER_PORT = String(port) +process.env.OPENCODE_PERFORMANCE_RUN_ID ??= `${new Date().toISOString().replace(/[:.]/g, "-")}-${process.pid}` + +export default { + ...config, + testDir: ".", + testIgnore: "unit/**", + outputDir: "../test-results/performance", + fullyParallel: false, + workers: 1, + reporter: [["html", { outputFolder: "../playwright-report/performance", open: "never" }], ["line"]], + webServer: { + ...config.webServer, + command: `bun run build && bun run serve -- --host 0.0.0.0 --port ${port} --strictPort`, + reuseExistingServer: false, + }, +} diff --git a/packages/app/e2e/performance/playwright.uncapped.config.ts b/packages/app/e2e/performance/playwright.uncapped.config.ts new file mode 100644 index 0000000000000000000000000000000000000000..9097c11f1d075a595bdc4b7a60f072fe8642ba48 --- /dev/null +++ b/packages/app/e2e/performance/playwright.uncapped.config.ts @@ -0,0 +1,13 @@ +import config from "./playwright.config" + +export default { + ...config, + outputDir: "../test-results/performance-uncapped", + reporter: [["html", { outputFolder: "../playwright-report/performance-uncapped", open: "never" }], ["line"]], + use: { + ...config.use, + launchOptions: { + args: ["--disable-frame-rate-limit", "--disable-gpu-vsync"], + }, + }, +} diff --git a/packages/app/e2e/performance/unit/session-timeline-stream-probe.test.ts b/packages/app/e2e/performance/unit/session-timeline-stream-probe.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..f8fca1adb733976b89b5f3f653cba3eafe219607 --- /dev/null +++ b/packages/app/e2e/performance/unit/session-timeline-stream-probe.test.ts @@ -0,0 +1,14 @@ +import { expect, test } from "bun:test" +import { streamChunk } from "../timeline/session-timeline-benchmark.fixture" +import { streamProgress } from "../timeline/session-timeline-stream-probe" + +test("classifies emitted stream markers using the fixture cycle", () => { + expect(streamProgress("before stream-17 after stream-18")).toEqual({ index: 18, phase: "boundary" }) + expect(streamProgress("before stream-18 after stream-19")).toEqual({ index: 19, phase: "stream" }) + expect(streamProgress("benchmark-complete stream-36")).toEqual({ index: 36, phase: "complete" }) + expect(streamProgress("no marker")).toEqual({ index: -1, phase: "unknown" }) +}) + +test("emits progress markers at fixture boundaries", () => { + expect(streamProgress(streamChunk(18, 160))).toEqual({ index: 18, phase: "boundary" }) +}) diff --git a/packages/app/e2e/performance/unit/session-timeline-visual-tracking.test.ts b/packages/app/e2e/performance/unit/session-timeline-visual-tracking.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..c0215c5cb6ca793f9c26497e0c30a67c08be5977 --- /dev/null +++ b/packages/app/e2e/performance/unit/session-timeline-visual-tracking.test.ts @@ -0,0 +1,16 @@ +import { expect, test } from "bun:test" +import { layoutShiftValue, removeVisibleRow } from "../timeline/session-timeline-stream-probe" + +test("excludes layout shifts before the probe window and recent input", () => { + expect(layoutShiftValue({ startTime: 9, value: 0.1 }, 10)).toBeUndefined() + expect(layoutShiftValue({ startTime: 10, value: 0.2, hadRecentInput: true }, 10)).toBeUndefined() + expect(layoutShiftValue({ startTime: 11, value: 0.3 }, 10)).toBe(0.3) +}) + +test("classifies removed rows from their last painted visibility", () => { + const row = {} + const visible = new Set([row]) + + expect(removeVisibleRow(visible, row)).toBe(true) + expect(removeVisibleRow(visible, row)).toBe(false) +}) diff --git a/packages/app/e2e/regression/cross-server-tab-close.spec.ts b/packages/app/e2e/regression/cross-server-tab-close.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..f09a2c7b63aec36233e5e58a4d105bd4d0da63e7 --- /dev/null +++ b/packages/app/e2e/regression/cross-server-tab-close.spec.ts @@ -0,0 +1,153 @@ +import { expect, test, type Page, type Route } from "@playwright/test" +import { base64Encode } from "@opencode-ai/core/util/encode" +import { currentSession } from "../utils/mock-server" + +const serverA = "http://127.0.0.1:4096" +const serverB = "http://127.0.0.1:4097" +const sessionA = session("ses_server_a", "C:/server-a", "Server A session") +const sessionB = session("ses_server_b", "/home/server-b", "Server B session") + +test("closing the active server's last tab opens the remaining server tab", async ({ page }) => { + const requests: string[] = [] + await mockServers(page, requests) + await page.addInitScript( + ({ serverB, sessionA, sessionB }) => { + localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } })) + localStorage.setItem("opencode.global.dat:server", JSON.stringify({ list: [serverB] })) + localStorage.setItem( + "opencode.window.browser.dat:tabs", + JSON.stringify([ + { type: "session", server: "http://127.0.0.1:4096", sessionId: sessionA }, + { type: "session", server: serverB, sessionId: sessionB }, + ]), + ) + }, + { serverB, sessionA: sessionA.id, sessionB: sessionB.id }, + ) + + const hrefA = `/server/${base64Encode(serverA)}/session/${sessionA.id}` + const hrefB = `/server/${base64Encode(serverB)}/session/${sessionB.id}` + await page.goto(hrefA) + await expect(page.getByText(sessionA.title).first()).toBeVisible() + + const tabA = page.locator(`[data-titlebar-tab-slot]:has(a[href="${hrefA}"])`) + await tabA.locator('[data-slot="tab-close"] button').click() + + await expect(page).toHaveURL(new RegExp(`${hrefB.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`)) + await expect.poll(() => requests.some((url) => url.startsWith(`${serverB}/api/session/${sessionB.id}`))).toBe(true) + await expect(page.getByText(sessionB.title).first()).toBeVisible() + const sessionBRequests = requests.filter((url) => url.includes(`/session/${sessionB.id}`)) + expect(sessionBRequests.every((url) => url.startsWith(serverB))).toBe(true) + expect( + requests.some((request) => { + const url = new URL(request) + return url.origin === serverB && url.searchParams.get("directory") === sessionB.directory + }), + ).toBe(true) +}) + +test("legacy session routes preserve an existing tab's server", async ({ page }) => { + await mockServers(page, []) + await page.addInitScript( + ({ serverB, sessionB }) => { + localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } })) + localStorage.setItem("opencode.global.dat:server", JSON.stringify({ list: [serverB] })) + localStorage.setItem( + "opencode.window.browser.dat:tabs", + JSON.stringify([{ type: "session", server: serverB, sessionId: sessionB }]), + ) + }, + { serverB, sessionB: sessionB.id }, + ) + + const hrefB = `/server/${base64Encode(serverB)}/session/${sessionB.id}` + await page.goto(`/${base64Encode(sessionB.directory)}/session/${sessionB.id}`) + await expect(page).toHaveURL(new RegExp(`${hrefB.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`)) +}) + +function session(id: string, directory: string, title: string) { + return { + id, + slug: id, + projectID: `project-${id}`, + directory, + title, + version: "dev", + time: { created: 1, updated: 1 }, + } +} + +async function mockServers(page: Page, requests: string[]) { + await page.route("**/*", async (route) => { + const url = new URL(route.request().url()) + if (url.origin !== serverA && url.origin !== serverB) return route.fallback() + requests.push(url.toString()) + const current = url.origin === serverA ? sessionA : sessionB + const directory = url.searchParams.get("directory") + if (directory && directory !== current.directory) return json(route, { name: "InvalidDirectory" }, 500) + if (url.pathname === "/global/event" || url.pathname === "/event" || url.pathname === "/api/event") + return sse(route) + if (url.pathname === "/global/health") return json(route, {}, 404) + if (url.pathname === "/api/health") return json(route, { pid: 1 }) + if (url.pathname === "/api/session") return json(route, { data: [currentSession(current)], cursor: {} }) + if (url.pathname === "/api/session/active") return json(route, { data: {} }) + if (url.pathname === `/api/session/${current.id}`) return json(route, { data: currentSession(current) }) + if (url.pathname === `/api/session/${current.id}/message`) return json(route, { data: [], cursor: {} }) + if (url.pathname === `/session/${current.id}`) return json(route, current) + if (/^\/session\/[^/]+$/.test(url.pathname)) return json(route, { name: "NotFoundError" }, 404) + if (url.pathname === `/session/${current.id}/message`) return json(route, []) + if (/^\/session\/[^/]+\/(children|todo|diff)$/.test(url.pathname)) return json(route, []) + if (["/skill", "/command", "/lsp", "/formatter", "/permission", "/question", "/vcs/diff"].includes(url.pathname)) + return json(route, []) + if (["/global/config", "/config", "/provider/auth", "/mcp"].includes(url.pathname)) return json(route, {}) + if (url.pathname === "/provider") + return json(route, { all: [], connected: [], default: { providerID: "", modelID: "" } }) + if (url.pathname === "/agent") return json(route, [{ name: "build", mode: "primary" }]) + if (url.pathname === "/project" || url.pathname === "/project/current") { + const project = { + id: current.projectID, + worktree: current.directory, + vcs: "git", + time: { created: 1, updated: 1 }, + sandboxes: [], + } + return json(route, url.pathname === "/project" ? [project] : project) + } + if (url.pathname === "/path") + return json(route, { + state: current.directory, + config: current.directory, + worktree: current.directory, + directory: current.directory, + home: current.directory, + }) + if (url.pathname === "/api/path") + return json(route, { + state: current.directory, + config: current.directory, + worktree: current.directory, + directory: current.directory, + home: current.directory, + }) + if (url.pathname === "/vcs") return json(route, { branch: "main", default_branch: "main" }) + if (url.pathname === "/api/vcs") + return json(route, { + location: { directory: current.directory }, + data: { branch: "main", defaultBranch: "main" }, + }) + return json(route, {}) + }) +} + +function json(route: Route, body: unknown, status = 200) { + return route.fulfill({ + status, + contentType: "application/json", + headers: { "access-control-allow-origin": "*" }, + body: JSON.stringify(body), + }) +} + +function sse(route: Route) { + return route.fulfill({ status: 200, contentType: "text/event-stream", body: ": ok\n\n" }) +} diff --git a/packages/app/e2e/regression/file-browser-sidebar-tab-switch.spec.ts b/packages/app/e2e/regression/file-browser-sidebar-tab-switch.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..29eb680c2913c5b873622ae5885001afea658504 --- /dev/null +++ b/packages/app/e2e/regression/file-browser-sidebar-tab-switch.spec.ts @@ -0,0 +1,187 @@ +import { base64Encode } from "@opencode-ai/core/util/encode" +import { expect, test, type Page } from "@playwright/test" +import { mockOpenCodeServer } from "../utils/mock-server" +import { expectSessionTitle } from "../utils/waits" + +const directory = "C:/OpenCode/FileBrowserSidebar" +const projectID = "proj_file_browser_sidebar" +const sessionID = "ses_file_browser_sidebar" +const title = "File browser sidebar" +const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}` +const files = Array.from({ length: 80 }, (_, index) => `file-${String(index).padStart(2, "0")}.ts`) +// Marks the file-browser sidebar DOM node so a remount (fresh node) is detectable. +const PROBE = "original" + +test.use({ viewport: { width: 1440, height: 900 } }) + +// The file-browser sidebar must stay mounted across preview/pinned file-tab +// switches. Remounting resets scroll and filter state. +test("keeps the file-browser sidebar mounted when switching file tabs", async ({ page }) => { + await setup(page) + + await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`) + await expectSessionTitle(page, title) + + const panel = page.locator("#review-panel") + await panel.getByRole("button", { name: "Open file" }).click() + await expect(panel.getByRole("tab", { name: "Open file" })).toHaveAttribute("data-selected", "") + + const sidebar = panel.locator('[data-component="session-review-v2-sidebar-root"]') + await expect(sidebar).toBeVisible() + await expect(panel.getByRole("button", { name: "file-00.ts" })).toBeVisible() + + await panel.getByRole("button", { name: "file-00.ts" }).click() + await expect(panel.getByRole("tab", { name: "file-00.ts" })).toHaveAttribute("data-selected", "") + await expect(panel.getByText("contents:file-00.ts", { exact: true })).toBeVisible() + + const viewport = panel.locator('[data-slot="session-review-v2-sidebar-tree"] .scroll-view__viewport') + await viewport.hover() + await page.mouse.wheel(0, 100_000) + await expect + .poll(() => viewport.evaluate((element) => element.scrollHeight - element.clientHeight - element.scrollTop)) + .toBeLessThanOrEqual(1) + const scrolled = await viewport.evaluate((element) => element.scrollTop) + expect(scrolled).toBeGreaterThan(0) + await writeProbe(page) + + await panel.getByRole("button", { name: "file-79.ts" }).click() + await expect(panel.getByRole("tab", { name: "file-79.ts" })).toHaveAttribute("data-selected", "") + await expect(panel.getByText("contents:file-79.ts", { exact: true })).toBeVisible() + expect(await readProbe(page)).toBe(PROBE) + await expect.poll(() => viewport.evaluate((element) => element.scrollTop)).toBe(scrolled) + + await panel.getByRole("button", { name: "file-78.ts" }).dblclick() + await expect(panel.getByRole("tab", { name: "file-78.ts" })).toHaveAttribute("data-selected", "") + await panel.getByRole("button", { name: "file-79.ts" }).click() + await expect(panel.getByRole("tab", { name: "file-79.ts" })).toHaveAttribute("data-selected", "") + await panel.getByRole("tab", { name: "file-78.ts" }).click() + await expect(panel.getByRole("tab", { name: "file-78.ts" })).toHaveAttribute("data-selected", "") + expect(await readProbe(page)).toBe(PROBE) + await expect.poll(() => viewport.evaluate((element) => element.scrollTop)).toBe(scrolled) +}) + +test("keeps previous file search results visible while the next search loads", async ({ page }) => { + const searchPending = Promise.withResolvers() + await setup(page, async ({ query }) => { + if (query === "file-0") return ["file-00.ts"] + if (query === "file-7") { + await searchPending.promise + return ["file-79.ts"] + } + return [] + }) + + await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`) + await expectSessionTitle(page, title) + + const panel = page.locator("#review-panel") + await panel.getByRole("button", { name: "Open file" }).click() + const filter = panel.getByRole("combobox", { name: "Filter files" }) + await filter.fill("file-0") + await expect(panel.getByRole("option", { name: "file-00.ts" })).toBeVisible() + + const nextSearch = page.waitForRequest((request) => { + const url = new URL(request.url()) + return url.pathname === "/find/file" && url.searchParams.get("query") === "file-7" + }) + await filter.fill("file-7") + await nextSearch + await expect(panel.getByRole("option", { name: "file-00.ts" })).toBeVisible() + + searchPending.resolve() + await expect(panel.getByRole("option", { name: "file-79.ts" })).toBeVisible() + await expect(panel.getByRole("option", { name: "file-00.ts" })).toBeHidden() +}) + +type Probed = HTMLElement & { __e2eProbe?: string } + +async function writeProbe(page: Page) { + await page.locator('#review-panel [data-component="session-review-v2-sidebar-root"]').evaluate((el, probe) => { + ;(el as Probed).__e2eProbe = probe + }, PROBE) +} + +async function readProbe(page: Page) { + return page + .locator('#review-panel [data-component="session-review-v2-sidebar-root"]') + .evaluate((el) => (el as Probed).__e2eProbe) +} + +async function setup( + page: Page, + findFiles?: (input: { query: string; dirs?: string; limit?: number }) => unknown | Promise, +) { + await mockOpenCodeServer(page, { + directory, + project: { + id: projectID, + worktree: directory, + vcs: "git", + name: "file-browser-sidebar", + time: { created: 1700000000000, updated: 1700000000000 }, + sandboxes: [], + }, + provider: { + all: [ + { + id: "opencode", + name: "OpenCode", + models: { test: { id: "test", name: "Test", limit: { context: 200_000 } } }, + }, + ], + connected: ["opencode"], + default: { providerID: "opencode", modelID: "test" }, + }, + sessions: [ + { + id: sessionID, + slug: sessionID, + projectID, + directory, + title, + version: "dev", + time: { created: 1700000000000, updated: 1700000000000 }, + }, + ], + vcsDiff: [], + fileList: (path) => { + if (path) return [] + return files.map((name) => ({ + name, + path: name, + absolute: `${directory}/${name}`, + type: "file" as const, + ignored: false, + })) + }, + fileContent: (path) => ({ type: "text", content: `contents:${path}` }), + findFiles, + pageMessages: () => ({ items: [] }), + }) + + await page.addInitScript( + ({ directory, server, sessionID }) => { + localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } })) + localStorage.setItem( + "opencode.global.dat:server", + JSON.stringify({ + projects: { local: [{ worktree: directory, expanded: true }] }, + lastProject: { local: directory }, + }), + ) + localStorage.setItem( + "opencode.global.dat:layout", + JSON.stringify({ review: { diffStyle: "split", panelOpened: true } }), + ) + localStorage.setItem( + "opencode.global.dat:review-panel-v2", + JSON.stringify({ sidebarOpened: true, sidebarWidth: 240, expandMode: "collapse" }), + ) + localStorage.setItem( + "opencode.window.browser.dat:tabs", + JSON.stringify([{ type: "session", server, sessionId: sessionID }]), + ) + }, + { directory, server, sessionID }, + ) +} diff --git a/packages/app/e2e/regression/new-session-panel-corner.spec.ts b/packages/app/e2e/regression/new-session-panel-corner.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..a17b8c08170993b88422f004499f7b85e69cf3f8 --- /dev/null +++ b/packages/app/e2e/regression/new-session-panel-corner.spec.ts @@ -0,0 +1,83 @@ +import { expect, test } from "@playwright/test" +import { mockOpenCodeServer } from "../utils/mock-server" +import { expectAppVisible } from "../utils/waits" + +const draftID = "draft_new_session_panel_corner" +const directory = "C:/OpenCode/NewSessionPanelCorner" +const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}` + +test.use({ + viewport: { width: 935, height: 522 }, + deviceScaleFactor: 1, +}) + +test("matches the rounded panel corners to the dark new-session background", async ({ page }, testInfo) => { + await mockOpenCodeServer(page, { + directory, + project: { + id: "proj_new_session_panel_corner", + worktree: directory, + vcs: "git", + name: "new-session-panel-corner", + time: { created: 1700000000000, updated: 1700000000000 }, + sandboxes: [], + }, + provider: { all: [], connected: [], default: {} }, + sessions: [], + pageMessages: () => ({ items: [] }), + }) + await page.addInitScript( + ({ directory, draftID, server }) => { + localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } })) + localStorage.setItem("opencode-theme-id", "oc-2") + localStorage.setItem("opencode-color-scheme", "dark") + localStorage.setItem( + "opencode.global.dat:server", + JSON.stringify({ + projects: { local: [{ worktree: directory, expanded: true }] }, + lastProject: { local: directory }, + }), + ) + localStorage.setItem( + "opencode.window.browser.dat:tabs", + JSON.stringify([{ type: "draft", draftID, server, directory }]), + ) + }, + { directory, draftID, server }, + ) + + await page.goto(`/new-session?draftId=${draftID}`) + await expectAppVisible(page.locator('[data-component="prompt-input"]')) + await expect(page.locator("html")).toHaveAttribute("data-color-scheme", "dark") + const panel = page.locator('main div[class*="rounded-[10px]"][class*="overflow-hidden"]') + await expect(panel).toHaveCount(1) + const box = await panel.boundingBox() + if (!box) throw new Error("New-session panel bounds are unavailable") + + const screenshot = await page.screenshot({ path: testInfo.outputPath("new-session-dark.png") }) + const corners = await page.evaluate( + async ({ source, points }) => { + const image = new Image() + image.src = source + await image.decode() + const canvas = document.createElement("canvas") + canvas.width = image.naturalWidth + canvas.height = image.naturalHeight + const context = canvas.getContext("2d") + if (!context) throw new Error("2D canvas is unavailable") + context.drawImage(image, 0, 0) + return points.map((point) => Array.from(context.getImageData(point.x, point.y, 1, 1).data)) + }, + { + source: `data:image/png;base64,${screenshot.toString("base64")}`, + points: [ + { x: Math.floor(box.x), y: Math.floor(box.y) }, + { x: Math.ceil(box.x + box.width) - 1, y: Math.floor(box.y) }, + { x: Math.floor(box.x), y: Math.ceil(box.y + box.height) - 1 }, + { x: Math.ceil(box.x + box.width) - 1, y: Math.ceil(box.y + box.height) - 1 }, + ], + }, + ) + + expect(corners.every(([red, green, blue, alpha]) => red <= 8 && green <= 8 && blue <= 8 && alpha === 255)).toBe(true) +}) diff --git a/packages/app/e2e/regression/open-file-expand-folder.spec.ts b/packages/app/e2e/regression/open-file-expand-folder.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..37739d2fbbc8245e5103021eb5a8ed26fbb34241 --- /dev/null +++ b/packages/app/e2e/regression/open-file-expand-folder.spec.ts @@ -0,0 +1,132 @@ +import { base64Encode } from "@opencode-ai/core/util/encode" +import { expect, test } from "@playwright/test" +import { mockOpenCodeServer } from "../utils/mock-server" +import { expectSessionTitle } from "../utils/waits" + +const directory = "C:/OpenCode/OpenFileExpand" +const projectID = "proj_open_file_expand" +const sessionID = "ses_open_file_expand" +const title = "Open file expand" +const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}` + +test.use({ viewport: { width: 1440, height: 900 } }) + +test("expands a folder whose path has a trailing Windows separator", async ({ page }) => { + await mockOpenCodeServer(page, { + directory, + project: { + id: projectID, + worktree: directory, + vcs: "git", + name: "open-file-expand", + time: { created: 1700000000000, updated: 1700000000000 }, + sandboxes: [], + }, + provider: { + all: [ + { + id: "opencode", + name: "OpenCode", + models: { test: { id: "test", name: "Test", limit: { context: 200_000 } } }, + }, + ], + connected: ["opencode"], + default: { providerID: "opencode", modelID: "test" }, + }, + sessions: [ + { + id: sessionID, + slug: sessionID, + projectID, + directory, + title, + version: "dev", + time: { created: 1700000000000, updated: 1700000000000 }, + }, + ], + vcsDiff: [], + fileList: (path) => { + if (path === "frontend\\" || path === "frontend") { + return [ + { + name: "app.ts", + path: "frontend\\app.ts", + absolute: `${directory}/frontend/app.ts`, + type: "file" as const, + ignored: false, + }, + ] + } + if (path) return [] + return [ + { + name: "frontend", + path: "frontend\\", + absolute: `${directory}/frontend`, + type: "directory" as const, + ignored: false, + }, + { + name: "README.md", + path: "README.md", + absolute: `${directory}/README.md`, + type: "file" as const, + ignored: false, + }, + ] + }, + fileContent: (path) => ({ type: "text", content: `contents:${path}` }), + pageMessages: () => ({ items: [] }), + }) + + await page.addInitScript( + ({ directory, server, sessionID }) => { + localStorage.setItem( + "settings.v3", + JSON.stringify({ general: { newLayoutDesigns: true, shouldDisplayTabsToast: false } }), + ) + localStorage.setItem( + "opencode.global.dat:server", + JSON.stringify({ + projects: { local: [{ worktree: directory, expanded: true }] }, + lastProject: { local: directory }, + }), + ) + localStorage.setItem( + "opencode.global.dat:layout", + JSON.stringify({ review: { diffStyle: "split", panelOpened: true } }), + ) + localStorage.setItem( + "opencode.global.dat:review-panel-v2", + JSON.stringify({ sidebarOpened: true, sidebarWidth: 240, expandMode: "collapse" }), + ) + localStorage.setItem( + "opencode.window.browser.dat:tabs", + JSON.stringify([{ type: "session", server, sessionId: sessionID }]), + ) + }, + { directory, server, sessionID }, + ) + + await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`) + await expectSessionTitle(page, title) + + const panel = page.locator("#review-panel") + await panel.getByRole("button", { name: "Open file" }).click() + await expect(panel.getByRole("tab", { name: "Open file" })).toHaveAttribute("data-selected", "") + + const sidebar = panel.locator('[data-component="session-review-v2-sidebar-root"]') + await expect(sidebar).toBeVisible() + + const frontendRow = panel.locator('[data-slot="file-tree-v2-row"][data-path="frontend"]') + await expect(frontendRow).toBeVisible() + await expect(frontendRow).toHaveAttribute("aria-expanded", "false") + await frontendRow.click() + await expect(frontendRow).toHaveAttribute("aria-expanded", "true") + + const appRow = panel.locator('[data-slot="file-tree-v2-row"][data-path="frontend/app.ts"]') + await expect(appRow).toBeVisible() + await appRow.click() + await expect(panel.getByRole("tab", { name: "app.ts" })).toHaveAttribute("data-selected", "") + await expect(panel.getByText("contents:frontend/app.ts", { exact: true })).toBeVisible() +}) diff --git a/packages/app/e2e/regression/project-picker-recent-search.spec.ts b/packages/app/e2e/regression/project-picker-recent-search.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..2cdb0b4a03b44d0056cceef2785c3e7bf063c984 --- /dev/null +++ b/packages/app/e2e/regression/project-picker-recent-search.spec.ts @@ -0,0 +1,60 @@ +import { expect, test } from "@playwright/test" +import type { Page } from "@playwright/test" +import { fixture, pageMessages } from "../smoke/session-timeline.fixture" +import { mockOpenCodeServer } from "../utils/mock-server" +import { expectAppVisible } from "../utils/waits" + +const NAMES = ["alpha-service", "bravo-web", "charlie-api", "delta-tools", "echo-infra", "foxtrot-docs"] +const worktrees = NAMES.map((name) => `/opencode-demo/${name}`) + +// The sixth project sits outside the five-item recent cap, so it is only reachable if the +// dialog hands every recent project to the list filter instead of a pre-truncated slice. +const OUTSIDE_CAP = "foxtrot-docs" + +// Dialog rows carry data-directory-path; the sidebar project list does not, so this +// scopes assertions to the picker instead of matching the sidebar entry of the same name. +const rows = (page: Page) => page.locator("[data-directory-path]") +const row = (page: Page, name: string) => page.locator(`[data-directory-path*="${name}"]`) + +async function openProjectDialog(page: Page) { + await mockOpenCodeServer(page, { + sessions: fixture.sessions, + provider: fixture.provider, + directory: fixture.directory, + project: fixture.project, + pageMessages, + fileList: () => [], + findFiles: () => [], + }) + await page.addInitScript((dirs) => { + localStorage.setItem( + "opencode.global.dat:server", + JSON.stringify({ + projects: { local: dirs.map((worktree: string) => ({ worktree, expanded: false })) }, + lastProject: {}, + }), + ) + }, worktrees) + await page.goto("/") + const add = page.getByRole("button", { name: "Add project" }).first() + await expectAppVisible(add) + await add.click() + await expect(rows(page)).toHaveCount(5) + return page.getByRole("textbox").last() +} + +test("searches every recent project, not just the five most recent", async ({ page }) => { + const search = await openProjectDialog(page) + await expect(row(page, OUTSIDE_CAP)).toHaveCount(0) + + await search.fill("foxtrot") + + await expect(row(page, OUTSIDE_CAP)).toHaveCount(1) +}) + +test("still caps the idle recent list at five projects", async ({ page }) => { + await openProjectDialog(page) + + await expect(row(page, NAMES[4])).toHaveCount(1) + await expect(row(page, OUTSIDE_CAP)).toHaveCount(0) +}) diff --git a/packages/app/e2e/regression/prompt-input-v2-command-draft.spec.ts b/packages/app/e2e/regression/prompt-input-v2-command-draft.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..50dcc8820b456380cdd62e19b1ae56c2efe01849 --- /dev/null +++ b/packages/app/e2e/regression/prompt-input-v2-command-draft.spec.ts @@ -0,0 +1,50 @@ +import { expect, test } from "@playwright/test" +import { base64Encode } from "@opencode-ai/core/util/encode" +import { mockOpenCodeServer } from "../utils/mock-server" +import { expectAppVisible } from "../utils/waits" + +const directory = "C:/OpenCode/PromptInputV2Editing" +const projectID = "proj_prompt_input_v2_editing" +const sessionID = "ses_prompt_input_v2_editing" + +test("preserves the draft when a populated command menu triggers a built-in", async ({ page }) => { + await mockOpenCodeServer(page, { + directory, + project: { + id: projectID, + worktree: directory, + vcs: "git", + name: "prompt-input-v2-editing", + time: { created: 1700000000000, updated: 1700000000000 }, + sandboxes: [], + }, + provider: { all: [], connected: [], default: {} }, + sessions: [ + { + id: sessionID, + slug: "prompt-input-v2-editing", + projectID, + directory, + title: "Prompt input V2 editing", + version: "dev", + time: { created: 1700000000000, updated: 1700000000000 }, + }, + ], + pageMessages: () => ({ items: [] }), + }) + await page.addInitScript(() => { + localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } })) + }) + + await page.goto(`/${base64Encode(directory)}/session/${sessionID}`) + const composer = page.locator('[data-component="prompt-input-v2"]') + const input = composer.locator('[data-component="prompt-input"]') + await expectAppVisible(composer) + + await input.fill("keep me") + await composer.getByRole("button", { name: "Add images and files" }).click() + await page.getByRole("menuitem", { name: "Commands" }).click() + await page.locator('[data-suggestion-id="model.choose"]').click() + + await expect(input).toHaveText("keep me") +}) diff --git a/packages/app/e2e/regression/prompt-thinking-level.spec.ts b/packages/app/e2e/regression/prompt-thinking-level.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..9315c347c0369409b19d1553f47ec4a6337f9a9c --- /dev/null +++ b/packages/app/e2e/regression/prompt-thinking-level.spec.ts @@ -0,0 +1,84 @@ +import { expect, test, type Page } from "@playwright/test" +import { base64Encode } from "@opencode-ai/core/util/encode" +import { mockOpenCodeServer } from "../utils/mock-server" +import { expectAppVisible } from "../utils/waits" + +const directory = "C:/OpenCode/PromptThinkingLevelRegression" +const projectID = "proj_prompt_thinking_level_regression" +const sessionID = "ses_prompt_thinking_level_regression" + +test("shows the V2 thinking level control while relevant", async ({ page }) => { + await mockOpenCodeServer(page, { + directory, + project: { + id: projectID, + worktree: directory, + vcs: "git", + name: "prompt-thinking-level-regression", + time: { created: 1700000000000, updated: 1700000000000 }, + sandboxes: [], + }, + provider: { + all: [ + { + id: "opencode", + name: "OpenCode", + models: { + "thinking-model": { + id: "thinking-model", + name: "Thinking Model", + limit: { context: 200_000 }, + variants: { high: {} }, + }, + }, + }, + ], + connected: ["opencode"], + default: { providerID: "opencode", modelID: "thinking-model" }, + }, + sessions: [ + { + id: sessionID, + slug: "prompt-thinking-level-regression", + projectID, + directory, + title: "Prompt thinking level regression", + version: "dev", + time: { created: 1700000000000, updated: 1700000000000 }, + }, + ], + pageMessages: () => ({ items: [] }), + }) + await page.addInitScript(() => { + localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } })) + }) + + await page.goto(`/${base64Encode(directory)}/session/${sessionID}`) + const composer = page.locator('[data-component="prompt-input-v2"]') + const input = composer.locator('[data-component="prompt-input"]') + const control = composer.getByRole("button", { name: "Choose model variant" }) + await expectAppVisible(composer) + + await idleComposer(page) + await expect(control).toBeVisible() + + await control.click() + const high = page.getByRole("menuitemradio", { name: "high" }) + await expect(high).toBeVisible() + await page.mouse.move(0, 0) + await expect(control).toBeVisible() + await expect(high).toBeVisible() + await high.click() + + await idleComposer(page) + await input.focus() + await expect(control).toBeVisible() + + await idleComposer(page) + await expect(control).toBeVisible() +}) + +async function idleComposer(page: Page) { + await page.mouse.move(0, 0) + await page.evaluate(() => (document.activeElement as HTMLElement | null)?.blur()) +} diff --git a/packages/app/e2e/regression/remote-session-settings.spec.ts b/packages/app/e2e/regression/remote-session-settings.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..35a0aa44cda8f286b95760d415e0fcc0dc91ad72 --- /dev/null +++ b/packages/app/e2e/regression/remote-session-settings.spec.ts @@ -0,0 +1,302 @@ +import { base64Encode } from "@opencode-ai/core/util/encode" +import { expect, test, type Page, type Route } from "@playwright/test" +import { installSseTransport } from "../utils/sse-transport" +import { currentSession } from "../utils/mock-server" + +const serverA = "http://127.0.0.1:4096" +const serverB = "http://127.0.0.1:4097" +const directoryA = "C:/server-a" +const directoryB = "/home/server-b" +const sessionA = session("ses_server_a", directoryA, "Server A session") +const childSessionA = { ...session("ses_server_a_child", directoryA, "Server A child session"), parentID: sessionA.id } +const sessionB = session("ses_server_b", directoryB, "Server B session") + +test("session settings use the remote server context", async ({ page }) => { + const permissionRequests: string[] = [] + await mockServers(page, permissionRequests) + await configureServers(page) + + await page.goto(`/server/${base64Encode(serverB)}/session/${sessionB.id}`) + await expect(page.getByText(sessionB.title).first()).toBeVisible() + await page.keyboard.press("Control+,") + + const dialog = page.locator(".settings-v2-dialog") + const autoAccept = dialog.locator('[data-action="settings-auto-accept-permissions"]') + const input = autoAccept.getByRole("switch") + await expect(autoAccept).toBeVisible() + await expect(input).toBeEnabled() + permissionRequests.length = 0 + await autoAccept.locator('[data-slot="switch-control"]').click() + await expect(input).toBeChecked() + await expect + .poll(() => + permissionRequests.some((request) => { + const url = new URL(request) + return url.origin === serverB && url.searchParams.get("directory") === directoryB + }), + ) + .toBe(true) + expect(permissionRequests.every((request) => new URL(request).origin === serverB)).toBe(true) + + await dialog.getByRole("tab", { name: "Models" }).click() + await expect(dialog.getByRole("switch", { name: "Server B Model" })).toBeEnabled() + await expect(dialog.getByRole("switch", { name: "Server A Model" })).toHaveCount(0) +}) + +test("auto-accept responds for an unfocused server session", async ({ page }) => { + const permissionRequests: string[] = [] + const permissionResponses: PermissionResponse[] = [] + const transport = await installSseTransport<{ directory: string; payload: Record }>(page, { + server: serverA, + retry: 20, + }) + await mockServers(page, permissionRequests, permissionResponses) + await configureServers(page, [ + { type: "session", server: serverA, sessionId: sessionA.id }, + { type: "session", server: serverB, sessionId: sessionB.id }, + ]) + + const hrefB = `/server/${base64Encode(serverB)}/session/${sessionB.id}` + await page.goto(`/server/${base64Encode(serverA)}/session/${sessionA.id}`) + await expect(page.getByText(sessionA.title).first()).toBeVisible() + await page.keyboard.press("Control+,") + const autoAccept = page.locator(".settings-v2-dialog").locator('[data-action="settings-auto-accept-permissions"]') + await autoAccept.locator('[data-slot="switch-control"]').click() + await expect(autoAccept.getByRole("switch")).toBeChecked() + await expect + .poll(() => + permissionRequests.some((request) => { + const url = new URL(request) + return url.origin === serverA && url.searchParams.get("directory") === directoryA + }), + ) + .toBe(true) + await page.keyboard.press("Escape") + + await page.locator(`[data-titlebar-tab-slot]:has(a[href="${hrefB}"])`).click() + await expect(page).toHaveURL(new RegExp(`${hrefB.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`)) + await expect(page.getByText(sessionB.title).first()).toBeVisible() + await transport.waitForConnection() + + await transport.send({ + directory: directoryA, + payload: { + id: "event-permission-background-a", + type: "permission.asked", + properties: { + id: "permission-background-a", + sessionID: sessionA.id, + permission: "bash", + patterns: ["git status"], + metadata: {}, + always: [], + }, + }, + }) + + await expect + .poll(() => permissionResponses) + .toEqual([ + { + origin: serverA, + directory: directoryA, + sessionID: sessionA.id, + permissionID: "permission-background-a", + body: { response: "once" }, + }, + ]) + + await transport.send({ + directory: directoryA, + payload: { + id: "event-permission-background-a-child", + type: "permission.asked", + properties: { + id: "permission-background-a-child", + sessionID: childSessionA.id, + permission: "bash", + patterns: ["git diff"], + metadata: {}, + always: [], + }, + }, + }) + + await expect + .poll(() => permissionResponses) + .toEqual([ + { + origin: serverA, + directory: directoryA, + sessionID: sessionA.id, + permissionID: "permission-background-a", + body: { response: "once" }, + }, + { + origin: serverA, + directory: directoryA, + sessionID: childSessionA.id, + permissionID: "permission-background-a-child", + body: { response: "once" }, + }, + ]) +}) + +type PermissionResponse = { + origin: string + directory?: string + sessionID: string + permissionID: string + body: unknown +} + +async function configureServers(page: Page, tabs: { type: "session"; server: string; sessionId: string }[] = []) { + await page.addInitScript( + ({ serverB, tabs }) => { + localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } })) + localStorage.setItem("opencode.global.dat:server", JSON.stringify({ list: [serverB] })) + localStorage.setItem("opencode.window.browser.dat:tabs", JSON.stringify(tabs)) + }, + { serverB, tabs }, + ) +} + +async function mockServers(page: Page, permissionRequests: string[], permissionResponses: PermissionResponse[] = []) { + await page.route("**/*", async (route) => { + const url = new URL(route.request().url()) + if (url.origin !== serverA && url.origin !== serverB) return route.fallback() + const remote = url.origin === serverB + const directory = remote ? directoryB : directoryA + const sessions = remote ? [sessionB] : [sessionA, childSessionA] + const requestDirectory = url.searchParams.get("directory") + const response = url.pathname.match(/^\/session\/([^/]+)\/permissions\/([^/]+)$/) + if (route.request().method() === "POST" && response) { + permissionResponses.push({ + origin: url.origin, + directory: requestDirectory ?? undefined, + sessionID: response[1]!, + permissionID: response[2]!, + body: route.request().postDataJSON(), + }) + return json(route, true) + } + if (requestDirectory && requestDirectory !== directory) return json(route, { name: "InvalidDirectory" }, 500) + if (url.pathname === "/global/event" || url.pathname === "/event" || url.pathname === "/api/event") + return sse(route) + if (url.pathname === "/global/health") return json(route, { healthy: true }) + if (url.pathname === "/api/provider" || url.pathname === "/api/model" || url.pathname === "/api/agent") + return json(route, { data: [] }) + if (url.pathname === "/api/model/default") return json(route, { data: null }) + if (["/api/command", "/api/reference", "/api/permission/request", "/api/question/request"].includes(url.pathname)) + return json(route, { location: { directory }, data: [] }) + if (url.pathname === "/api/mcp") return json(route, { location: { directory }, data: [] }) + if (url.pathname === "/api/mcp/resource") + return json(route, { location: { directory }, data: { resources: [], templates: [] } }) + if (url.pathname === "/api/project") { + return json(route, [ + { + id: remote ? sessionB.projectID : "project-server-a", + worktree: directory, + vcs: "git", + time: { created: 1, updated: 1 }, + sandboxes: [], + }, + ]) + } + if (url.pathname === "/api/project/current") + return json(route, { id: remote ? sessionB.projectID : "project-server-a", directory }) + if (url.pathname === "/api/session") return json(route, { data: sessions.map(currentSession), cursor: {} }) + if (url.pathname === "/api/session/active") return json(route, { data: {} }) + const currentSessionInfo = sessions.find((session) => url.pathname === `/api/session/${session.id}`) + if (currentSessionInfo) return json(route, { data: currentSession(currentSessionInfo) }) + if (sessions.some((session) => url.pathname === `/api/session/${session.id}/message`)) + return json(route, { data: [], cursor: {} }) + const current = sessions.find((session) => url.pathname === `/session/${session.id}`) + if (current) return json(route, current) + if (/^\/session\/[^/]+$/.test(url.pathname)) return json(route, { name: "NotFoundError" }, 404) + if (/^\/session\/[^/]+\/message$/.test(url.pathname)) return json(route, []) + if (/^\/session\/[^/]+\/(children|todo|diff)$/.test(url.pathname)) return json(route, []) + if (url.pathname === "/permission") { + permissionRequests.push(url.toString()) + return json(route, []) + } + if (["/skill", "/command", "/lsp", "/formatter", "/question", "/vcs/diff", "/pty/shells"].includes(url.pathname)) + return json(route, []) + if (["/global/config", "/config", "/provider/auth", "/mcp"].includes(url.pathname)) return json(route, {}) + if (url.pathname === "/provider") return json(route, provider(remote ? "server-b" : "server-a")) + if (url.pathname === "/agent") return json(route, [{ name: "build", mode: "primary" }]) + if (url.pathname === "/project" || url.pathname === "/project/current") { + const project = { + id: remote ? sessionB.projectID : "project-server-a", + worktree: directory, + vcs: "git", + time: { created: 1, updated: 1 }, + sandboxes: [], + } + return json(route, url.pathname === "/project" ? [project] : project) + } + if (url.pathname === "/path") + return json(route, { + state: directory, + config: directory, + worktree: directory, + directory, + home: directory, + }) + if (url.pathname === "/api/path") + return json(route, { state: directory, config: directory, worktree: directory, directory, home: directory }) + if (url.pathname === "/vcs") return json(route, { branch: "main", default_branch: "main" }) + if (url.pathname === "/api/vcs") + return json(route, { location: { directory }, data: { branch: "main", defaultBranch: "main" } }) + if (url.pathname === "/api/pty/shells") return json(route, { location: { directory }, data: [] }) + return json(route, {}) + }) +} + +function session(id: string, directory: string, title: string) { + return { + id, + slug: id, + projectID: `project-${id}`, + directory, + title, + version: "dev", + time: { created: 1, updated: 1 }, + } +} + +function provider(id: string) { + const name = id === "server-b" ? "Server B" : "Server A" + return { + all: [ + { + id, + name: `${name} Provider`, + models: { + [id]: { + id, + name: `${name} Model`, + family: id, + release_date: "2026-01-01", + limit: { context: 200_000 }, + }, + }, + }, + ], + connected: [id], + default: { providerID: id, modelID: id }, + } +} + +function json(route: Route, body: unknown, status = 200) { + return route.fulfill({ + status, + contentType: "application/json", + headers: { "access-control-allow-origin": "*" }, + body: JSON.stringify(body), + }) +} + +function sse(route: Route) { + return route.fulfill({ status: 200, contentType: "text/event-stream", body: ": ok\n\n" }) +} diff --git a/packages/app/e2e/regression/remote-tab-busy.spec.ts b/packages/app/e2e/regression/remote-tab-busy.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..2d9b1e234971944db21ff985570a0dd2c8e698ee --- /dev/null +++ b/packages/app/e2e/regression/remote-tab-busy.spec.ts @@ -0,0 +1,131 @@ +import { expect, test, type Page, type Route } from "@playwright/test" +import { base64Encode } from "@opencode-ai/core/util/encode" +import { currentSession } from "../utils/mock-server" + +const serverA = "http://127.0.0.1:4096" +const serverB = "http://127.0.0.1:4097" +const sessionA = session("ses_server_a", "C:/server-a", "Server A session") +const sessionB = session("ses_server_b", "/home/server-b", "Server B session") + +test("tab busy indicator reflects the tab server's own session status", async ({ page }) => { + await mockServers(page) + await page.addInitScript( + ({ serverA, serverB, sessionA, sessionB }) => { + localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } })) + localStorage.setItem("opencode.global.dat:server", JSON.stringify({ list: [serverB] })) + localStorage.setItem( + "opencode.window.browser.dat:tabs", + JSON.stringify([ + { type: "session", server: serverA, sessionId: sessionA }, + { type: "session", server: serverB, sessionId: sessionB }, + ]), + ) + }, + { serverA, serverB, sessionA: sessionA.id, sessionB: sessionB.id }, + ) + + const hrefA = `/server/${base64Encode(serverA)}/session/${sessionA.id}` + const hrefB = `/server/${base64Encode(serverB)}/session/${sessionB.id}` + await page.goto(hrefA) + await expect(page.getByText(sessionA.title).first()).toBeVisible() + + // Session B is busy on server B while server A stays the active server, so the + // busy indicator must come from the tab server's status, not the active server's. + const tabB = page.locator(`[data-titlebar-tab-slot]:has(a[href="${hrefB}"])`) + await expect(tabB.locator('[data-component="session-progress-indicator-v2"]')).toBeVisible() + + const tabA = page.locator(`[data-titlebar-tab-slot]:has(a[href="${hrefA}"])`) + await expect(tabA.locator("[data-titlebar-tab-title]")).toHaveText(sessionA.title) + await expect(tabA.locator('[data-component="session-progress-indicator-v2"]')).toHaveCount(0) +}) + +function session(id: string, directory: string, title: string) { + return { + id, + slug: id, + projectID: `project-${id}`, + directory, + title, + version: "dev", + time: { created: 1, updated: 1 }, + } +} + +async function mockServers(page: Page) { + await page.route("**/*", async (route) => { + const url = new URL(route.request().url()) + if (url.origin !== serverA && url.origin !== serverB) return route.fallback() + const current = url.origin === serverA ? sessionA : sessionB + const directory = url.searchParams.get("directory") + if (directory && directory !== current.directory) return json(route, { name: "InvalidDirectory" }, 500) + if (url.pathname === "/global/event" || url.pathname === "/event" || url.pathname === "/api/event") + return sse(route, url.pathname === "/api/event") + if (url.pathname === "/global/health") return json(route, {}, 404) + if (url.pathname === "/api/health") return json(route, { pid: 1 }) + if (url.pathname === "/api/session/active") + return json(route, { data: url.origin === serverB ? { [sessionB.id]: { type: "running" } } : {} }) + if (url.pathname === "/api/session") return json(route, { data: [currentSession(current)], cursor: {} }) + if (url.pathname === `/api/session/${current.id}`) return json(route, { data: currentSession(current) }) + if (url.pathname === `/api/session/${current.id}/message`) return json(route, { data: [], cursor: {} }) + if (url.pathname === `/session/${current.id}`) return json(route, current) + if (/^\/session\/[^/]+$/.test(url.pathname)) return json(route, { name: "NotFoundError" }, 404) + if (url.pathname === `/session/${current.id}/message`) return json(route, []) + if (/^\/session\/[^/]+\/(children|todo|diff)$/.test(url.pathname)) return json(route, []) + if (["/skill", "/command", "/lsp", "/formatter", "/permission", "/question", "/vcs/diff"].includes(url.pathname)) + return json(route, []) + if (["/global/config", "/config", "/provider/auth", "/mcp"].includes(url.pathname)) return json(route, {}) + if (url.pathname === "/provider") + return json(route, { all: [], connected: [], default: { providerID: "", modelID: "" } }) + if (url.pathname === "/agent") return json(route, [{ name: "build", mode: "primary" }]) + if (url.pathname === "/project" || url.pathname === "/project/current") { + const project = { + id: current.projectID, + worktree: current.directory, + vcs: "git", + time: { created: 1, updated: 1 }, + sandboxes: [], + } + return json(route, url.pathname === "/project" ? [project] : project) + } + if (url.pathname === "/path") + return json(route, { + state: current.directory, + config: current.directory, + worktree: current.directory, + directory: current.directory, + home: current.directory, + }) + if (url.pathname === "/api/path") + return json(route, { + state: current.directory, + config: current.directory, + worktree: current.directory, + directory: current.directory, + home: current.directory, + }) + if (url.pathname === "/vcs") return json(route, { branch: "main", default_branch: "main" }) + if (url.pathname === "/api/vcs") + return json(route, { + location: { directory: current.directory }, + data: { branch: "main", defaultBranch: "main" }, + }) + return json(route, {}) + }) +} + +function json(route: Route, body: unknown, status = 200) { + return route.fulfill({ + status, + contentType: "application/json", + headers: { "access-control-allow-origin": "*" }, + body: JSON.stringify(body), + }) +} + +function sse(route: Route, current: boolean) { + return route.fulfill({ + status: 200, + contentType: "text/event-stream", + body: current ? 'data: {"id":"evt_connected","type":"server.connected","data":{}}\n\n' : ": ok\n\n", + }) +} diff --git a/packages/app/e2e/regression/review-image-flash.spec.ts b/packages/app/e2e/regression/review-image-flash.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..dd200384d49a70b0a16229d2be821e7b2610ca3a --- /dev/null +++ b/packages/app/e2e/regression/review-image-flash.spec.ts @@ -0,0 +1,202 @@ +import { expect, test, type Page } from "@playwright/test" +import { base64Encode } from "@opencode-ai/core/util/encode" +import { mockOpenCodeServer } from "../utils/mock-server" +import { expectAppVisible, expectSessionTitle } from "../utils/waits" + +const directory = "C:/OpenCode/ReviewImageFlashRegression" +const sessionID = "ses_review_image_flash_regression" +const title = "Review image flash regression" +const imageFile = "assets/preview.png" + +test("clicking an image file in the v2 review pane does not blank the panel", async ({ page }) => { + await openReview(page) + await installReviewFlashProbe(page) + + await page.getByRole("button", { name: /preview\.png/ }).click() + await waitForReviewFlashProbe(page, 400) + const trace = await collectReviewFlashProbe(page) + const bad = trace.samples.filter((sample) => sample.blank || sample.blackCenter) + + expect(trace.samples.length).toBeGreaterThan(0) + expect( + bad, + JSON.stringify({ bad: bad.slice(0, 8), first: trace.samples.slice(0, 8), last: trace.samples.slice(-4) }, null, 2), + ).toEqual([]) +}) + +async function openReview(page: Page) { + await page.setViewportSize({ width: 960, height: 900 }) + await page.addInitScript(() => { + localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } })) + }) + await mockOpenCodeServer(page, { + directory, + project: { + id: "proj_review_image_flash_regression", + worktree: directory, + vcs: "git", + name: "review-image-flash-regression", + time: { created: 1700000000000, updated: 1700000000000 }, + sandboxes: [], + }, + provider: { all: [], connected: [], default: {} }, + sessions: [ + { + id: sessionID, + slug: "review-image-flash-regression", + projectID: "proj_review_image_flash_regression", + directory, + title, + version: "dev", + time: { created: 1700000000000, updated: 1700000000000 }, + }, + ], + vcsDiff: [ + { + file: "src/example.ts", + additions: 1, + deletions: 1, + status: "modified", + patch: + "diff --git a/src/example.ts b/src/example.ts\n--- a/src/example.ts\n+++ b/src/example.ts\n@@ -1 +1 @@\n-export const value = 'before'\n+export const value = 'after'\n", + }, + { + file: imageFile, + patch: "", + additions: 1, + deletions: 0, + status: "added", + }, + ], + fileContent: async (path) => { + if (path !== imageFile) return undefined + await new Promise((resolve) => setTimeout(resolve, 250)) + return { + type: "binary", + content: "iVBORw0KGgo=", + encoding: "base64", + mimeType: "image/png", + } + }, + fileList: (path) => { + if (!path) { + return [ + { name: "assets", path: "assets", absolute: `${directory}/assets`, type: "directory", ignored: false }, + { name: "src", path: "src", absolute: `${directory}/src`, type: "directory", ignored: false }, + ] + } + if (path === "assets") { + return [ + { + name: "preview.png", + path: imageFile, + absolute: `${directory}/${imageFile}`, + type: "file", + ignored: false, + }, + ] + } + if (path === "src") { + return [ + { + name: "example.ts", + path: "src/example.ts", + absolute: `${directory}/src/example.ts`, + type: "file", + ignored: false, + }, + ] + } + return [] + }, + pageMessages: () => ({ + items: [ + { + info: { + id: "msg_review_image_flash_regression", + sessionID, + role: "user", + time: { created: 1700000000000 }, + summary: { diffs: [] }, + agent: "build", + model: { providerID: "opencode", modelID: "test" }, + }, + parts: [ + { + id: "prt_review_image_flash_regression", + sessionID, + messageID: "msg_review_image_flash_regression", + type: "text", + text: "Review this change.", + }, + ], + }, + ], + }), + }) + + await page.goto(`/${base64Encode(directory)}/session/${sessionID}`) + await expectSessionTitle(page, title) + await page.getByRole("button", { name: "Toggle review" }).click() + await expectAppVisible(page.locator('#review-panel [data-component="session-review-v2"]')) + await expectAppVisible(page.getByRole("button", { name: /preview\.png/ })) +} + +async function installReviewFlashProbe(page: Page) { + await page.evaluate(() => { + const samples: Array<{ + observedAtMs: number + blank: boolean + blackCenter: boolean + text: string + background: string + }> = [] + const startedAt = performance.now() + const sample = () => { + const panel = document.querySelector('#review-panel [data-component="session-review-v2"]') + const rect = panel?.getBoundingClientRect() + const center = rect + ? document.elementFromPoint(rect.left + rect.width / 2, rect.top + rect.height / 2) + : undefined + const background = center instanceof Element ? getComputedStyle(center).backgroundColor : "" + samples.push({ + observedAtMs: performance.now() - startedAt, + blank: !panel || panel.textContent?.trim().length === 0, + blackCenter: background === "rgb(0, 0, 0)", + text: panel?.textContent?.trim().slice(0, 80) ?? "", + background, + }) + if (performance.now() - startedAt < 500) requestAnimationFrame(sample) + } + document.addEventListener( + "click", + (event) => { + const target = event.target instanceof Element ? event.target : undefined + if (!target?.closest('[data-slot="file-tree-v2-row"]')) return + requestAnimationFrame(sample) + }, + { capture: true, once: true }, + ) + ;(window as Window & { __reviewImageFlash?: { samples: typeof samples; startedAt: number } }).__reviewImageFlash = { + samples, + startedAt, + } + }) +} + +async function waitForReviewFlashProbe(page: Page, durationMs: number) { + await page.waitForFunction((durationMs) => { + const state = (window as Window & { __reviewImageFlash?: { samples: unknown[]; startedAt: number } }) + .__reviewImageFlash + return !!state && state.samples.length > 0 && performance.now() - state.startedAt >= durationMs + }, durationMs) +} + +async function collectReviewFlashProbe(page: Page) { + return page.evaluate(() => { + return (window as Window & { __reviewImageFlash?: { samples: unknown[]; startedAt: number } }).__reviewImageFlash! + }) as Promise<{ + startedAt: number + samples: Array<{ observedAtMs: number; blank: boolean; blackCenter: boolean; text: string; background: string }> + }> +} diff --git a/packages/app/e2e/regression/review-line-comment.spec.ts b/packages/app/e2e/regression/review-line-comment.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..3e50dab65d93e6a8f95137ce774814504668f122 --- /dev/null +++ b/packages/app/e2e/regression/review-line-comment.spec.ts @@ -0,0 +1,169 @@ +import { expect, test, type Page } from "@playwright/test" +import { base64Encode } from "@opencode-ai/core/util/encode" +import { mockOpenCodeServer } from "../utils/mock-server" +import { expectAppVisible, expectSessionTitle } from "../utils/waits" + +const directory = "C:/OpenCode/ReviewLineCommentRegression" +const sessionID = "ses_review_line_comment_regression" +const title = "Review line comment regression" + +test.beforeEach(async ({ page }) => { + await openReview(page) +}) + +test("opens the comment editor when code is clicked", async ({ page }) => { + const review = page.locator('[data-component="session-review"]') + const line = review.getByText("export const value = 'after'", { exact: true }) + await expectAppVisible(line) + await line.click() + + await expect(review.getByRole("textbox")).toBeVisible() + await expect(review.locator('[data-slot="line-comment-editor-label"]')).toHaveText("Commenting on line 2") +}) + +test("opens the comment editor when a line number is clicked", async ({ page }) => { + const review = page.locator('[data-component="session-review"]') + const lineNumber = review.locator('[data-column-number="1"]').last() + await expectAppVisible(lineNumber) + await lineNumber.click() + + await expect(review.getByRole("textbox")).toBeVisible() + await expect(review.locator('[data-slot="line-comment-editor-label"]')).toHaveText("Commenting on line 1") +}) + +test("opens the comment editor for a line number range", async ({ page }) => { + const review = page.locator('[data-component="session-review"]') + const start = review.locator('[data-column-number="1"]').last() + const end = review.locator('[data-column-number="3"]').last() + await expectAppVisible(start) + await expectAppVisible(end) + + await start.dragTo(end) + + await expect(review.getByRole("textbox")).toBeVisible() + await expect(review.locator('[data-slot="line-comment-editor-label"]')).toHaveText("Commenting on lines 1-3") +}) + +test("shows a comment button when a line number is hovered", async ({ page }) => { + const review = page.locator('[data-component="session-review"]') + const lineNumber = review.locator('[data-column-number="1"]').last() + await expectAppVisible(lineNumber) + + const comment = review.getByRole("button", { name: "Comment", exact: true }) + await expect(async () => { + await lineNumber.hover() + await expect(lineNumber).toHaveAttribute("data-hovered", "") + await expect(comment).toHaveCount(1) + await expect(comment).toHaveCSS("pointer-events", "auto") + await comment.focus() + await expect(comment).toBeFocused() + }).toPass({ timeout: 10_000 }) + await comment.press("Enter") + await expect(review.getByRole("textbox")).toBeVisible() + await expect(review.locator('[data-slot="line-comment-editor-label"]')).toHaveText("Commenting on line 1") +}) + +test("stages a submitted line comment in the prompt context", async ({ page }) => { + page.on("request", (request) => { + expect.soft(request.method(), `unexpected ${request.method()} ${new URL(request.url()).pathname}`).toBe("GET") + }) + + const review = page.locator('[data-component="session-review"]') + await review.getByText("export const value = 'after'", { exact: true }).click() + const textbox = review.getByRole("textbox") + await expect(textbox).toBeVisible() + await expect(review.locator('[data-slot="line-comment-editor-label"]')).toHaveText("Commenting on line 2") + await textbox.fill("Use the existing value instead") + const submit = review.locator('[data-slot="line-comment-action"][data-variant="primary"]') + await expect(submit).toBeEnabled() + await submit.click() + + await expect(review.getByText("Use the existing value instead", { exact: true })).toBeVisible() + await page.getByRole("tab", { name: "Session" }).click() + const context = page.getByText("Use the existing value instead", { exact: true }).last() + await expect(context).toBeVisible() + await expect(context.locator("..")).toContainText("review.ts:2") +}) + +async function openReview(page: Page) { + await page.setViewportSize({ width: 700, height: 900 }) + await mockOpenCodeServer(page, { + protocol: "v2", + directory, + project: { + id: "proj_review_line_comment_regression", + worktree: directory, + vcs: "git", + name: "review-line-comment-regression", + time: { created: 1700000000000, updated: 1700000000000 }, + sandboxes: [], + }, + provider: { all: [], connected: [], default: {} }, + sessions: [ + { + id: sessionID, + slug: "review-line-comment-regression", + projectID: "proj_review_line_comment_regression", + directory, + title, + version: "dev", + time: { created: 1700000000000, updated: 1700000000000 }, + }, + ], + vcsDiff: [ + { + file: "src/review.ts", + additions: 1, + deletions: 1, + status: "modified", + patch: + "diff --git a/src/review.ts b/src/review.ts\n--- a/src/review.ts\n+++ b/src/review.ts\n@@ -1,3 +1,3 @@\n export const first = 1\n-export const value = 'before'\n+export const value = 'after'\n export const last = 3\n", + }, + ], + pageMessages: () => ({ + items: [ + { + info: { + id: "msg_review_line_comment_regression", + sessionID, + role: "user", + time: { created: 1700000000000 }, + summary: { diffs: [] }, + agent: "build", + model: { providerID: "opencode", modelID: "test" }, + }, + parts: [ + { + id: "prt_review_line_comment_regression", + sessionID, + messageID: "msg_review_line_comment_regression", + type: "text", + text: "Review this change.", + }, + ], + }, + ], + }), + }) + + await page.goto(`/${base64Encode(directory)}/session/${sessionID}`) + await expectSessionTitle(page, title) + const changes = page.getByRole("tab", { name: "Changes" }) + const diffResponse = page.waitForResponse( + (response) => + response.request().method() === "GET" && response.ok() && new URL(response.url()).pathname === "/api/vcs/diff", + ) + await changes.click() + expect((await (await diffResponse).json()).data).toHaveLength(1) + await expect(page.getByRole("tab", { selected: true })).toHaveAccessibleName(/Files Changed/) + + const review = page.locator('[data-component="session-review"]') + await expectAppVisible(review) + const file = review.locator('[data-file="src/review.ts"]') + await expectAppVisible(file) + const trigger = file.getByRole("button", { expanded: false }) + await expect(trigger).toHaveCount(1) + await trigger.click() + await expect(file.getByRole("button", { expanded: true })).toBeVisible() + await expect(file.getByText("export const value = 'after'", { exact: true })).toBeVisible() +} diff --git a/packages/app/e2e/regression/review-open-file.spec.ts b/packages/app/e2e/regression/review-open-file.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..04e6d2cced83fa3170c36fca5c29b048c425ff1b --- /dev/null +++ b/packages/app/e2e/regression/review-open-file.spec.ts @@ -0,0 +1,163 @@ +import { base64Encode } from "@opencode-ai/core/util/encode" +import { expect, test } from "@playwright/test" +import { mockOpenCodeServer } from "../utils/mock-server" +import { expectSessionTitle } from "../utils/waits" + +const directory = "C:/OpenCode/ReviewOpenFile" +const projectID = "proj_review_open_file" +const sessionID = "ses_review_open_file" +const title = "Review open file" +const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}` + +test.use({ viewport: { width: 1440, height: 900 } }) + +test("opens and searches project files inline", async ({ page }) => { + const searches: { query: string; dirs?: string; limit?: number }[] = [] + await mockOpenCodeServer(page, { + directory, + project: { + id: projectID, + worktree: directory, + vcs: "git", + name: "open-file-project", + time: { created: 1700000000000, updated: 1700000000000 }, + sandboxes: [], + }, + provider: { + all: [ + { + id: "opencode", + name: "OpenCode", + models: { test: { id: "test", name: "Test", limit: { context: 200_000 } } }, + }, + ], + connected: ["opencode"], + default: { providerID: "opencode", modelID: "test" }, + }, + sessions: [ + { + id: sessionID, + slug: sessionID, + projectID, + directory, + title, + version: "dev", + time: { created: 1700000000000, updated: 1700000000000 }, + }, + ], + vcsDiff: [fileDiff("src/changed.ts")], + fileList: (path) => { + if (path) return [] + return [ + fileNode("README.md"), + { name: "src", path: "src", absolute: `${directory}/src`, type: "directory", ignored: false }, + ] + }, + fileContent: (path) => ({ type: "text", content: `contents:${path}` }), + findFiles: (input) => { + searches.push(input) + return input.query === "nested" ? ["src/nested.ts"] : [] + }, + pageMessages: () => ({ items: [] }), + }) + await page.addInitScript( + ({ directory, server, sessionID }) => { + localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } })) + localStorage.setItem( + "opencode.global.dat:server", + JSON.stringify({ + projects: { local: [{ worktree: directory, expanded: true }] }, + lastProject: { local: directory }, + }), + ) + localStorage.setItem( + "opencode.global.dat:layout", + JSON.stringify({ review: { diffStyle: "split", panelOpened: true } }), + ) + localStorage.setItem( + "opencode.global.dat:review-panel-v2", + JSON.stringify({ sidebarOpened: false, sidebarWidth: 240, expandMode: "collapse" }), + ) + localStorage.setItem( + "opencode.window.browser.dat:tabs", + JSON.stringify([{ type: "session", server, sessionId: sessionID }]), + ) + }, + { directory, server, sessionID }, + ) + + await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`) + await expectSessionTitle(page, title) + + const panel = page.locator("#review-panel") + const sidebar = panel.locator('[data-slot="session-review-v2-sidebar"]') + const sidebarToggle = panel.getByRole("button", { name: "Toggle file tree" }) + const contextButton = page.getByRole("button", { name: "View context usage" }) + await contextButton.click() + await expect(panel.getByRole("tab", { name: "Context" })).toHaveAttribute("data-selected", "") + await panel.getByRole("button", { name: "Open file" }).click() + await expect(panel.getByRole("tab", { name: "Open file" })).toHaveAttribute("data-selected", "") + await expect(sidebarToggle).toBeDisabled() + await expect(sidebar).toBeVisible() + await contextButton.click() + await expect(panel.getByRole("tab", { name: "Context" })).toHaveAttribute("data-selected", "") + await expect(sidebar).toBeHidden() + await panel.getByRole("button", { name: "Open file" }).click() + const filter = panel.getByRole("combobox", { name: "Filter files" }) + await expect(filter).toBeFocused() + await expect(panel.getByRole("tab", { name: "Open file" })).toHaveAttribute("data-selected", "") + await expect(panel.getByText("open-file-project", { exact: true })).toBeVisible() + + await panel.getByRole("button", { name: "README.md" }).click() + await expect(panel.getByRole("tab", { name: "README.md" })).toHaveAttribute("data-selected", "") + await expect(sidebarToggle).toBeEnabled() + await expect(panel.getByText("contents:README.md", { exact: true })).toBeVisible() + await expect(sidebar).toHaveCount(0) + + await panel.getByRole("button", { name: "Open file" }).click() + await expect(panel.getByRole("tab", { name: "README.md" })).toHaveCount(0) + await expect(sidebar).toBeVisible() + await filter.fill("nested") + const result = panel.getByRole("option", { name: /nested\.ts/ }) + await expect(result).toBeVisible() + const resultID = await result.getAttribute("id") + expect(resultID).toBeTruthy() + await expect(filter).toHaveAttribute("aria-activedescendant", resultID!) + await filter.press("Enter") + await expect(panel.getByRole("tab", { name: "nested.ts" })).toHaveAttribute("data-selected", "") + await expect(sidebarToggle).toBeEnabled() + await expect(panel.getByText("contents:src/nested.ts", { exact: true })).toBeVisible() + expect(searches).toContainEqual({ query: "nested", dirs: "false", limit: 200 }) + + await panel.getByRole("button", { name: "Open file" }).click() + await expect(panel.getByRole("tab", { name: "nested.ts" })).toHaveCount(1) + await expect(panel.getByRole("tab", { name: "Open file" })).toHaveAttribute("data-selected", "") + await expect(sidebarToggle).toBeDisabled() + await panel.locator("#session-side-panel-review-tab").click() + await expect(sidebarToggle).toBeEnabled() + await panel.getByRole("tab", { name: "Open file" }).click() + await page.keyboard.press("Control+w") + await expect(panel.getByRole("tab", { name: "Open file" })).toHaveCount(0) + await expect(panel.getByRole("tab", { name: "nested.ts" })).toHaveAttribute("data-selected", "") +}) + +function fileNode(path: string) { + return { + name: path, + path, + absolute: `${directory}/${path}`, + type: "file", + ignored: false, + } +} + +function fileDiff(file: string) { + return { + file, + before: "before\n", + after: "after\n", + additions: 1, + deletions: 1, + status: "modified", + } +} diff --git a/packages/app/e2e/regression/review-state-persistence.spec.ts b/packages/app/e2e/regression/review-state-persistence.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..0d6756201e7edd4244c36901682465405193081c --- /dev/null +++ b/packages/app/e2e/regression/review-state-persistence.spec.ts @@ -0,0 +1,153 @@ +import { base64Encode } from "@opencode-ai/core/util/encode" +import { expect, test, type Page } from "@playwright/test" +import { mockOpenCodeServer } from "../utils/mock-server" +import { expectSessionTitle } from "../utils/waits" + +const directory = "C:/OpenCode/ReviewStatePersistence" +const projectID = "proj_review_state_persistence" +const sessionA = "ses_review_state_a" +const sessionB = "ses_review_state_b" +const titleA = "Alpha review state" +const titleB = "Beta review state" +const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}` + +test.use({ viewport: { width: 1440, height: 900 } }) + +test("restores review mode and selected file per session", async ({ page }) => { + await setup(page) + await page.goto(sessionHref(sessionA)) + await expectSessionTitle(page, titleA) + await page.getByRole("button", { name: "Toggle review" }).click() + + await selectMode(page, "Git changes", "Branch changes") + await selectFile(page, "beta.ts") + + await switchSession(page, titleB) + await expect(page.getByRole("button", { name: "Git changes" })).toBeVisible() + await selectFile(page, "gamma.ts") + + await switchSession(page, titleA) + await expect(page.getByRole("button", { name: "Branch changes" })).toBeVisible() + await expectSelectedFile(page, "beta.ts") + await selectMode(page, "Branch changes", "Git changes") + await expectSelectedFile(page, "alpha.ts") + await selectMode(page, "Git changes", "Branch changes") + await expectSelectedFile(page, "beta.ts") + + await page.reload() + await expectSessionTitle(page, titleA) + await expect(page.getByRole("button", { name: "Branch changes" })).toBeVisible() + await expectSelectedFile(page, "beta.ts") + + await switchSession(page, titleB) + await expect(page.getByRole("button", { name: "Git changes" })).toBeVisible() + await expectSelectedFile(page, "gamma.ts") +}) + +async function selectMode(page: Page, current: string, next: string) { + await page.getByRole("button", { name: current }).click() + await page.getByRole("option", { name: next }).dispatchEvent("click") +} + +async function selectFile(page: Page, file: string) { + await page.getByRole("button", { name: file }).click() + await expectSelectedFile(page, file) +} + +async function expectSelectedFile(page: Page, file: string) { + await expect(page.locator('[data-slot="session-review-v2-file-name"]')).toHaveText(file) +} + +async function switchSession(page: Page, title: string) { + await page.locator("[data-titlebar-tab-slot]", { hasText: title }).click() + await expectSessionTitle(page, title) +} + +async function setup(page: Page) { + await mockOpenCodeServer(page, { + protocol: "v1", + directory, + project: { + id: projectID, + worktree: directory, + vcs: "git", + name: "review-state-persistence", + time: { created: 1700000000000, updated: 1700000000000 }, + sandboxes: [], + }, + provider: { + all: [ + { + id: "opencode", + name: "OpenCode", + models: { test: { id: "test", name: "Test", limit: { context: 200_000 } } }, + }, + ], + connected: ["opencode"], + default: { providerID: "opencode", modelID: "test" }, + }, + sessions: [session(sessionA, titleA, 1700000000000), session(sessionB, titleB, 1700000001000)], + pageMessages: () => ({ items: [] }), + }) + await page.route(/\/vcs(?:\?.*)?$/, (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ branch: "feature", default_branch: "dev" }), + }), + ) + await page.route("**/vcs/diff**", (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify( + new URL(route.request().url()).searchParams.get("mode") === "branch" + ? [diff("src/alpha.ts"), diff("src/beta.ts")] + : [diff("src/alpha.ts"), diff("src/gamma.ts")], + ), + }), + ) + await page.addInitScript( + ({ directory, server, sessions }) => { + localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } })) + localStorage.setItem( + "opencode.global.dat:server", + JSON.stringify({ + projects: { local: [{ worktree: directory, expanded: true }] }, + lastProject: { local: directory }, + }), + ) + localStorage.setItem( + "opencode.window.browser.dat:tabs", + JSON.stringify(sessions.map((sessionId: string) => ({ type: "session", server, sessionId }))), + ) + }, + { directory, server, sessions: [sessionA, sessionB] }, + ) +} + +function session(id: string, title: string, created: number) { + return { + id, + slug: id, + projectID, + directory, + title, + version: "dev", + time: { created, updated: created }, + } +} + +function diff(file: string) { + return { + file, + additions: 1, + deletions: 1, + status: "modified", + patch: `diff --git a/${file} b/${file}\n--- a/${file}\n+++ b/${file}\n@@ -1 +1 @@\n-export const value = 'before'\n+export const value = 'after'\n`, + } +} + +function sessionHref(sessionID: string) { + return `/server/${base64Encode(server)}/session/${sessionID}` +} diff --git a/packages/app/e2e/regression/review-tab-switch.spec.ts b/packages/app/e2e/regression/review-tab-switch.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..8634166e51281acb422728295a4dec063c05423f --- /dev/null +++ b/packages/app/e2e/regression/review-tab-switch.spec.ts @@ -0,0 +1,147 @@ +import { base64Encode } from "@opencode-ai/core/util/encode" +import { expect, test, type Page } from "@playwright/test" +import { mockOpenCodeServer } from "../utils/mock-server" +import { expectAppVisible, expectSessionTitle } from "../utils/waits" + +const directory = "C:/OpenCode/ReviewTabSwitch" +const projectID = "proj_review_tab_switch" +const sessionA = "ses_review_tab_a" +const sessionB = "ses_review_tab_b" +const titleA = "Alpha session" +const titleB = "Beta session" +const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}` +const diffs = Array.from({ length: 2_740 }, (_, index) => + fileDiff(`src/generated-${String(index).padStart(4, "0")}.ts`), +) +// Marks the review pane DOM node so a remount (fresh node) is detectable. +const PROBE = "original" + +test.use({ viewport: { width: 1440, height: 900 } }) + +// The v2 review pane's diff data is workspace-scoped: switching between session +// tabs in the same workspace must update its parameters reactively instead of +// tearing the pane down and remounting it (which flickers). +test("keeps the v2 review pane mounted when switching session tabs in a workspace", async ({ page }) => { + await setup(page) + + await page.goto(sessionHref(sessionA)) + await expectSessionTitle(page, titleA) + + await page.getByRole("button", { name: "Toggle review" }).click() + const reviewTab = page.locator("#session-side-panel-review-tab") + const reviewTabPanel = page.locator("#session-side-panel-review-tabpanel") + await expect(reviewTab).toHaveAttribute("aria-controls", "session-side-panel-review-tabpanel") + await expect(reviewTabPanel).toHaveAttribute("id", "session-side-panel-review-tabpanel") + const review = page.locator('#review-panel [data-component="session-review-v2"]') + await expectAppVisible(review) + await expectAppVisible(page.getByRole("button", { name: "generated-0000.ts" })) + await writeProbe(page) + + await switchTab(page, titleB) + await expectSessionTitle(page, titleB) + await expectAppVisible(review) + await expectAppVisible(page.getByRole("button", { name: "generated-0000.ts" })) + expect(await readProbe(page)).toBe(PROBE) + + await switchTab(page, titleA) + await expectSessionTitle(page, titleA) + await expectAppVisible(review) + await expectAppVisible(page.getByRole("button", { name: "generated-0000.ts" })) + expect(await readProbe(page)).toBe(PROBE) + + const viewport = page.locator('#review-panel [data-slot="session-review-v2-sidebar-tree"] .scroll-view__viewport') + await viewport.hover() + await page.mouse.wheel(0, 100_000) + await expect + .poll(() => viewport.evaluate((element) => element.scrollHeight - element.clientHeight - element.scrollTop)) + .toBeLessThanOrEqual(1) + await expect(page.getByRole("button", { name: "generated-2739.ts" })).toBeVisible() +}) + +type Probed = HTMLElement & { __e2eProbe?: string } + +async function switchTab(page: Page, title: string) { + await page.locator("[data-titlebar-tab-slot]", { hasText: title }).click() +} + +async function writeProbe(page: Page) { + await page.locator('#review-panel [data-component="session-review-v2"]').evaluate((el, probe) => { + ;(el as Probed).__e2eProbe = probe + }, PROBE) +} + +async function readProbe(page: Page) { + return page.locator('#review-panel [data-component="session-review-v2"]').evaluate((el) => (el as Probed).__e2eProbe) +} + +async function setup(page: Page) { + await mockOpenCodeServer(page, { + directory, + project: { + id: projectID, + worktree: directory, + vcs: "git", + name: "review-tab-switch", + time: { created: 1700000000000, updated: 1700000000000 }, + sandboxes: [], + }, + provider: { + all: [ + { + id: "opencode", + name: "OpenCode", + models: { test: { id: "test", name: "Test", limit: { context: 200_000 } } }, + }, + ], + connected: ["opencode"], + default: { providerID: "opencode", modelID: "test" }, + }, + sessions: [session(sessionA, titleA, 1700000000000), session(sessionB, titleB, 1700000001000)], + vcsDiff: diffs, + pageMessages: () => ({ items: [] }), + }) + + await page.addInitScript( + ({ directory, server, sessions }) => { + localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } })) + localStorage.setItem( + "opencode.global.dat:server", + JSON.stringify({ + projects: { local: [{ worktree: directory, expanded: true }] }, + lastProject: { local: directory }, + }), + ) + localStorage.setItem( + "opencode.window.browser.dat:tabs", + JSON.stringify(sessions.map((sessionId: string) => ({ type: "session", server, sessionId }))), + ) + }, + { directory, server, sessions: [sessionA, sessionB] }, + ) +} + +function session(id: string, title: string, created: number) { + return { + id, + slug: id, + projectID, + directory, + title, + version: "dev", + time: { created, updated: created }, + } +} + +function sessionHref(sessionID: string) { + return `/server/${base64Encode(server)}/session/${sessionID}` +} + +function fileDiff(file: string) { + return { + file, + additions: 1, + deletions: 1, + status: "modified", + patch: `diff --git a/${file} b/${file}\n--- a/${file}\n+++ b/${file}\n@@ -1 +1 @@\n-export const value = 'before'\n+export const value = 'after'\n`, + } +} diff --git a/packages/app/e2e/regression/review-terminal-stacked.spec.ts b/packages/app/e2e/regression/review-terminal-stacked.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..79b564820e20f21a4f071a266dc95d06dac16971 --- /dev/null +++ b/packages/app/e2e/regression/review-terminal-stacked.spec.ts @@ -0,0 +1,306 @@ +import { expect, test, type Page } from "@playwright/test" +import { mockOpenCodeServer } from "../utils/mock-server" +import { expectSessionTitle } from "../utils/waits" + +const directory = "C:/OpenCode/ReviewTerminalStacked" +const projectID = "proj_review_terminal_stacked" +const sessionID = "ses_review_terminal_stacked" +const title = "Review terminal stacked" +const branchDiffs = [ + fileDiff(".github/actions/setup-bun/action.yml", 7), + ...Array.from({ length: 2_739 }, (_, index) => + fileDiff( + `src/branch/d${String(Math.floor(index / 100)).padStart(5, "0")}/generated-${String(index).padStart(4, "0")}.ts`, + 100, + false, + ), + ), +] + +test("keeps the review tree and terminal sized when both panels are open", async ({ page }) => { + test.setTimeout(120_000) + const events: Array<{ directory: string; payload: Record }> = [] + const sessionStatus = { [sessionID]: { type: "idle" as "busy" | "idle" } } + let detailVersion = 1 + let detailFailures = 1 + await page.setViewportSize({ width: 1400, height: 900 }) + await mockOpenCodeServer(page, { + protocol: "v1", + directory, + project: { + id: projectID, + worktree: directory, + vcs: "git", + name: "review-terminal-stacked", + time: { created: 1700000000000, updated: 1700000000000 }, + sandboxes: [], + }, + provider: { + all: [ + { + id: "opencode", + name: "OpenCode", + models: { test: { id: "test", name: "Test", limit: { context: 200_000 } } }, + }, + ], + connected: ["opencode"], + default: { providerID: "opencode", modelID: "test" }, + }, + sessions: [ + { + id: sessionID, + slug: "review-terminal-stacked", + projectID, + directory, + title, + version: "dev", + time: { created: 1700000000000, updated: 1700000000000 }, + }, + ], + sessionStatus: () => sessionStatus, + pageMessages: () => ({ items: [] }), + events: () => events.splice(0, 1), + eventRetry: 16, + }) + await page.route(/\/vcs(?:\?.*)?$/, (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + branch: "review-pane-performance", + default_branch: "dev", + }), + }), + ) + await page.route("**/vcs/diff**", (route) => { + const url = new URL(route.request().url()) + const scope = url.searchParams.get("directory")?.replaceAll("\\", "/") + const detail = scope?.endsWith("/src/branch/d00027") + if (detail && detailFailures-- > 0) return route.fulfill({ status: 500, body: "retry detail" }) + return route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify( + url.searchParams.get("mode") === "branch" + ? detail + ? branchDiffs + .filter((diff) => diff.file.startsWith("src/branch/d00027/")) + .map((diff) => fileDiff(diff.file, diff.additions, true, detailVersion)) + : branchDiffs + : Array.from({ length: 7 }, (_, index) => fileDiff(`src/git-${index}.ts`, 1)), + ), + }) + }) + await page.route("**/pty*", (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + location: { directory, project: { id: projectID, directory } }, + data: { + id: "pty_review_terminal", + title: "Terminal 1", + command: "cmd.exe", + args: [], + cwd: directory, + status: "running", + pid: 1, + }, + }), + }), + ) + await page.route("**/pty/pty_review_terminal*", (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + location: { directory, project: { id: projectID, directory } }, + data: { + id: "pty_review_terminal", + title: "Terminal 1", + command: "cmd.exe", + args: [], + cwd: directory, + status: "running", + pid: 1, + }, + }), + }), + ) + await page.route("**/pty/pty_review_terminal/connect-token*", (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + location: { directory, project: { id: projectID, directory } }, + data: { ticket: "e2e-ticket", expires_in: 60 }, + }), + }), + ) + await page.routeWebSocket("**/pty/pty_review_terminal/connect", () => undefined) + await page.addInitScript(() => { + localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } })) + localStorage.setItem( + "opencode.global.dat:layout", + JSON.stringify({ review: { diffStyle: "split", panelOpened: true } }), + ) + }) + + await page.goto(`/${base64Encode(directory)}/session/${sessionID}`) + await expectSessionTitle(page, title) + await expect(page.locator("#review-panel")).toBeVisible() + await expectTree(page, 8, "git-0.ts") + + await selectMode(page, "Git changes", "Branch changes") + await expect(page.locator("#session-side-panel-review-tab")).toHaveText("Files Changed 2740") + await page.keyboard.press("Control+Backquote") + await expect(page.locator("#terminal-panel")).toBeVisible() + await expectTree(page, 2_773, "action.yml") + await expectStackGeometry(page) + + const treeViewport = page.locator('#review-panel [data-slot="session-review-v2-sidebar-tree"] .scroll-view__viewport') + await treeViewport.hover() + await page.mouse.wheel(0, 100_000) + await expect + .poll(() => treeViewport.evaluate((element) => element.scrollHeight - element.clientHeight - element.scrollTop)) + .toBeLessThanOrEqual(1) + const lastFile = page.getByRole("button", { name: "generated-2738.ts" }) + await expect(lastFile).toBeVisible() + const bottomGap = await lastFile.evaluate((element) => { + const viewport = element.closest(".scroll-view__viewport")!.getBoundingClientRect() + return viewport.bottom - element.getBoundingClientRect().bottom + }) + expect(bottomGap).toBeGreaterThanOrEqual(0) + expect(bottomGap).toBeLessThanOrEqual(16) + const lazyDiff = page.waitForRequest((request) => { + const url = new URL(request.url()) + return ( + url.pathname === "/vcs/diff" && + url.searchParams.get("directory")?.replaceAll("\\", "/").endsWith("/src/branch/d00027") === true + ) + }) + await lastFile.click() + await lazyDiff + const preview = page.locator('[data-slot="session-review-v2-diff-scroll"]') + await expect(preview).toContainText("after-1") + detailVersion = 2 + sessionStatus[sessionID] = { type: "busy" } + events.push(statusEvent("busy")) + await expect(page.getByRole("button", { name: "Stop" })).toBeVisible() + const refreshedDiff = page.waitForRequest((request) => { + const url = new URL(request.url()) + return ( + url.pathname === "/vcs/diff" && + url.searchParams.get("directory")?.replaceAll("\\", "/").endsWith("/src/branch/d00027") === true + ) + }) + sessionStatus[sessionID] = { type: "idle" } + events.push(statusEvent("idle")) + await refreshedDiff + await expect(preview).toContainText("after-2") + await selectMode(page, "Branch changes", "Git changes") + await expectTree(page, 8, "git-0.ts") + await page.getByRole("button", { name: "git-0.ts" }).click() + await selectMode(page, "Git changes", "Branch changes") + await expectTree(page, 2_773, "action.yml") + + const filter = page.getByRole("searchbox", { name: "Filter files" }) + await filter.fill("generated-2738") + await expectTree(page, 1, "generated-2738.ts") + await filter.fill("") + await expectTree(page, 2_773, "action.yml") + + await page.getByRole("button", { name: "Toggle file tree" }).click() + await expect(page.locator('[data-slot="session-review-v2-sidebar"]')).toHaveCount(0) + await expect(page.locator('#review-panel [data-component="file-tree-v2"]')).toHaveCount(0) + await page.getByRole("button", { name: "Toggle file tree" }).click() + await expectTree(page, 2_773, "action.yml") + + await page.keyboard.press("Control+Backquote") + await expect(page.locator("#terminal-panel")).toHaveCount(0) + await expectTree(page, 2_773, "action.yml") + await page.keyboard.press("Control+Backquote") + await expect(page.locator("#terminal-panel")).toBeVisible() + await expectTree(page, 2_773, "action.yml") + + await page.getByRole("button", { name: "Toggle review" }).click() + await expect(page.locator("#review-panel")).toHaveCount(0) + await page.getByRole("button", { name: "Toggle review" }).click() + await expectTree(page, 2_773, "action.yml") + await page.setViewportSize({ width: 1_000, height: 700 }) + await expectTree(page, 2_773, "action.yml") + await expectStackGeometry(page) + await page.setViewportSize({ width: 1_000, height: 120 }) + await page.setViewportSize({ width: 1_400, height: 900 }) + await expectTree(page, 2_773, "action.yml") + await expectStackGeometry(page) +}) + +async function selectMode(page: Page, current: string, next: string) { + await page.getByRole("button", { name: current }).click() + const option = page.getByRole("option", { name: next }) + await expect(option).toBeVisible() + await option.click() +} + +async function expectTree(page: Page, total: number, file: string) { + await expectMountedTree(page, total) + await expect(page.getByRole("button", { name: file })).toBeVisible() +} + +async function expectMountedTree(page: Page, total: number) { + const tree = page.locator('#review-panel [data-component="file-tree-v2"]') + await expect(tree).toHaveAttribute("data-total-rows", String(total)) + await expect + .poll(() => tree.evaluate((element) => element.querySelectorAll('[data-slot="file-tree-v2-row"]').length)) + .toBeGreaterThan(0) + const state = await tree.evaluate((element) => ({ + root: element.getBoundingClientRect().height, + viewport: element.closest(".scroll-view__viewport")!.getBoundingClientRect().height, + rows: element.querySelectorAll('[data-slot="file-tree-v2-row"]').length, + })) + expect(state.viewport).toBeGreaterThan(0) + expect(state.root).toBeGreaterThan(0) + expect(state.rows).toBeGreaterThan(0) + expect(state.rows).toBeLessThanOrEqual(60) +} + +async function expectStackGeometry(page: Page) { + const geometry = await page.evaluate(() => { + const review = document.querySelector("#review-panel")! + const terminal = document.querySelector("#terminal-panel")! + const reviewParent = review.parentElement!.getBoundingClientRect() + const terminalParent = terminal.parentElement!.getBoundingClientRect() + return { + review: review.getBoundingClientRect().height, + reviewParent: reviewParent.height, + terminal: terminal.getBoundingClientRect().height, + terminalParent: terminalParent.height, + } + }) + expect(Math.abs(geometry.review - geometry.reviewParent)).toBeLessThanOrEqual(1) + expect(Math.abs(geometry.terminal - geometry.terminalParent)).toBeLessThanOrEqual(1) +} + +function base64Encode(value: string) { + return Buffer.from(value, "utf8").toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "") +} + +function statusEvent(type: "busy" | "idle") { + return { + directory, + payload: { type: "session.status", properties: { sessionID, status: { type } } }, + } +} + +function fileDiff(file: string, additions: number, loaded = true, version = 1) { + return { + file, + additions, + deletions: 0, + status: "modified", + patch: loaded + ? `diff --git a/${file} b/${file}\n--- a/${file}\n+++ b/${file}\n@@ -1 +1 @@\n-export const value = 'before'\n+export const value = 'after-${version}'\n` + : `diff --git a/${file} b/${file}\n--- a/${file}\n+++ b/${file}`, + } +} diff --git a/packages/app/e2e/regression/session-list-path-loading.spec.ts b/packages/app/e2e/regression/session-list-path-loading.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..3319514df6489d1970fe03473117e4ef1c45c4ca --- /dev/null +++ b/packages/app/e2e/regression/session-list-path-loading.spec.ts @@ -0,0 +1,41 @@ +import { test } from "@playwright/test" +import { fixture, pageMessages } from "../smoke/session-timeline.fixture" +import { mockOpenCodeServer } from "../utils/mock-server" +import { expectAppVisible } from "../utils/waits" + +test("shows loaded sessions before the directory path request resolves", async ({ page }) => { + await mockOpenCodeServer(page, { + sessions: fixture.sessions, + provider: fixture.provider, + directory: fixture.directory, + project: fixture.project, + pageMessages, + }) + + let releasePath!: () => void + const pathBlocked = new Promise((resolve) => { + releasePath = resolve + }) + await page.route("**/api/path?*", async (route) => { + if (!new URL(route.request().url()).searchParams.has("location[directory]")) return route.fallback() + await pathBlocked + return route.fallback() + }) + + await page.addInitScript((directory) => { + localStorage.setItem( + "opencode.global.dat:server", + JSON.stringify({ + projects: { local: [{ worktree: directory, expanded: true }] }, + lastProject: { local: directory }, + }), + ) + }, fixture.directory) + + await page.goto("/") + try { + await expectAppVisible(page.getByText(fixture.expected.sourceTitle).first()) + } finally { + releasePath() + } +}) diff --git a/packages/app/e2e/regression/session-rename.spec.ts b/packages/app/e2e/regression/session-rename.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..2cd97c24c1c0ebc99252f27a82b02673b8334eee --- /dev/null +++ b/packages/app/e2e/regression/session-rename.spec.ts @@ -0,0 +1,141 @@ +import { expect, test } from "@playwright/test" +import { fixture, pageMessages } from "../smoke/session-timeline.fixture" +import { mockOpenCodeServer } from "../utils/mock-server" + +test.beforeEach(async ({ page }) => { + const sessions = fixture.sessions.map((session) => ({ ...session })) + await mockOpenCodeServer(page, { + protocol: "v1", + sessions, + provider: fixture.provider, + directory: fixture.directory, + project: fixture.project, + pageMessages, + }) + await page.route(/\/session\/[^/]+(?:\?.*)?$/, async (route) => { + if (route.request().method() !== "PATCH") return route.fallback() + const id = new URL(route.request().url()).pathname.split("/").at(-1) + const session = sessions.find((item) => item.id === id) + const payload: unknown = route.request().postDataJSON() + if ( + !session || + !payload || + typeof payload !== "object" || + !("title" in payload) || + typeof payload.title !== "string" + ) + throw new Error("Invalid rename request") + session.title = payload.title + await route.fulfill({ json: session, headers: { "access-control-allow-origin": "*" } }) + }) + await page.addInitScript((directory) => { + localStorage.setItem( + "opencode.global.dat:server", + JSON.stringify({ + projects: { local: [{ worktree: directory, expanded: true }] }, + lastProject: { local: directory }, + }), + ) + }, fixture.directory) + await page.goto("/") + await page.locator('[data-component="home-session-row"]').filter({ hasText: fixture.expected.targetTitle }).click() + await expect(page.getByRole("heading", { name: fixture.expected.targetTitle, exact: true })).toBeVisible() +}) + +for (const commit of ["Enter", "blur", "click outside"]) { + test(`saves the session heading on ${commit}`, async ({ page }) => { + await page.getByRole("heading", { name: fixture.expected.targetTitle, exact: true }).click() + const input = page.locator('input[data-slot="session-title-child"]') + await expect(input).toBeFocused() + await input.fill("Renamed session") + if (commit === "Enter") await input.press("Enter") + if (commit === "blur") await input.press("Tab") + if (commit === "click outside") await page.getByRole("textbox", { name: "Prompt", exact: true }).click() + await expect(page.getByRole("heading", { name: "Renamed session", exact: true })).toBeVisible() + await expect(page.locator('[data-slot="titlebar-tabs"] a').filter({ hasText: "Renamed session" })).toBeVisible() + await page.reload() + await expect(page.getByRole("heading", { name: "Renamed session", exact: true })).toBeVisible() + }) +} + +test("cancels the session heading with Escape", async ({ page }) => { + await page.getByRole("heading", { name: fixture.expected.targetTitle, exact: true }).click() + const input = page.locator('input[data-slot="session-title-child"]') + await input.fill("Discard this title") + await input.press("Escape") + await expect(page.getByRole("heading", { name: fixture.expected.targetTitle, exact: true })).toBeVisible() + await page.reload() + await expect(page.getByRole("heading", { name: fixture.expected.targetTitle, exact: true })).toBeVisible() +}) + +test("keeps the draft when saving the session heading fails", async ({ page }) => { + await page.route(/\/session\/[^/]+(?:\?.*)?$/, (route) => { + if (route.request().method() !== "PATCH") return route.fallback() + return route.fulfill({ status: 500, headers: { "access-control-allow-origin": "*" } }) + }) + await page.getByRole("heading", { name: fixture.expected.targetTitle, exact: true }).click() + const input = page.locator('input[data-slot="session-title-child"]') + await input.fill("Retry this title") + await input.press("Tab") + await expect(page.getByText("Request failed", { exact: true })).toBeVisible() + await expect(input).toBeEnabled() + await expect(input).toHaveValue("Retry this title") + await expect( + page.locator('[data-slot="titlebar-tabs"] a').filter({ hasText: fixture.expected.targetTitle }), + ).toBeVisible() +}) + +test("does not save an empty session heading", async ({ page }) => { + await page.getByRole("heading", { name: fixture.expected.targetTitle, exact: true }).click() + const input = page.locator('input[data-slot="session-title-child"]') + await input.fill(" ") + await input.press("Tab") + await expect(page.getByRole("heading", { name: fixture.expected.targetTitle, exact: true })).toBeVisible() + await page.reload() + await expect(page.getByRole("heading", { name: fixture.expected.targetTitle, exact: true })).toBeVisible() +}) + +test("renames and closes the session tab from its context menu", async ({ page }) => { + const tab = page.locator('[data-slot="titlebar-tabs"] a').filter({ hasText: fixture.expected.targetTitle }) + await tab.click({ button: "right" }) + await expect(page.getByRole("menuitem", { name: "Rename", exact: true })).toBeVisible() + await page.keyboard.press("Escape") + await expect(page.getByRole("menuitem", { name: "Rename", exact: true })).toBeHidden() + await expect(tab).toBeFocused() + await tab.press("Shift+F10") + await page.getByRole("menuitem", { name: "Rename", exact: true }).click() + const input = page.locator('[data-slot="tab-title"][contenteditable="true"]') + await expect(input).toBeFocused() + await input.fill("Renamed from tab") + await input.press("Enter") + await expect(page.getByRole("heading", { name: "Renamed from tab", exact: true })).toBeVisible() + await page.reload() + await expect(page.getByRole("heading", { name: "Renamed from tab", exact: true })).toBeVisible() + const renamed = page.locator('[data-slot="titlebar-tabs"] a').filter({ hasText: "Renamed from tab" }) + await renamed.click({ button: "right" }) + await page.getByRole("menuitem", { name: "Close tab", exact: true }).click() + await expect(renamed).toBeHidden() + await page.getByRole("button", { name: "Home", exact: true }).click() + await expect( + page.locator('[data-component="home-session-row"]').filter({ hasText: "Renamed from tab" }), + ).toBeVisible() +}) + +test("renames an inactive tab without switching sessions", async ({ page }) => { + await page.getByRole("button", { name: "Home", exact: true }).click() + await page.locator('[data-component="home-session-row"]').filter({ hasText: fixture.expected.sourceTitle }).click() + await expect(page.getByRole("heading", { name: fixture.expected.sourceTitle, exact: true })).toBeVisible() + const tab = page.locator('[data-slot="titlebar-tabs"] a').filter({ hasText: fixture.expected.targetTitle }) + await tab.click({ button: "right" }) + await page.getByRole("menuitem", { name: "Rename", exact: true }).click() + const input = page.locator('[data-slot="tab-title"][contenteditable="true"]') + await expect(input).toBeFocused() + await input.fill("Inactive tab renamed") + await input.press("Tab") + await expect(page.getByRole("heading", { name: fixture.expected.sourceTitle, exact: true })).toBeVisible() + await expect(page).toHaveURL(new RegExp(`/session/${fixture.sourceID}$`)) + await page.locator('[data-slot="titlebar-tabs"] a').filter({ hasText: "Inactive tab renamed" }).click() + await expect(page.getByRole("heading", { name: "Inactive tab renamed", exact: true })).toBeVisible() + await page.reload() + await expect(page.getByRole("heading", { name: "Inactive tab renamed", exact: true })).toBeVisible() +}) diff --git a/packages/app/e2e/regression/session-request-docks.spec.ts b/packages/app/e2e/regression/session-request-docks.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..5ea9d4f7613b22e97e0bbab097567f97e8cdf8e9 --- /dev/null +++ b/packages/app/e2e/regression/session-request-docks.spec.ts @@ -0,0 +1,221 @@ +import { base64Encode } from "@opencode-ai/core/util/encode" +import { expect, test, type Page } from "@playwright/test" +import { mockOpenCodeServer } from "../utils/mock-server" +import { installSseTransport } from "../utils/sse-transport" +import { expectSessionTitle } from "../utils/waits" + +const directory = "C:/OpenCode/RequestDocks" +const projectID = "proj_request_docks" +const sessionID = "ses_request_docks" +const title = "Request dock regression" + +test("shows a pending question dock", async ({ page }) => { + await mockServer(page, { + questions: [ + { + id: "question-request", + sessionID, + questions: [ + { + header: "Implementation", + question: "Which implementation should be used?", + options: [ + { label: "Minimal", description: "Use the smallest correct change" }, + { label: "Extended", description: "Include additional behavior" }, + ], + }, + ], + }, + ], + }) + + await page.goto(`/${base64Encode(directory)}/session/${sessionID}`) + await expectSessionTitle(page, title) + + const question = page.locator('[data-component="dock-prompt"][data-kind="question"]') + await expect(question).toBeVisible() + await expect(question.getByText("Which implementation should be used?")).toBeVisible() + await expect(question.getByRole("radio", { name: /Minimal/ })).toBeVisible() + await expect(question.getByRole("radio", { name: /Extended/ })).toBeVisible() + await expect(page.locator('[data-component="session-composer"]')).toHaveCount(0) + + const rejectRequests: string[] = [] + page.on("request", (request) => { + if (request.method() !== "POST") return + if (new URL(request.url()).pathname === `/api/session/${sessionID}/question/question-request/reject`) + rejectRequests.push(request.url()) + }) + + await question.locator('[data-component="icon-button"][data-icon="chevron-down"]').click() + await expect(question).toBeVisible() + await expect(question.getByText("Which implementation should be used?")).toBeVisible() + await expect(question.getByText("Select one answer")).toBeHidden() + await expect(question.getByRole("radio", { name: /Minimal/ })).toBeHidden() + await expect(question.getByRole("radio", { name: /Extended/ })).toBeHidden() + await expect(question.getByRole("button", { name: "Dismiss" })).toBeVisible() + await expect(question.getByRole("button", { name: "Submit" })).toBeVisible() + await expect(page.locator('[data-component="question-minimized-dock"]')).toHaveCount(0) + expect(rejectRequests).toEqual([]) + + await question.locator('[data-component="icon-button"][data-icon="chevron-down"]').click() + await expect(question).toBeVisible() + await expect(question.getByText("Which implementation should be used?")).toBeVisible() + await expect(question.getByRole("radio", { name: /Minimal/ })).toBeVisible() + expect(rejectRequests).toEqual([]) + + await question.getByRole("radio", { name: /Minimal/ }).click() + const reply = page.waitForRequest( + (request) => + request.method() === "POST" && + new URL(request.url()).pathname === `/api/session/${sessionID}/question/question-request/reply`, + ) + await question.getByRole("button", { name: "Submit" }).click() + expect((await reply).postDataJSON()).toEqual({ answers: [["Minimal"]] }) +}) + +test("shows a pending permission dock", async ({ page }) => { + await mockServer(page, { + permissions: [ + { + id: "permission-request", + sessionID, + permission: "bash", + patterns: ["git status", "git diff"], + metadata: {}, + always: [], + }, + ], + }) + + await page.goto(`/${base64Encode(directory)}/session/${sessionID}`) + await expectSessionTitle(page, title) + + const permission = page.locator('[data-component="dock-prompt"][data-kind="permission"]') + await expect(permission).toBeVisible() + await expect(permission.getByText("git status")).toBeVisible() + await expect(permission.getByText("git diff")).toBeVisible() + await expect(permission.locator('[data-slot="permission-footer-actions"] button')).toHaveCount(3) + await expect(page.locator('[data-component="session-composer"]')).toHaveCount(0) + + const reply = page.waitForRequest((request) => request.method() === "POST") + await permission.getByRole("button", { name: "Allow once" }).click() + const request = await reply + expect(new URL(request.url()).pathname).toBe(`/api/session/${sessionID}/permission/permission-request/reply`) + expect(request.postDataJSON()).toEqual({ reply: "once" }) +}) + +test("restores the draft caret before typing after a request dock closes", async ({ page }) => { + const transport = await installSseTransport(page, { + server: `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`, + retry: 20, + }) + await mockServer(page, { questions: [] }) + await page.goto(`/${base64Encode(directory)}/session/${sessionID}`) + await transport.waitForConnection() + await expectSessionTitle(page, title) + + const editor = page.locator('[data-component="prompt-input"][contenteditable="true"]') + const draft = "keep the caret at the end" + await editor.fill(draft) + await page.evaluate(() => new Promise((resolve) => requestAnimationFrame(() => resolve()))) + for (let index = 0; index < 4; index++) await page.keyboard.press("ArrowLeft") + const cursor = draft.length - 4 + await expect + .poll(() => + editor.evaluate((element) => { + const selection = window.getSelection() + if (!selection?.rangeCount || !element.contains(selection.anchorNode)) return -1 + const range = selection.getRangeAt(0).cloneRange() + range.selectNodeContents(element) + range.setEnd(selection.anchorNode!, selection.anchorOffset) + return range.toString().length + }), + ) + .toBe(cursor) + await transport.send({ + directory, + payload: { + type: "question.asked", + properties: { + id: "question-caret", + sessionID, + questions: [ + { + header: "Continue", + question: "Continue?", + options: [{ label: "Yes", description: "Continue the session" }], + }, + ], + tool: { messageID: "message-caret", callID: "call-caret" }, + }, + }, + }) + const question = page.locator('[data-component="dock-prompt"][data-kind="question"]') + await expect(question).toBeVisible() + await expect(editor).toHaveCount(0) + + await transport.send({ + directory, + payload: { type: "question.rejected", properties: { sessionID, requestID: "question-caret" } }, + }) + await expect(question).toHaveCount(0) + await expect(editor).toBeVisible() + await page.keyboard.press("x") + + await expect(editor).toHaveText(`${draft.slice(0, cursor)}x${draft.slice(cursor)}`) +}) + +async function mockServer( + page: Page, + requests: { + permissions?: unknown[] | (() => unknown[]) + questions?: unknown[] | (() => unknown[]) + }, +) { + await mockOpenCodeServer(page, { + protocol: "v2", + directory, + project: { + id: projectID, + worktree: directory, + vcs: "git", + name: "request-docks", + time: { created: 1700000000000, updated: 1700000000000 }, + sandboxes: [], + }, + provider: { + all: [ + { + id: "opencode", + name: "OpenCode", + models: { + "claude-opus-4-6": { + id: "claude-opus-4-6", + name: "Claude Opus 4.6", + limit: { context: 200_000 }, + }, + }, + }, + ], + connected: ["opencode"], + default: { providerID: "opencode", modelID: "claude-opus-4-6" }, + }, + sessions: [ + { + id: sessionID, + slug: "request-docks", + projectID, + directory, + title, + version: "dev", + time: { created: 1700000000000, updated: 1700000000000 }, + }, + ], + pageMessages: () => ({ items: [] }), + permissions: requests.permissions, + questions: requests.questions, + }) + await page.addInitScript(() => { + localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } })) + }) +} diff --git a/packages/app/e2e/regression/session-timeline-accessibility.spec.ts b/packages/app/e2e/regression/session-timeline-accessibility.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..598763c0225378ad74f7d4ca37777eb9d93bff7e --- /dev/null +++ b/packages/app/e2e/regression/session-timeline-accessibility.spec.ts @@ -0,0 +1,22 @@ +import { expect, test } from "@playwright/test" +import { assistantMessage, setupTimeline, shell, userMessage } from "../performance/timeline-stability/fixture" + +test("space activates a focused timeline button instead of scrolling", async ({ page }) => { + const shellID = "prt_space_button_shell" + await setupTimeline(page, { + messages: [userMessage(), assistantMessage([shell(shellID, "completed", lines(5))])], + settings: { shellToolPartsExpanded: false }, + reducedMotion: true, + }) + const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") }) + const trigger = page.locator(`[data-timeline-part-id="${shellID}"] [data-slot="collapsible-trigger"]`) + await trigger.focus() + const before = await scroller.evaluate((element) => element.scrollTop) + await trigger.press("Space") + await expect(trigger).toHaveAttribute("aria-expanded", "true") + expect(await scroller.evaluate((element) => element.scrollTop)).toBe(before) +}) + +function lines(count: number) { + return Array.from({ length: count }, (_, index) => `line ${index + 1}`).join("\n") +} diff --git a/packages/app/e2e/regression/session-timeline-collapse-state.spec.ts b/packages/app/e2e/regression/session-timeline-collapse-state.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..5b6e0b127b171808cdc134c53e3758bf55f5a2a0 --- /dev/null +++ b/packages/app/e2e/regression/session-timeline-collapse-state.spec.ts @@ -0,0 +1,439 @@ +import { expect, test, type Locator, type Page } from "@playwright/test" +import { mockOpenCodeServer } from "../utils/mock-server" +import { expectAppVisible, expectSessionTitle } from "../utils/waits" + +const directory = "C:/OpenCode/TimelineStateRegression" +const projectID = "proj_timeline_state_regression" +const sessionID = "ses_timeline_state_regression" +const userMessageID = "msg_user_regression" +const assistantMessageID = "msg_assistant_regression" +const editPartID = "prt_0001_edit" +const textPartID = "prt_9999_text" +const title = "Timeline collapse state regression" +const model = { providerID: "opencode", modelID: "claude-opus-4-6", variant: "max" } + +type EventPayload = { + directory: string + payload: Record +} + +declare global { + interface Window { + __timelineDiffProbe: { + reset: () => void + shadowRoots: () => number + } + } +} + +const userMessage = { + info: { + id: userMessageID, + sessionID, + role: "user", + time: { created: 1700000000000 }, + summary: { diffs: [] }, + agent: "build", + model, + }, + parts: [ + { + id: "prt_user_text", + sessionID, + messageID: userMessageID, + type: "text", + text: "Please edit the file.", + }, + ], +} + +const editPart = { + id: editPartID, + sessionID, + messageID: assistantMessageID, + type: "tool", + callID: "call_edit_regression", + tool: "edit", + state: { + status: "completed", + input: { filePath: "src/regression.ts" }, + output: "Edited src/regression.ts", + title: "src/regression.ts", + metadata: { + filediff: { + file: "src/regression.ts", + additions: 1, + deletions: 1, + before: "export const value = 'before'\n", + after: "export const value = 'after'\n", + }, + diff: "diff --git a/src/regression.ts b/src/regression.ts\n-export const value = 'before'\n+export const value = 'after'\n", + }, + time: { start: 1700000001000, end: 1700000002000 }, + }, +} + +const streamedTextPart = { + id: textPartID, + sessionID, + messageID: assistantMessageID, + type: "text", + text: "Streaming added a later assistant text part.", +} + +const assistantMessage = { + info: { + id: assistantMessageID, + sessionID, + role: "assistant", + time: { created: 1700000001000 }, + parentID: userMessageID, + modelID: model.modelID, + providerID: model.providerID, + mode: "build", + agent: "build", + path: { cwd: directory, root: directory }, + cost: 0.01, + tokens: { input: 100, output: 200, reasoning: 0, cache: { read: 0, write: 0 } }, + variant: "max", + }, + parts: [editPart], +} + +test.describe("regression: session timeline local row state", () => { + test("keeps a manually collapsed tool collapsed when later assistant content streams", async ({ page }) => { + const events: EventPayload[] = [] + await mockServer(page, events) + await configurePage(page) + + await page.goto(`/${base64Encode(directory)}/session/${sessionID}`) + await expectSessionTitle(page, title) + + const wrapper = page.locator(`[data-timeline-part-id="${editPartID}"]`).first() + await expectAppVisible(wrapper) + await expectExpanded(wrapper, true) + + await wrapper.evaluate((element) => { + ;(element as HTMLElement).dataset.regressionMarker = "before-stream" + }) + await wrapper.locator('[data-slot="collapsible-trigger"]').first().click() + await expectExpanded(wrapper, false) + + events.push({ + directory, + payload: { + type: "message.part.updated", + properties: { part: streamedTextPart }, + }, + }) + + await expect(page.locator(`[data-timeline-part-id="${textPartID}"]`).first()).toBeVisible({ timeout: 10_000 }) + + expect(await readToolState(page)).toEqual({ + expanded: false, + row: "AssistantPart", + streamedTextVisible: true, + }) + }) + + test("does not remount an edit diff when sibling parts or diff counts update", async ({ page }) => { + const events: EventPayload[] = [] + await installDiffProbe(page) + await mockServer(page, events) + await configurePage(page) + + await page.goto(`/${base64Encode(directory)}/session/${sessionID}`) + await expectSessionTitle(page, title) + + const wrapper = page.locator(`[data-timeline-part-id="${editPartID}"]`).first() + await expectAppVisible(wrapper) + const file = wrapper.locator('[data-component="file"][data-mode="diff"]').first() + await expectAppVisible(file) + await markDiffProbe(page) + + events.push({ + directory, + payload: { + type: "message.part.updated", + properties: { part: streamedTextPart }, + }, + }) + + await expect(page.locator(`[data-timeline-part-id="${textPartID}"]`).first()).toBeVisible({ timeout: 10_000 }) + const siblingProbe = await readDiffProbe(page) + expect(siblingProbe).toEqual({ + fileMarker: "before", + frameMarker: "before", + rowKey: `assistant-part:${userMessageID}:part:${assistantMessageID}:${editPartID}`, + rowMarker: "before", + shadowRoots: 0, + toolMarker: "before", + }) + + await markDiffProbe(page) + events.push({ + directory, + payload: { + type: "message.part.updated", + properties: { part: editPartWithAdditions(2) }, + }, + }) + + await expect(wrapper.locator('[data-slot="diff-changes-additions"]').filter({ hasText: "+2" }).first()).toBeVisible( + { timeout: 10_000 }, + ) + expect(await readDiffProbe(page)).toEqual({ + fileMarker: "before", + frameMarker: "before", + rowKey: `assistant-part:${userMessageID}:part:${assistantMessageID}:${editPartID}`, + rowMarker: "before", + shadowRoots: 0, + toolMarker: "before", + }) + }) + + test("keeps a sticky edit header aligned with a multi-hunk diff", async ({ page }) => { + const events: EventPayload[] = [] + const lines = Array.from({ length: 1_000 }, (_, index) => `export const value${index} = ${index}\n`).join("") + const after = [100, 300, 500, 700, 900].reduce( + (result, index) => + result.replace(`export const value${index} = ${index}`, `export const value${index} = compute(${index})`), + lines, + ) + const part = { + ...editPart, + state: { + ...editPart.state, + metadata: { + ...editPart.state.metadata, + filediff: { + file: "src/regression.ts", + additions: 1, + deletions: 1, + before: lines, + after, + }, + }, + }, + } + await mockServer(page, events, [userMessage, { ...assistantMessage, parts: [part] }]) + await configurePage(page) + + await page.goto(`/${base64Encode(directory)}/session/${sessionID}`) + await expectSessionTitle(page, title) + + const wrapper = page.locator(`[data-timeline-part-id="${editPartID}"]`).first() + const trigger = wrapper.locator('[data-slot="collapsible-trigger"]').first() + const diff = wrapper.locator('[data-component="edit-content"]').first() + await expectAppVisible(diff) + await expect.poll(() => wrapper.evaluate((element) => element.getBoundingClientRect().height)).toBeGreaterThan(500) + const samples = await wrapper.evaluate(async (element) => { + const root = element.closest(".scroll-view__viewport")! + element.scrollIntoView({ block: "start" }) + const result = [] + for (const offset of [0, 120, 240, 360, 480]) { + root.scrollBy(0, offset - (result.at(-1)?.offset ?? 0)) + await new Promise(requestAnimationFrame) + const trigger = element.querySelector('[data-slot="collapsible-trigger"]')! + const diff = element.querySelector('[data-component="edit-content"]')! + result.push({ + offset, + trigger: trigger.getBoundingClientRect().y, + diff: diff.getBoundingClientRect().y, + bottom: element.getBoundingClientRect().bottom, + }) + } + return result + }) + + expect(samples[0]!.trigger).toBeLessThan(samples[0]!.diff) + expect(samples.every((sample) => Math.abs(sample.trigger - samples[0]!.trigger) <= 1)).toBe(true) + expect(samples.every((sample) => sample.trigger < sample.bottom)).toBe(true) + }) +}) + +async function configurePage(page: Page) { + await page.addInitScript(() => { + localStorage.setItem( + "settings.v3", + JSON.stringify({ + general: { + editToolPartsExpanded: true, + shellToolPartsExpanded: true, + showReasoningSummaries: true, + }, + }), + ) + }) +} + +async function expectExpanded(locator: Locator, expected: boolean) { + await expect.poll(() => locator.evaluate(readExpanded)).toBe(expected) +} + +async function readToolState(page: Page) { + return page + .locator(`[data-timeline-part-id="${editPartID}"]`) + .first() + .evaluate( + (element, textPartID) => ({ + expanded: (() => { + const trigger = element.querySelector('[data-slot="collapsible-trigger"]') + const aria = trigger?.getAttribute("aria-expanded") + if (aria === "true") return true + if (aria === "false") return false + + const root = element.querySelector('[data-component="collapsible"]') + if (root?.hasAttribute("data-expanded")) return true + if (root?.hasAttribute("data-closed")) return false + + const content = element.querySelector('[data-slot="collapsible-content"]') + return !!content && content.getBoundingClientRect().height > 0 + })(), + row: element.closest("[data-timeline-row]")?.getAttribute("data-timeline-row"), + streamedTextVisible: !!document.querySelector(`[data-timeline-part-id="${textPartID}"]`), + }), + textPartID, + ) +} + +async function installDiffProbe(page: Page) { + await page.addInitScript(() => { + let shadowRootCount = 0 + const attachShadow = Element.prototype.attachShadow + Element.prototype.attachShadow = function (init) { + shadowRootCount += 1 + return attachShadow.call(this, init) + } + window.__timelineDiffProbe = { + reset: () => { + shadowRootCount = 0 + }, + shadowRoots: () => shadowRootCount, + } + }) +} + +async function markDiffProbe(page: Page) { + await page + .locator(`[data-timeline-part-id="${editPartID}"]`) + .first() + .evaluate((element) => { + const tool = element as HTMLElement + const file = tool.querySelector('[data-component="file"][data-mode="diff"]') + const row = tool.closest("[data-timeline-key]") + const frame = tool.closest("[data-timeline-row]") + if (!file) throw new Error("missing edit diff file") + if (!row) throw new Error("missing virtual timeline row") + if (!frame) throw new Error("missing timeline row frame") + + tool.dataset.timelineProbe = "before" + file.dataset.timelineProbe = "before" + row.dataset.timelineProbe = "before" + frame.dataset.timelineProbe = "before" + window.__timelineDiffProbe.reset() + }) +} + +async function readDiffProbe(page: Page) { + return page + .locator(`[data-timeline-part-id="${editPartID}"]`) + .first() + .evaluate((element) => { + const tool = element as HTMLElement + const file = tool.querySelector('[data-component="file"][data-mode="diff"]') + const row = tool.closest("[data-timeline-key]") + const frame = tool.closest("[data-timeline-row]") + return { + fileMarker: file?.dataset.timelineProbe, + shadowRoots: window.__timelineDiffProbe.shadowRoots(), + toolMarker: tool.dataset.timelineProbe, + rowMarker: row?.dataset.timelineProbe, + rowKey: row?.dataset.timelineKey, + frameMarker: frame?.dataset.timelineProbe, + } + }) +} + +function editPartWithAdditions(additions: number) { + return { + ...editPart, + state: { + ...editPart.state, + metadata: { + ...editPart.state.metadata, + filediff: { + ...editPart.state.metadata.filediff, + additions, + }, + }, + }, + } +} + +function readExpanded(element: Element) { + const trigger = element.querySelector('[data-slot="collapsible-trigger"]') + const aria = trigger?.getAttribute("aria-expanded") + if (aria === "true") return true + if (aria === "false") return false + + const root = element.querySelector('[data-component="collapsible"]') + if (root?.hasAttribute("data-expanded")) return true + if (root?.hasAttribute("data-closed")) return false + + const content = element.querySelector('[data-slot="collapsible-content"]') + return !!content && content.getBoundingClientRect().height > 0 +} + +async function mockServer(page: Page, events: EventPayload[], messages = [userMessage, assistantMessage]) { + await mockOpenCodeServer(page, { + directory, + project: project(), + provider: provider(), + sessions: [session()], + pageMessages: () => ({ items: messages }), + events: () => events.splice(0, 1), + eventRetry: 16, + }) +} + +function project() { + return { + id: projectID, + worktree: directory, + vcs: "git", + name: "timeline-state-regression", + time: { created: 1700000000000, updated: 1700000000000 }, + sandboxes: [], + } +} + +function session() { + return { + id: sessionID, + slug: "timeline-state-regression", + projectID, + directory, + title, + version: "dev", + time: { created: 1700000000000, updated: 1700000000000 }, + } +} + +function provider() { + return { + all: [ + { + id: "opencode", + name: "OpenCode", + models: { "claude-opus-4-6": { id: "claude-opus-4-6", name: "Claude Opus 4.6", limit: { context: 200_000 } } }, + }, + ], + connected: ["opencode"], + default: { providerID: "opencode", modelID: "claude-opus-4-6" }, + } +} + +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 new file mode 100644 index 0000000000000000000000000000000000000000..a9a4738da928a7e7c887ba69fb46a2ea3f44892e --- /dev/null +++ b/packages/app/e2e/regression/session-timeline-context-resize.spec.ts @@ -0,0 +1,374 @@ +import { expect, test, type Page } from "@playwright/test" +import { mockOpenCodeServer } from "../utils/mock-server" +import { expectAppVisible, expectSessionTitle } from "../utils/waits" +import { + analyzeVisualObservations, + defineVisualRegions, + startVisualProbe, + stopVisualProbe, + visualPlan, +} from "../utils/visual-stability" + +const directory = "C:/OpenCode/ContextResizeRegression" +const projectID = "proj_context_resize_regression" +const sessionID = "ses_context_resize_regression" +const title = "Context resize regression" +const model = { providerID: "opencode", modelID: "claude-opus-4-6", variant: "max" } +const contextIDs = ["prt_0100_read", "prt_0101_glob", "prt_0102_grep", "prt_0103_list"] +const followingTextID = "prt_0104_text" + +type Message = { + info: Record & { id: string; role: "user" | "assistant" } + parts: Record[] +} + +const messages = [...Array.from({ length: 8 }, (_, index) => turn(index, false)).flat(), ...turn(10, true)] + +test.describe("regression: session timeline context group resize", () => { + test("remeasures a recent explored context group before the next paint", async ({ page }) => { + await page.setViewportSize({ width: 1400, height: 900 }) + await mockServer(page) + await configurePage(page) + + await page.goto(`/${base64Encode(directory)}/session/${sessionID}`) + await expectSessionTitle(page, title) + await expectAppVisible(page.locator(`[data-timeline-part-ids="${contextIDs.join(",")}"]`).first()) + await expectAppVisible(page.locator(`[data-timeline-part-id="${followingTextID}"]`).first()) + await settle(page) + + const samples = await sampleExpansion(page) + const visibleOverlap = samples.filter((sample) => sample.frame >= 1 && sample.overlap > 0.5) + + expect(samples[0]?.overlap).toBe(0) + expect(visibleOverlap).toEqual([]) + expect(samples.at(-1)?.expanded).toBe("true") + }) + + test("paints a stable exploring to explored transition", async ({ page }) => { + const events: { directory: string; payload: Record }[] = [] + await page.setViewportSize({ width: 1400, height: 900 }) + await mockServer(page, events, [ + ...Array.from({ length: 8 }, (_, index) => turn(index, false)).flat(), + ...turn(10, true, "running"), + ]) + await configurePage(page) + + await page.goto(`/${base64Encode(directory)}/session/${sessionID}`) + await expectSessionTitle(page, title) + const devtools = await page.context().newCDPSession(page) + await devtools.send("Emulation.setCPUThrottlingRate", { rate: 4 }) + const context = page.locator(`[data-timeline-part-ids="${contextIDs.join(",")}"]`).first() + await expectAppVisible(context) + await expect(context.locator('[data-component="tool-status-title"]')).toHaveAttribute("aria-label", "Exploring") + + const contextSelector = `[data-timeline-part-ids="${contextIDs.join(",")}"]` + const regions = defineVisualRegions({ + status: { + selector: `${contextSelector} [data-component="tool-status-title"]`, + opacitySelectors: ['[data-slot="tool-status-active"]', '[data-slot="tool-status-done"]'], + }, + context: { selector: contextSelector, closest: '[data-timeline-row="AssistantPart"]' }, + following: { + selector: `[data-timeline-part-id="${followingTextID}"]`, + closest: '[data-timeline-row="AssistantPart"]', + }, + }) + await startVisualProbe(page, regions) + for (const [index, delay] of [120, 350, 80, 500].entries()) { + events.push({ + directory, + payload: { + type: "message.part.updated", + properties: { + part: contextTool( + contextIDs[index]!, + id("msg_assistant", 10), + ["read", "glob", "grep", "list"][index]!, + [ + { filePath: "src/recent-a.ts" }, + { path: directory, pattern: "**/*.ts" }, + { path: directory, pattern: "Explored" }, + { path: "src" }, + ][index]!, + ), + }, + }, + }) + await page.waitForTimeout(delay) + } + + await expect(context.locator('[data-component="tool-status-title"]')).toHaveAttribute("aria-label", "Explored") + await page.waitForTimeout(700) + const trace = await stopVisualProbe(page) + const labels = trace.samples + .map((sample) => sample.regions.status?.label) + .filter((value): value is string => !!value) + .filter((value, index, all) => value !== all[index - 1]) + const issues = analyzeVisualObservations( + trace.samples, + visualPlan(regions, [ + { type: "required", regions: ["context", "following"] }, + { type: "opacity", regions: "all" }, + { type: "continuity", regions: "all" }, + { type: "motion", regions: "all" }, + { type: "label-stability", regions: "all" }, + { type: "flow", regions: ["context", "following"] }, + ]), + ) + + expect(labels).toEqual(["Exploring", "Explored"]) + expect(issues, JSON.stringify(trace.samples, null, 2)).toEqual([]) + }) +}) + +async function configurePage(page: Page) { + await page.addInitScript(() => { + localStorage.setItem( + "settings.v3", + JSON.stringify({ + general: { + editToolPartsExpanded: true, + shellToolPartsExpanded: true, + showReasoningSummaries: true, + }, + }), + ) + }) +} + +async function sampleExpansion(page: Page) { + return page.evaluate( + ({ contextIDs, followingTextID }) => + new Promise< + { + frame: number + label: string + scrollTop: number + scrollHeight: number + contextBottom: number + textTop: number + overlap: number + gap: number + expanded: string | null + }[] + >((resolve) => { + const context = document.querySelector(`[data-timeline-part-ids="${contextIDs.join(",")}"]`) + const text = document.querySelector(`[data-timeline-part-id="${followingTextID}"]`) + const scroller = context?.closest(".scroll-view__viewport") + const trigger = context?.querySelector('[data-slot="collapsible-trigger"]') + const contextRow = context?.closest('[data-timeline-row="AssistantPart"]') + const textRow = text?.closest('[data-timeline-row="AssistantPart"]') + if (!context || !text || !scroller || !trigger || !contextRow || !textRow) + throw new Error("missing regression nodes") + + scroller.scrollTop = scroller.scrollHeight + const samples: { + frame: number + label: string + scrollTop: number + scrollHeight: number + contextBottom: number + textTop: number + overlap: number + gap: number + expanded: string | null + }[] = [] + const capture = (frame: number, label: string) => { + const contextRect = contextRow.getBoundingClientRect() + const textRect = textRow.getBoundingClientRect() + samples.push({ + frame, + label, + scrollTop: Math.round(scroller.scrollTop * 10) / 10, + scrollHeight: Math.round(scroller.scrollHeight * 10) / 10, + contextBottom: Math.round(contextRect.bottom * 10) / 10, + textTop: Math.round(textRect.top * 10) / 10, + overlap: Math.max(0, Math.round((contextRect.bottom - textRect.top) * 10) / 10), + gap: Math.max(0, Math.round((textRect.top - contextRect.bottom) * 10) / 10), + expanded: trigger.getAttribute("aria-expanded"), + }) + } + + capture(-1, "before") + trigger.click() + capture(0, "sync-after-click") + + let frame = 1 + const tick = () => { + setTimeout(() => { + capture(frame, "painted") + frame += 1 + if (frame > 8) { + resolve(samples) + return + } + requestAnimationFrame(tick) + }, 0) + } + requestAnimationFrame(tick) + }), + { contextIDs, followingTextID }, + ) +} + +function turn(index: number, target: boolean, status: "running" | "completed" = "completed"): Message[] { + const userID = id("msg_user", index) + const assistantID = id("msg_assistant", index) + return [ + { + info: { + id: userID, + sessionID, + role: "user", + time: { created: 1700000000000 + index * 10_000 }, + summary: { diffs: [] }, + agent: "build", + model, + }, + parts: [{ id: id("prt_user", index), sessionID, messageID: userID, type: "text", text: `User message ${index}` }], + }, + { + info: { + id: assistantID, + sessionID, + role: "assistant", + time: { created: 1700000000000 + index * 10_000 + 1_000, completed: 1700000000000 + index * 10_000 + 2_000 }, + parentID: userID, + modelID: model.modelID, + providerID: model.providerID, + mode: "build", + agent: "build", + path: { cwd: directory, root: directory }, + cost: 0.01, + tokens: { input: 100, output: 200, reasoning: 0, cache: { read: 0, write: 0 } }, + variant: "max", + finish: "stop", + }, + parts: target + ? [ + contextTool( + contextIDs[0]!, + assistantID, + "read", + { filePath: "src/recent-a.ts", offset: 0, limit: 120 }, + status, + ), + contextTool(contextIDs[1]!, assistantID, "glob", { path: directory, pattern: "**/*.ts" }, status), + contextTool( + contextIDs[2]!, + assistantID, + "grep", + { path: directory, pattern: "Explored", include: "*.ts" }, + status, + ), + contextTool(contextIDs[3]!, assistantID, "list", { path: "src" }, status), + { + id: followingTextID, + sessionID, + messageID: assistantID, + type: "text", + text: "This assistant text is immediately after the explored context group.", + }, + ] + : [ + { + id: id("prt_text", index), + sessionID, + messageID: assistantID, + type: "text", + text: `Assistant filler ${index}. ${"filler ".repeat(60)}`, + }, + ], + }, + ] +} + +function contextTool( + partID: string, + messageID: string, + tool: string, + input: Record, + status: "running" | "completed" = "completed", +) { + return { + id: partID, + sessionID, + messageID, + type: "tool", + callID: `call_${partID}`, + tool, + state: { + status, + input, + output: `Completed ${tool}.\n${"detail line\n".repeat(8)}`, + title: input.filePath || input.path || input.pattern || "completed", + metadata: {}, + time: { start: 1700000000000, end: 1700000000100 }, + }, + } +} + +async function mockServer( + page: Page, + events: { directory: string; payload: Record }[] = [], + fixtureMessages = messages, +) { + await mockOpenCodeServer(page, { + directory, + project: project(), + provider: provider(), + sessions: [session()], + pageMessages: () => ({ items: fixtureMessages }), + events: () => events.splice(0, 1), + eventRetry: 50, + }) +} + +async function settle(page: Page) { + await page.evaluate(() => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)))) +} + +function id(prefix: string, index: number) { + return `${prefix}_${String(index).padStart(4, "0")}` +} + +function project() { + return { + id: projectID, + worktree: directory, + vcs: "git", + name: "context-resize-regression", + time: { created: 1700000000000, updated: 1700000000000 }, + sandboxes: [], + } +} + +function session() { + return { + id: sessionID, + slug: "context-resize-regression", + projectID, + directory, + title, + version: "dev", + time: { created: 1700000000000, updated: 1700000000000 }, + } +} + +function provider() { + return { + all: [ + { + id: "opencode", + name: "OpenCode", + models: { "claude-opus-4-6": { id: "claude-opus-4-6", name: "Claude Opus 4.6", limit: { context: 200_000 } } }, + }, + ], + connected: ["opencode"], + default: { providerID: "opencode", modelID: "claude-opus-4-6" }, + } +} + +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-state.spec.ts b/packages/app/e2e/regression/session-timeline-context-state.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..37878325e14de8f16921c32bb7ac2d3a4e8b4ddf --- /dev/null +++ b/packages/app/e2e/regression/session-timeline-context-state.spec.ts @@ -0,0 +1,31 @@ +import { expect, test } from "@playwright/test" +import { + assistantMessage, + partUpdated, + setupTimeline, + toolPart, + userMessage, +} from "../performance/timeline-stability/fixture" + +test("preserves a collapsed context group through count and status updates", async ({ page }) => { + const ids = ["prt_closed_01_read", "prt_closed_02_glob"] + const inputs = { + read: { filePath: "src/a.ts", offset: 0, limit: 120 }, + glob: { path: ".", pattern: "**/*.ts" }, + } + const timeline = await setupTimeline(page, { + messages: [ + userMessage(), + assistantMessage( + [toolPart(ids[0]!, "read", "running", inputs.read), toolPart(ids[1]!, "glob", "running", inputs.glob)], + { completed: false }, + ), + ], + }) + const group = page.locator(`[data-timeline-part-ids="${ids.join(",")}"]`) + const trigger = group.locator('[data-slot="collapsible-trigger"]') + await expect(trigger).toHaveAttribute("aria-expanded", "false") + await timeline.send(partUpdated(toolPart(ids[0]!, "read", "completed", inputs.read)), 100) + await timeline.send(partUpdated(toolPart(ids[1]!, "glob", "completed", inputs.glob)), 300) + await expect(trigger).toHaveAttribute("aria-expanded", "false") +}) diff --git a/packages/app/e2e/regression/session-timeline-file-projection.spec.ts b/packages/app/e2e/regression/session-timeline-file-projection.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..f07da121c66674ecebf7fbf4192e3f34f297c1c6 --- /dev/null +++ b/packages/app/e2e/regression/session-timeline-file-projection.spec.ts @@ -0,0 +1,52 @@ +import { expect, test } from "@playwright/test" +import { assistantMessage, setupTimeline, toolPart, userMessage } from "../performance/timeline-stability/fixture" + +test("renders completed write content", async ({ page }) => { + const id = "prt_file_projection_write" + await setupTimeline(page, { + messages: [ + userMessage(), + assistantMessage([ + toolPart(id, "write", "completed", { filePath: "src/write.ts", content: "export const written = true\n" }), + ]), + ], + settings: { editToolPartsExpanded: true }, + }) + + await expect(page.locator(`[data-timeline-part-id="${id}"] [data-component="write-content"]`)).toBeVisible() +}) + +test("renders a completed single-file patch", async ({ page }) => { + const id = "prt_file_projection_single_patch" + await setupTimeline(page, { + messages: [ + userMessage(), + assistantMessage([ + toolPart( + id, + "apply_patch", + "completed", + { files: ["src/a.ts"] }, + { + metadata: { + files: [ + { + filePath: "src/a.ts", + relativePath: "src/a.ts", + type: "update", + additions: 1, + deletions: 1, + before: "export const value = 1\n", + after: "export const value = 2\n", + }, + ], + }, + }, + ), + ]), + ], + settings: { editToolPartsExpanded: true }, + }) + + await expect(page.locator(`[data-timeline-part-id="${id}"] [data-component="apply-patch-file-diff"]`)).toBeVisible() +}) diff --git a/packages/app/e2e/regression/session-timeline-file-state.spec.ts b/packages/app/e2e/regression/session-timeline-file-state.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..cb228c13c7af8ce8f8a400a6b833e331d8ac8207 --- /dev/null +++ b/packages/app/e2e/regression/session-timeline-file-state.spec.ts @@ -0,0 +1,94 @@ +import { expect, test } from "@playwright/test" +import { + assistantMessage, + partUpdated, + setupTimeline, + toolPart, + userMessage, +} from "../performance/timeline-stability/fixture" + +test("updates edit diagnostics without resetting manual collapse state", async ({ page }) => { + const editID = "prt_diagnostics_edit" + const base = editPart(editID, []) + const timeline = await setupTimeline(page, { + messages: [userMessage(), assistantMessage([base])], + settings: { editToolPartsExpanded: true }, + }) + const trigger = page.locator(`[data-timeline-part-id="${editID}"] [data-slot="collapsible-trigger"]`).first() + await trigger.click() + await expect(trigger).toHaveAttribute("aria-expanded", "false") + await timeline.send( + partUpdated(editPart(editID, [diagnostic("First failure", 2), diagnostic("Second failure", 4)])), + 300, + ) + await expect(trigger).toHaveAttribute("aria-expanded", "false") + await timeline.send(partUpdated(editPart(editID, [])), 300) + await expect(trigger).toHaveAttribute("aria-expanded", "false") +}) + +test("preserves nested patch file state through outer collapse and reopen", async ({ page }) => { + const patchID = "prt_nested_patch" + const files = [patchFile("src/a.ts", "update"), patchFile("src/b.ts", "add"), patchFile("src/old.ts", "delete")] + await setupTimeline(page, { + messages: [ + userMessage(), + assistantMessage([ + toolPart( + patchID, + "apply_patch", + "completed", + { files: files.map((file) => file.filePath) }, + { metadata: { files } }, + ), + ]), + ], + settings: { editToolPartsExpanded: true }, + }) + const wrapper = page.locator(`[data-timeline-part-id="${patchID}"]`) + const outer = wrapper.locator('[data-slot="collapsible-trigger"]').first() + const deleted = wrapper.locator('[data-scope="apply-patch"] [data-type="delete"]') + await deleted.getByRole("button").click() + await expect(deleted.getByRole("button")).toHaveAttribute("aria-expanded", "true") + await outer.click() + await expect(outer).toHaveAttribute("aria-expanded", "false") + await outer.click() + await expect(outer).toHaveAttribute("aria-expanded", "true") + await expect(deleted.getByRole("button")).toHaveAttribute("aria-expanded", "true") +}) + +function patchFile(filePath: string, type: "add" | "update" | "delete") { + return { + filePath, + relativePath: filePath, + type, + additions: type === "delete" ? 0 : 4, + deletions: type === "add" ? 0 : 3, + before: type === "add" ? undefined : source(false), + after: type === "delete" ? undefined : source(true), + } +} + +function editPart(id: string, diagnostics: Record[]) { + return toolPart( + id, + "edit", + "completed", + { filePath: "src/edit.ts" }, + { + metadata: { + filediff: { file: "src/edit.ts", additions: 1, deletions: 1, before: source(false), after: source(true) }, + diagnostics, + }, + }, + ) +} + +function diagnostic(message: string, line: number) { + return { message, severity: 1, range: { start: { line, character: 0 }, end: { line, character: 2 } } } +} + +function source(changed: boolean) { + return Array.from({ length: 12 }, (_, index) => `export const value${index} = ${changed ? index + 1 : index}\n`).join( + "", + ) +} diff --git a/packages/app/e2e/regression/session-timeline-history-root.spec.ts b/packages/app/e2e/regression/session-timeline-history-root.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..e5ef7998ea7f48be7d51e8adae3afc916d18484d --- /dev/null +++ b/packages/app/e2e/regression/session-timeline-history-root.spec.ts @@ -0,0 +1,242 @@ +import { base64Encode } from "@opencode-ai/core/util/encode" +import { expect, test, type Page } from "@playwright/test" +import { + assistantMessage, + directory, + messageUpdated, + project, + session, + sessionID, + status, + textPart, + title, + userID, + userMessage, +} from "../performance/timeline-stability/fixture" +import { mockOpenCodeServer } from "../utils/mock-server" +import { installSseTransport } from "../utils/sse-transport" +import { expectSessionTitle } from "../utils/waits" + +const initialPageSize = 20 +const historyPageSize = 200 +const assistants = Array.from({ length: initialPageSize + 1 }, (_, index) => + assistantMessage([textPart(`prt_history_root_${index}`, `Assistant response ${index}`)], { + id: `msg_${String(index + 1001).padStart(4, "0")}_history_root_assistant`, + parentID: userID, + created: 1700000001000 + index * 1_000, + completed: index < initialPageSize, + }), +) +const messages = [userMessage(), ...assistants] +const lastAssistant = assistants.at(-1)! +const lastPartID = assistants.at(-1)!.parts[0]!.id +const userPartID = `prt_${userID}_text` +const completed = { + ...lastAssistant.info, + time: { ...lastAssistant.info.time, completed: lastAssistant.info.time.created + 15_000 }, +} +const scenarios = [ + { name: "completion", info: completed, idleFirst: false, interrupted: false }, + { + name: "interruption", + info: { ...completed, error: { name: "MessageAbortedError", data: { message: "Stopped" } } }, + idleFirst: true, + interrupted: true, + }, +] as const + +test.use({ viewport: { width: 646, height: 1385 } }) + +for (const scenario of scenarios) { + test(`keeps visible timeline content visible through ${scenario.name}`, async ({ page }) => { + const requests: { before?: string; phase: "start" | "end" }[] = [] + const pages: { before?: string; limit: number }[] = [] + const roots: { sessionID: string; messageID: string }[] = [] + const sequence: string[] = [] + const history = Promise.withResolvers() + const transport = await installSseTransport<{ directory: string; payload: Record }>(page, { + server: `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`, + retry: 20, + }) + await mockOpenCodeServer(page, { + directory, + project: project(), + provider: { + all: [ + { + id: "opencode", + name: "OpenCode", + models: { + "claude-opus-4-6": { + id: "claude-opus-4-6", + name: "Claude Opus 4.6", + limit: { context: 200_000 }, + }, + }, + }, + ], + connected: ["opencode"], + default: { providerID: "opencode", modelID: "claude-opus-4-6" }, + }, + sessions: [session()], + sessionStatus: { [sessionID]: { type: "busy" } }, + beforeMessagesResponse: (request) => (request.before ? history.promise : Promise.resolve()), + onMessages: (request) => { + requests.push(request) + sequence.push(`messages:${request.phase}:${request.before ?? "latest"}`) + }, + onMessage: (request) => { + roots.push(request) + sequence.push(`message:${request.messageID}`) + }, + message: (requestedSessionID, messageID) => { + if (requestedSessionID !== sessionID) return + return messages.find((item) => item.info.id === messageID) + }, + pageMessages: (_, limit, before) => { + pages.push({ before, limit }) + const end = before ? messages.findIndex((message) => message.info.id === before) : messages.length + const start = Math.max(0, end - limit) + return { + items: messages.slice(start, end), + cursor: start > 0 ? messages[start]!.info.id : undefined, + } + }, + }) + await page.addInitScript(() => { + const visibleParts = () => { + const virtual = document.querySelector("[data-timeline-virtual-content]") + const viewport = virtual?.closest(".scroll-view__viewport") + const view = viewport?.getBoundingClientRect() + if (!viewport || !view) return [] + return [...viewport.querySelectorAll("[data-timeline-part-id]")] + .filter((part) => { + const rect = part.getBoundingClientRect() + return rect.width > 0 && rect.height > 0 && rect.bottom > view.top && rect.top < view.bottom + }) + .flatMap((part) => (part.dataset.timelinePartId ? [part.dataset.timelinePartId] : [])) + } + const state = { + armed: false, + hidden: false, + visibleParts: [] as string[], + samples: 0, + stop: false, + arm() { + state.visibleParts = visibleParts() + state.armed = true + }, + } + ;(window as Window & { __historyRootProbe?: typeof state }).__historyRootProbe = state + const sample = () => { + if (state.armed) { + const virtual = document.querySelector("[data-timeline-virtual-content]") + const viewport = virtual?.closest(".scroll-view__viewport") + const view = viewport?.getBoundingClientRect() + const visible = (partID: string) => { + const part = viewport?.querySelector(`[data-timeline-part-id="${CSS.escape(partID)}"]`) + const rect = part?.getBoundingClientRect() + return ( + !!rect && !!view && rect.width > 0 && rect.height > 0 && rect.bottom > view.top && rect.top < view.bottom + ) + } + if (!virtual || state.visibleParts.length === 0 || state.visibleParts.some((partID) => !visible(partID))) + state.hidden = true + state.samples++ + } + if (!state.stop) requestAnimationFrame(() => setTimeout(sample, 0)) + } + requestAnimationFrame(() => setTimeout(sample, 0)) + }) + + await page.goto(`/${base64Encode(directory)}/session/${sessionID}`) + await transport.waitForConnection() + await expectSessionTitle(page, title) + await expect(page.locator(`[data-timeline-part-id="${lastPartID}"]`)).toBeVisible() + await expect(page.locator(`[data-timeline-part-id="${userPartID}"]`)).toBeVisible() + await expect.poll(() => requests.filter((request) => request.phase === "start").length).toBe(2) + expect(requests.filter((request) => request.phase === "end")).toHaveLength(1) + expect(sequence.slice(0, 4)).toEqual([ + "messages:start:latest", + "messages:end:latest", + `message:${userID}`, + `messages:start:${messages.at(-initialPageSize)!.info.id}`, + ]) + await expect(page.locator('[data-timeline-part-id^="prt_history_root_"]')).toHaveCount(initialPageSize) + await page.evaluate(() => { + ;( + window as Window & { + __historyRootProbe?: { arm(): void } + } + ).__historyRootProbe!.arm() + }) + await waitForProbeSamples(page, 0) + expect(await visibleContentHidden(page)).toBe(false) + const beforeHistory = await probeSamples(page) + history.resolve() + await expect(page.locator('[data-timeline-part-id^="prt_history_root_"]')).toHaveCount(assistants.length) + await expect.poll(() => requests.filter((request) => request.phase === "end").length).toBe(2) + await expect(page.getByRole("button", { name: "Stop" })).toBeVisible() + await waitForProbeSamples(page, beforeHistory) + expect(pages).toEqual([ + { before: undefined, limit: initialPageSize }, + { before: messages.at(-initialPageSize)!.info.id, limit: historyPageSize }, + ]) + expect(roots).toEqual([{ sessionID, messageID: userID }]) + + const message = messageUpdated(scenario.info) + const idle = status("idle") + for (const event of scenario.idleFirst ? [idle, message] : [message, idle]) { + const beforeEvent = await probeSamples(page) + await transport.send(event) + if (event === idle) await expect(page.getByRole("button", { name: "Stop" })).toHaveCount(0) + if (event === message && scenario.interrupted) + await expect(page.getByText("Interrupted", { exact: true })).toBeVisible() + await waitForProbeSamples(page, beforeEvent) + const current = await timelineState(page) + expect(current, JSON.stringify(current)).toMatchObject({ virtual: true }) + expect(current.rows, JSON.stringify(current)).toBeGreaterThan(0) + } + + expect(requests[0]).toEqual({ before: undefined, phase: "start", sessionID }) + expect(requests[1]).toEqual({ before: undefined, phase: "end", sessionID }) + await expect(page.getByRole("button", { name: "Stop" })).toHaveCount(0) + await expect(page.locator('[data-timeline-row="bottom-spacer"]')).toBeVisible() + if (scenario.interrupted) await expect(page.getByText("Interrupted", { exact: true })).toBeVisible() + expect( + await page.evaluate(() => { + const state = (window as Window & { __historyRootProbe?: { hidden: boolean; stop: boolean } }) + .__historyRootProbe! + state.stop = true + return state.hidden + }), + ).toBe(false) + }) +} + +function timelineState(page: Page) { + return page.evaluate(() => ({ + virtual: !!document.querySelector("[data-timeline-virtual-content]"), + rows: document.querySelectorAll("[data-timeline-key]").length, + })) +} + +function probeSamples(page: Page) { + return page.evaluate( + () => (window as Window & { __historyRootProbe?: { samples: number } }).__historyRootProbe!.samples, + ) +} + +async function waitForProbeSamples(page: Page, after: number) { + await page.waitForFunction( + (after) => + (window as Window & { __historyRootProbe?: { samples: number } }).__historyRootProbe!.samples >= after + 3, + after, + ) +} + +function visibleContentHidden(page: Page) { + return page.evaluate( + () => (window as Window & { __historyRootProbe?: { hidden: boolean } }).__historyRootProbe!.hidden, + ) +} diff --git a/packages/app/e2e/regression/session-timeline-lifecycle-state.spec.ts b/packages/app/e2e/regression/session-timeline-lifecycle-state.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..b303071c87f7f699f602314c4f028d3df5fd80f4 --- /dev/null +++ b/packages/app/e2e/regression/session-timeline-lifecycle-state.spec.ts @@ -0,0 +1,111 @@ +import { expect, test } from "@playwright/test" +import { + assistantMessage, + completedAssistantInfo, + messageUpdated, + partUpdated, + reasoningPart, + setupTimeline, + shell, + status, + textPart, + userMessage, +} from "../performance/timeline-stability/fixture" + +for (const expanded of [false, true]) { + test(`preserves shell user intent from a ${expanded ? "expanded" : "collapsed"} default`, async ({ page }) => { + const id = `prt_shell_default_${expanded}` + const timeline = await setupTimeline(page, { + messages: [userMessage(), assistantMessage([shell(id, "completed", lines(3))])], + settings: { shellToolPartsExpanded: expanded }, + }) + const trigger = page.locator(`[data-timeline-part-id="${id}"] [data-slot="collapsible-trigger"]`) + await expect(trigger).toHaveAttribute("aria-expanded", String(expanded)) + await trigger.click() + await expect(trigger).toHaveAttribute("aria-expanded", String(!expanded)) + + await timeline.send(partUpdated(shell(id, "completed", lines(6))), 180) + await timeline.send(partUpdated(textPart(`prt_sibling_${expanded}`, "Sibling content")), 180) + await timeline.send(status("busy"), 100) + await timeline.send(status("idle"), 250) + await expect(trigger).toHaveAttribute("aria-expanded", String(!expanded)) + }) +} + +test("shows and expands a running shell command without shimmering it", async ({ page }) => { + const id = "prt_shell_running_command" + const command = "sleep 10 && echo done" + await setupTimeline(page, { + messages: [userMessage(), assistantMessage([shell(id, "running", "still running", command)], { completed: false })], + settings: { shellToolPartsExpanded: false }, + }) + + const tool = page.locator(`[data-timeline-part-id="${id}"]`) + await expect(tool.locator('[data-component="text-shimmer"]')).toHaveAttribute("data-active", "true") + await expect(tool.locator('[data-component="shell-submessage"]')).toHaveText(command) + await expect(tool.locator('[data-component="shell-submessage"] [data-component="text-shimmer"]')).toHaveCount(0) + await tool.locator('[data-slot="collapsible-trigger"]').click() + await expect(tool.locator('[data-slot="collapsible-trigger"]')).toHaveAttribute("aria-expanded", "true") + await expect(tool.locator('[data-slot="bash-pre"]')).toContainText("still running") +}) + +test("transitions thinking and hidden reasoning through busy to idle", async ({ page }) => { + const reasoningID = "prt_reasoning_hidden" + const assistant = assistantMessage([reasoningPart(reasoningID, "## Inspecting stability")], { completed: false }) + const timeline = await setupTimeline(page, { + messages: [userMessage(), assistant], + settings: { showReasoningSummaries: false }, + cpuRate: 4, + }) + await timeline.send(status("busy"), 150) + + await expect(page.locator('[data-timeline-row="Thinking"]')).toBeVisible() + await expect(page.getByText("Inspecting stability", { exact: true })).toBeVisible() + await expect(page.locator(`[data-timeline-part-id="${reasoningID}"]`)).toHaveCount(0) + await timeline.send(partUpdated(shell("prt_reasoning_shell", "running")), 160) + await expect(page.locator('[data-timeline-row="Thinking"]')).toBeVisible() + await timeline.send(partUpdated(shell("prt_reasoning_shell", "completed", "done")), 180) + await timeline.send(messageUpdated(completedAssistantInfo(assistant.info)), 100) + await timeline.send(status("idle"), 300) + await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(0) + await expect(page.locator(`[data-timeline-part-id="${reasoningID}"]`)).toHaveCount(0) +}) + +test("moves busy through retry and recovery to final idle content", async ({ page }) => { + const assistant = assistantMessage([], { completed: false }) + const timeline = await setupTimeline(page, { + messages: [ + userMessage(undefined, { + summary: { + diffs: [ + { + file: "src/retry.ts", + additions: 1, + deletions: 1, + patch: "@@ -1 +1 @@\n-export const retry = false\n+export const retry = true", + }, + ], + }, + }), + assistant, + ], + }) + await timeline.send(status("busy"), 140) + await expect(page.locator('[data-timeline-row="Thinking"]')).toBeVisible() + await expect(page.locator('[data-timeline-row="DiffSummary"]')).toHaveCount(0) + await timeline.send(status("retry"), 180) + await expect(page.locator('[data-timeline-row="Retry"]')).toBeVisible() + await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(0) + await timeline.send(status("busy", 2), 180) + await expect(page.locator('[data-timeline-row="Thinking"]')).toBeVisible() + await timeline.send(partUpdated(textPart("prt_recovered", "Recovered response")), 140) + await timeline.send(messageUpdated(completedAssistantInfo(assistant.info)), 100) + await timeline.send(status("idle"), 350) + await expect(page.locator('[data-timeline-row="Retry"]')).toHaveCount(0) + await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(0) + await expect(page.locator('[data-timeline-row="DiffSummary"]')).toBeVisible() +}) + +function lines(count: number) { + return Array.from({ length: count }, (_, index) => `line ${index + 1}`).join("\n") +} diff --git a/packages/app/e2e/regression/session-timeline-locale-projection.spec.ts b/packages/app/e2e/regression/session-timeline-locale-projection.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..d45ec0bd627bcb86128306d3109200104ae45ad0 --- /dev/null +++ b/packages/app/e2e/regression/session-timeline-locale-projection.spec.ts @@ -0,0 +1,25 @@ +import { expect, test } from "@playwright/test" +import { assistantMessage, setupTimeline, toolPart, userMessage } from "../performance/timeline-stability/fixture" + +for (const profile of [ + { locale: "de", label: "Erkundung abgeschlossen" }, + { locale: "ar", label: "تم الاستكشاف" }, +] as const) { + test(`projects translated context status in ${profile.locale}`, async ({ page }) => { + const ids = [`prt_locale_${profile.locale}_01_read`, `prt_locale_${profile.locale}_02_glob`] + await setupTimeline(page, { + messages: [ + userMessage(), + assistantMessage([ + toolPart(ids[0]!, "read", "completed", { filePath: "src/a.ts" }), + toolPart(ids[1]!, "glob", "completed", { path: ".", pattern: "**/*.ts" }), + ]), + ], + locale: profile.locale, + }) + + const group = page.locator(`[data-timeline-part-ids="${ids.join(",")}"]`) + await expect(group.locator('[data-component="tool-status-title"]')).toHaveAttribute("aria-label", profile.label) + await expect(page.locator("html")).toHaveAttribute("lang", profile.locale) + }) +} diff --git a/packages/app/e2e/regression/session-timeline-projection.spec.ts b/packages/app/e2e/regression/session-timeline-projection.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..54c84a2bea5de47a93e3659ed051bceb2b5ccf79 --- /dev/null +++ b/packages/app/e2e/regression/session-timeline-projection.spec.ts @@ -0,0 +1,283 @@ +import { expect, test } from "@playwright/test" +import { + assistantMessage, + setupTimeline, + status, + toolPart, + userMessage, + userText, + type PartSeed, +} from "../performance/timeline-stability/fixture" + +test.describe("session timeline projection", () => { + test("renders every admitted tool family and hides timeline-only exclusions", async ({ page }) => { + const parts = [ + toolPart("prt_01_read", "read", "completed", { filePath: "src/a.ts" }), + toolPart("prt_02_glob", "glob", "completed", { path: ".", pattern: "**/*.ts" }), + toolPart("prt_03_grep", "grep", "completed", { path: ".", pattern: "value" }), + toolPart("prt_04_list", "list", "completed", { path: "src" }), + toolPart("prt_webfetch", "webfetch", "completed", { url: "https://example.com" }), + toolPart( + "prt_websearch", + "websearch", + "completed", + { query: "timeline stability" }, + { output: "https://example.com/result" }, + ), + toolPart("prt_task", "task", "completed", { description: "Inspect timeline", subagent_type: "explore" }), + toolPart( + "prt_bash", + "bash", + "completed", + { command: "printf stable" }, + { output: "stable", title: "printf stable" }, + ), + editPart("prt_edit"), + toolPart("prt_write", "write", "completed", { filePath: "src/new.ts", content: "export const stable = true\n" }), + patchPart("prt_patch"), + toolPart("prt_todo", "todowrite", "completed", { todos: [{ content: "Hidden", status: "pending" }] }), + toolPart( + "prt_question", + "question", + "completed", + { questions: [{ question: "Keep stable?", header: "Stability", options: [] }] }, + { metadata: { answers: [["Yes"]] } }, + ), + toolPart("prt_skill", "skill", "completed", { name: "stability" }), + toolPart("prt_custom", "custom_mcp_tool", "completed", { target: "timeline", count: 2 }), + ] + await setupTimeline(page, { messages: [userMessage(), assistantMessage(parts)] }) + + await expect( + page.locator('[data-timeline-part-ids="prt_01_read,prt_02_glob,prt_03_grep,prt_04_list"]'), + ).toBeVisible() + for (const id of [ + "prt_webfetch", + "prt_websearch", + "prt_task", + "prt_bash", + "prt_edit", + "prt_write", + "prt_patch", + "prt_question", + "prt_skill", + "prt_custom", + ]) { + await expect(page.locator(`[data-timeline-part-id="${id}"]`).first(), id).toBeVisible() + } + await expect(page.locator('[data-timeline-part-id="prt_todo"]')).toHaveCount(0) + }) + + test("projects gaps, dividers, assistant parts, and errors together", async ({ page }) => { + const firstUser = userMessage( + [ + userText("The user made the following comment regarding lines 4 through 8 of src/a.ts: Keep this stable", { + id: "prt_comment", + synthetic: true, + metadata: { + opencodeComment: { + path: "src/a.ts", + selection: { startLine: 4, startChar: 0, endLine: 8, endChar: 0 }, + comment: "Keep this stable", + }, + }, + }), + userText("Continue after the comment", { id: "prt_visible_user" }), + ], + { summary: { diffs: Array.from({ length: 11 }, (_, index) => summaryDiff(index)) } }, + ) + const aborted = assistantMessage( + [ + { id: "prt_before_abort", type: "text", text: "Before interruption" }, + { id: "prt_compaction", type: "compaction", auto: true }, + ], + { + id: "msg_1001_assistant_aborted", + error: { name: "MessageAbortedError", data: { message: "Stopped" } }, + }, + ) + const failed = assistantMessage([{ id: "prt_after_abort", type: "text", text: "After interruption" }], { + id: "msg_1002_assistant_failed", + error: { + name: "APIError", + data: { + message: JSON.stringify({ error: { type: "provider_error", message: "Visible provider failure" } }), + isRetryable: false, + }, + }, + created: 1700000003000, + }) + const nextUser = userMessage([userText("Second turn", { id: "prt_second_user" })], { + id: "msg_2000_second_user", + created: 1700000005000, + }) + const nextAssistant = assistantMessage([{ id: "prt_second_text", type: "text", text: "Second response" }], { + id: "msg_2001_second_assistant", + parentID: "msg_2000_second_user", + created: 1700000006000, + }) + const timeline = await setupTimeline(page, { messages: [firstUser, aborted, failed, nextUser, nextAssistant] }) + await timeline.send(status("idle"), 100) + const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") }) + await scroller.evaluate((element) => (element.scrollTop = 0)) + + await expect(page.locator('[data-timeline-row="TurnDivider"]')).toHaveCount(1) + await expect(page.getByText("Session compacted", { exact: true })).toBeVisible() + await expect(page.getByText("Visible provider failure")).toBeVisible() + await scroller.evaluate((element) => (element.scrollTop = element.scrollHeight)) + await expect(page.locator('[data-timeline-row="TurnGap"]')).toBeVisible() + }) + + test("renders inline comments and historical diff summary overflow", async ({ page }) => { + const user = userMessage( + [ + userText("The user made the following comment regarding lines 4 through 8 of src/a.ts: Keep this stable", { + id: "prt_comment_only", + synthetic: true, + metadata: { + opencodeComment: { + path: "src/a.ts", + selection: { startLine: 4, startChar: 0, endLine: 8, endChar: 0 }, + comment: "Keep this stable", + }, + }, + }), + userText("Continue after the comment", { id: "prt_comment_visible" }), + ], + { summary: { diffs: Array.from({ length: 11 }, (_, index) => summaryDiff(index)) } }, + ) + const nextUser = userMessage(undefined, { id: "msg_2000_diff_next_user", created: 1700000010000 }) + const nextAssistant = assistantMessage([], { + id: "msg_2001_diff_next_assistant", + parentID: "msg_2000_diff_next_user", + created: 1700000011000, + }) + await setupTimeline(page, { messages: [user, assistantMessage(), nextUser, nextAssistant] }) + const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") }) + await scroller.evaluate((element) => (element.scrollTop = 0)) + + await expect(page.getByText("Keep this stable", { exact: true })).toBeVisible() + await expect(page.locator('[data-timeline-row="DiffSummary"]')).toBeVisible() + await expect(page.getByText(/show all/i)).toBeVisible() + }) + + test("renders interruption independently when the turn is not compacted", async ({ page }) => { + const user = userMessage() + const before = assistantMessage([{ id: "prt_before", type: "text", text: "Before" }], { + id: "msg_1001_before", + error: { name: "MessageAbortedError", data: { message: "Stopped" } }, + }) + const after = assistantMessage([{ id: "prt_after", type: "text", text: "After" }], { + id: "msg_1002_after", + created: 1700000003000, + }) + await setupTimeline(page, { messages: [user, before, after] }) + + await expect(page.getByText("Interrupted", { exact: true })).toBeVisible() + const rows = await page + .locator('[data-timeline-row="AssistantPart"], [data-timeline-row="TurnDivider"]') + .evaluateAll((elements) => elements.map((element) => element.getAttribute("data-timeline-row"))) + expect(rows).toEqual(["AssistantPart", "TurnDivider", "AssistantPart"]) + }) + + test("renders user image, file attachment, file reference, and agent reference", async ({ page }) => { + const text = "Use @explore with @src/a.ts and inspect the attachments" + const parts: PartSeed<"user">[] = [ + userText(text, { id: "prt_user_rich" }), + { + id: "prt_user_image", + type: "file", + mime: "image/png", + filename: "pixel.png", + url: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==", + }, + { + id: "prt_user_attachment", + type: "file", + mime: "application/json", + filename: "tsconfig.json", + url: "data:application/json;base64,e30=", + }, + { + id: "prt_user_reference", + type: "file", + mime: "text/plain", + filename: "a.ts", + url: "src/a.ts", + source: { type: "file", path: "src/a.ts", text: { value: "@src/a.ts", start: 18, end: 27 } }, + }, + { + id: "prt_user_agent", + type: "agent", + name: "explore", + source: { value: "@explore", start: 4, end: 12 }, + }, + ] + await setupTimeline(page, { messages: [userMessage(parts), assistantMessage()] }) + + await expect(page.getByAltText("pixel.png")).toBeVisible() + await expect(page.getByText("tsconfig.json")).toBeVisible() + await expect(page.getByText("@src/a.ts", { exact: true })).toBeVisible() + await expect(page.getByText("@explore", { exact: true })).toBeVisible() + }) +}) + +function editPart(id: string) { + return toolPart( + id, + "edit", + "completed", + { filePath: "src/a.ts" }, + { + metadata: { + filediff: { + file: "src/a.ts", + additions: 1, + deletions: 1, + before: "export const value = 1\n", + after: "export const value = 2\n", + }, + }, + }, + ) +} + +function patchPart(id: string) { + return toolPart( + id, + "apply_patch", + "completed", + { files: ["src/a.ts", "src/b.ts"] }, + { + metadata: { + files: [ + patchFile("src/a.ts", "update"), + patchFile("src/b.ts", "add"), + patchFile("src/old.ts", "delete"), + { ...patchFile("src/moved.ts", "move"), move: "src/new-place.ts" }, + ], + }, + }, + ) +} + +function patchFile(filePath: string, type: "add" | "update" | "delete" | "move") { + return { + filePath, + relativePath: filePath, + type, + additions: type === "delete" ? 0 : 1, + deletions: type === "add" ? 0 : 1, + before: type === "add" ? undefined : "export const before = true\n", + after: type === "delete" ? undefined : "export const after = true\n", + } +} + +function summaryDiff(index: number) { + return { + file: `src/diff-${index}.ts`, + additions: 1, + deletions: 1, + patch: `@@ -1 +1 @@\n-export const value = ${index}\n+export const value = ${index + 1}`, + } +} diff --git a/packages/app/e2e/regression/session-timeline-reasoning-projection.spec.ts b/packages/app/e2e/regression/session-timeline-reasoning-projection.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..7c0864e5845b82d60a533a4d4be138159938bfa8 --- /dev/null +++ b/packages/app/e2e/regression/session-timeline-reasoning-projection.spec.ts @@ -0,0 +1,93 @@ +import { expect, test } from "@playwright/test" +import { + assistantMessage, + reasoningPart, + setupTimeline, + status, + textPart, + toolPart, + userMessage, +} from "../performance/timeline-stability/fixture" + +const profiles = [ + { name: "summaries off no reasoning", summaries: false, reasoning: "", other: false, thinking: true, body: false }, + { + name: "summaries off reasoning heading", + summaries: false, + reasoning: "## Inspecting stability", + other: false, + thinking: true, + body: false, + }, + { + name: "summaries off with visible tool", + summaries: false, + reasoning: "## Inspecting stability", + other: true, + thinking: true, + body: false, + }, + { name: "summaries on no content", summaries: true, reasoning: "", other: false, thinking: true, body: false }, + { + name: "summaries on blank reasoning", + summaries: true, + reasoning: " ", + other: false, + thinking: true, + body: false, + }, + { + name: "summaries on visible reasoning", + summaries: true, + reasoning: "## Inspecting stability", + other: false, + thinking: false, + body: true, + }, + { + name: "summaries on visible tool no reasoning", + summaries: true, + reasoning: "", + other: true, + thinking: false, + body: false, + }, +] as const + +for (const profile of profiles) { + test(`projects busy reasoning profile ${profile.name}`, async ({ page }) => { + const reasoningID = `prt_reasoning_matrix_${profiles.indexOf(profile)}` + const parts = [ + ...(profile.reasoning ? [reasoningPart(reasoningID, profile.reasoning)] : []), + ...(profile.other + ? [toolPart(`prt_reasoning_tool_${profiles.indexOf(profile)}`, "skill", "running", { name: "inspect" })] + : []), + ] + const timeline = await setupTimeline(page, { + messages: [userMessage(), assistantMessage(parts, { completed: false })], + settings: { showReasoningSummaries: profile.summaries }, + }) + await timeline.send(status("busy"), 150) + + await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(profile.thinking ? 1 : 0) + await expect(page.locator(`[data-timeline-part-id="${reasoningID}"]`)).toHaveCount(profile.body ? 1 : 0) + if (!profile.summaries && profile.reasoning.trim()) { + await expect(page.getByText("Inspecting stability", { exact: true })).toBeVisible() + } + }) +} + +test("does not infer reasoning visibility from provider identity", async ({ page }) => { + const timeline = await setupTimeline(page, { + messages: [ + userMessage(), + assistantMessage([textPart("prt_provider_text", "No reasoning payload")], { completed: false }), + ], + settings: { showReasoningSummaries: true }, + }) + await timeline.send(status("busy"), 150) + + await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(0) + await expect(page.locator('[data-timeline-part-id*="reasoning"]')).toHaveCount(0) + await expect(page.locator('[data-timeline-part-id="prt_provider_text"]')).toBeVisible() +}) diff --git a/packages/app/e2e/regression/session-timeline-reducer-projection.spec.ts b/packages/app/e2e/regression/session-timeline-reducer-projection.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..ad35eef601cbb7559ff3268aca847d3511e64b36 --- /dev/null +++ b/packages/app/e2e/regression/session-timeline-reducer-projection.spec.ts @@ -0,0 +1,43 @@ +import { expect, test } from "@playwright/test" +import { + assistantMessage, + completedAssistantInfo, + messageUpdated, + partUpdated, + setupTimeline, + shell, + status, + textPart, + toolPart, + userMessage, +} from "../performance/timeline-stability/fixture" + +test("groups singleton and separated context operations at correct boundaries", async ({ page }) => { + const parts = [ + toolPart("prt_boundary_01_read", "read", "completed", { filePath: "src/a.ts" }), + textPart("prt_boundary_02_text", "Boundary text"), + toolPart("prt_boundary_03_glob", "glob", "completed", { path: ".", pattern: "**/*.ts" }), + toolPart("prt_boundary_04_grep", "grep", "completed", { path: ".", pattern: "stable" }), + shell("prt_boundary_05_shell", "completed", "done"), + toolPart("prt_boundary_06_list", "list", "completed", { path: "src" }), + ] + await setupTimeline(page, { messages: [userMessage(), assistantMessage(parts)] }) + + await expect(page.locator('[data-timeline-part-ids="prt_boundary_01_read"]')).toBeVisible() + await expect(page.locator('[data-timeline-part-ids="prt_boundary_03_glob,prt_boundary_04_grep"]')).toBeVisible() + await expect(page.locator('[data-timeline-part-ids="prt_boundary_06_list"]')).toBeVisible() + await expect(page.locator('[data-timeline-row="AssistantPart"]')).toHaveCount(5) +}) + +test("reducer-hardening: converges when idle arrives before final part and message completion", async ({ page }) => { + const textID = "prt_event_order_text" + const assistant = assistantMessage([textPart(textID, "Partial")], { completed: false }) + const timeline = await setupTimeline(page, { messages: [userMessage(), assistant] }) + await timeline.send(status("busy"), 100) + await timeline.send(status("idle"), 100) + await timeline.send(partUpdated(textPart(textID, "Final after early idle")), 120) + await timeline.send(messageUpdated(completedAssistantInfo(assistant.info)), 250) + + await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(0) + await expect(page.locator(`[data-timeline-part-id="${textID}"]`)).toContainText("Final after early idle") +}) diff --git a/packages/app/e2e/regression/session-timeline-shell-outline.spec.ts b/packages/app/e2e/regression/session-timeline-shell-outline.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..54139cc3711b4bc8077932e19d91a0f5353bfe1d --- /dev/null +++ b/packages/app/e2e/regression/session-timeline-shell-outline.spec.ts @@ -0,0 +1,228 @@ +import { expect, test, type Locator, type Page } from "@playwright/test" +import { + assistantMessage, + setupTimeline, + shell, + textPart, + toolPart, + userMessage, +} from "../performance/timeline-stability/fixture" + +for (const deviceScaleFactor of [1.25, 1.5]) { + test(`keeps the shell outline inside a fractionally short virtual row at ${deviceScaleFactor}x`, async ({ page }) => { + const shellID = "prt_shell_outline" + const timeline = await setupTimeline(page, { + messages: [userMessage(), assistantMessage([shell(shellID, "completed", "shell output")])], + settings: { newLayoutDesigns: true, shellToolPartsExpanded: true }, + reducedMotion: true, + deviceScaleFactor, + }) + const part = page.locator(`[data-timeline-part-id="${shellID}"]`) + const output = part.locator('[data-component="bash-output"]') + const row = page.locator("[data-timeline-key]", { has: part }) + await expect(output).toBeVisible() + await timeline.settle() + + const geometry = await row.evaluate((element) => { + const output = element.querySelector('[data-component="bash-output"]') + if (!output) throw new Error("Shell output is unavailable") + const rowRect = element.getBoundingClientRect() + const outputRect = output.getBoundingClientRect() + // Match a rounded-down measurement at a fractional device-pixel phase. + element.style.height = `${outputRect.bottom - rowRect.top - 0.49}px` + element.style.transform = "translateY(0.25px)" + output.style.setProperty("--v2-border-border-base", "rgb(255, 0, 255)") + output.style.setProperty("background", "rgb(0, 0, 0)", "important") + const style = getComputedStyle(output) + return { + outputWidth: outputRect.width, + outputHeight: outputRect.height, + borderColor: style.borderTopColor, + boxShadow: style.boxShadow, + clipMargin: getComputedStyle(element).overflowClipMargin, + } + }) + await timeline.settle() + + const clipped = await row.evaluate((element) => { + const output = element.querySelector('[data-component="bash-output"]')! + return output.getBoundingClientRect().bottom - element.getBoundingClientRect().bottom + }) + expect(clipped).toBeCloseTo(0.49, 1) + + expect(await page.evaluate(() => devicePixelRatio)).toBe(deviceScaleFactor) + const edges = await captureCardEdges(page, output) + + expect(edges.box.width).toBeCloseTo(geometry.outputWidth, 2) + expect(edges.box.height).toBeCloseTo(geometry.outputHeight, 2) + expect(geometry.borderColor).toBe("rgb(255, 0, 255)") + expect(geometry.boxShadow).toBe("none") + expect(geometry.clipMargin).toBe("0.5px") + expect(edges.magenta.top).toBeGreaterThan(0.75) + expect(edges.magenta.bottom).toBeGreaterThan(0.75) + expect(edges.magenta.vertical).toBeGreaterThanOrEqual(2) + }) +} + +test("keeps the patch card inside a fractionally short virtual row", async ({ page }) => { + const patchID = "prt_patch_outline" + const file = { + filePath: "src/outline.ts", + relativePath: "src/outline.ts", + type: "update", + additions: 1, + deletions: 1, + before: "const outline = false\n", + after: "const outline = true\n", + } + const timeline = await setupTimeline(page, { + messages: [ + userMessage(), + assistantMessage([ + toolPart(patchID, "apply_patch", "completed", { files: [file.filePath] }, { metadata: { files: [file] } }), + ]), + ], + settings: { editToolPartsExpanded: true, newLayoutDesigns: true }, + reducedMotion: true, + }) + const part = page.locator(`[data-timeline-part-id="${patchID}"]`) + const card = part.locator('[data-component="accordion"][data-scope="apply-patch"]') + const row = page.locator("[data-timeline-key]", { has: part }) + await expect(card).toBeVisible() + await timeline.settle() + + const geometry = await row.evaluate((element) => { + const card = element.querySelector('[data-component="accordion"][data-scope="apply-patch"]') + if (!card) throw new Error("Patch card is unavailable") + const rowRect = element.getBoundingClientRect() + const cardRect = card.getBoundingClientRect() + element.style.height = `${cardRect.bottom - rowRect.top - 0.49}px` + const clipMargin = getComputedStyle(element).overflowClipMargin + const bottom = element.getBoundingClientRect().bottom + return { + overflow: card.getBoundingClientRect().bottom - bottom, + paintOverflow: card.getBoundingClientRect().bottom - bottom - Number.parseFloat(clipMargin), + clipMargin, + cardWidth: cardRect.width, + cardHeight: cardRect.height, + } + }) + await timeline.settle() + + expect(geometry.overflow).toBeCloseTo(0.49, 1) + expect(geometry.paintOverflow).toBeLessThanOrEqual(0) + const edges = await captureCardEdges(page, card) + expect(edges.box.width).toBeCloseTo(geometry.cardWidth, 2) + expect(edges.box.height).toBeCloseTo(geometry.cardHeight, 2) + expect(edges.luminance.top).toBeLessThan(245) + expect(edges.luminance.bottom).toBeLessThan(245) + expect(Math.abs(edges.luminance.bottom - edges.luminance.top)).toBeLessThan(10) + expect(geometry.clipMargin).toBe("0.5px") +}) + +test("allows paint rounding for every framed row but not fixed turn gaps", async ({ page }) => { + const secondUserID = "msg_outline_second_user" + await setupTimeline(page, { + messages: [ + userMessage(undefined, { + summary: { + diffs: [ + { + file: "src/summary.ts", + additions: 1, + deletions: 1, + patch: "@@ -1 +1 @@\n-export const value = 1\n+export const value = 2", + }, + ], + }, + }), + assistantMessage([textPart("prt_outline_text", "Assistant text")]), + userMessage(undefined, { id: secondUserID, created: 1700000010000 }), + assistantMessage([], { + id: "msg_outline_second_assistant", + parentID: secondUserID, + created: 1700000011000, + }), + ], + }) + await expect(page.locator('[data-timeline-row="DiffSummary"]')).toBeVisible() + await expect(page.locator('[data-timeline-row="TurnGap"]')).toBeVisible() + + const rows = await page.locator("[data-timeline-key]").evaluateAll((elements) => + elements.map((element) => ({ + tag: element.querySelector("[data-timeline-row]")?.dataset.timelineRow, + clipMargin: getComputedStyle(element).overflowClipMargin, + })), + ) + expect(rows.filter((row) => row.tag !== "TurnGap").every((row) => row.clipMargin === "0.5px")).toBe(true) + expect(rows.filter((row) => row.tag === "TurnGap")).toEqual([{ tag: "TurnGap", clipMargin: "0px" }]) +}) + +async function captureCardEdges(page: Page, card: Locator) { + const box = await card.boundingBox() + if (!box) throw new Error("Tool card bounds are unavailable") + const viewport = page.viewportSize() + if (!viewport) throw new Error("Viewport bounds are unavailable") + const screenshot = await page.screenshot() + return page.evaluate( + async ({ source, box, viewport }) => { + const image = new Image() + image.src = source + await image.decode() + const canvas = document.createElement("canvas") + canvas.width = image.naturalWidth + canvas.height = image.naturalHeight + const context = canvas.getContext("2d") + if (!context) throw new Error("2D canvas is unavailable") + context.drawImage(image, 0, 0) + const scale = { + x: image.naturalWidth / viewport.width, + y: image.naturalHeight / viewport.height, + } + const rows = (candidates: number[]) => { + const left = Math.floor((box.x + 8) * scale.x) + const width = Math.floor((box.width - 16) * scale.x) + return candidates.map((row) => { + const pixels = context.getImageData(left, row, width, 1).data + const indexes = Array.from({ length: width }, (_, index) => index * 4) + return { + luminance: + indexes + .map((index) => (pixels[index]! + pixels[index + 1]! + pixels[index + 2]!) / 3) + .reduce((sum, value) => sum + value, 0) / width, + magenta: + indexes.filter((index) => pixels[index]! > 200 && pixels[index + 1]! < 180 && pixels[index + 2]! > 200) + .length / width, + } + }) + } + const pixels = context.getImageData(0, 0, image.naturalWidth, image.naturalHeight).data + const columns = new Uint32Array(image.naturalWidth) + for (let index = 0; index < pixels.length; index += 4) { + if (pixels[index]! <= 200 || pixels[index + 1]! >= 180 || pixels[index + 2]! <= 200) continue + columns[(index / 4) % image.naturalWidth] = columns[(index / 4) % image.naturalWidth]! + 1 + } + const top = box.y * scale.y + const bottom = (box.y + box.height) * scale.y + const topRows = rows([Math.floor(top) - 1, Math.floor(top), Math.ceil(top)]) + const bottomRows = rows([Math.floor(bottom) - 2, Math.floor(bottom) - 1, Math.ceil(bottom) - 1]) + return { + box, + luminance: { + top: Math.min(...topRows.map((row) => row.luminance)), + bottom: rows([Math.ceil(bottom) - 1])[0]!.luminance, + }, + magenta: { + top: Math.max(...topRows.map((row) => row.magenta)), + bottom: Math.max(...bottomRows.map((row) => row.magenta)), + vertical: Array.from(columns).filter((count) => count > box.height * scale.y * 0.75).length, + }, + } + }, + { + source: `data:image/png;base64,${screenshot.toString("base64")}`, + viewport, + box, + }, + ) +} diff --git a/packages/app/e2e/regression/session-timeline-tool-projection.spec.ts b/packages/app/e2e/regression/session-timeline-tool-projection.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..99f1acf270b4228e819eb437a6a602d41a7c70f8 --- /dev/null +++ b/packages/app/e2e/regression/session-timeline-tool-projection.spec.ts @@ -0,0 +1,99 @@ +import { expect, test } from "@playwright/test" +import { + assistantMessage, + partUpdated, + setupTimeline, + toolPart, + userMessage, +} from "../performance/timeline-stability/fixture" + +test("renders every tool error outcome without leaking hidden tools", async ({ page }) => { + const ordinary = ["bash", "edit", "write", "apply_patch", "webfetch", "websearch", "task", "skill", "mcp_probe"] + const parts = ordinary.map((tool, index) => + toolPart(`prt_error_${index}`, tool, "error", errorInput(tool), { error: `${tool} failed visibly` }), + ) + parts.push( + toolPart("prt_question_dismissed", "question", "error", questionInput(), { + error: "The user dismissed this question", + }), + toolPart("prt_question_error", "question", "error", questionInput(), { error: "Question transport failed" }), + toolPart("prt_todo_error", "todowrite", "error", { todos: [] }, { error: "Hidden todo failure" }), + ) + await setupTimeline(page, { messages: [userMessage(), assistantMessage(parts)] }) + + await expect(page.locator('[data-kind="tool-error-card"]')).toHaveCount(ordinary.length + 1) + await expect(page.getByText(/dismissed/i)).toBeVisible() + await expect(page.locator('[data-timeline-part-id="prt_todo_error"]')).toHaveCount(0) + for (let index = 0; index < ordinary.length; index++) { + await expect(page.locator(`[data-timeline-part-id="prt_error_${index}"]`)).toBeVisible() + } +}) + +test("transitions shell and question through running error outcomes", async ({ page }) => { + const shellID = "prt_transition_error_shell" + const questionID = "prt_transition_error_question" + const timeline = await setupTimeline(page, { + messages: [ + userMessage(), + assistantMessage( + [ + toolPart(shellID, "bash", "pending", { command: "exit 1" }), + toolPart(questionID, "question", "pending", questionInput()), + ], + { completed: false }, + ), + ], + }) + await timeline.waitForPart(shellID) + await expect(page.locator(`[data-timeline-part-id="${questionID}"]`)).toHaveCount(0) + await timeline.send(partUpdated(toolPart(shellID, "bash", "running", { command: "exit 1" })), 120) + await timeline.send(partUpdated(toolPart(questionID, "question", "running", questionInput())), 180) + await expect(page.locator(`[data-timeline-part-id="${questionID}"]`)).toHaveCount(0) + await timeline.send( + partUpdated(toolPart(shellID, "bash", "error", { command: "exit 1" }, { error: "Command exited 1" })), + 180, + ) + await timeline.send( + partUpdated( + toolPart(questionID, "question", "error", questionInput(), { error: "The user dismissed this question" }), + ), + 250, + ) + + await expect(page.locator(`[data-timeline-part-id="${shellID}"] [data-kind="tool-error-card"]`)).toBeVisible() + await expect(page.locator(`[data-timeline-part-id="${questionID}"]`)).toContainText(/dismissed/i) +}) + +test("labels all web search provider variants", async ({ page }) => { + const parts = [ + toolPart( + "prt_search_parallel", + "websearch", + "completed", + { query: "parallel" }, + { metadata: { provider: "parallel" } }, + ), + toolPart("prt_search_exa", "websearch", "completed", { query: "exa" }, { metadata: { provider: "exa" } }), + toolPart("prt_search_generic", "websearch", "completed", { query: "generic" }), + ] + await setupTimeline(page, { messages: [userMessage(), assistantMessage(parts)] }) + + await expect(page.getByRole("button", { name: /Parallel Web Search/ })).toBeVisible() + await expect(page.getByRole("button", { name: /Exa Web Search/ })).toBeVisible() + await expect(page.getByRole("button", { name: /^Web Search/ })).toBeVisible() +}) + +function questionInput() { + return { questions: [{ header: "Stability", question: "Keep it stable?", options: [] }] } +} + +function errorInput(tool: string) { + if (tool === "bash") return { command: "exit 1" } + if (["edit", "write"].includes(tool)) return { filePath: "src/error.ts", content: "" } + if (tool === "apply_patch") return { files: ["src/error.ts"] } + if (tool === "webfetch") return { url: "https://example.com" } + if (tool === "websearch") return { query: "failure" } + if (tool === "task") return { description: "Fail task", subagent_type: "explore" } + if (tool === "skill") return { name: "failure" } + return { target: "failure" } +} diff --git a/packages/app/e2e/regression/session-timeline-tool-state.spec.ts b/packages/app/e2e/regression/session-timeline-tool-state.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..63646f4454c6a2e1b613633df7e44772bc66b11f --- /dev/null +++ b/packages/app/e2e/regression/session-timeline-tool-state.spec.ts @@ -0,0 +1,77 @@ +import { expect, test } from "@playwright/test" +import { + assistantMessage, + partUpdated, + setupTimeline, + toolPart, + userMessage, +} from "../performance/timeline-stability/fixture" + +test("updates expanded web search links without resetting expansion", async ({ page }) => { + const searchID = "prt_websearch_mutation" + const input = { query: "timeline stability" } + const timeline = await setupTimeline(page, { + messages: [ + userMessage(), + assistantMessage([toolPart(searchID, "websearch", "completed", input, { output: "https://example.com/one" })]), + ], + }) + const wrapper = page.locator(`[data-timeline-part-id="${searchID}"]`) + const trigger = wrapper.locator('[data-slot="collapsible-trigger"]') + await trigger.click() + await expect(trigger).toHaveAttribute("aria-expanded", "true") + await timeline.send( + partUpdated( + toolPart(searchID, "websearch", "completed", input, { + output: "https://example.com/one\nhttps://example.com/two", + }), + ), + 300, + ) + await expect(trigger).toHaveAttribute("aria-expanded", "true") + await expect(wrapper.locator('a[href="https://example.com/two"]')).toBeVisible() +}) + +test("preserves an expanded tool error card across duplicate delivery", async ({ page }) => { + const toolID = "prt_duplicate_error" + const failed = toolPart(toolID, "bash", "error", { command: "exit 1" }, { error: "Command failed visibly" }) + const timeline = await setupTimeline(page, { messages: [userMessage(), assistantMessage([failed])] }) + const wrapper = page.locator(`[data-timeline-part-id="${toolID}"]`) + const trigger = wrapper.locator('[data-slot="collapsible-trigger"]') + await trigger.click() + await expect(trigger).toHaveAttribute("aria-expanded", "true") + await timeline.send(partUpdated(failed), 150) + await timeline.send(partUpdated(failed), 250) + await expect(trigger).toHaveAttribute("aria-expanded", "true") + await expect(wrapper).toContainText("Command failed visibly") +}) + +test("renders multiple question answers and preserves open state on answer updates", async ({ page }) => { + const questionID = "prt_multi_question" + const input = { + questions: [ + { header: "First", question: "First choice?", options: [] }, + { header: "Second", question: "Second choice?", options: [], multiple: true }, + ], + } + const timeline = await setupTimeline(page, { + messages: [ + userMessage(), + assistantMessage([ + toolPart(questionID, "question", "completed", input, { metadata: { answers: [["A"], ["B", "C"]] } }), + ]), + ], + }) + const wrapper = page.locator(`[data-timeline-part-id="${questionID}"]`) + const trigger = wrapper.locator('[data-slot="collapsible-trigger"]') + await expect(trigger).toHaveAttribute("aria-expanded", "true") + await timeline.send( + partUpdated( + toolPart(questionID, "question", "completed", input, { metadata: { answers: [["Updated"], ["B", "C"]] } }), + ), + 300, + ) + await expect(trigger).toHaveAttribute("aria-expanded", "true") + await expect(wrapper).toContainText("Updated") + await expect(wrapper).toContainText("B, C") +}) diff --git a/packages/app/e2e/regression/session-timeline-transport.spec.ts b/packages/app/e2e/regression/session-timeline-transport.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..359804997876f5a19c0ba9532b0951c16215f289 --- /dev/null +++ b/packages/app/e2e/regression/session-timeline-transport.spec.ts @@ -0,0 +1,154 @@ +import { expect, test, type Page } from "@playwright/test" +import { + assistantMessage, + partUpdated, + setupTimeline, + textPart, + userMessage, +} from "../performance/timeline-stability/fixture" + +test("keeps one connection open while delivering multiple events", async ({ page }) => { + const timeline = await setupTimeline(page) + + const first = await timeline.transport.send(partUpdated(textPart("prt_transport_first", "first event"))) + const second = await timeline.transport.send(partUpdated(textPart("prt_transport_second", "second event"))) + + await timeline.waitForPart("prt_transport_first") + await timeline.waitForPart("prt_transport_second") + expect(first.connectionID).toBe(second.connectionID) + await expect.poll(async () => (await timeline.transport.connections()).length).toBe(1) + expect(await timeline.transport.acknowledgements()).toHaveLength(2) +}) + +test("delivers a burst from one stream chunk", async ({ page }) => { + const timeline = await setupTimeline(page) + const acknowledgements = await timeline.transport.burst([ + partUpdated(textPart("prt_transport_burst_a", "burst a")), + partUpdated(textPart("prt_transport_burst_b", "burst b")), + ]) + + await timeline.waitForPart("prt_transport_burst_a") + await timeline.waitForPart("prt_transport_burst_b") + expect(acknowledgements.map((item) => item.chunkCount)).toEqual([1, 1]) + expect(new Set(acknowledgements.map((item) => item.deliveryID)).size).toBe(2) +}) + +test("parses split JSON and a split multibyte code point", async ({ page }) => { + const timeline = await setupTimeline(page) + const payload = partUpdated(textPart("prt_transport_split", "split snowman \u2603\u2603\u2603")) + const encoded = new TextEncoder().encode(`data: ${JSON.stringify(payload)}\n\n`) + const snowman = new TextEncoder().encode("\u2603")[0]! + const multibyte = encoded.indexOf(snowman) + + const acknowledgement = await timeline.transport.split(payload, [9, multibyte + 1, multibyte + 2]) + + await timeline.waitForPart("prt_transport_split") + await expect(page.locator('[data-timeline-part-id="prt_transport_split"]')).toContainText( + "split snowman \u2603\u2603\u2603", + ) + expect(acknowledgement.chunkCount).toBe(4) +}) + +test("delivers server heartbeat without mutating the timeline", async ({ page }) => { + const sentinelID = "prt_transport_heartbeat_sentinel" + const timeline = await setupTimeline(page, { + messages: [userMessage(), assistantMessage([textPart("prt_transport_steady", "steady")])], + }) + await timeline.waitForPart("prt_transport_steady") + const before = await stableTimelineRows(page) + + await timeline.transport.writeRaw(": heartbeat\n\n") + await timeline.transport.send(partUpdated(textPart(sentinelID, "heartbeat processed"))) + await timeline.waitForPart(sentinelID) + + await expect + .poll(async () => { + const rows = await timelineRows(page) + return rows.filter((row) => before.some((item) => item.key === row.key)) + }) + .toEqual(before) + await expect.poll(async () => (await timeline.transport.connections()).length).toBe(1) +}) + +test("reconnects after a clean close", async ({ page }) => { + const timeline = await setupTimeline(page) + const first = await timeline.transport.waitForConnection() + + await timeline.transport.close() + const second = await timeline.transport.waitForConnection({ after: first.id }) + await timeline.transport.send(partUpdated(textPart("prt_transport_close", "after close"))) + + await timeline.waitForPart("prt_transport_close") + expect(second.id).toBeGreaterThan(first.id) + expect((await timeline.transport.connections())[0]?.endedBy).toBe("close") +}) + +test("reconnects after a stream error", async ({ page }) => { + const timeline = await setupTimeline(page) + const first = await timeline.transport.waitForConnection() + + await timeline.transport.error("contract failure") + const second = await timeline.transport.waitForConnection({ after: first.id }) + await timeline.transport.send(partUpdated(textPart("prt_transport_error", "after error"))) + + await timeline.waitForPart("prt_transport_error") + await expect.poll(async () => (await timeline.transport.connections()).length).toBe(2) + expect(second.id).toBeGreaterThan(first.id) + expect((await timeline.transport.connections())[0]?.endedBy).toBe("error") +}) + +test("does not request replay when reconnecting the volatile V2 event stream", async ({ page }) => { + const timeline = await setupTimeline(page, { protocol: "v2" }) + const first = await timeline.transport.send(partUpdated(textPart("prt_transport_id", "event with id")), { + id: "timeline-event-7", + }) + await timeline.waitForPart("prt_transport_id") + + await timeline.transport.error("retry with event id") + const connection = await timeline.transport.waitForConnection({ after: first.connectionID }) + + expect(first.eventID).toBe("timeline-event-7") + expect(connection.headers["last-event-id"]).toBeUndefined() +}) + +test("passes through non-event fetches", async ({ page }) => { + const timeline = await setupTimeline(page) + + const health = await page.evaluate(async () => { + const response = await fetch("/global/health") + return response.json() + }) + + expect(health).toEqual({ healthy: true }) + await expect.poll(async () => (await timeline.transport.connections()).length).toBe(1) +}) + +async function stableTimelineRows(page: Page) { + let previous: Awaited> | undefined + let stable = 0 + await expect + .poll( + async () => { + const next = await timelineRows(page) + stable = JSON.stringify(next) === JSON.stringify(previous) ? stable + 1 : 0 + previous = next + return stable + }, + { intervals: [50, 50, 100] }, + ) + .toBeGreaterThanOrEqual(2) + return previous! +} + +function timelineRows(page: Page) { + return page.locator("[data-timeline-key]").evaluateAll((elements) => + elements.map((element) => ({ + key: element.getAttribute("data-timeline-key"), + row: element.querySelector("[data-timeline-row]")?.getAttribute("data-timeline-row"), + parts: Array.from(element.querySelectorAll("[data-timeline-part-id]"), (part) => + part.getAttribute("data-timeline-part-id"), + ), + text: element.textContent, + })), + ) +} diff --git a/packages/app/e2e/regression/session-todo-dock-navigation.spec.ts b/packages/app/e2e/regression/session-todo-dock-navigation.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..55e71212753c8d7c88e765a75b030b2e945a540b --- /dev/null +++ b/packages/app/e2e/regression/session-todo-dock-navigation.spec.ts @@ -0,0 +1,190 @@ +import { base64Encode } from "@opencode-ai/core/util/encode" +import { expect, test, type Page } from "@playwright/test" +import { mockOpenCodeServer } from "../utils/mock-server" +import { expectSessionTitle } from "../utils/waits" + +const directory = "C:/OpenCode/TodoDockNavigation" +const projectID = "proj_todo_dock_navigation" +const sourceID = "ses_todo_dock_source" +const otherID = "ses_todo_dock_other" +const sourceTitle = "Todo dock animation" +const otherTitle = "Separate session" + +const activeTodos = [ + { id: "todo-1", content: "Receive todos in the active session", status: "completed", priority: "high" }, + { id: "todo-2", content: "Keep the dock visible across tabs", status: "completed", priority: "high" }, + { id: "todo-3", content: "Close after the final todo", status: "in_progress", priority: "high" }, +] + +type EventPayload = { + directory: string + payload: Record +} + +test.use({ viewport: { width: 1440, height: 900 }, reducedMotion: "no-preference" }) + +test("animates todo lifecycle without replaying it across session tabs", async ({ page }) => { + test.setTimeout(90_000) + const events: EventPayload[] = [] + const todos: Record = { [sourceID]: [], [otherID]: [] } + const sessionStatus: Record = {} + + await mockOpenCodeServer(page, { + directory, + project: { + id: projectID, + worktree: directory, + vcs: "git", + name: "todo-dock-navigation", + time: { created: 1700000000000, updated: 1700000000000 }, + sandboxes: [], + }, + provider: { + all: [ + { + id: "opencode", + name: "OpenCode", + models: { + "claude-opus-4-6": { + id: "claude-opus-4-6", + name: "Claude Opus 4.6", + limit: { context: 200_000 }, + }, + }, + }, + ], + connected: ["opencode"], + default: { providerID: "opencode", modelID: "claude-opus-4-6" }, + }, + sessions: [session(sourceID, sourceTitle, 1700000000000), session(otherID, otherTitle, 1700000001000)], + sessionStatus: { [sourceID]: { type: "busy" } }, + pageMessages: () => ({ items: [] }), + events: () => events.splice(0, 1), + eventRetry: 16, + sessionStatus: () => sessionStatus, + todos: (sessionID) => todos[sessionID] ?? [], + }) + await configurePage(page) + + await page.goto(sessionHref(sourceID)) + await expectSessionTitle(page, sourceTitle) + const dock = page.locator('[data-component="session-todo-dock"]') + await expect(dock).toHaveCount(0) + + sessionStatus[sourceID] = { type: "busy" } + events.push(statusEvent(sourceID, "busy")) + await expect(page.getByRole("button", { name: "Stop" })).toBeVisible() + + await page.waitForTimeout(700) + const opening = sampleDock(page, 1_000) + todos[sourceID] = activeTodos + events.push(todoEvent(sourceID, activeTodos)) + await expect(dock).toBeVisible() + await expect(dock.locator('[data-state="in_progress"]')).toHaveCount(1) + expect((await opening).some((sample) => sample.opacity > 0.05 && sample.opacity < 0.95)).toBe(true) + + await switchSession(page, otherID, otherTitle) + await expect(dock).toHaveCount(0) + + const returningOpen = sampleDock(page, 700) + await switchSession(page, sourceID, sourceTitle) + const openSamples = (await returningOpen).filter((sample) => sample.present) + expect(openSamples.length).toBeGreaterThan(0) + expect(openSamples[0]!.opacity).toBeGreaterThan(0.98) + expect(openSamples[0]!.height).toBeGreaterThan(70) + await expect(dock.locator('[data-state="in_progress"]')).toHaveCount(1) + + const completedTodos = activeTodos.map((todo) => ({ ...todo, status: "completed" })) + const closing = sampleDock(page, 1_000) + todos[sourceID] = completedTodos + events.push(todoEvent(sourceID, completedTodos)) + await expect(dock).toHaveCount(0) + expect((await closing).some((sample) => sample.opacity > 0.05 && sample.opacity < 0.95)).toBe(true) + todos[sourceID] = [] + events.push(todoEvent(sourceID, [])) + + await switchSession(page, otherID, otherTitle) + const returningEmpty = sampleDock(page, 700) + await switchSession(page, sourceID, sourceTitle) + await expect(dock).toHaveCount(0) + expect((await returningEmpty).every((sample) => !sample.present)).toBe(true) +}) + +function session(id: string, title: string, created: number) { + return { + id, + slug: id, + projectID, + directory, + title, + version: "dev", + time: { created, updated: created }, + } +} + +function statusEvent(sessionID: string, type: "busy" | "idle"): EventPayload { + return { + directory, + payload: { type: "session.status", properties: { sessionID, status: { type } } }, + } +} + +function todoEvent(sessionID: string, next: typeof activeTodos): EventPayload { + return { + directory, + payload: { type: "todo.updated", properties: { sessionID, todos: next } }, + } +} + +async function configurePage(page: Page) { + const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}` + await page.addInitScript( + ({ directory, dirBase64, server, sessionIDs }) => { + localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } })) + localStorage.setItem( + "opencode.global.dat:server", + JSON.stringify({ + projects: { local: [{ worktree: directory, expanded: true }] }, + lastProject: { local: directory }, + }), + ) + localStorage.setItem( + "opencode.window.browser.dat:tabs", + JSON.stringify(sessionIDs.map((sessionId) => ({ type: "session", server, dirBase64, sessionId }))), + ) + }, + { directory, dirBase64: base64Encode(directory), server, sessionIDs: [sourceID, otherID] }, + ) +} + +function sessionHref(sessionID: string) { + const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}` + return `/server/${base64Encode(server)}/session/${sessionID}` +} + +async function switchSession(page: Page, sessionID: string, title: string) { + const href = sessionHref(sessionID) + const tab = page.locator(`[data-slot="titlebar-tabs"] a[href="${href}"]`).first() + await expect(tab).toBeVisible() + await tab.click() + await expectSessionTitle(page, title) +} + +function sampleDock(page: Page, duration: number) { + return page.evaluate(async (duration) => { + const samples: { present: boolean; height: number; opacity: number }[] = [] + const start = performance.now() + while (performance.now() - start < duration) { + const dock = document.querySelector('[data-component="session-todo-dock"]') + const clip = dock?.parentElement?.parentElement + const label = dock?.querySelector('[data-action="session-todo-toggle"] span[aria-label]') + samples.push({ + present: !!dock, + height: clip?.getBoundingClientRect().height ?? 0, + opacity: label ? Number.parseFloat(getComputedStyle(label).opacity) : 0, + }) + await new Promise(requestAnimationFrame) + } + return samples + }, duration) +} diff --git a/packages/app/e2e/regression/subagent-child-navigation.spec.ts b/packages/app/e2e/regression/subagent-child-navigation.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..c4ed6a78a040b5421d5cc9b6d948b4a8b27d3909 --- /dev/null +++ b/packages/app/e2e/regression/subagent-child-navigation.spec.ts @@ -0,0 +1,203 @@ +import { base64Encode } from "@opencode-ai/core/util/encode" +import { expect, test, type Page } from "@playwright/test" +import { currentSession, mockOpenCodeServer } from "../utils/mock-server" +import { expectSessionTitle } from "../utils/waits" + +const directory = "C:/OpenCode/SubagentNavigation" +const projectID = "proj_subagent_navigation" +const parentID = "ses_subagent_parent" +const childID = "ses_subagent_child" +const parentTitle = "Parent session" +const childTitle = "Subagent child session" +// Child session pages derive their heading from the task part that spawned them. +const taskDescription = "Inspect child navigation" + +type EventPayload = { directory: string; payload: Record } + +test.use({ viewport: { width: 1440, height: 900 } }) + +test("navigates to a subagent child session missing from the session list", async ({ page }) => { + await setup(page) + await openChildFromParent(page) + + await expectSessionTitle(page, taskDescription) + await expect(page.getByRole("heading", { name: parentTitle })).toHaveCount(0) + + const titlebarRight = page.locator("#opencode-titlebar-right") + await expect(titlebarRight.getByRole("button", { name: "Toggle review" })).toHaveCount(1) +}) + +test("shows the not found fallback when the viewed session is deleted", async ({ page }) => { + const events: EventPayload[] = [] + await setup(page, () => events.splice(0, 1)) + await openChildFromParent(page) + await expectSessionTitle(page, taskDescription) + + events.push({ + directory, + payload: { type: "session.deleted", properties: { info: childSession() } }, + }) + + await expect(page.getByText("This session cannot be found")).toBeVisible() + await expect(page.getByRole("button", { name: "Close Tab", exact: true })).toBeVisible() + await expect(page.getByRole("heading", { name: taskDescription })).toHaveCount(0) +}) + +async function setup(page: Page, events?: () => EventPayload[]) { + await mockOpenCodeServer(page, { + directory, + project: { + id: projectID, + worktree: directory, + vcs: "git", + name: "subagent-navigation", + time: { created: 1700000000000, updated: 1700000000000 }, + sandboxes: [], + }, + provider: { + all: [ + { + id: "opencode", + name: "OpenCode", + models: { + "claude-opus-4-6": { id: "claude-opus-4-6", name: "Claude Opus 4.6", limit: { context: 200_000 } }, + }, + }, + ], + connected: ["opencode"], + default: { providerID: "opencode", modelID: "claude-opus-4-6" }, + }, + sessions: [session(parentID, parentTitle, 1700000000000), childSession()], + pageMessages: (sessionID) => ({ items: sessionID === parentID ? parentMessages() : [] }), + events, + eventRetry: events ? 16 : undefined, + }) + // The child session resolves by ID but is absent from the session list, + // matching a subagent session that has not been loaded into the list cache yet. + await page.route( + (url) => url.pathname === "/api/session" && url.port === (process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"), + (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + headers: { "access-control-allow-origin": "*" }, + body: JSON.stringify({ + data: [currentSession(session(parentID, parentTitle, 1700000000000))], + cursor: {}, + }), + }), + ) + await configurePage(page) +} + +async function openChildFromParent(page: Page) { + await page.goto(sessionHref(parentID)) + await expectSessionTitle(page, parentTitle) + + const card = page.locator(`a[href="${sessionHref(childID)}"]`) + await expect(card).toBeVisible() + await card.click() + + await expect(page).toHaveURL(new RegExp(`/server/.+/session/${childID}$`), { timeout: 15_000 }) +} + +function session(id: string, title: string, created: number, extra?: Record) { + return { + id, + slug: id, + projectID, + directory, + title, + version: "dev", + time: { created, updated: created }, + ...extra, + } +} + +function childSession() { + return session(childID, childTitle, 1700000001000, { parentID }) +} + +function parentMessages() { + const userID = "msg_user_0001" + const assistantID = "msg_assistant_0001" + return [ + { + info: { + id: userID, + sessionID: parentID, + role: "user", + time: { created: 1700000000000 }, + agent: "build", + model: { providerID: "opencode", modelID: "claude-opus-4-6" }, + }, + parts: [ + { + id: "prt_user_text_0001", + sessionID: parentID, + messageID: userID, + type: "text", + text: "Delegate work to a subagent", + }, + ], + }, + { + info: { + id: assistantID, + sessionID: parentID, + role: "assistant", + time: { created: 1700000001000, completed: 1700000002000 }, + parentID: userID, + modelID: "claude-opus-4-6", + providerID: "opencode", + mode: "build", + agent: "build", + path: { cwd: directory, root: directory }, + cost: 0.01, + tokens: { input: 100, output: 200, reasoning: 0, cache: { read: 0, write: 0 } }, + finish: "stop", + }, + parts: [ + { + id: "prt_tool_task_0001", + sessionID: parentID, + messageID: assistantID, + type: "tool", + callID: "call_task_0001", + tool: "task", + state: { + status: "completed", + input: { description: taskDescription, subagent_type: "explore" }, + output: "Subagent finished", + title: taskDescription, + metadata: { sessionId: childID }, + time: { start: 1700000001000, end: 1700000002000 }, + }, + }, + ], + }, + ] +} + +async function configurePage(page: Page) { + const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}` + await page.addInitScript( + ({ directory, server, sessionId }) => { + localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } })) + localStorage.setItem( + "opencode.global.dat:server", + JSON.stringify({ + projects: { local: [{ worktree: directory, expanded: true }] }, + lastProject: { local: directory }, + }), + ) + localStorage.setItem("opencode.window.browser.dat:tabs", JSON.stringify([{ type: "session", server, sessionId }])) + }, + { directory, server, sessionId: parentID }, + ) +} + +function sessionHref(sessionID: string) { + const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}` + return `/server/${base64Encode(server)}/session/${sessionID}` +} diff --git a/packages/app/e2e/regression/tab-navigate-mousedown.spec.ts b/packages/app/e2e/regression/tab-navigate-mousedown.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..b969b590d89ba9e1c69e34ae919811e8133981ea --- /dev/null +++ b/packages/app/e2e/regression/tab-navigate-mousedown.spec.ts @@ -0,0 +1,159 @@ +import { expect, test, type Page, type Route } from "@playwright/test" +import { base64Encode } from "@opencode-ai/core/util/encode" +import { currentSession } from "../utils/mock-server" + +const server = "http://127.0.0.1:4096" +const sessionA = session("ses_tab_a", "Tab A session") +const sessionB = session("ses_tab_b", "Tab B session") +const sessionC = session("ses_tab_c", "Tab C session") +const unresolvedSessionID = "ses_tab_unresolved" + +test("pressing mouse down on a tab navigates before mouse up", async ({ page }) => { + await mockServer(page) + await page.addInitScript( + ({ server, sessionA, sessionB }) => { + localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } })) + localStorage.setItem( + "opencode.window.browser.dat:tabs", + JSON.stringify([ + { type: "session", server, sessionId: sessionA }, + { type: "session", server, sessionId: sessionB }, + ]), + ) + }, + { server, sessionA: sessionA.id, sessionB: sessionB.id }, + ) + + const hrefA = `/server/${base64Encode(server)}/session/${sessionA.id}` + const hrefB = `/server/${base64Encode(server)}/session/${sessionB.id}` + await page.goto(hrefA) + await expect(page.getByText(sessionA.title).first()).toBeVisible() + + const linkB = page.locator(`a[data-titlebar-tab-link][href="${hrefB}"]`) + await expect(linkB).toBeVisible() + const box = await linkB.boundingBox() + if (!box) throw new Error("tab link has no bounding box") + await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2) + await page.mouse.down() + + // Navigation must happen on mousedown, before the button is released. + await expect(page).toHaveURL(new RegExp(`${hrefB.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`)) + await page.mouse.up() + await expect(page).toHaveURL(new RegExp(`${hrefB.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`)) +}) + +test("keyboard navigation follows the visible tab order", async ({ page }) => { + await mockServer(page) + await page.addInitScript( + ({ server, sessionA, unresolved, sessionC }) => { + localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } })) + localStorage.setItem( + "opencode.window.browser.dat:tabs", + JSON.stringify([ + { type: "session", server, sessionId: sessionA }, + { type: "session", server, sessionId: unresolved }, + { type: "session", server, sessionId: sessionC }, + ]), + ) + }, + { server, sessionA: sessionA.id, unresolved: unresolvedSessionID, sessionC: sessionC.id }, + ) + + const hrefA = `/server/${base64Encode(server)}/session/${sessionA.id}` + const hrefC = `/server/${base64Encode(server)}/session/${sessionC.id}` + await page.goto(hrefA) + await expect(page.locator("[data-titlebar-tab-slot]:visible")).toHaveCount(2) + await expect(page.locator(`[data-titlebar-tab-slot]:has(a[href="${hrefC}"])`)).toBeVisible() + + await page.keyboard.press("Control+Alt+ArrowRight") + + await expect(page).toHaveURL(new RegExp(`${hrefC.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`)) +}) + +function session(id: string, title: string) { + return { + id, + slug: id, + projectID: "project-tabs", + directory: "C:/tab-project", + title, + version: "dev", + time: { created: 1, updated: 1 }, + } +} + +async function mockServer(page: Page) { + const sessions = [sessionA, sessionB, sessionC] + await page.route("**/*", async (route) => { + const url = new URL(route.request().url()) + if (url.origin !== server) return route.fallback() + if ([`/api/session/${unresolvedSessionID}`, `/session/${unresolvedSessionID}`].includes(url.pathname)) + return new Promise(() => {}) + if (url.pathname === "/global/event" || url.pathname === "/event" || url.pathname === "/api/event") + return sse(route) + if (url.pathname === "/global/health") return json(route, { healthy: true }) + if (url.pathname === "/api/session") return json(route, { data: sessions.map(currentSession), cursor: {} }) + if (url.pathname === "/api/session/active") return json(route, { data: {} }) + const currentSessionInfo = sessions.find((item) => url.pathname === `/api/session/${item.id}`) + if (currentSessionInfo) return json(route, { data: currentSession(currentSessionInfo) }) + if (sessions.some((item) => url.pathname === `/api/session/${item.id}/message`)) + return json(route, { data: [], cursor: {} }) + const byId = sessions.find((item) => url.pathname === `/session/${item.id}`) + if (byId) return json(route, byId) + if (/^\/session\/[^/]+$/.test(url.pathname)) return json(route, { name: "NotFoundError" }, 404) + if (/^\/session\/[^/]+\/message$/.test(url.pathname)) return json(route, []) + if (/^\/session\/[^/]+\/(children|todo|diff)$/.test(url.pathname)) return json(route, []) + if (["/skill", "/command", "/lsp", "/formatter", "/permission", "/question", "/vcs/diff"].includes(url.pathname)) + return json(route, []) + if (["/global/config", "/config", "/provider/auth", "/mcp"].includes(url.pathname)) return json(route, {}) + if (url.pathname === "/provider") + return json(route, { all: [], connected: [], default: { providerID: "", modelID: "" } }) + if (url.pathname === "/agent") return json(route, [{ name: "build", mode: "primary" }]) + if (url.pathname === "/project" || url.pathname === "/project/current") { + const project = { + id: sessionA.projectID, + worktree: sessionA.directory, + vcs: "git", + time: { created: 1, updated: 1 }, + sandboxes: [], + } + return json(route, url.pathname === "/project" ? [project] : project) + } + if (url.pathname === "/path") + return json(route, { + state: sessionA.directory, + config: sessionA.directory, + worktree: sessionA.directory, + directory: sessionA.directory, + home: sessionA.directory, + }) + if (url.pathname === "/api/path") + return json(route, { + state: sessionA.directory, + config: sessionA.directory, + worktree: sessionA.directory, + directory: sessionA.directory, + home: sessionA.directory, + }) + if (url.pathname === "/vcs") return json(route, { branch: "main", default_branch: "main" }) + if (url.pathname === "/api/vcs") + return json(route, { + location: { directory: sessionA.directory }, + data: { branch: "main", defaultBranch: "main" }, + }) + return json(route, {}) + }) +} + +function json(route: Route, body: unknown, status = 200) { + return route.fulfill({ + status, + contentType: "application/json", + headers: { "access-control-allow-origin": "*" }, + body: JSON.stringify(body), + }) +} + +function sse(route: Route) { + return route.fulfill({ status: 200, contentType: "text/event-stream", body: ": ok\n\n" }) +} diff --git a/packages/app/e2e/regression/terminal-composer-focus.spec.ts b/packages/app/e2e/regression/terminal-composer-focus.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..99bf689085087edc9da025a13d74d562ce333a4f --- /dev/null +++ b/packages/app/e2e/regression/terminal-composer-focus.spec.ts @@ -0,0 +1,227 @@ +import { base64Encode } from "@opencode-ai/core/util/encode" +import { expect, test, type Page } from "@playwright/test" +import { mockOpenCodeServer } from "../utils/mock-server" +import { expectSessionTitle } from "../utils/waits" + +const directory = "C:/OpenCode/TerminalComposerFocus" +const projectID = "proj_terminal_composer_focus" +const sessionID = "ses_terminal_composer_focus" +const ptyID = "pty_terminal_composer_focus" +const newPtyID = "pty_terminal_composer_focus_new" + +test.use({ viewport: { width: 1440, height: 900 } }) + +test.beforeEach(async ({ page }) => { + await mockOpenCodeServer(page, { + protocol: "v2", + directory, + project: { + id: projectID, + worktree: directory, + vcs: "git", + name: "terminal-composer-focus", + time: { created: 1700000000000, updated: 1700000000000 }, + sandboxes: [], + }, + provider: { + all: [ + { + id: "opencode", + name: "OpenCode", + models: { test: { id: "test", name: "Test", limit: { context: 200_000 } } }, + }, + ], + connected: ["opencode"], + default: { providerID: "opencode", modelID: "test" }, + }, + sessions: [ + { + id: sessionID, + slug: "terminal-composer-focus", + projectID, + directory, + title: "Terminal composer focus", + version: "dev", + time: { created: 1700000000000, updated: 1700000000000 }, + }, + ], + pageMessages: () => ({ items: [] }), + }) + await page.route("**/api/pty*", (route) => { + expect(new URL(route.request().url()).searchParams.get("location[directory]")).toBe(directory) + return route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ location: ptyLocation(), data: ptyInfo(ptyID, "Terminal 1") }), + }) + }) + await page.route(`**/api/pty/${ptyID}*`, (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ location: ptyLocation(), data: ptyInfo(ptyID, "Terminal 1") }), + }), + ) + await page.route(`**/api/pty/${ptyID}/connect-token*`, (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + headers: { "access-control-allow-origin": "*" }, + body: JSON.stringify({ location: ptyLocation(), data: { ticket: "e2e-ticket", expires_in: 60 } }), + }), + ) + await page.routeWebSocket(new RegExp(`/api/pty/${ptyID}/connect`), () => undefined) + await page.addInitScript(() => { + localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } })) + }) +}) + +test("routes typing to the composer unless the open terminal is focused", async ({ page }) => { + await page.goto(`/${base64Encode(directory)}/session/${sessionID}`) + await expectSessionTitle(page, "Terminal composer focus") + + const composer = page.locator('[data-component="prompt-input"]') + const terminal = page.locator('[data-component="terminal"]') + await page.keyboard.press("Control+Backquote") + await expect(terminal).toBeVisible() + await expect.poll(() => terminal.evaluate((element) => element.contains(document.activeElement))).toBe(true) + + await page.keyboard.type("x") + await expect(composer).toHaveText("") + + await page.waitForTimeout(300) + await page.evaluate(() => (document.activeElement as HTMLElement | null)?.blur()) + await page.keyboard.type("a") + + await expect(composer).toBeFocused() + await expect(composer).toHaveText("a") +}) + +test("keeps composer focus when a cached terminal finishes mounting", async ({ page }) => { + const ghostty = Promise.withResolvers() + const release = Promise.withResolvers() + const created = { count: 0 } + await page.route("**/api/pty*", (route) => { + created.count += 1 + return route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ location: ptyLocation(), data: ptyInfo(ptyID, "Terminal 1") }), + }) + }) + await page.route(/ghostty-web/, async (route) => { + ghostty.resolve() + await release.promise + await route.continue() + }) + await seedCachedTerminal(page) + + await page.goto(`/${base64Encode(directory)}/session/${sessionID}`, { waitUntil: "commit" }) + await expectSessionTitle(page, "Terminal composer focus") + + const composer = page.locator('[data-component="prompt-input"]') + const terminal = page.locator('[data-component="terminal"]') + await expect(terminal).toBeVisible() + expect(created.count).toBe(0) + await ghostty.promise + await composer.click() + await expect(composer).toBeFocused() + + release.resolve() + await expect(terminal.locator("textarea")).toHaveCount(1) + await page.waitForTimeout(300) + await expect(composer).toBeFocused() +}) + +test("keeps newer composer focus while an explicit terminal open finishes", async ({ page }) => { + const ghostty = Promise.withResolvers() + const release = Promise.withResolvers() + await page.route(/ghostty-web/, async (route) => { + ghostty.resolve() + await release.promise + await route.continue() + }) + + await page.goto(`/${base64Encode(directory)}/session/${sessionID}`) + await expectSessionTitle(page, "Terminal composer focus") + + const composer = page.locator('[data-component="prompt-input"]') + const terminal = page.locator('[data-component="terminal"]') + await page.keyboard.press("Control+Backquote") + await expect(terminal).toBeVisible() + await ghostty.promise + await composer.click() + await expect(composer).toBeFocused() + + release.resolve() + await expect(terminal.locator("textarea")).toHaveCount(1) + await page.waitForTimeout(50) + await expect(composer).toBeFocused() +}) + +test("focuses a terminal created from the new-terminal button", async ({ page }) => { + const created = { count: 0 } + await page.route("**/api/pty*", (route) => { + created.count += 1 + const next = created.count === 1 ? ptyInfo(ptyID, "Terminal 1") : ptyInfo(newPtyID, "Terminal 2") + return route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ location: ptyLocation(), data: next }), + }) + }) + await page.route(`**/api/pty/${newPtyID}*`, (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ location: ptyLocation(), data: ptyInfo(newPtyID, "Terminal 2") }), + }), + ) + await page.route(`**/api/pty/${newPtyID}/connect-token*`, (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + headers: { "access-control-allow-origin": "*" }, + body: JSON.stringify({ location: ptyLocation(), data: { ticket: "e2e-ticket", expires_in: 60 } }), + }), + ) + await page.routeWebSocket(new RegExp(`/api/pty/${newPtyID}/connect`), () => undefined) + + await page.goto(`/${base64Encode(directory)}/session/${sessionID}`) + await expectSessionTitle(page, "Terminal composer focus") + + const composer = page.locator('[data-component="prompt-input"]') + const terminal = page.locator('[data-component="terminal"]') + await page.keyboard.press("Control+Backquote") + await expect(terminal.locator("textarea")).toHaveCount(1) + await composer.click() + await expect(composer).toBeFocused() + + await page.getByRole("button", { name: "New terminal" }).click() + await expect(page.getByRole("tab", { name: "Terminal 2" })).toHaveAttribute("aria-selected", "true") + await expect.poll(() => terminal.evaluate((element) => element.contains(document.activeElement))).toBe(true) +}) + +function seedCachedTerminal(page: Page) { + return page.addInitScript( + ({ terminalKey, ptyID }) => { + localStorage.setItem("opencode.global.dat:layout", JSON.stringify({ terminal: { height: 320, opened: true } })) + localStorage.setItem( + terminalKey, + JSON.stringify({ + active: ptyID, + all: [{ id: ptyID, title: "Terminal 1", titleNumber: 1 }], + }), + ) + }, + { terminalKey: `${base64Encode(directory)}/terminal.v1`, ptyID }, + ) +} + +function ptyLocation() { + return { directory, project: { id: projectID, directory } } +} + +function ptyInfo(id: string, title: string) { + return { id, title, command: "cmd.exe", args: [], cwd: directory, status: "running", pid: 1 } +} diff --git a/packages/app/e2e/regression/terminal-hidden.spec.ts b/packages/app/e2e/regression/terminal-hidden.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..8e08d60ff2accbafbaec83be2c3f2c6bc75d3c95 --- /dev/null +++ b/packages/app/e2e/regression/terminal-hidden.spec.ts @@ -0,0 +1,117 @@ +import { expect, test } from "@playwright/test" +import { mockOpenCodeServer } from "../utils/mock-server" +import { expectSessionTitle } from "../utils/waits" + +const directory = "C:/OpenCode/HiddenTerminalRegression" +const projectID = "proj_hidden_terminal_regression" +const sessionID = "ses_hidden_terminal_regression" +const title = "Hidden terminal regression" + +test("unmounts the terminal panel while it is hidden", async ({ page }) => { + await page.setViewportSize({ width: 1400, height: 900 }) + await mockOpenCodeServer(page, { + protocol: "v2", + directory, + project: { + id: projectID, + worktree: directory, + vcs: "git", + name: "hidden-terminal-regression", + time: { created: 1700000000000, updated: 1700000000000 }, + sandboxes: [], + }, + provider: { + all: [ + { + id: "opencode", + name: "OpenCode", + models: { test: { id: "test", name: "Test", limit: { context: 200_000 } } }, + }, + ], + connected: ["opencode"], + default: { providerID: "opencode", modelID: "test" }, + }, + sessions: [ + { + id: sessionID, + slug: "hidden-terminal-regression", + projectID, + directory, + title, + version: "dev", + time: { created: 1700000000000, updated: 1700000000000 }, + }, + ], + pageMessages: () => ({ items: [] }), + }) + await page.route("**/api/pty*", (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + location: { directory, project: { id: projectID, directory } }, + data: { + id: "pty_hidden_terminal", + title: "Terminal 1", + command: "cmd.exe", + args: [], + cwd: directory, + status: "running", + pid: 1, + }, + }), + }), + ) + await page.route("**/api/pty/pty_hidden_terminal*", (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + location: { directory, project: { id: projectID, directory } }, + data: { + id: "pty_hidden_terminal", + title: "Terminal 1", + command: "cmd.exe", + args: [], + cwd: directory, + status: "running", + pid: 1, + }, + }), + }), + ) + await page.route("**/api/pty/pty_hidden_terminal/connect-token*", (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + location: { directory, project: { id: projectID, directory } }, + data: { ticket: "e2e-ticket", expires_in: 60 }, + }), + }), + ) + await page.routeWebSocket("**/api/pty/pty_hidden_terminal/connect", () => undefined) + + await page.goto(`/${base64Encode(directory)}/session/${sessionID}`) + await expectSessionTitle(page, title) + + await page.keyboard.press("Control+Backquote") + const panel = page.locator("#terminal-panel") + await expect(panel).toHaveAttribute("aria-hidden", "false") + await expect(page.locator('[data-component="terminal"]')).toBeVisible() + + await page.keyboard.press("Control+Backquote") + await expect(panel).toHaveCount(0) + await expect(page.locator('[data-component="terminal"]')).toHaveCount(0) + + await page.setViewportSize({ width: 1200, height: 700 }) + await expect(page.locator('[data-component="terminal"]')).toHaveCount(0) + + await page.keyboard.press("Control+Backquote") + await expect(panel).toBeVisible() + await expect(page.locator('[data-component="terminal"]')).toBeVisible() +}) + +function base64Encode(value: string) { + return Buffer.from(value, "utf8").toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "") +} diff --git a/packages/app/e2e/regression/terminal-tab-switch.spec.ts b/packages/app/e2e/regression/terminal-tab-switch.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..165920753cb2bdc40175d168b93349df8bdd8134 --- /dev/null +++ b/packages/app/e2e/regression/terminal-tab-switch.spec.ts @@ -0,0 +1,165 @@ +import { base64Encode } from "@opencode-ai/core/util/encode" +import { expect, test, type Page } from "@playwright/test" +import { mockOpenCodeServer } from "../utils/mock-server" +import { expectSessionTitle } from "../utils/waits" + +const directory = "C:/OpenCode/TerminalTabSwitch" +const projectID = "proj_terminal_tab_switch" +const sessionA = "ses_terminal_tab_a" +const sessionB = "ses_terminal_tab_b" +const titleA = "Alpha session" +const titleB = "Beta session" +const ptyID = "pty_tab_switch" +const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}` +// Marks the terminal DOM node so a remount (fresh node) is detectable. +const PROBE = "original" + +test.use({ viewport: { width: 1440, height: 900 } }) + +// Terminals are workspace-scoped: switching between session tabs in the same +// workspace must keep the terminal mounted and its PTY connection open instead +// of tearing it down and reconnecting. +test("keeps the terminal session alive when switching session tabs in a workspace", async ({ page }) => { + const connections = await setup(page) + + await page.goto(sessionHref(sessionA)) + await expectSessionTitle(page, titleA) + + await page.keyboard.press("Control+Backquote") + const terminal = page.locator('[data-component="terminal"]') + await expect(terminal).toBeVisible() + await expect.poll(() => connections.length).toBe(1) + const connection = new URL(connections[0]!) + expect(connection.pathname).toBe(`/api/pty/${ptyID}/connect`) + expect(connection.searchParams.get("location[directory]")).toBe(directory) + expect(connection.searchParams.get("ticket")).toBeNull() + await writeProbe(page) + + await switchTab(page, titleB) + await expectSessionTitle(page, titleB) + await expect(terminal).toBeVisible() + expect(await readProbe(page)).toBe(PROBE) + expect(connections.length).toBe(1) + + await switchTab(page, titleA) + await expectSessionTitle(page, titleA) + await expect(terminal).toBeVisible() + expect(await readProbe(page)).toBe(PROBE) + expect(connections.length).toBe(1) +}) + +type Probed = HTMLElement & { __e2eProbe?: string } + +async function switchTab(page: Page, title: string) { + await page.locator("[data-titlebar-tab-slot]", { hasText: title }).click() +} + +async function writeProbe(page: Page) { + await page.locator('[data-component="terminal"]').evaluate((el, probe) => { + ;(el as Probed).__e2eProbe = probe + }, PROBE) +} + +async function readProbe(page: Page) { + return page.locator('[data-component="terminal"]').evaluate((el) => (el as Probed).__e2eProbe) +} + +async function setup(page: Page) { + await mockOpenCodeServer(page, { + protocol: "v2", + directory, + project: { + id: projectID, + worktree: directory, + vcs: "git", + name: "terminal-tab-switch", + time: { created: 1700000000000, updated: 1700000000000 }, + sandboxes: [], + }, + provider: { + all: [ + { + id: "opencode", + name: "OpenCode", + models: { test: { id: "test", name: "Test", limit: { context: 200_000 } } }, + }, + ], + connected: ["opencode"], + default: { providerID: "opencode", modelID: "test" }, + }, + sessions: [session(sessionA, titleA, 1700000000000), session(sessionB, titleB, 1700000001000)], + pageMessages: () => ({ items: [] }), + }) + await page.route("**/api/pty*", (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ location: ptyLocation(), data: ptyInfo() }), + }), + ) + await page.route(`**/api/pty/${ptyID}*`, (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ location: ptyLocation(), data: ptyInfo() }), + }), + ) + await page.route(`**/api/pty/${ptyID}/connect-token*`, (route) => { + expect(route.request().headers()["x-opencode-ticket"]).toBe("1") + const url = new URL(route.request().url()) + expect(url.searchParams.get("location[directory]")).toBe(directory) + return route.fulfill({ + status: 200, + contentType: "application/json", + headers: { "access-control-allow-origin": "*" }, + body: JSON.stringify({ location: ptyLocation(), data: { ticket: "e2e-ticket", expires_in: 60 } }), + }) + }) + const connections: string[] = [] + await page.routeWebSocket(new RegExp(`/api/pty/${ptyID}/connect`), (ws) => { + connections.push(ws.url()) + }) + + await page.addInitScript( + ({ directory, server, sessions }) => { + localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } })) + localStorage.setItem( + "opencode.global.dat:server", + JSON.stringify({ + projects: { local: [{ worktree: directory, expanded: true }] }, + lastProject: { local: directory }, + }), + ) + localStorage.setItem( + "opencode.window.browser.dat:tabs", + JSON.stringify(sessions.map((sessionId: string) => ({ type: "session", server, sessionId }))), + ) + }, + { directory, server, sessions: [sessionA, sessionB] }, + ) + return connections +} + +function session(id: string, title: string, created: number) { + return { + id, + slug: id, + projectID, + directory, + title, + version: "dev", + time: { created, updated: created }, + } +} + +function sessionHref(sessionID: string) { + return `/server/${base64Encode(server)}/session/${sessionID}` +} + +function ptyLocation() { + return { directory, project: { id: projectID, directory } } +} + +function ptyInfo() { + return { id: ptyID, title: "Terminal 1", command: "cmd.exe", args: [], cwd: directory, status: "running", pid: 1 } +} diff --git a/packages/app/e2e/smoke/session-timeline.fixture.ts b/packages/app/e2e/smoke/session-timeline.fixture.ts new file mode 100644 index 0000000000000000000000000000000000000000..3dce37cafd9d207d9ab102555080a26cf62e4770 --- /dev/null +++ b/packages/app/e2e/smoke/session-timeline.fixture.ts @@ -0,0 +1,315 @@ +const words = [ + "alpha", + "bravo", + "charlie", + "delta", + "echo", + "foxtrot", + "golf", + "hotel", + "india", + "juliet", + "kilo", + "lima", + "metro", + "nova", + "orbit", + "pixel", + "quartz", + "river", + "signal", + "vector", +] + +const serverKey = "http://127.0.0.1:4096" +const sourceID = "ses_smoke_source" +const targetID = "ses_smoke_target" +const directory = "C:/OpenCode/SmokeProject" +const projectID = "proj_smoke_timeline" +const model = { providerID: "opencode", modelID: "claude-opus-4-6", variant: "max" } + +type MessageInfo = Record & { id: string; role: "user" | "assistant" } +type MessagePart = Record & { id: string; type: string; text?: string; tool?: string } +type Message = { info: MessageInfo; parts: MessagePart[] } + +function lorem(seed: number, length: number) { + let out = "" + let i = seed + while (out.length < length) { + const word = words[i % words.length] + out += (out ? " " : "") + word + if (i % 17 === 0) out += ".\n\n" + i += 7 + } + return out.slice(0, length) +} + +function id(prefix: string, value: number) { + return `${prefix}_smoke_${String(value).padStart(4, "0")}` +} + +function userMessage(sessionID: string, index: number, textLength: number, diffs: unknown[] = []): Message { + const messageID = id("msg_user", index) + return { + info: { + id: messageID, + sessionID, + role: "user", + time: { created: 1700000000000 + index * 10_000 }, + summary: { diffs }, + agent: "build", + model, + }, + parts: [ + { + id: id("prt_user_text", index), + sessionID, + messageID, + type: "text", + text: lorem(index, textLength), + }, + ], + } +} + +function assistantMessage(sessionID: string, index: number, parentID: string, parts: MessagePart[]): Message { + const messageID = id("msg_assistant", index) + return { + info: { + id: messageID, + sessionID, + role: "assistant", + time: { created: 1700000000000 + index * 10_000 + 1_000, completed: 1700000000000 + index * 10_000 + 8_000 }, + parentID, + modelID: model.modelID, + providerID: model.providerID, + mode: "build", + agent: "build", + path: { cwd: directory, root: directory }, + cost: 0.01, + tokens: { input: 100, output: 200, reasoning: 0, cache: { read: 0, write: 0 } }, + variant: "max", + finish: "stop", + }, + parts: parts.map((part) => ({ + ...part, + sessionID, + messageID, + })), + } +} + +function textPart(index: number, partIndex: number, length: number): MessagePart { + return { id: id(`prt_text_${partIndex}`, index), type: "text", text: lorem(index * 13 + partIndex, length) } +} + +function reasoningPart(index: number, partIndex: number, length: number): MessagePart { + return { + id: id(`prt_reasoning_${partIndex}`, index), + type: "reasoning", + text: lorem(index * 19 + partIndex, length), + time: { start: 1700000000000 + index * 10_000, end: 1700000000000 + index * 10_000 + 500 }, + } +} + +function toolPart( + index: number, + partIndex: number, + tool: string, + input: Record, + outputLength = 160, +): MessagePart { + const metadata = + tool === "apply_patch" + ? { files: [patchFile(index, "update"), patchFile(index + 1, index % 2 === 0 ? "add" : "delete")] } + : tool === "edit" || tool === "write" + ? { + filediff: fileDiff(String(input.filePath ?? `src/generated/file-${index}.ts`), index), + diff: patch(index, outputLength), + preview: patch(index + 1, 420), + } + : tool === "question" + ? { answers: [["Proceed"], ["Keep sample output"]] } + : {} + return { + id: id(`prt_tool_${tool}_${partIndex}`, index), + type: "tool", + callID: id("call", index * 10 + partIndex), + tool, + state: { + status: "completed", + input, + output: lorem(index * 23 + partIndex, outputLength), + title: tool === "bash" ? input.command : input.filePath || input.path || input.pattern || "completed", + metadata, + time: { start: 1700000000000 + index * 10_000, end: 1700000000000 + index * 10_000 + 400 }, + }, + } +} + +function patchFile(seed: number, type: "add" | "update" | "delete") { + return { + filePath: `src/generated/patch-${seed}.ts`, + relativePath: `src/generated/patch-${seed}.ts`, + type, + additions: (seed % 7) + 1, + deletions: type === "add" ? 0 : seed % 4, + patch: patch(seed, 520), + before: type === "add" ? undefined : code(seed, 18), + after: type === "delete" ? undefined : code(seed + 1, 24), + } +} + +function fileDiff(file: string, seed: number) { + return { + file, + additions: (seed % 9) + 1, + deletions: seed % 4, + before: code(seed, 32), + after: code(seed + 1, 38), + } +} + +function patch(seed: number, length: number) { + return `diff --git a/src/generated/file-${seed}.ts b/src/generated/file-${seed}.ts\n+${lorem(seed, length).replace(/\n/g, "\n+")}` +} + +function code(seed: number, lines: number) { + return Array.from({ length: lines }, (_, index) => `export const value${index} = "${lorem(seed + index, 32)}"`).join( + "\n", + ) +} + +function turn(index: number): Message[] { + const diff = index % 9 === 0 ? [fileDiff(`src/generated/summary-${index}.ts`, index)] : [] + const user = userMessage(targetID, index, 100 + (index % 4) * 80, diff) + const parts = [ + ...(index % 5 === 0 ? [reasoningPart(index, 0, 420)] : []), + ...(index % 3 === 0 + ? [ + toolPart(index, 0, "read", { filePath: `src/generated/file-${index}.ts`, offset: 0, limit: 80 }, 220), + toolPart(index, 5, "glob", { path: directory, pattern: `**/*sample-${index}*.ts` }, 140), + toolPart(index, 1, "grep", { path: directory, pattern: `sample-${index}`, include: "*.ts" }, 180), + toolPart(index, 6, "list", { path: `src/generated/${index}` }, 120), + ] + : []), + textPart(index, 2, 160 + (index % 6) * 90), + ...(index % 4 === 0 ? [toolPart(index, 3, "edit", { filePath: `src/generated/file-${index}.ts` }, 700)] : []), + ...(index % 6 === 0 + ? [toolPart(index, 7, "write", { filePath: `src/generated/write-${index}.ts`, content: code(index, 28) }, 560)] + : []), + ...(index % 8 === 0 + ? [toolPart(index, 8, "apply_patch", { files: [`src/generated/patch-${index}.ts`] }, 620)] + : []), + ...(index % 7 === 0 ? [toolPart(index, 4, "bash", { command: "bun typecheck" }, 620)] : []), + ...(index % 10 === 0 ? [toolPart(index, 9, "webfetch", { url: "https://example.com/docs/sample" }, 120)] : []), + ...(index % 11 === 0 ? [toolPart(index, 10, "websearch", { query: "sample movement notes" }, 240)] : []), + ...(index % 13 === 0 + ? [ + toolPart( + index, + 11, + "question", + { questions: [{ question: "Use generated fixture?" }, { question: "Keep same row shape?" }] }, + 120, + ), + ] + : []), + ...(index % 17 === 0 + ? [toolPart(index, 12, "task", { description: "Inspect generated fixture", subagent_type: "explore" }, 160)] + : []), + ] + return [user, assistantMessage(targetID, index, user.info.id, parts)] +} + +const targetMessages = Array.from({ length: 72 }, (_, index) => turn(index)).flat() +const sourceMessages = Array.from({ length: 12 }, (_, index) => [ + userMessage(sourceID, index + 1000, 120), + assistantMessage(sourceID, index + 1000, id("msg_user", index + 1000), [textPart(index + 1000, 0, 240)]), +]).flat() + +function renderable(part: MessagePart) { + if (part.type === "tool" && part.tool === "todowrite") return false + if (part.type === "text") return !!part.text.trim() + if (part.type === "reasoning") return !!part.text.trim() + return part.type !== "step-start" && part.type !== "step-finish" && part.type !== "patch" +} + +function orderedParts(message: Message) { + return message.parts.slice().sort((a, b) => a.id.localeCompare(b.id)) +} + +export const fixture = { + directory, + serverKey, + project: { + id: projectID, + worktree: directory, + vcs: "git", + name: "smoke-project", + time: { created: 1700000000000, updated: 1700000000000 }, + sandboxes: [], + }, + provider: { + all: [ + { + id: "opencode", + name: "OpenCode", + models: { "claude-opus-4-6": { id: "claude-opus-4-6", name: "Claude Opus 4.6", limit: { context: 200_000 } } }, + }, + ], + connected: ["opencode"], + default: { providerID: "opencode", modelID: "claude-opus-4-6" }, + }, + sessions: [ + { + id: sourceID, + slug: "source", + projectID, + directory, + title: "Uncommitted changes inquiry", + version: "dev", + time: { created: 1700000000000, updated: 1700000000000 }, + }, + { + id: targetID, + slug: "target", + projectID, + directory, + title: "Example Game: sample jump movement & sample physics analysis", + version: "dev", + time: { created: 1700000001000, updated: 1700000001000 }, + }, + ], + sourceID, + targetID, + messages: { [sourceID]: sourceMessages, [targetID]: targetMessages }, + expected: { + sourceTitle: "Uncommitted changes inquiry", + targetTitle: "Example Game: sample jump movement & sample physics analysis", + targetMessageIDs: targetMessages + .filter((message) => message.info.role === "user") + .map((message) => message.info.id), + targetPartIDs: targetMessages.flatMap((message) => + orderedParts(message) + .filter(renderable) + .map((part) => part.id), + ), + expandedShellPartID: targetMessages.flatMap((message) => message.parts).find((part) => part.tool === "bash")!.id, + }, +} + +export function pageMessages(sessionID: string, limit: number, before?: string) { + const messages = fixture.messages[sessionID as keyof typeof fixture.messages] ?? [] + const end = before + ? Math.max( + 0, + messages.findIndex((message) => message.info.id === before), + ) + : messages.length + const start = Math.max(0, end - limit) + return { + items: messages.slice(start, end), + cursor: start > 0 ? messages[start]!.info.id : undefined, + } +} diff --git a/packages/app/e2e/smoke/session-timeline.spec.ts b/packages/app/e2e/smoke/session-timeline.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..bdf3f55bdc1072976fab99736cf975a9b70047d0 --- /dev/null +++ b/packages/app/e2e/smoke/session-timeline.spec.ts @@ -0,0 +1,740 @@ +import { expect, test, type Page } from "@playwright/test" +import { base64Encode } from "@opencode-ai/core/util/encode" +import { fixture, pageMessages } from "./session-timeline.fixture" +import { trackPageErrors, expectNoSmokeErrors } from "../utils/errors" +import { mockOpenCodeServer } from "../utils/mock-server" +import { APP_READY_TIMEOUT, expectAppVisible, expectSessionTitle } from "../utils/waits" + +const forbiddenText = ["Load details", "Show earlier steps"] + +type SmokeState = { + ids: string[] + visibleIds: string[] + messageIds: string[] + visibleMessageIds: string[] + topVisibleId?: string + signature: string + scrollTop: number + scrollHeight: number + clientHeight: number + errorToasts: string[] + forbiddenText: string[] +} + +type SmokeWindow = Window & { + __timelineSmokeState?: () => SmokeState + __timelineSmokeErrorToasts?: string[] + __timelineSmokeForbiddenText?: string[] +} + +test.describe("smoke: session timeline", () => { + test.setTimeout(240_000) + + test("keeps the visible message fixed while prepending history", async ({ page }) => { + const requests: { before?: string; phase: "start" | "end"; at: number }[] = [] + await mockOpenCodeServer(page, { + sessions: fixture.sessions, + provider: fixture.provider, + directory: fixture.directory, + project: fixture.project, + pageMessages, + messageDelay: 3_000, + onMessages: (input) => requests.push({ before: input.before, phase: input.phase, at: performance.now() }), + }) + await configureSmokePage(page, fixture.directory) + + await navigateToSession(page, fixture.directory, fixture.targetID, fixture.expected.targetTitle) + await waitForTimelineStable(page) + const scroller = timelineScroller(page) + await pointAtTimeline(page) + const deadline = Date.now() + 120_000 + while (!requests.some((request) => request.before && request.phase === "start")) { + if (Date.now() >= deadline) throw new Error("Timed out scrolling to the history boundary") + await page.mouse.wheel(0, -240) + await page.waitForTimeout(20) + } + expect(requests.some((request) => request.before && request.phase === "end")).toBe(false) + for (let index = 0; index < 12; index++) { + await page.mouse.wheel(0, -120) + await page.waitForTimeout(20) + } + const keys = await scroller.evaluate((element) => { + const view = element.getBoundingClientRect() + return [...element.querySelectorAll("[data-timeline-part-id]")] + .filter((row) => { + const rect = row.getBoundingClientRect() + return rect.bottom > view.top && rect.top < view.bottom + }) + .map((row) => row.dataset.timelinePartId) + .filter((id): id is string => !!id) + .slice(0, 3) + }) + expect(keys.length).toBeGreaterThan(0) + const positions = () => + scroller.evaluate((element, keys) => { + const top = element.getBoundingClientRect().top + return Object.fromEntries( + keys.map((key) => { + const row = element.querySelector(`[data-timeline-part-id="${key}"]`) + if (!row) throw new Error(`Missing stable timeline key: ${key}`) + return [key, Math.round((row.getBoundingClientRect().top - top) * devicePixelRatio) / devicePixelRatio] + }), + ) + }, keys) + const before = await positions() + expect(requests.some((request) => request.before && request.phase === "end")).toBe(false) + + await expect.poll(() => requests.some((request) => request.before && request.phase === "end")).toBe(true) + await waitForTimelineStable(page) + await expect.poll(positions).toEqual(before) + }) + + test("preserves the timeline gap above the composer", async ({ page }) => { + await mockOpenCodeServer(page, { + sessions: fixture.sessions, + provider: fixture.provider, + directory: fixture.directory, + project: fixture.project, + pageMessages, + }) + await configureSmokePage(page, fixture.directory) + + await navigateToSession(page, fixture.directory, fixture.targetID, fixture.expected.targetTitle) + await waitForTimelineStable(page) + const scroller = timelineScroller(page) + await scroller.evaluate((element) => { + element.scrollTop = element.scrollHeight + }) + await waitForTimelineStable(page) + + const spacer = scroller.locator('[data-timeline-row="bottom-spacer"]') + await expect(spacer).toBeVisible() + expect(await spacer.evaluate((element) => element.getBoundingClientRect().height)).toBe(64) + await expect + .poll(() => scroller.evaluate((element) => element.scrollHeight - element.clientHeight - element.scrollTop)) + .toBeLessThanOrEqual(1) + }) + + test("paints cached session tabs at the latest message", async ({ page }) => { + await mockOpenCodeServer(page, { + sessions: fixture.sessions, + provider: fixture.provider, + directory: fixture.directory, + project: fixture.project, + pageMessages: (sessionID) => ({ items: fixture.messages[sessionID as keyof typeof fixture.messages] ?? [] }), + }) + await configureSmokePage(page, fixture.directory) + await page.addInitScript( + ({ dirBase64, sourceID, targetID }) => { + localStorage.setItem( + "opencode.window.browser.dat:tabs", + JSON.stringify( + [sourceID, targetID].map((sessionId) => ({ + type: "session", + server: "http://127.0.0.1:4096", + dirBase64, + sessionId, + })), + ), + ) + }, + { dirBase64: base64Encode(fixture.directory), sourceID: fixture.sourceID, targetID: fixture.targetID }, + ) + + await page.goto(`/${base64Encode(fixture.directory)}/session/${fixture.targetID}`) + await expectSessionTitle(page, fixture.expected.targetTitle) + await switchTitlebarSession(page, fixture.sourceID, fixture.expected.sourceTitle) + + const destination = fixture.messages[fixture.targetID].map((message) => message.info.id) + const last = fixture.expected.targetMessageIDs.at(-1)! + await page.evaluate( + ({ destination, last }) => { + const ids = new Set(destination) + const samples: Array<{ ids: string[]; last: boolean; bottomError?: number }> = [] + const firstPaintNodes = new WeakSet() + let firstPaint = false + let removedFirstPaintNodes = 0 + let running = true + new MutationObserver((records) => { + if (!firstPaint || !running) return + records.forEach((record) => + record.removedNodes.forEach((node) => { + if (firstPaintNodes.has(node)) removedFirstPaintNodes += 1 + if (!(node instanceof Element)) return + node.querySelectorAll("*").forEach((element) => { + if (firstPaintNodes.has(element)) removedFirstPaintNodes += 1 + }) + }), + ) + }).observe(document.documentElement, { childList: true, subtree: true }) + const sample = () => { + if (!running) return + setTimeout(() => { + if (!running) return + const root = [...document.querySelectorAll(".scroll-view__viewport")].find((element) => + element.querySelector("[data-timeline-row]"), + ) + if (root) { + const view = root.getBoundingClientRect() + const visible = [...root.querySelectorAll("[data-message-id]")] + .filter((element) => { + const rect = element.getBoundingClientRect() + return rect.bottom > view.top && rect.top < view.bottom + }) + .map((element) => element.dataset.messageId!) + .filter((id) => ids.has(id)) + const bottom = root + .querySelector('[data-timeline-row="bottom-spacer"]') + ?.getBoundingClientRect() + samples.push({ ids: visible, last: visible.includes(last), bottomError: bottom?.bottom - view.bottom }) + if (!firstPaint && visible.includes(last) && Math.abs((bottom?.bottom ?? Infinity) - view.bottom) <= 1) { + firstPaint = true + root.querySelectorAll("[data-timeline-key]").forEach((row) => { + const rect = row.getBoundingClientRect() + if (rect.bottom <= view.top || rect.top >= view.bottom) return + firstPaintNodes.add(row) + row.querySelectorAll("*").forEach((element) => firstPaintNodes.add(element)) + }) + } + } + requestAnimationFrame(sample) + }, 0) + } + ;( + window as Window & { + __sessionTabPaint?: { samples: typeof samples; removed: () => number; stop: () => void } + } + ).__sessionTabPaint = { + samples, + removed: () => removedFirstPaintNodes, + stop: () => { + running = false + }, + } + requestAnimationFrame(sample) + }, + { destination, last }, + ) + + await switchTitlebarSession(page, fixture.targetID, fixture.expected.targetTitle) + await page.waitForFunction(() => + ( + window as Window & { __sessionTabPaint?: { samples: Array<{ ids: string[] }> } } + ).__sessionTabPaint?.samples.some((sample) => sample.ids.length > 0), + ) + await page.waitForTimeout(200) + const first = await page.evaluate(() => { + const probe = ( + window as Window & { + __sessionTabPaint?: { + samples: Array<{ ids: string[]; last: boolean; bottomError?: number }> + removed: () => number + stop: () => void + } + } + ).__sessionTabPaint! + probe.stop() + return { first: probe.samples.find((sample) => sample.ids.length > 0), removed: probe.removed() } + }) + expect(first.first?.last).toBe(true) + expect(Math.abs(first.first?.bottomError ?? Infinity)).toBeLessThanOrEqual(1) + expect(first.removed).toBe(0) + }) + + test("paints a cold session tab at the latest message", async ({ page }) => { + await mockOpenCodeServer(page, { + sessions: fixture.sessions, + provider: fixture.provider, + directory: fixture.directory, + project: fixture.project, + pageMessages: (sessionID) => ({ items: fixture.messages[sessionID as keyof typeof fixture.messages] ?? [] }), + }) + await configureSmokePage(page, fixture.directory) + await page.addInitScript( + ({ dirBase64, sourceID, targetID }) => { + localStorage.setItem( + "opencode.window.browser.dat:tabs", + JSON.stringify( + [sourceID, targetID].map((sessionId) => ({ + type: "session", + server: "http://127.0.0.1:4096", + dirBase64, + sessionId, + })), + ), + ) + }, + { dirBase64: base64Encode(fixture.directory), sourceID: fixture.sourceID, targetID: fixture.targetID }, + ) + await page.goto(`/${base64Encode(fixture.directory)}/session/${fixture.sourceID}`) + await expectSessionTitle(page, fixture.expected.sourceTitle) + const last = fixture.expected.targetMessageIDs.at(-1)! + const destination = fixture.messages[fixture.targetID].map((message) => message.info.id) + await page.evaluate( + ({ destination, last }) => { + const ids = new Set(destination) + const samples: Array<{ destination: boolean; last: boolean; bottomError?: number }> = [] + const sample = () => { + const root = [...document.querySelectorAll(".scroll-view__viewport")].find((element) => + element.querySelector("[data-timeline-row]"), + ) + if (root) { + const view = root.getBoundingClientRect() + const spacer = root + .querySelector('[data-timeline-row="bottom-spacer"]') + ?.getBoundingClientRect() + const messages = [...root.querySelectorAll("[data-message-id]")].filter((element) => { + const rect = element.getBoundingClientRect() + return rect.bottom > view.top && rect.top < view.bottom + }) + samples.push({ + destination: messages.some((element) => ids.has(element.dataset.messageId!)), + last: messages.some((element) => element.dataset.messageId === last), + bottomError: spacer ? spacer.bottom - view.bottom : undefined, + }) + } + requestAnimationFrame(() => setTimeout(sample, 0)) + } + ;(window as Window & { __coldTabSamples?: typeof samples }).__coldTabSamples = samples + requestAnimationFrame(() => setTimeout(sample, 0)) + }, + { destination, last }, + ) + + await switchTitlebarSession(page, fixture.targetID, fixture.expected.targetTitle) + await page.waitForFunction(() => + (window as Window & { __coldTabSamples?: Array<{ destination: boolean }> }).__coldTabSamples?.some( + (sample) => sample.destination, + ), + ) + const result = await page.evaluate(() => { + const samples = ( + window as Window & { + __coldTabSamples?: Array<{ destination: boolean; last: boolean; bottomError?: number }> + } + ).__coldTabSamples! + return samples.find((sample) => sample.destination)! + }) + expect(result.last).toBe(true) + expect(Math.abs(result.bottomError ?? Infinity)).toBeLessThanOrEqual(1) + }) + + test("renders seeded timeline in order while paging through history", async ({ page }) => { + const errors = trackPageErrors(page) + await mockOpenCodeServer(page, { + sessions: fixture.sessions, + provider: fixture.provider, + directory: fixture.directory, + project: fixture.project, + pageMessages, + }) + await configureSmokePage(page, fixture.directory) + + await selectHomeProject(page, fixture.project.name) + await navigateToSession(page, fixture.directory, fixture.sourceID, fixture.expected.sourceTitle) + await expectSessionReady(page) + await navigateToSession(page, fixture.directory, fixture.targetID, fixture.expected.targetTitle) + const expectedPartIDs = fixture.expected.targetPartIDs + const expectedMessageIDs = fixture.expected.targetMessageIDs + await expectSessionTimelineReady(page, expectedPartIDs, expectedMessageIDs, errors) + await expectCanScrollToStart(page, expectedPartIDs, expectedMessageIDs, errors) + + const shell = page.locator(`[data-timeline-part-id="${fixture.expected.expandedShellPartID}"]`) + const shellTrigger = shell.locator('[data-slot="collapsible-trigger"]') + const shellSubtitle = shell.locator('[data-slot="basic-tool-tool-subtitle"]') + await expect(shellSubtitle).toHaveCount(0) + await expect(shell.locator('[data-slot="bash-pre"]')).toContainText("$ bun typecheck") + await shellTrigger.click() + await expect(shellTrigger).toHaveAttribute("aria-expanded", "false") + await expect(shellSubtitle).toHaveText("bun typecheck") + await shellTrigger.click() + await expect(shellTrigger).toHaveAttribute("aria-expanded", "true") + await expect(shellSubtitle).toHaveCount(0) + }) +}) + +async function configureSmokePage(page: Page, directory: string) { + await page.addInitScript(() => { + localStorage.setItem( + "settings.v3", + JSON.stringify({ + general: { + editToolPartsExpanded: true, + shellToolPartsExpanded: true, + showReasoningSummaries: true, + }, + }), + ) + }) + + await page.addInitScript((directory) => { + localStorage.setItem( + "opencode.global.dat:server", + JSON.stringify({ + projects: { + local: [{ worktree: directory, expanded: true }], + }, + lastProject: { + local: directory, + }, + }), + ) + }, directory) + + await page.addInitScript(() => { + const smoke = window as SmokeWindow + smoke.__timelineSmokeErrorToasts = [] + smoke.__timelineSmokeForbiddenText = [] + const partSelector = "[data-timeline-part-id], [data-timeline-part-ids]" + const idsOf = (el: HTMLElement) => + [el.dataset.timelinePartId, ...(el.dataset.timelinePartIds?.split(",") ?? [])].filter((id): id is string => !!id) + + smoke.__timelineSmokeState = () => { + const scroller = [...document.querySelectorAll(".scroll-view__viewport")].find((el) => + el.querySelector("[data-timeline-row], [data-session-title]"), + ) + if (!scroller) { + return { + ids: [], + visibleIds: [], + messageIds: [], + visibleMessageIds: [], + topVisibleId: undefined, + signature: "", + scrollTop: 0, + scrollHeight: 0, + clientHeight: 0, + errorToasts: smoke.__timelineSmokeErrorToasts ?? [], + forbiddenText: smoke.__timelineSmokeForbiddenText ?? [], + } + } + + const ids: string[] = [] + const visibleIds: string[] = [] + const scrollerRect = scroller.getBoundingClientRect() + let topVisibleId: string | undefined + for (const el of scroller.querySelectorAll(partSelector)) { + const next = idsOf(el) + ids.push(...next) + + const rect = el.getBoundingClientRect() + if (rect.bottom >= scrollerRect.top && rect.top <= scrollerRect.bottom) { + if (!topVisibleId) topVisibleId = next[0] + visibleIds.push(...next) + } + } + + const messageIds: string[] = [] + const visibleMessageIds: string[] = [] + const rows = [...scroller.querySelectorAll("[data-message-id]")].map((el) => { + const rect = el.getBoundingClientRect() + const id = el.dataset.messageId + if (id) { + messageIds.push(id) + if (rect.bottom >= scrollerRect.top && rect.top <= scrollerRect.bottom) visibleMessageIds.push(id) + } + return { + id, + top: Math.round(rect.top), + bottom: Math.round(rect.bottom), + } + }) + const signature = JSON.stringify({ + top: Math.round(scroller.scrollTop), + height: Math.round(scroller.scrollHeight), + rows, + ids, + }) + + return { + ids, + visibleIds, + messageIds, + visibleMessageIds, + topVisibleId, + signature, + scrollTop: Math.round(scroller.scrollTop), + scrollHeight: Math.round(scroller.scrollHeight), + clientHeight: Math.round(scroller.clientHeight), + errorToasts: smoke.__timelineSmokeErrorToasts ?? [], + forbiddenText: smoke.__timelineSmokeForbiddenText ?? [], + } + } + let recordFrame: number | undefined + const record = () => { + for (const toast of document.querySelectorAll('[data-component="toast"][data-variant="error"]')) { + const text = toast.textContent?.trim() + if (text && !smoke.__timelineSmokeErrorToasts!.includes(text)) smoke.__timelineSmokeErrorToasts!.push(text) + } + const text = document.body?.textContent ?? "" + for (const value of ["Load details", "Show earlier steps"]) { + if (text.includes(value) && !smoke.__timelineSmokeForbiddenText!.includes(value)) { + smoke.__timelineSmokeForbiddenText!.push(value) + } + } + } + const start = () => { + const root = document.documentElement ?? document.body + if (!root) return + new MutationObserver(() => { + if (recordFrame) return + recordFrame = requestAnimationFrame(() => { + recordFrame = undefined + record() + }) + }).observe(root, { childList: true, subtree: true }) + record() + } + if (document.documentElement ?? document.body) start() + else document.addEventListener("DOMContentLoaded", start, { once: true }) + }) +} + +async function expectCanScrollToStart( + page: Page, + expectedPartIDs: string[], + expectedMessageIDs: string[], + errors: string[], +) { + await pointAtTimeline(page) + const seenParts = new Set() + const seenMessages = new Set() + const samples: TraversalSample[] = [] + let current = await timelineState(page) + let unchangedAtTop = 0 + + for (let attempt = 0; attempt < 600; attempt++) { + collectSeen(current, seenParts, seenMessages) + samples.push(sampleTraversal(current, seenParts.size, seenMessages.size)) + expectNoSmokeErrors(errors, current.errorToasts, current.forbiddenText) + expectOrderedIDs(expectedPartIDs, current.ids, "mounted part") + expectOrderedIDs(expectedPartIDs, current.visibleIds, "visible part") + expectOrderedIDs(expectedMessageIDs, unique(current.messageIds), "mounted message") + expectOrderedIDs(expectedMessageIDs, unique(current.visibleMessageIds), "visible message") + + if ( + current.scrollTop <= 1 && + seenParts.size === expectedPartIDs.length && + seenMessages.size === expectedMessageIDs.length + ) { + expectCompleteScroll(current, expectedPartIDs, expectedMessageIDs, seenParts, seenMessages, samples) + return + } + + const before = current + const changed = await scrollTimelineUp(page, current) + current = await timelineState(page) + if (!changed && current.signature === before.signature && current.scrollTop <= 1) unchangedAtTop++ + else unchangedAtTop = 0 + if (unchangedAtTop >= 2) break + } + + collectSeen(current, seenParts, seenMessages) + samples.push(sampleTraversal(current, seenParts.size, seenMessages.size)) + expectCompleteScroll(current, expectedPartIDs, expectedMessageIDs, seenParts, seenMessages, samples) +} + +async function timelineState(page: Page) { + return page.evaluate( + () => + (window as SmokeWindow).__timelineSmokeState?.() ?? { + ids: [], + visibleIds: [], + messageIds: [], + visibleMessageIds: [], + topVisibleId: undefined, + signature: "", + scrollTop: 0, + scrollHeight: 0, + clientHeight: 0, + errorToasts: [], + forbiddenText: [], + }, + ) +} + +function timelineScroller(page: Page) { + return page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") }) +} + +async function pointAtTimeline(page: Page) { + const box = await timelineScroller(page).boundingBox() + if (!box) throw new Error("Timeline scroller is not visible") + await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2) +} + +async function scrollTimelineUp(page: Page, before: SmokeState) { + return page.evaluate( + (prev) => + new Promise((resolve) => { + const scroller = [...document.querySelectorAll(".scroll-view__viewport")].find((el) => + el.querySelector("[data-timeline-row], [data-session-title]"), + ) + if (!scroller) { + resolve(false) + return + } + + scroller.dispatchEvent(new WheelEvent("wheel", { bubbles: true, cancelable: true, deltaY: -1, deltaMode: 0 })) + scroller.scrollTop = Math.max(0, scroller.scrollTop - Math.max(80, Math.round(scroller.clientHeight * 0.45))) + + const read = () => (window as SmokeWindow).__timelineSmokeState?.().signature ?? "" + let frames = 0 + let stableFrames = 0 + let last = "" + let changed = false + const check = () => { + const current = read() + if (current !== prev) changed = true + if (current === last) stableFrames++ + else { + stableFrames = 0 + last = current + } + if (changed && stableFrames >= 2) { + resolve(true) + return + } + frames++ + if (frames >= 30) { + resolve(changed) + return + } + requestAnimationFrame(check) + } + requestAnimationFrame(check) + }), + before.signature, + ) +} + +function expectOrderedIDs(expected: string[], actual: string[], label: string) { + expect(actual.length, `${label} ids should not be empty`).toBeGreaterThan(0) + const actualSet = new Set(actual) + expect(actual, `${label} ids`).toEqual(expected.filter((id) => actualSet.has(id))) +} + +function unique(values: string[]) { + return values.filter((value, index) => values.indexOf(value) === index) +} + +function collectSeen(state: SmokeState, seenParts: Set, seenMessages: Set) { + for (const id of state.ids) seenParts.add(id) + for (const id of state.visibleIds) seenParts.add(id) + for (const id of state.messageIds) seenMessages.add(id) + for (const id of state.visibleMessageIds) seenMessages.add(id) +} + +type TraversalSample = ReturnType + +function sampleTraversal(state: SmokeState, seenParts: number, seenMessages: number) { + return { + seenParts, + seenMessages, + mounted: state.ids.length, + visible: state.visibleIds.length, + mountedMessages: unique(state.messageIds).length, + visibleMessages: unique(state.visibleMessageIds).length, + top: state.scrollTop, + height: state.scrollHeight, + first: state.ids[0], + last: state.ids.at(-1), + topVisible: state.topVisibleId, + visibleFirst: state.visibleIds[0], + visibleLast: state.visibleIds.at(-1), + } +} + +function sampleSummary(samples: TraversalSample[]) { + return samples + .filter((_, index) => index % Math.max(1, Math.floor(samples.length / 8)) === 0 || index === samples.length - 1) + .map( + (sample, index) => + `${index}: seenParts=${sample.seenParts} seenMessages=${sample.seenMessages} mounted=${sample.mounted}/${sample.mountedMessages} visible=${sample.visible}/${sample.visibleMessages} top=${sample.top}/${sample.height} first=${sample.first} last=${sample.last} topVisible=${sample.topVisible} visible=${sample.visibleFirst}..${sample.visibleLast}`, + ) + .join("\n") +} + +async function waitForTimelineStable(page: Page) { + await page.waitForFunction( + () => + new Promise((resolve) => { + requestAnimationFrame(() => { + const a = (window as SmokeWindow).__timelineSmokeState?.().signature ?? "" + requestAnimationFrame(() => { + const b = (window as SmokeWindow).__timelineSmokeState?.().signature ?? "" + requestAnimationFrame(() => + resolve(!!a && a === b && b === ((window as SmokeWindow).__timelineSmokeState?.().signature ?? "")), + ) + }) + }) + }), + ) +} + +async function expectSessionTimelineReady( + page: Page, + expectedPartIDs: string[], + expectedMessageIDs: string[], + errors: string[], +) { + await waitForTimelineStable(page) + for (const text of forbiddenText) await expect(page.getByText(text)).toHaveCount(0) + const currentState = await timelineState(page) + expectNoSmokeErrors(errors, currentState.errorToasts, currentState.forbiddenText) + expectOrderedIDs(expectedPartIDs, currentState.ids, "mounted part") + expectOrderedIDs(expectedPartIDs, currentState.visibleIds, "visible part") + expectOrderedIDs(expectedMessageIDs, unique(currentState.messageIds), "mounted message") + expectOrderedIDs(expectedMessageIDs, unique(currentState.visibleMessageIds), "visible message") +} + +function expectCompleteScroll( + state: SmokeState, + expectedPartIDs: string[], + expectedMessageIDs: string[], + seenParts: Set, + seenMessages: Set, + samples: TraversalSample[], +) { + expect(state.scrollTop, `timeline should reach the start\n${sampleSummary(samples)}`).toBeLessThanOrEqual(1) + expect( + expectedPartIDs.filter((id) => !seenParts.has(id)), + `missing visible timeline parts\n${sampleSummary(samples)}`, + ).toEqual([]) + expect( + expectedMessageIDs.filter((id) => !seenMessages.has(id)), + `missing visible messages\n${sampleSummary(samples)}`, + ).toEqual([]) + expect(new Set(expectedPartIDs).size).toBe(expectedPartIDs.length) + expect(new Set(expectedMessageIDs).size).toBe(expectedMessageIDs.length) + expect(expectedPartIDs.length).toBe(331) +} + +async function selectHomeProject(page: Page, projectName: string) { + await page.goto("/") + const row = page + .locator('[data-component="home-project-row"]') + .filter({ hasText: new RegExp(projectName, "i") }) + .first() + await expectAppVisible(row) + await row.click() + await expect(row).toHaveAttribute("data-selected", "", { timeout: APP_READY_TIMEOUT }) + await expect(page).toHaveURL(/\/$/) +} + +async function navigateToSession(page: Page, directory: string, sessionId: string, expectedTitle: string) { + await page.goto(`/${base64Encode(directory)}/session/${sessionId}`) + await expectSessionTitle(page, expectedTitle) +} + +async function switchTitlebarSession(page: Page, sessionID: string, title: string) { + const href = `/server/${base64Encode(fixture.serverKey)}/session/${sessionID}` + const tab = page.locator(`[data-slot="titlebar-tabs"] a[href="${href}"]`).first() + await expect(tab).toBeVisible() + await tab.click() + await expectSessionTitle(page, title) +} + +async function expectSessionReady(page: Page) { + await expectAppVisible(page.getByRole("textbox", { name: "Prompt" })) +} diff --git a/packages/app/e2e/user-story/model-selection-flow.spec.ts b/packages/app/e2e/user-story/model-selection-flow.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..22b8bb41fe25e51a0a42a44eeec3d84f873714aa --- /dev/null +++ b/packages/app/e2e/user-story/model-selection-flow.spec.ts @@ -0,0 +1,97 @@ +import { expect, test } from "@playwright/test" +import { mockOpenCodeServer } from "../utils/mock-server" +import { expectAppVisible } from "../utils/waits" + +const directory = "C:/OpenCode/NewProject" + +test("creates a session in a new project, connects OpenCode Go, and selects its model", async ({ page }) => { + let connectedGo = false + let pendingGo = false + const connections: Array<{ integrationID: string; body: unknown }> = [] + + await mockOpenCodeServer(page, { + directory, + project: { + id: "proj_model_selection_flow", + worktree: directory, + vcs: "git", + name: "NewProject", + time: { created: 1_700_000_000_000, updated: 1_700_000_000_000 }, + sandboxes: [], + }, + provider: () => ({ + all: [ + { + id: "opencode", + name: "OpenCode", + models: { + "free-model": { + id: "free-model", + name: "Free Model", + cost: { input: 0, output: 0 }, + limit: { context: 200_000 }, + }, + }, + }, + { + id: "opencode-go", + name: "OpenCode Go", + models: { + "go-model-1": { + id: "go-model-1", + name: "Go Model 1", + cost: { input: 1, output: 1 }, + limit: { context: 200_000 }, + }, + }, + }, + ], + connected: connectedGo ? ["opencode", "opencode-go"] : ["opencode"], + default: { providerID: "opencode", modelID: "free-model" }, + }), + integrationMethods: { "opencode-go": [{ type: "api", label: "API key" }] }, + onConnectKey: (input) => { + connections.push(input) + if (input.integrationID === "opencode-go") pendingGo = true + }, + onInstanceDispose: () => { + if (pendingGo) connectedGo = true + }, + sessions: [], + pageMessages: () => ({ items: [] }), + fileList: (path) => + path ? [] : [{ name: "NewProject", path: "NewProject", absolute: directory, type: "directory", ignored: false }], + findFiles: () => ["NewProject"], + }) + await page.addInitScript(() => { + localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } })) + localStorage.setItem("opencode.global.dat:server", JSON.stringify({ projects: { local: [] } })) + }) + + await page.goto("/") + const addProject = page.locator('[data-action="home-add-project-row"]') + await expectAppVisible(addProject) + await addProject.click() + await page.locator("[data-directory-path]").click() + + await page.locator('[data-action="home-new-session"]').click() + await expectAppVisible(page.locator('[data-component="prompt-input-v2"]')) + + const modelControl = page.locator('[data-action="prompt-model"]') + await modelControl.click() + await expect(page.locator('[data-section="free-models"]')).toContainText("Free models provided by OpenCode") + + await page.locator('[data-provider-id="opencode-go"]').click() + await page.locator('[data-input="provider-api-key"]').fill("mock-go-api-key") + await page.locator('[data-action="provider-connect-submit"]').click() + await expect(page.locator('[data-component="dialog-v2"]')).toHaveCount(0) + expect(connections).toEqual([{ integrationID: "opencode-go", body: { type: "api", key: "mock-go-api-key" } }]) + + await expect(modelControl).toHaveAttribute("data-control-type", "popover") + await modelControl.click() + const goModel = page.locator('[data-option-key="opencode-go:go-model-1"]') + await expect(goModel).toBeVisible() + await goModel.click() + + await expect(modelControl).toContainText("Go Model 1") +}) diff --git a/packages/app/e2e/utils/errors.ts b/packages/app/e2e/utils/errors.ts new file mode 100644 index 0000000000000000000000000000000000000000..74eb302b8410f4487011923edf3f843d27d2db0a --- /dev/null +++ b/packages/app/e2e/utils/errors.ts @@ -0,0 +1,18 @@ +import { expect, type Page } from "@playwright/test" + +export function trackPageErrors(page: Page) { + const errors: string[] = [] + page.on("console", (message) => { + if (message.type() === "error") errors.push(message.text()) + }) + page.on("pageerror", (error) => errors.push(error.stack ?? error.message)) + return errors +} + +export function expectNoSmokeErrors(consoleErrors: string[], toastErrors: string[], forbiddenText: string[]) { + expect({ consoleErrors, toastErrors, forbiddenText }).toEqual({ + consoleErrors: [], + toastErrors: [], + forbiddenText: [], + }) +} diff --git a/packages/app/e2e/utils/mock-server.ts b/packages/app/e2e/utils/mock-server.ts new file mode 100644 index 0000000000000000000000000000000000000000..a581eda8c1cd9fb4a1de7bba63c136c95dcd0466 --- /dev/null +++ b/packages/app/e2e/utils/mock-server.ts @@ -0,0 +1,460 @@ +import type { Page, Route } from "@playwright/test" + +const emptyList = new Set(["/skill", "/command", "/lsp", "/formatter", "/vcs/status", "/vcs/diff"]) +const emptyObject = new Set(["/global/config", "/config", "/provider/auth", "/mcp", "/experimental/resource"]) + +export interface MockServerConfig { + protocol?: "v1" | "v2" + provider: unknown | (() => unknown) + integrationMethods?: Record + onConnectKey?: (input: { integrationID: string; body: unknown }) => void + onInstanceDispose?: () => void + directory: string + project: unknown + sessions: ({ id: string } & Record)[] + pageMessages: (sessionId: string, limit: number, before?: string) => { items: unknown[]; cursor?: string } + vcsDiff?: unknown[] + messageDelay?: number + beforeMessagesResponse?: (input: { sessionID: string; before?: string }) => Promise + onMessages?: (input: { sessionID: string; before?: string; phase: "start" | "end" }) => void + message?: (sessionID: string, messageID: string) => unknown + onMessage?: (input: { sessionID: string; messageID: string }) => void + events?: () => unknown[] + eventRetry?: number + todos?: (sessionID: string) => unknown[] + permissions?: unknown[] | (() => unknown[]) + questions?: unknown[] | (() => unknown[]) + fileList?: (path: string) => unknown | Promise + fileContent?: (path: string) => unknown | Promise + findFiles?: (input: { query: string; dirs?: string; limit?: number }) => unknown | Promise + sessionStatus?: Record | (() => Record) +} + +export async function mockOpenCodeServer(page: Page, config: MockServerConfig) { + const cursors = new Map() + let nextCursor = 0 + const staticRoutes: Record = { + "/path": { + state: config.directory, + config: config.directory, + worktree: config.directory, + directory: config.directory, + home: "C:/OpenCode", + }, + "/project": [config.project], + "/project/current": config.project, + "/agent": [{ name: "build", mode: "primary" }], + "/vcs": { branch: "main", default_branch: "main" }, + "/session": config.sessions, + } + + await page.route("**/*", async (route) => { + const url = new URL(route.request().url()) + const targetPort = process.env.PLAYWRIGHT_SERVER_PORT ?? "4096" + const appPort = new URL( + process.env.PLAYWRIGHT_BASE_URL ?? `http://127.0.0.1:${process.env.PLAYWRIGHT_PORT ?? "3000"}`, + ).port + if (url.port !== targetPort && url.port !== appPort) return route.fallback() + + const path = url.pathname + if (path === "/global/event" || path === "/event" || path === "/api/event") { + const events = config.events?.() + return sse( + route, + path === "/api/event" + ? [{ id: "evt_mock_connected", type: "server.connected", data: {} }, ...(events?.map(currentEvent) ?? [])] + : [ + ...(path === "/global/event" + ? [{ payload: { id: "evt_mock_connected", type: "server.connected", properties: {} } }] + : []), + ...(events ?? []), + ], + config.eventRetry, + ) + } + if (path === "/global/health") + return config.protocol === "v2" ? json(route, {}, undefined, 404) : json(route, { healthy: true }) + if (path === "/api/health" && config.protocol === "v2") + return json(route, { healthy: true, version: "2.0.0", pid: 1 }) + if (path === "/experimental/capabilities") return json(route, { backgroundSubagents: true }) + if (path === "/provider") + return json(route, typeof config.provider === "function" ? config.provider() : config.provider) + if (path === "/provider/auth") return json(route, config.integrationMethods ?? {}) + const legacyAuth = path.match(/^\/auth\/([^/]+)$/)?.[1] + if (legacyAuth && route.request().method() === "PUT") { + config.onConnectKey?.({ integrationID: legacyAuth, body: route.request().postDataJSON() }) + return json(route, true) + } + if (path === "/instance/dispose" && route.request().method() === "POST") { + config.onInstanceDispose?.() + return json(route, true) + } + if (path === "/permission") + return json(route, typeof config.permissions === "function" ? config.permissions() : (config.permissions ?? [])) + if (path === "/question") + return json(route, typeof config.questions === "function" ? config.questions() : (config.questions ?? [])) + if (path === "/session/status") + return json( + route, + typeof config.sessionStatus === "function" ? config.sessionStatus() : (config.sessionStatus ?? {}), + ) + if (path === "/vcs/diff" && config.vcsDiff) return json(route, config.vcsDiff) + if (path === "/file" && config.fileList) + return json(route, await config.fileList(url.searchParams.get("path") ?? "")) + if (path === "/file/content" && config.fileContent) + return json(route, await config.fileContent(url.searchParams.get("path") ?? "")) + if (path === "/find/file" && config.findFiles) + return json( + route, + await config.findFiles({ + query: url.searchParams.get("query") ?? "", + dirs: url.searchParams.get("dirs") ?? undefined, + limit: url.searchParams.has("limit") ? Number(url.searchParams.get("limit")) : undefined, + }), + ) + if (path === "/api/reference") + return json(route, { + location: { + directory: config.directory, + project: { id: (config.project as { id?: string }).id, directory: config.directory }, + }, + data: [], + }) + if (path === "/api/agent") + return json(route, { + location: location(config), + data: [ + { + id: "build", + name: "Build", + mode: "primary", + hidden: false, + request: { settings: {}, headers: {}, body: {} }, + permissions: [], + }, + ], + }) + if (path === "/api/command") return json(route, { location: location(config), data: [] }) + if (path === "/api/mcp") return json(route, { location: location(config), data: [] }) + if (path === "/api/mcp/resource") + return json(route, { location: location(config), data: { resources: [], templates: [] } }) + const integration = path.match(/^\/api\/integration\/([^/]+)$/)?.[1] + if (integration && route.request().method() === "GET") + return json(route, { + location: location(config), + data: { id: integration, name: integration, methods: [{ type: "key", label: "API key" }], connections: [] }, + }) + const integrationConnect = path.match(/^\/api\/integration\/([^/]+)\/connect\/key$/)?.[1] + if (integrationConnect && route.request().method() === "POST") { + config.onConnectKey?.({ integrationID: integrationConnect, body: route.request().postDataJSON() }) + return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } }) + } + if (path === "/api/project") return json(route, [config.project]) + if (path === "/api/project/current") + return json(route, { id: (config.project as { id?: string }).id, directory: config.directory }) + if (path.startsWith("/api/project/") && route.request().method() === "PATCH") return json(route, config.project) + if (path === "/api/path") + return json(route, { + state: config.directory, + config: config.directory, + worktree: config.directory, + directory: config.directory, + home: "C:/OpenCode", + }) + if (path === "/api/permission/request") + return json(route, { + location: location(config), + data: (typeof config.permissions === "function" ? config.permissions() : (config.permissions ?? [])).map( + currentPermission, + ), + }) + if (path === "/api/question/request") + return json(route, { + location: location(config), + data: typeof config.questions === "function" ? config.questions() : (config.questions ?? []), + }) + if (path === "/api/vcs") + return json(route, { location: location(config), data: { branch: "main", defaultBranch: "main" } }) + if (path === "/api/vcs/status") return json(route, { location: location(config), data: [] }) + if (path === "/api/vcs/diff") return json(route, { location: location(config), data: config.vcsDiff ?? [] }) + if (path === "/api/pty/shells") return json(route, { location: location(config), data: [] }) + if (/^\/api\/pty\/[^/]+\/connect-token$/.test(path)) + return json(route, { location: location(config), data: { ticket: "e2e-ticket", expires_in: 60 } }) + if (emptyObject.has(path)) return json(route, {}) + if (emptyList.has(path)) return json(route, []) + if (path === "/api/session") { + const directory = url.searchParams.get("directory") + const parentID = url.searchParams.get("parentID") + const limit = Number(url.searchParams.get("limit") ?? 50) + const offset = Number(url.searchParams.get("cursor") ?? 0) + const sessions = config.sessions + .filter((session) => !directory || session.directory === directory) + .filter((session) => parentID !== "null" || session.parentID === undefined) + .filter((session) => { + const search = url.searchParams.get("search")?.toLowerCase() + return ( + !search || + String(session.title ?? "") + .toLowerCase() + .includes(search) + ) + }) + const ordered = url.searchParams.get("order") === "asc" ? sessions.toReversed() : sessions + const data = ordered.slice(offset, offset + limit) + const next = offset + limit < ordered.length ? String(offset + limit) : undefined + return json(route, { + data: data.map((session) => currentSession(session, config.directory)), + cursor: { next }, + }) + } + if (path === "/api/session/active") { + const statuses = (config.sessionStatus ?? {}) as Record + return json(route, { + data: Object.fromEntries( + Object.entries(statuses).flatMap(([id, status]) => + status.type === "idle" ? [] : [[id, { type: "running" }]], + ), + ), + }) + } + if (/^\/api\/session\/[^/]+\/shell$/.test(path) && route.request().method() === "POST") { + return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } }) + } + if (/^\/api\/session\/[^/]+\/question\/[^/]+\/(reply|reject)$/.test(path) && route.request().method() === "POST") { + return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } }) + } + if (/^\/api\/session\/[^/]+\/permission\/[^/]+\/reply$/.test(path) && route.request().method() === "POST") { + return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } }) + } + if (/^\/question\/[^/]+\/(reply|reject)$/.test(path) && route.request().method() === "POST") { + return json(route, true) + } + if (/^\/session\/[^/]+\/permissions\/[^/]+$/.test(path) && route.request().method() === "POST") { + return json(route, true) + } + if ( + /^\/api\/session\/[^/]+\/(archive|rename|interrupt|revert\/clear|revert\/commit)$/.test(path) && + route.request().method() === "POST" + ) { + return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } }) + } + if (/^\/api\/session\/[^/]+$/.test(path) && route.request().method() === "DELETE") { + return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } }) + } + if (path in staticRoutes) return json(route, staticRoutes[path]) + + const currentSessionMatch = path.match(/^\/api\/session\/([^/]+)$/) + if (currentSessionMatch) { + const session = config.sessions.find((item) => item.id === currentSessionMatch[1]) + if (!session) return json(route, { error: "Session not found" }, undefined, 404) + return json(route, { + data: currentSession(session, config.directory), + }) + } + + const sessionMatch = path.match(/^\/session\/([^/]+)$/) + if (sessionMatch) { + const session = config.sessions.find((s) => s.id === sessionMatch[1]) + return json(route, session ?? {}) + } + + const projectMatch = path.match(/^\/project\/([^/]+)$/) + if (projectMatch) return json(route, config.project) + + const messageMatch = path.match(/^\/session\/([^/]+)\/message\/([^/]+)$/) + if (messageMatch) { + config.onMessage?.({ sessionID: messageMatch[1]!, messageID: messageMatch[2]! }) + if (config.messageDelay !== undefined) await new Promise((resolve) => setTimeout(resolve, config.messageDelay)) + const message = config.message?.(messageMatch[1]!, messageMatch[2]!) + if (message === undefined) return json(route, { error: "Message not found" }, undefined, 404) + return json(route, message) + } + + const todoMatch = path.match(/^\/session\/([^/]+)\/todo$/) + if (todoMatch) return json(route, config.todos?.(todoMatch[1]!) ?? []) + if (/^\/session\/[^/]+\/(children|diff)$/.test(path)) return json(route, []) + + const currentMessagesMatch = path.match(/^\/api\/session\/([^/]+)\/message$/) + if (currentMessagesMatch) { + const token = url.searchParams.get("cursor") ?? undefined + const before = token ? cursors.get(token) : undefined + if (token && !before) return json(route, { error: "Invalid cursor" }, undefined, 400) + config.onMessages?.({ sessionID: currentMessagesMatch[1], before, phase: "start" }) + await config.beforeMessagesResponse?.({ sessionID: currentMessagesMatch[1]!, before }) + if (config.messageDelay !== undefined) await new Promise((resolve) => setTimeout(resolve, config.messageDelay)) + const pageData = config.pageMessages(currentMessagesMatch[1], Number(url.searchParams.get("limit") ?? 50), before) + config.onMessages?.({ sessionID: currentMessagesMatch[1], before, phase: "end" }) + const cursor = pageData.cursor ? `cursor_${++nextCursor}` : undefined + if (cursor) cursors.set(cursor, pageData.cursor!) + return json(route, { + data: pageData.items.map(currentMessage).reverse(), + cursor: { next: cursor }, + }) + } + + const messagesMatch = path.match(/^\/session\/([^/]+)\/message$/) + if (messagesMatch) { + const token = url.searchParams.get("before") ?? undefined + const before = token ? cursors.get(token) : undefined + if (token && !before) return json(route, { error: "Invalid cursor" }, undefined, 400) + config.onMessages?.({ sessionID: messagesMatch[1], before, phase: "start" }) + await config.beforeMessagesResponse?.({ sessionID: messagesMatch[1]!, before }) + if (config.messageDelay !== undefined) await new Promise((resolve) => setTimeout(resolve, config.messageDelay)) + const limit = Number(url.searchParams.get("limit") ?? 80) + const pageData = config.pageMessages(messagesMatch[1], limit, before) + config.onMessages?.({ sessionID: messagesMatch[1], before, phase: "end" }) + if (!pageData.cursor) return json(route, pageData.items) + const cursor = `cursor_${++nextCursor}` + cursors.set(cursor, pageData.cursor) + return json(route, pageData.items, { "x-next-cursor": cursor }) + } + + if (url.port === targetPort && targetPort !== appPort) return json(route, {}) + return route.fallback() + }) +} + +function location(config: MockServerConfig) { + return { + directory: config.directory, + project: { id: (config.project as { id?: string }).id, directory: config.directory }, + } +} + +function currentPermission(value: unknown) { + const permission = value as Record + if (permission.action) return permission + const tool = permission.tool as { messageID?: string; callID?: string } | undefined + return { + id: permission.id, + sessionID: permission.sessionID, + action: permission.permission, + resources: permission.patterns ?? [], + save: permission.always, + metadata: permission.metadata, + source: + tool?.messageID && tool.callID ? { type: "tool", messageID: tool.messageID, callID: tool.callID } : undefined, + } +} + +export function currentSession(session: { id: string } & Record, fallbackDirectory?: string) { + const time = session.time && typeof session.time === "object" ? session.time : {} + return { + id: session.id, + parentID: session.parentID, + projectID: session.projectID ?? "project", + agent: session.agent ?? "build", + model: session.model ?? { id: "mock-model", providerID: "mock-provider" }, + cost: session.cost ?? 0, + tokens: session.tokens ?? { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + time: { + created: "created" in time && typeof time.created === "number" ? time.created : 0, + updated: "updated" in time && typeof time.updated === "number" ? time.updated : 0, + ...(session.time && typeof session.time === "object" && "archived" in session.time + ? { archived: session.time.archived } + : {}), + }, + title: session.title ?? session.id, + location: { + directory: typeof session.directory === "string" ? session.directory : fallbackDirectory, + ...(typeof session.workspaceID === "string" ? { workspaceID: session.workspaceID } : {}), + }, + subpath: session.path, + revert: session.revert, + } +} + +function currentMessage(value: unknown) { + const item = value as { + info: Record & { id: string; role: "user" | "assistant"; time: { created: number } } + parts: Array & { type: string }> + } + if (item.info.role === "user") { + return { + id: item.info.id, + type: "user", + time: item.info.time, + text: item.parts + .flatMap((part) => (part.type === "text" && typeof part.text === "string" ? [part.text] : [])) + .join("\n"), + } + } + return { + id: item.info.id, + type: "assistant", + time: item.info.time, + agent: item.info.agent ?? "build", + model: { id: item.info.modelID ?? "model", providerID: item.info.providerID ?? "provider" }, + cost: item.info.cost, + tokens: item.info.tokens, + error: item.info.error, + content: item.parts.flatMap((part) => { + if (part.type === "text" || part.type === "reasoning") return [{ type: part.type, text: part.text ?? "" }] + if (part.type !== "tool") return [] + const state = part.state as Record + return [ + { + type: "tool", + id: part.id, + name: part.tool, + time: state.time ?? { created: item.info.time.created }, + state: + state.status === "pending" + ? { status: "streaming", input: state.raw ?? JSON.stringify(state.input ?? {}) } + : state.status === "completed" + ? { + status: "completed", + input: state.input ?? {}, + structured: state.metadata ?? {}, + content: [{ type: "text", text: state.output ?? "" }], + } + : state.status === "error" + ? { + status: "error", + input: state.input ?? {}, + structured: state.metadata ?? {}, + content: [], + error: { type: "ToolError", message: state.error ?? "Tool failed" }, + } + : { status: "running", input: state.input ?? {}, structured: state.metadata ?? {}, content: [] }, + }, + ] + }), + } +} + +function json(route: Route, body: unknown, headers?: Record, status = 200) { + return route.fulfill({ + status, + 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?: unknown[], retry?: number) { + return route.fulfill({ + status: 200, + contentType: "text/event-stream", + body: `${retry === undefined ? "" : `retry: ${retry}\n\n`}${events?.map((event) => `data: ${JSON.stringify(event)}\n\n`).join("") || ": ok\n\n"}`, + }) +} + +function currentEvent(input: unknown) { + if (!input || typeof input !== "object" || !("payload" in input)) return input + const envelope = input as { directory?: string; payload?: unknown } + if (!envelope.payload || typeof envelope.payload !== "object") return input + const payload = envelope.payload as { id?: string; type?: string; properties?: unknown } + if (!payload.type) return input + return { + id: payload.id ?? `evt_mock_${Date.now()}`, + created: Date.now(), + type: payload.type, + data: payload.properties ?? {}, + location: envelope.directory && envelope.directory !== "global" ? { directory: envelope.directory } : undefined, + } +} diff --git a/packages/app/e2e/utils/sse-transport.ts b/packages/app/e2e/utils/sse-transport.ts new file mode 100644 index 0000000000000000000000000000000000000000..a0a20a2e17f227aae2b532d400d700e00f63e36c --- /dev/null +++ b/packages/app/e2e/utils/sse-transport.ts @@ -0,0 +1,317 @@ +import type { Page } from "@playwright/test" + +export type SseConnectionRecord = { + id: number + url: string + path: "/global/event" | "/event" | "/api/event" + headers: Record + openedAt: number + endedAt?: number + endedBy?: "close" | "disconnect" | "error" | "abort" + error?: string +} + +export type SseDeliveryAcknowledgement = { + deliveryID: number + connectionID: number + bytes: number + chunkCount: number + deliveredAt: number + eventID?: string +} + +export type SseEventOptions = { + id?: string + event?: string + retry?: number + marker?: string +} + +export type SseTransport = { + server: string + waitForConnection(options?: { after?: number; timeout?: number }): Promise + send(payload: T, options?: SseEventOptions): Promise + burst(payloads: readonly T[], options?: readonly SseEventOptions[]): Promise + split(payload: T, cuts: readonly number[], options?: SseEventOptions): Promise + heartbeat(options?: SseEventOptions): Promise + writeRaw(value: string | Uint8Array, cuts?: readonly number[], marker?: string): Promise + close(): Promise + disconnect(message?: string): Promise + error(message?: string): Promise + connections(): Promise + acknowledgements(): Promise +} + +type BrowserCommand = + | { type: "send"; deliveries: { payload: T; options?: SseEventOptions }[]; burst: boolean; cuts?: number[] } + | { type: "raw"; bytes: number[]; cuts?: number[]; marker?: string } + | { type: "end"; mode: "close" | "disconnect" | "error"; message?: string } + | { type: "connections" } + | { type: "acknowledgements" } + +type BrowserTransport = Window & { + __testSseTransport?: { + command: (command: BrowserCommand) => unknown + } +} + +export async function installSseTransport( + page: Page, + options: { server: string; retry?: number }, +): Promise> { + const server = new URL(options.server).origin + await page.addInitScript( + ({ server, retry }) => { + type Connection = SseConnectionRecord & { controller: ReadableStreamDefaultController } + type ProbeWindow = Window & { + __visualStabilityProbe?: { startedAt: number; markers: { at: number; label: string }[] } + } + const originalFetch = window.fetch.bind(window) + const connections: Connection[] = [] + const acknowledgements: SseDeliveryAcknowledgement[] = [] + const encoder = new TextEncoder() + let nextConnectionID = 0 + let nextDeliveryID = 0 + + const current = () => connections.findLast((connection) => connection.endedAt === undefined) + const chunks = (bytes: Uint8Array, cuts?: readonly number[]) => { + const boundaries = [...new Set(cuts ?? [])] + .filter((cut) => Number.isInteger(cut) && cut > 0 && cut < bytes.byteLength) + .sort((a, b) => a - b) + return [0, ...boundaries].map((start, index) => bytes.slice(start, boundaries[index] ?? bytes.byteLength)) + } + const marker = (label?: string) => { + if (!label) return + const probe = (window as ProbeWindow).__visualStabilityProbe + if (!probe) return + probe.markers.push({ at: performance.now() - probe.startedAt, label }) + } + const frame = (payload: unknown, eventOptions: SseEventOptions = {}) => + [ + eventOptions.event === undefined ? "" : `event: ${eventOptions.event}\n`, + eventOptions.id === undefined ? "" : `id: ${eventOptions.id}\n`, + eventOptions.retry === undefined ? "" : `retry: ${eventOptions.retry}\n`, + `data: ${JSON.stringify(payload)}\n\n`, + ].join("") + const currentEvent = (input: unknown) => { + if (!input || typeof input !== "object" || !("payload" in input)) return input + const envelope = input as { directory?: string; payload?: unknown } + if (!envelope.payload || typeof envelope.payload !== "object") return input + const payload = envelope.payload as { id?: string; type?: string; properties?: unknown } + if (!payload.type) return input + return { + id: payload.id ?? `evt_mock_${Date.now()}`, + created: Date.now(), + type: payload.type, + data: payload.properties ?? {}, + location: + envelope.directory && envelope.directory !== "global" ? { directory: envelope.directory } : undefined, + } + } + const acknowledge = ( + connection: Connection, + bytes: number, + chunkCount: number, + eventID?: string, + ): SseDeliveryAcknowledgement => { + const acknowledgement = { + deliveryID: ++nextDeliveryID, + connectionID: connection.id, + bytes, + chunkCount, + deliveredAt: performance.now(), + ...(eventID === undefined ? {} : { eventID }), + } + acknowledgements.push(acknowledgement) + return acknowledgement + } + const end = (mode: "close" | "disconnect" | "error", message?: string) => { + const connection = current() + if (!connection) throw new Error("SSE transport has no active connection") + connection.endedAt = performance.now() + connection.endedBy = mode + if (message) connection.error = message + if (mode === "close") { + connection.controller.close() + return + } + const error = new DOMException( + message ?? "SSE connection disconnected", + mode === "error" ? "Error" : "NetworkError", + ) + connection.controller.error(error) + } + + const command = (input: BrowserCommand) => { + if (input.type === "connections") + return connections.map(({ controller: _controller, ...connection }) => connection) + if (input.type === "acknowledgements") return acknowledgements + if (input.type === "end") return end(input.mode, input.message) + const connection = current() + if (!connection) throw new Error("SSE transport has no active connection") + if (input.type === "raw") { + marker(input.marker) + const output = chunks(new Uint8Array(input.bytes), input.cuts) + output.forEach((chunk) => connection.controller.enqueue(chunk)) + return acknowledge(connection, input.bytes.length, output.length) + } + const encoded = input.deliveries.map((delivery) => { + const payload = connection.path === "/api/event" ? currentEvent(delivery.payload) : delivery.payload + return { delivery, payload, bytes: encoder.encode(frame(payload, delivery.options)) } + }) + encoded.forEach((item) => marker(item.delivery.options?.marker)) + if (input.burst) { + const bytes = encoder.encode(encoded.map((item) => frame(item.payload, item.delivery.options)).join("")) + connection.controller.enqueue(bytes) + return encoded.map((item) => acknowledge(connection, item.bytes.byteLength, 1, item.delivery.options?.id)) + } + const output = chunks(encoded[0]!.bytes, input.cuts) + output.forEach((chunk) => connection.controller.enqueue(chunk)) + return acknowledge(connection, encoded[0]!.bytes.byteLength, output.length, encoded[0]!.delivery.options?.id) + } + + ;(window as BrowserTransport).__testSseTransport = { command } + const fetch = (input: RequestInfo | URL, init?: RequestInit) => { + const request = new Request(input, init) + const url = new URL(request.url) + if ( + url.origin !== server || + (url.pathname !== "/global/event" && url.pathname !== "/event" && url.pathname !== "/api/event") + ) + return originalFetch(request) + + const id = ++nextConnectionID + const record = { + id, + url: url.href, + path: url.pathname, + headers: Object.fromEntries(request.headers.entries()), + openedAt: performance.now(), + } as Connection + const stream = new ReadableStream({ + start(controller) { + record.controller = controller + connections.push(record) + if (retry !== undefined) controller.enqueue(encoder.encode(`retry: ${retry}\n\n`)) + if (url.pathname === "/api/event") + controller.enqueue( + encoder.encode(frame({ id: `evt_mock_connected_${id}`, type: "server.connected", data: {} })), + ) + if (url.pathname === "/global/event") + controller.enqueue( + encoder.encode( + frame({ + payload: { id: `evt_mock_connected_${id}`, type: "server.connected", properties: {} }, + }), + ), + ) + request.signal.addEventListener( + "abort", + () => { + if (record.endedAt !== undefined) return + record.endedAt = performance.now() + record.endedBy = "abort" + controller.error(request.signal.reason ?? new DOMException("The operation was aborted", "AbortError")) + }, + { once: true }, + ) + }, + cancel() { + if (record.endedAt !== undefined) return + record.endedAt = performance.now() + record.endedBy = "disconnect" + }, + }) + return Promise.resolve( + new Response(stream, { + status: 200, + headers: { + "cache-control": "no-cache", + "content-type": "text/event-stream", + }, + }), + ) + } + Object.defineProperty(window, "fetch", { configurable: true, writable: true, value: fetch }) + }, + { server, retry: options.retry }, + ) + + const command = (input: BrowserCommand) => + page.evaluate((input) => { + const transport = (window as BrowserTransport).__testSseTransport + if (!transport) throw new Error("SSE transport was not installed before page load") + return transport.command(input as BrowserCommand) + }, input) as Promise + + return { + server, + async waitForConnection(input = {}) { + const connection = await page.waitForFunction( + (after) => { + const transport = (window as BrowserTransport).__testSseTransport + const connections = transport?.command({ type: "connections" }) as SseConnectionRecord[] | undefined + return connections?.findLast((connection) => connection.id > after && connection.endedAt === undefined) + }, + input.after ?? 0, + { timeout: input.timeout }, + ) + let result: SseConnectionRecord | undefined + try { + result = await connection.jsonValue() + } finally { + await connection.dispose() + } + if (!result) throw new Error("SSE transport connection disappeared while waiting") + return result + }, + send(payload, eventOptions) { + return command({ type: "send", deliveries: [{ payload, options: eventOptions }], burst: false }) + }, + burst(payloads, eventOptions = []) { + return command({ + type: "send", + deliveries: payloads.map((payload, index) => ({ payload, options: eventOptions[index] })), + burst: true, + }) + }, + split(payload, cuts, eventOptions) { + return command({ type: "send", deliveries: [{ payload, options: eventOptions }], burst: false, cuts: [...cuts] }) + }, + heartbeat(eventOptions) { + return command({ + type: "send", + deliveries: [ + { + payload: { directory: "global", payload: { type: "server.heartbeat", properties: {} } } as T, + options: eventOptions, + }, + ], + burst: false, + }) + }, + writeRaw(value, cuts, marker) { + return command({ + type: "raw", + bytes: Array.from(typeof value === "string" ? new TextEncoder().encode(value) : value), + cuts: cuts ? [...cuts] : undefined, + marker, + }) + }, + close() { + return command({ type: "end", mode: "close" }) + }, + disconnect(message) { + return command({ type: "end", mode: "disconnect", message }) + }, + error(message) { + return command({ type: "end", mode: "error", message }) + }, + connections() { + return command({ type: "connections" }) + }, + acknowledgements() { + return command({ type: "acknowledgements" }) + }, + } +} diff --git a/packages/app/e2e/utils/visual-stability.ts b/packages/app/e2e/utils/visual-stability.ts new file mode 100644 index 0000000000000000000000000000000000000000..2c56864ed8dd204559fffd5204b56f9bf7c8887a --- /dev/null +++ b/packages/app/e2e/utils/visual-stability.ts @@ -0,0 +1,54 @@ +import type { Page, TestInfo } from "@playwright/test" +import { analyzeVisualObservations, analyzeVisualTraceByMarker } from "./visual-stability/analyzer" +import { legacyVisualPlan, type LegacyVisualStabilityOptions } from "./visual-stability/invariant" +import type { CapturedFrame, VisualStabilityTrace } from "./visual-stability/model" +import { markVisualProbe, startVisualProbe, stopVisualProbe } from "./visual-stability/probe" +import type { VisualRegionDefinition } from "./visual-stability/regions" +import { reportVisualStability } from "./visual-stability/reporter" + +export * from "./visual-stability/index" + +const capturedFrames = Symbol("capturedFrames") + +export async function startVisualStabilityProbe(page: Page, regions: Record) { + await startVisualProbe(page, regions) +} + +export async function stopVisualStabilityProbe(page: Page) { + const result = await stopVisualProbe(page) + const trace: VisualStabilityTrace = { markers: result.markers, samples: result.samples } + Object.defineProperty(trace, capturedFrames, { value: result.frames }) + return trace +} + +export async function markVisualStability(page: Page, label: string) { + await markVisualProbe(page, label) +} + +export function analyzeVisualStability(trace: VisualStabilityTrace, options: LegacyVisualStabilityOptions = {}) { + return analyzeVisualObservations(trace.samples, legacyVisualPlan(options)) +} + +export function analyzeVisualStabilityByMarker( + trace: VisualStabilityTrace, + options: LegacyVisualStabilityOptions = {}, +) { + return analyzeVisualTraceByMarker(trace, legacyVisualPlan(options)) +} + +export async function expectVisualStability( + testInfo: TestInfo, + name: string, + trace: VisualStabilityTrace, + options: LegacyVisualStabilityOptions = {}, +) { + await reportVisualStability( + testInfo, + name, + { + ...trace, + frames: (trace as VisualStabilityTrace & { [capturedFrames]?: CapturedFrame[] })[capturedFrames] ?? [], + }, + legacyVisualPlan(options), + ) +} diff --git a/packages/app/e2e/utils/waits.ts b/packages/app/e2e/utils/waits.ts new file mode 100644 index 0000000000000000000000000000000000000000..8a47815674d296444378753e1609d02c37f73385 --- /dev/null +++ b/packages/app/e2e/utils/waits.ts @@ -0,0 +1,11 @@ +import { expect, type Locator, type Page } from "@playwright/test" + +export const APP_READY_TIMEOUT = 30_000 + +export async function expectAppVisible(locator: Locator) { + await expect(locator).toBeVisible({ timeout: APP_READY_TIMEOUT }) +} + +export async function expectSessionTitle(page: Page, title: string) { + await expectAppVisible(page.getByRole("heading", { name: title })) +} diff --git a/packages/app/src/addons/serialize.test.ts b/packages/app/src/addons/serialize.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..d45af34c43cb4a869bdd761c534d5f155c0b88fe --- /dev/null +++ b/packages/app/src/addons/serialize.test.ts @@ -0,0 +1,327 @@ +import { describe, test, expect, beforeAll, afterEach } from "bun:test" +import { Terminal, Ghostty } from "ghostty-web" +import { SerializeAddon } from "./serialize" + +let ghostty: Ghostty +beforeAll(async () => { + ghostty = await Ghostty.load() +}) + +const terminals: Terminal[] = [] + +afterEach(() => { + for (const term of terminals) { + term.dispose() + } + terminals.length = 0 + document.body.innerHTML = "" +}) + +function createTerminal(cols = 80, rows = 24): { term: Terminal; addon: SerializeAddon; container: HTMLElement } { + const container = document.createElement("div") + document.body.appendChild(container) + + const term = new Terminal({ cols, rows, ghostty }) + const addon = new SerializeAddon() + term.loadAddon(addon) + term.open(container) + terminals.push(term) + + return { term, addon, container } +} + +function writeAndWait(term: Terminal, data: string): Promise { + return new Promise((resolve) => { + term.write(data, resolve) + }) +} + +describe("SerializeAddon", () => { + test("preserves color scheme reporting mode", async () => { + const { term, addon } = createTerminal() + await writeAndWait(term, "\x1b[?2031h") + + expect(addon.serialize().startsWith("\x1b[?2031h")).toBe(true) + expect(addon.serialize({ excludeModes: true }).startsWith("\x1b[?2031h")).toBe(false) + }) + + describe("ANSI color preservation", () => { + test("should preserve text attributes (bold, italic, underline)", async () => { + const { term, addon } = createTerminal() + + const input = "\x1b[1mBOLD\x1b[0m \x1b[3mITALIC\x1b[0m \x1b[4mUNDER\x1b[0m" + await writeAndWait(term, input) + + const origLine = term.buffer.active.getLine(0) + expect(origLine!.getCell(0)!.isBold()).toBe(1) + expect(origLine!.getCell(5)!.isItalic()).toBe(1) + expect(origLine!.getCell(12)!.isUnderline()).toBe(1) + + const serialized = addon.serialize({ range: { start: 0, end: 0 } }) + + const { term: term2 } = createTerminal() + terminals.push(term2) + await writeAndWait(term2, serialized) + + const line = term2.buffer.active.getLine(0) + + const boldCell = line!.getCell(0) + expect(boldCell!.getChars()).toBe("B") + expect(boldCell!.isBold()).toBe(1) + + const italicCell = line!.getCell(5) + expect(italicCell!.getChars()).toBe("I") + expect(italicCell!.isItalic()).toBe(1) + + const underCell = line!.getCell(12) + expect(underCell!.getChars()).toBe("U") + expect(underCell!.isUnderline()).toBe(1) + }) + + test("should preserve basic 16-color foreground colors", async () => { + const { term, addon } = createTerminal() + + const input = "\x1b[31mRED\x1b[32mGREEN\x1b[34mBLUE\x1b[0mNORMAL" + await writeAndWait(term, input) + + const origLine = term.buffer.active.getLine(0) + const origRedFg = origLine!.getCell(0)!.getFgColor() + const origGreenFg = origLine!.getCell(3)!.getFgColor() + const origBlueFg = origLine!.getCell(8)!.getFgColor() + + const serialized = addon.serialize({ range: { start: 0, end: 0 } }) + + const { term: term2 } = createTerminal() + terminals.push(term2) + await writeAndWait(term2, serialized) + + const line = term2.buffer.active.getLine(0) + expect(line).toBeDefined() + + const redCell = line!.getCell(0) + expect(redCell!.getChars()).toBe("R") + expect(redCell!.getFgColor()).toBe(origRedFg) + + const greenCell = line!.getCell(3) + expect(greenCell!.getChars()).toBe("G") + expect(greenCell!.getFgColor()).toBe(origGreenFg) + + const blueCell = line!.getCell(8) + expect(blueCell!.getChars()).toBe("B") + expect(blueCell!.getFgColor()).toBe(origBlueFg) + }) + + test("should preserve 256-color palette colors", async () => { + const { term, addon } = createTerminal() + + const input = "\x1b[38;5;196mRED256\x1b[0mNORMAL" + await writeAndWait(term, input) + + const origLine = term.buffer.active.getLine(0) + const origRedFg = origLine!.getCell(0)!.getFgColor() + + const serialized = addon.serialize({ range: { start: 0, end: 0 } }) + + const { term: term2 } = createTerminal() + terminals.push(term2) + await writeAndWait(term2, serialized) + + const line = term2.buffer.active.getLine(0) + const redCell = line!.getCell(0) + expect(redCell!.getChars()).toBe("R") + expect(redCell!.getFgColor()).toBe(origRedFg) + }) + + test("should preserve RGB/truecolor colors", async () => { + const { term, addon } = createTerminal() + + const input = "\x1b[38;2;255;128;64mRGB_TEXT\x1b[0mNORMAL" + await writeAndWait(term, input) + + const origLine = term.buffer.active.getLine(0) + const origRgbFg = origLine!.getCell(0)!.getFgColor() + + const serialized = addon.serialize({ range: { start: 0, end: 0 } }) + + const { term: term2 } = createTerminal() + terminals.push(term2) + await writeAndWait(term2, serialized) + + const line = term2.buffer.active.getLine(0) + const rgbCell = line!.getCell(0) + expect(rgbCell!.getChars()).toBe("R") + expect(rgbCell!.getFgColor()).toBe(origRgbFg) + }) + + test("should preserve background colors", async () => { + const { term, addon } = createTerminal() + + const input = "\x1b[48;2;255;0;0mRED_BG\x1b[48;2;0;255;0mGREEN_BG\x1b[0mNORMAL" + await writeAndWait(term, input) + + const origLine = term.buffer.active.getLine(0) + const origRedBg = origLine!.getCell(0)!.getBgColor() + const origGreenBg = origLine!.getCell(6)!.getBgColor() + + const serialized = addon.serialize({ range: { start: 0, end: 0 } }) + + const { term: term2 } = createTerminal() + terminals.push(term2) + await writeAndWait(term2, serialized) + + const line = term2.buffer.active.getLine(0) + + const redBgCell = line!.getCell(0) + expect(redBgCell!.getChars()).toBe("R") + expect(redBgCell!.getBgColor()).toBe(origRedBg) + + const greenBgCell = line!.getCell(6) + expect(greenBgCell!.getChars()).toBe("G") + expect(greenBgCell!.getBgColor()).toBe(origGreenBg) + }) + + test("should handle combined colors and attributes", async () => { + const { term, addon } = createTerminal() + + const input = + "\x1b[1;38;2;255;0;0;48;2;255;255;0mCOMBO\x1b[0mNORMAL " + await writeAndWait(term, input) + + const origLine = term.buffer.active.getLine(0) + const _origFg = origLine!.getCell(0)!.getFgColor() + const _origBg = origLine!.getCell(0)!.getBgColor() + expect(origLine!.getCell(0)!.isBold()).toBe(1) + + const serialized = addon.serialize({ range: { start: 0, end: 0 } }) + const cleanSerialized = serialized.replace(/\x1b\[\d+X/g, "") + + expect(cleanSerialized.startsWith("\x1b[1;")).toBe(true) + + const { term: term2 } = createTerminal() + terminals.push(term2) + await writeAndWait(term2, cleanSerialized) + + const line = term2.buffer.active.getLine(0) + const comboCell = line!.getCell(0) + + expect(comboCell!.getChars()).toBe("C") + expect(cleanSerialized).toContain("\x1b[1;38;2;255;0;0;48;2;255;255;0m") + }) + }) + + describe("round-trip serialization", () => { + test("should not produce ECH sequences", async () => { + const { term, addon } = createTerminal() + + await writeAndWait(term, "\x1b[31mHello\x1b[0m World") + + const serialized = addon.serialize() + + const hasECH = /\x1b\[\d+X/.test(serialized) + expect(hasECH).toBe(false) + }) + + test("multi-line content should not have garbage characters", async () => { + const { term, addon } = createTerminal() + + const content = [ + "\x1b[1;32m❯\x1b[0m \x1b[34mcd\x1b[0m /some/path", + "\x1b[1;32m❯\x1b[0m \x1b[34mls\x1b[0m -la", + "total 42", + ].join("\r\n") + + await writeAndWait(term, content) + + const serialized = addon.serialize() + + expect(/\x1b\[\d+X/.test(serialized)).toBe(false) + + const { term: term2 } = createTerminal() + terminals.push(term2) + await writeAndWait(term2, serialized) + + for (let row = 0; row < 3; row++) { + const line = term2.buffer.active.getLine(row)?.translateToString(true) + expect(line?.includes("𑼝")).toBe(false) + } + + expect(term2.buffer.active.getLine(0)?.translateToString(true)).toContain("cd /some/path") + expect(term2.buffer.active.getLine(1)?.translateToString(true)).toContain("ls -la") + expect(term2.buffer.active.getLine(2)?.translateToString(true)).toBe("total 42") + }) + + test("serialized output should restore after Terminal.reset()", async () => { + const { term, addon } = createTerminal() + + const content = [ + "\x1b[1;32m❯\x1b[0m \x1b[34mcd\x1b[0m /some/path", + "\x1b[1;32m❯\x1b[0m \x1b[34mls\x1b[0m -la", + "total 42", + ].join("\r\n") + + await writeAndWait(term, content) + + const serialized = addon.serialize() + + const { term: term2 } = createTerminal() + terminals.push(term2) + term2.reset() + await writeAndWait(term2, serialized) + + expect(term2.buffer.active.getLine(0)?.translateToString(true)).toContain("cd /some/path") + expect(term2.buffer.active.getLine(1)?.translateToString(true)).toContain("ls -la") + expect(term2.buffer.active.getLine(2)?.translateToString(true)).toBe("total 42") + }) + + test("alternate buffer should round-trip without garbage", async () => { + const { term, addon } = createTerminal(20, 5) + + await writeAndWait(term, "normal\r\n") + await writeAndWait(term, "\x1b[?1049h\x1b[HALT") + + expect(term.buffer.active.type).toBe("alternate") + + const serialized = addon.serialize() + + const { term: term2 } = createTerminal(20, 5) + terminals.push(term2) + await writeAndWait(term2, serialized) + + expect(term2.buffer.active.type).toBe("alternate") + + const line = term2.buffer.active.getLine(0) + expect(line?.translateToString(true)).toBe("ALT") + + // Ensure a cell beyond content isn't garbage + const cellCode = line?.getCell(10)?.getCode() + expect(cellCode === 0 || cellCode === 32).toBe(true) + }) + + test("serialized output written to new terminal should match original colors", async () => { + const { term, addon } = createTerminal(40, 5) + + const input = "\x1b[38;2;255;0;0mHello\x1b[0m \x1b[38;2;0;255;0mWorld\x1b[0m! " + await writeAndWait(term, input) + + const origLine = term.buffer.active.getLine(0) + const origHelloFg = origLine!.getCell(0)!.getFgColor() + const origWorldFg = origLine!.getCell(6)!.getFgColor() + + const serialized = addon.serialize({ range: { start: 0, end: 0 } }) + + const { term: term2 } = createTerminal(40, 5) + terminals.push(term2) + await writeAndWait(term2, serialized) + + const newLine = term2.buffer.active.getLine(0) + + expect(newLine!.getCell(0)!.getChars()).toBe("H") + expect(newLine!.getCell(0)!.getFgColor()).toBe(origHelloFg) + + expect(newLine!.getCell(6)!.getChars()).toBe("W") + expect(newLine!.getCell(6)!.getFgColor()).toBe(origWorldFg) + + expect(newLine!.getCell(11)!.getChars()).toBe("!") + }) + }) +}) diff --git a/packages/app/src/addons/serialize.ts b/packages/app/src/addons/serialize.ts new file mode 100644 index 0000000000000000000000000000000000000000..515153488c1d8fa23abce30dc72294bef1a598f3 --- /dev/null +++ b/packages/app/src/addons/serialize.ts @@ -0,0 +1,642 @@ +/** + * SerializeAddon - Serialize terminal buffer contents + * + * Port of xterm.js addon-serialize for ghostty-web. + * Enables serialization of terminal contents to a string that can + * be written back to restore terminal state. + * + * Usage: + * ```typescript + * const serializeAddon = new SerializeAddon(); + * term.loadAddon(serializeAddon); + * const content = serializeAddon.serialize(); + * ``` + */ + +import type { ITerminalAddon, ITerminalCore, IBufferRange } from "ghostty-web" + +// ============================================================================ +// Buffer Types (matching ghostty-web internal interfaces) +// ============================================================================ + +interface IBuffer { + readonly type: "normal" | "alternate" + readonly cursorX: number + readonly cursorY: number + readonly viewportY: number + readonly baseY: number + readonly length: number + getLine(y: number): IBufferLine | undefined + getNullCell(): IBufferCell +} + +interface IBufferLine { + readonly length: number + readonly isWrapped: boolean + getCell(x: number): IBufferCell | undefined + translateToString(trimRight?: boolean, startColumn?: number, endColumn?: number): string +} + +interface IBufferCell { + getChars(): string + getCode(): number + getWidth(): number + getFgColorMode(): number + getBgColorMode(): number + getFgColor(): number + getBgColor(): number + isBold(): number + isItalic(): number + isUnderline(): number + isStrikethrough(): number + isBlink(): number + isInverse(): number + isInvisible(): number + isFaint(): number + isDim(): boolean +} + +type TerminalBuffers = { + active?: IBuffer + normal?: IBuffer + alternate?: IBuffer +} + +const isRecord = (value: unknown): value is Record => { + return typeof value === "object" && value !== null +} + +const isBuffer = (value: unknown): value is IBuffer => { + if (!isRecord(value)) return false + if (typeof value.length !== "number") return false + if (typeof value.cursorX !== "number") return false + if (typeof value.cursorY !== "number") return false + if (typeof value.baseY !== "number") return false + if (typeof value.viewportY !== "number") return false + if (typeof value.getLine !== "function") return false + if (typeof value.getNullCell !== "function") return false + return true +} + +const getTerminalBuffers = (value: ITerminalCore): TerminalBuffers | undefined => { + if (!isRecord(value)) return + const raw = value.buffer + if (!isRecord(raw)) return + const active = isBuffer(raw.active) ? raw.active : undefined + const normal = isBuffer(raw.normal) ? raw.normal : undefined + const alternate = isBuffer(raw.alternate) ? raw.alternate : undefined + if (!active && !normal) return + return { active, normal, alternate } +} + +const getTerminalMode = (value: ITerminalCore, mode: number) => { + if (!isRecord(value)) return false + const terminal = value.wasmTerm + if (!isRecord(terminal) || typeof terminal.getMode !== "function") return false + return terminal.getMode(mode) === true +} + +// ============================================================================ +// Types +// ============================================================================ + +export interface ISerializeOptions { + /** + * The row range to serialize. When an explicit range is specified, the cursor + * will get its final repositioning. + */ + range?: ISerializeRange + /** + * The number of rows in the scrollback buffer to serialize, starting from + * the bottom of the scrollback buffer. When not specified, all available + * rows in the scrollback buffer will be serialized. + */ + scrollback?: number + /** + * Whether to exclude the terminal modes from the serialization. + * Default: false + */ + excludeModes?: boolean + /** + * Whether to exclude the alt buffer from the serialization. + * Default: false + */ + excludeAltBuffer?: boolean +} + +export interface ISerializeRange { + /** + * The line to start serializing (inclusive). + */ + start: number + /** + * The line to end serializing (inclusive). + */ + end: number +} + +export interface IHTMLSerializeOptions { + /** + * The number of rows in the scrollback buffer to serialize, starting from + * the bottom of the scrollback buffer. + */ + scrollback?: number + /** + * Whether to only serialize the selection. + * Default: false + */ + onlySelection?: boolean + /** + * Whether to include the global background of the terminal. + * Default: false + */ + includeGlobalBackground?: boolean + /** + * The range to serialize. This is prioritized over onlySelection. + */ + range?: { + startLine: number + endLine: number + startCol: number + } +} + +// ============================================================================ +// Helper Functions +// ============================================================================ + +function constrain(value: number, low: number, high: number): number { + return Math.max(low, Math.min(value, high)) +} + +function equalFg(cell1: IBufferCell, cell2: IBufferCell): boolean { + return cell1.getFgColorMode() === cell2.getFgColorMode() && cell1.getFgColor() === cell2.getFgColor() +} + +function equalBg(cell1: IBufferCell, cell2: IBufferCell): boolean { + return cell1.getBgColorMode() === cell2.getBgColorMode() && cell1.getBgColor() === cell2.getBgColor() +} + +function equalFlags(cell1: IBufferCell, cell2: IBufferCell): boolean { + return ( + !!cell1.isInverse() === !!cell2.isInverse() && + !!cell1.isBold() === !!cell2.isBold() && + !!cell1.isUnderline() === !!cell2.isUnderline() && + !!cell1.isBlink() === !!cell2.isBlink() && + !!cell1.isInvisible() === !!cell2.isInvisible() && + !!cell1.isItalic() === !!cell2.isItalic() && + !!cell1.isDim() === !!cell2.isDim() && + !!cell1.isStrikethrough() === !!cell2.isStrikethrough() + ) +} + +// ============================================================================ +// Base Serialize Handler +// ============================================================================ + +abstract class BaseSerializeHandler { + constructor(protected readonly _buffer: IBuffer) {} + + public serialize(range: IBufferRange, excludeFinalCursorPosition?: boolean): string { + let oldCell = this._buffer.getNullCell() + + const startRow = range.start.y + const endRow = range.end.y + const startColumn = range.start.x + const endColumn = range.end.x + + this._beforeSerialize(endRow - startRow + 1, startRow, endRow) + + for (let row = startRow; row <= endRow; row++) { + const line = this._buffer.getLine(row) + if (line) { + const startLineColumn = row === range.start.y ? startColumn : 0 + const endLineColumn = Math.min(endColumn, line.length) + + for (let col = startLineColumn; col < endLineColumn; col++) { + const c = line.getCell(col) + if (!c) { + continue + } + this._nextCell(c, oldCell, row, col) + oldCell = c + } + } + this._rowEnd(row, row === endRow) + } + + this._afterSerialize() + + return this._serializeString(excludeFinalCursorPosition) + } + + protected _nextCell(_cell: IBufferCell, _oldCell: IBufferCell, _row: number, _col: number): void {} + protected _rowEnd(_row: number, _isLastRow: boolean): void {} + protected _beforeSerialize(_rows: number, _startRow: number, _endRow: number): void {} + protected _afterSerialize(): void {} + protected _serializeString(_excludeFinalCursorPosition?: boolean): string { + return "" + } +} + +// ============================================================================ +// String Serialize Handler +// ============================================================================ + +class StringSerializeHandler extends BaseSerializeHandler { + private _rowIndex: number = 0 + private _allRows: string[] = [] + private _allRowSeparators: string[] = [] + private _currentRow: string = "" + private _nullCellCount: number = 0 + private _cursorStyle: IBufferCell + private _firstRow: number = 0 + private _lastCursorRow: number = 0 + private _lastCursorCol: number = 0 + private _lastContentCursorRow: number = 0 + private _lastContentCursorCol: number = 0 + + constructor( + buffer: IBuffer, + private readonly _terminal: ITerminalCore, + ) { + super(buffer) + this._cursorStyle = this._buffer.getNullCell() + } + + protected _beforeSerialize(rows: number, start: number, _end: number): void { + this._allRows = Array.from({ length: rows }) + this._allRowSeparators = Array.from({ length: rows }) + this._rowIndex = 0 + + this._currentRow = "" + this._nullCellCount = 0 + this._cursorStyle = this._buffer.getNullCell() + + this._lastContentCursorRow = start + this._lastCursorRow = start + this._firstRow = start + } + + protected _rowEnd(row: number, isLastRow: boolean): void { + let rowSeparator = "" + + const nextLine = isLastRow ? undefined : this._buffer.getLine(row + 1) + const wrapped = !!nextLine?.isWrapped + + if (this._nullCellCount > 0 && wrapped) { + this._currentRow += " ".repeat(this._nullCellCount) + } + + this._nullCellCount = 0 + + if (!isLastRow && !wrapped) { + rowSeparator = "\r\n" + this._lastCursorRow = row + 1 + this._lastCursorCol = 0 + } + + this._allRows[this._rowIndex] = this._currentRow + this._allRowSeparators[this._rowIndex++] = rowSeparator + this._currentRow = "" + this._nullCellCount = 0 + } + + private _diffStyle(cell: IBufferCell, oldCell: IBufferCell): number[] { + const sgrSeq: number[] = [] + const fgChanged = !equalFg(cell, oldCell) + const bgChanged = !equalBg(cell, oldCell) + const flagsChanged = !equalFlags(cell, oldCell) + + if (fgChanged || bgChanged || flagsChanged) { + if (this._isAttributeDefault(cell)) { + if (!this._isAttributeDefault(oldCell)) { + sgrSeq.push(0) + } + } else { + if (flagsChanged) { + if (!!cell.isInverse() !== !!oldCell.isInverse()) { + sgrSeq.push(cell.isInverse() ? 7 : 27) + } + if (!!cell.isBold() !== !!oldCell.isBold()) { + sgrSeq.push(cell.isBold() ? 1 : 22) + } + if (!!cell.isUnderline() !== !!oldCell.isUnderline()) { + sgrSeq.push(cell.isUnderline() ? 4 : 24) + } + if (!!cell.isBlink() !== !!oldCell.isBlink()) { + sgrSeq.push(cell.isBlink() ? 5 : 25) + } + if (!!cell.isInvisible() !== !!oldCell.isInvisible()) { + sgrSeq.push(cell.isInvisible() ? 8 : 28) + } + if (!!cell.isItalic() !== !!oldCell.isItalic()) { + sgrSeq.push(cell.isItalic() ? 3 : 23) + } + if (!!cell.isDim() !== !!oldCell.isDim()) { + sgrSeq.push(cell.isDim() ? 2 : 22) + } + if (!!cell.isStrikethrough() !== !!oldCell.isStrikethrough()) { + sgrSeq.push(cell.isStrikethrough() ? 9 : 29) + } + } + if (fgChanged) { + const color = cell.getFgColor() + const mode = cell.getFgColorMode() + if (mode === 2 || mode === 3 || mode === -1) { + sgrSeq.push(38, 2, (color >>> 16) & 0xff, (color >>> 8) & 0xff, color & 0xff) + } else if (mode === 1) { + // Palette + if (color >= 16) { + sgrSeq.push(38, 5, color) + } else { + sgrSeq.push(color & 8 ? 90 + (color & 7) : 30 + (color & 7)) + } + } else { + sgrSeq.push(39) + } + } + if (bgChanged) { + const color = cell.getBgColor() + const mode = cell.getBgColorMode() + if (mode === 2 || mode === 3 || mode === -1) { + sgrSeq.push(48, 2, (color >>> 16) & 0xff, (color >>> 8) & 0xff, color & 0xff) + } else if (mode === 1) { + // Palette + if (color >= 16) { + sgrSeq.push(48, 5, color) + } else { + sgrSeq.push(color & 8 ? 100 + (color & 7) : 40 + (color & 7)) + } + } else { + sgrSeq.push(49) + } + } + } + } + + return sgrSeq + } + + private _isAttributeDefault(cell: IBufferCell): boolean { + const mode = cell.getFgColorMode() + const bgMode = cell.getBgColorMode() + + if (mode === 0 && bgMode === 0) { + return ( + !cell.isBold() && + !cell.isItalic() && + !cell.isUnderline() && + !cell.isBlink() && + !cell.isInverse() && + !cell.isInvisible() && + !cell.isDim() && + !cell.isStrikethrough() + ) + } + + const fgColor = cell.getFgColor() + const bgColor = cell.getBgColor() + const nullCell = this._buffer.getNullCell() + const nullFg = nullCell.getFgColor() + const nullBg = nullCell.getBgColor() + + return ( + fgColor === nullFg && + bgColor === nullBg && + !cell.isBold() && + !cell.isItalic() && + !cell.isUnderline() && + !cell.isBlink() && + !cell.isInverse() && + !cell.isInvisible() && + !cell.isDim() && + !cell.isStrikethrough() + ) + } + + protected _nextCell(cell: IBufferCell, _oldCell: IBufferCell, row: number, col: number): void { + const isPlaceHolderCell = cell.getWidth() === 0 + + if (isPlaceHolderCell) { + return + } + + const codepoint = cell.getCode() + const isInvalidCodepoint = codepoint > 0x10ffff || (codepoint >= 0xd800 && codepoint <= 0xdfff) + const isGarbage = isInvalidCodepoint || (codepoint >= 0xf000 && cell.getWidth() === 1) + const isEmptyCell = codepoint === 0 || cell.getChars() === "" || isGarbage + + const sgrSeq = this._diffStyle(cell, this._cursorStyle) + + const styleChanged = sgrSeq.length > 0 + + if (styleChanged) { + if (this._nullCellCount > 0) { + this._currentRow += " ".repeat(this._nullCellCount) + this._nullCellCount = 0 + } + + this._lastContentCursorRow = this._lastCursorRow = row + this._lastContentCursorCol = this._lastCursorCol = col + + this._currentRow += `\u001b[${sgrSeq.join(";")}m` + + const line = this._buffer.getLine(row) + const cellFromLine = line?.getCell(col) + if (cellFromLine) { + this._cursorStyle = cellFromLine + } + } + + if (isEmptyCell) { + this._nullCellCount += cell.getWidth() + } else { + if (this._nullCellCount > 0) { + this._currentRow += " ".repeat(this._nullCellCount) + this._nullCellCount = 0 + } + + this._currentRow += cell.getChars() + + this._lastContentCursorRow = this._lastCursorRow = row + this._lastContentCursorCol = this._lastCursorCol = col + cell.getWidth() + } + } + + protected _serializeString(excludeFinalCursorPosition?: boolean): string { + let rowEnd = this._allRows.length + + if (this._buffer.length - this._firstRow <= this._terminal.rows) { + rowEnd = this._lastContentCursorRow + 1 - this._firstRow + this._lastCursorCol = this._lastContentCursorCol + this._lastCursorRow = this._lastContentCursorRow + } + + let content = "" + + for (let i = 0; i < rowEnd; i++) { + content += this._allRows[i] + if (i + 1 < rowEnd) { + content += this._allRowSeparators[i] + } + } + + if (excludeFinalCursorPosition) return content + + const absoluteCursorRow = (this._buffer.baseY ?? 0) + this._buffer.cursorY + const cursorRow = constrain(absoluteCursorRow - this._firstRow + 1, 1, Number.MAX_SAFE_INTEGER) + const cursorCol = this._buffer.cursorX + 1 + content += `\u001b[${cursorRow};${cursorCol}H` + + const line = this._buffer.getLine(absoluteCursorRow) + const cell = line?.getCell(this._buffer.cursorX) + const style = (() => { + if (!cell) return this._buffer.getNullCell() + if (cell.getWidth() !== 0) return cell + if (this._buffer.cursorX > 0) return line?.getCell(this._buffer.cursorX - 1) ?? cell + return cell + })() + + const sgrSeq = this._diffStyle(style, this._cursorStyle) + if (sgrSeq.length) content += `\u001b[${sgrSeq.join(";")}m` + + return content + } +} + +// ============================================================================ +// SerializeAddon Class +// ============================================================================ + +export class SerializeAddon implements ITerminalAddon { + private _terminal?: ITerminalCore + + /** + * Activate the addon (called by Terminal.loadAddon) + */ + public activate(terminal: ITerminalCore): void { + this._terminal = terminal + } + + /** + * Dispose the addon and clean up resources + */ + public dispose(): void { + this._terminal = undefined + } + + /** + * Serializes terminal rows into a string that can be written back to the + * terminal to restore the state. The cursor will also be positioned to the + * correct cell. + * + * @param options Custom options to allow control over what gets serialized. + */ + public serialize(options?: ISerializeOptions): string { + if (!this._terminal) { + throw new Error("Cannot use addon until it has been loaded") + } + + const buffer = getTerminalBuffers(this._terminal) + + if (!buffer) { + return "" + } + + const normalBuffer = buffer.normal ?? buffer.active + const altBuffer = buffer.alternate + + if (!normalBuffer) { + return "" + } + + let content = !options?.excludeModes && getTerminalMode(this._terminal, 2031) ? "\u001b[?2031h" : "" + content += options?.range + ? this._serializeBufferByRange(normalBuffer, options.range, true) + : this._serializeBufferByScrollback(normalBuffer, options?.scrollback) + + if (!options?.excludeAltBuffer && buffer.active?.type === "alternate" && altBuffer) { + const alternateContent = this._serializeBufferByScrollback(altBuffer, undefined) + content += `\u001b[?1049h\u001b[H${alternateContent}` + } + + return content + } + + /** + * Serializes terminal content as plain text (no escape sequences) + * @param options Custom options to allow control over what gets serialized. + */ + public serializeAsText(options?: { scrollback?: number; trimWhitespace?: boolean }): string { + if (!this._terminal) { + throw new Error("Cannot use addon until it has been loaded") + } + + const buffer = getTerminalBuffers(this._terminal) + + if (!buffer) { + return "" + } + + const activeBuffer = buffer.active ?? buffer.normal + if (!activeBuffer) { + return "" + } + + const maxRows = activeBuffer.length + const scrollback = options?.scrollback + const correctRows = scrollback === undefined ? maxRows : constrain(scrollback + this._terminal.rows, 0, maxRows) + + const startRow = maxRows - correctRows + const endRow = maxRows - 1 + const lines: string[] = [] + + for (let row = startRow; row <= endRow; row++) { + const line = activeBuffer.getLine(row) + if (line) { + const text = line.translateToString(options?.trimWhitespace ?? true) + lines.push(text) + } + } + + // Trim trailing empty lines if requested + if (options?.trimWhitespace) { + while (lines.length > 0 && lines[lines.length - 1] === "") { + lines.pop() + } + } + + return lines.join("\n") + } + + private _serializeBufferByScrollback(buffer: IBuffer, scrollback?: number): string { + const maxRows = buffer.length + const rows = this._terminal?.rows ?? 24 + const correctRows = scrollback === undefined ? maxRows : constrain(scrollback + rows, 0, maxRows) + return this._serializeBufferByRange( + buffer, + { + start: maxRows - correctRows, + end: maxRows - 1, + }, + false, + ) + } + + private _serializeBufferByRange( + buffer: IBuffer, + range: ISerializeRange, + excludeFinalCursorPosition: boolean, + ): string { + const handler = new StringSerializeHandler(buffer, this._terminal!) + const cols = this._terminal?.cols ?? 80 + return handler.serialize( + { + start: { x: 0, y: range.start }, + end: { x: cols, y: range.end }, + }, + excludeFinalCursorPosition, + ) + } +} diff --git a/packages/app/src/components/dialog-select-model.tsx b/packages/app/src/components/dialog-select-model.tsx new file mode 100644 index 0000000000000000000000000000000000000000..587a36a6660173dd13b103ff91ad6f224417bf35 --- /dev/null +++ b/packages/app/src/components/dialog-select-model.tsx @@ -0,0 +1,557 @@ +import { Popover as Kobalte } from "@kobalte/core/popover" +import { Component, ComponentProps, createEffect, createMemo, For, JSX, Show } from "solid-js" +import { createStore } from "solid-js/store" +import { useLocal } from "@/context/local" +import { useDialog } from "@opencode-ai/ui/context/dialog" +import { popularProviders } from "@/hooks/use-providers" +import { Button } from "@opencode-ai/ui/button" +import { IconButton } from "@opencode-ai/ui/icon-button" +import { ScrollView } from "@opencode-ai/ui/scroll-view" +import { Tag } from "@opencode-ai/ui/tag" +import { Dialog } from "@opencode-ai/ui/dialog" +import { List } from "@opencode-ai/ui/list" +import { Tooltip } from "@opencode-ai/ui/tooltip" +import { Icon } from "@opencode-ai/ui/v2/icon" +import { Tag as TagV2 } from "@opencode-ai/ui/v2/badge-v2" +import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2" +import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2" +import { ModelTooltip } from "./model-tooltip" +import { useLanguage } from "@/context/language" +import { decode64 } from "@/utils/base64" +import { handleDocumentSearchKeydown } from "@/utils/search-keydown" +import { createMenuDismissController } from "@/utils/menu-dismiss-controller" +import { createEventListener } from "@solid-primitives/event-listener" +import { matchesModelSearch } from "./dialog-select-model-search" + +const isFree = (provider: string, cost: { input: number } | undefined) => + provider === "opencode" && (!cost || cost.input === 0) + +type ModelState = ReturnType["model"] +type ModelItem = ReturnType[number] + +const modelKey = (model: ModelItem) => `${model.provider.id}:${model.id}` +const manageKey = "action:manage" + +const sortModelGroups = (a: { category: string; items: ModelItem[] }, b: { category: string; items: ModelItem[] }) => { + const aIndex = popularProviders.indexOf(a.category) + const bIndex = popularProviders.indexOf(b.category) + const aPopular = aIndex >= 0 + const bPopular = bIndex >= 0 + + if (aPopular && !bPopular) return -1 + if (!aPopular && bPopular) return 1 + if (aPopular && bPopular) return aIndex - bIndex + return a.items[0].provider.name.localeCompare(b.items[0].provider.name) +} + +const ModelList: Component<{ + provider?: string + class?: string + onSelect: () => void + action?: JSX.Element + model?: ModelState +}> = (props) => { + const model = props.model ?? useLocal().model + const language = useLanguage() + + const models = createMemo(() => + model + .list() + .filter((m) => model.visible({ modelID: m.id, providerID: m.provider.id })) + .filter((m) => (props.provider ? m.provider.id === props.provider : true)), + ) + + return ( + `${x.provider.id}:${x.id}`} + items={models} + current={model.current()} + filterKeys={["provider.name", "name", "id"]} + sortBy={(a, b) => a.name.localeCompare(b.name)} + groupBy={(x) => x.provider.name} + sortGroupsBy={(a, b) => { + const aProvider = a.items[0].provider.id + const bProvider = b.items[0].provider.id + if (popularProviders.includes(aProvider) && !popularProviders.includes(bProvider)) return -1 + if (!popularProviders.includes(aProvider) && popularProviders.includes(bProvider)) return 1 + return popularProviders.indexOf(aProvider) - popularProviders.indexOf(bProvider) + }} + itemWrapper={(item, node) => ( + } + > + {node} + + )} + onSelect={(x) => { + model.set(x ? { modelID: x.id, providerID: x.provider.id } : undefined, { + recent: true, + }) + props.onSelect() + }} + > + {(i) => ( +
+ {i.name} + + {language.t("model.tag.free")} + + + {language.t("model.tag.latest")} + +
+ )} +
+ ) +} + +type ModelSelectorTriggerProps = Omit, "as" | "ref"> +type ModelSelectorTrigger = (props: ModelSelectorTriggerProps) => JSX.Element +type Dismiss = "escape" | "outside" | "select" | "manage" | "provider" + +export function ModelSelectorPopover(props: { + provider?: string + model?: ModelState + trigger: ModelSelectorTrigger + onClose?: (cause: "escape" | "select") => void +}) { + const [store, setStore] = createStore<{ + open: boolean + dismiss: Dismiss | null + }>({ + open: false, + dismiss: null, + }) + const dialog = useDialog() + const local = useLocal() + const directory = () => decode64(local.slug()) + + const close = (dismiss: Dismiss) => { + setStore("dismiss", dismiss) + setStore("open", false) + } + + const handleManage = () => { + close("manage") + void import("./dialog-manage-models").then((x) => { + dialog.show(() => ) + }) + } + + const handleConnectProvider = () => { + close("provider") + void import("./dialog-connect-provider").then((x) => { + void dialog.show(() => ) + }) + } + const language = useLanguage() + + return ( + { + if (next) setStore("dismiss", null) + setStore("open", next) + }} + modal={false} + placement="top-start" + gutter={4} + > + + + { + close("escape") + event.preventDefault() + event.stopPropagation() + }} + onPointerDownOutside={() => close("outside")} + onFocusOutside={() => close("outside")} + onCloseAutoFocus={(event) => { + const dismiss = store.dismiss + if (dismiss === "outside") event.preventDefault() + if (dismiss === "escape" || dismiss === "select") { + event.preventDefault() + props.onClose?.(dismiss) + } + setStore("dismiss", null) + }} + > + {language.t("dialog.model.select.title")} + close("select")} + class="p-1" + action={ +
+ + + + + + +
+ } + /> +
+
+
+ ) +} + +export function ModelSelectorPopoverV2(props: { + provider?: string + model?: ModelState + trigger: ModelSelectorTrigger + onClose?: () => void +}) { + const dialog = useDialog() + const controller = createModelSelectorController({ + model: props.model, + provider: () => props.provider, + onSelect: () => props.onClose?.(), + }) + + return ( + { + void import("./dialog-manage-models").then((module) => { + void dialog.show(() => ) + }) + }} + onClose={() => props.onClose?.()} + /> + ) +} + +function createModelSelectorController(input: { + provider: () => string | undefined + model?: ModelState + onSelect: () => void +}) { + const model = input.model ?? useLocal().model + const allModels = createMemo(() => + model + .list() + .filter((item) => model.visible({ modelID: item.id, providerID: item.provider.id })) + .filter((item) => (input.provider() ? item.provider.id === input.provider() : true)), + ) + + return { + models: (search: string) => { + const query = search.trim() + const filtered = query + ? allModels().filter((item) => matchesModelSearch(query, [item.name, item.id, item.provider.name])) + : allModels() + return [...filtered].sort((a, b) => a.name.localeCompare(b.name)) + }, + groups: (models: ModelItem[]) => { + const byProvider = new Map() + for (const item of models) { + byProvider.set(item.provider.id, [...(byProvider.get(item.provider.id) ?? []), item]) + } + return Array.from(byProvider, ([category, items]) => ({ category, items })).sort(sortModelGroups) + }, + current: () => { + const value = model.current() + return value ? modelKey(value) : undefined + }, + select: (item: ModelItem) => { + model.set({ modelID: item.id, providerID: item.provider.id }, { recent: true }) + input.onSelect() + }, + } +} + +function ModelSelectorPopoverV2View(props: { + trigger: ModelSelectorTrigger + models: (search: string) => ModelItem[] + groups: (models: ModelItem[]) => { category: string; items: ModelItem[] }[] + current: () => string | undefined + select: (item: ModelItem) => void + onManage: () => void + onClose: () => void +}) { + const language = useLanguage() + const [store, setStore] = createStore({ open: false, search: "", active: "" }) + let searchRef: HTMLInputElement | undefined + let contentRef: HTMLDivElement | undefined + const dismiss = createMenuDismissController(() => contentRef) + + const models = createMemo(() => props.models(store.search)) + const groups = createMemo(() => props.groups(models())) + const keys = () => [...models().map(modelKey), manageKey] + const initialActive = () => { + const selected = props.current() + const options = keys() + if (selected && options.includes(selected)) return selected + return options[0] ?? "" + } + const activeItem = () => + store.active ? contentRef?.querySelector(`[data-option-key="${CSS.escape(store.active)}"]`) : undefined + const setOpen = (open: boolean) => { + if (open) { + dismiss.allowTriggerRestore() + setStore({ open: true, active: initialActive() }) + setTimeout(() => + requestAnimationFrame(() => { + searchRef?.focus() + activeItem()?.scrollIntoView({ block: "nearest" }) + }), + ) + return + } + setStore({ open: false, search: "", active: "" }) + } + const selectModel = (item: ModelItem) => { + dismiss.preventTriggerRestore() + setOpen(false) + dismiss.afterClose(() => props.select(item)) + } + const manage = () => { + dismiss.preventTriggerRestore() + setOpen(false) + dismiss.afterClose(props.onManage) + } + const selectActive = () => { + const item = models().find((item) => modelKey(item) === store.active) + if (item) { + selectModel(item) + return + } + if (store.active === manageKey) manage() + } + const moveActive = (delta: number) => { + const options = keys() + if (options.length === 0) return + const index = options.indexOf(store.active) + const start = index === -1 ? 0 : index + setStore("active", options[(start + delta + options.length) % options.length]) + queueMicrotask(() => activeItem()?.scrollIntoView({ block: "nearest" })) + } + const setSearch = (value: string) => { + const first = props.models(value)[0] + setStore({ search: value, active: first ? modelKey(first) : manageKey }) + } + + createEffect(() => { + if (!store.open) return + createEventListener( + document, + "keydown", + (event: KeyboardEvent) => handleDocumentSearchKeydown(searchRef, event, store.search, setSearch), + true, + ) + }) + + return ( + + + + (contentRef = element)} + class="w-[284px] overflow-hidden rounded-md border-0 bg-v2-background-bg-layer-01 !p-0 shadow-[var(--v2-elevation-floating)] focus:outline-none" + onPointerDownOutside={dismiss.preventTriggerRestore} + onFocusOutside={dismiss.preventTriggerRestore} + onCloseAutoFocus={dismiss.onCloseAutoFocus} + > +
+
+ + (searchRef = el)} + value={store.search} + placeholder={language.t("dialog.model.search.placeholder")} + class="h-7 min-w-0 flex-1 border-0 bg-transparent text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-base outline-none placeholder:text-v2-text-text-faint" + spellcheck={false} + autocorrect="off" + autocomplete="off" + autocapitalize="off" + onInput={(event) => setSearch(event.currentTarget.value)} + onKeyDown={(event) => { + if (event.key === "Tab") return + event.stopPropagation() + if (event.key === "Escape") { + event.preventDefault() + dismiss.preventTriggerRestore() + setOpen(false) + dismiss.afterClose(props.onClose) + return + } + if (event.altKey || event.metaKey) return + if (event.key === "ArrowDown") { + event.preventDefault() + moveActive(1) + return + } + if (event.key === "ArrowUp") { + event.preventDefault() + moveActive(-1) + return + } + if (event.key === "Enter" && !event.isComposing) { + event.preventDefault() + selectActive() + } + }} + /> + + + +
+
+
+ +
+ 0} + fallback={ +
+ {language.t("dialog.model.empty")} +
+ } + > + + {(group) => ( + + + {group.items[0].provider.name} + + + + {(item) => ( + + } + > + { + setStore("active", modelKey(item)) + setTimeout(() => searchRef?.focus()) + }} + onSelect={() => selectModel(item)} + > + {item.name} + + {language.t("model.tag.free")} + + + {language.t("model.tag.latest")} + + + + )} + + + + )} + +
+
+
+
+
+ { + setStore("active", manageKey) + setTimeout(() => searchRef?.focus()) + }} + onSelect={manage} + > + + {language.t("dialog.model.manage")} + +
+ + + + ) +} + +export const DialogSelectModel: Component<{ provider?: string; model?: ModelState }> = (props) => { + const dialog = useDialog() + const language = useLanguage() + const local = useLocal() + const directory = () => decode64(local.slug()) + + const provider = () => { + void import("./dialog-connect-provider").then((x) => { + void dialog.show(() => ) + }) + } + + const manage = () => { + void import("./dialog-manage-models").then((x) => { + dialog.show(() => ) + }) + } + + return ( + + {language.t("command.provider.connect")} + + } + > + dialog.close()} /> + + + ) +} diff --git a/packages/app/src/components/session/index.ts b/packages/app/src/components/session/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..c4fdb2ff3834fe91f4eafdbbe8a4b776ff719f0f --- /dev/null +++ b/packages/app/src/components/session/index.ts @@ -0,0 +1,6 @@ +export { SessionHeader } from "./session-header" +export { SessionContextTab } from "./session-context-tab" +export { SortableTab, FileVisual } from "./session-sortable-tab" +export { SortableTabV2 } from "./session-sortable-tab-v2" +export { SortableTerminalTab } from "./session-sortable-terminal-tab" +export { NewSessionView } from "./session-new-view" diff --git a/packages/app/src/components/session/open-in-app.tsx b/packages/app/src/components/session/open-in-app.tsx new file mode 100644 index 0000000000000000000000000000000000000000..cd363d00c49a259527eb03b81aa4dc24af416669 --- /dev/null +++ b/packages/app/src/components/session/open-in-app.tsx @@ -0,0 +1,229 @@ +import { createEffect, createMemo } from "solid-js" +import { createStore } from "solid-js/store" +import { useLanguage } from "@/context/language" +import { usePlatform } from "@/context/platform" +import { useServer } from "@/context/server" +import { Persist, persisted } from "@/utils/persist" +import { showToast } from "@/utils/toast" + +export const OPEN_APPS = [ + "vscode", + "cursor", + "zed", + "textmate", + "antigravity", + "finder", + "terminal", + "iterm2", + "ghostty", + "warp", + "xcode", + "android-studio", + "powershell", + "sublime-text", +] as const + +export type OpenApp = (typeof OPEN_APPS)[number] +export type OpenAppOS = "macos" | "windows" | "linux" | "unknown" + +export const MAC_OPEN_APPS = [ + { + id: "vscode", + label: "session.header.open.app.vscode", + icon: "vscode", + openWith: "Visual Studio Code", + }, + { id: "cursor", label: "session.header.open.app.cursor", icon: "cursor", openWith: "Cursor" }, + { id: "zed", label: "session.header.open.app.zed", icon: "zed", openWith: "Zed" }, + { id: "textmate", label: "session.header.open.app.textmate", icon: "textmate", openWith: "TextMate" }, + { + id: "antigravity", + label: "session.header.open.app.antigravity", + icon: "antigravity", + openWith: "Antigravity", + }, + { id: "terminal", label: "session.header.open.app.terminal", icon: "terminal", openWith: "Terminal" }, + { id: "iterm2", label: "session.header.open.app.iterm2", icon: "iterm2", openWith: "iTerm" }, + { id: "ghostty", label: "session.header.open.app.ghostty", icon: "ghostty", openWith: "Ghostty" }, + { id: "warp", label: "session.header.open.app.warp", icon: "warp", openWith: "Warp" }, + { id: "xcode", label: "session.header.open.app.xcode", icon: "xcode", openWith: "Xcode" }, + { + id: "android-studio", + label: "session.header.open.app.androidStudio", + icon: "android-studio", + openWith: "Android Studio", + }, + { + id: "sublime-text", + label: "session.header.open.app.sublimeText", + icon: "sublime-text", + openWith: "Sublime Text", + }, +] as const + +export const WINDOWS_OPEN_APPS = [ + { id: "vscode", label: "session.header.open.app.vscode", icon: "vscode", openWith: "code" }, + { id: "cursor", label: "session.header.open.app.cursor", icon: "cursor", openWith: "cursor" }, + { id: "zed", label: "session.header.open.app.zed", icon: "zed", openWith: "zed" }, + { + id: "powershell", + label: "session.header.open.app.powershell", + icon: "powershell", + openWith: "powershell", + }, + { + id: "sublime-text", + label: "session.header.open.app.sublimeText", + icon: "sublime-text", + openWith: "Sublime Text", + }, +] as const + +export const LINUX_OPEN_APPS = [ + { id: "vscode", label: "session.header.open.app.vscode", icon: "vscode", openWith: "code" }, + { id: "cursor", label: "session.header.open.app.cursor", icon: "cursor", openWith: "cursor" }, + { id: "zed", label: "session.header.open.app.zed", icon: "zed", openWith: "zed" }, + { + id: "sublime-text", + label: "session.header.open.app.sublimeText", + icon: "sublime-text", + openWith: "Sublime Text", + }, +] as const + +export function detectOpenAppOS(platform: ReturnType): OpenAppOS { + if (platform.platform === "desktop" && platform.os) return platform.os + if (typeof navigator !== "object") return "unknown" + const value = navigator.platform || navigator.userAgent + if (/Mac/i.test(value)) return "macos" + if (/Win/i.test(value)) return "windows" + if (/Linux/i.test(value)) return "linux" + return "unknown" +} + +export function openAppFileManager(os: OpenAppOS) { + if (os === "macos") return { label: "session.header.open.finder", icon: "finder" as const } + if (os === "windows") return { label: "session.header.open.fileExplorer", icon: "file-explorer" as const } + return { label: "session.header.open.fileManager", icon: "finder" as const } +} + +export function openAppsForOS(os: OpenAppOS) { + if (os === "macos") return MAC_OPEN_APPS + if (os === "windows") return WINDOWS_OPEN_APPS + return LINUX_OPEN_APPS +} + +const showRequestError = (language: ReturnType, err: unknown) => { + showToast({ + variant: "error", + title: language.t("common.requestFailed"), + description: err instanceof Error ? err.message : String(err), + }) +} + +export function useOpenInApp(input: { directory: () => string }) { + const platform = usePlatform() + const server = useServer() + const language = useLanguage() + + const os = createMemo(() => detectOpenAppOS(platform)) + const apps = createMemo(() => openAppsForOS(os())) + const fileManager = createMemo(() => openAppFileManager(os())) + + const [exists, setExists] = createStore>>({ + finder: true, + }) + + createEffect(() => { + if (platform.platform !== "desktop") return + if (!platform.checkAppExists) return + + const list = apps() + + setExists(Object.fromEntries(list.map((app) => [app.id, undefined])) as Partial>) + + void Promise.all( + list.map((app) => + Promise.resolve(platform.checkAppExists?.(app.openWith)) + .then((value) => Boolean(value)) + .catch(() => false) + .then((ok) => [app.id, ok] as const), + ), + ).then((entries) => { + setExists(Object.fromEntries(entries) as Partial>) + }) + }) + + const options = createMemo(() => { + return [ + { id: "finder", label: language.t(fileManager().label), icon: fileManager().icon }, + ...apps() + .filter((app) => exists[app.id]) + .map((app) => ({ ...app, label: language.t(app.label) })), + ] as const + }) + + const [prefs, setPrefs] = persisted(Persist.global("open.app"), createStore({ app: "finder" as OpenApp | "finder" })) + const [menu, setMenu] = createStore({ open: false }) + const [openRequest, setOpenRequest] = createStore({ + app: undefined as OpenApp | undefined, + }) + + const canOpen = createMemo(() => platform.platform === "desktop" && !!platform.openPath && server.isLocal()) + const current = createMemo( + () => + options().find((o) => o.id === prefs.app) ?? + options()[0] ?? + ({ id: "finder", label: fileManager().label, icon: fileManager().icon } as const), + ) + const opening = createMemo(() => openRequest.app !== undefined) + + const selectApp = (app: OpenApp | "finder") => { + if (!options().some((item) => item.id === app)) return + setPrefs("app", app) + } + + const openDir = (app: OpenApp | "finder") => { + if (opening() || !canOpen() || !platform.openPath) return + const directory = input.directory() + if (!directory) return + + const item = options().find((o) => o.id === app) + const openWith = item && "openWith" in item ? item.openWith : undefined + setOpenRequest("app", app) + platform + .openPath(directory, openWith) + .catch((err: unknown) => showRequestError(language, err)) + .finally(() => { + setOpenRequest("app", undefined) + }) + } + + const copyPath = () => { + const directory = input.directory() + if (!directory) return + navigator.clipboard + .writeText(directory) + .then(() => { + showToast({ + variant: "success", + icon: "circle-check", + title: language.t("session.share.copy.copied"), + description: directory, + }) + }) + .catch((err: unknown) => showRequestError(language, err)) + } + + return { + canOpen, + opening, + current, + options, + menu, + setMenu, + openDir, + selectApp, + copyPath, + } +} diff --git a/packages/app/src/components/session/session-context-breakdown.test.ts b/packages/app/src/components/session/session-context-breakdown.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..f38aecb55da97c6a6ac55c8cfd9cb6c69ce73197 --- /dev/null +++ b/packages/app/src/components/session/session-context-breakdown.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, test } from "bun:test" +import type { Message, Part } from "@opencode-ai/sdk/v2/client" +import { estimateSessionContextBreakdown } from "./session-context-breakdown" + +const user = (id: string) => { + return { + id, + role: "user", + time: { created: 1 }, + } as unknown as Message +} + +const assistant = (id: string) => { + return { + id, + role: "assistant", + time: { created: 1 }, + } as unknown as Message +} + +describe("estimateSessionContextBreakdown", () => { + test("estimates tokens and keeps remaining tokens as other", () => { + const messages = [user("u1"), assistant("a1")] + const parts = { + u1: [{ type: "text", text: "hello world" }] as unknown as Part[], + a1: [{ type: "text", text: "assistant response" }] as unknown as Part[], + } + + const output = estimateSessionContextBreakdown({ + messages, + parts, + input: 20, + systemPrompt: "system prompt", + }) + + const map = Object.fromEntries(output.map((segment) => [segment.key, segment.tokens])) + expect(map.system).toBe(4) + expect(map.user).toBe(3) + expect(map.assistant).toBe(5) + expect(map.other).toBe(8) + }) + + test("scales segments when estimates exceed input", () => { + const messages = [user("u1"), assistant("a1")] + const parts = { + u1: [{ type: "text", text: "x".repeat(400) }] as unknown as Part[], + a1: [{ type: "text", text: "y".repeat(400) }] as unknown as Part[], + } + + const output = estimateSessionContextBreakdown({ + messages, + parts, + input: 10, + systemPrompt: "z".repeat(200), + }) + + const total = output.reduce((sum, segment) => sum + segment.tokens, 0) + expect(total).toBeLessThanOrEqual(10) + expect(output.every((segment) => segment.width <= 100)).toBeTrue() + }) +}) diff --git a/packages/app/src/components/session/session-context-breakdown.ts b/packages/app/src/components/session/session-context-breakdown.ts new file mode 100644 index 0000000000000000000000000000000000000000..e263b2957b378d92b877838a750a7f7264bb5923 --- /dev/null +++ b/packages/app/src/components/session/session-context-breakdown.ts @@ -0,0 +1,132 @@ +import type { Message, Part } from "@opencode-ai/sdk/v2/client" + +export type SessionContextBreakdownKey = "system" | "user" | "assistant" | "tool" | "other" + +export type SessionContextBreakdownSegment = { + key: SessionContextBreakdownKey + tokens: number + width: number + percent: number +} + +const estimateTokens = (chars: number) => Math.ceil(chars / 4) +const toPercent = (tokens: number, input: number) => (tokens / input) * 100 +const toPercentLabel = (tokens: number, input: number) => Math.round(toPercent(tokens, input) * 10) / 10 + +const charsFromUserPart = (part: Part) => { + if (part.type === "text") return part.text.length + if (part.type === "file") return part.source?.text.value.length ?? 0 + if (part.type === "agent") return part.source?.value.length ?? 0 + return 0 +} + +const charsFromAssistantPart = (part: Part) => { + if (part.type === "text") return { assistant: part.text.length, tool: 0 } + if (part.type === "reasoning") return { assistant: part.text.length, tool: 0 } + if (part.type !== "tool") return { assistant: 0, tool: 0 } + + const input = Object.keys(part.state.input).length * 16 + if (part.state.status === "pending") return { assistant: 0, tool: input + part.state.raw.length } + if (part.state.status === "completed") return { assistant: 0, tool: input + part.state.output.length } + if (part.state.status === "error") return { assistant: 0, tool: input + part.state.error.length } + return { assistant: 0, tool: input } +} + +const build = ( + tokens: { system: number; user: number; assistant: number; tool: number; other: number }, + input: number, +) => { + return [ + { + key: "system", + tokens: tokens.system, + }, + { + key: "user", + tokens: tokens.user, + }, + { + key: "assistant", + tokens: tokens.assistant, + }, + { + key: "tool", + tokens: tokens.tool, + }, + { + key: "other", + tokens: tokens.other, + }, + ] + .filter((x) => x.tokens > 0) + .map((x) => ({ + key: x.key, + tokens: x.tokens, + width: toPercent(x.tokens, input), + percent: toPercentLabel(x.tokens, input), + })) as SessionContextBreakdownSegment[] +} + +export function estimateSessionContextBreakdown(args: { + messages: Message[] + parts: Record + input: number + systemPrompt?: string +}) { + if (!args.input) return [] + + const counts = args.messages.reduce( + (acc, msg) => { + const parts = args.parts[msg.id] ?? [] + if (msg.role === "user") { + const user = parts.reduce((sum, part) => sum + charsFromUserPart(part), 0) + return { ...acc, user: acc.user + user } + } + + if (msg.role !== "assistant") return acc + const assistant = parts.reduce( + (sum, part) => { + const next = charsFromAssistantPart(part) + return { + assistant: sum.assistant + next.assistant, + tool: sum.tool + next.tool, + } + }, + { assistant: 0, tool: 0 }, + ) + return { + ...acc, + assistant: acc.assistant + assistant.assistant, + tool: acc.tool + assistant.tool, + } + }, + { + system: args.systemPrompt?.length ?? 0, + user: 0, + assistant: 0, + tool: 0, + }, + ) + + const tokens = { + system: estimateTokens(counts.system), + user: estimateTokens(counts.user), + assistant: estimateTokens(counts.assistant), + tool: estimateTokens(counts.tool), + } + const estimated = tokens.system + tokens.user + tokens.assistant + tokens.tool + + if (estimated <= args.input) { + return build({ ...tokens, other: args.input - estimated }, args.input) + } + + const scale = args.input / estimated + const scaled = { + system: Math.floor(tokens.system * scale), + user: Math.floor(tokens.user * scale), + assistant: Math.floor(tokens.assistant * scale), + tool: Math.floor(tokens.tool * scale), + } + const total = scaled.system + scaled.user + scaled.assistant + scaled.tool + return build({ ...scaled, other: Math.max(0, args.input - total) }, args.input) +} diff --git a/packages/app/src/components/session/session-context-format.ts b/packages/app/src/components/session/session-context-format.ts new file mode 100644 index 0000000000000000000000000000000000000000..e7c536d58411a21b9ab96257177ca248041b8ca0 --- /dev/null +++ b/packages/app/src/components/session/session-context-format.ts @@ -0,0 +1,20 @@ +import { DateTime } from "luxon" + +export function createSessionContextFormatter(locale: string) { + return { + number(value: number | null | undefined) { + if (value === undefined) return "—" + if (value === null) return "—" + return value.toLocaleString(locale) + }, + percent(value: number | null | undefined) { + if (value === undefined) return "—" + if (value === null) return "—" + return value.toLocaleString(locale) + "%" + }, + time(value: number | undefined) { + if (!value) return "—" + return DateTime.fromMillis(value).setLocale(locale).toLocaleString(DateTime.DATETIME_MED) + }, + } +} diff --git a/packages/app/src/components/session/session-context-metrics.ts b/packages/app/src/components/session/session-context-metrics.ts new file mode 100644 index 0000000000000000000000000000000000000000..30dc9e958b9ca2a17d99ae6948b57d484bd4afb0 --- /dev/null +++ b/packages/app/src/components/session/session-context-metrics.ts @@ -0,0 +1,65 @@ +import type { AssistantMessage, Message } from "@opencode-ai/sdk/v2/client" + +type Provider = { + id: string + name?: string + models: Record +} + +type Model = { + name?: string + limit: { + context: number + } +} + +type Context = { + message: AssistantMessage + provider?: Provider + model?: Model + providerLabel: string + modelLabel: string + limit: number | undefined + input: number + total: number + usage: number | null +} + +const tokenTotal = (msg: AssistantMessage) => { + return msg.tokens.input + msg.tokens.output + msg.tokens.reasoning + msg.tokens.cache.read + msg.tokens.cache.write +} + +const lastAssistantWithTokens = (messages: Message[]) => { + for (let i = messages.length - 1; i >= 0; i--) { + const msg = messages[i] + if (msg.role !== "assistant") continue + if (tokenTotal(msg) <= 0) continue + return msg + } +} + +const build = (messages: Message[] = [], providers: Provider[] = []): Context | undefined => { + const message = lastAssistantWithTokens(messages) + if (!message) return undefined + + const provider = providers.find((item) => item.id === message.providerID) + const model = provider?.models[message.modelID] + const limit = model?.limit.context + const total = tokenTotal(message) + + return { + message, + provider, + model, + providerLabel: provider?.name ?? message.providerID, + modelLabel: model?.name ?? message.modelID, + limit, + input: message.tokens.input, + total, + usage: limit ? Math.round((total / limit) * 100) : null, + } +} + +export function getSessionContext(messages: Message[] = [], providers: Provider[] = []) { + return build(messages, providers) +} diff --git a/packages/app/src/components/session/session-context-tab.tsx b/packages/app/src/components/session/session-context-tab.tsx new file mode 100644 index 0000000000000000000000000000000000000000..a0758f3eacae77d39d7492a8cf0b8ce030464c08 --- /dev/null +++ b/packages/app/src/components/session/session-context-tab.tsx @@ -0,0 +1,383 @@ +import { createMemo, createEffect, on, onCleanup, For, Show } from "solid-js" +import type { JSX } from "solid-js" +import { useSync } from "@/context/sync" +import { checksum } from "@opencode-ai/core/util/encode" +import { findLast } from "@opencode-ai/core/util/array" +import { same } from "@/utils/same" +import { Icon } from "@opencode-ai/ui/icon" +import { Button } from "@opencode-ai/ui/button" +import { Accordion } from "@opencode-ai/ui/accordion" +import { StickyAccordionHeader } from "@opencode-ai/ui/sticky-accordion-header" +import { File } from "@opencode-ai/session-ui/file" +import { Markdown } from "@opencode-ai/session-ui/markdown" +import { ScrollView } from "@opencode-ai/ui/scroll-view" +import type { Message, Part, UserMessage } from "@opencode-ai/sdk/v2/client" +import { showToast } from "@/utils/toast" +import { downloadSessionExport, fetchSessionExport, sessionExportFilename } from "@/utils/session-export" +import { useLanguage } from "@/context/language" +import { useProviders } from "@/hooks/use-providers" +import { useSDK } from "@/context/sdk" +import { useSessionLayout } from "@/pages/session/session-layout" +import { getSessionContext } from "./session-context-metrics" +import { estimateSessionContextBreakdown, type SessionContextBreakdownKey } from "./session-context-breakdown" +import { createSessionContextFormatter } from "./session-context-format" + +const BREAKDOWN_COLOR: Record = { + system: "var(--syntax-info)", + user: "var(--syntax-success)", + assistant: "var(--syntax-property)", + tool: "var(--syntax-warning)", + other: "var(--syntax-comment)", +} + +function Stat(props: { label: string; value: JSX.Element }) { + return ( +
+
{props.label}
+
{props.value}
+
+ ) +} + +function RawMessageContent(props: { message: Message; getParts: (id: string) => Part[]; onRendered: () => void }) { + const file = createMemo(() => { + const parts = props.getParts(props.message.id) + const contents = JSON.stringify({ message: props.message, parts }, null, 2) + return { + name: `${props.message.role}-${props.message.id}.json`, + contents, + cacheKey: checksum(contents), + } + }) + + return ( + requestAnimationFrame(props.onRendered)} + /> + ) +} + +function RawMessage(props: { + message: Message + getParts: (id: string) => Part[] + onRendered: () => void + time: (value: number | undefined) => string +}) { + return ( + + + +
+
+ {props.message.role} • {props.message.id} +
+
+
{props.time(props.message.time.created)}
+ +
+
+
+
+ +
+ +
+
+
+ ) +} + +const emptyMessages: Message[] = [] +const emptyUserMessages: UserMessage[] = [] + +export function SessionContextTab() { + const sync = useSync() + const language = useLanguage() + const sdk = useSDK() + const providers = useProviders(() => sdk().directory) + const { params, view } = useSessionLayout() + + const info = createMemo(() => (params.id ? sync().session.get(params.id) : undefined)) + + const messages = createMemo( + () => { + const id = params.id + if (!id) return emptyMessages + return (sync().data.message[id] ?? []) as Message[] + }, + emptyMessages, + { equals: same }, + ) + + const userMessages = createMemo( + () => messages().filter((m) => m.role === "user") as UserMessage[], + emptyUserMessages, + { equals: same }, + ) + + const visibleUserMessages = createMemo( + () => { + const revert = info()?.revert?.messageID + if (!revert) return userMessages() + const boundary = userMessages().findIndex((message) => message.id === revert) + return boundary < 0 ? userMessages() : userMessages().slice(0, boundary) + }, + emptyUserMessages, + { equals: same }, + ) + + const usd = createMemo( + () => + new Intl.NumberFormat(language.intl(), { + style: "currency", + currency: "USD", + }), + ) + + const ctx = createMemo(() => getSessionContext(messages(), [...providers.all().values()])) + const formatter = createMemo(() => createSessionContextFormatter(language.intl())) + + const cost = createMemo(() => { + return usd().format(info()?.cost ?? 0) + }) + + const counts = createMemo(() => { + const all = messages() + const user = all.reduce((count, x) => count + (x.role === "user" ? 1 : 0), 0) + const assistant = all.reduce((count, x) => count + (x.role === "assistant" ? 1 : 0), 0) + return { + all: all.length, + user, + assistant, + } + }) + + const systemPrompt = createMemo(() => { + const msg = findLast(visibleUserMessages(), (m) => !!m.system) + const system = msg?.system + if (!system) return + const trimmed = system.trim() + if (!trimmed) return + return trimmed + }) + + const providerLabel = createMemo(() => { + const c = ctx() + if (!c) return "—" + return c.providerLabel + }) + + const modelLabel = createMemo(() => { + const c = ctx() + if (!c) return "—" + return c.modelLabel + }) + + const breakdown = createMemo( + on( + () => [ctx()?.message.id, ctx()?.input, messages().length, systemPrompt()], + () => { + const c = ctx() + if (!c?.input) return [] + return estimateSessionContextBreakdown({ + messages: messages(), + parts: sync().data.part as Record, + input: c.input, + systemPrompt: systemPrompt(), + }) + }, + ), + ) + + const breakdownLabel = (key: SessionContextBreakdownKey) => { + if (key === "system") return language.t("context.breakdown.system") + if (key === "user") return language.t("context.breakdown.user") + if (key === "assistant") return language.t("context.breakdown.assistant") + if (key === "tool") return language.t("context.breakdown.tool") + return language.t("context.breakdown.other") + } + + const stats = [ + { label: "context.stats.session", value: () => info()?.title ?? params.id ?? "—" }, + { label: "context.stats.messages", value: () => counts().all.toLocaleString(language.intl()) }, + { label: "context.stats.provider", value: providerLabel }, + { label: "context.stats.model", value: modelLabel }, + { label: "context.stats.limit", value: () => formatter().number(ctx()?.limit) }, + { label: "context.stats.totalTokens", value: () => formatter().number(ctx()?.total) }, + { label: "context.stats.usage", value: () => formatter().percent(ctx()?.usage) }, + { label: "context.stats.inputTokens", value: () => formatter().number(ctx()?.input) }, + { label: "context.stats.outputTokens", value: () => formatter().number(ctx()?.message.tokens.output) }, + { label: "context.stats.reasoningTokens", value: () => formatter().number(ctx()?.message.tokens.reasoning) }, + { + label: "context.stats.cacheTokens", + value: () => + `${formatter().number(ctx()?.message.tokens.cache.read)} / ${formatter().number(ctx()?.message.tokens.cache.write)}`, + }, + { label: "context.stats.userMessages", value: () => counts().user.toLocaleString(language.intl()) }, + { label: "context.stats.assistantMessages", value: () => counts().assistant.toLocaleString(language.intl()) }, + { label: "context.stats.totalCost", value: cost }, + { label: "context.stats.sessionCreated", value: () => formatter().time(info()?.time.created) }, + { label: "context.stats.lastActivity", value: () => formatter().time(ctx()?.message.time.created) }, + ] satisfies { label: string; value: () => JSX.Element }[] + + const exportSession = async () => { + const sessionID = params.id + if (!sessionID) return + try { + const data = await fetchSessionExport({ + sessionID, + client: sdk().client, + }) + const filename = sessionExportFilename(data.info) + downloadSessionExport(filename, data) + showToast({ + variant: "success", + icon: "circle-check", + title: language.t("toast.session.export.success.title"), + description: language.t("toast.session.export.success.description", { filename }), + }) + } catch (err) { + showToast({ + variant: "error", + title: language.t("toast.session.export.failed.title"), + description: err instanceof Error ? err.message : language.t("toast.session.export.failed.description"), + }) + } + } + + let scroll: HTMLDivElement | undefined + let frame: number | undefined + let pending: { x: number; y: number } | undefined + const getParts = (id: string) => (sync().data.part[id] ?? []) as Part[] + + const restoreScroll = () => { + const el = scroll + if (!el) return + + const s = view().scroll("context") + if (!s) return + + if (el.scrollTop !== s.y) el.scrollTop = s.y + if (el.scrollLeft !== s.x) el.scrollLeft = s.x + } + + const handleScroll = (event: Event & { currentTarget: HTMLDivElement }) => { + pending = { + x: event.currentTarget.scrollLeft, + y: event.currentTarget.scrollTop, + } + if (frame !== undefined) return + + frame = requestAnimationFrame(() => { + frame = undefined + + const next = pending + pending = undefined + if (!next) return + + view().setScroll("context", next) + }) + } + + createEffect( + on( + () => messages().length, + () => { + requestAnimationFrame(restoreScroll) + }, + { defer: true }, + ), + ) + + onCleanup(() => { + if (frame === undefined) return + cancelAnimationFrame(frame) + }) + + return ( + { + scroll = el + restoreScroll() + }} + onScroll={handleScroll} + > +
+
+ + {(stat) => [0])} value={stat.value()} />} + +
+ + 0}> +
+
{language.t("context.breakdown.title")}
+
+ + {(segment) => ( +
+ )} + +
+
+ + {(segment) => ( +
+
+
{breakdownLabel(segment.key)}
+
{segment.percent.toLocaleString(language.intl())}%
+
+ )} + +
+ +
+ + + + {(prompt) => ( +
+
{language.t("context.systemPrompt.title")}
+
+ +
+
+ )} +
+ +
+
+
{language.t("context.rawMessages.title")}
+ +
+ + + {(message) => ( + + )} + + +
+
+ + ) +} diff --git a/packages/app/src/components/session/session-header.tsx b/packages/app/src/components/session/session-header.tsx new file mode 100644 index 0000000000000000000000000000000000000000..dc6cdaa6f3fd9a9cb144ef9cab52fc74dba25d69 --- /dev/null +++ b/packages/app/src/components/session/session-header.tsx @@ -0,0 +1,568 @@ +import { AppIcon } from "@opencode-ai/ui/app-icon" +import { Button } from "@opencode-ai/ui/button" +import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu" +import { Icon } from "@opencode-ai/ui/icon" +import { IconButton } from "@opencode-ai/ui/icon-button" +import { Keybind } from "@opencode-ai/ui/keybind" +import { Spinner } from "@opencode-ai/ui/spinner" +import { showToast } from "@/utils/toast" +import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip" +import { getFilename } from "@opencode-ai/core/util/path" +import { createEffect, createMemo, createSignal, For, onMount, Show } from "solid-js" +import { createStore } from "solid-js/store" +import { createMediaQuery } from "@solid-primitives/media" +import { Portal } from "solid-js/web" +import { useCommand } from "@/context/command" +import { useLanguage } from "@/context/language" +import { useLayout } from "@/context/layout" +import { usePlatform } from "@/context/platform" +import { useServer } from "@/context/server" +import { useSettings } from "@/context/settings" +import { useSync } from "@/context/sync" +import { useTerminal } from "@/context/terminal" +import { focusTerminalById } from "@/pages/session/helpers" +import { useSessionLayout } from "@/pages/session/session-layout" +import { messageAgentColor } from "@/utils/agent" +import { decode64 } from "@/utils/base64" +import { fileManagerApp } from "@/utils/file-manager" +import { Persist, persisted } from "@/utils/persist" +import { StatusPopover, StatusPopoverV2 } from "../status-popover" +import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2" +import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon" +import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2" +import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2" +import { reviewTooltipKeybind } from "../command-tooltip-keybind" +import { useTitlebarRightMount } from "../titlebar" + +const OPEN_APPS = [ + "vscode", + "cursor", + "zed", + "textmate", + "antigravity", + "finder", + "terminal", + "iterm2", + "ghostty", + "warp", + "xcode", + "android-studio", + "powershell", + "sublime-text", +] as const + +type OpenApp = (typeof OPEN_APPS)[number] +type OS = "macos" | "windows" | "linux" | "unknown" + +const MAC_APPS = [ + { + id: "vscode", + label: "session.header.open.app.vscode", + icon: "vscode", + openWith: "Visual Studio Code", + }, + { id: "cursor", label: "session.header.open.app.cursor", icon: "cursor", openWith: "Cursor" }, + { id: "zed", label: "session.header.open.app.zed", icon: "zed", openWith: "Zed" }, + { id: "textmate", label: "session.header.open.app.textmate", icon: "textmate", openWith: "TextMate" }, + { + id: "antigravity", + label: "session.header.open.app.antigravity", + icon: "antigravity", + openWith: "Antigravity", + }, + { id: "terminal", label: "session.header.open.app.terminal", icon: "terminal", openWith: "Terminal" }, + { id: "iterm2", label: "session.header.open.app.iterm2", icon: "iterm2", openWith: "iTerm" }, + { id: "ghostty", label: "session.header.open.app.ghostty", icon: "ghostty", openWith: "Ghostty" }, + { id: "warp", label: "session.header.open.app.warp", icon: "warp", openWith: "Warp" }, + { id: "xcode", label: "session.header.open.app.xcode", icon: "xcode", openWith: "Xcode" }, + { + id: "android-studio", + label: "session.header.open.app.androidStudio", + icon: "android-studio", + openWith: "Android Studio", + }, + { + id: "sublime-text", + label: "session.header.open.app.sublimeText", + icon: "sublime-text", + openWith: "Sublime Text", + }, +] as const + +const WINDOWS_APPS = [ + { id: "vscode", label: "session.header.open.app.vscode", icon: "vscode", openWith: "code" }, + { id: "cursor", label: "session.header.open.app.cursor", icon: "cursor", openWith: "cursor" }, + { id: "zed", label: "session.header.open.app.zed", icon: "zed", openWith: "zed" }, + { + id: "powershell", + label: "session.header.open.app.powershell", + icon: "powershell", + openWith: "powershell", + }, + { + id: "sublime-text", + label: "session.header.open.app.sublimeText", + icon: "sublime-text", + openWith: "Sublime Text", + }, +] as const + +const LINUX_APPS = [ + { id: "vscode", label: "session.header.open.app.vscode", icon: "vscode", openWith: "code" }, + { id: "cursor", label: "session.header.open.app.cursor", icon: "cursor", openWith: "cursor" }, + { id: "zed", label: "session.header.open.app.zed", icon: "zed", openWith: "zed" }, + { + id: "sublime-text", + label: "session.header.open.app.sublimeText", + icon: "sublime-text", + openWith: "Sublime Text", + }, +] as const + +const detectOS = (platform: ReturnType): OS => { + if (platform.platform === "desktop" && platform.os) return platform.os + if (typeof navigator !== "object") return "unknown" + const value = navigator.platform || navigator.userAgent + if (/Mac/i.test(value)) return "macos" + if (/Win/i.test(value)) return "windows" + if (/Linux/i.test(value)) return "linux" + return "unknown" +} + +const showRequestError = (language: ReturnType, err: unknown) => { + showToast({ + variant: "error", + title: language.t("common.requestFailed"), + description: err instanceof Error ? err.message : String(err), + }) +} + +export function SessionHeader() { + const layout = useLayout() + const command = useCommand() + const server = useServer() + const platform = usePlatform() + const language = useLanguage() + const settings = useSettings() + const sync = useSync() + const terminal = useTerminal() + const { params, view } = useSessionLayout() + + const projectDirectory = createMemo(() => decode64(params.dir) ?? "") + const project = createMemo(() => { + const directory = projectDirectory() + if (!directory) return + return layout.projects.list().find((p) => p.worktree === directory || p.sandboxes?.includes(directory)) + }) + const name = createMemo(() => { + const current = project() + if (current) return current.name || getFilename(current.worktree) + return getFilename(projectDirectory()) + }) + const hotkey = createMemo(() => command.keybind("file.open")) + const os = createMemo(() => detectOS(platform)) + const isV2 = settings.general.newLayoutDesigns + const search = settings.visibility.search + const status = settings.visibility.status + const isDesktop = createMediaQuery("(min-width: 768px)") + + const [exists, setExists] = createStore>>({ + finder: true, + }) + + const apps = createMemo(() => { + if (os() === "macos") return MAC_APPS + if (os() === "windows") return WINDOWS_APPS + return LINUX_APPS + }) + + const fileManager = createMemo(() => fileManagerApp(os())) + + createEffect(() => { + if (platform.platform !== "desktop") return + if (!platform.checkAppExists) return + + const list = apps() + + setExists(Object.fromEntries(list.map((app) => [app.id, undefined])) as Partial>) + + void Promise.all( + list.map((app) => + Promise.resolve(platform.checkAppExists?.(app.openWith)) + .then((value) => Boolean(value)) + .catch(() => false) + .then((ok) => [app.id, ok] as const), + ), + ).then((entries) => { + setExists(Object.fromEntries(entries) as Partial>) + }) + }) + + const options = createMemo(() => { + return [ + { id: "finder", label: language.t(fileManager().label), icon: fileManager().icon }, + ...apps() + .filter((app) => exists[app.id]) + .map((app) => ({ ...app, label: language.t(app.label) })), + ] as const + }) + + const toggleTerminal = () => { + const next = !view().terminal.opened() + view().terminal.toggle() + if (!next) return + + const id = terminal.active() + if (!id) return + focusTerminalById(id) + } + + const [prefs, setPrefs] = persisted(Persist.global("open.app"), createStore({ app: "finder" as OpenApp })) + const [menu, setMenu] = createStore({ open: false }) + const [openRequest, setOpenRequest] = createStore({ + app: undefined as OpenApp | undefined, + }) + + const canOpen = createMemo(() => platform.platform === "desktop" && !!platform.openPath && server.isLocal()) + const current = createMemo( + () => + options().find((o) => o.id === prefs.app) ?? + options()[0] ?? + ({ id: "finder", label: fileManager().label, icon: fileManager().icon } as const), + ) + const opening = createMemo(() => openRequest.app !== undefined) + const tint = createMemo(() => + messageAgentColor(params.id ? sync().data.message[params.id] : undefined, sync().data.agent), + ) + const v2ActionsState = createMemo(() => ({ + statusVisible: status(), + statusLabel: language.t("status.popover.trigger"), + reviewLabel: language.t("command.review.toggle"), + reviewKeybind: reviewTooltipKeybind(command), + reviewVisible: isDesktop(), + reviewOpened: view().reviewPanel.opened(), + onReviewToggle: () => view().reviewPanel.toggle(), + })) + + const selectApp = (app: OpenApp) => { + if (!options().some((item) => item.id === app)) return + setPrefs("app", app) + } + + const openDir = (app: OpenApp) => { + if (opening() || !canOpen() || !platform.openPath) return + const directory = projectDirectory() + if (!directory) return + + const item = options().find((o) => o.id === app) + const openWith = item && "openWith" in item ? item.openWith : undefined + setOpenRequest("app", app) + platform + .openPath(directory, openWith) + .catch((err: unknown) => showRequestError(language, err)) + .finally(() => { + setOpenRequest("app", undefined) + }) + } + + const copyPath = () => { + const directory = projectDirectory() + if (!directory) return + navigator.clipboard + .writeText(directory) + .then(() => { + showToast({ + variant: "success", + icon: "circle-check", + title: language.t("session.share.copy.copied"), + description: directory, + }) + }) + .catch((err: unknown) => showRequestError(language, err)) + } + + const [centerMount, setCenterMount] = createSignal(null) + const rightMount = useTitlebarRightMount() + onMount(() => { + setCenterMount(document.getElementById("opencode-titlebar-center")) + }) + + return ( + <> + + {(mount) => ( + + + + )} + + + {(mount) => ( + + + + + } + > +
+
+ + setMenu("open", open)} + > + + + + + + {language.t("session.header.openIn")} + + { + if (!OPEN_APPS.includes(value as OpenApp)) return + selectApp(value as OpenApp) + }} + > + + {(o) => ( + { + setMenu("open", false) + openDir(o.id) + }} + > +
+ +
+ {o.label} + + + +
+ )} +
+
+
+ + { + setMenu("open", false) + copyPath() + }} + > +
+ +
+ + {language.t("session.header.open.copyPath")} + +
+
+
+
+
+
+
+
+
+
+ + + + + + + + + + +
+
+ } + > + + + + )} + + + ) +} + +type SessionHeaderV2ActionsState = { + statusVisible: boolean + statusLabel: string + reviewLabel: string + reviewKeybind: string[] + reviewVisible: boolean + reviewOpened: boolean + onReviewToggle: () => void +} + +function SessionHeaderV2Actions(props: { state: SessionHeaderV2ActionsState }) { + const language = useLanguage() + + return ( +
+ + + + + + + + {props.state.reviewLabel} + 0}> + + + + } + > + } + /> + + +
+ ) +} diff --git a/packages/app/src/components/session/session-new-view.tsx b/packages/app/src/components/session/session-new-view.tsx new file mode 100644 index 0000000000000000000000000000000000000000..dba0560425e1cf74794f1e0f1e4ad0fb26e72d64 --- /dev/null +++ b/packages/app/src/components/session/session-new-view.tsx @@ -0,0 +1,91 @@ +import { Show, createMemo } from "solid-js" +import { DateTime } from "luxon" +import { useSync } from "@/context/sync" +import { useSDK } from "@/context/sdk" +import { useLanguage } from "@/context/language" +import { Icon } from "@opencode-ai/ui/icon" +import { Mark } from "@opencode-ai/ui/logo" +import { getDirectory, getFilename } from "@opencode-ai/core/util/path" + +const MAIN_WORKTREE = "main" +const CREATE_WORKTREE = "create" +const ROOT_CLASS = "size-full flex flex-col" + +interface NewSessionViewProps { + worktree: string +} + +export function NewSessionView(props: NewSessionViewProps) { + const sync = useSync() + const sdk = useSDK() + const language = useLanguage() + + const sandboxes = createMemo(() => sync().project?.sandboxes ?? []) + const options = createMemo(() => [MAIN_WORKTREE, ...sandboxes(), CREATE_WORKTREE]) + const current = createMemo(() => { + const selection = props.worktree + if (options().includes(selection)) return selection + return MAIN_WORKTREE + }) + const projectRoot = createMemo(() => sync().project?.worktree ?? sdk().directory) + const isWorktree = createMemo(() => { + const project = sync().project + if (!project) return false + return sdk().directory !== project.worktree + }) + + const label = (value: string) => { + if (value === MAIN_WORKTREE) { + if (isWorktree()) return language.t("session.new.worktree.main") + const branch = sync().data.vcs?.branch + if (branch) return language.t("session.new.worktree.mainWithBranch", { branch }) + return language.t("session.new.worktree.main") + } + + if (value === CREATE_WORKTREE) return language.t("session.new.worktree.create") + + return getFilename(value) + } + + return ( +
+
+
+
+
+ +
{language.t("session.new.title")}
+
+
+
+
+ {getDirectory(projectRoot())} + {getFilename(projectRoot())} +
+
+
+ +
+ {label(current())} +
+
+ + {(project) => ( +
+
+ {language.t("session.new.lastModified")}  + + {DateTime.fromMillis(project().time.updated ?? project().time.created) + .setLocale(language.intl()) + .toRelative()} + +
+
+ )} +
+
+
+
+
+ ) +} diff --git a/packages/app/src/components/session/session-sortable-tab-v2.tsx b/packages/app/src/components/session/session-sortable-tab-v2.tsx new file mode 100644 index 0000000000000000000000000000000000000000..e109e5d2ecaf60815dc065349f65c1616bbdfad9 --- /dev/null +++ b/packages/app/src/components/session/session-sortable-tab-v2.tsx @@ -0,0 +1,74 @@ +import { createMemo, Show } from "solid-js" +import type { JSX } from "solid-js" +import { useSortable } from "@dnd-kit/solid/sortable" +import { IconButton } from "@opencode-ai/ui/icon-button" +import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2" +import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2" +import { Tabs } from "@opencode-ai/ui/tabs" +import { useFile } from "@/context/file" +import { useLanguage } from "@/context/language" +import { useCommand } from "@/context/command" +import { FileVisual } from "./session-sortable-tab" + +export function SortableTabV2(props: { + tab: string + index: () => number + temporary?: boolean + onTabClose: (tab: string) => void + onTabDoubleClick?: (tab: string) => void +}): JSX.Element { + const file = useFile() + const language = useLanguage() + const command = useCommand() + const closeTabKeybind = createMemo(() => command.keybindParts("tab.close")) + const sortable = useSortable({ + get id() { + return props.tab + }, + get index() { + return props.index() + }, + }) + const path = createMemo(() => file.pathFromTab(props.tab)) + const content = createMemo(() => { + const value = path() + if (!value) return + return + }) + return ( +
+
+ + {language.t("common.closeTab")} + 0}> + + + + } + placement="bottom" + gutter={10} + > + props.onTabClose(props.tab)} + aria-label={language.t("common.closeTab")} + /> + + } + hideCloseButton + onMiddleClick={() => props.onTabClose(props.tab)} + onDblClick={() => props.onTabDoubleClick?.(props.tab)} + > + {(value) => value()} + +
+
+ ) +} diff --git a/packages/app/src/components/session/session-sortable-tab.tsx b/packages/app/src/components/session/session-sortable-tab.tsx new file mode 100644 index 0000000000000000000000000000000000000000..d78f392941ac343e7003b3e6240ded76cb2ba0a4 --- /dev/null +++ b/packages/app/src/components/session/session-sortable-tab.tsx @@ -0,0 +1,78 @@ +import { createMemo, Show } from "solid-js" +import type { JSX } from "solid-js" +import { createSortable } from "@thisbeyond/solid-dnd" +import { FileIcon } from "@opencode-ai/ui/file-icon" +import { IconButton } from "@opencode-ai/ui/icon-button" +import { TooltipKeybind } from "@opencode-ai/ui/tooltip" +import { Tabs } from "@opencode-ai/ui/tabs" +import { getFilename } from "@opencode-ai/core/util/path" +import { useFile } from "@/context/file" +import { useLanguage } from "@/context/language" +import { useCommand } from "@/context/command" + +export function FileVisual(props: { path: string; active?: boolean; temporary?: boolean }): JSX.Element { + return ( +
+ } + > + + + + + + + {getFilename(props.path)} + +
+ ) +} + +export function SortableTab(props: { + tab: string + temporary?: boolean + onTabClose: (tab: string) => void + onTabDoubleClick?: (tab: string) => void +}): JSX.Element { + const file = useFile() + const language = useLanguage() + const command = useCommand() + const sortable = createSortable(props.tab) + const path = createMemo(() => file.pathFromTab(props.tab)) + const content = createMemo(() => { + const value = path() + if (!value) return + return + }) + return ( +
+
+ + props.onTabClose(props.tab)} + aria-label={language.t("common.closeTab")} + /> + + } + hideCloseButton + onMiddleClick={() => props.onTabClose(props.tab)} + onDblClick={() => props.onTabDoubleClick?.(props.tab)} + > + {(value) => value()} + +
+
+ ) +} diff --git a/packages/app/src/components/session/session-sortable-terminal-tab-v2.tsx b/packages/app/src/components/session/session-sortable-terminal-tab-v2.tsx new file mode 100644 index 0000000000000000000000000000000000000000..a27b29668e518a2705f065353a8b79698cfdd2a4 --- /dev/null +++ b/packages/app/src/components/session/session-sortable-terminal-tab-v2.tsx @@ -0,0 +1,284 @@ +import type { JSX } from "solid-js" +import { Show, createEffect, onCleanup } from "solid-js" +import { createStore } from "solid-js/store" +import { useSortable } from "@dnd-kit/solid/sortable" +import { IconButton } from "@opencode-ai/ui/icon-button" +import { Tabs } from "@opencode-ai/ui/tabs" +import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu" +import { Icon } from "@opencode-ai/ui/icon" +import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2" +import { isDefaultTitle as isDefaultTerminalTitle } from "@/context/terminal-title" +import { useTerminal, type LocalPTY } from "@/context/terminal" +import { useLanguage } from "@/context/language" +import { focusTerminalById } from "@/pages/session/helpers" + +export function SortableTerminalTabV2(props: { + terminal: LocalPTY + index: () => number + newLayout: boolean + onClose?: () => void +}): JSX.Element { + const terminal = useTerminal() + const language = useLanguage() + const sortable = useSortable({ + get id() { + return props.terminal.id + }, + get index() { + return props.index() + }, + }) + const [store, setStore] = createStore({ + editing: false, + title: props.terminal.title, + menuOpen: false, + menuPosition: { x: 0, y: 0 }, + blurEnabled: false, + }) + let input: HTMLInputElement | undefined + let blurFrame: number | undefined + let editRequested = false + + const isDefaultTitle = () => { + const number = props.terminal.titleNumber + if (!Number.isFinite(number) || number <= 0) return false + return isDefaultTerminalTitle(props.terminal.title, number) + } + + const label = () => { + language.locale() + if (props.terminal.title && !isDefaultTitle()) return props.terminal.title + + const number = props.terminal.titleNumber + if (Number.isFinite(number) && number > 0) return language.t("terminal.title.numbered", { number }) + if (props.terminal.title) return props.terminal.title + return language.t("terminal.title") + } + + const close = () => { + const count = terminal.all().length + void terminal.close(props.terminal.id) + if (count === 1) { + props.onClose?.() + } + } + + const focus = () => { + if (store.editing) return + terminal.requestFocus(props.terminal.id) + terminal.open(props.terminal.id) + if (document.activeElement instanceof HTMLElement) document.activeElement.blur() + focusTerminalById(props.terminal.id) + const input = document.getElementById(`terminal-wrapper-${props.terminal.id}`)?.querySelector("textarea") + if (input === document.activeElement) terminal.consumeFocus(props.terminal.id) + } + + const edit = (e?: Event) => { + if (e) { + e.stopPropagation() + e.preventDefault() + } + + setStore("blurEnabled", false) + setStore("title", props.terminal.title) + setStore("editing", true) + } + + const save = () => { + if (!store.blurEnabled) return + + const value = store.title.trim() + if (value && value !== props.terminal.title) { + terminal.update({ id: props.terminal.id, title: value }) + } + setStore("editing", false) + } + + const keydown = (e: KeyboardEvent) => { + if (e.key === "Enter") { + e.preventDefault() + save() + return + } + if (e.key === "Escape") { + e.preventDefault() + setStore("editing", false) + } + } + + const menu = (e: MouseEvent) => { + e.preventDefault() + setStore("menuPosition", { x: e.clientX, y: e.clientY }) + setStore("menuOpen", true) + } + + createEffect(() => { + if (!store.editing) return + if (!input) return + input.focus() + input.select() + if (blurFrame !== undefined) cancelAnimationFrame(blurFrame) + blurFrame = requestAnimationFrame(() => { + blurFrame = undefined + setStore("blurEnabled", true) + }) + }) + + onCleanup(() => { + if (blurFrame === undefined) return + cancelAnimationFrame(blurFrame) + }) + + return ( +
+ + e.preventDefault()} + onContextMenu={menu} + class="!shadow-none" + classes={{ + button: "border-0 outline-none focus:outline-none focus-visible:outline-none !shadow-none !ring-0", + }} + closeButton={ + { + e.stopPropagation() + close() + }} + aria-label={language.t("terminal.close")} + /> + } + > + + {label()} + + + +
+ setStore("title", e.currentTarget.value)} + onBlur={save} + onKeyDown={keydown} + onMouseDown={(e) => e.stopPropagation()} + class="bg-transparent border-none outline-none text-sm min-w-0 flex-1" + /> +
+
+ setStore("menuOpen", open)}> + + { + if (!editRequested) return + e.preventDefault() + editRequested = false + requestAnimationFrame(() => edit()) + }} + > + (editRequested = true)}> + + {language.t("common.rename")} + + + + {language.t("common.close")} + + + + +
+ } + > + + + { + // Switch on mousedown to shave the press-release delay off tab switches. + if (e.button !== 0) return + if (store.editing) return + focus() + }} + onClick={(e) => { + // Mouse navigation already happened on mousedown; detail 0 means keyboard activation. + if (e.detail > 0) return + focus() + }} + closeButton={ + { + e.stopPropagation() + close() + }} + aria-label={language.t("terminal.close")} + /> + } + hideCloseButton + onMiddleClick={close} + > + + {label()} + + + +
+ setStore("title", e.currentTarget.value)} + onBlur={save} + onKeyDown={keydown} + onMouseDown={(e) => e.stopPropagation()} + class="bg-transparent border-none outline-none min-w-0 flex-1 p-0 text-[13px] leading-4 tracking-[-0.04px] text-v2-text-text-base [font-weight:440] [font-variation-settings:'slnt'_0] [font-variant-numeric:tabular-nums]" + /> +
+
+
+ + { + if (!editRequested) return + e.preventDefault() + editRequested = false + requestAnimationFrame(() => edit()) + }} + > + (editRequested = true)}>{language.t("common.rename")} + {language.t("common.close")} + + +
+ +
+ ) +} diff --git a/packages/app/src/components/session/session-sortable-terminal-tab.tsx b/packages/app/src/components/session/session-sortable-terminal-tab.tsx new file mode 100644 index 0000000000000000000000000000000000000000..2d88ed180645bc536e2c39488334d83763e2a442 --- /dev/null +++ b/packages/app/src/components/session/session-sortable-terminal-tab.tsx @@ -0,0 +1,193 @@ +import type { JSX } from "solid-js" +import { Show, createEffect, onCleanup } from "solid-js" +import { createStore } from "solid-js/store" +import { createSortable } from "@thisbeyond/solid-dnd" +import { IconButton } from "@opencode-ai/ui/icon-button" +import { Tabs } from "@opencode-ai/ui/tabs" +import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu" +import { Icon } from "@opencode-ai/ui/icon" +import { isDefaultTitle as isDefaultTerminalTitle } from "@/context/terminal-title" +import { useTerminal, type LocalPTY } from "@/context/terminal" +import { useLanguage } from "@/context/language" +import { focusTerminalById } from "@/pages/session/helpers" + +export function SortableTerminalTab(props: { terminal: LocalPTY; onClose?: () => void }): JSX.Element { + const terminal = useTerminal() + const language = useLanguage() + const sortable = createSortable(props.terminal.id) + const [store, setStore] = createStore({ + editing: false, + title: props.terminal.title, + menuOpen: false, + menuPosition: { x: 0, y: 0 }, + blurEnabled: false, + }) + let input: HTMLInputElement | undefined + let blurFrame: number | undefined + let editRequested = false + + const isDefaultTitle = () => { + const number = props.terminal.titleNumber + if (!Number.isFinite(number) || number <= 0) return false + return isDefaultTerminalTitle(props.terminal.title, number) + } + + const label = () => { + language.locale() + if (props.terminal.title && !isDefaultTitle()) return props.terminal.title + + const number = props.terminal.titleNumber + if (Number.isFinite(number) && number > 0) return language.t("terminal.title.numbered", { number }) + if (props.terminal.title) return props.terminal.title + return language.t("terminal.title") + } + + const close = () => { + const count = terminal.all().length + void terminal.close(props.terminal.id) + if (count === 1) { + props.onClose?.() + } + } + + const focus = () => { + if (store.editing) return + if (document.activeElement instanceof HTMLElement) document.activeElement.blur() + focusTerminalById(props.terminal.id) + } + + const edit = (e?: Event) => { + if (e) { + e.stopPropagation() + e.preventDefault() + } + + setStore("blurEnabled", false) + setStore("title", props.terminal.title) + setStore("editing", true) + } + + const save = () => { + if (!store.blurEnabled) return + + const value = store.title.trim() + if (value && value !== props.terminal.title) { + terminal.update({ id: props.terminal.id, title: value }) + } + setStore("editing", false) + } + + const keydown = (e: KeyboardEvent) => { + if (e.key === "Enter") { + e.preventDefault() + save() + return + } + if (e.key === "Escape") { + e.preventDefault() + setStore("editing", false) + } + } + + const menu = (e: MouseEvent) => { + e.preventDefault() + setStore("menuPosition", { x: e.clientX, y: e.clientY }) + setStore("menuOpen", true) + } + + createEffect(() => { + if (!store.editing) return + if (!input) return + input.focus() + input.select() + if (blurFrame !== undefined) cancelAnimationFrame(blurFrame) + blurFrame = requestAnimationFrame(() => { + blurFrame = undefined + setStore("blurEnabled", true) + }) + }) + + onCleanup(() => { + if (blurFrame === undefined) return + cancelAnimationFrame(blurFrame) + }) + + return ( +
+
+ e.preventDefault()} + onContextMenu={menu} + class="!shadow-none" + classes={{ + button: "border-0 outline-none focus:outline-none focus-visible:outline-none !shadow-none !ring-0", + }} + closeButton={ + { + e.stopPropagation() + close() + }} + aria-label={language.t("terminal.close")} + /> + } + > + + {label()} + + + +
+ setStore("title", e.currentTarget.value)} + onBlur={save} + onKeyDown={keydown} + onMouseDown={(e) => e.stopPropagation()} + class="bg-transparent border-none outline-none text-sm min-w-0 flex-1" + /> +
+
+ setStore("menuOpen", open)}> + + { + if (!editRequested) return + e.preventDefault() + editRequested = false + requestAnimationFrame(() => edit()) + }} + > + (editRequested = true)}> + + {language.t("common.rename")} + + + + {language.t("common.close")} + + + + +
+
+ ) +} diff --git a/packages/app/src/components/settings-providers.tsx b/packages/app/src/components/settings-providers.tsx new file mode 100644 index 0000000000000000000000000000000000000000..080e6c517bfe7205470d70c84e36eba0311229bc --- /dev/null +++ b/packages/app/src/components/settings-providers.tsx @@ -0,0 +1,264 @@ +import { Button } from "@opencode-ai/ui/button" +import { useDialog } from "@opencode-ai/ui/context/dialog" +import { ProviderIcon } from "@opencode-ai/ui/provider-icon" +import { Tag } from "@opencode-ai/ui/tag" +import { showToast } from "@/utils/toast" +import { popularProviders, useProviders } from "@/hooks/use-providers" +import { createMemo, type Component, For, Show } from "solid-js" +import { useLanguage } from "@/context/language" +import { useServerProtocol, useServerSDK } from "@/context/server-sdk" +import { useServerSync } from "@/context/server-sync" +import { DialogConnectProvider, useProviderConnectController } from "./dialog-connect-provider" +import { DialogCustomProvider } from "./dialog-custom-provider" +import { SettingsList } from "./settings-list" +import { SettingsServerPicker, SettingsServerScope } from "./settings-server-picker" + +type ProviderSource = "env" | "api" | "config" | "custom" +type ProviderItem = ReturnType["connected"]>[number] + +const PROVIDER_NOTES = [ + { match: (id: string) => id === "opencode", key: "dialog.provider.opencode.note" }, + { match: (id: string) => id === "opencode-go", key: "dialog.provider.opencodeGo.tagline" }, + { match: (id: string) => id === "anthropic", key: "dialog.provider.anthropic.note" }, + { match: (id: string) => id.startsWith("github-copilot"), key: "dialog.provider.copilot.note" }, + { match: (id: string) => id === "openai", key: "dialog.provider.openai.note" }, + { match: (id: string) => id === "google", key: "dialog.provider.google.note" }, + { match: (id: string) => id === "openrouter", key: "dialog.provider.openrouter.note" }, + { match: (id: string) => id === "vercel", key: "dialog.provider.vercel.note" }, +] as const + +export const SettingsProviders: Component<{ onBack?: () => void }> = (props) => { + return ( + + + + ) +} + +const SettingsProvidersContent: Component<{ onBack?: () => void }> = (props) => { + const dialog = useDialog() + const language = useLanguage() + const serverSDK = useServerSDK() + const protocol = useServerProtocol() + const serverSync = useServerSync() + const providers = useProviders(() => undefined) + const providerConnect = useProviderConnectController({ onBack: props.onBack }) + + const connect = (provider?: string) => { + providerConnect.select(provider) + void dialog.show(() => ) + } + + const connected = createMemo(() => { + return providers + .connected() + .filter((p) => p.id !== "opencode" || Object.values(p.models).find((m) => m.cost?.input)) + }) + + const popular = createMemo(() => { + const connectedIDs = new Set(connected().map((p) => p.id)) + const items = providers + .popular() + .filter((p) => !connectedIDs.has(p.id)) + .slice() + items.sort((a, b) => popularProviders.indexOf(a.id) - popularProviders.indexOf(b.id)) + return items + }) + + const source = (item: ProviderItem): ProviderSource | undefined => { + if (!("source" in item)) return + const value = item.source + if (value === "env" || value === "api" || value === "config" || value === "custom") return value + return + } + + const type = (item: ProviderItem) => { + const current = source(item) + if (current === "env") return language.t("settings.providers.tag.environment") + if (current === "api") return language.t("provider.connect.method.apiKey") + if (current === "config") { + if (isConfigCustom(item.id)) return language.t("settings.providers.tag.custom") + return language.t("settings.providers.tag.config") + } + if (current === "custom") return language.t("settings.providers.tag.custom") + return language.t("settings.providers.tag.other") + } + + const canDisconnect = (item: ProviderItem) => + source(item) !== "env" && (protocol() === "v1" || !isConfigCustom(item.id)) + + const note = (id: string) => PROVIDER_NOTES.find((item) => item.match(id))?.key + + const isConfigCustom = (providerID: string) => { + const provider = serverSync().data.config.provider?.[providerID] + if (!provider) return false + if (provider.npm !== "@ai-sdk/openai-compatible") return false + if (!provider.models || Object.keys(provider.models).length === 0) return false + return true + } + + const disableProvider = async (providerID: string, name: string) => { + if (protocol() !== "v1") return + const before = serverSync().data.config.disabled_providers ?? [] + const next = before.includes(providerID) ? before : [...before, providerID] + serverSync().set("config", "disabled_providers", next) + + await serverSync() + .updateConfig({ disabled_providers: next }) + .then(() => { + showToast({ + variant: "success", + icon: "circle-check", + title: language.t("provider.disconnect.toast.disconnected.title", { provider: name }), + description: language.t("provider.disconnect.toast.disconnected.description", { provider: name }), + }) + }) + .catch((err: unknown) => { + serverSync().set("config", "disabled_providers", before) + const message = err instanceof Error ? err.message : String(err) + showToast({ title: language.t("common.requestFailed"), description: message }) + }) + } + + const disconnect = async (providerID: string, name: string) => { + if (isConfigCustom(providerID)) { + await serverSDK() + .client.auth.remove({ providerID }) + .catch(() => undefined) + await disableProvider(providerID, name) + return + } + await serverSDK() + .client.auth.remove({ providerID }) + .then(async () => { + await serverSDK().client.global.dispose() + showToast({ + variant: "success", + icon: "circle-check", + title: language.t("provider.disconnect.toast.disconnected.title", { provider: name }), + description: language.t("provider.disconnect.toast.disconnected.description", { provider: name }), + }) + }) + .catch((err: unknown) => { + const message = err instanceof Error ? err.message : String(err) + showToast({ title: language.t("common.requestFailed"), description: message }) + }) + } + + return ( +
+
+
+

{language.t("settings.providers.title")}

+ +
+
+ +
+
+

{language.t("settings.providers.section.connected")}

+ + 0} + fallback={ +
+ {language.t("settings.providers.connected.empty")} +
+ } + > + + {(item) => ( +
+
+ + {item.name} + {type(item)} +
+ + {language.t("settings.providers.connected.environmentDescription")} + + } + > + + +
+ )} +
+
+
+
+ +
+

{language.t("settings.providers.section.popular")}

+ + + {(item) => ( +
+
+
+ + {item.name} + + {language.t("dialog.provider.tag.recommended")} + + + {language.t("dialog.provider.tag.recommended")} + +
+ + {(key) => {language.t(key())}} + +
+ +
+ )} +
+ + +
+
+
+ + {language.t("provider.custom.title")} + {language.t("settings.providers.tag.custom")} +
+ + {language.t("settings.providers.custom.description")} + +
+ +
+
+
+ + +
+
+
+ ) +} diff --git a/packages/app/src/components/settings-v2/parts/list.tsx b/packages/app/src/components/settings-v2/parts/list.tsx new file mode 100644 index 0000000000000000000000000000000000000000..2ebbdbe98fcc5f0f0bccc7b5c1fdb7adaf52d16e --- /dev/null +++ b/packages/app/src/components/settings-v2/parts/list.tsx @@ -0,0 +1,6 @@ +import type { Component, JSX } from "solid-js" +import "../settings-v2.css" + +export const SettingsListV2: Component<{ children: JSX.Element }> = (props) => { + return
{props.children}
+} diff --git a/packages/app/src/components/settings-v2/parts/row.tsx b/packages/app/src/components/settings-v2/parts/row.tsx new file mode 100644 index 0000000000000000000000000000000000000000..2c48240bb378c91b7cf8e6cff7ce869e1c1763a5 --- /dev/null +++ b/packages/app/src/components/settings-v2/parts/row.tsx @@ -0,0 +1,20 @@ +import type { Component, JSX } from "solid-js" +import "../settings-v2.css" + +export interface SettingsRowV2Props { + title: string | JSX.Element + description: string | JSX.Element + children: JSX.Element +} + +export const SettingsRowV2: Component = (props) => { + return ( +
+
+
{props.title}
+
{props.description}
+
+
{props.children}
+
+ ) +} diff --git a/packages/app/src/components/status-popover-indicator.test.ts b/packages/app/src/components/status-popover-indicator.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..d6d6d07a1d814f297f923f08e6f908918fdfb6e1 --- /dev/null +++ b/packages/app/src/components/status-popover-indicator.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, test } from "bun:test" +import { + hasNonBlockingServiceIssue, + hasServiceNeedingAttention, + serverStatusDotClass, +} from "./status-popover-indicator" + +describe("serverStatusDotClass", () => { + test("uses the success token while the server and services are healthy", () => { + expect(serverStatusDotClass({ ready: true, serverHealth: true, issue: false })).toBe("bg-icon-success-base") + }) + + test("uses the session attention token when a service needs attention", () => { + expect(serverStatusDotClass({ ready: true, serverHealth: true, attention: true, issue: true })).toBe( + "bg-v2-background-bg-accent", + ) + }) + + test("uses the warning token for non-blocking issues while the server is online", () => { + expect(serverStatusDotClass({ ready: true, serverHealth: true, issue: true })).toBe("bg-icon-warning-base") + }) + + test("uses the critical token only after the server connection drops", () => { + expect(serverStatusDotClass({ ready: true, serverHealth: false, issue: false })).toBe("bg-icon-critical-base") + expect(serverStatusDotClass({ ready: true, serverHealth: false, issue: true })).toBe("bg-icon-critical-base") + }) + + test("stays neutral before status is ready", () => { + expect(serverStatusDotClass({ ready: false, serverHealth: true, issue: false })).toBe("bg-border-weak-base") + expect(serverStatusDotClass({ ready: false, serverHealth: undefined, issue: false })).toBe("bg-border-weak-base") + }) +}) + +describe("hasNonBlockingServiceIssue", () => { + test("detects MCP failures that do not block chatting", () => { + expect(hasNonBlockingServiceIssue({ mcp: ["failed"], lsp: [] })).toBe(true) + expect(hasNonBlockingServiceIssue({ mcp: ["needs_auth"], lsp: [] })).toBe(true) + expect(hasNonBlockingServiceIssue({ mcp: ["needs_client_registration"], lsp: [] })).toBe(true) + expect(hasNonBlockingServiceIssue({ mcp: ["connected", "pending", "disabled"], lsp: [] })).toBe(false) + }) + + test("detects LSP failures that do not block chatting", () => { + expect(hasNonBlockingServiceIssue({ mcp: [], lsp: ["error"] })).toBe(true) + expect(hasNonBlockingServiceIssue({ mcp: [], lsp: ["connected"] })).toBe(false) + }) +}) + +describe("hasServiceNeedingAttention", () => { + test("detects MCP states that need user attention", () => { + expect(hasServiceNeedingAttention({ mcp: ["needs_auth"] })).toBe(true) + expect(hasServiceNeedingAttention({ mcp: ["needs_client_registration"] })).toBe(true) + }) + + test("ignores states that do not need user attention", () => { + expect(hasServiceNeedingAttention({ mcp: ["failed"] })).toBe(false) + expect(hasServiceNeedingAttention({ mcp: ["connected", "pending", "disabled"] })).toBe(false) + }) +}) diff --git a/packages/app/src/constants/file-picker.ts b/packages/app/src/constants/file-picker.ts new file mode 100644 index 0000000000000000000000000000000000000000..1e029b551cc64171cc29081fce64dc18d95c1a60 --- /dev/null +++ b/packages/app/src/constants/file-picker.ts @@ -0,0 +1,89 @@ +export const ACCEPTED_IMAGE_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"] + +export const ACCEPTED_FILE_TYPES = [ + ...ACCEPTED_IMAGE_TYPES, + "application/pdf", + "text/*", + "application/json", + "application/ld+json", + "application/toml", + "application/x-toml", + "application/x-yaml", + "application/xml", + "application/yaml", + ".c", + ".cc", + ".cjs", + ".conf", + ".cpp", + ".css", + ".csv", + ".cts", + ".env", + ".go", + ".gql", + ".graphql", + ".h", + ".hh", + ".hpp", + ".htm", + ".html", + ".ini", + ".java", + ".js", + ".json", + ".jsx", + ".log", + ".md", + ".mdx", + ".mjs", + ".mts", + ".py", + ".rb", + ".rs", + ".sass", + ".scss", + ".sh", + ".sql", + ".toml", + ".ts", + ".tsx", + ".txt", + ".xml", + ".yaml", + ".yml", + ".zsh", +] + +const MIME_EXT = new Map([ + ["image/png", "png"], + ["image/jpeg", "jpg"], + ["image/gif", "gif"], + ["image/webp", "webp"], + ["application/pdf", "pdf"], + ["application/json", "json"], + ["application/ld+json", "jsonld"], + ["application/toml", "toml"], + ["application/x-toml", "toml"], + ["application/x-yaml", "yaml"], + ["application/xml", "xml"], + ["application/yaml", "yaml"], +]) + +const TEXT_EXT = ["txt", "text", "md", "markdown", "log", "csv"] + +export const ACCEPTED_FILE_EXTENSIONS = Array.from( + new Set( + ACCEPTED_FILE_TYPES.flatMap((item) => { + if (item.startsWith(".")) return [item.slice(1)] + if (item === "text/*") return TEXT_EXT + const out = MIME_EXT.get(item) + return out ? [out] : [] + }), + ), +).sort() + +export function filePickerFilters(name: string, ext?: string[]) { + if (!ext || ext.length === 0) return undefined + return [{ name, extensions: ext }] +} diff --git a/packages/app/src/context/closed-tabs.ts b/packages/app/src/context/closed-tabs.ts new file mode 100644 index 0000000000000000000000000000000000000000..5c49b94b6fb2b120c2f53af847cd09dec58409c8 --- /dev/null +++ b/packages/app/src/context/closed-tabs.ts @@ -0,0 +1,40 @@ +import type { SessionTab, Tab } from "./tabs" + +export type ClosedTab = { + tab: SessionTab + index: number +} + +const CLOSED_TAB_LIMIT = 25 + +// Only session tabs are recorded; closing a draft tab deletes its persisted +// state, so a reopened draft would come back empty anyway. +export function pushClosedTab(stack: ClosedTab[], tab: Tab, index: number): ClosedTab[] { + if (tab.type !== "session") return stack + return [...stack, { tab: { ...tab }, index }].slice(-CLOSED_TAB_LIMIT) +} + +// Pops the most recently closed tab that is not open again, +// discarding stale entries along the way. +export function takeClosedTab(stack: ClosedTab[], tabs: Tab[]): { entry?: ClosedTab; stack: ClosedTab[] } { + const remaining = [...stack] + while (remaining.length) { + const entry = remaining.pop() + if (entry && !isOpen(tabs, entry.tab)) return { entry, stack: remaining } + } + return { stack: remaining } +} + +export function removeClosedTabs(stack: ClosedTab[], server: SessionTab["server"], sessionIDs: string[]) { + const removed = new Set(sessionIDs) + return stack.filter((entry) => entry.tab.server !== server || !removed.has(entry.tab.sessionId)) +} + +export function nextTabAfterClose(tabs: Tab[], index: number, active: boolean) { + if (!active) return undefined + return tabs[index + 1] ?? tabs[index - 1] ?? null +} + +function isOpen(tabs: Tab[], tab: SessionTab) { + return tabs.some((item) => item.type === "session" && item.server === tab.server && item.sessionId === tab.sessionId) +} diff --git a/packages/app/src/context/command-keybind.test.ts b/packages/app/src/context/command-keybind.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..c8e2dbb5d0f54f62a1a9ce7717ba53d362359f96 --- /dev/null +++ b/packages/app/src/context/command-keybind.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, test } from "bun:test" +import { formatKeybind, matchKeybind, parseKeybind } from "./command" + +describe("command keybind helpers", () => { + test("parseKeybind handles aliases and multiple combos", () => { + const keybinds = parseKeybind("control+option+k, mod+shift+comma") + + expect(keybinds).toHaveLength(2) + expect(keybinds[0]).toEqual({ + key: "k", + ctrl: true, + meta: false, + shift: false, + alt: true, + }) + expect(keybinds[1]?.shift).toBe(true) + expect(keybinds[1]?.key).toBe("comma") + expect(Boolean(keybinds[1]?.ctrl || keybinds[1]?.meta)).toBe(true) + }) + + test("parseKeybind treats none and empty as disabled", () => { + expect(parseKeybind("none")).toEqual([]) + expect(parseKeybind("")).toEqual([]) + }) + + test("matchKeybind normalizes punctuation keys", () => { + const keybinds = parseKeybind("ctrl+comma, shift+plus, meta+space") + + expect(matchKeybind(keybinds, new KeyboardEvent("keydown", { key: ",", ctrlKey: true }))).toBe(true) + expect(matchKeybind(keybinds, new KeyboardEvent("keydown", { key: "+", shiftKey: true }))).toBe(true) + expect(matchKeybind(keybinds, new KeyboardEvent("keydown", { key: " ", metaKey: true }))).toBe(true) + expect(matchKeybind(keybinds, new KeyboardEvent("keydown", { key: ",", ctrlKey: true, altKey: true }))).toBe(false) + }) + + test("matchKeybind supports bracket keys", () => { + const keybinds = parseKeybind("mod+alt+[, mod+alt+]") + const prev = keybinds[0] + const next = keybinds[1] + + expect( + matchKeybind( + keybinds, + new KeyboardEvent("keydown", { key: "[", ctrlKey: prev?.ctrl, metaKey: prev?.meta, altKey: true }), + ), + ).toBe(true) + expect( + matchKeybind( + keybinds, + new KeyboardEvent("keydown", { key: "]", ctrlKey: next?.ctrl, metaKey: next?.meta, altKey: true }), + ), + ).toBe(true) + }) + + test("formatKeybind returns human readable output", () => { + const display = formatKeybind("ctrl+alt+arrowup") + + expect(display).toContain("↑") + expect(display.includes("Ctrl") || display.includes("⌃")).toBe(true) + expect(display.includes("Alt") || display.includes("⌥")).toBe(true) + expect(formatKeybind("none")).toBe("") + }) + + test("formatKeybind prefers the first combo", () => { + const display = formatKeybind("mod+k,mod+p") + + expect(display.includes("K") || display.includes("k")).toBe(true) + expect(display.includes("P") || display.includes("p")).toBe(false) + }) +}) diff --git a/packages/app/src/context/command.tsx b/packages/app/src/context/command.tsx new file mode 100644 index 0000000000000000000000000000000000000000..054b99e806bda9f20efa195dbeff8b091babd2a4 --- /dev/null +++ b/packages/app/src/context/command.tsx @@ -0,0 +1,476 @@ +import { createSimpleContext } from "@opencode-ai/ui/context" +import { useDialog } from "@opencode-ai/ui/context/dialog" +import { type Accessor, createEffect, createMemo, onCleanup, onMount } from "solid-js" +import { createStore } from "solid-js/store" +import { makeEventListener } from "@solid-primitives/event-listener" +import { useLanguage } from "@/context/language" +import { useSettings } from "@/context/settings" +import { dict as en } from "@/i18n/en" +import { Persist, persisted } from "@/utils/persist" + +const IS_MAC = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform) + +const PALETTE_ID = "command.palette" +export const DEFAULT_PALETTE_KEYBIND = "mod+k,mod+shift+p" +const SUGGESTED_PREFIX = "suggested." +const EDITABLE_KEYBIND_IDS = new Set(["terminal.toggle", "terminal.new", "file.attach"]) + +type KeyLabel = + | "common.key.ctrl" + | "common.key.alt" + | "common.key.shift" + | "common.key.meta" + | "common.key.space" + | "common.key.backspace" + | "common.key.enter" + | "common.key.tab" + | "common.key.delete" + | "common.key.home" + | "common.key.end" + | "common.key.pageUp" + | "common.key.pageDown" + | "common.key.insert" + | "common.key.esc" + +function keyText(key: KeyLabel, t?: (key: KeyLabel) => string) { + return t ? t(key) : en[key] +} + +function actionId(id: string) { + if (!id.startsWith(SUGGESTED_PREFIX)) return id + return id.slice(SUGGESTED_PREFIX.length) +} + +function normalizeKey(key: string) { + if (key === ",") return "comma" + if (key === "+") return "plus" + if (key === " ") return "space" + return key.toLowerCase() +} + +function signature(key: string, ctrl: boolean, meta: boolean, shift: boolean, alt: boolean) { + const mask = (ctrl ? 1 : 0) | (meta ? 2 : 0) | (shift ? 4 : 0) | (alt ? 8 : 0) + return `${key}:${mask}` +} + +function signatureFromEvent(event: KeyboardEvent) { + return signature(normalizeKey(event.key), event.ctrlKey, event.metaKey, event.shiftKey, event.altKey) +} + +function isAllowedEditableKeybind(id: string | undefined) { + if (!id) return false + return EDITABLE_KEYBIND_IDS.has(actionId(id)) +} + +export type KeybindConfig = string + +export interface Keybind { + key: string + ctrl: boolean + meta: boolean + shift: boolean + alt: boolean +} + +export interface CommandOption { + id: string + title: string + description?: string + category?: string + keybind?: KeybindConfig + slash?: string + suggested?: boolean + disabled?: boolean + hidden?: boolean + when?: (event: KeyboardEvent) => boolean + onSelect?: (source?: "palette" | "keybind" | "slash") => void + onHighlight?: () => (() => void) | void +} + +export function commandPaletteOptions(options: CommandOption[]) { + return options.filter( + (option) => + !option.disabled && !option.hidden && !option.id.startsWith(SUGGESTED_PREFIX) && option.id !== "file.open", + ) +} + +export function resolveKeybindOption(candidates: CommandOption[] | undefined, event: KeyboardEvent) { + return candidates?.find((option) => option.when?.(event)) ?? candidates?.find((option) => !option.when) +} + +type CommandSource = "palette" | "keybind" | "slash" + +export type CommandCatalogItem = { + title: string + description?: string + category?: string + keybind?: KeybindConfig + slash?: string + hidden?: boolean +} + +export type CommandRegistration = { + key?: string + options: Accessor +} + +export function addCommandRegistration(registrations: CommandRegistration[], entry: CommandRegistration) { + return [entry, ...registrations] +} + +export function activeCommandRegistrations(registrations: CommandRegistration[]) { + const keys = new Set() + return registrations.filter((entry) => { + if (entry.key === undefined) return true + if (keys.has(entry.key)) return false + keys.add(entry.key) + return true + }) +} + +export function parseKeybind(config: string): Keybind[] { + if (!config || config === "none") return [] + + return config.split(",").map((combo) => { + const parts = combo.trim().toLowerCase().split("+") + const keybind: Keybind = { + key: "", + ctrl: false, + meta: false, + shift: false, + alt: false, + } + + for (const part of parts) { + switch (part) { + case "ctrl": + case "control": + keybind.ctrl = true + break + case "meta": + case "cmd": + case "command": + keybind.meta = true + break + case "mod": + if (IS_MAC) keybind.meta = true + else keybind.ctrl = true + break + case "alt": + case "option": + keybind.alt = true + break + case "shift": + keybind.shift = true + break + default: + keybind.key = part + break + } + } + + return keybind + }) +} + +export function matchKeybind(keybinds: Keybind[], event: KeyboardEvent): boolean { + const eventKey = normalizeKey(event.key) + + for (const kb of keybinds) { + const keyMatch = kb.key === eventKey + const ctrlMatch = kb.ctrl === (event.ctrlKey || false) + const metaMatch = kb.meta === (event.metaKey || false) + const shiftMatch = kb.shift === (event.shiftKey || false) + const altMatch = kb.alt === (event.altKey || false) + + if (keyMatch && ctrlMatch && metaMatch && shiftMatch && altMatch) { + return true + } + } + + return false +} + +function displayKeybindParts(kb: Keybind, t?: (key: KeyLabel) => string) { + const parts: string[] = [] + + if (kb.ctrl) parts.push(IS_MAC ? "⌃" : keyText("common.key.ctrl", t)) + if (kb.alt) parts.push(IS_MAC ? "⌥" : keyText("common.key.alt", t)) + if (kb.shift) parts.push(IS_MAC ? "⇧" : keyText("common.key.shift", t)) + if (kb.meta) parts.push(IS_MAC ? "⌘" : keyText("common.key.meta", t)) + + if (!kb.key) return parts + + const keys: Record = { + arrowup: "↑", + arrowdown: "↓", + arrowleft: "←", + arrowright: "→", + comma: ",", + plus: "+", + } + const named: Record = { + backspace: "common.key.backspace", + delete: "common.key.delete", + end: "common.key.end", + enter: "common.key.enter", + esc: "common.key.esc", + escape: "common.key.esc", + home: "common.key.home", + insert: "common.key.insert", + pagedown: "common.key.pageDown", + pageup: "common.key.pageUp", + space: "common.key.space", + tab: "common.key.tab", + } + const key = kb.key.toLowerCase() + const displayKey = + keys[key] ?? + (named[key] + ? keyText(named[key], t) + : key.length === 1 + ? key.toUpperCase() + : key.charAt(0).toUpperCase() + key.slice(1)) + parts.push(displayKey) + + return parts +} + +export function formatKeybindParts(config: string, t?: (key: KeyLabel) => string): string[] { + if (!config || config === "none") return [] + const keybind = parseKeybind(config)[0] + return keybind ? displayKeybindParts(keybind, t) : [] +} + +export function formatKeybind(config: string, t?: (key: KeyLabel) => string): string { + const parts = formatKeybindParts(config, t) + if (parts.length === 0) return "" + return IS_MAC ? parts.join("") : parts.join("+") +} + +// KeybindV2 takes an array instead of a string +export function formatKeybindKeys(config: string, t?: (key: KeyLabel) => string): string[] { + return formatKeybindParts(config, t) +} + +function isEditableTarget(target: EventTarget | null) { + if (!(target instanceof HTMLElement)) return false + if (target.isContentEditable) return true + if (target.closest("[contenteditable='true']")) return true + if (target.closest("input, textarea, select")) return true + return false +} + +export const { use: useCommand, provider: CommandProvider } = createSimpleContext({ + name: "Command", + init: () => { + const dialog = useDialog() + const settings = useSettings() + const language = useLanguage() + const [store, setStore] = createStore({ + registrations: [] as CommandRegistration[], + suspendCount: 0, + }) + const warnedDuplicates = new Set() + + type CommandCatalog = Record + const [catalog, setCatalog, _, catalogReady] = persisted( + Persist.global("command.catalog.v1"), + createStore({}), + ) + + const bind = (id: string, def: KeybindConfig | undefined) => { + const custom = settings.keybinds.get(actionId(id)) + const config = custom ?? def + if (!config || config === "none") return + return config + } + + const registered = createMemo(() => { + const seen = new Set() + const all: CommandOption[] = [] + + for (const reg of activeCommandRegistrations(store.registrations)) { + for (const opt of reg.options()) { + if (seen.has(opt.id)) { + if (import.meta.env.DEV && !warnedDuplicates.has(opt.id)) { + warnedDuplicates.add(opt.id) + console.warn(`[command] duplicate command id "${opt.id}" registered; keeping first entry`) + } + continue + } + seen.add(opt.id) + all.push(opt) + } + } + + return all + }) + + createEffect(() => { + if (!catalogReady()) return + + setCatalog( + registered().reduce((acc, opt) => { + const id = actionId(opt.id) + if (opt.title) + acc[id] = { + title: opt.title, + description: opt.description, + category: opt.category, + keybind: opt.keybind, + slash: opt.slash, + } + return acc + }, {} as CommandCatalog), + ) + }) + + const catalogOptions = createMemo(() => Object.entries(catalog).map(([id, meta]) => ({ id, ...meta }))) + + const options = createMemo(() => { + const resolved = registered().map((opt) => ({ + ...opt, + keybind: bind(opt.id, opt.keybind), + })) + + const suggested = resolved.filter((x) => x.suggested && !x.disabled) + + return [ + ...suggested.map((x) => ({ + ...x, + id: SUGGESTED_PREFIX + x.id, + category: language.t("command.category.suggested"), + })), + ...resolved, + ] + }) + + const suspended = () => store.suspendCount > 0 + + const palette = createMemo(() => { + const config = settings.keybinds.get(PALETTE_ID) ?? DEFAULT_PALETTE_KEYBIND + const keybinds = parseKeybind(config) + return new Set(keybinds.map((kb) => signature(kb.key, kb.ctrl, kb.meta, kb.shift, kb.alt))) + }) + + const keymap = createMemo(() => { + const map = new Map() + for (const option of options()) { + if (option.id.startsWith(SUGGESTED_PREFIX)) continue + if (option.disabled) continue + if (!option.keybind) continue + + const keybinds = parseKeybind(option.keybind) + for (const kb of keybinds) { + if (!kb.key) continue + const sig = signature(kb.key, kb.ctrl, kb.meta, kb.shift, kb.alt) + const existing = map.get(sig) + if (existing) { + existing.push(option) + continue + } + map.set(sig, [option]) + } + } + return map + }) + + const optionMap = createMemo(() => { + const map = new Map() + for (const option of options()) { + map.set(option.id, option) + map.set(actionId(option.id), option) + } + return map + }) + + const run = (id: string, source?: CommandSource) => { + const option = optionMap().get(id) + option?.onSelect?.(source) + } + + const showPalette = () => { + run(PALETTE_ID, "palette") + } + + const handleKeyDown = (event: KeyboardEvent) => { + if (suspended() || dialog.active) return + + const sig = signatureFromEvent(event) + const isPalette = palette().has(sig) + const option = resolveKeybindOption(keymap().get(sig), event) + const modified = event.ctrlKey || event.metaKey || event.altKey + const isTab = event.key === "Tab" + + if (isEditableTarget(event.target) && !isPalette && !isAllowedEditableKeybind(option?.id) && !modified && !isTab) + return + + if (isPalette) { + event.preventDefault() + event.stopPropagation() + showPalette() + return + } + + if (!option) return + event.preventDefault() + event.stopPropagation() + option.onSelect?.("keybind") + } + + onMount(() => { + makeEventListener(document, "keydown", handleKeyDown, { capture: true }) + }) + + function register(cb: () => CommandOption[]): void + function register(key: string, cb: () => CommandOption[]): void + function register(key: string | (() => CommandOption[]), cb?: () => CommandOption[]) { + const id = typeof key === "string" ? key : undefined + const next = typeof key === "function" ? key : cb + if (!next) return + const options = createMemo(next) + const entry: CommandRegistration = { + key: id, + options, + } + setStore("registrations", (arr) => addCommandRegistration(arr, entry)) + onCleanup(() => { + setStore("registrations", (arr) => arr.filter((x) => x !== entry)) + }) + } + + const keybindConfig = (id: string) => { + if (id === PALETTE_ID) return settings.keybinds.get(PALETTE_ID) ?? DEFAULT_PALETTE_KEYBIND + const base = actionId(id) + return options().find((x) => actionId(x.id) === base)?.keybind ?? bind(base, catalog[base]?.keybind) + } + + return { + register, + trigger(id: string, source?: CommandSource) { + run(id, source) + }, + keybind(id: string) { + const config = keybindConfig(id) + if (!config) return "" + return formatKeybind(config, language.t) + }, + keybindParts(id: string) { + const config = keybindConfig(id) + return config ? formatKeybindParts(config, language.t) : [] + }, + show: showPalette, + keybinds(enabled: boolean) { + setStore("suspendCount", (count) => Math.max(0, count + (enabled ? -1 : 1))) + }, + suspended, + get catalog() { + return catalogOptions() + }, + get options() { + return options() + }, + } + }, +}) diff --git a/packages/app/src/context/comments.tsx b/packages/app/src/context/comments.tsx new file mode 100644 index 0000000000000000000000000000000000000000..09e890a5e51b27f952aa43ebfeeaa4272127a20e --- /dev/null +++ b/packages/app/src/context/comments.tsx @@ -0,0 +1,261 @@ +import { batch, createMemo, createRoot, onCleanup } from "solid-js" +import { createStore, reconcile, type SetStoreFunction, type Store } from "solid-js/store" +import { createSimpleContext } from "@opencode-ai/ui/context" +import { useParams } from "@solidjs/router" +import { base64Encode } from "@opencode-ai/core/util/encode" +import { Persist, persisted } from "@/utils/persist" +import { useServerSDK } from "./server-sdk" +import type { ServerScope } from "@/utils/server-scope" +import { createScopedCache } from "@/utils/scoped-cache" +import { uuid } from "@/utils/uuid" +import type { SelectedLineRange } from "@/context/file" +import { useSDK } from "./sdk" + +export type LineComment = { + id: string + file: string + selection: SelectedLineRange + comment: string + time: number +} + +type CommentFocus = { file: string; id: string } + +const WORKSPACE_KEY = "__workspace__" +const MAX_COMMENT_SESSIONS = 20 + +function sessionKey(dir: string, id: string | undefined) { + return `${dir}\n${id ?? WORKSPACE_KEY}` +} + +function decodeSessionKey(key: string) { + const split = key.lastIndexOf("\n") + if (split < 0) return { dir: key, id: WORKSPACE_KEY } + return { + dir: key.slice(0, split), + id: key.slice(split + 1), + } +} + +type CommentStore = { + comments: Record +} + +function aggregate(comments: Record) { + return Object.keys(comments) + .flatMap((file) => comments[file] ?? []) + .slice() + .sort((a, b) => a.time - b.time) +} + +function cloneSelection(selection: SelectedLineRange): SelectedLineRange { + const next: SelectedLineRange = { + start: selection.start, + end: selection.end, + } + + if (selection.side) next.side = selection.side + if (selection.endSide) next.endSide = selection.endSide + return next +} + +function cloneComment(comment: LineComment): LineComment { + return { + ...comment, + selection: cloneSelection(comment.selection), + } +} + +function group(comments: LineComment[]) { + return comments.reduce>((acc, comment) => { + const list = acc[comment.file] + const next = cloneComment(comment) + if (list) { + list.push(next) + return acc + } + acc[comment.file] = [next] + return acc + }, {}) +} + +function createCommentSessionState(store: Store, setStore: SetStoreFunction) { + const [state, setState] = createStore({ + focus: null as CommentFocus | null, + active: null as CommentFocus | null, + }) + + // Reuse the previous array when contents are unchanged so consumers keep a stable + // identity; a fresh array per call cascaded into diff annotation re-renders. + let lastAll: LineComment[] = [] + const all = () => { + const next = aggregate(store.comments) + if (next.length === lastAll.length && next.every((item, index) => item === lastAll[index])) return lastAll + lastAll = next + return next + } + + const setRef = ( + key: "focus" | "active", + value: CommentFocus | null | ((value: CommentFocus | null) => CommentFocus | null), + ) => setState(key, value) + + const setFocus = (value: CommentFocus | null | ((value: CommentFocus | null) => CommentFocus | null)) => + setRef("focus", value) + + const setActive = (value: CommentFocus | null | ((value: CommentFocus | null) => CommentFocus | null)) => + setRef("active", value) + + const list = (file: string) => store.comments[file] ?? [] + + const add = (input: Omit) => { + const next: LineComment = { + id: uuid(), + time: Date.now(), + ...input, + selection: cloneSelection(input.selection), + } + + batch(() => { + setStore("comments", input.file, (items) => [...(items ?? []), next]) + setFocus({ file: input.file, id: next.id }) + }) + + return next + } + + const remove = (file: string, id: string) => { + batch(() => { + setStore("comments", file, (items) => (items ?? []).filter((item) => item.id !== id)) + setFocus((current) => (current?.file === file && current.id === id ? null : current)) + }) + } + + const update = (file: string, id: string, comment: string) => { + setStore("comments", file, (items) => + (items ?? []).map((item) => { + if (item.id !== id) return item + return { ...item, comment } + }), + ) + } + + const replace = (comments: LineComment[]) => { + batch(() => { + setStore("comments", reconcile(group(comments))) + setFocus(null) + setActive(null) + }) + } + + const clear = () => { + batch(() => { + setStore("comments", reconcile({})) + setFocus(null) + setActive(null) + }) + } + + return { + list, + all, + add, + remove, + update, + replace, + clear, + focus: () => state.focus, + setFocus, + clearFocus: () => setRef("focus", null), + active: () => state.active, + setActive, + clearActive: () => setRef("active", null), + } +} + +export function createCommentSessionForTest(comments: Record = {}) { + const [store, setStore] = createStore({ comments }) + return createCommentSessionState(store, setStore) +} + +function createCommentSession(scope: ServerScope, dir: string, id: string | undefined) { + const legacy = `${dir}/comments${id ? "/" + id : ""}.v1` + + const [store, setStore, _, ready] = persisted( + Persist.serverScoped(scope, dir, id, "comments", [legacy]), + createStore({ + comments: {}, + }), + ) + const session = createCommentSessionState(store, setStore) + + return { + ready, + list: session.list, + all: session.all, + add: session.add, + remove: session.remove, + update: session.update, + replace: session.replace, + clear: session.clear, + focus: session.focus, + setFocus: session.setFocus, + clearFocus: session.clearFocus, + active: session.active, + setActive: session.setActive, + clearActive: session.clearActive, + } +} + +export const { use: useComments, provider: CommentsProvider } = createSimpleContext({ + name: "Comments", + gate: false, + init: () => { + const params = useParams() + const sdk = useSDK() + const serverSDK = useServerSDK() + const cache = createScopedCache( + (key) => { + const decoded = decodeSessionKey(key) + return createRoot((dispose) => ({ + value: createCommentSession( + serverSDK().scope, + decoded.dir, + decoded.id === WORKSPACE_KEY ? undefined : decoded.id, + ), + dispose, + })) + }, + { + maxEntries: MAX_COMMENT_SESSIONS, + dispose: (entry) => entry.dispose(), + }, + ) + + onCleanup(() => cache.clear()) + + const load = (dir: string, id: string | undefined) => { + const key = sessionKey(dir, id) + return cache.get(key).value + } + + const session = createMemo(() => load(base64Encode(sdk().directory), params.id)) + + return { + ready: () => session().ready(), + list: (file: string) => session().list(file), + all: () => session().all(), + add: (input: Omit) => session().add(input), + remove: (file: string, id: string) => session().remove(file, id), + update: (file: string, id: string, comment: string) => session().update(file, id, comment), + replace: (comments: LineComment[]) => session().replace(comments), + clear: () => session().clear(), + focus: () => session().focus(), + setFocus: (focus: CommentFocus | null) => session().setFocus(focus), + clearFocus: () => session().clearFocus(), + active: () => session().active(), + setActive: (active: CommentFocus | null) => session().setActive(active), + clearActive: () => session().clearActive(), + } + }, +}) diff --git a/packages/app/src/context/directory-sync.ts b/packages/app/src/context/directory-sync.ts new file mode 100644 index 0000000000000000000000000000000000000000..befd5b61e0954d0b13ecf8f2706ebc3d764e4f1b --- /dev/null +++ b/packages/app/src/context/directory-sync.ts @@ -0,0 +1,156 @@ +import { Binary } from "@opencode-ai/core/util/binary" +import type { Message, Part, Session } from "@opencode-ai/sdk/v2/client" +import { createMemo } from "solid-js" +import { produce, reconcile, type SetStoreFunction } from "solid-js/store" +import type { createServerSdkContext } from "./server-sdk" +import type { createServerSyncContextInner } from "./server-sync" +import type { State } from "./global-sync/types" +import { normalizeSessionInfo } from "@/utils/session" + +const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0) +const sessionFields = new Set([ + "session_status", + "session_working", + "session_diff", + "todo", + "permission", + "question", + "message", + "session_message", + "part", + "part_text_accum_delta", +]) + +export const createDirSyncContext = ( + directory: string, + serverSync: ReturnType, + serverSDK: ReturnType, +) => { + const client = serverSDK.createClient({ directory, throwOnError: true }) + const current = createMemo(() => serverSync.child(directory, { mcp: true })) + const absolute = (path: string) => (current()[0].path.directory + "/" + path).replace("//", "/") + const data = new Proxy({} as State, { + get(_, property: keyof State) { + if (property === "session_working") return serverSync.session.data.session_working.bind(serverSync.session.data) + if (sessionFields.has(property)) return serverSync.session.data[property as keyof typeof serverSync.session.data] + return current()[0][property] + }, + }) + const set = ((...input: unknown[]) => { + if (typeof input[0] === "string" && sessionFields.has(input[0])) { + return (serverSync.session.set as (...args: unknown[]) => unknown)(...input) + } + const result = (current()[1] as (...args: unknown[]) => unknown)(...input) + if (input[0] === "session") current()[0].session.forEach(serverSync.session.remember) + return result + }) as SetStoreFunction + + const index = (sessionID: string) => { + const session = serverSync.session.get(sessionID) + if (!session || session.directory !== directory) return + const [store, setStore] = current() + const result = Binary.search(store.session, session.id, (item) => item.id) + if (result.found) { + setStore("session", result.index, reconcile(session)) + return + } + setStore( + "session", + produce((draft) => void draft.splice(result.index, 0, session)), + ) + } + + return { + data, + set, + get status() { + return current()[0].status + }, + get ready() { + return current()[0].status !== "loading" + }, + get project() { + const store = current()[0] + const match = Binary.search(serverSync.data.project, store.project, (project) => project.id) + if (match.found) return serverSync.data.project[match.index] + }, + session: { + remember(session: Session) { + serverSync.session.remember(session) + index(session.id) + }, + get(sessionID: string) { + const session = serverSync.session.get(sessionID) + if (session?.directory === directory) return session + }, + optimistic: { + add(input: { directory?: string; sessionID: string; message: Message; parts: Part[] }) { + serverSync.session.optimistic.add(input) + }, + remove(input: { directory?: string; sessionID: string; messageID: string }) { + serverSync.session.optimistic.remove(input) + }, + }, + addOptimisticMessage(input: { + sessionID: string + messageID: string + parts: Part[] + agent: string + model: { providerID: string; modelID: string } + variant?: string + }) { + serverSync.session.optimistic.add({ + sessionID: input.sessionID, + message: { + id: input.messageID, + sessionID: input.sessionID, + role: "user", + time: { created: Date.now() }, + agent: input.agent, + model: { ...input.model, variant: input.variant }, + }, + parts: input.parts, + }) + }, + async sync(sessionID: string, options?: { force?: boolean }) { + await serverSync.session.sync(sessionID, options) + index(sessionID) + }, + todo: serverSync.session.todo, + history: serverSync.session.history, + evict(sessionID: string) { + serverSync.session.evict(sessionID) + }, + fetch: async (count = 10) => { + const [store, setStore] = current() + setStore("limit", (value) => value + count) + const response = await serverSDK.api.session.list({ directory, limit: store.limit, order: "desc" }) + const sessions = response.data + .map(normalizeSessionInfo) + .sort((a, b) => cmp(a.id, b.id)) + .slice(0, store.limit) + sessions.forEach(serverSync.session.remember) + setStore("session", reconcile(sessions, { key: "id" })) + }, + more: createMemo(() => current()[0].session.length >= current()[0].limit), + archive: async (sessionID: string) => { + if ((await serverSDK.protocol) !== "v1") return + await serverSDK.client.session.update({ sessionID, directory, time: { archived: Date.now() } }) + current()[1]( + "session", + produce((draft) => { + const match = Binary.search(draft, sessionID, (session) => session.id) + if (match.found) draft.splice(match.index, 1) + }), + ) + }, + }, + mcp: { + toggle: (name: string) => serverSync.mcp.toggle(directory, name), + }, + absolute, + get directory() { + return current()[0].path.directory + }, + } +} diff --git a/packages/app/src/context/file-content-eviction-accounting.test.ts b/packages/app/src/context/file-content-eviction-accounting.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..4ef5f947c7d236231bba02b8e1c8a2d514d87147 --- /dev/null +++ b/packages/app/src/context/file-content-eviction-accounting.test.ts @@ -0,0 +1,65 @@ +import { afterEach, describe, expect, test } from "bun:test" +import { + evictContentLru, + getFileContentBytesTotal, + getFileContentEntryCount, + removeFileContentBytes, + resetFileContentLru, + setFileContentBytes, + touchFileContent, +} from "./file/content-cache" + +describe("file content eviction accounting", () => { + afterEach(() => { + resetFileContentLru() + }) + + test("updates byte totals incrementally for set, overwrite, remove, and reset", () => { + setFileContentBytes("a", 10) + setFileContentBytes("b", 15) + expect(getFileContentBytesTotal()).toBe(25) + expect(getFileContentEntryCount()).toBe(2) + + setFileContentBytes("a", 5) + expect(getFileContentBytesTotal()).toBe(20) + expect(getFileContentEntryCount()).toBe(2) + + touchFileContent("a") + expect(getFileContentBytesTotal()).toBe(20) + + removeFileContentBytes("b") + expect(getFileContentBytesTotal()).toBe(5) + expect(getFileContentEntryCount()).toBe(1) + + resetFileContentLru() + expect(getFileContentBytesTotal()).toBe(0) + expect(getFileContentEntryCount()).toBe(0) + }) + + test("evicts by entry cap using LRU order", () => { + for (const i of Array.from({ length: 41 }, (_, n) => n)) { + setFileContentBytes(`f-${i}`, 1) + } + + const evicted: string[] = [] + evictContentLru(undefined, (path) => evicted.push(path)) + + expect(evicted).toEqual(["f-0"]) + expect(getFileContentEntryCount()).toBe(40) + expect(getFileContentBytesTotal()).toBe(40) + }) + + test("evicts by byte cap while preserving protected entries", () => { + const chunk = 8 * 1024 * 1024 + setFileContentBytes("a", chunk) + setFileContentBytes("b", chunk) + setFileContentBytes("c", chunk) + + const evicted: string[] = [] + evictContentLru(new Set(["a"]), (path) => evicted.push(path)) + + expect(evicted).toEqual(["b"]) + expect(getFileContentEntryCount()).toBe(2) + expect(getFileContentBytesTotal()).toBe(chunk * 2) + }) +}) diff --git a/packages/app/src/context/file.tsx b/packages/app/src/context/file.tsx new file mode 100644 index 0000000000000000000000000000000000000000..fbbef3a2a8e7af14bbc6af8e48286d95ac687d17 --- /dev/null +++ b/packages/app/src/context/file.tsx @@ -0,0 +1,303 @@ +import { batch, createEffect, createMemo, onCleanup } from "solid-js" +import { createStore, produce, reconcile } from "solid-js/store" +import { createSimpleContext } from "@opencode-ai/ui/context" +import { showToast } from "@/utils/toast" +import { useParams } from "@solidjs/router" +import { base64Encode } from "@opencode-ai/core/util/encode" +import { getFilename } from "@opencode-ai/core/util/path" +import { useSDK } from "./sdk" +import { useSync } from "./sync" +import { useLanguage } from "@/context/language" +import { useLayout } from "@/context/layout" +import { createPathHelpers } from "./file/path" +import { + approxBytes, + evictContentLru, + getFileContentBytesTotal, + getFileContentEntryCount, + hasFileContent, + removeFileContentBytes, + resetFileContentLru, + setFileContentBytes, + touchFileContent, +} from "./file/content-cache" +import { createFileViewCache } from "./file/view-cache" +import { useServerSDK } from "./server-sdk" +import { SessionRouteKey, SessionStateKey } from "@/utils/server-scope" +import { createFileTreeStore } from "./file/tree-store" +import { invalidateFromWatcher } from "./file/watcher" +import { + selectionFromLines, + type FileState, + type FileSelection, + type FileViewState, + type SelectedLineRange, +} from "./file/types" + +export type { FileSelection, SelectedLineRange, FileViewState, FileState } +export { selectionFromLines } +export { + evictContentLru, + getFileContentBytesTotal, + getFileContentEntryCount, + removeFileContentBytes, + resetFileContentLru, + setFileContentBytes, + touchFileContent, +} + +function errorMessage(error: unknown, fallback: string) { + if (error instanceof Error && error.message) return error.message + if (typeof error === "string" && error) return error + return fallback +} + +export const { use: useFile, provider: FileProvider } = createSimpleContext({ + name: "File", + gate: false, + init: () => { + const sdk = useSDK() + useSync() + const params = useParams() + const serverSDK = useServerSDK() + const language = useLanguage() + const layout = useLayout() + + const scope = createMemo(() => sdk().directory) + const path = createPathHelpers(scope) + const tabs = layout.tabs(() => + SessionStateKey.from(serverSDK().scope, SessionRouteKey.fromRoute(base64Encode(sdk().directory), params.id)), + ) + + const inflight = new Map>() + const [store, setStore] = createStore<{ + file: Record + }>({ + file: {}, + }) + + const tree = createFileTreeStore({ + scope, + normalizeDir: path.normalizeDir, + list: (dir) => + sdk() + .client.file.list({ path: dir }) + .then((x) => x.data ?? []), + onError: (message) => { + showToast({ + variant: "error", + title: language.t("toast.file.listFailed.title"), + description: message, + }) + }, + }) + + const evictContent = (keep?: Set) => { + evictContentLru(keep, (target) => { + if (!store.file[target]) return + setStore( + "file", + target, + produce((draft) => { + draft.content = undefined + draft.loaded = false + }), + ) + }) + } + + createEffect(() => { + scope() + inflight.clear() + resetFileContentLru() + batch(() => { + setStore("file", reconcile({})) + tree.reset() + }) + }) + + const viewCache = createFileViewCache(serverSDK().scope) + const view = createMemo(() => viewCache.load(scope(), params.id)) + + const ensure = (file: string) => { + if (!file) return + if (store.file[file]) return + setStore("file", file, { path: file, name: getFilename(file) }) + } + + const setLoading = (file: string) => { + setStore( + "file", + file, + produce((draft) => { + draft.loading = true + draft.error = undefined + }), + ) + } + + const setLoaded = (file: string, content: FileState["content"]) => { + setStore( + "file", + file, + produce((draft) => { + draft.loaded = true + draft.loading = false + draft.content = content + }), + ) + } + + const setLoadError = (file: string, message: string) => { + setStore( + "file", + file, + produce((draft) => { + draft.loading = false + draft.error = message + }), + ) + showToast({ + variant: "error", + title: language.t("toast.file.loadFailed.title"), + description: message, + }) + } + + const load = (input: string, options?: { force?: boolean }) => { + const file = path.normalize(input) + if (!file) return Promise.resolve() + + const directory = scope() + const key = `${directory}\n${file}` + ensure(file) + + const current = store.file[file] + if (!options?.force && current?.loaded) return Promise.resolve() + + const pending = inflight.get(key) + if (pending) return pending + + setLoading(file) + + const promise = sdk() + .client.file.read({ path: file }) + .then((x) => { + if (scope() !== directory) return + const content = x.data + setLoaded(file, content) + + if (!content) return + touchFileContent(file, approxBytes(content)) + evictContent(new Set([file])) + }) + .catch((e) => { + if (scope() !== directory) return + setLoadError(file, errorMessage(e, language.t("error.chain.unknown"))) + }) + .finally(() => { + inflight.delete(key) + }) + + inflight.set(key, promise) + return promise + } + + const search = (query: string, dirs: "true" | "false", options?: { limit?: number; signal?: AbortSignal }) => + serverSDK() + .api.file.find( + { + location: { directory: sdk().directory }, + query, + type: dirs === "true" ? "directory" : "file", + limit: options?.limit, + }, + { signal: options?.signal }, + ) + .then( + (x) => x.data.map((entry) => path.normalize(entry.path)), + (error) => { + if (options?.signal?.aborted) throw error + return [] + }, + ) + + const stop = sdk().event.listen((e) => { + invalidateFromWatcher(e.details, { + normalize: path.normalize, + hasFile: (file) => Boolean(store.file[file]), + isOpen: (file) => tabs.all().some((tab) => path.pathFromTab(tab) === file), + loadFile: (file) => { + void load(file, { force: true }) + }, + node: tree.node, + isDirLoaded: tree.isLoaded, + refreshDir: (dir) => { + void tree.listDir(dir, { force: true }) + }, + }) + }) + + const get = (input: string) => { + const file = path.normalize(input) + const state = store.file[file] + const content = state?.content + if (!content) return state + if (hasFileContent(file)) { + touchFileContent(file) + return state + } + touchFileContent(file, approxBytes(content)) + return state + } + + function withPath(input: string, action: (file: string) => unknown) { + return action(path.normalize(input)) + } + const scrollTop = (input: string) => withPath(input, (file) => view().scrollTop(file)) + const scrollLeft = (input: string) => withPath(input, (file) => view().scrollLeft(file)) + const selectedLines = (input: string) => withPath(input, (file) => view().selectedLines(file)) + const setScrollTop = (input: string, top: number) => withPath(input, (file) => view().setScrollTop(file, top)) + const setScrollLeft = (input: string, left: number) => withPath(input, (file) => view().setScrollLeft(file, left)) + const setSelectedLines = (input: string, range: SelectedLineRange | null) => + withPath(input, (file) => view().setSelectedLines(file, range)) + + onCleanup(() => { + stop() + viewCache.clear() + }) + + return { + ready: () => view().ready(), + normalize: path.normalize, + tab: path.tab, + pathFromTab: path.pathFromTab, + tree: { + list: tree.listDir, + refresh: (input: string) => tree.listDir(input, { force: true }), + state: tree.dirState, + children: tree.children, + expand: tree.expandDir, + collapse: tree.collapseDir, + toggle(input: string) { + if (tree.dirState(input)?.expanded) { + tree.collapseDir(input) + return + } + tree.expandDir(input) + }, + }, + get, + load, + scrollTop, + scrollLeft, + setScrollTop, + setScrollLeft, + selectedLines, + setSelectedLines, + searchFiles: (query: string, options?: { limit?: number; signal?: AbortSignal }) => + search(query, "false", options), + searchFilesAndDirectories: (query: string) => search(query, "true"), + } + }, +}) diff --git a/packages/app/src/context/file/content-cache.ts b/packages/app/src/context/file/content-cache.ts new file mode 100644 index 0000000000000000000000000000000000000000..4b724068834b40c3f00e01102079ff240288dfd2 --- /dev/null +++ b/packages/app/src/context/file/content-cache.ts @@ -0,0 +1,88 @@ +import type { FileContent } from "@opencode-ai/sdk/v2" + +const MAX_FILE_CONTENT_ENTRIES = 40 +const MAX_FILE_CONTENT_BYTES = 20 * 1024 * 1024 + +const lru = new Map() +let total = 0 + +export function approxBytes(content: FileContent) { + const patchBytes = + content.patch?.hunks.reduce((sum, hunk) => { + return sum + hunk.lines.reduce((lineSum, line) => lineSum + line.length, 0) + }, 0) ?? 0 + + return (content.content.length + (content.diff?.length ?? 0) + patchBytes) * 2 +} + +function setBytes(path: string, nextBytes: number) { + const prev = lru.get(path) + if (prev !== undefined) total -= prev + lru.delete(path) + lru.set(path, nextBytes) + total += nextBytes +} + +function touch(path: string, bytes?: number) { + const prev = lru.get(path) + if (prev === undefined && bytes === undefined) return + setBytes(path, bytes ?? prev ?? 0) +} + +function remove(path: string) { + const prev = lru.get(path) + if (prev === undefined) return + lru.delete(path) + total -= prev +} + +function reset() { + lru.clear() + total = 0 +} + +export function evictContentLru(keep: Set | undefined, evict: (path: string) => void) { + const set = keep ?? new Set() + + while (lru.size > MAX_FILE_CONTENT_ENTRIES || total > MAX_FILE_CONTENT_BYTES) { + const path = lru.keys().next().value + if (!path) return + + if (set.has(path)) { + touch(path) + if (lru.size <= set.size) return + continue + } + + remove(path) + evict(path) + } +} + +export function resetFileContentLru() { + reset() +} + +export function setFileContentBytes(path: string, bytes: number) { + setBytes(path, bytes) +} + +export function removeFileContentBytes(path: string) { + remove(path) +} + +export function touchFileContent(path: string, bytes?: number) { + touch(path, bytes) +} + +export function getFileContentBytesTotal() { + return total +} + +export function getFileContentEntryCount() { + return lru.size +} + +export function hasFileContent(path: string) { + return lru.has(path) +} diff --git a/packages/app/src/context/file/path.test.ts b/packages/app/src/context/file/path.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..99dd88ae0e9c4792930e1a435318719f52f91eb3 --- /dev/null +++ b/packages/app/src/context/file/path.test.ts @@ -0,0 +1,383 @@ +import { describe, expect, test } from "bun:test" +import { createPathHelpers, stripQueryAndHash, unquoteGitPath, encodeFilePath } from "./path" + +describe("file path helpers", () => { + test("normalizes file inputs against workspace root", () => { + const path = createPathHelpers(() => "/repo") + expect(path.normalize("file:///repo/src/app.ts?x=1#h")).toBe("src/app.ts") + expect(path.normalize("/repo/src/app.ts")).toBe("src/app.ts") + expect(path.normalize("./src/app.ts")).toBe("src/app.ts") + expect(path.normalizeDir("src/components///")).toBe("src/components") + expect(path.tab("src/app.ts")).toBe("file://src/app.ts") + expect(path.pathFromTab("file://src/app.ts")).toBe("src/app.ts") + expect(path.pathFromTab("other://src/app.ts")).toBeUndefined() + }) + + test("normalizes Windows absolute paths with mixed separators", () => { + const path = createPathHelpers(() => "C:\\repo") + expect(path.normalize("C:\\repo\\src\\app.ts")).toBe("src\\app.ts") + expect(path.normalize("C:/repo/src/app.ts")).toBe("src/app.ts") + expect(path.normalize("file://C:/repo/src/app.ts")).toBe("src/app.ts") + expect(path.normalize("c:\\repo\\src\\app.ts")).toBe("src\\app.ts") + }) + + test("normalizes Windows directory separators", () => { + const path = createPathHelpers(() => "C:\\repo") + expect(path.normalizeDir("frontend\\")).toBe("frontend") + expect(path.normalizeDir("frontend\\src\\")).toBe("frontend/src") + expect(path.normalizeDir("C:\\repo\\frontend\\")).toBe("frontend") + }) + + test("normalizes separators for Windows roots written with forward slashes", () => { + const path = createPathHelpers(() => "C:/repo") + expect(path.normalizeDir("frontend\\src\\")).toBe("frontend/src") + }) + + test("normalizes separators for Windows UNC roots", () => { + const path = createPathHelpers(() => "\\\\server\\share") + expect(path.normalizeDir("\\\\server\\share\\frontend\\")).toBe("frontend") + }) + + test("preserves backslashes in POSIX directory names", () => { + const path = createPathHelpers(() => "/repo") + expect(path.normalizeDir("literal\\name\\")).toBe("literal\\name\\") + expect(path.normalizeDir("literal\\name/")).toBe("literal\\name") + }) + + test("keeps query/hash stripping behavior stable", () => { + expect(stripQueryAndHash("a/b.ts#L12?x=1")).toBe("a/b.ts") + expect(stripQueryAndHash("a/b.ts?x=1#L12")).toBe("a/b.ts") + expect(stripQueryAndHash("a/b.ts")).toBe("a/b.ts") + }) + + test("unquotes git escaped octal path strings", () => { + expect(unquoteGitPath('"a/\\303\\251.txt"')).toBe("a/\u00e9.txt") + expect(unquoteGitPath('"plain\\nname"')).toBe("plain\nname") + expect(unquoteGitPath("a/b/c.ts")).toBe("a/b/c.ts") + }) +}) + +describe("encodeFilePath", () => { + describe("Linux/Unix paths", () => { + test("should handle Linux absolute path", () => { + const linuxPath = "/home/user/project/README.md" + const result = encodeFilePath(linuxPath) + const fileUrl = `file://${result}` + + // Should create a valid URL + expect(() => new URL(fileUrl)).not.toThrow() + expect(result).toBe("/home/user/project/README.md") + + const url = new URL(fileUrl) + expect(url.protocol).toBe("file:") + expect(url.pathname).toBe("/home/user/project/README.md") + }) + + test("should handle Linux path with special characters", () => { + const linuxPath = "/home/user/file#name with spaces.txt" + const result = encodeFilePath(linuxPath) + const fileUrl = `file://${result}` + + expect(() => new URL(fileUrl)).not.toThrow() + expect(result).toBe("/home/user/file%23name%20with%20spaces.txt") + }) + + test("should handle Linux relative path", () => { + const relativePath = "src/components/App.tsx" + const result = encodeFilePath(relativePath) + + expect(result).toBe("src/components/App.tsx") + }) + + test("should handle Linux root directory", () => { + const result = encodeFilePath("/") + expect(result).toBe("/") + }) + + test("should handle Linux path with all special chars", () => { + const path = "/path/to/file#with?special%chars&more.txt" + const result = encodeFilePath(path) + const fileUrl = `file://${result}` + + expect(() => new URL(fileUrl)).not.toThrow() + expect(result).toContain("%23") // # + expect(result).toContain("%3F") // ? + expect(result).toContain("%25") // % + expect(result).toContain("%26") // & + }) + }) + + describe("macOS paths", () => { + test("should handle macOS absolute path", () => { + const macPath = "/Users/kelvin/Projects/opencode/README.md" + const result = encodeFilePath(macPath) + const fileUrl = `file://${result}` + + expect(() => new URL(fileUrl)).not.toThrow() + expect(result).toBe("/Users/kelvin/Projects/opencode/README.md") + }) + + test("should handle macOS path with spaces", () => { + const macPath = "/Users/kelvin/My Documents/file.txt" + const result = encodeFilePath(macPath) + const fileUrl = `file://${result}` + + expect(() => new URL(fileUrl)).not.toThrow() + expect(result).toContain("My%20Documents") + }) + }) + + describe("Windows paths", () => { + test("should handle Windows absolute path with backslashes", () => { + const windowsPath = "D:\\dev\\projects\\opencode\\README.bs.md" + const result = encodeFilePath(windowsPath) + const fileUrl = `file://${result}` + + // Should create a valid, parseable URL + expect(() => new URL(fileUrl)).not.toThrow() + + const url = new URL(fileUrl) + expect(url.protocol).toBe("file:") + expect(url.pathname).toContain("README.bs.md") + expect(result).toBe("/D:/dev/projects/opencode/README.bs.md") + }) + + test("should handle mixed separator path (Windows + Unix)", () => { + // This is what happens in build-request-parts.ts when concatenating paths + const mixedPath = "D:\\dev\\projects\\opencode/README.bs.md" + const result = encodeFilePath(mixedPath) + const fileUrl = `file://${result}` + + expect(() => new URL(fileUrl)).not.toThrow() + expect(result).toBe("/D:/dev/projects/opencode/README.bs.md") + }) + + test("should handle Windows path with spaces", () => { + const windowsPath = "C:\\Program Files\\MyApp\\file with spaces.txt" + const result = encodeFilePath(windowsPath) + const fileUrl = `file://${result}` + + expect(() => new URL(fileUrl)).not.toThrow() + expect(result).toContain("Program%20Files") + expect(result).toContain("file%20with%20spaces.txt") + }) + + test("should handle Windows path with special chars in filename", () => { + const windowsPath = "D:\\projects\\file#name with ?marks.txt" + const result = encodeFilePath(windowsPath) + const fileUrl = `file://${result}` + + expect(() => new URL(fileUrl)).not.toThrow() + expect(result).toContain("file%23name%20with%20%3Fmarks.txt") + }) + + test("should handle Windows root directory", () => { + const windowsPath = "C:\\" + const result = encodeFilePath(windowsPath) + const fileUrl = `file://${result}` + + expect(() => new URL(fileUrl)).not.toThrow() + expect(result).toBe("/C:/") + }) + + test("should handle Windows relative path with backslashes", () => { + const windowsPath = "src\\components\\App.tsx" + const result = encodeFilePath(windowsPath) + + // Relative paths shouldn't get the leading slash + expect(result).toBe("src/components/App.tsx") + }) + + test("should NOT create invalid URL like the bug report", () => { + // This is the exact scenario from bug report by @alexyaroshuk + const windowsPath = "D:\\dev\\projects\\opencode\\README.bs.md" + const result = encodeFilePath(windowsPath) + const fileUrl = `file://${result}` + + // The bug was creating: file://D%3A%5Cdev%5Cprojects%5Copencode/README.bs.md + expect(result).not.toContain("%5C") // Should not have encoded backslashes + expect(result).not.toBe("D%3A%5Cdev%5Cprojects%5Copencode/README.bs.md") + + // Should be valid + expect(() => new URL(fileUrl)).not.toThrow() + }) + + test("should handle lowercase drive letters", () => { + const windowsPath = "c:\\users\\test\\file.txt" + const result = encodeFilePath(windowsPath) + const fileUrl = `file://${result}` + + expect(() => new URL(fileUrl)).not.toThrow() + expect(result).toBe("/c:/users/test/file.txt") + }) + }) + + describe("Cross-platform compatibility", () => { + test("should preserve Unix paths unchanged (except encoding)", () => { + const unixPath = "/usr/local/bin/app" + const result = encodeFilePath(unixPath) + expect(result).toBe("/usr/local/bin/app") + }) + + test("should normalize Windows paths for cross-platform use", () => { + const windowsPath = "C:\\Users\\test\\file.txt" + const result = encodeFilePath(windowsPath) + // Should convert to forward slashes and add leading / + expect(result).not.toContain("\\") + expect(result).toMatch(/^\/[A-Za-z]:\//) + }) + + test("should handle relative paths the same on all platforms", () => { + const unixRelative = "src/app.ts" + const windowsRelative = "src\\app.ts" + + const unixResult = encodeFilePath(unixRelative) + const windowsResult = encodeFilePath(windowsRelative) + + // Both should normalize to forward slashes + expect(unixResult).toBe("src/app.ts") + expect(windowsResult).toBe("src/app.ts") + }) + }) + + describe("Edge cases", () => { + test("should handle empty path", () => { + const result = encodeFilePath("") + expect(result).toBe("") + }) + + test("should handle path with multiple consecutive slashes", () => { + const result = encodeFilePath("//path//to///file.txt") + // Multiple slashes should be preserved (backend handles normalization) + expect(result).toBe("//path//to///file.txt") + }) + + test("should encode Unicode characters", () => { + const unicodePath = "/home/user/文档/README.md" + const result = encodeFilePath(unicodePath) + const fileUrl = `file://${result}` + + expect(() => new URL(fileUrl)).not.toThrow() + // Unicode should be encoded + expect(result).toContain("%E6%96%87%E6%A1%A3") + }) + + test("should handle already normalized Windows path", () => { + // Path that's already been normalized (has / before drive letter) + const alreadyNormalized = "/D:/path/file.txt" + const result = encodeFilePath(alreadyNormalized) + + // Should not add another leading slash + expect(result).toBe("/D:/path/file.txt") + expect(result).not.toContain("//D") + }) + + test("should handle just drive letter", () => { + const justDrive = "D:" + const result = encodeFilePath(justDrive) + const fileUrl = `file://${result}` + + expect(result).toBe("/D:") + expect(() => new URL(fileUrl)).not.toThrow() + }) + + test("should handle Windows path with trailing backslash", () => { + const trailingBackslash = "C:\\Users\\test\\" + const result = encodeFilePath(trailingBackslash) + const fileUrl = `file://${result}` + + expect(() => new URL(fileUrl)).not.toThrow() + expect(result).toBe("/C:/Users/test/") + }) + + test("should handle very long paths", () => { + const longPath = "C:\\Users\\test\\" + "verylongdirectoryname\\".repeat(20) + "file.txt" + const result = encodeFilePath(longPath) + const fileUrl = `file://${result}` + + expect(() => new URL(fileUrl)).not.toThrow() + expect(result).not.toContain("\\") + }) + + test("should handle paths with dots", () => { + const pathWithDots = "C:\\Users\\..\\test\\.\\file.txt" + const result = encodeFilePath(pathWithDots) + const fileUrl = `file://${result}` + + expect(() => new URL(fileUrl)).not.toThrow() + // Dots should be preserved (backend normalizes) + expect(result).toContain("..") + expect(result).toContain("/./") + }) + }) + + describe("Regression tests for PR #12424", () => { + test("should handle file with # in name", () => { + const path = "/path/to/file#name.txt" + const result = encodeFilePath(path) + const fileUrl = `file://${result}` + + expect(() => new URL(fileUrl)).not.toThrow() + expect(result).toBe("/path/to/file%23name.txt") + }) + + test("should handle file with ? in name", () => { + const path = "/path/to/file?name.txt" + const result = encodeFilePath(path) + const fileUrl = `file://${result}` + + expect(() => new URL(fileUrl)).not.toThrow() + expect(result).toBe("/path/to/file%3Fname.txt") + }) + + test("should handle file with % in name", () => { + const path = "/path/to/file%name.txt" + const result = encodeFilePath(path) + const fileUrl = `file://${result}` + + expect(() => new URL(fileUrl)).not.toThrow() + expect(result).toBe("/path/to/file%25name.txt") + }) + }) + + describe("Integration with file:// URL construction", () => { + test("should work with query parameters (Linux)", () => { + const path = "/home/user/file.txt" + const encoded = encodeFilePath(path) + const fileUrl = `file://${encoded}?start=10&end=20` + + const url = new URL(fileUrl) + expect(url.searchParams.get("start")).toBe("10") + expect(url.searchParams.get("end")).toBe("20") + expect(url.pathname).toBe("/home/user/file.txt") + }) + + test("should work with query parameters (Windows)", () => { + const path = "C:\\Users\\test\\file.txt" + const encoded = encodeFilePath(path) + const fileUrl = `file://${encoded}?start=10&end=20` + + const url = new URL(fileUrl) + expect(url.searchParams.get("start")).toBe("10") + expect(url.searchParams.get("end")).toBe("20") + }) + + test("should parse correctly in URL constructor (Linux)", () => { + const path = "/var/log/app.log" + const fileUrl = `file://${encodeFilePath(path)}` + const url = new URL(fileUrl) + + expect(url.protocol).toBe("file:") + expect(url.pathname).toBe("/var/log/app.log") + }) + + test("should parse correctly in URL constructor (Windows)", () => { + const path = "D:\\logs\\app.log" + const fileUrl = `file://${encodeFilePath(path)}` + const url = new URL(fileUrl) + + expect(url.protocol).toBe("file:") + expect(url.pathname).toContain("app.log") + }) + }) +}) diff --git a/packages/app/src/context/file/path.ts b/packages/app/src/context/file/path.ts new file mode 100644 index 0000000000000000000000000000000000000000..2bc4bde5e9b41a30b87fd3e041430d4b26c5a519 --- /dev/null +++ b/packages/app/src/context/file/path.ts @@ -0,0 +1,156 @@ +export function stripFileProtocol(input: string) { + if (!input.startsWith("file://")) return input + return input.slice("file://".length) +} + +export function stripQueryAndHash(input: string) { + const hashIndex = input.indexOf("#") + const queryIndex = input.indexOf("?") + + if (hashIndex !== -1 && queryIndex !== -1) { + return input.slice(0, Math.min(hashIndex, queryIndex)) + } + + if (hashIndex !== -1) return input.slice(0, hashIndex) + if (queryIndex !== -1) return input.slice(0, queryIndex) + return input +} + +export function unquoteGitPath(input: string) { + if (!input.startsWith('"')) return input + if (!input.endsWith('"')) return input + const body = input.slice(1, -1) + const bytes: number[] = [] + + for (let i = 0; i < body.length; i++) { + const char = body[i]! + if (char !== "\\") { + bytes.push(char.charCodeAt(0)) + continue + } + + const next = body[i + 1] + if (!next) { + bytes.push("\\".charCodeAt(0)) + continue + } + + if (next >= "0" && next <= "7") { + const chunk = body.slice(i + 1, i + 4) + const match = chunk.match(/^[0-7]{1,3}/) + if (!match) { + bytes.push(next.charCodeAt(0)) + i++ + continue + } + bytes.push(parseInt(match[0], 8)) + i += match[0].length + continue + } + + const escaped = + next === "n" + ? "\n" + : next === "r" + ? "\r" + : next === "t" + ? "\t" + : next === "b" + ? "\b" + : next === "f" + ? "\f" + : next === "v" + ? "\v" + : next === "\\" || next === '"' + ? next + : undefined + + bytes.push((escaped ?? next).charCodeAt(0)) + i++ + } + + return new TextDecoder().decode(new Uint8Array(bytes)) +} + +export function decodeFilePath(input: string) { + try { + return decodeURIComponent(input) + } catch { + return input + } +} + +export function encodeFilePath(filepath: string): string { + // Normalize Windows paths: convert backslashes to forward slashes + let normalized = filepath.replace(/\\/g, "/") + + // Handle Windows absolute paths (D:/path -> /D:/path for proper file:// URLs) + if (/^[A-Za-z]:/.test(normalized)) { + normalized = "/" + normalized + } + + // Encode each path segment (preserving forward slashes as path separators) + // Keep the colon in Windows drive letters (`/C:/...`) so downstream file URL parsers + // can reliably detect drives. + return normalized + .split("/") + .map((segment, index) => { + if (index === 1 && /^[A-Za-z]:$/.test(segment)) return segment + return encodeURIComponent(segment) + }) + .join("/") +} + +export function createPathHelpers(scope: () => string) { + const normalize = (input: string) => { + const root = scope() + + let path = unquoteGitPath(decodeFilePath(stripQueryAndHash(stripFileProtocol(input)))) + + // Separator-agnostic prefix stripping for Cygwin/native Windows compatibility + // Only case-insensitive on Windows (drive letter or UNC paths) + const windows = /^[A-Za-z]:/.test(root) || root.startsWith("\\\\") + const canonRoot = windows ? root.replace(/\\/g, "/").toLowerCase() : root.replace(/\\/g, "/") + const canonPath = windows ? path.replace(/\\/g, "/").toLowerCase() : path.replace(/\\/g, "/") + if ( + canonPath.startsWith(canonRoot) && + (canonRoot.endsWith("/") || canonPath === canonRoot || canonPath[canonRoot.length] === "/") + ) { + // Slice from original path to preserve native separators + path = path.slice(root.length) + } + + if (path.startsWith("./") || path.startsWith(".\\")) { + path = path.slice(2) + } + + if (path.startsWith("/") || path.startsWith("\\")) { + path = path.slice(1) + } + return path + } + + const tab = (input: string) => { + const path = normalize(input) + return `file://${encodeFilePath(path)}` + } + + const pathFromTab = (tabValue: string) => { + if (!tabValue.startsWith("file://")) return + return normalize(tabValue) + } + + const normalizeDir = (input: string) => { + const path = normalize(input) + const root = scope() + const windows = /^[A-Za-z]:/.test(root) || root.startsWith("\\\\") + return (windows ? path.replace(/\\/g, "/") : path).replace(/\/+$/, "") + } + + return { + normalize, + tab, + pathFromTab, + normalizeDir, + } +} diff --git a/packages/app/src/context/file/tree-store.ts b/packages/app/src/context/file/tree-store.ts new file mode 100644 index 0000000000000000000000000000000000000000..5769c318f07e9f848a904b2e6e25433fa3e35eda --- /dev/null +++ b/packages/app/src/context/file/tree-store.ts @@ -0,0 +1,174 @@ +import { createStore, produce, reconcile } from "solid-js/store" +import type { FileNode } from "@opencode-ai/sdk/v2" + +type DirectoryState = { + expanded: boolean + loaded?: boolean + loading?: boolean + error?: string + children?: string[] +} + +type TreeStoreOptions = { + scope: () => string + normalizeDir: (input: string) => string + list: (input: string) => Promise + onError: (message: string) => void +} + +export function createFileTreeStore(options: TreeStoreOptions) { + const [tree, setTree] = createStore<{ + node: Record + dir: Record + }>({ + node: {}, + dir: { "": { expanded: true } }, + }) + + const inflight = new Map>() + + const reset = () => { + inflight.clear() + setTree("node", reconcile({})) + setTree("dir", reconcile({})) + setTree("dir", "", { expanded: true }) + } + + const ensureDir = (path: string) => { + if (tree.dir[path]) return + setTree("dir", path, { expanded: false }) + } + + const listDir = (input: string, opts?: { force?: boolean }) => { + const dir = options.normalizeDir(input) + ensureDir(dir) + + const current = tree.dir[dir] + if (!opts?.force && current?.loaded) return Promise.resolve() + + const pending = inflight.get(dir) + if (pending) return pending + + setTree( + "dir", + dir, + produce((draft) => { + draft.loading = true + draft.error = undefined + }), + ) + + const directory = options.scope() + + const promise = options + .list(dir) + .then((nodes) => { + if (options.scope() !== directory) return + const prevChildren = tree.dir[dir]?.children ?? [] + const nextChildren = nodes.map((node) => node.path) + const nextSet = new Set(nextChildren) + + setTree( + "node", + produce((draft) => { + const removedDirs: string[] = [] + + for (const child of prevChildren) { + if (nextSet.has(child)) continue + const existing = draft[child] + if (existing?.type === "directory") removedDirs.push(child) + delete draft[child] + } + + if (removedDirs.length > 0) { + const keys = Object.keys(draft) + for (const key of keys) { + for (const removed of removedDirs) { + if (!key.startsWith(removed + "/")) continue + delete draft[key] + break + } + } + } + + for (const node of nodes) { + draft[node.path] = node + } + }), + ) + + setTree( + "dir", + dir, + produce((draft) => { + draft.loaded = true + draft.loading = false + draft.children = nextChildren + }), + ) + }) + .catch((e) => { + if (options.scope() !== directory) return + setTree( + "dir", + dir, + produce((draft) => { + draft.loading = false + draft.error = e.message + }), + ) + options.onError(e.message) + }) + .finally(() => { + inflight.delete(dir) + }) + + inflight.set(dir, promise) + return promise + } + + // `list: false` marks a directory expanded without fetching its children, for + // trees whose nodes are synthesized from a filter; listing directories that + // only exist on a diff's base branch fails and surfaces error toasts. + const expandDir = (input: string, behavior?: { list?: boolean }) => { + const dir = options.normalizeDir(input) + ensureDir(dir) + setTree("dir", dir, "expanded", true) + if (behavior?.list === false) return + void listDir(dir) + } + + const collapseDir = (input: string) => { + const dir = options.normalizeDir(input) + ensureDir(dir) + setTree("dir", dir, "expanded", false) + } + + const dirState = (input: string) => { + const dir = options.normalizeDir(input) + return tree.dir[dir] + } + + const children = (input: string) => { + const dir = options.normalizeDir(input) + const ids = tree.dir[dir]?.children + if (!ids) return [] + const out: FileNode[] = [] + for (const id of ids) { + const node = tree.node[id] + if (node) out.push(node) + } + return out + } + + return { + listDir, + expandDir, + collapseDir, + dirState, + children, + node: (path: string) => tree.node[path], + isLoaded: (path: string) => Boolean(tree.dir[path]?.loaded), + reset, + } +} diff --git a/packages/app/src/context/file/types.ts b/packages/app/src/context/file/types.ts new file mode 100644 index 0000000000000000000000000000000000000000..7ce8a37c25e68b8d6377d169e0459c93e5f3eac7 --- /dev/null +++ b/packages/app/src/context/file/types.ts @@ -0,0 +1,41 @@ +import type { FileContent } from "@opencode-ai/sdk/v2" + +export type FileSelection = { + startLine: number + startChar: number + endLine: number + endChar: number +} + +export type SelectedLineRange = { + start: number + end: number + side?: "additions" | "deletions" + endSide?: "additions" | "deletions" +} + +export type FileViewState = { + scrollTop?: number + scrollLeft?: number + selectedLines?: SelectedLineRange | null +} + +export type FileState = { + path: string + name: string + loaded?: boolean + loading?: boolean + error?: string + content?: FileContent +} + +export function selectionFromLines(range: SelectedLineRange): FileSelection { + const startLine = Math.min(range.start, range.end) + const endLine = Math.max(range.start, range.end) + return { + startLine, + endLine, + startChar: 0, + endChar: 0, + } +} diff --git a/packages/app/src/context/file/view-cache.ts b/packages/app/src/context/file/view-cache.ts new file mode 100644 index 0000000000000000000000000000000000000000..87b859dea82b537256d32bebc0bf70769f47f97b --- /dev/null +++ b/packages/app/src/context/file/view-cache.ts @@ -0,0 +1,147 @@ +import { createEffect, createRoot } from "solid-js" +import { createStore, produce } from "solid-js/store" +import { Persist, persisted } from "@/utils/persist" +import { createScopedCache } from "@/utils/scoped-cache" +import type { FileViewState, SelectedLineRange } from "./types" +import type { ServerScope } from "@/utils/server-scope" + +const WORKSPACE_KEY = "__workspace__" +const MAX_FILE_VIEW_SESSIONS = 20 +const MAX_VIEW_FILES = 500 + +function normalizeSelectedLines(range: SelectedLineRange): SelectedLineRange { + if (range.start <= range.end) return { ...range } + + const startSide = range.side + const endSide = range.endSide ?? startSide + + return { + ...range, + start: range.end, + end: range.start, + side: endSide, + endSide: startSide !== endSide ? startSide : undefined, + } +} + +function equalSelectedLines(a: SelectedLineRange | null | undefined, b: SelectedLineRange | null | undefined) { + if (!a && !b) return true + if (!a || !b) return false + const left = normalizeSelectedLines(a) + const right = normalizeSelectedLines(b) + return ( + left.start === right.start && left.end === right.end && left.side === right.side && left.endSide === right.endSide + ) +} + +function createViewSession(scope: ServerScope, dir: string, id: string | undefined) { + const legacyViewKey = `${dir}/file${id ? "/" + id : ""}.v1` + + const [view, setView, _, ready] = persisted( + Persist.serverScoped(scope, dir, id, "file-view", [legacyViewKey]), + createStore<{ + file: Record + }>({ + file: {}, + }), + ) + + const meta = { pruned: false } + + const pruneView = (keep?: string) => { + const keys = Object.keys(view.file) + if (keys.length <= MAX_VIEW_FILES) return + + const drop = keys.filter((key) => key !== keep).slice(0, keys.length - MAX_VIEW_FILES) + if (drop.length === 0) return + + setView( + produce((draft) => { + for (const key of drop) { + delete draft.file[key] + } + }), + ) + } + + createEffect(() => { + if (!ready()) return + if (meta.pruned) return + meta.pruned = true + pruneView() + }) + + const scrollTop = (path: string) => view.file[path]?.scrollTop + const scrollLeft = (path: string) => view.file[path]?.scrollLeft + const selectedLines = (path: string) => view.file[path]?.selectedLines + + const setScrollTop = (path: string, top: number) => { + setView( + produce((draft) => { + const file = draft.file[path] ?? (draft.file[path] = {}) + if (file.scrollTop === top) return + file.scrollTop = top + }), + ) + pruneView(path) + } + + const setScrollLeft = (path: string, left: number) => { + setView( + produce((draft) => { + const file = draft.file[path] ?? (draft.file[path] = {}) + if (file.scrollLeft === left) return + file.scrollLeft = left + }), + ) + pruneView(path) + } + + const setSelectedLines = (path: string, range: SelectedLineRange | null) => { + const next = range ? normalizeSelectedLines(range) : null + setView( + produce((draft) => { + const file = draft.file[path] ?? (draft.file[path] = {}) + if (equalSelectedLines(file.selectedLines, next)) return + file.selectedLines = next + }), + ) + pruneView(path) + } + + return { + ready, + scrollTop, + scrollLeft, + selectedLines, + setScrollTop, + setScrollLeft, + setSelectedLines, + } +} + +export function createFileViewCache(scope: ServerScope) { + const cache = createScopedCache( + (key) => { + const split = key.lastIndexOf("\n") + const dir = split >= 0 ? key.slice(0, split) : key + const id = split >= 0 ? key.slice(split + 1) : WORKSPACE_KEY + return createRoot((dispose) => ({ + value: createViewSession(scope, dir, id === WORKSPACE_KEY ? undefined : id), + dispose, + })) + }, + { + maxEntries: MAX_FILE_VIEW_SESSIONS, + dispose: (entry) => entry.dispose(), + }, + ) + + return { + load: (dir: string, id: string | undefined) => { + const key = `${dir}\n${id ?? WORKSPACE_KEY}` + return cache.get(key).value + }, + clear: () => cache.clear(), + } +} diff --git a/packages/app/src/context/file/watcher.test.ts b/packages/app/src/context/file/watcher.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..9536b52536b6036363b20d362d7da8dcf6bedbaa --- /dev/null +++ b/packages/app/src/context/file/watcher.test.ts @@ -0,0 +1,149 @@ +import { describe, expect, test } from "bun:test" +import { invalidateFromWatcher } from "./watcher" + +describe("file watcher invalidation", () => { + test("reloads open files and refreshes loaded parent on add", () => { + const loads: string[] = [] + const refresh: string[] = [] + invalidateFromWatcher( + { + type: "file.watcher.updated", + properties: { + file: "src/new.ts", + event: "add", + }, + }, + { + normalize: (input) => input, + hasFile: (path) => path === "src/new.ts", + loadFile: (path) => loads.push(path), + node: () => undefined, + isDirLoaded: (path) => path === "src", + refreshDir: (path) => refresh.push(path), + }, + ) + + expect(loads).toEqual(["src/new.ts"]) + expect(refresh).toEqual(["src"]) + }) + + test("reloads files that are open in tabs", () => { + const loads: string[] = [] + + invalidateFromWatcher( + { + type: "file.watcher.updated", + properties: { + file: "src/open.ts", + event: "change", + }, + }, + { + normalize: (input) => input, + hasFile: () => false, + isOpen: (path) => path === "src/open.ts", + loadFile: (path) => loads.push(path), + node: () => ({ + path: "src/open.ts", + type: "file", + name: "open.ts", + absolute: "/repo/src/open.ts", + ignored: false, + }), + isDirLoaded: () => false, + refreshDir: () => {}, + }, + ) + + expect(loads).toEqual(["src/open.ts"]) + }) + + test("refreshes only changed loaded directory nodes", () => { + const refresh: string[] = [] + + invalidateFromWatcher( + { + type: "file.watcher.updated", + properties: { + file: "src", + event: "change", + }, + }, + { + normalize: (input) => input, + hasFile: () => false, + loadFile: () => {}, + node: () => ({ path: "src", type: "directory", name: "src", absolute: "/repo/src", ignored: false }), + isDirLoaded: (path) => path === "src", + refreshDir: (path) => refresh.push(path), + }, + ) + + invalidateFromWatcher( + { + type: "file.watcher.updated", + properties: { + file: "src/file.ts", + event: "change", + }, + }, + { + normalize: (input) => input, + hasFile: () => false, + loadFile: () => {}, + node: () => ({ + path: "src/file.ts", + type: "file", + name: "file.ts", + absolute: "/repo/src/file.ts", + ignored: false, + }), + isDirLoaded: () => true, + refreshDir: (path) => refresh.push(path), + }, + ) + + expect(refresh).toEqual(["src"]) + }) + + test("ignores invalid or git watcher updates", () => { + const refresh: string[] = [] + + invalidateFromWatcher( + { + type: "file.watcher.updated", + properties: { + file: ".git/index.lock", + event: "change", + }, + }, + { + normalize: (input) => input, + hasFile: () => true, + loadFile: () => { + throw new Error("should not load") + }, + node: () => undefined, + isDirLoaded: () => true, + refreshDir: (path) => refresh.push(path), + }, + ) + + invalidateFromWatcher( + { + type: "project.updated", + properties: {}, + }, + { + normalize: (input) => input, + hasFile: () => false, + loadFile: () => {}, + node: () => undefined, + isDirLoaded: () => true, + refreshDir: (path) => refresh.push(path), + }, + ) + + expect(refresh).toEqual([]) + }) +}) diff --git a/packages/app/src/context/file/watcher.ts b/packages/app/src/context/file/watcher.ts new file mode 100644 index 0000000000000000000000000000000000000000..fbf71992791a5ac78cedeccac4328c1375b16509 --- /dev/null +++ b/packages/app/src/context/file/watcher.ts @@ -0,0 +1,53 @@ +import type { FileNode } from "@opencode-ai/sdk/v2" + +type WatcherEvent = { + type: string + properties: unknown +} + +type WatcherOps = { + normalize: (input: string) => string + hasFile: (path: string) => boolean + isOpen?: (path: string) => boolean + loadFile: (path: string) => void + node: (path: string) => FileNode | undefined + isDirLoaded: (path: string) => boolean + refreshDir: (path: string) => void +} + +export function invalidateFromWatcher(event: WatcherEvent, ops: WatcherOps) { + if (event.type !== "file.watcher.updated") return + const props = + typeof event.properties === "object" && event.properties ? (event.properties as Record) : undefined + const rawPath = typeof props?.file === "string" ? props.file : undefined + const kind = typeof props?.event === "string" ? props.event : undefined + if (!rawPath) return + if (!kind) return + + const path = ops.normalize(rawPath) + if (!path) return + if (path.startsWith(".git/")) return + + if (ops.hasFile(path) || ops.isOpen?.(path)) { + ops.loadFile(path) + } + + if (kind === "change") { + const dir = (() => { + if (path === "") return "" + const node = ops.node(path) + if (node?.type !== "directory") return + return path + })() + if (dir === undefined) return + if (!ops.isDirLoaded(dir)) return + ops.refreshDir(dir) + return + } + if (kind !== "add" && kind !== "unlink") return + + const parent = path.split("/").slice(0, -1).join("/") + if (!ops.isDirLoaded(parent)) return + + ops.refreshDir(parent) +} diff --git a/packages/app/src/context/global-sync/bootstrap.test.ts b/packages/app/src/context/global-sync/bootstrap.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..a95706a3dbe5136c312ff3ed451645752a81eb9f --- /dev/null +++ b/packages/app/src/context/global-sync/bootstrap.test.ts @@ -0,0 +1,330 @@ +import { describe, expect, test } from "bun:test" +import { createStore } from "solid-js/store" +import { QueryClient } from "@tanstack/solid-query" +import type { Config, OpencodeClient, Project } from "@opencode-ai/sdk/v2/client" +import type { AgentApi, CatalogApi, CommandApi, ReferenceApi } from "@opencode-ai/client/promise" +import type { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context" +import { + bootstrapDirectory, + loadAgentsQuery, + loadCommands, + loadGlobalConfigQuery, + loadPathQuery, + loadProjectsQuery, + loadProvidersQuery, + loadReferencesQuery, +} from "./bootstrap" +import type { State, VcsCache } from "./types" +import { ServerScope } from "@/utils/server-scope" +import type { ServerApi } from "@/utils/server" + +type ProjectApi = ServerApi["project"] + +const provider = { all: new Map(), connected: [], default: {} } satisfies NormalizedProviderListResponse +const api = { + agent: { list: async () => ({ location: {}, data: [] }) }, + provider: { list: async () => ({ location: {}, data: [] }) }, + model: { + list: async () => ({ location: {}, data: [] }), + default: async () => ({ location: {}, data: null }), + }, + permission: { request: { list: async () => ({ location: {}, data: [] }) } }, + project: { + list: async () => [], + current: async () => ({ id: "project", directory: "/project" }), + }, + question: { request: { list: async () => ({ location: {}, data: [] }) } }, + reference: { list: async () => ({ location: {}, data: [] }) }, + vcs: { get: async () => ({ location: {}, data: {} }) }, +} as unknown as ServerApi + +function directoryState() { + return createStore({ + status: "loading", + agent: [], + command: [], + reference: [], + project: "", + projectMeta: undefined, + icon: undefined, + provider_ready: true, + provider, + config: {}, + path: { state: "", config: "", worktree: "/project", directory: "/project", home: "/home" }, + session: [], + sessionTotal: 0, + session_status: {}, + session_working(id: string) { + return this.session_status[id]?.type !== "idle" + }, + session_diff: {}, + todo: {}, + permission: {}, + question: {}, + mcp_ready: true, + mcp: {}, + mcp_resource: {}, + lsp_ready: true, + lsp: [], + vcs: undefined, + limit: 5, + message: {}, + session_message: {}, + part: {}, + part_text_accum_delta: {}, + }) +} + +describe("bootstrapDirectory", () => { + test("uses legacy MCP endpoints while refreshing a v1 directory", async () => { + const legacyConfigReads: string[] = [] + const mcpReads: string[] = [] + const [store, setStore] = directoryState() + + await bootstrapDirectory({ + directory: "/project", + scope: ServerScope.local, + mcp: true, + global: { + config: {} satisfies Config, + path: { state: "", config: "", worktree: "/project", directory: "/project", home: "/home" }, + project: [{ id: "project", worktree: "/project" } as Project], + provider, + }, + sdk: { + app: { agents: async () => ({ data: [{ name: "build", mode: "primary" }] }) }, + config: { + get: async () => { + legacyConfigReads.push("directory") + return { data: {} } + }, + }, + session: { status: async () => ({ data: {} }) }, + vcs: { get: async () => ({ data: undefined }) }, + command: { + list: async () => { + mcpReads.push("command") + return { data: [] } + }, + }, + permission: { list: async () => ({ data: [] }) }, + question: { list: async () => ({ data: [] }) }, + v2: { reference: { list: async () => ({ data: { data: [] } }) } }, + mcp: { + status: async () => { + mcpReads.push("status") + return { data: {} } + }, + }, + experimental: { + resource: { + list: async () => { + mcpReads.push("resource") + return { data: {} } + }, + }, + }, + provider: { list: async () => ({ data: { all: [], connected: [], default: {} } }) }, + } as unknown as OpencodeClient, + api, + store, + setStore, + vcsCache: { setStore() {} } as unknown as VcsCache, + loadSessions() {}, + translate: (key) => key, + queryClient: new QueryClient(), + protocol: Promise.resolve("v1"), + }) + + expect(store.status).toBe("partial") + + await new Promise((resolve) => setTimeout(resolve, 80)) + + expect(store.status).toBe("complete") + expect(legacyConfigReads).toEqual(["directory"]) + expect(mcpReads.sort()).toEqual(["command", "resource", "status"]) + }) + + test("skips legacy config while refreshing a v2 directory", async () => { + const [store, setStore] = directoryState() + + await bootstrapDirectory({ + directory: "/project", + scope: ServerScope.local, + mcp: false, + global: { + config: {} satisfies Config, + path: { state: "", config: "", worktree: "/project", directory: "/project", home: "/home" }, + project: [{ id: "project", worktree: "/project" } as Project], + provider, + }, + sdk: { + config: { + get: async () => { + throw new Error("legacy directory config should not be called") + }, + }, + } as unknown as OpencodeClient, + api, + store, + setStore, + vcsCache: { setStore() {} } as unknown as VcsCache, + loadSessions() {}, + translate: (key) => key, + queryClient: new QueryClient(), + protocol: Promise.resolve("v2"), + }) + + expect(store.status).toBe("partial") + + await new Promise((resolve) => setTimeout(resolve, 80)) + + expect(store.status).toBe("complete") + }) +}) + +describe("config queries", () => { + test("skips legacy global config for v2 servers", async () => { + const sdk = { + global: { + config: { + get: async () => { + throw new Error("legacy global config should not be called") + }, + }, + }, + } as unknown as OpencodeClient + + const result = await new QueryClient().fetchQuery( + loadGlobalConfigQuery(ServerScope.local, sdk, Promise.resolve("v2")), + ) + + expect(result).toEqual({}) + }) + + test("loads legacy global config for v1 servers", async () => { + const calls: string[] = [] + const config = { shell: "zsh" } satisfies Config + const sdk = { + global: { + config: { + get: async () => { + calls.push("global") + return { data: config } + }, + }, + }, + } as unknown as OpencodeClient + + const result = await new QueryClient().fetchQuery( + loadGlobalConfigQuery(ServerScope.local, sdk, Promise.resolve("v1")), + ) + + expect(result).toEqual(config) + expect(calls).toEqual(["global"]) + }) +}) + +describe("query keys", () => { + test("partitions identical directories by server scope", () => { + const client = {} as Parameters[2] + const api = {} as CatalogApi + const remote = "https://debian.example" as typeof ServerScope.local + + expect([...loadPathQuery(ServerScope.local, "/repo", client).queryKey]).toEqual(["local", "/repo", "path"]) + expect([...loadPathQuery(remote, "/repo", client).queryKey]).toEqual(["https://debian.example", "/repo", "path"]) + expect([...loadProvidersQuery(remote, null, api).queryKey]).toEqual(["https://debian.example", null, "providers"]) + }) + + test("loads the current provider and model catalog", async () => { + const calls: unknown[] = [] + const api = { + provider: { + list: async (input: unknown) => { + calls.push(["provider", input]) + return { location: {}, data: [{ id: "openai", name: "OpenAI", package: "@ai-sdk/openai" }] } + }, + }, + model: { + list: async (input: unknown) => { + calls.push(["model", input]) + return { location: {}, data: [] } + }, + default: async (input: unknown) => { + calls.push(["default", input]) + return { location: {}, data: null } + }, + }, + } as unknown as CatalogApi + + const result = await new QueryClient().fetchQuery(loadProvidersQuery(ServerScope.local, "/repo", api)) + + expect(calls).toEqual([ + ["provider", { location: { directory: "/repo" } }], + ["model", { location: { directory: "/repo" } }], + ["default", { location: { directory: "/repo" } }], + ]) + expect(result.connected).toEqual(["openai"]) + }) + + test("loads agents from the current location-scoped endpoint", async () => { + const calls: unknown[] = [] + const api = { + list: async (input: unknown) => { + calls.push(input) + return { location: {}, data: [] } + }, + } as unknown as AgentApi + + const result = await new QueryClient().fetchQuery(loadAgentsQuery(ServerScope.local, "/repo", api)) + + expect(calls).toEqual([{ location: { directory: "/repo" } }]) + expect(result).toEqual([]) + }) + + test("loads commands from the current location-scoped endpoint", async () => { + const calls: unknown[] = [] + const api = { + list: async (input: unknown) => { + calls.push(input) + return { + location: {}, + data: [{ name: "review", template: "Review files" /* source: "command" as const */ }], + } + }, + } as unknown as CommandApi + + const result = await loadCommands("/repo", api) + + expect(calls).toEqual([{ location: { directory: "/repo" } }]) + expect(result).toEqual([{ name: "review", template: "Review files" /* source: "command" */ }]) + }) + + test("loads projects from the current endpoint", async () => { + const api = { + list: async () => [ + { id: "b", worktree: "/b", time: { created: 1, updated: 1 }, sandboxes: [] }, + { id: "a", worktree: "/a", time: { created: 1, updated: 1 }, sandboxes: [] }, + ], + } as unknown as ProjectApi + + const result = await new QueryClient().fetchQuery(loadProjectsQuery(ServerScope.local, api)) + + expect(result.map((project) => project.id)).toEqual(["a", "b"]) + }) + + test("loads references from the current location-scoped endpoint", async () => { + const calls: unknown[] = [] + const api = { + list: async (input: unknown) => { + calls.push(input) + return { location: {}, data: [{ name: "AGENTS.md", path: "/repo/AGENTS.md", source: "instructions" }] } + }, + } as unknown as ReferenceApi + + const result = await new QueryClient().fetchQuery(loadReferencesQuery(ServerScope.local, "/repo", api)) + + expect(calls).toEqual([{ location: { directory: "/repo" } }]) + expect(result).toHaveLength(1) + }) +}) diff --git a/packages/app/src/context/global-sync/bootstrap.ts b/packages/app/src/context/global-sync/bootstrap.ts new file mode 100644 index 0000000000000000000000000000000000000000..0f3e4738164980439b94418cdb5e5ec2134d3d47 --- /dev/null +++ b/packages/app/src/context/global-sync/bootstrap.ts @@ -0,0 +1,554 @@ +import type { + Config, + OpencodeClient, + Path, + PermissionRequest, + Project, + ProviderAuthResponse, + QuestionRequest, + ReferenceInfo, + Session, +} from "@opencode-ai/sdk/v2/client" +import type { + AgentListInput, + AgentListOutput, + CatalogApi, + CommandInfo, + CommandListInput, + CommandListOutput, + ProjectCurrentInput, + ProjectCurrentOutput, + ProjectListOutput, + ReferenceListInput, + ReferenceListOutput, + SessionApi, +} from "@opencode-ai/client/promise" +import { showToast } from "@/utils/toast" +import { getFilename } from "@opencode-ai/core/util/path" +import { retry } from "@opencode-ai/core/util/retry" +import { batch } from "solid-js" +import { produce, reconcile, type SetStoreFunction, type Store } from "solid-js/store" +import type { State, VcsCache } from "./types" +import type { ServerSession } from "../server-session" +import { + cmp, + normalizeAgentList, + normalizePermissionRequest, + normalizeProjectInfo, + normalizeProviderList, +} from "./utils" +import { formatServerError } from "@/utils/server-errors" +import { QueryClient, queryOptions } from "@tanstack/solid-query" +import { loadMcpQuery, loadMcpResourcesQuery } from "../server-sync" +import { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context" +import { ScopedKey, type ServerScope } from "@/utils/server-scope" +import { normalizeSessionInfo } from "@/utils/session" +import type { ServerProtocol } from "@/utils/server-protocol" +import type { ServerApi } from "@/utils/server" + +type GlobalStore = { + ready: boolean + path: Path + project: Project[] + provider: NormalizedProviderListResponse + provider_auth: ProviderAuthResponse + config: Config + reload: undefined | "pending" | "complete" +} + +function waitForPaint() { + return new Promise((resolve) => { + let done = false + const finish = () => { + if (done) return + done = true + resolve() + } + const timer = setTimeout(finish, 50) + if (typeof requestAnimationFrame !== "function") return + requestAnimationFrame(() => { + setTimeout(() => { + clearTimeout(timer) + finish() + }, 0) + }) + }) +} + +function errors(list: PromiseSettledResult[]) { + return list.filter((item): item is PromiseRejectedResult => item.status === "rejected").map((item) => item.reason) +} + +const providerRev = new Map() + +export function clearProviderRev(scope: ServerScope, directory: string) { + providerRev.delete(ScopedKey.from(scope, directory)) +} + +function runAll(list: Array<() => Promise>) { + return Promise.allSettled(list.map((item) => item())) +} + +function showErrors(input: { + errors: unknown[] + title: string + translate: (key: string, vars?: Record) => string + formatMoreCount: (count: number) => string +}) { + if (input.errors.length === 0) return + const message = formatServerError(input.errors[0], input.translate) + const more = input.errors.length > 1 ? input.formatMoreCount(input.errors.length - 1) : "" + showToast({ + variant: "error", + title: input.title, + description: message + more, + }) +} + +export const loadGlobalConfigQuery = (scope: ServerScope, sdk: OpencodeClient, protocol?: Promise) => + queryOptions({ + queryKey: [scope, "config"], + queryFn: async () => { + if ((await protocol) !== "v1") return {} + return retry(() => sdk.global.config.get().then((x) => x.data!)) + }, + }) + +type ProjectApi = { + readonly list: () => Promise + readonly current: (input?: ProjectCurrentInput) => Promise +} + +type McpApi = ServerApi["mcp"] +type PermissionApi = ServerApi["permission"] +type QuestionApi = ServerApi["question"] +type VcsApi = ServerApi["vcs"] + +export const loadProjectsQuery = (scope: ServerScope, api: ProjectApi) => + queryOptions({ + queryKey: [scope, "project"], + queryFn: () => + retry(() => + api.list().then((projects) => { + return projects + .filter((p) => !!p?.id) + .filter((p) => !!p.worktree && !p.worktree.includes("opencode-test")) + .map(normalizeProjectInfo) + .slice() + .sort((a, b) => cmp(a.id, b.id)) + }), + ), + }) + +export async function bootstrapGlobal(input: { + serverSDK: OpencodeClient + serverAPI: CatalogApi & { readonly project: ProjectApi } + protocol?: Promise + scope: ServerScope + requestFailedTitle: string + translate: (key: string, vars?: Record) => string + formatMoreCount: (count: number) => string + setGlobalStore: SetStoreFunction + queryClient: QueryClient +}) { + const slow = [ + () => input.queryClient.fetchQuery(loadGlobalConfigQuery(input.scope, input.serverSDK, input.protocol)), + () => + input.queryClient.fetchQuery( + loadProvidersQuery(input.scope, null, input.serverAPI, input.serverSDK, input.protocol), + ), + () => input.queryClient.fetchQuery(loadPathQuery(input.scope, null, input.serverSDK, input.protocol)), + () => + input.queryClient + .fetchQuery(loadProjectsQuery(input.scope, input.serverAPI.project)) + .then((data) => input.setGlobalStore("project", data)), + ] + await runAll(slow) + // showErrors({ + // errors: errors(), + // title: input.requestFailedTitle, + // translate: input.translate, + // formatMoreCount: input.formatMoreCount, + // }) +} + +function groupBySession(input: T[]) { + return input.reduce>((acc, item) => { + if (!item?.id || !item.sessionID) return acc + const list = acc[item.sessionID] + if (list) list.push(item) + if (!list) acc[item.sessionID] = [item] + return acc + }, {}) +} + +function projectID(directory: string, projects: Project[]) { + return projects.find((project) => project.worktree === directory || project.sandboxes?.includes(directory))?.id +} + +function mergeSession(setStore: SetStoreFunction, session: Session) { + setStore("session", (list) => { + const next = list.slice() + const idx = next.findIndex((item) => item.id >= session.id) + if (idx === -1) return [...next, session] + if (next[idx]?.id === session.id) { + next[idx] = session + return next + } + next.splice(idx, 0, session) + return next + }) +} + +function warmSessions(input: { + ids: string[] + store: Store + setStore: SetStoreFunction + api: SessionApi +}) { + const known = new Set(input.store.session.map((item) => item.id)) + const ids = [...new Set(input.ids)].filter((id) => !!id && !known.has(id)) + if (ids.length === 0) return Promise.resolve() + return Promise.all( + ids.map((sessionID) => + retry(() => input.api.get({ sessionID })).then((session) => + mergeSession(input.setStore, normalizeSessionInfo(session)), + ), + ), + ).then(() => undefined) +} + +export const loadProvidersQuery = ( + scope: ServerScope, + directory: string | null, + sdk: CatalogApi, + legacy?: OpencodeClient, + protocol?: Promise, +) => + queryOptions({ + queryKey: [scope, directory, "providers"], + queryFn: () => + retry(async () => { + if ((await protocol) === "v1" && legacy) { + const result = await legacy.provider.list() + return normalizeProviderList(result.data!) + } + const location = directory ? { location: { directory } } : undefined + const [providers, models, defaultModel] = await Promise.all([ + sdk.provider.list(location), + sdk.model.list(location), + sdk.model.default(location), + ]) + return normalizeProviderList(providers.data, models.data, defaultModel.data) + }), + }) + +type AgentListApi = { + readonly list: (input?: AgentListInput) => Promise +} + +type CommandListApi = { + readonly list: (input?: CommandListInput) => Promise +} + +type ReferenceListApi = { + readonly list: (input?: ReferenceListInput) => Promise +} + +export const loadAgentsQuery = ( + scope: ServerScope, + directory: string, + sdk: AgentListApi, + legacy?: OpencodeClient, + protocol?: Promise, +) => + queryOptions({ + queryKey: [scope, directory, "agents"], + queryFn: () => + retry(async () => { + if ((await protocol) === "v1" && legacy) return normalizeAgentList((await legacy.app.agents()).data ?? []) + return sdk.list({ location: { directory } }).then((result) => normalizeAgentList(result.data)) + }), + }) + +export const loadCommands = ( + directory: string, + api: CommandListApi, + legacy?: OpencodeClient, + protocol?: Promise, +): Promise => + retry(async () => { + if ((await protocol) === "v1" && legacy) { + return ((await legacy.command.list()).data ?? []).map((command) => { + const [providerID, id] = command.model?.split("/") ?? [] + return { + name: command.name, + template: command.template, + description: command.description, + agent: command.agent, + model: providerID && id ? { providerID, id } : undefined, + subtask: command.subtask, + // source: command.source === "skill" ? undefined : command.source, + } + }) + } + return api.list({ location: { directory } }).then((result) => result.data) + }) + +export const loadPathQuery = ( + scope: ServerScope, + directory: string | null, + sdk: OpencodeClient, + protocol?: Promise, +) => + queryOptions({ + queryKey: [scope, directory, "path"], + queryFn: async () => { + if ((await protocol) !== "v1") + return { state: "", config: "", worktree: "", directory: directory ?? "", home: "" } + return retry(() => sdk.path.get({ directory: directory ?? undefined }).then((result) => result.data!)) + }, + }) + +export const loadReferencesQuery = ( + scope: ServerScope, + directory: string, + api: ReferenceListApi, + legacy?: OpencodeClient, + protocol?: Promise, +) => + queryOptions({ + queryKey: [scope, directory, "references"] as const, + queryFn: () => + retry(async () => { + if ((await protocol) === "v1" && legacy) return (await legacy.v2.reference.list()).data?.data ?? [] + return api.list({ location: { directory } }).then((result) => result.data) + }).catch(() => []), + placeholderData: [], + }) + +export async function bootstrapDirectory(input: { + directory: string + scope: ServerScope + mcp: boolean + sdk: OpencodeClient + api: CatalogApi & { + readonly agent: AgentListApi + readonly command: CommandListApi + readonly mcp: McpApi + readonly permission: PermissionApi + readonly project: ProjectApi + readonly question: QuestionApi + readonly reference: ReferenceListApi + readonly session: SessionApi + readonly vcs: VcsApi + } + store: Store + setStore: SetStoreFunction + vcsCache: VcsCache + loadSessions: (directory: string) => Promise | void + translate: (key: string, vars?: Record) => string + global: { + config: Config + path: Path + project: Project[] + provider: NormalizedProviderListResponse + } + queryClient: QueryClient + session?: ServerSession + protocol?: Promise +}) { + const loading = input.store.status !== "complete" + const seededProject = projectID(input.directory, input.global.project) + const seededPath = input.global.path.directory === input.directory ? input.global.path : undefined + if (seededProject) input.setStore("project", seededProject) + if (seededPath) input.setStore("path", seededPath) + if (Object.keys(input.store.config).length === 0 && Object.keys(input.global.config).length > 0) { + input.setStore("config", reconcile(input.global.config, { merge: false })) + } + if (loading) input.setStore("status", "partial") + + const revKey = ScopedKey.from(input.scope, input.directory) + const rev = (providerRev.get(revKey) ?? 0) + 1 + providerRev.set(revKey, rev) + ;(async () => { + const slow = [ + () => Promise.resolve(input.loadSessions(input.directory)), + () => + input.queryClient + .ensureQueryData(loadAgentsQuery(input.scope, input.directory, input.api.agent, input.sdk, input.protocol)) + .then((data) => input.setStore("agent", data)), + () => + retry(async () => { + if ((await input.protocol) !== "v1") return + return input.sdk.config.get().then((x) => input.setStore("config", reconcile(x.data!, { merge: false }))) + }), + () => + retry(() => + (async () => { + if ((await input.protocol) !== "v1") return + const x = await input.sdk.session.status() + if (!input.session) { + input.setStore("session_status", x.data!) + return + } + const statuses = x.data ?? {} + input.session.set( + "session_status", + produce((draft) => { + for (const sessionID of Object.keys(draft)) { + if (statuses[sessionID]) continue + if (input.session?.get(sessionID)?.directory === input.directory) delete draft[sessionID] + } + }), + ) + for (const [sessionID, status] of Object.entries(statuses)) { + input.session.set("session_status", sessionID, reconcile(status)) + } + await Promise.all( + Object.keys(statuses).map((sessionID) => input.session!.resolve(sessionID).catch(() => undefined)), + ) + })(), + ), + !seededProject && + (() => + retry(() => input.api.project.current({ location: { directory: input.directory } })).then((project) => + input.setStore("project", project.id), + )), + !seededPath && + (() => + input.queryClient + .ensureQueryData(loadPathQuery(input.scope, input.directory, input.sdk, input.protocol)) + .then((data) => { + const next = projectID(data.directory ?? input.directory, input.global.project) + if (next) input.setStore("project", next) + })), + () => + retry(async () => { + if ((await input.protocol) !== "v1") return + return input.sdk.vcs.get().then((result) => { + const next = { branch: result.data?.branch, default_branch: result.data?.default_branch } + input.setStore("vcs", next) + if (next) input.vcsCache.setStore("value", next) + }) + }), + input.mcp && + (() => + loadCommands(input.directory, input.api.command, input.sdk, input.protocol).then((commands) => + input.setStore("command", commands), + )), + () => + input.queryClient.fetchQuery( + loadReferencesQuery(input.scope, input.directory, input.api.reference, input.sdk, input.protocol), + ), + () => + retry(() => + (async () => { + if ((await input.protocol) === "v1") return (await input.sdk.permission.list()).data ?? [] + return input.api.permission.request + .list({ location: { directory: input.directory } }) + .then((result) => result.data.map(normalizePermissionRequest)) + })().then((permissions) => { + const ids = permissions.map((permission) => permission.sessionID) + const grouped = groupBySession( + permissions.filter((permission) => !!permission.id && !!permission.sessionID), + ) + const warm = input.session + ? Promise.all(ids.map((sessionID) => input.session!.resolve(sessionID))).then(() => undefined) + : warmSessions({ ids, store: input.store, setStore: input.setStore, api: input.api.session }) + return warm.then(() => + batch(() => { + const current = input.session?.data.permission ?? input.store.permission + for (const sessionID of Object.keys(current)) { + if (grouped[sessionID]) continue + if (input.session?.get(sessionID)?.directory !== input.directory) continue + if (input.session) input.session.set("permission", sessionID, []) + if (!input.session) input.setStore("permission", sessionID, []) + } + for (const [sessionID, permissions] of Object.entries(grouped)) { + const value = reconcile( + permissions.filter((p) => !!p?.id).sort((a, b) => cmp(a.id, b.id)), + { key: "id" }, + ) + if (input.session) input.session.set("permission", sessionID, value) + if (!input.session) input.setStore("permission", sessionID, value) + } + }), + ) + }), + ), + () => + retry(() => + (async () => { + if ((await input.protocol) === "v1") return (await input.sdk.question.list()).data ?? [] + return input.api.question.request + .list({ location: { directory: input.directory } }) + .then((result) => result.data) + })().then((questions) => { + const ids = questions.map((question) => question.sessionID) + const grouped = groupBySession( + questions.filter((question) => !!question.id && !!question.sessionID) as QuestionRequest[], + ) + const warm = input.session + ? Promise.all(ids.map((sessionID) => input.session!.resolve(sessionID))).then(() => undefined) + : warmSessions({ ids, store: input.store, setStore: input.setStore, api: input.api.session }) + return warm.then(() => + batch(() => { + const current = input.session?.data.question ?? input.store.question + for (const sessionID of Object.keys(current)) { + if (grouped[sessionID]) continue + if (input.session?.get(sessionID)?.directory !== input.directory) continue + if (input.session) input.session.set("question", sessionID, []) + if (!input.session) input.setStore("question", sessionID, []) + } + for (const [sessionID, questions] of Object.entries(grouped)) { + const value = reconcile( + questions.filter((q) => !!q?.id).sort((a, b) => cmp(a.id, b.id)), + { key: "id" }, + ) + if (input.session) input.session.set("question", sessionID, value) + if (!input.session) input.setStore("question", sessionID, value) + } + }), + ) + }), + ), + () => Promise.resolve(input.loadSessions(input.directory)), + input.mcp && + (() => + input.queryClient.fetchQuery( + loadMcpQuery(input.scope, input.directory, input.api.mcp, input.sdk, input.protocol), + )), + input.mcp && + (() => + input.queryClient.fetchQuery( + loadMcpResourcesQuery(input.scope, input.directory, input.api.mcp, input.sdk, input.protocol), + )), + () => + input.queryClient + .fetchQuery(loadProvidersQuery(input.scope, input.directory, input.api, input.sdk, input.protocol)) + .catch((err) => { + const project = getFilename(input.directory) + showToast({ + variant: "error", + title: input.translate("toast.project.reloadFailed.title", { project }), + description: formatServerError(err, input.translate), + }) + }), + ].filter(Boolean) as (() => Promise)[] + + await waitForPaint() + const slowErrs = errors(await runAll(slow)) + if (slowErrs.length > 0) { + console.error("Failed to finish bootstrap instance", slowErrs[0]) + const project = getFilename(input.directory) + showToast({ + variant: "error", + title: input.translate("toast.project.reloadFailed.title", { project }), + description: formatServerError(slowErrs[0], input.translate), + }) + } + + if (loading && slowErrs.length === 0) input.setStore("status", "complete") + })() +} diff --git a/packages/app/src/context/global-sync/child-store.test.ts b/packages/app/src/context/global-sync/child-store.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..e05b7c39a9683714f9a470d3eb60cd1be7d29bc5 --- /dev/null +++ b/packages/app/src/context/global-sync/child-store.test.ts @@ -0,0 +1,276 @@ +import { beforeAll, describe, expect, mock, test } from "bun:test" +import { createRoot, getOwner, type Owner } from "solid-js" +import { createStore } from "solid-js/store" +import type { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context" +import type { State } from "./types" +import type { QueryOptionsApi } from "../server-sync" +import { ServerScope } from "@/utils/server-scope" + +let createChildStoreManager: typeof import("./child-store").createChildStoreManager +const querySingles: Array<() => { queryKey?: unknown[]; enabled?: boolean }> = [] +const persist: typeof import("@/utils/persist").persisted = (_target, store) => [ + store[0], + store[1], + null, + Object.assign(() => true, { promise: undefined }), +] + +const child = () => createStore({} as State) +const provider = { all: new Map(), connected: [], default: {} } satisfies NormalizedProviderListResponse + +const queryOptionsApi = { + globalConfig: () => ({ queryKey: ["globalConfig"], queryFn: async () => ({}) }), + projects: () => ({ queryKey: ["projects"], queryFn: async () => [] }), + providers: (directory: string | null) => ({ queryKey: [directory, "providers"], queryFn: async () => provider }), + path: (directory: string | null) => ({ + queryKey: [directory, "path"], + queryFn: async () => ({ + state: "", + config: "", + worktree: "", + directory: directory ?? "", + home: "", + }), + }), + agents: (directory: string) => ({ queryKey: [directory, "agents"], queryFn: async () => [] }), + mcp: (directory: string) => ({ queryKey: [directory, "mcp"], queryFn: async () => ({}) }), + mcpResources: (directory: string) => ({ queryKey: [directory, "mcpResources"], queryFn: async () => ({}) }), + lsp: (directory: string) => ({ queryKey: [directory, "lsp"], queryFn: async () => [] }), + references: (directory: string) => ({ queryKey: [directory, "references"], queryFn: async () => [] }), + sessions: (directory: string) => ({ queryKey: [directory, "loadSessions"] as const }), +} as unknown as QueryOptionsApi + +function createOwner(callback: (owner: Owner) => void) { + return createRoot((dispose) => { + const owner = getOwner() + if (!owner) throw new Error("owner required") + callback(owner) + + return dispose + }) +} + +beforeAll(async () => { + mock.module("@tanstack/solid-query", () => ({ + useQuery: (options: () => { queryKey?: unknown[]; enabled?: boolean }) => { + querySingles.push(options) + return { + get isLoading() { + return options().queryKey?.[1] === "path" + }, + get data() { + if (options().queryKey?.[1] === "path") throw new Error("pending path data read") + if (options().queryKey?.[1] === "mcp") return options().enabled ? { demo: { status: "disabled" } } : undefined + if (options().queryKey?.[1] === "lsp") return [] + if (options().queryKey?.[1] === "providers") return provider + return undefined + }, + } + }, + })) + + createChildStoreManager = (await import("./child-store")).createChildStoreManager +}) + +describe("createChildStoreManager", () => { + test("does not evict the active directory during mark", () => { + const owner = createRoot((dispose) => { + const current = getOwner() + dispose() + return current + }) + if (!owner) throw new Error("owner required") + + const manager = createChildStoreManager({ + owner, + scope: ServerScope.local, + persist, + isBooting: () => false, + isLoadingSessions: () => false, + onBootstrap() {}, + onMcp() {}, + onDispose() {}, + translate: (key) => key, + queryOptions: queryOptionsApi, + global: { provider }, + }) + + Array.from({ length: 30 }, (_, index) => `/pinned-${index}`).forEach((directory) => { + manager.children[directory] = child() + manager.pin(directory) + }) + + const directory = "/active" + manager.children[directory] = child() + manager.mark(directory) + + expect(manager.children[directory]).toBeDefined() + }) + + test("starts new child stores as loading and bootstraps them on first access", () => { + const bootstraps: string[] = [] + let manager: ReturnType | undefined + + const dispose = createOwner((owner) => { + manager = createChildStoreManager({ + owner, + scope: ServerScope.local, + persist, + isBooting: () => false, + isLoadingSessions: () => false, + onBootstrap(directory) { + bootstraps.push(directory) + }, + onMcp() {}, + onDispose() {}, + translate: (key) => key, + queryOptions: queryOptionsApi, + global: { provider }, + }) + }) + + try { + if (!manager) throw new Error("manager required") + + const [store] = manager.child("/project") + + expect(store.status).toBe("loading") + expect(store.limit).toBe(5) + expect(bootstraps).toEqual(["/project"]) + } finally { + dispose() + } + }) + + test("provides the requested directory while the path query is pending", () => { + let manager: ReturnType | undefined + + const dispose = createOwner((owner) => { + manager = createChildStoreManager({ + owner, + scope: ServerScope.local, + persist, + isBooting: () => false, + isLoadingSessions: () => false, + onBootstrap() {}, + onMcp() {}, + onDispose() {}, + translate: (key) => key, + queryOptions: queryOptionsApi, + global: { provider }, + }) + }) + + try { + if (!manager) throw new Error("manager required") + + const [store] = manager.child("/project", { bootstrap: false }) + + expect(store.path.directory).toBe("/project") + expect(store.path.worktree).toBe("") + } finally { + dispose() + } + }) + + test("enables MCP only when requested for the directory", () => { + let manager: ReturnType | undefined + const offset = querySingles.length + const mcpLoads: string[] = [] + + const dispose = createOwner((owner) => { + manager = createChildStoreManager({ + owner, + scope: ServerScope.local, + persist, + isBooting: () => false, + isLoadingSessions: () => false, + onBootstrap() {}, + onMcp(directory) { + mcpLoads.push(directory) + }, + onDispose() {}, + translate: (key) => key, + queryOptions: queryOptionsApi, + global: { provider }, + }) + }) + + try { + if (!manager) throw new Error("manager required") + const [store, setStore] = manager.child("/project", { bootstrap: false }) + expect(querySingles.length - offset).toBe(6) + const query = querySingles[offset + 1] + const resourceQuery = querySingles[offset + 2] + if (!query) throw new Error("query required") + if (!resourceQuery) throw new Error("resource query required") + expect(query().enabled).toBe(false) + expect(resourceQuery().enabled).toBe(false) + + setStore("status", "complete") + manager.child("/project", { bootstrap: false, mcp: true }) + expect(query().enabled).toBe(true) + expect(resourceQuery().enabled).toBe(true) + expect(store.mcp).toEqual({ demo: { status: "disabled" } }) + expect(mcpLoads).toEqual(["/project"]) + + manager.disableMcp("/project") + expect(query().enabled).toBe(false) + expect(manager.mcp("/project")).toBe(false) + } finally { + dispose() + } + }) + + test("keeps non-bootstrapping children passive until a real directory access", () => { + let manager: ReturnType | undefined + const offset = querySingles.length + const bootstraps: string[] = [] + + const dispose = createOwner((owner) => { + manager = createChildStoreManager({ + owner, + scope: ServerScope.local, + persist, + isBooting: () => false, + isLoadingSessions: () => false, + onBootstrap(directory) { + bootstraps.push(directory) + }, + onMcp() {}, + onDispose() {}, + translate: (key) => key, + queryOptions: queryOptionsApi, + global: { provider }, + }) + }) + + try { + if (!manager) throw new Error("manager required") + const [store] = manager.child("/project", { bootstrap: false }) + const queries = querySingles.slice(offset) + + expect(queries).toHaveLength(6) + expect(queries[0]?.().enabled).toBe(false) + expect(queries[3]?.().enabled).toBe(false) + expect(queries[4]?.().enabled).toBe(false) + expect(queries[5]?.().enabled).toBe(false) + expect(store.path.directory).toBe("/project") + expect(store.provider_ready).toBe(false) + expect(store.lsp_ready).toBe(false) + expect(bootstraps).toEqual([]) + + manager.child("/project") + expect(queries[0]?.().enabled).toBe(true) + expect(queries[3]?.().enabled).toBe(true) + expect(queries[4]?.().enabled).toBe(true) + expect(queries[5]?.().enabled).toBe(true) + expect(bootstraps).toEqual(["/project"]) + + manager.child("/project", { bootstrap: false }) + expect(queries[0]?.().enabled).toBe(true) + } finally { + dispose() + } + }) +}) diff --git a/packages/app/src/context/global-sync/child-store.ts b/packages/app/src/context/global-sync/child-store.ts new file mode 100644 index 0000000000000000000000000000000000000000..4eaa785789fcf63bd193882abeef207a1f00fd0a --- /dev/null +++ b/packages/app/src/context/global-sync/child-store.ts @@ -0,0 +1,396 @@ +import { createRoot, createSignal, getOwner, onCleanup, runWithOwner, type Owner } from "solid-js" +import { createStore, type SetStoreFunction, type Store } from "solid-js/store" +import { Persist, persisted } from "@/utils/persist" +import type { VcsInfo } from "@opencode-ai/sdk/v2/client" +import { + DIR_IDLE_TTL_MS, + MAX_DIR_STORES, + type ChildOptions, + type DirState, + type IconCache, + type MetaCache, + type ProjectMeta, + type State, + type VcsCache, +} from "./types" +import { canDisposeDirectory, pickDirectoriesToEvict } from "./eviction" +import { useQuery } from "@tanstack/solid-query" +import { QueryOptionsApi } from "../server-sync" +import { directoryKey, type DirectoryKey } from "./utils" +import { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context" +import type { ServerScope } from "@/utils/server-scope" + +export function createChildStoreManager(input: { + owner: Owner + scope: ServerScope + persist: typeof persisted + isBooting: (directory: string) => boolean + isLoadingSessions: (directory: string) => boolean + onBootstrap: (directory: string) => void + onMcp: (directory: string, setStore: SetStoreFunction) => void + onDispose: (directory: string) => void + translate: (key: string, vars?: Record) => string + queryOptions: QueryOptionsApi + global: { + provider: NormalizedProviderListResponse + } +}) { + const children: Record, SetStoreFunction]> = {} + const vcsCache = new Map() + const metaCache = new Map() + const iconCache = new Map() + const lifecycle = new Map() + const pins = new Map() + const ownerPins = new WeakMap>() + const disposers = new Map void>() + const mcpDirectories = new Set() + const mcpToggles = new Map void>() + const activeDirectories = new Set() + const activationToggles = new Map void>() + + const markKey = (key: DirectoryKey) => { + if (!key) return + lifecycle.set(key, { lastAccessAt: Date.now() }) + runEviction(key) + } + + const mark = (directory: string) => { + const key = directoryKey(directory) + markKey(key) + } + + const pin = (directory: string) => { + const key = directoryKey(directory) + if (!key) return + pins.set(key, (pins.get(key) ?? 0) + 1) + markKey(key) + } + + const unpin = (directory: string) => { + const key = directoryKey(directory) + if (!key) return + const next = (pins.get(key) ?? 0) - 1 + if (next > 0) { + pins.set(key, next) + return + } + pins.delete(key) + runEviction() + } + + const pinned = (directory: string) => (pins.get(directoryKey(directory)) ?? 0) > 0 + + const pinForOwner = (directory: string) => { + const current = getOwner() + if (!current) return + if (current === input.owner) return + const key = current as object + const set = ownerPins.get(key) + if (set?.has(directory)) return + if (set) set.add(directory) + if (!set) ownerPins.set(key, new Set([directory])) + pin(directory) + onCleanup(() => { + const set = ownerPins.get(key) + if (set) { + set.delete(directory) + if (set.size === 0) ownerPins.delete(key) + } + unpin(directory) + }) + } + + function disposeDirectory(directory: DirectoryKey) { + const key = directory + if ( + !canDisposeDirectory({ + directory: key, + hasStore: !!children[key], + pinned: pinned(key), + booting: input.isBooting(key), + loadingSessions: input.isLoadingSessions(key), + }) + ) { + return false + } + + vcsCache.delete(key) + metaCache.delete(key) + iconCache.delete(key) + lifecycle.delete(key) + mcpDirectories.delete(key) + mcpToggles.delete(key) + activeDirectories.delete(key) + activationToggles.delete(key) + const dispose = disposers.get(key) + if (dispose) { + dispose() + disposers.delete(key) + } + delete children[key] + input.onDispose(key) + return true + } + + function runEviction(skip?: string) { + const stores = Object.keys(children) + if (stores.length === 0) return + const list = pickDirectoriesToEvict({ + stores, + state: lifecycle, + pins: new Set(stores.filter(pinned)), + max: MAX_DIR_STORES, + ttl: DIR_IDLE_TTL_MS, + now: Date.now(), + }).filter((directory) => directory !== skip) + if (list.length === 0) return + for (const directory of list) { + if (!disposeDirectory(directoryKey(directory))) continue + } + } + + function ensureChild(directory: string) { + const key = directoryKey(directory) + if (!key) console.error("No directory provided") + if (!children[key]) { + const vcs = runWithOwner(input.owner, () => + input.persist( + Persist.serverWorkspace(input.scope, directory, "vcs", ["vcs.v1"]), + createStore({ value: undefined as VcsInfo | undefined }), + ), + ) + if (!vcs) throw new Error(input.translate("error.childStore.persistedCacheCreateFailed")) + const vcsStore = vcs[0] + vcsCache.set(key, { store: vcsStore, setStore: vcs[1], ready: vcs[3] }) + + const meta = runWithOwner(input.owner, () => + input.persist( + Persist.serverWorkspace(input.scope, directory, "project", ["project.v1"]), + createStore({ value: undefined as ProjectMeta | undefined }), + ), + ) + if (!meta) throw new Error(input.translate("error.childStore.persistedProjectMetadataCreateFailed")) + metaCache.set(key, { store: meta[0], setStore: meta[1], ready: meta[3] }) + + const icon = runWithOwner(input.owner, () => + input.persist( + Persist.serverWorkspace(input.scope, directory, "icon", ["icon.v1"]), + createStore({ value: undefined as string | undefined }), + ), + ) + if (!icon) throw new Error(input.translate("error.childStore.persistedProjectIconCreateFailed")) + iconCache.set(key, { store: icon[0], setStore: icon[1], ready: icon[3] }) + + const init = () => + createRoot((dispose) => { + const initialMeta = meta[0].value + const initialIcon = icon[0].value + const [mcpEnabled, setMcpEnabled] = createSignal(false) + const [instanceQueriesEnabled, setInstanceQueriesEnabled] = createSignal(false) + + const pathQuery = useQuery(() => ({ ...input.queryOptions.path(key), enabled: instanceQueriesEnabled() })) + const mcpQuery = useQuery(() => ({ ...input.queryOptions.mcp(key), enabled: mcpEnabled() })) + const mcpResourceQuery = useQuery(() => ({ ...input.queryOptions.mcpResources(key), enabled: mcpEnabled() })) + const lspQuery = useQuery(() => ({ ...input.queryOptions.lsp(key), enabled: instanceQueriesEnabled() })) + const providerQuery = useQuery(() => ({ + ...input.queryOptions.providers(key), + enabled: instanceQueriesEnabled(), + })) + const referenceQuery = useQuery(() => ({ + ...input.queryOptions.references(key), + enabled: instanceQueriesEnabled(), + })) + + const child = createStore({ + project: "", + projectMeta: initialMeta, + icon: initialIcon, + get provider_ready() { + return instanceQueriesEnabled() && !providerQuery.isLoading + }, + get provider() { + const EMPTY = { all: new Map(), connected: [], default: {} } + if (providerQuery.isLoading) return EMPTY + if (providerQuery.data?.all.size === 0 && input.global.provider.all.size > 0) return input.global.provider + return providerQuery.data ?? EMPTY + }, + config: {}, + get path() { + const EMPTY = { state: "", config: "", worktree: "", directory, home: "" } + if (pathQuery.isLoading) return EMPTY + return pathQuery.data ?? EMPTY + }, + status: "loading" as const, + agent: [], + command: [], + get reference() { + return referenceQuery.isLoading ? [] : (referenceQuery.data ?? []) + }, + session: [], + sessionTotal: 0, + session_status: {}, + session_working(id: string) { + const type = this.session_status[id]?.type + return (type ?? "idle") !== "idle" + }, + session_diff: {}, + todo: {}, + permission: {}, + question: {}, + get mcp_ready() { + return !mcpQuery.isLoading + }, + get mcp() { + return mcpQuery.isLoading ? {} : (mcpQuery.data ?? {}) + }, + get mcp_resource() { + return mcpResourceQuery.isLoading ? {} : (mcpResourceQuery.data ?? {}) + }, + get lsp_ready() { + return instanceQueriesEnabled() && !lspQuery.isLoading + }, + get lsp() { + return lspQuery.isLoading ? [] : (lspQuery.data ?? []) + }, + vcs: vcsStore.value, + limit: 5, + message: {}, + session_message: {}, + part: {}, + part_text_accum_delta: {}, + }) + children[key] = child + disposers.set(key, dispose) + mcpToggles.set(key, setMcpEnabled) + activationToggles.set(key, setInstanceQueriesEnabled) + + const onPersistedInit = (init: Promise | string | null, run: () => void) => { + if (!(init instanceof Promise)) return + void init.then(() => { + if (children[key] !== child) return + run() + }) + } + + onPersistedInit(vcs[2], () => { + const cached = vcsStore.value + if (!cached?.branch) return + child[1]("vcs", (value) => value ?? cached) + }) + + onPersistedInit(meta[2], () => { + if (child[0].projectMeta !== initialMeta) return + child[1]("projectMeta", meta[0].value) + }) + + onPersistedInit(icon[2], () => { + if (child[0].icon !== initialIcon) return + child[1]("icon", icon[0].value) + }) + }) + + runWithOwner(input.owner, init) + } + markKey(key) + const childStore = children[key] + if (!childStore) throw new Error(input.translate("error.childStore.storeCreateFailed")) + return childStore + } + + function child(directory: string, options: ChildOptions = {}) { + const key = directoryKey(directory) + const childStore = ensureChild(directory) + pinForOwner(key) + if (options.mcp) enableMcp(directory, key, childStore) + const shouldBootstrap = options.bootstrap ?? true + if (shouldBootstrap) activate(key) + if (shouldBootstrap && childStore[0].status === "loading") { + input.onBootstrap(directory) + } + return childStore + } + + function peek(directory: string, options: ChildOptions = {}) { + const key = directoryKey(directory) + const childStore = ensureChild(directory) + if (options.mcp) enableMcp(directory, key, childStore) + const shouldBootstrap = options.bootstrap ?? true + if (shouldBootstrap) activate(key) + if (shouldBootstrap && childStore[0].status === "loading") { + input.onBootstrap(directory) + } + return childStore + } + + function enableMcp(directory: string, key: DirectoryKey, childStore: [Store, SetStoreFunction]) { + if (mcpDirectories.has(key)) return + mcpDirectories.add(key) + mcpToggles.get(key)?.(true) + if (childStore[0].status !== "loading") input.onMcp(directory, childStore[1]) + } + + // Passive Home/project metadata reads must not initialize the directory. + // A real directory access enables these queries once for the store lifetime. + // TODO(v2): After Home switches to v2.project.list and root-filtered, + // updated-time v2.session.list, remove any Home-only passive child creation. + function activate(key: DirectoryKey) { + if (activeDirectories.has(key)) return + activeDirectories.add(key) + activationToggles.get(key)?.(true) + } + + function disableMcp(directory: string) { + const key = directoryKey(directory) + if (!mcpDirectories.delete(key)) return + mcpToggles.get(key)?.(false) + } + + function projectMeta(directory: string, patch: ProjectMeta) { + const key = directoryKey(directory) + const [store, setStore] = ensureChild(directory) + const cached = metaCache.get(key) + if (!cached) return + const previous = store.projectMeta ?? {} + const icon = patch.icon ? { ...previous.icon, ...patch.icon } : previous.icon + const commands = patch.commands ? { ...previous.commands, ...patch.commands } : previous.commands + const next = { + ...previous, + ...patch, + icon, + commands, + } + cached.setStore("value", next) + setStore("projectMeta", next) + } + + function projectIcon(directory: string, value: string | undefined) { + const key = directoryKey(directory) + const [store, setStore] = ensureChild(directory) + const cached = iconCache.get(key) + if (!cached) return + if (store.icon === value) return + cached.setStore("value", value) + setStore("icon", value) + } + + return { + children, + ensureChild, + child, + peek, + projectMeta, + projectIcon, + mark, + pin, + unpin, + pinned, + mcp: (directory: string) => mcpDirectories.has(directoryKey(directory)), + active: (directory: string) => activeDirectories.has(directoryKey(directory)), + disableMcp, + disposeDirectory, + runEviction, + vcsCache, + metaCache, + iconCache, + } +} diff --git a/packages/app/src/context/global-sync/event-reducer.test.ts b/packages/app/src/context/global-sync/event-reducer.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..06d536618e0548409f2fe71a910ff16ab206cabc --- /dev/null +++ b/packages/app/src/context/global-sync/event-reducer.test.ts @@ -0,0 +1,616 @@ +import { describe, expect, test } from "bun:test" +import type { Message, Part, PermissionRequest, Project, QuestionRequest, Session } from "@opencode-ai/sdk/v2/client" +import { createStore } from "solid-js/store" +import type { State } from "./types" +import { applyDirectoryEvent, applyGlobalEvent, cleanupDroppedSessionCaches } from "./event-reducer" + +const rootSession = (input: { id: string; parentID?: string; archived?: number }) => + ({ + id: input.id, + parentID: input.parentID, + time: { + created: 1, + updated: 1, + archived: input.archived, + }, + }) as Session + +const userMessage = (id: string, sessionID: string, created = 1) => + ({ + id, + sessionID, + role: "user", + time: { created }, + agent: "assistant", + model: { providerID: "openai", modelID: "gpt" }, + }) as Message + +const textPart = (id: string, sessionID: string, messageID: string) => + ({ + id, + sessionID, + messageID, + type: "text", + text: id, + }) as Part + +const permissionRequest = (id: string, sessionID: string, title = id) => + ({ + id, + sessionID, + permission: title, + patterns: ["*"], + metadata: {}, + always: [], + }) as PermissionRequest + +const questionRequest = (id: string, sessionID: string, title = id) => + ({ + id, + sessionID, + questions: [ + { + question: title, + header: title, + options: [{ label: title, description: title }], + }, + ], + }) as QuestionRequest + +const baseState = (input: Partial = {}) => + ({ + status: "complete", + agent: [], + command: [], + project: "", + projectMeta: undefined, + icon: undefined, + provider: {} as State["provider"], + config: {} as State["config"], + path: { directory: "/tmp" } as State["path"], + session: [], + sessionTotal: 0, + session_status: {}, + session_diff: {}, + todo: {}, + permission: {}, + question: {}, + mcp: {}, + lsp: [], + vcs: undefined, + limit: 10, + message: {}, + session_message: {}, + part: {}, + part_text_accum_delta: {}, + ...input, + }) as State + +describe("applyGlobalEvent", () => { + test("upserts project.updated in sorted position", () => { + const project = [{ id: "a" }, { id: "c" }] as Project[] + let refreshCount = 0 + applyGlobalEvent({ + event: { type: "project.updated", properties: { id: "b" } }, + project, + refresh: () => { + refreshCount += 1 + }, + setGlobalProject(next) { + if (typeof next === "function") next(project) + }, + }) + + expect(project.map((x) => x.id)).toEqual(["a", "b", "c"]) + expect(refreshCount).toBe(0) + }) + + test("handles global.disposed by triggering refresh", () => { + let refreshCount = 0 + applyGlobalEvent({ + event: { type: "global.disposed" }, + project: [], + refresh: () => { + refreshCount += 1 + }, + setGlobalProject() {}, + }) + + expect(refreshCount).toBe(1) + }) + + test("handles server.connected by triggering refresh", () => { + let refreshCount = 0 + applyGlobalEvent({ + event: { type: "server.connected" }, + project: [], + refresh: () => { + refreshCount += 1 + }, + setGlobalProject() {}, + }) + + expect(refreshCount).toBe(1) + }) +}) + +describe("applyDirectoryEvent", () => { + test("initializes text delta accumulation from the current part text", () => { + const part = { ...textPart("part", "session", "message"), text: "existing" } + const [store, setStore] = createStore(baseState({ part: { message: [part] } })) + + applyDirectoryEvent({ + event: { + type: "message.part.delta", + properties: { messageID: "message", partID: "part", field: "text", delta: " appended" }, + }, + store, + setStore, + push() {}, + directory: "/tmp", + loadLsp() {}, + }) + + expect(store.part_text_accum_delta.part).toBe("existing appended") + expect((store.part.message?.[0] as { text: string }).text).toBe("existing appended") + }) + + test("preserves a Home-specific retained session limit", () => { + const [store, setStore] = createStore( + baseState({ + limit: 1, + session: [rootSession({ id: "a" }), rootSession({ id: "b" }), rootSession({ id: "c" })], + }), + ) + + applyDirectoryEvent({ + event: { type: "session.created", properties: { info: rootSession({ id: "d" }) } }, + store, + setStore, + push() {}, + directory: "/tmp", + loadLsp() {}, + retainedLimit: 3, + }) + + expect(store.session).toHaveLength(3) + }) + + test("inserts root sessions in sorted order and updates sessionTotal", () => { + const [store, setStore] = createStore( + baseState({ + session: [rootSession({ id: "b" })], + sessionTotal: 1, + }), + ) + + applyDirectoryEvent({ + event: { type: "session.created", properties: { info: rootSession({ id: "a" }) } }, + store, + setStore, + push() {}, + directory: "/tmp", + loadLsp() {}, + }) + + expect(store.session.map((x) => x.id)).toEqual(["a", "b"]) + expect(store.sessionTotal).toBe(2) + + applyDirectoryEvent({ + event: { type: "session.created", properties: { info: rootSession({ id: "c", parentID: "a" }) } }, + store, + setStore, + push() {}, + directory: "/tmp", + loadLsp() {}, + }) + + expect(store.sessionTotal).toBe(2) + }) + + test("cleans session caches when archived", () => { + const message = userMessage("msg_1", "ses_1") + const [store, setStore] = createStore( + baseState({ + session: [rootSession({ id: "ses_1" }), rootSession({ id: "ses_2" })], + sessionTotal: 2, + message: { ses_1: [message] }, + part: { [message.id]: [textPart("prt_1", "ses_1", message.id)] }, + session_diff: { ses_1: [] }, + todo: { ses_1: [] }, + permission: { ses_1: [] }, + question: { ses_1: [] }, + session_status: { ses_1: { type: "busy" } }, + }), + ) + + applyDirectoryEvent({ + event: { type: "session.updated", properties: { info: rootSession({ id: "ses_1", archived: 10 }) } }, + store, + setStore, + push() {}, + directory: "/tmp", + loadLsp() {}, + }) + + expect(store.session.map((x) => x.id)).toEqual(["ses_2"]) + expect(store.sessionTotal).toBe(1) + expect(store.message.ses_1).toBeUndefined() + expect(store.part[message.id]).toBeUndefined() + expect(store.session_diff.ses_1).toBeUndefined() + expect(store.todo.ses_1).toBeUndefined() + expect(store.permission.ses_1).toBeUndefined() + expect(store.question.ses_1).toBeUndefined() + expect(store.session_status.ses_1).toBeUndefined() + }) + + test("ignores an archived session absent from a passive directory store", () => { + const [store, setStore] = createStore(baseState({ session: [], sessionTotal: 0 })) + + applyDirectoryEvent({ + event: { type: "session.updated", properties: { info: rootSession({ id: "missing", archived: 10 }) } }, + store, + setStore, + push() {}, + directory: "/tmp", + loadLsp() {}, + }) + + expect(store.session).toEqual([]) + expect(store.sessionTotal).toBe(0) + }) + + test("cleans session caches when deleted and decrements only root totals", () => { + const cases = [ + { info: rootSession({ id: "ses_1" }), expectedTotal: 1, current: false }, + { info: rootSession({ id: "ses_2", parentID: "ses_1" }), expectedTotal: 2, current: true }, + ] + + for (const item of cases) { + const message = userMessage("msg_1", item.info.id) + const [store, setStore] = createStore( + baseState({ + session: [ + rootSession({ id: "ses_1" }), + rootSession({ id: "ses_2", parentID: "ses_1" }), + rootSession({ id: "ses_3" }), + ], + sessionTotal: 2, + message: { [item.info.id]: [message] }, + part: { [message.id]: [textPart("prt_1", item.info.id, message.id)] }, + session_diff: { [item.info.id]: [] }, + todo: { [item.info.id]: [] }, + permission: { [item.info.id]: [] }, + question: { [item.info.id]: [] }, + session_status: { [item.info.id]: { type: "busy" } }, + }), + ) + + applyDirectoryEvent({ + event: { + type: "session.deleted", + properties: item.current ? { sessionID: item.info.id } : { info: item.info }, + }, + store, + setStore, + push() {}, + directory: "/tmp", + loadLsp() {}, + }) + + expect(store.session.find((x) => x.id === item.info.id)).toBeUndefined() + expect(store.sessionTotal).toBe(item.expectedTotal) + expect(store.message[item.info.id]).toBeUndefined() + expect(store.part[message.id]).toBeUndefined() + expect(store.session_diff[item.info.id]).toBeUndefined() + expect(store.todo[item.info.id]).toBeUndefined() + expect(store.permission[item.info.id]).toBeUndefined() + expect(store.question[item.info.id]).toBeUndefined() + expect(store.session_status[item.info.id]).toBeUndefined() + } + }) + + test("cleans caches for trimmed sessions on session.created", () => { + const dropped = rootSession({ id: "ses_b" }) + const kept = rootSession({ id: "ses_a" }) + const message = userMessage("msg_1", dropped.id) + const todos: string[] = [] + const [store, setStore] = createStore( + baseState({ + limit: 1, + session: [dropped], + message: { [dropped.id]: [message] }, + part: { [message.id]: [textPart("prt_1", dropped.id, message.id)] }, + session_diff: { [dropped.id]: [] }, + todo: { [dropped.id]: [] }, + permission: { [dropped.id]: [] }, + question: { [dropped.id]: [] }, + session_status: { [dropped.id]: { type: "busy" } }, + }), + ) + + applyDirectoryEvent({ + event: { type: "session.created", properties: { info: kept } }, + store, + setStore, + push() {}, + directory: "/tmp", + loadLsp() {}, + setSessionTodo(sessionID, value) { + if (value !== undefined) return + todos.push(sessionID) + }, + }) + + expect(store.session.map((x) => x.id)).toEqual([kept.id]) + expect(store.message[dropped.id]).toBeUndefined() + expect(store.part[message.id]).toBeUndefined() + expect(store.session_diff[dropped.id]).toBeUndefined() + expect(store.todo[dropped.id]).toBeUndefined() + expect(store.permission[dropped.id]).toBeUndefined() + expect(store.question[dropped.id]).toBeUndefined() + expect(store.session_status[dropped.id]).toBeUndefined() + expect(todos).toEqual([dropped.id]) + }) + + test("cleanupDroppedSessionCaches clears part-only orphan state", () => { + const [store, setStore] = createStore( + baseState({ + session: [rootSession({ id: "ses_keep" })], + part: { msg_1: [textPart("prt_1", "ses_drop", "msg_1")] }, + }), + ) + + cleanupDroppedSessionCaches(store, setStore, store.session) + + expect(store.part.msg_1).toBeUndefined() + }) + + test("upserts and removes messages while clearing orphaned parts", () => { + const sessionID = "ses_1" + const [store, setStore] = createStore( + baseState({ + message: { [sessionID]: [userMessage("msg_z", sessionID, 1), userMessage("msg_b", sessionID, 3)] }, + part: { msg_a: [textPart("prt_1", sessionID, "msg_a")] }, + }), + ) + + applyDirectoryEvent({ + event: { type: "message.updated", properties: { info: userMessage("msg_a", sessionID, 2) } }, + store, + setStore, + push() {}, + directory: "/tmp", + loadLsp() {}, + }) + + expect(store.message[sessionID]?.map((x) => x.id)).toEqual(["msg_z", "msg_a", "msg_b"]) + + applyDirectoryEvent({ + event: { + type: "message.updated", + properties: { + info: { + ...userMessage("msg_a", sessionID, 2), + role: "assistant", + } as Message, + }, + }, + store, + setStore, + push() {}, + directory: "/tmp", + loadLsp() {}, + }) + + expect(store.message[sessionID]?.find((x) => x.id === "msg_a")?.role).toBe("assistant") + + applyDirectoryEvent({ + event: { type: "message.removed", properties: { sessionID, messageID: "msg_a" } }, + store, + setStore, + push() {}, + directory: "/tmp", + loadLsp() {}, + }) + + expect(store.message[sessionID]?.map((x) => x.id)).toEqual(["msg_z", "msg_b"]) + expect(store.part.msg_a).toBeUndefined() + }) + + test("upserts and prunes message parts", () => { + const sessionID = "ses_1" + const messageID = "msg_1" + const [store, setStore] = createStore( + baseState({ + part: { [messageID]: [textPart("prt_1", sessionID, messageID), textPart("prt_3", sessionID, messageID)] }, + }), + ) + + applyDirectoryEvent({ + event: { type: "message.part.updated", properties: { part: textPart("prt_2", sessionID, messageID) } }, + store, + setStore, + push() {}, + directory: "/tmp", + loadLsp() {}, + }) + expect(store.part[messageID]?.map((x) => x.id)).toEqual(["prt_1", "prt_2", "prt_3"]) + + applyDirectoryEvent({ + event: { + type: "message.part.updated", + properties: { + part: { + ...textPart("prt_2", sessionID, messageID), + text: "changed", + } as Part, + }, + }, + store, + setStore, + push() {}, + directory: "/tmp", + loadLsp() {}, + }) + const updated = store.part[messageID]?.find((x) => x.id === "prt_2") + expect(updated?.type).toBe("text") + if (updated?.type === "text") expect(updated.text).toBe("changed") + + applyDirectoryEvent({ + event: { type: "message.part.removed", properties: { messageID, partID: "prt_1" } }, + store, + setStore, + push() {}, + directory: "/tmp", + loadLsp() {}, + }) + applyDirectoryEvent({ + event: { type: "message.part.removed", properties: { messageID, partID: "prt_2" } }, + store, + setStore, + push() {}, + directory: "/tmp", + loadLsp() {}, + }) + applyDirectoryEvent({ + event: { type: "message.part.removed", properties: { messageID, partID: "prt_3" } }, + store, + setStore, + push() {}, + directory: "/tmp", + loadLsp() {}, + }) + + expect(store.part[messageID]).toBeUndefined() + }) + + test("tracks permission and question request lifecycles", () => { + const sessionID = "ses_1" + const [store, setStore] = createStore( + baseState({ + permission: { [sessionID]: [permissionRequest("perm_1", sessionID), permissionRequest("perm_3", sessionID)] }, + question: { [sessionID]: [questionRequest("q_1", sessionID), questionRequest("q_3", sessionID)] }, + }), + ) + + applyDirectoryEvent({ + event: { type: "permission.asked", properties: permissionRequest("perm_2", sessionID) }, + store, + setStore, + push() {}, + directory: "/tmp", + loadLsp() {}, + }) + expect(store.permission[sessionID]?.map((x) => x.id)).toEqual(["perm_1", "perm_2", "perm_3"]) + + applyDirectoryEvent({ + event: { type: "permission.asked", properties: permissionRequest("perm_2", sessionID, "updated") }, + store, + setStore, + push() {}, + directory: "/tmp", + loadLsp() {}, + }) + expect(store.permission[sessionID]?.find((x) => x.id === "perm_2")?.permission).toBe("updated") + + applyDirectoryEvent({ + event: { type: "permission.replied", properties: { sessionID, requestID: "perm_2" } }, + store, + setStore, + push() {}, + directory: "/tmp", + loadLsp() {}, + }) + expect(store.permission[sessionID]?.map((x) => x.id)).toEqual(["perm_1", "perm_3"]) + + applyDirectoryEvent({ + event: { type: "question.asked", properties: questionRequest("q_2", sessionID) }, + store, + setStore, + push() {}, + directory: "/tmp", + loadLsp() {}, + }) + expect(store.question[sessionID]?.map((x) => x.id)).toEqual(["q_1", "q_2", "q_3"]) + + applyDirectoryEvent({ + event: { type: "question.asked", properties: questionRequest("q_2", sessionID, "updated") }, + store, + setStore, + push() {}, + directory: "/tmp", + loadLsp() {}, + }) + expect(store.question[sessionID]?.find((x) => x.id === "q_2")?.questions[0]?.header).toBe("updated") + + applyDirectoryEvent({ + event: { type: "question.rejected", properties: { sessionID, requestID: "q_2" } }, + store, + setStore, + push() {}, + directory: "/tmp", + loadLsp() {}, + }) + expect(store.question[sessionID]?.map((x) => x.id)).toEqual(["q_1", "q_3"]) + }) + + test("updates vcs branch in store and cache", () => { + const [store, setStore] = createStore(baseState({ vcs: { branch: "main", default_branch: "main" } })) + const [cacheStore, setCacheStore] = createStore({ + value: { branch: "main", default_branch: "main" } as State["vcs"], + }) + + applyDirectoryEvent({ + event: { type: "vcs.branch.updated", properties: { branch: "feature/test" } }, + store, + setStore, + push() {}, + directory: "/tmp", + loadLsp() {}, + vcsCache: { + store: cacheStore, + setStore: setCacheStore, + ready: () => true, + }, + }) + + expect(store.vcs).toEqual({ branch: "feature/test", default_branch: "main" }) + expect(cacheStore.value).toEqual({ branch: "feature/test", default_branch: "main" }) + }) + + test("routes disposal and lsp events to side-effect handlers", () => { + const [store, setStore] = createStore(baseState()) + const pushes: string[] = [] + let lspLoads = 0 + + applyDirectoryEvent({ + event: { type: "server.instance.disposed" }, + store, + setStore, + push(directory) { + pushes.push(directory) + }, + directory: "/tmp", + loadLsp() { + lspLoads += 1 + }, + }) + + applyDirectoryEvent({ + event: { type: "lsp.updated" }, + store, + setStore, + push(directory) { + pushes.push(directory) + }, + directory: "/tmp", + loadLsp() { + lspLoads += 1 + }, + }) + + expect(pushes).toEqual(["/tmp"]) + expect(lspLoads).toBe(1) + }) +}) diff --git a/packages/app/src/context/global-sync/event-reducer.ts b/packages/app/src/context/global-sync/event-reducer.ts new file mode 100644 index 0000000000000000000000000000000000000000..b84d5201cf557e8eea881b0eec8e698d14a1ce76 --- /dev/null +++ b/packages/app/src/context/global-sync/event-reducer.ts @@ -0,0 +1,479 @@ +import { Binary } from "@opencode-ai/core/util/binary" +import { produce, reconcile, type SetStoreFunction, type Store } from "solid-js/store" +import type { + Message, + Part, + PermissionRequest, + Project, + QuestionRequest, + Session, + SessionStatus, + Todo, +} from "@opencode-ai/sdk/v2/client" +import type { FileDiffInfo } from "@opencode-ai/client/promise" +import type { State, VcsCache } from "./types" +import { trimSessions } from "./session-trim" +import { dropSessionCaches } from "./session-cache" +import { diffs as list, message as clean } from "@/utils/diffs" +import { messageKey } from "@/utils/session-message" + +const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"]) +const SESSION_CONTENT_EVENTS = new Set([ + "session.diff", + "todo.updated", + "session.status", + "message.updated", + "message.removed", + "message.part.updated", + "message.part.removed", + "message.part.delta", + "permission.asked", + "permission.replied", + "question.asked", + "question.replied", + "question.rejected", +]) + +export function applyGlobalEvent(input: { + event: { type: string; properties?: unknown } + project: Project[] + setGlobalProject: (next: Project[] | ((draft: Project[]) => Project[])) => void + refresh: () => void +}) { + if (input.event.type === "global.disposed" || input.event.type === "server.connected") { + input.refresh() + return + } + + if (input.event.type !== "project.updated") return + const properties = input.event.properties as Project + const result = Binary.search(input.project, properties.id, (s) => s.id) + if (result.found) { + input.setGlobalProject( + produce((draft) => { + draft[result.index] = { ...draft[result.index], ...properties } + }), + ) + return + } + input.setGlobalProject( + produce((draft) => { + draft.splice(result.index, 0, properties) + }), + ) +} + +function cleanupSessionCaches( + setStore: SetStoreFunction, + sessionID: string, + setSessionTodo?: (sessionID: string, todos: Todo[] | undefined) => void, +) { + if (!sessionID) return + setSessionTodo?.(sessionID, undefined) + setStore( + produce((draft) => { + dropSessionCaches(draft, [sessionID]) + }), + ) +} + +export function cleanupDroppedSessionCaches( + store: Store, + setStore: SetStoreFunction, + next: Session[], + setSessionTodo?: (sessionID: string, todos: Todo[] | undefined) => void, +) { + const keep = new Set(next.map((item) => item.id)) + const stale = [ + ...Object.keys(store.message), + ...Object.keys(store.session_diff), + ...Object.keys(store.todo), + ...Object.keys(store.permission), + ...Object.keys(store.question), + ...Object.keys(store.session_status), + ...Object.values(store.part) + .map((parts) => parts?.find((part) => !!part?.sessionID)?.sessionID) + .filter((sessionID): sessionID is string => !!sessionID), + ].filter((sessionID, index, list) => !keep.has(sessionID) && list.indexOf(sessionID) === index) + if (stale.length === 0) return + for (const sessionID of stale) { + setSessionTodo?.(sessionID, undefined) + } + setStore( + produce((draft) => { + dropSessionCaches(draft, stale) + }), + ) +} + +export function applyDirectoryEvent(input: { + event: { type: string; properties?: unknown } + store: Store + setStore: SetStoreFunction + push: (directory: string) => void + directory: string + loadLsp: () => void + loadReferences?: () => void + vcsCache?: VcsCache + setSessionTodo?: (sessionID: string, todos: Todo[] | undefined) => void + retainedLimit?: number + sessionContent?: boolean + permission?: State["permission"] +}) { + const event = input.event + if (input.sessionContent === false && SESSION_CONTENT_EVENTS.has(event.type)) return + const limit = Math.max(input.store.limit, input.retainedLimit ?? 0) + switch (event.type) { + case "server.instance.disposed": { + input.push(input.directory) + return + } + case "session.created": { + const info = (event.properties as { info: Session }).info + const result = Binary.search(input.store.session, info.id, (s) => s.id) + if (result.found) { + input.setStore("session", result.index, reconcile(info)) + break + } + const next = input.store.session.slice() + next.splice(result.index, 0, info) + const trimmed = trimSessions(next, { limit, permission: input.permission ?? input.store.permission }) + input.setStore("session", reconcile(trimmed, { key: "id" })) + cleanupDroppedSessionCaches(input.store, input.setStore, trimmed, input.setSessionTodo) + if (!info.parentID) input.setStore("sessionTotal", (value) => value + 1) + break + } + case "session.updated": { + const info = (event.properties as { info: Session }).info + const result = Binary.search(input.store.session, info.id, (s) => s.id) + if (info.time.archived) { + if (!result.found) break + if (input.store.session[result.index]!.time.archived === info.time.archived) break + input.setStore( + "session", + produce((draft) => { + draft.splice(result.index, 1) + }), + ) + cleanupSessionCaches(input.setStore, info.id, input.setSessionTodo) + if (info.parentID) break + input.setStore("sessionTotal", (value) => Math.max(0, value - 1)) + break + } + if (result.found) { + input.setStore("session", result.index, reconcile(info)) + break + } + const next = input.store.session.slice() + next.splice(result.index, 0, info) + const trimmed = trimSessions(next, { limit, permission: input.permission ?? input.store.permission }) + input.setStore("session", reconcile(trimmed, { key: "id" })) + cleanupDroppedSessionCaches(input.store, input.setStore, trimmed, input.setSessionTodo) + break + } + case "session.deleted": { + const properties = event.properties as { sessionID?: string; info?: Session } + const sessionID = properties.info?.id ?? properties.sessionID + if (!sessionID) break + const result = Binary.search(input.store.session, sessionID, (s) => s.id) + const info = properties.info ?? (result.found ? input.store.session[result.index] : undefined) + if (result.found) { + input.setStore( + "session", + produce((draft) => { + draft.splice(result.index, 1) + }), + ) + } + cleanupSessionCaches(input.setStore, sessionID, input.setSessionTodo) + if (info?.parentID) break + input.setStore("sessionTotal", (value) => Math.max(0, value - 1)) + break + } + case "session.renamed": { + const properties = event.properties as { sessionID: string; title: string } + const result = Binary.search(input.store.session, properties.sessionID, (session) => session.id) + if (!result.found) break + input.setStore("session", result.index, (session) => ({ + ...session, + title: properties.title, + time: { ...session.time, updated: Date.now() }, + })) + break + } + case "session.usage.updated": { + const properties = event.properties as Pick & { sessionID: string } + const result = Binary.search(input.store.session, properties.sessionID, (session) => session.id) + if (!result.found) break + input.setStore("session", result.index, (session) => ({ + ...session, + cost: properties.cost, + tokens: properties.tokens, + })) + break + } + // case "session.archived": { + // const properties = event.properties as { sessionID: string } + // const result = Binary.search(input.store.session, properties.sessionID, (session) => session.id) + // if (!result.found) break + // const info = input.store.session[result.index] + // input.setStore( + // "session", + // produce((draft) => void draft.splice(result.index, 1)), + // ) + // cleanupSessionCaches(input.setStore, properties.sessionID) + // if (!info?.parentID) input.setStore("sessionTotal", (value) => Math.max(0, value - 1)) + // break + // } + case "session.moved": { + const properties = event.properties as { + sessionID: string + location: { directory: string; workspaceID?: string } + projectID?: string + subpath?: string + } + const result = Binary.search(input.store.session, properties.sessionID, (session) => session.id) + if (!result.found) break + if (properties.location.directory === input.directory) { + input.setStore("session", result.index, (session) => ({ + ...session, + projectID: properties.projectID ?? session.projectID, + workspaceID: properties.location.workspaceID, + directory: properties.location.directory, + path: properties.subpath, + time: { ...session.time, updated: Date.now() }, + })) + break + } + const info = input.store.session[result.index] + input.setStore( + "session", + produce((draft) => void draft.splice(result.index, 1)), + ) + if (!info?.parentID) input.setStore("sessionTotal", (value) => Math.max(0, value - 1)) + break + } + case "session.diff": { + const props = event.properties as { sessionID: string; diff: FileDiffInfo[] } + input.setStore("session_diff", props.sessionID, reconcile(list(props.diff) as FileDiffInfo[], { key: "file" })) + break + } + case "todo.updated": { + const props = event.properties as { sessionID: string; todos: Todo[] } + input.setStore("todo", props.sessionID, reconcile(props.todos, { key: "id" })) + input.setSessionTodo?.(props.sessionID, props.todos) + break + } + case "session.status": { + const props = event.properties as { sessionID: string; status: SessionStatus } + input.setStore("session_status", props.sessionID, reconcile(props.status)) + break + } + case "message.updated": { + const info = clean((event.properties as { info: Message }).info) + const messages = input.store.message[info.sessionID] + if (!messages) { + input.setStore("message", info.sessionID, [info]) + break + } + const result = Binary.search(messages, messageKey(info), messageKey) + if (result.found) { + input.setStore("message", info.sessionID, result.index, reconcile(info)) + break + } + input.setStore( + "message", + info.sessionID, + produce((draft) => { + draft.splice(result.index, 0, info) + }), + ) + break + } + case "message.removed": { + const props = event.properties as { sessionID: string; messageID: string } + input.setStore( + produce((draft) => { + const messages = draft.message[props.sessionID] + if (messages) { + const index = messages.findIndex((message) => message.id === props.messageID) + if (index >= 0) messages.splice(index, 1) + } + const parts = draft.part[props.messageID] + if (parts) { + for (const part of parts) { + delete draft.part_text_accum_delta[part.id] + } + } + delete draft.part[props.messageID] + }), + ) + break + } + case "message.part.updated": { + const part = (event.properties as { part: Part }).part + if (SKIP_PARTS.has(part.type)) break + input.setStore( + produce((draft) => { + delete draft.part_text_accum_delta[part.id] + }), + ) + const parts = input.store.part[part.messageID] + if (!parts) { + input.setStore("part", part.messageID, [part]) + break + } + const result = Binary.search(parts, part.id, (item) => item.id) + if (result.found) { + input.setStore("part", part.messageID, result.index, reconcile(part)) + break + } + input.setStore( + "part", + part.messageID, + produce((draft) => { + draft.splice(result.index, 0, part) + }), + ) + break + } + case "message.part.removed": { + const props = event.properties as { messageID: string; partID: string } + input.setStore( + produce((draft) => { + delete draft.part_text_accum_delta[props.partID] + }), + ) + const parts = input.store.part[props.messageID] + if (!parts) break + const result = Binary.search(parts, props.partID, (part) => part.id) + if (result.found) { + input.setStore( + produce((draft) => { + const list = draft.part[props.messageID] + if (!list) return + const next = Binary.search(list, props.partID, (part) => part.id) + if (!next.found) return + list.splice(next.index, 1) + if (list.length === 0) delete draft.part[props.messageID] + }), + ) + } + break + } + case "message.part.delta": { + const props = event.properties as { messageID: string; partID: string; field: string; delta: string } + const parts = input.store.part[props.messageID] + if (!parts) break + const result = Binary.search(parts, props.partID, (part) => part.id) + if (!result.found) break + const field = props.field as keyof (typeof parts)[number] + const current = parts[result.index]?.[field] + input.setStore( + "part_text_accum_delta", + props.partID, + (existing) => (existing ?? (typeof current === "string" ? current : "")) + props.delta, + ) + input.setStore( + "part", + props.messageID, + produce((draft) => { + const part = draft[result.index] + const field = props.field as keyof typeof part + const existing = part[field] as string | undefined + ;(part[field] as string) = (existing ?? "") + props.delta + }), + ) + break + } + case "vcs.branch.updated": { + const props = event.properties as { branch?: string } + if (input.store.vcs?.branch === props.branch) break + const next = { ...input.store.vcs, branch: props.branch } + input.setStore("vcs", next) + if (input.vcsCache) input.vcsCache.setStore("value", next) + break + } + case "permission.asked": { + const permission = event.properties as PermissionRequest + const permissions = input.store.permission[permission.sessionID] + if (!permissions) { + input.setStore("permission", permission.sessionID, [permission]) + break + } + const result = Binary.search(permissions, permission.id, (p) => p.id) + if (result.found) { + input.setStore("permission", permission.sessionID, result.index, reconcile(permission)) + break + } + input.setStore( + "permission", + permission.sessionID, + produce((draft) => { + draft.splice(result.index, 0, permission) + }), + ) + break + } + case "permission.replied": { + const props = event.properties as { sessionID: string; requestID: string } + const permissions = input.store.permission[props.sessionID] + if (!permissions) break + const result = Binary.search(permissions, props.requestID, (p) => p.id) + if (!result.found) break + input.setStore( + "permission", + props.sessionID, + produce((draft) => { + draft.splice(result.index, 1) + }), + ) + break + } + case "question.asked": { + const question = event.properties as QuestionRequest + const questions = input.store.question[question.sessionID] + if (!questions) { + input.setStore("question", question.sessionID, [question]) + break + } + const result = Binary.search(questions, question.id, (q) => q.id) + if (result.found) { + input.setStore("question", question.sessionID, result.index, reconcile(question)) + break + } + input.setStore( + "question", + question.sessionID, + produce((draft) => { + draft.splice(result.index, 0, question) + }), + ) + break + } + case "question.replied": + case "question.rejected": { + const props = event.properties as { sessionID: string; requestID: string } + const questions = input.store.question[props.sessionID] + if (!questions) break + const result = Binary.search(questions, props.requestID, (q) => q.id) + if (!result.found) break + input.setStore( + "question", + props.sessionID, + produce((draft) => { + draft.splice(result.index, 1) + }), + ) + break + } + case "lsp.updated": { + input.loadLsp() + break + } + case "reference.updated": { + input.loadReferences?.() + break + } + } +} diff --git a/packages/app/src/context/global-sync/eviction.ts b/packages/app/src/context/global-sync/eviction.ts new file mode 100644 index 0000000000000000000000000000000000000000..676a6ee17e1fe582080f4a0b3fca43d14366b852 --- /dev/null +++ b/packages/app/src/context/global-sync/eviction.ts @@ -0,0 +1,28 @@ +import type { DisposeCheck, EvictPlan } from "./types" + +export function pickDirectoriesToEvict(input: EvictPlan) { + const overflow = Math.max(0, input.stores.length - input.max) + let pendingOverflow = overflow + const sorted = input.stores + .filter((dir) => !input.pins.has(dir)) + .slice() + .sort((a, b) => (input.state.get(a)?.lastAccessAt ?? 0) - (input.state.get(b)?.lastAccessAt ?? 0)) + const output: string[] = [] + for (const dir of sorted) { + const last = input.state.get(dir)?.lastAccessAt ?? 0 + const idle = input.now - last >= input.ttl + if (!idle && pendingOverflow <= 0) continue + output.push(dir) + if (pendingOverflow > 0) pendingOverflow -= 1 + } + return output +} + +export function canDisposeDirectory(input: DisposeCheck) { + if (!input.directory) return false + if (!input.hasStore) return false + if (input.pinned) return false + if (input.booting) return false + if (input.loadingSessions) return false + return true +} diff --git a/packages/app/src/context/global-sync/home-session-index.test.ts b/packages/app/src/context/global-sync/home-session-index.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..9b94f1de2128ae7ac22331345c1fa59377ad44ea --- /dev/null +++ b/packages/app/src/context/global-sync/home-session-index.test.ts @@ -0,0 +1,185 @@ +import { describe, expect, test } from "bun:test" +import { QueryClient } from "@tanstack/solid-query" +import type { Session, SessionV2Info } from "@opencode-ai/sdk/v2/client" +import { + applyHomeSessionEvent, + appendHomeSessionEvent, + createHomeSessionIndexCache, + HOME_V2_SESSION_PAGE_LIMIT, + loadHomeSessionIndex, + homeSessionIndexSessions, + homeSessionIndexRefresh, + parseHomeSessionIndex, + retainHomeSessions, +} from "./home-session-index" + +const session = (input: { + id: string + directory?: string + parentID?: string + archived?: number + updated?: number +}) => ({ + id: input.id, + parentID: input.parentID, + projectID: "project", + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + time: { created: 1, updated: input.updated ?? 1, archived: input.archived }, + title: input.id, + location: { directory: input.directory ?? "/project" }, +}) + +describe("Home V2 session index", () => { + test("loads the Home index with one global V2 request", async () => { + const calls: unknown[] = [] + const result = await loadHomeSessionIndex(async (input) => { + calls.push(input) + return { data: { data: [session({ id: "root" })], cursor: {} } } + }) + + expect(result.sessions).toHaveLength(1) + expect(calls).toEqual([{ limit: HOME_V2_SESSION_PAGE_LIMIT, order: "desc" }]) + }) + + test("loads subsequent pages until the session index is complete", async () => { + const calls: unknown[] = [] + const controller = new AbortController() + const result = await loadHomeSessionIndex( + async (input, options) => { + calls.push({ input, signal: options.signal }) + if (!("cursor" in input)) { + return { + data: { + data: Array.from({ length: HOME_V2_SESSION_PAGE_LIMIT }, (_, index) => + session({ id: `page-1-${index}` }), + ), + cursor: { next: "next-page" }, + }, + } + } + return { data: { data: [session({ id: "page-2" })], cursor: {} } } + }, + 0, + controller.signal, + ) + + expect(result.sessions).toHaveLength(HOME_V2_SESSION_PAGE_LIMIT + 1) + expect(calls).toEqual([ + { input: { limit: HOME_V2_SESSION_PAGE_LIMIT, order: "desc" }, signal: controller.signal }, + { + input: { limit: HOME_V2_SESSION_PAGE_LIMIT, order: "desc", cursor: "next-page" }, + signal: controller.signal, + }, + ]) + }) + + test("maps visible roots to Home session summaries", () => { + const activeNull = { + ...session({ id: "active-null", updated: 20 }), + time: { created: 1, updated: 20, archived: null }, + } as unknown as SessionV2Info + const result = parseHomeSessionIndex([ + session({ id: "root", updated: 30 }), + activeNull, + session({ id: "child", parentID: "root", updated: 40 }), + session({ id: "archived", archived: 50, updated: 50 }), + ]) + + expect(result).toEqual([ + expect.objectContaining({ + id: "root", + slug: "root", + version: "", + directory: "/project", + projectID: "project", + title: "root", + time: { created: 1, updated: 30 }, + }), + expect.objectContaining({ + id: "active-null", + time: { created: 1, updated: 20, archived: null }, + }), + ]) + }) + + test("preserves the per-directory Home retention limit", () => { + const now = 10 * 60 * 60 * 1000 + const sessions = Array.from({ length: 80 }, (_, index) => ({ + ...parseHomeSessionIndex([session({ id: `session-${index}`, updated: index + 1 })])[0], + directory: index % 2 === 0 ? "/one" : "/two", + })) + + const retained = retainHomeSessions(sessions, 10, now) + expect(retained.filter((item) => item.directory === "/one")).toHaveLength(10) + expect(retained.filter((item) => item.directory === "/two")).toHaveLength(10) + }) + + test("replays session events over the loaded index", () => { + const initial = parseHomeSessionIndex([session({ id: "old" })]) + const created = { ...initial[0], id: "new", slug: "new", title: "new", time: { created: 2, updated: 2 } } + + const afterCreate = applyHomeSessionEvent(initial, { + type: "session.created", + properties: { sessionID: created.id, info: created }, + }) + expect( + applyHomeSessionEvent(afterCreate, { + type: "session.deleted", + properties: { sessionID: initial[0]!.id, info: initial[0]! }, + }), + ).toEqual([created]) + }) + + test("applies only events newer than the index baseline", () => { + const initial = parseHomeSessionIndex([session({ id: "old" })]) + const stale = { ...initial[0], title: "stale" } + const current = { ...initial[0], title: "current" } + const first = appendHomeSessionEvent(undefined, { + type: "session.updated", + properties: { sessionID: stale.id, info: stale }, + }) + const events = appendHomeSessionEvent(first, { + type: "session.updated", + properties: { sessionID: current.id, info: current }, + }) + + expect(homeSessionIndexSessions({ sessions: initial, eventSequence: 1 }, events)[0]?.title).toBe("current") + }) + + test("refetches after reconnect, disposal, and session moves", () => { + expect(homeSessionIndexRefresh("server.connected", false)).toEqual({ connected: true, refetch: false }) + expect(homeSessionIndexRefresh("server.connected", true)).toEqual({ connected: true, refetch: true }) + expect(homeSessionIndexRefresh("global.disposed", true).refetch).toBe(true) + expect(homeSessionIndexRefresh("session.next.moved", true).refetch).toBe(true) + }) + + test("removes a session from the loaded Home index", () => { + const queryClient = new QueryClient() + const cache = createHomeSessionIndexCache(queryClient, "server") + const sessions = [ + { id: "a", time: { created: 1, updated: 1 } }, + { id: "b", time: { created: 1, updated: 1 } }, + ] as Session[] + queryClient.setQueryData(cache.indexKey, { sessions, eventSequence: 0 }) + + cache.remove("a") + + const index = queryClient.getQueryData<{ sessions: Session[] }>(cache.indexKey) + expect(index?.sessions.map((item) => item.id)).toEqual(["b"]) + }) + + test("keeps the session out of the Home list when the index is not mounted", () => { + const queryClient = new QueryClient() + const cache = createHomeSessionIndexCache(queryClient, "server") + const sessions = [ + { id: "a", time: { created: 1, updated: 1 } }, + { id: "b", time: { created: 1, updated: 1 } }, + ] as Session[] + + cache.remove("a") + + expect(queryClient.getQueryData(cache.indexKey)).toBeUndefined() + expect(cache.sessions({ sessions, eventSequence: 0 }, undefined).map((item) => item.id)).toEqual(["b"]) + }) +}) diff --git a/packages/app/src/context/global-sync/home-session-index.ts b/packages/app/src/context/global-sync/home-session-index.ts new file mode 100644 index 0000000000000000000000000000000000000000..781c39c450122375fe16201f631b5d70c9d74ceb --- /dev/null +++ b/packages/app/src/context/global-sync/home-session-index.ts @@ -0,0 +1,186 @@ +import type { Event, Session, SessionV2Info, V2SessionListResponse } from "@opencode-ai/sdk/v2/client" +import type { QueryClient } from "@tanstack/solid-query" +import { trimSessions } from "./session-trim" +import { pathKey } from "@/utils/path-key" + +export const HOME_V2_SESSION_PAGE_LIMIT = 5_000 + +export type HomeSessionEvent = { + type: "session.created" | "session.updated" | "session.deleted" + properties: { sessionID: string; info: Session } +} +export type HomeSessionEvents = { + sequence: number + entries: Array<{ sequence: number; event: HomeSessionEvent }> +} +export type HomeSessionIndex = { + sessions: Session[] + eventSequence: number +} + +export const homeSessionIndexKey = (server: string) => ["home", "session-index", server] as const +export const homeSessionEventsKey = (server: string) => ["home", "session-events", server] as const + +type HomeSessionPage = { data?: V2SessionListResponse } + +export async function loadHomeSessionIndex( + list: ( + input: { limit: number; order: "desc"; cursor?: string }, + options: { signal?: AbortSignal }, + ) => Promise, + eventSequence = 0, + signal?: AbortSignal, +) { + const data: SessionV2Info[] = [] + let cursor: string | undefined + + for (;;) { + const response = await list( + { + limit: HOME_V2_SESSION_PAGE_LIMIT, + order: "desc", + ...(cursor ? { cursor } : {}), + }, + { signal }, + ) + const page = response.data! + data.push(...page.data) + if (page.data.length < HOME_V2_SESSION_PAGE_LIMIT || !page.cursor.next) + return { sessions: parseHomeSessionIndex(data), eventSequence } + cursor = page.cursor.next + } +} + +export function appendHomeSessionEvent(current: HomeSessionEvents | undefined, event: HomeSessionEvent) { + const sequence = (current?.sequence ?? 0) + 1 + return { + sequence, + entries: [...(current?.entries ?? []), { sequence, event }], + } +} + +export function trimHomeSessionEvents(current: HomeSessionEvents | undefined, sequence: number): HomeSessionEvents { + return { + sequence: current?.sequence ?? sequence, + entries: (current?.entries ?? []).filter((entry) => entry.sequence > sequence), + } +} + +export function homeSessionIndexSessions(index: HomeSessionIndex | undefined, events: HomeSessionEvents | undefined) { + if (!index) return [] + return (events?.entries ?? []) + .filter((entry) => entry.sequence > index.eventSequence) + .reduce((sessions, entry) => applyHomeSessionEvent(sessions, entry.event), index.sessions) +} + +export function homeSessionIndexRefresh(event: Event["type"], connected: boolean) { + if (event === "server.connected") return { connected: true, refetch: connected } + return { + connected, + refetch: event === "global.disposed" || event === "session.next.moved", + } +} + +export function createHomeSessionIndexCache(queryClient: QueryClient, server: string) { + const indexKey = homeSessionIndexKey(server) + const eventsKey = homeSessionEventsKey(server) + let connected = false + const removed = new Set() + + return { + indexKey, + eventsKey, + eventSequence() { + return queryClient.getQueryData(eventsKey)?.sequence ?? 0 + }, + complete(sequence: number) { + // Keep events received after the fetch began so its response cannot overwrite them. + queryClient.setQueryData(eventsKey, (current) => trimHomeSessionEvents(current, sequence)) + }, + sessions(index: HomeSessionIndex | undefined, events: HomeSessionEvents | undefined) { + const sessions = homeSessionIndexSessions(index, events) + return removed.size === 0 ? sessions : sessions.filter((session) => !removed.has(session.id)) + }, + apply(event: HomeSessionEvent) { + if (!queryClient.getQueryState(indexKey)) return + const next = appendHomeSessionEvent(queryClient.getQueryData(eventsKey), event) + if (queryClient.isFetching({ queryKey: indexKey, exact: true }) > 0) { + queryClient.setQueryData(eventsKey, next) + return + } + + const index = queryClient.getQueryData(indexKey) + if (index) { + queryClient.setQueryData(indexKey, { + sessions: homeSessionIndexSessions(index, next), + eventSequence: next.sequence, + }) + } + queryClient.setQueryData(eventsKey, { sequence: next.sequence, entries: [] }) + }, + remove(sessionID: string) { + removed.add(sessionID) + if (!queryClient.getQueryState(indexKey)) return + queryClient.setQueryData(indexKey, (index) => { + if (!index) return index + const at = index.sessions.findIndex((session) => session.id === sessionID) + if (at === -1) return index + return { ...index, sessions: index.sessions.toSpliced(at, 1) } + }) + }, + refresh(event: Event["type"]) { + const result = homeSessionIndexRefresh(event, connected) + connected = result.connected + if (!result.refetch) return + void queryClient.refetchQueries({ queryKey: indexKey, exact: true, type: "active" }) + }, + } +} + +// TODO(v2): This deliberately dumb full-table scan is necessary because the +// current V2 API orders by creation time and cannot filter roots, archives, or +// multiple directories. A bounded page could omit an old session updated today. +// Once released, use client.v2.project.list() and client.v2.session.list({ +// parentID: null, order: "desc" }), then remove this adapter and its V1 fields. +export function parseHomeSessionIndex(sessions: SessionV2Info[]): Session[] { + return sessions.flatMap((item) => { + if (item.parentID || typeof item.time.archived === "number") return [] + return [toLegacySummary(item)] + }) +} + +export function retainHomeSessions(sessions: Session[], limit: number, now: number) { + const grouped = Map.groupBy(sessions, (session) => pathKey(session.directory)) + return [...grouped.values()].flatMap((items) => trimSessions(items, { limit, permission: {}, now })) +} + +export function applyHomeSessionEvent(sessions: Session[], event: HomeSessionEvent) { + const info = event.properties.info + const index = sessions.findIndex((session) => session.id === info.id) + if (event.type === "session.deleted" || info.parentID || typeof info.time.archived === "number") { + if (index === -1) return sessions + return sessions.toSpliced(index, 1) + } + if (event.type !== "session.created" && event.type !== "session.updated") return sessions + if (index === -1) return [...sessions, info] + return sessions.with(index, info) +} + +function toLegacySummary(session: SessionV2Info): Session { + return { + id: session.id, + slug: session.id, + projectID: session.projectID, + workspaceID: session.location.workspaceID, + directory: session.location.directory, + path: session.subpath, + parentID: session.parentID, + cost: session.cost, + tokens: session.tokens, + title: session.title, + agent: session.agent, + model: session.model, + version: "", + time: session.time, + } +} diff --git a/packages/app/src/context/global-sync/mcp.test.ts b/packages/app/src/context/global-sync/mcp.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..ebfd9738ee59035e8accfa6ae0ae4179dba533e2 --- /dev/null +++ b/packages/app/src/context/global-sync/mcp.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, test } from "bun:test" +import { toggleMcp } from "./mcp" + +describe("toggleMcp", () => { + test("runs the status action before refreshing the owning query", async () => { + const calls: string[] = [] + const input = (status: "connected" | "needs_auth" | "disabled") => ({ + status, + connect: async () => { + calls.push("connect") + }, + disconnect: async () => { + calls.push("disconnect") + }, + authenticate: async () => { + calls.push("authenticate") + }, + refresh: async () => { + calls.push("refresh") + }, + }) + + await toggleMcp(input("connected")) + expect(calls).toEqual(["disconnect", "refresh"]) + + calls.length = 0 + await toggleMcp(input("needs_auth")) + expect(calls).toEqual(["authenticate", "refresh"]) + + calls.length = 0 + await toggleMcp(input("disabled")) + expect(calls).toEqual(["connect", "refresh"]) + }) + + test("does not toggle a server while its connection is pending", async () => { + const calls: string[] = [] + await toggleMcp({ + status: "pending", + connect: async () => { + calls.push("connect") + }, + disconnect: async () => { + calls.push("disconnect") + }, + authenticate: async () => { + calls.push("authenticate") + }, + refresh: async () => { + calls.push("refresh") + }, + }) + expect(calls).toEqual([]) + }) +}) diff --git a/packages/app/src/context/global-sync/mcp.ts b/packages/app/src/context/global-sync/mcp.ts new file mode 100644 index 0000000000000000000000000000000000000000..cd91f396d0e407e3237a94b57ff093e3f847d2d3 --- /dev/null +++ b/packages/app/src/context/global-sync/mcp.ts @@ -0,0 +1,19 @@ +import type { McpServer } from "@opencode-ai/client/promise" + +export async function toggleMcp(input: { + status: McpServer["status"]["status"] + connect: () => Promise + disconnect: () => Promise + authenticate: () => Promise + refresh: () => Promise +}) { + if (input.status === "pending") return + await { + connected: input.disconnect, + needs_auth: input.authenticate, + disabled: input.connect, + failed: input.connect, + needs_client_registration: input.connect, + }[input.status]() + await input.refresh() +} diff --git a/packages/app/src/context/global-sync/queue.test.ts b/packages/app/src/context/global-sync/queue.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..c9919855ebe10f16ab78b56ca9a257cc11193c60 --- /dev/null +++ b/packages/app/src/context/global-sync/queue.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, test } from "bun:test" +import { createRefreshQueue } from "./queue" +import { directoryKey } from "./utils" + +const tick = () => new Promise((resolve) => setTimeout(resolve, 10)) + +describe("createRefreshQueue", () => { + test("clears queued directories by normalized key", async () => { + const calls: string[] = [] + const queue = createRefreshQueue({ + paused: () => false, + key: directoryKey, + bootstrap: async () => {}, + bootstrapInstance: (directory) => { + calls.push(directory) + }, + }) + + queue.push("C:\\tmp\\demo") + queue.clear("C:/tmp/demo") + + await tick() + + expect(calls).toEqual([]) + queue.dispose() + }) + + test("passes the original directory to bootstrapInstance", async () => { + const calls: string[] = [] + const queue = createRefreshQueue({ + paused: () => false, + key: directoryKey, + bootstrap: async () => {}, + bootstrapInstance: (directory) => { + calls.push(directory) + }, + }) + + queue.push("C:\\tmp\\demo") + + await tick() + + expect(calls).toEqual(["C:\\tmp\\demo"]) + queue.dispose() + }) +}) diff --git a/packages/app/src/context/global-sync/queue.ts b/packages/app/src/context/global-sync/queue.ts new file mode 100644 index 0000000000000000000000000000000000000000..947e31ac908006a715e71c3ba020b527282e3520 --- /dev/null +++ b/packages/app/src/context/global-sync/queue.ts @@ -0,0 +1,87 @@ +type QueueInput = { + paused: () => boolean + bootstrap: () => Promise + bootstrapInstance: (directory: string) => Promise | void + key?: (directory: string) => string +} + +export function createRefreshQueue(input: QueueInput) { + const queued = new Map() + let root = false + let running = false + let timer: ReturnType | undefined + + const key = input.key ?? ((directory: string) => directory) + + const tick = () => new Promise((resolve) => setTimeout(resolve, 0)) + + const take = (count: number) => { + if (queued.size === 0) return [] as string[] + const items: string[] = [] + for (const [id, directory] of queued) { + queued.delete(id) + items.push(directory) + if (items.length >= count) break + } + return items + } + + const schedule = () => { + if (timer) return + timer = setTimeout(() => { + timer = undefined + void drain() + }, 0) + } + + const push = (directory: string) => { + if (!directory) return + queued.set(key(directory), directory) + if (input.paused()) return + schedule() + } + + const refresh = () => { + root = true + if (input.paused()) return + schedule() + } + + async function drain() { + if (running) return + running = true + try { + while (true) { + if (input.paused()) return + if (root) { + root = false + await input.bootstrap() + await tick() + continue + } + const dirs = take(2) + if (dirs.length === 0) return + await Promise.all(dirs.map((dir) => input.bootstrapInstance(dir))) + await tick() + } + } finally { + running = false + // oxlint-disable-next-line no-unsafe-finally -- intentional: early return skips schedule() when paused + if (input.paused()) return + if (root || queued.size) schedule() + } + } + + return { + push, + refresh, + clear(directory: string) { + queued.delete(key(directory)) + }, + dispose() { + if (!timer) return + clearTimeout(timer) + timer = undefined + }, + } +} diff --git a/packages/app/src/context/global-sync/session-cache.test.ts b/packages/app/src/context/global-sync/session-cache.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..45fbe38abe73476cd17ffb0c2072f24efa2a16d8 --- /dev/null +++ b/packages/app/src/context/global-sync/session-cache.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, test } from "bun:test" +import type { Message, Part, PermissionRequest, QuestionRequest, SessionStatus, Todo } from "@opencode-ai/sdk/v2/client" +import type { FileDiffInfo } from "@opencode-ai/client/promise" +import { dropSessionCaches, pickSessionCacheEvictions } from "./session-cache" + +const msg = (id: string, sessionID: string) => + ({ + id, + sessionID, + role: "user", + time: { created: 1 }, + agent: "assistant", + model: { providerID: "openai", modelID: "gpt" }, + }) as Message + +const part = (id: string, sessionID: string, messageID: string) => + ({ + id, + sessionID, + messageID, + type: "text", + text: id, + }) as Part + +describe("app session cache", () => { + test("dropSessionCaches clears orphaned parts without message rows", () => { + const store: { + session_status: Record + session_diff: Record + todo: Record + message: Record + session_message: Record + part: Record + permission: Record + question: Record + part_text_accum_delta: Record + } = { + session_status: { ses_1: { type: "busy" } as SessionStatus }, + session_diff: { ses_1: [] }, + todo: { ses_1: [] as Todo[] }, + message: {}, + session_message: {}, + part: { msg_1: [part("prt_1", "ses_1", "msg_1")] }, + permission: { ses_1: [] as PermissionRequest[] }, + question: { ses_1: [] as QuestionRequest[] }, + part_text_accum_delta: { prt_1: "streamed text" }, + } + + dropSessionCaches(store, ["ses_1"]) + + expect(store.message.ses_1).toBeUndefined() + expect(store.part.msg_1).toBeUndefined() + expect(store.part_text_accum_delta.prt_1).toBeUndefined() + expect(store.todo.ses_1).toBeUndefined() + expect(store.session_diff.ses_1).toBeUndefined() + expect(store.session_status.ses_1).toBeUndefined() + expect(store.permission.ses_1).toBeUndefined() + expect(store.question.ses_1).toBeUndefined() + }) + + test("dropSessionCaches clears message-backed parts", () => { + const m = msg("msg_1", "ses_1") + const store: { + session_status: Record + session_diff: Record + todo: Record + message: Record + session_message: Record + part: Record + permission: Record + question: Record + part_text_accum_delta: Record + } = { + session_status: {}, + session_diff: {}, + todo: {}, + message: { ses_1: [m] }, + session_message: {}, + part: { [m.id]: [part("prt_1", "ses_1", m.id)] }, + permission: {}, + question: {}, + part_text_accum_delta: {}, + } + + dropSessionCaches(store, ["ses_1"]) + + expect(store.message.ses_1).toBeUndefined() + expect(store.part[m.id]).toBeUndefined() + }) + + test("pickSessionCacheEvictions preserves requested sessions", () => { + const seen = new Set(["ses_1", "ses_2", "ses_3"]) + + const stale = pickSessionCacheEvictions({ + seen, + keep: "ses_4", + limit: 2, + preserve: ["ses_1"], + }) + + expect(stale).toEqual(["ses_2", "ses_3"]) + expect([...seen]).toEqual(["ses_1", "ses_4"]) + }) +}) diff --git a/packages/app/src/context/global-sync/session-cache.ts b/packages/app/src/context/global-sync/session-cache.ts new file mode 100644 index 0000000000000000000000000000000000000000..7d684a5a1ab02908f5df949a3191c90bafa0e1a0 --- /dev/null +++ b/packages/app/src/context/global-sync/session-cache.ts @@ -0,0 +1,62 @@ +import type { Message, Part, PermissionRequest, QuestionRequest, SessionStatus, Todo } from "@opencode-ai/sdk/v2/client" +import type { FileDiffInfo } from "@opencode-ai/client/promise" +import type { SessionMessageInfo } from "@opencode-ai/client/promise" + +export const SESSION_CACHE_LIMIT = 40 + +type SessionCache = { + session_status: Record + session_diff: Record + todo: Record + message: Record + session_message: Record + part: Record + permission: Record + question: Record + part_text_accum_delta: Record +} + +export function dropSessionCaches(store: SessionCache, sessionIDs: Iterable) { + const stale = new Set(Array.from(sessionIDs).filter(Boolean)) + if (stale.size === 0) return + + for (const key of Object.keys(store.part)) { + const parts = store.part[key] + if (!parts?.some((part) => stale.has(part?.sessionID ?? ""))) continue + for (const part of parts) { + delete store.part_text_accum_delta[part.id] + } + delete store.part[key] + } + + for (const sessionID of stale) { + delete store.message[sessionID] + delete store.todo[sessionID] + delete store.session_message[sessionID] + delete store.session_diff[sessionID] + delete store.session_status[sessionID] + delete store.permission[sessionID] + delete store.question[sessionID] + } +} + +export function pickSessionCacheEvictions(input: { + seen: Set + keep: string + limit: number + preserve?: Iterable +}) { + const stale: string[] = [] + const keep = new Set([input.keep, ...Array.from(input.preserve ?? [])]) + if (input.seen.has(input.keep)) input.seen.delete(input.keep) + input.seen.add(input.keep) + for (const id of input.seen) { + if (input.seen.size - stale.length <= input.limit) break + if (keep.has(id)) continue + stale.push(id) + } + for (const id of stale) { + input.seen.delete(id) + } + return stale +} diff --git a/packages/app/src/context/global-sync/session-load.ts b/packages/app/src/context/global-sync/session-load.ts new file mode 100644 index 0000000000000000000000000000000000000000..46d8ec6a0517a365907879330f0b276dff4dfcb7 --- /dev/null +++ b/packages/app/src/context/global-sync/session-load.ts @@ -0,0 +1,33 @@ +import type { SessionApi } from "@opencode-ai/client/promise" +import { normalizeSessionInfo } from "@/utils/session" +import type { OpencodeClient } from "@opencode-ai/sdk/v2/client" + +export async function loadRootSessions(input: { api: Pick; directory: string; limit: number }) { + const result = await input.api.list({ + directory: input.directory, + parentID: null, + limit: input.limit, + order: "desc", + }) + return { + data: result.data.map(normalizeSessionInfo), + limit: input.limit, + limited: true, + } as const +} + +export async function loadRootSessionsV1(input: { client: OpencodeClient; directory: string; limit: number }) { + try { + const result = await input.client.session.list({ directory: input.directory, roots: true, limit: input.limit }) + return { data: result.data, limit: input.limit, limited: true } as const + } catch { + const result = await input.client.session.list({ directory: input.directory, roots: true }) + return { data: result.data, limit: input.limit, limited: false } as const + } +} + +export function estimateRootSessionTotal(input: { count: number; limit: number; limited: boolean }) { + if (!input.limited) return input.count + if (input.count < input.limit) return input.count + return input.count + 1 +} diff --git a/packages/app/src/context/global-sync/session-trim.test.ts b/packages/app/src/context/global-sync/session-trim.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..be12c074b5dc7e474df1066350158f3b8867ecf1 --- /dev/null +++ b/packages/app/src/context/global-sync/session-trim.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, test } from "bun:test" +import type { PermissionRequest, Session } from "@opencode-ai/sdk/v2/client" +import { trimSessions } from "./session-trim" + +const session = (input: { id: string; parentID?: string; created: number; updated?: number; archived?: number }) => + ({ + id: input.id, + parentID: input.parentID, + time: { + created: input.created, + updated: input.updated, + archived: input.archived, + }, + }) as Session + +describe("trimSessions", () => { + test("keeps base roots and recent roots beyond the limit", () => { + const now = 1_000_000 + const list = [ + session({ id: "a", created: now - 100_000 }), + session({ id: "b", created: now - 90_000 }), + session({ id: "c", created: now - 80_000 }), + session({ id: "d", created: now - 70_000, updated: now - 1_000 }), + session({ id: "e", created: now - 60_000, archived: now - 10 }), + ] + + const result = trimSessions(list, { limit: 2, permission: {}, now }) + expect(result.map((x) => x.id)).toEqual(["a", "b", "c", "d"]) + }) + + test("keeps children when root is kept, permission exists, or child is recent", () => { + const now = 1_000_000 + const list = [ + session({ id: "root-1", created: now - 1000 }), + session({ id: "root-2", created: now - 2000 }), + session({ id: "z-root", created: now - 30_000_000 }), + session({ id: "child-kept-by-root", parentID: "root-1", created: now - 20_000_000 }), + session({ id: "child-kept-by-permission", parentID: "z-root", created: now - 20_000_000 }), + session({ id: "child-kept-by-recency", parentID: "z-root", created: now - 500 }), + session({ id: "child-trimmed", parentID: "z-root", created: now - 20_000_000 }), + ] + + const result = trimSessions(list, { + limit: 2, + permission: { + "child-kept-by-permission": [{ id: "perm-1" } as PermissionRequest], + }, + now, + }) + + expect(result.map((x) => x.id)).toEqual([ + "child-kept-by-permission", + "child-kept-by-recency", + "child-kept-by-root", + "root-1", + "root-2", + ]) + }) +}) diff --git a/packages/app/src/context/global-sync/session-trim.ts b/packages/app/src/context/global-sync/session-trim.ts new file mode 100644 index 0000000000000000000000000000000000000000..ba13cb5ec0ec2ccb43f2431bd2e78d031fff1675 --- /dev/null +++ b/packages/app/src/context/global-sync/session-trim.ts @@ -0,0 +1,57 @@ +import type { PermissionRequest, Session } from "@opencode-ai/sdk/v2/client" +import { cmp } from "./utils" +import { SESSION_RECENT_LIMIT, SESSION_RECENT_WINDOW } from "./types" + +export function sessionUpdatedAt(session: Session) { + return session.time.updated ?? session.time.created +} + +export function compareSessionRecent(a: Session, b: Session) { + const aUpdated = sessionUpdatedAt(a) + const bUpdated = sessionUpdatedAt(b) + if (aUpdated !== bUpdated) return bUpdated - aUpdated + return cmp(a.id, b.id) +} + +export function takeRecentSessions(sessions: Session[], limit: number, cutoff: number) { + if (limit <= 0) return [] as Session[] + const selected: Session[] = [] + const seen = new Set() + for (const session of sessions) { + if (!session?.id) continue + if (seen.has(session.id)) continue + seen.add(session.id) + if (sessionUpdatedAt(session) <= cutoff) continue + const index = selected.findIndex((x) => compareSessionRecent(session, x) < 0) + if (index === -1) selected.push(session) + if (index !== -1) selected.splice(index, 0, session) + if (selected.length > limit) selected.pop() + } + return selected +} + +export function trimSessions( + input: Session[], + options: { limit: number; permission: Record; now?: number }, +) { + const limit = Math.max(0, options.limit) + const cutoff = (options.now ?? Date.now()) - SESSION_RECENT_WINDOW + const all = input + .filter((s) => !!s?.id) + .filter((s) => !s.time?.archived) + .sort((a, b) => cmp(a.id, b.id)) + const roots = all.filter((s) => !s.parentID) + roots.sort(compareSessionRecent) + const children = all.filter((s) => !!s.parentID) + const base = roots.slice(0, limit) + const recent = takeRecentSessions(roots.slice(limit), SESSION_RECENT_LIMIT, cutoff) + const keepRoots = [...base, ...recent] + const keepRootIds = new Set(keepRoots.map((s) => s.id)) + const keepChildren = children.filter((s) => { + if (s.parentID && keepRootIds.has(s.parentID)) return true + const perms = options.permission[s.id] ?? [] + if (perms.length > 0) return true + return sessionUpdatedAt(s) > cutoff + }) + return [...keepRoots, ...keepChildren].sort((a, b) => cmp(a.id, b.id)) +} diff --git a/packages/app/src/context/global-sync/types.ts b/packages/app/src/context/global-sync/types.ts new file mode 100644 index 0000000000000000000000000000000000000000..74191e10b0794b4cbf40ca41dd01b8567813f92c --- /dev/null +++ b/packages/app/src/context/global-sync/types.ts @@ -0,0 +1,135 @@ +import type { + Agent, + Config, + LspStatus, + Message, + Part, + Path, + PermissionRequest, + QuestionRequest, + ReferenceInfo, + Session, + SessionStatus, + Todo, + VcsInfo, +} from "@opencode-ai/sdk/v2/client" +import type { FileDiffInfo } from "@opencode-ai/client/promise" +import { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context" +import type { CommandInfo, McpResource, McpServer, SessionMessageInfo } from "@opencode-ai/client/promise" +import type { Accessor } from "solid-js" +import type { SetStoreFunction, Store } from "solid-js/store" + +export type ProjectMeta = { + name?: string + icon?: { + override?: string + color?: string + } + commands?: { + start?: string + } +} + +export type State = { + status: "loading" | "partial" | "complete" + agent: Agent[] + command: CommandInfo[] + reference: ReferenceInfo[] + project: string + projectMeta: ProjectMeta | undefined + icon: string | undefined + provider_ready: boolean + provider: NormalizedProviderListResponse + config: Config + path: Path + session: Session[] + sessionTotal: number + session_status: { + [sessionID: string]: SessionStatus + } + session_working(id: string): boolean + session_diff: { + [sessionID: string]: FileDiffInfo[] + } + todo: { + [sessionID: string]: Todo[] + } + permission: { + [sessionID: string]: PermissionRequest[] + } + question: { + [sessionID: string]: QuestionRequest[] + } + mcp_ready: boolean + mcp: { + [name: string]: McpServer["status"] + } + mcp_resource: { + [key: string]: McpResource + } + lsp_ready: boolean + lsp: LspStatus[] + vcs: VcsInfo | undefined + limit: number + message: { + [sessionID: string]: Message[] + } + session_message: { + [sessionID: string]: SessionMessageInfo[] + } + part: { + [messageID: string]: Part[] + } + part_text_accum_delta: { + [partID: string]: string + } +} + +export type VcsCache = { + store: Store<{ value: VcsInfo | undefined }> + setStore: SetStoreFunction<{ value: VcsInfo | undefined }> + ready: Accessor +} + +export type MetaCache = { + store: Store<{ value: ProjectMeta | undefined }> + setStore: SetStoreFunction<{ value: ProjectMeta | undefined }> + ready: Accessor +} + +export type IconCache = { + store: Store<{ value: string | undefined }> + setStore: SetStoreFunction<{ value: string | undefined }> + ready: Accessor +} + +export type ChildOptions = { + bootstrap?: boolean + mcp?: boolean +} + +export type DirState = { + lastAccessAt: number +} + +export type EvictPlan = { + stores: string[] + state: Map + pins: Set + max: number + ttl: number + now: number +} + +export type DisposeCheck = { + directory: string + hasStore: boolean + pinned: boolean + booting: boolean + loadingSessions: boolean +} + +export const MAX_DIR_STORES = 30 +export const DIR_IDLE_TTL_MS = 20 * 60 * 1000 +export const SESSION_RECENT_WINDOW = 4 * 60 * 60 * 1000 +export const SESSION_RECENT_LIMIT = 50 diff --git a/packages/app/src/context/global-sync/utils.test.ts b/packages/app/src/context/global-sync/utils.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..5c62a8f6a07b910bda0c26112b80cfa5e0cc8e54 --- /dev/null +++ b/packages/app/src/context/global-sync/utils.test.ts @@ -0,0 +1,138 @@ +import { describe, expect, test } from "bun:test" +import type { + AgentListOutput, + ModelDefaultOutput, + ModelListOutput, + ProviderListOutput, +} from "@opencode-ai/client/promise" +import { directoryKey, normalizeAgentList, normalizePermissionRequest, normalizeProviderList } from "./utils" + +describe("normalizeAgentList", () => { + test("adapts current agents to the app agent shape", () => { + const result = normalizeAgentList([ + { + id: "build", + name: "Build", + mode: "primary", + hidden: false, + color: "primary", + model: { id: "gpt-5", providerID: "openai", variant: "high" }, + request: { settings: { temperature: 0.2, topP: 0.9 }, headers: {}, body: {} }, + system: "Build software", + permissions: [{ action: "read", resource: "*", effect: "allow" }], + }, + ] as AgentListOutput["data"]) + + expect(result).toEqual([ + { + name: "build", + description: undefined, + mode: "primary", + hidden: false, + temperature: 0.2, + topP: 0.9, + color: "primary", + permission: [{ permission: "read", pattern: "*", action: "allow" }], + model: { providerID: "openai", modelID: "gpt-5" }, + variant: "high", + prompt: "Build software", + options: { temperature: 0.2, topP: 0.9 }, + steps: undefined, + }, + ]) + }) +}) + +describe("normalizePermissionRequest", () => { + test("adapts the current permission request to app state", () => { + expect( + normalizePermissionRequest({ + id: "permission-1", + sessionID: "session-1", + action: "read", + resources: ["README.md"], + save: ["*.md"], + metadata: { path: "README.md" }, + source: { type: "tool", messageID: "message-1", callID: "call-1" }, + }), + ).toEqual({ + id: "permission-1", + sessionID: "session-1", + permission: "read", + patterns: ["README.md"], + always: ["*.md"], + metadata: { path: "README.md" }, + tool: { messageID: "message-1", callID: "call-1" }, + }) + }) +}) + +describe("normalizeProviderList", () => { + test("groups current models into the app provider catalog", () => { + const result = normalizeProviderList( + [{ id: "openai", name: "OpenAI", package: "@ai-sdk/openai" }] as ProviderListOutput["data"], + [ + { + id: "gpt-5", + modelID: "gpt-5", + providerID: "openai", + name: "GPT-5", + capabilities: { tools: true, input: ["text", "image"], output: ["text"] }, + variants: [{ id: "high" }], + time: { released: 1 }, + cost: [{ input: 1, output: 2, cache: { read: 0.1, write: 0.2 } }], + status: "active", + enabled: true, + limit: { context: 128_000, output: 8_192 }, + }, + { + id: "gpt-old", + modelID: "gpt-old", + providerID: "openai", + name: "GPT Old", + capabilities: { tools: false, input: ["text"], output: ["text"] }, + variants: [], + time: { released: 0 }, + cost: [], + status: "deprecated", + enabled: true, + limit: { context: 1, output: 1 }, + }, + ] as ModelListOutput["data"], + { id: "gpt-5", providerID: "openai" } as ModelDefaultOutput["data"], + ) + + expect(result.connected).toEqual(["openai"]) + expect(result.defaultModel).toEqual({ providerID: "openai", modelID: "gpt-5" }) + expect(result.default).toEqual({ openai: "gpt-5" }) + expect(result.all.get("openai")?.models["gpt-old"]).toBeUndefined() + expect(result.all.get("openai")?.models["gpt-5"]).toMatchObject({ + id: "gpt-5", + providerID: "openai", + capabilities: { toolcall: true, attachment: true }, + cost: { input: 1, output: 2 }, + variants: { high: {} }, + }) + }) + + test("preserves an empty current default", () => { + expect(normalizeProviderList([] as ProviderListOutput["data"], [], null).defaultModel).toBeNull() + }) +}) + +describe("directoryKey", () => { + test("normalizes slashes", () => { + expect(String(directoryKey("C:\\Repos\\sst\\opencode"))).toBe("C:/Repos/sst/opencode") + expect(String(directoryKey("C:/Repos/sst/opencode"))).toBe("C:/Repos/sst/opencode") + }) + + test("preserves backslashes in posix paths", () => { + expect(String(directoryKey("/tmp/foo\\bar"))).toBe("/tmp/foo\\bar") + }) + + test("trims trailing slashes without breaking roots", () => { + expect(String(directoryKey("C:/Repos/sst/opencode/"))).toBe("C:/Repos/sst/opencode") + expect(String(directoryKey("C:/"))).toBe("C:/") + expect(String(directoryKey("/"))).toBe("/") + }) +}) diff --git a/packages/app/src/context/global-sync/utils.ts b/packages/app/src/context/global-sync/utils.ts new file mode 100644 index 0000000000000000000000000000000000000000..1b2c2f4d2da4840f992da92daa624f6f56241630 --- /dev/null +++ b/packages/app/src/context/global-sync/utils.ts @@ -0,0 +1,172 @@ +import type { + AgentListOutput, + ModelDefaultOutput, + ModelListOutput, + PermissionV2Request, + ProviderListOutput, +} from "@opencode-ai/client/promise" +import type { Agent, PermissionRequest, Project, Provider, ProviderListResponse } from "@opencode-ai/sdk/v2/client" +import type { Project as CurrentProject } from "@opencode-ai/client/promise" +import { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context" +export { pathKey as directoryKey, type PathKey as DirectoryKey } from "@/utils/path-key" + +export const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0) + +export function normalizeAgentList(input: AgentListOutput["data"] | Agent[]): Agent[] { + if (input.every((agent) => !("request" in agent))) return input as Agent[] + return (input as AgentListOutput["data"]).map((agent) => ({ + name: agent.id, + description: agent.description, + mode: agent.mode, + hidden: agent.hidden, + temperature: + typeof agent.request.settings.temperature === "number" ? agent.request.settings.temperature : undefined, + topP: typeof agent.request.settings.topP === "number" ? agent.request.settings.topP : undefined, + color: agent.color, + permission: agent.permissions.map((rule) => ({ + permission: rule.action, + pattern: rule.resource, + action: rule.effect, + })), + model: agent.model && { providerID: agent.model.providerID, modelID: agent.model.id }, + variant: agent.model?.variant, + prompt: agent.system, + options: agent.request.settings, + steps: agent.steps, + })) +} + +export function normalizePermissionRequest(input: PermissionV2Request | PermissionRequest): PermissionRequest { + if ("permission" in input) return input + return { + id: input.id, + sessionID: input.sessionID, + permission: input.action, + patterns: input.resources, + always: input.save ?? [], + metadata: input.metadata ?? {}, + tool: + input.source?.type === "tool" ? { messageID: input.source.messageID, callID: input.source.callID } : undefined, + } +} + +export function normalizeProviderList( + providers: ProviderListOutput["data"] | ProviderListResponse, + models?: ModelListOutput["data"], + defaultModel?: ModelDefaultOutput["data"], +): NormalizedProviderListResponse { + if (!Array.isArray(providers)) { + return { + ...providers, + all: new Map( + providers.all.map((provider) => [ + provider.id, + { + ...provider, + models: Object.fromEntries( + Object.entries(provider.models).filter(([, model]) => model.status !== "deprecated"), + ), + }, + ]), + ), + } + } + const all = new Map() + + for (const provider of providers) { + all.set(provider.id, { + id: provider.id, + name: provider.name, + source: "custom", + env: [], + options: provider.settings ?? {}, + models: {}, + }) + } + + for (const model of models ?? []) { + const provider = all.get(model.providerID) + if (!provider || model.status === "deprecated") continue + const cost = model.cost.find((item) => item.tier === undefined) ?? model.cost[0] + provider.models[model.id] = { + id: model.id, + providerID: model.providerID, + api: { + id: model.modelID, + url: "", + npm: model.package ?? provider.id, + }, + name: model.name, + family: model.family, + capabilities: { + temperature: false, + reasoning: false, + attachment: model.capabilities.input.some((item) => item !== "text"), + toolcall: model.capabilities.tools, + input: { + text: model.capabilities.input.includes("text"), + audio: model.capabilities.input.includes("audio"), + image: model.capabilities.input.includes("image"), + video: model.capabilities.input.includes("video"), + pdf: model.capabilities.input.includes("pdf"), + }, + output: { + text: model.capabilities.output.includes("text"), + audio: model.capabilities.output.includes("audio"), + image: model.capabilities.output.includes("image"), + video: model.capabilities.output.includes("video"), + pdf: model.capabilities.output.includes("pdf"), + }, + interleaved: false, + }, + cost: { + input: cost?.input ?? 0, + output: cost?.output ?? 0, + cache: { + read: cost?.cache.read ?? 0, + write: cost?.cache.write ?? 0, + }, + }, + limit: model.limit, + status: model.status, + options: model.settings ?? {}, + headers: model.headers ?? {}, + release_date: new Date(model.time.released).toISOString().slice(0, 10), + variants: Object.fromEntries(model.variants.map((variant) => [variant.id, variant.settings ?? {}])), + } + } + + return { + all, + connected: providers.map((provider) => provider.id), + defaultModel: defaultModel ? { providerID: defaultModel.providerID, modelID: defaultModel.id } : null, + default: Object.fromEntries( + providers.flatMap((provider) => { + const model = + defaultModel?.providerID === provider.id + ? defaultModel + : models?.find((item) => item.providerID === provider.id && item.status !== "deprecated") + return model ? [[provider.id, model.id]] : [] + }), + ), + } +} + +export function sanitizeProject(project: Project) { + if (!project.icon?.url && !project.icon?.override) return project + return { + ...project, + icon: { + ...project.icon, + url: undefined, + override: undefined, + }, + } +} + +export function normalizeProjectInfo(project: Project | CurrentProject): Project { + return { + ...project, + vcs: project.vcs === "git" ? "git" : undefined, + } +} diff --git a/packages/app/src/context/highlights.tsx b/packages/app/src/context/highlights.tsx new file mode 100644 index 0000000000000000000000000000000000000000..058f7cc4b6ccc93817cc13098f285977210f28c9 --- /dev/null +++ b/packages/app/src/context/highlights.tsx @@ -0,0 +1,233 @@ +import { createEffect, onCleanup } from "solid-js" +import { createStore } from "solid-js/store" +import { createSimpleContext } from "@opencode-ai/ui/context" +import { useDialog } from "@opencode-ai/ui/context/dialog" +import { usePlatform } from "@/context/platform" +import { useSettings } from "@/context/settings" +import { persisted } from "@/utils/persist" +import { DialogReleaseNotes, type Highlight } from "@/components/dialog-release-notes" + +const CHANGELOG_URL = "https://opencode.ai/changelog.json" + +type Store = { + version?: string +} + +type ParsedRelease = { + tag?: string + highlights: Highlight[] +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value) +} + +function getText(value: unknown): string | undefined { + if (typeof value === "string") { + const text = value.trim() + return text.length > 0 ? text : undefined + } + + if (typeof value === "number") return String(value) + return +} + +function normalizeVersion(value: string | undefined) { + const text = value?.trim() + if (!text) return + return text.startsWith("v") || text.startsWith("V") ? text.slice(1) : text +} + +function parseMedia(value: unknown, alt: string): Highlight["media"] | undefined { + if (!isRecord(value)) return + const type = getText(value.type)?.toLowerCase() + const src = getText(value.src) ?? getText(value.url) + if (!src) return + if (type !== "image" && type !== "video") return + + return { type, src, alt } +} + +function parseHighlight(value: unknown): Highlight | undefined { + if (!isRecord(value)) return + + const title = getText(value.title) + if (!title) return + + const description = getText(value.description) ?? getText(value.shortDescription) + if (!description) return + + const media = parseMedia(value.media, title) + return { title, description, media } +} + +function parseRelease(value: unknown): ParsedRelease | undefined { + if (!isRecord(value)) return + const tag = getText(value.tag) ?? getText(value.tag_name) ?? getText(value.name) + + if (!Array.isArray(value.highlights)) { + return { tag, highlights: [] } + } + + const highlights = value.highlights.flatMap((group) => { + if (!isRecord(group)) return [] + + const source = getText(group.source) + if (!source) return [] + if (!source.toLowerCase().includes("desktop")) return [] + + if (Array.isArray(group.items)) { + return group.items.map((item) => parseHighlight(item)).filter((item): item is Highlight => item !== undefined) + } + + const item = parseHighlight(group) + if (!item) return [] + return [item] + }) + + return { tag, highlights } +} + +function parseChangelog(value: unknown): ParsedRelease[] | undefined { + if (Array.isArray(value)) { + return value.map(parseRelease).filter((release): release is ParsedRelease => release !== undefined) + } + + if (!isRecord(value)) return + if (!Array.isArray(value.releases)) return + + return value.releases.map(parseRelease).filter((release): release is ParsedRelease => release !== undefined) +} + +function sliceHighlights(input: { releases: ParsedRelease[]; current?: string; previous?: string }) { + const current = normalizeVersion(input.current) + const previous = normalizeVersion(input.previous) + const releases = input.releases + + const start = (() => { + if (!current) return 0 + const index = releases.findIndex((release) => normalizeVersion(release.tag) === current) + return index === -1 ? 0 : index + })() + + const end = (() => { + if (!previous) return releases.length + const index = releases.findIndex((release, i) => i >= start && normalizeVersion(release.tag) === previous) + return index === -1 ? releases.length : index + })() + + const highlights = releases.slice(start, end).flatMap((release) => release.highlights) + const seen = new Set() + const unique = highlights.filter((highlight) => { + const key = dedupeKey(highlight) + if (seen.has(key)) return false + seen.add(key) + return true + }) + return unique.slice(0, 5) +} + +function dedupeKey(highlight: Highlight) { + return [highlight.title, highlight.description, highlight.media?.type ?? "", highlight.media?.src ?? ""].join("\n") +} + +function loadReleaseHighlights(value: unknown, current?: string, previous?: string) { + const releases = parseChangelog(value) + if (!releases?.length) return [] + return sliceHighlights({ releases, current, previous }) +} + +export const { use: useHighlights, provider: HighlightsProvider } = createSimpleContext({ + name: "Highlights", + gate: false, + init: () => { + const platform = usePlatform() + const dialog = useDialog() + const settings = useSettings() + const [store, setStore, _, ready] = persisted("highlights.v1", createStore({ version: undefined })) + + const [range, setRange] = createStore({ + from: undefined as string | undefined, + to: undefined as string | undefined, + }) + const state = { started: false } + let timer: ReturnType | undefined + + const clearTimer = () => { + if (timer === undefined) return + clearTimeout(timer) + timer = undefined + } + + const markSeen = () => { + if (!platform.version) return + setStore("version", platform.version) + } + + const start = (previous: string) => { + if (!settings.general.releaseNotes()) { + markSeen() + return + } + + const fetcher = platform.fetch ?? fetch + const controller = new AbortController() + onCleanup(() => { + controller.abort() + clearTimer() + }) + + fetcher(CHANGELOG_URL, { + signal: controller.signal, + headers: { Accept: "application/json" }, + }) + .then((response) => (response.ok ? (response.json() as Promise) : undefined)) + .then((json) => { + if (!json) return + const highlights = loadReleaseHighlights(json, platform.version, previous) + if (controller.signal.aborted) return + + if (highlights.length === 0) { + markSeen() + return + } + + timer = setTimeout(() => { + timer = undefined + markSeen() + dialog.show(() => ) + }, 500) + }) + .catch(() => undefined) + } + + createEffect(() => { + if (state.started) return + if (!ready()) return + if (!settings.ready()) return + if (!platform.version) return + state.started = true + + const previous = store.version + if (!previous) { + setStore("version", platform.version) + return + } + + if (previous === platform.version) return + + setRange({ from: previous, to: platform.version }) + start(previous) + }) + + return { + ready, + from: () => range.from, + to: () => range.to, + get last() { + return store.version + }, + markSeen, + } + }, +}) diff --git a/packages/app/src/context/language.tsx b/packages/app/src/context/language.tsx new file mode 100644 index 0000000000000000000000000000000000000000..e387391f68bf4edd96d2bfe4798ab1ea99d31dfb --- /dev/null +++ b/packages/app/src/context/language.tsx @@ -0,0 +1,242 @@ +import * as i18n from "@solid-primitives/i18n" +import { createEffect, createMemo, createResource } from "solid-js" +import { createStore } from "solid-js/store" +import { createSimpleContext } from "@opencode-ai/ui/context" +import { pluralCategory, type UiI18nPluralKey } from "@opencode-ai/ui/context/i18n" +import { Persist, persisted } from "@/utils/persist" +import { dict as en } from "@/i18n/en" +import { dict as uiEn } from "@opencode-ai/ui/i18n/en" +import { + createDesktopNativeBundle, + detectDesktopNativeLocale, + DESKTOP_NATIVE_ENGLISH, + DESKTOP_NATIVE_LABELS, + DESKTOP_NATIVE_LOCALES, + DESKTOP_NATIVE_LOCALE_TAGS, + type DesktopNativeBundle, + type DesktopNativeLocale, +} from "@/i18n/desktop-native" + +export type Locale = DesktopNativeLocale +export type Direction = "ltr" | "rtl" + +const RTL_LOCALES: ReadonlySet = new Set(["ar", "ur", "pa", "fa", "dv"]) + +function localeDirection(locale: Locale): Direction { + return RTL_LOCALES.has(locale) ? "rtl" : "ltr" +} + +type RawDictionary = typeof en & typeof uiEn +type Dictionary = i18n.Flatten +type PluralKey = + | UiI18nPluralKey + | "session.question.pending" + | "session.followupDock.summary" + | "session.revertDock.summary" +type Source = { dict: Record } + +function cookie(locale: Locale) { + return `oc_locale=${encodeURIComponent(locale)}; Path=/; Max-Age=31536000; SameSite=Lax` +} + +const LOCALES: readonly Locale[] = DESKTOP_NATIVE_LOCALES + +const INTL = DESKTOP_NATIVE_LOCALE_TAGS + +const base = i18n.flatten({ ...en, ...uiEn }) +const dicts = new Map([["en", base]]) + +const merge = (app: Promise, ui: Promise) => + Promise.all([app, ui]).then(([a, b]) => ({ ...base, ...i18n.flatten({ ...a.dict, ...b.dict }) }) as Dictionary) + +const loaders: Record, () => Promise> = { + zh: () => merge(import("@/i18n/zh"), import("@opencode-ai/ui/i18n/zh")), + zht: () => merge(import("@/i18n/zht"), import("@opencode-ai/ui/i18n/zht")), + ko: () => merge(import("@/i18n/ko"), import("@opencode-ai/ui/i18n/ko")), + de: () => merge(import("@/i18n/de"), import("@opencode-ai/ui/i18n/de")), + es: () => merge(import("@/i18n/es"), import("@opencode-ai/ui/i18n/es")), + fr: () => merge(import("@/i18n/fr"), import("@opencode-ai/ui/i18n/fr")), + da: () => merge(import("@/i18n/da"), import("@opencode-ai/ui/i18n/da")), + ja: () => merge(import("@/i18n/ja"), import("@opencode-ai/ui/i18n/ja")), + pl: () => merge(import("@/i18n/pl"), import("@opencode-ai/ui/i18n/pl")), + ru: () => merge(import("@/i18n/ru"), import("@opencode-ai/ui/i18n/ru")), + uk: () => merge(import("@/i18n/uk"), import("@opencode-ai/ui/i18n/uk")), + ar: () => merge(import("@/i18n/ar"), import("@opencode-ai/ui/i18n/ar")), + no: () => merge(import("@/i18n/no"), import("@opencode-ai/ui/i18n/no")), + br: () => merge(import("@/i18n/br"), import("@opencode-ai/ui/i18n/br")), + th: () => merge(import("@/i18n/th"), import("@opencode-ai/ui/i18n/th")), + bs: () => merge(import("@/i18n/bs"), import("@opencode-ai/ui/i18n/bs")), + tr: () => merge(import("@/i18n/tr"), import("@opencode-ai/ui/i18n/tr")), + hi: () => merge(import("@/i18n/hi"), import("@opencode-ai/ui/i18n/hi")), + nl: () => merge(import("@/i18n/nl"), import("@opencode-ai/ui/i18n/nl")), + id: () => merge(import("@/i18n/id"), import("@opencode-ai/ui/i18n/id")), + vi: () => merge(import("@/i18n/vi"), import("@opencode-ai/ui/i18n/vi")), + it: () => merge(import("@/i18n/it"), import("@opencode-ai/ui/i18n/it")), + ur: () => merge(import("@/i18n/ur"), import("@opencode-ai/ui/i18n/ur")), + pa: () => merge(import("@/i18n/pa"), import("@opencode-ai/ui/i18n/pa")), + az: () => merge(import("@/i18n/az"), import("@opencode-ai/ui/i18n/az")), + fi: () => merge(import("@/i18n/fi"), import("@opencode-ai/ui/i18n/fi")), + sv: () => merge(import("@/i18n/sv"), import("@opencode-ai/ui/i18n/sv")), + am: () => merge(import("@/i18n/am"), import("@opencode-ai/ui/i18n/am")), + bg: () => merge(import("@/i18n/bg"), import("@opencode-ai/ui/i18n/bg")), + bn: () => merge(import("@/i18n/bn"), import("@opencode-ai/ui/i18n/bn")), + ca: () => merge(import("@/i18n/ca"), import("@opencode-ai/ui/i18n/ca")), + cs: () => merge(import("@/i18n/cs"), import("@opencode-ai/ui/i18n/cs")), + dv: () => merge(import("@/i18n/dv"), import("@opencode-ai/ui/i18n/dv")), + dz: () => merge(import("@/i18n/dz"), import("@opencode-ai/ui/i18n/dz")), + el: () => merge(import("@/i18n/el"), import("@opencode-ai/ui/i18n/el")), + et: () => merge(import("@/i18n/et"), import("@opencode-ai/ui/i18n/et")), + fa: () => merge(import("@/i18n/fa"), import("@opencode-ai/ui/i18n/fa")), + fo: () => merge(import("@/i18n/fo"), import("@opencode-ai/ui/i18n/fo")), + hr: () => merge(import("@/i18n/hr"), import("@opencode-ai/ui/i18n/hr")), + hu: () => merge(import("@/i18n/hu"), import("@opencode-ai/ui/i18n/hu")), + hy: () => merge(import("@/i18n/hy"), import("@opencode-ai/ui/i18n/hy")), + is: () => merge(import("@/i18n/is"), import("@opencode-ai/ui/i18n/is")), + ka: () => merge(import("@/i18n/ka"), import("@opencode-ai/ui/i18n/ka")), + km: () => merge(import("@/i18n/km"), import("@opencode-ai/ui/i18n/km")), + lo: () => merge(import("@/i18n/lo"), import("@opencode-ai/ui/i18n/lo")), + lt: () => merge(import("@/i18n/lt"), import("@opencode-ai/ui/i18n/lt")), + lv: () => merge(import("@/i18n/lv"), import("@opencode-ai/ui/i18n/lv")), + mk: () => merge(import("@/i18n/mk"), import("@opencode-ai/ui/i18n/mk")), + mn: () => merge(import("@/i18n/mn"), import("@opencode-ai/ui/i18n/mn")), + ms: () => merge(import("@/i18n/ms"), import("@opencode-ai/ui/i18n/ms")), + my: () => merge(import("@/i18n/my"), import("@opencode-ai/ui/i18n/my")), + ne: () => merge(import("@/i18n/ne"), import("@opencode-ai/ui/i18n/ne")), + ro: () => merge(import("@/i18n/ro"), import("@opencode-ai/ui/i18n/ro")), + si: () => merge(import("@/i18n/si"), import("@opencode-ai/ui/i18n/si")), + sk: () => merge(import("@/i18n/sk"), import("@opencode-ai/ui/i18n/sk")), + sl: () => merge(import("@/i18n/sl"), import("@opencode-ai/ui/i18n/sl")), + sq: () => merge(import("@/i18n/sq"), import("@opencode-ai/ui/i18n/sq")), + sr: () => merge(import("@/i18n/sr"), import("@opencode-ai/ui/i18n/sr")), + tg: () => merge(import("@/i18n/tg"), import("@opencode-ai/ui/i18n/tg")), + tk: () => merge(import("@/i18n/tk"), import("@opencode-ai/ui/i18n/tk")), + uz: () => merge(import("@/i18n/uz"), import("@opencode-ai/ui/i18n/uz")), +} + +function loadDict(locale: Locale) { + const hit = dicts.get(locale) + if (hit) return Promise.resolve(hit) + if (locale === "en") return Promise.resolve(base) + const load = loaders[locale] + return load().then((next: Dictionary) => { + dicts.set(locale, next) + return next + }) +} + +export function loadLocaleDict(locale: Locale) { + return loadDict(locale).then(() => undefined) +} + +function detectLocale(): Locale { + if (typeof navigator !== "object") return "en" + return detectDesktopNativeLocale(navigator.languages?.length ? navigator.languages : [navigator.language]) +} + +export function normalizeLocale(value: string): Locale { + return LOCALES.includes(value as Locale) ? (value as Locale) : "en" +} + +function readStoredLocale() { + if (typeof localStorage !== "object") return + try { + const raw = localStorage.getItem("opencode.global.dat:language") + if (!raw) return + const next = JSON.parse(raw) as { locale?: string } + if (typeof next?.locale !== "string") return + return normalizeLocale(next.locale) + } catch { + return + } +} + +const warm = readStoredLocale() ?? detectLocale() +const initialLocale = + warm === "en" + ? Promise.resolve(warm) + : loadDict(warm).then( + () => warm, + () => "en" as const, + ) + +export function loadInitialLocale() { + return initialLocale +} + +export const { use: useLanguage, provider: LanguageProvider } = createSimpleContext({ + name: "Language", + gate: false, + init: (props: { locale?: Locale; onNativeTranslations?: (bundle: DesktopNativeBundle) => void }) => { + const initial = props.locale ?? readStoredLocale() ?? detectLocale() + const [store, setStore, _, ready] = persisted( + Persist.global("language", ["language.v1"]), + createStore({ + locale: initial, + }), + ) + + const locale = createMemo(() => normalizeLocale(store.locale)) + const intl = createMemo(() => INTL[locale()]) + const [layout, setLayout] = createStore({ direction: undefined as Direction | undefined }) + const direction = createMemo(() => layout.direction ?? localeDirection(locale())) + const layoutLocale = createMemo(() => { + if (!layout.direction) return intl() + // Kobalte derives menu direction from locale rather than accepting a direction override. + return layout.direction === "rtl" ? "ar" : "en" + }) + + const [dict] = createResource(locale, loadDict, { + initialValue: dicts.get(initial) ?? base, + }) + + const t = i18n.translator(() => dict() ?? base, i18n.resolveTemplate) as ( + key: keyof Dictionary, + params?: Record, + ) => string + + const plural = (key: PluralKey, count: number, params?: Record) => { + const category = pluralCategory(intl(), count) + const current = (dict.loading ? base : (dict() ?? base)) as Record + const candidate = `${key}.${category}` + const fallback = `${key}.other` + return i18n.resolveTemplate(current[candidate] ?? current[fallback] ?? fallback, { ...params, count }) + } + + const label = (value: Locale) => DESKTOP_NATIVE_LABELS[value] + + createEffect(() => { + if (typeof document !== "object") return + const value = locale() + document.documentElement.lang = intl() + document.documentElement.dir = direction() + document.cookie = cookie(value) + }) + + createEffect(() => { + if (!props.onNativeTranslations || dict.loading) return + const current = dict() + if (!current) return + props.onNativeTranslations( + createDesktopNativeBundle(locale(), (key) => current[key] ?? DESKTOP_NATIVE_ENGLISH[key]), + ) + }) + + return { + ready, + locale, + intl, + direction, + layoutLocale, + locales: LOCALES, + label, + t, + plural, + setLocale(next: Locale) { + setStore("locale", normalizeLocale(next)) + }, + setDirection(next: Direction) { + setLayout("direction", next === localeDirection(locale()) ? undefined : next) + }, + } + }, +}) diff --git a/packages/app/src/context/layout-helpers.ts b/packages/app/src/context/layout-helpers.ts new file mode 100644 index 0000000000000000000000000000000000000000..e2c5ce1c3c4251c0175d3f97d69c79137defe770 --- /dev/null +++ b/packages/app/src/context/layout-helpers.ts @@ -0,0 +1,38 @@ +import type { Accessor } from "solid-js" + +export function ensureSessionKey(key: string, touch: (key: string) => void, seed: (key: string) => void) { + touch(key) + seed(key) + return key +} + +export function createSessionKeyReader(sessionKey: string | Accessor, ensure: (key: string) => void) { + const key = typeof sessionKey === "function" ? sessionKey : () => sessionKey + return () => { + const value = key() + ensure(value) + return value + } +} + +export function pruneSessionKeys(input: { + keep?: string + max: number + used: Map + view: string[] + tabs: string[] +}) { + if (!input.keep) return [] + + const keys = new Set([...input.view, ...input.tabs]) + if (keys.size <= input.max) return [] + + const score = (key: string) => { + if (key === input.keep) return Number.MAX_SAFE_INTEGER + return input.used.get(key) ?? 0 + } + + return Array.from(keys) + .sort((a, b) => score(b) - score(a)) + .slice(input.max) +} diff --git a/packages/app/src/context/layout-scroll.test.ts b/packages/app/src/context/layout-scroll.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..483be150f66857e3022d33a42a83bc86e547638c --- /dev/null +++ b/packages/app/src/context/layout-scroll.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, test, vi } from "bun:test" +import { createScrollPersistence } from "./layout-scroll" + +describe("createScrollPersistence", () => { + test("debounces persisted scroll writes", () => { + vi.useFakeTimers() + try { + const snapshot = { + session: { + review: { x: 0, y: 0 }, + }, + } as Record> + const writes: Array> = [] + const scroll = createScrollPersistence({ + debounceMs: 10, + getSnapshot: (sessionKey) => snapshot[sessionKey], + onFlush: (sessionKey, next) => { + snapshot[sessionKey] = next + writes.push(next) + }, + }) + + for (const i of Array.from({ length: 30 }, (_, n) => n + 1)) { + scroll.setScroll("session", "review", { x: 0, y: i }) + } + + vi.advanceTimersByTime(9) + expect(writes).toHaveLength(0) + + vi.advanceTimersByTime(1) + + expect(writes).toHaveLength(1) + expect(writes[0]?.review).toEqual({ x: 0, y: 30 }) + + scroll.setScroll("session", "review", { x: 0, y: 30 }) + vi.advanceTimersByTime(20) + + expect(writes).toHaveLength(1) + scroll.dispose() + } finally { + vi.useRealTimers() + } + }) + + test("reseeds empty cache after persisted snapshot loads", () => { + const snapshot = { + session: {}, + } as Record> + + const scroll = createScrollPersistence({ + getSnapshot: (sessionKey) => snapshot[sessionKey], + onFlush: () => {}, + }) + + expect(scroll.scroll("session", "review")).toBeUndefined() + + snapshot.session = { + review: { x: 12, y: 34 }, + } + + expect(scroll.scroll("session", "review")).toEqual({ x: 12, y: 34 }) + scroll.dispose() + }) +}) diff --git a/packages/app/src/context/layout-scroll.ts b/packages/app/src/context/layout-scroll.ts new file mode 100644 index 0000000000000000000000000000000000000000..ef66eccd90417f2b3a458e4e4a0057c028b93844 --- /dev/null +++ b/packages/app/src/context/layout-scroll.ts @@ -0,0 +1,126 @@ +import { createStore, produce } from "solid-js/store" + +export type SessionScroll = { + x: number + y: number +} + +type ScrollMap = Record + +type Options = { + debounceMs?: number + getSnapshot: (sessionKey: string) => ScrollMap | undefined + onFlush: (sessionKey: string, scroll: ScrollMap) => void +} + +export function createScrollPersistence(opts: Options) { + const wait = opts.debounceMs ?? 200 + const [cache, setCache] = createStore>({}) + const dirty = new Set() + const timers = new Map>() + + function clone(input?: ScrollMap) { + const out: ScrollMap = {} + if (!input) return out + + for (const key of Object.keys(input)) { + const pos = input[key] + if (!pos) continue + out[key] = { x: pos.x, y: pos.y } + } + + return out + } + + function seed(sessionKey: string) { + const next = clone(opts.getSnapshot(sessionKey)) + const current = cache[sessionKey] + if (!current) { + setCache(sessionKey, next) + return + } + + if (Object.keys(current).length > 0) return + if (Object.keys(next).length === 0) return + setCache(sessionKey, next) + } + + function scroll(sessionKey: string, tab: string) { + seed(sessionKey) + return cache[sessionKey]?.[tab] ?? opts.getSnapshot(sessionKey)?.[tab] + } + + function schedule(sessionKey: string) { + const prev = timers.get(sessionKey) + if (prev) clearTimeout(prev) + timers.set( + sessionKey, + setTimeout(() => flush(sessionKey), wait), + ) + } + + function setScroll(sessionKey: string, tab: string, pos: SessionScroll) { + seed(sessionKey) + + const prev = cache[sessionKey]?.[tab] + if (prev?.x === pos.x && prev?.y === pos.y) return + + setCache(sessionKey, tab, { x: pos.x, y: pos.y }) + dirty.add(sessionKey) + schedule(sessionKey) + } + + function flush(sessionKey: string) { + const timer = timers.get(sessionKey) + if (timer) clearTimeout(timer) + timers.delete(sessionKey) + + if (!dirty.has(sessionKey)) return + dirty.delete(sessionKey) + + opts.onFlush(sessionKey, clone(cache[sessionKey])) + } + + function flushAll() { + const keys = Array.from(dirty) + if (keys.length === 0) return + + for (const key of keys) { + flush(key) + } + } + + function drop(keys: string[]) { + if (keys.length === 0) return + + for (const key of keys) { + const timer = timers.get(key) + if (timer) clearTimeout(timer) + timers.delete(key) + dirty.delete(key) + } + + setCache( + produce((draft) => { + for (const key of keys) { + delete draft[key] + } + }), + ) + } + + function dispose() { + drop(Array.from(timers.keys())) + } + + return { + cache, + drop, + flush, + flushAll, + scroll, + seed, + setScroll, + dispose, + } +} diff --git a/packages/app/src/context/layout-tabs.ts b/packages/app/src/context/layout-tabs.ts new file mode 100644 index 0000000000000000000000000000000000000000..ea4c675ff4ec6ecc6561cd2b810a74172d7abbff --- /dev/null +++ b/packages/app/src/context/layout-tabs.ts @@ -0,0 +1,103 @@ +export const SESSION_OPEN_FILE_TAB = "open-file" + +export type SessionTabs = { + active?: string + all: string[] +} + +export type SessionTabState = { + tabs: SessionTabs + preview?: string +} + +const sessionTabPreview = (current: SessionTabState) => + current.preview ?? (current.tabs.all.includes(SESSION_OPEN_FILE_TAB) ? SESSION_OPEN_FILE_TAB : undefined) + +export function previewSessionTab(current: SessionTabState, tab: string): SessionTabState { + const preview = sessionTabPreview(current) + const previewIndex = preview ? current.tabs.all.indexOf(preview) : -1 + const existingIndex = current.tabs.all.indexOf(tab) + + if (existingIndex !== -1) { + if (previewIndex === -1 || preview === tab) { + return { tabs: { all: current.tabs.all, active: tab }, preview: preview === tab ? tab : undefined } + } + return { + tabs: { all: current.tabs.all.filter((item) => item !== preview), active: tab }, + } + } + + if (previewIndex === -1) { + return { tabs: { all: [...current.tabs.all, tab], active: tab }, preview: tab } + } + + return { + tabs: { + all: current.tabs.all.map((item, index) => (index === previewIndex ? tab : item)), + active: tab, + }, + preview: tab, + } +} + +export function openSessionTab(current: SessionTabState, tab: string): SessionTabState { + const preview = sessionTabPreview(current) + if (tab === "review") { + return { + tabs: { all: current.tabs.all.filter((item) => item !== tab), active: tab }, + preview, + } + } + + if (tab === "context") { + return { + tabs: { all: [tab, ...current.tabs.all.filter((item) => item !== tab)], active: tab }, + preview, + } + } + + const previewIndex = preview ? current.tabs.all.indexOf(preview) : -1 + const existingIndex = current.tabs.all.indexOf(tab) + if (existingIndex !== -1) { + if (previewIndex === -1 || preview === tab) { + return { tabs: { all: current.tabs.all, active: tab } } + } + return { + tabs: { all: current.tabs.all.filter((item) => item !== preview), active: tab }, + } + } + + if (previewIndex === -1) { + return { tabs: { all: [...current.tabs.all, tab], active: tab } } + } + + return { + tabs: { + all: current.tabs.all.map((item, index) => (index === previewIndex ? tab : item)), + active: tab, + }, + } +} + +export function closeSessionTab(current: SessionTabState, tab: string): SessionTabState { + if (tab === "review") { + if (current.tabs.active !== tab) return current + return { + tabs: { all: current.tabs.all, active: current.tabs.all[0] }, + preview: current.preview, + } + } + + const all = current.tabs.all.filter((item) => item !== tab) + const preview = current.preview === tab ? undefined : current.preview + if (current.tabs.active !== tab) return { tabs: { ...current.tabs, all }, preview } + + const index = current.tabs.all.indexOf(tab) + return { + tabs: { + all, + active: current.tabs.all[index - 1] ?? current.tabs.all[index + 1] ?? all[0], + }, + preview, + } +} diff --git a/packages/app/src/context/layout.test.ts b/packages/app/src/context/layout.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..895c0e85e217db91e723835051879328287ef755 --- /dev/null +++ b/packages/app/src/context/layout.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, test } from "bun:test" +import { createRoot, createSignal } from "solid-js" +import { createSessionKeyReader, ensureSessionKey, pruneSessionKeys } from "./layout-helpers" + +describe("layout session-key helpers", () => { + test("couples touch and scroll seed in order", () => { + const calls: string[] = [] + const result = ensureSessionKey( + "dir/a", + (key) => calls.push(`touch:${key}`), + (key) => calls.push(`seed:${key}`), + ) + + expect(result).toBe("dir/a") + expect(calls).toEqual(["touch:dir/a", "seed:dir/a"]) + }) + + test("reads dynamic accessor keys lazily", () => { + const seen: string[] = [] + + createRoot((dispose) => { + const [key, setKey] = createSignal("dir/one") + const read = createSessionKeyReader(key, (value) => seen.push(value)) + + expect(read()).toBe("dir/one") + setKey("dir/two") + expect(read()).toBe("dir/two") + + dispose() + }) + + expect(seen).toEqual(["dir/one", "dir/two"]) + }) +}) + +describe("pruneSessionKeys", () => { + test("keeps active key and drops lowest-used keys", () => { + const drop = pruneSessionKeys({ + keep: "k4", + max: 3, + used: new Map([ + ["k1", 1], + ["k2", 2], + ["k3", 3], + ["k4", 4], + ]), + view: ["k1", "k2", "k4"], + tabs: ["k1", "k3", "k4"], + }) + + expect(drop).toEqual(["k1"]) + expect(drop.includes("k4")).toBe(false) + }) + + test("does not prune without keep key", () => { + const drop = pruneSessionKeys({ + keep: undefined, + max: 1, + used: new Map([ + ["k1", 1], + ["k2", 2], + ]), + view: ["k1"], + tabs: ["k2"], + }) + + expect(drop).toEqual([]) + }) +}) diff --git a/packages/app/src/context/layout.tsx b/packages/app/src/context/layout.tsx new file mode 100644 index 0000000000000000000000000000000000000000..6235f35c45ae14e42e8056733b917690e5fd2d7c --- /dev/null +++ b/packages/app/src/context/layout.tsx @@ -0,0 +1,1081 @@ +import { createStore, produce, reconcile } from "solid-js/store" +import { batch, createEffect, createMemo, onCleanup, onMount, type Accessor } from "solid-js" +import { useLocation } from "@solidjs/router" +import { createSimpleContext } from "@opencode-ai/ui/context" +import { makeEventListener } from "@solid-primitives/event-listener" +import { useServerSync } from "./server-sync" +import { useServerSDK } from "./server-sdk" +import { RECENTLY_CLOSED_DISPLAY_LIMIT, ServerConnection, useServer } from "./server" +import { usePlatform } from "./platform" +import { Project } from "@opencode-ai/sdk/v2" +import { normalizeProjectInfo } from "./global-sync/utils" +import { Persist, persisted, removePersisted } from "@/utils/persist" +import { pathKey } from "@/utils/path-key" +import { decode64 } from "@/utils/base64" +import { same } from "@/utils/same" +import { createScrollPersistence, type SessionScroll } from "./layout-scroll" +import { createPathHelpers } from "./file/path" +import type { ProjectAvatarVariant } from "@opencode-ai/ui/v2/project-avatar-v2" +import { migrateLegacySessionStateKeys, ServerScope, SessionStateKey } from "@/utils/server-scope" +import { createSessionKeyReader, ensureSessionKey, pruneSessionKeys } from "./layout-helpers" +import { requireServerKey } from "@/utils/session-route" +import { type DraftTab, useTabs } from "./tabs" +import { closeSessionTab, openSessionTab, previewSessionTab, type SessionTabs } from "./layout-tabs" + +export { createSessionKeyReader, ensureSessionKey, pruneSessionKeys } + +export type { ProjectAvatarVariant } + +const AVATAR_COLOR_KEYS = ["pink", "mint", "orange", "purple", "cyan", "lime"] as const +const DEFAULT_SIDEBAR_WIDTH = 344 +const DEFAULT_FILE_TREE_WIDTH = 200 +const DEFAULT_SESSION_WIDTH = 600 +const DEFAULT_TERMINAL_HEIGHT = 280 +const DEFAULT_REVIEW_PANEL_OPENED = false +export type AvatarColorKey = (typeof AVATAR_COLOR_KEYS)[number] + +export function getAvatarColors(key?: string) { + if (key && AVATAR_COLOR_KEYS.includes(key as AvatarColorKey)) { + return { + background: `var(--avatar-background-${key})`, + foreground: `var(--avatar-text-${key})`, + } + } + return { + background: "var(--surface-info-base)", + foreground: "var(--text-base)", + } +} + +export function getProjectAvatarVariant(key?: string): ProjectAvatarVariant { + if (key === "mint") return "cyan" + if (key === "lime") return "green" + if ( + key === "orange" || + key === "yellow" || + key === "cyan" || + key === "green" || + key === "red" || + key === "pink" || + key === "blue" || + key === "purple" || + key === "gray" + ) + return key + return "gray" +} + +type SessionView = { + scroll: Record + reviewOpen?: string[] + reviewMode?: ReviewChangeMode + reviewFile?: string + pendingMessage?: string + pendingMessageAt?: number + todoCollapsed?: boolean +} + +type TabHandoff = { + scope: ServerScope + dir: string + id: string + at: number +} + +export type LocalProject = Partial & { worktree: string; expanded: boolean } +export type HomeProjectSelection = { server: ServerConnection.Key; directory?: string } + +export type ReviewDiffStyle = "unified" | "split" +export type ReviewChangeMode = "git" | "branch" | "turn" +export type ReviewPanelSource = "context-button" | "other" + +export type LayoutRoute = + | { type: "home" } + | { type: "draft"; draftID: string; server?: ServerConnection.Key } + | { type: "dir-new-sesssion"; dir: string; dirBase64: string; server?: ServerConnection.Key } + | { type: "session"; sessionId: string; server?: ServerConnection.Key } + +const sessionPath = (key: string) => { + const dir = SessionStateKey.route(key).split("/")[0] + if (!dir) return + const root = decode64(dir) + if (!root) return + return createPathHelpers(() => root) +} + +const normalizeSessionTab = (path: ReturnType | undefined, tab: string) => { + if (!tab.startsWith("file://")) return tab + if (!path) return tab + return path.tab(tab) +} + +const normalizeSessionTabList = (path: ReturnType | undefined, all: string[]) => { + const seen = new Set() + return all.flatMap((tab) => { + const value = normalizeSessionTab(path, tab) + if (seen.has(value)) return [] + seen.add(value) + return [value] + }) +} + +const normalizeStoredSessionTabs = (key: string, tabs: SessionTabs) => { + const path = sessionPath(key) + return { + all: normalizeSessionTabList(path, tabs.all), + active: tabs.active ? normalizeSessionTab(path, tabs.active) : tabs.active, + } +} + +export const currentRoute = (pathname: string, search: string): LayoutRoute => { + const parts = pathname.split("/").filter(Boolean) + if (parts.length === 0) return { type: "home" } + + if (parts[0] === "new-session") { + const draftID = new URLSearchParams(search).get("draftId") + if (!draftID) return { type: "home" } + return { type: "draft", draftID } + } + + if (parts[0] === "server" && parts[2] === "session" && parts[3]) { + return { + type: "session", + sessionId: parts[3], + server: requireServerKey(parts[1]), + } + } + + const dirBase64 = parts[0] + const dir = decode64(dirBase64) + if (!dir) return { type: "home" } + + if (parts[1] !== "session") return { type: "home" } + + const id = parts[2] + if (id) return { type: "session", sessionId: id } + return { type: "dir-new-sesssion", dir, dirBase64 } +} + +export const { use: useLayout, provider: LayoutProvider } = createSimpleContext({ + name: "Layout", + gate: false, + init: () => { + const serverSdk = useServerSDK() + const serverSync = useServerSync() + const server = useServer() + const tabs = useTabs() + const platform = usePlatform() + const location = useLocation() + const route = createMemo(() => { + const value = currentRoute(location.pathname, location.search) + if (value.type === "home") return value + if (value.server) return value + if (value.type === "draft") { + const draft = tabs.store.find((tab): tab is DraftTab => tab.type === "draft" && tab.draftID === value.draftID) + if (draft) return { ...value, server: draft.server } + } + return { ...value, server: server.key } + }) + + const isRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value) + + const migrate = (value: unknown) => { + if (!isRecord(value)) return value + + const sidebar = value.sidebar + const migratedSidebar = (() => { + if (!isRecord(sidebar)) return sidebar + if (typeof sidebar.workspaces !== "boolean") return sidebar + return { + ...sidebar, + workspaces: {}, + workspacesDefault: sidebar.workspaces, + } + })() + + const review = value.review + const fileTree = value.fileTree + const migratedFileTree = (() => { + if (!isRecord(fileTree)) return fileTree + if (fileTree.tab === "changes" || fileTree.tab === "all") return fileTree + + const width = typeof fileTree.width === "number" ? fileTree.width : DEFAULT_FILE_TREE_WIDTH + return { + ...fileTree, + opened: true, + width: width === 260 ? DEFAULT_FILE_TREE_WIDTH : width, + tab: "changes", + } + })() + + const migratedReview = (() => { + if (!isRecord(review)) return review + if (typeof review.panelOpened === "boolean") return review + + const opened = + isRecord(fileTree) && typeof fileTree.opened === "boolean" ? fileTree.opened : DEFAULT_REVIEW_PANEL_OPENED + return { + ...review, + panelOpened: opened, + } + })() + + const sessionTabs = migrateLegacySessionStateKeys(value.sessionTabs) + const sessionView = migrateLegacySessionStateKeys(value.sessionView) + const migratedSessionTabs = (() => { + if (!isRecord(sessionTabs)) return sessionTabs + + let changed = false + const next = Object.fromEntries( + Object.entries(sessionTabs).map(([key, tabs]) => { + if (!isRecord(tabs) || !Array.isArray(tabs.all)) return [key, tabs] + + const current = { + all: tabs.all.filter((tab): tab is string => typeof tab === "string"), + active: typeof tabs.active === "string" ? tabs.active : undefined, + } + const normalized = normalizeStoredSessionTabs(key, current) + if (current.all.length !== tabs.all.length) changed = true + if (!same(current.all, normalized.all) || current.active !== normalized.active) changed = true + if (tabs.active !== undefined && typeof tabs.active !== "string") changed = true + return [key, normalized] + }), + ) + + if (!changed) return sessionTabs + return next + })() + + if ( + migratedSidebar === sidebar && + migratedReview === review && + migratedFileTree === fileTree && + migratedSessionTabs === value.sessionTabs && + sessionView === value.sessionView + ) { + return value + } + + return { + ...value, + sidebar: migratedSidebar, + review: migratedReview, + fileTree: migratedFileTree, + sessionTabs: migratedSessionTabs, + sessionView, + } + } + + const target = Persist.serverGlobal(serverSdk().scope, "layout", ["layout.v6"]) + const [store, setStore, _, ready] = persisted( + { ...target, migrate }, + createStore({ + sidebar: { + opened: false, + width: DEFAULT_SIDEBAR_WIDTH, + workspaces: {} as Record, + workspacesDefault: false, + }, + terminal: { + height: DEFAULT_TERMINAL_HEIGHT, + opened: false, + }, + review: { + diffStyle: "split" as ReviewDiffStyle, + panelOpened: DEFAULT_REVIEW_PANEL_OPENED, + }, + fileTree: { + opened: false, + width: DEFAULT_FILE_TREE_WIDTH, + tab: "changes" as "changes" | "all", + }, + session: { + width: DEFAULT_SESSION_WIDTH, + }, + mobileSidebar: { + opened: false, + }, + sessionTabs: {} as Record, + sessionView: {} as Record, + handoff: { + tabs: undefined as TabHandoff | undefined, + }, + home: { + selection: { server: server.key } as HomeProjectSelection, + }, + }), + ) + const [ephemeral, setEphemeral] = createStore({ + reviewPanelSource: "other" as ReviewPanelSource, + sessionTabPreview: {} as Record, + }) + + const MAX_SESSION_KEYS = 50 + const PENDING_MESSAGE_TTL_MS = 2 * 60 * 1000 + const usage = { + active: undefined as string | undefined, + pruned: false, + used: new Map(), + } + + const SESSION_STATE_KEYS = [ + { key: "prompt", legacy: "prompt", version: "v2" }, + { key: "terminal", legacy: "terminal", version: "v1" }, + { key: "file-view", legacy: "file", version: "v1" }, + ] as const + + const dropSessionState = (keys: string[]) => { + for (const key of keys) { + const scope = SessionStateKey.scope(key) + const parts = SessionStateKey.route(key).split("/") + const dir = parts[0] + const session = parts[1] + if (!dir) continue + + for (const entry of SESSION_STATE_KEYS) { + const target = session + ? Persist.serverSession(scope, dir, session, entry.key) + : Persist.serverWorkspace(scope, dir, entry.key) + void removePersisted(target, platform) + + if (scope !== ServerScope.local) continue + const legacyKey = `${dir}/${entry.legacy}${session ? "/" + session : ""}.${entry.version}` + void removePersisted({ key: legacyKey }, platform) + } + } + } + + function prune(keep?: string) { + const drop = pruneSessionKeys({ + keep, + max: MAX_SESSION_KEYS, + used: usage.used, + view: Object.keys(store.sessionView), + tabs: Object.keys(store.sessionTabs), + }) + if (drop.length === 0) return + + setStore( + produce((draft) => { + for (const key of drop) { + delete draft.sessionView[key] + delete draft.sessionTabs[key] + } + }), + ) + + scroll.drop(drop) + dropSessionState(drop) + setEphemeral( + "sessionTabPreview", + produce((draft) => { + for (const key of drop) delete draft[key] + }), + ) + + for (const key of drop) { + usage.used.delete(key) + } + } + + function touch(sessionKey: string) { + usage.active = sessionKey + usage.used.set(sessionKey, Date.now()) + + if (!ready()) return + if (usage.pruned) return + + usage.pruned = true + prune(sessionKey) + } + + const scroll = createScrollPersistence({ + debounceMs: 250, + getSnapshot: (sessionKey) => store.sessionView[sessionKey]?.scroll, + onFlush: (sessionKey, next) => { + const current = store.sessionView[sessionKey] + const keep = usage.active ?? sessionKey + if (!current) { + setStore("sessionView", sessionKey, { scroll: next }) + prune(keep) + return + } + + setStore("sessionView", sessionKey, "scroll", (prev) => ({ ...prev, ...next })) + prune(keep) + }, + }) + + const ensureKey = (key: string) => ensureSessionKey(key, touch, (sessionKey) => scroll.seed(sessionKey)) + + createEffect(() => { + if (!ready()) return + if (usage.pruned) return + const active = usage.active + if (!active) return + usage.pruned = true + prune(active) + }) + + onMount(() => { + const flush = () => batch(() => scroll.flushAll()) + const handleVisibility = () => { + if (document.visibilityState !== "hidden") return + flush() + } + + makeEventListener(window, "pagehide", flush) + makeEventListener(document, "visibilitychange", handleVisibility) + + onCleanup(() => { + scroll.dispose() + }) + }) + + const [colors, setColors] = createStore>({}) + const colorRequested = new Map() + + function pickAvailableColor(used: Set): AvatarColorKey { + const available = AVATAR_COLOR_KEYS.filter((c) => !used.has(c)) + if (available.length === 0) return AVATAR_COLOR_KEYS[Math.floor(Math.random() * AVATAR_COLOR_KEYS.length)] + return available[Math.floor(Math.random() * available.length)] + } + + function enrich(project: { worktree: string; expanded: boolean }) { + const [childStore] = serverSync().child(project.worktree, { bootstrap: false }) + const projectID = childStore.project + const metadata = projectID + ? serverSync().data.project.find((x) => x.id === projectID) + : serverSync().data.project.find((x) => x.worktree === project.worktree) + + // Preserve local icon override from per-workspace localStorage cache (childStore.icon). + // Without this, different subdirectories of the same git repo would share the same + // icon from the database instead of using their individual overrides. + const base = { ...metadata, ...project } + if (childStore.icon) { + return { ...base, icon: { ...base.icon, override: childStore.icon } } + } + return base + } + + const roots = createMemo(() => { + const map = new Map() + for (const project of serverSync().data.project) { + const sandboxes = project.sandboxes ?? [] + for (const sandbox of sandboxes) { + map.set(sandbox, project.worktree) + } + } + return map + }) + + const rootFor = (directory: string) => { + const map = roots() + if (map.size === 0) return directory + + const visited = new Set() + const chain = [directory] + + while (chain.length) { + const current = chain[chain.length - 1] + if (!current) return directory + + const next = map.get(current) + if (!next) return current + + if (visited.has(next)) return directory + visited.add(next) + chain.push(next) + } + + return directory + } + + createEffect(() => { + const projects = server.projects.list() + const seen = new Set(projects.map((project) => project.worktree)) + + batch(() => { + for (const project of projects) { + const root = rootFor(project.worktree) + if (root === project.worktree) continue + + server.projects.remove(project.worktree) + + if (!seen.has(root)) { + server.projects.open(root) + seen.add(root) + } + + if (project.expanded) server.projects.expand(root) + } + }) + }) + + const enriched = createMemo(() => server.projects.list().map(enrich)) + const list = createMemo(() => { + const projects = enriched() + return projects.map((project) => { + const color = project.icon?.color ?? colors[project.worktree] + if (!color) return project + const icon = project.icon ? { ...project.icon, color } : { color } + return { ...project, icon } + }) + }) + + createEffect(() => { + const projects = enriched() + if (projects.length === 0) return + if (!serverSync().ready) return + + for (const project of projects) { + if (!project.id) continue + if (project.id === "global") continue + serverSync().project.icon(project.worktree, project.icon?.override) + } + }) + + createEffect(() => { + const projects = enriched() + if (projects.length === 0) return + + for (const project of projects) { + if (project.icon?.color) colorRequested.delete(project.worktree) + } + + const used = new Set() + for (const project of projects) { + const color = project.icon?.color ?? colors[project.worktree] + if (color) used.add(color) + } + + for (const project of projects) { + if (project.icon?.color || project.icon?.override || project.icon?.url) continue + const worktree = project.worktree + const existing = colors[worktree] + const color = existing ?? pickAvailableColor(used) + if (!existing) { + used.add(color) + setColors(worktree, color) + } + if (!project.id) continue + + const requested = colorRequested.get(worktree) + if (requested === color) continue + colorRequested.set(worktree, color) + + if (project.id === "global") { + serverSync().project.meta(worktree, { icon: { color } }) + continue + } + + const projectID = project.id + void (async () => { + const sdk = serverSdk() + if ((await sdk.protocol) !== "v1") return + return sdk.client.project + .update({ projectID, directory: worktree, icon: { color } }) + .then((response) => response.data) + .then((result) => { + if (!result) return + serverSync().set("project", (items) => + items.map((item) => (item.id === result.id ? normalizeProjectInfo(result) : item)), + ) + }) + })().catch(() => { + if (colorRequested.get(worktree) === color) colorRequested.delete(worktree) + }) + } + }) + + let sessionFrame: number | undefined + let sessionTimer: number | undefined + + onMount(() => { + sessionFrame = requestAnimationFrame(() => { + sessionFrame = undefined + sessionTimer = window.setTimeout(() => { + sessionTimer = undefined + void Promise.all( + server.projects.list().map((project) => { + return serverSync().project.loadSessions(project.worktree) + }), + ) + }, 0) + }) + }) + + onCleanup(() => { + if (sessionFrame !== undefined) cancelAnimationFrame(sessionFrame) + if (sessionTimer !== undefined) window.clearTimeout(sessionTimer) + }) + + return { + route, + ready, + home: { + selection: createMemo(() => store.home.selection), + setSelection(selection: HomeProjectSelection) { + setStore("home", "selection", reconcile(selection)) + }, + }, + handoff: { + tabs: createMemo(() => store.handoff?.tabs), + setTabs(dir: string, id: string) { + setStore("handoff", "tabs", { scope: serverSdk().scope, dir, id, at: Date.now() }) + }, + clearTabs() { + if (!store.handoff?.tabs) return + setStore("handoff", "tabs", undefined) + }, + }, + projects: { + list, + recentlyClosed: createMemo(() => { + const known = new Set(serverSync().data.project.map((project) => pathKey(project.worktree))) + return server.projects + .recentlyClosed() + .filter((worktree) => known.has(pathKey(worktree))) + .slice(0, RECENTLY_CLOSED_DISPLAY_LIMIT) + .map((worktree) => enrich({ worktree, expanded: false })) + }), + open(directory: string) { + const root = rootFor(directory) + if (server.projects.list().find((x) => x.worktree === root)) return + void serverSync().project.loadSessions(root) + server.projects.open(root) + }, + close(directory: string) { + server.projects.close(directory) + }, + expand(directory: string) { + server.projects.expand(directory) + }, + collapse(directory: string) { + server.projects.collapse(directory) + }, + move(directory: string, toIndex: number) { + server.projects.move(directory, toIndex) + }, + }, + sidebar: { + opened: createMemo(() => store.sidebar.opened), + open() { + setStore("sidebar", "opened", true) + }, + close() { + setStore("sidebar", "opened", false) + }, + toggle() { + setStore("sidebar", "opened", (x) => !x) + }, + width: createMemo(() => store.sidebar.width), + resize(width: number) { + setStore("sidebar", "width", width) + }, + workspaces(directory: string) { + return () => store.sidebar.workspaces[directory] ?? store.sidebar.workspacesDefault ?? false + }, + setWorkspaces(directory: string, value: boolean) { + setStore("sidebar", "workspaces", directory, value) + }, + toggleWorkspaces(directory: string) { + const current = store.sidebar.workspaces[directory] ?? store.sidebar.workspacesDefault ?? false + setStore("sidebar", "workspaces", directory, !current) + }, + }, + terminal: { + height: createMemo(() => store.terminal.height), + resize(height: number) { + setStore("terminal", "height", height) + }, + }, + review: { + diffStyle: createMemo(() => store.review?.diffStyle ?? "split"), + setDiffStyle(diffStyle: ReviewDiffStyle) { + if (!store.review) { + setStore("review", { diffStyle, panelOpened: DEFAULT_REVIEW_PANEL_OPENED }) + return + } + setStore("review", "diffStyle", diffStyle) + }, + }, + fileTree: { + opened: createMemo(() => store.fileTree?.opened ?? true), + width: createMemo(() => store.fileTree?.width ?? DEFAULT_FILE_TREE_WIDTH), + tab: createMemo(() => store.fileTree?.tab ?? "changes"), + setTab(tab: "changes" | "all") { + if (!store.fileTree) { + setStore("fileTree", { opened: true, width: DEFAULT_FILE_TREE_WIDTH, tab }) + return + } + setStore("fileTree", "tab", tab) + }, + open() { + if (!store.fileTree) { + setStore("fileTree", { opened: true, width: DEFAULT_FILE_TREE_WIDTH, tab: "changes" }) + return + } + setStore("fileTree", "opened", true) + }, + close() { + if (!store.fileTree) { + setStore("fileTree", { opened: false, width: DEFAULT_FILE_TREE_WIDTH, tab: "changes" }) + return + } + setStore("fileTree", "opened", false) + }, + toggle() { + if (!store.fileTree) { + setStore("fileTree", { opened: true, width: DEFAULT_FILE_TREE_WIDTH, tab: "changes" }) + return + } + setStore("fileTree", "opened", (x) => !x) + }, + resize(width: number) { + if (!store.fileTree) { + setStore("fileTree", { opened: true, width, tab: "changes" }) + return + } + setStore("fileTree", "width", width) + }, + }, + session: { + width: createMemo(() => store.session?.width ?? DEFAULT_SESSION_WIDTH), + resize(width: number) { + if (!store.session) { + setStore("session", { width }) + return + } + setStore("session", "width", width) + }, + }, + mobileSidebar: { + opened: createMemo(() => store.mobileSidebar?.opened ?? false), + show() { + setStore("mobileSidebar", "opened", true) + }, + hide() { + setStore("mobileSidebar", "opened", false) + }, + toggle() { + setStore("mobileSidebar", "opened", (x) => !x) + }, + }, + pendingMessage: { + set(sessionKey: string, messageID: string) { + const at = Date.now() + touch(sessionKey) + const current = store.sessionView[sessionKey] + if (!current) { + setStore("sessionView", sessionKey, { + scroll: {}, + pendingMessage: messageID, + pendingMessageAt: at, + }) + prune(usage.active ?? sessionKey) + return + } + + setStore( + "sessionView", + sessionKey, + produce((draft) => { + draft.pendingMessage = messageID + draft.pendingMessageAt = at + }), + ) + }, + consume(sessionKey: string) { + const current = store.sessionView[sessionKey] + const message = current?.pendingMessage + const at = current?.pendingMessageAt + if (!message || !at) return + + setStore( + "sessionView", + sessionKey, + produce((draft) => { + delete draft.pendingMessage + delete draft.pendingMessageAt + }), + ) + + if (Date.now() - at > PENDING_MESSAGE_TTL_MS) return + return message + }, + }, + view(sessionKey: string | Accessor) { + const key = createSessionKeyReader(sessionKey, ensureKey) + const s = createMemo(() => store.sessionView[key()] ?? { scroll: {} }) + const reviewMode = createMemo(() => { + const mode = s().reviewMode + if (mode === "git" || mode === "branch" || mode === "turn") return mode + }) + const reviewFile = createMemo(() => { + const file = s().reviewFile + if (typeof file === "string") return file + }) + const terminalOpened = createMemo(() => store.terminal?.opened ?? false) + const reviewPanelOpened = createMemo(() => store.review?.panelOpened ?? DEFAULT_REVIEW_PANEL_OPENED) + const reviewPanelSource = createMemo(() => (reviewPanelOpened() ? ephemeral.reviewPanelSource : "other")) + + function setTerminalOpened(next: boolean) { + const current = store.terminal + if (!current) { + setStore("terminal", { height: DEFAULT_TERMINAL_HEIGHT, opened: next }) + return + } + + const value = current.opened ?? false + if (value === next) return + setStore("terminal", "opened", next) + } + + function setReviewPanelOpened(next: boolean, source: ReviewPanelSource) { + const nextSource = next ? source : "other" + const current = store.review + if (!current) { + batch(() => { + setStore("review", { diffStyle: "split" as ReviewDiffStyle, panelOpened: next }) + setEphemeral("reviewPanelSource", nextSource) + }) + return + } + + const value = current.panelOpened ?? DEFAULT_REVIEW_PANEL_OPENED + if (value === next) { + if (ephemeral.reviewPanelSource !== nextSource) setEphemeral("reviewPanelSource", nextSource) + return + } + batch(() => { + setStore("review", "panelOpened", next) + setEphemeral("reviewPanelSource", nextSource) + }) + } + + return { + scroll(tab: string) { + return scroll.scroll(key(), tab) + }, + setScroll(tab: string, pos: SessionScroll) { + scroll.setScroll(key(), tab, pos) + }, + todoCollapsed: { + get: () => s().todoCollapsed ?? false, + set(collapsed: boolean) { + const session = key() + const current = store.sessionView[session] + if (!current) { + setStore("sessionView", session, { scroll: {}, todoCollapsed: collapsed }) + } else { + setStore("sessionView", session, "todoCollapsed", collapsed) + } + }, + }, + terminal: { + opened: terminalOpened, + open() { + setTerminalOpened(true) + }, + close() { + setTerminalOpened(false) + }, + toggle() { + setTerminalOpened(!terminalOpened()) + }, + }, + reviewPanel: { + opened: reviewPanelOpened, + source: reviewPanelSource, + open(source: ReviewPanelSource = "other") { + setReviewPanelOpened(true, source) + }, + close() { + setReviewPanelOpened(false, "other") + }, + toggle() { + setReviewPanelOpened(!reviewPanelOpened(), "other") + }, + }, + review: { + mode: reviewMode, + setMode(mode: ReviewChangeMode) { + const session = key() + const current = store.sessionView[session] + if (!current) { + setStore("sessionView", session, { scroll: {}, reviewMode: mode }) + prune(session) + return + } + if (current.reviewMode === mode) return + setStore("sessionView", session, "reviewMode", mode) + prune(session) + }, + file: reviewFile, + setFile(file: string) { + const session = key() + const current = store.sessionView[session] + if (!current) { + setStore("sessionView", session, { scroll: {}, reviewFile: file }) + prune(session) + return + } + if (current.reviewFile === file) return + setStore("sessionView", session, "reviewFile", file) + prune(session) + }, + open: createMemo(() => s().reviewOpen ?? []), + setOpen(open: string[]) { + const session = key() + const next = Array.from(new Set(open)) + const current = store.sessionView[session] + if (!current) { + setStore("sessionView", session, { + scroll: {}, + reviewOpen: next, + }) + return + } + + if (same(current.reviewOpen, next)) return + setStore("sessionView", session, "reviewOpen", next) + }, + openPath(path: string) { + const session = key() + const current = store.sessionView[session] + if (!current) { + setStore("sessionView", session, { + scroll: {}, + reviewOpen: [path], + }) + return + } + + if (!current.reviewOpen) { + setStore("sessionView", session, "reviewOpen", [path]) + return + } + + if (current.reviewOpen.includes(path)) return + setStore("sessionView", session, "reviewOpen", current.reviewOpen.length, path) + }, + closePath(path: string) { + const session = key() + const current = store.sessionView[session]?.reviewOpen + if (!current) return + + const index = current.indexOf(path) + if (index === -1) return + setStore( + "sessionView", + session, + "reviewOpen", + produce((draft) => { + if (!draft) return + draft.splice(index, 1) + }), + ) + }, + togglePath(path: string) { + const session = key() + const current = store.sessionView[session]?.reviewOpen + if (!current || !current.includes(path)) { + this.openPath(path) + return + } + + this.closePath(path) + }, + }, + } + }, + tabs(sessionKey: string | Accessor) { + const key = createSessionKeyReader(sessionKey, ensureKey) + const path = createMemo(() => sessionPath(key())) + const tabs = createMemo(() => store.sessionTabs[key()] ?? { all: [] }) + const normalize = (tab: string) => normalizeSessionTab(path(), tab) + const normalizeAll = (all: string[]) => normalizeSessionTabList(path(), all) + const apply = (session: string, next: ReturnType) => { + batch(() => { + setStore("sessionTabs", session, next.tabs) + setEphemeral("sessionTabPreview", session, next.preview) + }) + } + return { + tabs, + active: createMemo(() => tabs().active), + all: createMemo(() => tabs().all.filter((tab) => tab !== "review")), + preview: createMemo(() => ephemeral.sessionTabPreview[key()]), + setActive(tab: string | undefined) { + const session = key() + const next = tab ? normalize(tab) : tab + if (!store.sessionTabs[session]) { + setStore("sessionTabs", session, { all: [], active: next }) + } else { + setStore("sessionTabs", session, "active", next) + } + }, + setAll(all: string[]) { + const session = key() + const next = normalizeAll(all).filter((tab) => tab !== "review") + batch(() => { + if (!store.sessionTabs[session]) { + setStore("sessionTabs", session, { all: next, active: undefined }) + } else { + setStore("sessionTabs", session, "all", next) + } + const preview = ephemeral.sessionTabPreview[session] + if (preview && !next.includes(preview)) setEphemeral("sessionTabPreview", session, undefined) + }) + }, + async open(tab: string) { + const session = key() + apply( + session, + openSessionTab( + { tabs: store.sessionTabs[session] ?? { all: [] }, preview: ephemeral.sessionTabPreview[session] }, + normalize(tab), + ), + ) + }, + previewTab(tab: string) { + const session = key() + apply( + session, + previewSessionTab( + { tabs: store.sessionTabs[session] ?? { all: [] }, preview: ephemeral.sessionTabPreview[session] }, + normalize(tab), + ), + ) + }, + close(tab: string) { + const session = key() + const current = store.sessionTabs[session] + if (!current) return + apply( + session, + closeSessionTab({ tabs: current, preview: ephemeral.sessionTabPreview[session] }, normalize(tab)), + ) + }, + move(tab: string, to: number) { + const session = key() + const current = store.sessionTabs[session] + if (!current) return + const index = current.all.findIndex((f) => f === tab) + if (index === -1) return + setStore( + "sessionTabs", + session, + "all", + produce((opened) => { + opened.splice(to, 0, opened.splice(index, 1)[0]) + }), + ) + }, + } + }, + } + }, +}) diff --git a/packages/app/src/context/local-agent.test.ts b/packages/app/src/context/local-agent.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..c7a95f9a5b13c7209bd08e01498907e73ebae7c6 --- /dev/null +++ b/packages/app/src/context/local-agent.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, test } from "bun:test" +import { hasCustomAgent, resolveAgent } from "./local-agent" + +describe("hasCustomAgent", () => { + test("detects explicitly custom agents", () => { + expect(hasCustomAgent([{ native: true }, { native: false }])).toBe(true) + }) + + test("ignores built-in and unclassified agents", () => { + expect(hasCustomAgent([{ native: true }, {}])).toBe(false) + }) +}) + +describe("resolveAgent", () => { + const agents = [{ name: "plan" }, { name: "build" }, { name: "custom" }] + + test("uses the requested available agent", () => { + expect(resolveAgent(agents, "custom")?.name).toBe("custom") + }) + + test("defaults to build", () => { + expect(resolveAgent(agents)?.name).toBe("build") + expect(resolveAgent(agents, "missing")?.name).toBe("build") + }) + + test("uses the first agent when build is unavailable", () => { + expect(resolveAgent([{ name: "custom" }], "missing")?.name).toBe("custom") + }) +}) diff --git a/packages/app/src/context/local-agent.ts b/packages/app/src/context/local-agent.ts new file mode 100644 index 0000000000000000000000000000000000000000..f5c76d146955a3f3a0036c5e6dce5d0b1a60dbd3 --- /dev/null +++ b/packages/app/src/context/local-agent.ts @@ -0,0 +1,7 @@ +export function hasCustomAgent(items: Array<{ native?: boolean }>) { + return items.some((item) => item.native === false) +} + +export function resolveAgent(items: T[], name?: string) { + return items.find((item) => item.name === name) ?? items.find((item) => item.name === "build") ?? items[0] +} diff --git a/packages/app/src/context/local.tsx b/packages/app/src/context/local.tsx new file mode 100644 index 0000000000000000000000000000000000000000..b99ce2db9085e7c57157335269cd951fc177fdc2 --- /dev/null +++ b/packages/app/src/context/local.tsx @@ -0,0 +1,416 @@ +import { createSimpleContext } from "@opencode-ai/ui/context" +import { base64Encode } from "@opencode-ai/core/util/encode" +import { useParams } from "@solidjs/router" +import { batch, createEffect, createMemo, startTransition } from "solid-js" +import { createStore } from "solid-js/store" +import { useModels } from "@/context/models" +import { useSettings } from "@/context/settings" +import { useProviders } from "@/hooks/use-providers" +import { resolveDefaultModel } from "@/hooks/provider-catalog" +import { Persist, persisted } from "@/utils/persist" +import { hasCustomAgent, resolveAgent } from "./local-agent" +import { cycleModelVariant, getConfiguredAgentVariant, resolveModelVariant } from "./model-variant" +import { useSDK } from "./sdk" +import { useSync } from "./sync" +import { useServerSDK } from "./server-sdk" +import { ScopedKey, type ServerScope } from "@/utils/server-scope" + +export type ModelKey = { providerID: string; modelID: string; variant?: string } + +type State = { + agent?: string + model?: ModelKey + variant?: string | null +} + +type Saved = { + session: Record +} + +const WORKSPACE_KEY = "__workspace__" +const handoff = new Map() + +const handoffKey = (scope: ServerScope, dir: string, id: string) => ScopedKey.from(scope, dir, id) + +const migrate = (value: unknown) => { + if (!value || typeof value !== "object") return { session: {} } + + const item = value as { + session?: Record + pick?: Record + } + + if (item.session && typeof item.session === "object") return { session: item.session } + if (!item.pick || typeof item.pick !== "object") return { session: {} } + + return { + session: Object.fromEntries(Object.entries(item.pick).filter(([key]) => key !== WORKSPACE_KEY)), + } +} + +const clone = (value: State | undefined) => { + if (!value) return + return { + ...value, + model: value.model ? { ...value.model } : undefined, + } satisfies State +} + +export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ + name: "Local", + init: () => { + const params = useParams() + const sdk = useSDK() + const sync = useSync() + const serverSDK = useServerSDK() + const providers = useProviders(() => sdk().directory) + const models = useModels() + const settings = useSettings() + + const id = createMemo(() => params.id || undefined) + const list = createMemo(() => sync().data.agent.filter((item) => item.mode !== "subagent" && !item.hidden)) + const agentsVisible = createMemo(() => settings.visibility.customAgents() || hasCustomAgent(list())) + const connected = createMemo(() => new Set(providers.connected().map((item) => item.id))) + + const [saved, setSaved, , savedReady] = persisted( + { + ...Persist.serverWorkspace(serverSDK().scope, sdk().directory, "model-selection", ["model-selection.v1"]), + migrate, + }, + createStore({ + session: {}, + }), + ) + + const [store, setStore] = createStore<{ + current?: string + draft?: State + promoting?: State + last?: { + type: "agent" | "model" | "variant" + agent?: string + model?: ModelKey | null + variant?: string | null + } + }>({ + current: list()[0]?.name, + draft: undefined, + last: undefined, + }) + + const validModel = (model: ModelKey) => { + const provider = providers.all().get(model.providerID) + return !!provider?.models[model.modelID] && connected().has(model.providerID) + } + + const firstModel = (...items: Array<() => ModelKey | undefined>) => { + for (const item of items) { + const model = item() + if (!model) continue + if (validModel(model)) return model + } + } + + const pickAgent = (name: string | undefined) => { + return resolveAgent(list(), name) + } + + createEffect(() => { + const items = list() + if (items.length === 0) { + if (store.current !== undefined) setStore("current", undefined) + return + } + if (items.some((item) => item.name === store.current)) return + setStore("current", items[0]?.name) + }) + + const scope = createMemo(() => { + const session = id() + if (!session) return store.draft ?? store.promoting + return saved.session[session] ?? handoff.get(handoffKey(serverSDK().scope, sdk().directory, session)) + }) + + createEffect(() => { + const session = id() + if (!session) return + + const key = handoffKey(serverSDK().scope, sdk().directory, session) + const next = handoff.get(key) + if (!next) return + if (saved.session[session] !== undefined) { + handoff.delete(key) + setStore("promoting", undefined) + return + } + + setSaved("session", session, clone(next)) + handoff.delete(key) + setStore("promoting", undefined) + }) + + const configuredModel = () => { + const model = resolveDefaultModel(providers.defaultModel(), sync().data.config.model) + if (!model) return + if (validModel(model)) return model + } + + const recentModel = () => { + for (const item of models.recent.list()) { + if (validModel(item)) return item + } + } + + const defaultModel = () => { + const defaults = providers.default() + for (const provider of providers.connected()) { + const configured = defaults[provider.id] + if (configured) { + const model = { providerID: provider.id, modelID: configured } + if (validModel(model)) return model + } + + const first = Object.values(provider.models)[0] + if (!first) continue + const model = { providerID: provider.id, modelID: first.id } + if (validModel(model)) return model + } + } + + const fallback = createMemo(() => configuredModel() ?? recentModel() ?? defaultModel()) + + const agent = { + list, + visible: agentsVisible, + current() { + return pickAgent(agentsVisible() ? (scope()?.agent ?? store.current) : "build") + }, + set(name: string | undefined) { + const item = pickAgent(name) + if (!item) { + setStore("current", undefined) + return + } + + batch(() => { + setStore("current", item.name) + setStore("last", { + type: "agent", + agent: item.name, + model: item.model, + variant: item.variant ?? null, + }) + const prev = scope() + const next = { + agent: item.name, + model: item.model ?? prev?.model, + variant: item.variant ?? prev?.variant, + } satisfies State + const session = id() + if (session) { + setSaved("session", session, next) + return + } + setStore("draft", next) + }) + }, + move(direction: 1 | -1) { + const items = list() + if (items.length === 0) { + setStore("current", undefined) + return + } + + let next = items.findIndex((item) => item.name === agent.current()?.name) + direction + if (next < 0) next = items.length - 1 + if (next >= items.length) next = 0 + const item = items[next] + if (!item) return + agent.set(item.name) + }, + } + + const current = () => { + const item = firstModel( + () => scope()?.model, + () => agent.current()?.model, + fallback, + ) + if (!item) return + return models.find(item) + } + + const configured = () => { + const item = agent.current() + const model = current() + if (!item || !model) return + return getConfiguredAgentVariant({ + agent: { model: item.model, variant: item.variant }, + model: { providerID: model.provider.id, modelID: model.id, variants: model.variants }, + }) + } + + const selected = () => scope()?.variant + + const snapshot = () => { + const model = current() + return { + agent: agent.current()?.name, + model: model ? { providerID: model.provider.id, modelID: model.id } : undefined, + variant: selected(), + } satisfies State + } + + const write = (next: Partial) => { + const state = { + ...(scope() ?? { agent: agent.current()?.name }), + ...next, + } satisfies State + + const session = id() + if (session) { + setSaved("session", session, state) + return + } + setStore("draft", state) + } + + const recent = createMemo(() => models.recent.list().map(models.find).filter(Boolean)) + + const model = { + ready: models.ready, + current, + recent, + list: models.list, + cycle(direction: 1 | -1) { + const items = recent() + const item = current() + if (!item) return + + const index = items.findIndex((entry) => entry?.provider.id === item.provider.id && entry?.id === item.id) + if (index === -1) return + + let next = index + direction + if (next < 0) next = items.length - 1 + if (next >= items.length) next = 0 + + const entry = items[next] + if (!entry) return + model.set({ providerID: entry.provider.id, modelID: entry.id }) + }, + set(item: ModelKey | undefined, options?: { recent?: boolean }) { + startTransition(() => + batch(() => { + setStore("last", { + type: "model", + agent: agent.current()?.name, + model: item ?? null, + variant: selected(), + }) + write({ model: item }) + if (!item) return + models.setVisibility(item, true) + if (!options?.recent) return + models.recent.push(item) + }), + ) + }, + visible(item: ModelKey) { + return models.visible(item) + }, + setVisibility(item: ModelKey, visible: boolean) { + models.setVisibility(item, visible) + }, + variant: { + configured, + selected, + current() { + const resolved = resolveModelVariant({ + variants: this.list(), + selected: this.selected(), + configured: this.configured(), + }) + if (resolved) return resolved + const model = current() + if (!model) return + const saved = models.variant.get({ providerID: model.provider.id, modelID: model.id }) + if (saved && this.list().includes(saved)) return saved + }, + list() { + const item = current() + if (!item?.variants) return [] + return Object.keys(item.variants) + }, + set(value: string | undefined) { + startTransition(() => + batch(() => { + const model = current() + setStore("last", { + type: "variant", + agent: agent.current()?.name, + model: model ? { providerID: model.provider.id, modelID: model.id } : null, + variant: value ?? null, + }) + write({ variant: value ?? null }) + if (model) { + models.variant.set({ providerID: model.provider.id, modelID: model.id }, value ?? undefined) + } + }), + ) + }, + cycle() { + const items = this.list() + if (items.length === 0) return + this.set( + cycleModelVariant({ + variants: items, + selected: this.selected(), + configured: this.configured(), + }), + ) + }, + }, + } + + const result = { + slug: createMemo(() => base64Encode(sdk().directory)), + model, + agent, + session: { + ready: savedReady, + reset() { + setStore({ draft: undefined, promoting: undefined }) + }, + promote(dir: string, session: string, state?: State) { + const next = clone(state ?? snapshot()) + if (!next) return + const key = handoffKey(serverSDK().scope, dir, session) + handoff.set(key, next) + + if (dir === sdk().directory) { + setSaved("session", session, next) + } + + setStore("promoting", next) + setStore("draft", undefined) + }, + restore(msg: { sessionID: string; agent: string; model: ModelKey }) { + const session = id() + if (!session) return + if (msg.sessionID !== session) return + if (saved.session[session] !== undefined) return + if (handoff.has(handoffKey(serverSDK().scope, sdk().directory, session))) return + + setSaved("session", session, { + agent: msg.agent, + model: msg.model, + variant: msg.model?.variant ?? null, + }) + }, + }, + } + return result + }, +}) + +export type ModelSelection = ReturnType["model"] diff --git a/packages/app/src/context/mcp.ts b/packages/app/src/context/mcp.ts new file mode 100644 index 0000000000000000000000000000000000000000..af34b49886e45627dfac73e19ff8297d79249e11 --- /dev/null +++ b/packages/app/src/context/mcp.ts @@ -0,0 +1,19 @@ +import { useMutation } from "@tanstack/solid-query" +import { useLanguage } from "@/context/language" +import { useSync } from "@/context/sync" +import { showToast } from "@/utils/toast" + +export function useMcpToggle() { + const sync = useSync() + const language = useLanguage() + + return useMutation(() => ({ + mutationFn: sync().mcp.toggle, + onError: (error) => + showToast({ + variant: "error", + title: language.t("common.requestFailed"), + description: error instanceof Error ? error.message : String(error), + }), + })) +} diff --git a/packages/app/src/context/model-variant.test.ts b/packages/app/src/context/model-variant.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..583bc5c3dc71f8d090b30135b51e59568c0d7a2f --- /dev/null +++ b/packages/app/src/context/model-variant.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, test } from "bun:test" +import { cycleModelVariant, getConfiguredAgentVariant, resolveModelVariant } from "./model-variant" + +describe("model variant", () => { + test("resolves configured agent variant when model matches", () => { + const value = getConfiguredAgentVariant({ + agent: { + model: { providerID: "openai", modelID: "gpt-5.2" }, + variant: "xhigh", + }, + model: { + providerID: "openai", + modelID: "gpt-5.2", + variants: { low: {}, high: {}, xhigh: {} }, + }, + }) + + expect(value).toBe("xhigh") + }) + + test("ignores configured variant when model does not match", () => { + const value = getConfiguredAgentVariant({ + agent: { + model: { providerID: "openai", modelID: "gpt-5.2" }, + variant: "xhigh", + }, + model: { + providerID: "anthropic", + modelID: "claude-sonnet-4", + variants: { low: {}, high: {}, xhigh: {} }, + }, + }) + + expect(value).toBeUndefined() + }) + + test("prefers selected variant over configured variant", () => { + const value = resolveModelVariant({ + variants: ["low", "high", "xhigh"], + selected: "high", + configured: "xhigh", + }) + + expect(value).toBe("high") + }) + + test("lets an explicit default override the configured variant", () => { + const value = resolveModelVariant({ + variants: ["low", "high", "xhigh"], + selected: null, + configured: "xhigh", + }) + + expect(value).toBeUndefined() + }) + + test("cycles from configured variant to next", () => { + const value = cycleModelVariant({ + variants: ["low", "high", "xhigh"], + selected: undefined, + configured: "high", + }) + + expect(value).toBe("xhigh") + }) + + test("wraps from configured last variant to first", () => { + const value = cycleModelVariant({ + variants: ["low", "high", "xhigh"], + selected: undefined, + configured: "xhigh", + }) + + expect(value).toBe("low") + }) + + test("cycles from an explicit default to the first variant", () => { + const value = cycleModelVariant({ + variants: ["low", "high", "xhigh"], + selected: null, + configured: "xhigh", + }) + + expect(value).toBe("low") + }) +}) diff --git a/packages/app/src/context/model-variant.ts b/packages/app/src/context/model-variant.ts new file mode 100644 index 0000000000000000000000000000000000000000..525acbba3219dc0ed7dfb5bcc6cda63d9acf4ceb --- /dev/null +++ b/packages/app/src/context/model-variant.ts @@ -0,0 +1,52 @@ +type AgentModel = { + providerID: string + modelID: string +} + +type Agent = { + model?: AgentModel + variant?: string +} + +type Model = AgentModel & { + variants?: Record +} + +type VariantInput = { + variants: string[] + selected: string | null | undefined + configured: string | undefined +} + +export function getConfiguredAgentVariant(input: { agent: Agent | undefined; model: Model | undefined }) { + if (!input.agent?.variant) return undefined + if (!input.agent.model) return undefined + if (!input.model?.variants) return undefined + if (input.agent.model.providerID !== input.model.providerID) return undefined + if (input.agent.model.modelID !== input.model.modelID) return undefined + if (!(input.agent.variant in input.model.variants)) return undefined + return input.agent.variant +} + +export function resolveModelVariant(input: VariantInput) { + if (input.selected === null) return undefined + if (input.selected && input.variants.includes(input.selected)) return input.selected + if (input.configured && input.variants.includes(input.configured)) return input.configured + return undefined +} + +export function cycleModelVariant(input: VariantInput) { + if (input.variants.length === 0) return undefined + if (input.selected === null) return input.variants[0] + if (input.selected && input.variants.includes(input.selected)) { + const index = input.variants.indexOf(input.selected) + if (index === input.variants.length - 1) return undefined + return input.variants[index + 1] + } + if (input.configured && input.variants.includes(input.configured)) { + const index = input.variants.indexOf(input.configured) + if (index === input.variants.length - 1) return input.variants[0] + return input.variants[index + 1] + } + return input.variants[0] +} diff --git a/packages/app/src/context/notification.tsx b/packages/app/src/context/notification.tsx new file mode 100644 index 0000000000000000000000000000000000000000..799c4713dd59fda901c18329aceaea5c7c9eb1af --- /dev/null +++ b/packages/app/src/context/notification.tsx @@ -0,0 +1,483 @@ +import { createStore, reconcile } from "solid-js/store" +import { type Accessor, batch, createEffect, createMemo, createRoot, getOwner, onCleanup } from "solid-js" +import { useNavigate, useParams, useSearchParams } from "@solidjs/router" +import { createSimpleContext } from "@opencode-ai/ui/context" +import type { ServerSDK } from "./server-sdk" +import type { ServerSync } from "./server-sync" +import { usePlatform } from "@/context/platform" +import { useLanguage } from "@/context/language" +import { useSettings } from "@/context/settings" +import { base64Encode } from "@opencode-ai/core/util/encode" +import { decode64 } from "@/utils/base64" +import { EventSessionError } from "@opencode-ai/sdk/v2" +import { Persist, persisted } from "@/utils/persist" +import { playSoundById } from "@/utils/sound" +import { useGlobal } from "./global" +import { ServerConnection, useServer } from "./server" +import { type DraftTab, useTabs } from "./tabs" +import { requireServerKey } from "@/utils/session-route" +import type { ServerScope } from "@/utils/server-scope" + +type NotificationBase = { + directory?: string + session?: string + metadata?: unknown + time: number + viewed: boolean +} + +type TurnCompleteNotification = NotificationBase & { + type: "turn-complete" +} + +type ErrorNotification = NotificationBase & { + type: "error" + error: EventSessionError["properties"]["error"] +} + +export type Notification = TurnCompleteNotification | ErrorNotification + +type NotificationIndex = { + session: { + all: Record + unseen: Record + unseenCount: Record + unseenHasError: Record + } + project: { + all: Record + unseen: Record + unseenCount: Record + unseenHasError: Record + } +} + +const MAX_NOTIFICATIONS = 500 +const NOTIFICATION_TTL_MS = 1000 * 60 * 60 * 24 * 30 + +function pruneNotifications(list: Notification[]) { + const cutoff = Date.now() - NOTIFICATION_TTL_MS + const pruned = list.filter((n) => n.time >= cutoff) + if (pruned.length <= MAX_NOTIFICATIONS) return pruned + return pruned.slice(pruned.length - MAX_NOTIFICATIONS) +} + +function createNotificationIndex(): NotificationIndex { + return { + session: { + all: {}, + unseen: {}, + unseenCount: {}, + unseenHasError: {}, + }, + project: { + all: {}, + unseen: {}, + unseenCount: {}, + unseenHasError: {}, + }, + } +} + +function buildNotificationIndex(list: Notification[]) { + const index = createNotificationIndex() + + list.forEach((notification) => { + if (notification.session) { + const all = index.session.all[notification.session] ?? [] + index.session.all[notification.session] = [...all, notification] + if (!notification.viewed) { + const unseen = index.session.unseen[notification.session] ?? [] + index.session.unseen[notification.session] = [...unseen, notification] + index.session.unseenCount[notification.session] = unseen.length + 1 + if (notification.type === "error") index.session.unseenHasError[notification.session] = true + } + } + + if (notification.directory) { + const all = index.project.all[notification.directory] ?? [] + index.project.all[notification.directory] = [...all, notification] + if (!notification.viewed) { + const unseen = index.project.unseen[notification.directory] ?? [] + index.project.unseen[notification.directory] = [...unseen, notification] + index.project.unseenCount[notification.directory] = unseen.length + 1 + if (notification.type === "error") index.project.unseenHasError[notification.directory] = true + } + } + }) + + return index +} + +export const { use: useNotification, provider: NotificationProvider } = createSimpleContext({ + name: "Notification", + gate: false, + init: () => { + const params = useParams<{ serverKey?: string; dir?: string; id?: string }>() + const [search] = useSearchParams<{ draftId?: string }>() + const global = useGlobal() + const server = useServer() + const tabs = useTabs() + const navigate = useNavigate() + const platform = usePlatform() + const settings = useSettings() + const language = useLanguage() + const owner = getOwner() + const states = new Map void; state: NotificationState }>() + + const activeServer = createMemo(() => { + if (params.serverKey) return requireServerKey(params.serverKey) + if (search.draftId) { + const draft = tabs.store.find((tab): tab is DraftTab => tab.type === "draft" && tab.draftID === search.draftId) + if (draft) return draft.server + } + return server.key + }) + const activeDirectory = createMemo(() => decode64(params.dir)) + const activeSession = createMemo(() => params.id) + + const ensure = (key: ServerConnection.Key) => { + const conn = global.servers.list().find((item) => ServerConnection.key(item) === key) + if (!conn) throw new Error(`Notification server not found: ${key}`) + const ctx = global.ensureServerCtx(conn) + const existing = states.get(ctx.sdk.scope) + if (existing) return existing.state + const root = createRoot( + (dispose) => ({ + dispose, + state: createServerNotificationState({ + sdk: ctx.sdk, + sync: ctx.sync, + active: () => server.scope(activeServer()) === ctx.sdk.scope, + directory: activeDirectory, + sessionID: activeSession, + platform, + settings, + language, + navigate, + }), + }), + owner ?? undefined, + ) + states.set(ctx.sdk.scope, root) + return root.state + } + + createEffect(() => { + global.servers.list().forEach((conn) => ensure(ServerConnection.key(conn))) + }) + + createEffect(() => { + const scopes = new Set(global.servers.list().map((conn) => server.scope(ServerConnection.key(conn)))) + states.forEach((value, scope) => { + if (scopes.has(scope)) return + value.dispose() + states.delete(scope) + }) + }) + + onCleanup(() => states.forEach((value) => value.dispose())) + + const selected = () => { + const list = global.servers.list() + const key = activeServer() + if (list.some((conn) => ServerConnection.key(conn) === key)) return ensure(key) + const conn = list.find((conn) => ServerConnection.key(conn) === server.key) ?? list[0] + if (!conn) throw new Error("Notification server not found") + return ensure(ServerConnection.key(conn)) + } + + return { + ready: () => selected().ready(), + ensureServerState: ensure, + session: { + all: (session: string) => selected().session.all(session), + unseen: (session: string) => selected().session.unseen(session), + unseenCount: (session: string) => selected().session.unseenCount(session), + unseenHasError: (session: string) => selected().session.unseenHasError(session), + markViewed: (session: string) => selected().session.markViewed(session), + }, + project: { + all: (directory: string) => selected().project.all(directory), + unseen: (directory: string) => selected().project.unseen(directory), + unseenCount: (directory: string) => selected().project.unseenCount(directory), + unseenHasError: (directory: string) => selected().project.unseenHasError(directory), + markViewed: (directory: string) => selected().project.markViewed(directory), + }, + } + }, +}) + +type NotificationState = ReturnType + +function createServerNotificationState(input: { + sdk: ServerSDK + sync: ServerSync + active: Accessor + directory: Accessor + sessionID: Accessor + platform: ReturnType + settings: ReturnType + language: ReturnType + navigate: (href: string) => void +}) { + const serverSDK = () => input.sdk + const serverSync = () => input.sync + const platform = input.platform + const settings = input.settings + const language = input.language + + const empty: Notification[] = [] + + const currentDirectory = input.directory + const currentSession = input.sessionID + + const [store, setStore, _, ready] = persisted( + Persist.serverGlobal(serverSDK().scope, "notification", ["notification.v1"]), + createStore({ + list: [] as Notification[], + }), + ) + const [index, setIndex] = createStore(buildNotificationIndex(store.list)) + + const meta = { pruned: false, disposed: false } + + const updateUnseen = (scope: "session" | "project", key: string, unseen: Notification[]) => { + setIndex(scope, "unseen", key, unseen) + setIndex(scope, "unseenCount", key, unseen.length) + setIndex( + scope, + "unseenHasError", + key, + unseen.some((notification) => notification.type === "error"), + ) + } + + const appendToIndex = (notification: Notification) => { + if (notification.session) { + setIndex("session", "all", notification.session, (all = []) => [...all, notification]) + if (!notification.viewed) { + setIndex("session", "unseen", notification.session, (unseen = []) => [...unseen, notification]) + setIndex("session", "unseenCount", notification.session, (count = 0) => count + 1) + if (notification.type === "error") setIndex("session", "unseenHasError", notification.session, true) + } + } + + if (notification.directory) { + setIndex("project", "all", notification.directory, (all = []) => [...all, notification]) + if (!notification.viewed) { + setIndex("project", "unseen", notification.directory, (unseen = []) => [...unseen, notification]) + setIndex("project", "unseenCount", notification.directory, (count = 0) => count + 1) + if (notification.type === "error") setIndex("project", "unseenHasError", notification.directory, true) + } + } + } + + const removeFromIndex = (notification: Notification) => { + if (notification.session) { + setIndex("session", "all", notification.session, (all = []) => all.filter((n) => n !== notification)) + if (!notification.viewed) { + const unseen = (index.session.unseen[notification.session] ?? empty).filter((n) => n !== notification) + updateUnseen("session", notification.session, unseen) + } + } + + if (notification.directory) { + setIndex("project", "all", notification.directory, (all = []) => all.filter((n) => n !== notification)) + if (!notification.viewed) { + const unseen = (index.project.unseen[notification.directory] ?? empty).filter((n) => n !== notification) + updateUnseen("project", notification.directory, unseen) + } + } + } + + createEffect(() => { + if (!ready()) return + if (meta.pruned) return + meta.pruned = true + const list = pruneNotifications(store.list) + batch(() => { + setStore("list", list) + setIndex(reconcile(buildNotificationIndex(list), { merge: false })) + }) + }) + + const append = (notification: Notification) => { + const list = pruneNotifications([...store.list, notification]) + const keep = new Set(list) + const removed = store.list.filter((n) => !keep.has(n)) + + batch(() => { + if (keep.has(notification)) appendToIndex(notification) + removed.forEach((n) => removeFromIndex(n)) + setStore("list", list) + }) + } + + const lookup = async (directory: string, sessionID?: string) => { + if (!sessionID) return undefined + const sync = serverSync().ensureDirSyncContext(directory) + const session = sync.session.get(sessionID) + if (session) return session + return sync.session + .sync(sessionID) + .then(() => sync.session.get(sessionID)) + .catch(() => undefined) + } + + const viewedInCurrentSession = (directory: string, sessionID?: string) => { + if (!input.active()) return false + const activeDirectory = currentDirectory() + const activeSession = currentSession() + if (!activeSession) return false + if (!sessionID) return false + if (activeDirectory && directory !== activeDirectory) return false + return sessionID === activeSession + } + + const handleSessionIdle = (directory: string, event: { properties: { sessionID?: string } }, time: number) => { + const sessionID = event.properties.sessionID + void lookup(directory, sessionID).then((session) => { + if (meta.disposed) return + if (!session) return + if (session.parentID) return + + if (settings.sounds.agentEnabled()) { + void playSoundById(settings.sounds.agent()) + } + + append({ + directory, + time, + viewed: viewedInCurrentSession(directory, sessionID), + type: "turn-complete", + session: sessionID, + }) + + const href = `/${base64Encode(directory)}/session/${sessionID}` + if (settings.notifications.agent()) { + void platform.notify(language.t("notification.session.responseReady.title"), session.title ?? sessionID, () => + input.navigate(href), + ) + } + }) + } + + const handleSessionError = ( + directory: string, + event: { properties: { sessionID?: string; error?: EventSessionError["properties"]["error"] } }, + time: number, + ) => { + const sessionID = event.properties.sessionID + void lookup(directory, sessionID).then((session) => { + if (meta.disposed) return + if (session?.parentID) return + + if (settings.sounds.errorsEnabled()) { + void playSoundById(settings.sounds.errors()) + } + + const error = "error" in event.properties ? event.properties.error : undefined + append({ + directory, + time, + viewed: viewedInCurrentSession(directory, sessionID), + type: "error", + session: sessionID ?? "global", + error, + }) + const description = + session?.title ?? + (typeof error === "string" ? error : language.t("notification.session.error.fallbackDescription")) + const href = sessionID ? `/${base64Encode(directory)}/session/${sessionID}` : `/${base64Encode(directory)}` + if (settings.notifications.errors()) { + void platform.notify(language.t("notification.session.error.title"), description, () => input.navigate(href)) + } + }) + } + + const unsub = serverSDK().event.listen((e) => { + const event = e.details + if (event.type !== "session.idle" && event.type !== "session.error") return + + const directory = e.name + const time = Date.now() + if (event.type === "session.idle") { + handleSessionIdle(directory, event, time) + return + } + handleSessionError(directory, event, time) + }) + onCleanup(() => { + meta.disposed = true + unsub() + }) + + return { + ready, + session: { + all(session: string) { + return index.session.all[session] ?? empty + }, + unseen(session: string) { + return index.session.unseen[session] ?? empty + }, + unseenCount(session: string) { + return index.session.unseenCount[session] ?? 0 + }, + unseenHasError(session: string) { + return index.session.unseenHasError[session] ?? false + }, + markViewed(session: string) { + const unseen = index.session.unseen[session] ?? empty + if (!unseen.length) return + + const projects = [ + ...new Set(unseen.flatMap((notification) => (notification.directory ? [notification.directory] : []))), + ] + batch(() => { + setStore("list", (n) => n.session === session && !n.viewed, "viewed", true) + updateUnseen("session", session, []) + projects.forEach((directory) => { + const next = (index.project.unseen[directory] ?? empty).filter( + (notification) => notification.session !== session, + ) + updateUnseen("project", directory, next) + }) + }) + }, + }, + project: { + all(directory: string) { + return index.project.all[directory] ?? empty + }, + unseen(directory: string) { + return index.project.unseen[directory] ?? empty + }, + unseenCount(directory: string) { + return index.project.unseenCount[directory] ?? 0 + }, + unseenHasError(directory: string) { + return index.project.unseenHasError[directory] ?? false + }, + markViewed(directory: string) { + const unseen = index.project.unseen[directory] ?? empty + if (!unseen.length) return + + const sessions = [ + ...new Set(unseen.flatMap((notification) => (notification.session ? [notification.session] : []))), + ] + batch(() => { + setStore("list", (n) => n.directory === directory && !n.viewed, "viewed", true) + updateUnseen("project", directory, []) + sessions.forEach((session) => { + const next = (index.session.unseen[session] ?? empty).filter( + (notification) => notification.directory !== directory, + ) + updateUnseen("session", session, next) + }) + }) + }, + }, + } +} diff --git a/packages/app/src/context/permission-auto-respond.ts b/packages/app/src/context/permission-auto-respond.ts new file mode 100644 index 0000000000000000000000000000000000000000..8fff6d70c1743f6386680e3f01c86bb863778fd8 --- /dev/null +++ b/packages/app/src/context/permission-auto-respond.ts @@ -0,0 +1,60 @@ +import { base64Encode } from "@opencode-ai/core/util/encode" + +export function acceptKey(sessionID: string, directory?: string) { + if (!directory) return sessionID + return `${base64Encode(directory)}/${sessionID}` +} + +export function directoryAcceptKey(directory: string) { + return `${base64Encode(directory)}/*` +} + +function accepted(autoAccept: Record, sessionID: string, directory?: string) { + const key = acceptKey(sessionID, directory) + return autoAccept[key] ?? autoAccept[sessionID] +} + +export function isDirectoryAutoAccepting(autoAccept: Record, directory: string) { + const key = directoryAcceptKey(directory) + return autoAccept[key] ?? false +} + +function sessionLineage(session: { id: string; parentID?: string }[], sessionID: string) { + const parent = session.reduce((acc, item) => { + if (item.parentID) acc.set(item.id, item.parentID) + return acc + }, new Map()) + const seen = new Set([sessionID]) + const ids = [sessionID] + + for (const id of ids) { + const parentID = parent.get(id) + if (!parentID || seen.has(parentID)) continue + seen.add(parentID) + ids.push(parentID) + } + + return ids +} + +export function autoRespondsPermission( + autoAccept: Record, + session: { id: string; parentID?: string }[], + permission: { sessionID: string }, + directory?: string, +) { + const value = sessionAutoAccept(autoAccept, session, permission, directory) + if (value !== undefined) return value + return directory ? isDirectoryAutoAccepting(autoAccept, directory) : false +} + +export function sessionAutoAccept( + autoAccept: Record, + session: { id: string; parentID?: string }[], + permission: { sessionID: string }, + directory?: string, +) { + return sessionLineage(session, permission.sessionID) + .map((id) => accepted(autoAccept, id, directory)) + .find((item): item is boolean => item !== undefined) +} diff --git a/packages/app/src/context/permission.tsx b/packages/app/src/context/permission.tsx new file mode 100644 index 0000000000000000000000000000000000000000..d6d8019262a4960391852629f32651ed96f3823a --- /dev/null +++ b/packages/app/src/context/permission.tsx @@ -0,0 +1,484 @@ +import { createEffect, createMemo, createRoot, getOwner, onCleanup } from "solid-js" +import { createStore, produce } from "solid-js/store" +import { createSimpleContext } from "@opencode-ai/ui/context" +import type { PermissionRequest } from "@opencode-ai/sdk/v2/client" +import { Persist, persisted } from "@/utils/persist" +import type { ServerSDK } from "@/context/server-sdk" +import type { ServerSync } from "./server-sync" +import { useParams, useSearchParams } from "@solidjs/router" +import { decode64 } from "@/utils/base64" +import { useGlobal } from "./global" +import { ServerConnection, useServer } from "./server" +import { type DraftTab, useTabs } from "./tabs" +import { useSettings } from "./settings" +import { requireServerKey } from "@/utils/session-route" +import type { ServerScope } from "@/utils/server-scope" +import { normalizePermissionRequest } from "./global-sync/utils" +import { + acceptKey, + directoryAcceptKey, + isDirectoryAutoAccepting, + autoRespondsPermission, + sessionAutoAccept, +} from "./permission-auto-respond" + +type PermissionRespondFn = (input: { + sessionID: string + permissionID: string + response: "once" | "always" | "reject" + directory?: string +}) => void + +function isNonAllowRule(rule: unknown) { + if (!rule) return false + if (typeof rule === "string") return rule !== "allow" + if (typeof rule !== "object") return false + if (Array.isArray(rule)) return false + + for (const action of Object.values(rule)) { + if (action !== "allow") return true + } + + return false +} + +function hasPermissionPromptRules(permission: unknown) { + if (!permission) return false + if (typeof permission === "string") return permission !== "allow" + if (typeof permission !== "object") return false + if (Array.isArray(permission)) return false + + const config = permission as Record + return Object.values(config).some(isNonAllowRule) +} + +export const { use: usePermission, provider: PermissionProvider } = createSimpleContext({ + name: "Permission", + gate: false, + init: () => { + const params = useParams<{ serverKey?: string; dir?: string; id?: string }>() + const [search] = useSearchParams<{ draftId?: string }>() + const global = useGlobal() + const server = useServer() + const tabs = useTabs() + const settings = useSettings() + const owner = getOwner() + const states = new Map void; state: PermissionState }>() + + const activeDraft = createMemo(() => { + if (!search.draftId) return + return tabs.store.find((tab): tab is DraftTab => tab.type === "draft" && tab.draftID === search.draftId) + }) + + const activeServer = createMemo(() => { + if (params.serverKey && settings.general.newLayoutDesigns()) return requireServerKey(params.serverKey) + return activeDraft()?.server ?? server.key + }) + + const ensure = (key: ServerConnection.Key) => { + const conn = global.servers.list().find((item) => ServerConnection.key(item) === key) + if (!conn) throw new Error(`Permission server not found: ${key}`) + const ctx = global.ensureServerCtx(conn) + const existing = states.get(ctx.sdk.scope) + if (existing && global.servers.list().some((item) => ServerConnection.key(item) === existing.key)) { + return existing.state + } + if (existing) { + existing.dispose() + states.delete(ctx.sdk.scope) + } + const root = createRoot( + (dispose) => ({ + key, + dispose, + state: createServerPermissionState({ sdk: ctx.sdk, sync: ctx.sync }), + }), + owner ?? undefined, + ) + states.set(ctx.sdk.scope, root) + return root.state + } + + createEffect(() => { + global.servers.list().forEach((conn) => ensure(ServerConnection.key(conn))) + }) + + createEffect(() => { + const list = global.servers.list() + const keys = new Set(list.map(ServerConnection.key)) + states.forEach((value, scope) => { + if (keys.has(value.key)) return + value.dispose() + states.delete(scope) + const replacement = list.find((conn) => server.scope(ServerConnection.key(conn)) === scope) + if (replacement) ensure(ServerConnection.key(replacement)) + }) + }) + + onCleanup(() => states.forEach((value) => value.dispose())) + + let lastSelected: PermissionState | undefined + const selected = () => { + const key = activeServer() + if (global.servers.list().some((conn) => ServerConnection.key(conn) === key)) { + lastSelected = ensure(key) + } + if (lastSelected) return lastSelected + return ensure(server.key) + } + const activeDirectory = createMemo(() => { + const directory = decode64(params.dir) + if (directory) return directory + const draft = activeDraft() + if (draft) return draft.directory + if (!params.id) return + if (!global.servers.list().some((conn) => ServerConnection.key(conn) === activeServer())) return + return selected().sync.session.lineage.peek(params.id)?.session.directory + }) + + createEffect(() => { + const directory = activeDirectory() + if (!directory) return + selected().enableConfiguredDirectory(directory) + }) + + const permissionsEnabled = createMemo(() => { + const directory = activeDirectory() + if (!directory) return false + return selected().permissionsEnabled(directory) + }) + + return { + ready: () => selected().ready(), + ensureServerState: (key: ServerConnection.Key) => ensure(key).api, + currentServerState: () => selected().api, + respond(input: Parameters[0]) { + selected().respond(input) + }, + autoResponds(permission: PermissionRequest, directory?: string) { + return selected().autoResponds(permission, directory) + }, + isAutoAccepting(sessionID: string, directory?: string) { + return selected().isAutoAccepting(sessionID, directory) + }, + isAutoAcceptingDirectory(directory: string) { + return selected().isAutoAcceptingDirectory(directory) + }, + toggleAutoAccept(sessionID: string, directory: string) { + selected().toggleAutoAccept(sessionID, directory) + }, + toggleAutoAcceptDirectory(directory: string) { + selected().toggleAutoAcceptDirectory(directory) + }, + enableAutoAccept(sessionID: string, directory: string) { + selected().enableAutoAccept(sessionID, directory) + }, + disableAutoAccept(sessionID: string, directory?: string) { + selected().disableAutoAccept(sessionID, directory) + }, + permissionsEnabled, + isPermissionAllowAll(directory: string) { + return selected().isPermissionAllowAll(directory) + }, + } + }, +}) + +type PermissionState = ReturnType +type PermissionEvent = Parameters[0]>[0] + +function createServerPermissionState(input: { sdk: ServerSDK; sync: ServerSync }) { + const [store, setStore, _, ready] = persisted( + { + ...Persist.serverGlobal(input.sdk.scope, "permission", ["permission.v3"]), + migrate(value) { + if (!value || typeof value !== "object" || Array.isArray(value)) return value + + const data = value as Record + if (data.autoAccept) return value + + return { + ...data, + autoAccept: + typeof data.autoAcceptEdits === "object" && data.autoAcceptEdits && !Array.isArray(data.autoAcceptEdits) + ? data.autoAcceptEdits + : {}, + } + }, + }, + createStore({ + autoAccept: {} as Record, + }), + ) + + function enableConfiguredDirectory(directory: string) { + if (input.sdk.protocolKind() !== "v1") return + if (meta.disposed || !ready()) return + const [childStore] = input.sync.child(directory) + if (childStore.config.permission !== "allow") return + const key = directoryAcceptKey(directory) + if (store.autoAccept[key] !== undefined) return + setStore( + produce((draft) => { + draft.autoAccept[key] = true + }), + ) + } + + const MAX_RESPONDED = 1000 + const RESPONDED_TTL_MS = 60 * 60 * 1000 + const responded = new Map() + const enableVersion = new Map() + const meta = { disposed: false } + + function pruneResponded(now: number) { + for (const [id, ts] of responded) { + if (now - ts < RESPONDED_TTL_MS) break + responded.delete(id) + } + + for (const id of responded.keys()) { + if (responded.size <= MAX_RESPONDED) break + responded.delete(id) + } + } + + const respond: PermissionRespondFn = (request) => { + if (meta.disposed) return + input.sdk.api.permission + .reply({ + sessionID: request.sessionID, + requestID: request.permissionID, + reply: request.response, + location: request.directory ? { directory: request.directory } : undefined, + }) + .catch(() => { + responded.delete(request.permissionID) + }) + } + + const list = async (directory: string) => { + if ((await input.sdk.protocol) === "v1") { + return (await input.sdk.client.permission.list({ directory })).data ?? [] + } + return input.sdk.api.permission.request + .list({ location: { directory } }) + .then((result) => result.data.map(normalizePermissionRequest)) + } + + function respondOnce(permission: PermissionRequest, directory?: string) { + const now = Date.now() + const hit = responded.has(permission.id) + responded.delete(permission.id) + responded.set(permission.id, now) + pruneResponded(now) + if (hit) return + respond({ + sessionID: permission.sessionID, + permissionID: permission.id, + response: "once", + directory, + }) + } + + function sessions(directory?: string) { + const info = Object.values(input.sync.session.data.info).filter((session) => !!session) + if (!directory) return info + return [...info, ...input.sync.child(directory, { bootstrap: false })[0].session] + } + + function isAutoAccepting(sessionID: string, directory?: string) { + return autoRespondsPermission(store.autoAccept, sessions(directory), { sessionID }, directory) + } + + function isAutoAcceptingDirectory(directory: string) { + return isDirectoryAutoAccepting(store.autoAccept, directory) + } + + function shouldAutoRespond(permission: PermissionRequest, directory?: string) { + return autoRespondsPermission(store.autoAccept, sessions(directory), permission, directory) + } + + function isPending(permission: PermissionRequest) { + const pending = input.sync.session.data.permission[permission.sessionID] + return pending === undefined || pending.some((item) => item.id === permission.id) + } + + async function shouldAutoRespondResolved(permission: PermissionRequest, directory?: string) { + const override = sessionAutoAccept(store.autoAccept, sessions(directory), permission, directory) + if (override !== undefined) return override + if (input.sync.session.lineage.peek(permission.sessionID)) return shouldAutoRespond(permission, directory) + const lineage = await input.sync.session.lineage.resolve(permission.sessionID).catch(() => undefined) + if (meta.disposed || !lineage) return false + return shouldAutoRespond(permission, directory) + } + + async function respondPending( + permission: PermissionRequest, + directory?: string, + current: () => boolean = () => true, + ) { + if (!current() || !isPending(permission)) return + if (!(await shouldAutoRespondResolved(permission, directory))) return + if (meta.disposed || !current() || !isPending(permission)) return + respondOnce(permission, directory) + } + + function bumpEnableVersion(sessionID: string, directory?: string) { + const key = acceptKey(sessionID, directory) + const next = (enableVersion.get(key) ?? 0) + 1 + enableVersion.set(key, next) + return next + } + + const handlePermission = (e: PermissionEvent) => { + const event = e.details + if (event?.type !== "permission.asked") return + void respondPending(event.properties, e.name) + } + + const unsubscribe = input.sdk.event.listen((event) => { + if (ready()) { + handlePermission(event) + return + } + void ready.promise?.then(() => { + if (meta.disposed) return + handlePermission(event) + }) + }) + onCleanup(() => { + meta.disposed = true + unsubscribe() + }) + + function enableDirectory(directory: string) { + if (meta.disposed) return + const key = directoryAcceptKey(directory) + setStore( + produce((draft) => { + draft.autoAccept[key] = true + }), + ) + + list(directory) + .then((permissions) => { + if (meta.disposed) return + if (!isAutoAcceptingDirectory(directory)) return + for (const permission of permissions) { + void respondPending(permission, directory, () => isAutoAcceptingDirectory(directory)) + } + }) + .catch(() => undefined) + } + + function disableDirectory(directory: string) { + if (meta.disposed) return + const key = directoryAcceptKey(directory) + setStore( + produce((draft) => { + draft.autoAccept[key] = false + }), + ) + } + + function enable(sessionID: string, directory: string) { + if (meta.disposed) return + const key = acceptKey(sessionID, directory) + const version = bumpEnableVersion(sessionID, directory) + setStore( + produce((draft) => { + draft.autoAccept[key] = true + delete draft.autoAccept[sessionID] + }), + ) + + list(directory) + .then((permissions) => { + if (meta.disposed) return + if (enableVersion.get(key) !== version) return + if (!isAutoAccepting(sessionID, directory)) return + for (const permission of permissions) { + void respondPending( + permission, + directory, + () => enableVersion.get(key) === version && isAutoAccepting(sessionID, directory), + ) + } + }) + .catch(() => undefined) + } + + function disable(sessionID: string, directory?: string) { + if (meta.disposed) return + bumpEnableVersion(sessionID, directory) + const key = directory ? acceptKey(sessionID, directory) : sessionID + setStore( + produce((draft) => { + draft.autoAccept[key] = false + if (!directory) return + delete draft.autoAccept[sessionID] + }), + ) + } + + const api = { + ready: () => !meta.disposed && ready(), + respond, + autoResponds(permission: PermissionRequest, directory?: string) { + if (meta.disposed) return false + return shouldAutoRespond(permission, directory) + }, + isAutoAccepting(sessionID: string, directory?: string) { + if (meta.disposed) return false + return isAutoAccepting(sessionID, directory) + }, + isAutoAcceptingDirectory(directory: string) { + if (meta.disposed) return false + return isAutoAcceptingDirectory(directory) + }, + toggleAutoAccept(sessionID: string, directory: string) { + if (meta.disposed) return + if (isAutoAccepting(sessionID, directory)) { + disable(sessionID, directory) + return + } + + enable(sessionID, directory) + }, + toggleAutoAcceptDirectory(directory: string) { + if (meta.disposed) return + if (isAutoAcceptingDirectory(directory)) { + disableDirectory(directory) + return + } + enableDirectory(directory) + }, + enableAutoAccept(sessionID: string, directory: string) { + if (meta.disposed) return + if (isAutoAccepting(sessionID, directory)) return + enable(sessionID, directory) + }, + disableAutoAccept(sessionID: string, directory?: string) { + if (meta.disposed) return + disable(sessionID, directory) + }, + isPermissionAllowAll(directory: string) { + if (meta.disposed) return false + const [childStore] = input.sync.child(directory) + return childStore.config.permission === "allow" + }, + } + + return { + ...api, + api, + sync: input.sync, + enableConfiguredDirectory, + permissionsEnabled(directory: string) { + if (meta.disposed) return false + const [childStore] = input.sync.child(directory) + return hasPermissionPromptRules(childStore.config.permission) + }, + } +} diff --git a/packages/app/src/context/prompt-state.ts b/packages/app/src/context/prompt-state.ts new file mode 100644 index 0000000000000000000000000000000000000000..65f47257810d7cc0375192e4c0f4d222e441ea75 --- /dev/null +++ b/packages/app/src/context/prompt-state.ts @@ -0,0 +1,273 @@ +import { checksum } from "@opencode-ai/core/util/encode" +import type { FilePartSource } from "@opencode-ai/sdk/v2/client" +import { batch, createMemo, type Accessor } from "solid-js" +import { createStore, type SetStoreFunction } from "solid-js/store" +import type { FileSelection } from "@/context/file" +import { Persist, persisted } from "@/utils/persist" +import type { ServerScope } from "@/utils/server-scope" +import type { BlobReference } from "@/utils/draft-store" +import type { Platform } from "@/context/platform" + +interface PartBase { + content: string + start: number + end: number +} + +export interface TextPart extends PartBase { + type: "text" +} + +export interface FileAttachmentPart extends PartBase { + type: "file" + path: string + selection?: FileSelection + mime?: string + filename?: string + url?: string + source?: FilePartSource +} + +export interface AgentPart extends PartBase { + type: "agent" + name: string +} + +export interface ImageAttachmentPart { + type: "image" + id: string + filename: string + sourcePath?: string + mime: string + blob: BlobReference +} + +export type ContentPart = TextPart | FileAttachmentPart | AgentPart | ImageAttachmentPart +export type Prompt = ContentPart[] + +export type PromptModel = { + providerID: string + modelID: string + variant?: string | null +} + +export type FileContextItem = { + type: "file" + path: string + selection?: FileSelection + comment?: string + commentID?: string + commentOrigin?: "review" | "file" + preview?: string +} + +export type ContextItem = FileContextItem +export type PromptScope = { draftID: string } | { dir: string; id?: string } + +export const DEFAULT_PROMPT: Prompt = [{ type: "text", content: "", start: 0, end: 0 }] + +export type PromptStore = { + prompt: Prompt + cursor?: number + model?: PromptModel + context: { + items: (ContextItem & { key: string })[] + } +} + +type InitialPrompt = { + prompt?: string + model?: PromptModel +} + +function isSelectionEqual(a?: FileSelection, b?: FileSelection) { + if (!a && !b) return true + if (!a || !b) return false + return ( + a.startLine === b.startLine && a.startChar === b.startChar && a.endLine === b.endLine && a.endChar === b.endChar + ) +} + +function isPartEqual(partA: ContentPart, partB: ContentPart) { + switch (partA.type) { + case "text": + return partB.type === "text" && partA.content === partB.content + case "file": + return ( + partB.type === "file" && + partA.path === partB.path && + partA.mime === partB.mime && + partA.filename === partB.filename && + isSelectionEqual(partA.selection, partB.selection) + ) + case "agent": + return partB.type === "agent" && partA.name === partB.name + case "image": + return partB.type === "image" && partA.id === partB.id + } +} + +export function isPromptEqual(promptA: Prompt, promptB: Prompt): boolean { + if (promptA.length !== promptB.length) return false + for (let i = 0; i < promptA.length; i++) { + if (!isPartEqual(promptA[i], promptB[i])) return false + } + return true +} + +function cloneSelection(selection?: FileSelection) { + if (!selection) return undefined + return { ...selection } +} + +function clonePart(part: ContentPart): ContentPart { + if (part.type === "text") return { ...part } + if (part.type === "image") return { ...part } + if (part.type === "agent") return { ...part } + return { + ...part, + selection: cloneSelection(part.selection), + } +} + +function clonePrompt(prompt: Prompt): Prompt { + return prompt.map(clonePart) +} + +function contextItemKey(item: ContextItem) { + if (item.type !== "file") return item.type + const start = item.selection?.startLine + const end = item.selection?.endLine + const key = `${item.type}:${item.path}:${start}:${end}` + + if (item.commentID) return `${key}:c=${item.commentID}` + const comment = item.comment?.trim() + if (!comment) return key + const digest = checksum(comment) ?? comment + return `${key}:c=${digest.slice(0, 8)}` +} + +export function isCommentItem(item: ContextItem | (ContextItem & { key: string })) { + return item.type === "file" && !!item.comment?.trim() +} + +function createPromptActions(setStore: SetStoreFunction) { + return { + set(prompt: Prompt, cursorPosition?: number) { + const next = clonePrompt(prompt) + batch(() => { + setStore("prompt", next) + if (cursorPosition !== undefined) setStore("cursor", cursorPosition) + }) + }, + reset() { + batch(() => { + setStore("prompt", clonePrompt(DEFAULT_PROMPT)) + setStore("cursor", 0) + }) + }, + } +} + +function promptTarget(serverScope: ServerScope, scope: PromptScope) { + if ("draftID" in scope) return Persist.prompt(Persist.draft(scope.draftID, "prompt")) + const legacy = `${scope.dir}/prompt${scope.id ? "/" + scope.id : ""}.v2` + return Persist.prompt(Persist.serverScoped(serverScope, scope.dir, scope.id, "prompt", [legacy])) +} + +function promptStore(initial?: InitialPrompt): PromptStore { + const text = initial?.prompt + return { + prompt: + text === undefined ? clonePrompt(DEFAULT_PROMPT) : [{ type: "text", content: text, start: 0, end: text.length }], + cursor: text === undefined ? undefined : text.length, + model: initial?.model ? { ...initial.model } : undefined, + context: { + items: [], + }, + } +} + +function createPromptStateValue(store: PromptStore, setStore: SetStoreFunction) { + const actions = createPromptActions(setStore) + const value = { + store: [() => store, setStore] as [Accessor, SetStoreFunction], + current: () => store.prompt, + cursor: createMemo(() => store.cursor), + dirty: () => !isPromptEqual(store.prompt, DEFAULT_PROMPT), + model: { + current: () => store.model, + set: (model: PromptModel | undefined) => setStore("model", model), + }, + context: { + items: createMemo(() => store.context.items), + add(item: ContextItem) { + const key = contextItemKey(item) + if (store.context.items.find((x) => x.key === key)) return + setStore("context", "items", (items) => [...items, { key, ...item }]) + }, + remove(key: string) { + setStore("context", "items", (items) => items.filter((x) => x.key !== key)) + }, + removeComment(path: string, commentID: string) { + setStore("context", "items", (items) => + items.filter((item) => !(item.type === "file" && item.path === path && item.commentID === commentID)), + ) + }, + updateComment(path: string, commentID: string, next: Partial & { comment?: string }) { + setStore("context", "items", (items) => + items.map((item) => { + if (item.type !== "file" || item.path !== path || item.commentID !== commentID) return item + const value = { ...item, ...next } + return { ...value, key: contextItemKey(value) } + }), + ) + }, + replaceComments(items: FileContextItem[]) { + setStore("context", "items", (current) => [ + ...current.filter((item) => !isCommentItem(item)), + ...items.map((item) => ({ ...item, key: contextItemKey(item) })), + ]) + }, + }, + set: actions.set, + reset: actions.reset, + capture: () => value, + } + return value +} + +function createPersistedPrompt(target: ReturnType, initial?: InitialPrompt, platform?: Platform) { + const [store, setStore, _, ready] = persisted(target, createStore(promptStore(initial)), platform) + return { ready, ...createPromptStateValue(store, setStore) } +} + +export function createPromptSession( + serverScope: ServerScope, + scope: PromptScope, + initial?: InitialPrompt, + platform?: Platform, +) { + return createPersistedPrompt(promptTarget(serverScope, scope), initial, platform) +} + +export function createDraftPromptSession(draftID: string, initial?: InitialPrompt) { + return createPersistedPrompt(Persist.prompt(Persist.draft(draftID, "prompt")), initial) +} + +export type PromptSession = ReturnType + +export function createPromptReady(session: Accessor) { + return Object.defineProperty(() => session().ready(), "promise", { + get: () => session().ready.promise, + }) as (() => boolean) & { readonly promise: Promise | undefined } +} + +export function createPromptState(initial?: InitialPrompt) { + const [store, setStore] = createStore(promptStore(initial)) + const ready = Object.assign(() => true, { promise: Promise.resolve(true) }) + return { + ready, + ...createPromptStateValue(store, setStore), + } +} diff --git a/packages/app/src/context/sdk.tsx b/packages/app/src/context/sdk.tsx new file mode 100644 index 0000000000000000000000000000000000000000..c5ee9bd7c7439e78f262dbb884de36c15ca1ab92 --- /dev/null +++ b/packages/app/src/context/sdk.tsx @@ -0,0 +1,17 @@ +import { createSimpleContext } from "@opencode-ai/ui/context" +import { type Accessor, createMemo } from "solid-js" +import { type ServerSDK, useServerSDK } from "./server-sdk" + +export type DirectorySDK = ReturnType + +export const { use: useSDK, provider: SDKProvider } = createSimpleContext({ + name: "SDK", + // Resolves the directory-scoped SDK reactively from the (possibly changing) server. + init: (props: { directory: string | Accessor }) => { + const serverSDK = useServerSDK() + return createMemo(() => { + const directory = typeof props.directory === "function" ? props.directory() : props.directory + return serverSDK().ensureDirSdkContext(directory) + }) + }, +}) diff --git a/packages/app/src/context/server-sdk.test.ts b/packages/app/src/context/server-sdk.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..57e1cd86f3ace4a5af5d0b3d92ab846b49b9d557 --- /dev/null +++ b/packages/app/src/context/server-sdk.test.ts @@ -0,0 +1,196 @@ +import { describe, expect, test } from "bun:test" +import { adaptServerEvent, coalesceServerEvents, enqueueServerEvent, resumeStreamAfterPageShow } from "./server-sdk" +import type { OpenCodeEvent } from "@opencode-ai/client/promise" +import type { Event } from "@opencode-ai/sdk/v2/client" + +describe("resumeStreamAfterPageShow", () => { + test("restarts a stream only after a back-forward cache restore", () => { + let starts = 0 + const start = () => starts++ + + resumeStreamAfterPageShow({ persisted: false } as PageTransitionEvent, start) + resumeStreamAfterPageShow({ persisted: true } as PageTransitionEvent, start) + + expect(starts).toBe(1) + }) +}) + +describe("adaptServerEvent", () => { + test("preserves V2 events while adapting permission requests for existing consumers", () => { + const current = { + id: "evt_1", + created: 1, + type: "permission.v2.asked", + data: { id: "perm_1", sessionID: "ses_1", action: "read", resources: ["src/**"] }, + } as OpenCodeEvent + + expect(adaptServerEvent(current)).toMatchObject({ + type: "permission.asked", + properties: { id: "perm_1", sessionID: "ses_1", permission: "read", patterns: ["src/**"] }, + current, + }) + }) +}) + +describe("coalesceServerEvents", () => { + const delta = (value: string, field = "text", partID = "part") => ({ + directory: "/repo", + payload: { + type: "message.part.delta", + properties: { messageID: "msg", partID, field, delta: value }, + } as Event, + }) + + test("merges adjacent deltas for the same field", () => { + const first = delta("hello ") + const second = delta("world") + first.payload.id = "first" + second.payload.id = "second" + const result = coalesceServerEvents([first, second]) + + expect(result).toHaveLength(1) + expect(result[0]?.payload).toMatchObject({ id: "second", properties: { delta: "hello world" } }) + }) + + test("merges adjacent current text deltas", () => { + const current = (id: string, value: string) => + adaptServerEvent({ + id, + created: 1, + type: "session.text.delta", + location: { directory: "/repo" }, + data: { sessionID: "ses", assistantMessageID: "msg", ordinal: 0, delta: value }, + } as OpenCodeEvent) + const result = coalesceServerEvents([ + { directory: "/repo", payload: current("evt_1", "hello ") }, + { directory: "/repo", payload: current("evt_2", "world") }, + ]) + + expect(result).toHaveLength(1) + expect(result[0]?.payload.current).toMatchObject({ id: "evt_2", data: { delta: "hello world" } }) + }) + + test("preserves event boundaries and distinct fields", () => { + const status = { + directory: "/repo", + payload: { type: "session.status", properties: { sessionID: "ses", status: { type: "idle" } } } as Event, + } + const result = coalesceServerEvents([delta("a"), delta("b", "metadata"), status, delta("c")]) + + expect(result.map((event) => event.payload.type)).toEqual([ + "message.part.delta", + "message.part.delta", + "session.status", + "message.part.delta", + ]) + }) + + test("preserves event ID order across interleaved deltas", () => { + const first = delta("a") + const other = delta("b", "text", "other") + const last = delta("c") + first.payload.id = "1" + other.payload.id = "2" + last.payload.id = "3" + + const result = coalesceServerEvents([first, other, last]) + + expect(result.map((event) => event.payload.id)).toEqual(["1", "2", "3"]) + }) +}) + +describe("enqueueServerEvent", () => { + const partUpdated = (text: string) => + ({ + type: "message.part.updated", + properties: { + sessionID: "session", + part: { id: "part", sessionID: "session", messageID: "message", type: "text", text }, + }, + }) as Event + + test("preserves part updates across message remove and re-add barriers", () => { + const events: Array<{ directory: string; payload: Event }> = [] + const enqueue = (payload: Event) => enqueueServerEvent(events, { directory: "/repo", payload }) + + enqueue(partUpdated("old")) + enqueue({ type: "message.removed", properties: { sessionID: "session", messageID: "message" } } as Event) + enqueue({ + type: "message.updated", + properties: { + sessionID: "session", + info: { + id: "message", + sessionID: "session", + role: "user", + time: { created: 1 }, + agent: "build", + model: { providerID: "provider", modelID: "model" }, + }, + }, + } as Event) + enqueue(partUpdated("new")) + + expect(events.map((event) => event.payload.type)).toEqual([ + "message.part.updated", + "message.removed", + "message.updated", + "message.part.updated", + ]) + }) + + test("preserves deltas after a replacement snapshot", () => { + const events: Array<{ directory: string; payload: Event }> = [] + const enqueue = (payload: Event) => enqueueServerEvent(events, { directory: "/repo", payload }) + + enqueue(partUpdated("a")) + enqueue(partUpdated("ab")) + enqueue({ + type: "message.part.delta", + properties: { sessionID: "session", messageID: "message", partID: "part", field: "text", delta: "c" }, + } as Event) + + const result = coalesceServerEvents(events) + expect(result.map((event) => event.payload.type)).toEqual(["message.part.updated", "message.part.delta"]) + expect(result[0]?.payload).toMatchObject({ properties: { part: { text: "ab" } } }) + expect(result[1]?.payload).toMatchObject({ properties: { delta: "c" } }) + }) + + test("preserves updates after session deletion", () => { + const events: Array<{ directory: string; payload: Event }> = [] + const enqueue = (payload: Event) => enqueueServerEvent(events, { directory: "/repo", payload }) + + enqueue(partUpdated("old")) + enqueue({ + type: "session.deleted", + properties: { sessionID: "session", info: { id: "session" } }, + } as Event) + enqueue(partUpdated("new")) + + expect(events.map((event) => event.payload.type)).toEqual([ + "message.part.updated", + "session.deleted", + "message.part.updated", + ]) + }) + + test("does not coalesce edge-triggered session statuses", () => { + const events: Array<{ directory: string; payload: Event }> = [] + const enqueue = (status: "retry" | "busy") => + enqueueServerEvent(events, { + directory: "/repo", + payload: { + type: "session.status", + properties: { + sessionID: "session", + status: status === "retry" ? { type: "retry", attempt: 1, message: "retry", next: 1 } : { type: "busy" }, + }, + } as Event, + }) + + enqueue("retry") + enqueue("busy") + + expect(events).toHaveLength(2) + }) +}) diff --git a/packages/app/src/context/server-sdk.tsx b/packages/app/src/context/server-sdk.tsx new file mode 100644 index 0000000000000000000000000000000000000000..7dd2a6e59edff7f70e521eee2055db3ce5f3b9b4 --- /dev/null +++ b/packages/app/src/context/server-sdk.tsx @@ -0,0 +1,444 @@ +import type { OpenCodeEvent } from "@opencode-ai/client/promise" +import type { Event } from "@opencode-ai/sdk/v2/client" +import { createSimpleContext } from "@opencode-ai/ui/context" +import { createGlobalEmitter } from "@solid-primitives/event-bus" +import { makeEventListener } from "@solid-primitives/event-listener" +import { type Accessor, batch, createMemo, createResource, onCleanup, onMount } from "solid-js" +import { createApiForServer, createSdkForServer, type ServerApi } from "@/utils/server" +import { useLanguage } from "./language" +import { usePlatform } from "./platform" +import { ServerConnection, useServer } from "./server" +import { createRefCountMap } from "@/utils/refcount" +import { useGlobal } from "./global" +import { ServerScope } from "@/utils/server-scope" +import { detectServerProtocol, type ServerProtocol } from "@/utils/server-protocol" +import { createCompatibleApi, type CompatibleApi } from "@/utils/server-compat" + +const isAbortError = (error: unknown) => + error !== null && typeof error === "object" && "name" in error && error.name === "AbortError" + +const isStreamClosed = (error: unknown, signal?: AbortSignal) => isAbortError(error) || signal?.aborted === true +export type ServerEvent = Event & { current?: OpenCodeEvent } +type QueuedServerEvent = { directory: string; payload: ServerEvent } +type CurrentDelta = Extract< + OpenCodeEvent, + { type: "session.text.delta" | "session.reasoning.delta" | "session.tool.input.delta" | "session.compaction.delta" } +> + +export function adaptServerEvent(event: OpenCodeEvent): ServerEvent { + if (event.type === "permission.v2.asked") { + return { + id: event.id, + type: "permission.asked", + properties: { + id: event.data.id, + sessionID: event.data.sessionID, + permission: event.data.action, + patterns: event.data.resources, + always: event.data.save ?? [], + metadata: event.data.metadata ?? {}, + tool: + event.data.source?.type === "tool" + ? { messageID: event.data.source.messageID, callID: event.data.source.callID } + : undefined, + }, + current: event, + } as ServerEvent + } + if (event.type === "permission.v2.replied") + return { id: event.id, type: "permission.replied", properties: event.data, current: event } as ServerEvent + if (event.type === "question.v2.asked") + return { id: event.id, type: "question.asked", properties: event.data, current: event } as ServerEvent + if (event.type === "question.v2.replied") + return { id: event.id, type: "question.replied", properties: event.data, current: event } as ServerEvent + if (event.type === "question.v2.rejected") + return { id: event.id, type: "question.rejected", properties: event.data, current: event } as ServerEvent + return { id: event.id, type: event.type, properties: event.data, current: event } as ServerEvent +} + +const coalescedKey = (event: QueuedServerEvent) => { + if (event.payload.type === "lsp.updated") return `lsp.updated:${event.directory}` + if (event.payload.type === "message.part.updated") { + const part = event.payload.properties.part + return `message.part.updated:${event.directory}:${part.messageID}:${part.id}` + } + return undefined +} + +export function enqueueServerEvent(queue: QueuedServerEvent[], event: QueuedServerEvent) { + const key = coalescedKey(event) + const previous = queue[queue.length - 1] + if (key && previous && coalescedKey(previous) === key) { + queue[queue.length - 1] = event + return false + } + queue.push(event) + return true +} + +export function coalesceServerEvents(events: QueuedServerEvent[]) { + const output: QueuedServerEvent[] = [] + events.forEach((event) => { + const current = currentDelta(event.payload.current) + if (current) { + const previous = output[output.length - 1] + const prior = currentDelta(previous?.payload.current) + if ( + previous && + prior && + previous.directory === event.directory && + currentDeltaKey(prior) === currentDeltaKey(current) + ) { + const fragment = currentDeltaFragment(prior) + currentDeltaFragment(current) + const data = + current.type === "session.compaction.delta" + ? { ...current.data, text: fragment } + : { ...current.data, delta: fragment } + output[output.length - 1] = { + directory: event.directory, + payload: { + ...event.payload, + properties: data, + current: { ...current, data } as CurrentDelta, + } as ServerEvent, + } + return + } + output.push(event) + return + } + if (event.payload.type !== "message.part.delta") { + output.push(event) + return + } + const props = event.payload.properties + const previous = output[output.length - 1] + if ( + !previous || + previous.payload.type !== "message.part.delta" || + previous.directory !== event.directory || + previous.payload.properties.messageID !== props.messageID || + previous.payload.properties.partID !== props.partID || + previous.payload.properties.field !== props.field + ) { + output.push({ + directory: event.directory, + payload: { ...event.payload, properties: { ...props } }, + }) + return + } + output[output.length - 1] = { + directory: event.directory, + payload: { + ...event.payload, + properties: { ...props, delta: previous.payload.properties.delta + props.delta }, + }, + } + }) + return output +} + +function currentDelta(event: OpenCodeEvent | undefined): CurrentDelta | undefined { + if ( + event?.type === "session.text.delta" || + event?.type === "session.reasoning.delta" || + event?.type === "session.tool.input.delta" || + event?.type === "session.compaction.delta" + ) + return event +} + +function currentDeltaKey(event: CurrentDelta) { + if (event.type === "session.tool.input.delta") + return `${event.type}:${event.data.sessionID}:${event.data.assistantMessageID}:${event.data.callID}` + if (event.type === "session.compaction.delta") return `${event.type}:${event.data.sessionID}` + return `${event.type}:${event.data.sessionID}:${event.data.assistantMessageID}:${event.data.ordinal}` +} + +function currentDeltaFragment(event: CurrentDelta) { + return event.type === "session.compaction.delta" ? event.data.text : event.data.delta +} + +export function resumeStreamAfterPageShow(event: PageTransitionEvent, start: () => unknown) { + if (!event.persisted) return + start() +} + +type ServerEventEmitter = ReturnType> +type ServerSDKBase = { + server: ServerConnection.Any + scope: ServerScope + protocol: Promise + protocolKind: Accessor + url: string + client: ReturnType + api: CompatibleApi + currentApi: ServerApi + event: { + on: ServerEventEmitter["on"] + listen: ServerEventEmitter["listen"] + start: () => Promise | undefined + } + createClient: ( + opts: Omit[0], "server" | "fetch">, + ) => ReturnType +} + +function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerScope): ServerSDKBase { + const platform = usePlatform() + const abort = new AbortController() + + const eventFetch = (() => { + if (!platform.fetch || !server) return + try { + const url = new URL(server.http.url) + const loopback = url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "::1" + if (url.protocol === "http:" && !loopback) return platform.fetch + } catch { + return + } + })() + + const eventApi = createApiForServer({ server: server.http, fetch: eventFetch }) + const eventSdk = createSdkForServer({ + signal: abort.signal, + fetch: eventFetch, + server: server.http, + }) + const protocol = detectServerProtocol(server.http, platform.fetch ?? globalThis.fetch) + const [protocolKind] = createResource( + () => protocol, + (value) => value, + ) + const emitter = createGlobalEmitter<{ + [key: string]: ServerEvent + }>() + + type Queued = QueuedServerEvent + const FLUSH_FRAME_MS = 16 + const STREAM_YIELD_MS = 8 + const RECONNECT_DELAY_MS = 250 + + let queue: Queued[] = [] + let buffer: Queued[] = [] + let timer: ReturnType | undefined + let last = 0 + + const flush = () => { + if (timer) clearTimeout(timer) + timer = undefined + + if (queue.length === 0) return + + const events = queue + queue = buffer + buffer = events + queue.length = 0 + + last = Date.now() + const output = coalesceServerEvents(events) + batch(() => { + output.forEach((event) => emitter.emit(event.directory, event.payload)) + }) + + buffer.length = 0 + } + + const schedule = () => { + if (timer) return + const elapsed = Date.now() - last + timer = setTimeout(flush, Math.max(0, FLUSH_FRAME_MS - elapsed)) + } + + let streamErrorLogged = false + const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) + let attempt: AbortController | undefined + let run: Promise | undefined + let started = false + let generation = 0 + + const start = () => { + if (started) return run + started = true + const active = ++generation + const previous = run + const current = (async () => { + if (previous) await previous + // oxlint-disable-next-line no-unmodified-loop-condition -- `started` is set to false by stop() which also aborts; both flags are checked to allow graceful exit + while (!abort.signal.aborted && started && generation === active) { + attempt = new AbortController() + const onAbort = () => { + attempt?.abort() + } + abort.signal.addEventListener("abort", onAbort) + try { + const kind = await protocol + const events = + kind === "v1" + ? (await eventSdk.global.event({ signal: attempt.signal })).stream + : eventApi.event.subscribe({ signal: attempt.signal }) + let yielded = Date.now() + for await (const event of events) { + streamErrorLogged = false + const legacy = "payload" in event + if (legacy && event.payload.type === "sync") continue + const directory = legacy ? (event.directory ?? "global") : (event.location?.directory ?? "global") + const payload = legacy ? (event.payload as Event) : adaptServerEvent(event) + if (enqueueServerEvent(queue, { directory, payload })) schedule() + + if (Date.now() - yielded < STREAM_YIELD_MS) continue + yielded = Date.now() + await wait(0) + } + } catch (error) { + if (!isStreamClosed(error, attempt?.signal) && !streamErrorLogged) { + streamErrorLogged = true + console.error("[global-sdk] event stream failed", { + url: server.http.url, + fetch: eventFetch ? "platform" : "webview", + error, + }) + } + } finally { + abort.signal.removeEventListener("abort", onAbort) + attempt = undefined + } + + if (abort.signal.aborted || !started || generation !== active) return + await wait(RECONNECT_DELAY_MS) + } + })().finally(() => { + if (run !== current) return + run = undefined + flush() + }) + run = current + return run + } + + const stop = () => { + started = false + generation++ + attempt?.abort() + } + + onMount(() => { + makeEventListener(window, "pagehide", stop) + makeEventListener(window, "pageshow", (event) => resumeStreamAfterPageShow(event, start)) + }) + + onCleanup(() => { + stop() + abort.abort() + flush() + }) + + const sdk = createSdkForServer({ + server: server.http, + fetch: platform.fetch, + throwOnError: true, + }) + const currentApi: ServerApi = createApiForServer({ server: server.http, fetch: platform.fetch }) + const legacy = (directory?: string) => + createSdkForServer({ + server: server.http, + fetch: platform.fetch, + throwOnError: true, + directory, + }) + const api = createCompatibleApi({ protocol, current: currentApi, legacy }) + + return { + server, + scope, + protocol, + protocolKind, + url: server.http.url, + client: sdk, + api, + currentApi, + event: { + on: emitter.on.bind(emitter), + listen: emitter.listen.bind(emitter), + start, + }, + createClient(opts: Omit[0], "server" | "fetch">) { + return createSdkForServer({ + server: server.http, + fetch: platform.fetch, + ...opts, + }) + }, + } +} + +export type ServerSDK = ServerSDKBase & { + ensureDirSdkContext: (directory: string) => ReturnType +} + +export function createServerSdkContext(server: ServerConnection.Any, scope: ServerScope): ServerSDK { + const sdk = createServerSdkContextBase(server, scope) + return Object.assign(sdk, { + ensureDirSdkContext: createRefCountMap((dir) => createDirSdkContext(dir, sdk)), + }) +} + +export const { use: useServerSDK, provider: ServerSDKProvider } = createSimpleContext({ + name: "ServerSDK", + // Returns an accessor so the resolved server can change reactively (e.g. a + // /new-session draft retargeting its server) without re-instantiating the subtree. + init: (props: { server?: Accessor }) => { + const global = useGlobal() + const language = useLanguage() + const server = useServer() + + return createMemo(() => { + const conn = props.server?.() ?? server.current + if (!conn) throw new Error(language.t("error.serverSDK.noServerAvailable")) + return global.ensureServerCtx(conn).sdk + }) + }, +}) + +export function useServerProtocol() { + const serverSDK = useServerSDK() + return createMemo(() => serverSDK().protocolKind()) +} + +type SDKEventMap = { + [key in Event["type"]]: Extract +} + +function createDirSdkContext(directory: string, serverSDK: ServerSDKBase) { + const client = serverSDK.createClient({ + directory, + throwOnError: true, + }) + + const emitter = createGlobalEmitter() + + const unsub = serverSDK.event.on(directory, (event) => { + emitter.emit(event.type, event) + }) + onCleanup(unsub) + + return { + scope: serverSDK.scope, + protocol: serverSDK.protocol, + directory, + client, + api: createCompatibleApi({ + protocol: serverSDK.protocol, + current: serverSDK.currentApi, + legacy: (next) => serverSDK.createClient({ directory: next ?? directory, throwOnError: true }), + directory, + }), + event: emitter, + get url() { + return serverSDK.url + }, + createClient(opts: Parameters[0]) { + return serverSDK.createClient(opts) + }, + } +} diff --git a/packages/app/src/context/server-session-v2-reducer.test.ts b/packages/app/src/context/server-session-v2-reducer.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..00cc37cf5240385619bf94f59e62767fb6121e55 --- /dev/null +++ b/packages/app/src/context/server-session-v2-reducer.test.ts @@ -0,0 +1,156 @@ +import { describe, expect, test } from "bun:test" +import type { OpenCodeEvent, SessionMessageInfo } from "@opencode-ai/client/promise" +import { createV2SessionReducer } from "./server-session-v2-reducer" + +const event = (input: object) => input as OpenCodeEvent +const base = { created: 1, location: { directory: "/repo" }, durable: { aggregateID: "ses_1", seq: 1, version: 1 } } + +describe("v2 session reducer", () => { + test("projects promoted input and streaming assistant content", () => { + const reducer = createV2SessionReducer() + let messages: SessionMessageInfo[] = [] + const apply = (input: object) => { + const result = reducer.reduce(messages, event(input)) + if (result) messages = result.messages + return result + } + + apply({ + ...base, + id: "evt_admitted", + type: "session.input.admitted", + data: { + sessionID: "ses_1", + inputID: "msg_user", + input: { type: "user", delivery: "steer", data: { text: "hello" } }, + }, + }) + apply({ + ...base, + id: "evt_promoted", + type: "session.input.promoted", + data: { sessionID: "ses_1", inputID: "msg_user" }, + }) + apply({ + ...base, + id: "evt_step", + type: "session.step.started", + data: { + sessionID: "ses_1", + assistantMessageID: "msg_assistant", + agent: "build", + model: { id: "model", providerID: "provider" }, + }, + }) + apply({ + ...base, + id: "evt_text_start", + type: "session.text.started", + data: { sessionID: "ses_1", assistantMessageID: "msg_assistant", ordinal: 0 }, + }) + apply({ + ...base, + id: "evt_text_delta", + type: "session.text.delta", + data: { sessionID: "ses_1", assistantMessageID: "msg_assistant", ordinal: 0, delta: "hel" }, + }) + apply({ + ...base, + id: "evt_text_end", + type: "session.text.ended", + data: { sessionID: "ses_1", assistantMessageID: "msg_assistant", ordinal: 0, text: "hello" }, + }) + + expect(messages[0]).toMatchObject({ id: "msg_user", type: "user", text: "hello" }) + expect(messages[1]).toMatchObject({ + id: "msg_assistant", + type: "assistant", + content: [{ type: "text", text: "hello" }], + }) + }) + + test("folds tool, retry, and completion events", () => { + const reducer = createV2SessionReducer() + let messages: SessionMessageInfo[] = [] + const apply = (input: object) => { + const result = reducer.reduce(messages, event(input)) + if (result) messages = result.messages + } + + apply({ + ...base, + id: "evt_step", + type: "session.step.started", + data: { + sessionID: "ses_1", + assistantMessageID: "msg_assistant", + agent: "build", + model: { id: "model", providerID: "provider" }, + }, + }) + apply({ + ...base, + id: "evt_tool_start", + type: "session.tool.input.started", + data: { sessionID: "ses_1", assistantMessageID: "msg_assistant", callID: "call_1", name: "bash" }, + }) + apply({ + ...base, + id: "evt_tool_delta", + type: "session.tool.input.delta", + data: { sessionID: "ses_1", assistantMessageID: "msg_assistant", callID: "call_1", delta: "{}" }, + }) + apply({ + ...base, + id: "evt_tool_called", + type: "session.tool.called", + data: { sessionID: "ses_1", assistantMessageID: "msg_assistant", callID: "call_1", input: {}, executed: true }, + }) + apply({ + ...base, + id: "evt_tool_success", + type: "session.tool.success", + data: { + sessionID: "ses_1", + assistantMessageID: "msg_assistant", + callID: "call_1", + metadata: {}, + content: [{ type: "text", text: "done" }], + executed: true, + }, + }) + apply({ + ...base, + id: "evt_retry", + type: "session.retry.scheduled", + data: { + sessionID: "ses_1", + assistantMessageID: "msg_assistant", + attempt: 2, + at: 10, + error: { type: "ProviderError", message: "retry" }, + }, + }) + apply({ ...base, id: "evt_done", type: "session.execution.succeeded", data: { sessionID: "ses_1" } }) + + expect(messages[0]).toMatchObject({ + type: "assistant", + retry: undefined, + content: [{ type: "tool", id: "call_1", state: { status: "completed", content: [{ text: "done" }] } }], + }) + }) + + test("requests hydration when promotion admission was missed", () => { + const result = createV2SessionReducer().reduce( + [], + event({ + ...base, + id: "evt_promoted", + type: "session.input.promoted", + data: { sessionID: "ses_1", inputID: "msg_user" }, + }), + ) + + expect(result).toMatchObject({ sessionID: "ses_1", missing: "msg_user", touched: [] }) + }) +}) diff --git a/packages/app/src/context/server-session.test.ts b/packages/app/src/context/server-session.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..2ebf5f88ef0bfee093af7575083240e151457d69 --- /dev/null +++ b/packages/app/src/context/server-session.test.ts @@ -0,0 +1,1642 @@ +import { describe, expect, test } from "bun:test" +import type { retry } from "@opencode-ai/core/util/retry" +import type { OpenCodeEvent, SessionApi } from "@opencode-ai/client/promise" +import type { Message, OpencodeClient, Part, Session } from "@opencode-ai/sdk/v2/client" +import { createServerSession } from "./server-session" +import type { ServerApi } from "@/utils/server" + +type MessageApi = ServerApi["message"] + +const session = (id: string, parentID?: string): Session => ({ + id, + slug: id, + projectID: "project", + directory: "/repo", + title: id, + version: "1", + parentID, + time: { created: 1, updated: 1 }, +}) + +type UserMessage = Extract +type AssistantMessage = Extract +type TextPart = Extract +type MessageResponse = { + data: { info: Message; parts: Part[] }[] + response: { headers: Headers } +} +type SingleMessageResponse = { data: MessageResponse["data"][number] } + +const userMessage = (id: string, input: Partial = {}): UserMessage => ({ + id, + sessionID: "child", + role: "user", + time: { created: 1 }, + agent: "build", + model: { providerID: "provider", modelID: "model" }, + ...input, +}) + +const assistantMessage = (id: string, parentID: string, input: Partial = {}): AssistantMessage => ({ + id, + sessionID: "child", + role: "assistant", + time: { created: Number(id.at(-1)), completed: Number(id.at(-1)) }, + parentID, + modelID: "model", + providerID: "provider", + mode: "build", + agent: "build", + path: { cwd: "/repo", root: "/repo" }, + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + ...input, +}) + +const textPart = (messageID: string, input: Partial = {}): TextPart => ({ + id: "part", + sessionID: "child", + messageID, + type: "text", + text: "text", + ...input, +}) + +const response = (data: MessageResponse["data"] = [], cursor?: string): MessageResponse => ({ + data, + response: { headers: new Headers(cursor ? { "x-next-cursor": cursor } : undefined) }, +}) + +const singleResponse = (info: Message, parts: Part[] = []): SingleMessageResponse => ({ data: { info, parts } }) + +const deferredResponse = () => Promise.withResolvers() + +function messageClient(...responses: Array>) { + let index = 0 + const requests: unknown[] = [] + const waiting = new Map void>() + const client = { + session: { + get: async () => ({ data: session("child", "root") }), + messages: (input: unknown) => { + requests.push(input) + waiting.get(requests.length)?.() + waiting.delete(requests.length) + return responses[index++] + }, + }, + } as unknown as OpencodeClient + return Object.assign(client, { + requests, + requested(count: number) { + if (requests.length >= count) return Promise.resolve() + return new Promise((resolve) => waiting.set(count, resolve)) + }, + }) +} + +function rootMessageClient( + pages: Array>, + roots: Array>, +) { + let pageIndex = 0 + let rootIndex = 0 + const requests: unknown[] = [] + const rootRequests: unknown[] = [] + const rootWaiting = new Map void>() + const client = { + session: { + get: async () => ({ data: session("child", "root") }), + messages: (input: unknown) => { + requests.push(input) + return pages[pageIndex++] + }, + message: (input: unknown) => { + rootRequests.push(input) + rootWaiting.get(rootRequests.length)?.() + rootWaiting.delete(rootRequests.length) + return roots[rootIndex++] + }, + }, + } as unknown as OpencodeClient + return Object.assign(client, { + requests, + rootRequests, + rootRequested(count: number) { + if (rootRequests.length >= count) return Promise.resolve() + return new Promise((resolve) => rootWaiting.set(count, resolve)) + }, + }) +} + +const retryImmediately: typeof retry = async (task, options = {}) => { + const attempts = options.attempts ?? 3 + for (let attempt = 0; ; attempt++) { + try { + return await task() + } catch (error) { + if (attempt === attempts - 1) throw error + } + } +} + +function setup(sessions: Record) { + const get: unknown[] = [] + const messages: unknown[] = [] + const client = { + session: { + get: async (input: unknown) => { + get.push(input) + const id = (input as { sessionID: string }).sessionID + return { data: sessions[id] } + }, + messages: async (input: unknown) => { + messages.push(input) + return response() + }, + diff: async () => ({ data: [] }), + todo: async () => ({ data: [] }), + }, + } as unknown as OpencodeClient + return { get, messages, store: createServerSession(client) } +} + +describe("server session", () => { + test("projects V2 session events into current and legacy message state", () => { + const ctx = setup({ child: session("child") }) + ctx.store.remember(session("child")) + ctx.store.set("session_message", "child", [ + { + id: "msg_1_user", + type: "user", + text: "hello", + time: { created: 1 }, + }, + ]) + const apply = (input: object) => ctx.store.applyV2(input as OpenCodeEvent) + + apply({ + id: "evt_step", + created: 2, + type: "session.step.started", + durable: { aggregateID: "child", seq: 1, version: 1 }, + location: { directory: "/repo" }, + data: { + sessionID: "child", + assistantMessageID: "msg_2_assistant", + agent: "build", + model: { id: "model", providerID: "provider" }, + }, + }) + apply({ + id: "evt_text_start", + created: 3, + type: "session.text.started", + durable: { aggregateID: "child", seq: 2, version: 1 }, + location: { directory: "/repo" }, + data: { sessionID: "child", assistantMessageID: "msg_2_assistant", ordinal: 0 }, + }) + apply({ + id: "evt_text_delta", + created: 4, + type: "session.text.delta", + location: { directory: "/repo" }, + data: { sessionID: "child", assistantMessageID: "msg_2_assistant", ordinal: 0, delta: "world" }, + }) + + expect(ctx.store.data.session_message.child?.at(-1)).toMatchObject({ + id: "msg_2_assistant", + type: "assistant", + content: [{ type: "text", text: "world" }], + }) + expect(ctx.store.data.message.child?.map((message) => message.id)).toEqual(["msg_1_user", "msg_2_assistant"]) + expect(ctx.store.data.part.msg_2_assistant).toMatchObject([{ type: "text", text: "world" }]) + }) + + test("resolves lineage by session ID without directory", async () => { + const ctx = setup({ child: session("child", "root"), root: session("root") }) + + const result = await ctx.store.lineage.resolve("child") + + expect(result.root.id).toBe("root") + expect(ctx.get).toEqual([{ sessionID: "child" }, { sessionID: "root" }]) + expect(ctx.store.lineage.peek("child")).toEqual(result) + }) + + test("loads session content through the server client", async () => { + const ctx = setup({ root: session("root") }) + + await ctx.store.sync("root") + + expect(ctx.get).toEqual([{ sessionID: "root" }]) + expect(ctx.messages).toEqual([{ sessionID: "root", limit: 20, before: undefined }]) + expect(ctx.store.data.message.root).toEqual([]) + }) + + test("loads current session content through the current message API", async () => { + const requests: unknown[] = [] + const user = { id: "msg_z_user", type: "user", text: "hello", time: { created: 1 } } + const assistant = { + id: "msg_a_assistant", + type: "assistant", + agent: "build", + model: { id: "model", providerID: "provider" }, + content: [{ type: "text", text: "hi" }], + time: { created: 2, completed: 3 }, + } + const client = { + session: { + messages: () => { + throw new Error("legacy message endpoint called") + }, + }, + } as unknown as OpencodeClient + const messageApi = { + list: async (input: unknown) => { + requests.push(input) + return { data: [assistant, user], cursor: { previous: null, next: null } } + }, + } as unknown as MessageApi + const store = createServerSession(client, {} as SessionApi, messageApi) + store.remember(session("root")) + + await store.sync("root") + + expect(requests).toEqual([{ sessionID: "root", limit: 20, order: "desc" }]) + expect(store.data.session_message.root.map((message) => message.id)).toEqual([user.id, assistant.id]) + expect(store.data.message.root.map((message) => message.id)).toEqual([user.id, assistant.id]) + }) + + test("extends a current page to include the user for split assistant turns", async () => { + const user = { id: "msg_1_user", type: "user", text: "hello", time: { created: 1 } } as const + const assistant = (id: string, created: number) => ({ + id, + type: "assistant" as const, + agent: "build", + model: { id: "model", providerID: "provider" }, + content: [{ type: "text" as const, text: id }], + time: { created, completed: created }, + }) + const assistants = [ + assistant("msg_2_assistant", 2), + assistant("msg_3_assistant", 3), + assistant("msg_4_assistant", 4), + ] + const pages = [ + { data: assistants.slice(1).toReversed(), cursor: { previous: null, next: "older" } }, + { data: [assistants[0], user], cursor: { previous: null, next: null } }, + ] + const requests: unknown[] = [] + const messageApi = { + list: async (input: unknown) => { + requests.push(input) + return pages.shift()! + }, + } as unknown as MessageApi + const store = createServerSession({} as OpencodeClient, {} as SessionApi, messageApi) + store.remember(session("root")) + + await store.sync("root") + + expect(requests).toEqual([ + { sessionID: "root", limit: 20, order: "desc" }, + { sessionID: "root", limit: 20, cursor: "older" }, + ]) + expect(store.data.message.root.map((message) => message.id)).toEqual([ + user.id, + ...assistants.map((item) => item.id), + ]) + expect(assistants.map((item) => store.data.part[item.id]?.[0]?.type)).toEqual(["text", "text", "text"]) + }) + + test("indexes V1 messages for the current timeline projection", async () => { + const user = userMessage("message-1", { sessionID: "root" }) + const assistant = assistantMessage("message-2", user.id, { sessionID: "root" }) + const client = messageClient( + response([ + { info: user, parts: [textPart(user.id, { sessionID: "root" })] }, + { info: assistant, parts: [textPart(assistant.id, { sessionID: "root" })] }, + ]), + ) + const messageApi = { + list: () => { + throw new Error("current message endpoint called") + }, + } as unknown as MessageApi + const store = createServerSession(client, {} as SessionApi, messageApi, { + protocol: Promise.resolve("v1"), + }) + store.remember(session("root")) + + await store.sync("root") + + expect(store.data.message.root.map((message) => message.id)).toEqual([user.id, assistant.id]) + expect(store.data.session_message.root).toMatchObject([ + { id: user.id, type: "user", text: "text" }, + { id: assistant.id, type: "assistant" }, + ]) + + const next = userMessage("message-3", { sessionID: "root" }) + store.apply({ type: "message.updated", properties: { info: next } }) + expect(store.data.session_message.root.map((message) => message.id)).toEqual([user.id, assistant.id, next.id]) + + store.apply({ type: "message.removed", properties: { sessionID: "root", messageID: next.id } }) + expect(store.data.session_message.root.map((message) => message.id)).toEqual([user.id, assistant.id]) + }) + + test("backfills an assistant-only initial page through its user root", async () => { + const user = userMessage("message-1") + const assistants = [assistantMessage("message-2", user.id), assistantMessage("message-3", user.id)] + const client = rootMessageClient( + [ + response( + assistants.map((info) => ({ info, parts: [] })), + "older", + ), + ], + [singleResponse(user)], + ) + const store = createServerSession(client) + + await store.sync("child") + + expect(client.requests).toEqual([{ sessionID: "child", limit: 20, before: undefined }]) + expect(client.rootRequests).toEqual([{ sessionID: "child", messageID: user.id }]) + expect(store.data.message.child).toEqual([user, ...assistants]) + expect(store.history.more("child")).toBe(true) + }) + + test("keeps assistant history when its deleted parent cannot be backfilled", async () => { + const missing = Promise.withResolvers() + const assistant = assistantMessage("message-2", "message-missing") + const client = rootMessageClient([response([{ info: assistant, parts: [] }], "older")], [missing.promise]) + const store = createServerSession(client) + const loading = store.sync("child") + await client.rootRequested(1) + + missing.reject(new Error("Message not found: message-missing", { cause: { status: 404 } })) + await loading + + expect(client.rootRequests).toEqual([{ sessionID: "child", messageID: "message-missing" }]) + expect(store.data.message.child).toEqual([assistant]) + expect(store.history.more("child")).toBe(true) + }) + + test("drops a cached parent when a forced refresh confirms it was deleted", async () => { + const missing = Promise.withResolvers() + const parent = userMessage("message-1") + const part = textPart(parent.id) + const assistant = assistantMessage("message-2", parent.id) + const client = rootMessageClient( + [ + response([ + { info: parent, parts: [part] }, + { info: assistant, parts: [] }, + ]), + response([{ info: assistant, parts: [] }], "older"), + ], + [missing.promise], + ) + const store = createServerSession(client) + await store.sync("child") + const loading = store.sync("child", { force: true }) + await client.rootRequested(1) + + missing.reject(new Error(`Message not found: ${parent.id}`, { cause: { status: 404 } })) + await loading + + expect(store.data.message.child).toEqual([assistant]) + expect(store.data.part[parent.id]).toBeUndefined() + }) + + test("does not let an optimistic user suppress initial root backfill", async () => { + const user = userMessage("message-1") + const part = textPart(user.id) + const assistants = [assistantMessage("message-2", user.id), assistantMessage("message-3", user.id)] + const client = rootMessageClient( + [ + response( + assistants.map((info) => ({ info, parts: [] })), + "older", + ), + ], + [singleResponse(user)], + ) + const store = createServerSession(client) + store.optimistic.add({ sessionID: "child", message: user, parts: [part] }) + + await store.sync("child") + store.optimistic.remove({ sessionID: "child", messageID: user.id }) + + expect(client.requests).toHaveLength(1) + expect(client.rootRequests).toHaveLength(1) + expect(store.data.message.child).toEqual([user, ...assistants]) + }) + + test("backfills the parent of fetched assistants when another user is cached", async () => { + const unrelated = userMessage("message-0", { time: { created: 0 } }) + const user = userMessage("message-1") + const assistants = [assistantMessage("message-2", user.id), assistantMessage("message-3", user.id)] + const client = rootMessageClient( + [ + response([{ info: unrelated, parts: [] }]), + response( + assistants.map((info) => ({ info, parts: [] })), + "older", + ), + ], + [singleResponse(user)], + ) + const store = createServerSession(client) + await store.sync("child") + + await store.sync("child", { force: true }) + + expect(client.requests).toHaveLength(2) + expect(client.rootRequests).toHaveLength(1) + expect(store.data.message.child).toEqual([unrelated, user, ...assistants]) + }) + + test("preserves cached history between an injected parent and the page boundary", async () => { + const user = userMessage("message-1") + const cached = userMessage("message-3", { time: { created: 3 } }) + const assistant = assistantMessage("message-4", user.id) + const client = rootMessageClient( + [response([{ info: cached, parts: [] }]), response([{ info: assistant, parts: [] }], "older")], + [singleResponse(user)], + ) + const store = createServerSession(client) + await store.sync("child") + + await store.sync("child", { force: true }) + + expect(store.data.message.child).toEqual([user, cached, assistant]) + }) + + test("refreshes a cached parent omitted by an assistant-only replacement page", async () => { + const stale = userMessage("message-1", { summary: { title: "stale", diffs: [] } }) + const fresh = { ...stale, summary: { title: "fresh", diffs: [] } } + const stalePart = textPart(stale.id, { text: "stale" }) + const freshPart = { ...stalePart, text: "fresh" } + const assistant = assistantMessage("message-2", stale.id) + const client = rootMessageClient( + [response([{ info: stale, parts: [stalePart] }]), response([{ info: assistant, parts: [] }], "older")], + [singleResponse(fresh, [freshPart])], + ) + const store = createServerSession(client) + await store.sync("child") + + await store.sync("child", { force: true }) + + expect(client.rootRequests).toEqual([{ sessionID: "child", messageID: stale.id }]) + expect(store.data.message.child).toEqual([fresh, assistant]) + expect(store.data.part[stale.id]).toEqual([freshPart]) + }) + + test("refreshes a confirmed optimistic parent while preserving pending parts", async () => { + const stale = userMessage("message-1", { summary: { title: "stale", diffs: [] } }) + const fresh = { ...stale, summary: { title: "fresh", diffs: [] } } + const confirmed = textPart(stale.id, { id: "confirmed", text: "stale" }) + const refreshed = { ...confirmed, text: "fresh" } + const pending = textPart(stale.id, { id: "pending", text: "pending" }) + const assistant = assistantMessage("message-2", stale.id) + const client = rootMessageClient( + [response([{ info: stale, parts: [confirmed] }]), response([{ info: assistant, parts: [] }], "older")], + [singleResponse(fresh, [refreshed])], + ) + const store = createServerSession(client) + store.optimistic.add({ sessionID: "child", message: stale, parts: [confirmed, pending] }) + await store.sync("child") + + await store.sync("child", { force: true }) + + expect(client.rootRequests).toEqual([{ sessionID: "child", messageID: stale.id }]) + expect(store.data.message.child).toEqual([fresh, assistant]) + expect(store.data.part[stale.id]).toEqual([refreshed, pending]) + }) + + test("uses a parent received by SSE during the replacement load", async () => { + const pending = deferredResponse() + const user = userMessage("message-1") + const assistant = assistantMessage("message-2", user.id) + const client = rootMessageClient([pending.promise], []) + const store = createServerSession(client) + const loading = store.sync("child") + + store.apply({ type: "message.updated", properties: { info: user } }) + pending.resolve(response([{ info: assistant, parts: [] }], "older")) + await loading + + expect(client.rootRequests).toEqual([]) + expect(store.data.message.child).toEqual([user, assistant]) + }) + + test("uses a successful retry over events received by a failed backfill attempt", async () => { + const failed = deferredResponse() + const user = userMessage("message-1") + const live = { ...user, agent: "stale" } + const assistants = [assistantMessage("message-2", user.id), assistantMessage("message-3", user.id)] + const client = rootMessageClient( + [ + response( + assistants.map((info) => ({ info, parts: [] })), + "older", + ), + ], + [failed.promise.then((result) => ({ data: result.data[0]! })), singleResponse(user)], + ) + const store = createServerSession(client, { retry: retryImmediately }) + const loading = store.sync("child") + await client.rootRequested(1) + + store.apply({ type: "message.updated", properties: { info: live } }) + failed.reject(new Error("retry")) + await loading + + expect(client.requests).toHaveLength(1) + expect(client.rootRequests).toHaveLength(2) + expect(store.data.message.child).toEqual([user, ...assistants]) + }) + + test("preserves newer-page events across a failed parent retry", async () => { + const failed = deferredResponse() + const user = userMessage("message-1") + const assistant = assistantMessage("message-2", user.id) + const live = { ...assistant, cost: 1 } + const client = rootMessageClient( + [response([{ info: assistant, parts: [] }], "older")], + [failed.promise.then((result) => ({ data: result.data[0]! })), singleResponse(user)], + ) + const store = createServerSession(client, { retry: retryImmediately }) + const loading = store.sync("child") + await client.rootRequested(1) + + store.apply({ type: "message.updated", properties: { info: live } }) + failed.reject(new Error("retry")) + await loading + + expect(store.data.message.child).toEqual([user, live]) + }) + + test("preserves unrelated message events across a failed parent retry", async () => { + const failed = deferredResponse() + const user = userMessage("message-1") + const assistant = assistantMessage("message-2", user.id) + const live = userMessage("message-4", { time: { created: 4 } }) + const client = rootMessageClient( + [response([{ info: assistant, parts: [] }], "older")], + [failed.promise.then((result) => ({ data: result.data[0]! })), singleResponse(user)], + ) + const store = createServerSession(client, { retry: retryImmediately }) + const loading = store.sync("child") + await client.rootRequested(1) + + store.apply({ type: "message.updated", properties: { info: live } }) + failed.reject(new Error("retry")) + await loading + + expect(store.data.message.child).toEqual([user, assistant, live]) + }) + + test("preserves newer-page part events across a failed parent retry", async () => { + const failed = deferredResponse() + const user = userMessage("message-1") + const assistant = assistantMessage("message-2", user.id) + const stale = textPart(assistant.id, { text: "stale" }) + const live = { ...stale, text: "live" } + const client = rootMessageClient( + [response([{ info: assistant, parts: [stale] }], "older")], + [failed.promise.then((result) => ({ data: result.data[0]! })), singleResponse(user)], + ) + const store = createServerSession(client, { retry: retryImmediately }) + const loading = store.sync("child") + await client.rootRequested(1) + + store.apply({ type: "message.part.updated", properties: { sessionID: "child", part: live, time: 2 } }) + failed.reject(new Error("retry")) + await loading + + expect(store.data.part[assistant.id]).toEqual([live]) + }) + + test("merges live events into the initial page", async () => { + const pending = deferredResponse() + const user = userMessage("message-1") + const live = userMessage("message-2", { time: { created: 2 } }) + const livePart = textPart(live.id, { text: "live" }) + const store = createServerSession(messageClient(pending.promise)) + const loading = store.sync("child") + + store.apply({ type: "message.updated", properties: { info: live } }) + store.apply({ type: "message.part.updated", properties: { sessionID: "child", part: livePart, time: 2 } }) + pending.resolve(response([{ info: user, parts: [] }])) + await loading + + expect(store.data.message.child).toEqual([user, live]) + expect(store.data.part[live.id]).toEqual([livePart]) + }) + + test("preserves same-ID live updates over the initial page", async () => { + const pending = deferredResponse() + const fetched = userMessage("message") + const fetchedPart = textPart(fetched.id, { text: "fetched" }) + const live = { ...fetched, time: { created: 2 } } + const livePart = { ...fetchedPart, text: "live" } + const store = createServerSession(messageClient(pending.promise)) + const loading = store.sync("child") + + store.apply({ type: "message.updated", properties: { info: live } }) + store.apply({ type: "message.part.updated", properties: { sessionID: "child", part: livePart, time: 2 } }) + pending.resolve(response([{ info: fetched, parts: [fetchedPart] }])) + await loading + + expect(store.data.message.child).toEqual([live]) + expect(store.data.part[live.id]).toEqual([livePart]) + }) + + test("preserves removals received during the initial load", async () => { + const pending = deferredResponse() + const removed = userMessage("message-1") + const kept = { ...removed, id: "message-2" } + const part = textPart(kept.id, { text: "removed" }) + const store = createServerSession(messageClient(pending.promise)) + const loading = store.sync("child") + + store.apply({ type: "message.removed", properties: { sessionID: "child", messageID: removed.id } }) + store.apply({ + type: "message.part.removed", + properties: { sessionID: "child", messageID: kept.id, partID: part.id }, + }) + pending.resolve( + response([ + { info: removed, parts: [] }, + { info: kept, parts: [part] }, + ]), + ) + await loading + + expect(store.data.message.child).toEqual([kept]) + expect(store.data.part[kept.id]).toBeUndefined() + }) + + test("keeps removal tracking isolated across load generations", async () => { + const firstResponse = deferredResponse() + const secondResponse = deferredResponse() + const message = userMessage("message") + const store = createServerSession(messageClient(firstResponse.promise, secondResponse.promise)) + const first = store.sync("child") + + store.apply({ type: "message.removed", properties: { sessionID: "child", messageID: message.id } }) + store.apply({ + type: "session.deleted", + properties: { sessionID: "child", info: session("child", "root") }, + }) + const second = store.sync("child") + + firstResponse.resolve(response()) + await first + secondResponse.resolve(response([{ info: message, parts: [] }])) + await second + + expect(store.data.message.child).toEqual([message]) + }) + + test("tracks removals in a replacement load generation", async () => { + const firstResponse = deferredResponse() + const secondResponse = deferredResponse() + const message = userMessage("message") + const store = createServerSession(messageClient(firstResponse.promise, secondResponse.promise)) + const first = store.sync("child") + store.apply({ + type: "session.deleted", + properties: { sessionID: "child", info: session("child", "root") }, + }) + const second = store.sync("child") + + store.apply({ type: "message.removed", properties: { sessionID: "child", messageID: message.id } }) + firstResponse.resolve(response()) + await first + secondResponse.resolve(response([{ info: message, parts: [] }])) + await second + + expect(store.data.message.child).toEqual([]) + }) + + test("preserves remove then re-add when a refresh omits the message", async () => { + const pending = deferredResponse() + const message = userMessage("message") + const store = createServerSession(messageClient(response([{ info: message, parts: [] }]), pending.promise)) + await store.sync("child") + const refreshing = store.sync("child", { force: true }) + + store.apply({ type: "message.removed", properties: { sessionID: "child", messageID: message.id } }) + store.apply({ type: "message.updated", properties: { info: message } }) + pending.resolve(response()) + await refreshing + + expect(store.data.message.child).toEqual([message]) + }) + + test("preserves a re-added message without restoring removed parts", async () => { + const pending = deferredResponse() + const message = userMessage("message") + const part = textPart(message.id, { text: "stale" }) + const store = createServerSession(messageClient(response([{ info: message, parts: [] }]), pending.promise)) + await store.sync("child") + const refreshing = store.sync("child", { force: true }) + + store.apply({ type: "message.removed", properties: { sessionID: "child", messageID: message.id } }) + store.apply({ type: "message.updated", properties: { info: message } }) + pending.resolve(response([{ info: message, parts: [part] }])) + await refreshing + + expect(store.data.message.child).toEqual([message]) + expect(store.data.part[message.id]).toBeUndefined() + }) + + test("preserves optimistic parts re-added after removal during a refresh", async () => { + const pending = deferredResponse() + const message = userMessage("message") + const stale = textPart(message.id, { id: "stale", text: "stale" }) + const part = textPart(message.id, { id: "optimistic", text: "optimistic" }) + const store = createServerSession( + messageClient(response([{ info: message, parts: [] }]), pending.promise, response()), + ) + await store.sync("child") + const refreshing = store.sync("child", { force: true }) + + store.apply({ type: "message.removed", properties: { sessionID: "child", messageID: message.id } }) + store.optimistic.add({ sessionID: "child", message, parts: [part] }) + pending.resolve(response([{ info: message, parts: [stale] }])) + await refreshing + + expect(store.data.message.child).toEqual([message]) + expect(store.data.part[message.id]).toEqual([part]) + + await store.sync("child", { force: true }) + expect(store.data.message.child).toEqual([message]) + expect(store.data.part[message.id]).toEqual([part]) + }) + + test("drops stale event content omitted by a complete initial page", async () => { + const stale = userMessage("stale") + const store = createServerSession(messageClient(response())) + store.apply({ type: "message.updated", properties: { info: stale } }) + + await store.sync("child") + + expect(store.data.message.child).toEqual([]) + }) + + test("preserves event content outside an incomplete initial page", async () => { + const live = userMessage("message-1") + const fetched = userMessage("message-2", { time: { created: 2 } }) + const store = createServerSession(messageClient(response([{ info: fetched, parts: [] }], "older"))) + store.apply({ type: "message.updated", properties: { info: live } }) + + await store.sync("child") + + expect(store.data.message.child).toEqual([live, fetched]) + }) + + test("does not restore removed optimistic content on refresh", async () => { + const message = userMessage("message") + const part = textPart(message.id, { text: "removed" }) + const kept = { ...message, id: "kept" } + const keptPart = { ...part, id: "kept-part", messageID: kept.id } + const store = createServerSession(messageClient(response([{ info: kept, parts: [] }]))) + store.optimistic.add({ sessionID: "child", message, parts: [part] }) + store.optimistic.add({ sessionID: "child", message: kept, parts: [keptPart] }) + + store.apply({ type: "message.removed", properties: { sessionID: "child", messageID: message.id } }) + store.apply({ + type: "message.part.removed", + properties: { sessionID: "child", messageID: kept.id, partID: keptPart.id }, + }) + await store.sync("child", { force: true }) + + expect(store.data.message.child).toEqual([kept]) + expect(store.data.part[message.id]).toBeUndefined() + expect(store.data.part[kept.id]).toBeUndefined() + }) + + test("replaces confirmed optimistic content with the initial page", async () => { + const optimistic = userMessage("message") + const fetched = { ...optimistic, time: { created: 2 } } + const store = createServerSession(messageClient(response([{ info: fetched, parts: [] }]))) + store.optimistic.add({ sessionID: "child", message: optimistic, parts: [] }) + + await store.sync("child") + + expect(store.data.message.child).toEqual([fetched]) + }) + + test("replaces a confirmed optimistic part with fetched content", async () => { + const pending = deferredResponse() + const message = userMessage("message") + const optimistic = textPart(message.id, { text: "optimistic" }) + const fetched = { ...optimistic, text: "fetched" } + const store = createServerSession(messageClient(pending.promise)) + const loading = store.sync("child") + + store.optimistic.add({ sessionID: "child", message, parts: [optimistic] }) + pending.resolve(response([{ info: message, parts: [fetched] }])) + await loading + + expect(store.data.part[message.id]).toEqual([fetched]) + }) + + test("rolls back only unconfirmed optimistic parts", async () => { + const pending = deferredResponse() + const message = userMessage("message") + const confirmed = textPart(message.id, { id: "confirmed", text: "confirmed" }) + const pendingPart = textPart(message.id, { id: "pending", text: "pending" }) + const store = createServerSession(messageClient(pending.promise)) + const loading = store.sync("child") + store.optimistic.add({ sessionID: "child", message, parts: [confirmed, pendingPart] }) + + pending.resolve(response([{ info: message, parts: [confirmed] }])) + await loading + store.optimistic.remove({ sessionID: "child", messageID: message.id }) + + expect(store.data.message.child).toEqual([message]) + expect(store.data.part[message.id]).toEqual([confirmed]) + }) + + test("updates confirmed optimistic parts from later pages", async () => { + const message = userMessage("message") + const confirmed = textPart(message.id, { id: "confirmed", text: "first" }) + const updated = { ...confirmed, text: "updated" } + const pendingPart = textPart(message.id, { id: "pending", text: "pending" }) + const store = createServerSession( + messageClient(response([{ info: message, parts: [confirmed] }]), response([{ info: message, parts: [updated] }])), + ) + store.optimistic.add({ sessionID: "child", message, parts: [confirmed, pendingPart] }) + await store.sync("child") + + await store.sync("child", { force: true }) + store.optimistic.remove({ sessionID: "child", messageID: message.id }) + + expect(store.data.part[message.id]).toEqual([updated]) + }) + + test("does not restore a confirmed optimistic part after its removal event", async () => { + const message = userMessage("message") + const confirmed = textPart(message.id, { id: "confirmed", text: "confirmed" }) + const pendingPart = textPart(message.id, { id: "pending", text: "pending" }) + const store = createServerSession( + messageClient(response([{ info: message, parts: [confirmed] }]), response([{ info: message, parts: [] }])), + ) + store.optimistic.add({ sessionID: "child", message, parts: [confirmed, pendingPart] }) + await store.sync("child") + store.apply({ + type: "message.part.removed", + properties: { sessionID: "child", messageID: message.id, partID: confirmed.id }, + }) + + await store.sync("child", { force: true }) + + expect(store.data.part[message.id]).toEqual([pendingPart]) + }) + + test("clears delta buffers when removing optimistic content", () => { + const message = userMessage("message") + const part = textPart(message.id, { text: "optimistic" }) + const store = setup({ child: session("child") }).store + store.optimistic.add({ sessionID: "child", message, parts: [part] }) + store.apply({ + type: "message.part.delta", + properties: { sessionID: "child", messageID: message.id, partID: part.id, field: "text", delta: " delta" }, + }) + + store.optimistic.remove({ sessionID: "child", messageID: message.id }) + + expect(store.data.part[message.id]).toBeUndefined() + expect(store.data.part_text_accum_delta[part.id]).toBeUndefined() + }) + + test("does not remove content confirmed by a message event", () => { + const message = userMessage("message") + const part = textPart(message.id) + const store = setup({ child: session("child") }).store + store.optimistic.add({ sessionID: "child", message, parts: [part] }) + store.apply({ type: "message.updated", properties: { sessionID: "child", info: message } }) + + store.optimistic.remove({ sessionID: "child", messageID: message.id }) + + expect(store.data.message.child).toEqual([message]) + expect(store.data.part[message.id]).toBeUndefined() + }) + + test("does not remove parts confirmed by part events", () => { + const message = userMessage("message") + const part = textPart(message.id) + const store = setup({ child: session("child") }).store + store.optimistic.add({ sessionID: "child", message, parts: [part] }) + store.apply({ type: "message.updated", properties: { sessionID: "child", info: message } }) + store.apply({ type: "message.part.updated", properties: { sessionID: "child", part, time: 2 } }) + + store.optimistic.remove({ sessionID: "child", messageID: message.id }) + + expect(store.data.message.child).toEqual([message]) + expect(store.data.part[message.id]).toEqual([part]) + }) + + test("treats a part event as confirmation when it precedes the message event", () => { + const message = userMessage("message") + const part = textPart(message.id) + const store = setup({ child: session("child") }).store + store.optimistic.add({ sessionID: "child", message, parts: [part] }) + store.apply({ type: "message.part.updated", properties: { sessionID: "child", part, time: 2 } }) + + store.optimistic.remove({ sessionID: "child", messageID: message.id }) + + expect(store.data.message.child).toEqual([message]) + expect(store.data.part[message.id]).toEqual([part]) + }) + + test("clears stale parts when the initial page has none", async () => { + const pending = deferredResponse() + const message = userMessage("message") + const part = textPart(message.id, { text: "stale" }) + const store = createServerSession(messageClient(pending.promise)) + store.apply({ type: "message.updated", properties: { info: message } }) + store.apply({ type: "message.part.updated", properties: { sessionID: "child", part, time: 1 } }) + const loading = store.sync("child") + + pending.resolve(response([{ info: message, parts: [] }])) + await loading + + expect(store.data.part[message.id]).toBeUndefined() + }) + + test("clears delta buffers for parts omitted by the initial page", async () => { + const pending = deferredResponse() + const message = userMessage("message") + const kept = textPart(message.id, { id: "part-1", text: "kept" }) + const removed: Part = { ...kept, id: "part-2", text: "removed" } + const store = createServerSession(messageClient(pending.promise)) + store.apply({ type: "message.updated", properties: { info: message } }) + store.apply({ type: "message.part.updated", properties: { sessionID: "child", part: kept, time: 1 } }) + store.apply({ type: "message.part.updated", properties: { sessionID: "child", part: removed, time: 1 } }) + store.apply({ + type: "message.part.delta", + properties: { sessionID: "child", messageID: message.id, partID: removed.id, field: "text", delta: " delta" }, + }) + const loading = store.sync("child") + + pending.resolve(response([{ info: message, parts: [kept] }])) + await loading + + expect(store.data.part[message.id]).toEqual([kept]) + expect(store.data.part_text_accum_delta[removed.id]).toBeUndefined() + }) + + test("clears a stale delta buffer when a refresh replaces its part", async () => { + const message = userMessage("message") + const stale = textPart(message.id, { text: "stale" }) + const fetched = { ...stale, text: "fetched" } + const store = createServerSession( + messageClient(response([{ info: message, parts: [stale] }]), response([{ info: message, parts: [fetched] }])), + ) + await store.sync("child") + store.apply({ + type: "message.part.delta", + properties: { sessionID: "child", messageID: message.id, partID: stale.id, field: "text", delta: " delta" }, + }) + + await store.sync("child", { force: true }) + + expect(store.data.part[message.id]).toEqual([fetched]) + expect(store.data.part_text_accum_delta[stale.id]).toBeUndefined() + }) + + test("preserves a non-durable delta received before refresh", async () => { + const message = userMessage("message") + const part = textPart(message.id, { text: "stale" }) + const store = createServerSession( + messageClient(response([{ info: message, parts: [part] }]), response([{ info: message, parts: [{ ...part }] }])), + ) + await store.sync("child") + store.apply({ + type: "message.part.delta", + properties: { sessionID: "child", messageID: message.id, partID: part.id, field: "text", delta: " delta" }, + }) + + await store.sync("child", { force: true }) + + expect(store.data.part[message.id]).toEqual([{ ...part, text: "stale delta" }]) + expect(store.data.part_text_accum_delta[part.id]).toBe("stale delta") + }) + + test("accepts fetched text that intentionally replaces an accumulated prefix", async () => { + const message = userMessage("message") + const part = textPart(message.id, { text: "abc" }) + const fetched = { ...part, text: "ab" } + const store = createServerSession( + messageClient(response([{ info: message, parts: [part] }]), response([{ info: message, parts: [fetched] }])), + ) + await store.sync("child") + store.apply({ + type: "message.part.delta", + properties: { sessionID: "child", messageID: message.id, partID: part.id, field: "text", delta: "def" }, + }) + + await store.sync("child", { force: true }) + + expect(store.data.part[message.id]).toEqual([fetched]) + expect(store.data.part_text_accum_delta[part.id]).toBeUndefined() + }) + + test("preserves an unpersisted delta suffix after partial server catch-up", async () => { + const message = userMessage("message") + const part = textPart(message.id, { text: "a" }) + const fetched = { ...part, text: "ab" } + const store = createServerSession( + messageClient(response([{ info: message, parts: [part] }]), response([{ info: message, parts: [fetched] }])), + ) + await store.sync("child") + store.apply({ + type: "message.part.delta", + properties: { sessionID: "child", messageID: message.id, partID: part.id, field: "text", delta: "bc" }, + }) + + await store.sync("child", { force: true }) + + expect(store.data.part[message.id]).toEqual([{ ...part, text: "abc" }]) + expect(store.data.part_text_accum_delta[part.id]).toBe("abc") + }) + + test("clears delta state after exact server catch-up", async () => { + const message = userMessage("message") + const part = textPart(message.id, { text: "a" }) + const fetched = { ...part, text: "ab" } + const store = createServerSession( + messageClient(response([{ info: message, parts: [part] }]), response([{ info: message, parts: [fetched] }])), + ) + await store.sync("child") + store.apply({ + type: "message.part.delta", + properties: { sessionID: "child", messageID: message.id, partID: part.id, field: "text", delta: "b" }, + }) + + await store.sync("child", { force: true }) + + expect(store.data.part[message.id]).toEqual([fetched]) + expect(store.data.part_text_accum_delta[part.id]).toBeUndefined() + }) + + test("uses the successful retry response over events from a failed attempt", async () => { + const failed = Promise.withResolvers() + const retried = Promise.withResolvers() + const message = userMessage("message") + const stale = textPart(message.id, { text: "stale" }) + const intermediate = { ...stale, text: "intermediate" } + const fetched = { ...stale, text: "fetched" } + const client = messageClient(failed.promise, retried.promise) + const store = createServerSession(client, { retry: retryImmediately }) + store.apply({ type: "message.updated", properties: { info: message } }) + store.apply({ type: "message.part.updated", properties: { sessionID: "child", part: stale, time: 1 } }) + const loading = store.sync("child") + + store.apply({ type: "message.part.updated", properties: { sessionID: "child", part: intermediate, time: 2 } }) + failed.reject(new Error("failed to fetch")) + await client.requested(2) + retried.resolve(response([{ info: message, parts: [fetched] }])) + await loading + + expect(store.data.part[message.id]).toEqual([fetched]) + }) + + test("preserves non-durable deltas across message retries", async () => { + const failed = Promise.withResolvers() + const retried = Promise.withResolvers() + const message = userMessage("message") + const part = textPart(message.id, { text: "stale" }) + const client = messageClient(failed.promise, retried.promise) + const store = createServerSession(client, { retry: retryImmediately }) + store.apply({ type: "message.updated", properties: { info: message } }) + store.apply({ type: "message.part.updated", properties: { sessionID: "child", part, time: 1 } }) + const loading = store.sync("child") + + store.apply({ + type: "message.part.delta", + properties: { sessionID: "child", messageID: message.id, partID: part.id, field: "text", delta: " delta" }, + }) + failed.reject(new Error("failed to fetch")) + await client.requested(2) + retried.resolve(response([{ info: message, parts: [part] }])) + await loading + + expect(store.data.part[message.id]).toEqual([{ ...part, text: "stale delta" }]) + }) + + test("preserves part removals across message retries", async () => { + const failed = Promise.withResolvers() + const retried = Promise.withResolvers() + const message = userMessage("message") + const part = textPart(message.id) + const client = messageClient(response([{ info: message, parts: [part] }]), failed.promise, retried.promise) + const store = createServerSession(client, { retry: retryImmediately }) + await store.sync("child") + const loading = store.sync("child", { force: true }) + + store.apply({ + type: "message.part.removed", + properties: { sessionID: "child", messageID: message.id, partID: part.id }, + }) + failed.reject(new Error("failed to fetch")) + await client.requested(3) + retried.resolve(response([{ info: message, parts: [part] }])) + await loading + + expect(store.data.part[message.id]).toBeUndefined() + }) + + test("preserves message removals across message retries", async () => { + const failed = Promise.withResolvers() + const retried = Promise.withResolvers() + const message = userMessage("message") + const part = textPart(message.id) + const client = messageClient(response([{ info: message, parts: [part] }]), failed.promise, retried.promise) + const store = createServerSession(client, { retry: retryImmediately }) + await store.sync("child") + const loading = store.sync("child", { force: true }) + + store.apply({ type: "message.removed", properties: { sessionID: "child", messageID: message.id } }) + failed.reject(new Error("failed to fetch")) + await client.requested(3) + retried.resolve(response([{ info: message, parts: [part] }])) + await loading + + expect(store.data.message.child).toEqual([]) + expect(store.data.part[message.id]).toBeUndefined() + }) + + test("preserves optimistic re-adds across message retries", async () => { + const failed = Promise.withResolvers() + const retried = Promise.withResolvers() + const message = userMessage("message") + const stale = textPart(message.id, { id: "stale", text: "stale" }) + const optimistic = textPart(message.id, { id: "optimistic", text: "optimistic" }) + const client = messageClient(response([{ info: message, parts: [stale] }]), failed.promise, retried.promise) + const store = createServerSession(client, { retry: retryImmediately }) + await store.sync("child") + const loading = store.sync("child", { force: true }) + + store.apply({ type: "message.removed", properties: { sessionID: "child", messageID: message.id } }) + store.optimistic.add({ sessionID: "child", message, parts: [optimistic] }) + failed.reject(new Error("failed to fetch")) + await client.requested(3) + retried.resolve(response([{ info: message, parts: [stale] }])) + await loading + + expect(store.data.message.child).toEqual([message]) + expect(store.data.part[message.id]).toEqual([optimistic]) + }) + + test("accepts part omission from a successful retry after an earlier delta", async () => { + const failed = Promise.withResolvers() + const retried = Promise.withResolvers() + const message = userMessage("message") + const part = textPart(message.id) + const client = messageClient(response([{ info: message, parts: [part] }]), failed.promise, retried.promise) + const store = createServerSession(client, { retry: retryImmediately }) + await store.sync("child") + const loading = store.sync("child", { force: true }) + + store.apply({ + type: "message.part.delta", + properties: { sessionID: "child", messageID: message.id, partID: part.id, field: "text", delta: " delta" }, + }) + failed.reject(new Error("failed to fetch")) + await client.requested(3) + retried.resolve(response([{ info: message, parts: [] }])) + await loading + + expect(store.data.part[message.id]).toBeUndefined() + expect(store.data.part_text_accum_delta[part.id]).toBeUndefined() + }) + + test("clears load-owned orphan parts when all retries fail", async () => { + const first = Promise.withResolvers() + const second = Promise.withResolvers() + const third = Promise.withResolvers() + const message = userMessage("message") + const part = textPart(message.id) + const client = messageClient(first.promise, second.promise, third.promise) + const store = createServerSession(client, { retry: retryImmediately }) + const loading = store.sync("child").catch((error) => error) + + store.apply({ type: "message.part.updated", properties: { sessionID: "child", part, time: 2 } }) + first.reject(new Error("failed to fetch")) + await client.requested(2) + second.reject(new Error("failed to fetch")) + await client.requested(3) + third.reject(new Error("failed to fetch")) + await loading + + expect(store.data.part[message.id]).toBeUndefined() + }) + + test("preserves live updates during a forced refresh", async () => { + const pending = deferredResponse() + const stale = userMessage("message") + const stalePart = textPart(stale.id, { text: "stale" }) + const store = createServerSession(messageClient(response([{ info: stale, parts: [stalePart] }]), pending.promise)) + await store.sync("child") + const refreshing = store.sync("child", { force: true }) + const live = { ...stale, time: { created: 2 } } + + store.apply({ type: "message.updated", properties: { info: live } }) + store.apply({ + type: "message.part.delta", + properties: { sessionID: "child", messageID: stale.id, partID: stalePart.id, field: "text", delta: " live" }, + }) + pending.resolve(response([{ info: stale, parts: [stalePart] }])) + await refreshing + + expect(store.data.message.child).toEqual([live]) + expect(store.data.part[stale.id]).toEqual([{ ...stalePart, text: "stale live" }]) + }) + + test("keeps fetched message metadata when only a part changes", async () => { + const pending = deferredResponse() + const stale = userMessage("message") + const fetched = { ...stale, time: { created: 2 } } + const part = textPart(stale.id, { text: "stale" }) + const store = createServerSession(messageClient(response([{ info: stale, parts: [part] }]), pending.promise)) + await store.sync("child") + const refreshing = store.sync("child", { force: true }) + + store.apply({ + type: "message.part.delta", + properties: { sessionID: "child", messageID: stale.id, partID: part.id, field: "text", delta: " live" }, + }) + pending.resolve(response([{ info: fetched, parts: [part] }])) + await refreshing + + expect(store.data.message.child).toEqual([fetched]) + expect(store.data.part[stale.id]).toEqual([{ ...part, text: "stale live" }]) + }) + + test("preserves a part update when a forced refresh omits its message", async () => { + const pending = deferredResponse() + const message = userMessage("message") + const stale = textPart(message.id, { text: "stale" }) + const live = { ...stale, text: "live" } + const store = createServerSession(messageClient(response([{ info: message, parts: [stale] }]), pending.promise)) + await store.sync("child") + const refreshing = store.sync("child", { force: true }) + + store.apply({ type: "message.part.updated", properties: { sessionID: "child", part: live, time: 2 } }) + pending.resolve(response()) + await refreshing + + expect(store.data.message.child).toEqual([message]) + expect(store.data.part[message.id]).toEqual([live]) + }) + + test("ignores a late part update after its message is removed", async () => { + const pending = deferredResponse() + const message = userMessage("message") + const part = textPart(message.id) + const store = createServerSession(messageClient(pending.promise)) + const loading = store.sync("child") + + store.apply({ type: "message.updated", properties: { info: message } }) + store.apply({ type: "message.removed", properties: { sessionID: "child", messageID: message.id } }) + store.apply({ type: "message.part.updated", properties: { sessionID: "child", part, time: 2 } }) + pending.resolve(response([{ info: message, parts: [part] }])) + await loading + + expect(store.data.message.child).toEqual([]) + expect(store.data.part[message.id]).toBeUndefined() + }) + + test("ignores a late part update after a completed message removal", () => { + const message = userMessage("message") + const part = textPart(message.id) + const store = setup({ child: session("child") }).store + store.apply({ type: "message.updated", properties: { info: message } }) + store.apply({ type: "message.removed", properties: { sessionID: "child", messageID: message.id } }) + + store.apply({ type: "message.part.updated", properties: { sessionID: "child", part, time: 2 } }) + + expect(store.data.part[message.id]).toBeUndefined() + }) + + test("does not restore a completed message removal from a stale refresh", async () => { + const message = userMessage("message") + const part = textPart(message.id) + const store = createServerSession( + messageClient(response([{ info: message, parts: [part] }]), response([{ info: message, parts: [part] }])), + ) + await store.sync("child") + store.apply({ type: "message.removed", properties: { sessionID: "child", messageID: message.id } }) + + await store.sync("child", { force: true }) + + expect(store.data.message.child).toEqual([]) + expect(store.data.part[message.id]).toBeUndefined() + }) + + test("does not restore a completed part removal from a stale refresh", async () => { + const message = userMessage("message") + const part = textPart(message.id) + const store = createServerSession( + messageClient(response([{ info: message, parts: [part] }]), response([{ info: message, parts: [part] }])), + ) + await store.sync("child") + store.apply({ + type: "message.part.removed", + properties: { sessionID: "child", messageID: message.id, partID: part.id }, + }) + + await store.sync("child", { force: true }) + + expect(store.data.part[message.id]).toBeUndefined() + }) + + test("does not cache skipped optimistic parts", () => { + const message = userMessage("message") + const part = { id: "part", sessionID: "child", messageID: message.id, type: "step-start" as const } + const store = setup({ child: session("child") }).store + + store.optimistic.add({ sessionID: "child", message, parts: [part] }) + + expect(store.data.part[message.id]).toEqual([]) + }) + + test("clears stale delta buffers when replacing optimistic parts", () => { + const message = userMessage("message") + const stale = textPart(message.id, { id: "stale", text: "stale" }) + const optimistic = textPart(message.id, { id: "optimistic", text: "optimistic" }) + const store = setup({ child: session("child") }).store + store.optimistic.add({ sessionID: "child", message, parts: [stale] }) + store.apply({ + type: "message.part.delta", + properties: { sessionID: "child", messageID: message.id, partID: stale.id, field: "text", delta: " delta" }, + }) + + store.optimistic.add({ sessionID: "child", message, parts: [optimistic] }) + + expect(store.data.part_text_accum_delta[stale.id]).toBeUndefined() + expect(store.data.part_text_accum_delta[optimistic.id]).toBeUndefined() + }) + + test("preserves removals during history prepend", async () => { + const pending = deferredResponse() + const latest = userMessage("message-2", { time: { created: 2 } }) + const older = { ...latest, id: "message-1", time: { created: 1 } } + const store = createServerSession(messageClient(response([{ info: latest, parts: [] }], "older"), pending.promise)) + await store.sync("child") + const loading = store.history.loadMore("child") + + store.apply({ type: "message.removed", properties: { sessionID: "child", messageID: older.id } }) + pending.resolve(response([{ info: older, parts: [] }])) + await loading + + expect(store.data.message.child).toEqual([latest]) + }) + + test("does not scan cached messages for user roots during history prepend", async () => { + const guard = { active: false } + const latest = new Proxy(userMessage("message-2", { time: { created: 2 } }), { + get(target, property, receiver) { + if (guard.active && property === "role") throw new Error("cached role accessed") + return Reflect.get(target, property, receiver) + }, + }) + const older = userMessage("message-1") + const store = createServerSession( + messageClient(response([{ info: latest, parts: [] }], "older"), response([{ info: older, parts: [] }])), + ) + await store.sync("child") + guard.active = true + + await store.history.loadMore("child") + + expect(store.data.message.child).toEqual([older, latest]) + }) + + test("preserves loaded history during an incomplete refresh", async () => { + const older = userMessage("message-1") + const latest = userMessage("message-2", { time: { created: 2 } }) + const fresh = userMessage("message-3", { time: { created: 3 } }) + const store = createServerSession( + messageClient( + response( + [ + { info: older, parts: [] }, + { info: latest, parts: [] }, + ], + "older", + ), + response( + [ + { info: latest, parts: [] }, + { info: fresh, parts: [] }, + ], + "older", + ), + ), + ) + await store.sync("child") + + await store.sync("child", { force: true }) + + expect(store.data.message.child).toEqual([older, latest, fresh]) + }) + + test("drops stale recent messages omitted by an incomplete refresh", async () => { + const third = userMessage("message-3", { time: { created: 3 } }) + const fourth = userMessage("message-4", { time: { created: 4 } }) + const stale = userMessage("message-5", { time: { created: 5 } }) + const store = createServerSession( + messageClient( + response( + [ + { info: fourth, parts: [] }, + { info: stale, parts: [] }, + ], + "older", + ), + response( + [ + { info: third, parts: [] }, + { info: fourth, parts: [] }, + ], + "older", + ), + ), + ) + await store.sync("child") + + await store.sync("child", { force: true }) + + expect(store.data.message.child).toEqual([third, fourth]) + }) + + test("uses message creation time for incomplete refresh boundaries", async () => { + const older = userMessage("msg_z", { time: { created: 1 } }) + const boundary = userMessage("msg_m", { time: { created: 2 } }) + const stale = userMessage("msg_a", { time: { created: 3 } }) + const store = createServerSession( + messageClient( + response( + [ + { info: older, parts: [] }, + { info: stale, parts: [] }, + ], + "older", + ), + response([{ info: boundary, parts: [] }], "older"), + ), + ) + await store.sync("child") + + await store.sync("child", { force: true }) + + expect(store.data.message.child).toEqual([older, boundary]) + }) + + test("preserves a part update for a message being loaded from history", async () => { + const pending = deferredResponse() + const latest = userMessage("message-2", { time: { created: 2 } }) + const older = userMessage("message-1") + const stale = textPart(older.id, { text: "stale" }) + const live = { ...stale, text: "live" } + const store = createServerSession(messageClient(response([{ info: latest, parts: [] }], "older"), pending.promise)) + await store.sync("child") + const loading = store.history.loadMore("child") + + store.apply({ type: "message.part.updated", properties: { sessionID: "child", part: live, time: 2 } }) + pending.resolve(response([{ info: older, parts: [stale] }])) + await loading + + expect(store.data.part[older.id]).toEqual([live]) + }) + + test("does not clear newer orphan parts after terminal history prepend", async () => { + const pending = deferredResponse() + const latest = userMessage("message-2", { time: { created: 2 } }) + const older = userMessage("message-1") + const newer = userMessage("message-3", { time: { created: 3 } }) + const part = textPart(newer.id, { text: "live" }) + const store = createServerSession(messageClient(response([{ info: latest, parts: [] }], "older"), pending.promise)) + await store.sync("child") + const loading = store.history.loadMore("child") + + store.apply({ type: "message.part.updated", properties: { sessionID: "child", part, time: 3 } }) + pending.resolve(response([{ info: older, parts: [] }])) + await loading + store.apply({ type: "message.updated", properties: { sessionID: "child", info: newer } }) + + expect(store.data.part[newer.id]).toEqual([part]) + }) + + test("accepts an authoritative history part after an earlier unknown-parent update", async () => { + const pending = deferredResponse() + const history = deferredResponse() + const latest = userMessage("message-2", { time: { created: 2 } }) + const older = userMessage("message-1") + const part = textPart(older.id, { text: "live" }) + const store = createServerSession(messageClient(pending.promise, history.promise)) + const loading = store.sync("child") + + store.apply({ type: "message.part.updated", properties: { sessionID: "child", part, time: 2 } }) + pending.resolve(response([{ info: latest, parts: [] }], "older")) + await loading + + expect(store.data.part[older.id]).toEqual([part]) + + const loadingHistory = store.history.loadMore("child") + history.resolve(response([{ info: older, parts: [{ ...part, text: "stale" }] }])) + await loadingHistory + + expect(store.data.part[older.id]).toEqual([{ ...part, text: "stale" }]) + }) + + test("preserves an unknown-parent part removal across pages", async () => { + const initial = deferredResponse() + const history = deferredResponse() + const latest = userMessage("message-2", { time: { created: 2 } }) + const older = userMessage("message-1") + const part = textPart(older.id) + const store = createServerSession(messageClient(initial.promise, history.promise)) + const loading = store.sync("child") + + store.apply({ + type: "message.part.removed", + properties: { sessionID: "child", messageID: older.id, partID: part.id }, + }) + initial.resolve(response([{ info: latest, parts: [] }], "older")) + await loading + const loadingHistory = store.history.loadMore("child") + history.resolve(response([{ info: older, parts: [part] }])) + await loadingHistory + + expect(store.data.part[older.id]).toBeUndefined() + }) + + test("clears orphaned parts when a refresh drops a message", async () => { + const message = userMessage("message") + const part = textPart(message.id, { text: "stale" }) + const store = createServerSession(messageClient(response([{ info: message, parts: [part] }]), response())) + await store.sync("child") + store.apply({ + type: "message.part.delta", + properties: { sessionID: "child", messageID: message.id, partID: part.id, field: "text", delta: " delta" }, + }) + await store.sync("child", { force: true }) + + expect(store.data.message.child).toEqual([]) + expect(store.data.part[message.id]).toBeUndefined() + expect(store.data.part_text_accum_delta[part.id]).toBeUndefined() + }) + + test("applies events without a directory store", () => { + const ctx = setup({}) + ctx.store.apply({ type: "session.created", properties: { sessionID: "root", info: session("root") } }) + ctx.store.apply({ type: "session.status", properties: { sessionID: "root", status: { type: "busy" } } }) + + expect(ctx.store.get("root")?.directory).toBe("/repo") + expect(ctx.store.data.session_working("root")).toBe(true) + expect(ctx.get).toEqual([]) + }) + + test("preserves pinned session content under server-wide cache pressure", () => { + const ctx = setup({}) + ctx.store.pin("active") + ctx.store.optimistic.add({ + sessionID: "active", + message: { + id: "message", + sessionID: "active", + role: "assistant", + time: { created: 1 }, + parentID: "parent", + modelID: "model", + providerID: "provider", + mode: "build", + agent: "agent", + path: { cwd: "/repo", root: "/repo" }, + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + }, + parts: [], + }) + + for (let index = 0; index < 50; index++) { + ctx.store.remember(session(`session-${index}`)) + ctx.store.apply({ + type: "session.status", + properties: { sessionID: `session-${index}`, status: { type: "idle" } }, + }) + } + + expect(ctx.store.data.message.active?.map((message) => message.id)).toEqual(["message"]) + expect(ctx.store.data.session_status["session-0"]).toBeUndefined() + }) +}) diff --git a/packages/app/src/context/server-session.ts b/packages/app/src/context/server-session.ts new file mode 100644 index 0000000000000000000000000000000000000000..c5d98682f03cb361fa46d520f3cd9952d6fa759c --- /dev/null +++ b/packages/app/src/context/server-session.ts @@ -0,0 +1,1427 @@ +import { Binary } from "@opencode-ai/core/util/binary" +import { retry } from "@opencode-ai/core/util/retry" +import type { OpenCodeEvent, SessionApi, SessionMessageInfo } from "@opencode-ai/client/promise" +import type { + Message, + OpencodeClient, + Part, + PermissionRequest, + QuestionRequest, + Session, + SessionStatus, + Todo, +} from "@opencode-ai/sdk/v2/client" +import type { FileDiffInfo } from "@opencode-ai/client/promise" +import { batch } from "solid-js" +import { createStore, produce, reconcile } from "solid-js/store" +import { message as cleanMessage } from "@/utils/diffs" +import { sessionNotFoundError } from "@/utils/server-errors" +import { rootSession } from "@/utils/session-route" +import { normalizeSessionInfo } from "@/utils/session" +import { compareMessages, messageKey, normalizeSessionMessages } from "@/utils/session-message" +import { dropSessionCaches, pickSessionCacheEvictions, SESSION_CACHE_LIMIT } from "./global-sync/session-cache" +import { createV2SessionReducer, type V2SessionReduction } from "./server-session-v2-reducer" +import type { ServerApi } from "@/utils/server" + +type MessageApi = ServerApi["message"] + +const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0) +const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"]) +const initialMessagePageSize = 20 +const historyMessagePageSize = 200 +const sessionInfoLimit = 2_048 +const emptyIDs: ReadonlySet = new Set() + +function needsOlderTurnRoot(source: readonly SessionMessageInfo[]) { + const boundary = source.find( + (message) => + message.type === "user" || + message.type === "shell" || + message.type === "assistant" || + (message.type === "synthetic" && message.description?.trim()), + ) + return boundary?.type === "assistant" +} + +type OptimisticItem = { + message: Message + parts: Part[] + confirmedParts?: Part[] + confirmedMessage?: boolean +} + +type MessagePage = { + session: Message[] + part: { id: string; part: Part[] }[] + source?: SessionMessageInfo[] + sourceMode?: "latest" | "older" + projectSource?: boolean + cursor?: string + complete: boolean +} + +function legacyMessageSource(items: { info: Message; parts: Part[] }[]): SessionMessageInfo[] { + return items + .slice() + .sort((a, b) => compareMessages(a.info, b.info)) + .map((item) => { + if (item.info.role === "user") { + return { + id: item.info.id, + type: "user" as const, + text: item.parts.flatMap((part) => (part.type === "text" ? [part.text] : [])).join("\n"), + time: item.info.time, + } + } + return { + id: item.info.id, + type: "assistant" as const, + agent: item.info.agent ?? item.info.mode, + model: { id: item.info.modelID, providerID: item.info.providerID, variant: item.info.variant }, + content: [], + time: item.info.time, + } + }) +} + +// Most markers describe the current HTTP attempt; deltaParts persists non-durable stream state across retries. +type MessageLoadState = { + touchedMessages: Set + removedMessages: Set + retainedMessages: Set + touchedParts: Map> + deltaParts: Map> + carriedDeltaParts: Map> + removedParts: Map> + optimisticParts: Map> + orphanParents: Set + clearedMessageParts: Set + touchedSource: Set +} + +type MessageLoadBaseline = Pick< + MessageLoadState, + "touchedMessages" | "retainedMessages" | "touchedParts" | "clearedMessageParts" +> + +function mergeOptimisticPage(page: MessagePage, items: OptimisticItem[]) { + if (items.length === 0) return { ...page, observed: [] as { messageID: string; parts: Part[] }[] } + const session = [...page.session] + const part = new Map(page.part.map((item) => [item.id, item.part])) + const observed: { messageID: string; parts: Part[] }[] = [] + for (const item of items) { + const result = Binary.search(session, messageKey(item.message), messageKey) + const found = result.found + if (!found) session.splice(result.index, 0, item.message) + const current = part.get(item.message.id) + const confirmed = found ? item.parts.filter((part) => current?.some((value) => value.id === part.id)) : [] + if (found) observed.push({ messageID: item.message.id, parts: confirmed }) + part.set( + item.message.id, + merge( + found ? (current ?? []) : merge(item.confirmedParts ?? [], current ?? []), + item.parts.filter((part) => !confirmed.includes(part)), + ), + ) + } + return { + ...page, + session, + part: [...part.entries()].sort((a, b) => cmp(a[0], b[0])).map(([id, parts]) => ({ id, part: parts })), + observed, + } +} + +function runInflight(map: Map>, key: string, task: () => Promise) { + const pending = map.get(key) + if (pending) return pending + const promise = task().finally(() => { + if (map.get(key) === promise) map.delete(key) + }) + map.set(key, promise) + return promise +} + +function merge(a: readonly T[], b: readonly T[]) { + const items = new Map(a.map((item) => [item.id, item] as const)) + for (const item of b) items.set(item.id, item) + return [...items.values()].sort((x, y) => cmp(x.id, y.id)) +} + +function reconcileFetched( + fetched: T[], + current: readonly T[], + options: { + touched?: ReadonlySet + retained?: ReadonlySet + removed?: ReadonlySet + preserveUnfetched?: boolean | ((item: T) => boolean) + compare?: (a: T, b: T) => number + } = {}, +) { + const result = new Map(fetched.map((item) => [item.id, item])) + const live = new Map(current.map((item) => [item.id, item])) + if (options.preserveUnfetched) { + for (const item of current) { + if (!result.has(item.id) && (options.preserveUnfetched === true || options.preserveUnfetched(item))) + result.set(item.id, item) + } + } + for (const id of options.retained ?? emptyIDs) { + if (result.has(id)) continue + const item = live.get(id) + if (item) result.set(id, item) + } + // Events observed while the request is pending are the freshest client state for those identities. + for (const id of options.touched ?? emptyIDs) { + const item = live.get(id) + if (item) result.set(id, item) + if (!item) result.delete(id) + } + for (const id of options.removed ?? emptyIDs) result.delete(id) + const items = [...result.values()] + return options.compare ? items.sort(options.compare) : items +} + +type ServerSessionOptions = { retry?: typeof retry; protocol?: Promise<"v1" | "v2"> } + +export function createServerSession( + client: OpencodeClient, + sessionApiOrOptions?: SessionApi | ServerSessionOptions, + messageApi?: MessageApi, + currentOptions?: ServerSessionOptions, +) { + const sessionApi = messageApi ? (sessionApiOrOptions as SessionApi) : undefined + const options = messageApi ? currentOptions : (sessionApiOrOptions as ServerSessionOptions | undefined) + const [data, setData] = createStore({ + info: {} as Record, + session_status: {} as Record, + session_diff: {} as Record, + todo: {} as Record, + permission: {} as Record, + question: {} as Record, + message: {} as Record, + session_message: {} as Record, + part: {} as Record, + part_text_accum_delta: {} as Record, + session_working(id: string) { + return (this.session_status[id]?.type ?? "idle") !== "idle" + }, + }) + const requests = new Map>() + const inflight = new Map>() + const inflightTodo = new Map>() + const optimistic = new Map>() + const v2 = createV2SessionReducer() + const messageLoads = new Map() + const pendingParts = new Map>>() + const orphanParts = new Map>() + const removedMessages = new Map>() + const deltaBases = new Map() + const deleteMessageParts = ( + cache: { part: Record; part_text_accum_delta: Record }, + messageID: string, + ) => { + for (const part of cache.part[messageID] ?? []) { + delete cache.part_text_accum_delta[part.id] + deltaBases.delete(part.id) + } + delete cache.part[messageID] + } + const seen = new Set() + const infoSeen = new Set() + const pinned = new Map() + const generations = new Map() + const generation = (sessionID: string) => { + const current = generations.get(sessionID) + if (current) return current + const created = {} + generations.set(sessionID, created) + return created + } + const [meta, setMeta] = createStore({ + limit: {} as Record, + cursor: {} as Record, + complete: {} as Record, + loading: {} as Record, + at: {} as Record, + }) + + const indexLegacyMessage = (message: Message) => { + const current = data.session_message[message.sessionID] ?? [] + if (current.some((item) => item.id === message.id)) return + setData( + "session_message", + message.sessionID, + reconcile([...current, ...legacyMessageSource([{ info: message, parts: [] }])]), + ) + } + + const remember = (session: Session) => { + setData("info", session.id, reconcile(session)) + infoSeen.delete(session.id) + infoSeen.add(session.id) + if (infoSeen.size > sessionInfoLimit) { + const preserve = new Set([ + ...pinned.keys(), + ...requests.keys(), + ...inflight.keys(), + ...inflightTodo.keys(), + ...messageLoads.keys(), + ...optimistic.keys(), + ...Object.entries(data.permission) + .filter(([, items]) => items.length > 0) + .map(([sessionID]) => sessionID), + ...Object.entries(data.question) + .filter(([, items]) => items.length > 0) + .map(([sessionID]) => sessionID), + ...Object.entries(data.session_status) + .filter(([, status]) => status.type !== "idle") + .map(([sessionID]) => sessionID), + ]) + for (const sessionID of preserve) { + let current = data.info[sessionID] + while (current) { + preserve.add(current.id) + current = current.parentID ? data.info[current.parentID] : undefined + } + } + const stale: string[] = [] + for (const sessionID of infoSeen) { + if (infoSeen.size - stale.length <= sessionInfoLimit) break + if (!preserve.has(sessionID)) stale.push(sessionID) + } + stale.forEach((sessionID) => infoSeen.delete(sessionID)) + stale.forEach((sessionID) => generations.delete(sessionID)) + setData( + "info", + produce((draft) => stale.forEach((sessionID) => delete draft[sessionID])), + ) + } + return session + } + + const resolve = (sessionID: string, options?: { force?: boolean }) => { + const cached = data.info[sessionID] + if (cached && !options?.force) return Promise.resolve(cached) + const pending = requests.get(sessionID) + if (pending) return pending + const active = generation(sessionID) + const request = sessionApi + ? sessionApi.get({ sessionID }).then(normalizeSessionInfo) + : client.session.get({ sessionID }).then((result) => { + if (!result.data) throw sessionNotFoundError(sessionID) + return result.data + }) + const resolved = request.then((result) => { + if (generations.get(sessionID) !== active) return result + return remember(result) + }) + requests.set(sessionID, resolved) + const cleanup = () => { + if (requests.get(sessionID) === resolved) requests.delete(sessionID) + if ( + generations.get(sessionID) === active && + !data.info[sessionID] && + !requests.has(sessionID) && + !messageLoads.has(sessionID) && + !inflight.has(sessionID) && + !inflightTodo.has(sessionID) + ) + generations.delete(sessionID) + } + void resolved.then(cleanup, cleanup) + return resolved + } + + const peekLineage = (sessionID: string) => { + const session = data.info[sessionID] + if (!session) return + const seen = new Set([session.id]) + let root = session + while (root.parentID) { + if (seen.has(root.parentID)) throw new Error(`Session parent cycle: ${root.parentID}`) + seen.add(root.parentID) + const parent = data.info[root.parentID] + if (!parent) return + root = parent + } + return { session, root } + } + + const clearOptimistic = (sessionID: string, messageID?: string) => { + if (!messageID) { + optimistic.delete(sessionID) + return + } + const items = optimistic.get(sessionID) + if (!items) return + items.delete(messageID) + if (items.size === 0) optimistic.delete(sessionID) + } + + const clearOptimisticPart = (sessionID: string, messageID: string, partID: string) => { + const items = optimistic.get(sessionID) + const item = items?.get(messageID) + if (!items || !item) return + const parts = item.parts.filter((part) => part.id !== partID) + const confirmedParts = item.confirmedParts?.filter((part) => part.id !== partID) + if (parts.length === 0) { + clearOptimistic(sessionID, messageID) + return + } + items.set(messageID, { ...item, parts, confirmedParts, confirmedMessage: true }) + } + + const confirmOptimisticPart = (sessionID: string, messageID: string, part: Part) => { + const items = optimistic.get(sessionID) + const item = items?.get(messageID) + if (!items || !item) return + const parts = item.parts.filter((value) => value.id !== part.id) + if (parts.length === 0) { + clearOptimistic(sessionID, messageID) + return + } + items.set(messageID, { + ...item, + parts, + confirmedParts: merge(item.confirmedParts ?? [], [part]), + confirmedMessage: true, + }) + } + + const confirmOptimistic = (sessionID: string, messageID: string, confirmedParts: Part[]) => { + const items = optimistic.get(sessionID) + const item = items?.get(messageID) + if (!items || !item) return + const confirmed = new Set(confirmedParts.map((part) => part.id)) + const parts = item.parts.filter((part) => !confirmed.has(part.id)) + if (parts.length === 0) { + clearOptimistic(sessionID, messageID) + return + } + items.set(messageID, { + ...item, + parts, + confirmedParts: merge(item.confirmedParts ?? [], confirmedParts), + confirmedMessage: true, + }) + } + + const trackPartChange = (sessionID: string, messageID: string, partID: string) => { + const load = messageLoads.get(sessionID) + if (!load) return + // A part event keeps an existing parent when the fetched page omits it without overriding fetched metadata. + const messages = data.message[sessionID] + if (messages?.some((message) => message.id === messageID)) load.retainedMessages.add(messageID) + const parts = load.touchedParts.get(messageID) + if (parts) { + parts.add(partID) + return + } + load.touchedParts.set(messageID, new Set([partID])) + } + + const resetMessageLoad = (sessionID: string, load: MessageLoadState, baseline?: MessageLoadBaseline) => { + load.touchedMessages.clear() + load.retainedMessages.clear() + load.touchedParts.clear() + load.carriedDeltaParts.clear() + load.clearedMessageParts.clear() + for (const messageID of load.removedMessages) { + load.touchedMessages.add(messageID) + load.clearedMessageParts.add(messageID) + } + for (const [messageID, parts] of load.deltaParts) { + load.touchedParts.set(messageID, new Set(parts)) + load.carriedDeltaParts.set(messageID, new Set(parts)) + const messages = data.message[sessionID] + if (messages?.some((message) => message.id === messageID)) load.retainedMessages.add(messageID) + } + for (const [messageID, parts] of load.removedParts) { + const touched = load.touchedParts.get(messageID) ?? new Set() + parts.forEach((partID) => touched.add(partID)) + load.touchedParts.set(messageID, touched) + const messages = data.message[sessionID] + if (messages?.some((message) => message.id === messageID)) load.retainedMessages.add(messageID) + } + for (const [messageID, parts] of load.optimisticParts) { + load.removedMessages.delete(messageID) + load.clearedMessageParts.add(messageID) + load.touchedMessages.add(messageID) + const touched = load.touchedParts.get(messageID) ?? new Set() + parts.forEach((partID) => touched.add(partID)) + load.touchedParts.set(messageID, touched) + } + baseline?.touchedMessages.forEach((messageID) => load.touchedMessages.add(messageID)) + baseline?.retainedMessages.forEach((messageID) => load.retainedMessages.add(messageID)) + baseline?.clearedMessageParts.forEach((messageID) => load.clearedMessageParts.add(messageID)) + baseline?.touchedParts.forEach((parts, messageID) => { + const touched = load.touchedParts.get(messageID) ?? new Set() + parts.forEach((partID) => touched.add(partID)) + load.touchedParts.set(messageID, touched) + }) + } + + const messageLoadBaseline = (load: MessageLoadState, exclude: string): MessageLoadBaseline => ({ + touchedMessages: new Set([...load.touchedMessages].filter((messageID) => messageID !== exclude)), + retainedMessages: new Set([...load.retainedMessages].filter((messageID) => messageID !== exclude)), + touchedParts: new Map( + [...load.touchedParts] + .filter(([messageID]) => messageID !== exclude) + .map(([messageID, parts]) => [messageID, new Set(parts)]), + ), + clearedMessageParts: new Set([...load.clearedMessageParts].filter((messageID) => messageID !== exclude)), + }) + + const evict = (sessionIDs: string[]) => { + if (sessionIDs.length === 0) return + const evicted = new Set(sessionIDs) + for (const [partID, item] of deltaBases) { + if (evicted.has(item.sessionID)) deltaBases.delete(partID) + } + sessionIDs.forEach((sessionID) => { + generations.delete(sessionID) + clearOptimistic(sessionID) + requests.delete(sessionID) + inflight.delete(sessionID) + inflightTodo.delete(sessionID) + messageLoads.delete(sessionID) + v2.clear(sessionID) + pendingParts.delete(sessionID) + orphanParts.delete(sessionID) + removedMessages.delete(sessionID) + }) + setData( + produce((draft) => { + dropSessionCaches(draft, sessionIDs) + }), + ) + setMeta( + produce((draft) => { + for (const sessionID of sessionIDs) { + delete draft.limit[sessionID] + delete draft.cursor[sessionID] + delete draft.complete[sessionID] + delete draft.loading[sessionID] + delete draft.at[sessionID] + } + }), + ) + } + + const protectedSessions = () => + new Set([ + ...pinned.keys(), + ...requests.keys(), + ...inflight.keys(), + ...inflightTodo.keys(), + ...messageLoads.keys(), + ...optimistic.keys(), + ...Object.entries(data.permission) + .filter(([, items]) => items.length > 0) + .map(([sessionID]) => sessionID), + ...Object.entries(data.question) + .filter(([, items]) => items.length > 0) + .map(([sessionID]) => sessionID), + ...Object.entries(data.session_status) + .filter(([, status]) => status.type !== "idle") + .map(([sessionID]) => sessionID), + ]) + + const touch = (sessionID: string) => + evict( + pickSessionCacheEvictions({ seen, keep: sessionID, limit: SESSION_CACHE_LIMIT, preserve: protectedSessions() }), + ) + + const fetchMessages = async (sessionID: string, limit: number, before?: string, onAttempt?: () => void) => { + if (messageApi && (await options?.protocol) !== "v1") { + const request = (cursor?: string) => + (options?.retry ?? retry)(() => { + onAttempt?.() + return messageApi.list(cursor ? { sessionID, limit, cursor } : { sessionID, limit, order: "desc" }) + }) + const first = await request(before) + const pages = [first] + while (pages.at(-1)?.cursor.next && needsOlderTurnRoot(pages.flatMap((page) => page.data).toReversed())) { + const response = await request(pages.at(-1)!.cursor.next ?? undefined) + pages.push(response) + if (!response.data.length) break + } + const response = pages.at(-1)! + const source = pages.flatMap((page) => page.data).toReversed() + const normalized = normalizeSessionMessages(sessionID, source) + return { + session: normalized.messages.sort(compareMessages), + part: [...normalized.parts.entries()] + .map(([id, part]) => ({ id, part: part.sort((a, b) => cmp(a.id, b.id)) })) + .sort((a, b) => cmp(a.id, b.id)), + source, + sourceMode: before ? ("older" as const) : ("latest" as const), + projectSource: true, + cursor: response.cursor.next ?? undefined, + complete: response.data.length === 0, + } + } + const response = await (options?.retry ?? retry)(() => { + onAttempt?.() + return client.session.messages({ sessionID, limit, before }) + }) + const items = (response.data ?? []).filter((item) => !!item?.info?.id) + return { + session: items.map((item) => cleanMessage(item.info)).sort(compareMessages), + part: items.map((item) => ({ + id: item.info.id, + part: item.parts.filter((part) => !!part?.id).sort((a, b) => cmp(a.id, b.id)), + })), + source: legacyMessageSource(items), + sourceMode: before ? ("older" as const) : ("latest" as const), + cursor: response.response.headers.get("x-next-cursor") ?? undefined, + complete: !response.response.headers.get("x-next-cursor"), + } + } + + const fetchMessage = async (sessionID: string, messageID: string, onAttempt?: () => void) => { + if (sessionApi && (await options?.protocol) !== "v1") { + const response = await (options?.retry ?? retry)(() => { + onAttempt?.() + return sessionApi.message({ sessionID, messageID }) + }) + const normalized = normalizeSessionMessages(sessionID, [response]) + const message = normalized.messages[0] + if (!message) throw new Error(`Message not found: ${messageID}`) + return { message, parts: normalized.parts.get(messageID) ?? [] } + } + const response = await (options?.retry ?? retry)(() => { + onAttempt?.() + return client.session.message({ sessionID, messageID }) + }) + if (!response.data?.info?.id) throw new Error(`Message not found: ${messageID}`) + return { + message: cleanMessage(response.data.info), + parts: response.data.parts.filter((part) => !!part?.id).sort((a, b) => cmp(a.id, b.id)), + } + } + + const replaceMessages = (sessionID: string, messages: Message[]) => { + const messageIDs = new Set(messages.map((message) => message.id)) + const dropped = (data.message[sessionID] ?? []).filter((message) => !messageIDs.has(message.id)) + setData("message", sessionID, reconcile(messages, { key: "id" })) + setData( + produce((draft) => { + for (const message of dropped) deleteMessageParts(draft, message.id) + }), + ) + return messageIDs + } + + const replaceParts = ( + sessionID: string, + items: MessagePage["part"], + messageIDs: Set, + load?: MessageLoadState, + ) => { + for (const item of items) { + if (!messageIDs.has(item.id)) continue + const fetched = load?.clearedMessageParts.has(item.id) + ? [] + : item.part.filter((part) => !SKIP_PARTS.has(part.type)) + const fetchedIDs = new Set(fetched.map((part) => part.id)) + const pending = pendingParts.get(sessionID)?.get(item.id) + const touched = new Set([...(load?.touchedParts.get(item.id) ?? []), ...(pending ?? [])]) + for (const part of fetched) { + const accumulated = data.part_text_accum_delta[part.id] + const base = deltaBases.get(part.id)?.base + const preserveDelta = + base !== undefined && + accumulated !== undefined && + "text" in part && + typeof part.text === "string" && + part.text.startsWith(base) && + accumulated.startsWith(part.text) && + accumulated !== part.text + if (preserveDelta) touched.add(part.id) + if (load?.carriedDeltaParts.get(item.id)?.has(part.id) && !preserveDelta) touched.delete(part.id) + } + for (const partID of load?.carriedDeltaParts.get(item.id) ?? []) { + if (!fetchedIDs.has(partID)) touched.delete(partID) + } + const parts = reconcileFetched(fetched, data.part[item.id] ?? [], { touched }) + if (!parts.length) { + orphanParts.get(sessionID)?.delete(item.id) + setData(produce((draft) => deleteMessageParts(draft, item.id))) + continue + } + const partIDs = new Set(parts.map((part) => part.id)) + setData( + "part_text_accum_delta", + produce((draft) => { + for (const part of data.part[item.id] ?? []) { + if (!partIDs.has(part.id) || !touched.has(part.id)) { + delete draft[part.id] + deltaBases.delete(part.id) + } + } + }), + ) + setData("part", item.id, reconcile(parts, { key: "id" })) + orphanParts.get(sessionID)?.delete(item.id) + } + } + + const applyMessagePage = ( + sessionID: string, + page: MessagePage, + load: MessageLoadState | undefined, + preserveUnfetched: boolean | ((message: Message) => boolean), + cleanupOrphans: boolean, + ) => { + const source = page.source + ? (() => { + const incoming = new Map(page.source.map((message) => [message.id, message])) + const existing = data.session_message[sessionID] ?? [] + const current = existing.filter((message) => !incoming.has(message.id)) + const live = new Map(existing.map((message) => [message.id, message])) + return (page.sourceMode === "older" ? [...page.source, ...current] : [...current, ...page.source]).map( + (message) => (load?.touchedSource.has(message.id) ? (live.get(message.id) ?? message) : message), + ) + })() + : undefined + const projected = + page.projectSource && source + ? (() => { + const normalized = normalizeSessionMessages(sessionID, source) + return { + ...page, + session: normalized.messages.sort(compareMessages), + part: [...normalized.parts.entries()] + .map(([id, part]) => ({ id, part: part.sort((a, b) => cmp(a.id, b.id)) })) + .sort((a, b) => cmp(a.id, b.id)), + } + })() + : page + const merged = mergeOptimisticPage(projected, [...(optimistic.get(sessionID)?.values() ?? [])]) + merged.observed.forEach((item) => { + if (!load?.clearedMessageParts.has(item.messageID)) confirmOptimistic(sessionID, item.messageID, item.parts) + }) + const touchedMessages = new Set([...(load?.touchedMessages ?? []), ...(removedMessages.get(sessionID) ?? [])]) + const messages = reconcileFetched(merged.session, data.message[sessionID] ?? [], { + touched: touchedMessages, + retained: load?.retainedMessages, + removed: load?.removedMessages, + preserveUnfetched, + compare: compareMessages, + }) + batch(() => { + if (source) setData("session_message", sessionID, reconcile(source)) + const messageIDs = replaceMessages(sessionID, messages) + replaceParts(sessionID, merged.part, messageIDs, load) + const orphans = orphanParts.get(sessionID) + if (cleanupOrphans && page.complete && orphans) { + for (const messageID of orphans) { + if (!messageIDs.has(messageID)) setData(produce((draft) => deleteMessageParts(draft, messageID))) + } + orphanParts.delete(sessionID) + } + setMeta("limit", sessionID, messages.length) + setMeta("cursor", sessionID, merged.cursor) + setMeta("complete", sessionID, merged.complete) + setMeta("at", sessionID, Date.now()) + }) + } + + const loadMessages = async (sessionID: string, limit: number, before?: string, mode?: "replace" | "prepend") => { + if (meta.loading[sessionID]) return + const active = generation(sessionID) + const load: MessageLoadState = { + touchedMessages: new Set(), + removedMessages: new Set(), + retainedMessages: new Set(), + touchedParts: new Map(), + deltaParts: new Map(), + carriedDeltaParts: new Map(), + removedParts: new Map(), + optimisticParts: new Map(), + orphanParents: new Set(), + clearedMessageParts: new Set(), + touchedSource: new Set(), + } + messageLoads.set(sessionID, load) + setMeta("loading", sessionID, true) + let applied = false + try { + const page = await fetchMessages(sessionID, limit, before, () => resetMessageLoad(sessionID, load)) + const first = page.session.reduce( + (oldest, message) => (!oldest || compareMessages(message, oldest) < 0 ? message : oldest), + undefined, + ) + if (generations.get(sessionID) !== active) return + + const parents = [] as Awaited>[] + if (mode !== "prepend") { + const users = new Set([ + ...page.session.filter((message) => message.role === "user").map((message) => message.id), + ...(data.message[sessionID] ?? []) + .filter((message) => { + if (message.role !== "user") return false + const item = optimistic.get(sessionID)?.get(message.id) + return load.touchedMessages.has(message.id) && (!item || item.confirmedMessage === true) + }) + .map((message) => message.id), + ]) + const parentIDs = [ + ...new Set( + page.session.flatMap((message) => + message.role === "assistant" && !users.has(message.parentID) ? [message.parentID] : [], + ), + ), + ] + for (const parentID of parentIDs) { + if (generations.get(sessionID) !== active) break + const parent = await fetchMessage(sessionID, parentID, () => + resetMessageLoad(sessionID, load, messageLoadBaseline(load, parentID)), + ).catch((error) => { + const cause = error instanceof Error && typeof error.cause === "object" ? error.cause : undefined + if (cause && "status" in cause && cause.status === 404) { + load.removedMessages.add(parentID) + return + } + throw error + }) + if (!parent) continue + if (parent.message.role !== "user") throw new Error(`Assistant parent is not a user message: ${parentID}`) + parents.push(parent) + } + } + if (generations.get(sessionID) !== active) return + const result = + mode === "prepend" + ? page + : { + ...page, + session: merge( + page.session, + parents.map((parent) => parent.message), + ).sort(compareMessages), + part: merge( + page.part, + parents.map((parent) => ({ id: parent.message.id, part: parent.parts })), + ), + } + const preserveUnfetched = + mode === "prepend" || + (!result.complete && (!first || ((message: Message) => compareMessages(message, first) < 0))) + applyMessagePage( + sessionID, + result, + messageLoads.get(sessionID) === load ? load : undefined, + preserveUnfetched, + mode !== "prepend", + ) + applied = true + } finally { + if (!applied && generations.get(sessionID) === active && messageLoads.get(sessionID) === load) { + for (const messageID of load.orphanParents) { + if (!orphanParts.get(sessionID)?.has(messageID)) continue + setData(produce((draft) => deleteMessageParts(draft, messageID))) + orphanParts.get(sessionID)?.delete(messageID) + } + if (orphanParts.get(sessionID)?.size === 0) orphanParts.delete(sessionID) + } + if (messageLoads.get(sessionID) === load) messageLoads.delete(sessionID) + if (generations.get(sessionID) === active) setMeta("loading", sessionID, false) + } + } + + const sync = (sessionID: string, options?: { force?: boolean; messageLimit?: number }) => { + touch(sessionID) + return runInflight(inflight, sessionID, async () => { + const cached = data.message[sessionID] !== undefined && meta.limit[sessionID] !== undefined + if (cached && data.info[sessionID] && !options?.force) return + await Promise.all([ + resolve(sessionID, options), + cached && !options?.force + ? Promise.resolve() + : loadMessages(sessionID, options?.messageLimit ?? meta.limit[sessionID] ?? initialMessagePageSize), + ]) + }) + } + + const prefetch = async (sessionID: string, limit: number) => { + touch(sessionID) + await inflight.get(sessionID) + if ( + Date.now() - (meta.at[sessionID] ?? 0) <= 15_000 && + (meta.complete[sessionID] || (data.message[sessionID]?.length ?? 0) >= limit) + ) + return + await runInflight(inflight, sessionID, () => loadMessages(sessionID, limit)) + } + + const eventSessionID = (event: { type: string; properties?: unknown }) => { + const properties = event.properties + if (!properties || typeof properties !== "object") return + if ("sessionID" in properties && typeof properties.sessionID === "string") return properties.sessionID + if ( + "info" in properties && + properties.info && + typeof properties.info === "object" && + "sessionID" in properties.info && + typeof properties.info.sessionID === "string" + ) + return properties.info.sessionID + if ( + "part" in properties && + properties.part && + typeof properties.part === "object" && + "sessionID" in properties.part && + typeof properties.part.sessionID === "string" + ) + return properties.part.sessionID + } + + const projectV2 = (reduction: V2SessionReduction) => { + reduction.touched.forEach((messageID) => messageLoads.get(reduction.sessionID)?.touchedSource.add(messageID)) + setData("session_message", reduction.sessionID, reconcile(reduction.messages)) + if (reduction.touched.length === 0) return + + const touched = new Set(reduction.touched) + let parentID: string | undefined + for (const message of reduction.messages) { + if (message.type === "user" || (message.type === "synthetic" && message.description?.trim())) + parentID = message.id + if (message.type === "shell") { + if (touched.has(message.id)) touched.add(`${message.id}:assistant`) + parentID = undefined + } + if (message.type === "assistant" && touched.has(message.id) && parentID) touched.add(parentID) + if (message.type === "compaction" && touched.has(message.id) && parentID) touched.add(parentID) + } + + const normalized = normalizeSessionMessages(reduction.sessionID, reduction.messages) + batch(() => { + for (const message of normalized.messages) { + if (!touched.has(message.id)) continue + apply({ type: "message.updated", properties: { sessionID: reduction.sessionID, info: message } }) + } + for (const messageID of touched) { + const next = normalized.parts.get(messageID) ?? [] + const nextIDs = new Set(next.map((part) => part.id)) + for (const part of next) { + apply({ type: "message.part.updated", properties: { sessionID: reduction.sessionID, part } }) + } + for (const part of data.part[messageID] ?? []) { + if (nextIDs.has(part.id)) continue + apply({ + type: "message.part.removed", + properties: { sessionID: reduction.sessionID, messageID, partID: part.id }, + }) + } + } + }) + } + + const hydrateV2Message = (sessionID: string, messageID: string) => { + if (!sessionApi) return + void sessionApi + .message({ sessionID, messageID }) + .then((message) => { + const current = data.session_message[sessionID] ?? [] + const messages = [...current.filter((item) => item.id !== message.id), message].sort(compareMessages) + projectV2({ sessionID, messages, touched: [message.id] }) + }) + .catch(() => {}) + } + + const applyV2 = (event: OpenCodeEvent) => { + if (!("data" in event) || !("sessionID" in event.data) || typeof event.data.sessionID !== "string") return + const sessionID = event.data.sessionID + const reduction = v2.reduce(data.session_message[sessionID] ?? [], event) + if (reduction) { + projectV2(reduction) + if (reduction.missing) hydrateV2Message(sessionID, reduction.missing) + } + + const info = data.info[sessionID] + if (event.type === "session.renamed" && info) + remember({ ...info, title: event.data.title, time: { ...info.time, updated: event.created } }) + if (event.type === "session.moved" && info) + remember({ + ...info, + projectID: event.data.projectID ?? info.projectID, + workspaceID: event.data.location.workspaceID, + directory: event.data.location.directory, + path: event.data.subpath, + time: { ...info.time, updated: event.created }, + }) + if (event.type === "session.usage.updated" && info) + remember({ ...info, cost: event.data.cost, tokens: event.data.tokens }) + // if (event.type === "session.archived") { + // if (info) remember({ ...info, time: { ...info.time, archived: event.created, updated: event.created } }) + // evict([sessionID]) + // } + if (event.type === "session.execution.started") setData("session_status", sessionID, { type: "busy" }) + if ( + event.type === "session.execution.succeeded" || + event.type === "session.execution.failed" || + event.type === "session.execution.interrupted" + ) + setData("session_status", sessionID, { type: "idle" }) + if (event.type === "session.retry.scheduled") + setData("session_status", sessionID, { + type: "retry", + attempt: event.data.attempt, + message: event.data.error.message, + next: event.data.at, + }) + if (event.type === "session.forked") void resolve(sessionID, { force: true }).catch(() => {}) + if ( + event.type === "session.revert.staged" || + event.type === "session.revert.cleared" || + event.type === "session.revert.committed" + ) + void resolve(sessionID, { force: true }).catch(() => {}) + } + + const apply = (event: { type: string; properties?: unknown }) => { + const eventID = eventSessionID(event) + if (eventID) { + touch(eventID) + if ( + !data.info[eventID] && + event.type !== "session.created" && + event.type !== "session.updated" && + event.type !== "session.deleted" + ) + void resolve(eventID).catch(() => {}) + } + switch (event.type) { + case "session.created": + remember((event.properties as { info: Session }).info) + return + case "session.updated": { + const info = (event.properties as { info: Session }).info + remember(info) + if (info.time.archived) evict([info.id]) + return + } + case "session.deleted": { + const properties = event.properties as { sessionID?: string; info?: Session } + const sessionID = properties.info?.id ?? properties.sessionID + if (!sessionID) return + infoSeen.delete(sessionID) + setData( + "info", + produce((draft) => void delete draft[sessionID]), + ) + evict([sessionID]) + return + } + case "todo.updated": { + const props = event.properties as { sessionID: string; todos: Todo[] } + setData("todo", props.sessionID, reconcile(props.todos, { key: "id" })) + return + } + case "session.status": { + const props = event.properties as { sessionID: string; status: SessionStatus } + setData("session_status", props.sessionID, reconcile(props.status)) + return + } + case "message.updated": { + const info = cleanMessage((event.properties as { info: Message }).info) + indexLegacyMessage(info) + const load = messageLoads.get(info.sessionID) + load?.touchedMessages.add(info.id) + load?.removedMessages.delete(info.id) + const items = optimistic.get(info.sessionID) + const item = items?.get(info.id) + if (items && item) { + if (item.parts.length === 0) clearOptimistic(info.sessionID, info.id) + if (item.parts.length > 0) items.set(info.id, { ...item, confirmedMessage: true }) + } + const orphans = orphanParts.get(info.sessionID) + orphans?.delete(info.id) + if (orphans?.size === 0) orphanParts.delete(info.sessionID) + const removedMessagesForSession = removedMessages.get(info.sessionID) + removedMessagesForSession?.delete(info.id) + if (removedMessagesForSession?.size === 0) removedMessages.delete(info.sessionID) + const messages = data.message[info.sessionID] + if (!messages) { + setData("message", info.sessionID, [info]) + return + } + const result = Binary.search(messages, messageKey(info), messageKey) + if (result.found) setData("message", info.sessionID, result.index, reconcile(info)) + if (!result.found) + setData("message", info.sessionID, (value = []) => { + const next = value.slice() + next.splice(result.index, 0, info) + return next + }) + return + } + case "message.removed": { + const props = event.properties as { sessionID: string; messageID: string } + setData("session_message", props.sessionID, (messages) => + messages?.filter((message) => message.id !== props.messageID), + ) + const load = messageLoads.get(props.sessionID) + load?.touchedMessages.add(props.messageID) + load?.removedMessages.add(props.messageID) + load?.clearedMessageParts.add(props.messageID) + load?.deltaParts.delete(props.messageID) + load?.carriedDeltaParts.delete(props.messageID) + load?.removedParts.delete(props.messageID) + load?.optimisticParts.delete(props.messageID) + pendingParts.get(props.sessionID)?.delete(props.messageID) + if (pendingParts.get(props.sessionID)?.size === 0) pendingParts.delete(props.sessionID) + const removedMessagesForSession = removedMessages.get(props.sessionID) ?? new Set() + removedMessagesForSession.add(props.messageID) + removedMessages.set(props.sessionID, removedMessagesForSession) + clearOptimistic(props.sessionID, props.messageID) + setData( + produce((draft) => { + const messages = draft.message[props.sessionID] + if (messages) { + const index = messages.findIndex((message) => message.id === props.messageID) + if (index >= 0) messages.splice(index, 1) + } + deleteMessageParts(draft, props.messageID) + }), + ) + return + } + case "message.part.updated": { + const part = (event.properties as { part: Part }).part + if (SKIP_PARTS.has(part.type)) return + const messages = data.message[part.sessionID] + const load = messageLoads.get(part.sessionID) + const missing = !messages?.some((message) => message.id === part.messageID) + // Outside a page load, accepting a part without its ordered parent event would create an unbounded orphan. + if ( + missing && + (!load || + load.clearedMessageParts.has(part.messageID) || + removedMessages.get(part.sessionID)?.has(part.messageID)) + ) + return + if (missing) { + const orphans = orphanParts.get(part.sessionID) ?? new Set() + orphans.add(part.messageID) + orphanParts.set(part.sessionID, orphans) + load?.orphanParents.add(part.messageID) + } + const deltas = load?.deltaParts.get(part.messageID) + deltas?.delete(part.id) + if (deltas?.size === 0) load?.deltaParts.delete(part.messageID) + const carried = load?.carriedDeltaParts.get(part.messageID) + carried?.delete(part.id) + if (carried?.size === 0) load?.carriedDeltaParts.delete(part.messageID) + const removed = load?.removedParts.get(part.messageID) + removed?.delete(part.id) + if (removed?.size === 0) load?.removedParts.delete(part.messageID) + const pending = pendingParts.get(part.sessionID)?.get(part.messageID) + pending?.delete(part.id) + if (pending?.size === 0) pendingParts.get(part.sessionID)?.delete(part.messageID) + if (pendingParts.get(part.sessionID)?.size === 0) pendingParts.delete(part.sessionID) + const optimistic = load?.optimisticParts.get(part.messageID) + optimistic?.delete(part.id) + if (optimistic?.size === 0) load?.optimisticParts.delete(part.messageID) + deltaBases.delete(part.id) + trackPartChange(part.sessionID, part.messageID, part.id) + confirmOptimisticPart(part.sessionID, part.messageID, part) + setData( + "part_text_accum_delta", + produce((draft) => void delete draft[part.id]), + ) + const parts = data.part[part.messageID] + if (!parts) { + setData("part", part.messageID, [part]) + return + } + const result = Binary.search(parts, part.id, (item) => item.id) + if (result.found) setData("part", part.messageID, result.index, reconcile(part)) + if (!result.found) + setData("part", part.messageID, (value = []) => { + const next = value.slice() + next.splice(result.index, 0, part) + return next + }) + return + } + case "message.part.removed": { + const props = event.properties as { sessionID: string; messageID: string; partID: string } + // Part removal is event-only on the server, so its tombstone lasts until a later update or eviction. + const pending = pendingParts.get(props.sessionID) ?? new Map>() + const parts = pending.get(props.messageID) ?? new Set() + parts.add(props.partID) + pending.set(props.messageID, parts) + pendingParts.set(props.sessionID, pending) + const deltas = messageLoads.get(props.sessionID)?.deltaParts.get(props.messageID) + deltas?.delete(props.partID) + if (deltas?.size === 0) messageLoads.get(props.sessionID)?.deltaParts.delete(props.messageID) + const load = messageLoads.get(props.sessionID) + const carried = load?.carriedDeltaParts.get(props.messageID) + carried?.delete(props.partID) + if (carried?.size === 0) load?.carriedDeltaParts.delete(props.messageID) + if (load) { + const parts = load.removedParts.get(props.messageID) ?? new Set() + parts.add(props.partID) + load.removedParts.set(props.messageID, parts) + const optimistic = load.optimisticParts.get(props.messageID) + optimistic?.delete(props.partID) + if (optimistic?.size === 0) load.optimisticParts.delete(props.messageID) + } + trackPartChange(props.sessionID, props.messageID, props.partID) + clearOptimisticPart(props.sessionID, props.messageID, props.partID) + setData( + produce((draft) => { + delete draft.part_text_accum_delta[props.partID] + deltaBases.delete(props.partID) + const parts = draft.part[props.messageID] + if (!parts) return + const result = Binary.search(parts, props.partID, (part) => part.id) + if (result.found) parts.splice(result.index, 1) + if (parts.length === 0) delete draft.part[props.messageID] + }), + ) + return + } + case "message.part.delta": { + const props = event.properties as { + sessionID: string + messageID: string + partID: string + field: string + delta: string + } + const parts = data.part[props.messageID] + if (!parts) return + const result = Binary.search(parts, props.partID, (part) => part.id) + if (!result.found) return + trackPartChange(props.sessionID, props.messageID, props.partID) + const load = messageLoads.get(props.sessionID) + if (load) { + const parts = load.deltaParts.get(props.messageID) ?? new Set() + parts.add(props.partID) + load.deltaParts.set(props.messageID, parts) + const carried = load.carriedDeltaParts.get(props.messageID) + carried?.delete(props.partID) + if (carried?.size === 0) load.carriedDeltaParts.delete(props.messageID) + } + const field = props.field as keyof (typeof parts)[number] + const current = parts[result.index]?.[field] + if (!deltaBases.has(props.partID) && typeof current === "string") + deltaBases.set(props.partID, { base: current, sessionID: props.sessionID }) + setData( + "part_text_accum_delta", + props.partID, + (value) => (value ?? (typeof current === "string" ? current : "")) + props.delta, + ) + setData( + "part", + props.messageID, + produce((draft) => { + if (!draft) return + const part = draft[result.index] + const field = props.field as keyof typeof part + ;(part[field] as string) = ((part[field] as string | undefined) ?? "") + props.delta + }), + ) + return + } + case "permission.asked": { + const permission = event.properties as PermissionRequest + const permissions = data.permission[permission.sessionID] + if (!permissions) { + setData("permission", permission.sessionID, [permission]) + return + } + const result = Binary.search(permissions, permission.id, (item) => item.id) + if (result.found) setData("permission", permission.sessionID, result.index, reconcile(permission)) + if (!result.found) + setData( + "permission", + permission.sessionID, + produce((draft) => void draft.splice(result.index, 0, permission)), + ) + return + } + case "permission.replied": { + const props = event.properties as { sessionID: string; requestID: string } + setData( + "permission", + props.sessionID, + produce((draft) => { + if (!draft) return + const result = Binary.search(draft, props.requestID, (item) => item.id) + if (result.found) draft.splice(result.index, 1) + }), + ) + return + } + case "question.asked": { + const question = event.properties as QuestionRequest + const questions = data.question[question.sessionID] + if (!questions) { + setData("question", question.sessionID, [question]) + return + } + const result = Binary.search(questions, question.id, (item) => item.id) + if (result.found) setData("question", question.sessionID, result.index, reconcile(question)) + if (!result.found) + setData( + "question", + question.sessionID, + produce((draft) => void draft.splice(result.index, 0, question)), + ) + return + } + case "question.replied": + case "question.rejected": { + const props = event.properties as { sessionID: string; requestID: string } + setData( + "question", + props.sessionID, + produce((draft) => { + if (!draft) return + const result = Binary.search(draft, props.requestID, (item) => item.id) + if (result.found) draft.splice(result.index, 1) + }), + ) + } + } + } + + return { + data, + set: setData, + get: (sessionID: string) => data.info[sessionID], + peek: (sessionID: string) => data.info[sessionID], + remember, + resolve, + lineage: { + peek: peekLineage, + async resolve(sessionID: string) { + const session = await resolve(sessionID) + return { session, root: await rootSession(session, resolve) } + }, + }, + sync, + prefetch, + shouldPrefetch(sessionID: string, limit: number) { + if (data.message[sessionID] === undefined) return true + if (Date.now() - (meta.at[sessionID] ?? 0) > 15_000) return true + if (meta.complete[sessionID]) return false + return (meta.limit[sessionID] ?? 0) <= limit + }, + fresh(sessionID: string, ttl: number) { + return Date.now() - (meta.at[sessionID] ?? 0) <= ttl + }, + optimistic: { + add(input: { sessionID: string; message: Message; parts: Part[] }) { + const parts = input.parts + .filter((part) => !!part?.id && !SKIP_PARTS.has(part.type)) + .sort((a, b) => cmp(a.id, b.id)) + const load = messageLoads.get(input.sessionID) + if (load?.clearedMessageParts.has(input.message.id)) { + const touched = load.touchedParts.get(input.message.id) ?? new Set() + parts.forEach((part) => touched.add(part.id)) + load.touchedParts.set(input.message.id, touched) + } + if (load) { + load.removedMessages.delete(input.message.id) + load.optimisticParts.set(input.message.id, new Set(parts.map((part) => part.id))) + } + const items = optimistic.get(input.sessionID) + const removedMessagesForSession = removedMessages.get(input.sessionID) + removedMessagesForSession?.delete(input.message.id) + if (removedMessagesForSession?.size === 0) removedMessages.delete(input.sessionID) + if (items) items.set(input.message.id, { ...input, parts, confirmedParts: [] }) + if (!items) + optimistic.set(input.sessionID, new Map([[input.message.id, { ...input, parts, confirmedParts: [] }]])) + setData("message", input.sessionID, (messages = []) => merge(messages, [input.message]).sort(compareMessages)) + setData( + "part_text_accum_delta", + produce((draft) => { + for (const part of [...(data.part[input.message.id] ?? []), ...parts]) { + delete draft[part.id] + deltaBases.delete(part.id) + } + }), + ) + setData("part", input.message.id, parts) + }, + remove(input: { sessionID: string; messageID: string }) { + const item = optimistic.get(input.sessionID)?.get(input.messageID) + if (!item) return + messageLoads.get(input.sessionID)?.optimisticParts.delete(input.messageID) + clearOptimistic(input.sessionID, input.messageID) + if (item.confirmedMessage) { + const partIDs = new Set(item.parts.map((part) => part.id)) + setData( + produce((draft) => { + for (const part of item.parts) { + delete draft.part_text_accum_delta[part.id] + deltaBases.delete(part.id) + } + const parts = draft.part[input.messageID] + if (!parts) return + draft.part[input.messageID] = parts.filter((part) => !partIDs.has(part.id)) + if (draft.part[input.messageID]?.length === 0) delete draft.part[input.messageID] + }), + ) + return + } + setData("message", input.sessionID, (messages) => messages?.filter((message) => message.id !== input.messageID)) + setData(produce((draft) => deleteMessageParts(draft, input.messageID))) + }, + }, + async todo(sessionID: string, request?: { force?: boolean }) { + touch(sessionID) + if (data.todo[sessionID] !== undefined && !request?.force) return + if ((await options?.protocol) === "v2") { + setData("todo", sessionID, []) + return + } + return runInflight(inflightTodo, sessionID, () => { + const active = generation(sessionID) + return (options?.retry ?? retry)(() => client.session.todo({ sessionID })).then((result) => { + if (generations.get(sessionID) !== active) return + setData("todo", sessionID, reconcile(result.data ?? [], { key: "id" })) + }) + }) + }, + history: { + more: (sessionID: string) => + data.message[sessionID] !== undefined && + meta.limit[sessionID] !== undefined && + !meta.complete[sessionID] && + !!meta.cursor[sessionID], + loading: (sessionID: string) => meta.loading[sessionID] ?? false, + async loadMore(sessionID: string, count = historyMessagePageSize) { + touch(sessionID) + if (meta.loading[sessionID] || meta.complete[sessionID] || !meta.cursor[sessionID]) return + await loadMessages(sessionID, count, meta.cursor[sessionID], "prepend") + }, + }, + evict(sessionID: string) { + if (protectedSessions().has(sessionID)) return + seen.delete(sessionID) + evict([sessionID]) + }, + pin(sessionID: string) { + pinned.set(sessionID, (pinned.get(sessionID) ?? 0) + 1) + touch(sessionID) + }, + unpin(sessionID: string) { + const count = pinned.get(sessionID) + if (!count || count === 1) pinned.delete(sessionID) + if (count && count > 1) pinned.set(sessionID, count - 1) + }, + apply, + applyV2, + } +} + +export type ServerSession = ReturnType diff --git a/packages/app/src/context/server.test.ts b/packages/app/src/context/server.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..d78880ecf104a296f122a1f759ebd39e60a5ab9d --- /dev/null +++ b/packages/app/src/context/server.test.ts @@ -0,0 +1,245 @@ +import { describe, expect, test } from "bun:test" +import { createRoot, createSignal } from "solid-js" +import { createStore } from "solid-js/store" +import { + createServerProjects, + migrateCanonicalLocalServerState, + nextServerAfterRemoval, + resolveServerList, + ServerConnection, +} from "./server" +import { ServerScope } from "@/utils/server-scope" + +describe("resolveServerList", () => { + test("lets startup auth_token credentials override a persisted same-url server", () => { + const list = resolveServerList({ + stored: [{ url: "https://server.example.test" }], + props: [ + { + type: "http", + authToken: true, + http: { + url: "https://server.example.test", + username: "opencode", + password: "secret", + }, + }, + ], + }) + + expect(list).toHaveLength(1) + expect(list[0]?.type).toBe("http") + expect(list[0]?.http).toEqual({ + url: "https://server.example.test", + username: "opencode", + password: "secret", + }) + expect(list[0]?.type === "http" ? list[0].authToken : false).toBe(true) + expect(ServerConnection.key(list[0]!) as string).toBe("https://server.example.test") + }) + + test("keeps persisted credentials when startup has no auth_token", () => { + const list = resolveServerList({ + stored: [ + { + url: "https://server.example.test", + username: "opencode", + password: "saved", + }, + ], + props: [{ type: "http", http: { url: "https://server.example.test" } }], + }) + + expect(list).toHaveLength(1) + expect(list[0]?.type).toBe("http") + expect(list[0]?.http).toEqual({ + url: "https://server.example.test", + username: "opencode", + password: "saved", + }) + expect(list[0]?.type === "http" ? list[0].authToken : true).toBeUndefined() + }) +}) + +test("treats WSL sidecars as remote server connections", () => { + expect( + ServerConnection.local({ + type: "sidecar", + variant: "wsl", + distro: "Debian", + http: { url: "http://127.0.0.1:4097" }, + }), + ).toBe(false) + expect(ServerConnection.local({ type: "sidecar", variant: "base", http: { url: "http://127.0.0.1:4096" } })).toBe( + true, + ) + expect(ServerConnection.local({ type: "http", http: { url: "http://localhost:4096" } })).toBe(true) + expect(ServerConnection.local({ type: "http", http: { url: "https://server.example.test" } })).toBe(false) +}) + +test("active server removal falls back across built-in and persisted servers", () => { + const local = { type: "sidecar", variant: "base", http: { url: "http://127.0.0.1:4096" } } as const + const debian = { + type: "sidecar", + variant: "wsl", + distro: "Debian", + http: { url: "http://127.0.0.1:4097" }, + } as const + + expect( + nextServerAfterRemoval( + [local, debian], + ServerConnection.Key.make("wsl:Debian"), + ServerConnection.Key.make("sidecar"), + ), + ).toBe(ServerConnection.Key.make("sidecar")) +}) + +describe("createServerProjects", () => { + test("keeps active and explicit server buckets in one reactive store", () => { + createRoot((dispose) => { + const [scope] = createSignal(ServerScope.local) + const [store, setStore] = createStore({ projects: {}, lastProject: {}, recentlyClosed: {} }) + const active = createServerProjects({ scope, store, setStore }) + const remote = createServerProjects({ scope: () => "https://debian.example" as ServerScope, store, setStore }) + + remote.open("/repo") + expect(remote.list()).toEqual([{ worktree: "/repo", expanded: true }]) + expect(active.list()).toEqual([]) + + const adopted = createServerProjects({ scope: () => "https://debian.example" as ServerScope, store, setStore }) + expect(adopted.list()).toEqual([{ worktree: "/repo", expanded: true }]) + + adopted.close("/repo") + expect(remote.list()).toEqual([]) + dispose() + }) + }) + + test("tracks recently closed projects and drops them when reopened", () => { + createRoot((dispose) => { + const [scope] = createSignal(ServerScope.local) + const [store, setStore] = createStore({ projects: {}, lastProject: {}, recentlyClosed: {} }) + const projects = createServerProjects({ scope, store, setStore }) + + projects.open("/a") + projects.open("/b") + projects.close("/a") + expect(projects.recentlyClosed()).toEqual(["/a"]) + + projects.close("/b") + expect(projects.recentlyClosed()).toEqual(["/b", "/a"]) + + projects.open("/a") + expect(projects.recentlyClosed()).toEqual(["/b"]) + expect(projects.list()).toEqual([{ worktree: "/a", expanded: true }]) + dispose() + }) + }) + + test("remove drops a project without recording it as recently closed", () => { + createRoot((dispose) => { + const [scope] = createSignal(ServerScope.local) + const [store, setStore] = createStore({ projects: {}, lastProject: {}, recentlyClosed: {} }) + const projects = createServerProjects({ scope, store, setStore }) + + projects.open("/repo/subdir") + projects.remove("/repo/subdir") + expect(projects.list()).toEqual([]) + expect(projects.recentlyClosed()).toEqual([]) + dispose() + }) + }) + + test("retains recently closed history beyond the visible display limit", () => { + createRoot((dispose) => { + const [scope] = createSignal(ServerScope.local) + const [store, setStore] = createStore({ projects: {}, lastProject: {}, recentlyClosed: {} }) + const projects = createServerProjects({ scope, store, setStore }) + + // Closing 6 projects keeps all 6 in the store even though only 5 are displayed; + // this prevents display-filtered entries from evicting still-visible ones. + for (const dir of ["/1", "/2", "/3", "/4", "/5", "/6"]) { + projects.open(dir) + projects.close(dir) + } + expect(projects.recentlyClosed()).toEqual(["/6", "/5", "/4", "/3", "/2", "/1"]) + dispose() + }) + }) + + test("caps recently closed history at the store limit", () => { + createRoot((dispose) => { + const [scope] = createSignal(ServerScope.local) + const [store, setStore] = createStore({ projects: {}, lastProject: {}, recentlyClosed: {} }) + const projects = createServerProjects({ scope, store, setStore }) + + for (let i = 1; i <= 20; i++) { + projects.open(`/p${i}`) + projects.close(`/p${i}`) + } + expect(projects.recentlyClosed()).toHaveLength(16) + expect(projects.recentlyClosed()[0]).toBe("/p20") + expect(projects.recentlyClosed().at(-1)).toBe("/p5") + dispose() + }) + }) + + test("dedupes recently closed entries by normalized path", () => { + createRoot((dispose) => { + const [scope] = createSignal(ServerScope.local) + const [store, setStore] = createStore({ projects: {}, lastProject: {}, recentlyClosed: {} }) + const projects = createServerProjects({ scope, store, setStore }) + + projects.close("/repo") + projects.close("/repo/") + expect(projects.recentlyClosed()).toEqual(["/repo/"]) + dispose() + }) + }) +}) + +describe("migrateCanonicalLocalServerState", () => { + test("moves an existing canonical web bucket into local scope", () => { + expect( + migrateCanonicalLocalServerState( + { + list: [], + projects: { "https://opencode.example.com": [{ worktree: "/remote", expanded: true }] }, + lastProject: { "https://opencode.example.com": "/remote" }, + }, + ServerConnection.Key.make("https://opencode.example.com"), + ), + ).toEqual({ + list: [], + projects: { local: [{ worktree: "/remote", expanded: true }] }, + lastProject: { local: "/remote" }, + }) + }) + + test("preserves existing local state while merging a canonical web bucket", () => { + expect( + migrateCanonicalLocalServerState( + { + projects: { + local: [{ worktree: "/local", expanded: false }], + "https://opencode.example.com": [ + { worktree: "/local", expanded: true }, + { worktree: "/remote", expanded: true }, + ], + }, + lastProject: { local: "/local", "https://opencode.example.com": "/remote" }, + }, + ServerConnection.Key.make("https://opencode.example.com"), + ), + ).toEqual({ + projects: { + local: [ + { worktree: "/local", expanded: false }, + { worktree: "/remote", expanded: true }, + ], + }, + lastProject: { local: "/local" }, + }) + }) +}) diff --git a/packages/app/src/context/settings.test.ts b/packages/app/src/context/settings.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..51b35eacbba71214ca0048c95474c9fafd857814 --- /dev/null +++ b/packages/app/src/context/settings.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, test } from "bun:test" +import { + hasExistingWebState, + initialAgentVisibility, + isAppUpgrade, + layoutTransitionState, + maximumSunsetTimeout, + newLayoutDesignsDefault, + nextSunsetCheckDelay, + resolveNewLayoutDesigns, + shouldDisplayTabsToast, + shouldEnableNewLayout, +} from "./settings" + +describe("agent visibility", () => { + test("shows the picker for existing profiles and hides it for first-time installs", () => { + expect(initialAgentVisibility(undefined, true)).toBe(true) + expect(initialAgentVisibility(undefined, false)).toBe(false) + }) + + test("shows the picker when updating from a recent release", () => { + expect(initialAgentVisibility(undefined, false, "1.18.8")).toBe(true) + }) + + test("preserves the preference after initialization", () => { + expect(initialAgentVisibility(true, true, "1.18.8")).toBeUndefined() + expect(initialAgentVisibility(true, false)).toBeUndefined() + }) +}) + +describe("layout transition", () => { + test("blank profiles default to the new layout", () => { + expect(newLayoutDesignsDefault).toBe(true) + }) + + test("hides the transition until a sunset is scheduled", () => { + expect(layoutTransitionState(false, true, false, false)).toEqual({ available: false, notice: false }) + }) + + test("existing profiles can switch before sunset", () => { + expect(layoutTransitionState(true, true, false, false)).toEqual({ available: true, notice: false }) + }) + + test("classifies web profiles from existing settings or a recorded version", () => { + expect(hasExistingWebState("{}", undefined)).toBe(true) + expect(hasExistingWebState(null, "1.17.19")).toBe(true) + expect(hasExistingWebState(null, undefined)).toBe(false) + }) + + test("preserves explicit and default layout preferences", () => { + expect(resolveNewLayoutDesigns(false, false, true)).toBe(false) + expect(resolveNewLayoutDesigns(false, undefined, false)).toBe(false) + expect(resolveNewLayoutDesigns(false, undefined, true)).toBe(true) + }) + + test("sunset replaces the toggle with a dismissible notice", () => { + expect(layoutTransitionState(true, true, true, false)).toEqual({ available: false, notice: true }) + expect(layoutTransitionState(true, true, true, true)).toEqual({ available: false, notice: false }) + expect(resolveNewLayoutDesigns(true, false)).toBe(true) + }) + + test("caps checks for sunsets beyond the browser timeout limit", () => { + expect(nextSunsetCheckDelay(maximumSunsetTimeout + 1_000, 0)).toBe(maximumSunsetTimeout) + expect(nextSunsetCheckDelay(10_000, 9_000)).toBe(1_000) + expect(nextSunsetCheckDelay(9_000, 10_000)).toBe(0) + }) + + test("enables the new layout when upgrading from 1.17.19 or earlier", () => { + expect(shouldEnableNewLayout("v1.17.19", "1.17.20")).toBe(true) + expect(shouldEnableNewLayout("1.16.9", "2.0.0")).toBe(true) + }) + + test("enables the new layout when no previous version was recorded", () => { + expect(shouldEnableNewLayout(undefined, "1.17.20")).toBe(true) + }) + + test("detects upgrades only when a previous version is older", () => { + expect(isAppUpgrade("1.17.19", "1.17.20")).toBe(true) + expect(isAppUpgrade(undefined, "1.17.20")).toBe(false) + expect(isAppUpgrade("1.17.20", "1.17.20")).toBe(false) + expect(isAppUpgrade("1.17.21", "1.17.20")).toBe(false) + }) + + test("shows the tabs toast for upgrades and existing installs without a recorded version", () => { + expect(shouldDisplayTabsToast("1.17.19", "1.17.20", false)).toBe(true) + expect(shouldDisplayTabsToast(undefined, "1.17.20", true)).toBe(true) + expect(shouldDisplayTabsToast(undefined, "1.17.20", false)).toBe(false) + }) + + test("does not enable the new layout without a qualifying upgrade", () => { + expect(shouldEnableNewLayout("1.17.19", "1.17.19")).toBe(false) + expect(shouldEnableNewLayout("1.17.20", "1.17.21")).toBe(false) + expect(shouldEnableNewLayout(undefined, "1.17.19")).toBe(false) + expect(shouldEnableNewLayout("dev", "1.17.20")).toBe(false) + }) +}) diff --git a/packages/app/src/context/sync-optimistic.test.ts b/packages/app/src/context/sync-optimistic.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..d7ac9fd9641f34d59843d0df72534dd773d94059 --- /dev/null +++ b/packages/app/src/context/sync-optimistic.test.ts @@ -0,0 +1,137 @@ +import { describe, expect, test } from "bun:test" +import type { Message, Part } from "@opencode-ai/sdk/v2/client" +import { applyOptimisticAdd, applyOptimisticRemove, mergeOptimisticPage } from "./sync" + +type Text = Extract + +const userMessage = (id: string, sessionID: string, created = 1): Message => ({ + id, + sessionID, + role: "user", + time: { created }, + agent: "assistant", + model: { providerID: "openai", modelID: "gpt" }, +}) + +const textPart = (id: string, sessionID: string, messageID: string): Text => ({ + id, + sessionID, + messageID, + type: "text", + text: id, +}) + +describe("sync optimistic reducers", () => { + test("applyOptimisticAdd inserts by creation time", () => { + const sessionID = "ses_1" + const draft = { + message: { [sessionID]: [userMessage("msg_z", sessionID, 1)] }, + part: {} as Record, + } + + applyOptimisticAdd(draft, { + sessionID, + message: userMessage("msg_a", sessionID, 2), + parts: [textPart("prt_2", sessionID, "msg_a"), textPart("prt_1", sessionID, "msg_a")], + }) + + expect(draft.message[sessionID]?.map((x) => x.id)).toEqual(["msg_z", "msg_a"]) + expect(draft.part.msg_a?.map((x) => x.id)).toEqual(["prt_1", "prt_2"]) + }) + + test("applyOptimisticRemove removes message and part entries", () => { + const sessionID = "ses_1" + const draft = { + message: { [sessionID]: [userMessage("msg_1", sessionID), userMessage("msg_2", sessionID)] }, + part: { + msg_1: [textPart("prt_1", sessionID, "msg_1")], + msg_2: [textPart("prt_2", sessionID, "msg_2")], + } as Record, + } + + applyOptimisticRemove(draft, { sessionID, messageID: "msg_1" }) + + expect(draft.message[sessionID]?.map((x) => x.id)).toEqual(["msg_2"]) + expect(draft.part.msg_1).toBeUndefined() + expect(draft.part.msg_2).toHaveLength(1) + }) + + test("mergeOptimisticPage keeps pending messages in fetched timelines", () => { + const sessionID = "ses_1" + const page = mergeOptimisticPage( + { + session: [userMessage("msg_z", sessionID, 1)], + part: [{ id: "msg_z", part: [textPart("prt_1", sessionID, "msg_z")] }], + complete: true, + }, + [{ message: userMessage("msg_a", sessionID, 2), parts: [textPart("prt_2", sessionID, "msg_a")] }], + ) + + expect(page.session.map((x) => x.id)).toEqual(["msg_z", "msg_a"]) + expect(page.part.find((x) => x.id === "msg_a")?.part.map((x) => x.id)).toEqual(["prt_2"]) + expect(page.confirmed).toEqual([]) + expect(page.complete).toBe(true) + }) + + test("mergeOptimisticPage uses IDs only to break equal-time ties", () => { + const sessionID = "ses_1" + const page = mergeOptimisticPage( + { + session: [userMessage("msg_z", sessionID, 1)], + part: [], + complete: true, + }, + [{ message: userMessage("msg_a", sessionID, 1), parts: [] }], + ) + + expect(page.session.map((message) => message.id)).toEqual(["msg_a", "msg_z"]) + }) + + test("mergeOptimisticPage keeps missing optimistic parts until the server has them", () => { + const sessionID = "ses_1" + const page = mergeOptimisticPage( + { + session: [userMessage("msg_2", sessionID)], + part: [{ id: "msg_2", part: [textPart("prt_2", sessionID, "msg_2")] }], + complete: true, + }, + [ + { + message: userMessage("msg_2", sessionID), + parts: [textPart("prt_1", sessionID, "msg_2"), textPart("prt_2", sessionID, "msg_2")], + }, + ], + ) + + expect(page.part.find((x) => x.id === "msg_2")?.part.map((x) => x.id)).toEqual(["prt_1", "prt_2"]) + expect(page.confirmed).toEqual([]) + }) + + test("mergeOptimisticPage confirms echoed messages once all parts arrive", () => { + const sessionID = "ses_1" + const page = mergeOptimisticPage( + { + session: [userMessage("msg_2", sessionID)], + part: [ + { + id: "msg_2", + part: [{ ...textPart("prt_1", sessionID, "msg_2"), text: "server" }, textPart("prt_2", sessionID, "msg_2")], + }, + ], + complete: true, + }, + [ + { + message: userMessage("msg_2", sessionID), + parts: [textPart("prt_1", sessionID, "msg_2"), textPart("prt_2", sessionID, "msg_2")], + }, + ], + ) + + expect(page.confirmed).toEqual(["msg_2"]) + expect(page.part.find((x) => x.id === "msg_2")?.part).toMatchObject([ + { id: "prt_1", type: "text", text: "server" }, + { id: "prt_2", type: "text", text: "prt_2" }, + ]) + }) +}) diff --git a/packages/app/src/context/sync.tsx b/packages/app/src/context/sync.tsx new file mode 100644 index 0000000000000000000000000000000000000000..7dbf66eb7363b56c9b2c06eda3f8bbd1df02653e --- /dev/null +++ b/packages/app/src/context/sync.tsx @@ -0,0 +1,120 @@ +import { Binary } from "@opencode-ai/core/util/binary" +import { createMemo } from "solid-js" +import { useServerSync } from "./server-sync" +import { useSDK } from "./sdk" +import type { Message, Part } from "@opencode-ai/sdk/v2/client" +import { messageKey } from "@/utils/session-message" + +const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"]) + +function sortParts(parts: Part[]) { + return parts.filter((part) => !!part?.id).sort((a, b) => cmp(a.id, b.id)) +} + +const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0) + +type OptimisticStore = { + message: Record + part: Record +} + +type OptimisticAddInput = { + sessionID: string + message: Message + parts: Part[] +} + +type OptimisticRemoveInput = { + sessionID: string + messageID: string +} + +type OptimisticItem = { + message: Message + parts: Part[] +} + +type MessagePage = { + session: Message[] + part: { id: string; part: Part[] }[] + cursor?: string + complete: boolean +} + +const hasParts = (parts: Part[] | undefined, want: Part[]) => { + if (!parts) return want.length === 0 + return want.every((part) => Binary.search(parts, part.id, (item) => item.id).found) +} + +const mergeParts = (parts: Part[] | undefined, want: Part[]) => { + if (!parts) return sortParts(want) + const next = [...parts] + let changed = false + for (const part of want) { + const result = Binary.search(next, part.id, (item) => item.id) + if (result.found) continue + next.splice(result.index, 0, part) + changed = true + } + if (!changed) return parts + return next +} + +export function mergeOptimisticPage(page: MessagePage, items: OptimisticItem[]) { + if (items.length === 0) return { ...page, confirmed: [] as string[] } + + const session = [...page.session] + const part = new Map(page.part.map((item) => [item.id, sortParts(item.part)])) + const confirmed: string[] = [] + + for (const item of items) { + const result = Binary.search(session, messageKey(item.message), messageKey) + const found = result.found + if (!found) session.splice(result.index, 0, item.message) + + const current = part.get(item.message.id) + if (found && hasParts(current, item.parts)) { + confirmed.push(item.message.id) + continue + } + + part.set(item.message.id, mergeParts(current, item.parts)) + } + + return { + cursor: page.cursor, + complete: page.complete, + session, + part: [...part.entries()].sort((a, b) => cmp(a[0], b[0])).map(([id, part]) => ({ id, part })), + confirmed, + } +} + +export function applyOptimisticAdd(draft: OptimisticStore, input: OptimisticAddInput) { + const messages = draft.message[input.sessionID] + if (messages) { + const result = Binary.search(messages, messageKey(input.message), messageKey) + messages.splice(result.index, 0, input.message) + } else { + draft.message[input.sessionID] = [input.message] + } + draft.part[input.message.id] = sortParts(input.parts) +} + +export function applyOptimisticRemove(draft: OptimisticStore, input: OptimisticRemoveInput) { + const messages = draft.message[input.sessionID] + if (messages) { + const index = messages.findIndex((message) => message.id === input.messageID) + if (index >= 0) messages.splice(index, 1) + } + delete draft.part[input.messageID] +} + +export const useSync = () => { + const serverSync = useServerSync() + const sdk = useSDK() + + return createMemo(() => serverSync().ensureDirSyncContext(sdk().directory)) +} + +export type DirectorySync = ReturnType> diff --git a/packages/app/src/context/tab-memory.ts b/packages/app/src/context/tab-memory.ts new file mode 100644 index 0000000000000000000000000000000000000000..70bb987c7bb47dc42564893adfd18a44d37f3ccb --- /dev/null +++ b/packages/app/src/context/tab-memory.ts @@ -0,0 +1,36 @@ +import { createRoot, type Owner } from "solid-js" + +type Entry = { + value: unknown + dispose: VoidFunction +} + +export function createTabMemory(owner: Owner | null) { + const entries = new Map>() + + const remove = (key: string) => { + const state = entries.get(key) + if (!state) return + for (const entry of state.values()) entry.dispose() + entries.delete(key) + } + + return { + get(key: string, name: string) { + return entries.get(key)?.get(name)?.value as T | undefined + }, + ensure(key: string, name: string, init: () => T) { + const state = entries.get(key) ?? new Map() + if (!entries.has(key)) entries.set(key, state) + const existing = state.get(name) + if (existing) return existing.value as T + const entry = createRoot((dispose) => ({ value: init(), dispose }), owner) + state.set(name, entry) + return entry.value + }, + remove, + dispose() { + for (const key of entries.keys()) remove(key) + }, + } +} diff --git a/packages/app/src/context/tabs.test.ts b/packages/app/src/context/tabs.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..abd62a4dae1360699bd4cab9098c380936bd7797 --- /dev/null +++ b/packages/app/src/context/tabs.test.ts @@ -0,0 +1,125 @@ +import { describe, expect, test } from "bun:test" +import { createRoot, getOwner, onCleanup } from "solid-js" +import { createTabMemory } from "./tab-memory" +import { nextTabAfterClose, pushClosedTab, removeClosedTabs, takeClosedTab, type ClosedTab } from "./closed-tabs" +import type { SessionTab, Tab } from "./tabs" +import { migrateTabs } from "./tab-migration" +import type { ServerConnection } from "./server" + +const server = "local\nhttp://localhost:4096" as ServerConnection.Key + +function sessionTab(sessionId: string): SessionTab { + return { type: "session", server, sessionId } +} + +describe("tab migration", () => { + test("drops null and malformed persisted tabs", () => { + expect( + migrateTabs([null, sessionTab("a"), { type: "session", server }, { type: "unknown", server }, "invalid"], server), + ).toEqual([sessionTab("a")]) + }) + + test("adds the fallback server to valid legacy tabs", () => { + expect(migrateTabs([{ type: "session", sessionId: "a", dirBase64: "legacy" }], server)).toEqual([sessionTab("a")]) + }) + + test("replaces invalid top-level persisted data", () => { + expect(migrateTabs(null, server)).toEqual([]) + expect(migrateTabs({}, server)).toEqual([]) + }) +}) + +describe("tab memory", () => { + test("keeps state until its tab is removed", () => { + createRoot((dispose) => { + const memory = createTabMemory(getOwner()) + let disposed = 0 + const first = memory.ensure("tab", "prompt", () => { + onCleanup(() => disposed++) + return { value: "prompt" } + }) + + expect(memory.ensure("tab", "prompt", () => ({ value: "other" }))).toBe(first) + expect(memory.get("tab", "prompt")).toBe(first) + expect(memory.get("missing", "prompt")).toBeUndefined() + expect(memory.ensure("other", "prompt", () => ({ value: "other" }))).not.toBe(first) + + memory.remove("tab") + expect(disposed).toBe(1) + expect(memory.ensure("tab", "prompt", () => ({ value: "new" }))).not.toBe(first) + dispose() + }) + }) +}) + +describe("closed tab stack", () => { + test("records session tabs with their index", () => { + const stack = pushClosedTab([], sessionTab("a"), 2) + + expect(stack).toEqual([{ tab: sessionTab("a"), index: 2 }]) + }) + + test("ignores draft tabs", () => { + const draft: Tab = { type: "draft", draftID: "d1", server, directory: "/tmp" } + + expect(pushClosedTab([], draft, 0)).toEqual([]) + }) + + test("caps the stack size", () => { + const stack = Array.from({ length: 30 }, (_, i) => i).reduce( + (acc, i) => pushClosedTab(acc, sessionTab(`s${i}`), i), + [], + ) + + expect(stack).toHaveLength(25) + expect(stack[0]?.tab.sessionId).toBe("s5") + expect(stack.at(-1)?.tab.sessionId).toBe("s29") + }) + + test("pops the most recently closed tab", () => { + const stack = [ + { tab: sessionTab("a"), index: 0 }, + { tab: sessionTab("b"), index: 1 }, + ] + const result = takeClosedTab(stack, []) + + expect(result.entry?.tab.sessionId).toBe("b") + expect(result.stack).toEqual([{ tab: sessionTab("a"), index: 0 }]) + }) + + test("skips entries whose tab is already open", () => { + const stack = [ + { tab: sessionTab("a"), index: 0 }, + { tab: sessionTab("b"), index: 1 }, + ] + const result = takeClosedTab(stack, [sessionTab("b")]) + + expect(result.entry?.tab.sessionId).toBe("a") + expect(result.stack).toEqual([]) + }) + + test("returns no entry when everything is open or empty", () => { + expect(takeClosedTab([], []).entry).toBeUndefined() + + const result = takeClosedTab([{ tab: sessionTab("a"), index: 0 }], [sessionTab("a")]) + expect(result.entry).toBeUndefined() + expect(result.stack).toEqual([]) + }) + + test("purges removed sessions", () => { + const stack = [ + { tab: sessionTab("a"), index: 0 }, + { tab: sessionTab("b"), index: 1 }, + ] + + expect(removeClosedTabs(stack, server, ["a"])).toEqual([{ tab: sessionTab("b"), index: 1 }]) + }) + + test("does not navigate when a background tab closes", () => { + const tabs = [sessionTab("a"), sessionTab("b"), sessionTab("c")] + + expect(nextTabAfterClose(tabs, 1, false)).toBeUndefined() + expect(nextTabAfterClose(tabs, 1, true)).toEqual(sessionTab("c")) + expect(nextTabAfterClose([sessionTab("a")], 0, true)).toBeNull() + }) +}) diff --git a/packages/app/src/context/terminal.tsx b/packages/app/src/context/terminal.tsx new file mode 100644 index 0000000000000000000000000000000000000000..df575b71f7ce713022f188d672e9a2d87135b31b --- /dev/null +++ b/packages/app/src/context/terminal.tsx @@ -0,0 +1,546 @@ +import { createStore, produce } from "solid-js/store" +import { createSimpleContext } from "@opencode-ai/ui/context" +import { batch, createEffect, createMemo, createRoot, on, onCleanup } from "solid-js" +import { useParams } from "@solidjs/router" +import { useSDK, type DirectorySDK } from "./sdk" +import type { Platform } from "./platform" +import { useServerSDK } from "./server-sdk" +import { base64Encode } from "@opencode-ai/core/util/encode" +import { defaultTitle, titleNumber } from "./terminal-title" +import { Persist, persisted, removePersisted } from "@/utils/persist" +import { ScopedKey, ServerScope, type ServerScope as ServerScopeValue } from "@/utils/server-scope" + +export type LocalPTY = { + id: string + title: string + titleNumber: number + rows?: number + cols?: number + buffer?: string + scrollY?: number + cursor?: number +} + +const WORKSPACE_KEY = "__workspace__" +const MAX_TERMINAL_SESSIONS = 20 + +function record(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value) +} + +function text(value: unknown) { + return typeof value === "string" ? value : undefined +} + +function num(value: unknown) { + return typeof value === "number" && Number.isFinite(value) ? value : undefined +} + +function numberFromTitle(title: string) { + return titleNumber(title, MAX_TERMINAL_SESSIONS) +} + +function pty(value: unknown): LocalPTY | undefined { + if (!record(value)) return + + const id = text(value.id) + if (!id) return + + const title = text(value.title) ?? "" + const number = num(value.titleNumber) + const rows = num(value.rows) + const cols = num(value.cols) + const buffer = text(value.buffer) + const scrollY = num(value.scrollY) + const cursor = num(value.cursor) + + return { + id, + title, + titleNumber: number && number > 0 ? number : (numberFromTitle(title) ?? 0), + ...(rows !== undefined ? { rows } : {}), + ...(cols !== undefined ? { cols } : {}), + ...(buffer !== undefined ? { buffer } : {}), + ...(scrollY !== undefined ? { scrollY } : {}), + ...(cursor !== undefined ? { cursor } : {}), + } +} + +export function migrateTerminalState(value: unknown) { + if (!record(value)) return value + + const seen = new Set() + const all = (Array.isArray(value.all) ? value.all : []).flatMap((item) => { + const next = pty(item) + if (!next || seen.has(next.id)) return [] + seen.add(next.id) + return [next] + }) + + const active = text(value.active) + + return { + active: active && seen.has(active) ? active : all[0]?.id, + all, + } +} + +export function getWorkspaceTerminalCacheKey(dir: string, scope: ServerScopeValue = ServerScope.local) { + return ScopedKey.from(scope, dir, WORKSPACE_KEY) +} + +export function getLegacyTerminalStorageKeys(dir: string, legacySessionID?: string) { + if (!legacySessionID) return [`${dir}/terminal.v1`] + return [`${dir}/terminal/${legacySessionID}.v1`, `${dir}/terminal.v1`] +} + +type TerminalSession = ReturnType + +type TerminalCacheEntry = { + value: TerminalSession + dispose: VoidFunction +} + +const caches = new Set>() + +const trimTerminal = (pty: LocalPTY) => { + if (!pty.buffer && pty.cursor === undefined && pty.scrollY === undefined) return pty + return { + ...pty, + buffer: undefined, + cursor: undefined, + scrollY: undefined, + } +} + +function terminalPersistTarget(scope: ServerScopeValue, dir: string, legacy?: string[]) { + return Persist.serverWorkspace(scope, dir, "terminal", legacy) +} + +export function clearWorkspaceTerminals( + dir: string, + sessionIDs?: string[], + platform?: Platform, + scope: ServerScopeValue = ServerScope.local, +) { + const key = getWorkspaceTerminalCacheKey(dir, scope) + for (const cache of caches) { + const entry = cache.get(key) + entry?.value.clear() + } + + void removePersisted(terminalPersistTarget(scope, dir), platform) + + if (scope !== ServerScope.local) return + const legacy = new Set(getLegacyTerminalStorageKeys(dir)) + for (const id of sessionIDs ?? []) { + for (const key of getLegacyTerminalStorageKeys(dir, id)) { + legacy.add(key) + } + } + for (const key of legacy) { + void removePersisted({ key }, platform) + } +} + +function createWorkspaceTerminalSession( + sdk: DirectorySDK, + dir: string, + scope: ServerScopeValue, + legacySessionID?: string, +) { + const location = { directory: sdk.directory } + const legacy = scope === ServerScope.local ? getLegacyTerminalStorageKeys(dir, legacySessionID) : [] + + const [store, setStore, _, ready] = persisted( + { + ...terminalPersistTarget(scope, dir, legacy), + migrate: migrateTerminalState, + }, + createStore<{ + active?: string + all: LocalPTY[] + }>({ + all: [], + }), + ) + const [ui, setUi] = createStore({ + focus: undefined as { request: number; id?: string; pending: boolean } | undefined, + }) + const focus = { request: 0 } + + const requestFocus = (id?: string, pending = false) => { + focus.request += 1 + setUi("focus", { request: focus.request, id, pending }) + return focus.request + } + + const focusRequested = (id?: string) => { + if (!id) return false + if (!ui.focus || ui.focus.pending) return false + return !ui.focus.id || ui.focus.id === id + } + + const consumeFocus = (id: string) => { + if (!focusRequested(id)) return + setUi("focus", undefined) + } + + const cancelFocus = (request?: number) => { + if (request !== undefined && ui.focus?.request !== request) return + setUi("focus", undefined) + } + + if (typeof document !== "undefined") { + const cancelOnOutsideFocus = (event: FocusEvent) => { + if (!ui.focus) return + if (!(event.target instanceof Element)) return + if (event.target.closest("#terminal-panel")) return + cancelFocus() + } + document.addEventListener("focusin", cancelOnOutsideFocus) + onCleanup(() => document.removeEventListener("focusin", cancelOnOutsideFocus)) + } + + const pickNextTerminalNumber = () => { + const existingTitleNumbers = new Set( + store.all.flatMap((pty) => { + const direct = Number.isFinite(pty.titleNumber) && pty.titleNumber > 0 ? pty.titleNumber : undefined + if (direct !== undefined) return [direct] + const parsed = numberFromTitle(pty.title) + if (parsed === undefined) return [] + return [parsed] + }), + ) + + return ( + Array.from({ length: existingTitleNumbers.size + 1 }, (_, index) => index + 1).find( + (number) => !existingTitleNumbers.has(number), + ) ?? 1 + ) + } + + const removeExited = (id: string) => { + const all = store.all + const index = all.findIndex((x) => x.id === id) + if (index === -1) return + const active = store.active === id ? (index === 0 ? all[1]?.id : all[0]?.id) : store.active + batch(() => { + setStore("active", active) + setStore( + "all", + produce((draft) => { + draft.splice(index, 1) + }), + ) + }) + } + + const unsub = sdk.event.on("pty.exited", (event: { properties: { id: string } }) => { + removeExited(event.properties.id) + }) + onCleanup(unsub) + + const update = (pty: Partial & { id: string }) => { + const index = store.all.findIndex((x) => x.id === pty.id) + const previous = index >= 0 ? store.all[index] : undefined + if (index >= 0) { + setStore("all", index, (item) => ({ ...item, ...pty })) + } + const doUpdate = async () => { + if ((await sdk.protocol) === "v1") { + await sdk.client.pty.update({ + ptyID: pty.id, + title: pty.title, + size: pty.cols && pty.rows ? { rows: pty.rows, cols: pty.cols } : undefined, + }) + } else { + await sdk.api.pty.update({ + ptyID: pty.id, + location, + title: pty.title, + size: pty.cols && pty.rows ? { rows: pty.rows, cols: pty.cols } : undefined, + }) + } + } + doUpdate().catch((error: unknown) => { + if (previous) { + const currentIndex = store.all.findIndex((item) => item.id === pty.id) + if (currentIndex >= 0) setStore("all", currentIndex, previous) + } + console.error("Failed to update terminal", error) + }) + } + + const clone = async (id: string) => { + const index = store.all.findIndex((x) => x.id === id) + const pty = store.all[index] + if (!pty) return + const data = await (async () => { + if ((await sdk.protocol) === "v1") { + return (await sdk.client.pty.create({ title: pty.title })).data + } + return ( + await sdk.api.pty.create({ + location, + title: pty.title, + }) + ).data + })().catch((error: unknown) => { + console.error("Failed to clone terminal", error) + return undefined + }) + if (!data?.id) return + + const active = store.active === pty.id + + batch(() => { + setStore("all", index, { + id: data.id, + title: data.title ?? pty.title, + titleNumber: pty.titleNumber, + buffer: undefined, + cursor: undefined, + scrollY: undefined, + rows: undefined, + cols: undefined, + }) + if (active) { + setStore("active", data.id) + } + }) + } + + return { + ready, + all: createMemo(() => store.all), + active: createMemo(() => store.active), + clear() { + batch(() => { + setStore("active", undefined) + setStore("all", []) + }) + }, + new(options?: { focus?: boolean }) { + const nextNumber = pickNextTerminalNumber() + const focusRequest = options?.focus ? requestFocus(undefined, true) : undefined + + const doCreate = async () => { + if ((await sdk.protocol) === "v1") { + return (await sdk.client.pty.create({ title: defaultTitle(nextNumber) })).data + } + return (await sdk.api.pty.create({ location, title: defaultTitle(nextNumber) })).data + } + doCreate() + .then((data) => { + const id = data?.id + if (!id) { + if (focusRequest !== undefined) cancelFocus(focusRequest) + return + } + const newTerminal = { + id, + title: data?.title ?? defaultTitle(nextNumber), + titleNumber: nextNumber, + } + batch(() => { + setStore("all", store.all.length, newTerminal) + setStore("active", id) + if (focusRequest !== undefined && ui.focus?.request === focusRequest) { + setUi("focus", { request: focusRequest, id, pending: false }) + } + }) + }) + .catch((error: unknown) => { + if (focusRequest !== undefined) cancelFocus(focusRequest) + console.error("Failed to create terminal", error) + }) + }, + update(pty: Partial & { id: string }) { + update(pty) + }, + trim(id: string) { + const index = store.all.findIndex((x) => x.id === id) + if (index === -1) return + setStore("all", index, (pty) => trimTerminal(pty)) + }, + trimAll() { + setStore("all", (all) => { + const next = all.map(trimTerminal) + if (next.every((pty, index) => pty === all[index])) return all + return next + }) + }, + async clone(id: string) { + await clone(id) + }, + bind() { + return { + trim(id: string) { + const index = store.all.findIndex((x) => x.id === id) + if (index === -1) return + setStore("all", index, (pty) => trimTerminal(pty)) + }, + update(pty: Partial & { id: string }) { + update(pty) + }, + async clone(id: string) { + await clone(id) + }, + } + }, + open(id: string) { + setStore("active", id) + }, + requestFocus(id?: string) { + requestFocus(id) + }, + focusRequested(id?: string) { + return focusRequested(id) + }, + consumeFocus(id: string) { + consumeFocus(id) + }, + cancelFocus() { + cancelFocus() + }, + next() { + const index = store.all.findIndex((x) => x.id === store.active) + if (index === -1) return + const nextIndex = (index + 1) % store.all.length + setStore("active", store.all[nextIndex]?.id) + }, + previous() { + const index = store.all.findIndex((x) => x.id === store.active) + if (index === -1) return + const prevIndex = index === 0 ? store.all.length - 1 : index - 1 + setStore("active", store.all[prevIndex]?.id) + }, + async close(id: string) { + const index = store.all.findIndex((f) => f.id === id) + if (index !== -1) { + batch(() => { + if (store.active === id) { + const next = index > 0 ? store.all[index - 1]?.id : store.all[1]?.id + setStore("active", next) + } + setStore( + "all", + produce((all) => { + all.splice(index, 1) + }), + ) + }) + } + + const removePromise = + (await sdk.protocol) === "v1" + ? sdk.client.pty.remove({ ptyID: id }) + : sdk.api.pty.remove({ ptyID: id, location }) + await removePromise.catch((error: unknown) => { + console.error("Failed to close terminal", error) + }) + }, + move(id: string, to: number) { + const index = store.all.findIndex((f) => f.id === id) + if (index === -1) return + setStore( + "all", + produce((all) => { + all.splice(to, 0, all.splice(index, 1)[0]) + }), + ) + }, + } +} + +export const { use: useTerminal, provider: TerminalProvider } = createSimpleContext({ + name: "Terminal", + gate: false, + init: () => { + const sdk = useSDK() + const serverSDK = useServerSDK() + const params = useParams() + const cache = new Map() + const scope = () => serverSDK().scope + const directory = createMemo(() => base64Encode(sdk().directory)) + + caches.add(cache) + onCleanup(() => caches.delete(cache)) + + const disposeAll = () => { + for (const entry of cache.values()) { + entry.dispose() + } + cache.clear() + } + + onCleanup(disposeAll) + + const prune = () => { + while (cache.size > MAX_TERMINAL_SESSIONS) { + const first = cache.keys().next().value + if (!first) return + const entry = cache.get(first) + entry?.dispose() + cache.delete(first) + } + } + + const loadWorkspace = (dir: string, legacySessionID: string | undefined, serverScope: ServerScopeValue) => { + // Terminals are workspace-scoped so tabs persist while switching sessions in the same directory. + const key = getWorkspaceTerminalCacheKey(dir, serverScope) + const existing = cache.get(key) + if (existing) { + cache.delete(key) + cache.set(key, existing) + return existing.value + } + + const entry = createRoot((dispose) => ({ + value: createWorkspaceTerminalSession(sdk(), dir, serverScope, legacySessionID), + dispose, + })) + + cache.set(key, entry) + prune() + return entry.value + } + + const workspace = createMemo(() => loadWorkspace(directory(), params.id, scope())) + + createEffect( + on( + () => ({ dir: directory(), id: params.id, scope: scope() }), + (next, prev) => { + if (!prev?.dir) return + if (next.dir === prev.dir && next.id === prev.id && next.scope === prev.scope) return + if (next.dir === prev.dir && next.id && next.scope === prev.scope) return + loadWorkspace(prev.dir, prev.id, prev.scope).trimAll() + }, + { defer: true }, + ), + ) + + return { + ready: () => workspace().ready(), + all: () => workspace().all(), + active: () => workspace().active(), + new: (options?: { focus?: boolean }) => workspace().new(options), + update: (pty: Partial & { id: string }) => workspace().update(pty), + trim: (id: string) => workspace().trim(id), + trimAll: () => workspace().trimAll(), + clone: (id: string) => workspace().clone(id), + bind: () => workspace(), + open: (id: string) => workspace().open(id), + requestFocus: (id?: string) => workspace().requestFocus(id), + focusRequested: (id?: string) => workspace().focusRequested(id), + consumeFocus: (id: string) => workspace().consumeFocus(id), + cancelFocus: () => workspace().cancelFocus(), + close: (id: string) => workspace().close(id), + move: (id: string, to: number) => workspace().move(id, to), + next: () => workspace().next(), + previous: () => workspace().previous(), + } + }, +}) diff --git a/packages/app/src/utils/agent.ts b/packages/app/src/utils/agent.ts new file mode 100644 index 0000000000000000000000000000000000000000..59da53af102acce105d3abbfc17674b6033e3beb --- /dev/null +++ b/packages/app/src/utils/agent.ts @@ -0,0 +1,44 @@ +const defaults: Record = { + ask: "var(--icon-agent-ask-base)", + build: "var(--icon-agent-build-base)", + docs: "var(--icon-agent-docs-base)", + plan: "var(--icon-agent-plan-base)", +} + +const palette = [ + "var(--icon-agent-ask-base)", + "var(--icon-agent-build-base)", + "var(--icon-agent-docs-base)", + "var(--icon-agent-plan-base)", + "var(--syntax-info)", + "var(--syntax-success)", + "var(--syntax-warning)", + "var(--syntax-property)", + "var(--syntax-constant)", + "var(--text-diff-add-base)", + "var(--text-diff-delete-base)", + "var(--icon-warning-base)", +] + +function tone(name: string) { + let hash = 0 + for (const char of name) hash = (hash * 31 + char.charCodeAt(0)) >>> 0 + return palette[hash % palette.length] +} + +export function agentColor(name: string, custom?: string) { + if (custom) return custom + return defaults[name] ?? defaults[name.toLowerCase()] ?? tone(name.toLowerCase()) +} + +export function messageAgentColor( + list: readonly { role: string; agent?: string }[] | undefined, + agents: readonly { name: string; color?: string }[], +) { + if (!list) return undefined + for (let i = list.length - 1; i >= 0; i--) { + const item = list[i] + if (item.role !== "user" || !item.agent) continue + return agentColor(item.agent, agents.find((agent) => agent.name === item.agent)?.color) + } +} diff --git a/packages/app/src/utils/aim.ts b/packages/app/src/utils/aim.ts new file mode 100644 index 0000000000000000000000000000000000000000..23471959e16822be320febf3f1521a0c4e319b55 --- /dev/null +++ b/packages/app/src/utils/aim.ts @@ -0,0 +1,138 @@ +type Point = { x: number; y: number } + +export function createAim(props: { + enabled: () => boolean + active: () => string | undefined + el: () => HTMLElement | undefined + onActivate: (id: string) => void + delay?: number + max?: number + tolerance?: number + edge?: number +}) { + const state = { + locs: [] as Point[], + timer: undefined as number | undefined, + pending: undefined as string | undefined, + over: undefined as string | undefined, + last: undefined as Point | undefined, + } + + const delay = props.delay ?? 250 + const max = props.max ?? 4 + const tolerance = props.tolerance ?? 80 + const edge = props.edge ?? 18 + + const cancel = () => { + if (state.timer !== undefined) clearTimeout(state.timer) + state.timer = undefined + state.pending = undefined + } + + const reset = () => { + cancel() + state.over = undefined + state.last = undefined + state.locs.length = 0 + } + + const move = (event: MouseEvent) => { + if (!props.enabled()) return + const el = props.el() + if (!el) return + + const rect = el.getBoundingClientRect() + const x = event.clientX + const y = event.clientY + if (x < rect.left || x > rect.right || y < rect.top || y > rect.bottom) return + + state.locs.push({ x, y }) + if (state.locs.length > max) state.locs.shift() + } + + const wait = () => { + if (!props.enabled()) return 0 + if (!props.active()) return 0 + + const el = props.el() + if (!el) return 0 + if (state.locs.length < 2) return 0 + + const rect = el.getBoundingClientRect() + const loc = state.locs[state.locs.length - 1] + if (!loc) return 0 + + const prev = state.locs[0] ?? loc + if (prev.x < rect.left || prev.x > rect.right || prev.y < rect.top || prev.y > rect.bottom) return 0 + if (state.last && loc.x === state.last.x && loc.y === state.last.y) return 0 + + if (rect.right - loc.x <= edge) { + state.last = loc + return delay + } + + const upper = { x: rect.right, y: rect.top - tolerance } + const lower = { x: rect.right, y: rect.bottom + tolerance } + const slope = (a: Point, b: Point) => (b.y - a.y) / (b.x - a.x) + + const decreasing = slope(loc, upper) + const increasing = slope(loc, lower) + const prevDecreasing = slope(prev, upper) + const prevIncreasing = slope(prev, lower) + + if (decreasing < prevDecreasing && increasing > prevIncreasing) { + state.last = loc + return delay + } + + state.last = undefined + return 0 + } + + const activate = (id: string) => { + cancel() + props.onActivate(id) + } + + const request = (id: string) => { + if (!id) return + if (props.active() === id) return + + if (!props.active()) { + activate(id) + return + } + + const ms = wait() + if (ms === 0) { + activate(id) + return + } + + cancel() + state.pending = id + state.timer = window.setTimeout(() => { + state.timer = undefined + if (state.pending !== id) return + state.pending = undefined + if (!props.enabled()) return + if (!props.active()) return + if (state.over !== id) return + props.onActivate(id) + }, ms) + } + + const enter = (id: string, event: MouseEvent) => { + if (!props.enabled()) return + state.over = id + move(event) + request(id) + } + + const leave = (id: string) => { + if (state.over === id) state.over = undefined + if (state.pending === id) cancel() + } + + return { move, enter, leave, activate, request, cancel, reset } +} diff --git a/packages/app/src/utils/base64.ts b/packages/app/src/utils/base64.ts new file mode 100644 index 0000000000000000000000000000000000000000..34b904051caa17e7f8eef1314422a53007930f99 --- /dev/null +++ b/packages/app/src/utils/base64.ts @@ -0,0 +1,10 @@ +import { base64Decode } from "@opencode-ai/core/util/encode" + +export function decode64(value: string | undefined) { + if (value === undefined) return + try { + return base64Decode(value) + } catch { + return + } +} diff --git a/packages/app/src/utils/comment-note.ts b/packages/app/src/utils/comment-note.ts new file mode 100644 index 0000000000000000000000000000000000000000..99e87fc81c75bb58026ef4ed9c14780e1856009c --- /dev/null +++ b/packages/app/src/utils/comment-note.ts @@ -0,0 +1,88 @@ +import type { FileSelection } from "@/context/file" + +export type PromptComment = { + path: string + selection?: FileSelection + comment: string + preview?: string + origin?: "review" | "file" +} + +function selection(selection: unknown) { + if (!selection || typeof selection !== "object") return undefined + const startLine = Number((selection as FileSelection).startLine) + const startChar = Number((selection as FileSelection).startChar) + const endLine = Number((selection as FileSelection).endLine) + const endChar = Number((selection as FileSelection).endChar) + if (![startLine, startChar, endLine, endChar].every(Number.isFinite)) return undefined + return { + startLine, + startChar, + endLine, + endChar, + } satisfies FileSelection +} + +export function createCommentMetadata(input: PromptComment) { + return { + opencodeComment: { + path: input.path, + selection: input.selection, + comment: input.comment, + preview: input.preview, + origin: input.origin, + }, + } +} + +export function readCommentMetadata(value: unknown) { + if (!value || typeof value !== "object") return + const meta = (value as { opencodeComment?: unknown }).opencodeComment + if (!meta || typeof meta !== "object") return + const path = (meta as { path?: unknown }).path + const comment = (meta as { comment?: unknown }).comment + if (typeof path !== "string" || typeof comment !== "string") return + const preview = (meta as { preview?: unknown }).preview + const origin = (meta as { origin?: unknown }).origin + return { + path, + selection: selection((meta as { selection?: unknown }).selection), + comment, + preview: typeof preview === "string" ? preview : undefined, + origin: origin === "review" || origin === "file" ? origin : undefined, + } satisfies PromptComment +} + +export function formatCommentNote(input: { path: string; selection?: FileSelection; comment: string }) { + const start = input.selection ? Math.min(input.selection.startLine, input.selection.endLine) : undefined + const end = input.selection ? Math.max(input.selection.startLine, input.selection.endLine) : undefined + const range = + start === undefined || end === undefined + ? "this file" + : start === end + ? `line ${start}` + : `lines ${start} through ${end}` + return `The user made the following comment regarding ${range} of ${input.path}: ${input.comment}` +} + +export function parseCommentNote(text: string) { + const match = text.match( + /^The user made the following comment regarding (this file|line (\d+)|lines (\d+) through (\d+)) of (.+?): ([\s\S]+)$/, + ) + if (!match) return + const start = match[2] ? Number(match[2]) : match[3] ? Number(match[3]) : undefined + const end = match[2] ? Number(match[2]) : match[4] ? Number(match[4]) : undefined + return { + path: match[5], + selection: + start !== undefined && end !== undefined + ? { + startLine: start, + startChar: 0, + endLine: end, + endChar: 0, + } + : undefined, + comment: match[6], + } satisfies PromptComment +} diff --git a/packages/app/src/utils/diffs.test.ts b/packages/app/src/utils/diffs.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..a3d25f42795906d28ce99c5209ee8b2e7977a185 --- /dev/null +++ b/packages/app/src/utils/diffs.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, test } from "bun:test" +import type { SnapshotFileDiff } from "@opencode-ai/sdk/v2" +import type { FileDiffInfo } from "@opencode-ai/client/promise" +import type { Message } from "@opencode-ai/sdk/v2/client" +import { diffs, message } from "./diffs" + +const item = { + file: "src/app.ts", + patch: "@@ -1 +1 @@\n-old\n+new\n", + additions: 1, + deletions: 1, + status: "modified", +} satisfies FileDiffInfo & SnapshotFileDiff + +describe("diffs", () => { + test("keeps valid arrays", () => { + expect(diffs([item])).toEqual([item]) + }) + + test("wraps a single diff object", () => { + expect(diffs(item)).toEqual([item]) + }) + + test("reads keyed diff objects", () => { + expect(diffs({ a: item })).toEqual([item]) + }) + + test("drops invalid entries", () => { + expect( + diffs([ + item, + { file: "src/bad.ts", additions: 1, deletions: 1 }, + { patch: item.patch, additions: 1, deletions: 1 }, + ]), + ).toEqual([item]) + }) +}) + +describe("message", () => { + test("normalizes user summaries with object diffs", () => { + const input = { + id: "msg_1", + sessionID: "ses_1", + role: "user", + time: { created: 1 }, + agent: "build", + model: { providerID: "openai", modelID: "gpt-5" }, + summary: { + title: "Edit", + diffs: { a: item }, + }, + } as unknown as Message + + expect(message(input)).toMatchObject({ + summary: { + title: "Edit", + diffs: [item], + }, + }) + }) + + test("drops invalid user summaries", () => { + const input = { + id: "msg_1", + sessionID: "ses_1", + role: "user", + time: { created: 1 }, + agent: "build", + model: { providerID: "openai", modelID: "gpt-5" }, + summary: true, + } as unknown as Message + + expect(message(input)).toMatchObject({ summary: undefined }) + }) +}) diff --git a/packages/app/src/utils/diffs.ts b/packages/app/src/utils/diffs.ts new file mode 100644 index 0000000000000000000000000000000000000000..a8eec75a9af3c2a45a1898ae310b73b6e18ae11c --- /dev/null +++ b/packages/app/src/utils/diffs.ts @@ -0,0 +1,50 @@ +import type { SnapshotFileDiff, VcsFileDiff } from "@opencode-ai/sdk/v2" +import type { FileDiffInfo } from "@opencode-ai/client/promise" +import type { Message } from "@opencode-ai/sdk/v2/client" + +type Diff = FileDiffInfo | SnapshotFileDiff | VcsFileDiff + +function diff(value: unknown): value is Diff { + if (!value || typeof value !== "object" || Array.isArray(value)) return false + if (!("file" in value) || typeof value.file !== "string") return false + if (!("patch" in value) || typeof value.patch !== "string") return false + if (!("additions" in value) || typeof value.additions !== "number") return false + if (!("deletions" in value) || typeof value.deletions !== "number") return false + if (!("status" in value) || value.status === undefined) return true + return value.status === "added" || value.status === "deleted" || value.status === "modified" +} + +function object(value: unknown): value is Record { + return !!value && typeof value === "object" && !Array.isArray(value) +} + +export function diffs(value: unknown): Diff[] { + if (Array.isArray(value) && value.every(diff)) return value + if (Array.isArray(value)) return value.filter(diff) + if (diff(value)) return [value] + if (!object(value)) return [] + return Object.values(value).filter(diff) +} + +export function message(value: Message): Message { + if (value.role !== "user") return value + + const raw = value.summary as unknown + if (raw === undefined) return value + if (!object(raw)) return { ...value, summary: undefined } + + const title = typeof raw.title === "string" ? raw.title : undefined + const body = typeof raw.body === "string" ? raw.body : undefined + const next = diffs(raw.diffs) + + if (title === raw.title && body === raw.body && next === raw.diffs) return value + + return { + ...value, + summary: { + ...(title === undefined ? {} : { title }), + ...(body === undefined ? {} : { body }), + diffs: next, + }, + } +} diff --git a/packages/app/src/utils/draft-store.ts b/packages/app/src/utils/draft-store.ts new file mode 100644 index 0000000000000000000000000000000000000000..cd0895f52ce98b7026267ae88f985c1c1b141c82 --- /dev/null +++ b/packages/app/src/utils/draft-store.ts @@ -0,0 +1,171 @@ +import type { AsyncStorage } from "@solid-primitives/storage" + +export type BlobReference = { id: string; url: string } + +type Driver = { + get(key: string): Promise + set(key: string, value: string): Promise + remove(key: string): Promise + putBlob(blob: Blob): Promise + getBlob(id: string): Promise +} + +export type DraftStore = AsyncStorage & { putBlob(blob: Blob): Promise } +const urls = new Map() + +function blobUrl(id: string, blob: Blob) { + const existing = urls.get(id) + if (existing) return existing + const url = URL.createObjectURL(blob) + urls.set(id, url) + return url +} + +async function blobID(blob: Blob) { + const id = Array.from(new Uint8Array(await crypto.subtle.digest("SHA-256", await blob.arrayBuffer()))) + .map((byte) => byte.toString(16).padStart(2, "0")) + .join("") + return id +} + +export async function createBlobReference(blob: Blob): Promise { + const id = await blobID(blob) + return { id, url: blobUrl(id, blob) } +} + +export function createDraftStore(driver: Driver): DraftStore { + const versions = new Map() + const putBlob = async (blob: Blob) => { + const id = await driver.putBlob(blob) + return { id, url: blobUrl(id, blob) } + } + const encode = async (value: unknown): Promise => { + if (Array.isArray(value)) return Promise.all(value.map(encode)) + if (!value || typeof value !== "object") return value + const item = value as Record + if (item.type === "image" && typeof item.dataUrl === "string") { + const blob = await fetch(item.dataUrl).then((response) => response.blob()) + const { dataUrl: _, ...rest } = item + return { ...rest, blob: { id: await driver.putBlob(blob) } } + } + if ("blob" in item && item.blob && typeof item.blob === "object") { + const blob = item.blob as Record + if (typeof blob.id === "string" && blob.id.startsWith("data:")) { + const data = await fetch(blob.id).then((response) => response.blob()) + return { ...item, blob: { id: await driver.putBlob(data) } } + } + return { ...item, blob: { id: blob.id } } + } + return Object.fromEntries( + await Promise.all(Object.entries(item).map(async ([key, entry]) => [key, await encode(entry)])), + ) + } + const decode = async (value: unknown): Promise => { + if (Array.isArray(value)) return Promise.all(value.map(decode)) + if (!value || typeof value !== "object") return value + const item = value as Record + if (item.blob && typeof item.blob === "object") { + const ref = item.blob as Record + if (typeof ref.id === "string") { + const blob = await driver.getBlob(ref.id) + if (blob) return { ...item, blob: { id: ref.id, url: blobUrl(ref.id, blob) } } + } + } + return Object.fromEntries( + await Promise.all(Object.entries(item).map(async ([key, entry]) => [key, await decode(entry)])), + ) + } + return { + getItem: async (key) => { + const value = await driver.get(key) + return value === null ? null : JSON.stringify(await decode(JSON.parse(value))) + }, + setItem: async (key, value) => { + const version = (versions.get(key) ?? 0) + 1 + versions.set(key, version) + const encoded = JSON.stringify(await encode(JSON.parse(value))) + if (versions.get(key) === version) await driver.set(key, encoded) + }, + removeItem: async (key) => { + versions.set(key, (versions.get(key) ?? 0) + 1) + await driver.remove(key) + }, + putBlob, + } +} + +export function createBrowserDraftStore(): DraftStore { + const request = indexedDB.open("opencode-drafts", 1) + request.addEventListener("upgradeneeded", () => { + request.result.createObjectStore("documents") + request.result.createObjectStore("blobs") + }) + const db = new Promise((resolve, reject) => { + request.addEventListener("success", () => { + const database = request.result + const transaction = database.transaction(["documents", "blobs"], "readwrite") + const documents = transaction.objectStore("documents").getAll() + documents.addEventListener("success", () => { + const used = new Set() + JSON.parse(`[${documents.result.join(",")}]`, (_key, item) => { + if (item?.blob && typeof item.blob.id === "string") used.add(item.blob.id) + return item + }) + const blobs = transaction.objectStore("blobs").openKeyCursor() + blobs.addEventListener("success", () => { + const cursor = blobs.result + if (!cursor) return + if (!used.has(String(cursor.key))) cursor.delete() + cursor.continue() + }) + }) + transaction.addEventListener("complete", () => resolve(database)) + transaction.addEventListener("abort", () => resolve(database)) + }) + request.addEventListener("error", () => reject(request.error)) + }) + const get = async (store: string, key: string) => { + const result = (await db).transaction(store).objectStore(store).get(key) + return new Promise((resolve, reject) => { + result.addEventListener("success", () => resolve(result.result)) + result.addEventListener("error", () => reject(result.error)) + }) + } + const write = async (store: string, key: string, value?: unknown) => { + const transaction = (await db).transaction(store, "readwrite") + if (value === undefined) transaction.objectStore(store).delete(key) + else transaction.objectStore(store).put(value, key) + return new Promise((resolve, reject) => { + transaction.addEventListener("complete", () => resolve()) + transaction.addEventListener("error", () => reject(transaction.error)) + }) + } + return createDraftStore({ + get: async (key) => ((await get("documents", key)) as string | undefined) ?? null, + set: (key, value) => write("documents", key, value), + remove: (key) => write("documents", key), + putBlob: async (blob) => { + const id = await blobID(blob) + await write("blobs", id, blob) + return id + }, + getBlob: async (id) => ((await get("blobs", id)) as Blob | undefined) ?? null, + }) +} + +export async function blobDataUrl(blob: BlobReference, mime: string) { + const data = await fetch(blob.url).then((response) => response.blob()) + return new Promise((resolve, reject) => { + const reader = new FileReader() + reader.addEventListener("error", () => reject(reader.error)) + reader.addEventListener("load", () => { + const value = typeof reader.result === "string" ? reader.result : "" + resolve(`data:${mime};base64,${value.slice(value.indexOf(",") + 1)}`) + }) + reader.readAsDataURL(data) + }) +} + +export function createLegacyBlobReference(dataUrl: string): BlobReference { + return { id: dataUrl, url: dataUrl } +} diff --git a/packages/app/src/utils/file-manager.ts b/packages/app/src/utils/file-manager.ts new file mode 100644 index 0000000000000000000000000000000000000000..2fba23cc5abca835ca862d523b636970c5b81c98 --- /dev/null +++ b/packages/app/src/utils/file-manager.ts @@ -0,0 +1,24 @@ +export type FileManagerOS = "macos" | "windows" | "linux" | "unknown" + +export function fileManagerApp(os: FileManagerOS): { + label: "session.header.open.finder" | "session.header.open.fileExplorer" | "session.header.open.fileManager" + actionLabel: + | "session.header.reveal.finder" + | "session.header.reveal.fileExplorer" + | "session.header.reveal.containingFolder" + icon: "finder" | "file-explorer" +} { + if (os === "macos") + return { label: "session.header.open.finder", actionLabel: "session.header.reveal.finder", icon: "finder" } + if (os === "windows") + return { + label: "session.header.open.fileExplorer", + actionLabel: "session.header.reveal.fileExplorer", + icon: "file-explorer", + } + return { + label: "session.header.open.fileManager", + actionLabel: "session.header.reveal.containingFolder", + icon: "finder", + } +} diff --git a/packages/app/src/utils/id.ts b/packages/app/src/utils/id.ts new file mode 100644 index 0000000000000000000000000000000000000000..dba7a8d95135368767c98829d9071ae90f24cbc7 --- /dev/null +++ b/packages/app/src/utils/id.ts @@ -0,0 +1,93 @@ +const prefixes = { + session: "ses", + message: "msg", + permission: "per", + user: "usr", + part: "prt", + pty: "pty", +} as const + +const LENGTH = 26 +let lastTimestamp = 0 +let counter = 0 + +type Prefix = keyof typeof prefixes +export namespace Identifier { + export function ascending(prefix: Prefix, given?: string) { + return generateID(prefix, false, given) + } + + export function descending(prefix: Prefix, given?: string) { + return generateID(prefix, true, given) + } +} + +function generateID(prefix: Prefix, descending: boolean, given?: string): string { + if (!given) { + return create(prefix, descending) + } + + if (!given.startsWith(prefixes[prefix])) { + throw new Error(`ID ${given} does not start with ${prefixes[prefix]}`) + } + + return given +} + +function create(prefix: Prefix, descending: boolean, timestamp?: number): string { + const currentTimestamp = timestamp ?? Date.now() + + if (currentTimestamp !== lastTimestamp) { + lastTimestamp = currentTimestamp + counter = 0 + } + + counter += 1 + + let now = BigInt(currentTimestamp) * BigInt(0x1000) + BigInt(counter) + + if (descending) { + now = ~now + } + + const timeBytes = new Uint8Array(6) + for (let i = 0; i < 6; i += 1) { + timeBytes[i] = Number((now >> BigInt(40 - 8 * i)) & BigInt(0xff)) + } + + return prefixes[prefix] + "_" + bytesToHex(timeBytes) + randomBase62(LENGTH - 12) +} + +function bytesToHex(bytes: Uint8Array): string { + let hex = "" + for (let i = 0; i < bytes.length; i += 1) { + hex += bytes[i].toString(16).padStart(2, "0") + } + return hex +} + +function randomBase62(length: number): string { + const chars = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" + const bytes = getRandomBytes(length) + let result = "" + for (let i = 0; i < length; i += 1) { + result += chars[bytes[i] % 62] + } + return result +} + +function getRandomBytes(length: number): Uint8Array { + const bytes = new Uint8Array(length) + const cryptoObj = typeof globalThis !== "undefined" ? globalThis.crypto : undefined + + if (cryptoObj && typeof cryptoObj.getRandomValues === "function") { + cryptoObj.getRandomValues(bytes) + return bytes + } + + for (let i = 0; i < length; i += 1) { + bytes[i] = Math.floor(Math.random() * 256) + } + + return bytes +} diff --git a/packages/app/src/utils/menu-dismiss-controller.ts b/packages/app/src/utils/menu-dismiss-controller.ts new file mode 100644 index 0000000000000000000000000000000000000000..0a3009eb716f76d0647696d8a729972607fd9bdd --- /dev/null +++ b/packages/app/src/utils/menu-dismiss-controller.ts @@ -0,0 +1,30 @@ +/** Coordinates focus restoration and actions that must run after menu content unmounts. */ +export function createMenuDismissController(content: () => HTMLElement | undefined) { + let restoreTrigger = true + + return { + /** Allows the menu primitive to restore focus to its trigger when closing. */ + allowTriggerRestore() { + restoreTrigger = true + }, + /** Keeps focus at its current or next destination instead of returning it to the trigger. */ + preventTriggerRestore() { + restoreTrigger = false + }, + /** Applies the current restoration policy during the menu primitive's close-focus event. */ + onCloseAutoFocus(event: Event) { + if (!restoreTrigger) event.preventDefault() + }, + /** Runs an action after the menu unmounts and its focus-close work has settled. */ + afterClose(callback: () => void) { + const complete = () => { + if (content()?.isConnected) { + requestAnimationFrame(complete) + return + } + requestAnimationFrame(() => requestAnimationFrame(callback)) + } + requestAnimationFrame(complete) + }, + } +} diff --git a/packages/app/src/utils/path-key.ts b/packages/app/src/utils/path-key.ts new file mode 100644 index 0000000000000000000000000000000000000000..68d53e91d8639b3c8956c1d769e141c61a17ceb7 --- /dev/null +++ b/packages/app/src/utils/path-key.ts @@ -0,0 +1,24 @@ +export type PathKey = string & { _brand: "PathKey" } + +const isDrive = (value: string) => { + if (value.length !== 2) return false + const code = value.charCodeAt(0) + return value[1] === ":" && ((code >= 65 && code <= 90) || (code >= 97 && code <= 122)) +} + +const trimTrailingSlashes = (value: string) => { + for (let i = value.length - 1; i >= 0; i--) { + if (value[i] !== "/") return value.slice(0, i + 1) + } + return "" +} + +const isWindowsPath = (value: string) => value[1] === ":" || value.startsWith("\\\\") + +export const pathKey = (path: string) => { + const value = isWindowsPath(path) ? path.replaceAll("\\", "/") : path + const trimmed = trimTrailingSlashes(value) + if (!trimmed && value.startsWith("/")) return "/" as PathKey + if (isDrive(trimmed)) return `${trimmed}/` as PathKey + return trimmed as PathKey +} diff --git a/packages/app/src/utils/persist.test.ts b/packages/app/src/utils/persist.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..d8b822d856bb6d991630fdc5351b3c8817ef1f8e --- /dev/null +++ b/packages/app/src/utils/persist.test.ts @@ -0,0 +1,211 @@ +import { beforeAll, beforeEach, describe, expect, mock, test } from "bun:test" +import { ServerScope } from "./server-scope" + +type PersistTestingType = typeof import("./persist").PersistTesting +type PersistType = typeof import("./persist").Persist +type RemovePersistedType = typeof import("./persist").removePersisted + +class MemoryStorage implements Storage { + private values = new Map() + readonly events: string[] = [] + readonly calls = { get: 0, set: 0, remove: 0 } + + clear() { + this.values.clear() + } + + get length() { + return this.values.size + } + + key(index: number) { + return Array.from(this.values.keys())[index] ?? null + } + + getItem(key: string) { + this.calls.get += 1 + this.events.push(`get:${key}`) + if (key.startsWith("opencode.throw")) throw new Error("storage get failed") + return this.values.get(key) ?? null + } + + setItem(key: string, value: string) { + this.calls.set += 1 + this.events.push(`set:${key}`) + if (key.startsWith("opencode.quota")) throw new DOMException("quota", "QuotaExceededError") + if (key.startsWith("opencode.throw")) throw new Error("storage set failed") + this.values.set(key, value) + } + + removeItem(key: string) { + this.calls.remove += 1 + this.events.push(`remove:${key}`) + if (key.startsWith("opencode.throw")) throw new Error("storage remove failed") + this.values.delete(key) + } +} + +const storage = new MemoryStorage() + +let persistTesting: PersistTestingType +let Persist: PersistType +let removePersisted: RemovePersistedType + +beforeAll(async () => { + mock.module("@/context/platform", () => ({ + usePlatform: () => ({ platform: "web" }), + })) + + const mod = await import("./persist") + persistTesting = mod.PersistTesting + Persist = mod.Persist + removePersisted = mod.removePersisted +}) + +beforeEach(() => { + storage.clear() + storage.events.length = 0 + storage.calls.get = 0 + storage.calls.set = 0 + storage.calls.remove = 0 + Object.defineProperty(globalThis, "localStorage", { + value: storage, + configurable: true, + }) +}) + +describe("persist localStorage resilience", () => { + test("does not cache values as persisted when quota write and eviction fail", () => { + const storageApi = persistTesting.localStorageWithPrefix("opencode.quota.scope") + storageApi.setItem("value", '{"value":1}') + + expect(storage.getItem("opencode.quota.scope:value")).toBeNull() + expect(storageApi.getItem("value")).toBeNull() + }) + + test("disables only the failing scope when storage throws", () => { + const bad = persistTesting.localStorageWithPrefix("opencode.throw.scope") + bad.setItem("value", '{"value":1}') + + const before = storage.calls.set + bad.setItem("value", '{"value":2}') + expect(storage.calls.set).toBe(before) + expect(bad.getItem("value")).toBeNull() + + const healthy = persistTesting.localStorageWithPrefix("opencode.safe.scope") + healthy.setItem("value", '{"value":3}') + expect(storage.getItem("opencode.safe.scope:value")).toBe('{"value":3}') + }) + + test("failing fallback scope does not poison direct storage scope", () => { + const broken = persistTesting.localStorageWithPrefix("opencode.throw.scope2") + broken.setItem("value", '{"value":1}') + + const direct = persistTesting.localStorageDirect() + direct.setItem("direct-value", '{"value":5}') + + expect(storage.getItem("direct-value")).toBe('{"value":5}') + }) + + test("normalizer rejects malformed JSON payloads", () => { + const result = persistTesting.normalize({ value: "ok" }, '{"value":"\\x"}') + expect(result).toBeUndefined() + }) + + test("workspace storage sanitizes Windows filename characters", () => { + const result = persistTesting.workspaceStorage("C:\\Users\\foo") + + expect(result).toStartWith("opencode.workspace.") + expect(result.endsWith(".dat")).toBeTrue() + expect(/[:\\/]/.test(result)).toBeFalse() + }) + + test("workspace target keeps raw path storage as legacy fallback", () => { + const target = Persist.workspace("C:\\Users\\foo", "vcs") + + expect(target.storage).toBe(persistTesting.workspaceStorage("C:/Users/foo")) + expect(target.legacyStorageNames).toEqual([persistTesting.workspaceStorage("C:\\Users\\foo")]) + }) + + test("workspace target keeps backslash storage as fallback for normalized Windows paths", () => { + const target = Persist.workspace("C:/Users/foo", "vcs") + + expect(target.storage).toBe(persistTesting.workspaceStorage("C:/Users/foo")) + expect(target.legacyStorageNames).toEqual([persistTesting.workspaceStorage("C:\\Users\\foo")]) + }) + + test("migrates direct legacy keys into scoped storage", () => { + storage.setItem("legacy.workspace", '{"value":2}') + const target = Persist.workspace("C:/Users/foo", "demo", ["legacy.workspace"]) + const current = persistTesting.localStorageWithPrefix(target.storage!) + const legacyStore = persistTesting.localStorageDirect() + + const result = persistTesting.migrateLegacy({ + current, + legacyStore, + stores: [], + keys: target.legacy!, + key: target.key, + defaults: { value: 1 }, + }) + + expect(result).toBe('{"value":2}') + expect(storage.getItem(`${target.storage}:${target.key}`)).toBe('{"value":2}') + expect(legacyStore.getItem("legacy.workspace")).toBeNull() + expect(storage.getItem("legacy.workspace")).toBeNull() + }) + + test("removes legacy workspace storage when removing persisted target", () => { + const target = Persist.workspace("C:\\Users\\foo", "terminal") + storage.setItem(`${target.storage}:${target.key}`, '{"value":1}') + storage.setItem(`${target.legacyStorageNames![0]}:${target.key}`, '{"value":2}') + + removePersisted(target) + + expect(storage.getItem(`${target.storage}:${target.key}`)).toBeNull() + expect(storage.getItem(`${target.legacyStorageNames![0]}:${target.key}`)).toBeNull() + }) + + test("draft target isolates storage per draft and namespaces keys", () => { + const a = Persist.draft("draft-a", "prompt") + const b = Persist.draft("draft-b", "prompt") + + expect(a.key).toBe("draft:prompt") + expect(a.storage).not.toBe(b.storage) + expect(a.storage).not.toBe(Persist.workspace("/home/luke/repo", "prompt").storage) + }) + + test("removes draft storage when removing persisted target", () => { + const target = Persist.draft("draft-a", "prompt") + storage.setItem(`${target.storage}:${target.key}`, '{"value":1}') + + removePersisted(target) + + expect(storage.getItem(`${target.storage}:${target.key}`)).toBeNull() + }) + + test("server workspace target preserves local storage and isolates remote storage", () => { + const local = Persist.serverWorkspace(ServerScope.local, "/home/luke/repo", "prompt") + const windows = Persist.serverWorkspace("https://windows.example" as ServerScope, "/home/luke/repo", "prompt") + const debian = Persist.serverWorkspace("https://debian.example" as ServerScope, "/home/luke/repo", "prompt") + + expect(local).toEqual(Persist.workspace("/home/luke/repo", "prompt")) + expect(windows.storage).not.toBe(local.storage) + expect(debian.storage).not.toBe(local.storage) + expect(debian.storage).not.toBe(windows.storage) + expect(windows.legacyStorageNames).toBeUndefined() + expect(debian.legacyStorageNames).toBeUndefined() + }) + + test("server global target preserves local key and isolates remote keys", () => { + expect(Persist.serverGlobal(ServerScope.local, "notification")).toEqual(Persist.global("notification")) + expect(Persist.serverGlobal("https://debian.example" as ServerScope, "notification")).toEqual({ + storage: "opencode.global.dat", + key: "https://debian.example\0notification", + }) + }) + + test("server global target cannot collide when scope and key contain colons", () => { + expect(Persist.serverGlobal("a:b" as ServerScope, "c")).not.toEqual(Persist.serverGlobal("a" as ServerScope, "b:c")) + }) +}) diff --git a/packages/app/src/utils/persist.ts b/packages/app/src/utils/persist.ts new file mode 100644 index 0000000000000000000000000000000000000000..ebecde50975cfe1f302489d114fdff7d38e421b3 --- /dev/null +++ b/packages/app/src/utils/persist.ts @@ -0,0 +1,704 @@ +import { Platform, usePlatform } from "@/context/platform" +import { makePersisted, type AsyncStorage, type SyncStorage } from "@solid-primitives/storage" +import { checksum } from "@opencode-ai/core/util/encode" +import { createResource, type Accessor } from "solid-js" +import type { SetStoreFunction, Store } from "solid-js/store" +import { pathKey } from "@/utils/path-key" +import { ScopedKey, ServerScope, type ServerScope as ServerScopeValue } from "@/utils/server-scope" + +type InitType = Promise | string | null +type PersistedWithReady = [ + Store, + SetStoreFunction, + InitType, + Accessor & { promise: undefined | Promise }, +] + +type PersistTarget = { + draft?: boolean + storage?: string + scope?: "window" + legacyStorageNames?: string[] + key: string + legacy?: string[] + migrate?: (value: unknown) => unknown +} + +const LEGACY_STORAGE = "default.dat" +const GLOBAL_STORAGE = "opencode.global.dat" +const WINDOW_STORAGE = "opencode.window" +const LOCAL_PREFIX = "opencode." +const fallback = new Map() + +const CACHE_MAX_ENTRIES = 500 +const CACHE_MAX_BYTES = 8 * 1024 * 1024 + +type CacheEntry = { value: string; bytes: number } +const cache = new Map() +const cacheTotal = { bytes: 0 } + +function cacheDelete(key: string) { + const entry = cache.get(key) + if (!entry) return + cacheTotal.bytes -= entry.bytes + cache.delete(key) +} + +function cachePrune() { + for (;;) { + if (cache.size <= CACHE_MAX_ENTRIES && cacheTotal.bytes <= CACHE_MAX_BYTES) return + const oldest = cache.keys().next().value as string | undefined + if (!oldest) return + cacheDelete(oldest) + } +} + +function cacheSet(key: string, value: string) { + const bytes = value.length * 2 + if (bytes > CACHE_MAX_BYTES) { + cacheDelete(key) + return + } + + const entry = cache.get(key) + if (entry) cacheTotal.bytes -= entry.bytes + cache.delete(key) + cache.set(key, { value, bytes }) + cacheTotal.bytes += bytes + cachePrune() +} + +function cacheGet(key: string) { + const entry = cache.get(key) + if (!entry) return + cache.delete(key) + cache.set(key, entry) + return entry.value +} + +function fallbackDisabled(scope: string) { + return fallback.get(scope) === true +} + +function fallbackSet(scope: string) { + fallback.set(scope, true) +} + +function quota(error: unknown) { + if (error instanceof DOMException) { + if (error.name === "QuotaExceededError") return true + if (error.name === "NS_ERROR_DOM_QUOTA_REACHED") return true + if (error.name === "QUOTA_EXCEEDED_ERR") return true + if (error.code === 22 || error.code === 1014) return true + return false + } + + if (!error || typeof error !== "object") return false + const name = (error as { name?: string }).name + if (name === "QuotaExceededError" || name === "NS_ERROR_DOM_QUOTA_REACHED") return true + if (name && /quota/i.test(name)) return true + + const code = (error as { code?: number }).code + if (code === 22 || code === 1014) return true + + const message = (error as { message?: string }).message + if (typeof message !== "string") return false + if (/quota/i.test(message)) return true + return false +} + +type Evict = { key: string; size: number } + +function evict(storage: Storage, keep: string, value: string) { + const total = storage.length + const indexes = Array.from({ length: total }, (_, index) => index) + const items: Evict[] = [] + + for (const index of indexes) { + const name = storage.key(index) + if (!name) continue + if (!name.startsWith(LOCAL_PREFIX)) continue + if (name === keep) continue + const stored = storage.getItem(name) + items.push({ key: name, size: stored?.length ?? 0 }) + } + + items.sort((a, b) => b.size - a.size) + + for (const item of items) { + storage.removeItem(item.key) + cacheDelete(item.key) + + try { + storage.setItem(keep, value) + cacheSet(keep, value) + return true + } catch (error) { + if (!quota(error)) throw error + } + } + + return false +} + +function write(storage: Storage, key: string, value: string) { + try { + storage.setItem(key, value) + cacheSet(key, value) + return true + } catch (error) { + if (!quota(error)) throw error + } + + try { + storage.removeItem(key) + cacheDelete(key) + storage.setItem(key, value) + cacheSet(key, value) + return true + } catch (error) { + if (!quota(error)) throw error + } + + const ok = evict(storage, key, value) + return ok +} + +function snapshot(value: unknown) { + return JSON.parse(JSON.stringify(value)) as unknown +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value) +} + +function merge(defaults: unknown, value: unknown): unknown { + if (value === undefined) return defaults + if (value === null) return value + + if (Array.isArray(defaults)) { + if (Array.isArray(value)) return value + return defaults + } + + if (isRecord(defaults)) { + if (!isRecord(value)) return defaults + + const result: Record = { ...defaults } + for (const key of Object.keys(value)) { + if (key in defaults) { + result[key] = merge((defaults as Record)[key], (value as Record)[key]) + } else { + result[key] = (value as Record)[key] + } + } + return result + } + + return value +} + +function parse(value: string) { + try { + return JSON.parse(value) as unknown + } catch { + return undefined + } +} + +function normalize(defaults: unknown, raw: string, migrate?: (value: unknown) => unknown) { + const parsed = parse(raw) + if (parsed === undefined) return + const migrated = migrate ? migrate(parsed) : parsed + const merged = merge(defaults, migrated) + return JSON.stringify(merged) +} + +function readCurrent(input: { + storage: SyncStorage + key: string + defaults: unknown + migrate?: (value: unknown) => unknown +}) { + const raw = input.storage.getItem(input.key) + if (raw === null) return + const next = normalize(input.defaults, raw, input.migrate) + if (next === undefined) { + input.storage.removeItem(input.key) + return null + } + if (raw !== next) input.storage.setItem(input.key, next) + return next +} + +function migrateLegacy(input: { + current: SyncStorage + legacyStore?: SyncStorage + stores: SyncStorage[] + keys: string[] + key: string + defaults: unknown + migrate?: (value: unknown) => unknown +}) { + for (const store of input.stores) { + const raw = store.getItem(input.key) + if (raw === null) continue + + const next = normalize(input.defaults, raw, input.migrate) + if (next === undefined) { + store.removeItem(input.key) + continue + } + input.current.setItem(input.key, next) + store.removeItem(input.key) + return next + } + + if (!input.legacyStore) return null + + for (const key of input.keys) { + const raw = input.legacyStore.getItem(key) + if (raw === null) continue + + const next = normalize(input.defaults, raw, input.migrate) + if (next === undefined) { + input.legacyStore.removeItem(key) + continue + } + input.current.setItem(input.key, next) + input.legacyStore.removeItem(key) + return next + } + + return null +} + +async function readCurrentAsync(input: { + storage: AsyncStorage + key: string + defaults: unknown + migrate?: (value: unknown) => unknown +}) { + const raw = await input.storage.getItem(input.key) + if (raw === null) return + const next = normalize(input.defaults, raw, input.migrate) + if (next === undefined) { + await input.storage.removeItem(input.key).catch(() => undefined) + return null + } + if (raw !== next) await input.storage.setItem(input.key, next) + return next +} + +async function removeAsync(storage: AsyncStorage, key: string) { + try { + await storage.removeItem(key) + } catch {} +} + +function toAsyncStorage(storage: SyncStorage | AsyncStorage): AsyncStorage { + return { + getItem: async (key) => storage.getItem(key), + setItem: async (key, value) => storage.setItem(key, value), + removeItem: async (key) => storage.removeItem(key), + } +} + +async function migrateLegacyAsync(input: { + current: AsyncStorage + legacyStore?: AsyncStorage + stores: AsyncStorage[] + keys: string[] + key: string + defaults: unknown + migrate?: (value: unknown) => unknown +}) { + for (const store of input.stores) { + const raw = await store.getItem(input.key) + if (raw === null) continue + + const next = normalize(input.defaults, raw, input.migrate) + if (next === undefined) { + await removeAsync(store, input.key) + continue + } + await input.current.setItem(input.key, next) + await store.removeItem(input.key) + return next + } + + if (!input.legacyStore) return null + + for (const key of input.keys) { + const raw = await input.legacyStore.getItem(key) + if (raw === null) continue + + const next = normalize(input.defaults, raw, input.migrate) + if (next === undefined) { + await removeAsync(input.legacyStore, key) + continue + } + await input.current.setItem(input.key, next) + await input.legacyStore.removeItem(key) + return next + } + + return null +} + +function workspaceStorage(dir: string) { + const head = (dir.slice(0, 12) || "workspace").replace(/[^a-zA-Z0-9._-]/g, "-") + const sum = checksum(dir) ?? "0" + return `opencode.workspace.${head}.${sum}.dat` +} + +function draftStorage(draftID: string) { + const head = (draftID.slice(0, 12) || "draft").replace(/[^a-zA-Z0-9._-]/g, "-") + const sum = checksum(draftID) ?? "0" + return `opencode.draft.${head}.${sum}.dat` +} + +function windowStorage(windowID: string) { + const safe = (windowID || "browser").replace(/[^a-zA-Z0-9._-]/g, "-") + return `${WINDOW_STORAGE}.${safe}.dat` +} + +function legacyWorkspaceStorage(dir: string) { + const storage = workspaceStorage(pathKey(dir)) + const result = new Set() + const raw = workspaceStorage(dir) + if (raw !== storage) result.add(raw) + + const key = pathKey(dir) + const drive = key.length >= 3 && key[1] === ":" && key[2] === "/" + if (drive) { + const backslash = workspaceStorage(key.replaceAll("/", "\\")) + if (backslash !== storage) result.add(backslash) + } + + if (result.size === 0) return + return [...result] +} + +function serverWorkspaceTarget(scope: ServerScopeValue, dir: string, key: string, legacy?: string[]): PersistTarget { + if (scope !== ServerScope.local) return { storage: workspaceStorage(ScopedKey.from(scope, pathKey(dir))), key } + return { storage: workspaceStorage(pathKey(dir)), legacyStorageNames: legacyWorkspaceStorage(dir), key, legacy } +} + +function localStorageWithPrefix(prefix: string): SyncStorage { + const base = `${prefix}:` + const scope = `prefix:${prefix}` + const item = (key: string) => base + key + return { + getItem: (key) => { + const name = item(key) + const cached = cacheGet(name) + if (fallbackDisabled(scope)) return cached ?? null + + const stored = (() => { + try { + return localStorage.getItem(name) + } catch { + fallbackSet(scope) + return null + } + })() + if (stored === null) return cached ?? null + cacheSet(name, stored) + return stored + }, + setItem: (key, value) => { + const name = item(key) + if (fallbackDisabled(scope)) return + try { + if (write(localStorage, name, value)) return + } catch { + fallbackSet(scope) + return + } + fallbackSet(scope) + }, + removeItem: (key) => { + const name = item(key) + cacheDelete(name) + if (fallbackDisabled(scope)) return + try { + localStorage.removeItem(name) + } catch { + fallbackSet(scope) + } + }, + } +} + +function localStorageDirect(): SyncStorage { + const scope = "direct" + return { + getItem: (key) => { + const cached = cacheGet(key) + if (fallbackDisabled(scope)) return cached ?? null + + const stored = (() => { + try { + return localStorage.getItem(key) + } catch { + fallbackSet(scope) + return null + } + })() + if (stored === null) return cached ?? null + cacheSet(key, stored) + return stored + }, + setItem: (key, value) => { + if (fallbackDisabled(scope)) return + try { + if (write(localStorage, key, value)) return + } catch { + fallbackSet(scope) + return + } + fallbackSet(scope) + }, + removeItem: (key) => { + cacheDelete(key) + if (fallbackDisabled(scope)) return + try { + localStorage.removeItem(key) + } catch { + fallbackSet(scope) + } + }, + } +} + +const DRAFT_PERSISTED_KEYS = ["prompt", "comments", "file-view", "layout"] + +export function draftPersistedKeys() { + return DRAFT_PERSISTED_KEYS +} + +export const PersistTesting = { + localStorageDirect, + localStorageWithPrefix, + migrateLegacy, + normalize, + resolveTarget, + windowStorage, + workspaceStorage, +} + +export const Persist = { + global(key: string, legacy?: string[]): PersistTarget { + return { storage: GLOBAL_STORAGE, key, legacy } + }, + window(key: string, legacy?: string[]): PersistTarget { + return { scope: "window", key, legacy } + }, + draft(draftID: string, key: string, legacy?: string[]): PersistTarget { + return { storage: draftStorage(draftID), key: `draft:${key}`, legacy } + }, + serverGlobal(scope: ServerScopeValue, key: string, legacy?: string[]): PersistTarget { + if (scope === ServerScope.local) return Persist.global(key, legacy) + return { storage: GLOBAL_STORAGE, key: ScopedKey.from(scope, key) } + }, + workspace(dir: string, key: string, legacy?: string[]): PersistTarget { + return serverWorkspaceTarget(ServerScope.local, dir, `workspace:${key}`, legacy) + }, + serverWorkspace(scope: ServerScopeValue, dir: string, key: string, legacy?: string[]): PersistTarget { + return serverWorkspaceTarget(scope, dir, `workspace:${key}`, legacy) + }, + session(dir: string, session: string, key: string, legacy?: string[]): PersistTarget { + return serverWorkspaceTarget(ServerScope.local, dir, `session:${session}:${key}`, legacy) + }, + serverSession(scope: ServerScopeValue, dir: string, session: string, key: string, legacy?: string[]): PersistTarget { + return serverWorkspaceTarget(scope, dir, `session:${session}:${key}`, legacy) + }, + scoped(dir: string, session: string | undefined, key: string, legacy?: string[]): PersistTarget { + if (session) return Persist.session(dir, session, key, legacy) + return Persist.workspace(dir, key, legacy) + }, + serverScoped(scope: ServerScopeValue, dir: string, session: string | undefined, key: string, legacy?: string[]) { + if (session) return Persist.serverSession(scope, dir, session, key, legacy) + return Persist.serverWorkspace(scope, dir, key, legacy) + }, + prompt(target: PersistTarget): PersistTarget { + return { ...target, draft: true } + }, +} + +function resolveTarget(target: PersistTarget, platform: Platform): PersistTarget { + if (target.scope !== "window") return target + if (platform.platform === "desktop" && !platform.windowID) return { ...target, storage: GLOBAL_STORAGE } + const windowID = platform.platform === "desktop" ? (platform.windowID ?? "browser") : "browser" + return { + ...target, + storage: windowStorage(windowID), + } +} + +export function removePersisted( + target: { draft?: boolean; storage?: string; legacyStorageNames?: string[]; key: string }, + platform?: Platform, +) { + if (target.draft && platform?.draftStore) { + void platform.draftStore.removeItem(`${target.storage ?? "default"}:${target.key}`) + } + const isDesktop = platform?.platform === "desktop" && !!platform.storage + + if (isDesktop) { + void platform.storage?.(target.storage)?.removeItem(target.key) + for (const storage of target.legacyStorageNames ?? []) { + void platform.storage?.(storage)?.removeItem(target.key) + } + return + } + + if (!target.storage) { + localStorageDirect().removeItem(target.key) + return + } + + localStorageWithPrefix(target.storage).removeItem(target.key) + for (const storage of target.legacyStorageNames ?? []) { + localStorageWithPrefix(storage).removeItem(target.key) + } +} + +export function persisted( + target: string | PersistTarget, + store: [Store, SetStoreFunction], + platformOverride?: Platform, +): PersistedWithReady { + const platform = platformOverride ?? usePlatform() + const config = resolveTarget(typeof target === "string" ? { key: target } : target, platform) + + const defaults = snapshot(store[0]) + const legacy = config.legacy ?? [] + + const isDesktop = platform.platform === "desktop" && !!platform.storage + const draft = config.draft ? platform.draftStore : undefined + + const currentStorage = (() => { + if (draft) { + const prefix = `${config.storage ?? "default"}:` + return { + getItem: (key: string) => draft.getItem(prefix + key), + setItem: (key: string, value: string) => draft.setItem(prefix + key, value), + removeItem: (key: string) => draft.removeItem(prefix + key), + } satisfies AsyncStorage + } + if (isDesktop) return platform.storage?.(config.storage) + if (!config.storage) return localStorageDirect() + return localStorageWithPrefix(config.storage) + })() + + const legacyStorage = (() => { + if (!isDesktop) return localStorageDirect() + if (!config.storage) return platform.storage?.() + return platform.storage?.(LEGACY_STORAGE) + })() + + const legacyStorageNames = config.legacyStorageNames ?? [] + + const storage = (() => { + if (!isDesktop && !draft) { + const current = currentStorage as SyncStorage + const legacyStore = legacyStorage as SyncStorage + const legacyStores = legacyStorageNames.map(localStorageWithPrefix) + + const api: SyncStorage = { + getItem: (key) => { + const value = readCurrent({ storage: current, key, defaults, migrate: config.migrate }) + if (value !== undefined) return value + return migrateLegacy({ + current, + legacyStore, + stores: legacyStores, + keys: legacy, + key, + defaults, + migrate: config.migrate, + }) + }, + setItem: (key, value) => { + current.setItem(key, value) + }, + removeItem: (key) => { + current.removeItem(key) + }, + } + + return api + } + + const current = currentStorage as AsyncStorage + const legacyStore = legacyStorage as AsyncStorage | undefined + const oldCurrent = draft + ? isDesktop + ? platform.storage?.(config.storage) + : config.storage + ? localStorageWithPrefix(config.storage) + : localStorageDirect() + : undefined + const legacyStores = [ + oldCurrent, + ...legacyStorageNames.map((name) => (isDesktop ? platform.storage?.(name) : localStorageWithPrefix(name))), + ] + .filter((x) => !!x) + .map(toAsyncStorage) + let draftLatest: string | undefined + + const api: AsyncStorage = { + getItem: async (key) => { + const value = await readCurrentAsync({ storage: current, key, defaults, migrate: config.migrate }) + if (value !== undefined) return value + const migrated = await migrateLegacyAsync({ + current, + legacyStore, + stores: legacyStores, + keys: legacy, + key, + defaults, + migrate: config.migrate, + }) + if (draftLatest === undefined) { + if (draft && migrated !== null) return (await current.getItem(key)) ?? migrated + return migrated + } + await current.setItem(key, draftLatest) + return draftLatest + }, + setItem: async (key, value) => { + if (draft) draftLatest = value + await current.setItem(key, value) + }, + removeItem: async (key) => { + await current.removeItem(key) + }, + } + + return api + })() + + const [state, setState, init] = makePersisted(store, { name: config.key, storage }) + + const isAsync = init instanceof Promise + const [ready] = createResource( + () => init, + async (initValue) => { + if (initValue instanceof Promise) await initValue + return true + }, + { initialValue: !isAsync }, + ) + + return [ + state, + setState, + init, + Object.assign(() => (ready.loading ? false : ready.latest === true), { + promise: init instanceof Promise ? init : undefined, + }), + ] +} diff --git a/packages/app/src/utils/prompt.test.ts b/packages/app/src/utils/prompt.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..8b86d43b9092fdd8aa3df9f09af90a3e1b10e5cc --- /dev/null +++ b/packages/app/src/utils/prompt.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, test } from "bun:test" +import type { Part } from "@opencode-ai/sdk/v2" +import { extractPromptFromParts } from "./prompt" + +describe("extractPromptFromParts", () => { + test("restores multiple uploaded attachments", () => { + const parts = [ + { + id: "text_1", + type: "text", + text: "check these", + sessionID: "ses_1", + messageID: "msg_1", + }, + { + id: "file_1", + type: "file", + mime: "image/png", + url: "data:image/png;base64,AAA", + filename: "a.png", + sessionID: "ses_1", + messageID: "msg_1", + }, + { + id: "file_2", + type: "file", + mime: "application/pdf", + url: "data:application/pdf;base64,BBB", + filename: "b.pdf", + sessionID: "ses_1", + messageID: "msg_1", + }, + ] satisfies Part[] + + const result = extractPromptFromParts(parts) + + expect(result).toHaveLength(3) + expect(result[0]).toMatchObject({ type: "text", content: "check these" }) + expect(result.slice(1)).toMatchObject([ + { + type: "image", + filename: "a.png", + mime: "image/png", + blob: expect.objectContaining({ id: expect.any(String) }), + }, + { + type: "image", + filename: "b.pdf", + mime: "application/pdf", + blob: expect.objectContaining({ id: expect.any(String) }), + }, + ]) + }) +}) diff --git a/packages/app/src/utils/prompt.ts b/packages/app/src/utils/prompt.ts new file mode 100644 index 0000000000000000000000000000000000000000..67d32086bb56cba40df69e886b6f6c06f85eebfb --- /dev/null +++ b/packages/app/src/utils/prompt.ts @@ -0,0 +1,204 @@ +import type { AgentPart as MessageAgentPart, FilePart, Part, TextPart } from "@opencode-ai/sdk/v2" +import type { AgentPart, FileAttachmentPart, ImageAttachmentPart, Prompt } from "@/context/prompt" +import { createLegacyBlobReference } from "@/utils/draft-store" + +type Inline = + | { + type: "file" + start: number + end: number + value: string + path: string + selection?: { + startLine: number + endLine: number + startChar: number + endChar: number + } + } + | { + type: "agent" + start: number + end: number + value: string + name: string + } + +function selectionFromFileUrl(url: string): Extract["selection"] { + const queryIndex = url.indexOf("?") + if (queryIndex === -1) return undefined + const params = new URLSearchParams(url.slice(queryIndex + 1)) + const startLine = Number(params.get("start")) + const endLine = Number(params.get("end")) + if (!Number.isFinite(startLine) || !Number.isFinite(endLine)) return undefined + return { + startLine, + endLine, + startChar: 0, + endChar: 0, + } +} + +function textPartValue(parts: Part[]) { + const candidates = parts + .filter((part): part is TextPart => part.type === "text") + .filter((part) => !part.synthetic && !part.ignored) + return candidates.reduce((best: TextPart | undefined, part) => { + if (!best) return part + if (part.text.length > best.text.length) return part + return best + }, undefined) +} + +/** + * Extract prompt content from message parts for restoring into the prompt input. + * This is used by undo to restore the original user prompt. + */ +export function extractPromptFromParts(parts: Part[], opts?: { directory?: string; attachmentName?: string }): Prompt { + const textPart = textPartValue(parts) + const text = textPart?.text ?? "" + const directory = opts?.directory + const attachmentName = opts?.attachmentName ?? "attachment" + + const toRelative = (path: string) => { + if (!directory) return path + + const prefix = directory.endsWith("/") ? directory : directory + "/" + if (path.startsWith(prefix)) return path.slice(prefix.length) + + if (path.startsWith(directory)) { + const next = path.slice(directory.length) + if (next.startsWith("/")) return next.slice(1) + return next + } + + return path + } + + const inline: Inline[] = [] + const images: ImageAttachmentPart[] = [] + + for (const part of parts) { + if (part.type === "file") { + const filePart = part as FilePart + const sourceText = filePart.source?.text + if (sourceText) { + const value = sourceText.value + const start = sourceText.start + const end = sourceText.end + let path = value + if (value.startsWith("@")) path = value.slice(1) + if (!value.startsWith("@") && filePart.source && "path" in filePart.source) { + path = filePart.source.path + } + inline.push({ + type: "file", + start, + end, + value, + path: toRelative(path), + selection: selectionFromFileUrl(filePart.url), + }) + continue + } + + if (filePart.url.startsWith("data:")) { + images.push({ + type: "image", + id: filePart.id, + filename: filePart.filename ?? attachmentName, + mime: filePart.mime, + blob: createLegacyBlobReference(filePart.url), + }) + } + } + + if (part.type === "agent") { + const agentPart = part as MessageAgentPart + const source = agentPart.source + if (!source) continue + inline.push({ + type: "agent", + start: source.start, + end: source.end, + value: source.value, + name: agentPart.name, + }) + } + } + + inline.sort((a, b) => { + if (a.start !== b.start) return a.start - b.start + return a.end - b.end + }) + + const result: Prompt = [] + let position = 0 + let cursor = 0 + + const pushText = (content: string) => { + if (!content) return + result.push({ + type: "text", + content, + start: position, + end: position + content.length, + }) + position += content.length + } + + const pushFile = (item: Extract) => { + const content = item.value + const attachment: FileAttachmentPart = { + type: "file", + path: item.path, + content, + start: position, + end: position + content.length, + selection: item.selection, + } + result.push(attachment) + position += content.length + } + + const pushAgent = (item: Extract) => { + const content = item.value + const mention: AgentPart = { + type: "agent", + name: item.name, + content, + start: position, + end: position + content.length, + } + result.push(mention) + position += content.length + } + + for (const item of inline) { + if (item.start < 0 || item.end < item.start) continue + + const expected = item.value + if (!expected) continue + + const mismatch = item.end > text.length || item.start < cursor || text.slice(item.start, item.end) !== expected + const start = mismatch ? text.indexOf(expected, cursor) : item.start + if (start === -1) continue + const end = mismatch ? start + expected.length : item.end + + pushText(text.slice(cursor, start)) + + if (item.type === "file") pushFile(item) + if (item.type === "agent") pushAgent(item) + + cursor = end + } + + pushText(text.slice(cursor)) + + if (result.length === 0) { + result.push({ type: "text", content: "", start: 0, end: 0 }) + } + + if (images.length === 0) return result + return [...result, ...images] +} diff --git a/packages/app/src/utils/refcount.test.ts b/packages/app/src/utils/refcount.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..f48d80db35df7b58f36f9e249957472ddfccb8d6 --- /dev/null +++ b/packages/app/src/utils/refcount.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, test } from "bun:test" +import { createRoot } from "solid-js" +import { createRefCountMap } from "./refcount" +import { pathKey } from "./path-key" + +describe("createRefCountMap", () => { + test("removes an item after its last owner is disposed", () => { + const removed: string[] = [] + const map = createRefCountMap( + (key) => key, + (key) => removed.push(key), + ) + const first = createRoot((dispose) => { + map("/project") + return dispose + }) + const second = createRoot((dispose) => { + map("/project") + return dispose + }) + + first() + expect(removed).toEqual([]) + second() + expect(removed).toEqual(["/project"]) + }) + + test("keeps equivalent path consumers until the last owner is disposed", () => { + const removed: string[] = [] + const map = createRefCountMap( + (key) => key, + (key) => removed.push(key), + pathKey, + ) + const first = createRoot((dispose) => { + map("C:\\repo") + return dispose + }) + const second = createRoot((dispose) => { + map("C:/repo/") + return dispose + }) + + first() + expect(removed).toEqual([]) + second() + expect(removed).toEqual(["C:/repo"]) + }) +}) diff --git a/packages/app/src/utils/refcount.ts b/packages/app/src/utils/refcount.ts new file mode 100644 index 0000000000000000000000000000000000000000..c284491cce7187e310c329bc814b07a87d9e41a2 --- /dev/null +++ b/packages/app/src/utils/refcount.ts @@ -0,0 +1,32 @@ +import { onCleanup } from "solid-js" + +export function createRefCountMap( + create: (key: string) => T, + remove?: (key: string) => void, + identity: (key: string) => string = (key) => key, +) { + const items = new Map() + const refCounts = new Map() + + return (key: string) => { + const id = identity(key) + onCleanup(() => { + refCounts.set(id, (refCounts.get(id) ?? 0) - 1) + if (refCounts.get(id) === 0) { + remove?.(id) + items.delete(id) + refCounts.delete(id) + } + }) + + const cached = items.get(id) + if (cached) { + refCounts.set(id, (refCounts.get(id) ?? 0) + 1) + return cached + } + const item = create(key) + items.set(id, item) + refCounts.set(id, 1) + return item + } +} diff --git a/packages/app/src/utils/runtime-adapters.test.ts b/packages/app/src/utils/runtime-adapters.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..49552e179c2f8de1f82145d4a8a282265c2fe872 --- /dev/null +++ b/packages/app/src/utils/runtime-adapters.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, test } from "bun:test" +import { + disposeIfDisposable, + getHoveredLinkText, + getSpeechRecognitionCtor, + hasSetOption, + isDisposable, + setOptionIfSupported, +} from "./runtime-adapters" + +describe("runtime adapters", () => { + test("detects and disposes disposable values", () => { + let count = 0 + const value = { + dispose: () => { + count += 1 + }, + } + expect(isDisposable(value)).toBe(true) + disposeIfDisposable(value) + expect(count).toBe(1) + }) + + test("ignores non-disposable values", () => { + expect(isDisposable({ dispose: "nope" })).toBe(false) + expect(() => disposeIfDisposable({ dispose: "nope" })).not.toThrow() + }) + + test("sets options only when setter exists", () => { + const calls: Array<[string, unknown]> = [] + const value = { + setOption: (key: string, next: unknown) => { + calls.push([key, next]) + }, + } + expect(hasSetOption(value)).toBe(true) + setOptionIfSupported(value, "fontFamily", "Berkeley Mono") + expect(calls).toEqual([["fontFamily", "Berkeley Mono"]]) + expect(() => setOptionIfSupported({}, "fontFamily", "Berkeley Mono")).not.toThrow() + }) + + test("reads hovered link text safely", () => { + expect(getHoveredLinkText({ currentHoveredLink: { text: "https://example.com" } })).toBe("https://example.com") + expect(getHoveredLinkText({ currentHoveredLink: { text: 1 } })).toBeUndefined() + expect(getHoveredLinkText(null)).toBeUndefined() + }) + + test("resolves speech recognition constructor with webkit precedence", () => { + // oxlint-disable-next-line no-extraneous-class + class SpeechCtor {} + // oxlint-disable-next-line no-extraneous-class + class WebkitCtor {} + const ctor = getSpeechRecognitionCtor({ + SpeechRecognition: SpeechCtor, + webkitSpeechRecognition: WebkitCtor, + }) + expect(ctor).toBe(WebkitCtor) + }) + + test("returns undefined when no valid speech constructor exists", () => { + expect(getSpeechRecognitionCtor({ SpeechRecognition: "nope" })).toBeUndefined() + expect(getSpeechRecognitionCtor(undefined)).toBeUndefined() + }) +}) diff --git a/packages/app/src/utils/runtime-adapters.ts b/packages/app/src/utils/runtime-adapters.ts new file mode 100644 index 0000000000000000000000000000000000000000..4c74da5dc1dff7f5827dab1a1f371f7c945c0bc0 --- /dev/null +++ b/packages/app/src/utils/runtime-adapters.ts @@ -0,0 +1,39 @@ +type RecordValue = Record + +const isRecord = (value: unknown): value is RecordValue => { + return typeof value === "object" && value !== null +} + +export const isDisposable = (value: unknown): value is { dispose: () => void } => { + return isRecord(value) && typeof value.dispose === "function" +} + +export const disposeIfDisposable = (value: unknown) => { + if (!isDisposable(value)) return + value.dispose() +} + +export const hasSetOption = (value: unknown): value is { setOption: (key: string, next: unknown) => void } => { + return isRecord(value) && typeof value.setOption === "function" +} + +export const setOptionIfSupported = (value: unknown, key: string, next: unknown) => { + if (!hasSetOption(value)) return + value.setOption(key, next) +} + +export const getHoveredLinkText = (value: unknown) => { + if (!isRecord(value)) return + const link = value.currentHoveredLink + if (!isRecord(link)) return + if (typeof link.text !== "string") return + return link.text +} + +export const getSpeechRecognitionCtor = (value: unknown): (new () => T) | undefined => { + if (!isRecord(value)) return + const ctor = + typeof value.webkitSpeechRecognition === "function" ? value.webkitSpeechRecognition : value.SpeechRecognition + if (typeof ctor !== "function") return + return ctor as new () => T +} diff --git a/packages/app/src/utils/same.ts b/packages/app/src/utils/same.ts new file mode 100644 index 0000000000000000000000000000000000000000..c956f92998a6fa92b64a57a072dfb76280291e83 --- /dev/null +++ b/packages/app/src/utils/same.ts @@ -0,0 +1,6 @@ +export function same(a: readonly T[] | undefined, b: readonly T[] | undefined) { + if (a === b) return true + if (!a || !b) return false + if (a.length !== b.length) return false + return a.every((x, i) => x === b[i]) +} diff --git a/packages/app/src/utils/scoped-cache.test.ts b/packages/app/src/utils/scoped-cache.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..0c6189dafe565067918b72dda827a6024f0d8205 --- /dev/null +++ b/packages/app/src/utils/scoped-cache.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, test } from "bun:test" +import { createScopedCache } from "./scoped-cache" + +describe("createScopedCache", () => { + test("evicts least-recently-used entry when max is reached", () => { + const disposed: string[] = [] + const cache = createScopedCache((key) => ({ key }), { + maxEntries: 2, + dispose: (value) => disposed.push(value.key), + }) + + const a = cache.get("a") + const b = cache.get("b") + expect(a.key).toBe("a") + expect(b.key).toBe("b") + + cache.get("a") + const c = cache.get("c") + + expect(c.key).toBe("c") + expect(cache.peek("a")?.key).toBe("a") + expect(cache.peek("b")).toBeUndefined() + expect(cache.peek("c")?.key).toBe("c") + expect(disposed).toEqual(["b"]) + }) + + test("disposes entries on delete and clear", () => { + const disposed: string[] = [] + const cache = createScopedCache((key) => ({ key }), { + dispose: (value) => disposed.push(value.key), + }) + + cache.get("a") + cache.get("b") + + const removed = cache.delete("a") + expect(removed?.key).toBe("a") + expect(cache.peek("a")).toBeUndefined() + + cache.clear() + expect(cache.peek("b")).toBeUndefined() + expect(disposed).toEqual(["a", "b"]) + }) + + test("expires stale entries with ttl and recreates on get", () => { + let clock = 0 + let count = 0 + const disposed: string[] = [] + const cache = createScopedCache((key) => ({ key, count: ++count }), { + ttlMs: 10, + now: () => clock, + dispose: (value) => disposed.push(`${value.key}:${value.count}`), + }) + + const first = cache.get("a") + expect(first.count).toBe(1) + + clock = 9 + expect(cache.peek("a")?.count).toBe(1) + + clock = 11 + expect(cache.peek("a")).toBeUndefined() + expect(disposed).toEqual(["a:1"]) + + const second = cache.get("a") + expect(second.count).toBe(2) + expect(disposed).toEqual(["a:1"]) + }) +}) diff --git a/packages/app/src/utils/scoped-cache.ts b/packages/app/src/utils/scoped-cache.ts new file mode 100644 index 0000000000000000000000000000000000000000..224c363c1ebc0aab314390051f49759bcc481f25 --- /dev/null +++ b/packages/app/src/utils/scoped-cache.ts @@ -0,0 +1,104 @@ +type ScopedCacheOptions = { + maxEntries?: number + ttlMs?: number + dispose?: (value: T, key: string) => void + now?: () => number +} + +type Entry = { + value: T + touchedAt: number +} + +export function createScopedCache(createValue: (key: string) => T, options: ScopedCacheOptions = {}) { + const store = new Map>() + const now = options.now ?? Date.now + + const dispose = (key: string, entry: Entry) => { + options.dispose?.(entry.value, key) + } + + const expired = (entry: Entry) => { + if (options.ttlMs === undefined) return false + return now() - entry.touchedAt >= options.ttlMs + } + + const sweep = () => { + if (options.ttlMs === undefined) return + for (const [key, entry] of store) { + if (!expired(entry)) continue + store.delete(key) + dispose(key, entry) + } + } + + const touch = (key: string, entry: Entry) => { + entry.touchedAt = now() + store.delete(key) + store.set(key, entry) + } + + const prune = () => { + if (options.maxEntries === undefined) return + while (store.size > options.maxEntries) { + const key = store.keys().next().value + if (!key) return + const entry = store.get(key) + store.delete(key) + if (!entry) continue + dispose(key, entry) + } + } + + const remove = (key: string) => { + const entry = store.get(key) + if (!entry) return + store.delete(key) + dispose(key, entry) + return entry.value + } + + const peek = (key: string) => { + sweep() + const entry = store.get(key) + if (!entry) return + if (!expired(entry)) return entry.value + store.delete(key) + dispose(key, entry) + } + + const get = (key: string) => { + sweep() + const entry = store.get(key) + if (entry && !expired(entry)) { + touch(key, entry) + return entry.value + } + if (entry) { + store.delete(key) + dispose(key, entry) + } + + const created = { + value: createValue(key), + touchedAt: now(), + } + store.set(key, created) + prune() + return created.value + } + + const clear = () => { + for (const [key, entry] of store) { + dispose(key, entry) + } + store.clear() + } + + return { + get, + peek, + delete: remove, + clear, + } +} diff --git a/packages/app/src/utils/search-keydown.ts b/packages/app/src/utils/search-keydown.ts new file mode 100644 index 0000000000000000000000000000000000000000..1e0b0ea695b7b89a00c4e40a553b13921d7ed206 --- /dev/null +++ b/packages/app/src/utils/search-keydown.ts @@ -0,0 +1,116 @@ +const editableSelector = "input, textarea, select, [contenteditable=''], [contenteditable='true']" + +export function handleDocumentSearchKeydown( + input: HTMLInputElement | undefined, + event: KeyboardEvent, + inputValue: string, + setInputValue: (value: string) => void, +) { + if (!input) return false + if (event.defaultPrevented || event.isComposing) return false + if (event.target === input) return false + if (event.target instanceof Element && event.target.closest(editableSelector)) return false + + const action = searchKeyAction(event) + if (!action) return false + + event.preventDefault() + event.stopPropagation() + input.focus() + + const start = input.selectionStart ?? inputValue.length + const end = input.selectionEnd ?? inputValue.length + + if (action.type === "selectAll") { + input.setSelectionRange(0, inputValue.length) + return true + } + + if (action.type === "move") { + moveSelection(input, inputValue, action.delta, event.shiftKey) + return true + } + + if (action.type === "home") { + setBoundarySelection(input, start, 0, event.shiftKey) + return true + } + + if (action.type === "end") { + setBoundarySelection(input, start, inputValue.length, event.shiftKey) + return true + } + + if (action.type === "deleteBackward") { + if (start !== end) + return updateValue(input, inputValue.slice(0, start) + inputValue.slice(end), start, setInputValue) + if (start === 0) return true + return updateValue(input, inputValue.slice(0, start - 1) + inputValue.slice(end), start - 1, setInputValue) + } + + if (action.type === "deleteForward") { + if (start !== end) + return updateValue(input, inputValue.slice(0, start) + inputValue.slice(end), start, setInputValue) + if (end === inputValue.length) return true + return updateValue(input, inputValue.slice(0, start) + inputValue.slice(end + 1), start, setInputValue) + } + + return updateValue( + input, + inputValue.slice(0, start) + action.value + inputValue.slice(end), + start + action.value.length, + setInputValue, + ) +} + +function searchKeyAction(event: KeyboardEvent) { + if ((event.ctrlKey || event.metaKey) && !event.altKey && event.key.toLowerCase() === "a") { + return { type: "selectAll" } as const + } + if (event.ctrlKey || event.metaKey || event.altKey) return undefined + if (event.key.length === 1) return { type: "insert", value: event.key } as const + if (event.key === "Backspace") return { type: "deleteBackward" } as const + if (event.key === "Delete") return { type: "deleteForward" } as const + if (event.key === "ArrowLeft") return { type: "move", delta: -1 } as const + if (event.key === "ArrowRight") return { type: "move", delta: 1 } as const + if (event.key === "Home") return { type: "home" } as const + if (event.key === "End") return { type: "end" } as const + return undefined +} + +function moveSelection(input: HTMLInputElement, inputValue: string, delta: -1 | 1, extend: boolean) { + const start = input.selectionStart ?? inputValue.length + const end = input.selectionEnd ?? inputValue.length + if (!extend && start !== end) { + const caret = delta < 0 ? start : end + input.setSelectionRange(caret, caret) + return + } + + if (!extend) { + const caret = Math.max(0, Math.min(inputValue.length, start + delta)) + input.setSelectionRange(caret, caret) + return + } + + const backward = input.selectionDirection === "backward" + const anchor = backward ? end : start + const focus = backward ? start : end + const next = Math.max(0, Math.min(inputValue.length, focus + delta)) + input.setSelectionRange(Math.min(anchor, next), Math.max(anchor, next), next < anchor ? "backward" : "forward") +} + +function setBoundarySelection(input: HTMLInputElement, anchor: number, focus: number, extend: boolean) { + if (!extend) { + input.setSelectionRange(focus, focus) + return + } + input.setSelectionRange(Math.min(anchor, focus), Math.max(anchor, focus), focus < anchor ? "backward" : "forward") +} + +function updateValue(input: HTMLInputElement, value: string, caret: number, setInputValue: (value: string) => void) { + input.value = value + setInputValue(value) + input.setSelectionRange(caret, caret) + return true +} diff --git a/packages/app/src/utils/server-compat.test.ts b/packages/app/src/utils/server-compat.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..52e5ec6e3bee60963587a9450432418926556067 --- /dev/null +++ b/packages/app/src/utils/server-compat.test.ts @@ -0,0 +1,236 @@ +import { describe, expect, test } from "bun:test" +import { createApiForServer, createSdkForServer } from "./server" +import { createCompatibleApi } from "./server-compat" + +function setup( + protocol: "v1" | "v2" | Promise<"v1" | "v2">, + responses?: { vcs?: { branch: string; default_branch: string } }, +) { + const requests: Request[] = [] + const fetcher = Object.assign( + async (input: string | URL | Request, init?: RequestInit) => { + const request = new Request(input, init) + requests.push(request) + if (request.method === "PATCH") { + return Response.json({ + id: "ses_1", + slug: "ses_1", + projectID: "project", + directory: "/repo", + title: "Session", + version: "1", + time: { created: 1, updated: 1 }, + }) + } + if (request.method === "POST" && request.url.endsWith("/prompt_async")) + return new Response(undefined, { status: 204 }) + if (request.method === "POST" && request.url.endsWith("/prompt")) { + return Response.json({ + admittedSeq: 1, + id: "msg_1", + sessionID: "ses_1", + timeCreated: 1, + type: "user", + data: { text: "hello" }, + delivery: "steer", + }) + } + if (request.method === "GET" && new URL(request.url).pathname === "/vcs") + return Response.json(responses?.vcs ?? {}) + if (request.method === "GET") return Response.json([]) + return new Response(undefined, { status: 204 }) + }, + { preconnect: globalThis.fetch.preconnect }, + ) + const server = { url: "http://localhost:4096" } + const api = createCompatibleApi({ + protocol: typeof protocol === "string" ? Promise.resolve(protocol) : protocol, + current: createApiForServer({ server, fetch: fetcher }), + legacy: (directory) => createSdkForServer({ server, fetch: fetcher, directory, throwOnError: true }), + directory: "/repo", + }) + return { api, requests } +} + +describe("createCompatibleApi", () => { + /* + test("routes V1 archive through the legacy session update", async () => { + const { api, requests } = setup("v1") + await api.session.archive({ sessionID: "ses_1", directory: "/repo" }) + + const url = new URL(requests[0]!.url) + expect(url.pathname).toBe("/session/ses_1") + expect(requests[0]!.headers.get("x-opencode-directory")).toBe("%2Frepo") + expect(requests[0]!.method).toBe("PATCH") + expect(await requests[0]!.json()).toMatchObject({ time: { archived: expect.any(Number) } }) + }) + */ + + test("converts current prompts to the V1 prompt contract", async () => { + const { api, requests } = setup("v1") + await api.session.prompt({ + sessionID: "ses_1", + id: "msg_1", + text: "hello @src/index.ts", + agent: "build", + model: { providerID: "provider", modelID: "model" }, + files: [ + { uri: "file:///repo/src/index.ts", name: "index.ts", mention: { text: "@src/index.ts", start: 6, end: 19 } }, + { uri: "data:text/plain;base64,aGVsbG8=", name: "notes.txt" }, + ], + }) + + expect(new URL(requests[0]!.url).pathname).toBe("/session/ses_1/prompt_async") + const body = await requests[0]!.json() + expect(body).toMatchObject({ + messageID: "msg_1", + agent: "build", + model: { providerID: "provider", modelID: "model" }, + parts: [ + { type: "text", text: "hello @src/index.ts" }, + { + type: "file", + mime: "text/plain", + url: "file:///repo/src/index.ts", + filename: "index.ts", + source: { + type: "file", + text: { value: "@src/index.ts", start: 6, end: 19 }, + path: "file:///repo/src/index.ts", + }, + }, + { + type: "file", + mime: "text/plain", + url: "data:text/plain;base64,aGVsbG8=", + filename: "notes.txt", + }, + ], + }) + expect(body.parts[2]).not.toHaveProperty("source") + }) + + test("preserves original parts for V1 optimistic reconciliation", async () => { + const { api, requests } = setup("v1") + await api.session.prompt({ + sessionID: "ses_1", + id: "msg_1", + text: "look", + files: [{ uri: "data:image/png;base64,AAAA", name: "image.png" }], + legacyParts: [ + { id: "prt_text", type: "text", text: "look" }, + { id: "prt_image", type: "file", mime: "image/png", url: "data:image/png;base64,AAAA", filename: "image.png" }, + ], + }) + + expect((await requests[0]!.json()).parts).toEqual([ + { id: "prt_text", type: "text", text: "look" }, + { id: "prt_image", type: "file", mime: "image/png", url: "data:image/png;base64,AAAA", filename: "image.png" }, + ]) + }) + + test("resolves protocol detection once across implementation methods", async () => { + let detections = 0 + const resolved = Promise.resolve<"v1" | "v2">("v2") + const protocol = new Proxy(resolved, { + get(target, property) { + if (property !== "then") return Reflect.get(target, property, target) + detections++ + return target.then.bind(target) + }, + }) + const { api } = setup(protocol) + + await api.session.list() + await api.session.list() + + expect(detections).toBe(1) + }) + + /* + test("keeps V2 session actions on the current API", async () => { + const { api, requests } = setup("v2") + await api.session.archive({ sessionID: "ses_1" }) + + expect(new URL(requests[0]!.url).pathname).toBe("/api/session/ses_1/archive") + expect(requests[0]!.method).toBe("POST") + }) + */ + + test("uses the global V1 session search endpoint", async () => { + const { api, requests } = setup("v1") + await api.session.list({ parentID: null, search: "session", limit: 50 }) + + expect(new URL(requests[0]!.url).pathname).toBe("/experimental/session") + }) + + /* + test("projects the V1 default branch", async () => { + const { api } = setup("v1", { vcs: { branch: "feature", default_branch: "dev" } }) + + expect(await api.vcs.get({ location: { directory: "/repo" } })).toMatchObject({ + data: { branch: "feature", defaultBranch: "dev" }, + }) + }) + */ + + test("translates current file searches to the V1 dirs parameter", async () => { + const { api, requests } = setup("v1") + await api.file.find({ location: { directory: "/repo" }, query: "src", type: "file", limit: 20 }) + + const url = new URL(requests[0]!.url) + expect(url.pathname).toBe("/find/file") + expect(url.searchParams.get("dirs")).toBe("false") + expect(url.searchParams.get("limit")).toBe("20") + }) + + test("routes V1 permission replies through the requested directory", async () => { + const { api, requests } = setup("v1") + await api.permission.reply({ + sessionID: "ses_1", + requestID: "permission_1", + reply: "once", + location: { directory: "/other" }, + }) + + expect(new URL(requests[0]!.url).pathname).toBe("/session/ses_1/permissions/permission_1") + expect(new URL(requests[0]!.url).searchParams.get("directory")).toBe("/other") + }) + + test("disposes the V1 instance after connecting a provider", async () => { + const { api, requests } = setup("v1") + + await api.integration.connect.key({ + integrationID: "openrouter", + key: "secret", + location: { directory: "/repo" }, + }) + + expect(requests.map((request) => new URL(request.url).pathname)).toEqual([ + "/auth/openrouter", + "/instance/dispose", + "/instance/dispose", + ]) + expect(requests[1]!.headers.get("x-opencode-directory")).toBe("%2Frepo") + expect(requests[2]!.headers.get("x-opencode-directory")).toBeNull() + }) + + test("disposes the V1 instance after completing provider OAuth", async () => { + const { api, requests } = setup("v1") + + await api.integration.oauth.complete({ + integrationID: "openrouter", + attemptID: "openrouter:0", + code: "code", + location: { directory: "/repo" }, + }) + + expect(requests.map((request) => new URL(request.url).pathname)).toEqual([ + "/provider/openrouter/oauth/callback", + "/instance/dispose", + "/instance/dispose", + ]) + expect(requests[1]!.headers.get("x-opencode-directory")).toBe("%2Frepo") + expect(requests[2]!.headers.get("x-opencode-directory")).toBeNull() + }) +}) diff --git a/packages/app/src/utils/server-compat.ts b/packages/app/src/utils/server-compat.ts new file mode 100644 index 0000000000000000000000000000000000000000..1df1338b71e5d482a2972e9cc6bae1654de885cb --- /dev/null +++ b/packages/app/src/utils/server-compat.ts @@ -0,0 +1,518 @@ +import type { ServerApi } from "./server" +import type { ServerProtocol } from "./server-protocol" +import type { AgentPartInput, FilePartInput, OpencodeClient, Session, TextPartInput } from "@opencode-ai/sdk/v2/client" +import type { + Project, + ProjectCurrent, + SessionApi, + SessionCommandInput, + SessionCommandOutput, + SessionCompactInput, + SessionCompactOutput, + SessionInfo, + SessionPromptInput, + SessionPromptOutput, + SessionShellInput, + SessionShellOutput, +} from "@opencode-ai/client/promise" + +type LegacyClient = OpencodeClient +type LegacyFor = (directory?: string) => LegacyClient +type CompatibleSessionApi = Omit< + SessionApi, + "prompt" | "command" | "shell" | "compact" | "rename" | "archive" | "remove" +> & { + prompt: (input: SessionPromptInput & LegacyPrompt) => Promise + command: (input: SessionCommandInput) => Promise + shell: (input: SessionShellInput & LegacyPrompt) => Promise + compact: (input: SessionCompactInput & { model?: LegacyPrompt["model"] }) => Promise + rename: (input: Parameters[0] & LegacyLocation) => ReturnType + // archive: (input: Parameters[0] & LegacyLocation) => ReturnType + remove: (input: Parameters[0] & LegacyLocation) => ReturnType +} +type CompatiblePermissionApi = Omit & { + reply: ( + input: Parameters[0] & { location?: { directory?: string } }, + ) => ReturnType +} +export type CompatibleApi = Omit & { + readonly session: CompatibleSessionApi + readonly permission: CompatiblePermissionApi +} +type LegacyPrompt = { + agent?: string + model?: { providerID: string; modelID: string } + variant?: string + legacyParts?: (TextPartInput | FilePartInput | AgentPartInput)[] +} +type LegacyLocation = { directory?: string } +type CompatibleInput = { + protocol: Promise + current: ServerApi + legacy: LegacyFor + directory?: string +} + +function mime(uri: string) { + const match = /^data:([^;,]+)/.exec(uri) + return match?.[1] ?? "application/octet-stream" +} + +function sessionInfo(session: Session): SessionInfo { + return { + id: session.id, + parentID: session.parentID, + projectID: session.projectID, + agent: session.agent, + model: session.model && { + id: session.model.id, + providerID: session.model.providerID, + variant: session.model.variant, + }, + cost: session.cost ?? 0, + tokens: session.tokens ?? { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + time: session.time, + title: session.title, + location: { directory: session.directory, workspaceID: session.workspaceID }, + subpath: session.path, + revert: session.revert && { + messageID: session.revert.messageID, + partID: session.revert.partID, + snapshot: session.revert.snapshot, + }, + } +} + +export function createCompatibleApi(input: CompatibleInput): CompatibleApi { + const v1 = createV1Api(input) + return lazyApi( + input.protocol.then((protocol) => (protocol === "v1" ? v1 : input.current)), + input.current, + ) +} + +function lazyApi(implementation: Promise, shape: T): T { + const cache = new Map() + return new Proxy(shape, { + get(target, property, receiver) { + const sample = Reflect.get(target, property, receiver) + if (typeof sample === "function") { + return (...args: unknown[]) => + implementation.then((value) => { + const method = Reflect.get(value, property) + if (typeof method !== "function") throw new Error(`API method unavailable: ${String(property)}`) + return Reflect.apply(method, value, args) + }) + } + if (sample === null || typeof sample !== "object") return sample + if (cache.has(property)) return cache.get(property) + const nested = lazyApi( + implementation.then((value) => { + const result = Reflect.get(value, property) + if (result === null || typeof result !== "object") { + throw new Error(`API namespace unavailable: ${String(property)}`) + } + return result + }), + sample, + ) + cache.set(property, nested) + return nested + }, + }) +} + +function createV1Api(input: CompatibleInput): CompatibleApi { + const directory = (location?: { directory?: string }) => location?.directory ?? input.directory + const legacy = (location?: { directory?: string }) => input.legacy(directory(location)) + const located = (data: T, value?: { directory?: string }) => ({ + location: { + directory: directory(value) ?? "", + project: { id: "", directory: directory(value) ?? "" }, + }, + data, + }) + + return { + ...input.current, + session: { + ...input.current.session, + async list( + value?: Parameters[0], + options?: Parameters[1], + ) { + if (!value?.directory && value?.search !== undefined) { + const result = await legacy().experimental.session.list( + { + roots: value.parentID === null ? true : undefined, + search: value.search, + limit: value.limit, + }, + options, + ) + return { data: (result.data ?? []).map(sessionInfo), cursor: {} } + } + const result = await legacy({ directory: value?.directory }).session.list({ + directory: value?.directory, + roots: value?.parentID === null ? true : undefined, + search: value?.search, + limit: value?.limit, + }) + return { data: (result.data ?? []).map(sessionInfo), cursor: {} } + }, + async create(value?: Parameters[0]) { + const result = await legacy(value?.location ?? undefined).session.create({ + directory: directory(value?.location ?? undefined), + }) + if (!result.data) throw new Error("Failed to create session") + return sessionInfo(result.data) + }, + async get(value: Parameters[0]) { + const result = await legacy().session.get(value) + if (!result.data) throw new Error(`Session not found: ${value.sessionID}`) + return sessionInfo(result.data) + }, + async active() { + const result = await legacy().session.status() + return Object.fromEntries( + Object.entries(result.data ?? {}).flatMap(([sessionID, status]) => + status.type === "idle" ? [] : [[sessionID, { type: "running" as const }]], + ), + ) + }, + async rename(value: Parameters[0] & LegacyLocation) { + await legacy(value).session.update({ sessionID: value.sessionID, title: value.title }) + }, + // async archive(value: Parameters[0] & LegacyLocation) { + // await legacy(value).session.update({ sessionID: value.sessionID, time: { archived: Date.now() } }) + // }, + async remove(value: Parameters[0] & LegacyLocation) { + await legacy(value).session.delete(value) + }, + async fork(value: Parameters[0]) { + const result = await legacy().session.fork(value) + if (!result.data) throw new Error("Failed to fork session") + return sessionInfo(result.data) + }, + async interrupt(value: Parameters[0]) { + await legacy().session.abort(value) + }, + async prompt(value: SessionPromptInput & LegacyPrompt) { + await legacy().session.promptAsync({ + sessionID: value.sessionID, + messageID: value.id ?? undefined, + agent: value.agent, + model: value.model, + variant: value.variant, + parts: value.legacyParts ?? [ + { type: "text", text: value.text }, + ...(value.files ?? []).map((file) => ({ + type: "file" as const, + mime: file.mention ? "text/plain" : mime(file.uri), + url: file.uri, + filename: file.name, + source: file.mention + ? { + type: "file" as const, + text: { value: file.mention.text, start: file.mention.start, end: file.mention.end }, + path: file.uri, + } + : undefined, + })), + ...(value.agents ?? []).map((agent) => ({ + type: "agent" as const, + name: agent.name, + source: agent.mention + ? { value: agent.mention.text, start: agent.mention.start, end: agent.mention.end } + : undefined, + })), + ], + }) + return { + admittedSeq: 0, + id: value.id ?? "", + sessionID: value.sessionID, + timeCreated: Date.now(), + type: "user", + data: { text: value.text }, + delivery: value.delivery ?? "steer", + } + }, + async command(value: SessionCommandInput) { + await legacy().session.command({ + sessionID: value.sessionID, + messageID: value.id ?? undefined, + command: value.command, + arguments: value.arguments ?? "", + agent: value.agent ?? undefined, + model: value.model ? `${value.model.providerID}/${value.model.id}` : undefined, + variant: value.model?.variant, + parts: value.files?.map((file) => ({ + type: "file" as const, + mime: mime(file.uri), + url: file.uri, + filename: file.name, + })), + }) + return { + admittedSeq: 0, + id: value.id ?? "", + sessionID: value.sessionID, + timeCreated: Date.now(), + type: "user", + data: { text: `/${value.command} ${value.arguments ?? ""}`.trim() }, + delivery: value.delivery ?? "steer", + } + }, + async shell(value: SessionShellInput & LegacyPrompt) { + await legacy().session.shell({ + sessionID: value.sessionID, + command: value.command, + agent: value.agent, + model: value.model, + }) + }, + compact: async (value: SessionCompactInput & { model?: LegacyPrompt["model"] }) => { + if (!value.model) throw new Error("A model is required to compact a V1 session") + await legacy().session.summarize({ + sessionID: value.sessionID, + providerID: value.model.providerID, + modelID: value.model.modelID, + }) + return { + admittedSeq: 0, + id: value.id ?? "", + sessionID: value.sessionID, + timeCreated: Date.now(), + type: "compaction", + } + }, + revert: { + stage: async (value: Parameters[0]) => { + await legacy().session.revert(value) + return { messageID: value.messageID } + }, + clear: async (value: Parameters[0]) => { + await legacy().session.unrevert(value) + }, + commit: input.current.session.revert.commit, + }, + }, + project: { + ...input.current.project, + async list() { + return ((await legacy().project.list()).data ?? []) as Project[] + }, + async current(value?: Parameters[0]) { + const result = await legacy(value?.location).project.current() + if (!result.data) throw new Error("Project not found") + return { id: result.data.id, directory: result.data.worktree } satisfies ProjectCurrent + }, + // async update(value: Parameters[0]) { + // const project = (await legacy().project.list()).data?.find((item) => item.id === value.projectID) + // const result = await legacy({ directory: project?.worktree }).project.update({ + // ...value, + // directory: project?.worktree, + // }) + // if (!result.data) throw new Error(`Project not found: ${value.projectID}`) + // return result.data as Project + // }, + async directories(value: Parameters[0]) { + const result = await legacy(value.location).worktree.list() + return (result.data ?? []).map((item) => ({ directory: item })) + }, + }, + // path: { + // ...input.current.path, + // async get(value?: Parameters[0]) { + // const result = await legacy(value?.location).path.get() + // if (!result.data) throw new Error("Path unavailable") + // return result.data + // }, + // }, + vcs: { + ...input.current.vcs, + // async get(value?: Parameters[0]) { + // const result = await legacy(value?.location).vcs.get() + // return located({ branch: result.data?.branch, defaultBranch: result.data?.default_branch }, value?.location) + // }, + async status(value?: Parameters[0]) { + const result = await legacy(value?.location).vcs.status() + return located(result.data ?? [], value?.location) + }, + async diff(value: Parameters[0]) { + const result = await legacy(value.location).vcs.diff({ + mode: value.mode === "working" ? "git" : value.mode, + context: value.context, + }) + return located( + (result.data ?? []).map((file) => ({ + file: file.file, + patch: file.patch ?? "", + additions: file.additions, + deletions: file.deletions, + status: file.status ?? "modified", + })), + value.location, + ) + }, + }, + file: { + ...input.current.file, + async list(value?: Parameters[0]) { + const result = await legacy(value?.location).file.list({ path: value?.path ?? "" }) + return located(result.data ?? [], value?.location) + }, + async find(value: Parameters[0]) { + const result = await legacy(value.location).find.files({ + query: value.query, + dirs: value.type === undefined ? undefined : value.type === "directory" ? "true" : "false", + limit: value.limit, + }) + return located( + (result.data ?? []).map((path) => ({ path, type: value.type ?? "file" })), + value.location, + ) + }, + }, + integration: { + ...input.current.integration, + async get(value: Parameters[0]) { + const methods = ((await legacy(value.location).provider.auth()).data?.[value.integrationID] ?? []).map( + (method, index) => + method.type === "api" + ? { type: "key" as const, label: method.label } + : { type: "oauth" as const, id: String(index), label: method.label, prompts: method.prompts }, + ) + return located( + { + id: value.integrationID, + name: value.integrationID, + methods, + connections: [], + }, + value.location, + ) + }, + connect: { + ...input.current.integration.connect, + key: async (value: Parameters[0]) => { + await legacy(value.location).auth.set({ + providerID: value.integrationID, + auth: { type: "api", key: value.key }, + }) + await legacy(value.location).instance.dispose() + await input.legacy().instance.dispose() + }, + }, + oauth: { + ...input.current.integration.oauth, + connect: async (value: Parameters[0]) => { + const method = Number(value.methodID) + const result = await legacy(value.location).provider.oauth.authorize( + { providerID: value.integrationID, method, inputs: value.inputs }, + { throwOnError: true }, + ) + if (!result.data) throw new Error("Failed to start OAuth authorization") + return located( + { + attemptID: `${value.integrationID}:${method}`, + url: result.data.url, + instructions: result.data.instructions, + mode: result.data.method, + time: { created: Date.now(), expires: Date.now() + 10 * 60 * 1000 }, + }, + value.location, + ) + }, + complete: async (value: Parameters[0]) => { + const method = Number(value.attemptID.split(":").at(-1)) + await legacy(value.location).provider.oauth.callback( + { providerID: value.integrationID, method, code: value.code }, + { throwOnError: true }, + ) + await legacy(value.location).instance.dispose() + await input.legacy().instance.dispose() + }, + status: async (value: Parameters[0]) => { + const method = Number(value.attemptID.split(":").at(-1)) + await legacy(value.location).provider.oauth.callback( + { providerID: value.integrationID, method }, + { throwOnError: true }, + ) + await legacy(value.location).instance.dispose() + await input.legacy().instance.dispose() + return located( + { status: "complete" as const, time: { created: Date.now(), expires: Date.now() } }, + value.location, + ) + }, + }, + }, + pty: { + ...input.current.pty, + // async shells(value?: Parameters[0]) { + // return located((await legacy(value?.location).pty.shells()).data ?? [], value?.location) + // }, + async list(value?: Parameters[0]) { + return located((await legacy(value?.location).pty.list()).data ?? [], value?.location) + }, + async create(value?: Parameters[0]) { + const result = await legacy(value?.location).pty.create({ + command: value?.command, + args: value?.args ? [...value.args] : undefined, + cwd: value?.cwd, + title: value?.title, + env: value?.env, + }) + if (!result.data) throw new Error("Failed to create terminal") + return located(result.data, value?.location) + }, + async get(value: Parameters[0]) { + const result = await legacy(value.location).pty.get({ ptyID: value.ptyID }) + if (!result.data) throw new Error(`Terminal not found: ${value.ptyID}`) + return located(result.data, value.location) + }, + async update(value: Parameters[0]) { + const result = await legacy(value.location).pty.update({ + ptyID: value.ptyID, + title: value.title, + size: value.size, + }) + if (!result.data) throw new Error(`Terminal not found: ${value.ptyID}`) + return located(result.data, value.location) + }, + async remove(value: Parameters[0]) { + await legacy(value.location).pty.remove({ ptyID: value.ptyID }) + }, + // async connectToken(value: Parameters[0]) { + // const result = await legacy(value.location).pty.connectToken({ ptyID: value.ptyID }) + // if (!result.data) throw new Error(`Failed to connect terminal: ${value.ptyID}`) + // return located(result.data, value.location) + // }, + }, + permission: { + ...input.current.permission, + async reply(value: Parameters[0] & { location?: { directory?: string } }) { + await legacy(value.location).permission.respond({ + sessionID: value.sessionID, + permissionID: value.requestID, + response: value.reply, + directory: directory(value.location), + }) + }, + }, + question: { + ...input.current.question, + async reply(value: Parameters[0]) { + await legacy().question.reply({ + requestID: value.requestID, + answers: value.answers.map((answer) => [...answer]), + }) + }, + async reject(value: Parameters[0]) { + await legacy().question.reject({ requestID: value.requestID }) + }, + }, + } +} diff --git a/packages/app/src/utils/server-errors.test.ts b/packages/app/src/utils/server-errors.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..9c735fe6891bb9c7d8afc0933e5d7bc2d3ca2b04 --- /dev/null +++ b/packages/app/src/utils/server-errors.test.ts @@ -0,0 +1,175 @@ +import { describe, expect, test } from "bun:test" +import type { SessionNotFoundError } from "@opencode-ai/sdk/v2/client" +import type { ConfigInvalidError, ProviderModelNotFoundError } from "./server-errors" +import { formatServerError, isSessionNotFoundError, parseReadableConfigInvalidError } from "./server-errors" + +function fill(text: string, vars?: Record) { + if (!vars) return text + return text.replace(/{{\s*(\w+)\s*}}/g, (_, key: string) => { + const value = vars[key] + if (value === undefined) return "" + return String(value) + }) +} + +function useLanguageMock() { + const dict: Record = { + "error.chain.unknown": "Erro desconhecido", + "error.chain.configInvalid": "Arquivo de config em {{path}} invalido", + "error.chain.configInvalidWithMessage": "Arquivo de config em {{path}} invalido: {{message}}", + "error.chain.modelNotFound": "Modelo nao encontrado: {{provider}}/{{model}}", + "error.chain.didYouMean": "Voce quis dizer: {{suggestions}}", + "error.chain.checkConfig": "Revise provider/model no config", + } + return { + t(key: string, vars?: Record) { + const text = dict[key] + if (!text) return key + return fill(text, vars) + }, + } +} + +const language = useLanguageMock() + +describe("parseReadableConfigInvalidError", () => { + test("formats issues with file path", () => { + const error = { + name: "ConfigInvalidError", + data: { + path: "opencode.config.ts", + issues: [ + { path: ["settings", "host"], message: "Required" }, + { path: ["mode"], message: "Invalid" }, + ], + }, + } satisfies ConfigInvalidError + + const result = parseReadableConfigInvalidError(error, language.t) + + expect(result).toBe( + ["Arquivo de config em opencode.config.ts invalido: settings.host: Required", "mode: Invalid"].join("\n"), + ) + }) + + test("uses trimmed message when issues are missing", () => { + const error = { + name: "ConfigInvalidError", + data: { + path: "config", + message: " Bad value ", + }, + } satisfies ConfigInvalidError + + const result = parseReadableConfigInvalidError(error, language.t) + + expect(result).toBe("Arquivo de config em config invalido: Bad value") + }) +}) + +describe("formatServerError", () => { + test("formats config invalid errors", () => { + const error = { + name: "ConfigInvalidError", + data: { + message: "Missing host", + }, + } satisfies ConfigInvalidError + + const result = formatServerError(error, language.t) + + expect(result).toBe("Arquivo de config em config invalido: Missing host") + }) + + test("returns error messages", () => { + expect(formatServerError(new Error("Request failed with status 503"), language.t)).toBe( + "Request failed with status 503", + ) + }) + + test("returns provided string errors", () => { + expect(formatServerError("Failed to connect to server", language.t)).toBe("Failed to connect to server") + }) + + test("uses translated unknown fallback", () => { + expect(formatServerError(0, language.t)).toBe("Erro desconhecido") + }) + + test("falls back for unknown error objects and names", () => { + expect(formatServerError({ name: "ServerTimeoutError", data: { seconds: 30 } }, language.t)).toBe( + "Erro desconhecido", + ) + }) + + test("formats provider model errors using provider/model", () => { + const error = { + name: "ProviderModelNotFoundError", + data: { + providerID: "openai", + modelID: "gpt-4.1", + }, + } satisfies ProviderModelNotFoundError + + expect(formatServerError(error, language.t)).toBe( + ["Modelo nao encontrado: openai/gpt-4.1", "Revise provider/model no config"].join("\n"), + ) + }) + + test("formats provider model suggestions", () => { + const error = { + name: "ProviderModelNotFoundError", + data: { + providerID: "x", + modelID: "y", + suggestions: ["x/y2", "x/y3"], + }, + } satisfies ProviderModelNotFoundError + + expect(formatServerError(error, language.t)).toBe( + ["Modelo nao encontrado: x/y", "Voce quis dizer: x/y2, x/y3", "Revise provider/model no config"].join("\n"), + ) + }) + + test("unwraps SDK-wrapped errors from cause.body", () => { + const body = { + name: "ConfigInvalidError", + data: { + message: "Missing host", + }, + } satisfies ConfigInvalidError + + const wrapped = new Error("ConfigInvalidError", { cause: { body, status: 400 } }) + + expect(formatServerError(wrapped, language.t)).toBe("Arquivo de config em config invalido: Missing host") + }) +}) + +describe("isSessionNotFoundError", () => { + test("matches an SDK-wrapped error for the requested session", () => { + const body = { + _tag: "SessionNotFoundError", + sessionID: "ses_missing", + message: "Session not found", + } satisfies SessionNotFoundError + + expect(isSessionNotFoundError(new Error(body.message, { cause: { body, status: 404 } }), body.sessionID)).toBe(true) + }) + + test("rejects errors for other sessions and other 404 responses", () => { + const body = { + _tag: "SessionNotFoundError", + sessionID: "ses_parent", + message: "Session not found", + } satisfies SessionNotFoundError + + expect(isSessionNotFoundError(new Error(body.message, { cause: { body, status: 404 } }), "ses_tab")).toBe(false) + expect( + isSessionNotFoundError( + new Error("Provider not found", { + cause: { body: { _tag: "ProviderNotFoundError", providerID: "missing" }, status: 404 }, + }), + "ses_tab", + ), + ).toBe(false) + }) +}) diff --git a/packages/app/src/utils/server-errors.ts b/packages/app/src/utils/server-errors.ts new file mode 100644 index 0000000000000000000000000000000000000000..b34ae609ae2f43318e8c4ade07ebedd1b4e82a71 --- /dev/null +++ b/packages/app/src/utils/server-errors.ts @@ -0,0 +1,109 @@ +export type ConfigInvalidError = { + name: "ConfigInvalidError" + data: { + path?: string + message?: string + issues?: Array<{ message: string; path: string[] }> + } +} + +export type ProviderModelNotFoundError = { + name: "ProviderModelNotFoundError" + data: { + providerID: string + modelID: string + suggestions?: string[] + } +} + +type Translator = (key: string, vars?: Record) => string + +function tr(translator: Translator | undefined, key: string, text: string, vars?: Record) { + if (!translator) return text + const out = translator(key, vars) + if (!out || out === key) return text + return out +} + +export function formatServerError(error: unknown, translate?: Translator, fallback?: string) { + const unwrapped = unwrapNamedError(error) + if (isConfigInvalidErrorLike(unwrapped)) return parseReadableConfigInvalidError(unwrapped, translate) + if (isProviderModelNotFoundErrorLike(unwrapped)) return parseReadableProviderModelNotFoundError(unwrapped, translate) + if (error instanceof Error && error.message) return error.message + if (typeof error === "string" && error) return error + if (fallback) return fallback + return tr(translate, "error.chain.unknown", "Unknown error") +} + +function unwrapNamedError(error: unknown): unknown { + if (error instanceof Error && error.cause && typeof error.cause === "object" && "body" in error.cause) { + return (error.cause as Record).body + } + return error +} + +// Client-synthesized session not-found errors share one constructor and +// predicate so the message contract cannot drift between the sync store +// (server-session.ts), the route lineage (session-lineage.ts), and the +// not-found fallback matching (session.tsx). +const sessionNotFoundMessage = (sessionID: string) => `Session not found: ${sessionID}` + +export function sessionNotFoundError(sessionID: string) { + return new Error(sessionNotFoundMessage(sessionID)) +} + +export function isLocalSessionNotFoundError(error: unknown, sessionID: string) { + return error instanceof Error && error.message === sessionNotFoundMessage(sessionID) +} + +export function isSessionNotFoundError(error: unknown, sessionID: string) { + const unwrapped = unwrapNamedError(error) + if (typeof unwrapped !== "object" || unwrapped === null) return false + const value = unwrapped as Record + return value._tag === "SessionNotFoundError" && value.sessionID === sessionID +} + +function isConfigInvalidErrorLike(error: unknown): error is ConfigInvalidError { + if (typeof error !== "object" || error === null) return false + const o = error as Record + return o.name === "ConfigInvalidError" && typeof o.data === "object" && o.data !== null +} + +function isProviderModelNotFoundErrorLike(error: unknown): error is ProviderModelNotFoundError { + if (typeof error !== "object" || error === null) return false + const o = error as Record + return o.name === "ProviderModelNotFoundError" && typeof o.data === "object" && o.data !== null +} + +export function parseReadableConfigInvalidError(errorInput: ConfigInvalidError, translator?: Translator) { + const file = errorInput.data.path && errorInput.data.path !== "config" ? errorInput.data.path : "config" + const detail = errorInput.data.message?.trim() ?? "" + const issues = (errorInput.data.issues ?? []) + .map((issue) => { + const msg = issue.message.trim() + if (!issue.path.length) return msg + return `${issue.path.join(".")}: ${msg}` + }) + .filter(Boolean) + const msg = issues.length ? issues.join("\n") : detail + if (!msg) return tr(translator, "error.chain.configInvalid", `Config file at ${file} is invalid`, { path: file }) + return tr(translator, "error.chain.configInvalidWithMessage", `Config file at ${file} is invalid: ${msg}`, { + path: file, + message: msg, + }) +} + +function parseReadableProviderModelNotFoundError(errorInput: ProviderModelNotFoundError, translator?: Translator) { + const p = errorInput.data.providerID.trim() + const m = errorInput.data.modelID.trim() + const list = (errorInput.data.suggestions ?? []).map((v) => v.trim()).filter(Boolean) + const body = tr(translator, "error.chain.modelNotFound", `Model not found: ${p}/${m}`, { provider: p, model: m }) + const tail = tr(translator, "error.chain.checkConfig", "Check your config (opencode.json) provider/model names") + if (list.length) { + const suggestions = list.slice(0, 5).join(", ") + return [body, tr(translator, "error.chain.didYouMean", `Did you mean: ${suggestions}`, { suggestions }), tail].join( + "\n", + ) + } + return [body, tail].join("\n") +} diff --git a/packages/app/src/utils/server-health.test.ts b/packages/app/src/utils/server-health.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..69a8c7b3be2b140ed3e48055d60b507b59d8ef75 --- /dev/null +++ b/packages/app/src/utils/server-health.test.ts @@ -0,0 +1,178 @@ +import { describe, expect, test } from "bun:test" +import type { ServerConnection } from "@/context/server" +import { checkServerHealth } from "./server-health" + +const server: ServerConnection.HttpBase = { + url: "http://localhost:4096", +} + +function abortFromInput(input: RequestInfo | URL, init?: RequestInit) { + if (init?.signal) return init.signal + if (input instanceof Request) return input.signal + return undefined +} + +describe("checkServerHealth", () => { + test("returns healthy response with version", async () => { + let request: URL | undefined + const fetch = (async (input: RequestInfo | URL) => { + request = input instanceof URL ? input : new URL(input instanceof Request ? input.url : input) + return new Response(JSON.stringify({ healthy: true, version: "1.2.3" }), { + status: 200, + headers: { "content-type": "application/json" }, + }) + }) as unknown as typeof globalThis.fetch + + const result = await checkServerHealth(server, fetch) + + expect(result).toEqual({ healthy: true, version: "1.2.3" }) + expect(request?.pathname).toBe("/api/health") + }) + + test("falls back to the V1 health endpoint", async () => { + const paths: string[] = [] + const fetch = (async (input: RequestInfo | URL) => { + const url = input instanceof URL ? input : new URL(input instanceof Request ? input.url : input) + paths.push(url.pathname) + if (url.pathname === "/api/health") return new Response(undefined, { status: 404 }) + return Response.json({ healthy: true, version: "1.18.4" }) + }) as unknown as typeof globalThis.fetch + + expect(await checkServerHealth(server, fetch)).toEqual({ healthy: true, version: "1.18.4" }) + expect(paths).toEqual(["/api/health", "/global/health"]) + }) + + test("falls back when the current health response is malformed", async () => { + const paths: string[] = [] + const fetch = (async (input: RequestInfo | URL) => { + const url = input instanceof URL ? input : new URL(input instanceof Request ? input.url : input) + paths.push(url.pathname) + if (url.pathname === "/api/health") return Response.json({}) + return Response.json({ healthy: true, version: "1.18.4" }) + }) as unknown as typeof globalThis.fetch + + expect(await checkServerHealth(server, fetch)).toEqual({ healthy: true, version: "1.18.4" }) + expect(paths).toEqual(["/api/health", "/global/health"]) + }) + + test("allows slow servers thirty seconds by default", async () => { + const timeout = Object.getOwnPropertyDescriptor(AbortSignal, "timeout") + let timeoutMs = 0 + Object.defineProperty(AbortSignal, "timeout", { + configurable: true, + value: (ms: number) => { + timeoutMs = ms + return new AbortController().signal + }, + }) + + const fetch = (async () => + new Response(JSON.stringify({ healthy: true, version: "1.2.3" }), { + status: 200, + headers: { "content-type": "application/json" }, + })) as unknown as typeof globalThis.fetch + + await checkServerHealth(server, fetch).finally(() => { + if (timeout) Object.defineProperty(AbortSignal, "timeout", timeout) + if (!timeout) Reflect.deleteProperty(AbortSignal, "timeout") + }) + + expect(timeoutMs).toBe(30_000) + }) + + test("returns unhealthy when request fails", async () => { + const fetch = (async () => { + throw new Error("network") + }) as unknown as typeof globalThis.fetch + + const result = await checkServerHealth(server, fetch) + + expect(result).toEqual({ healthy: false }) + }) + + test("uses timeout fallback when AbortSignal.timeout is unavailable", async () => { + const timeout = Object.getOwnPropertyDescriptor(AbortSignal, "timeout") + Object.defineProperty(AbortSignal, "timeout", { + configurable: true, + value: undefined, + }) + + let aborted = false + const fetch = ((input: RequestInfo | URL, init?: RequestInit) => + new Promise((_resolve, reject) => { + const signal = abortFromInput(input, init) + signal?.addEventListener( + "abort", + () => { + aborted = true + reject(new DOMException("Aborted", "AbortError")) + }, + { once: true }, + ) + })) as unknown as typeof globalThis.fetch + + const result = await checkServerHealth(server, fetch, { + timeoutMs: 10, + }).finally(() => { + if (timeout) Object.defineProperty(AbortSignal, "timeout", timeout) + if (!timeout) Reflect.deleteProperty(AbortSignal, "timeout") + }) + + expect(aborted).toBe(true) + expect(result).toEqual({ healthy: false }) + }) + + test("uses provided abort signal", async () => { + let signal: AbortSignal | undefined + const fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + signal = abortFromInput(input, init) + return new Response(JSON.stringify({ healthy: true, version: "1.2.3" }), { + status: 200, + headers: { "content-type": "application/json" }, + }) + }) as unknown as typeof globalThis.fetch + + const abort = new AbortController() + await checkServerHealth(server, fetch, { + signal: abort.signal, + }) + + expect(signal).toBe(abort.signal) + }) + + test("retries transient failures and eventually succeeds", async () => { + let count = 0 + const fetch = (async () => { + count += 1 + if (count < 3) throw new TypeError("network") + return new Response(JSON.stringify({ healthy: true, version: "1.2.3" }), { + status: 200, + headers: { "content-type": "application/json" }, + }) + }) as unknown as typeof globalThis.fetch + + const result = await checkServerHealth(server, fetch, { + retryCount: 2, + retryDelayMs: 1, + }) + + expect(count).toBe(3) + expect(result).toEqual({ healthy: true, version: "1.2.3" }) + }) + + test("returns unhealthy when retries are exhausted", async () => { + let count = 0 + const fetch = (async () => { + count += 1 + throw new TypeError("network") + }) as unknown as typeof globalThis.fetch + + const result = await checkServerHealth(server, fetch, { + retryCount: 2, + retryDelayMs: 1, + }) + + expect(count).toBe(6) + expect(result).toEqual({ healthy: false }) + }) +}) diff --git a/packages/app/src/utils/server-health.ts b/packages/app/src/utils/server-health.ts new file mode 100644 index 0000000000000000000000000000000000000000..1d7d9e4b2ea607670aa48119eea6ee1866eec097 --- /dev/null +++ b/packages/app/src/utils/server-health.ts @@ -0,0 +1,172 @@ +import { usePlatform } from "@/context/platform" +import { ServerConnection } from "@/context/server" +import { authTokenFromCredentials, createSdkForServer } from "./server" +import { ClientError, OpenCode } from "@opencode-ai/client" +import { Accessor, createEffect, onCleanup } from "solid-js" +import { createStore, reconcile } from "solid-js/store" + +export type ServerHealth = { healthy: boolean; version?: string } + +interface CheckServerHealthOptions { + timeoutMs?: number + signal?: AbortSignal + retryCount?: number + retryDelayMs?: number +} + +const defaultTimeoutMs = 30_000 +const defaultRetryCount = 2 +const defaultRetryDelayMs = 100 +const cacheMs = 750 +const healthCache = new Map< + string, + { at: number; done: boolean; fetch: typeof globalThis.fetch; promise: Promise } +>() + +function cacheKey(server: ServerConnection.HttpBase) { + return `${server.url}\n${server.username ?? ""}\n${server.password ?? ""}` +} + +function timeoutSignal(timeoutMs: number) { + const timeout = (AbortSignal as unknown as { timeout?: (ms: number) => AbortSignal }).timeout + if (timeout) { + try { + return { + signal: timeout.call(AbortSignal, timeoutMs), + clear: undefined as (() => void) | undefined, + } + } catch {} + } + const controller = new AbortController() + const timer = setTimeout(() => controller.abort(), timeoutMs) + return { signal: controller.signal, clear: () => clearTimeout(timer) } +} + +function wait(ms: number, signal?: AbortSignal) { + return new Promise((resolve, reject) => { + if (signal?.aborted) { + reject(new DOMException("Aborted", "AbortError")) + return + } + const timer = setTimeout(() => { + signal?.removeEventListener("abort", onAbort) + resolve() + }, ms) + const onAbort = () => { + clearTimeout(timer) + reject(new DOMException("Aborted", "AbortError")) + } + signal?.addEventListener("abort", onAbort, { once: true }) + }) +} + +function retryable(error: unknown, signal?: AbortSignal) { + if (signal?.aborted) return false + if (error instanceof ClientError) return error.reason === "Transport" + if (!(error instanceof Error)) return false + if (error.name === "AbortError" || error.name === "TimeoutError") return false + if (error instanceof TypeError) return true + return /network|fetch|econnreset|econnrefused|enotfound|timedout/i.test(error.message) +} + +export async function checkServerHealth( + server: ServerConnection.HttpBase, + fetch: typeof globalThis.fetch, + opts?: CheckServerHealthOptions, +): Promise { + const timeout = opts?.signal ? undefined : timeoutSignal(opts?.timeoutMs ?? defaultTimeoutMs) + const signal = opts?.signal ?? timeout?.signal + const retryCount = opts?.retryCount ?? defaultRetryCount + const retryDelayMs = opts?.retryDelayMs ?? defaultRetryDelayMs + const next = (count: number, error: unknown) => { + if (count >= retryCount || !retryable(error, signal)) return Promise.resolve({ healthy: false } as const) + return wait(retryDelayMs * (count + 1), signal) + .then(() => attempt(count + 1)) + .catch(() => ({ healthy: false })) + } + const attempt = async (count: number): Promise => { + const current = await OpenCode.make({ + baseUrl: server.url, + fetch, + headers: server.password + ? { + Authorization: `Basic ${authTokenFromCredentials({ username: server.username, password: server.password })}`, + } + : undefined, + }) + .health.get({ signal }) + .then((x) => + typeof x.healthy === "boolean" + ? { data: { healthy: x.healthy, version: x.version } } + : { error: new Error("Invalid health response") }, + ) + .catch((error) => ({ error })) + if ("data" in current && current.data) return current.data + if (signal?.aborted) return { healthy: false } + + return createSdkForServer({ server, fetch, signal }) + .global.health() + .then((x) => (x.error ? next(count, x.error) : { healthy: x.data?.healthy === true, version: x.data?.version })) + .catch((error) => next(count, error)) + } + return attempt(0).finally(() => timeout?.clear?.()) +} + +const pollMs = 10_000 + +export function useCheckServerHealth() { + const platform = usePlatform() + const fetcher = platform.fetch ?? globalThis.fetch + + return (http: ServerConnection.HttpBase) => { + const key = cacheKey(http) + const hit = healthCache.get(key) + const now = Date.now() + if (hit && hit.fetch === fetcher && (!hit.done || now - hit.at < cacheMs)) return hit.promise + const promise = checkServerHealth(http, fetcher).finally(() => { + const next = healthCache.get(key) + if (!next || next.promise !== promise) return + next.done = true + next.at = Date.now() + }) + healthCache.set(key, { at: now, done: false, fetch: fetcher, promise }) + return promise + } +} + +export const useServerHealth = (servers: Accessor, enabled: Accessor) => { + const checkServerHealth = useCheckServerHealth() + const [status, setStatus] = createStore({} as Record) + + createEffect(() => { + if (!enabled()) { + setStatus(reconcile({})) + return + } + const list = servers() + let dead = false + + const refresh = async () => { + const results: Record = {} + await Promise.all( + list.map(async (conn) => { + const key = ServerConnection.key(conn) + const result = await checkServerHealth(conn.http) + results[key] = result + if (!dead) setStatus(key, result) + }), + ) + if (dead) return + setStatus(reconcile(results)) + } + + void refresh() + const id = setInterval(() => void refresh(), pollMs) + onCleanup(() => { + dead = true + clearInterval(id) + }) + }) + + return status +} diff --git a/packages/app/src/utils/server-protocol.test.ts b/packages/app/src/utils/server-protocol.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..2130a968c4bc4ef9172d2816efad24b997c04512 --- /dev/null +++ b/packages/app/src/utils/server-protocol.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, test } from "bun:test" +import { detectServerProtocol } from "./server-protocol" + +const server = { url: "http://localhost:4096" } +const json = (value: unknown, status = 200) => + new Response(JSON.stringify(value), { status, headers: { "content-type": "application/json" } }) +const mockFetch = (run: (input: string | URL | Request) => Promise) => + Object.assign(run, { preconnect: globalThis.fetch.preconnect }) + +describe("detectServerProtocol", () => { + test("prefers the legacy health endpoint when both API generations exist", async () => { + const fetcher = mockFetch((input) => { + const path = new URL(input instanceof Request ? input.url : input).pathname + if (path === "/global/health") return Promise.resolve(json({ healthy: true, version: "1.18.4" })) + return Promise.resolve(json({ healthy: true, version: "2.0.0", pid: 123 })) + }) + + expect(await detectServerProtocol(server, fetcher)).toBe("v1") + }) + + test("recognizes V2 health by its process identifier", async () => { + const fetcher = mockFetch((input) => { + const path = new URL(input instanceof Request ? input.url : input).pathname + if (path === "/global/health") return Promise.resolve(json({}, 404)) + return Promise.resolve(json({ healthy: true, version: "2.0.0", pid: 123 })) + }) + + expect(await detectServerProtocol(server, fetcher)).toBe("v2") + }) + + test("recognizes the transitional V1 API health response", async () => { + const fetcher = mockFetch((input) => { + const path = new URL(input instanceof Request ? input.url : input).pathname + if (path === "/global/health") return Promise.resolve(json({}, 404)) + return Promise.resolve(json({ healthy: true })) + }) + + expect(await detectServerProtocol(server, fetcher)).toBe("v1") + }) +}) diff --git a/packages/app/src/utils/server-protocol.ts b/packages/app/src/utils/server-protocol.ts new file mode 100644 index 0000000000000000000000000000000000000000..27b8dc208eac32ce6a6d26bee89c3d1094b58917 --- /dev/null +++ b/packages/app/src/utils/server-protocol.ts @@ -0,0 +1,35 @@ +import type { ServerConnection } from "@/context/server" +import { authTokenFromCredentials } from "./server" + +export type ServerProtocol = "v1" | "v2" + +function headers(server: ServerConnection.HttpBase) { + if (!server.password) return + return { + Authorization: `Basic ${authTokenFromCredentials({ username: server.username, password: server.password })}`, + } +} + +async function probe(server: ServerConnection.HttpBase, fetch: typeof globalThis.fetch, path: string) { + const response = await fetch(new URL(path, server.url), { + headers: headers(server), + signal: AbortSignal.timeout(5_000), + }) + if (!response.ok || !response.headers.get("content-type")?.includes("application/json")) return + const value: unknown = await response.json() + if (!value || typeof value !== "object") return + return value +} + +export async function detectServerProtocol( + server: ServerConnection.HttpBase, + fetch: typeof globalThis.fetch, +): Promise { + const legacy = await probe(server, fetch, "/global/health").catch(() => undefined) + if (legacy && "healthy" in legacy && legacy.healthy === true) return "v1" + + const current = await probe(server, fetch, "/api/health").catch(() => undefined) + if (current && "pid" in current && typeof current.pid === "number") return "v2" + if (current && "healthy" in current && current.healthy === true) return "v1" + return "v2" +} diff --git a/packages/app/src/utils/server-scope.test.ts b/packages/app/src/utils/server-scope.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..67d43b7abbb6e8ae95d5e4c62baef2591b5d6c18 --- /dev/null +++ b/packages/app/src/utils/server-scope.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, test } from "bun:test" +import { ScopedKey, ServerScope, SessionRouteKey, SessionStateKey, migrateLegacySessionStateKeys } from "./server-scope" + +describe("ServerScope", () => { + test("uses a stable local scope for the canonical sidecar", () => { + expect(String(ServerScope.fromServerKey("sidecar" as Parameters[0]))).toBe( + "local", + ) + }) + + test("keeps configured loopback servers distinct from the canonical sidecar", () => { + expect( + String(ServerScope.fromServerKey("http://localhost:4096" as Parameters[0])), + ).toBe("http://localhost:4096") + }) + + test("uses a stable local scope for an explicit canonical web server", () => { + const key = "http://localhost:4096" as Parameters[0] + expect(String(ServerScope.fromServerKey(key, key))).toBe("local") + }) +}) + +describe("SessionStateKey", () => { + test("combines local and remote scope with route identity", () => { + const route = SessionRouteKey.fromRoute("cmVwbw", "session-1") + expect(String(SessionStateKey.from(ServerScope.local, route))).toBe("local\0cmVwbw/session-1") + expect(String(SessionStateKey.from("https://windows.example" as ServerScope, route))).toBe( + "https://windows.example\0cmVwbw/session-1", + ) + expect(SessionStateKey.from("https://debian.example" as ServerScope, route)).not.toBe( + SessionStateKey.from("https://windows.example" as ServerScope, route), + ) + }) + + test("extracts route keys from scoped and legacy state keys", () => { + expect(String(SessionStateKey.route("cmVwbw/session-1"))).toBe("cmVwbw/session-1") + expect(String(SessionStateKey.route("local\0cmVwbw/session-1"))).toBe("cmVwbw/session-1") + expect(String(SessionStateKey.route("https://debian.example\0cmVwbw/session-1"))).toBe("cmVwbw/session-1") + }) +}) + +describe("migrateLegacySessionStateKeys", () => { + test("copies legacy route keys into local scope without overwriting scoped state", () => { + expect( + migrateLegacySessionStateKeys({ + "cmVwbw/session-1": { active: "legacy" }, + "local\0cmVwbw/session-1": { active: "scoped" }, + "https://debian.example\0cmVwbw/session-1": { active: "remote" }, + }), + ).toEqual({ + "local\0cmVwbw/session-1": { active: "scoped" }, + "https://debian.example\0cmVwbw/session-1": { active: "remote" }, + }) + }) + + test("rejects invalid identity fragments", () => { + expect(() => ScopedKey.from(ServerScope.local, "bad\0directory")).toThrow( + "Scoped key part cannot contain null bytes", + ) + }) +}) diff --git a/packages/app/src/utils/server-scope.ts b/packages/app/src/utils/server-scope.ts new file mode 100644 index 0000000000000000000000000000000000000000..9a4e941d389ec2b7a6e3be8d696ff06473dbf7f7 --- /dev/null +++ b/packages/app/src/utils/server-scope.ts @@ -0,0 +1,73 @@ +import type { ServerConnection } from "@/context/server" + +export type ServerScope = string & { readonly __brand: "ServerScope" } +export type SessionRouteKey = string & { readonly __brand: "SessionRouteKey" } +export type SessionStateKey = string & { readonly __brand: "SessionStateKey" } +export type ScopedKey = string & { readonly __brand: "ScopedKey" } + +const separator = "\u0000" + +function fragment(label: string, value: string) { + if (value.includes(separator)) throw new Error(`${label} cannot contain null bytes`) + return value +} + +function compose(scope: ServerScope, parts: string[]) { + return [fragment("Server scope", scope), ...parts.map((part) => fragment("Scoped key part", part))].join(separator) +} + +export const ServerScope = { + local: "local" as ServerScope, + fromServerKey(key: ServerConnection.Key, canonicalLocalServer?: ServerConnection.Key) { + return fragment( + "Server scope", + key === "sidecar" || key === canonicalLocalServer ? ServerScope.local : key, + ) as ServerScope + }, +} + +export const SessionRouteKey = { + fromRoute(dir: string | undefined, sessionID?: string) { + return fragment("Session route", `${dir ?? ""}${sessionID ? "/" + sessionID : ""}`) as SessionRouteKey + }, + fromLegacy(key: string) { + return fragment("Legacy session route", key) as SessionRouteKey + }, +} + +export const SessionStateKey = { + from(scope: ServerScope, route: SessionRouteKey) { + return compose(scope, [route]) as SessionStateKey + }, + route(key: string) { + const split = key.lastIndexOf(separator) + return SessionRouteKey.fromLegacy(split === -1 ? key : key.slice(split + 1)) + }, + scope(key: string) { + const split = key.indexOf(separator) + if (split === -1) return ServerScope.local + return fragment("Stored server scope", key.slice(0, split)) as ServerScope + }, +} + +export const ScopedKey = { + from(scope: ServerScope, ...parts: string[]) { + return compose(scope, parts) as ScopedKey + }, + prefix(scope: ServerScope, ...parts: string[]) { + return `${ScopedKey.from(scope, ...parts)}${separator}` + }, +} + +export function migrateLegacySessionStateKeys(value: unknown) { + if (!value || typeof value !== "object" || Array.isArray(value)) return value + const entries = Object.entries(value) + if (entries.every(([key]) => key.includes(separator))) return value + const scoped = Object.fromEntries(entries.filter(([key]) => key.includes(separator))) + for (const [key, item] of entries) { + if (key.includes(separator)) continue + const next = SessionStateKey.from(ServerScope.local, SessionRouteKey.fromLegacy(key)) + if (!(next in scoped)) scoped[next] = item + } + return scoped +} diff --git a/packages/app/src/utils/server.test.ts b/packages/app/src/utils/server.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..4666b7d6d03c75d5727328426ad518acf06aa7a2 --- /dev/null +++ b/packages/app/src/utils/server.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, test } from "bun:test" +import { authFromToken, authTokenFromCredentials } from "./server" + +describe("authFromToken", () => { + test("decodes basic auth credentials from auth_token", () => { + expect(authFromToken(btoa("kit:secret"))).toEqual({ username: "kit", password: "secret" }) + }) + + test("defaults blank username to opencode", () => { + expect(authFromToken(btoa(":secret"))).toEqual({ username: "opencode", password: "secret" }) + }) + + test("ignores malformed tokens", () => { + expect(authFromToken("not base64")).toBeUndefined() + expect(authFromToken(btoa("missing-separator"))).toBeUndefined() + }) +}) + +describe("authTokenFromCredentials", () => { + test("encodes credentials with the default username", () => { + expect(authTokenFromCredentials({ password: "secret" })).toBe(btoa("opencode:secret")) + }) +}) diff --git a/packages/app/src/utils/server.ts b/packages/app/src/utils/server.ts new file mode 100644 index 0000000000000000000000000000000000000000..1c8292ca9d957ab4820288cd5f1091a7220f5fba --- /dev/null +++ b/packages/app/src/utils/server.ts @@ -0,0 +1,62 @@ +import { createOpencodeClient } from "@opencode-ai/sdk/v2/client" +import { OpenCode, type OpenCodeClient } from "@opencode-ai/client/promise" +import type { ServerConnection } from "@/context/server" +import { decode64 } from "@/utils/base64" + +export function authTokenFromCredentials(input: { username?: string; password: string }) { + return btoa(`${input.username ?? "opencode"}:${input.password}`) +} + +export function authFromToken(token: string | null) { + const decoded = decode64(token ?? undefined) + if (!decoded) return + const separator = decoded.indexOf(":") + if (separator === -1) return + return { + username: decoded.slice(0, separator) || "opencode", + password: decoded.slice(separator + 1), + } +} + +export function createSdkForServer({ + server, + ...config +}: Omit[0]>, "baseUrl"> & { + server: ServerConnection.HttpBase +}) { + const auth = (() => { + if (!server.password) return + return { + Authorization: `Basic ${authTokenFromCredentials({ username: server.username, password: server.password })}`, + } + })() + + return createOpencodeClient({ + ...config, + headers: { + ...(config.headers instanceof Headers ? Object.fromEntries(config.headers.entries()) : config.headers), + ...auth, + }, + baseUrl: server.url, + }) +} + +export function createApiForServer(input: { + server: ServerConnection.HttpBase + fetch?: typeof globalThis.fetch +}): OpenCodeClient { + return OpenCode.make({ + baseUrl: input.server.url, + fetch: input.fetch, + headers: input.server.password + ? { + Authorization: `Basic ${authTokenFromCredentials({ + username: input.server.username, + password: input.server.password, + })}`, + } + : undefined, + }) +} + +export type ServerApi = OpenCodeClient diff --git a/packages/app/src/utils/session-export.test.ts b/packages/app/src/utils/session-export.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..ff18a16b7fa349622f592ddedfbf330322bd9690 --- /dev/null +++ b/packages/app/src/utils/session-export.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, test } from "bun:test" +import { fetchSessionExport, sessionExportFilename } from "./session-export" +import type { Message, Part, Session } from "@opencode-ai/sdk/v2/client" + +describe("sessionExportFilename", () => { + test("generates filename from title", () => { + expect(sessionExportFilename({ id: "ses_123", title: "Clone PR in worktree from fork" })).toBe( + "clone-pr-in-worktree-from-fork.json", + ) + }) + + test("generates filename from slug when title missing", () => { + expect(sessionExportFilename({ id: "ses_123", slug: "my-session-slug" })).toBe("my-session-slug.json") + }) + + test("falls back to id when title and slug are empty", () => { + expect(sessionExportFilename({ id: "ses_123" })).toBe("ses_123.json") + }) +}) + +describe("fetchSessionExport", () => { + test("fetches full transcript from client", async () => { + const session = { id: "ses_1", title: "Test Session" } as Session + const msg = { id: "msg_1", role: "user" } as Message + const part = { id: "prt_1", type: "text", text: "hello" } as Part + const messages = [{ info: msg, parts: [part] }] + + const client = { + session: { + get: async () => ({ data: session }), + messages: async () => ({ data: messages }), + }, + } + + const result = await fetchSessionExport({ + sessionID: "ses_1", + client, + }) + + expect(result).toEqual({ + info: session, + messages, + }) + }) + + test("throws when session not found", async () => { + const client = { + session: { + get: async () => ({ data: null }), + messages: async () => ({ data: [] }), + }, + } + + expect( + fetchSessionExport({ + sessionID: "ses_missing", + client, + }), + ).rejects.toThrow("Session not found: ses_missing") + }) +}) diff --git a/packages/app/src/utils/session-export.ts b/packages/app/src/utils/session-export.ts new file mode 100644 index 0000000000000000000000000000000000000000..6eb9f9ab6a7c6afc3dad39d24d7ae9ebb6dd9bd1 --- /dev/null +++ b/packages/app/src/utils/session-export.ts @@ -0,0 +1,61 @@ +import type { Message, Part, Session } from "@opencode-ai/sdk/v2/client" + +// Matches the exact `{ info, messages: [{ info, parts }] }` structure produced by `opencode export` CLI +export type SessionExportData = { + info: Session + messages: { + info: Message + parts: Part[] + }[] +} + +export type SessionExportClient = { + session: { + get: (input: { sessionID: string }) => Promise<{ data?: Session | null }> + messages: (input: { sessionID: string }) => Promise<{ data?: SessionExportData["messages"] | null }> + } +} + +export async function fetchSessionExport(input: { + sessionID: string + client: SessionExportClient +}): Promise { + const [sessionRes, messagesRes] = await Promise.all([ + input.client.session.get({ sessionID: input.sessionID }), + input.client.session.messages({ sessionID: input.sessionID }), + ]) + + if (!sessionRes?.data) { + throw new Error(`Session not found: ${input.sessionID}`) + } + if (!messagesRes?.data) { + throw new Error(`Failed to load messages for session: ${input.sessionID}`) + } + + return { + info: sessionRes.data, + messages: messagesRes.data, + } +} + +export function sessionExportFilename(session: { id: string; title?: string; slug?: string }) { + const name = session.title || session.slug || session.id + const clean = name + .toLowerCase() + .replace(/[^a-z0-9_-]+/gi, "-") + .replace(/^-+|-+$/g, "") + return `${clean || session.id}.json` +} + +export function downloadSessionExport(filename: string, data: unknown) { + const json = JSON.stringify(data, null, 2) + const blob = new Blob([json], { type: "application/json" }) + const url = URL.createObjectURL(blob) + const a = document.createElement("a") + a.href = url + a.download = filename + document.body.appendChild(a) + a.click() + document.body.removeChild(a) + URL.revokeObjectURL(url) +} diff --git a/packages/app/src/utils/session-message.test.ts b/packages/app/src/utils/session-message.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..a69c414e1686b2c990e6186b20b8a44f623b8e86 --- /dev/null +++ b/packages/app/src/utils/session-message.test.ts @@ -0,0 +1,214 @@ +import { describe, expect, test } from "bun:test" +import type { SessionMessageInfo } from "@opencode-ai/client/promise" +import { normalizeSessionMessages } from "./session-message" + +describe("normalizeSessionMessages", () => { + test("projects current turns into stable legacy rendering records", () => { + const source = [ + { id: "msg_1", type: "agent-switched", agent: "build", time: { created: 1 } }, + { + id: "msg_2", + type: "model-switched", + model: { id: "claude", providerID: "anthropic", variant: "high" }, + time: { created: 2 }, + }, + { + id: "msg_3", + type: "user", + text: "inspect @src/client.ts", + files: [ + { + data: "aGVsbG8=", + mime: "text/plain", + name: "note.txt", + source: { type: "inline" }, + }, + { + data: "ZXhwb3J0IHt9", + mime: "text/plain", + name: "client.ts", + source: { type: "inline" }, + mention: { text: "@src/client.ts", start: 8, end: 22 }, + }, + ], + agents: [{ name: "review", mention: { text: "@review", start: 0, end: 7 } }], + time: { created: 3 }, + }, + { + id: "msg_4", + type: "assistant", + agent: "build", + model: { id: "claude", providerID: "anthropic", variant: "high" }, + content: [ + { type: "reasoning", text: "Thinking", time: { created: 4, completed: 5 } }, + { type: "text", text: "Result" }, + { + type: "tool", + id: "call_1", + name: "read", + state: { + status: "completed", + input: { filePath: "note.txt" }, + metadata: { title: "note.txt" }, + content: [{ type: "text", text: "hello" }], + }, + time: { created: 5, ran: 6, completed: 7 }, + }, + ], + cost: 0.1, + tokens: { input: 10, output: 5, reasoning: 2, cache: { read: 1, write: 0 } }, + time: { created: 4, completed: 7 }, + }, + { + id: "msg_5", + type: "compaction", + status: "completed", + reason: "auto", + summary: "summary", + recent: "recent", + time: { created: 8 }, + }, + ] satisfies SessionMessageInfo[] + + const result = normalizeSessionMessages("ses_1", source) + + expect(result.messages).toHaveLength(2) + expect(result.messages[0]).toMatchObject({ + id: "msg_3", + role: "user", + agent: "build", + model: { providerID: "anthropic", modelID: "claude", variant: "high" }, + }) + expect(result.messages[1]).toMatchObject({ id: "msg_4", role: "assistant", parentID: "msg_3", cost: 0.1 }) + expect(result.parts.get("msg_3")?.map((part) => part.id)).toEqual([ + "msg_3:text:0", + "msg_3:file:0", + "msg_3:file:1", + "msg_3:agent:0", + "msg_5:compaction", + ]) + expect(result.parts.get("msg_3")?.[2]).toMatchObject({ + type: "file", + source: { + type: "file", + path: "src/client.ts", + text: { value: "@src/client.ts", start: 8, end: 22 }, + }, + }) + expect(result.parts.get("msg_4")?.map((part) => part.id)).toEqual(["msg_4:reasoning:0", "msg_4:text:0", "call_1"]) + expect(result.parts.get("msg_4")?.[2]).toMatchObject({ + type: "tool", + tool: "read", + state: { status: "completed", output: "hello" }, + }) + }) + + test("does not invent a parent for an assistant-only page", () => { + const source = [ + { + id: "msg_2", + type: "assistant", + agent: "build", + model: { id: "model", providerID: "provider" }, + content: [{ type: "text", text: "orphan" }], + time: { created: 2 }, + }, + ] satisfies SessionMessageInfo[] + + expect(normalizeSessionMessages("ses_1", source).messages).toEqual([]) + }) + + test("projects a current shell message into a renderable standalone turn", () => { + const source = [ + { + id: "msg_shell", + type: "shell", + shellID: "shell_1", + command: "printf hello", + status: "exited", + exit: 0, + output: { output: "hello", cursor: 5, size: 5, truncated: false }, + time: { created: 1, completed: 2 }, + }, + ] satisfies SessionMessageInfo[] + + const result = normalizeSessionMessages("ses_1", source) + + expect(result.messages).toEqual([ + expect.objectContaining({ id: "msg_shell", role: "user" }), + expect.objectContaining({ id: "msg_shell:assistant", role: "assistant", parentID: "msg_shell" }), + ]) + expect(result.parts.get("msg_shell")).toEqual([expect.objectContaining({ type: "text", text: "printf hello" })]) + expect(result.parts.get("msg_shell:assistant")).toEqual([ + expect.objectContaining({ + type: "tool", + tool: "bash", + state: expect.objectContaining({ + status: "completed", + input: { command: "printf hello" }, + output: "hello", + title: "Shell", + }), + }), + ]) + }) + + test("adapts current edit fields for the legacy edit renderer", () => { + const source = [ + { id: "msg_user", type: "user", text: "edit it", time: { created: 1 } }, + { + id: "msg_assistant", + type: "assistant", + agent: "build", + model: { id: "model", providerID: "provider" }, + content: [ + { + type: "tool", + id: "call_edit", + name: "edit", + state: { + status: "completed", + input: { path: "/repo/README.md", oldString: "old", newString: "new" }, + content: [{ type: "text", text: "Edited file successfully" }], + metadata: { + files: [ + { + file: "README.md", + patch: "@@ -1 +1 @@\n-old\n+new", + additions: 1, + deletions: 1, + status: "modified", + }, + ], + replacements: 1, + }, + }, + time: { created: 2, ran: 3, completed: 4 }, + }, + ], + time: { created: 2, completed: 4 }, + }, + ] satisfies SessionMessageInfo[] + + const result = normalizeSessionMessages("ses_1", source) + + expect(result.parts.get("msg_assistant")).toEqual([ + expect.objectContaining({ + type: "tool", + tool: "edit", + state: expect.objectContaining({ + status: "completed", + input: expect.objectContaining({ path: "/repo/README.md", filePath: "/repo/README.md" }), + metadata: expect.objectContaining({ + filediff: { + file: "README.md", + patch: "@@ -1 +1 @@\n-old\n+new", + additions: 1, + deletions: 1, + }, + }), + }), + }), + ]) + }) +}) diff --git a/packages/app/src/utils/session-message.ts b/packages/app/src/utils/session-message.ts new file mode 100644 index 0000000000000000000000000000000000000000..eef9f4e6dfbfa9a14fc97a5a351226c24629f199 --- /dev/null +++ b/packages/app/src/utils/session-message.ts @@ -0,0 +1,366 @@ +import type { + SessionMessageAssistant, + SessionMessageAssistantTool, + SessionMessageInfo, + SessionMessageShell, + SessionMessageUser, +} from "@opencode-ai/client/promise" +import type { AssistantMessage, FilePart, Message, Part, ToolPart, UserMessage } from "@opencode-ai/sdk/v2" +import { Option, Schema } from "effect" + +const emptyTokens = { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } } +const emptyModel: { id: string; providerID: string; variant?: string } = { id: "", providerID: "" } +const decodeToolInput = Schema.decodeUnknownOption(Schema.UnknownFromJsonString) + +export function compareMessages(a: Pick, b: Pick) { + const left = messageKey(a) + const right = messageKey(b) + return left < right ? -1 : left > right ? 1 : 0 +} + +export const messageKey = (message: Pick) => message.time.created + message.id + +function record(value: unknown): value is Record { + return !!value && typeof value === "object" && !Array.isArray(value) +} + +function normalizeToolInput(name: string, input: Record) { + if (!["edit", "write"].includes(name) || typeof input.path !== "string" || typeof input.filePath === "string") + return input + return { ...input, filePath: input.path } +} + +function normalizeToolMetadata(name: string, metadata: Record) { + if (name !== "edit" || !Array.isArray(metadata.files)) return metadata + const file = metadata.files.find(record) + if (!file || typeof file.file !== "string") return metadata + return { + ...metadata, + filediff: { + file: file.file, + patch: typeof file.patch === "string" ? file.patch : undefined, + additions: typeof file.additions === "number" ? file.additions : 0, + deletions: typeof file.deletions === "number" ? file.deletions : 0, + }, + } +} + +export function normalizeSessionMessages(sessionID: string, source: readonly SessionMessageInfo[]) { + const messages: Message[] = [] + const parts = new Map() + let agent = "" + let model = emptyModel + let parentID: string | undefined + + source.forEach((message) => { + if (message.type === "agent-switched") { + agent = message.agent + return + } + if (message.type === "model-switched") { + model = message.model + return + } + if (message.type === "user") { + parentID = message.id + messages.push(userMessage(sessionID, message, agent, model)) + parts.set(message.id, userParts(sessionID, message)) + return + } + if (message.type === "synthetic" && message.description?.trim()) { + parentID = message.id + messages.push({ + id: message.id, + sessionID, + role: "user", + time: message.time, + agent, + model: { providerID: model.providerID, modelID: model.id, variant: model.variant }, + }) + parts.set(message.id, [textPart(sessionID, message.id, 0, message.description, true)]) + return + } + if (message.type === "shell") { + messages.push(...shellMessages(sessionID, message, agent, model)) + parts.set(message.id, [textPart(sessionID, message.id, 0, message.command)]) + parts.set(`${message.id}:assistant`, [shellPart(sessionID, message)]) + parentID = undefined + return + } + if (message.type === "assistant") { + agent = message.agent + model = message.model + if (!parentID) return + const parent = messages.findLast((item) => item.id === parentID) + if (parent?.role === "user") { + parent.agent = message.agent + parent.model = { + providerID: message.model.providerID, + modelID: message.model.id, + variant: message.model.variant, + } + } + messages.push(assistantMessage(sessionID, parentID, message)) + parts.set(message.id, assistantParts(sessionID, message)) + return + } + if (message.type !== "compaction" || !parentID) return + parts.set(parentID, [ + ...(parts.get(parentID) ?? []), + { + id: `${message.id}:compaction`, + sessionID, + messageID: parentID, + type: "compaction", + auto: message.reason === "auto", + }, + ]) + }) + + return { messages, parts } +} + +function shellMessages( + sessionID: string, + message: SessionMessageShell, + agent: string, + model: { id: string; providerID: string; variant?: string }, +): [UserMessage, AssistantMessage] { + return [ + { + id: message.id, + sessionID, + role: "user", + time: { created: message.time.created }, + agent, + model: { providerID: model.providerID, modelID: model.id, variant: model.variant }, + }, + { + id: `${message.id}:assistant`, + sessionID, + role: "assistant", + time: message.time, + parentID: message.id, + modelID: model.id, + providerID: model.providerID, + variant: model.variant, + mode: agent, + agent, + path: { cwd: "", root: "" }, + cost: 0, + tokens: emptyTokens, + }, + ] +} + +function shellPart(sessionID: string, message: SessionMessageShell): ToolPart { + const input = { command: message.command } + const start = message.time.created + const state: ToolPart["state"] = + message.status === "running" + ? { status: "running", input, time: { start } } + : { + status: "completed", + input, + output: message.output?.output ?? "", + title: "Shell", + metadata: { + status: message.status, + exit: message.exit, + truncated: message.output?.truncated, + }, + time: { start, end: message.time.completed ?? start }, + } + return { + id: `${message.id}:tool`, + sessionID, + messageID: `${message.id}:assistant`, + type: "tool", + callID: message.shellID, + tool: "bash", + state, + } +} + +export function sessionMessagePartID(messageID: string, type: "text" | "reasoning", ordinal: number) { + return `${messageID}:${type}:${ordinal}` +} + +function userMessage( + sessionID: string, + message: SessionMessageUser, + agent: string, + model: { id: string; providerID: string; variant?: string }, +): UserMessage { + return { + id: message.id, + sessionID, + role: "user", + time: message.time, + agent, + model: { providerID: model.providerID, modelID: model.id, variant: model.variant }, + } +} + +function userParts(sessionID: string, message: SessionMessageUser): Part[] { + return [ + textPart(sessionID, message.id, 0, message.text), + ...(message.files ?? []).map( + (file, index): FilePart => ({ + id: `${message.id}:file:${index}`, + sessionID, + messageID: message.id, + type: "file", + mime: file.mime, + filename: file.name, + url: file.source.type === "uri" ? file.source.uri : `data:${file.mime};base64,${file.data}`, + source: file.mention + ? { + type: "file", + text: { value: file.mention.text, start: file.mention.start, end: file.mention.end }, + path: file.mention.text.startsWith("@") ? file.mention.text.slice(1) : (file.name ?? file.mention.text), + } + : undefined, + }), + ), + ...(message.agents ?? []).map( + (item, index): Part => ({ + id: `${message.id}:agent:${index}`, + sessionID, + messageID: message.id, + type: "agent", + name: item.name, + source: item.mention + ? { value: item.mention.text, start: item.mention.start, end: item.mention.end } + : undefined, + }), + ), + ] +} + +function assistantMessage(sessionID: string, parentID: string, message: SessionMessageAssistant): AssistantMessage { + const error = message.error + ? message.error.type.toLowerCase().includes("abort") || message.error.type.toLowerCase().includes("interrupt") + ? { name: "MessageAbortedError" as const, data: { message: message.error.message } } + : { name: "UnknownError" as const, data: { message: message.error.message } } + : undefined + return { + id: message.id, + sessionID, + role: "assistant", + time: message.time, + error, + parentID, + modelID: message.model.id, + providerID: message.model.providerID, + variant: message.model.variant, + mode: message.agent, + agent: message.agent, + path: { cwd: "", root: "" }, + cost: message.cost ?? 0, + tokens: message.tokens ?? emptyTokens, + finish: message.finish, + } +} + +function assistantParts(sessionID: string, message: SessionMessageAssistant): Part[] { + const ordinals = { text: 0, reasoning: 0 } + return message.content.flatMap((content): Part[] => { + if (content.type === "text") { + const part = textPart(sessionID, message.id, ordinals.text++, content.text) + return content.text.trim() ? [part] : [] + } + if (content.type === "reasoning") { + const part: Part = { + id: sessionMessagePartID(message.id, "reasoning", ordinals.reasoning++), + sessionID, + messageID: message.id, + type: "reasoning", + text: content.text, + metadata: content.state, + time: { + start: content.time?.created ?? message.time.created, + end: content.time?.completed, + }, + } + return content.text.trim() ? [part] : [] + } + return [toolPart(sessionID, message.id, content)] + }) +} + +function textPart(sessionID: string, messageID: string, ordinal: number, text: string, synthetic?: boolean): Part { + return { + id: sessionMessagePartID(messageID, "text", ordinal), + sessionID, + messageID, + type: "text", + text, + synthetic, + } +} + +function toolPart(sessionID: string, messageID: string, tool: SessionMessageAssistantTool): ToolPart { + const start = tool.time.ran ?? tool.time.created + const state = (() => { + if (tool.state.status === "streaming") { + const value = Option.getOrUndefined(decodeToolInput(tool.state.input)) + const input = normalizeToolInput(tool.name, record(value) ? value : {}) + return { status: "pending" as const, input, raw: tool.state.input } + } + if (tool.state.status === "running") { + return { + status: "running" as const, + input: normalizeToolInput(tool.name, tool.state.input), + // metadata: normalizeToolMetadata(tool.name, tool.state.structured), + metadata: normalizeToolMetadata(tool.name, tool.state.metadata ?? {}), + time: { start }, + } + } + if (tool.state.status === "error") { + return { + status: "error" as const, + input: normalizeToolInput(tool.name, tool.state.input), + error: tool.state.error.message, + // metadata: normalizeToolMetadata(tool.name, tool.state.structured), + metadata: normalizeToolMetadata(tool.name, tool.state.metadata ?? {}), + time: { start, end: tool.time.completed ?? start }, + } + } + const attachments = tool.state.content.flatMap((item, index): FilePart[] => + item.type === "file" + ? [ + { + id: `${tool.id}:file:${index}`, + sessionID, + messageID, + type: "file", + mime: item.mime, + filename: item.name, + url: item.uri, + }, + ] + : [], + ) + return { + status: "completed" as const, + input: normalizeToolInput(tool.name, tool.state.input), + output: tool.state.content.flatMap((item) => (item.type === "text" ? [item.text] : [])).join("\n"), + title: tool.name, + // metadata: normalizeToolMetadata(tool.name, tool.state.structured), + metadata: normalizeToolMetadata(tool.name, tool.state.metadata ?? {}), + time: { start, end: tool.time.completed ?? start }, + attachments: attachments.length ? attachments : undefined, + } + })() + return { + id: tool.id, + sessionID, + messageID, + type: "tool", + callID: tool.id, + tool: tool.name, + state, + metadata: { providerState: tool.providerState, providerResultState: tool.providerResultState }, + } +} diff --git a/packages/app/src/utils/session-route.test.ts b/packages/app/src/utils/session-route.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..6a87e366cf8bc2443547ab29b759e8829a7611f5 --- /dev/null +++ b/packages/app/src/utils/session-route.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, test } from "bun:test" +import { ServerConnection } from "@/context/server" +import { legacySessionHref, legacySessionServer, requireServerKey, rootSession, sessionHref } from "./session-route" + +describe("session routes", () => { + test("uses the unique persisted server for a legacy session route", () => { + expect( + legacySessionServer( + [{ type: "session", server: ServerConnection.Key.make("server-b"), sessionId: "session-1" }], + "session-1", + ServerConnection.Key.make("server-a"), + ), + ).toBe(ServerConnection.Key.make("server-b")) + }) + + test("prefers the active server when a legacy session ID is ambiguous", () => { + expect( + legacySessionServer( + [ + { type: "session", server: ServerConnection.Key.make("server-a"), sessionId: "session-1" }, + { type: "session", server: ServerConnection.Key.make("server-b"), sessionId: "session-1" }, + ], + "session-1", + ServerConnection.Key.make("server-b"), + ), + ).toBe(ServerConnection.Key.make("server-b")) + }) + + test("builds and decodes a server-keyed session route", () => { + const server = ServerConnection.Key.make("https://example.com:4096") + const href = sessionHref(server, "session-1") + + expect(href).toBe("/server/aHR0cHM6Ly9leGFtcGxlLmNvbTo0MDk2/session/session-1") + expect(requireServerKey(href.split("/")[2])).toBe(server) + }) + + test("rejects malformed server keys", () => { + expect(() => requireServerKey("not-base64")).toThrow("Invalid server route") + }) + + test("builds the legacy directory-keyed route", () => { + expect(legacySessionHref("/Users/example/project", "session-1")).toBe( + "/L1VzZXJzL2V4YW1wbGUvcHJvamVjdA/session/session-1", + ) + }) + + test("resolves the root session", async () => { + const sessions: Record = { + child: { id: "child", parentID: "parent" }, + parent: { id: "parent", parentID: "root" }, + root: { id: "root" }, + } + + expect( + await rootSession(sessions.child, async (id) => { + const session = sessions[id] + if (!session) throw new Error(`Missing session: ${id}`) + return session + }), + ).toBe(sessions.root) + }) + + test("rejects a parent cycle", async () => { + const sessions: Record = { + child: { id: "child", parentID: "parent" }, + parent: { id: "parent", parentID: "child" }, + } + + expect(rootSession(sessions.child, async (id) => sessions[id]!)).rejects.toThrow("Session parent cycle: child") + }) +}) diff --git a/packages/app/src/utils/session-route.ts b/packages/app/src/utils/session-route.ts new file mode 100644 index 0000000000000000000000000000000000000000..4721611d2eae4143b564c69105267eecc7d33d17 --- /dev/null +++ b/packages/app/src/utils/session-route.ts @@ -0,0 +1,39 @@ +import { base64Encode } from "@opencode-ai/core/util/encode" +import { ServerConnection } from "@/context/server" +import { decode64 } from "@/utils/base64" + +export function sessionHref(server: ServerConnection.Key, sessionID: string) { + return `/server/${base64Encode(server)}/session/${sessionID}` +} + +export function legacySessionHref(directory: string, sessionID: string) { + return `/${base64Encode(directory)}/session/${sessionID}` +} + +export function requireServerKey(segment: string | undefined) { + const key = decode64(segment) + if (!key || base64Encode(key) !== segment) throw new Error("Invalid server route") + return ServerConnection.Key.make(key) +} + +export function legacySessionServer( + tabs: readonly { type: "session"; server: ServerConnection.Key; sessionId: string }[], + sessionID: string, + active: ServerConnection.Key, +) { + const matches = tabs.filter((tab) => tab.sessionId === sessionID) + return matches.find((tab) => tab.server === active)?.server ?? (matches.length === 1 ? matches[0]?.server : active) +} + +type SessionParent = { id: string; parentID?: string } + +export async function rootSession(session: T, get: (sessionID: string) => Promise) { + const seen = new Set([session.id]) + let current = session + while (current.parentID) { + if (seen.has(current.parentID)) throw new Error(`Session parent cycle: ${current.parentID}`) + seen.add(current.parentID) + current = await get(current.parentID) + } + return current +} diff --git a/packages/app/src/utils/session-title.ts b/packages/app/src/utils/session-title.ts new file mode 100644 index 0000000000000000000000000000000000000000..9d4c544b990cdd52bb15d8cd9c5af84f2ae00079 --- /dev/null +++ b/packages/app/src/utils/session-title.ts @@ -0,0 +1,19 @@ +const pattern = /^(New session|Child session) - \d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/ + +interface Info { + readonly title?: string + readonly parentID?: string + readonly time: { + readonly created: number + } +} + +export function withTimestampedFallback(info: Info) { + return info.title ?? `${info.parentID ? "Child" : "New"} session - ${new Date(info.time.created).toISOString()}` +} + +export function sessionTitle(title?: string) { + if (!title) return title + const match = title.match(pattern) + return match?.[1] ?? title +} diff --git a/packages/app/src/utils/session.test.ts b/packages/app/src/utils/session.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..836a990a56538df18038bf56e52c75af89e52c55 --- /dev/null +++ b/packages/app/src/utils/session.test.ts @@ -0,0 +1,114 @@ +import { describe, expect, test } from "bun:test" +import type { SessionApi, SessionInfo, SessionListInput } from "@opencode-ai/client/promise" +import { listAllSessions, normalizeSessionInfo } from "./session" + +describe("normalizeSessionInfo", () => { + test("adapts a current session to the app session shape", () => { + const result = normalizeSessionInfo({ + id: "session-1", + projectID: "project-1", + agent: "build", + model: { id: "gpt-5", providerID: "openai", variant: "high" }, + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + time: { created: 1, updated: 1 }, + title: "New session", + location: { directory: "/repo/worktree", workspaceID: "workspace-1" }, + subpath: "worktree", + revert: { messageID: "message-1", partID: "part-1", snapshot: "snapshot", files: [] }, + } as SessionInfo) + + expect(result).toEqual({ + id: "session-1", + slug: "session-1", + projectID: "project-1", + workspaceID: "workspace-1", + directory: "/repo/worktree", + path: "worktree", + parentID: undefined, + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + title: "New session", + agent: "build", + model: { id: "gpt-5", providerID: "openai", variant: "high" }, + version: "", + time: { created: 1, updated: 1 }, + revert: { messageID: "message-1", partID: "part-1", snapshot: "snapshot" }, + }) + }) + + test("supplies timestamped titles for untitled current sessions", () => { + const root = currentSession("session-1") + const child = currentSession("session-2", "session-1") + + expect(normalizeSessionInfo(root).title).toBe("New session - 1970-01-01T00:00:00.000Z") + expect(normalizeSessionInfo(child).title).toBe("Child session - 1970-01-01T00:00:00.000Z") + }) +}) + +describe("listAllSessions", () => { + test("loads every page in server order and retains the query", async () => { + const calls: SessionListInput[] = [] + const pages = new Map([ + [undefined, { data: [sessionInfo("session-3"), sessionInfo("session-2")], cursor: { next: "next" } }], + ["next", { data: [sessionInfo("session-1", true)], cursor: {} }], + ]) + const api = { + list: async (query = {}) => { + calls.push(query) + return pages.get(query.cursor) ?? { data: [], cursor: {} } + }, + } satisfies Pick + + const result = await listAllSessions(api, { directory: "/repo", order: "desc" }) + + expect(result.map((session) => session.id)).toEqual(["session-3", "session-2", "session-1"]) + expect(result[2]?.time.archived).toBe(2) + expect(calls).toEqual([ + { directory: "/repo", order: "desc", limit: 100, cursor: undefined }, + { directory: "/repo", order: "desc", limit: 100, cursor: "next" }, + ]) + }) + + test("requests the terminal empty page when the server returns a next cursor", async () => { + const cursors: Array = [] + const api = { + list: async (query = {}) => { + cursors.push(query.cursor) + if (query.cursor) return { data: [], cursor: { next: "unused" } } + return { data: [sessionInfo("session-1")], cursor: { next: "terminal" } } + }, + } satisfies Pick + + const result = await listAllSessions(api, { directory: "/repo", limit: 25 }) + + expect(result.map((session) => session.id)).toEqual(["session-1"]) + expect(cursors).toEqual([undefined, "terminal"]) + }) +}) + +function sessionInfo(id: string, archived = false) { + return { + id, + projectID: "project-1", + agent: "build", + model: { id: "model-1", providerID: "provider-1" }, + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + time: { created: 1, updated: 1, archived: archived ? 2 : undefined }, + title: id, + location: { directory: "/repo" }, + } as SessionInfo +} + +function currentSession(id: string, parentID?: string) { + return { + id, + parentID, + projectID: "project-1", + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + time: { created: 0, updated: 0 }, + location: { directory: "/repo" }, + } as SessionInfo +} diff --git a/packages/app/src/utils/session.ts b/packages/app/src/utils/session.ts new file mode 100644 index 0000000000000000000000000000000000000000..976ecb9bd67d054b920598452f8d5072c7be2369 --- /dev/null +++ b/packages/app/src/utils/session.ts @@ -0,0 +1,38 @@ +import type { SessionApi, SessionInfo, SessionListInput } from "@opencode-ai/client/promise" +import type { Session } from "@opencode-ai/sdk/v2/client" +import { withTimestampedFallback } from "./session-title" + +export function normalizeSessionInfo(input: SessionInfo | Session): Session { + if (!("location" in input)) return input + return { + id: input.id, + slug: input.id, + projectID: input.projectID, + workspaceID: input.location.workspaceID, + directory: input.location.directory, + path: input.subpath, + parentID: input.parentID, + cost: input.cost, + tokens: input.tokens, + title: withTimestampedFallback(input), + agent: input.agent, + model: input.model, + version: "", + time: input.time, + revert: input.revert && { + messageID: input.revert.messageID, + partID: input.revert.partID, + snapshot: input.revert.snapshot, + }, + } +} + +export async function listAllSessions(api: Pick, input: Omit) { + const load = async (cursor?: string): Promise => { + const result = await api.list({ ...input, limit: input.limit ?? 100, cursor }) + const sessions = result.data.map(normalizeSessionInfo) + if (result.data.length === 0 || !result.cursor.next) return sessions + return [...sessions, ...(await load(result.cursor.next))] + } + return load() +} diff --git a/packages/app/src/utils/solid-dnd.tsx b/packages/app/src/utils/solid-dnd.tsx new file mode 100644 index 0000000000000000000000000000000000000000..8e30a033aecd04478cd315820bbe68672646b1a5 --- /dev/null +++ b/packages/app/src/utils/solid-dnd.tsx @@ -0,0 +1,49 @@ +import { useDragDropContext } from "@thisbeyond/solid-dnd" +import type { Transformer } from "@thisbeyond/solid-dnd" +import { createRoot, onCleanup, type JSXElement } from "solid-js" + +type DragEvent = { draggable?: { id?: unknown } } + +const isDragEvent = (event: unknown): event is DragEvent => { + if (typeof event !== "object" || event === null) return false + return "draggable" in event +} + +export const getDraggableId = (event: unknown): string | undefined => { + if (!isDragEvent(event)) return undefined + const draggable = event.draggable + if (!draggable) return undefined + return typeof draggable.id === "string" ? draggable.id : undefined +} + +const createTransformer = (id: string, axis: "x" | "y"): Transformer => ({ + id, + order: 100, + callback: (transform) => (axis === "x" ? { ...transform, x: 0 } : { ...transform, y: 0 }), +}) + +const createAxisConstraint = (axis: "x" | "y", transformerId: string) => (): JSXElement => { + const context = useDragDropContext() + if (!context) return null + const [, { onDragStart, onDragEnd, addTransformer, removeTransformer }] = context + const transformer = createTransformer(transformerId, axis) + const dispose = createRoot((dispose) => { + onDragStart((event) => { + const id = getDraggableId(event) + if (!id) return + addTransformer("draggables", id, transformer) + }) + onDragEnd((event) => { + const id = getDraggableId(event) + if (!id) return + removeTransformer("draggables", id, transformer.id) + }) + return dispose + }) + onCleanup(dispose) + return null +} + +export const ConstrainDragXAxis = createAxisConstraint("x", "constrain-x-axis") + +export const ConstrainDragYAxis = createAxisConstraint("y", "constrain-y-axis") diff --git a/packages/app/src/utils/sound.ts b/packages/app/src/utils/sound.ts new file mode 100644 index 0000000000000000000000000000000000000000..78e5a0c565e0ad96540e8d37328a4cffdcedc693 --- /dev/null +++ b/packages/app/src/utils/sound.ts @@ -0,0 +1,102 @@ +let files: Record Promise> | undefined +let loads: Record Promise> | undefined + +function getFiles() { + if (files) return files + files = import.meta.glob("../../../ui/src/assets/audio/*.aac", { import: "default" }) as Record< + string, + () => Promise + > + return files +} + +export const SOUND_OPTIONS = [ + { id: "alert-01", label: "sound.option.alert01" }, + { id: "alert-02", label: "sound.option.alert02" }, + { id: "alert-03", label: "sound.option.alert03" }, + { id: "alert-04", label: "sound.option.alert04" }, + { id: "alert-05", label: "sound.option.alert05" }, + { id: "alert-06", label: "sound.option.alert06" }, + { id: "alert-07", label: "sound.option.alert07" }, + { id: "alert-08", label: "sound.option.alert08" }, + { id: "alert-09", label: "sound.option.alert09" }, + { id: "alert-10", label: "sound.option.alert10" }, + { id: "bip-bop-01", label: "sound.option.bipbop01" }, + { id: "bip-bop-02", label: "sound.option.bipbop02" }, + { id: "bip-bop-03", label: "sound.option.bipbop03" }, + { id: "bip-bop-04", label: "sound.option.bipbop04" }, + { id: "bip-bop-05", label: "sound.option.bipbop05" }, + { id: "bip-bop-06", label: "sound.option.bipbop06" }, + { id: "bip-bop-07", label: "sound.option.bipbop07" }, + { id: "bip-bop-08", label: "sound.option.bipbop08" }, + { id: "bip-bop-09", label: "sound.option.bipbop09" }, + { id: "bip-bop-10", label: "sound.option.bipbop10" }, + { id: "staplebops-01", label: "sound.option.staplebops01" }, + { id: "staplebops-02", label: "sound.option.staplebops02" }, + { id: "staplebops-03", label: "sound.option.staplebops03" }, + { id: "staplebops-04", label: "sound.option.staplebops04" }, + { id: "staplebops-05", label: "sound.option.staplebops05" }, + { id: "staplebops-06", label: "sound.option.staplebops06" }, + { id: "staplebops-07", label: "sound.option.staplebops07" }, + { id: "nope-01", label: "sound.option.nope01" }, + { id: "nope-02", label: "sound.option.nope02" }, + { id: "nope-03", label: "sound.option.nope03" }, + { id: "nope-04", label: "sound.option.nope04" }, + { id: "nope-05", label: "sound.option.nope05" }, + { id: "nope-06", label: "sound.option.nope06" }, + { id: "nope-07", label: "sound.option.nope07" }, + { id: "nope-08", label: "sound.option.nope08" }, + { id: "nope-09", label: "sound.option.nope09" }, + { id: "nope-10", label: "sound.option.nope10" }, + { id: "nope-11", label: "sound.option.nope11" }, + { id: "nope-12", label: "sound.option.nope12" }, + { id: "yup-01", label: "sound.option.yup01" }, + { id: "yup-02", label: "sound.option.yup02" }, + { id: "yup-03", label: "sound.option.yup03" }, + { id: "yup-04", label: "sound.option.yup04" }, + { id: "yup-05", label: "sound.option.yup05" }, + { id: "yup-06", label: "sound.option.yup06" }, +] as const + +export type SoundOption = (typeof SOUND_OPTIONS)[number] +export type SoundID = SoundOption["id"] + +function getLoads() { + if (loads) return loads + loads = Object.fromEntries( + Object.entries(getFiles()).flatMap(([path, load]) => { + const file = path.split("/").at(-1) + if (!file) return [] + return [[file.replace(/\.aac$/, ""), load] as const] + }), + ) as Record Promise> + return loads +} + +const cache = new Map>() + +export function soundSrc(id: string | undefined) { + const loads = getLoads() + if (!id || !(id in loads)) return Promise.resolve(undefined) + const key = id as SoundID + const hit = cache.get(key) + if (hit) return hit + const next = loads[key]().catch(() => undefined) + cache.set(key, next) + return next +} + +export function playSound(src: string | undefined) { + if (typeof Audio === "undefined") return + if (!src) return + const audio = new Audio(src) + audio.play().catch(() => undefined) + return () => { + audio.pause() + audio.currentTime = 0 + } +} + +export function playSoundById(id: string | undefined) { + return soundSrc(id).then((src) => playSound(src)) +} diff --git a/packages/app/src/utils/terminal-websocket-url.test.ts b/packages/app/src/utils/terminal-websocket-url.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..aac854ca82d66a731c1502b432161cf48954e9df --- /dev/null +++ b/packages/app/src/utils/terminal-websocket-url.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, test } from "bun:test" +import { terminalWebSocketURL } from "./terminal-websocket-url" + +describe("terminalWebSocketURL", () => { + test("uses the current ticketed PTY route", () => { + const url = terminalWebSocketURL({ + url: "http://127.0.0.1:49365", + id: "pty_test", + directory: "/tmp/project", + cursor: 0, + ticket: "connect-ticket", + }) + + expect(url.protocol).toBe("ws:") + expect(url.username).toBe("") + expect(url.password).toBe("") + expect(url.pathname).toBe("/api/pty/pty_test/connect") + expect(url.searchParams.get("location[directory]")).toBe("/tmp/project") + expect(url.searchParams.get("cursor")).toBe("0") + expect(url.searchParams.get("ticket")).toBe("connect-ticket") + expect(url.searchParams.has("auth_token")).toBe(false) + }) + + test("uses query auth without embedding credentials in websocket URL for v1", () => { + const url = terminalWebSocketURL({ + protocol: "v1", + url: "http://127.0.0.1:49365", + id: "pty_test", + directory: "/tmp/project", + cursor: 0, + sameOrigin: false, + username: "opencode", + password: "secret", + }) + + expect(url.protocol).toBe("ws:") + expect(url.username).toBe("") + expect(url.password).toBe("") + expect(url.pathname).toBe("/pty/pty_test/connect") + expect(url.searchParams.get("directory")).toBe("/tmp/project") + expect(url.searchParams.get("auth_token")).toBe(btoa("opencode:secret")) + }) + + test("omits query auth for same-origin saved credentials for v1", () => { + const url = terminalWebSocketURL({ + protocol: "v1", + url: "https://app.example.test", + id: "pty_test", + directory: "/tmp/project", + cursor: 10, + sameOrigin: true, + username: "opencode", + password: "secret", + }) + + expect(url.protocol).toBe("wss:") + expect(url.pathname).toBe("/pty/pty_test/connect") + expect(url.searchParams.get("directory")).toBe("/tmp/project") + expect(url.searchParams.has("auth_token")).toBe(false) + }) + + test("uses query auth for same-origin credentials from auth_token for v1", () => { + const url = terminalWebSocketURL({ + protocol: "v1", + url: "https://app.example.test", + id: "pty_test", + directory: "/tmp/project", + cursor: 10, + sameOrigin: true, + username: "opencode", + password: "secret", + authToken: true, + }) + + expect(url.protocol).toBe("wss:") + expect(url.pathname).toBe("/pty/pty_test/connect") + expect(url.searchParams.get("directory")).toBe("/tmp/project") + expect(url.searchParams.get("auth_token")).toBe(btoa("opencode:secret")) + }) +}) diff --git a/packages/app/src/utils/terminal-websocket-url.ts b/packages/app/src/utils/terminal-websocket-url.ts new file mode 100644 index 0000000000000000000000000000000000000000..a32b239cc932e882c1fb5b38273c2a8560f3f90c --- /dev/null +++ b/packages/app/src/utils/terminal-websocket-url.ts @@ -0,0 +1,35 @@ +import { authTokenFromCredentials } from "@/utils/server" + +export function terminalWebSocketURL(input: { + protocol?: "v1" | "v2" + url: string + id: string + directory: string + cursor: number + ticket?: string + sameOrigin?: boolean + username?: string + password?: string + authToken?: boolean +}) { + const isV1 = input.protocol === "v1" + const next = new URL(`${input.url}${isV1 ? `/pty/${input.id}/connect` : `/api/pty/${input.id}/connect`}`) + if (isV1) { + next.searchParams.set("directory", input.directory) + } else { + next.searchParams.set("location[directory]", input.directory) + } + next.searchParams.set("cursor", String(input.cursor)) + next.protocol = next.protocol === "https:" ? "wss:" : "ws:" + if (input.ticket) { + next.searchParams.set("ticket", input.ticket) + return next + } + if (isV1 && input.password && (!input.sameOrigin || input.authToken)) { + next.searchParams.set( + "auth_token", + authTokenFromCredentials({ username: input.username, password: input.password }), + ) + } + return next +} diff --git a/packages/app/src/utils/terminal-writer.test.ts b/packages/app/src/utils/terminal-writer.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..c49702e39b154874bc3fe0a47e6e507391ee63c2 --- /dev/null +++ b/packages/app/src/utils/terminal-writer.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, test } from "bun:test" +import { terminalWriter } from "./terminal-writer" + +describe("terminalWriter", () => { + test("buffers and flushes once per schedule", () => { + const calls: string[] = [] + const scheduled: VoidFunction[] = [] + const writer = terminalWriter( + (data, done) => { + calls.push(data) + done?.() + }, + (flush) => scheduled.push(flush), + ) + + writer.push("a") + writer.push("b") + writer.push("c") + + expect(calls).toEqual([]) + expect(scheduled).toHaveLength(1) + + scheduled[0]?.() + expect(calls).toEqual(["abc"]) + }) + + test("flush is a no-op when empty", () => { + const calls: string[] = [] + const writer = terminalWriter( + (data, done) => { + calls.push(data) + done?.() + }, + (flush) => flush(), + ) + writer.flush() + expect(calls).toEqual([]) + }) + + test("flush waits for pending write completion", () => { + const calls: string[] = [] + let done: VoidFunction | undefined + const writer = terminalWriter( + (data, finish) => { + calls.push(data) + done = finish + }, + (flush) => flush(), + ) + + writer.push("a") + + let settled = false + writer.flush(() => { + settled = true + }) + + expect(calls).toEqual(["a"]) + expect(settled).toBe(false) + + done?.() + expect(settled).toBe(true) + }) +}) diff --git a/packages/app/src/utils/terminal-writer.ts b/packages/app/src/utils/terminal-writer.ts new file mode 100644 index 0000000000000000000000000000000000000000..083f51de471f00238b1d974a26e138c6326e2036 --- /dev/null +++ b/packages/app/src/utils/terminal-writer.ts @@ -0,0 +1,65 @@ +export function terminalWriter( + write: (data: string, done?: VoidFunction) => void, + schedule: (flush: VoidFunction) => void = queueMicrotask, +) { + let chunks: string[] | undefined + let waits: VoidFunction[] | undefined + let scheduled = false + let writing = false + + const settle = () => { + if (scheduled || writing || chunks?.length) return + const list = waits + if (!list?.length) return + waits = undefined + for (const fn of list) { + fn() + } + } + + const run = () => { + if (writing) return + scheduled = false + const items = chunks + if (!items?.length) { + settle() + return + } + chunks = undefined + writing = true + write(items.join(""), () => { + writing = false + if (chunks?.length) { + if (scheduled) return + scheduled = true + schedule(run) + return + } + settle() + }) + } + + const push = (data: string) => { + if (!data) return + if (chunks) chunks.push(data) + else chunks = [data] + + if (scheduled || writing) return + scheduled = true + schedule(run) + } + + const flush = (done?: VoidFunction) => { + if (!scheduled && !writing && !chunks?.length) { + done?.() + return + } + if (done) { + if (waits) waits.push(done) + else waits = [done] + } + run() + } + + return { push, flush } +} diff --git a/packages/app/src/utils/time.ts b/packages/app/src/utils/time.ts new file mode 100644 index 0000000000000000000000000000000000000000..d183e10807d55a42f5c03da95a5335f7593f91e9 --- /dev/null +++ b/packages/app/src/utils/time.ts @@ -0,0 +1,22 @@ +type TimeKey = + | "common.time.justNow" + | "common.time.minutesAgo.short" + | "common.time.hoursAgo.short" + | "common.time.daysAgo.short" + +type Translate = (key: TimeKey, params?: Record) => string + +export function getRelativeTime(dateString: string, t: Translate): string { + const date = new Date(dateString) + const now = new Date() + const diffMs = now.getTime() - date.getTime() + const diffSeconds = Math.floor(diffMs / 1000) + const diffMinutes = Math.floor(diffSeconds / 60) + const diffHours = Math.floor(diffMinutes / 60) + const diffDays = Math.floor(diffHours / 24) + + if (diffSeconds < 60) return t("common.time.justNow") + if (diffMinutes < 60) return t("common.time.minutesAgo.short", { count: diffMinutes }) + if (diffHours < 24) return t("common.time.hoursAgo.short", { count: diffHours }) + return t("common.time.daysAgo.short", { count: diffDays }) +} diff --git a/packages/app/src/utils/toast.tsx b/packages/app/src/utils/toast.tsx new file mode 100644 index 0000000000000000000000000000000000000000..6f23b63d1e83cc1cb21c9e8b2131b0fbbe7ecf0e --- /dev/null +++ b/packages/app/src/utils/toast.tsx @@ -0,0 +1,47 @@ +import { Icon, type IconProps } from "@opencode-ai/ui/icon" +import { + Toast, + showToast as showLegacyToast, + toaster as legacyToaster, + type ToastOptions, + type ToastVariant, +} from "@opencode-ai/ui/toast" +import { ToastV2, showToastV2, toasterV2 } from "@opencode-ai/ui/v2/toast-v2" + +let v2 = false + +export function setV2Toast(value: boolean) { + v2 = value +} + +export function ToastRegion(props: { v2: boolean }) { + if (props.v2) return + return +} + +export function showToast(options: ToastOptions | string) { + if (!v2) return showLegacyToast(options) + if (typeof options === "string") return showToastV2(options) + + return showToastV2({ + ...options, + icon: resolveIcon(options.icon, options.variant), + actions: options.actions?.map((action) => ({ + ...action, + variant: action.onClick === "dismiss" ? "secondary" : "primary", + })), + }) +} + +// v1 and v2 ids come from separate registries, so dismissal has to use the same +// implementation that issued the id. +export function dismissToast(toastId: number) { + if (!v2) return legacyToaster.dismiss(toastId) + return toasterV2.dismiss(toastId) +} + +function resolveIcon(icon: IconProps["name"] | undefined, variant: ToastVariant | undefined) { + const name = icon ?? (variant === "success" ? "check" : undefined) + if (!name) return + return +} diff --git a/packages/app/src/utils/uuid.test.ts b/packages/app/src/utils/uuid.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..e6b4e28240997481521eb326f464e0e680f11a9b --- /dev/null +++ b/packages/app/src/utils/uuid.test.ts @@ -0,0 +1,78 @@ +import { afterEach, describe, expect, test } from "bun:test" +import { uuid } from "./uuid" + +const cryptoDescriptor = Object.getOwnPropertyDescriptor(globalThis, "crypto") +const secureDescriptor = Object.getOwnPropertyDescriptor(globalThis, "isSecureContext") +const randomDescriptor = Object.getOwnPropertyDescriptor(Math, "random") + +const setCrypto = (value: Partial) => { + Object.defineProperty(globalThis, "crypto", { + configurable: true, + value: value as Crypto, + }) +} + +const setSecure = (value: boolean) => { + Object.defineProperty(globalThis, "isSecureContext", { + configurable: true, + value, + }) +} + +const setRandom = (value: () => number) => { + Object.defineProperty(Math, "random", { + configurable: true, + value, + }) +} + +afterEach(() => { + if (cryptoDescriptor) { + Object.defineProperty(globalThis, "crypto", cryptoDescriptor) + } + + if (secureDescriptor) { + Object.defineProperty(globalThis, "isSecureContext", secureDescriptor) + } + + if (!secureDescriptor) { + delete (globalThis as { isSecureContext?: boolean }).isSecureContext + } + + if (randomDescriptor) { + Object.defineProperty(Math, "random", randomDescriptor) + } +}) + +describe("uuid", () => { + test("uses randomUUID in secure contexts", () => { + setCrypto({ randomUUID: () => "00000000-0000-0000-0000-000000000000" }) + setSecure(true) + expect(uuid()).toBe("00000000-0000-0000-0000-000000000000") + }) + + test("falls back in insecure contexts", () => { + setCrypto({ randomUUID: () => "00000000-0000-0000-0000-000000000000" }) + setSecure(false) + setRandom(() => 0.5) + expect(uuid()).toBe("8") + }) + + test("falls back when randomUUID throws", () => { + setCrypto({ + randomUUID: () => { + throw new DOMException("Failed", "OperationError") + }, + }) + setSecure(true) + setRandom(() => 0.5) + expect(uuid()).toBe("8") + }) + + test("falls back when randomUUID is unavailable", () => { + setCrypto({}) + setSecure(true) + setRandom(() => 0.5) + expect(uuid()).toBe("8") + }) +}) diff --git a/packages/app/src/utils/uuid.ts b/packages/app/src/utils/uuid.ts new file mode 100644 index 0000000000000000000000000000000000000000..7b964068c86f47a1122abbd8a43af41b7e737d02 --- /dev/null +++ b/packages/app/src/utils/uuid.ts @@ -0,0 +1,12 @@ +const fallback = () => Math.random().toString(16).slice(2) + +export function uuid() { + const c = globalThis.crypto + if (!c || typeof c.randomUUID !== "function") return fallback() + if (typeof globalThis.isSecureContext === "boolean" && !globalThis.isSecureContext) return fallback() + try { + return c.randomUUID() + } catch { + return fallback() + } +} diff --git a/packages/app/src/utils/worktree.test.ts b/packages/app/src/utils/worktree.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..856a74c92720be9efd0999a712c61b377a994601 --- /dev/null +++ b/packages/app/src/utils/worktree.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, test } from "bun:test" +import { Worktree } from "./worktree" +import { ServerScope } from "./server-scope" + +const dir = (name: string) => `/tmp/opencode-worktree-${name}-${crypto.randomUUID()}` + +describe("Worktree", () => { + const scope = ServerScope.local + test("normalizes trailing slashes", () => { + const key = dir("normalize") + Worktree.ready(scope, `${key}/`) + + expect(Worktree.get(scope, key)).toEqual({ status: "ready" }) + }) + + test("pending does not overwrite a terminal state", () => { + const key = dir("pending") + Worktree.failed(scope, key, "boom") + Worktree.pending(scope, key) + + expect(Worktree.get(scope, key)).toEqual({ status: "failed", message: "boom" }) + }) + + test("wait resolves shared pending waiter when ready", async () => { + const key = dir("wait-ready") + Worktree.pending(scope, key) + + const a = Worktree.wait(scope, key) + const b = Worktree.wait(scope, `${key}/`) + + expect(a).toBe(b) + + Worktree.ready(scope, key) + + expect(await a).toEqual({ status: "ready" }) + expect(await b).toEqual({ status: "ready" }) + }) + + test("wait resolves with failure message", async () => { + const key = dir("wait-failed") + const waiting = Worktree.wait(scope, key) + + Worktree.failed(scope, key, "permission denied") + + expect(await waiting).toEqual({ status: "failed", message: "permission denied" }) + expect(await Worktree.wait(scope, key)).toEqual({ status: "failed", message: "permission denied" }) + }) + + test("isolates identical directories by server scope", () => { + const key = dir("scope") + const remote = "https://debian.example" as ServerScope + Worktree.ready(scope, key) + Worktree.failed(remote, key, "remote failed") + + expect(Worktree.get(scope, key)).toEqual({ status: "ready" }) + expect(Worktree.get(remote, key)).toEqual({ status: "failed", message: "remote failed" }) + }) +}) diff --git a/packages/app/src/utils/worktree.ts b/packages/app/src/utils/worktree.ts new file mode 100644 index 0000000000000000000000000000000000000000..5b9a6d39264cd6f2f1f8f76d17be8aef6ea8258e --- /dev/null +++ b/packages/app/src/utils/worktree.ts @@ -0,0 +1,76 @@ +import { ScopedKey, type ServerScope } from "@/utils/server-scope" + +const normalize = (directory: string) => directory.replace(/[\\/]+$/, "") +const key = (scope: ServerScope, directory: string) => ScopedKey.from(scope, normalize(directory)) + +type State = + | { + status: "pending" + } + | { + status: "ready" + } + | { + status: "failed" + message: string + } + +const state = new Map() +const waiters = new Map< + string, + { + promise: Promise + resolve: (state: State) => void + } +>() + +function deferred() { + const box = { resolve: (_: State) => {} } + const promise = new Promise((resolve) => { + box.resolve = resolve + }) + return { promise, resolve: box.resolve } +} + +export const Worktree = { + get(scope: ServerScope, directory: string) { + return state.get(key(scope, directory)) + }, + pending(scope: ServerScope, directory: string) { + const id = key(scope, directory) + const current = state.get(id) + if (current && current.status !== "pending") return + state.set(id, { status: "pending" }) + }, + ready(scope: ServerScope, directory: string) { + const id = key(scope, directory) + const next = { status: "ready" } as const + state.set(id, next) + const waiter = waiters.get(id) + if (!waiter) return + waiters.delete(id) + waiter.resolve(next) + }, + failed(scope: ServerScope, directory: string, message: string) { + const id = key(scope, directory) + const next = { status: "failed", message } as const + state.set(id, next) + const waiter = waiters.get(id) + if (!waiter) return + waiters.delete(id) + waiter.resolve(next) + }, + wait(scope: ServerScope, directory: string) { + const id = key(scope, directory) + const current = state.get(id) + if (current && current.status !== "pending") return Promise.resolve(current) + + const existing = waiters.get(id) + if (existing) return existing.promise + + const waiter = deferred() + + waiters.set(id, waiter) + return waiter.promise + }, +} diff --git a/packages/core/test/config/fixtures/plugin/directory-plugin.ts b/packages/core/test/config/fixtures/plugin/directory-plugin.ts new file mode 100644 index 0000000000000000000000000000000000000000..e26e12bdac7a92d18a04776bd606d04b5e46a780 --- /dev/null +++ b/packages/core/test/config/fixtures/plugin/directory-plugin.ts @@ -0,0 +1,13 @@ +import { define } from "@opencode-ai/plugin/v2/promise" + +export default define({ + id: "directory-plugin", + setup: async (ctx) => { + await ctx.agent.transform((agents) => { + agents.update("directory", (agent) => { + agent.description = "Loaded from plugin directory" + agent.mode = "subagent" + }) + }) + }, +}) diff --git a/packages/core/test/plugin/fixtures/config-promise-plugin.ts b/packages/core/test/plugin/fixtures/config-promise-plugin.ts new file mode 100644 index 0000000000000000000000000000000000000000..ed53e4b947b6eda20517e859d44870f115f47549 --- /dev/null +++ b/packages/core/test/plugin/fixtures/config-promise-plugin.ts @@ -0,0 +1,13 @@ +import { define } from "@opencode-ai/plugin/v2/promise" + +export default define({ + id: "config-promise-plugin", + setup: async (ctx) => { + await ctx.agent.transform((agents) => { + agents.update("configured", (agent) => { + agent.description = ctx.options.description + agent.mode = "subagent" + }) + }) + }, +}) diff --git a/packages/core/test/plugin/fixtures/models-dev.json b/packages/core/test/plugin/fixtures/models-dev.json new file mode 100644 index 0000000000000000000000000000000000000000..fb8f2622be35f863ee3de4b6c34c39e659c5e964 --- /dev/null +++ b/packages/core/test/plugin/fixtures/models-dev.json @@ -0,0 +1,14 @@ +{ + "acme": { + "id": "acme", + "name": "Acme", + "env": ["ACME_API_KEY"], + "models": {} + }, + "local": { + "id": "local", + "name": "Local", + "env": [], + "models": {} + } +} diff --git a/packages/core/test/plugin/fixtures/provider-factory.ts b/packages/core/test/plugin/fixtures/provider-factory.ts new file mode 100644 index 0000000000000000000000000000000000000000..7278c231dd5405dfbedc533b45ffecdf29aabfcf --- /dev/null +++ b/packages/core/test/plugin/fixtures/provider-factory.ts @@ -0,0 +1,9 @@ +export function createFixtureProvider(options: Record) { + const captured = Object.fromEntries(Object.entries(options)) + return Object.assign((modelID: string) => ({ modelID, options: captured }), { + options: captured, + languageModel(modelID: string) { + return { modelID, options: captured } + }, + }) +} diff --git a/packages/httpapi-codegen/src/index.ts b/packages/httpapi-codegen/src/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..298a1211dddcaeea66b0c41d6833d90865c828c0 --- /dev/null +++ b/packages/httpapi-codegen/src/index.ts @@ -0,0 +1,1185 @@ +import { isAbsolute, join } from "node:path" +import { Effect, FileSystem, PlatformError, Schema, SchemaAST, SchemaRepresentation } from "effect" +import { HttpMethod, type HttpRouter } from "effect/unstable/http" +import { HttpApi, HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "effect/unstable/httpapi" +import { format } from "prettier" + +export type InputField = { + readonly name: string + readonly source: "params" | "query" | "headers" | "payload" +} + +export type Operation = { + readonly group: string + readonly name: string + readonly input: ReadonlyArray + readonly inputMode: "none" | "optional" | "required" + readonly success: "value" | "void" | "stream" + readonly errors: ReadonlyArray +} + +export type Output = { + readonly operations: ReadonlyArray + readonly files: ReadonlyArray<{ + readonly path: string + readonly content: string + }> +} + +export type Contract = { + readonly groups: ReadonlyArray +} + +export class GenerationError extends Schema.TaggedErrorClass()("GenerationError", { + reason: Schema.String, +}) { + override get message() { + return this.reason + } +} + +export type Endpoint = { + readonly group: string + readonly sourceGroup: string + readonly topLevel: boolean + readonly endpoint: HttpApiEndpoint.AnyWithProps + readonly params: Schema.Top | undefined + readonly query: Schema.Top | undefined + readonly headers: Schema.Top | undefined + readonly payloads: ReadonlyArray + readonly operation: Operation + readonly input: ReadonlyArray + readonly unwrapData: boolean + readonly errors: ReadonlyArray<{ readonly status: number; readonly schema: Schema.Top }> + readonly successes: ReadonlyArray + readonly effectPortable: boolean +} + +export type Group = { + readonly identifier: string + readonly sourceIdentifier: string + readonly module: string + readonly endpoints: ReadonlyArray +} + +type Slot = { + readonly name: string + readonly schema: Schema.Top +} + +const resolveHttpApiStatus = SchemaAST.resolveAt("httpApiStatus") +const resolveHttpApiEncoding = SchemaAST.resolveAt("~httpApiEncoding") +const resolveContentSchema = SchemaAST.resolveAt("contentSchema") +const Manifest = Schema.fromJsonString(Schema.Array(Schema.String)) +const manifestName = ".httpapi-codegen.json" + +export function compile( + api: HttpApi.HttpApi, + options?: { + readonly groupNames?: Readonly> + readonly endpointNames?: Readonly> + readonly omitEndpoints?: ReadonlySet + }, +): Contract { + const endpoints: Array = [] + const portable = new Map() + + HttpApi.reflect(api, { + onGroup() {}, + onEndpoint({ endpoint, errors, group, middleware }) { + if (options?.omitEndpoints?.has(endpoint.name)) return + const groupName = options?.groupNames?.[group.identifier] ?? group.identifier + const name = `${groupName}.${endpoint.name}` + const required = Array.from(middleware).find((item) => item.requiredForClient) + if (required !== undefined) { + throw new GenerationError({ reason: `Client middleware requires adapter: ${required.key}` }) + } + + const successSchemas = Array.from(endpoint.success) + if (successSchemas.length === 0) successSchemas.push(HttpApiSchema.NoContent) + if (successSchemas.length > 1) throw new GenerationError({ reason: `Multiple success schemas: ${name}` }) + + const params = normalizeTransport(endpoint.params, "params", endpoint, name) + const query = normalizeTransport(endpoint.query, "query", endpoint, name) + const headers = normalizeTransport(endpoint.headers, "headers", endpoint, name) + const sourcePayloads = Array.from(endpoint.payload.values()).flatMap(({ schemas }) => schemas) + if (sourcePayloads.length > 1) { + throw new GenerationError({ reason: `Multiple payload schemas: ${name}` }) + } + const payloads = sourcePayloads.map((schema) => normalizeTransport(schema, "payload", endpoint, name)!) + const success = normalizeTransport(successSchemas[0], "success", endpoint, name)! + const errorSchemas = Array.from(errors).flatMap(([status, schemas]) => + schemas.map((schema) => ({ status, ...normalizeTransport(schema, "error", endpoint, name)! })), + ) + const inputs = [ + ...inputFields(params?.schema, "params", name), + ...inputFields(query?.schema, "query", name), + ...inputFields(headers?.schema, "headers", name), + ...payloads.flatMap((item) => inputFields(item.schema, "payload", name)), + ] + const names = new Set() + for (const field of inputs) { + if (names.has(field.name)) throw new GenerationError({ reason: `Input field collision: ${field.name}` }) + names.add(field.name) + } + + const schemaPaths: Array = [ + ...(params === undefined ? [] : [[`${name}.params`, params.schema] as const]), + ...(query === undefined ? [] : [[`${name}.query`, query.schema] as const]), + ...(headers === undefined ? [] : [[`${name}.headers`, headers.schema] as const]), + ...payloads.map((item) => [`${name}.payload`, item.schema] as const), + ...responseSchemas(success.schema, `${name}.success`), + ...errorSchemas.map((item) => [`${name}.error.${item.status}`, item.schema] as const), + ] + const effectPortable = + [params, query, headers, ...payloads, success, ...errorSchemas].every( + (item) => item?.effectPortable !== false, + ) && streamEffectPortable(success.schema) + if (effectPortable) { + for (const [path, schema] of schemaPaths) assertPortable(schema, path, portable) + } + + endpoints.push({ + group: groupName, + sourceGroup: group.identifier, + topLevel: group.topLevel, + endpoint, + params: params?.schema, + query: query?.schema, + headers: headers?.schema, + payloads: payloads.map((item) => item.schema), + input: inputs, + unwrapData: isDataEnvelope(success.schema), + successes: [success.schema], + errors: errorSchemas.map((item) => ({ status: item.status, schema: item.schema })), + effectPortable, + operation: { + group: groupName, + name: options?.endpointNames?.[endpoint.name] ?? clientEndpointName(endpoint.name), + input: inputs.map(({ name, source }) => ({ name, source })), + inputMode: inputs.length === 0 ? "none" : inputs.every((field) => field.optional) ? "optional" : "required", + success: isStreamSchema(success.schema) + ? "stream" + : HttpApiSchema.isNoContent(success.schema.ast) + ? "void" + : "value", + errors: [ + ...new Set([ + ...errorSchemas.flatMap((item) => { + const identifier = SchemaAST.resolveIdentifier(item.schema.ast) + return identifier === undefined ? [] : [identifier] + }), + "ClientError", + ]), + ], + }, + }) + }, + }) + + const modules = new Set(["client", "client-error", "index"]) + const groups = Array.from( + Map.groupBy(endpoints, (endpoint) => endpoint.group), + ([identifier, endpoints], index) => { + if (new Set(endpoints.map((endpoint) => endpoint.sourceGroup)).size > 1) { + throw new GenerationError({ reason: `Client group name collision: ${identifier}` }) + } + const base = /^[A-Za-z0-9_-]+$/.test(identifier) ? identifier : `group-${index}` + const module = uniqueModule(base, index, modules) + modules.add(module.toLowerCase()) + return { identifier, sourceIdentifier: endpoints[0].sourceGroup, module, endpoints } + }, + ) + const publicNames = new Set() + for (const group of groups) { + const endpointNames = new Set() + for (const endpoint of group.endpoints) { + if (endpointNames.has(endpoint.operation.name)) { + throw new GenerationError({ + reason: `Client endpoint name collision: ${group.identifier}.${endpoint.operation.name}`, + }) + } + endpointNames.add(endpoint.operation.name) + } + const names = group.endpoints[0]?.topLevel ? group.endpoints.map((item) => item.operation.name) : [group.identifier] + for (const name of names) { + if (publicNames.has(name)) throw new GenerationError({ reason: `Client name collision: ${name}` }) + publicNames.add(name) + } + } + return { + groups, + } +} + +export function emitEffect(contract: Contract): Output { + const endpoint = contract.groups.flatMap((group) => group.endpoints).find((endpoint) => !endpoint.effectPortable) + if (endpoint !== undefined) { + throw new GenerationError({ + reason: `Effect schema requires authoritative import: ${endpoint.group}.${endpoint.endpoint.name}`, + }) + } + return { operations: operations(contract.groups), files: renderEffectFiles(contract.groups) } +} + +export function emitEffectImported( + contract: Contract, + options: + | { readonly module: string; readonly api: string } + | { readonly module: string; readonly group: string } + | { readonly module: string; readonly endpoints: Readonly> }, +): Output { + return { + operations: operations(contract.groups), + files: renderImportedEffectFiles(contract.groups, options), + } +} + +export function emitPromise( + contract: Contract, + options?: { + readonly outputTypes?: Readonly> + }, +): Output { + const groups = contract.groups + for (const group of groups) { + for (const endpoint of group.endpoints) assertPromiseEndpoint(endpoint) + } + return { + operations: operations(groups), + files: [ + { path: "types.ts", content: renderPromiseTypes(groups, options?.outputTypes) }, + { + path: "client-error.ts", + content: `export type ClientErrorReason = "Transport" | "UnexpectedStatus" | "UnsupportedContentType" | "MalformedResponse"\n\nexport class ClientError extends Error {\n override readonly name = "ClientError"\n constructor(readonly reason: ClientErrorReason, options?: ErrorOptions) {\n super(reason, options)\n }\n}\n`, + }, + { + path: "client.ts", + content: renderPromiseClient(groups).replace("let next: ReadableStreamReadResult", "let next"), + }, + { + path: "index.ts", + content: + 'export { ClientError, type ClientErrorReason } from "./client-error"\nexport * as OpenCode from "./client"\nexport * from "./types"\n', + }, + ], + } +} + +function assertPromiseEndpoint(endpoint: Endpoint) { + const name = `${endpoint.group}.${endpoint.endpoint.name}` + const payload = endpoint.payloads[0] + const payloadEncoding = payload === undefined ? undefined : resolveHttpApiEncoding(payload.ast) + if ( + payload !== undefined && + (payloadEncoding?._tag ?? (HttpMethod.hasBody(endpoint.endpoint.method) ? "Json" : "FormUrlEncoded")) !== "Json" + ) { + throw new GenerationError({ reason: `Unsupported Promise payload encoding: ${name}` }) + } + const success = endpoint.successes[0] + if (isStreamSchema(success)) { + if ( + success._tag !== "StreamSse" || + success.sseMode !== "data" || + !SchemaAST.isNever(Schema.toType(success.error).ast) + ) { + throw new GenerationError({ reason: `Unsupported Promise stream: ${name}` }) + } + } else if ( + !HttpApiSchema.isNoContent(success.ast) && + (resolveHttpApiEncoding(success.ast)?._tag ?? "Json") !== "Json" + ) { + throw new GenerationError({ reason: `Unsupported Promise success encoding: ${name}` }) + } + for (const error of endpoint.errors) { + if (declaredErrorFields(error.schema) === undefined) { + throw new GenerationError({ reason: `Promise error must have a literal discriminator: ${name}` }) + } + if ((resolveHttpApiEncoding(error.schema.ast)?._tag ?? "Json") !== "Json") { + throw new GenerationError({ reason: `Unsupported Promise error encoding: ${name}` }) + } + } +} + +function operations(groups: ReadonlyArray) { + return groups.flatMap((group) => group.endpoints.map((endpoint) => endpoint.operation)) +} + +function renderEffectFiles(groups: ReadonlyArray): Output["files"] { + return [ + ...groups.map((group, index) => ({ path: `${group.module}.ts`, content: renderGroup(group, index) })), + { + path: "client-error.ts", + content: + 'import { Schema } from "effect"\n\nexport class ClientError extends Schema.TaggedErrorClass()("ClientError", {\n cause: Schema.Defect(),\n}) {}\n', + }, + { path: "client.ts", content: renderClient(groups) }, + { + path: "index.ts", + content: 'export { ClientError } from "./client-error"\nexport * as OpenCode from "./client"\n', + }, + ] +} + +function renderImportedEffectFiles( + groups: ReadonlyArray, + options: + | { readonly module: string; readonly api: string } + | { readonly module: string; readonly group: string } + | { readonly module: string; readonly endpoints: Readonly> }, +): Output["files"] { + const adapters = groups.map((group, groupIndex) => { + const rawGroup = group.endpoints[0]?.topLevel ? "RawClient" : `RawClient[${JSON.stringify(group.sourceIdentifier)}]` + const methods = group.endpoints.map((item, endpointIndex) => { + const prefix = `Endpoint${groupIndex}_${endpointIndex}` + const request = (["params", "query", "headers", "payload"] as const) + .flatMap((source) => { + const fields = item.input.filter((field) => field.source === source) + if (fields.length === 0) return [] + return [ + `${source}: { ${fields.map((field) => `${JSON.stringify(field.name)}: input${item.operation.inputMode === "optional" ? "?." : ""}[${JSON.stringify(field.name)}]`).join(", ")} }`, + ] + }) + .join(", ") + const input = item.input + .map( + (field) => + `readonly ${JSON.stringify(field.name)}${field.optional ? "?" : ""}: ${prefix}Request[${JSON.stringify(field.source)}][${JSON.stringify(field.name)}]`, + ) + .join("; ") + const argument = + item.operation.inputMode === "none" + ? "" + : `input${item.operation.inputMode === "optional" ? "?" : ""}: ${prefix}Input` + const rawCall = `raw[${JSON.stringify(item.endpoint.name)}]({ ${request} })` + const mapped = `${rawCall}.pipe(Effect.mapError(mapClientError)${item.unwrapData ? ", Effect.map((value) => value.data)" : ""})` + return `${item.operation.inputMode === "none" ? "" : `type ${prefix}Request = Parameters<${rawGroup}[${JSON.stringify(item.endpoint.name)}]>[0]\ntype ${prefix}Input = { ${input} }\n`}const ${prefix} = (raw: ${rawGroup}) => (${argument}) => ${item.operation.success === "stream" ? `Stream.unwrap(${rawCall}.pipe(Effect.mapError(mapClientError), Effect.map((stream) => stream.pipe(Stream.mapError(mapClientError)))))` : mapped}` + }) + return `${methods.join("\n\n")}\n\nconst adaptGroup${groupIndex} = (raw: ${rawGroup}) => ({ ${group.endpoints.map((item, endpointIndex) => `${JSON.stringify(item.operation.name)}: Endpoint${groupIndex}_${endpointIndex}(raw)`).join(", ")} })` + }) + const fields = groups.flatMap((group, index) => + group.endpoints[0]?.topLevel + ? [`...adaptGroup${index}(raw)`] + : [`${JSON.stringify(group.identifier)}: adaptGroup${index}(raw[${JSON.stringify(group.sourceIdentifier)}])`], + ) + const usesStream = groups.some((group) => group.endpoints.some((item) => item.operation.success === "stream")) + const imported = "api" in options + const projection = imported + ? undefined + : "group" in options + ? renderImportedGroup(options.group) + : renderImportedProjection(groups, options.endpoints) + const api = imported ? options.api : "Api" + const imports = + projection === undefined + ? `import { ${api} } from ${JSON.stringify(options.module)}` + : `import { HttpApi, HttpApiClient${"endpoints" in options ? ", HttpApiGroup" : ""} } from "effect/unstable/httpapi"\nimport { ${projection.imports.join(", ")} } from ${JSON.stringify(options.module)}` + const httpApiImport = projection === undefined ? 'import { HttpApiClient } from "effect/unstable/httpapi"\n' : "" + const client = `// Generated by @opencode-ai/httpapi-codegen. Do not edit.\nimport { Effect${usesStream ? ", Stream" : ""}, Schema } from "effect"\nimport { Sse } from "effect/unstable/encoding"\nimport { HttpClientError } from "effect/unstable/http"\n${httpApiImport}${imports}\nimport { ClientError } from "./client-error"\n\n${projection?.source ?? ""}type RawClient = HttpApiClient.ForApi\n\nconst mapClientError = (error: E) => HttpClientError.isHttpClientError(error) || Schema.isSchemaError(error) || Sse.Retry.is(error) ? new ClientError({ cause: error }) : error\n\n${adapters.join("\n\n")}\n\nconst adaptClient = (raw: RawClient) => ({ ${fields.join(", ")} })\n\nexport const make = (options?: { readonly baseUrl?: URL | string }) => HttpApiClient.make(${api}, options).pipe(Effect.map(adaptClient))\n` + return [ + { + path: "client-error.ts", + content: + 'import { Schema } from "effect"\n\nexport class ClientError extends Schema.TaggedErrorClass()("ClientError", {\n cause: Schema.Defect(),\n}) {}\n', + }, + { path: "client.ts", content: client }, + { + path: "index.ts", + content: 'export { ClientError } from "./client-error"\nexport * as OpenCode from "./client"\n', + }, + ] +} + +function renderImportedGroup(group: string) { + return { + imports: [group], + source: `const Api = HttpApi.make("generated").add(${group})\n\n`, + } +} + +function renderImportedProjection(groups: ReadonlyArray, endpoints: Readonly>) { + const imports = groups.flatMap((group) => + group.endpoints.map((endpoint) => { + const name = endpoints[`${group.identifier}.${endpoint.endpoint.name}`] + if (name === undefined) { + throw new GenerationError({ + reason: `Missing imported endpoint: ${group.identifier}.${endpoint.endpoint.name}`, + }) + } + return name + }), + ) + const source = `const Api = HttpApi.make("generated").${groups + .map((group) => { + const options = group.endpoints[0]?.topLevel ? ", { topLevel: true }" : "" + return `add(HttpApiGroup.make(${JSON.stringify(group.identifier)}${options})${group.endpoints.map((endpoint) => `.add(${endpoints[`${group.identifier}.${endpoint.endpoint.name}`]})`).join("")})` + }) + .join(".")}\n\n` + return { imports: [...new Set(imports)], source } +} + +function renderPromiseTypes( + groups: ReadonlyArray, + outputTypes?: Readonly>, +) { + const types = new Map() + const typeOf = (schema: Schema.Top, decoded = false) => { + const projected = decoded ? Schema.toType(schema) : Schema.toEncoded(schema) + const cached = types.get(projected.ast) + if (cached !== undefined) return cached + const type = structuralType(projected) + types.set(projected.ast, type) + return type + } + const errors = new Map( + groups.flatMap((group) => + group.endpoints.flatMap((endpoint) => + endpoint.errors.flatMap((error) => { + const tagged = declaredErrorFields(error.schema) + return tagged === undefined ? [] : [[tagged.tag, tagged] as const] + }), + ), + ), + ) + const errorTypes = Array.from(errors.values()).map((error) => { + const fields = error.fields + .map(([name, schema, optional]) => `readonly ${JSON.stringify(name)}${optional ? "?" : ""}: ${typeOf(schema)}`) + .join("; ") + return `export type ${error.identifier} = { readonly ${JSON.stringify(error.key)}: ${JSON.stringify(error.tag)}; ${fields} }\nexport const is${error.identifier} = (value: unknown): value is ${error.identifier} => typeof value === "object" && value !== null && ${JSON.stringify(error.key)} in value && value[${JSON.stringify(error.key)}] === ${JSON.stringify(error.tag)}` + }) + const operations = groups + .flatMap((group) => + group.endpoints.flatMap((endpoint) => { + const prefix = promiseTypePrefix(group.identifier, endpoint.operation.name) + const schemas = { + params: endpoint.params, + query: endpoint.query, + headers: endpoint.headers, + payload: endpoint.payloads[0], + } + const input = endpoint.input + .map((field) => { + const schema = schemas[field.source] + if (schema === undefined) + throw new GenerationError({ reason: `Missing input schema: ${prefix}.${field.name}` }) + return `readonly ${JSON.stringify(field.name)}${field.optional ? "?" : ""}: (${typeOf(schema, field.source === "query")})[${JSON.stringify(field.name)}]` + }) + .join("; ") + const successSchema = endpoint.successes[0] + const success = + outputTypes?.[`${group.identifier}.${endpoint.operation.name}`]?.name ?? + typeOf( + isStreamSchema(successSchema) && successSchema._tag === "StreamSse" + ? successSchema.sseMode === "data" + ? streamEncodedDataSchema(successSchema) + : successSchema.events + : successSchema, + ) + return [ + ...(endpoint.operation.inputMode === "none" ? [] : [`export type ${prefix}Input = { ${input} }`]), + `export type ${prefix}Output = ${endpoint.unwrapData ? `(${success})["data"]` : success}`, + ] + }), + ) + .join("\n\n") + const json = operations.includes("JsonValue") + ? "export type JsonValue = null | boolean | number | string | ReadonlyArray | { readonly [key: string]: JsonValue }" + : "" + const imports = [...new Set(Object.values(outputTypes ?? {}).map((override) => override.import))] + return [...imports, json, ...errorTypes, operations].filter(Boolean).join("\n\n") +} + +function renderPromiseClient(groups: ReadonlyArray) { + const imports = groups.flatMap((group) => + group.endpoints.flatMap((endpoint) => { + const prefix = promiseTypePrefix(group.identifier, endpoint.operation.name) + return [...(endpoint.operation.inputMode === "none" ? [] : [`${prefix}Input`]), `${prefix}Output`] + }), + ) + const fields = groups.map((group) => { + const methods = group.endpoints.map((endpoint) => { + const prefix = promiseTypePrefix(group.identifier, endpoint.operation.name) + const argument = + endpoint.operation.inputMode === "none" + ? "requestOptions?: RequestOptions" + : `input${endpoint.operation.inputMode === "optional" ? "?" : ""}: ${prefix}Input, requestOptions?: RequestOptions` + const path = promisePath(endpoint.endpoint.path, endpoint.input) + const access = (name: string) => + `input${endpoint.operation.inputMode === "optional" ? "?." : ""}[${JSON.stringify(name)}]` + const part = (source: InputField["source"]) => { + const inputs = endpoint.input.filter((field) => field.source === source) + return inputs.length === 0 + ? undefined + : `{ ${inputs.map((field) => `${JSON.stringify(field.name)}: ${access(field.name)}`).join(", ")} }` + } + const parts = [ + endpoint.query === undefined ? undefined : `query: ${part("query")}`, + endpoint.headers === undefined ? undefined : `headers: ${part("headers")}`, + endpoint.payloads.length === 0 ? undefined : `body: ${part("payload")}`, + ].filter((value): value is string => value !== undefined) + const declaredStatuses = [...new Set(endpoint.errors.map((error) => error.status))] + const descriptor = `{ method: ${JSON.stringify(endpoint.endpoint.method)}, path: ${path}${parts.length === 0 ? "" : `, ${parts.join(", ")}`}, successStatus: ${resolveHttpApiStatus(endpoint.successes[0].ast) ?? 200}, declaredStatuses: [${declaredStatuses.join(", ")}], empty: ${endpoint.operation.success === "void"} }` + if (endpoint.operation.success === "stream") { + const success = endpoint.successes[0] + if (!isStreamSchema(success) || success._tag !== "StreamSse" || success.sseMode !== "data") { + throw new GenerationError({ + reason: `Promise stream emission is not implemented: ${group.identifier}.${endpoint.endpoint.name}`, + }) + } + return `${JSON.stringify(endpoint.operation.name)}: (${argument}): AsyncIterable<${prefix}Output> => sse<${prefix}Output>(${descriptor}, requestOptions)` + } + const unwrap = endpoint.unwrapData ? ".then((value) => value.data)" : "" + return `${JSON.stringify(endpoint.operation.name)}: (${argument}) => request<${endpoint.unwrapData ? `{ readonly data: ${prefix}Output }` : `${prefix}Output`}>(${descriptor}, requestOptions)${unwrap}` + }) + if (group.endpoints[0]?.topLevel) return methods.join(", ") + return `${JSON.stringify(group.identifier)}: { ${methods.join(", ")} }` + }) + return `import type { ${imports.join(", ")} } from "./types"\nimport { ClientError } from "./client-error"\n\nexport interface ClientOptions {\n readonly baseUrl: string\n readonly fetch?: typeof globalThis.fetch\n readonly headers?: HeadersInit\n}\n\nexport interface RequestOptions {\n readonly signal?: AbortSignal\n readonly headers?: HeadersInit\n}\n\ninterface RequestDescriptor {\n readonly method: string\n readonly path: string\n readonly query?: Record\n readonly headers?: Record\n readonly body?: unknown\n readonly successStatus: number\n readonly declaredStatuses: ReadonlyArray\n readonly empty: boolean\n}\n\nexport function make(options: ClientOptions) {\n const fetch = options.fetch ?? globalThis.fetch\n\n const prepare = (descriptor: RequestDescriptor, requestOptions?: RequestOptions) => {\n const url = new URL(descriptor.path, options.baseUrl)\n for (const [key, value] of Object.entries(descriptor.query ?? {})) appendQuery(url.searchParams, key, value)\n const headers = new Headers(options.headers)\n for (const [key, value] of Object.entries(descriptor.headers ?? {})) {\n if (value !== undefined && value !== null) headers.set(key, String(value))\n }\n for (const [key, value] of new Headers(requestOptions?.headers)) headers.set(key, value)\n if (descriptor.body !== undefined && !headers.has("content-type")) headers.set("content-type", "application/json")\n return {\n url,\n init: {\n method: descriptor.method,\n signal: requestOptions?.signal,\n headers,\n body: descriptor.body === undefined ? undefined : JSON.stringify(descriptor.body),\n } satisfies RequestInit,\n }\n }\n\n const execute = async (descriptor: RequestDescriptor, requestOptions?: RequestOptions) => {\n try {\n const prepared = prepare(descriptor, requestOptions)\n return await fetch(prepared.url, prepared.init)\n } catch (cause) {\n throw new ClientError("Transport", { cause })\n }\n }\n\n const responseError = async (response: Response, descriptor: RequestDescriptor): Promise => {\n if (descriptor.declaredStatuses.includes(response.status)) throw await json(response)\n try {\n await response.body?.cancel()\n } catch {}\n throw new ClientError("UnexpectedStatus", { cause: { status: response.status } })\n }\n\n const request = async (descriptor: RequestDescriptor, requestOptions?: RequestOptions): Promise => {\n const response = await execute(descriptor, requestOptions)\n if (response.status !== descriptor.successStatus) return responseError(response, descriptor)\n if (descriptor.empty) {\n try {\n await response.body?.cancel()\n } catch {}\n return undefined as A\n }\n return await json(response) as A\n }\n\n const sse = (descriptor: RequestDescriptor, requestOptions?: RequestOptions): AsyncIterable => ({\n async *[Symbol.asyncIterator]() {\n const response = await execute(descriptor, requestOptions)\n if (response.status !== descriptor.successStatus) await responseError(response, descriptor)\n if (!isContentType(response, "text/event-stream")) {\n try {\n await response.body?.cancel()\n } catch {}\n throw new ClientError("UnsupportedContentType")\n }\n if (response.body === null) throw new ClientError("MalformedResponse")\n const reader = response.body.getReader()\n const decoder = new TextDecoder()\n let buffer = ""\n try {\n while (true) {\n let next: ReadableStreamReadResult\n try {\n next = await reader.read()\n } catch (cause) {\n throw new ClientError("Transport", { cause })\n }\n buffer += decoder.decode(next.value, { stream: !next.done })\n if (buffer.length > 1_048_576) throw new ClientError("MalformedResponse")\n const trailingCarriageReturn = !next.done && buffer.endsWith("\\r")\n if (trailingCarriageReturn) buffer = buffer.slice(0, -1)\n buffer = buffer.replaceAll("\\r\\n", "\\n").replaceAll("\\r", "\\n")\n if (trailingCarriageReturn) buffer += "\\r"\n if (next.done && buffer !== "") buffer += "\\n\\n"\n let boundary = buffer.indexOf("\\n\\n")\n while (boundary >= 0) {\n const block = buffer.slice(0, boundary)\n buffer = buffer.slice(boundary + 2)\n const data = block.split("\\n").flatMap((line) => line.startsWith("data:") ? [line.slice(5).trimStart()] : []).join("\\n")\n if (data !== "") {\n try {\n yield JSON.parse(data) as A\n } catch (cause) {\n throw new ClientError("MalformedResponse", { cause })\n }\n }\n boundary = buffer.indexOf("\\n\\n")\n }\n if (next.done) return\n }\n } finally {\n try {\n await reader.cancel()\n } catch {}\n reader.releaseLock()\n }\n },\n })\n\n return { ${fields.join(", ")} }\n}\n\nfunction appendQuery(params: URLSearchParams, key: string, value: unknown): void {\n if (value === undefined || value === null) return\n if (Array.isArray(value)) {\n for (const item of value) appendQuery(params, key, item)\n return\n }\n if (typeof value === "object") {\n for (const [child, item] of Object.entries(value)) appendQuery(params, \`\${key}[\${child}]\`, item)\n return\n }\n params.append(key, String(value))\n}\n\nasync function json(response: Response): Promise {\n if (!isContentType(response, "application/json") && !response.headers.get("content-type")?.includes("+json")) {\n try {\n await response.body?.cancel()\n } catch {}\n throw new ClientError("UnsupportedContentType")\n }\n let text: string\n try {\n text = await response.text()\n } catch (cause) {\n throw new ClientError("Transport", { cause })\n }\n if (text === "") throw new ClientError("MalformedResponse")\n try {\n return JSON.parse(text)\n } catch (cause) {\n throw new ClientError("MalformedResponse", { cause })\n }\n}\n\nfunction isContentType(response: Response, expected: string) {\n return response.headers.get("content-type")?.split(";", 1)[0]?.trim().toLowerCase() === expected\n}\n` +} + +function promiseTypePrefix(group: string, endpoint: string) { + return `${identifierPart(group)}${identifierPart(endpoint)}` +} + +function clientEndpointName(name: string) { + return name.slice(name.lastIndexOf(".") + 1) +} + +function identifierPart(value: string) { + return value + .split(/[^A-Za-z0-9]+/) + .filter(Boolean) + .map((part) => `${part[0]?.toUpperCase() ?? ""}${part.slice(1)}`) + .join("") +} + +function structuralType(schema: Schema.Top) { + const document = SchemaRepresentation.toCodeDocument(SchemaRepresentation.fromASTs([schema.ast])) + if ( + document.artifacts.some( + (artifact) => + artifact._tag !== "Import" || artifact.importDeclaration !== 'import type * as Brand from "effect/Brand"', + ) || + Object.keys(document.references.recursives).length > 0 + ) { + throw new GenerationError({ reason: "Referenced Promise types are not implemented" }) + } + const references = new Map( + document.references.nonRecursives.map((reference) => [reference.$ref, reference.code.Type]), + ) + const expand = (type: string, seen = new Set()): string => { + for (const [reference, value] of references) { + const pattern = `(?/g, "") + .replaceAll("Schema.Json", "JsonValue") +} + +function promisePath(path: string, input: ReadonlyArray) { + if (path.includes("*")) throw new GenerationError({ reason: `Unsupported Promise path wildcard: ${path}` }) + const fields = new Set(input.filter((field) => field.source === "params").map((field) => field.name)) + const segments = path.split(/(:[A-Za-z_][A-Za-z0-9_]*)/g).filter(Boolean) + const template = segments + .map((segment) => { + if (!segment.startsWith(":")) return segment.replaceAll("`", "\\`") + const name = segment.slice(1) + if (!fields.has(name)) throw new GenerationError({ reason: `Missing path parameter: ${name}` }) + return `\${encodeURIComponent(input.${name})}` + }) + .join("") + return `\`${template}\`` +} + +function uniqueModule(base: string, index: number, modules: ReadonlySet) { + if (!modules.has(base.toLowerCase())) return base + const seed = `${base}-${index}` + let suffix = 0 + while (modules.has(`${seed}${suffix === 0 ? "" : `-${suffix}`}`.toLowerCase())) suffix++ + return `${seed}${suffix === 0 ? "" : `-${suffix}`}` +} + +function normalizeTransport( + schema: Schema.Top | undefined, + source: InputField["source"] | "success" | "error", + endpoint: HttpApiEndpoint.AnyWithProps, + operation: string, +) { + if (schema === undefined) return undefined + if (isStreamSchema(schema)) return { schema, effectPortable: true } as const + if (!metadataPortable(schema.ast, new Set())) { + throw new GenerationError({ reason: `Unportable schema: ${operation}.${source}` }) + } + const decoded = Schema.toType(schema) + if (!isPathInput(endpoint.path)) { + throw new GenerationError({ reason: `Invalid endpoint path: ${operation}` }) + } + const rebuilt = HttpApiEndpoint.make(endpoint.method)(endpoint.name, endpoint.path, { + ...(source === "params" ? { params: decoded } : undefined), + ...(source === "query" ? { query: decoded } : undefined), + ...(source === "headers" ? { headers: decoded } : undefined), + ...(source === "payload" ? { payload: decoded } : undefined), + ...(source === "success" ? { success: decoded } : { success: Schema.String }), + ...(source === "error" ? { error: decoded } : undefined), + }) + const normalized = + source === "params" + ? rebuilt.params + : source === "query" + ? rebuilt.query + : source === "headers" + ? rebuilt.headers + : source === "payload" + ? Array.from(rebuilt.payload.values())[0]?.schemas[0] + : source === "success" + ? Array.from(rebuilt.success)[0] + : Array.from(rebuilt.error)[0] + if (normalized === undefined) throw new GenerationError({ reason: `Unportable schema: ${operation}.${source}` }) + if (!sameEncoding(schema.ast, normalized.ast)) return { schema, effectPortable: false } as const + return { schema: decoded, effectPortable: true } as const +} + +function isPathInput(path: string): path is HttpRouter.PathInput { + return path === "*" || path.startsWith("/") +} + +function sameEncoding(left: SchemaAST.AST, right: SchemaAST.AST): boolean { + if (left._tag !== right._tag || left.encoding?.length !== right.encoding?.length) return false + if ( + left.encoding?.some((link, index) => { + const other = right.encoding?.[index] + return other === undefined || link.transformation !== other.transformation || !sameEncoding(link.to, other.to) + }) + ) + return false + if (!sameChecks(left.checks, right.checks) || !sameContext(left.context, right.context)) return false + if (SchemaAST.isSuspend(left) && SchemaAST.isSuspend(right)) return sameEncoding(left.thunk(), right.thunk()) + if (SchemaAST.isUnion(left) && SchemaAST.isUnion(right)) { + return ( + left.types.length === right.types.length && + left.types.every((ast, index) => sameEncoding(ast, right.types[index])) + ) + } + if (SchemaAST.isArrays(left) && SchemaAST.isArrays(right)) { + return ( + left.elements.length === right.elements.length && + left.rest.length === right.rest.length && + left.elements.every((ast, index) => sameEncoding(ast, right.elements[index])) && + left.rest.every((ast, index) => sameEncoding(ast, right.rest[index])) + ) + } + if (SchemaAST.isObjects(left) && SchemaAST.isObjects(right)) { + return ( + left.propertySignatures.length === right.propertySignatures.length && + left.indexSignatures.length === right.indexSignatures.length && + left.propertySignatures.every((field, index) => sameEncoding(field.type, right.propertySignatures[index].type)) && + left.indexSignatures.every( + (field, index) => + sameEncoding(field.parameter, right.indexSignatures[index].parameter) && + sameEncoding(field.type, right.indexSignatures[index].type), + ) + ) + } + return true +} + +function sameChecks(left: SchemaAST.Checks | undefined, right: SchemaAST.Checks | undefined): boolean { + if (left?.length !== right?.length) return false + if (left === undefined || right === undefined) return true + return left.every((check, index) => { + const other = right[index] + if (other === undefined || check._tag !== other._tag) return false + if (check._tag === "Filter" && other._tag === "Filter") { + return check.run === other.run && check.aborted === other.aborted + } + return check._tag === "FilterGroup" && other._tag === "FilterGroup" && sameChecks(check.checks, other.checks) + }) +} + +function sameContext(left: SchemaAST.Context | undefined, right: SchemaAST.Context | undefined) { + return left?.isOptional === right?.isOptional && left?.isMutable === right?.isMutable +} + +export function write( + output: Output, + directory: string, +): Effect.Effect { + return Effect.gen(function* () { + const paths = new Set() + const normalizedPaths = new Set() + for (const file of output.files) { + if (!isSafeOutputPath(file.path)) yield* new GenerationError({ reason: `Unsafe output path: ${file.path}` }) + const path = file.path.toLowerCase() + if (normalizedPaths.has(path)) yield* new GenerationError({ reason: `Duplicate output path: ${file.path}` }) + normalizedPaths.add(path) + paths.add(file.path) + } + const fs = yield* FileSystem.FileSystem + yield* fs.makeDirectory(directory, { recursive: true }) + const manifest = join(directory, manifestName) + const previous = (yield* fs.exists(manifest)) + ? yield* fs.readFileString(manifest).pipe( + Effect.flatMap(Schema.decodeUnknownEffect(Manifest)), + Effect.mapError(() => new GenerationError({ reason: `Invalid generated file manifest: ${manifest}` })), + ) + : [] + if (previous.some((path) => !isSafeOutputPath(path))) { + yield* new GenerationError({ reason: `Invalid generated file manifest: ${manifest}` }) + } + yield* Effect.forEach( + previous.filter((path) => !paths.has(path)), + (path) => fs.remove(join(directory, path), { force: true }), + { concurrency: 8, discard: true }, + ) + yield* Effect.forEach( + output.files, + (file) => + fs.exists(join(directory, file.path)).pipe( + Effect.flatMap((exists) => (exists ? fs.stat(join(directory, file.path)) : Effect.succeed(undefined))), + Effect.flatMap((info) => + info?.type === "SymbolicLink" + ? new GenerationError({ reason: `Unsafe output path: ${file.path}` }) + : Effect.void, + ), + ), + { concurrency: 8, discard: true }, + ) + yield* Effect.forEach( + output.files, + (file) => + Effect.tryPromise({ + try: () => format(file.content, { filepath: file.path, parser: "typescript", semi: false, printWidth: 120 }), + catch: (error) => new GenerationError({ reason: `Failed to format ${file.path}: ${String(error)}` }), + }).pipe(Effect.flatMap((content) => fs.writeFileString(join(directory, file.path), content))), + { concurrency: 8, discard: true }, + ) + yield* fs.writeFileString(manifest, JSON.stringify(output.files.map((file) => file.path).sort(), null, 2) + "\n") + }) +} + +function isSafeOutputPath(path: string) { + return path !== manifestName && !isAbsolute(path) && path !== "." && path !== ".." && !/[\\/]/.test(path) +} + +export function generate( + api: HttpApi.HttpApi, + options: { readonly directory: string }, +): Effect.Effect { + return Effect.try({ + try: () => emitEffect(compile(api)), + catch: (error) => (error instanceof GenerationError ? error : new GenerationError({ reason: String(error) })), + }).pipe(Effect.flatMap((output) => write(output, options.directory))) +} + +function inputFields(schema: Schema.Top | undefined, source: InputField["source"], operation: string) { + if (schema === undefined) return [] + const ast = Schema.toType(schema).ast + if (!SchemaAST.isObjects(ast) || ast.indexSignatures.length > 0) { + throw new GenerationError({ reason: `Input schema must be a struct: ${operation}.${source}` }) + } + return ast.propertySignatures.map((field) => { + if (typeof field.name !== "string") { + throw new GenerationError({ reason: `Input field must have a string name: ${operation}.${source}` }) + } + return { + name: field.name, + source, + optional: SchemaAST.isOptional(field.type), + } + }) +} + +function responseSchemas(schema: Schema.Top, path: string): Array { + if (HttpApiSchema.isNoContent(schema.ast)) return [] + if (!isStreamSchema(schema)) return [[path, schema]] + if (schema._tag === "StreamUint8Array") return [] + const value = schema.sseMode === "data" ? streamDataSchema(schema) : schema.events + return [ + [`${path}.${schema.sseMode}`, value], + [`${path}.error`, schema.error], + ] +} + +function assertPortable(schema: Schema.Top, path: string, portable: Map) { + const visiting = new Set() + const taggedError = taggedErrorFields(schema) + const visit = (ast: SchemaAST.AST): boolean => { + const cached = portable.get(ast) + if (cached !== undefined) return cached + if (visiting.has(ast)) return true + visiting.add(ast) + const result = visitCurrent(ast) + visiting.delete(ast) + portable.set(ast, result) + return result + } + const visitCurrent = (ast: SchemaAST.AST): boolean => { + if (!annotationsPortable(ast.annotations)) return false + if (!checksPortable(ast.checks) || ("encodingChecks" in ast && !checksPortable(ast.encodingChecks))) return false + if (SchemaAST.isDeclaration(ast)) { + return generationPortable(ast.annotations?.generation) && ast.typeParameters.every(visit) + } + if (ast.encoding !== undefined && ast.annotations?.generation === undefined) return false + if (SchemaAST.isSuspend(ast)) return visit(ast.thunk()) + if (SchemaAST.isUnion(ast)) return ast.types.every(visit) + if (SchemaAST.isArrays(ast)) { + return ast.elements.every(visit) && ast.rest.every(visit) + } + if (SchemaAST.isObjects(ast)) { + return ( + ast.propertySignatures.every((field) => visit(field.type)) && + ast.indexSignatures.every((index) => visit(index.parameter) && visit(index.type)) + ) + } + if (SchemaAST.isTemplateLiteral(ast)) return ast.parts.every(visit) + return true + } + if (taggedError !== undefined && SchemaAST.isDeclaration(schema.ast)) { + if ( + schema.ast.checks !== undefined || + ("encodingChecks" in schema.ast && !checksPortable(schema.ast.encodingChecks)) || + schema.ast.typeParameters.some((ast) => ast.checks !== undefined) || + !schema.ast.typeParameters.every(visit) + ) { + throw new GenerationError({ reason: `Unportable schema: ${path}` }) + } + return + } + if (!visit(schema.ast)) throw new GenerationError({ reason: `Unportable schema: ${path}` }) +} + +function checksPortable(checks: SchemaAST.Checks | undefined): boolean { + if (checks === undefined) return true + return checks.every((check) => + check._tag === "Filter" + ? !check.aborted && + check.annotations?.meta !== undefined && + typeof check.annotations.arbitrary === "object" && + check.annotations.arbitrary !== null && + "constraint" in check.annotations.arbitrary + : checksPortable(check.checks), + ) +} + +function metadataPortable(ast: SchemaAST.AST, seen: Set): boolean { + if (seen.has(ast)) return true + seen.add(ast) + if (!annotationsPortable(ast.annotations) || !checksPortable(ast.checks)) return false + if ("encodingChecks" in ast && !checksPortable(ast.encodingChecks)) return false + if (ast.encoding?.some((link) => !metadataPortable(link.to, seen))) return false + if (SchemaAST.isDeclaration(ast)) return ast.typeParameters.every((item) => metadataPortable(item, seen)) + if (SchemaAST.isSuspend(ast)) return metadataPortable(ast.thunk(), seen) + if (SchemaAST.isUnion(ast)) return ast.types.every((item) => metadataPortable(item, seen)) + if (SchemaAST.isArrays(ast)) { + return ( + ast.elements.every((item) => metadataPortable(item, seen)) && + ast.rest.every((item) => metadataPortable(item, seen)) + ) + } + if (SchemaAST.isObjects(ast)) { + return ( + ast.propertySignatures.every((field) => metadataPortable(field.type, seen)) && + ast.indexSignatures.every( + (field) => metadataPortable(field.parameter, seen) && metadataPortable(field.type, seen), + ) + ) + } + return true +} + +function generationPortable(generation: unknown): boolean { + if (typeof generation !== "object" || generation === null) return false + const value = generation as { + readonly runtime?: unknown + readonly Type?: unknown + readonly importDeclaration?: unknown + } + if (typeof value.runtime !== "string" || typeof value.Type !== "string") return false + if (value.importDeclaration !== undefined) { + if ( + typeof value.importDeclaration !== "string" || + !/from ["']effect(?:\/[^"']+)?["']$/.test(value.importDeclaration) + ) { + return false + } + } + const namespace = + typeof value.importDeclaration === "string" + ? /import(?: type)? \* as ([A-Za-z_$][\w$]*)/.exec(value.importDeclaration)?.[1] + : undefined + return value.runtime.startsWith("Schema.") || (namespace !== undefined && value.runtime.startsWith(`${namespace}.`)) +} + +function annotationsPortable(annotations: Schema.Annotations.Annotations | undefined) { + if (annotations === undefined) return true + return Object.entries(annotations).every(([key, value]) => { + if ( + ["toCodec", "toCodecJson", "toArbitrary", "toFormatter", "toEquivalence", "~effect/Schema/Class"].includes(key) + ) { + return true + } + if (key === "generation") return generationPortable(value) + return serializable(value) + }) +} + +function serializable(value: unknown): boolean { + if (value === null || ["string", "number", "boolean"].includes(typeof value)) return true + if (Array.isArray(value)) return value.every(serializable) + if (typeof value !== "object") return false + return Object.values(value).every(serializable) +} + +function taggedErrorFields(schema: Schema.Top) { + const fields = declaredErrorFields(schema) + return fields?.key === "_tag" ? fields : undefined +} + +function declaredErrorFields(schema: Schema.Top) { + if (!SchemaAST.isDeclaration(schema.ast) || schema.ast.annotations?.["~effect/Schema/Class"] === undefined) { + return undefined + } + const fields = schema.ast.typeParameters[0] + if (!SchemaAST.isObjects(fields) || fields.indexSignatures.length > 0) return undefined + const key = fields.propertySignatures.find((field) => field.name === "_tag" || field.name === "name")?.name + if (key !== "_tag" && key !== "name") return undefined + const tag = fields.propertySignatures.find((field) => field.name === key)?.type + if (tag === undefined || !SchemaAST.isLiteral(tag) || typeof tag.literal !== "string") return undefined + return { + key, + tag: tag.literal, + identifier: SchemaAST.resolveIdentifier(schema.ast) ?? tag.literal, + fields: fields.propertySignatures.flatMap((field) => + field.name === key || typeof field.name !== "string" + ? [] + : [[field.name, Schema.make(field.type), SchemaAST.isOptional(field.type)] as const], + ), + } +} + +function isDataEnvelope(schema: Schema.Top) { + if (isStreamSchema(schema) || HttpApiSchema.isNoContent(schema.ast)) return false + const ast = Schema.toType(schema).ast + return ( + SchemaAST.isObjects(ast) && + ast.indexSignatures.length === 0 && + ast.propertySignatures.length === 1 && + ast.propertySignatures[0]?.name === "data" + ) +} + +function isStreamSchema(schema: Schema.Top): schema is HttpApiSchema.StreamSchema { + return "_tag" in schema && (schema._tag === "StreamSse" || schema._tag === "StreamUint8Array") +} + +function streamDataSchema(schema: Extract) { + return Schema.make(streamDataAst(Schema.toType(schema.events).ast)) +} + +function streamEncodedDataSchema(schema: Extract) { + const data = streamDataAst(schema.events.ast) + const encodedAst = data.encoding?.at(-1)?.to + if (encodedAst === undefined) throw new GenerationError({ reason: "Invalid SSE data schema" }) + const encoded = resolveContentSchema(encodedAst) + if (!SchemaAST.isAST(encoded)) throw new GenerationError({ reason: "Invalid SSE data schema" }) + return Schema.make(encoded) +} + +function streamDataAst(ast: SchemaAST.AST) { + if (!SchemaAST.isObjects(ast)) throw new GenerationError({ reason: "Invalid SSE data schema" }) + const data = ast.propertySignatures.find((field) => field.name === "data")?.type + if (data === undefined) throw new GenerationError({ reason: "Invalid SSE data schema" }) + return data +} + +function streamEffectPortable(schema: Schema.Top) { + if (!isStreamSchema(schema) || schema._tag === "StreamUint8Array" || schema.sseMode === "events") return true + const rebuilt = HttpApiSchema.StreamSse({ + data: streamDataSchema(schema), + error: schema.error, + contentType: schema.contentType, + }) + return sameEncoding(schema.events.ast, rebuilt.events.ast) +} + +function renderGroup(group: Group, groupIndex: number) { + const slots: Array = [] + const adapters: Array = [] + const endpointSources = group.endpoints.map((operation, endpointIndex) => { + const { + endpoint, + errors, + headers: endpointHeaders, + params: endpointParams, + payloads: endpointPayloads, + query: endpointQuery, + successes, + } = operation + const prefix = `Endpoint${endpointIndex}` + const params = addSlot(endpointParams, `${prefix}Params`) + const query = addSlot(endpointQuery, `${prefix}Query`) + const headers = addSlot(endpointHeaders, `${prefix}Headers`) + const payloads = endpointPayloads.map((schema, index) => addSlot(schema, `${prefix}Payload${index}`)!) + const success = renderSuccess(successes[0], `${prefix}Success`) + const errorSlots = errors.map((error, index) => addSlot(error.schema, `${prefix}Error${index}`)!) + const options = [ + params === undefined ? undefined : `params: ${params.name}`, + query === undefined ? undefined : `query: ${query.name}`, + headers === undefined ? undefined : `headers: ${headers.name}`, + payloads.length === 0 + ? undefined + : `payload: ${payloads.length === 1 ? payloads[0].name : `[${payloads.map((slot) => slot.name).join(", ")}]`}`, + `success: ${success.source}`, + errorSlots.length === 0 + ? undefined + : `error: ${errorSlots.length === 1 ? errorSlots[0].name : `[${errorSlots.map((slot) => slot.name).join(", ")}]`}`, + ].filter((option): option is string => option !== undefined) + const schemaBySource = { params, query, headers, payload: payloads[0] } + const inputType = operation.input + .map((field) => { + const slot = schemaBySource[field.source] + if (slot === undefined) { + throw new GenerationError({ reason: `Missing input schema: ${group.identifier}.${endpoint.name}` }) + } + return `readonly ${JSON.stringify(field.name)}${field.optional ? "?" : ""}: (typeof ${slot.name}.Type)[${JSON.stringify(field.name)}]` + }) + .join("; ") + const argument = + operation.operation.inputMode === "none" + ? "" + : `input${operation.operation.inputMode === "optional" ? "?" : ""}: ${prefix}Input` + const request = (["params", "query", "headers", "payload"] as const) + .flatMap((source) => { + const slot = schemaBySource[source] + if (slot === undefined) return [] + const fields = operation.input + .filter((field) => field.source === source) + .map( + (field) => + `${JSON.stringify(field.name)}: input${operation.operation.inputMode === "optional" ? "?." : ""}[${JSON.stringify(field.name)}]`, + ) + return [`${source}: { ${fields.join(", ")} }`] + }) + .join(", ") + const declared = [...errorSlots, ...(success.streamError === undefined ? [] : [success.streamError])] + const declaredSchema = + declared.length === 0 ? "Schema.Never" : `Schema.Union([${declared.map((slot) => slot.name).join(", ")}])` + const rawCall = `raw[${JSON.stringify(endpoint.name)}]({ ${request} })` + const mapped = `${rawCall}.pipe(Effect.mapError(map${prefix}Error)${operation.unwrapData ? ", Effect.map((value) => value.data)" : ""})` + const inputDeclaration = operation.operation.inputMode === "none" ? "" : `type ${prefix}Input = { ${inputType} }\n` + adapters.push( + `${inputDeclaration}const ${prefix}DeclaredError = ${declaredSchema}\nconst map${prefix}Error = (error: unknown) => HttpClientError.isHttpClientError(error) || Schema.isSchemaError(error) || Sse.Retry.is(error) ? new ClientError({ cause: error }) : Schema.is(${prefix}DeclaredError)(error) ? error : new ClientError({ cause: error })\nconst ${prefix} = (raw: RawGroup) => (${argument}) => ${operation.operation.success === "stream" ? `Stream.unwrap(${rawCall}.pipe(Effect.mapError(map${prefix}Error), Effect.map((stream) => stream.pipe(Stream.mapError(map${prefix}Error)))))` : mapped}`, + ) + return `HttpApiEndpoint.make(${JSON.stringify(endpoint.method)})(${JSON.stringify(endpoint.name)}, ${JSON.stringify(endpoint.path)}, { ${options.join(", ")} })` + }) + + function addSlot(schema: Schema.Top | undefined, name: string) { + if (schema === undefined) return undefined + const slot = { name, schema } + slots.push(slot) + return slot + } + + function renderSuccess(schema: Schema.Top, name: string) { + if (!isStreamSchema(schema)) return { source: addSlot(schema, name)!.name } + const status = resolveHttpApiStatus(schema.ast) ?? 200 + const annotate = status === 200 ? "" : `.pipe(HttpApiSchema.status(${status}))` + if (schema._tag === "StreamUint8Array") { + return { + source: `HttpApiSchema.StreamUint8Array({ contentType: ${JSON.stringify(schema.contentType)} })${annotate}`, + } + } + const value = addSlot( + schema.sseMode === "data" ? streamDataSchema(schema) : schema.events, + `${name}${schema.sseMode === "data" ? "Data" : "Events"}`, + )! + const error = addSlot(schema.error, `${name}Error`)! + return { + source: `HttpApiSchema.StreamSse({ ${schema.sseMode}: ${value.name}, error: ${error.name}, contentType: ${JSON.stringify(schema.contentType)} })${annotate}`, + streamError: error, + } + } + + const declarations = renderSchemas(slots) + const groupSource = `HttpApiGroup.make(${JSON.stringify(group.identifier)}, { topLevel: ${group.endpoints[0]?.topLevel ?? false} })${endpointSources.map((endpoint) => `.add(${endpoint})`).join("")}` + const usesHttpApiSchema = endpointSources.some((source) => source.includes("HttpApiSchema.")) + const methods = group.endpoints + .map((item, index) => `${JSON.stringify(item.operation.name)}: Endpoint${index}(raw)`) + .join(", ") + const rawGroup = group.endpoints[0]?.topLevel + ? `HttpApiClient.Client` + : `HttpApiClient.Client.Group` + const usesStream = group.endpoints.some((item) => item.operation.success === "stream") + return `// Generated by @opencode-ai/httpapi-codegen. Do not edit.\nimport { Effect, Schema${usesStream ? ", Stream" : ""} } from "effect"\nimport { Sse } from "effect/unstable/encoding"\nimport { HttpClientError } from "effect/unstable/http"\nimport { HttpApiClient, HttpApiEndpoint, HttpApiGroup${usesHttpApiSchema ? ", HttpApiSchema" : ""} } from "effect/unstable/httpapi"\nimport { ClientError } from "./client-error"\n\n${declarations}\n\nexport const Group${groupIndex} = ${groupSource}\n\ntype RawGroup = ${rawGroup}\n\n${adapters.join("\n\n")}\n\nexport const adaptGroup${groupIndex} = (raw: RawGroup) => ({ ${methods} })\n` +} + +function renderSchemas(slots: ReadonlyArray) { + if (slots.length === 0) return "" + const classes = new Map( + slots.flatMap((slot, index) => { + const tagged = taggedErrorFields(slot.schema) + return tagged === undefined ? [] : [[index, tagged] as const] + }), + ) + const expanded = [ + ...slots.map((slot, index) => (classes.has(index) ? { name: slot.name, schema: Schema.Never } : slot)), + ...Array.from(classes.values()).flatMap((tagged, classIndex) => + tagged.fields.map(([name, schema]) => ({ name: `Class${classIndex}${name}`, schema })), + ), + ] + const [first, ...rest] = expanded + const document = SchemaRepresentation.toCodeDocument( + SchemaRepresentation.fromASTs([first.schema.ast, ...rest.map((slot) => slot.schema.ast)]), + ) + const artifacts = document.artifacts.flatMap((artifact) => { + if (artifact._tag === "Import") return [artifact.importDeclaration] + if (artifact._tag === "Enum") return [artifact.generation.runtime] + return [`const ${artifact.identifier} = ${artifact.generation.runtime}`] + }) + const references = [ + ...document.references.nonRecursives.map(({ $ref, code }) => `const ${$ref} = ${code.runtime}`), + ...Object.entries(document.references.recursives).map( + ([$ref, code]) => `type ${$ref} = ${code.Type}\nconst ${$ref}: Schema.Codec<${$ref}> = ${code.runtime}`, + ), + ] + let fieldIndex = slots.length + const declarations = slots.map((slot, index) => { + const tagged = classes.get(index) + if (tagged === undefined) return `const ${slot.name} = ${document.codes[index].runtime}` + const fields = tagged.fields + .map(([name]) => `${JSON.stringify(name)}: ${document.codes[fieldIndex++].runtime}`) + .join(", ") + const annotations = Object.entries({ + httpApiStatus: resolveHttpApiStatus(slot.schema.ast), + "~httpApiEncoding": resolveHttpApiEncoding(slot.schema.ast), + }).filter((entry) => entry[1] !== undefined) + const annotate = + annotations.length === 0 + ? "" + : `.annotate({ ${annotations.map(([key, value]) => `${JSON.stringify(key)}: ${JSON.stringify(value)}`).join(", ")} })` + return `class ${slot.name}Class extends Schema.TaggedErrorClass<${slot.name}Class>(${JSON.stringify(tagged.identifier)})(${JSON.stringify(tagged.tag)}, { ${fields} }) {}\nconst ${slot.name} = ${slot.name}Class${annotate}` + }) + return [...artifacts, ...references, ...declarations].join("\n\n") +} + +function renderClient(groups: ReadonlyArray) { + const imports = groups + .map((group, index) => `import { adaptGroup${index}, Group${index} } from ${JSON.stringify(`./${group.module}`)}`) + .join("\n") + const api = `HttpApi.make("generated")${groups.map((_, index) => `.add(Group${index})`).join("")}` + const fields = groups.flatMap((group, index) => { + if (!group.endpoints[0]?.topLevel) { + return [`${JSON.stringify(group.identifier)}: adaptGroup${index}(raw[${JSON.stringify(group.identifier)}])`] + } + const raw = `{ ${group.endpoints.map((item) => `${JSON.stringify(item.endpoint.name)}: raw[${JSON.stringify(item.endpoint.name)}]`).join(", ")} }` + return [`...adaptGroup${index}(${raw})`] + }) + return `// Generated by @opencode-ai/httpapi-codegen. Do not edit.\nimport { Effect } from "effect"\nimport { HttpApi, HttpApiClient } from "effect/unstable/httpapi"\n${imports}\n\nconst Api = ${api}\nconst adaptClient = (raw: HttpApiClient.ForApi) => ({ ${fields.join(", ")} })\n\nexport const make = (options?: { readonly baseUrl?: URL | string }) =>\n HttpApiClient.make(Api, options).pipe(Effect.map(adaptClient))\n` +} diff --git a/packages/httpapi-codegen/test/effect.ts b/packages/httpapi-codegen/test/effect.ts new file mode 100644 index 0000000000000000000000000000000000000000..3accad3cc936ded195ed8dd1c49a0d3afad407ea --- /dev/null +++ b/packages/httpapi-codegen/test/effect.ts @@ -0,0 +1,28 @@ +import { test } from "bun:test" +import { Cause, Effect, Exit, Layer } from "effect" +import type { Scope } from "effect/Scope" +import { TestClock, TestConsole } from "effect/testing" + +type Body = Effect.Effect | (() => Effect.Effect) + +const layer = Layer.mergeAll(TestConsole.layer, TestClock.layer()) + +const effect = (name: string, body: Body, options?: Parameters[2]) => + test( + name, + () => + Effect.gen(function* () { + const exit = yield* Effect.suspend(() => (typeof body === "function" ? body() : body)).pipe( + Effect.scoped, + Effect.provide(layer), + Effect.exit, + ) + if (Exit.isFailure(exit)) { + yield* Effect.forEach(Cause.prettyErrors(exit.cause), Effect.logError, { discard: true }) + } + return yield* exit + }).pipe(Effect.runPromise), + options, + ) + +export const it = { effect } diff --git a/packages/httpapi-codegen/test/fixture.ts b/packages/httpapi-codegen/test/fixture.ts new file mode 100644 index 0000000000000000000000000000000000000000..9fb7fedda101c81ce0b4e8fb455a9dd9f014edb8 --- /dev/null +++ b/packages/httpapi-codegen/test/fixture.ts @@ -0,0 +1,45 @@ +import { Schema } from "effect" +import { HttpApi, HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "effect/unstable/httpapi" + +export class Missing extends Schema.TaggedErrorClass()("Missing", { + message: Schema.String, +}) {} + +export const Api = HttpApi.make("fixture") + .add( + HttpApiGroup.make("session") + .add(HttpApiEndpoint.get("health", "/session/health", { success: Schema.String })) + .add( + HttpApiEndpoint.get("list", "/session", { + query: { archived: Schema.optional(Schema.Boolean) }, + success: Schema.Array(Schema.String), + }), + ) + .add( + HttpApiEndpoint.get("get", "/session/:sessionID", { + params: { sessionID: Schema.String }, + success: Schema.Struct({ data: Schema.String }), + error: Missing.pipe(HttpApiSchema.status(404)), + }), + ) + .add( + HttpApiEndpoint.post("interrupt", "/session/:sessionID/interrupt", { + params: { sessionID: Schema.String }, + success: HttpApiSchema.NoContent, + }), + ), + ) + .add( + HttpApiGroup.make("event").add( + HttpApiEndpoint.get("subscribe", "/event", { + success: HttpApiSchema.StreamSse({ data: Schema.Struct({ type: Schema.String }) }).pipe( + HttpApiSchema.status(202), + ), + }), + ), + ) + .add( + HttpApiGroup.make("system", { topLevel: true }).add( + HttpApiEndpoint.get("status", "/status", { success: Schema.String }), + ), + ) diff --git a/packages/httpapi-codegen/test/generate.test.ts b/packages/httpapi-codegen/test/generate.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..6e076b7ef4e133b001b158ae372c7acd81aa3897 --- /dev/null +++ b/packages/httpapi-codegen/test/generate.test.ts @@ -0,0 +1,1022 @@ +import { describe, expect, test } from "bun:test" +import { mkdtemp, rm } from "node:fs/promises" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { Effect, FileSystem, Schema, SchemaAST, SchemaGetter } from "effect" +import { HttpApi, HttpApiEndpoint, HttpApiGroup, HttpApiMiddleware, HttpApiSchema } from "effect/unstable/httpapi" +import { format } from "prettier" +import { + compile as compileContract, + emitEffect, + emitEffectImported, + emitPromise, + generate, + GenerationError, +} from "../src" +import { it } from "./effect" +import { Api as FixtureApi, Missing } from "./fixture" + +function api(endpoint: HttpApiEndpoint.Any) { + return HttpApi.make("test").add(HttpApiGroup.make("session").add(endpoint)) +} + +function compile(source: HttpApi.HttpApi) { + return emitEffect(compileContract(source)) +} + +describe("HttpApiCodegen.generate", () => { + test("compiles one contract for Promise and Effect emitters", () => { + const contract = compileContract( + api( + HttpApiEndpoint.get("get", "/session/:sessionID", { + params: { sessionID: Schema.String }, + success: Schema.Struct({ data: Schema.String }), + }), + ), + ) + + const promise = emitPromise(contract) + const effect = emitEffect(contract) + + expect(promise.operations).toEqual(effect.operations) + expect(promise.files.map((file) => file.path)).toEqual(["types.ts", "client-error.ts", "client.ts", "index.ts"]) + const promiseClient = promise.files.find((file) => file.path === "client.ts")?.content + expect(promiseClient).toContain('"get": (input: SessionGetInput, requestOptions?: RequestOptions)') + expect(promiseClient).toContain("`/session/${encodeURIComponent(input.sessionID)}`") + expect(effect.files.find((file) => file.path === "session.ts")?.content).toContain( + 'params: { "sessionID": input["sessionID"] }', + ) + }) + + test("allows Promise outputs to use an authoritative imported wire type", () => { + const contract = compileContract( + api(HttpApiEndpoint.get("events", "/event", { success: HttpApiSchema.StreamSse({ data: Schema.Unknown }) })), + ) + const output = emitPromise(contract, { + outputTypes: { + "session.events": { + name: "EventWire", + import: 'import type { EventWire } from "./event-wire"', + }, + }, + }) + const types = output.files.find((file) => file.path === "types.ts")?.content + + expect(types).toContain('import type { EventWire } from "./event-wire"') + expect(types).toContain("export type SessionEventsOutput = EventWire") + }) + + test("emits an Effect client against an imported authoritative API", () => { + const output = emitEffectImported( + compileContract( + api( + HttpApiEndpoint.get("get", "/session/:sessionID", { + params: { sessionID: Schema.String }, + success: Schema.Struct({ data: Schema.String }), + }), + ), + ), + { module: "@example/api", api: "Api" }, + ) + + expect(output.files.map((file) => file.path)).toEqual(["client-error.ts", "client.ts", "index.ts"]) + expect(output.files.find((file) => file.path === "client.ts")?.content).toContain( + 'import { Api } from "@example/api"', + ) + expect(output.files.find((file) => file.path === "client.ts")?.content).toContain( + "HttpApiClient.ForApi", + ) + }) + + test("projects imported endpoint constants into a generated API", () => { + const output = emitEffectImported( + compileContract( + api( + HttpApiEndpoint.get("get", "/session/:sessionID", { + params: { sessionID: Schema.String }, + success: Schema.Struct({ data: Schema.String }), + }), + ), + ), + { module: "@example/api", endpoints: { "session.get": "SessionGet" } }, + ) + const client = output.files.find((file) => file.path === "client.ts")?.content + + expect(client).toContain('import { SessionGet } from "@example/api"') + expect(client).toContain('const Api = HttpApi.make("generated").add(HttpApiGroup.make("session").add(SessionGet))') + }) + + test("imports an authoritative group without reconstructing it", () => { + const output = emitEffectImported( + compileContract( + api( + HttpApiEndpoint.get("get", "/session/:sessionID", { + params: { sessionID: Schema.String }, + success: Schema.String, + }), + ), + ), + { module: "@example/api", group: "SessionGroup" }, + ) + const client = output.files.find((file) => file.path === "client.ts")?.content + + expect(client).toContain('import { SessionGroup } from "@example/api"') + expect(client).toContain('const Api = HttpApi.make("generated").add(SessionGroup)') + expect(client).not.toContain("HttpApiGroup") + }) + + test("separates hosted and consumer group names", () => { + const source = HttpApi.make("test").add( + HttpApiGroup.make("server.session").add( + HttpApiEndpoint.get("session.get", "/session", { success: Schema.String }), + ), + ) + const contract = compileContract(source, { groupNames: { "server.session": "sessions" } }) + + expect(contract.groups[0]?.identifier).toBe("sessions") + expect(contract.groups[0]?.sourceIdentifier).toBe("server.session") + expect(contract.groups[0]?.endpoints[0]?.operation).toMatchObject({ group: "sessions", name: "get" }) + }) + + test("supports explicit public endpoint names", () => { + const source = HttpApi.make("test").add( + HttpApiGroup.make("server.permission") + .add(HttpApiEndpoint.get("permission.request.list", "/request", { success: Schema.String })) + .add(HttpApiEndpoint.get("session.permission.list", "/session", { success: Schema.String })), + ) + const contract = compileContract(source, { + endpointNames: { "permission.request.list": "listRequests" }, + }) + + expect(contract.groups[0]?.endpoints.map((endpoint) => endpoint.operation.name)).toEqual(["listRequests", "list"]) + }) + + test("omits custom transport endpoints", () => { + const source = HttpApi.make("test").add( + HttpApiGroup.make("server.pty") + .add(HttpApiEndpoint.get("pty.get", "/pty", { success: Schema.String })) + .add(HttpApiEndpoint.get("pty.connect", "/pty/connect", { success: Schema.Boolean })), + ) + const contract = compileContract(source, { omitEndpoints: new Set(["pty.connect"]) }) + + expect(contract.groups[0]?.endpoints.map((endpoint) => endpoint.endpoint.name)).toEqual(["pty.get"]) + }) + + test("uses bracket access for input field names", () => { + const source = api( + HttpApiEndpoint.post("token", "/token", { + headers: { "x-example-token": Schema.Literal("1") }, + success: Schema.String, + }), + ) + const contract = compileContract(source) + const promise = emitPromise(contract).files.find((file) => file.path === "client.ts")?.content + const effect = emitEffectImported(contract, { + module: "@example/api", + endpoints: { "session.token": "Token" }, + }).files.find((file) => file.path === "client.ts")?.content + + expect(promise).toContain('"x-example-token": input["x-example-token"]') + expect(effect).toContain('"x-example-token": input["x-example-token"]') + }) + + test("rejects consumer group name collisions", () => { + const source = HttpApi.make("test") + .add(HttpApiGroup.make("first").add(HttpApiEndpoint.get("one", "/one", { success: Schema.String }))) + .add(HttpApiGroup.make("second").add(HttpApiEndpoint.get("two", "/two", { success: Schema.String }))) + + expect(() => compileContract(source, { groupNames: { first: "same", second: "same" } })).toThrow( + "Client group name collision: same", + ) + }) + + test("uses the unqualified endpoint name for the public client", () => { + const contract = compileContract( + api( + HttpApiEndpoint.get("session.get", "/session/:sessionID", { + params: { sessionID: Schema.String }, + success: Schema.String, + }), + ), + ) + const promise = emitPromise(contract).files.find((file) => file.path === "client.ts")?.content + const effect = emitEffectImported(contract, { + module: "@example/api", + endpoints: { "session.session.get": "SessionGet" }, + }).files.find((file) => file.path === "client.ts")?.content + + expect(contract.groups[0]?.endpoints[0]?.operation.name).toBe("get") + expect(promise).toContain('"get": (input: SessionGetInput, requestOptions?: RequestOptions)') + expect(effect).toContain('const adaptGroup0 = (raw: RawClient["session"]) => ({ "get": Endpoint0_0(raw) })') + expect(effect).toContain('raw["session.get"]') + }) + + test("preserves optional keys in Promise error types", () => { + class OptionalError extends Schema.TaggedErrorClass()( + "OptionalError", + { message: Schema.String, detail: Schema.String.pipe(Schema.optional) }, + { httpApiStatus: 400 }, + ) {} + const output = emitPromise( + compileContract(api(HttpApiEndpoint.get("get", "/session", { success: Schema.String, error: OptionalError }))), + ) + + expect(output.files.find((file) => file.path === "types.ts")?.content).toContain( + 'readonly "message": string; readonly "detail"?: string | undefined', + ) + }) + + test("supports name-discriminated Promise errors", () => { + class NamedError extends Schema.ErrorClass("NamedError")( + { name: Schema.Literal("NamedError"), message: Schema.String }, + { httpApiStatus: 400 }, + ) {} + const output = emitPromise( + compileContract( + api(HttpApiEndpoint.get("get", "/session", { success: Schema.NumberFromString, error: NamedError })), + ), + ) + const types = output.files.find((file) => file.path === "types.ts")?.content + + expect(types).toContain('readonly "name": "NamedError"') + expect(types).toContain('"name" in value && value["name"] === "NamedError"') + }) + + test("preserves reflected default error statuses", () => { + class MissingStatus extends Schema.TaggedErrorClass()("MissingStatus", { + message: Schema.String, + }) {} + const output = emitPromise( + compileContract(api(HttpApiEndpoint.get("get", "/session", { success: Schema.String, error: MissingStatus }))), + ) + + expect(output.files.find((file) => file.path === "client.ts")?.content).toContain("declaredStatuses: [500]") + }) + + test("erases brands from Promise wire types", () => { + const output = emitPromise( + compileContract( + api( + HttpApiEndpoint.get("get", "/session/:sessionID", { + params: { sessionID: Schema.String.pipe(Schema.brand("SessionID")) }, + success: Schema.Struct({ data: Schema.String.pipe(Schema.brand("SessionID")) }), + }), + ), + ), + ) + const types = output.files.find((file) => file.path === "types.ts")?.content + + expect(types).toContain('readonly "sessionID": string') + expect(types).not.toContain("Brand") + }) + + test("inlines non-recursive references in Promise wire types", () => { + const Referenced = Schema.Struct({ value: Schema.String }).annotate({ identifier: "Referenced" }) + const output = emitPromise( + compileContract( + api( + HttpApiEndpoint.get("get", "/session", { + success: Schema.Struct({ data: Referenced }), + }), + ), + ), + ) + + expect(output.files.find((file) => file.path === "types.ts")?.content).toContain( + 'export type SessionGetOutput = ({ readonly "data": ({ readonly "value": string }) })["data"]', + ) + }) + + test("expands Promise references only at identifier boundaries", () => { + const Session = Schema.Struct({ name: Schema.Literal("Session"), id: Schema.String }).annotate({ + identifier: "Session", + }) + const SessionID = Schema.String.annotate({ identifier: "SessionID" }) + const output = emitPromise( + compileContract( + api( + HttpApiEndpoint.get("get", "/session", { + success: Schema.Struct({ session: Session, sessionID: SessionID }), + }), + ), + ), + ) + + expect(output.files.find((file) => file.path === "types.ts")?.content).toContain( + 'readonly "session": ({ readonly "name": "Session", readonly "id": string })', + ) + }) + + test("emits Effect Json schemas as standalone Promise types", () => { + const output = emitPromise( + compileContract( + api( + HttpApiEndpoint.get("get", "/session", { + success: Schema.Json, + }), + ), + ), + ) + const types = output.files.find((file) => file.path === "types.ts")?.content + + expect(types).toContain("export type JsonValue =") + expect(types).toContain("{ readonly [key: string]: JsonValue }") + expect(types).not.toContain("Schema.Json") + }) + + test("emits an optional Promise input when every field is optional", () => { + const output = emitPromise( + compileContract( + api( + HttpApiEndpoint.get("list", "/session", { + query: { limit: Schema.optional(Schema.Number) }, + success: Schema.Array(Schema.String), + }), + ), + ), + ) + + expect(output.files.find((file) => file.path === "client.ts")?.content).toContain( + '"list": (input?: SessionListInput, requestOptions?: RequestOptions)', + ) + }) + + test("rejects Promise transports that are not implemented", () => { + expect(() => + emitPromise( + compileContract( + api( + HttpApiEndpoint.get("text", "/text", { + success: Schema.String.pipe(HttpApiSchema.asText()), + }), + ), + ), + ), + ).toThrow("Unsupported Promise success encoding: session.text") + + expect(() => + emitPromise( + compileContract( + api( + HttpApiEndpoint.get("binary", "/binary", { + success: Schema.Uint8Array.pipe(HttpApiSchema.asUint8Array()), + }), + ), + ), + ), + ).toThrow("Unsupported Promise success encoding: session.binary") + + expect(() => + emitPromise(compileContract(api(HttpApiEndpoint.get("read", "/file/*", { success: Schema.String })))), + ).toThrow("Unsupported Promise path wildcard: /file/*") + + expect(() => + emitPromise( + compileContract( + api( + HttpApiEndpoint.get("events", "/events", { + success: HttpApiSchema.StreamSse({ data: Schema.String, error: Missing }), + }), + ), + ), + ), + ).toThrow("Unsupported Promise stream: session.events") + }) + + test("executes an emitted Promise GET through fetch", async () => { + const output = emitPromise( + compileContract( + api( + HttpApiEndpoint.get("get", "/session/:sessionID", { + params: { sessionID: Schema.String }, + success: Schema.Struct({ data: Schema.String }), + }), + ), + ), + ) + const directory = await mkdtemp(join(tmpdir(), "opencode-httpapi-codegen-")) + + try { + await Promise.all(output.files.map((file) => Bun.write(join(directory, file.path), file.content))) + const generated = await import(`${join(directory, "index.ts")}?t=${crypto.randomUUID()}`) + let request: Request | undefined + const client = generated.OpenCode.make({ + baseUrl: "https://example.com", + fetch: async (input: RequestInfo | URL) => { + request = input instanceof Request ? input : new Request(input) + return Response.json({ data: "hello" }) + }, + }) + + expect(await client.session.get({ sessionID: "a/b" })).toBe("hello") + expect(request?.method).toBe("GET") + expect(request?.url).toBe("https://example.com/session/a%2Fb") + } finally { + await rm(directory, { recursive: true, force: true }) + } + }) + + test("maps an emitted no-content response to undefined", async () => { + const output = emitPromise( + compileContract( + api( + HttpApiEndpoint.post("interrupt", "/session/:sessionID/interrupt", { + params: { sessionID: Schema.String }, + success: HttpApiSchema.NoContent, + }), + ), + ), + ) + const directory = await mkdtemp(join(tmpdir(), "opencode-httpapi-codegen-")) + + try { + await Promise.all(output.files.map((file) => Bun.write(join(directory, file.path), file.content))) + const generated = await import(`${join(directory, "index.ts")}?t=${crypto.randomUUID()}`) + const client = generated.OpenCode.make({ + baseUrl: "https://example.com", + fetch: async () => new Response(null, { status: 204 }), + }) + + expect(await client.session.interrupt({ sessionID: "session" })).toBeUndefined() + } finally { + await rm(directory, { recursive: true, force: true }) + } + }) + + test("serializes flattened query, header, and JSON payload inputs", async () => { + const output = emitPromise( + compileContract( + api( + HttpApiEndpoint.post("prompt", "/session/:sessionID", { + params: { sessionID: Schema.String }, + query: { resume: Schema.optional(Schema.Boolean) }, + headers: { traceID: Schema.String }, + payload: Schema.Struct({ prompt: Schema.String }), + success: Schema.Struct({ data: Schema.String }), + }), + ), + ), + ) + const directory = await mkdtemp(join(tmpdir(), "opencode-httpapi-codegen-")) + + try { + await Promise.all(output.files.map((file) => Bun.write(join(directory, file.path), file.content))) + const generated = await import(`${join(directory, "index.ts")}?t=${crypto.randomUUID()}`) + let request: Request | undefined + const client = generated.OpenCode.make({ + baseUrl: "https://example.com", + fetch: async (input: RequestInfo | URL, init?: RequestInit) => { + request = input instanceof Request ? input : new Request(input, init) + return Response.json({ data: "admitted" }) + }, + }) + + expect( + await client.session.prompt({ sessionID: "session", resume: true, traceID: "trace", prompt: "hello" }), + ).toBe("admitted") + expect(request?.url).toBe("https://example.com/session/session?resume=true") + expect(request?.headers.get("traceID")).toBe("trace") + expect(await request?.json()).toEqual({ prompt: "hello" }) + } finally { + await rm(directory, { recursive: true, force: true }) + } + }) + + test("rejects with declared tagged errors and exports a type guard", async () => { + const output = emitPromise( + compileContract( + api( + HttpApiEndpoint.get("get", "/session/:sessionID", { + params: { sessionID: Schema.String }, + success: Schema.Struct({ data: Schema.String }), + error: Missing.pipe(HttpApiSchema.status(404)), + }), + ), + ), + ) + const directory = await mkdtemp(join(tmpdir(), "opencode-httpapi-codegen-")) + + try { + await Promise.all(output.files.map((file) => Bun.write(join(directory, file.path), file.content))) + const generated = await import(`${join(directory, "index.ts")}?t=${crypto.randomUUID()}`) + const client = generated.OpenCode.make({ + baseUrl: "https://example.com", + fetch: async () => Response.json({ _tag: "Missing", message: "gone" }, { status: 404 }), + }) + + const error = await client.session.get({ sessionID: "missing" }).catch((cause: unknown) => cause) + expect(error).toEqual({ _tag: "Missing", message: "gone" }) + expect(generated.isMissing(error)).toBeTrue() + } finally { + await rm(directory, { recursive: true, force: true }) + } + }) + + test("iterates an emitted SSE stream lazily without reconnecting", async () => { + const output = emitPromise( + compileContract( + api( + HttpApiEndpoint.get("subscribe", "/event", { + query: { after: Schema.optional(Schema.Number) }, + success: HttpApiSchema.StreamSse({ + data: Schema.Struct({ type: Schema.String, count: Schema.NumberFromString }), + }), + }), + ), + ), + ) + const directory = await mkdtemp(join(tmpdir(), "opencode-httpapi-codegen-")) + + try { + await Promise.all(output.files.map((file) => Bun.write(join(directory, file.path), file.content))) + const generated = await import(`${join(directory, "index.ts")}?t=${crypto.randomUUID()}`) + let requests = 0 + let url: string | undefined + const client = generated.OpenCode.make({ + baseUrl: "https://example.com", + fetch: async (input: RequestInfo | URL) => { + requests++ + url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url + const encoder = new TextEncoder() + return new Response( + new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode('data: {"type":"ready","count":"1"}\r')) + controller.enqueue(encoder.encode("\n\r\n")) + controller.close() + }, + }), + { headers: { "content-type": "text/event-stream" } }, + ) + }, + }) + const events = client.session.subscribe({ after: 2 }) + + expect(requests).toBe(0) + const received = [] + for await (const event of events) received.push(event) + expect(received).toEqual([{ type: "ready", count: "1" }]) + expect(requests).toBe(1) + expect(url).toBe("https://example.com/event?after=2") + } finally { + await rm(directory, { recursive: true, force: true }) + } + }) + + test("preserves public group and endpoint identifiers exactly", () => { + const output = compile( + HttpApi.make("test").add( + HttpApiGroup.make("session").add(HttpApiEndpoint.get("get", "/session/:sessionID", { success: Schema.String })), + ), + ) + + expect(output.operations[0]).toMatchObject({ group: "session", name: "get" }) + }) + + test("emits one client module per HttpApi group", () => { + const source = HttpApi.make("test") + .add(HttpApiGroup.make("session").add(HttpApiEndpoint.get("get", "/session", { success: Schema.String }))) + .add(HttpApiGroup.make("tool").add(HttpApiEndpoint.get("list", "/tool", { success: Schema.String }))) + + const output = compile(source) + + expect(output.files.map((file) => file.path)).toEqual([ + "session.ts", + "tool.ts", + "client-error.ts", + "client.ts", + "index.ts", + ]) + }) + + test("emits syntactically valid TypeScript modules", () => { + const output = compile( + api( + HttpApiEndpoint.get("get", "/session/:sessionID", { + params: { sessionID: Schema.String }, + success: Schema.Struct({ data: Schema.String }), + }), + ), + ) + const transpiler = new Bun.Transpiler({ loader: "ts" }) + + for (const file of output.files) expect(() => transpiler.transformSync(file.content)).not.toThrow() + }) + + it.effect("keeps the strict generated-consumer fixture current", () => + Effect.gen(function* () { + const output = compile(FixtureApi) + const actual = yield* Effect.promise(() => + Array.fromAsync(new Bun.Glob("*.ts").scan(new URL("generated", import.meta.url).pathname)), + ) + expect(actual.sort((a, b) => a.localeCompare(b))).toEqual( + output.files.map((file) => file.path).sort((a, b) => a.localeCompare(b)), + ) + yield* Effect.forEach(output.files, (file) => + Effect.tryPromise(() => + Promise.all([ + Bun.file(new URL(`generated/${file.path}`, import.meta.url)).text(), + format(file.content, { parser: "typescript", semi: false, printWidth: 120 }), + ]), + ).pipe(Effect.map(([content, expected]) => expect(content).toBe(expected))), + ) + }), + ) + + test("flattens transport input channels into one domain input", () => { + const output = compile( + api( + HttpApiEndpoint.post("prompt", "/session/:sessionID", { + params: { sessionID: Schema.String }, + query: { resume: Schema.String }, + headers: { traceID: Schema.String }, + payload: Schema.Struct({ prompt: Schema.String }), + success: Schema.Struct({ data: Schema.String }), + }), + ), + ) + + expect(output.operations[0]?.input).toEqual([ + { name: "sessionID", source: "params" }, + { name: "resume", source: "query" }, + { name: "traceID", source: "headers" }, + { name: "prompt", source: "payload" }, + ]) + expect(output.files.find((file) => file.path === "session.ts")?.content).toContain( + 'params: { "sessionID": input["sessionID"] }', + ) + }) + + test("uses no argument when an operation has no input fields", () => { + const output = compile(api(HttpApiEndpoint.get("health", "/health", { success: Schema.String }))) + + expect(output.operations[0]?.inputMode).toBe("none") + }) + + test("uses an optional object when every input field is optional", () => { + const output = compile( + api( + HttpApiEndpoint.get("list", "/session", { + query: { limit: Schema.optional(Schema.String) }, + success: Schema.Array(Schema.String), + }), + ), + ) + + expect(output.operations[0]?.inputMode).toBe("optional") + expect(output.files.find((file) => file.path === "session.ts")?.content).toContain('input?.["limit"]') + }) + + test("regenerates standard HttpApi transport codecs from decoded schemas", () => { + const output = compile( + api( + HttpApiEndpoint.get("list", "/session", { + query: { archived: Schema.optional(Schema.Boolean) }, + success: Schema.String, + }), + ), + ) + + expect(output.files.find((file) => file.path === "session.ts")?.content).toContain("Schema.Boolean") + }) + + test("uses a required object when any input field is required", () => { + const output = compile( + api( + HttpApiEndpoint.get("get", "/session/:sessionID", { + params: { sessionID: Schema.String }, + query: { includeArchived: Schema.optional(Schema.String) }, + success: Schema.String, + }), + ), + ) + + expect(output.operations[0]?.inputMode).toBe("required") + }) + + test("rejects colliding input names across transport channels", () => { + expect(() => + compile( + api( + HttpApiEndpoint.post("prompt", "/session/:id", { + params: { id: Schema.String }, + payload: Schema.Struct({ id: Schema.String }), + success: Schema.Void, + }), + ), + ), + ).toThrow("Input field collision: id") + }) + + test("rejects multiple payload alternatives until selection semantics are explicit", () => { + expect(() => + compile( + api( + HttpApiEndpoint.post("prompt", "/session", { + payload: [Schema.Struct({ text: Schema.String }), Schema.Struct({ count: Schema.Number })], + success: Schema.String, + }), + ), + ), + ).toThrow("Multiple payload schemas: session.prompt") + }) + + test("unwraps an exact data success envelope", () => { + const output = compile( + api( + HttpApiEndpoint.get("get", "/session/:sessionID", { + params: { sessionID: Schema.String }, + success: Schema.Struct({ data: Schema.String }), + }), + ), + ) + + expect(output.operations[0]?.success).toBe("value") + expect(output.files.find((file) => file.path === "session.ts")?.content).toContain( + "Effect.map((value) => value.data)", + ) + }) + + test("maps no-content success to void", () => { + const output = compile( + api(HttpApiEndpoint.post("interrupt", "/session/:sessionID/interrupt", { success: HttpApiSchema.NoContent })), + ) + + expect(output.operations[0]?.success).toBe("void") + expect(output.files.find((file) => file.path === "session.ts")?.content).toContain('"httpApiStatus": 204') + }) + + test("preserves non-default empty response statuses", () => { + const output = compile(api(HttpApiEndpoint.post("create", "/session", { success: HttpApiSchema.Created }))) + + expect(output.files.find((file) => file.path === "session.ts")?.content).toContain('"httpApiStatus": 201') + }) + + test("returns a non-envelope success unchanged", () => { + const output = compile(api(HttpApiEndpoint.get("health", "/health", { success: Schema.String }))) + + expect(output.operations[0]?.success).toBe("value") + }) + + test("rejects multiple success shapes until their public semantics are explicit", () => { + expect(() => + compile( + api( + HttpApiEndpoint.get("get", "/session", { + success: [Schema.String, Schema.Number], + }), + ), + ), + ).toThrow("Multiple success schemas: session.get") + }) + + test("models an SSE success as a direct stream", () => { + const output = compile( + api( + HttpApiEndpoint.get("subscribe", "/event", { + success: HttpApiSchema.StreamSse({ data: Schema.Struct({ type: Schema.String }) }), + }), + ), + ) + + expect(output.operations[0]?.success).toBe("stream") + }) + + test("preserves annotated stream response statuses", () => { + const output = compile( + api( + HttpApiEndpoint.get("subscribe", "/event", { + success: HttpApiSchema.StreamSse({ data: Schema.String }).pipe(HttpApiSchema.status(202)), + }), + ), + ) + + expect(output.files.find((file) => file.path === "session.ts")?.content).toContain( + ".pipe(HttpApiSchema.status(202))", + ) + }) + + test("rejects schemas whose semantics cannot be emitted exactly", () => { + const OpaqueUrl = Schema.declare((input): input is URL => input instanceof URL) + + expect(() => compile(api(HttpApiEndpoint.get("get", "/url", { success: OpaqueUrl })))).toThrow( + "Unportable schema: session.get.success", + ) + }) + + test("rejects custom transformations hidden beneath standard HttpApi codecs", () => { + const QueryBoolean = Schema.Literals(["yes", "no"]).pipe( + Schema.decodeTo(Schema.Boolean, { + decode: SchemaGetter.transform((value) => value === "yes"), + encode: SchemaGetter.transform((value) => (value ? "yes" : "no")), + }), + ) + + expect(() => + compile( + api( + HttpApiEndpoint.get("get", "/session", { + query: { archived: QueryBoolean }, + success: Schema.String, + }), + ), + ), + ).toThrow("Effect schema requires authoritative import: session.get") + }) + + test("rejects custom validation checks without portable metadata", () => { + const Positive = Schema.Number.check(Schema.makeFilter((value) => (value > 0 ? undefined : "positive"))) + + expect(() => compile(api(HttpApiEndpoint.get("get", "/session", { success: Positive })))).toThrow( + "Unportable schema: session.get.success", + ) + }) + + test("rejects spoofed and aborted validation checks", () => { + const Spoofed = Schema.Number.check( + Schema.makeFilter(() => "always fails", { meta: { _tag: "isFinite" }, arbitrary: {} }), + ) + const Aborted = Schema.Number.check(Schema.isFinite().abort()) + + expect(() => compile(api(HttpApiEndpoint.get("spoofed", "/session", { success: Spoofed })))).toThrow( + "Unportable schema: session.spoofed.success", + ) + expect(() => compile(api(HttpApiEndpoint.get("aborted", "/session", { success: Aborted })))).toThrow( + "Unportable schema: session.aborted.success", + ) + }) + + test("rejects altered wire-side schemas even when the codec transformation is canonical", () => { + const JsonNumber = Schema.toCodecJson(Schema.Number) + const link = JsonNumber.ast.encoding?.[0] + if (link === undefined) throw new Error("Expected JSON number encoding") + // This helper is present at runtime but omitted from the public declaration surface. + const replaceEncoding: unknown = Reflect.get(SchemaAST, "replaceEncoding") + if (typeof replaceEncoding !== "function") throw new Error("Expected SchemaAST.replaceEncoding") + const ast: unknown = replaceEncoding(JsonNumber.ast, [ + new SchemaAST.Link(Schema.String.check(Schema.isMinLength(2)).ast, link.transformation), + ]) + if (!SchemaAST.isAST(ast)) throw new Error("Expected altered schema AST") + const Altered = Schema.make(ast) + + expect(() => compile(api(HttpApiEndpoint.get("get", "/session", { success: Altered })))).toThrow( + "Effect schema requires authoritative import: session.get", + ) + }) + + test("rejects lexical generation and annotation values", () => { + const Generated = Schema.declare((input): input is string => typeof input === "string").annotate({ + generation: { runtime: "LocalOnly", Type: "string" }, + }) + const Annotated = Schema.declare((input): input is string => typeof input === "string").annotate({ + custom: () => "local", + }) + + expect(() => compile(api(HttpApiEndpoint.get("generated", "/session", { success: Generated })))).toThrow( + "Unportable schema: session.generated.success", + ) + expect(() => compile(api(HttpApiEndpoint.get("annotated", "/session", { success: Annotated })))).toThrow( + "Unportable schema: session.annotated.success", + ) + }) + + test("preserves errors from server-only middleware", () => { + class Unauthorized extends Schema.TaggedErrorClass()("Unauthorized", {}) {} + class Authorization extends HttpApiMiddleware.Service()("Authorization", { + error: Unauthorized, + }) {} + + const output = compile( + api(HttpApiEndpoint.get("get", "/session", { success: Schema.String }).middleware(Authorization)), + ) + + expect(output.operations[0]).toBeDefined() + expect(output.files.find((file) => file.path === "session.ts")?.content).toContain( + 'extends Schema.TaggedErrorClass("Unauthorized")', + ) + }) + + test("preserves tagged error response statuses", () => { + class Missing extends Schema.TaggedErrorClass()("Missing", {}) {} + const output = compile( + api( + HttpApiEndpoint.get("get", "/session", { + success: Schema.String, + error: Missing.pipe(HttpApiSchema.status(404)), + }), + ), + ) + + expect(output.files.find((file) => file.path === "session.ts")?.content).toContain( + 'Endpoint0Error0Class.annotate({ "httpApiStatus": 404 })', + ) + }) + + test("supports every HttpApi method through the generic constructor", () => { + const output = compile(api(HttpApiEndpoint.make("TRACE")("trace", "/trace", { success: Schema.String }))) + + expect(output.files.find((file) => file.path === "session.ts")?.content).toContain('HttpApiEndpoint.make("TRACE")') + }) + + test("uses safe unique module paths without changing public group identifiers", () => { + const output = compile( + HttpApi.make("test") + .add(HttpApiGroup.make("../session").add(HttpApiEndpoint.get("get", "/session", { success: Schema.String }))) + .add(HttpApiGroup.make("GROUP-0").add(HttpApiEndpoint.get("list", "/session", { success: Schema.String }))), + ) + + expect(output.files.slice(0, 2).map((file) => file.path)).toEqual(["group-0.ts", "GROUP-0-1.ts"]) + expect(output.files[0]?.content).toContain('HttpApiGroup.make("../session"') + }) + + test("reserves support module names case-insensitively", () => { + const output = compile( + HttpApi.make("test") + .add(HttpApiGroup.make("client").add(HttpApiEndpoint.get("get", "/client", { success: Schema.String }))) + .add(HttpApiGroup.make("INDEX").add(HttpApiEndpoint.get("get", "/index", { success: Schema.String }))), + ) + + expect(output.files.slice(0, 2).map((file) => file.path)).toEqual(["client-0.ts", "INDEX-1.ts"]) + }) + + test("keeps searching when a reserved-name fallback is also occupied", () => { + const output = compile( + HttpApi.make("test") + .add(HttpApiGroup.make("client-1").add(HttpApiEndpoint.get("first", "/first", { success: Schema.String }))) + .add(HttpApiGroup.make("client").add(HttpApiEndpoint.get("second", "/second", { success: Schema.String }))), + ) + + expect(output.files.slice(0, 2).map((file) => file.path)).toEqual(["client-1.ts", "client-1-1.ts"]) + }) + + test("rejects collisions in the flattened client namespace", () => { + expect(() => + compile( + HttpApi.make("test") + .add(HttpApiGroup.make("status").add(HttpApiEndpoint.get("get", "/nested", { success: Schema.String }))) + .add( + HttpApiGroup.make("system", { topLevel: true }).add( + HttpApiEndpoint.get("status", "/status", { success: Schema.String }), + ), + ), + ), + ).toThrow("Client name collision: status") + }) + + test("emits a usable raw type for top-level groups", () => { + const output = compile( + HttpApi.make("test").add( + HttpApiGroup.make("health", { topLevel: true }).add( + HttpApiEndpoint.get("check", "/health", { success: Schema.String }), + ), + ), + ) + + expect(output.files[0]?.content).toContain("type RawGroup = HttpApiClient.Client + Effect.gen(function* () { + const error = yield* generate( + api( + HttpApiEndpoint.get("get", "/url", { + success: Schema.declare((input): input is URL => input instanceof URL), + }), + ), + { + directory: "/generated", + }, + ).pipe(Effect.flip) + + expect(error).toBeInstanceOf(GenerationError) + if (error instanceof GenerationError) expect(error.reason).toBe("Unportable schema: session.get.success") + }).pipe(Effect.provideService(FileSystem.FileSystem, FileSystem.makeNoop({}))), + ) + + test("rejects required client middleware without an adapter", () => { + class SignedRequest extends HttpApiMiddleware.Service()("SignedRequest", { + requiredForClient: true, + }) {} + + expect(() => + compile(api(HttpApiEndpoint.get("get", "/session", { success: Schema.String }).middleware(SignedRequest))), + ).toThrow("Client middleware requires adapter: SignedRequest") + }) + + test("maps transport and decode failures to one stable client error", () => { + const output = compile( + api( + HttpApiEndpoint.get("get", "/session", { + success: Schema.String, + }), + ), + ) + + expect(output.operations[0]?.errors).toContain("ClientError") + expect(output.operations[0]?.errors).not.toContain("HttpClientError") + expect(output.operations[0]?.errors).not.toContain("SchemaError") + expect(output.files.find((file) => file.path === "session.ts")?.content).toContain( + "new ClientError({ cause: error })", + ) + }) +}) diff --git a/packages/httpapi-codegen/test/generated-consumer.ts b/packages/httpapi-codegen/test/generated-consumer.ts new file mode 100644 index 0000000000000000000000000000000000000000..448db01be8237e59518d99e17bd418e1e83f5bde --- /dev/null +++ b/packages/httpapi-codegen/test/generated-consumer.ts @@ -0,0 +1,28 @@ +import { Effect, Stream } from "effect" +import { HttpClient } from "effect/unstable/http" +import { ClientError, OpenCode } from "./generated" +import { Missing } from "./fixture" + +export const program = OpenCode.make().pipe( + Effect.map((client) => { + const health = client.session.health() + const list = client.session.list() + const filtered = client.session.list({ archived: true }) + const get = client.session.get({ sessionID: "session" }) + const interrupt = client.session.interrupt({ sessionID: "session" }) + const status = client.status() + const subscribe = client.event.subscribe() + + const _health: Effect.Effect = health + const _list: Effect.Effect, ClientError> = list + const _filtered: Effect.Effect, ClientError> = filtered + const _get: Effect.Effect = get + const _interrupt: Effect.Effect = interrupt + const _status: Effect.Effect = status + const _subscribe: Stream.Stream<{ readonly type: string }, ClientError> = subscribe + + return { _health, _list, _filtered, _get, _interrupt, _status, _subscribe } + }), +) + +const _requiresHttpClient: Effect.Effect = program diff --git a/packages/httpapi-codegen/test/generated/client-error.ts b/packages/httpapi-codegen/test/generated/client-error.ts new file mode 100644 index 0000000000000000000000000000000000000000..bcc65d9bdd208f4ceea214feaa1e048fa1821f38 --- /dev/null +++ b/packages/httpapi-codegen/test/generated/client-error.ts @@ -0,0 +1,5 @@ +import { Schema } from "effect" + +export class ClientError extends Schema.TaggedErrorClass()("ClientError", { + cause: Schema.Defect(), +}) {} diff --git a/packages/httpapi-codegen/test/generated/client.ts b/packages/httpapi-codegen/test/generated/client.ts new file mode 100644 index 0000000000000000000000000000000000000000..f67fc00a99a2bf8963cca3268a2f16ffc3933bb7 --- /dev/null +++ b/packages/httpapi-codegen/test/generated/client.ts @@ -0,0 +1,16 @@ +// Generated by @opencode-ai/httpapi-codegen. Do not edit. +import { Effect } from "effect" +import { HttpApi, HttpApiClient } from "effect/unstable/httpapi" +import { adaptGroup0, Group0 } from "./session" +import { adaptGroup1, Group1 } from "./event" +import { adaptGroup2, Group2 } from "./system" + +const Api = HttpApi.make("generated").add(Group0).add(Group1).add(Group2) +const adaptClient = (raw: HttpApiClient.ForApi) => ({ + session: adaptGroup0(raw["session"]), + event: adaptGroup1(raw["event"]), + ...adaptGroup2({ status: raw["status"] }), +}) + +export const make = (options?: { readonly baseUrl?: URL | string }) => + HttpApiClient.make(Api, options).pipe(Effect.map(adaptClient)) diff --git a/packages/httpapi-codegen/test/generated/event.ts b/packages/httpapi-codegen/test/generated/event.ts new file mode 100644 index 0000000000000000000000000000000000000000..764f2fd1c70b85e502a5203bd4a09f6605781631 --- /dev/null +++ b/packages/httpapi-codegen/test/generated/event.ts @@ -0,0 +1,39 @@ +// Generated by @opencode-ai/httpapi-codegen. Do not edit. +import { Effect, Schema, Stream } from "effect" +import { Sse } from "effect/unstable/encoding" +import { HttpClientError } from "effect/unstable/http" +import { HttpApiClient, HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "effect/unstable/httpapi" +import { ClientError } from "./client-error" + +const Endpoint0SuccessData = Schema.Struct({ type: Schema.String }) + +const Endpoint0SuccessError = Schema.Never + +export const Group1 = HttpApiGroup.make("event", { topLevel: false }).add( + HttpApiEndpoint.make("GET")("subscribe", "/event", { + success: HttpApiSchema.StreamSse({ + data: Endpoint0SuccessData, + error: Endpoint0SuccessError, + contentType: "text/event-stream", + }).pipe(HttpApiSchema.status(202)), + }), +) + +type RawGroup = HttpApiClient.Client.Group + +const Endpoint0DeclaredError = Schema.Union([Endpoint0SuccessError]) +const mapEndpoint0Error = (error: unknown) => + HttpClientError.isHttpClientError(error) || Schema.isSchemaError(error) || Sse.Retry.is(error) + ? new ClientError({ cause: error }) + : Schema.is(Endpoint0DeclaredError)(error) + ? error + : new ClientError({ cause: error }) +const Endpoint0 = (raw: RawGroup) => () => + Stream.unwrap( + raw["subscribe"]({}).pipe( + Effect.mapError(mapEndpoint0Error), + Effect.map((stream) => stream.pipe(Stream.mapError(mapEndpoint0Error))), + ), + ) + +export const adaptGroup1 = (raw: RawGroup) => ({ subscribe: Endpoint0(raw) }) diff --git a/packages/httpapi-codegen/test/generated/index.ts b/packages/httpapi-codegen/test/generated/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..bc0dbc9fa4df8f555aecceb70252d830a74394ad --- /dev/null +++ b/packages/httpapi-codegen/test/generated/index.ts @@ -0,0 +1,2 @@ +export { ClientError } from "./client-error" +export * as OpenCode from "./client" diff --git a/packages/httpapi-codegen/test/generated/session.ts b/packages/httpapi-codegen/test/generated/session.ts new file mode 100644 index 0000000000000000000000000000000000000000..6a1937c49e244841eddaff14a2021e9384f5c539 --- /dev/null +++ b/packages/httpapi-codegen/test/generated/session.ts @@ -0,0 +1,96 @@ +// Generated by @opencode-ai/httpapi-codegen. Do not edit. +import { Effect, Schema } from "effect" +import { Sse } from "effect/unstable/encoding" +import { HttpClientError } from "effect/unstable/http" +import { HttpApiClient, HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi" +import { ClientError } from "./client-error" + +const Endpoint0Success = Schema.String + +const Endpoint1Query = Schema.Struct({ archived: Schema.optionalKey(Schema.Union([Schema.Boolean, Schema.Undefined])) }) + +const Endpoint1Success = Schema.Array(Schema.String) + +const Endpoint2Params = Schema.Struct({ sessionID: Schema.String }) + +const Endpoint2Success = Schema.Struct({ data: Schema.String }) + +class Endpoint2Error0Class extends Schema.TaggedErrorClass("Missing")("Missing", { + message: Schema.String, +}) {} +const Endpoint2Error0 = Endpoint2Error0Class.annotate({ httpApiStatus: 404 }) + +const Endpoint3Params = Schema.Struct({ sessionID: Schema.String }) + +const Endpoint3Success = Schema.Void.annotate({ httpApiStatus: 204 }) + +export const Group0 = HttpApiGroup.make("session", { topLevel: false }) + .add(HttpApiEndpoint.make("GET")("health", "/session/health", { success: Endpoint0Success })) + .add(HttpApiEndpoint.make("GET")("list", "/session", { query: Endpoint1Query, success: Endpoint1Success })) + .add( + HttpApiEndpoint.make("GET")("get", "/session/:sessionID", { + params: Endpoint2Params, + success: Endpoint2Success, + error: Endpoint2Error0, + }), + ) + .add( + HttpApiEndpoint.make("POST")("interrupt", "/session/:sessionID/interrupt", { + params: Endpoint3Params, + success: Endpoint3Success, + }), + ) + +type RawGroup = HttpApiClient.Client.Group + +const Endpoint0DeclaredError = Schema.Never +const mapEndpoint0Error = (error: unknown) => + HttpClientError.isHttpClientError(error) || Schema.isSchemaError(error) || Sse.Retry.is(error) + ? new ClientError({ cause: error }) + : Schema.is(Endpoint0DeclaredError)(error) + ? error + : new ClientError({ cause: error }) +const Endpoint0 = (raw: RawGroup) => () => raw["health"]({}).pipe(Effect.mapError(mapEndpoint0Error)) + +type Endpoint1Input = { readonly archived?: (typeof Endpoint1Query.Type)["archived"] } +const Endpoint1DeclaredError = Schema.Never +const mapEndpoint1Error = (error: unknown) => + HttpClientError.isHttpClientError(error) || Schema.isSchemaError(error) || Sse.Retry.is(error) + ? new ClientError({ cause: error }) + : Schema.is(Endpoint1DeclaredError)(error) + ? error + : new ClientError({ cause: error }) +const Endpoint1 = (raw: RawGroup) => (input?: Endpoint1Input) => + raw["list"]({ query: { archived: input?.["archived"] } }).pipe(Effect.mapError(mapEndpoint1Error)) + +type Endpoint2Input = { readonly sessionID: (typeof Endpoint2Params.Type)["sessionID"] } +const Endpoint2DeclaredError = Schema.Union([Endpoint2Error0]) +const mapEndpoint2Error = (error: unknown) => + HttpClientError.isHttpClientError(error) || Schema.isSchemaError(error) || Sse.Retry.is(error) + ? new ClientError({ cause: error }) + : Schema.is(Endpoint2DeclaredError)(error) + ? error + : new ClientError({ cause: error }) +const Endpoint2 = (raw: RawGroup) => (input: Endpoint2Input) => + raw["get"]({ params: { sessionID: input["sessionID"] } }).pipe( + Effect.mapError(mapEndpoint2Error), + Effect.map((value) => value.data), + ) + +type Endpoint3Input = { readonly sessionID: (typeof Endpoint3Params.Type)["sessionID"] } +const Endpoint3DeclaredError = Schema.Never +const mapEndpoint3Error = (error: unknown) => + HttpClientError.isHttpClientError(error) || Schema.isSchemaError(error) || Sse.Retry.is(error) + ? new ClientError({ cause: error }) + : Schema.is(Endpoint3DeclaredError)(error) + ? error + : new ClientError({ cause: error }) +const Endpoint3 = (raw: RawGroup) => (input: Endpoint3Input) => + raw["interrupt"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapEndpoint3Error)) + +export const adaptGroup0 = (raw: RawGroup) => ({ + health: Endpoint0(raw), + list: Endpoint1(raw), + get: Endpoint2(raw), + interrupt: Endpoint3(raw), +}) diff --git a/packages/httpapi-codegen/test/generated/system.ts b/packages/httpapi-codegen/test/generated/system.ts new file mode 100644 index 0000000000000000000000000000000000000000..6ba523d2c0e0f76982f68f008f796fc828dc5779 --- /dev/null +++ b/packages/httpapi-codegen/test/generated/system.ts @@ -0,0 +1,25 @@ +// Generated by @opencode-ai/httpapi-codegen. Do not edit. +import { Effect, Schema } from "effect" +import { Sse } from "effect/unstable/encoding" +import { HttpClientError } from "effect/unstable/http" +import { HttpApiClient, HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi" +import { ClientError } from "./client-error" + +const Endpoint0Success = Schema.String + +export const Group2 = HttpApiGroup.make("system", { topLevel: true }).add( + HttpApiEndpoint.make("GET")("status", "/status", { success: Endpoint0Success }), +) + +type RawGroup = HttpApiClient.Client + +const Endpoint0DeclaredError = Schema.Never +const mapEndpoint0Error = (error: unknown) => + HttpClientError.isHttpClientError(error) || Schema.isSchemaError(error) || Sse.Retry.is(error) + ? new ClientError({ cause: error }) + : Schema.is(Endpoint0DeclaredError)(error) + ? error + : new ClientError({ cause: error }) +const Endpoint0 = (raw: RawGroup) => () => raw["status"]({}).pipe(Effect.mapError(mapEndpoint0Error)) + +export const adaptGroup2 = (raw: RawGroup) => ({ status: Endpoint0(raw) }) diff --git a/packages/httpapi-codegen/test/write.test.ts b/packages/httpapi-codegen/test/write.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..f0704abea2b08d312ea0590c348902bb737703e3 --- /dev/null +++ b/packages/httpapi-codegen/test/write.test.ts @@ -0,0 +1,160 @@ +import { describe, expect } from "bun:test" +import { Effect, FileSystem, Option } from "effect" +import { write, type Output } from "../src" +import { it } from "./effect" + +describe("HttpApiCodegen.write", () => { + it.effect("writes compiled files beneath the output directory", () => { + const writes: Array<{ readonly path: string; readonly content: string }> = [] + const output: Output = { + operations: [], + files: [{ path: "session.ts", content: "export const session = {}" }], + } + + return Effect.gen(function* () { + yield* write(output, "/generated") + + expect(writes).toEqual([ + { path: "/generated/session.ts", content: "export const session = {}\n" }, + { path: "/generated/.httpapi-codegen.json", content: '[\n "session.ts"\n]\n' }, + ]) + }).pipe( + Effect.provideService( + FileSystem.FileSystem, + FileSystem.makeNoop({ + exists: () => Effect.succeed(false), + makeDirectory: () => Effect.void, + writeFileString: (path, content) => { + writes.push({ path, content }) + return Effect.void + }, + }), + ), + ) + }) + + it.effect("removes only stale files owned by the previous manifest", () => { + const removed: Array = [] + return write( + { + operations: [], + files: [{ path: "session.ts", content: "" }], + }, + "/generated", + ).pipe( + Effect.provideService( + FileSystem.FileSystem, + FileSystem.makeNoop({ + exists: (path) => Effect.succeed(path.endsWith(".httpapi-codegen.json")), + makeDirectory: () => Effect.void, + readFileString: () => Effect.succeed('["old.ts", "session.ts"]'), + remove: (path) => { + removed.push(path) + return Effect.void + }, + writeFileString: () => Effect.void, + }), + ), + Effect.tap(() => Effect.sync(() => expect(removed).toEqual(["/generated/old.ts"]))), + ) + }) + + it.effect("rejects unsafe and duplicate output paths before writing", () => { + const writes: Array = [] + return Effect.gen(function* () { + const error = yield* write( + { + operations: [], + files: [ + { path: "../outside.ts", content: "" }, + { path: "client.ts", content: "" }, + { path: "CLIENT.ts", content: "" }, + ], + }, + "/generated", + ).pipe(Effect.flip) + + expect(error._tag).toBe("GenerationError") + expect(writes).toEqual([]) + }).pipe( + Effect.provideService( + FileSystem.FileSystem, + FileSystem.makeNoop({ + writeFileString: (path) => { + writes.push(path) + return Effect.void + }, + }), + ), + ) + }) + + it.effect("rejects case-insensitive duplicate output paths", () => { + const writes: Array = [] + return Effect.gen(function* () { + const error = yield* write( + { + operations: [], + files: [ + { path: "client.ts", content: "" }, + { path: "CLIENT.ts", content: "" }, + ], + }, + "/generated", + ).pipe(Effect.flip) + + expect(error._tag).toBe("GenerationError") + expect(error.reason).toBe("Duplicate output path: CLIENT.ts") + expect(writes).toEqual([]) + }).pipe( + Effect.provideService( + FileSystem.FileSystem, + FileSystem.makeNoop({ + writeFileString: (path) => { + writes.push(path) + return Effect.void + }, + }), + ), + ) + }) + + it.effect("reserves the private manifest path", () => + write({ operations: [], files: [{ path: ".httpapi-codegen.json", content: "" }] }, "/generated").pipe( + Effect.flip, + Effect.tap((error) => Effect.sync(() => expect(error.reason).toContain("Unsafe output path"))), + Effect.provideService(FileSystem.FileSystem, FileSystem.makeNoop({})), + ), + ) + + it.effect("rejects existing symbolic-link output targets", () => + write({ operations: [], files: [{ path: "session.ts", content: "" }] }, "/generated").pipe( + Effect.flip, + Effect.tap((error) => Effect.sync(() => expect(error.reason).toBe("Unsafe output path: session.ts"))), + Effect.provideService( + FileSystem.FileSystem, + FileSystem.makeNoop({ + exists: (path) => Effect.succeed(path.endsWith("session.ts")), + makeDirectory: () => Effect.void, + stat: () => + Effect.succeed({ + type: "SymbolicLink", + mtime: Option.none(), + atime: Option.none(), + birthtime: Option.none(), + dev: 0, + ino: Option.none(), + mode: 0, + nlink: Option.none(), + uid: Option.none(), + gid: Option.none(), + rdev: Option.none(), + size: FileSystem.Size(0), + blksize: Option.none(), + blocks: Option.none(), + }), + }), + ), + ), + ) +}) diff --git a/packages/llm/example/call-sites.md b/packages/llm/example/call-sites.md new file mode 100644 index 0000000000000000000000000000000000000000..093f74e51de5519d371a3b113e3ff103a7f0a393 --- /dev/null +++ b/packages/llm/example/call-sites.md @@ -0,0 +1,591 @@ +# LLM Call Site Sketches + +Scratchpad for examples first, abstractions second. Current direction: routes +execute, provider facades organize configured route sets, and models carry route +values directly. + +## Conversation Summary + +Kit and Aidan want provider-specific LLM behavior to move out of opencode's AI +SDK transform path and into `packages/llm` where possible. The goal is not a big +generic transform layer; the goal is small composable route definitions backed by +recorded golden tests. + +Things to keep testing against: + +- Cache placement: `cache: "auto"`, manual cache breakpoints, provider cache usage. +- Images: golden image tests for providers/protocols that claim image support. +- Reasoning: canonical reasoning parts/events versus provider-native knobs. +- Auth: bearer, custom headers, multiple credentials, query auth, SigV4, OAuth, no auth. +- OpenAI-compatible providers: DeepSeek, Together, Groq, Alibaba/DashScope, custom routers. +- Provider switching: stale signatures, encrypted reasoning, provider metadata, incompatible parts. +- Error quality: typed errors instead of generic SDK/server failures. + +## Final Guide: Routes Execute, Providers Organize + +Do not introduce a first-class `Deployment` abstraction unless it gains real +semantics. Provider facades are ergonomic configured route groups, not execution +registries. The executable/composable thing is still a route. Do not make route +construction publish to a global registry; models should carry their route value +directly. + +Keep durable identity separate from runtime capability: + +- Durable identity is small serializable data like `{ providerID, modelID }` for + config, sessions, logs, and catalogs. +- Runtime capability is a `Model` with a route value, protocol, transport, auth, + and defaults. It is allowed to contain functions and schemas. +- If persisted identity needs to become executable, resolve it through an app + boundary first. Do not make `LLMRequest` recover behavior from a global route + side table. + +Keep unconfigured behavior values as values, not factories. A transport like +`HttpTransport.sseJson` should be a reusable immutable value. Use a function only +when the caller supplies options or when construction needs fresh state. + +Use constants to remove repetition before inventing abstractions. Provider ids +are branded once per provider facade and reused across routes; a plain exported +object is enough for the provider-facing API unless a helper earns its keep by +removing repeated route projection. + +Expose default configured provider instances, and put provider-specific setup on +`.configure(...)`. Model selectors stay pure: `model(id)`, `responses(id)`, +`chat(id)`, etc. Endpoint/auth/resource/api-version configuration happens before +model selection, not as a second argument to model selection. + +Use provider/product facades consistently: + +- One coherent provider/product config surface gets one top-level facade. +- APIs/model kinds that share that config are methods on the facade. +- Different products with different required config get separate top-level + facades, not a shared namespace with unrelated children. +- Default facades are exposed only when concrete defaults or lazy env/credential + defaults make the facade valid. + +Examples: + +```ts +OpenAI.responses("gpt-4o") +OpenAI.chat("gpt-4o") +OpenAI.responsesWebSocket("gpt-4o") + +Azure.configure({ resourceName, apiKey }).responses("my-deployment") +AmazonBedrock.configure({ region, credentials }).model("anthropic.claude-3-5-sonnet-20241022-v2:0") + +CloudflareAIGateway.configure({ accountId, gatewayId, gatewayApiKey, apiKey }).model("openai/gpt-4o") +CloudflareWorkersAI.configure({ accountId, apiKey }).model("@cf/meta/llama-3.1-8b-instruct") + +OpenAICompatible.configure({ + provider: "custom", + baseURL: "https://custom.example/v1", + auth: Auth.bearer(apiKey), +}).model("custom-model") +``` + +Standardize the provider facade contract before abstracting construction. A +plain object is enough at first; add a helper only if repeated route projection +starts hiding the real provider-specific config. + +`Route.with(...)` patch semantics should be boring and explicit: + +- Omitted fields inherit from the original route. +- `endpoint` patches merge with the existing endpoint, so overriding `baseURL` + keeps the existing `path`. +- `endpoint.query` merges by default; later values win. +- `auth` replaces. +- `headers` merge by default; undefined values are omitted. +- `id` is optional in patches. Route ids are diagnostic/provider API labels, not + global runtime registry keys. + +1. **Route** + - route id + - provider id + - protocol + - body schema + - body builder + - stream event schema + - parser/state machine + - transport + - method / IO shape + - framing + - request preparation + - constants when unconfigured; functions only when configured + - endpoint + - base URL + - static path + - body/model-derived path + - query params + - auth + - bearer + - custom header + - multiple credentials + - SigV4 + - none + - defaults + - headers + - generation defaults + - provider options + - limits +2. **Provider Facade** + - default configured provider instance + - provider-specific `.configure(...)` + - plain object/function facade over one or more routes + - top-level export only when it represents one coherent config surface + - no passive `Provider.make(...)` wrapper unless it gains runtime behavior +3. **Model Selector** + - route/provider-owned selector + - accepts model id only + - returns executable models + - does not accept endpoint/auth/deployment overrides +4. **Model** + - model id + - route value + - provider id + - configured route value at selection time +5. **LLM Request** + - model + - messages/tools + - generation/cache/reasoning/response-format options + - request-level HTTP overlays for per-request headers/query/body additions, + not provider endpoint/auth reconfiguration +6. **Compile** + - read route from model + - merge route defaults and request overrides + - build final URL from route endpoint + - apply auth from the configured route + - build body with protocol + - execute with transport and parse with protocol + +## Provider Facade Shape + +The provider abstraction is a facade over configured routes, not the runtime +execution mechanism: + +```ts +type ProviderFacade = { + readonly id: ProviderID + readonly model: (id: string) => Model + readonly configure: (input?: Config) => ProviderFacade +} & APIs +``` + +Manual construction is fine and should be the default until duplication earns a +helper: + +```ts +export const OpenAI = { + id: openAIProvider, + model: openAIResponses.model, + responses: openAIResponses.model, + chat: openAIChat.model, + configure: configureOpenAI, +} satisfies ProviderFacade< + { + responses: (id: string) => Model + chat: (id: string) => Model + }, + OpenAIConfig +> +``` + +If several providers repeat the same projection from route values to model +methods, the helper can stay deliberately tiny: + +```ts +const configureOpenAI = (input: OpenAIConfig = {}) => + Provider.define({ + id: openAIProvider, + routes: { + responses: openAIResponses.with(openAIConfig(input)), + chat: openAIChat.with(openAIConfig(input)), + }, + default: "responses", + configure: configureOpenAI, + }) + +export const OpenAI = configureOpenAI() +``` + +`Provider.define(...)` would only project route methods and preserve types: + +```ts +OpenAI.model("gpt-4o") +OpenAI.responses("gpt-4o") +OpenAI.chat("gpt-4o") +OpenAI.configure({ apiKey }).responses("gpt-4o") +``` + +It must not register routes, select routes dynamically, or participate in +execution. Execution still reads the route value carried by the model. + +## Ideal Call Sites + +Define concrete routes for a native provider, then project them through a +provider facade: + +```ts +const openAIProvider = ProviderID.make("openai") + +const openAIResponses = Route.make({ + id: "openai-responses", + provider: openAIProvider, + protocol: OpenAIResponses.protocol, + transport: HttpTransport.sseJson, + endpoint: { + baseURL: "https://api.openai.com/v1", + path: "/responses", + }, + auth: Auth.envBearer("OPENAI_API_KEY"), +}) + +const openAIChat = Route.make({ + id: "openai-chat", + provider: openAIProvider, + protocol: OpenAIChat.protocol, + transport: HttpTransport.sseJson, + endpoint: { + baseURL: "https://api.openai.com/v1", + path: "/chat/completions", + }, + auth: Auth.envBearer("OPENAI_API_KEY"), +}) + +const openAIResponsesWebSocket = openAIResponses.with({ + id: "openai-responses-websocket", + transport: WebSocketTransport.json, +}) + +const openAIConfig = (input: OpenAIConfig) => ({ + endpoint: input.endpoint, + auth: input.auth ?? (input.apiKey ? Auth.bearer(input.apiKey) : undefined), + headers: { + "OpenAI-Organization": input.organization, + "OpenAI-Project": input.project, + }, +}) + +const configureOpenAI = (input: OpenAIConfig = {}) => { + const responses = openAIResponses.with(openAIConfig(input)) + const responsesWebSocket = openAIResponsesWebSocket.with(openAIConfig(input)) + const chat = openAIChat.with(openAIConfig(input)) + + return { + id: openAIProvider, + responses: responses.model, + responsesWebSocket: responsesWebSocket.model, + chat: chat.model, + model: responses.model, + configure: configureOpenAI, + } +} + +export const OpenAI = configureOpenAI() +``` + +Specialize it functionally for concrete providers: + +```ts +const deepSeekProvider = ProviderID.make("deepseek") + +const deepseekChat = openAIChat.with({ + id: "deepseek-chat", + provider: deepSeekProvider, + endpoint: { + baseURL: "https://api.deepseek.com/v1", + }, + auth: Auth.envBearer("DEEPSEEK_API_KEY"), +}) + +const configureDeepSeek = (input: OpenAICompatibleConfig = {}) => { + const route = deepseekChat.with({ + endpoint: input.endpoint, + auth: input.auth ?? (input.apiKey ? Auth.bearer(input.apiKey) : undefined), + }) + + return { + id: deepSeekProvider, + model: route.model, + configure: configureDeepSeek, + } +} + +export const DeepSeek = { + id: deepSeekProvider, + model: deepseekChat.model, + configure: configureDeepSeek, +} +``` + +Provider-specific configuration happens before model selection: + +```ts +const deepseek = DeepSeek.configure({ + endpoint: { + baseURL: "https://proxy.example.com/v1", + }, + auth: Auth.bearer(apiKey), +}) + +const model = deepseek.model("deepseek-chat") +``` + +Final request call site stays boring: + +```ts +const response = + yield * + LLM.generate( + LLM.request({ + model: DeepSeek.model("deepseek-chat"), + prompt: "Hello.", + }), + ) +``` + +HTTP versus WebSocket is represented as named route selectors, not as model or +request overrides. Same protocol, different transport, different route: + +```ts +OpenAI.responses("gpt-4o") +OpenAI.responsesWebSocket("gpt-4o") +``` + +The client should not require a different public layer just because a selected +route uses WebSocket. Use one `LLMClient.layer` with HTTP and WebSocket runtime +capabilities available; routes that do not need WebSocket simply never touch it. +If a WebSocket route is selected in an environment without WebSocket support, +fail with a typed transport configuration error. + +Azure is a route specialization with auth/path/default changes plus input +mapping. The public API configures the Azure resource once, then selects +deployment ids with pure model selectors: + +```ts +const azureProvider = ProviderID.make("azure") + +const azureResponses = openAIResponses.with({ + id: "azure-openai-responses", + provider: azureProvider, + auth: Auth.envHeader("api-key", "AZURE_OPENAI_API_KEY"), +}) + +const configureAzure = (input: AzureConfig = {}) => { + const route = azureResponses.with({ + endpoint: { + baseURL: + input.baseURL ?? + Endpoint.envBaseURL( + "AZURE_RESOURCE_NAME", + (resourceName) => `https://${resourceName}.openai.azure.com/openai/v1`, + ), + query: { "api-version": input.apiVersion ?? "v1" }, + }, + auth: input.apiKey ? Auth.header("api-key", input.apiKey) : Auth.envHeader("api-key", "AZURE_OPENAI_API_KEY"), + }) + + return { + id: azureProvider, + model: route.model, + responses: route.model, + configure: configureAzure, + } +} + +export const Azure = configureAzure() + +const azure = Azure.configure({ + resourceName: "my-resource", + apiVersion: "v1", +}) + +const model = azure.responses("my-deployment") +``` + +Default provider facades are only valid when required configuration has a lazy +default source. `Azure.responses("my-deployment")` can be valid if endpoint +resolution reads `AZURE_RESOURCE_NAME` lazily and fails with a typed +configuration error when missing. If a provider has no sensible lazy default, +do not expose a default model selector; expose only a configured entrypoint. + +Cloudflare AI Gateway and Workers AI are separate product facades because their +configuration surfaces differ. Do not make a root `Cloudflare.configure(...)` +pretend there is one coherent Cloudflare provider configuration: + +```ts +const cloudflareProvider = ProviderID.make("cloudflare-ai-gateway") + +const cloudflareOpenAIChat = openAIChat.with({ + id: "cloudflare-ai-gateway-openai-chat", + provider: cloudflareProvider, + auth: Auth.bearerHeader("cf-aig-authorization").andThen(Auth.bearer()), +}) + +const configureCloudflareAIGateway = (input: CloudflareAIGatewayConfig) => { + const route = cloudflareOpenAIChat.with({ + endpoint: { + baseURL: `https://gateway.ai.cloudflare.com/v1/${input.accountId}/${input.gatewayId}/openai`, + }, + auth: Auth.bearerHeader("cf-aig-authorization", input.gatewayApiKey).andThen(Auth.bearer(input.apiKey)), + }) + + return { + id: cloudflareProvider, + model: (modelID: string) => route.model({ id: modelID }), + configure: configureCloudflareAIGateway, + } +} + +export const CloudflareAIGateway = { + id: cloudflareProvider, + configure: configureCloudflareAIGateway, +} + +const gateway = CloudflareAIGateway.configure({ + accountId: "account", + gatewayId: "gateway", + gatewayApiKey, + apiKey, +}) + +const model = gateway.model("openai/gpt-4o") +``` + +If a Cloudflare product gains a full lazy env default, it can expose a direct +selector too. Until then, omitting `CloudflareAIGateway.model(...)` makes missing +account/gateway configuration unrepresentable. + +opencode's dynamic runtime should construct executable models at its app +boundary instead of exposing a giant unstructured public model constructor or a +generic dynamic resolver: + +```ts +const model = + providerID === "azure" + ? Azure.configure(resolvedAzureConfig).responses(apiModelID) + : endpoint.websocket + ? OpenAI.responsesWebSocket(apiModelID) + : OpenAI.responses(apiModelID) +``` + +That boundary can branch on durable config/catalog metadata and call typed +provider APIs directly. Transport selection belongs there too: map metadata like +`endpoint.websocket` to `OpenAI.responsesWebSocket(apiModelID)`; otherwise use +the normal `OpenAI.responses(apiModelID)` route. The client runtime only executes +the route carried by the model. + +## Competitive Shape + +This follows the strongest parts of adjacent libraries: + +- AI SDK: configured provider instances expose provider-specific model methods. +- Effect AI: executable models carry provider requirements and can be resolved by + an app boundary. +- LiteLLM/opencode config: dynamic `providerID/modelID` branching belongs at the + app boundary, not in the typed public provider API or a global runtime + resolver. +- LangChain/LlamaIndex: constructor-style config plus model id is convenient, + but we avoid making model selection also configure endpoint/auth. + +The chosen split is: + +```txt +Route = execution mechanics +Provider facade = configured route group +Model = selected executable model carrying route value +App boundary = explicit durable-config -> typed-provider call +``` + +## What This Removes + +- No `Provider.make(...)` as a core abstraction. +- No `Provider.make(...)` wrapper just to bind an id to model functions. Use a + branded provider id constant and a plain exported provider facade. +- No `Deployment.define(...)` unless future examples force it. +- No global route registry as the normal execution path. +- No import side effects required before a model can execute. +- No duplicate `provider.id` object when selected models already carry provider + id. +- No `model(id, overrides)` escape hatch. Model selection takes the model id; + endpoint/auth/deployment customization happens by configuring the route first. +- No transport override on model/request. HTTP SSE versus WebSocket is a named + route selector such as `responses` versus `responsesWebSocket`. +- No separate public `LLMClient.layerWithWebSocket`. The runtime should expose one + client layer with the available transport capabilities. +- No executable `ModelRef`. The executable handle is `Model`; durable model + identity stays separate and cannot execute on its own. + +## Implementation Todo + +- [x] Replace the current executable `ModelRef` with `Model`. +- [x] Change `Model.route` to carry a route value, not a `RouteID` string. +- [ ] Keep a separate durable model identity type for persisted/session/catalog + data, likely `{ providerID, modelID }`, and make it clear that it cannot + execute without resolver context. +- [x] Change route model selectors so `route.model(id)` returns an executable + model with the route value attached, not a globally registered route id. +- [x] Remove the standalone `Route.model(route, defaults, mapInput)` helper; + configured route instances own model selection. +- [x] Remove endpoint/auth escape hatches from route model selection; callers must + configure endpoint/auth through `route.with(...)` or provider facades before + calling `.model(...)`. +- [x] Remove request-shaping defaults from `Model`; selected models now carry only + id, provider, and configured route while defaults live on routes or requests. +- [x] Rework `LLMClient.prepare` / `stream` / `generate` to read + `request.model.route` directly instead of calling `registeredRoute(...)`. +- [x] Remove `Route.make(...)` global registration from the normal execution + path; keep route ids only as diagnostics/provider API labels. +- [x] Model endpoint as `{ baseURL, path, query }` on routes, then remove the + current split where host/query live on the model and path lives in route + transport setup. +- [x] Define `Route.with(...)` with explicit patch semantics for endpoint merge, + query merge, header merge, auth replacement, and optional diagnostic id. +- [x] Make unconfigured transports reusable constants such as + `HttpTransport.sseJson`; keep transport functions only for configured/fresh + state construction. +- [x] Collapse the public WebSocket runtime split so one `LLMClient.layer` + exposes available transport capabilities and selected routes fail with typed + transport config errors when a required capability is missing. +- [x] Convert OpenAI provider APIs to provider-facade shape: + `OpenAI.configure(config).responses(id)`, `.chat(id)`, and + `.responsesWebSocket(id)`. +- [x] Convert Azure to a configured facade where resource/base URL/api version + setup happens before selecting deployment ids. +- [x] Split Cloudflare products into separate facades such as + `CloudflareAIGateway` and `CloudflareWorkersAI`; do not expose a shared root + config surface unless one product actually exists. +- [x] Migrate remaining built-in provider facades one at a time so configuration + happens before model selection and selectors accept only ids: + xAI, GitHub Copilot, OpenRouter, OpenAI-compatible families, Anthropic, + Google/Gemini, and Amazon Bedrock now use configured facades such as + `Provider.configure(options).model(id)` with named selectors where needed. +- [ ] Decide whether a tiny `Provider.define(...)` helper is warranted after two + or three provider conversions; start with plain objects if duplication is not + yet painful. +- [x] Update `packages/opencode/src/session/llm/native-request.ts` to construct + executable models at the session boundary with explicit provider facade + calls, mapping catalog metadata such as `endpoint.websocket` to the correct + named route selector. +- [ ] Update tests so direct route/provider tests assert route values are carried + by executable models, and opencode/native tests assert boundary-based route + selection. +- [ ] Remove compatibility exports or stale docs only after internal call sites + are migrated; do not keep duplicate constructor paths without an external + compatibility need. + +## Open Questions + +- Default facades with required setup: should providers like Azure and Bedrock + expose default model selectors only when all required setup has lazy env or + credential-chain defaults? If not, omit the default selector so missing config + is impossible at the type/API level. +- Lazy endpoint/auth values: should `Endpoint.envBaseURL(...)` and env-backed + auth produce typed configuration/authentication errors at compile/prepare time + or only when executing the transport? +- `Route.with(...)` clearing semantics: endpoint/query/header patches merge by + default, but what is the explicit way to remove an inherited value? +- Provider facade helper: keep plain objects until duplication hurts, or add a + tiny `Provider.define(...)` immediately to enforce shape and method projection? +- Auth shape: should auth stay as today's composable `Auth`, or split into an + auth placement/strategy and credential sources? +- Naming: is `baseURL` still the right endpoint field name, or should it be + `origin` / `urlPrefix` to clarify that route `path` is appended? diff --git a/packages/llm/example/tutorial.ts b/packages/llm/example/tutorial.ts new file mode 100644 index 0000000000000000000000000000000000000000..fddc34596682e6d5ee285247ca01dd8cf4985cff --- /dev/null +++ b/packages/llm/example/tutorial.ts @@ -0,0 +1,255 @@ +import { Config, Effect, Formatter, Layer, Schema, Stream } from "effect" +import { LLM, LLMClient, Message, ProviderID, Tool, ToolRuntime } from "@opencode-ai/llm" +import { Route, Auth, Endpoint, Framing, Protocol, RequestExecutor, WebSocketExecutor } from "@opencode-ai/llm/route" +import { OpenAI } from "@opencode-ai/llm/providers" + +/** + * A runnable walkthrough of the LLM package use-site API. + * + * Run from `packages/llm` with an OpenAI key in the environment: + * + * OPENAI_API_KEY=... bun example/tutorial.ts + * + * The file is intentionally written as a normal TypeScript program. You can + * hover imports and local values to see how the public API is typed. + */ + +const apiKey = Config.redacted("OPENAI_API_KEY") + +// 1. Pick a model. The provider helper records provider identity, protocol +// choice, capabilities, deployment options, authentication, and defaults. +const model = OpenAI.configure({ + apiKey, + generation: { maxTokens: 160 }, + providerOptions: { + openai: { store: false }, + }, +}).model("gpt-4o-mini") + +// 2. Build a provider-neutral request. This is useful when reusing one request +// across generate and stream examples. +// +// Options can live on both the configured route/provider facade and the request: +// +// - `generation`: common controls such as max tokens, temperature, topP/topK, +// penalties, seed, and stop sequences. +// - `providerOptions`: namespaced provider-native behavior. For example, +// OpenAI cache keys and store behavior, Anthropic thinking, Gemini thinking +// config, or OpenRouter routing/reasoning. +// - `http`: last-resort serializable overlays for final request body, headers, +// and query params. Prefer typed `providerOptions` when a field is stable. +// +// Route/provider options are defaults. Request options override them for this call. +const request = LLM.request({ + model, + system: "You are concise and practical.", + prompt: "Tell me a joke", + generation: { maxTokens: 80, temperature: 0.7 }, + providerOptions: { + openai: { promptCacheKey: "tutorial-joke" }, + }, +}) + +// `http` is intentionally not needed for normal calls. This shows the shape for +// newly released provider fields before they deserve a typed provider option. +const rawOverlayExample = LLM.request({ + model, + prompt: "Show the final HTTP overlay shape.", + http: { + body: { metadata: { example: "tutorial" } }, + headers: { "x-opencode-tutorial": "1" }, + query: { debug: "1" }, + }, +}) + +// 3. `generate` sends the request and collects the event stream into one +// response object. `response.text` is the collected text output. +const generateOnce = Effect.gen(function* () { + const response = yield* LLM.generate(request) + + console.log("\n== generate ==") + console.log("generated text:", response.text) + console.log("usage", Formatter.formatJson(response.usage, { space: 2 })) +}) + +// 4. `stream` exposes provider output as common `LLMEvent`s for UIs that want +// incremental text, reasoning, tool input, usage, or finish events. +const streamText = LLM.stream(request).pipe( + Stream.tap((event) => + Effect.sync(() => { + if (event.type === "text-delta") process.stdout.write(`\ntext: ${event.text}`) + if (event.type === "finish") process.stdout.write(`\nfinish: ${event.reason}\n`) + }), + ), + Stream.runDrain, +) + +// 5. Tools are typed with Effect Schema. Provider turns remain explicit: +// advertise definitions on the request, stream one turn, dispatch local calls, +// then persist/build follow-up history in the enclosing product flow. +const tools = { + get_weather: Tool.make({ + description: "Get current weather for a city.", + parameters: Schema.Struct({ city: Schema.String }), + success: Schema.Struct({ forecast: Schema.String }), + execute: (input) => Effect.succeed({ forecast: `${input.city}: sunny, 72F` }), + }), +} + +const streamWithTools = Effect.gen(function* () { + const request = LLM.request({ + model, + prompt: "Use get_weather for San Francisco, then answer in one sentence.", + generation: { maxTokens: 80, temperature: 0 }, + tools: Tool.toDefinitions(tools), + }) + const events = Array.from(yield* LLM.stream(request).pipe(Stream.runCollect)) + for (const event of events) { + if (event.type === "tool-call") console.log("tool call", event.name, event.input) + if (event.type === "text-delta") process.stdout.write(event.text) + if (event.type !== "tool-call" || event.providerExecuted) continue + const dispatched = yield* ToolRuntime.dispatch(tools, event) + console.log("tool result", event.name, dispatched.result) + + // A durable agent would persist these messages before starting another + // raw model turn. This tutorial keeps the boundary visible instead. + const followUp = LLM.updateRequest(request, { + messages: [ + ...request.messages, + Message.assistant([event]), + Message.tool({ ...event, result: dispatched.result }), + ], + }) + console.log("follow-up history messages:", followUp.messages.length) + } +}) + +// 6. `generateObject` is the structured-output helper. It forces a synthetic +// tool call internally, so the same call site works across providers instead of +// depending on provider-specific JSON mode flags. +const WeatherReport = Schema.Struct({ + city: Schema.String, + forecast: Schema.String, + highFahrenheit: Schema.Number, +}) + +const generateStructuredObject = Effect.gen(function* () { + const response = yield* LLM.generateObject({ + model, + system: "Return only structured weather data.", + prompt: "Give me today's weather for San Francisco.", + schema: WeatherReport, + generation: { maxTokens: 120, temperature: 0 }, + }) + + console.log("\n== generateObject ==") + console.log(Formatter.formatJson(response.object, { space: 2 })) +}) + +// If the shape is only known at runtime, pass raw JSON Schema instead. The +// `.object` type is `unknown`; callers that need static types should validate it. +const generateDynamicObject = LLM.generateObject({ + model, + prompt: "Extract the city and forecast from: San Francisco is sunny.", + jsonSchema: { + type: "object", + properties: { + city: { type: "string" }, + forecast: { type: "string" }, + }, + required: ["city", "forecast"], + }, +}) + +// ----------------------------------------------------------------------------- +// Part 2: provider composition with a fake provider +// ----------------------------------------------------------------------------- + +// A protocol is the provider-native API shape: common request -> body, response +// frames -> common events. This fake one turns text prompts into a JSON body +// and treats every SSE frame as output text. +const FakeBody = Schema.Struct({ + model: Schema.String, + input: Schema.String, +}) +type FakeBody = Schema.Schema.Type + +const FakeProtocol = Protocol.make({ + // Protocol ids are open strings, so external packages can define their own + // protocols without changing this package. + id: "fake-echo", + body: { + schema: FakeBody, + from: (request) => + Effect.succeed({ + model: request.model.id, + input: request.messages + .flatMap((message) => message.content) + .filter((part) => part.type === "text") + .map((part) => part.text) + .join("\n"), + }), + }, + stream: { + event: Schema.String, + initial: () => undefined, + step: (_, frame) => Effect.succeed([undefined, [{ type: "text-delta", id: "text-0", text: frame }]] as const), + onHalt: () => [{ type: "finish", reason: "stop" }], + }, +}) + +// An route is the runnable binding for that protocol. It adds the deployment +// axes that the protocol deliberately does not know: URL, auth, and framing. +const FakeAdapter = Route.make({ + id: "fake-echo", + provider: "fake-echo", + protocol: FakeProtocol, + endpoint: Endpoint.path("/v1/echo", { baseURL: "https://fake.local" }), + auth: Auth.passthrough, + framing: Framing.sse, +}) + +// A provider module exports a configured facade. Configuration happens before +// model selection; model selectors accept ids only. +const FakeEcho = { + id: ProviderID.make("fake-echo"), + configure: () => ({ + id: ProviderID.make("fake-echo"), + model: (id: string) => FakeAdapter.model({ id }), + }), +} + +// `LLMClient.prepare` is the lower-level inspection hook: it compiles through +// body conversion, validation, endpoint, auth, and HTTP construction without +// sending anything over the network. +const inspectFakeProvider = Effect.gen(function* () { + const prepared = yield* LLMClient.prepare( + LLM.request({ + model: FakeEcho.configure().model("tiny-echo"), + prompt: "Show me the provider pipeline.", + }), + ) + + console.log("\n== fake provider prepare ==") + console.log("route:", prepared.route) + console.log("body:", Formatter.formatJson(prepared.body, { space: 2 })) +}) + +// Provide the LLM runtime and the HTTP request executor once. Keep one path +// enabled at a time so the tutorial can demonstrate generate, prepare, stream, +// or tool-loop behavior without spending tokens on every example. +const requestExecutorLayer = RequestExecutor.fetchLayer +const llmDeps = Layer.mergeAll(requestExecutorLayer, WebSocketExecutor.layer) +const llmClientLayer = LLMClient.layer.pipe(Layer.provide(llmDeps)) + +const program = Effect.gen(function* () { + // yield* generateOnce + // yield* inspectFakeProvider + // yield* LLMClient.prepare(rawOverlayExample).pipe(Effect.andThen((prepared) => Effect.sync(() => console.log(prepared.body)))) + // yield* streamText + // yield* generateStructuredObject + // yield* generateDynamicObject.pipe(Effect.andThen((response) => Effect.sync(() => console.log(response.object)))) + yield* streamWithTools +}).pipe(Effect.provide(Layer.mergeAll(llmDeps, llmClientLayer))) + +Effect.runPromise(program) diff --git a/packages/llm/script/recording-cost-report.ts b/packages/llm/script/recording-cost-report.ts new file mode 100644 index 0000000000000000000000000000000000000000..1f42dc59323ee3424790f475f4d99cb003ecc8bd --- /dev/null +++ b/packages/llm/script/recording-cost-report.ts @@ -0,0 +1,250 @@ +import * as fs from "node:fs/promises" +import * as path from "node:path" + +const RECORDINGS_DIR = path.resolve(import.meta.dir, "..", "test", "fixtures", "recordings") +const MODELS_DEV_URL = "https://models.opencode.ai/api.json" + +type JsonRecord = Record + +type Pricing = { + readonly input?: number + readonly output?: number + readonly cache_read?: number + readonly cache_write?: number + readonly reasoning?: number +} + +type Usage = { + readonly inputTokens: number + readonly outputTokens: number + readonly cacheReadTokens: number + readonly cacheWriteTokens: number + readonly reasoningTokens: number + readonly reportedCost: number +} + +type Row = Usage & { + readonly cassette: string + readonly provider: string + readonly model: string + readonly estimatedCost: number + readonly pricingSource: string +} + +const isRecord = (value: unknown): value is JsonRecord => + value !== null && typeof value === "object" && !Array.isArray(value) + +const asNumber = (value: unknown) => (typeof value === "number" && Number.isFinite(value) ? value : 0) + +const asString = (value: unknown) => (typeof value === "string" ? value : undefined) + +const readJson = async (file: string) => JSON.parse(await Bun.file(file).text()) as unknown + +const walk = async (dir: string): Promise> => + (await fs.readdir(dir, { withFileTypes: true })) + .flatMap((entry) => { + const file = path.join(dir, entry.name) + return entry.isDirectory() ? [] : [file] + }) + .concat( + ...(await Promise.all( + (await fs.readdir(dir, { withFileTypes: true })) + .filter((entry) => entry.isDirectory()) + .map((entry) => walk(path.join(dir, entry.name))), + )), + ) + +const providerFromUrl = (url: string) => { + if (url.includes("api.openai.com")) return "openai" + if (url.includes("api.anthropic.com")) return "anthropic" + if (url.includes("generativelanguage.googleapis.com")) return "google" + if (url.includes("bedrock")) return "amazon-bedrock" + if (url.includes("openrouter.ai")) return "openrouter" + if (url.includes("api.x.ai")) return "xai" + if (url.includes("api.groq.com")) return "groq" + if (url.includes("api.deepseek.com")) return "deepseek" + if (url.includes("api.together.xyz")) return "togetherai" + return "unknown" +} + +const providerAliases: Record> = { + openai: ["openai"], + anthropic: ["anthropic"], + google: ["google"], + "amazon-bedrock": ["amazon-bedrock"], + openrouter: ["openrouter", "openai", "anthropic", "google"], + xai: ["xai"], + groq: ["groq"], + deepseek: ["deepseek"], + togetherai: ["togetherai"], +} + +const modelAliases = (model: string) => [ + model, + model.replace(/^models\//, ""), + model.replace(/-\d{8}$/, ""), + model.replace(/-\d{4}-\d{2}-\d{2}$/, ""), + model.replace(/-\d{4}-\d{2}-\d{2}$/, "").replace(/-\d{8}$/, ""), + model.replace(/^openai\//, ""), + model.replace(/^anthropic\//, ""), + model.replace(/^google\//, ""), +] + +const pricingFor = (models: JsonRecord, provider: string, model: string) => { + for (const providerID of providerAliases[provider] ?? [provider]) { + const providerEntry = models[providerID] + if (!isRecord(providerEntry) || !isRecord(providerEntry.models)) continue + for (const modelID of modelAliases(model)) { + const modelEntry = providerEntry.models[modelID] + if (isRecord(modelEntry) && isRecord(modelEntry.cost)) + return { pricing: modelEntry.cost as Pricing, source: `${providerID}/${modelID}` } + } + } + return { pricing: undefined, source: "missing" } +} + +const estimateCost = (usage: Usage, pricing: Pricing | undefined) => { + if (!pricing) return 0 + return ( + (usage.inputTokens * (pricing.input ?? 0) + + usage.outputTokens * (pricing.output ?? 0) + + usage.cacheReadTokens * (pricing.cache_read ?? 0) + + usage.cacheWriteTokens * (pricing.cache_write ?? 0) + + usage.reasoningTokens * (pricing.reasoning ?? 0)) / + 1_000_000 + ) +} + +const emptyUsage = (): Usage => ({ + inputTokens: 0, + outputTokens: 0, + cacheReadTokens: 0, + cacheWriteTokens: 0, + reasoningTokens: 0, + reportedCost: 0, +}) + +const addUsage = (a: Usage, b: Usage): Usage => ({ + inputTokens: a.inputTokens + b.inputTokens, + outputTokens: a.outputTokens + b.outputTokens, + cacheReadTokens: a.cacheReadTokens + b.cacheReadTokens, + cacheWriteTokens: a.cacheWriteTokens + b.cacheWriteTokens, + reasoningTokens: a.reasoningTokens + b.reasoningTokens, + reportedCost: a.reportedCost + b.reportedCost, +}) + +const usageFromObject = (usage: unknown): Usage => { + if (!isRecord(usage)) return emptyUsage() + const promptDetails = isRecord(usage.prompt_tokens_details) ? usage.prompt_tokens_details : {} + const completionDetails = isRecord(usage.completion_tokens_details) ? usage.completion_tokens_details : {} + const inputDetails = isRecord(usage.input_tokens_details) ? usage.input_tokens_details : {} + const outputDetails = isRecord(usage.output_tokens_details) ? usage.output_tokens_details : {} + const cacheWriteTokens = asNumber(promptDetails.cache_write_tokens) + asNumber(inputDetails.cache_write_tokens) + return { + inputTokens: asNumber(usage.prompt_tokens) + asNumber(usage.input_tokens), + outputTokens: asNumber(usage.completion_tokens) + asNumber(usage.output_tokens), + cacheReadTokens: asNumber(promptDetails.cached_tokens) + asNumber(inputDetails.cached_tokens), + cacheWriteTokens, + reasoningTokens: asNumber(completionDetails.reasoning_tokens) + asNumber(outputDetails.reasoning_tokens), + reportedCost: asNumber(usage.cost), + } +} + +const jsonPayloads = (body: string) => + body + .split("\n") + .map((line) => line.trim()) + .filter((line) => line.startsWith("data:")) + .map((line) => line.slice("data:".length).trim()) + .filter((line) => line !== "" && line !== "[DONE]") + .flatMap((line) => { + try { + return [JSON.parse(line) as unknown] + } catch { + return [] + } + }) + +const usageFromResponseBody = (body: string) => + jsonPayloads(body).reduce((usage, payload) => { + if (!isRecord(payload)) return usage + return addUsage( + usage, + addUsage( + usageFromObject(payload.usage), + usageFromObject(isRecord(payload.response) ? payload.response.usage : undefined), + ), + ) + }, emptyUsage()) + +const modelFromRequest = (request: unknown) => { + if (!isRecord(request)) return "unknown" + const requestBody = asString(request.body) + if (!requestBody) return "unknown" + try { + const body = JSON.parse(requestBody) as unknown + if (!isRecord(body)) return "unknown" + return asString(body.model) ?? "unknown" + } catch { + return "unknown" + } +} + +const rowFor = (models: JsonRecord, file: string, cassette: unknown): Row | undefined => { + if (!isRecord(cassette) || !Array.isArray(cassette.interactions)) return undefined + const first = cassette.interactions.find(isRecord) + if (!first || !isRecord(first.request)) return undefined + const provider = providerFromUrl(asString(first.request.url) ?? "") + const model = modelFromRequest(first.request) + const usage = cassette.interactions.filter(isRecord).reduce((total, interaction) => { + if (!isRecord(interaction.response)) return total + const responseBody = asString(interaction.response.body) + if (!responseBody) return total + return addUsage(total, usageFromResponseBody(responseBody)) + }, emptyUsage()) + const priced = pricingFor(models, provider, model) + return { + cassette: path.relative(RECORDINGS_DIR, file), + provider, + model, + ...usage, + estimatedCost: estimateCost(usage, priced.pricing), + pricingSource: priced.source, + } +} + +const money = (value: number) => (value === 0 ? "$0.000000" : `$${value.toFixed(6)}`) +const tokens = (value: number) => value.toLocaleString("en-US") + +const models = (await (await fetch(MODELS_DEV_URL)).json()) as JsonRecord +const rows = ( + await Promise.all( + (await walk(RECORDINGS_DIR)) + .filter((file) => file.endsWith(".json")) + .map(async (file) => rowFor(models, file, await readJson(file))), + ) +).filter((row): row is Row => row !== undefined) + +const totals = rows.reduce( + (total, row) => ({ + ...addUsage(total, row), + estimatedCost: total.estimatedCost + row.estimatedCost, + }), + { ...emptyUsage(), estimatedCost: 0 }, +) + +console.log("# Recording Cost Report") +console.log("") +console.log(`Pricing: ${MODELS_DEV_URL}`) +console.log(`Cassettes: ${rows.length}`) +console.log(`Reported cost: ${money(totals.reportedCost)}`) +console.log(`Estimated cost: ${money(totals.estimatedCost)}`) +console.log("") +console.log("| Provider | Model | Input | Output | Reasoning | Reported | Estimated | Pricing | Cassette |") +console.log("|---|---:|---:|---:|---:|---:|---:|---|---|") +for (const row of rows.toSorted((a, b) => b.reportedCost + b.estimatedCost - (a.reportedCost + a.estimatedCost))) { + if (row.inputTokens + row.outputTokens + row.reasoningTokens + row.reportedCost + row.estimatedCost === 0) continue + console.log( + `| ${row.provider} | ${row.model} | ${tokens(row.inputTokens)} | ${tokens(row.outputTokens)} | ${tokens(row.reasoningTokens)} | ${money(row.reportedCost)} | ${money(row.estimatedCost)} | ${row.pricingSource} | ${row.cassette} |`, + ) +} diff --git a/packages/llm/script/setup-recording-env.ts b/packages/llm/script/setup-recording-env.ts new file mode 100644 index 0000000000000000000000000000000000000000..d32769b3cea56aec64feef93bc786150c82e8bfb --- /dev/null +++ b/packages/llm/script/setup-recording-env.ts @@ -0,0 +1,542 @@ +#!/usr/bin/env bun + +import { NodeFileSystem } from "@effect/platform-node" +import * as path from "node:path" +import * as prompts from "@clack/prompts" +import { AwsV4Signer } from "aws4fetch" +import { Config, ConfigProvider, Effect, FileSystem, PlatformError, Redacted } from "effect" +import { FetchHttpClient, HttpClient, HttpClientRequest, type HttpClientResponse } from "effect/unstable/http" +import * as ProviderShared from "../src/protocols/shared" +import * as Cloudflare from "../src/providers/cloudflare" + +type Provider = { + readonly id: string + readonly label: string + readonly tier: "core" | "canary" | "compatible" | "optional" + readonly note: string + readonly vars: ReadonlyArray<{ + readonly name: string + readonly label?: string + readonly optional?: boolean + readonly secret?: boolean + }> + readonly validate?: (env: Env) => Effect.Effect +} + +type Env = Record + +const PROVIDERS: ReadonlyArray = [ + { + id: "openai", + label: "OpenAI", + tier: "core", + note: "Native OpenAI Chat / Responses recorded tests", + vars: [{ name: "OPENAI_API_KEY" }], + validate: (env) => validateBearer("https://api.openai.com/v1/models", Redacted.make(env.OPENAI_API_KEY)), + }, + { + id: "anthropic", + label: "Anthropic", + tier: "core", + note: "Native Anthropic Messages recorded tests", + vars: [{ name: "ANTHROPIC_API_KEY" }], + validate: (env) => + HttpClientRequest.get("https://api.anthropic.com/v1/models").pipe( + HttpClientRequest.setHeaders({ + "anthropic-version": "2023-06-01", + "x-api-key": Redacted.value(Redacted.make(env.ANTHROPIC_API_KEY)), + }), + executeRequest, + ), + }, + { + id: "google", + label: "Google Gemini", + tier: "core", + note: "Native Gemini recorded tests", + vars: [{ name: "GOOGLE_GENERATIVE_AI_API_KEY" }], + validate: (env) => + HttpClientRequest.get( + `https://generativelanguage.googleapis.com/v1beta/models?key=${encodeURIComponent(env.GOOGLE_GENERATIVE_AI_API_KEY)}`, + ).pipe(executeRequest), + }, + { + id: "bedrock", + label: "Amazon Bedrock", + tier: "core", + note: "Native Bedrock Converse recorded tests", + vars: [ + { name: "AWS_ACCESS_KEY_ID" }, + { name: "AWS_SECRET_ACCESS_KEY" }, + { name: "AWS_SESSION_TOKEN", optional: true }, + { name: "BEDROCK_RECORDING_REGION", optional: true }, + { name: "BEDROCK_MODEL_ID", optional: true }, + ], + validate: (env) => validateBedrock(env), + }, + { + id: "groq", + label: "Groq", + tier: "canary", + note: "Fast OpenAI-compatible canary for text/tool streaming", + vars: [{ name: "GROQ_API_KEY" }], + validate: (env) => validateBearer("https://api.groq.com/openai/v1/models", Redacted.make(env.GROQ_API_KEY)), + }, + { + id: "openrouter", + label: "OpenRouter", + tier: "canary", + note: "Router canary for OpenAI-compatible text/tool streaming", + vars: [{ name: "OPENROUTER_API_KEY" }], + validate: (env) => + validateChat({ + url: "https://openrouter.ai/api/v1/chat/completions", + token: Redacted.make(env.OPENROUTER_API_KEY), + model: "openai/gpt-4o-mini", + }), + }, + { + id: "xai", + label: "xAI", + tier: "canary", + note: "OpenAI-compatible xAI chat endpoint", + vars: [{ name: "XAI_API_KEY" }], + validate: (env) => validateBearer("https://api.x.ai/v1/models", Redacted.make(env.XAI_API_KEY)), + }, + { + id: "cloudflare-ai-gateway", + label: "Cloudflare AI Gateway", + tier: "canary", + note: "Cloudflare Unified/OpenAI-compatible gateway; supports provider/model ids like workers-ai/@cf/...", + vars: [ + { name: "CLOUDFLARE_ACCOUNT_ID", label: "Cloudflare account ID", secret: false }, + { + name: "CLOUDFLARE_GATEWAY_ID", + label: "Cloudflare AI Gateway ID (defaults to default)", + optional: true, + secret: false, + }, + { name: "CLOUDFLARE_API_TOKEN", label: "Cloudflare AI Gateway token" }, + ], + validate: (env) => + validateChat({ + url: `${Cloudflare.aiGatewayBaseURL({ + accountId: env.CLOUDFLARE_ACCOUNT_ID, + gatewayId: env.CLOUDFLARE_GATEWAY_ID || undefined, + })}/chat/completions`, + token: Redacted.make(envValue(env, Cloudflare.aiGatewayAuthEnvVars)), + tokenHeader: "cf-aig-authorization", + model: "workers-ai/@cf/meta/llama-3.1-8b-instruct", + }), + }, + { + id: "cloudflare-workers-ai", + label: "Cloudflare Workers AI", + tier: "canary", + note: "Direct Workers AI OpenAI-compatible endpoint; supports model ids like @cf/meta/...", + vars: [ + { name: "CLOUDFLARE_ACCOUNT_ID", label: "Cloudflare account ID", secret: false }, + { name: "CLOUDFLARE_API_KEY", label: "Cloudflare Workers AI API token" }, + ], + validate: (env) => + validateChat({ + url: `${Cloudflare.workersAIBaseURL({ accountId: env.CLOUDFLARE_ACCOUNT_ID })}/chat/completions`, + token: Redacted.make(envValue(env, Cloudflare.workersAIAuthEnvVars)), + model: "@cf/meta/llama-3.1-8b-instruct", + }), + }, + { + id: "deepseek", + label: "DeepSeek", + tier: "compatible", + note: "Existing OpenAI-compatible recorded tests", + vars: [{ name: "DEEPSEEK_API_KEY" }], + validate: (env) => validateBearer("https://api.deepseek.com/models", Redacted.make(env.DEEPSEEK_API_KEY)), + }, + { + id: "togetherai", + label: "TogetherAI", + tier: "compatible", + note: "Existing OpenAI-compatible text/tool recorded tests", + vars: [{ name: "TOGETHER_AI_API_KEY" }], + validate: (env) => validateBearer("https://api.together.xyz/v1/models", Redacted.make(env.TOGETHER_AI_API_KEY)), + }, + { + id: "mistral", + label: "Mistral", + tier: "optional", + note: "OpenAI-compatible bridge; native reasoning parity is follow-up work", + vars: [{ name: "MISTRAL_API_KEY" }], + validate: (env) => validateBearer("https://api.mistral.ai/v1/models", Redacted.make(env.MISTRAL_API_KEY)), + }, + { + id: "perplexity", + label: "Perplexity", + tier: "optional", + note: "OpenAI-compatible bridge; citations/search metadata are follow-up work", + vars: [{ name: "PERPLEXITY_API_KEY" }], + validate: (env) => validateBearer("https://api.perplexity.ai/models", Redacted.make(env.PERPLEXITY_API_KEY)), + }, + { + id: "venice", + label: "Venice", + tier: "optional", + note: "OpenAI-compatible bridge", + vars: [{ name: "VENICE_API_KEY" }], + validate: (env) => validateBearer("https://api.venice.ai/api/v1/models", Redacted.make(env.VENICE_API_KEY)), + }, + { + id: "cerebras", + label: "Cerebras", + tier: "optional", + note: "OpenAI-compatible bridge", + vars: [{ name: "CEREBRAS_API_KEY" }], + validate: (env) => validateBearer("https://api.cerebras.ai/v1/models", Redacted.make(env.CEREBRAS_API_KEY)), + }, + { + id: "deepinfra", + label: "DeepInfra", + tier: "optional", + note: "OpenAI-compatible bridge", + vars: [{ name: "DEEPINFRA_API_KEY" }], + validate: (env) => + validateBearer("https://api.deepinfra.com/v1/openai/models", Redacted.make(env.DEEPINFRA_API_KEY)), + }, + { + id: "fireworks", + label: "Fireworks", + tier: "optional", + note: "OpenAI-compatible bridge", + vars: [{ name: "FIREWORKS_API_KEY" }], + validate: (env) => + validateBearer("https://api.fireworks.ai/inference/v1/models", Redacted.make(env.FIREWORKS_API_KEY)), + }, + { + id: "baseten", + label: "Baseten", + tier: "optional", + note: "OpenAI-compatible bridge", + vars: [{ name: "BASETEN_API_KEY" }], + }, +] + +const args = process.argv.slice(2) +const hasFlag = (name: string) => args.includes(name) +const option = (name: string) => { + const index = args.indexOf(name) + if (index === -1) return undefined + return args[index + 1] +} + +const envPath = path.resolve(process.cwd(), option("--env") ?? ".env.local") +const checkOnly = hasFlag("--check") +const providerOption = option("--providers") +const interactive = Boolean(process.stdin.isTTY && process.stdout.isTTY) + +const envNames = Array.from(new Set(PROVIDERS.flatMap((provider) => provider.vars.map((item) => item.name)))) + +const providersForOption = (value: string | undefined) => { + if (!value || value === "recommended") + return PROVIDERS.filter((provider) => provider.tier === "core" || provider.tier === "canary") + if (value === "recorded") return PROVIDERS.filter((provider) => provider.tier !== "optional") + if (value === "all") return PROVIDERS + const ids = new Set( + value + .split(",") + .map((item) => item.trim()) + .filter(Boolean), + ) + return PROVIDERS.filter((provider) => ids.has(provider.id)) +} + +const chooseProviders = async () => { + if (providerOption) return providersForOption(providerOption) + return providersForOption("recommended") +} + +const catchMissingFile = (error: PlatformError.PlatformError) => { + if (error.reason._tag === "NotFound") return Effect.succeed("") + return Effect.fail(error) +} + +const readEnvFile = Effect.fn("RecordingEnv.readFile")(function* () { + const fileSystem = yield* FileSystem.FileSystem + return yield* fileSystem.readFileString(envPath).pipe(Effect.catch(catchMissingFile)) +}) + +const readConfigString = (provider: ConfigProvider.ConfigProvider, name: string) => + Config.string(name) + .parse(provider) + .pipe( + Effect.match({ + onFailure: () => undefined, + onSuccess: (value) => value, + }), + ) + +const parseEnv = Effect.fn("RecordingEnv.parseEnv")(function* (contents: string) { + const provider = ConfigProvider.fromDotEnvContents(contents) + return Object.fromEntries( + (yield* Effect.forEach(envNames, (name) => + readConfigString(provider, name).pipe(Effect.map((value) => [name, value] as const)), + )).filter((entry): entry is readonly [string, string] => entry[1] !== undefined), + ) +}) + +const quote = (value: string) => JSON.stringify(value) + +const status = (name: string, fileEnv: Env) => { + if (fileEnv[name]) return "file" + if (process.env[name]) return "shell" + return "missing" +} + +const statusLine = (provider: Provider, fileEnv: Env) => + [ + `${provider.label} (${provider.tier})`, + provider.note, + ...provider.vars.map((item) => { + const value = status(item.name, fileEnv) + const suffix = item.optional ? " optional" : "" + return ` ${value === "missing" ? "missing" : "set"} ${item.name}${suffix}${value === "shell" ? " (shell only)" : ""}` + }), + ].join("\n") + +const printStatus = (providers: ReadonlyArray, fileEnv: Env) => { + prompts.note(providers.map((provider) => statusLine(provider, fileEnv)).join("\n\n"), `Recording env: ${envPath}`) +} + +const exitIfCancel = (value: A | symbol): A => { + if (!prompts.isCancel(value)) return value as A + prompts.cancel("Cancelled") + process.exit(130) +} + +const upsertEnv = (contents: string, values: Env) => { + const names = Object.keys(values) + const seen = new Set() + const lines = contents.split(/\r?\n/).map((line) => { + const match = line.match(/^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=/) + if (!match || !names.includes(match[1])) return line + seen.add(match[1]) + return `${match[1]}=${quote(values[match[1]])}` + }) + const missing = names.filter((name) => !seen.has(name)) + if (missing.length === 0) return lines.join("\n").replace(/\n*$/, "\n") + const prefix = lines.join("\n").trimEnd() + const block = [ + "", + "# Added by bun run setup:recording-env", + ...missing.map((name) => `${name}=${quote(values[name])}`), + ].join("\n") + return `${prefix}${block}\n` +} + +const providerRequiredStatus = (provider: Provider, fileEnv: Env) => { + const required = requiredVars(provider) + if (required.some((item) => status(item.name, fileEnv) === "missing")) return "missing" + if (required.some((item) => status(item.name, fileEnv) === "shell")) return "set in shell" + return "already added" +} + +const requiredVars = (provider: Provider) => provider.vars.filter((item) => !item.optional) + +const promptVars = (provider: Provider) => provider.vars.filter((item) => !item.optional || item.secret === false) + +const processEnv = (): Env => + Object.fromEntries(Object.entries(process.env).filter((entry): entry is [string, string] => entry[1] !== undefined)) + +const envValue = (env: Env, names: ReadonlyArray) => names.map((name) => env[name]).find(Boolean) ?? "" + +const envWithValues = (fileEnv: Env, values: Env): Env => ({ + ...processEnv(), + ...fileEnv, + ...values, +}) + +const responseError = Effect.fn("RecordingEnv.responseError")(function* ( + response: HttpClientResponse.HttpClientResponse, +) { + if (response.status >= 200 && response.status < 300) return undefined + const body = yield* response.text.pipe(Effect.catch(() => Effect.succeed(""))) + return `${response.status}${body ? `: ${body.slice(0, 180)}` : ""}` +}) + +const executeRequest = Effect.fn("RecordingEnv.executeRequest")(function* ( + request: HttpClientRequest.HttpClientRequest, +) { + const http = yield* HttpClient.HttpClient + return yield* http.execute(request).pipe(Effect.flatMap(responseError)) +}) + +const validateBearer = (url: string, token: Redacted.Redacted, headers: Record = {}) => + HttpClientRequest.get(url).pipe( + HttpClientRequest.setHeaders({ ...headers, authorization: `Bearer ${Redacted.value(token)}` }), + executeRequest, + ) + +const validateChat = (input: { + readonly url: string + readonly token: Redacted.Redacted + readonly tokenHeader?: string + readonly model: string + readonly headers?: Record +}) => + ProviderShared.jsonPost({ + url: input.url, + headers: { ...input.headers, [input.tokenHeader ?? "authorization"]: `Bearer ${Redacted.value(input.token)}` }, + body: ProviderShared.encodeJson({ + model: input.model, + messages: [{ role: "user", content: "Reply with exactly: ok" }], + max_tokens: 3, + temperature: 0, + }), + }).pipe(executeRequest) + +const validateBedrock = (env: Env) => + Effect.gen(function* () { + const request = yield* Effect.promise(() => + new AwsV4Signer({ + url: `https://bedrock.${env.BEDROCK_RECORDING_REGION || "us-east-1"}.amazonaws.com/foundation-models`, + method: "GET", + service: "bedrock", + region: env.BEDROCK_RECORDING_REGION || "us-east-1", + accessKeyId: env.AWS_ACCESS_KEY_ID, + secretAccessKey: env.AWS_SECRET_ACCESS_KEY, + sessionToken: env.AWS_SESSION_TOKEN || undefined, + }).sign(), + ) + return yield* HttpClientRequest.get(request.url.toString()).pipe( + HttpClientRequest.setHeaders(Object.fromEntries(request.headers.entries())), + executeRequest, + ) + }) + +const validateProvider = Effect.fn("RecordingEnv.validateProvider")(function* (provider: Provider, env: Env) { + return yield* (provider.validate?.(env) ?? Effect.succeed("no lightweight validator")).pipe( + Effect.catch((error) => { + if (error instanceof Error) return Effect.succeed(error.message) + return Effect.succeed(String(error)) + }), + ) +}) + +const validateProviders = Effect.fn("RecordingEnv.validateProviders")(function* ( + providers: ReadonlyArray, + env: Env, +) { + const spinner = prompts.spinner() + spinner.start("Validating credentials") + const results = yield* Effect.forEach( + providers, + (provider) => validateProvider(provider, env).pipe(Effect.map((error) => ({ provider, error }))), + { concurrency: 4 }, + ) + spinner.stop("Validation complete") + prompts.note( + results + .map( + (result) => + `${result.error ? "failed" : "ok"} ${result.provider.label}${result.error ? ` - ${result.error}` : ""}`, + ) + .join("\n"), + "Credential validation", + ) +}) + +const writeEnvFile = Effect.fn("RecordingEnv.writeFile")(function* (contents: string) { + const fileSystem = yield* FileSystem.FileSystem + yield* fileSystem.makeDirectory(path.dirname(envPath), { recursive: true }) + yield* fileSystem.writeFileString(envPath, contents, { mode: 0o600 }) +}) + +const prompt = (run: () => Promise) => Effect.promise(run).pipe(Effect.map(exitIfCancel)) + +const chooseConfigurableProviders = Effect.fn("RecordingEnv.chooseConfigurableProviders")(function* ( + providers: ReadonlyArray, + fileEnv: Env, +) { + const configurable = providers.filter((provider) => requiredVars(provider).length > 0) + const selected = yield* prompt>(() => + prompts.multiselect({ + message: "Select provider credentials to add or override", + options: configurable.map((provider) => ({ + value: provider.id, + label: provider.label, + hint: `${providerRequiredStatus(provider, fileEnv)} - ${requiredVars(provider) + .map((item) => item.name) + .join(", ")}`, + })), + initialValues: configurable + .filter((provider) => providerRequiredStatus(provider, fileEnv) === "missing") + .map((provider) => provider.id), + }), + ) + return configurable.filter((provider) => selected.includes(provider.id)) +}) + +const promptEnvVar = (item: Provider["vars"][number]) => + prompt(() => { + const input = { + message: item.label ?? item.name, + validate: (input: string | undefined) => { + if (item.optional) return undefined + return !input || input.length === 0 ? "Leave blank by pressing Esc/cancel, or paste a value" : undefined + }, + } + return item.secret === false ? prompts.text(input) : prompts.password(input) + }) + +const promptProviderValues = Effect.fn("RecordingEnv.promptProviderValues")(function* ( + providers: ReadonlyArray, +) { + const values: Env = {} + for (const provider of providers) { + prompts.log.info(`${provider.label}: ${provider.note}`) + for (const item of promptVars(provider)) { + if (values[item.name]) continue + const value = yield* promptEnvVar(item) + if (value !== "") values[item.name] = value + } + } + return values +}) + +const main = Effect.fn("RecordingEnv.main")(function* () { + prompts.intro("LLM recording credentials") + const contents = yield* readEnvFile() + const fileEnv = yield* parseEnv(contents) + const providers = yield* Effect.promise(() => chooseProviders()) + printStatus(providers, fileEnv) + if (checkOnly) { + prompts.outro("Check complete") + return + } + if (!interactive) { + prompts.outro("Run this command in a terminal to enter credentials") + return + } + + const selectedProviders = yield* chooseConfigurableProviders(providers, fileEnv) + const values = yield* promptProviderValues(selectedProviders) + + if (Object.keys(values).length === 0) { + prompts.outro("No changes") + return + } + + if ( + interactive && + (yield* prompt(() => prompts.confirm({ message: "Validate credentials before saving?", initialValue: true }))) + ) { + yield* validateProviders(selectedProviders, envWithValues(fileEnv, values)) + } + + yield* writeEnvFile(upsertEnv(contents, values)) + prompts.log.success( + `Saved ${Object.keys(values).length} value${Object.keys(values).length === 1 ? "" : "s"} to ${envPath}`, + ) + prompts.outro("Keep .env.local local. Store shared team credentials in a password manager or vault.") +}) + +await Effect.runPromise(main().pipe(Effect.provide(NodeFileSystem.layer), Effect.provide(FetchHttpClient.layer))) diff --git a/packages/llm/src/cache-policy.ts b/packages/llm/src/cache-policy.ts new file mode 100644 index 0000000000000000000000000000000000000000..60f96dc69aaa84e7b7d62fca97b96dac9f674924 --- /dev/null +++ b/packages/llm/src/cache-policy.ts @@ -0,0 +1,111 @@ +// Apply an `LLMRequest.cache` policy by injecting `CacheHint`s onto the parts +// the policy designates. Runs once at compile time, before the per-protocol +// body builder, so the existing inline-hint lowering path handles the rest. +// +// The default `"auto"` shape places one breakpoint at the last tool definition, +// one at the last system part, and one at the latest user message. This +// matches what production agent harnesses (LangChain's caching middleware, +// kern-ai's 10x cost-reduction playbook) converge on for tool-use loops: the +// latest user message stays put while a single turn explodes into many +// assistant/tool round-trips, so caching at that boundary lets every +// intra-turn API call hit the prefix. +// +// Manual `cache: CacheHint` placements on individual parts are preserved — +// this function only fills gaps the caller left empty. +import { CacheHint, type CachePolicy, type CachePolicyObject } from "./schema/options" +import { LLMRequest, Message, ToolDefinition, type ContentPart } from "./schema/messages" + +const AUTO: CachePolicyObject = { + tools: true, + system: true, + messages: "latest-user-message", +} + +const NONE: CachePolicyObject = {} + +// Resolution rules: +// - undefined → "auto" — caching is on by default. The math favors it: +// Anthropic 5m-cache write is 1.25x base, read is 0.1x, +// so a single reuse within 5 minutes already wins. +// - "auto" → tools + system + latest user msg. +// - "none" → no auto placement; manual `CacheHint`s still flow. +// - object form → exactly what the caller asked for. +const resolve = (policy: CachePolicy | undefined): CachePolicyObject => { + if (policy === undefined || policy === "auto") return AUTO + if (policy === "none") return NONE + return policy +} + +// Protocols whose wire format ignores inline cache markers (OpenAI's implicit +// prefix caching, Gemini's implicit + out-of-band CachedContent). Skip the +// whole policy pass for these — emitting hints would be harmless but pointless. +const RESPECTS_INLINE_HINTS = new Set(["anthropic-messages", "bedrock-converse"]) + +const makeHint = (ttlSeconds: number | undefined): CacheHint => + ttlSeconds !== undefined ? new CacheHint({ type: "ephemeral", ttlSeconds }) : new CacheHint({ type: "ephemeral" }) + +const markLastTool = (tools: ReadonlyArray, hint: CacheHint): ReadonlyArray => { + if (tools.length === 0) return tools + const last = tools.length - 1 + if (tools[last]!.cache) return tools + return tools.map((tool, i) => (i === last ? new ToolDefinition({ ...tool, cache: hint }) : tool)) +} + +const markLastSystem = (system: LLMRequest["system"], hint: CacheHint): LLMRequest["system"] => { + if (system.length === 0) return system + const last = system.length - 1 + if (system[last]!.cache) return system + return system.map((part, i) => (i === last ? { ...part, cache: hint } : part)) +} + +const lastIndexOfRole = (messages: ReadonlyArray, role: Message["role"]): number => + messages.findLastIndex((m) => m.role === role) + +// Mark the last text part of `messages[index]`. If no text part exists, mark +// the last content part regardless of type — that's the breakpoint position +// in tool-result-only messages too. +const markMessageAt = (messages: ReadonlyArray, index: number, hint: CacheHint): ReadonlyArray => { + if (index < 0 || index >= messages.length) return messages + const target = messages[index]! + if (target.content.length === 0) return messages + const lastTextIndex = target.content.findLastIndex((part) => part.type === "text") + const markAt = lastTextIndex >= 0 ? lastTextIndex : target.content.length - 1 + const existing = target.content[markAt]! + if ("cache" in existing && existing.cache) return messages + const nextContent = target.content.map((part, i) => (i === markAt ? ({ ...part, cache: hint } as ContentPart) : part)) + const next = new Message({ ...target, content: nextContent }) + // Single pass over `messages`, substituting the one updated entry. Long + // conversations call this on every request, so avoid `.map()` here — its + // closure dispatch and identity copies show up in profiling. + const result = messages.slice() + result[index] = next + return result +} + +const markMessages = ( + messages: ReadonlyArray, + strategy: NonNullable, + hint: CacheHint, +): ReadonlyArray => { + if (messages.length === 0) return messages + if (strategy === "latest-user-message") return markMessageAt(messages, lastIndexOfRole(messages, "user"), hint) + if (strategy === "latest-assistant") return markMessageAt(messages, lastIndexOfRole(messages, "assistant"), hint) + const start = Math.max(0, messages.length - strategy.tail) + let next = messages + for (let i = start; i < messages.length; i++) next = markMessageAt(next, i, hint) + return next +} + +export const applyCachePolicy = (request: LLMRequest): LLMRequest => { + if (!RESPECTS_INLINE_HINTS.has(request.model.route.id)) return request + const policy = resolve(request.cache) + if (!policy.tools && !policy.system && !policy.messages) return request + + const hint = makeHint(policy.ttlSeconds) + const tools = policy.tools ? markLastTool(request.tools, hint) : request.tools + const system = policy.system ? markLastSystem(request.system, hint) : request.system + const messages = policy.messages ? markMessages(request.messages, policy.messages, hint) : request.messages + + if (tools === request.tools && system === request.system && messages === request.messages) return request + return LLMRequest.update(request, { tools, system, messages }) +} diff --git a/packages/llm/src/index.ts b/packages/llm/src/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..735520ff77c2c8a106b5e5b27f7301f1907d46a8 --- /dev/null +++ b/packages/llm/src/index.ts @@ -0,0 +1,33 @@ +export { LLMClient } from "./route/client" +export { Auth } from "./route/auth" +export { Provider } from "./provider" +export { isContextOverflow, isContextOverflowFailure } from "./provider-error" +export type { + RouteModelInput, + RouteRoutedModelInput, + Interface as LLMClientShape, + Service as LLMClientService, +} from "./route/client" +export * from "./schema" +export { Tool, ToolFailure, toDefinitions } from "./tool" +export { ToolRuntime } from "./tool-runtime" +export type { DispatchResult as ToolDispatchResult, ToolSettlement } from "./tool-runtime" +export type { + AnyExecutableTool, + AnyTool, + ExecutableTool, + ExecutableTools, + Tool as ToolShape, + ToolExecute, + ToolExecuteContext, + ToolModelOutputInput, + Tools, + ToolSchema, + ToolToModelOutput, +} from "./tool" +export * as LLM from "./llm" +export type { + Definition as ProviderDefinition, + ModelFactory as ProviderModelFactory, + ModelOptions as ProviderModelOptions, +} from "./provider" diff --git a/packages/llm/src/llm.ts b/packages/llm/src/llm.ts new file mode 100644 index 0000000000000000000000000000000000000000..e4781d8608b0185c500866aae20fda8335640550 --- /dev/null +++ b/packages/llm/src/llm.ts @@ -0,0 +1,186 @@ +import { Effect, JsonSchema, Schema } from "effect" +import { LLMClient } from "./route/client" +import { + GenerationOptions, + HttpOptions, + InvalidProviderOutputReason, + LLMError, + LLMEvent, + LLMRequest, + LLMResponse, + Message, + type ModelInput as SchemaModelInput, + SystemPart, + ToolChoice, + ToolDefinition, + type ContentPart, + ToolResultPart, +} from "./schema" +import { make as makeTool, toDefinitions, type ToolSchema } from "./tool" + +export type ModelInput = SchemaModelInput + +export type MessageInput = Message.Input + +export type ToolChoiceInput = ToolChoice.Input +export type ToolChoiceMode = ToolChoice.Mode + +export type ToolResultInput = Parameters[0] + +/** Input accepted by `LLM.request`, normalized into the canonical `LLMRequest` class. */ +export type RequestInput = Omit< + ConstructorParameters[0], + "system" | "messages" | "tools" | "toolChoice" | "generation" | "http" | "providerOptions" +> & { + readonly system?: string | SystemPart | ReadonlyArray + readonly prompt?: string | ContentPart | ReadonlyArray + readonly messages?: ReadonlyArray + readonly tools?: ReadonlyArray + readonly toolChoice?: ToolChoiceInput + readonly generation?: GenerationOptions.Input + readonly providerOptions?: ConstructorParameters[0]["providerOptions"] + readonly http?: HttpOptions.Input +} + +export const generate = LLMClient.generate + +export const stream = LLMClient.stream + +export const requestInput = (input: LLMRequest): RequestInput => ({ + ...LLMRequest.input(input), +}) + +export const request = (input: RequestInput) => { + const { + system: requestSystem, + prompt, + messages, + tools, + toolChoice: requestToolChoice, + generation: requestGeneration, + providerOptions: requestProviderOptions, + http: requestHttp, + ...rest + } = input + return new LLMRequest({ + ...rest, + system: SystemPart.content(requestSystem), + messages: [...(messages?.map(Message.make) ?? []), ...(prompt === undefined ? [] : [Message.user(prompt)])], + tools: tools?.map(ToolDefinition.make) ?? [], + toolChoice: requestToolChoice ? ToolChoice.make(requestToolChoice) : undefined, + generation: requestGeneration === undefined ? undefined : GenerationOptions.make(requestGeneration), + providerOptions: requestProviderOptions, + http: requestHttp === undefined ? undefined : HttpOptions.make(requestHttp), + }) +} + +export const updateRequest = (input: LLMRequest, patch: Partial) => + request({ ...requestInput(input), ...patch }) + +const GENERATE_OBJECT_TOOL_NAME = "generate_object" + +const GENERATE_OBJECT_TOOL_DESCRIPTION = "Return the structured result by calling this tool." + +type GenerateObjectBase = Omit + +export class GenerateObjectResponse { + constructor( + readonly object: T, + readonly response: LLMResponse, + ) {} + + get events() { + return this.response.events + } + + get usage() { + return this.response.usage + } +} + +export interface GenerateObjectOptions> extends GenerateObjectBase { + readonly schema: S +} + +export interface GenerateObjectDynamicOptions extends GenerateObjectBase { + /** Raw JSON Schema object describing the expected output shape. */ + readonly jsonSchema: JsonSchema.JsonSchema +} + +const runGenerateObject = Effect.fn("LLM.generateObject")(function* ( + options: GenerateObjectBase, + tool: ReturnType, +) { + const baseRequest = request(options) + const generateRequest = LLMRequest.update(baseRequest, { + tools: toDefinitions({ [GENERATE_OBJECT_TOOL_NAME]: tool }), + toolChoice: ToolChoice.named(GENERATE_OBJECT_TOOL_NAME), + }) + const response = yield* LLMClient.generate(generateRequest) + const call = response.toolCalls.find( + (event) => LLMEvent.is.toolCall(event) && event.name === GENERATE_OBJECT_TOOL_NAME, + ) + if (!call || !LLMEvent.is.toolCall(call)) + return yield* new LLMError({ + module: "LLM", + method: "generateObject", + reason: new InvalidProviderOutputReason({ + message: `generateObject: model did not call the forced \`${GENERATE_OBJECT_TOOL_NAME}\` tool`, + }), + }) + const object = yield* tool._decode(call.input).pipe( + Effect.mapError( + (error) => + new LLMError({ + module: "LLM", + method: "generateObject", + reason: new InvalidProviderOutputReason({ + message: `generateObject: tool input failed schema decode: ${error.message}`, + }), + }), + ), + ) + return new GenerateObjectResponse(object, response) +}) + +/** + * Run a model and decode its output against `schema`. Works on every protocol + * because it forces a synthetic tool call internally — provider-native JSON + * modes are intentionally avoided so behaviour is uniform. + * + * Two input modes: + * + * 1. `schema: EffectSchema` — `.object` is decoded and typed as `T`. + * Decode failures surface as `LLMError`. + * 2. `jsonSchema: JsonSchema.JsonSchema` — `.object` is `unknown`. Use when + * the schema is only available at runtime (MCP, plugin manifests). Caller validates. + */ +export function generateObject>( + options: GenerateObjectOptions, +): Effect.Effect>, LLMError> +export function generateObject( + options: GenerateObjectDynamicOptions, +): Effect.Effect, LLMError> +export function generateObject(options: GenerateObjectOptions> | GenerateObjectDynamicOptions) { + if ("schema" in options) { + const { schema, ...rest } = options + return runGenerateObject( + rest, + makeTool({ + description: GENERATE_OBJECT_TOOL_DESCRIPTION, + parameters: schema, + success: Schema.Unknown as ToolSchema, + execute: () => Effect.void, + }), + ) + } + const { jsonSchema, ...rest } = options + return runGenerateObject( + rest, + makeTool({ + description: GENERATE_OBJECT_TOOL_DESCRIPTION, + jsonSchema, + execute: () => Effect.void, + }), + ) +} diff --git a/packages/llm/src/protocols/anthropic-messages.ts b/packages/llm/src/protocols/anthropic-messages.ts new file mode 100644 index 0000000000000000000000000000000000000000..1c0dcd32a4332261a1361010f2f314cbda4fda54 --- /dev/null +++ b/packages/llm/src/protocols/anthropic-messages.ts @@ -0,0 +1,855 @@ +import { Effect, Schema } from "effect" +import { Route } from "../route/client" +import { Auth } from "../route/auth" +import { Endpoint } from "../route/endpoint" +import { Framing } from "../route/framing" +import { Protocol } from "../route/protocol" +import { + LLMEvent, + Usage, + type CacheHint, + type FinishReason, + type JsonSchema, + type LLMRequest, + type MediaPart, + type ProviderMetadata, + type ToolCallPart, + type ToolDefinition, + type ToolContent, + type ToolResultPart, +} from "../schema" +import { JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared" +import { isContextOverflow } from "../provider-error" +import * as Cache from "./utils/cache" +import { Lifecycle } from "./utils/lifecycle" +import { ToolSchemaProjection } from "./utils/tool-schema" +import { ToolStream } from "./utils/tool-stream" + +const ADAPTER = "anthropic-messages" +export const DEFAULT_BASE_URL = "https://api.anthropic.com/v1" +export const PATH = "/messages" + +// ============================================================================= +// Request Body Schema +// ============================================================================= +const AnthropicCacheControl = Schema.Struct({ + type: Schema.tag("ephemeral"), + ttl: Schema.optional(Schema.Literals(["5m", "1h"])), +}) + +const AnthropicTextBlock = Schema.Struct({ + type: Schema.tag("text"), + text: Schema.String, + cache_control: Schema.optional(AnthropicCacheControl), +}) +type AnthropicTextBlock = Schema.Schema.Type + +const AnthropicImageBlock = Schema.Struct({ + type: Schema.tag("image"), + source: Schema.Struct({ + type: Schema.tag("base64"), + media_type: Schema.String, + data: Schema.String, + }), + cache_control: Schema.optional(AnthropicCacheControl), +}) +type AnthropicImageBlock = Schema.Schema.Type + +const AnthropicThinkingBlock = Schema.Struct({ + type: Schema.tag("thinking"), + thinking: Schema.String, + signature: Schema.optional(Schema.String), + cache_control: Schema.optional(AnthropicCacheControl), +}) + +const AnthropicToolUseBlock = Schema.Struct({ + type: Schema.tag("tool_use"), + id: Schema.String, + name: Schema.String, + input: Schema.Unknown, + cache_control: Schema.optional(AnthropicCacheControl), +}) +type AnthropicToolUseBlock = Schema.Schema.Type + +const AnthropicServerToolUseBlock = Schema.Struct({ + type: Schema.tag("server_tool_use"), + id: Schema.String, + name: Schema.String, + input: Schema.Unknown, + cache_control: Schema.optional(AnthropicCacheControl), +}) +type AnthropicServerToolUseBlock = Schema.Schema.Type + +// Server tool result blocks: web_search_tool_result, code_execution_tool_result, +// and web_fetch_tool_result. The provider executes the tool and inlines the +// structured result into the assistant turn — there is no client tool_result +// round-trip. We round-trip the structured `content` payload as opaque JSON so +// the next request can echo it back when continuing the conversation. +const AnthropicServerToolResultType = Schema.Literals([ + "web_search_tool_result", + "code_execution_tool_result", + "web_fetch_tool_result", +]) +type AnthropicServerToolResultType = Schema.Schema.Type + +const AnthropicServerToolResultBlock = Schema.Struct({ + type: AnthropicServerToolResultType, + tool_use_id: Schema.String, + content: Schema.Unknown, + cache_control: Schema.optional(AnthropicCacheControl), +}) +type AnthropicServerToolResultBlock = Schema.Schema.Type + +// Anthropic accepts either a plain string or an ordered array of text/image +// blocks inside `tool_result.content`. The array form is required when a tool +// returns image bytes (screenshot, image search, etc.) so they can be passed +// to the model as proper image inputs instead of being JSON-stringified into +// the prompt — which silently inflates context by megabytes and can push the +// conversation over the model's token limit. +const AnthropicToolResultContent = Schema.Union([AnthropicTextBlock, AnthropicImageBlock]) + +const AnthropicToolResultBlock = Schema.Struct({ + type: Schema.tag("tool_result"), + tool_use_id: Schema.String, + content: Schema.Union([Schema.String, Schema.Array(AnthropicToolResultContent)]), + is_error: Schema.optional(Schema.Boolean), + cache_control: Schema.optional(AnthropicCacheControl), +}) + +const AnthropicUserBlock = Schema.Union([AnthropicTextBlock, AnthropicImageBlock, AnthropicToolResultBlock]) +type AnthropicUserBlock = Schema.Schema.Type +const AnthropicAssistantBlock = Schema.Union([ + AnthropicTextBlock, + AnthropicThinkingBlock, + AnthropicToolUseBlock, + AnthropicServerToolUseBlock, + AnthropicServerToolResultBlock, +]) +type AnthropicAssistantBlock = Schema.Schema.Type +type AnthropicToolResultBlock = Schema.Schema.Type + +const AnthropicMessage = Schema.Union([ + Schema.Struct({ role: Schema.Literal("user"), content: Schema.Array(AnthropicUserBlock) }), + Schema.Struct({ role: Schema.Literal("assistant"), content: Schema.Array(AnthropicAssistantBlock) }), + Schema.Struct({ role: Schema.Literal("system"), content: Schema.Array(AnthropicTextBlock) }), +]).pipe(Schema.toTaggedUnion("role")) +type AnthropicMessage = Schema.Schema.Type + +const AnthropicTool = Schema.Struct({ + name: Schema.String, + description: Schema.String, + input_schema: JsonObject, + cache_control: Schema.optional(AnthropicCacheControl), +}) +type AnthropicTool = Schema.Schema.Type + +const AnthropicToolChoice = Schema.Union([ + Schema.Struct({ type: Schema.Literals(["auto", "any"]) }), + Schema.Struct({ type: Schema.tag("tool"), name: Schema.String }), +]) + +const AnthropicThinking = Schema.Struct({ + type: Schema.tag("enabled"), + budget_tokens: Schema.Number, +}) + +const AnthropicBodyFields = { + model: Schema.String, + system: optionalArray(AnthropicTextBlock), + messages: Schema.Array(AnthropicMessage), + tools: optionalArray(AnthropicTool), + tool_choice: Schema.optional(AnthropicToolChoice), + stream: Schema.Literal(true), + max_tokens: Schema.Number, + temperature: Schema.optional(Schema.Number), + top_p: Schema.optional(Schema.Number), + top_k: Schema.optional(Schema.Number), + stop_sequences: optionalArray(Schema.String), + thinking: Schema.optional(AnthropicThinking), +} +const AnthropicMessagesBody = Schema.Struct(AnthropicBodyFields) +export type AnthropicMessagesBody = Schema.Schema.Type + +const AnthropicUsage = Schema.Struct({ + input_tokens: Schema.optional(Schema.Number), + output_tokens: Schema.optional(Schema.Number), + cache_creation_input_tokens: optionalNull(Schema.Number), + cache_read_input_tokens: optionalNull(Schema.Number), +}) +type AnthropicUsage = Schema.Schema.Type + +const AnthropicStreamBlock = Schema.Struct({ + type: Schema.String, + id: Schema.optional(Schema.String), + name: Schema.optional(Schema.String), + text: Schema.optional(Schema.String), + thinking: Schema.optional(Schema.String), + signature: Schema.optional(Schema.String), + input: Schema.optional(Schema.Unknown), + // *_tool_result blocks arrive whole as content_block_start (no streaming + // delta) with the structured payload in `content` and the originating + // server_tool_use id in `tool_use_id`. + tool_use_id: Schema.optional(Schema.String), + content: Schema.optional(Schema.Unknown), +}) + +const AnthropicStreamDelta = Schema.Struct({ + type: Schema.optional(Schema.String), + text: Schema.optional(Schema.String), + thinking: Schema.optional(Schema.String), + partial_json: Schema.optional(Schema.String), + signature: Schema.optional(Schema.String), + stop_reason: optionalNull(Schema.String), + stop_sequence: optionalNull(Schema.String), +}) + +const AnthropicEvent = Schema.Struct({ + type: Schema.String, + index: Schema.optional(Schema.Number), + message: Schema.optional(Schema.Struct({ usage: Schema.optional(AnthropicUsage) })), + content_block: Schema.optional(AnthropicStreamBlock), + delta: Schema.optional(AnthropicStreamDelta), + usage: Schema.optional(AnthropicUsage), + // `type` and `message` are both required per Anthropic's spec, but + // OpenAI-compatible proxies and gateway translations occasionally drop one + // or the other; mark them optional so a partial payload still parses and + // the parser can fall back to whichever field is populated. + error: Schema.optional( + Schema.Struct({ type: Schema.optional(Schema.String), message: Schema.optional(Schema.String) }), + ), +}) +type AnthropicEvent = Schema.Schema.Type + +interface ParserState { + readonly tools: ToolStream.State + readonly usage?: Usage + readonly lifecycle: Lifecycle.State +} + +const invalid = ProviderShared.invalidRequest + +// ============================================================================= +// Request Lowering +// ============================================================================= +// Anthropic accepts at most 4 explicit cache_control breakpoints per request, +// across `tools`, `system`, and `messages`. Beyond the cap the API returns a +// 400 — so the lowering layer counts emitted markers and silently drops any +// that exceed it. +const ANTHROPIC_BREAKPOINT_CAP = 4 + +const EPHEMERAL_5M = { type: "ephemeral" as const } +const EPHEMERAL_1H = { type: "ephemeral" as const, ttl: "1h" as const } + +const cacheControl = (breakpoints: Cache.Breakpoints, cache: CacheHint | undefined) => { + if (cache?.type !== "ephemeral" && cache?.type !== "persistent") return undefined + if (breakpoints.remaining <= 0) { + breakpoints.dropped += 1 + return undefined + } + breakpoints.remaining -= 1 + return Cache.ttlBucket(cache.ttlSeconds) === "1h" ? EPHEMERAL_1H : EPHEMERAL_5M +} + +const anthropicMetadata = (metadata: Record): ProviderMetadata => ({ anthropic: metadata }) + +const signatureFromMetadata = (metadata: ProviderMetadata | undefined): string | undefined => { + const anthropic = metadata?.anthropic + if (!ProviderShared.isRecord(anthropic)) return undefined + return typeof anthropic.signature === "string" ? anthropic.signature : undefined +} + +const lowerTool = (breakpoints: Cache.Breakpoints, tool: ToolDefinition, inputSchema: JsonSchema): AnthropicTool => ({ + name: tool.name, + description: tool.description, + input_schema: inputSchema, + cache_control: cacheControl(breakpoints, tool.cache), +}) + +const lowerToolChoice = (toolChoice: NonNullable) => + ProviderShared.matchToolChoice("Anthropic Messages", toolChoice, { + auto: () => ({ type: "auto" as const }), + none: () => undefined, + required: () => ({ type: "any" as const }), + tool: (name) => ({ type: "tool" as const, name }), + }) + +const lowerToolCall = (part: ToolCallPart): AnthropicToolUseBlock => ({ + type: "tool_use", + id: part.id, + name: part.name, + input: part.input, +}) + +const lowerServerToolCall = (part: ToolCallPart): AnthropicServerToolUseBlock => ({ + type: "server_tool_use", + id: part.id, + name: part.name, + input: part.input, +}) + +// Server tool result blocks are typed by name. Anthropic ships three today; +// extend this list when new server tools land. The block content is the +// structured payload returned by the provider, which we round-trip as-is. +const serverToolResultType = (name: string): AnthropicServerToolResultType | undefined => { + if (name === "web_search") return "web_search_tool_result" + if (name === "code_execution") return "code_execution_tool_result" + if (name === "web_fetch") return "web_fetch_tool_result" + return undefined +} + +const lowerServerToolResult = Effect.fn("AnthropicMessages.lowerServerToolResult")(function* (part: ToolResultPart) { + const wireType = serverToolResultType(part.name) + if (!wireType) + return yield* invalid(`Anthropic Messages does not know how to round-trip server tool result for ${part.name}`) + return { type: wireType, tool_use_id: part.id, content: part.result.value } satisfies AnthropicServerToolResultBlock +}) + +const lowerImage = Effect.fn("AnthropicMessages.lowerImage")(function* (part: MediaPart) { + const media = yield* ProviderShared.validateMedia( + "Anthropic Messages", + part, + new Set(ProviderShared.IMAGE_MIMES), + ) + return { + type: "image" as const, + source: { + type: "base64" as const, + media_type: media.mime, + data: media.base64, + }, + } satisfies AnthropicImageBlock +}) + +// Tool results may carry structured text/images. Keep media as provider-native +// content instead of JSON-stringifying base64 into a prompt string. +const lowerToolResultContentItem = Effect.fn("AnthropicMessages.lowerToolResultContentItem")(function* ( + item: ToolContent, +) { + if (item.type === "text") return { type: "text" as const, text: item.text } satisfies AnthropicTextBlock + const media = yield* ProviderShared.validateToolFile( + "Anthropic Messages", + item, + new Set(ProviderShared.IMAGE_MIMES), + ) + return { + type: "image" as const, + source: { + type: "base64" as const, + media_type: media.mime, + data: media.base64, + }, + } satisfies AnthropicImageBlock +}) + +const lowerToolResultContent = Effect.fn("AnthropicMessages.lowerToolResultContent")(function* (part: ToolResultPart) { + // Text / json / error results stay as a string for backward compatibility + // with existing cassettes and provider expectations. + if (part.result.type !== "content") return ProviderShared.toolResultText(part) + // Preserve the narrowed array element type when compiled through a consumer package. + const content: ReadonlyArray = part.result.value + return yield* Effect.forEach(content, lowerToolResultContentItem) +}) + +// Mid-conversation system messages are a native Claude API feature only for +// Opus 4.8. Other Anthropic models intentionally use the same visible wrapped- +// user fallback as non-Anthropic routes rather than sending a role they reject. +const supportsNativeSystemUpdates = (request: LLMRequest) => String(request.model.id) === "claude-opus-4-8" + +const endsInServerToolUse = (message: LLMRequest["messages"][number]) => { + const last = message.content.at(-1) + return message.role === "assistant" && last?.type === "tool-call" && last.providerExecuted === true +} + +const canUseNativeSystemUpdate = (messages: LLMRequest["messages"], index: number) => { + const previous = messages[index - 1] + const next = messages[index + 1] + return ( + previous !== undefined && + previous.role !== "system" && + (previous.role === "user" || previous.role === "tool" || endsInServerToolUse(previous)) && + next?.role !== "system" && + (next === undefined || next.role === "assistant") + ) +} + +const splitsLocalToolResults = (messages: LLMRequest["messages"], index: number) => { + const pending = new Set() + for (const message of messages.slice(0, index)) { + for (const part of message.content) { + if (message.role === "assistant" && part.type === "tool-call" && part.providerExecuted !== true) + pending.add(part.id) + if (message.role === "tool" && part.type === "tool-result") pending.delete(part.id) + } + } + return pending.size > 0 +} + +const lowerNativeSystemUpdate = Effect.fn("AnthropicMessages.lowerNativeSystemUpdate")(function* ( + message: LLMRequest["messages"][number], + breakpoints: Cache.Breakpoints, +) { + const content = yield* ProviderShared.systemUpdateText("Anthropic Messages", message) + return { + role: "system" as const, + content: content.map((part) => ({ + type: "text" as const, + text: part.text, + cache_control: cacheControl(breakpoints, part.cache), + })), + } +}) + +const lowerMessages = Effect.fn("AnthropicMessages.lowerMessages")(function* ( + request: LLMRequest, + breakpoints: Cache.Breakpoints, +) { + const messages: AnthropicMessage[] = [] + + for (const [index, message] of request.messages.entries()) { + if (message.role === "system") { + if (splitsLocalToolResults(request.messages, index)) + return yield* invalid("Anthropic Messages system updates cannot split a local tool call from its tool result") + if (supportsNativeSystemUpdates(request) && canUseNativeSystemUpdate(request.messages, index)) { + messages.push(yield* lowerNativeSystemUpdate(message, breakpoints)) + continue + } + const part = yield* ProviderShared.wrappedSystemUpdate("Anthropic Messages", message) + const block = { type: "text" as const, text: part.text, cache_control: cacheControl(breakpoints, part.cache) } + const previous = messages.at(-1) + if (previous?.role === "user") + messages[messages.length - 1] = { role: "user", content: [...previous.content, block] } + else messages.push({ role: "user", content: [block] }) + continue + } + + if (message.role === "user") { + const content: AnthropicUserBlock[] = [] + for (const part of message.content) { + if (part.type === "text") { + content.push({ type: "text", text: part.text, cache_control: cacheControl(breakpoints, part.cache) }) + continue + } + if (part.type === "media") { + content.push(yield* lowerImage(part)) + continue + } + return yield* ProviderShared.unsupportedContent("Anthropic Messages", "user", ["text", "media"]) + } + messages.push({ role: "user", content }) + continue + } + + if (message.role === "assistant") { + const content: AnthropicAssistantBlock[] = [] + for (const part of message.content) { + if (part.type === "text") { + content.push({ type: "text", text: part.text, cache_control: cacheControl(breakpoints, part.cache) }) + continue + } + if (part.type === "reasoning") { + content.push({ + type: "thinking", + thinking: part.text, + signature: part.encrypted ?? signatureFromMetadata(part.providerMetadata), + }) + continue + } + if (part.type === "tool-call") { + content.push(part.providerExecuted ? lowerServerToolCall(part) : lowerToolCall(part)) + continue + } + if (part.type === "tool-result" && part.providerExecuted) { + content.push(yield* lowerServerToolResult(part)) + continue + } + return yield* invalid( + `Anthropic Messages assistant messages only support text, reasoning, and tool-call content for now`, + ) + } + messages.push({ role: "assistant", content }) + continue + } + + const content: AnthropicToolResultBlock[] = [] + for (const part of message.content) { + if (!ProviderShared.supportsContent(part, ["tool-result"])) + return yield* ProviderShared.unsupportedContent("Anthropic Messages", "tool", ["tool-result"]) + content.push({ + type: "tool_result", + tool_use_id: part.id, + content: yield* lowerToolResultContent(part), + is_error: part.result.type === "error" ? true : undefined, + cache_control: cacheControl(breakpoints, part.cache), + }) + } + messages.push({ role: "user", content }) + } + + return messages +}) + +const anthropicOptions = (request: LLMRequest) => request.providerOptions?.anthropic + +const lowerThinking = Effect.fn("AnthropicMessages.lowerThinking")(function* (request: LLMRequest) { + const thinking = anthropicOptions(request)?.thinking + if (!ProviderShared.isRecord(thinking) || thinking.type !== "enabled") return undefined + const budget = + typeof thinking.budgetTokens === "number" + ? thinking.budgetTokens + : typeof thinking.budget_tokens === "number" + ? thinking.budget_tokens + : undefined + if (budget === undefined) return yield* invalid("Anthropic thinking provider option requires budgetTokens") + return { type: "enabled" as const, budget_tokens: budget } +}) + +const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (request: LLMRequest) { + const toolChoice = request.toolChoice ? yield* lowerToolChoice(request.toolChoice) : undefined + const generation = request.generation + const toolSchemaCompatibility = request.model.compatibility?.toolSchema + const outputLimit = request.model.defaults?.limits?.output ?? request.model.route.defaults.limits?.output ?? 4096 + // Allocate the 4-breakpoint budget in invalidation order: tools → system → + // messages. Tools live highest in the cache hierarchy, so when callers + // over-mark we keep their tool hints and shed the message-tail ones first. + const breakpoints = Cache.newBreakpoints(ANTHROPIC_BREAKPOINT_CAP) + const tools = + request.tools.length === 0 || request.toolChoice?.type === "none" + ? undefined + : request.tools.map((tool) => + lowerTool( + breakpoints, + tool, + ToolSchemaProjection.modelCompatibility(tool.inputSchema, toolSchemaCompatibility), + ), + ) + const system = + request.system.length === 0 + ? undefined + : request.system.map((part) => ({ + type: "text" as const, + text: part.text, + cache_control: cacheControl(breakpoints, part.cache), + })) + const messages = yield* lowerMessages(request, breakpoints) + if (breakpoints.dropped > 0) { + yield* Effect.logWarning( + `Anthropic Messages: dropped ${breakpoints.dropped} cache breakpoint(s); the API allows at most ${ANTHROPIC_BREAKPOINT_CAP} per request.`, + ) + } + return { + model: request.model.id, + system, + messages, + tools, + tool_choice: toolChoice, + stream: true as const, + max_tokens: generation?.maxTokens ?? outputLimit, + temperature: generation?.temperature, + top_p: generation?.topP, + top_k: generation?.topK, + stop_sequences: generation?.stop, + thinking: yield* lowerThinking(request), + } +}) + +// ============================================================================= +// Stream Parsing +// ============================================================================= +const mapFinishReason = (reason: string | null | undefined): FinishReason => { + if (reason === "end_turn" || reason === "stop_sequence" || reason === "pause_turn") return "stop" + if (reason === "max_tokens") return "length" + if (reason === "tool_use") return "tool-calls" + if (reason === "refusal") return "content-filter" + return "unknown" +} + +// Anthropic reports the non-overlapping breakdown natively — its +// `input_tokens` is the *non-cached* count per the Messages API docs, with +// cache reads and writes as separate fields. We sum them to derive the +// inclusive `inputTokens` the rest of the contract expects. Extended +// thinking tokens are *not* broken out by Anthropic — they're billed as +// part of `output_tokens`, so `reasoningTokens` stays `undefined` and +// `outputTokens` carries the combined total. +const mapUsage = (usage: AnthropicUsage | undefined): Usage | undefined => { + if (!usage) return undefined + const nonCached = usage.input_tokens + const cacheRead = usage.cache_read_input_tokens ?? undefined + const cacheWrite = usage.cache_creation_input_tokens ?? undefined + const inputTokens = ProviderShared.sumTokens(nonCached, cacheRead, cacheWrite) + return new Usage({ + inputTokens, + outputTokens: usage.output_tokens, + nonCachedInputTokens: nonCached, + cacheReadInputTokens: cacheRead, + cacheWriteInputTokens: cacheWrite, + totalTokens: ProviderShared.totalTokens(inputTokens, usage.output_tokens, undefined), + providerMetadata: { anthropic: usage }, + }) +} + +// Anthropic emits usage on `message_start` and again on `message_delta` — the +// final delta carries the authoritative totals. Right-biased merge: each +// field prefers `right` when defined, falls back to `left`. `inputTokens` is +// recomputed from the merged breakdown so the inclusive total stays +// consistent with `nonCached + cacheRead + cacheWrite`. +const mergeUsage = (left: Usage | undefined, right: Usage | undefined) => { + if (!left) return right + if (!right) return left + const nonCachedInputTokens = right.nonCachedInputTokens ?? left.nonCachedInputTokens + const cacheReadInputTokens = right.cacheReadInputTokens ?? left.cacheReadInputTokens + const cacheWriteInputTokens = right.cacheWriteInputTokens ?? left.cacheWriteInputTokens + const inputTokens = ProviderShared.sumTokens(nonCachedInputTokens, cacheReadInputTokens, cacheWriteInputTokens) + const outputTokens = right.outputTokens ?? left.outputTokens + return new Usage({ + inputTokens, + outputTokens, + nonCachedInputTokens, + cacheReadInputTokens, + cacheWriteInputTokens, + totalTokens: ProviderShared.totalTokens(inputTokens, outputTokens, undefined), + providerMetadata: { + anthropic: { + ...left.providerMetadata?.["anthropic"], + ...right.providerMetadata?.["anthropic"], + }, + }, + }) +} + +// Server tool result blocks come whole in `content_block_start` (no streaming +// delta sequence). We convert the payload to a `tool-result` event with +// `providerExecuted: true`. The runtime appends it to the assistant message +// for round-trip; downstream consumers can inspect `result.value` for the +// structured payload. +const SERVER_TOOL_RESULT_NAMES: Record = { + web_search_tool_result: "web_search", + code_execution_tool_result: "code_execution", + web_fetch_tool_result: "web_fetch", +} + +const isServerToolResultType = (type: string): type is AnthropicServerToolResultType => type in SERVER_TOOL_RESULT_NAMES + +const serverToolResultEvent = (block: NonNullable): LLMEvent | undefined => { + if (!block.type || !isServerToolResultType(block.type)) return undefined + const errorPayload = + typeof block.content === "object" && block.content !== null && "type" in block.content + ? String((block.content as Record).type) + : "" + const isError = errorPayload.endsWith("_tool_result_error") + return LLMEvent.toolResult({ + id: block.tool_use_id ?? "", + name: SERVER_TOOL_RESULT_NAMES[block.type], + result: isError ? { type: "error", value: block.content } : { type: "json", value: block.content }, + providerExecuted: true, + providerMetadata: anthropicMetadata({ blockType: block.type }), + }) +} + +type StepResult = readonly [ParserState, ReadonlyArray] + +const NO_EVENTS: StepResult["1"] = [] + +const onMessageStart = (state: ParserState, event: AnthropicEvent): StepResult => { + const usage = mapUsage(event.message?.usage) + return [usage ? { ...state, usage: mergeUsage(state.usage, usage) } : state, NO_EVENTS] +} + +const onContentBlockStart = (state: ParserState, event: AnthropicEvent): StepResult => { + const block = event.content_block + if (!block) return [state, NO_EVENTS] + + if ((block.type === "tool_use" || block.type === "server_tool_use") && event.index !== undefined) { + const events: LLMEvent[] = [] + const lifecycle = Lifecycle.stepStart(state.lifecycle, events) + return [ + { + ...state, + lifecycle, + tools: ToolStream.start(state.tools, event.index, { + id: block.id ?? String(event.index), + name: block.name ?? "", + providerExecuted: block.type === "server_tool_use", + }), + }, + [...events, LLMEvent.toolInputStart({ id: block.id ?? String(event.index), name: block.name ?? "" })], + ] + } + + if (block.type === "text" && block.text) { + const events: LLMEvent[] = [] + return [ + { ...state, lifecycle: Lifecycle.textDelta(state.lifecycle, events, `text-${event.index ?? 0}`, block.text) }, + events, + ] + } + + if (block.type === "thinking" && block.thinking) { + const events: LLMEvent[] = [] + return [ + { + ...state, + lifecycle: Lifecycle.reasoningDelta(state.lifecycle, events, `reasoning-${event.index ?? 0}`, block.thinking), + }, + events, + ] + } + + const result = serverToolResultEvent(block) + if (!result) return [state, NO_EVENTS] + const events: LLMEvent[] = [] + return [{ ...state, lifecycle: Lifecycle.stepStart(state.lifecycle, events) }, [...events, result]] +} + +const onContentBlockDelta = Effect.fn("AnthropicMessages.onContentBlockDelta")(function* ( + state: ParserState, + event: AnthropicEvent, +) { + const delta = event.delta + + if (delta?.type === "text_delta" && delta.text) { + const events: LLMEvent[] = [] + return [ + { ...state, lifecycle: Lifecycle.textDelta(state.lifecycle, events, `text-${event.index ?? 0}`, delta.text) }, + events, + ] satisfies StepResult + } + + if (delta?.type === "thinking_delta" && delta.thinking) { + const events: LLMEvent[] = [] + return [ + { + ...state, + lifecycle: Lifecycle.reasoningDelta(state.lifecycle, events, `reasoning-${event.index ?? 0}`, delta.thinking), + }, + events, + ] satisfies StepResult + } + + if (delta?.type === "signature_delta" && delta.signature) { + const events: LLMEvent[] = [] + return [ + { + ...state, + lifecycle: Lifecycle.reasoningEnd( + state.lifecycle, + events, + `reasoning-${event.index ?? 0}`, + anthropicMetadata({ signature: delta.signature }), + ), + }, + events, + ] satisfies StepResult + } + + if (delta?.type === "input_json_delta" && event.index !== undefined) { + if (!delta.partial_json) return [state, NO_EVENTS] satisfies StepResult + const result = ToolStream.appendExisting( + ADAPTER, + state.tools, + event.index, + delta.partial_json, + "Anthropic Messages tool argument delta is missing its tool call", + ) + if (ToolStream.isError(result)) return yield* result + const events: LLMEvent[] = [] + const lifecycle = result.events.length ? Lifecycle.stepStart(state.lifecycle, events) : state.lifecycle + events.push(...result.events) + return [{ ...state, lifecycle, tools: result.tools }, events] satisfies StepResult + } + + return [state, NO_EVENTS] satisfies StepResult +}) + +const onContentBlockStop = Effect.fn("AnthropicMessages.onContentBlockStop")(function* ( + state: ParserState, + event: AnthropicEvent, +) { + if (event.index === undefined) return [state, NO_EVENTS] satisfies StepResult + const result = yield* ToolStream.finish(ADAPTER, state.tools, event.index) + const events: LLMEvent[] = [] + const resultEvents = result.events ?? [] + const lifecycle = resultEvents.length + ? Lifecycle.stepStart(state.lifecycle, events) + : Lifecycle.reasoningEnd( + Lifecycle.textEnd(state.lifecycle, events, `text-${event.index}`), + events, + `reasoning-${event.index}`, + ) + events.push(...resultEvents) + return [{ ...state, lifecycle, tools: result.tools }, events] satisfies StepResult +}) + +const onMessageDelta = (state: ParserState, event: AnthropicEvent): StepResult => { + const usage = mergeUsage(state.usage, mapUsage(event.usage)) + const events: LLMEvent[] = [] + const lifecycle = Lifecycle.finish(state.lifecycle, events, { + reason: mapFinishReason(event.delta?.stop_reason), + usage, + providerMetadata: event.delta?.stop_sequence + ? anthropicMetadata({ stopSequence: event.delta.stop_sequence }) + : undefined, + }) + return [{ ...state, lifecycle, usage }, events] +} + +// Prefix `error.type` so overloads, rate limits, and quota errors are visible +// even when the provider message is generic or empty. +const providerErrorMessage = (event: AnthropicEvent): string => { + const type = event.error?.type + const message = event.error?.message + if (type && message) return `${type}: ${message}` + return message || type || "Anthropic Messages stream error" +} + +const onError = (state: ParserState, event: AnthropicEvent): StepResult => [ + state, + [ + LLMEvent.providerError({ + message: providerErrorMessage(event), + classification: isContextOverflow(event.error?.message ?? "") ? "context-overflow" : undefined, + }), + ], +] + +const step = (state: ParserState, event: AnthropicEvent) => { + if (event.type === "message_start") return Effect.succeed(onMessageStart(state, event)) + if (event.type === "content_block_start") return Effect.succeed(onContentBlockStart(state, event)) + if (event.type === "content_block_delta") return onContentBlockDelta(state, event) + if (event.type === "content_block_stop") return onContentBlockStop(state, event) + if (event.type === "message_delta") return Effect.succeed(onMessageDelta(state, event)) + if (event.type === "error") return Effect.succeed(onError(state, event)) + return Effect.succeed([state, NO_EVENTS]) +} + +// ============================================================================= +// Protocol And Anthropic Route +// ============================================================================= +/** + * The Anthropic Messages protocol — request body construction, body schema, + * and the streaming-event state machine. Used by native Anthropic Cloud and + * (once registered) Vertex Anthropic / Bedrock-hosted Anthropic passthrough. + */ +export const protocol = Protocol.make({ + id: ADAPTER, + body: { + schema: AnthropicMessagesBody, + from: fromRequest, + }, + stream: { + event: Protocol.jsonEvent(AnthropicEvent), + initial: () => ({ tools: ToolStream.empty(), lifecycle: Lifecycle.initial() }), + step, + }, +}) + +export const route = Route.make({ + id: ADAPTER, + provider: "anthropic", + protocol, + endpoint: Endpoint.path(PATH, { baseURL: DEFAULT_BASE_URL }), + auth: Auth.none, + framing: Framing.sse, + headers: () => ({ "anthropic-version": "2023-06-01" }), +}) + +export * as AnthropicMessages from "./anthropic-messages" diff --git a/packages/llm/src/protocols/bedrock-converse.ts b/packages/llm/src/protocols/bedrock-converse.ts new file mode 100644 index 0000000000000000000000000000000000000000..c447a1a39d346a39dd29eab59e2f3413ab9f8850 --- /dev/null +++ b/packages/llm/src/protocols/bedrock-converse.ts @@ -0,0 +1,674 @@ +import { Effect, Schema } from "effect" +import { Route } from "../route/client" +import { Endpoint } from "../route/endpoint" +import { Protocol } from "../route/protocol" +import { + LLMEvent, + Usage, + type CacheHint, + type FinishReason, + type JsonSchema, + type LLMRequest, + type ModelToolSchemaCompatibility, + type ProviderMetadata, + type ReasoningPart, + type ToolCallPart, + type ToolDefinition, + type ToolResultPart, +} from "../schema" +import { BedrockEventStream } from "./bedrock-event-stream" +import { isContextOverflow } from "../provider-error" +import { JsonObject, optionalArray, ProviderShared } from "./shared" +import { BedrockAuth } from "./utils/bedrock-auth" +import { BedrockCache } from "./utils/bedrock-cache" +import { BedrockMedia } from "./utils/bedrock-media" +import { Lifecycle } from "./utils/lifecycle" +import { ToolSchemaProjection } from "./utils/tool-schema" +import { ToolStream } from "./utils/tool-stream" + +const ADAPTER = "bedrock-converse" + +export type { Credentials as BedrockCredentials } from "./utils/bedrock-auth" + +// ============================================================================= +// Request Body Schema +// ============================================================================= +const BedrockTextBlock = Schema.Struct({ + text: Schema.String, +}) +type BedrockTextBlock = Schema.Schema.Type + +const BedrockToolUseBlock = Schema.Struct({ + toolUse: Schema.Struct({ + toolUseId: Schema.String, + name: Schema.String, + input: Schema.Unknown, + }), +}) +type BedrockToolUseBlock = Schema.Schema.Type + +const BedrockToolResultContentItem = Schema.Union([ + Schema.Struct({ text: Schema.String }), + Schema.Struct({ json: Schema.Unknown }), + BedrockMedia.ImageBlock, +]) + +const BedrockToolResultBlock = Schema.Struct({ + toolResult: Schema.Struct({ + toolUseId: Schema.String, + content: Schema.Array(BedrockToolResultContentItem), + status: Schema.optional(Schema.Literals(["success", "error"])), + }), +}) +type BedrockToolResultBlock = Schema.Schema.Type + +const BedrockReasoningBlock = Schema.Struct({ + reasoningContent: Schema.Struct({ + reasoningText: Schema.optional( + Schema.Struct({ + text: Schema.String, + signature: Schema.optional(Schema.String), + }), + ), + }), +}) + +const BedrockUserBlock = Schema.Union([ + BedrockTextBlock, + BedrockMedia.ImageBlock, + BedrockMedia.DocumentBlock, + BedrockToolResultBlock, + BedrockCache.CachePointBlock, +]) +type BedrockUserBlock = Schema.Schema.Type + +const BedrockAssistantBlock = Schema.Union([ + BedrockTextBlock, + BedrockReasoningBlock, + BedrockToolUseBlock, + BedrockCache.CachePointBlock, +]) +type BedrockAssistantBlock = Schema.Schema.Type + +const BedrockMessage = Schema.Union([ + Schema.Struct({ role: Schema.Literal("user"), content: Schema.Array(BedrockUserBlock) }), + Schema.Struct({ role: Schema.Literal("assistant"), content: Schema.Array(BedrockAssistantBlock) }), +]).pipe(Schema.toTaggedUnion("role")) +type BedrockMessage = Schema.Schema.Type + +const BedrockSystemBlock = Schema.Union([BedrockTextBlock, BedrockCache.CachePointBlock]) +type BedrockSystemBlock = Schema.Schema.Type + +const BedrockToolSpec = Schema.Struct({ + toolSpec: Schema.Struct({ + name: Schema.String, + description: Schema.String, + inputSchema: Schema.Struct({ + json: JsonObject, + }), + }), +}) +type BedrockToolSpec = Schema.Schema.Type + +const BedrockTool = Schema.Union([BedrockToolSpec, BedrockCache.CachePointBlock]) +type BedrockTool = Schema.Schema.Type + +const BedrockToolChoice = Schema.Union([ + Schema.Struct({ auto: Schema.Struct({}) }), + Schema.Struct({ any: Schema.Struct({}) }), + Schema.Struct({ tool: Schema.Struct({ name: Schema.String }) }), +]) + +const BedrockBodyFields = { + modelId: Schema.String, + messages: Schema.Array(BedrockMessage), + system: optionalArray(BedrockSystemBlock), + inferenceConfig: Schema.optional( + Schema.Struct({ + maxTokens: Schema.optional(Schema.Number), + temperature: Schema.optional(Schema.Number), + topP: Schema.optional(Schema.Number), + stopSequences: optionalArray(Schema.String), + }), + ), + toolConfig: Schema.optional( + Schema.Struct({ + tools: Schema.Array(BedrockTool), + toolChoice: Schema.optional(BedrockToolChoice), + }), + ), + additionalModelRequestFields: Schema.optional(JsonObject), +} +const BedrockConverseBody = Schema.Struct(BedrockBodyFields) +export type BedrockConverseBody = Schema.Schema.Type + +const BedrockUsageSchema = Schema.Struct({ + inputTokens: Schema.optional(Schema.Number), + outputTokens: Schema.optional(Schema.Number), + totalTokens: Schema.optional(Schema.Number), + cacheReadInputTokens: Schema.optional(Schema.Number), + cacheWriteInputTokens: Schema.optional(Schema.Number), +}) +type BedrockUsageSchema = Schema.Schema.Type + +// Streaming event shape — the AWS event stream wraps each JSON payload by its +// `:event-type` header (e.g. `messageStart`, `contentBlockDelta`). We +// reconstruct that wrapping in `decodeFrames` below so the event schema can +// stay a plain discriminated record. +const BedrockEvent = Schema.Struct({ + messageStart: Schema.optional(Schema.Struct({ role: Schema.String })), + contentBlockStart: Schema.optional( + Schema.Struct({ + contentBlockIndex: Schema.Number, + start: Schema.optional( + Schema.Struct({ + toolUse: Schema.optional(Schema.Struct({ toolUseId: Schema.String, name: Schema.String })), + }), + ), + }), + ), + contentBlockDelta: Schema.optional( + Schema.Struct({ + contentBlockIndex: Schema.Number, + delta: Schema.optional( + Schema.Struct({ + text: Schema.optional(Schema.String), + toolUse: Schema.optional(Schema.Struct({ input: Schema.String })), + reasoningContent: Schema.optional( + Schema.Struct({ + text: Schema.optional(Schema.String), + signature: Schema.optional(Schema.String), + }), + ), + }), + ), + }), + ), + contentBlockStop: Schema.optional(Schema.Struct({ contentBlockIndex: Schema.Number })), + messageStop: Schema.optional( + Schema.Struct({ + stopReason: Schema.String, + additionalModelResponseFields: Schema.optional(Schema.Unknown), + }), + ), + metadata: Schema.optional( + Schema.Struct({ + usage: Schema.optional(BedrockUsageSchema), + metrics: Schema.optional(Schema.Unknown), + }), + ), + internalServerException: Schema.optional(Schema.Struct({ message: Schema.String })), + modelStreamErrorException: Schema.optional(Schema.Struct({ message: Schema.String })), + validationException: Schema.optional(Schema.Struct({ message: Schema.String })), + throttlingException: Schema.optional(Schema.Struct({ message: Schema.String })), + serviceUnavailableException: Schema.optional(Schema.Struct({ message: Schema.String })), +}) +type BedrockEvent = Schema.Schema.Type + +// ============================================================================= +// Request Lowering +// ============================================================================= +const lowerToolSpec = (tool: ToolDefinition, inputSchema: JsonSchema): BedrockToolSpec => ({ + toolSpec: { + name: tool.name, + description: tool.description, + inputSchema: { json: inputSchema }, + }, +}) + +const lowerTools = ( + compatibility: ModelToolSchemaCompatibility | undefined, + breakpoints: BedrockCache.Breakpoints, + tools: ReadonlyArray, +): BedrockTool[] => { + const result: BedrockTool[] = [] + for (const tool of tools) { + result.push(lowerToolSpec(tool, ToolSchemaProjection.modelCompatibility(tool.inputSchema, compatibility))) + const cachePoint = BedrockCache.block(breakpoints, tool.cache) + if (cachePoint) result.push(cachePoint) + } + return result +} + +const textWithCache = ( + breakpoints: BedrockCache.Breakpoints, + text: string, + cache: CacheHint | undefined, +): Array => { + const cachePoint = BedrockCache.block(breakpoints, cache) + return cachePoint ? [{ text }, cachePoint] : [{ text }] +} + +const lowerToolChoice = (toolChoice: NonNullable) => + ProviderShared.matchToolChoice("Bedrock Converse", toolChoice, { + auto: () => ({ auto: {} }) as const, + none: () => undefined, + required: () => ({ any: {} }) as const, + tool: (name) => ({ tool: { name } }) as const, + }) + +const bedrockMetadata = (metadata: Record): ProviderMetadata => ({ bedrock: metadata }) + +const reasoningSignature = (part: ReasoningPart) => { + const bedrock = part.providerMetadata?.bedrock + return ( + part.encrypted ?? + (ProviderShared.isRecord(bedrock) && typeof bedrock.signature === "string" ? bedrock.signature : undefined) + ) +} + +const lowerToolCall = (part: ToolCallPart): BedrockToolUseBlock => ({ + toolUse: { + toolUseId: part.id, + name: part.name, + input: part.input, + }, +}) + +const lowerToolResultContent = Effect.fn("BedrockConverse.lowerToolResultContent")(function* (part: ToolResultPart) { + if (part.result.type === "text" || part.result.type === "error") + return [{ text: ProviderShared.toolResultText(part) }] + if (part.result.type === "json") return [{ json: part.result.value }] + + const content: Array> = [] + for (const item of part.result.value) { + if (item.type === "text") { + content.push({ text: item.text }) + continue + } + const media = yield* BedrockMedia.lower({ + type: "media", + mediaType: item.mime, + data: item.uri, + filename: item.name, + }) + if (!("image" in media)) + return yield* ProviderShared.invalidRequest("Bedrock Converse only supports image media in tool results") + content.push(media) + } + return content +}) + +const lowerToolResult = Effect.fn("BedrockConverse.lowerToolResult")(function* (part: ToolResultPart) { + return { + toolResult: { + toolUseId: part.id, + content: yield* lowerToolResultContent(part), + status: part.result.type === "error" ? "error" : "success", + }, + } satisfies BedrockToolResultBlock +}) + +const lowerMessages = Effect.fn("BedrockConverse.lowerMessages")(function* ( + request: LLMRequest, + breakpoints: BedrockCache.Breakpoints, +) { + const messages: BedrockMessage[] = [] + + for (const message of request.messages) { + if (message.role === "system") { + const part = yield* ProviderShared.wrappedSystemUpdate("Bedrock Converse", message) + const content = textWithCache(breakpoints, part.text, part.cache) + const previous = messages.at(-1) + if (previous?.role === "user") + messages[messages.length - 1] = { role: "user", content: [...previous.content, ...content] } + else messages.push({ role: "user", content }) + continue + } + + if (message.role === "user") { + const content: BedrockUserBlock[] = [] + for (const part of message.content) { + if (!ProviderShared.supportsContent(part, ["text", "media"])) + return yield* ProviderShared.unsupportedContent("Bedrock Converse", "user", ["text", "media"]) + if (part.type === "text") { + content.push(...textWithCache(breakpoints, part.text, part.cache)) + continue + } + if (part.type === "media") { + content.push(yield* BedrockMedia.lower(part)) + continue + } + } + messages.push({ role: "user", content }) + continue + } + + if (message.role === "assistant") { + const content: BedrockAssistantBlock[] = [] + for (const part of message.content) { + if (!ProviderShared.supportsContent(part, ["text", "reasoning", "tool-call"])) + return yield* ProviderShared.unsupportedContent("Bedrock Converse", "assistant", [ + "text", + "reasoning", + "tool-call", + ]) + if (part.type === "text") { + content.push(...textWithCache(breakpoints, part.text, part.cache)) + continue + } + if (part.type === "reasoning") { + content.push({ + reasoningContent: { + reasoningText: { text: part.text, signature: reasoningSignature(part) }, + }, + }) + continue + } + if (part.type === "tool-call") { + content.push(lowerToolCall(part)) + continue + } + } + messages.push({ role: "assistant", content }) + continue + } + + const content: BedrockUserBlock[] = [] + for (const part of message.content) { + if (!ProviderShared.supportsContent(part, ["tool-result"])) + return yield* ProviderShared.unsupportedContent("Bedrock Converse", "tool", ["tool-result"]) + content.push(yield* lowerToolResult(part)) + const cachePoint = BedrockCache.block(breakpoints, part.cache) + if (cachePoint) content.push(cachePoint) + } + messages.push({ role: "user", content }) + } + + return messages +}) + +// System prompts share the cache-point convention: emit the text block, then +// optionally a positional `cachePoint` marker. +const lowerSystem = ( + breakpoints: BedrockCache.Breakpoints, + system: ReadonlyArray, +): BedrockSystemBlock[] => system.flatMap((part) => textWithCache(breakpoints, part.text, part.cache)) + +const fromRequest = Effect.fn("BedrockConverse.fromRequest")(function* (request: LLMRequest) { + const toolChoice = request.toolChoice ? yield* lowerToolChoice(request.toolChoice) : undefined + const generation = request.generation + // Bedrock-Claude shares Anthropic's 4-breakpoint cap. Spend the budget in + // tools → system → messages order to favour the highest-impact prefixes. + const breakpoints = BedrockCache.breakpoints() + const toolConfig = + request.tools.length > 0 && request.toolChoice?.type !== "none" + ? { tools: lowerTools(request.model.compatibility?.toolSchema, breakpoints, request.tools), toolChoice } + : undefined + const system = request.system.length === 0 ? undefined : lowerSystem(breakpoints, request.system) + const messages = yield* lowerMessages(request, breakpoints) + if (breakpoints.dropped > 0) { + yield* Effect.logWarning( + `Bedrock Converse: dropped ${breakpoints.dropped} cache breakpoint(s); the API allows at most ${BedrockCache.BEDROCK_BREAKPOINT_CAP} per request.`, + ) + } + return { + modelId: request.model.id, + messages, + system, + inferenceConfig: + generation?.maxTokens === undefined && + generation?.temperature === undefined && + generation?.topP === undefined && + (generation?.stop === undefined || generation.stop.length === 0) + ? undefined + : { + maxTokens: generation?.maxTokens, + temperature: generation?.temperature, + topP: generation?.topP, + stopSequences: generation?.stop, + }, + toolConfig, + // Converse's base inferenceConfig has no topK; Anthropic/Nova accept it + // as a model-specific field, so it goes through additionalModelRequestFields. + additionalModelRequestFields: generation?.topK === undefined ? undefined : { top_k: generation.topK }, + } +}) + +// ============================================================================= +// Stream Parsing +// ============================================================================= +const mapFinishReason = (reason: string): FinishReason => { + if (reason === "end_turn" || reason === "stop_sequence") return "stop" + if (reason === "max_tokens") return "length" + if (reason === "tool_use") return "tool-calls" + if (reason === "content_filtered" || reason === "guardrail_intervened") return "content-filter" + return "unknown" +} + +// AWS Bedrock Converse reports `inputTokens` (inclusive total) with +// `cacheReadInputTokens` and `cacheWriteInputTokens` as subsets. Pass +// the total through and derive the non-cached breakdown. Bedrock does +// not break reasoning out of `outputTokens` for any current model. +const mapUsage = (usage: BedrockUsageSchema | undefined): Usage | undefined => { + if (!usage) return undefined + const cacheTotal = (usage.cacheReadInputTokens ?? 0) + (usage.cacheWriteInputTokens ?? 0) + const nonCached = ProviderShared.subtractTokens(usage.inputTokens, cacheTotal) + return new Usage({ + inputTokens: usage.inputTokens, + outputTokens: usage.outputTokens, + nonCachedInputTokens: nonCached, + cacheReadInputTokens: usage.cacheReadInputTokens, + cacheWriteInputTokens: usage.cacheWriteInputTokens, + totalTokens: ProviderShared.totalTokens(usage.inputTokens, usage.outputTokens, usage.totalTokens), + providerMetadata: { bedrock: usage }, + }) +} + +interface ParserState { + readonly tools: ToolStream.State + // Bedrock splits the finish into `messageStop` (carries `stopReason`) and + // `metadata` (carries usage). Hold the terminal event in state so `onHalt` + // can emit exactly one finish after both chunks have had a chance to arrive. + readonly pendingFinish: { readonly reason: FinishReason; readonly usage?: Usage } | undefined + readonly hasToolCalls: boolean + readonly lifecycle: Lifecycle.State + readonly reasoningSignatures: Readonly> +} + +const step = (state: ParserState, event: BedrockEvent) => + Effect.gen(function* () { + if (event.contentBlockStart?.start?.toolUse) { + const index = event.contentBlockStart.contentBlockIndex + const events: LLMEvent[] = [] + const lifecycle = Lifecycle.stepStart(state.lifecycle, events) + return [ + { + ...state, + lifecycle, + tools: ToolStream.start(state.tools, index, { + id: event.contentBlockStart.start.toolUse.toolUseId, + name: event.contentBlockStart.start.toolUse.name, + }), + }, + [ + ...events, + LLMEvent.toolInputStart({ + id: event.contentBlockStart.start.toolUse.toolUseId, + name: event.contentBlockStart.start.toolUse.name, + }), + ], + ] as const + } + + if (event.contentBlockDelta?.delta?.text) { + const events: LLMEvent[] = [] + return [ + { + ...state, + lifecycle: Lifecycle.textDelta( + state.lifecycle, + events, + `text-${event.contentBlockDelta.contentBlockIndex}`, + event.contentBlockDelta.delta.text, + ), + }, + events, + ] as const + } + + if (event.contentBlockDelta?.delta?.reasoningContent) { + const index = event.contentBlockDelta.contentBlockIndex + const reasoning = event.contentBlockDelta.delta.reasoningContent + const events: LLMEvent[] = [] + return [ + { + ...state, + lifecycle: reasoning.text + ? Lifecycle.reasoningDelta(state.lifecycle, events, `reasoning-${index}`, reasoning.text) + : state.lifecycle, + reasoningSignatures: reasoning.signature + ? { ...state.reasoningSignatures, [index]: reasoning.signature } + : state.reasoningSignatures, + }, + events, + ] as const + } + + if (event.contentBlockDelta?.delta?.toolUse) { + const index = event.contentBlockDelta.contentBlockIndex + const result = ToolStream.appendExisting( + ADAPTER, + state.tools, + index, + event.contentBlockDelta.delta.toolUse.input, + "Bedrock Converse tool delta is missing its tool call", + ) + if (ToolStream.isError(result)) return yield* result + const events: LLMEvent[] = [] + const lifecycle = result.events.length ? Lifecycle.stepStart(state.lifecycle, events) : state.lifecycle + events.push(...result.events) + return [{ ...state, lifecycle, tools: result.tools }, events] as const + } + + if (event.contentBlockStop) { + const index = event.contentBlockStop.contentBlockIndex + const result = yield* ToolStream.finish(ADAPTER, state.tools, index) + const events: LLMEvent[] = [] + const resultEvents = result.events ?? [] + const lifecycle = resultEvents.length + ? Lifecycle.stepStart(state.lifecycle, events) + : Lifecycle.reasoningEnd( + Lifecycle.textEnd(state.lifecycle, events, `text-${index}`), + events, + `reasoning-${index}`, + state.reasoningSignatures[index] + ? bedrockMetadata({ signature: state.reasoningSignatures[index] }) + : undefined, + ) + events.push(...resultEvents) + return [ + { + ...state, + hasToolCalls: resultEvents.some(LLMEvent.is.toolCall) ? true : state.hasToolCalls, + lifecycle, + tools: result.tools, + reasoningSignatures: Object.fromEntries( + Object.entries(state.reasoningSignatures).filter(([key]) => key !== String(index)), + ), + }, + events, + ] as const + } + + if (event.messageStop) { + return [ + { + ...state, + pendingFinish: { reason: mapFinishReason(event.messageStop.stopReason), usage: state.pendingFinish?.usage }, + }, + [], + ] as const + } + + if (event.metadata) { + const usage = mapUsage(event.metadata.usage) + return [{ ...state, pendingFinish: { reason: state.pendingFinish?.reason ?? "stop", usage } }, []] as const + } + + if (event.internalServerException || event.modelStreamErrorException || event.serviceUnavailableException) { + const message = + event.internalServerException?.message ?? + event.modelStreamErrorException?.message ?? + event.serviceUnavailableException?.message ?? + "Bedrock Converse stream error" + return [state, [LLMEvent.providerError({ message, retryable: true })]] as const + } + + if (event.validationException || event.throttlingException) { + const message = + event.validationException?.message ?? event.throttlingException?.message ?? "Bedrock Converse error" + return [ + state, + [ + LLMEvent.providerError({ + message, + classification: event.validationException && isContextOverflow(message) ? "context-overflow" : undefined, + retryable: event.throttlingException !== undefined, + }), + ], + ] as const + } + + return [state, []] as const + }) + +const framing = BedrockEventStream.framing(ADAPTER) + +const onHalt = (state: ParserState): ReadonlyArray => + state.pendingFinish + ? (() => { + const events: LLMEvent[] = [] + Lifecycle.finish(state.lifecycle, events, { + reason: + state.pendingFinish.reason === "stop" && state.hasToolCalls ? "tool-calls" : state.pendingFinish.reason, + usage: state.pendingFinish.usage, + }) + return events + })() + : [] + +// ============================================================================= +// Protocol And Bedrock Route +// ============================================================================= +/** + * The Bedrock Converse protocol — request body construction, body schema, and + * the streaming-event state machine. + */ +export const protocol = Protocol.make({ + id: ADAPTER, + body: { + schema: BedrockConverseBody, + from: fromRequest, + }, + stream: { + event: BedrockEvent, + initial: () => ({ + tools: ToolStream.empty(), + pendingFinish: undefined, + hasToolCalls: false, + lifecycle: Lifecycle.initial(), + reasoningSignatures: {}, + }), + step, + onHalt, + }, +}) + +export const route = Route.make({ + id: ADAPTER, + provider: "bedrock", + protocol, + // Bedrock's URL embeds the region in the route endpoint host and the + // validated modelId in the path. We read the validated body so the URL + // matches the body that gets signed. + endpoint: Endpoint.path( + ({ body }) => `/model/${encodeURIComponent(body.modelId)}/converse-stream`, + ), + auth: BedrockAuth.auth, + framing, +}) + +export const sigV4Auth = BedrockAuth.sigV4 + +export * as BedrockConverse from "./bedrock-converse" diff --git a/packages/llm/src/protocols/bedrock-event-stream.ts b/packages/llm/src/protocols/bedrock-event-stream.ts new file mode 100644 index 0000000000000000000000000000000000000000..d07d7de475993df317e4018b235b094ac9ec25d4 --- /dev/null +++ b/packages/llm/src/protocols/bedrock-event-stream.ts @@ -0,0 +1,87 @@ +import { EventStreamCodec } from "@smithy/eventstream-codec" +import { fromUtf8, toUtf8 } from "@smithy/util-utf8" +import { Effect, Stream } from "effect" +import type { Framing } from "../route/framing" +import { ProviderShared } from "./shared" + +// Bedrock streams responses using the AWS event stream binary protocol — each +// frame is `[length:4][headers-length:4][prelude-crc:4][headers][payload][crc:4]`. +// We use `@smithy/eventstream-codec` to validate framing and CRCs, then +// reconstruct the JSON wrapping by `:event-type` so the chunk schema can match. +const eventCodec = new EventStreamCodec(toUtf8, fromUtf8) +const utf8 = new TextDecoder() + +// Cursor-tracking buffer state. Bytes accumulate in `buffer`; `offset` is the +// read position. Reading by `subarray` is zero-copy. We only allocate a fresh +// buffer when a new network chunk arrives and we need to append. +interface FrameBufferState { + readonly buffer: Uint8Array + readonly offset: number +} + +const initialFrameBuffer: FrameBufferState = { buffer: new Uint8Array(0), offset: 0 } + +const appendChunk = (state: FrameBufferState, chunk: Uint8Array): FrameBufferState => { + const remaining = state.buffer.length - state.offset + // Compact: drop the consumed prefix and append the new chunk in one alloc. + // This bounds buffer growth to at most one network chunk past the live + // window, regardless of stream length. + const next = new Uint8Array(remaining + chunk.length) + next.set(state.buffer.subarray(state.offset), 0) + next.set(chunk, remaining) + return { buffer: next, offset: 0 } +} + +const consumeFrames = (route: string) => (state: FrameBufferState, chunk: Uint8Array) => + Effect.gen(function* () { + let cursor = appendChunk(state, chunk) + const out: object[] = [] + while (cursor.buffer.length - cursor.offset >= 4) { + const view = cursor.buffer.subarray(cursor.offset) + const totalLength = new DataView(view.buffer, view.byteOffset, view.byteLength).getUint32(0, false) + if (view.length < totalLength) break + + const decoded = yield* Effect.try({ + try: () => eventCodec.decode(view.subarray(0, totalLength)), + catch: (error) => + ProviderShared.eventError( + route, + `Failed to decode Bedrock Converse event-stream frame: ${ + error instanceof Error ? error.message : String(error) + }`, + ), + }) + cursor = { buffer: cursor.buffer, offset: cursor.offset + totalLength } + + if (decoded.headers[":message-type"]?.value !== "event") continue + const eventType = decoded.headers[":event-type"]?.value + if (typeof eventType !== "string") continue + const payload = utf8.decode(decoded.body) + if (!payload) continue + // The AWS event stream pads short payloads with a `p` field. Drop it + // before handing the object to the chunk schema. JSON decode goes + // through the shared Schema-driven codec to satisfy the package rule + // against ad-hoc `JSON.parse` calls. + const parsed = (yield* ProviderShared.parseJson( + route, + payload, + "Failed to parse Bedrock Converse event-stream payload", + )) as Record + delete parsed.p + out.push({ [eventType]: parsed }) + } + return [cursor, out] as const + }) + +/** + * AWS event-stream framing for Bedrock Converse. Each frame is decoded by + * `@smithy/eventstream-codec` (length + header + payload + CRC) and rewrapped + * under its `:event-type` header so the chunk schema can match the JSON + * payload directly. + */ +export const framing = (route: string): Framing => ({ + id: "aws-event-stream", + frame: (bytes) => bytes.pipe(Stream.mapAccumEffect(() => initialFrameBuffer, consumeFrames(route))), +}) + +export * as BedrockEventStream from "./bedrock-event-stream" diff --git a/packages/llm/src/protocols/gemini.ts b/packages/llm/src/protocols/gemini.ts new file mode 100644 index 0000000000000000000000000000000000000000..c4bb9476a49dd6f53d98f323063cb67794b45f16 --- /dev/null +++ b/packages/llm/src/protocols/gemini.ts @@ -0,0 +1,512 @@ +import { Effect, Schema } from "effect" +import { Route } from "../route/client" +import { Auth } from "../route/auth" +import { Endpoint } from "../route/endpoint" +import { Framing } from "../route/framing" +import { Protocol } from "../route/protocol" +import { + LLMEvent, + Usage, + type FinishReason, + type JsonSchema, + type LLMRequest, + type MediaPart, + type ProviderMetadata, + type TextPart, + type ToolCallPart, + type ToolDefinition, + type ToolContent, +} from "../schema" +import { JsonObject, optionalArray, ProviderShared } from "./shared" +import { GeminiToolSchema } from "./utils/gemini-tool-schema" +import { Lifecycle } from "./utils/lifecycle" +import { ToolSchemaProjection } from "./utils/tool-schema" + +const ADAPTER = "gemini" +const MEDIA_MIMES = new Set(ProviderShared.MEDIA_MIMES) +export const DEFAULT_BASE_URL = "https://generativelanguage.googleapis.com/v1beta" + +// ============================================================================= +// Request Body Schema +// ============================================================================= +const GeminiTextPart = Schema.Struct({ + text: Schema.String, + thought: Schema.optional(Schema.Boolean), + thoughtSignature: Schema.optional(Schema.String), +}) + +const GeminiInlineDataPart = Schema.Struct({ + inlineData: Schema.Struct({ + mimeType: Schema.String, + data: Schema.String, + }), +}) + +const GeminiFunctionCallPart = Schema.Struct({ + functionCall: Schema.Struct({ + name: Schema.String, + args: Schema.Unknown, + }), + thoughtSignature: Schema.optional(Schema.String), +}) + +const GeminiFunctionResponsePart = Schema.Struct({ + functionResponse: Schema.Struct({ + name: Schema.String, + response: Schema.Unknown, + }), +}) + +const GeminiContentPart = Schema.Union([ + GeminiTextPart, + GeminiInlineDataPart, + GeminiFunctionCallPart, + GeminiFunctionResponsePart, +]) + +const GeminiContent = Schema.Struct({ + role: Schema.Literals(["user", "model"]), + parts: Schema.Array(GeminiContentPart), +}) +type GeminiContent = Schema.Schema.Type + +const GeminiSystemInstruction = Schema.Struct({ + parts: Schema.Array(Schema.Struct({ text: Schema.String })), +}) + +const GeminiFunctionDeclaration = Schema.Struct({ + name: Schema.String, + description: Schema.String, + parameters: Schema.optional(JsonObject), +}) + +const GeminiTool = Schema.Struct({ + functionDeclarations: Schema.Array(GeminiFunctionDeclaration), +}) + +const GeminiToolConfig = Schema.Struct({ + functionCallingConfig: Schema.Struct({ + mode: Schema.Literals(["AUTO", "NONE", "ANY"]), + allowedFunctionNames: optionalArray(Schema.String), + }), +}) + +const GeminiThinkingConfig = Schema.Struct({ + thinkingBudget: Schema.optional(Schema.Number), + includeThoughts: Schema.optional(Schema.Boolean), +}) + +const GeminiGenerationConfig = Schema.Struct({ + maxOutputTokens: Schema.optional(Schema.Number), + temperature: Schema.optional(Schema.Number), + topP: Schema.optional(Schema.Number), + topK: Schema.optional(Schema.Number), + stopSequences: optionalArray(Schema.String), + thinkingConfig: Schema.optional(GeminiThinkingConfig), +}) + +const GeminiBodyFields = { + contents: Schema.Array(GeminiContent), + systemInstruction: Schema.optional(GeminiSystemInstruction), + tools: optionalArray(GeminiTool), + toolConfig: Schema.optional(GeminiToolConfig), + generationConfig: Schema.optional(GeminiGenerationConfig), +} +const GeminiBody = Schema.Struct(GeminiBodyFields) +export type GeminiBody = Schema.Schema.Type + +const GeminiUsage = Schema.Struct({ + cachedContentTokenCount: Schema.optional(Schema.Number), + thoughtsTokenCount: Schema.optional(Schema.Number), + promptTokenCount: Schema.optional(Schema.Number), + candidatesTokenCount: Schema.optional(Schema.Number), + totalTokenCount: Schema.optional(Schema.Number), +}) +type GeminiUsage = Schema.Schema.Type + +const GeminiCandidate = Schema.Struct({ + content: Schema.optional(GeminiContent), + finishReason: Schema.optional(Schema.String), +}) + +const GeminiEvent = Schema.Struct({ + candidates: optionalArray(GeminiCandidate), + usageMetadata: Schema.optional(GeminiUsage), +}) +type GeminiEvent = Schema.Schema.Type + +interface ParserState { + readonly finishReason?: string + readonly hasToolCalls: boolean + readonly nextToolCallId: number + readonly usage?: Usage + readonly lifecycle: Lifecycle.State + readonly reasoningSignature?: string +} + +// ============================================================================= +// Tool Schema Conversion +// ============================================================================= +// Tool-schema conversion has two distinct concerns: +// +// 1. Sanitize — fix common authoring mistakes Gemini rejects: integer/number +// enums (must be strings), `required` entries that don't match a property, +// untyped arrays (`items` must be present), and `properties`/`required` +// keys on non-object scalars. Mirrors OpenCode's historical Gemini rules. +// +// 2. Project — lossy mapping from JSON Schema to Gemini's schema dialect: +// drop empty objects, derive `nullable: true` from `type: [..., "null"]`, +// coerce `const` to `[const]` enum, recurse properties/items, propagate +// only an allowlisted set of keys (description, required, format, type, +// properties, items, allOf, anyOf, oneOf, minLength). Anything outside the +// allowlist (e.g. `additionalProperties`, `$ref`) is silently dropped. +// +// Sanitize runs first, then project. The implementation lives in +// `utils/gemini-tool-schema` so this protocol keeps the same shape as the other +// provider protocols. + +// ============================================================================= +// Request Lowering +// ============================================================================= +const lowerTool = (tool: ToolDefinition, inputSchema: JsonSchema) => ({ + name: tool.name, + description: tool.description, + parameters: GeminiToolSchema.convert(inputSchema), +}) + +const lowerToolConfig = (toolChoice: NonNullable) => + ProviderShared.matchToolChoice("Gemini", toolChoice, { + auto: () => ({ functionCallingConfig: { mode: "AUTO" as const } }), + none: () => ({ functionCallingConfig: { mode: "NONE" as const } }), + required: () => ({ functionCallingConfig: { mode: "ANY" as const } }), + tool: (name) => ({ functionCallingConfig: { mode: "ANY" as const, allowedFunctionNames: [name] } }), + }) + +const lowerUserPart = Effect.fn("Gemini.lowerUserPart")(function* (part: TextPart | MediaPart) { + if (part.type === "text") return { text: part.text } + const media = yield* ProviderShared.validateMedia("Gemini", part, MEDIA_MIMES) + return { inlineData: { mimeType: media.mime, data: media.base64 } } +}) + +const googleMetadata = (metadata: Record): ProviderMetadata => ({ google: metadata }) + +const thoughtSignature = (providerMetadata: ProviderMetadata | undefined) => { + const google = providerMetadata?.google + return ProviderShared.isRecord(google) && typeof google.thoughtSignature === "string" + ? google.thoughtSignature + : undefined +} + +const lowerToolCall = (part: ToolCallPart) => ({ + functionCall: { name: part.name, args: part.input }, + thoughtSignature: thoughtSignature(part.providerMetadata), +}) + +const lowerMessages = Effect.fn("Gemini.lowerMessages")(function* (request: LLMRequest) { + const contents: GeminiContent[] = [] + + for (const message of request.messages) { + if (message.role === "system") { + const part = yield* ProviderShared.wrappedSystemUpdate("Gemini", message) + const previous = contents.at(-1) + if (previous?.role === "user") + contents[contents.length - 1] = { role: "user", parts: [...previous.parts, { text: part.text }] } + else contents.push({ role: "user", parts: [{ text: part.text }] }) + continue + } + + if (message.role === "user") { + const parts: Array> = [] + for (const part of message.content) { + if (!ProviderShared.supportsContent(part, ["text", "media"])) + return yield* ProviderShared.unsupportedContent("Gemini", "user", ["text", "media"]) + parts.push(yield* lowerUserPart(part)) + } + contents.push({ role: "user", parts }) + continue + } + + if (message.role === "assistant") { + const parts: Array> = [] + for (const part of message.content) { + if (!ProviderShared.supportsContent(part, ["text", "reasoning", "tool-call"])) + return yield* ProviderShared.unsupportedContent("Gemini", "assistant", ["text", "reasoning", "tool-call"]) + if (part.type === "text") { + parts.push({ text: part.text }) + continue + } + if (part.type === "reasoning") { + parts.push({ text: part.text, thought: true, thoughtSignature: thoughtSignature(part.providerMetadata) }) + continue + } + if (part.type === "tool-call") { + parts.push(lowerToolCall(part)) + continue + } + } + contents.push({ role: "model", parts }) + continue + } + + const parts: Array> = [] + for (const part of message.content) { + if (!ProviderShared.supportsContent(part, ["tool-result"])) + return yield* ProviderShared.unsupportedContent("Gemini", "tool", ["tool-result"]) + if (part.result.type !== "content") { + parts.push({ + functionResponse: { + name: part.name, + response: { + name: part.name, + content: ProviderShared.toolResultText(part), + }, + }, + }) + continue + } + const content: ReadonlyArray = part.result.value + const text = content.filter((item) => item.type === "text").map((item) => item.text) + parts.push({ + functionResponse: { + name: part.name, + response: { + name: part.name, + content: text.join("\n"), + }, + }, + }) + for (const item of content) { + if (item.type === "text") continue + const media = yield* ProviderShared.validateToolFile("Gemini", item, MEDIA_MIMES) + parts.push({ inlineData: { mimeType: media.mime, data: media.base64 } }) + } + } + contents.push({ role: "user", parts }) + } + + return contents +}) + +const geminiOptions = (request: LLMRequest) => request.providerOptions?.gemini + +const thinkingConfig = (request: LLMRequest) => { + const value = geminiOptions(request)?.thinkingConfig + if (!ProviderShared.isRecord(value)) return undefined + const result = { + thinkingBudget: typeof value.thinkingBudget === "number" ? value.thinkingBudget : undefined, + includeThoughts: typeof value.includeThoughts === "boolean" ? value.includeThoughts : undefined, + } + return Object.values(result).some((item) => item !== undefined) ? result : undefined +} + +const fromRequest = Effect.fn("Gemini.fromRequest")(function* (request: LLMRequest) { + const toolsEnabled = request.tools.length > 0 && request.toolChoice?.type !== "none" + const generation = request.generation + const toolSchemaCompatibility = request.model.compatibility?.toolSchema + const generationConfig = { + maxOutputTokens: generation?.maxTokens, + temperature: generation?.temperature, + topP: generation?.topP, + topK: generation?.topK, + stopSequences: generation?.stop, + thinkingConfig: thinkingConfig(request), + } + + return { + contents: yield* lowerMessages(request), + systemInstruction: + request.system.length === 0 ? undefined : { parts: [{ text: ProviderShared.joinText(request.system) }] }, + tools: toolsEnabled + ? [ + { + functionDeclarations: request.tools.map((tool) => + lowerTool(tool, ToolSchemaProjection.modelCompatibility(tool.inputSchema, toolSchemaCompatibility)), + ), + }, + ] + : undefined, + toolConfig: toolsEnabled && request.toolChoice ? yield* lowerToolConfig(request.toolChoice) : undefined, + generationConfig: Object.values(generationConfig).some((value) => value !== undefined) + ? generationConfig + : undefined, + } +}) + +// ============================================================================= +// Stream Parsing +// ============================================================================= +// Gemini reports `promptTokenCount` (inclusive total) with a +// `cachedContentTokenCount` subset. `candidatesTokenCount` is *exclusive* +// of `thoughtsTokenCount` — visible-only, not a total — so we sum the two +// to produce the inclusive `outputTokens` the rest of the contract expects. +const mapUsage = (usage: GeminiUsage | undefined) => { + if (!usage) return undefined + const cached = usage.cachedContentTokenCount + const nonCached = ProviderShared.subtractTokens(usage.promptTokenCount, cached) + // `candidatesTokenCount` is visible-only; sum with thoughts to produce the + // inclusive `outputTokens` the contract expects. Only compute the total + // when the visible component is reported — otherwise we'd fabricate an + // inclusive number from a partial breakdown. + const outputTokens = + usage.candidatesTokenCount !== undefined ? usage.candidatesTokenCount + (usage.thoughtsTokenCount ?? 0) : undefined + return new Usage({ + inputTokens: usage.promptTokenCount, + outputTokens, + nonCachedInputTokens: nonCached, + cacheReadInputTokens: cached, + reasoningTokens: usage.thoughtsTokenCount, + totalTokens: ProviderShared.totalTokens(usage.promptTokenCount, outputTokens, usage.totalTokenCount), + providerMetadata: { google: usage }, + }) +} + +const mapFinishReason = (finishReason: string | undefined, hasToolCalls: boolean): FinishReason => { + if (finishReason === "STOP") return hasToolCalls ? "tool-calls" : "stop" + if (finishReason === "MAX_TOKENS") return "length" + if ( + finishReason === "IMAGE_SAFETY" || + finishReason === "RECITATION" || + finishReason === "SAFETY" || + finishReason === "BLOCKLIST" || + finishReason === "PROHIBITED_CONTENT" || + finishReason === "SPII" + ) + return "content-filter" + if (finishReason === "MALFORMED_FUNCTION_CALL") return "error" + return "unknown" +} + +const finish = (state: ParserState): ReadonlyArray => + state.finishReason || state.usage + ? (() => { + const events: LLMEvent[] = [] + const lifecycle = state.reasoningSignature + ? Lifecycle.reasoningEnd( + state.lifecycle, + events, + "reasoning-0", + googleMetadata({ thoughtSignature: state.reasoningSignature }), + ) + : state.lifecycle + Lifecycle.finish(lifecycle, events, { + reason: mapFinishReason(state.finishReason, state.hasToolCalls), + usage: state.usage, + }) + return events + })() + : [] + +const step = (state: ParserState, event: GeminiEvent) => { + const nextState = { + ...state, + usage: event.usageMetadata ? (mapUsage(event.usageMetadata) ?? state.usage) : state.usage, + } + const candidate = event.candidates?.[0] + if (!candidate?.content) + return Effect.succeed([ + { ...nextState, finishReason: candidate?.finishReason ?? nextState.finishReason }, + [], + ] as const) + + const events: LLMEvent[] = [] + let hasToolCalls = nextState.hasToolCalls + let lifecycle = nextState.lifecycle + let nextToolCallId = nextState.nextToolCallId + let reasoningSignature = nextState.reasoningSignature + + for (const part of candidate.content.parts) { + if ("thoughtSignature" in part && part.thoughtSignature && "thought" in part && part.thought) + reasoningSignature = part.thoughtSignature + if ("text" in part && part.text.length > 0) { + if (part.thought) { + lifecycle = Lifecycle.reasoningDelta( + lifecycle, + events, + "reasoning-0", + part.text, + part.thoughtSignature ? googleMetadata({ thoughtSignature: part.thoughtSignature }) : undefined, + ) + continue + } + lifecycle = Lifecycle.reasoningEnd( + lifecycle, + events, + "reasoning-0", + reasoningSignature ? googleMetadata({ thoughtSignature: reasoningSignature }) : undefined, + ) + lifecycle = Lifecycle.textDelta(lifecycle, events, "text-0", part.text) + continue + } + + if ("functionCall" in part) { + const input = part.functionCall.args + const id = `tool_${nextToolCallId++}` + lifecycle = Lifecycle.reasoningEnd( + lifecycle, + events, + "reasoning-0", + reasoningSignature ? googleMetadata({ thoughtSignature: reasoningSignature }) : undefined, + ) + lifecycle = Lifecycle.stepStart(lifecycle, events) + events.push( + LLMEvent.toolCall({ + id, + name: part.functionCall.name, + input, + providerMetadata: part.thoughtSignature + ? googleMetadata({ thoughtSignature: part.thoughtSignature }) + : undefined, + }), + ) + hasToolCalls = true + } + } + + return Effect.succeed([ + { + ...nextState, + hasToolCalls, + lifecycle, + nextToolCallId, + reasoningSignature, + finishReason: candidate.finishReason ?? nextState.finishReason, + }, + events, + ] as const) +} + +// ============================================================================= +// Protocol And Gemini Route +// ============================================================================= +/** + * The Gemini protocol — request body construction, body schema, and the + * streaming-event state machine. Used by Google AI Studio Gemini and (once + * registered) Vertex Gemini. + */ +export const protocol = Protocol.make({ + id: ADAPTER, + body: { + schema: GeminiBody, + from: fromRequest, + }, + stream: { + event: Protocol.jsonEvent(GeminiEvent), + initial: () => ({ hasToolCalls: false, nextToolCallId: 0, lifecycle: Lifecycle.initial() }), + step, + onHalt: finish, + }, +}) + +export const route = Route.make({ + id: ADAPTER, + provider: "google", + protocol, + // Gemini's path embeds the model id and pins SSE framing at the URL level. + endpoint: Endpoint.path(({ request }) => `/models/${request.model.id}:streamGenerateContent?alt=sse`, { + baseURL: DEFAULT_BASE_URL, + }), + auth: Auth.none, + framing: Framing.sse, +}) + +export * as Gemini from "./gemini" diff --git a/packages/llm/src/protocols/index.ts b/packages/llm/src/protocols/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..bd8c8d3d9d9be1fce7efdef1069a3dc5207e9f1e --- /dev/null +++ b/packages/llm/src/protocols/index.ts @@ -0,0 +1,6 @@ +export * as AnthropicMessages from "./anthropic-messages" +export * as BedrockConverse from "./bedrock-converse" +export * as Gemini from "./gemini" +export * as OpenAIChat from "./openai-chat" +export * as OpenAICompatibleChat from "./openai-compatible-chat" +export * as OpenAIResponses from "./openai-responses" diff --git a/packages/llm/src/protocols/openai-chat.ts b/packages/llm/src/protocols/openai-chat.ts new file mode 100644 index 0000000000000000000000000000000000000000..9ac85b07b139f2a7a87f1a62d829a274b9cfd1ca --- /dev/null +++ b/packages/llm/src/protocols/openai-chat.ts @@ -0,0 +1,506 @@ +import { Effect, Schema } from "effect" +import { Route } from "../route/client" +import { Auth } from "../route/auth" +import { Endpoint } from "../route/endpoint" +import { HttpTransport } from "../route/transport" +import { Protocol } from "../route/protocol" +import { + LLMEvent, + Usage, + type FinishReason, + type JsonSchema, + type LLMRequest, + type MediaPart, + type ReasoningPart, + type TextPart, + type ToolCallPart, + type ToolDefinition, + type ToolContent, +} from "../schema" +import { isRecord, JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared" +import { OpenAIOptions } from "./utils/openai-options" +import { Lifecycle } from "./utils/lifecycle" +import { ToolSchemaProjection } from "./utils/tool-schema" +import { ToolStream } from "./utils/tool-stream" + +const ADAPTER = "openai-chat" +const IMAGE_MIMES = new Set(ProviderShared.IMAGE_MIMES) +export const DEFAULT_BASE_URL = "https://api.openai.com/v1" +export const PATH = "/chat/completions" + +// ============================================================================= +// Request Body Schema +// ============================================================================= +// The body schema is the provider-native JSON body. `fromRequest` below builds +// this shape from the common `LLMRequest`, then `Route.make` validates and +// JSON-encodes it before transport. +const OpenAIChatFunction = Schema.Struct({ + name: Schema.String, + description: Schema.String, + parameters: JsonObject, +}) + +const OpenAIChatTool = Schema.Struct({ + type: Schema.tag("function"), + function: OpenAIChatFunction, +}) +type OpenAIChatTool = Schema.Schema.Type + +const OpenAIChatAssistantToolCall = Schema.Struct({ + id: Schema.String, + type: Schema.tag("function"), + function: Schema.Struct({ + name: Schema.String, + arguments: Schema.String, + }), +}) +type OpenAIChatAssistantToolCall = Schema.Schema.Type + +const OpenAIChatUserContent = Schema.Union([ + Schema.Struct({ type: Schema.Literal("text"), text: Schema.String }), + Schema.Struct({ + type: Schema.Literal("image_url"), + image_url: Schema.Struct({ url: Schema.String }), + }), +]) + +const OpenAIChatMessage = Schema.Union([ + Schema.Struct({ role: Schema.Literal("system"), content: Schema.String }), + Schema.Struct({ + role: Schema.Literal("user"), + content: Schema.Union([Schema.String, Schema.Array(OpenAIChatUserContent)]), + }), + Schema.Struct({ + role: Schema.Literal("assistant"), + content: Schema.NullOr(Schema.String), + tool_calls: optionalArray(OpenAIChatAssistantToolCall), + reasoning_content: Schema.optional(Schema.String), + }), + Schema.Struct({ role: Schema.Literal("tool"), tool_call_id: Schema.String, content: Schema.String }), +]).pipe(Schema.toTaggedUnion("role")) +type OpenAIChatMessage = Schema.Schema.Type + +const OpenAIChatToolChoice = Schema.Union([ + Schema.Literals(["auto", "none", "required"]), + Schema.Struct({ + type: Schema.tag("function"), + function: Schema.Struct({ name: Schema.String }), + }), +]) + +export const bodyFields = { + model: Schema.String, + messages: Schema.Array(OpenAIChatMessage), + tools: optionalArray(OpenAIChatTool), + tool_choice: Schema.optional(OpenAIChatToolChoice), + stream: Schema.Literal(true), + stream_options: Schema.optional(Schema.Struct({ include_usage: Schema.Boolean })), + store: Schema.optional(Schema.Boolean), + reasoning_effort: Schema.optional(OpenAIOptions.OpenAIReasoningEffort), + max_tokens: Schema.optional(Schema.Number), + temperature: Schema.optional(Schema.Number), + top_p: Schema.optional(Schema.Number), + frequency_penalty: Schema.optional(Schema.Number), + presence_penalty: Schema.optional(Schema.Number), + seed: Schema.optional(Schema.Number), + stop: optionalArray(Schema.String), +} +const OpenAIChatBody = Schema.Struct(bodyFields) +export type OpenAIChatBody = Schema.Schema.Type + +// ============================================================================= +// Streaming Event Schema +// ============================================================================= +// The event schema is one decoded SSE `data:` payload. `Framing.sse` splits the +// byte stream into strings, then `Protocol.jsonEvent` decodes each string into +// this provider-native event shape. +const OpenAIChatUsage = Schema.Struct({ + prompt_tokens: Schema.optional(Schema.Number), + completion_tokens: Schema.optional(Schema.Number), + total_tokens: Schema.optional(Schema.Number), + prompt_tokens_details: optionalNull( + Schema.Struct({ + cached_tokens: Schema.optional(Schema.Number), + }), + ), + completion_tokens_details: optionalNull( + Schema.Struct({ + reasoning_tokens: Schema.optional(Schema.Number), + }), + ), +}) + +const OpenAIChatToolCallDeltaFunction = Schema.Struct({ + name: optionalNull(Schema.String), + arguments: optionalNull(Schema.String), +}) + +const OpenAIChatToolCallDelta = Schema.Struct({ + index: Schema.Number, + id: optionalNull(Schema.String), + function: optionalNull(OpenAIChatToolCallDeltaFunction), +}) +type OpenAIChatToolCallDelta = Schema.Schema.Type + +const OpenAIChatDelta = Schema.Struct({ + content: optionalNull(Schema.String), + reasoning_content: optionalNull(Schema.String), + tool_calls: optionalNull(Schema.Array(OpenAIChatToolCallDelta)), +}) + +const OpenAIChatChoice = Schema.Struct({ + delta: optionalNull(OpenAIChatDelta), + finish_reason: optionalNull(Schema.String), +}) + +const OpenAIChatEvent = Schema.Struct({ + choices: Schema.Array(OpenAIChatChoice), + usage: optionalNull(OpenAIChatUsage), +}) +type OpenAIChatEvent = Schema.Schema.Type +type OpenAIChatRequestMessage = LLMRequest["messages"][number] + +interface ParserState { + readonly tools: ToolStream.State + readonly toolCallEvents: ReadonlyArray + readonly usage?: Usage + readonly finishReason?: FinishReason + readonly lifecycle: Lifecycle.State +} + +const invalid = ProviderShared.invalidRequest + +// ============================================================================= +// Request Lowering +// ============================================================================= +// Lowering is the only place that knows how common LLM messages map onto the +// OpenAI Chat wire format. Keep provider quirks here instead of leaking native +// fields into `LLMRequest`. +const lowerTool = (tool: ToolDefinition, inputSchema: JsonSchema): OpenAIChatTool => ({ + type: "function", + function: { + name: tool.name, + description: tool.description, + parameters: ToolSchemaProjection.openAI(inputSchema), + }, +}) + +const lowerToolChoice = (toolChoice: NonNullable) => + ProviderShared.matchToolChoice("OpenAI Chat", toolChoice, { + auto: () => "auto" as const, + none: () => "none" as const, + required: () => "required" as const, + tool: (name) => ({ type: "function" as const, function: { name } }), + }) + +const lowerToolCall = (part: ToolCallPart): OpenAIChatAssistantToolCall => ({ + id: part.id, + type: "function", + function: { + name: part.name, + arguments: ProviderShared.encodeJson(part.input), + }, +}) + +const lowerMedia = Effect.fn("OpenAIChat.lowerMedia")(function* (part: MediaPart) { + const media = yield* ProviderShared.validateMedia("OpenAI Chat", part, IMAGE_MIMES) + return { type: "image_url" as const, image_url: { url: media.dataUrl } } +}) + +const openAICompatibleReasoningContent = (native: unknown) => + isRecord(native) && typeof native.reasoning_content === "string" ? native.reasoning_content : undefined + +const lowerUserMessage = Effect.fn("OpenAIChat.lowerUserMessage")(function* (message: OpenAIChatRequestMessage) { + const content: Array> = [] + for (const part of message.content) { + if (part.type === "text") { + content.push({ type: "text", text: part.text }) + continue + } + if (part.type === "media") { + content.push(yield* lowerMedia(part)) + continue + } + return yield* ProviderShared.unsupportedContent("OpenAI Chat", "user", ["text", "media"]) + } + if (content.every((part) => part.type === "text")) + return { role: "user" as const, content: content.map((part) => part.text).join("") } + return { role: "user" as const, content } +}) + +const lowerAssistantMessage = Effect.fn("OpenAIChat.lowerAssistantMessage")(function* ( + message: OpenAIChatRequestMessage, +) { + const content: TextPart[] = [] + const reasoning: ReasoningPart[] = [] + const toolCalls: OpenAIChatAssistantToolCall[] = [] + for (const part of message.content) { + if (!ProviderShared.supportsContent(part, ["text", "reasoning", "tool-call"])) + return yield* ProviderShared.unsupportedContent("OpenAI Chat", "assistant", ["text", "reasoning", "tool-call"]) + if (part.type === "text") { + content.push(part) + continue + } + if (part.type === "reasoning") { + reasoning.push(part) + continue + } + if (part.type === "tool-call") { + toolCalls.push(lowerToolCall(part)) + continue + } + } + return { + role: "assistant" as const, + content: content.length === 0 ? null : ProviderShared.joinText(content), + tool_calls: toolCalls.length === 0 ? undefined : toolCalls, + reasoning_content: + reasoning.length > 0 + ? reasoning.map((part) => part.text).join("") + : openAICompatibleReasoningContent(message.native?.openaiCompatible), + } +}) + +const lowerToolMessages = Effect.fn("OpenAIChat.lowerToolMessages")(function* (message: OpenAIChatRequestMessage) { + const messages: OpenAIChatMessage[] = [] + const images: Array> = [] + for (const part of message.content) { + if (!ProviderShared.supportsContent(part, ["tool-result"])) + return yield* ProviderShared.unsupportedContent("OpenAI Chat", "tool", ["tool-result"]) + if (part.result.type !== "content") { + messages.push({ role: "tool", tool_call_id: part.id, content: ProviderShared.toolResultText(part) }) + continue + } + const content: ReadonlyArray = part.result.value + const text = content.filter((item) => item.type === "text").map((item) => item.text) + messages.push({ role: "tool", tool_call_id: part.id, content: text.join("\n") }) + const files = content.filter((item) => item.type === "file") + images.push( + ...(yield* Effect.forEach(files, (item) => + lowerMedia({ type: "media", mediaType: item.mime, data: item.uri, filename: item.name }), + )), + ) + } + return { messages, images } +}) + +const lowerMessage = Effect.fn("OpenAIChat.lowerMessage")(function* (message: OpenAIChatRequestMessage) { + if (message.role === "user") return [yield* lowerUserMessage(message)] + if (message.role === "assistant") return [yield* lowerAssistantMessage(message)] + return (yield* lowerToolMessages(message)).messages +}) + +const lowerMessages = Effect.fn("OpenAIChat.lowerMessages")(function* (request: LLMRequest) { + const system: OpenAIChatMessage[] = + request.system.length === 0 ? [] : [{ role: "system", content: ProviderShared.joinText(request.system) }] + const messages = [...system] + const pendingImages: Array> = [] + const flushImages = () => { + if (pendingImages.length === 0) return + messages.push({ role: "user", content: pendingImages.splice(0) }) + } + for (const message of request.messages) { + if (message.role === "system") { + const part = yield* ProviderShared.wrappedSystemUpdate("OpenAI Chat", message) + if (pendingImages.length > 0) { + messages.push({ role: "user", content: [...pendingImages.splice(0), { type: "text", text: part.text }] }) + continue + } + const previous = messages.at(-1) + if (previous?.role === "user" && typeof previous.content === "string") + messages[messages.length - 1] = { role: "user", content: `${previous.content}\n${part.text}` } + else if (previous?.role === "user" && Array.isArray(previous.content)) + messages[messages.length - 1] = { + role: "user", + content: [...previous.content, { type: "text", text: part.text }], + } + else messages.push({ role: "user", content: part.text }) + continue + } + if (message.role === "tool") { + const lowered = yield* lowerToolMessages(message) + messages.push(...lowered.messages) + pendingImages.push(...lowered.images) + continue + } + flushImages() + messages.push(...(yield* lowerMessage(message))) + } + flushImages() + return messages +}) + +const lowerOptions = Effect.fn("OpenAIChat.lowerOptions")(function* (request: LLMRequest) { + const store = OpenAIOptions.store(request) + const reasoningEffort = OpenAIOptions.reasoningEffort(request) + if (reasoningEffort && !OpenAIOptions.isReasoningEffort(reasoningEffort)) + return yield* invalid(`OpenAI Chat does not support reasoning effort ${reasoningEffort}`) + return { + ...(store !== undefined ? { store } : {}), + ...(reasoningEffort ? { reasoning_effort: reasoningEffort } : {}), + } +}) + +const fromRequest = Effect.fn("OpenAIChat.fromRequest")(function* (request: LLMRequest) { + // `fromRequest` returns the provider body only. Endpoint, auth, framing, + // validation, and HTTP execution are composed by `Route.make`. + const generation = request.generation + const toolSchemaCompatibility = request.model.compatibility?.toolSchema + return { + model: request.model.id, + messages: yield* lowerMessages(request), + tools: + request.tools.length === 0 + ? undefined + : request.tools.map((tool) => + lowerTool(tool, ToolSchemaProjection.modelCompatibility(tool.inputSchema, toolSchemaCompatibility)), + ), + tool_choice: request.toolChoice ? yield* lowerToolChoice(request.toolChoice) : undefined, + stream: true as const, + stream_options: { include_usage: true }, + max_tokens: generation?.maxTokens, + temperature: generation?.temperature, + top_p: generation?.topP, + frequency_penalty: generation?.frequencyPenalty, + presence_penalty: generation?.presencePenalty, + seed: generation?.seed, + stop: generation?.stop, + ...(yield* lowerOptions(request)), + } +}) + +// ============================================================================= +// Stream Parsing +// ============================================================================= +// Streaming parsers are small state machines: every event returns a new state +// plus the common `LLMEvent`s produced by that event. Tool calls are accumulated +// because OpenAI streams JSON arguments across multiple deltas. +const mapFinishReason = (reason: string | null | undefined): FinishReason => { + if (reason === "stop") return "stop" + if (reason === "length") return "length" + if (reason === "content_filter") return "content-filter" + if (reason === "function_call" || reason === "tool_calls") return "tool-calls" + return "unknown" +} + +// OpenAI Chat reports `prompt_tokens` (inclusive total) with a +// `cached_tokens` subset, and `completion_tokens` (inclusive total) with +// a `reasoning_tokens` subset. We pass the inclusive totals through and +// derive the non-cached breakdown so the `LLM.Usage` contract is +// satisfied on both sides. +const mapUsage = (usage: OpenAIChatEvent["usage"]): Usage | undefined => { + if (!usage) return undefined + const cached = usage.prompt_tokens_details?.cached_tokens + const reasoning = usage.completion_tokens_details?.reasoning_tokens + const nonCached = ProviderShared.subtractTokens(usage.prompt_tokens, cached) + return new Usage({ + inputTokens: usage.prompt_tokens, + outputTokens: usage.completion_tokens, + nonCachedInputTokens: nonCached, + cacheReadInputTokens: cached, + reasoningTokens: reasoning, + totalTokens: ProviderShared.totalTokens(usage.prompt_tokens, usage.completion_tokens, usage.total_tokens), + providerMetadata: { openai: usage }, + }) +} + +const step = (state: ParserState, event: OpenAIChatEvent) => + Effect.gen(function* () { + const events: LLMEvent[] = [] + const usage = mapUsage(event.usage) ?? state.usage + const choice = event.choices[0] + const finishReason = choice?.finish_reason ? mapFinishReason(choice.finish_reason) : state.finishReason + const delta = choice?.delta + const toolDeltas = delta?.tool_calls ?? [] + let tools = state.tools + + let lifecycle = state.lifecycle + + if (delta?.reasoning_content) + lifecycle = Lifecycle.reasoningDelta(lifecycle, events, "reasoning-0", delta.reasoning_content) + + if (delta?.content) { + lifecycle = Lifecycle.reasoningEnd(lifecycle, events, "reasoning-0") + lifecycle = Lifecycle.textDelta(lifecycle, events, "text-0", delta.content) + } + + if (toolDeltas.length) lifecycle = Lifecycle.reasoningEnd(lifecycle, events, "reasoning-0") + + for (const tool of toolDeltas) { + const result = ToolStream.appendOrStart( + ADAPTER, + tools, + tool.index, + { id: tool.id ?? undefined, name: tool.function?.name ?? undefined, text: tool.function?.arguments ?? "" }, + "OpenAI Chat tool call delta is missing id or name", + ) + if (ToolStream.isError(result)) return yield* result + tools = result.tools + if (result.events.length) lifecycle = Lifecycle.stepStart(lifecycle, events) + events.push(...result.events) + } + + // Finalize accumulated tool inputs eagerly when finish_reason arrives so + // JSON parse failures fail the stream at the boundary rather than at halt. + const finished = + finishReason !== undefined && state.finishReason === undefined && Object.keys(tools).length > 0 + ? yield* ToolStream.finishAll(ADAPTER, tools) + : undefined + + return [ + { + tools: finished?.tools ?? tools, + toolCallEvents: finished?.events ?? state.toolCallEvents, + usage, + finishReason, + lifecycle, + }, + events, + ] as const + }) + +const finishEvents = (state: ParserState): ReadonlyArray => { + const events: LLMEvent[] = [] + const hasToolCalls = state.toolCallEvents.length > 0 + const reason = state.finishReason === "stop" && hasToolCalls ? "tool-calls" : state.finishReason + const lifecycle = state.toolCallEvents.length ? Lifecycle.stepStart(state.lifecycle, events) : state.lifecycle + events.push(...state.toolCallEvents) + if (reason) Lifecycle.finish(lifecycle, events, { reason, usage: state.usage }) + return events +} + +// ============================================================================= +// Protocol And OpenAI Route +// ============================================================================= +/** + * The OpenAI Chat protocol — request body construction, body schema, and the + * streaming-event state machine. Reused by every route that speaks OpenAI Chat + * over HTTP+SSE: native OpenAI, DeepSeek, TogetherAI, Cerebras, Baseten, + * Fireworks, DeepInfra, and (once added) Azure OpenAI Chat. + */ +export const protocol = Protocol.make({ + id: ADAPTER, + body: { + schema: OpenAIChatBody, + from: fromRequest, + }, + stream: { + event: Protocol.jsonEvent(OpenAIChatEvent), + initial: () => ({ tools: ToolStream.empty(), toolCallEvents: [], lifecycle: Lifecycle.initial() }), + step, + onHalt: finishEvents, + }, +}) + +export const httpTransport = HttpTransport.sseJson.with() + +export const route = Route.make({ + id: ADAPTER, + provider: "openai", + protocol, + endpoint: Endpoint.path(PATH, { baseURL: DEFAULT_BASE_URL }), + auth: Auth.none, + transport: httpTransport, +}) + +export * as OpenAIChat from "./openai-chat" diff --git a/packages/llm/src/protocols/openai-compatible-chat.ts b/packages/llm/src/protocols/openai-compatible-chat.ts new file mode 100644 index 0000000000000000000000000000000000000000..ce3f0a83d75a8dd646a9b7d588e9aa2dd6a61807 --- /dev/null +++ b/packages/llm/src/protocols/openai-compatible-chat.ts @@ -0,0 +1,24 @@ +import { Route, type RouteRoutedModelInput } from "../route/client" +import { Endpoint } from "../route/endpoint" +import { Framing } from "../route/framing" +import * as OpenAIChat from "./openai-chat" + +const ADAPTER = "openai-compatible-chat" + +export type OpenAICompatibleChatModelInput = RouteRoutedModelInput + +/** + * Route for non-OpenAI providers that expose an OpenAI Chat-compatible + * `/chat/completions` endpoint. Reuses `OpenAIChat.protocol` end-to-end and + * overrides only the route id so providers can be resolved per-family without + * colliding with native OpenAI. Provider helpers configure the route endpoint + * before model selection. + */ +export const route = Route.make({ + id: ADAPTER, + protocol: OpenAIChat.protocol, + endpoint: Endpoint.path("/chat/completions"), + framing: Framing.sse, +}) + +export * as OpenAICompatibleChat from "./openai-compatible-chat" diff --git a/packages/llm/src/protocols/openai-responses.ts b/packages/llm/src/protocols/openai-responses.ts new file mode 100644 index 0000000000000000000000000000000000000000..4936d31c921b229d3e56a43e0c831d5bdd1d1790 --- /dev/null +++ b/packages/llm/src/protocols/openai-responses.ts @@ -0,0 +1,1022 @@ +import { Effect, Schema } from "effect" +import { Route } from "../route/client" +import { Auth } from "../route/auth" +import { Endpoint } from "../route/endpoint" +import { HttpTransport, WebSocketTransport } from "../route/transport" +import { Protocol } from "../route/protocol" +import { + LLMEvent, + Usage, + type FinishReason, + type JsonSchema, + type LLMRequest, + type ProviderMetadata, + type ReasoningPart, + type TextPart, + type ToolCallPart, + type ToolDefinition, + type ToolContent, + type ToolResultPart, +} from "../schema" +import { JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared" +import { isContextOverflow } from "../provider-error" +import { OpenAIOptions } from "./utils/openai-options" +import { Lifecycle } from "./utils/lifecycle" +import { ToolSchemaProjection } from "./utils/tool-schema" +import { ToolStream } from "./utils/tool-stream" + +const ADAPTER = "openai-responses" +export const DEFAULT_BASE_URL = "https://api.openai.com/v1" +export const PATH = "/responses" + +// ============================================================================= +// Request Body Schema +// ============================================================================= +const OpenAIResponsesInputText = Schema.Struct({ + type: Schema.tag("input_text"), + text: Schema.String, +}) +const OpenAIResponsesInputImage = Schema.Struct({ + type: Schema.tag("input_image"), + image_url: Schema.String, +}) +const OpenAIResponsesInputContent = Schema.Union([OpenAIResponsesInputText, OpenAIResponsesInputImage]) +type OpenAIResponsesInputContent = Schema.Schema.Type + +const OpenAIResponsesOutputText = Schema.Struct({ + type: Schema.tag("output_text"), + text: Schema.String, +}) + +const OpenAIResponsesReasoningSummaryText = Schema.Struct({ + type: Schema.tag("summary_text"), + text: Schema.String, +}) + +const OpenAIResponsesReasoningItem = Schema.Struct({ + type: Schema.tag("reasoning"), + id: Schema.optionalKey(Schema.String), + summary: Schema.Array(OpenAIResponsesReasoningSummaryText), + encrypted_content: optionalNull(Schema.String), +}) + +const OpenAIResponsesItemReference = Schema.Struct({ + type: Schema.tag("item_reference"), + id: Schema.String, +}) + +// `function_call_output.output` accepts either a plain string or an ordered +// array of content items so tools can return images in addition to text. +// https://platform.openai.com/docs/api-reference/responses/object +const OpenAIResponsesFunctionCallOutputContent = Schema.Union([OpenAIResponsesInputText, OpenAIResponsesInputImage]) + +const OpenAIResponsesFunctionCallOutput = Schema.Union([ + Schema.String, + Schema.Array(OpenAIResponsesFunctionCallOutputContent), +]) + +const OpenAIResponsesInputItem = Schema.Union([ + Schema.Struct({ role: Schema.tag("system"), content: Schema.String }), + Schema.Struct({ role: Schema.tag("user"), content: Schema.Array(OpenAIResponsesInputContent) }), + Schema.Struct({ role: Schema.tag("assistant"), content: Schema.Array(OpenAIResponsesOutputText) }), + OpenAIResponsesReasoningItem, + OpenAIResponsesItemReference, + Schema.Struct({ + type: Schema.tag("function_call"), + call_id: Schema.String, + name: Schema.String, + arguments: Schema.String, + }), + Schema.Struct({ + type: Schema.tag("function_call_output"), + call_id: Schema.String, + output: OpenAIResponsesFunctionCallOutput, + }), +]) +type OpenAIResponsesInputItem = Schema.Schema.Type + +// Mutable counterpart of the schema reasoning item so `lowerMessages` can fold +// multiple streamed summary parts into the same item before flushing. +type OpenAIResponsesReasoningInput = { + type: "reasoning" + id: string + summary: Array<{ type: "summary_text"; text: string }> + encrypted_content?: string | null +} +type OpenAIResponsesReasoningReplay = Omit + +const OpenAIResponsesTool = Schema.Struct({ + type: Schema.tag("function"), + name: Schema.String, + description: Schema.String, + parameters: JsonObject, + strict: Schema.optional(Schema.Boolean), +}) +type OpenAIResponsesTool = Schema.Schema.Type + +const OpenAIResponsesToolChoice = Schema.Union([ + Schema.Literals(["auto", "none", "required"]), + Schema.Struct({ type: Schema.tag("function"), name: Schema.String }), +]) + +// Fields shared between the HTTP body and the WebSocket `response.create` +// message. The HTTP body adds `stream: true`; the WebSocket message adds +// `type: "response.create"`. Defining the shared shape once keeps the two +// transports in sync without a destructure-and-strip dance. +const OpenAIResponsesCoreFields = { + model: Schema.String, + input: Schema.Array(OpenAIResponsesInputItem), + instructions: Schema.optional(Schema.String), + tools: optionalArray(OpenAIResponsesTool), + tool_choice: Schema.optional(OpenAIResponsesToolChoice), + store: Schema.optional(Schema.Boolean), + service_tier: Schema.optional(OpenAIOptions.OpenAIServiceTier), + prompt_cache_key: Schema.optional(Schema.String), + include: optionalArray(OpenAIOptions.OpenAIResponseIncludable), + reasoning: Schema.optional( + Schema.Struct({ + effort: Schema.optional(OpenAIOptions.OpenAIReasoningEffort), + summary: Schema.optional(Schema.Literal("auto")), + }), + ), + text: Schema.optional( + Schema.Struct({ + verbosity: Schema.optional(OpenAIOptions.OpenAITextVerbosity), + }), + ), + max_output_tokens: Schema.optional(Schema.Number), + temperature: Schema.optional(Schema.Number), + top_p: Schema.optional(Schema.Number), +} + +const OpenAIResponsesBody = Schema.Struct({ + ...OpenAIResponsesCoreFields, + stream: Schema.Literal(true), +}) +export type OpenAIResponsesBody = Schema.Schema.Type + +const OpenAIResponsesWebSocketMessage = Schema.StructWithRest( + Schema.Struct({ + type: Schema.tag("response.create"), + ...OpenAIResponsesCoreFields, + }), + [Schema.Record(Schema.String, Schema.Unknown)], +) +type OpenAIResponsesWebSocketMessage = Schema.Schema.Type +const encodeWebSocketMessage = Schema.encodeSync(Schema.fromJsonString(OpenAIResponsesWebSocketMessage)) + +const OpenAIResponsesUsage = Schema.Struct({ + input_tokens: Schema.optional(Schema.Number), + input_tokens_details: optionalNull(Schema.Struct({ cached_tokens: Schema.optional(Schema.Number) })), + output_tokens: Schema.optional(Schema.Number), + output_tokens_details: optionalNull(Schema.Struct({ reasoning_tokens: Schema.optional(Schema.Number) })), + total_tokens: Schema.optional(Schema.Number), +}) +type OpenAIResponsesUsage = Schema.Schema.Type + +const OpenAIResponsesStreamItem = Schema.Struct({ + type: Schema.String, + id: Schema.optional(Schema.String), + call_id: Schema.optional(Schema.String), + name: Schema.optional(Schema.String), + arguments: Schema.optional(Schema.String), + // Hosted (provider-executed) tool fields. Each hosted tool item carries its + // own subset of these — we capture them generically so we can surface the + // call's typed input portion and round-trip the full result payload without + // hand-rolling a per-tool schema. + status: Schema.optional(Schema.String), + action: Schema.optional(Schema.Unknown), + queries: Schema.optional(Schema.Unknown), + results: Schema.optional(Schema.Unknown), + code: Schema.optional(Schema.String), + container_id: Schema.optional(Schema.String), + outputs: Schema.optional(Schema.Unknown), + server_label: Schema.optional(Schema.String), + output: Schema.optional(Schema.Unknown), + error: Schema.optional(Schema.Unknown), + encrypted_content: optionalNull(Schema.String), +}) +type OpenAIResponsesStreamItem = Schema.Schema.Type + +// OpenAI Responses surfaces provider failures in two related shapes. The +// streaming `error` event carries the details at the top level +// (`{ type: "error", code, message, param, sequence_number }`), while +// `response.failed` carries them under `response.error`. We capture both so +// the parser can surface a useful provider-error message in either path. +const OpenAIResponsesErrorPayload = Schema.Struct({ + code: optionalNull(Schema.String), + message: optionalNull(Schema.String), + param: optionalNull(Schema.String), +}) + +const OpenAIResponsesEvent = Schema.Struct({ + type: Schema.String, + delta: Schema.optional(Schema.String), + item_id: Schema.optional(Schema.String), + summary_index: Schema.optional(Schema.Number), + item: Schema.optional(OpenAIResponsesStreamItem), + response: Schema.optional( + Schema.StructWithRest( + Schema.Struct({ + id: Schema.optional(Schema.String), + service_tier: optionalNull(Schema.String), + incomplete_details: optionalNull(Schema.Struct({ reason: Schema.String })), + usage: optionalNull(OpenAIResponsesUsage), + error: optionalNull(OpenAIResponsesErrorPayload), + }), + [Schema.Record(Schema.String, Schema.Unknown)], + ), + ), + code: Schema.optional(Schema.String), + message: Schema.optional(Schema.String), + param: Schema.optional(Schema.String), +}) +type OpenAIResponsesEvent = Schema.Schema.Type + +interface ParserState { + readonly tools: ToolStream.State + readonly hasFunctionCall: boolean + readonly lifecycle: Lifecycle.State + readonly reasoningItems: Readonly> + readonly store: boolean | undefined +} + +type ReasoningSummaryStatus = "active" | "can-conclude" | "concluded" + +interface ReasoningStreamItem { + readonly encryptedContent: string | null | undefined + // Keyed by OpenAI's numeric `summary_index`. JS object keys coerce to + // strings, but typing the map as `Record` documents intent + // and matches the wire field. + readonly summaryParts: Readonly> +} + +const invalid = ProviderShared.invalidRequest + +// ============================================================================= +// Request Lowering +// ============================================================================= +const lowerTool = (tool: ToolDefinition, inputSchema: JsonSchema): OpenAIResponsesTool => ({ + type: "function", + name: tool.name, + description: tool.description, + parameters: ToolSchemaProjection.openAI(inputSchema), + // TODO: Read this from OpenAI-specific tool options so direct LLM callers can opt into strict schemas. + strict: false, +}) + +const lowerToolChoice = (toolChoice: NonNullable) => + ProviderShared.matchToolChoice("OpenAI Responses", toolChoice, { + auto: () => "auto" as const, + none: () => "none" as const, + required: () => "required" as const, + tool: (name) => ({ type: "function" as const, name }), + }) + +const lowerToolCall = (part: ToolCallPart): OpenAIResponsesInputItem => ({ + type: "function_call", + call_id: part.id, + name: part.name, + arguments: ProviderShared.encodeJson(part.input), +}) + +const lowerReasoning = (part: ReasoningPart): OpenAIResponsesReasoningInput | undefined => { + const openai = part.providerMetadata?.openai + if (!ProviderShared.isRecord(openai) || typeof openai.itemId !== "string" || openai.itemId.length === 0) + return undefined + const encryptedContent = + typeof openai.reasoningEncryptedContent === "string" + ? openai.reasoningEncryptedContent + : openai.reasoningEncryptedContent === null + ? null + : undefined + return { + type: "reasoning", + id: openai.itemId, + summary: part.text.length > 0 ? [{ type: "summary_text", text: part.text }] : [], + encrypted_content: encryptedContent, + } +} + +const hostedToolItemID = (part: ToolResultPart) => { + const openai = part.providerMetadata?.openai + return ProviderShared.isRecord(openai) && typeof openai.itemId === "string" && openai.itemId.length > 0 + ? openai.itemId + : undefined +} + +const lowerUserContent = Effect.fn("OpenAIResponses.lowerUserContent")(function* ( + part: LLMRequest["messages"][number]["content"][number], +) { + if (part.type === "text") return { type: "input_text" as const, text: part.text } + if (part.type === "media") { + const media = yield* ProviderShared.validateMedia( + "OpenAI Responses", + part, + new Set(ProviderShared.IMAGE_MIMES), + ) + return { type: "input_image" as const, image_url: media.dataUrl } + } + return yield* ProviderShared.unsupportedContent("OpenAI Responses", "user", ["text", "media"]) +}) + +// Tool results may carry structured text/images. Keep media as provider-native +// content instead of JSON-stringifying base64 into a prompt string. +const lowerToolResultContentItem = Effect.fn("OpenAIResponses.lowerToolResultContentItem")(function* ( + item: ToolContent, +) { + if (item.type === "text") return { type: "input_text" as const, text: item.text } + const media = yield* ProviderShared.validateToolFile( + "OpenAI Responses", + item, + new Set(ProviderShared.IMAGE_MIMES), + ) + return { type: "input_image" as const, image_url: media.dataUrl } +}) + +const lowerToolResultOutput = Effect.fn("OpenAIResponses.lowerToolResultOutput")(function* (part: ToolResultPart) { + // Text/json/error results are encoded as a plain string for backward + // compatibility with existing cassettes and provider expectations. + if (part.result.type !== "content") return ProviderShared.toolResultText(part) + // Preserve the narrowed array element type when compiled through a consumer package. + const content: ReadonlyArray = part.result.value + return yield* Effect.forEach(content, lowerToolResultContentItem) +}) + +const lowerMessages = Effect.fn("OpenAIResponses.lowerMessages")(function* (request: LLMRequest) { + const system: OpenAIResponsesInputItem[] = + request.system.length === 0 ? [] : [{ role: "system", content: ProviderShared.joinText(request.system) }] + const input: OpenAIResponsesInputItem[] = [...system] + const store = OpenAIOptions.store(request) + + for (const message of request.messages) { + if (message.role === "system") { + const part = yield* ProviderShared.wrappedSystemUpdate("OpenAI Responses", message) + const previous = input.at(-1) + if (previous && "role" in previous && previous.role === "user") + input[input.length - 1] = { + role: "user", + content: [...previous.content, { type: "input_text", text: part.text }], + } + else input.push({ role: "user", content: [{ type: "input_text", text: part.text }] }) + continue + } + + if (message.role === "user") { + input.push({ role: "user", content: yield* Effect.forEach(message.content, lowerUserContent) }) + continue + } + + if (message.role === "assistant") { + const content: TextPart[] = [] + const reasoningItems: Record = {} + const reasoningReferences = new Set() + const hostedToolReferences = new Set() + const flushText = () => { + if (content.length === 0) return + input.push({ role: "assistant", content: content.map((part) => ({ type: "output_text", text: part.text })) }) + content.splice(0, content.length) + } + for (const part of message.content) { + if (part.type === "text") { + content.push(part) + continue + } + if (part.type === "reasoning") { + flushText() + const reasoning = lowerReasoning(part) + if (!reasoning) continue + if (store !== false) { + if (!reasoningReferences.has(reasoning.id)) input.push({ type: "item_reference", id: reasoning.id }) + reasoningReferences.add(reasoning.id) + continue + } + const existing = reasoningItems[reasoning.id] + if (existing) { + existing.summary.push(...reasoning.summary) + if (typeof reasoning.encrypted_content === "string") + existing.encrypted_content = reasoning.encrypted_content + continue + } + const replay = { + type: reasoning.type, + summary: reasoning.summary, + encrypted_content: reasoning.encrypted_content, + } + reasoningItems[reasoning.id] = replay + input.push(replay) + continue + } + if (part.type === "tool-call") { + flushText() + if (part.providerExecuted === true) continue + input.push(lowerToolCall(part)) + continue + } + if (part.type === "tool-result" && part.providerExecuted === true) { + flushText() + const itemID = hostedToolItemID(part) + if (store !== false && itemID && !hostedToolReferences.has(itemID)) + input.push({ type: "item_reference", id: itemID }) + if (itemID) hostedToolReferences.add(itemID) + continue + } + return yield* ProviderShared.unsupportedContent("OpenAI Responses", "assistant", [ + "text", + "reasoning", + "tool-call", + "tool-result", + ]) + } + flushText() + continue + } + + for (const part of message.content) { + if (!ProviderShared.supportsContent(part, ["tool-result"])) + return yield* ProviderShared.unsupportedContent("OpenAI Responses", "tool", ["tool-result"]) + input.push({ + type: "function_call_output", + call_id: part.id, + output: yield* lowerToolResultOutput(part), + }) + } + } + + // With store:false, OpenAI only accepts previous reasoning items when the + // complete item has encrypted state. Summary blocks for one item may carry + // that state only on the last block, so filter after they have been joined. + return store === false + ? input.filter( + (item) => !("type" in item) || item.type !== "reasoning" || typeof item.encrypted_content === "string", + ) + : input +}) + +const lowerOptions = Effect.fn("OpenAIResponses.lowerOptions")(function* (request: LLMRequest) { + const store = OpenAIOptions.store(request) + const promptCacheKey = OpenAIOptions.promptCacheKey(request) + const effort = OpenAIOptions.reasoningEffort(request) + if (effort && !OpenAIOptions.isReasoningEffort(effort)) + return yield* invalid(`OpenAI Responses does not support reasoning effort ${effort}`) + const summary = OpenAIOptions.reasoningSummary(request) + const include = OpenAIOptions.include(request) + const verbosity = OpenAIOptions.textVerbosity(request) + const instructions = OpenAIOptions.instructions(request) + const serviceTier = OpenAIOptions.serviceTier(request) + return { + ...(instructions ? { instructions } : {}), + ...(store !== undefined ? { store } : {}), + ...(promptCacheKey ? { prompt_cache_key: promptCacheKey } : {}), + ...(include ? { include } : {}), + ...(effort || summary ? { reasoning: { effort, summary } } : {}), + ...(verbosity ? { text: { verbosity } } : {}), + ...(serviceTier ? { service_tier: serviceTier } : {}), + } +}) + +const fromRequest = Effect.fn("OpenAIResponses.fromRequest")(function* (request: LLMRequest) { + const generation = request.generation + const options = yield* lowerOptions(request) + const toolSchemaCompatibility = request.model.compatibility?.toolSchema + return { + model: request.model.id, + input: yield* lowerMessages(request), + tools: + request.tools.length === 0 + ? undefined + : request.tools.map((tool) => + lowerTool(tool, ToolSchemaProjection.modelCompatibility(tool.inputSchema, toolSchemaCompatibility)), + ), + tool_choice: request.toolChoice ? yield* lowerToolChoice(request.toolChoice) : undefined, + stream: true as const, + max_output_tokens: generation?.maxTokens, + temperature: generation?.temperature, + top_p: generation?.topP, + ...options, + } +}) + +// ============================================================================= +// Stream Parsing +// ============================================================================= +// OpenAI Responses reports `input_tokens` (inclusive total) with a +// `cached_tokens` subset, and `output_tokens` (inclusive total) with a +// `reasoning_tokens` subset. Pass the totals through and derive the +// non-cached breakdown. +const mapUsage = (usage: OpenAIResponsesUsage | null | undefined) => { + if (!usage) return undefined + const cached = usage.input_tokens_details?.cached_tokens + const reasoning = usage.output_tokens_details?.reasoning_tokens + const nonCached = ProviderShared.subtractTokens(usage.input_tokens, cached) + return new Usage({ + inputTokens: usage.input_tokens, + outputTokens: usage.output_tokens, + nonCachedInputTokens: nonCached, + cacheReadInputTokens: cached, + reasoningTokens: reasoning, + totalTokens: ProviderShared.totalTokens(usage.input_tokens, usage.output_tokens, usage.total_tokens), + providerMetadata: { openai: usage }, + }) +} + +const mapFinishReason = (event: OpenAIResponsesEvent, hasFunctionCall: boolean): FinishReason => { + const reason = event.response?.incomplete_details?.reason + if (reason === undefined || reason === null) return hasFunctionCall ? "tool-calls" : "stop" + if (reason === "max_output_tokens") return "length" + if (reason === "content_filter") return "content-filter" + return hasFunctionCall ? "tool-calls" : "unknown" +} + +const openaiMetadata = (metadata: Record): ProviderMetadata => ({ openai: metadata }) + +// Hosted tool items (provider-executed) ship their typed input + status + +// result fields all in one item. We expose them as a `tool-call` + +// `tool-result` pair so consumers can treat them uniformly with client tools, +// only differentiated by `providerExecuted: true`. +// +// One record per OpenAI Responses item type that represents a hosted +// (provider-executed) tool call: the common name we surface, plus an `input` +// extractor that picks the fields the model actually populated for that tool. +// Falling back to `{}` when an entry isn't fully typed keeps unknown tools +// observable without rolling a per-tool schema. +const HOSTED_TOOLS = { + web_search_call: { name: "web_search", input: (item) => item.action ?? {} }, + web_search_preview_call: { name: "web_search_preview", input: (item) => item.action ?? {} }, + file_search_call: { name: "file_search", input: (item) => ({ queries: item.queries ?? [] }) }, + code_interpreter_call: { + name: "code_interpreter", + input: (item) => ({ code: item.code, container_id: item.container_id }), + }, + computer_use_call: { name: "computer_use", input: (item) => item.action ?? {} }, + image_generation_call: { name: "image_generation", input: () => ({}) }, + mcp_call: { + name: "mcp", + input: (item) => ({ server_label: item.server_label, name: item.name, arguments: item.arguments }), + }, + local_shell_call: { name: "local_shell", input: (item) => item.action ?? {} }, +} as const satisfies Record< + string, + { readonly name: string; readonly input: (item: OpenAIResponsesStreamItem) => unknown } +> + +type HostedToolType = keyof typeof HOSTED_TOOLS + +const isHostedToolItem = ( + item: OpenAIResponsesStreamItem, +): item is OpenAIResponsesStreamItem & { type: HostedToolType; id: string } => + item.type in HOSTED_TOOLS && typeof item.id === "string" && item.id.length > 0 + +const isReasoningItem = ( + item: OpenAIResponsesStreamItem, +): item is OpenAIResponsesStreamItem & { type: "reasoning"; id: string } => + item.type === "reasoning" && typeof item.id === "string" && item.id.length > 0 + +// Round-trip the full item as the structured result so consumers can extract +// outputs / sources / status without re-decoding. +const hostedToolResult = (item: OpenAIResponsesStreamItem) => { + const isError = typeof item.error !== "undefined" && item.error !== null + return isError ? { type: "error" as const, value: item.error } : { type: "json" as const, value: item } +} + +const hostedToolEvents = ( + item: OpenAIResponsesStreamItem & { type: HostedToolType; id: string }, +): ReadonlyArray => { + const tool = HOSTED_TOOLS[item.type] + const providerMetadata = openaiMetadata({ itemId: item.id }) + return [ + LLMEvent.toolCall({ + id: item.id, + name: tool.name, + input: tool.input(item), + providerExecuted: true, + providerMetadata, + }), + LLMEvent.toolResult({ + id: item.id, + name: tool.name, + result: hostedToolResult(item), + providerExecuted: true, + providerMetadata, + }), + ] +} + +type StepResult = readonly [ParserState, ReadonlyArray] + +const NO_EVENTS: StepResult["1"] = [] + +// `response.completed` / `response.incomplete` are clean finishes that emit a +// `finish` event; `response.failed` is a hard failure that emits a +// `provider-error`. All three end the stream — kept in one set so `step` and +// the protocol's `terminal` predicate stay in sync. +const TERMINAL_TYPES = new Set(["response.completed", "response.incomplete", "response.failed"]) + +const onOutputTextDelta = (state: ParserState, event: OpenAIResponsesEvent): StepResult => { + if (!event.delta) return [state, NO_EVENTS] + const events: LLMEvent[] = [] + return [ + { ...state, lifecycle: Lifecycle.textDelta(state.lifecycle, events, event.item_id ?? "text-0", event.delta) }, + events, + ] +} + +const onReasoningDelta = (state: ParserState, event: OpenAIResponsesEvent): StepResult => { + if (!event.delta) return [state, NO_EVENTS] + const events: LLMEvent[] = [] + const itemID = event.item_id ?? "reasoning-0" + const id = + event.summary_index !== undefined || state.reasoningItems[itemID] ? `${itemID}:${event.summary_index ?? 0}` : itemID + return [ + { + ...state, + lifecycle: Lifecycle.reasoningDelta(state.lifecycle, events, id, event.delta), + }, + events, + ] +} + +const onReasoningDone = (state: ParserState, _event: OpenAIResponsesEvent): StepResult => [state, NO_EVENTS] + +const reasoningMetadata = (item: OpenAIResponsesStreamItem & { id: string }) => + openaiMetadata({ itemId: item.id, reasoningEncryptedContent: item.encrypted_content ?? null }) + +// OpenAI Responses streams reasoning items in a stable order: +// `output_item.added` (reasoning) → +// `reasoning_summary_part.added` (index=0) → +// `reasoning_summary_text.delta` → +// `reasoning_summary_part.done` (index=0) → +// (repeat for index>0) → +// `output_item.done` (reasoning). +// The handlers below rely on this ordering: `onOutputItemAdded` seeds the +// per-item entry, `onReasoningSummaryPartAdded` for `summary_index === 0` +// short-circuits when the entry already exists, and higher-index handlers +// fold against the same entry. Behaviour for out-of-order events is +// best-effort, not guaranteed. +const onOutputItemAdded = (state: ParserState, event: OpenAIResponsesEvent): StepResult => { + const item = event.item + if (item && isReasoningItem(item)) { + const events: LLMEvent[] = [] + return [ + { + ...state, + lifecycle: Lifecycle.reasoningStart(state.lifecycle, events, `${item.id}:0`, reasoningMetadata(item)), + reasoningItems: { + ...state.reasoningItems, + [item.id]: { encryptedContent: item.encrypted_content, summaryParts: { 0: "active" } }, + }, + }, + events, + ] + } + if (item?.type !== "function_call" || !item.id) return [state, NO_EVENTS] + const providerMetadata = openaiMetadata({ itemId: item.id }) + const events: LLMEvent[] = [] + const lifecycle = Lifecycle.stepStart(state.lifecycle, events) + return [ + { + ...state, + lifecycle, + hasFunctionCall: state.hasFunctionCall, + tools: ToolStream.start(state.tools, item.id, { + id: item.call_id ?? item.id, + name: item.name ?? "", + input: item.arguments ?? "", + providerMetadata, + }), + }, + [...events, LLMEvent.toolInputStart({ id: item.call_id ?? item.id, name: item.name ?? "", providerMetadata })], + ] +} + +const onReasoningSummaryPartAdded = (state: ParserState, event: OpenAIResponsesEvent): StepResult => { + if (!event.item_id || event.summary_index === undefined) return [state, NO_EVENTS] + const item = state.reasoningItems[event.item_id] ?? { encryptedContent: undefined, summaryParts: {} } + if (event.summary_index === 0) { + if (state.reasoningItems[event.item_id]) return [state, NO_EVENTS] + const events: LLMEvent[] = [] + return [ + { + ...state, + lifecycle: Lifecycle.reasoningStart( + state.lifecycle, + events, + `${event.item_id}:0`, + openaiMetadata({ itemId: event.item_id, reasoningEncryptedContent: null }), + ), + reasoningItems: { + ...state.reasoningItems, + [event.item_id]: { ...item, summaryParts: { 0: "active" } }, + }, + }, + events, + ] + } + + const events: LLMEvent[] = [] + const closed = Object.entries(item.summaryParts) + .filter((entry) => entry[1] === "can-conclude") + .reduce( + (lifecycle, entry) => + Lifecycle.reasoningEnd( + lifecycle, + events, + `${event.item_id}:${entry[0]}`, + openaiMetadata({ itemId: event.item_id }), + ), + state.lifecycle, + ) + return [ + { + ...state, + lifecycle: Lifecycle.reasoningStart( + closed, + events, + `${event.item_id}:${event.summary_index}`, + openaiMetadata({ itemId: event.item_id, reasoningEncryptedContent: item.encryptedContent ?? null }), + ), + reasoningItems: { + ...state.reasoningItems, + [event.item_id]: { + ...item, + summaryParts: { + ...Object.fromEntries( + Object.entries(item.summaryParts).map((entry) => + entry[1] === "can-conclude" ? [entry[0], "concluded" as const] : entry, + ), + ), + [event.summary_index]: "active", + }, + }, + }, + }, + events, + ] +} + +const onReasoningSummaryPartDone = (state: ParserState, event: OpenAIResponsesEvent): StepResult => { + if (!event.item_id || event.summary_index === undefined) return [state, NO_EVENTS] + const item = state.reasoningItems[event.item_id] + if (!item) return [state, NO_EVENTS] + const events: LLMEvent[] = [] + return [ + { + ...state, + lifecycle: + state.store !== false + ? Lifecycle.reasoningEnd( + state.lifecycle, + events, + `${event.item_id}:${event.summary_index}`, + openaiMetadata({ itemId: event.item_id }), + ) + : state.lifecycle, + reasoningItems: { + ...state.reasoningItems, + [event.item_id]: { + ...item, + summaryParts: { + ...item.summaryParts, + [event.summary_index]: state.store !== false ? "concluded" : "can-conclude", + }, + }, + }, + }, + events, + ] +} + +const onFunctionCallArgumentsDelta = Effect.fn("OpenAIResponses.onFunctionCallArgumentsDelta")(function* ( + state: ParserState, + event: OpenAIResponsesEvent, +) { + if (!event.item_id || !event.delta) return [state, NO_EVENTS] satisfies StepResult + const result = ToolStream.appendExisting( + ADAPTER, + state.tools, + event.item_id, + event.delta, + "OpenAI Responses tool argument delta is missing its tool call", + ) + if (ToolStream.isError(result)) return yield* result + const events: LLMEvent[] = [] + const lifecycle = result.events.length ? Lifecycle.stepStart(state.lifecycle, events) : state.lifecycle + events.push(...result.events) + return [{ ...state, lifecycle, tools: result.tools }, events] satisfies StepResult +}) + +const onOutputItemDone = Effect.fn("OpenAIResponses.onOutputItemDone")(function* ( + state: ParserState, + event: OpenAIResponsesEvent, +) { + const item = event.item + if (!item) return [state, NO_EVENTS] satisfies StepResult + + if (item.type === "function_call") { + if (!item.id || !item.call_id || !item.name) return [state, NO_EVENTS] satisfies StepResult + const tools = state.tools[item.id] + ? state.tools + : ToolStream.start(state.tools, item.id, { id: item.call_id, name: item.name }) + const result = + item.arguments === undefined + ? yield* ToolStream.finish(ADAPTER, tools, item.id) + : yield* ToolStream.finishWithInput(ADAPTER, tools, item.id, item.arguments) + const events: LLMEvent[] = [] + const resultEvents = result.events ?? [] + const lifecycle = resultEvents.length ? Lifecycle.stepStart(state.lifecycle, events) : state.lifecycle + events.push(...resultEvents) + return [ + { + ...state, + lifecycle, + hasFunctionCall: resultEvents.some(LLMEvent.is.toolCall) ? true : state.hasFunctionCall, + tools: result.tools, + }, + events, + ] satisfies StepResult + } + + if (isHostedToolItem(item)) { + const events: LLMEvent[] = [] + const lifecycle = Lifecycle.stepStart(state.lifecycle, events) + events.push(...hostedToolEvents(item)) + return [{ ...state, lifecycle }, events] satisfies StepResult + } + + if (isReasoningItem(item)) { + const events: LLMEvent[] = [] + const providerMetadata = reasoningMetadata(item) + const reasoningItem = state.reasoningItems[item.id] + if (reasoningItem) { + const lifecycle = Object.entries(reasoningItem.summaryParts) + .filter((entry) => entry[1] === "active" || entry[1] === "can-conclude") + .reduce( + (lifecycle, entry) => Lifecycle.reasoningEnd(lifecycle, events, `${item.id}:${entry[0]}`, providerMetadata), + state.lifecycle, + ) + const { [item.id]: _removed, ...reasoningItems } = state.reasoningItems + return [{ ...state, lifecycle, reasoningItems }, events] satisfies StepResult + } + if (!state.lifecycle.reasoning.has(item.id)) { + const lifecycle = Lifecycle.stepStart(state.lifecycle, events) + events.push(LLMEvent.reasoningStart({ id: item.id, providerMetadata })) + events.push(LLMEvent.reasoningEnd({ id: item.id, providerMetadata })) + return [{ ...state, lifecycle }, events] satisfies StepResult + } + return [ + { ...state, lifecycle: Lifecycle.reasoningEnd(state.lifecycle, events, item.id, providerMetadata) }, + events, + ] satisfies StepResult + } + + return [state, NO_EVENTS] satisfies StepResult +}) + +const onResponseFinish = (state: ParserState, event: OpenAIResponsesEvent): StepResult => { + const events: LLMEvent[] = [] + const lifecycle = Lifecycle.finish(state.lifecycle, events, { + reason: mapFinishReason(event, state.hasFunctionCall), + usage: mapUsage(event.response?.usage), + providerMetadata: + event.response?.id || event.response?.service_tier + ? openaiMetadata({ + responseId: event.response.id, + serviceTier: event.response.service_tier, + }) + : undefined, + }) + return [{ ...state, lifecycle }, events] +} + +// Build a single human-readable message from whatever the provider supplied. +// When both code and message are present, prefix the code so consumers see +// the failure mode (e.g. `rate_limit_exceeded: Slow down`) instead of just +// the bare message — production rate limits and context-length failures used +// to be indistinguishable from generic stream drops. +const providerErrorMessage = (event: OpenAIResponsesEvent, fallback: string): string => { + const nested = event.response?.error ?? undefined + const message = event.message || nested?.message || undefined + const code = event.code || nested?.code || undefined + if (message && code) return `${code}: ${message}` + return message || code || fallback +} + +const providerError = (event: OpenAIResponsesEvent, fallback: string) => { + const code = event.code || event.response?.error?.code || undefined + const message = providerErrorMessage(event, fallback) + return LLMEvent.providerError({ + message, + classification: code === "context_length_exceeded" || isContextOverflow(message) ? "context-overflow" : undefined, + }) +} + +const onResponseFailed = (state: ParserState, event: OpenAIResponsesEvent): StepResult => [ + state, + [providerError(event, "OpenAI Responses response failed")], +] + +const onError = (state: ParserState, event: OpenAIResponsesEvent): StepResult => [ + state, + [providerError(event, "OpenAI Responses stream error")], +] + +const step = (state: ParserState, event: OpenAIResponsesEvent) => { + if (event.type === "response.output_text.delta") return Effect.succeed(onOutputTextDelta(state, event)) + if ( + event.type === "response.reasoning_text.delta" || + event.type === "response.reasoning_summary.delta" || + event.type === "response.reasoning_summary_text.delta" + ) + return Effect.succeed(onReasoningDelta(state, event)) + if ( + event.type === "response.reasoning_text.done" || + event.type === "response.reasoning_summary.done" || + event.type === "response.reasoning_summary_text.done" + ) + return Effect.succeed(onReasoningDone(state, event)) + if (event.type === "response.reasoning_summary_part.added") + return Effect.succeed(onReasoningSummaryPartAdded(state, event)) + if (event.type === "response.reasoning_summary_part.done") + return Effect.succeed(onReasoningSummaryPartDone(state, event)) + if (event.type === "response.output_item.added") return Effect.succeed(onOutputItemAdded(state, event)) + if (event.type === "response.function_call_arguments.delta") return onFunctionCallArgumentsDelta(state, event) + if (event.type === "response.output_item.done") return onOutputItemDone(state, event) + if (event.type === "response.completed" || event.type === "response.incomplete") + return Effect.succeed(onResponseFinish(state, event)) + if (event.type === "response.failed") return Effect.succeed(onResponseFailed(state, event)) + if (event.type === "error") return Effect.succeed(onError(state, event)) + return Effect.succeed([state, NO_EVENTS]) +} + +// ============================================================================= +// Protocol And OpenAI Route +// ============================================================================= +/** + * The OpenAI Responses protocol — request body construction, body schema, and + * the streaming-event state machine. Used by native OpenAI and (once + * registered) Azure OpenAI Responses. + */ +export const protocol = Protocol.make({ + id: ADAPTER, + body: { + schema: OpenAIResponsesBody, + from: fromRequest, + }, + stream: { + event: Protocol.jsonEvent(OpenAIResponsesEvent), + initial: (request) => ({ + hasFunctionCall: false, + tools: ToolStream.empty(), + lifecycle: Lifecycle.initial(), + reasoningItems: {}, + store: OpenAIOptions.store(request), + }), + step, + terminal: (event) => TERMINAL_TYPES.has(event.type), + }, +}) + +const endpoint = Endpoint.path(PATH, { baseURL: DEFAULT_BASE_URL }) +const auth = Auth.none + +export const httpTransport = HttpTransport.sseJson.with() + +export const route = Route.make({ + id: ADAPTER, + provider: "openai", + protocol, + endpoint, + auth, + transport: httpTransport, + defaults: { providerOptions: { openai: { store: false } } }, +}) + +const decodeWebSocketMessage = ProviderShared.validateWith(Schema.decodeUnknownEffect(OpenAIResponsesWebSocketMessage)) + +const webSocketMessage = (body: OpenAIResponsesBody | Record) => + Effect.gen(function* () { + if (!ProviderShared.isRecord(body)) + return yield* ProviderShared.invalidRequest("OpenAI Responses WebSocket body must be a JSON object") + const { stream: _stream, ...message } = body + return yield* decodeWebSocketMessage({ ...message, type: "response.create" }) + }) + +export const webSocketTransport = WebSocketTransport.jsonTransport.with< + OpenAIResponsesBody, + OpenAIResponsesWebSocketMessage +>({ + toMessage: webSocketMessage, + encodeMessage: encodeWebSocketMessage, +}) + +export const webSocketRoute = Route.make({ + id: `${ADAPTER}-websocket`, + provider: "openai", + protocol, + endpoint, + auth, + transport: webSocketTransport, + defaults: { providerOptions: { openai: { store: false } } }, +}) + +export * as OpenAIResponses from "./openai-responses" diff --git a/packages/llm/src/protocols/shared.ts b/packages/llm/src/protocols/shared.ts new file mode 100644 index 0000000000000000000000000000000000000000..173dc511bb034c52647d8f61faec4ab9826ecb18 --- /dev/null +++ b/packages/llm/src/protocols/shared.ts @@ -0,0 +1,326 @@ +import { Buffer } from "node:buffer" +import { Effect, Schema, Stream } from "effect" +import * as Sse from "effect/unstable/encoding/Sse" +import { Headers, HttpClientRequest } from "effect/unstable/http" +import { + InvalidProviderOutputReason, + InvalidRequestReason, + LLMError, + type ContentPart, + type LLMRequest, + type MediaPart, + type ToolFileContent, + type TextPart, + type ToolResultPart, +} from "../schema" +import { isRecord } from "../utils/record" +export { isRecord } + +export const Json = Schema.fromJsonString(Schema.Unknown) +export const decodeJson = Schema.decodeUnknownSync(Json) +export const encodeJson = Schema.encodeSync(Json) +const isJson = Schema.is(Schema.Json) +export const JsonObject = Schema.Record(Schema.String, Schema.Unknown) +export const optionalArray = (schema: S) => Schema.optional(Schema.Array(schema)) +export const optionalNull = (schema: S) => Schema.optional(Schema.NullOr(schema)) + +/** + * Streaming tool-call accumulator. Adapters that build a tool call across + * multiple `tool-input-delta` chunks store the partial JSON input string here + * and finalize it with `parseToolInput` once the call completes. + */ +export interface ToolAccumulator { + readonly id: string + readonly name: string + readonly input: string +} + +/** + * `Usage.totalTokens` policy shared by every route. Honors a provider- + * supplied total; otherwise falls back to `inputTokens + outputTokens` only + * when at least one is defined. Returns `undefined` when neither input nor + * output is known so routes don't publish a misleading `0`. + * + * Under the additive `LLM.Usage` contract, `inputTokens` and `outputTokens` + * are the non-cached input and visible output only. The provider-supplied + * `total` is the source of truth when present; the computed fallback + * under-counts cache and reasoning by design and exists mainly so + * Anthropic-style providers (which don't surface a total) still get a + * sensible aggregate on the input + output axes. + */ +export const totalTokens = ( + inputTokens: number | undefined, + outputTokens: number | undefined, + total: number | undefined, +) => { + if (total !== undefined) return total + if (inputTokens === undefined && outputTokens === undefined) return undefined + return (inputTokens ?? 0) + (outputTokens ?? 0) +} + +/** + * Subtract `subtrahend` from `total`, clamping to zero if the provider + * reports a non-sensical breakdown (e.g. `cached_tokens > prompt_tokens`). + * Used by protocol mappers when deriving a non-overlapping breakdown field + * from a provider's inclusive total — `nonCachedInputTokens` from + * `inputTokens - cacheReadInputTokens - cacheWriteInputTokens`. + * + * If `total` is `undefined`, returns `undefined` (we don't fabricate + * counts). If `subtrahend` is `undefined`, returns `total` unchanged. The + * provider-native breakdown stays available on `Usage.native` for debugging. + */ +export const subtractTokens = (total: number | undefined, subtrahend: number | undefined): number | undefined => { + if (total === undefined) return undefined + if (subtrahend === undefined) return total + return Math.max(0, total - subtrahend) +} + +/** + * Sum a list of optional token counts, returning `undefined` only when + * every value is `undefined` (so we don't fabricate a `0`). Used by + * protocol mappers to derive the inclusive `inputTokens` total from a + * provider that natively reports a non-overlapping breakdown + * (e.g. Anthropic, whose `input_tokens` is already non-cached only). + */ +export const sumTokens = (...values: ReadonlyArray): number | undefined => { + if (values.every((value) => value === undefined)) return undefined + return values.reduce((acc: number, value) => acc + (value ?? 0), 0) +} + +export const eventError = (route: string, message: string, raw?: string) => + new LLMError({ + module: "ProviderShared", + method: "stream", + reason: new InvalidProviderOutputReason({ route, message, raw }), + }) + +export const parseJson = (route: string, input: string, message: string) => + Effect.try({ + try: () => decodeJson(input), + catch: () => eventError(route, message, input), + }) + +/** + * Join the `text` field of a list of parts with newlines. Used by routes + * that flatten system / message content arrays into a single provider string + * (OpenAI Chat `system` content, OpenAI Responses `system` content, Gemini + * `systemInstruction.parts[].text`). + */ +export const joinText = (parts: ReadonlyArray<{ readonly text: string }>) => parts.map((part) => part.text).join("\n") + +const escapeSystemUpdateText = (text: string) => + text.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">") + +/** + * Stable fallback representation for chronological `Message.system(...)` + * updates on routes that do not support that privileged role natively. The + * wrapper remains visibly lower-authority user text, preserves the original + * temporal position, and XML-escapes content so it cannot close the wrapper. + */ +export const wrapSystemUpdate = (parts: ReadonlyArray<{ readonly text: string }>) => + `\n${escapeSystemUpdateText(joinText(parts))}\n` + +/** + * Chronological system updates deliberately accept text only. Do not insert + * raw retrieved, tool, or web content into privileged updates: keep untrusted + * data in ordinary user/tool messages instead. + */ +export const systemUpdateText = Effect.fn("ProviderShared.systemUpdateText")(function* ( + route: string, + message: LLMRequest["messages"][number], +) { + const content: TextPart[] = [] + for (const part of message.content) { + if (!supportsContent(part, ["text"])) return yield* unsupportedContent(route, "system", ["text"]) + content.push(part) + } + return content +}) + +/** Lower an unsupported privileged update into visible, in-order user text. */ +export const wrappedSystemUpdate = Effect.fn("ProviderShared.wrappedSystemUpdate")(function* ( + route: string, + message: LLMRequest["messages"][number], +) { + const content = yield* systemUpdateText(route, message) + return { type: "text" as const, text: wrapSystemUpdate(content), cache: content.at(-1)?.cache } +}) + +/** + * Parse the streamed JSON input of a tool call. Treats an empty string as + * `"{}"` — providers occasionally finish a tool call without ever emitting + * input deltas (e.g. zero-arg tools). The error message is uniform across + * routes: `Invalid JSON input for tool call `. + */ +export const parseToolInput = (route: string, name: string, raw: string) => + parseJson(route, raw || "{}", `Invalid JSON input for ${route} tool call ${name}`) + +export const IMAGE_MIMES = ["image/png", "image/jpeg", "image/gif", "image/webp"] as const +export const VIDEO_MIMES = ["video/mp4", "video/webm", "video/quicktime"] as const +export const AUDIO_MIMES = ["audio/wav", "audio/mp3", "audio/aiff", "audio/aac", "audio/ogg", "audio/flac"] as const +export const MEDIA_MIMES = [...IMAGE_MIMES, ...VIDEO_MIMES, ...AUDIO_MIMES] as const +export const MAX_MEDIA_ENCODED_BYTES = 28 * 1024 * 1024 +export const MAX_MEDIA_DECODED_BYTES = 20 * 1024 * 1024 + +const base64Pattern = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/ + +export interface ValidatedMedia { + readonly mime: string + readonly base64: string + readonly dataUrl: string + readonly bytes: Uint8Array +} + +export const validateMedia = Effect.fn("ProviderShared.validateMedia")(function* ( + route: string, + part: MediaPart, + supportedMimes: ReadonlySet, +) { + const mime = part.mediaType.toLowerCase() + if (!supportedMimes.has(mime)) return yield* invalidRequest(`${route} does not support media type ${part.mediaType}`) + + let base64: string + if (typeof part.data !== "string") { + if (part.data.byteLength > MAX_MEDIA_DECODED_BYTES) + return yield* invalidRequest(`${route} media exceeds the ${MAX_MEDIA_DECODED_BYTES} byte decoded limit`) + base64 = Buffer.from(part.data).toString("base64") + } else if (part.data.startsWith("data:")) { + const match = /^data:([^;,]+);base64,([A-Za-z0-9+/]*={0,2})$/s.exec(part.data) + if (!match) return yield* invalidRequest(`${route} media data URL must contain valid base64`) + if (match[1]!.toLowerCase() !== mime) + return yield* invalidRequest(`${route} media type ${part.mediaType} does not match data URL type ${match[1]}`) + base64 = match[2]! + } else { + base64 = part.data + } + + if (Buffer.byteLength(base64, "utf8") > MAX_MEDIA_ENCODED_BYTES) + return yield* invalidRequest(`${route} media exceeds the ${MAX_MEDIA_ENCODED_BYTES} byte encoded limit`) + if (!base64 || base64.length % 4 !== 0 || !base64Pattern.test(base64)) + return yield* invalidRequest(`${route} media must contain valid base64`) + const bytes = Buffer.from(base64, "base64") + if (bytes.byteLength > MAX_MEDIA_DECODED_BYTES) + return yield* invalidRequest(`${route} media exceeds the ${MAX_MEDIA_DECODED_BYTES} byte decoded limit`) + if (bytes.toString("base64") !== base64) return yield* invalidRequest(`${route} media must contain canonical base64`) + return { mime, base64, dataUrl: `data:${mime};base64,${base64}`, bytes } satisfies ValidatedMedia +}) + +export const validateToolFile = (route: string, part: ToolFileContent, supportedMimes: ReadonlySet) => + validateMedia(route, { type: "media", mediaType: part.mime, data: part.uri, filename: part.name }, supportedMimes) + +export const trimBaseUrl = (value: string) => value.replace(/\/+$/, "") + +export const toolResultText = (part: ToolResultPart) => { + if (part.result.type === "text") return String(part.result.value) + if (part.result.type === "error") { + const value = part.result.value + const prototype = + typeof value === "object" && value !== null && !Array.isArray(value) && Object.getPrototypeOf(value) + const structured = Array.isArray(value) || prototype === Object.prototype || prototype === null + return structured && isJson(value) ? encodeJson(value) : String(value) + } + return encodeJson(part.result.value) +} + +export const errorText = (error: unknown) => { + if (error instanceof Error) return error.message + if (typeof error === "string") return error + if (typeof error === "number" || typeof error === "boolean" || typeof error === "bigint") return String(error) + if (error === null) return "null" + if (error === undefined) return "undefined" + return "Unknown stream error" +} + +/** + * `framing` step for Server-Sent Events. Decodes UTF-8, runs the SSE channel + * decoder, and drops empty / `[DONE]` keep-alive events so the downstream + * `decodeChunk` sees one JSON string per element. The SSE channel emits a + * `Retry` control event on its error channel; we drop it here (we don't + * implement client-driven retries) so the public error channel stays + * `LLMError`. + */ +export const sseFraming = (bytes: Stream.Stream): Stream.Stream => + bytes.pipe( + Stream.decodeText(), + Stream.pipeThroughChannel(Sse.decode()), + Stream.catchTag("Retry", () => Stream.empty), + Stream.filter((event) => event.data.length > 0 && event.data !== "[DONE]"), + Stream.map((event) => event.data), + ) + +/** + * Canonical invalid-request constructor. Lift one-line `const invalid = + * (message) => invalidRequest(message)` aliases out of every + * route so the error constructor lives in one place. If we ever extend + * `InvalidRequestReason` with route context or trace metadata, the change + * lands here. + */ +export const invalidRequest = (message: string) => + new LLMError({ + module: "ProviderShared", + method: "request", + reason: new InvalidRequestReason({ message }), + }) + +export const matchToolChoice = ( + route: string, + toolChoice: NonNullable, + cases: { + readonly auto: () => Auto + readonly none: () => None + readonly required: () => Required + readonly tool: (name: string) => Tool + }, +) => + Effect.gen(function* () { + if (toolChoice.type === "auto") return cases.auto() + if (toolChoice.type === "none") return cases.none() + if (toolChoice.type === "required") return cases.required() + if (!toolChoice.name) return yield* invalidRequest(`${route} tool choice requires a tool name`) + return cases.tool(toolChoice.name) + }) + +type ContentType = ContentPart["type"] + +const formatContentTypes = (types: ReadonlyArray) => { + if (types.length <= 1) return types[0] ?? "" + if (types.length === 2) return `${types[0]} and ${types[1]}` + return `${types.slice(0, -1).join(", ")}, and ${types.at(-1)}` +} + +export const supportsContent = ( + part: ContentPart, + types: ReadonlyArray, +): part is Extract => (types as ReadonlyArray).includes(part.type) + +export const unsupportedContent = ( + route: string, + role: LLMRequest["messages"][number]["role"], + types: ReadonlyArray, +) => invalidRequest(`${route} ${role} messages only support ${formatContentTypes(types)} content for now`) + +/** + * Build a `validate` step from a Schema decoder. Replaces the per-route + * lambda body `(payload) => decode(payload).pipe(Effect.mapError((e) => + * invalid(e.message)))`. Any decode error is translated into + * `LLMError` carrying the original parse-error message. + */ +export const validateWith = + (decode: (input: I) => Effect.Effect) => + (payload: I) => + decode(payload).pipe(Effect.mapError((error) => invalidRequest(error.message))) + +/** + * Build an HTTP POST with a JSON body. Sets `content-type: application/json` + * automatically after caller-supplied headers so routes cannot accidentally + * send JSON with a stale content type. The body is passed pre-encoded so + * routes can choose between + * `Schema.encodeSync(payload)` and `ProviderShared.encodeJson(payload)`. + */ +export const jsonPost = (input: { readonly url: string; readonly body: string; readonly headers?: Headers.Input }) => + HttpClientRequest.post(input.url).pipe( + HttpClientRequest.setHeaders(Headers.set(Headers.fromInput(input.headers), "content-type", "application/json")), + HttpClientRequest.bodyText(input.body, "application/json"), + ) + +export * as ProviderShared from "./shared" diff --git a/packages/llm/src/protocols/utils/bedrock-auth.ts b/packages/llm/src/protocols/utils/bedrock-auth.ts new file mode 100644 index 0000000000000000000000000000000000000000..37cc451256d30e1c9b8872675fb0aabee01b40d0 --- /dev/null +++ b/packages/llm/src/protocols/utils/bedrock-auth.ts @@ -0,0 +1,70 @@ +import { AwsV4Signer } from "aws4fetch" +import { Effect } from "effect" +import { Headers } from "effect/unstable/http" +import { Auth, type AuthInput } from "../../route/auth" +import { ProviderShared } from "../shared" + +/** + * AWS credentials for SigV4 signing. Bedrock also supports Bearer API key auth, + * which provider facades configure as route auth instead of SigV4. STS-vended + * credentials should be refreshed by the consumer (rebuild the model) before + * they expire; the route does not refresh. + */ +export interface Credentials { + readonly region: string + readonly accessKeyId: string + readonly secretAccessKey: string + readonly sessionToken?: string +} + +const signRequest = (input: { + readonly url: string + readonly body: string + readonly headers: Headers.Headers + readonly credentials: Credentials +}) => + Effect.tryPromise({ + try: async () => { + const signed = await new AwsV4Signer({ + url: input.url, + method: "POST", + headers: Object.entries(input.headers), + body: input.body, + region: input.credentials.region, + accessKeyId: input.credentials.accessKeyId, + secretAccessKey: input.credentials.secretAccessKey, + sessionToken: input.credentials.sessionToken, + service: "bedrock", + }).sign() + return Object.fromEntries(signed.headers.entries()) + }, + catch: (error) => + ProviderShared.invalidRequest( + `Bedrock Converse SigV4 signing failed: ${error instanceof Error ? error.message : String(error)}`, + ), + }) + +/** Sign the exact JSON bytes with SigV4 using credentials configured on the route. */ +export const sigV4 = (credentials: Credentials | undefined) => + Auth.custom((input: AuthInput) => { + return Effect.gen(function* () { + if (!credentials) { + return yield* ProviderShared.invalidRequest( + "Bedrock Converse requires either route bearer auth or AWS credentials configured on the route", + ) + } + const headersForSigning = Headers.set(input.headers, "content-type", "application/json") + const signed = yield* signRequest({ + url: input.url, + body: input.body, + headers: headersForSigning, + credentials, + }) + return Headers.setAll(headersForSigning, signed) + }) + }) + +/** Bedrock route auth defaults to SigV4 and expects credentials from route configuration. */ +export const auth = sigV4(undefined) + +export * as BedrockAuth from "./bedrock-auth" diff --git a/packages/llm/src/protocols/utils/bedrock-cache.ts b/packages/llm/src/protocols/utils/bedrock-cache.ts new file mode 100644 index 0000000000000000000000000000000000000000..fab4d07b5c4a435b1bc21fefebb4e4868e6a000c --- /dev/null +++ b/packages/llm/src/protocols/utils/bedrock-cache.ts @@ -0,0 +1,37 @@ +import { Schema } from "effect" +import type { CacheHint } from "../../schema" +import { newBreakpoints, ttlBucket, type Breakpoints } from "./cache" + +// Bedrock cache markers are positional: emit a `cachePoint` block immediately +// after the content the caller wants treated as a cacheable prefix. Bedrock +// accepts optional `ttl: "5m" | "1h"` on cachePoint, mirroring Anthropic. +export const CachePointBlock = Schema.Struct({ + cachePoint: Schema.Struct({ + type: Schema.tag("default"), + ttl: Schema.optional(Schema.Literals(["5m", "1h"])), + }), +}) +export type CachePointBlock = Schema.Schema.Type + +// Bedrock-Claude enforces the same 4-breakpoint cap as the Anthropic Messages +// API. Callers pass a shared counter through every `block()` call site so the +// budget is respected across `system`, `messages`, and `tools`. +export const BEDROCK_BREAKPOINT_CAP = 4 + +export type { Breakpoints } from "./cache" +export const breakpoints = () => newBreakpoints(BEDROCK_BREAKPOINT_CAP) + +const DEFAULT_5M: CachePointBlock = { cachePoint: { type: "default" } } +const DEFAULT_1H: CachePointBlock = { cachePoint: { type: "default", ttl: "1h" } } + +export const block = (breakpoints: Breakpoints, cache: CacheHint | undefined): CachePointBlock | undefined => { + if (cache?.type !== "ephemeral" && cache?.type !== "persistent") return undefined + if (breakpoints.remaining <= 0) { + breakpoints.dropped += 1 + return undefined + } + breakpoints.remaining -= 1 + return ttlBucket(cache.ttlSeconds) === "1h" ? DEFAULT_1H : DEFAULT_5M +} + +export * as BedrockCache from "./bedrock-cache" diff --git a/packages/llm/src/protocols/utils/bedrock-media.ts b/packages/llm/src/protocols/utils/bedrock-media.ts new file mode 100644 index 0000000000000000000000000000000000000000..6fda6c4fbb4351684565bbba1e097ab1ee25d498 --- /dev/null +++ b/packages/llm/src/protocols/utils/bedrock-media.ts @@ -0,0 +1,90 @@ +import { Effect, Schema } from "effect" +import type { MediaPart } from "../../schema" +import { ProviderShared } from "../shared" + +// Bedrock Converse accepts image `format` as the file extension and +// `source.bytes` as base64 in the JSON wire format. +export const ImageFormat = Schema.Literals(["png", "jpeg", "gif", "webp"]) +export type ImageFormat = Schema.Schema.Type + +export const ImageBlock = Schema.Struct({ + image: Schema.Struct({ + format: ImageFormat, + source: Schema.Struct({ bytes: Schema.String }), + }), +}) +export type ImageBlock = Schema.Schema.Type + +// Bedrock document blocks require a user-facing name so the model can refer to +// the uploaded document. +export const DocumentFormat = Schema.Literals(["pdf", "csv", "doc", "docx", "xls", "xlsx", "html", "txt", "md"]) +export type DocumentFormat = Schema.Schema.Type + +export const DocumentBlock = Schema.Struct({ + document: Schema.Struct({ + format: DocumentFormat, + name: Schema.String, + source: Schema.Struct({ bytes: Schema.String }), + }), +}) +export type DocumentBlock = Schema.Schema.Type + +const IMAGE_FORMATS = { + "image/png": "png", + "image/jpeg": "jpeg", + "image/jpg": "jpeg", + "image/gif": "gif", + "image/webp": "webp", +} as const satisfies Record + +const DOCUMENT_FORMATS = { + "application/pdf": "pdf", + "text/csv": "csv", + "application/msword": "doc", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document": "docx", + "application/vnd.ms-excel": "xls", + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": "xlsx", + "text/html": "html", + "text/plain": "txt", + "text/markdown": "md", +} as const satisfies Record + +const documentBlock = (part: MediaPart, format: DocumentFormat, bytes: string): DocumentBlock => ({ + document: { + format, + name: part.filename ?? `document.${format}`, + source: { bytes }, + }, +}) + +// Route by MIME. Known image/document formats lower into a typed block; anything +// else fails with a clear error instead of silently degrading to a malformed +// document block. Image MIME types not in `IMAGE_FORMATS` (e.g. `image/svg+xml`) +// get an image-specific error so the caller knows it's a format-support issue, +// not a kind-detection issue. +export const lower = Effect.fn("BedrockMedia.lower")(function* (part: MediaPart) { + const mime = part.mediaType.toLowerCase() + const imageFormat = IMAGE_FORMATS[mime as keyof typeof IMAGE_FORMATS] + if (imageFormat) { + const media = yield* ProviderShared.validateMedia( + "Bedrock Converse", + part, + new Set(Object.keys(IMAGE_FORMATS)), + ) + return { image: { format: imageFormat, source: { bytes: media.base64 } } } satisfies ImageBlock + } + if (mime.startsWith("image/")) + return yield* ProviderShared.invalidRequest(`Bedrock Converse does not support image media type ${part.mediaType}`) + const documentFormat = DOCUMENT_FORMATS[mime as keyof typeof DOCUMENT_FORMATS] + if (documentFormat) { + const media = yield* ProviderShared.validateMedia( + "Bedrock Converse", + part, + new Set(Object.keys(DOCUMENT_FORMATS)), + ) + return documentBlock(part, documentFormat, media.base64) + } + return yield* ProviderShared.invalidRequest(`Bedrock Converse does not support media type ${part.mediaType}`) +}) + +export * as BedrockMedia from "./bedrock-media" diff --git a/packages/llm/src/protocols/utils/cache.ts b/packages/llm/src/protocols/utils/cache.ts new file mode 100644 index 0000000000000000000000000000000000000000..dd3e213e0eecf08c5f8fe0667ba2aeaec719f43d --- /dev/null +++ b/packages/llm/src/protocols/utils/cache.ts @@ -0,0 +1,16 @@ +// Shared helpers for provider cache-marker lowering. Anthropic and Bedrock +// both enforce a 4-breakpoint cap per request and accept the same `5m`/`1h` +// TTL buckets, so the counter and TTL mapping live here. + +export interface Breakpoints { + remaining: number + dropped: number +} + +export const newBreakpoints = (cap: number): Breakpoints => ({ remaining: cap, dropped: 0 }) + +// Returns `"1h"` for any `ttlSeconds >= 3600`, otherwise `undefined` (the +// provider default 5m). Anthropic & Bedrock both treat anything shorter than +// an hour as 5m. +export const ttlBucket = (ttlSeconds: number | undefined): "1h" | undefined => + ttlSeconds !== undefined && ttlSeconds >= 3600 ? "1h" : undefined diff --git a/packages/llm/src/protocols/utils/gemini-tool-schema.ts b/packages/llm/src/protocols/utils/gemini-tool-schema.ts new file mode 100644 index 0000000000000000000000000000000000000000..efdbe3f6ec65cdfa82b0a2e2c02ca6ea5e810197 --- /dev/null +++ b/packages/llm/src/protocols/utils/gemini-tool-schema.ts @@ -0,0 +1,99 @@ +import { isRecord } from "../../utils/record" + +// Gemini accepts a JSON Schema-like dialect for tool parameters, but rejects a +// handful of common JSON Schema shapes. Keep this projection isolated so the +// Gemini protocol file still reads like the other protocol modules. +const SCHEMA_INTENT_KEYS = [ + "type", + "properties", + "items", + "prefixItems", + "enum", + "const", + "$ref", + "additionalProperties", + "patternProperties", + "required", + "not", + "if", + "then", + "else", +] + +const hasCombiner = (schema: unknown) => + isRecord(schema) && (Array.isArray(schema.anyOf) || Array.isArray(schema.oneOf) || Array.isArray(schema.allOf)) + +const hasSchemaIntent = (schema: unknown) => + isRecord(schema) && (hasCombiner(schema) || SCHEMA_INTENT_KEYS.some((key) => key in schema)) + +const sanitizeNode = (schema: unknown): unknown => { + if (!isRecord(schema)) return Array.isArray(schema) ? schema.map(sanitizeNode) : schema + + const result: Record = Object.fromEntries( + Object.entries(schema).map(([key, value]) => [ + key, + key === "enum" && Array.isArray(value) ? value.map(String) : sanitizeNode(value), + ]), + ) + + if (Array.isArray(result.enum) && (result.type === "integer" || result.type === "number")) result.type = "string" + + const properties = result.properties + if (result.type === "object" && isRecord(properties) && Array.isArray(result.required)) { + result.required = result.required.filter((field) => typeof field === "string" && field in properties) + } + + if (result.type === "array" && !hasCombiner(result)) { + result.items = result.items ?? {} + if (isRecord(result.items) && !hasSchemaIntent(result.items)) result.items = { ...result.items, type: "string" } + } + + if (typeof result.type === "string" && result.type !== "object" && !hasCombiner(result)) { + delete result.properties + delete result.required + } + + return result +} + +const emptyObjectSchema = (schema: Record) => + schema.type === "object" && + (!isRecord(schema.properties) || Object.keys(schema.properties).length === 0) && + !schema.additionalProperties + +const projectNode = (schema: unknown): Record | undefined => { + if (!isRecord(schema)) return undefined + if (emptyObjectSchema(schema)) return undefined + return Object.fromEntries( + [ + ["description", schema.description], + ["required", schema.required], + ["format", schema.format], + ["type", Array.isArray(schema.type) ? schema.type.filter((type) => type !== "null")[0] : schema.type], + ["nullable", Array.isArray(schema.type) && schema.type.includes("null") ? true : undefined], + ["enum", schema.const !== undefined ? [schema.const] : schema.enum], + [ + "properties", + isRecord(schema.properties) + ? Object.fromEntries(Object.entries(schema.properties).map(([key, value]) => [key, projectNode(value)])) + : undefined, + ], + [ + "items", + Array.isArray(schema.items) + ? schema.items.map(projectNode) + : schema.items === undefined + ? undefined + : projectNode(schema.items), + ], + ["allOf", Array.isArray(schema.allOf) ? schema.allOf.map(projectNode) : undefined], + ["anyOf", Array.isArray(schema.anyOf) ? schema.anyOf.map(projectNode) : undefined], + ["oneOf", Array.isArray(schema.oneOf) ? schema.oneOf.map(projectNode) : undefined], + ["minLength", schema.minLength], + ].filter((entry) => entry[1] !== undefined), + ) +} + +export const convert = (schema: unknown) => projectNode(sanitizeNode(schema)) + +export * as GeminiToolSchema from "./gemini-tool-schema" diff --git a/packages/llm/src/protocols/utils/lifecycle.ts b/packages/llm/src/protocols/utils/lifecycle.ts new file mode 100644 index 0000000000000000000000000000000000000000..eb6c95dfbdabf0b92b697c2377d5329dbfb222d1 --- /dev/null +++ b/packages/llm/src/protocols/utils/lifecycle.ts @@ -0,0 +1,102 @@ +import { LLMEvent, type FinishReason, type ProviderMetadata, type Usage } from "../../schema" + +export interface State { + readonly stepStarted: boolean + readonly text: ReadonlySet + readonly reasoning: ReadonlySet +} + +export const initial = (): State => ({ stepStarted: false, text: new Set(), reasoning: new Set() }) + +export const stepStart = (state: State, events: LLMEvent[]): State => { + if (state.stepStarted) return state + events.push(LLMEvent.stepStart({ index: 0 })) + return { ...state, stepStarted: true } +} + +export const textDelta = (state: State, events: LLMEvent[], id: string, text: string): State => { + const stepped = stepStart(state, events) + if (stepped.text.has(id)) { + events.push(LLMEvent.textDelta({ id, text })) + return stepped + } + events.push(LLMEvent.textStart({ id }), LLMEvent.textDelta({ id, text })) + return { ...stepped, text: new Set([...stepped.text, id]) } +} + +export const reasoningStart = ( + state: State, + events: LLMEvent[], + id: string, + providerMetadata?: ProviderMetadata, +): State => { + if (state.reasoning.has(id)) return state + const stepped = stepStart(state, events) + events.push(LLMEvent.reasoningStart({ id, providerMetadata })) + return { ...stepped, reasoning: new Set([...stepped.reasoning, id]) } +} + +export const reasoningDelta = ( + state: State, + events: LLMEvent[], + id: string, + text: string, + providerMetadata?: ProviderMetadata, +): State => { + const started = reasoningStart(state, events, id, providerMetadata) + events.push(LLMEvent.reasoningDelta({ id, text })) + return started +} + +export const reasoningEnd = ( + state: State, + events: LLMEvent[], + id: string, + providerMetadata?: ProviderMetadata, +): State => { + if (!state.reasoning.has(id)) return state + const stepped = stepStart(state, events) + events.push(LLMEvent.reasoningEnd({ id, providerMetadata })) + const reasoning = new Set(stepped.reasoning) + reasoning.delete(id) + return { ...stepped, reasoning } +} + +export const textEnd = (state: State, events: LLMEvent[], id: string, providerMetadata?: ProviderMetadata): State => { + if (!state.text.has(id)) return state + const stepped = stepStart(state, events) + events.push(LLMEvent.textEnd({ id, providerMetadata })) + const text = new Set(stepped.text) + text.delete(id) + return { ...stepped, text } +} + +const closeOpenBlocks = (state: State, events: LLMEvent[]): State => { + for (const id of state.reasoning) events.push(LLMEvent.reasoningEnd({ id })) + for (const id of state.text) events.push(LLMEvent.textEnd({ id })) + return { ...state, text: new Set(), reasoning: new Set() } +} + +export const finish = ( + state: State, + events: LLMEvent[], + input: { + readonly reason: FinishReason + readonly usage?: Usage + readonly providerMetadata?: ProviderMetadata + }, +): State => { + const stepped = closeOpenBlocks(stepStart(state, events), events) + events.push( + LLMEvent.stepFinish({ + index: 0, + reason: input.reason, + usage: input.usage, + providerMetadata: input.providerMetadata, + }), + LLMEvent.finish(input), + ) + return { ...stepped, stepStarted: false } +} + +export * as Lifecycle from "./lifecycle" diff --git a/packages/llm/src/protocols/utils/openai-options.ts b/packages/llm/src/protocols/utils/openai-options.ts new file mode 100644 index 0000000000000000000000000000000000000000..51e56ae21642fa4a717c11b15177fd2922f532b8 --- /dev/null +++ b/packages/llm/src/protocols/utils/openai-options.ts @@ -0,0 +1,93 @@ +import { Schema } from "effect" +import type { LLMRequest, ReasoningEffort, TextVerbosity as TextVerbosityValue } from "../../schema" +import { ReasoningEfforts, TextVerbosity } from "../../schema" + +export const OpenAIReasoningEfforts = ReasoningEfforts.filter( + (effort): effort is Exclude => effort !== "max", +) +export type OpenAIReasoningEffort = (typeof OpenAIReasoningEfforts)[number] + +// Mirrors OpenAI's `ResponseIncludable` union from the official SDK. Keep this +// in lockstep with `openai-node/src/resources/responses/responses.ts`. +export const OpenAIResponseIncludables = [ + "file_search_call.results", + "web_search_call.results", + "web_search_call.action.sources", + "message.input_image.image_url", + "computer_call_output.output.image_url", + "code_interpreter_call.outputs", + "reasoning.encrypted_content", + "message.output_text.logprobs", +] as const +export type OpenAIResponseIncludable = (typeof OpenAIResponseIncludables)[number] +export const OpenAIServiceTiers = ["auto", "default", "flex", "priority"] as const +export type OpenAIServiceTier = (typeof OpenAIServiceTiers)[number] + +const REASONING_EFFORTS = new Set(ReasoningEfforts) +const OPENAI_REASONING_EFFORTS = new Set(OpenAIReasoningEfforts) +const TEXT_VERBOSITY = new Set(["low", "medium", "high"]) +const INCLUDABLES = new Set(OpenAIResponseIncludables) +const SERVICE_TIERS = new Set(OpenAIServiceTiers) + +export const OpenAIReasoningEffort = Schema.Literals(OpenAIReasoningEfforts) +export const OpenAITextVerbosity = TextVerbosity +export const OpenAIResponseIncludable = Schema.Literals(OpenAIResponseIncludables) +export const OpenAIServiceTier = Schema.Literals(OpenAIServiceTiers) + +const isAnyReasoningEffort = (effort: unknown): effort is ReasoningEffort => + typeof effort === "string" && REASONING_EFFORTS.has(effort) + +export const isReasoningEffort = (effort: unknown): effort is OpenAIReasoningEffort => + typeof effort === "string" && OPENAI_REASONING_EFFORTS.has(effort) + +const isTextVerbosity = (value: unknown): value is TextVerbosityValue => + typeof value === "string" && TEXT_VERBOSITY.has(value) + +const options = (request: LLMRequest) => request.providerOptions?.openai + +export const store = (request: LLMRequest): boolean | undefined => { + const value = options(request)?.store + return typeof value === "boolean" ? value : undefined +} + +export const reasoningEffort = (request: LLMRequest): ReasoningEffort | undefined => { + const value = options(request)?.reasoningEffort + return isAnyReasoningEffort(value) ? value : undefined +} + +export const reasoningSummary = (request: LLMRequest): "auto" | undefined => + options(request)?.reasoningSummary === "auto" ? "auto" : undefined + +// Resolve the OpenAI Responses `include` field. Filters out unknown +// includable values defensively so a typo in upstream config drops the +// invalid entry instead of poisoning the wire body. An empty array (either +// passed directly or produced by filtering) is treated as "no include" and +// returns undefined so the request body omits the field entirely. +export const include = (request: LLMRequest): ReadonlyArray | undefined => { + const value = options(request)?.include + if (!Array.isArray(value)) return undefined + const filtered = value.filter((entry): entry is OpenAIResponseIncludable => INCLUDABLES.has(entry)) + return filtered.length > 0 ? filtered : undefined +} + +export const promptCacheKey = (request: LLMRequest) => { + const value = options(request)?.promptCacheKey + return typeof value === "string" ? value : undefined +} + +export const textVerbosity = (request: LLMRequest) => { + const value = options(request)?.textVerbosity + return isTextVerbosity(value) ? value : undefined +} + +export const serviceTier = (request: LLMRequest) => { + const value = options(request)?.serviceTier + return typeof value === "string" && SERVICE_TIERS.has(value) ? (value as OpenAIServiceTier) : undefined +} + +export const instructions = (request: LLMRequest) => { + const value = options(request)?.instructions + return typeof value === "string" ? value : undefined +} + +export * as OpenAIOptions from "./openai-options" diff --git a/packages/llm/src/protocols/utils/tool-schema.ts b/packages/llm/src/protocols/utils/tool-schema.ts new file mode 100644 index 0000000000000000000000000000000000000000..3a311eb34ce9ca72e0d801c58bb854161c3258e9 --- /dev/null +++ b/packages/llm/src/protocols/utils/tool-schema.ts @@ -0,0 +1,86 @@ +import type { JsonSchema, ModelToolSchemaCompatibility } from "../../schema" +import { isRecord } from "../../utils/record" +import { GeminiToolSchema } from "./gemini-tool-schema" + +const removeNullSchemas = (value: unknown): unknown => { + if (Array.isArray(value)) return value.map(removeNullSchemas) + if (!isRecord(value)) return value + const fields = Object.fromEntries( + Object.entries(value) + .filter(([key]) => key !== "anyOf") + .map(([key, field]) => [key, removeNullSchemas(field)]), + ) + if (!Array.isArray(value.anyOf)) return fields + const variants = value.anyOf.filter((variant) => !isRecord(variant) || variant.type !== "null").map(removeNullSchemas) + if (variants.length === 1 && isRecord(variants[0])) return { ...fields, ...variants[0] } + return { ...fields, anyOf: variants } +} + +const tupleItemsSchema = (items: ReadonlyArray) => { + const projected = items.map(moonshotNode) + if (projected.length === 0) return {} + if (projected.length === 1) return projected[0] + return { anyOf: projected } +} + +const moonshotNode = (schema: unknown): unknown => { + if (Array.isArray(schema)) return schema.map(moonshotNode) + if (!isRecord(schema)) return schema + if (typeof schema.$ref === "string") return { $ref: schema.$ref } + return Object.fromEntries( + Object.entries(schema).flatMap(([key, value]) => { + if (key === "items" && Array.isArray(value)) return [[key, tupleItemsSchema(value)]] + if (key === "prefixItems") { + if ("items" in schema) return [] + return [["items", tupleItemsSchema(Array.isArray(value) ? value : [])]] + } + if (key === "unevaluatedItems") return [] + return [[key, moonshotNode(value)]] + }), + ) +} + +const moonshot = (schema: JsonSchema): JsonSchema => { + const projected = moonshotNode(schema) + return isRecord(projected) ? projected : {} +} + +const openAI = (schema: JsonSchema): JsonSchema => { + const variants = Array.isArray(schema.anyOf) ? schema.anyOf.filter(isRecord) : [] + const flattened = + variants.length === 0 + ? { ...schema, type: "object" } + : { + ...Object.fromEntries(Object.entries(schema).filter(([key]) => key !== "anyOf")), + type: "object", + properties: variants.reduce( + (properties, variant) => ({ ...(isRecord(variant.properties) ? variant.properties : {}), ...properties }), + {}, + ), + additionalProperties: false, + } + const normalized = removeNullSchemas(flattened) + return isRecord(normalized) ? normalized : { type: "object" } +} + +const gemini = (schema: JsonSchema): JsonSchema => GeminiToolSchema.convert(schema) ?? {} + +const modelCompatibility = ( + schema: JsonSchema, + compatibility: ModelToolSchemaCompatibility | undefined, +): JsonSchema => { + if (compatibility === undefined) return schema + switch (compatibility) { + case "gemini": + return gemini(schema) + case "moonshot": + return moonshot(schema) + } +} + +export const ToolSchemaProjection = { + gemini, + modelCompatibility, + moonshot, + openAI, +} as const diff --git a/packages/llm/src/protocols/utils/tool-stream.ts b/packages/llm/src/protocols/utils/tool-stream.ts new file mode 100644 index 0000000000000000000000000000000000000000..8e07a64bfed843d2c0fcca5b6651de5ef09d23a7 --- /dev/null +++ b/packages/llm/src/protocols/utils/tool-stream.ts @@ -0,0 +1,218 @@ +import { Effect } from "effect" +import { LLMError, LLMEvent, type ProviderMetadata, type ToolCall } from "../../schema" +import { eventError, parseToolInput, type ToolAccumulator } from "../shared" + +type StreamKey = string | number + +/** + * One pending streamed tool call. Providers emit the tool identity and JSON + * argument text across separate chunks; `input` is the raw JSON string collected + * so far, not the parsed object. + */ +export interface PendingTool extends ToolAccumulator { + readonly providerExecuted?: boolean + readonly providerMetadata?: ProviderMetadata +} + +/** + * Sparse parser state keyed by the provider's stream-local tool identifier. + * + * This key is not the final tool-call id (`call_...`). It is the id/index the + * provider uses while streaming a partial call: OpenAI Chat / Anthropic / + * Bedrock use numeric content indexes, while OpenAI Responses uses string + * `item_id`s. The generic keeps each protocol internally consistent. + */ +export type State = Partial> + +/** + * Result of adding argument text to one pending tool call. It returns both the + * next `tools` state and the updated `tool` because parsers often need the + * current id/name immediately. `events` contains lifecycle and delta events + * produced by the append; metadata-only deltas update identity without output. + */ +export interface AppendOutcome { + readonly tools: State + readonly tool: PendingTool + readonly events: ReadonlyArray +} + +/** Create empty accumulator state for one provider stream. */ +export const empty = (): State => ({}) + +const withTool = (tools: State, key: K, tool: PendingTool): State => { + return { ...tools, [key]: tool } +} + +const withoutTool = (tools: State, key: K): State => { + const next = { ...tools } + delete next[key] + return next +} + +const inputStart = (tool: PendingTool) => + LLMEvent.toolInputStart({ + id: tool.id, + name: tool.name, + providerMetadata: tool.providerMetadata, + }) + +const inputDelta = (tool: PendingTool, text: string) => + LLMEvent.toolInputDelta({ + id: tool.id, + name: tool.name, + text, + }) + +const toolCall = (route: string, tool: PendingTool, inputOverride?: string) => + parseToolInput(route, tool.name, inputOverride ?? tool.input).pipe( + Effect.map( + (input): ToolCall => + LLMEvent.toolCall({ + id: tool.id, + name: tool.name, + input, + providerExecuted: tool.providerExecuted ? true : undefined, + providerMetadata: tool.providerMetadata, + }), + ), + ) + +/** Store the updated tool and produce the optional public delta event. */ +const appendTool = ( + tools: State, + key: K, + tool: PendingTool, + text: string, +): AppendOutcome => { + const events: LLMEvent[] = [] + if (!tools[key]) events.push(inputStart(tool)) + if (text.length > 0) events.push(inputDelta(tool, text)) + return { + tools: withTool(tools, key, tool), + tool, + events, + } +} + +export const isError = (result: AppendOutcome | LLMError): result is LLMError => + result instanceof LLMError + +/** + * Register a tool call whose start event arrived before any argument deltas. + * Used by Anthropic `content_block_start`, Bedrock `contentBlockStart`, and + * OpenAI Responses `response.output_item.added`. + */ +export const start = ( + tools: State, + key: K, + tool: Omit & { readonly input?: string }, +) => withTool(tools, key, { ...tool, input: tool.input ?? "" }) + +/** + * Append a streamed argument delta, starting the tool if this provider encodes + * identity on the first delta instead of a separate start event. OpenAI Chat has + * this shape: `tool_calls[].index` is the stream key, and `id` / `name` may only + * appear on the first delta for that index. + */ +export const appendOrStart = ( + route: string, + tools: State, + key: K, + delta: { readonly id?: string; readonly name?: string; readonly text: string }, + missingToolMessage: string, +): AppendOutcome | LLMError => { + const current = tools[key] + const id = delta.id ?? current?.id + const name = delta.name ?? current?.name + if (!id || !name) return eventError(route, missingToolMessage) + + const tool = { + id, + name, + input: `${current?.input ?? ""}${delta.text}`, + providerExecuted: current?.providerExecuted, + providerMetadata: current?.providerMetadata, + } + if (current && delta.text.length === 0 && current.id === id && current.name === name) + return { tools, tool: current, events: [] } + return appendTool(tools, key, tool, delta.text) +} + +/** + * Append argument text to a tool that must already have been started. This keeps + * protocols honest when their stream grammar promises a start event before any + * argument delta. + */ +export const appendExisting = ( + route: string, + tools: State, + key: K, + text: string, + missingToolMessage: string, +): AppendOutcome | LLMError => { + const current = tools[key] + if (!current) return eventError(route, missingToolMessage) + if (text.length === 0) return { tools, tool: current, events: [] } + return appendTool(tools, key, { ...current, input: `${current.input}${text}` }, text) +} + +/** + * Finalize one pending tool call: parse the accumulated raw JSON, remove it + * from state, and return the optional public `tool-call` event. Missing keys are + * a no-op because some providers emit stop events for non-tool content blocks. + */ +export const finish = (route: string, tools: State, key: K) => + Effect.gen(function* () { + const tool = tools[key] + if (!tool) return { tools } + return { + tools: withoutTool(tools, key), + events: [ + LLMEvent.toolInputEnd({ id: tool.id, name: tool.name, providerMetadata: tool.providerMetadata }), + yield* toolCall(route, tool), + ], + } + }) + +/** + * Finalize one pending tool call with an authoritative final input string. + * OpenAI Responses can send accumulated deltas and then repeat the completed + * arguments on `response.output_item.done`; the final value wins. + */ +export const finishWithInput = (route: string, tools: State, key: K, input: string) => + Effect.gen(function* () { + const tool = tools[key] + if (!tool) return { tools } + return { + tools: withoutTool(tools, key), + events: [ + LLMEvent.toolInputEnd({ id: tool.id, name: tool.name, providerMetadata: tool.providerMetadata }), + yield* toolCall(route, tool, input), + ], + } + }) + +/** + * Finalize every pending tool call at once. OpenAI Chat has this shape: it does + * not emit per-tool stop events, so all accumulated calls finish when the choice + * receives a terminal `finish_reason`. + */ +export const finishAll = (route: string, tools: State) => + Effect.gen(function* () { + const pending = Object.values(tools).filter( + (tool): tool is PendingTool => tool !== undefined, + ) + return { + tools: empty(), + events: yield* Effect.forEach(pending, (tool) => + toolCall(route, tool).pipe( + Effect.map((call) => [ + LLMEvent.toolInputEnd({ id: tool.id, name: tool.name, providerMetadata: tool.providerMetadata }), + call, + ]), + ), + ).pipe(Effect.map((events) => events.flat())), + } + }) + +export * as ToolStream from "./tool-stream" diff --git a/packages/llm/src/provider-error.ts b/packages/llm/src/provider-error.ts new file mode 100644 index 0000000000000000000000000000000000000000..f8b8a5c013e1d307475870a1a93b27c4facb2d8e --- /dev/null +++ b/packages/llm/src/provider-error.ts @@ -0,0 +1,43 @@ +import { Schema } from "effect" +import { LLMError, ProviderErrorEvent } from "./schema" + +const patterns = [ + /prompt is too long/i, + /request_too_large/i, + /input is too long for requested model/i, + /exceeds the context window/i, + /exceeds (?:the )?(?:model'?s )?maximum context length(?: of [\d,]+ tokens?|\s*\([\d,]+\))/i, + /input token count.*exceeds the maximum/i, + /tokens in request more than max tokens allowed/i, + /maximum prompt length is \d+/i, + /reduce the length of the messages/i, + /maximum context length is \d+ tokens/i, + /exceeds (?:the )?maximum allowed input length of [\d,]+ tokens?/i, + /input \(\d+ tokens\) is longer than the model'?s context length \(\d+ tokens\)/i, + /exceeds the limit of \d+/i, + /exceeds the available context size/i, + /greater than the context length/i, + /context window exceeds limit/i, + /exceeded model token limit/i, + /context[_ ]length[_ ]exceeded/i, + /request entity too large/i, + /context length is only \d+ tokens/i, + /input length.*exceeds.*context length/i, + /prompt too long; exceeded (?:max )?context length/i, + /too large for model with \d+ maximum context length/i, + /prompt has [\d,]+ tokens?, but the configured context size is [\d,]+ tokens?/i, + /model_context_window_exceeded/i, + /too many tokens/i, + /token limit exceeded/i, +] + +const exclusions = [/^(throttling error|service unavailable):/i, /rate limit/i, /too many requests/i] + +export const isContextOverflow = (message: string) => + !exclusions.some((pattern) => pattern.test(message)) && + (patterns.some((pattern) => pattern.test(message)) || /^4(00|13)\s*(status code)?\s*\(no body\)/i.test(message)) + +export const isContextOverflowFailure = (failure: unknown) => + failure instanceof LLMError + ? failure.reason._tag === "InvalidRequest" && failure.reason.classification === "context-overflow" + : Schema.is(ProviderErrorEvent)(failure) && failure.classification === "context-overflow" diff --git a/packages/llm/src/provider.ts b/packages/llm/src/provider.ts new file mode 100644 index 0000000000000000000000000000000000000000..c0406f3da02d611523142e39b4949118c1c8bd6d --- /dev/null +++ b/packages/llm/src/provider.ts @@ -0,0 +1,36 @@ +import type { Model, ModelID, ProviderID } from "./schema" + +export type ModelOptions = Pick + +/** + * Advanced structural provider definition helper. Built-in providers should + * prefer explicit `configure(options).model(id)` facades so deployment config is + * chosen before model selection. The optional `apis` map remains for external + * structural providers that expose multiple route selectors behind one provider. + */ +export type ModelFactory = ( + id: string | ModelID, + options?: Options, +) => Model + +type AnyModelFactory = (...args: never[]) => Model + +export interface Definition { + readonly id: ProviderID + readonly model: Factory + readonly apis?: Record +} + +type DefinitionShape = { + readonly id: ProviderID + readonly model: (...args: never[]) => Model + readonly apis?: Record Model> +} + +type NoExtraFields = Input & Record, never> + +export const make = ( + definition: NoExtraFields, +) => definition + +export * as Provider from "./provider" diff --git a/packages/llm/src/providers/amazon-bedrock.ts b/packages/llm/src/providers/amazon-bedrock.ts new file mode 100644 index 0000000000000000000000000000000000000000..2f1791e0d620ab139e32158526e83229c464fa3f --- /dev/null +++ b/packages/llm/src/providers/amazon-bedrock.ts @@ -0,0 +1,43 @@ +import type { RouteDefaultsInput } from "../route/client" +import { Auth } from "../route/auth" +import { ProviderID, type ModelID } from "../schema" +import * as BedrockConverse from "../protocols/bedrock-converse" +import type { BedrockCredentials } from "../protocols/bedrock-converse" + +export const id = ProviderID.make("amazon-bedrock") + +export type Config = RouteDefaultsInput & { + readonly apiKey?: string + readonly headers?: Record + readonly credentials?: BedrockCredentials + /** AWS region. Defaults to `us-east-1` when neither this nor `credentials.region` is set. */ + readonly region?: string + /** Override the computed `https://bedrock-runtime..amazonaws.com` URL. */ + readonly baseURL?: string +} +export const routes = [BedrockConverse.route] + +const bedrockBaseURL = (region: string) => `https://bedrock-runtime.${region}.amazonaws.com` + +const configuredRoute = (input: Config) => { + const { apiKey, credentials, region, baseURL, ...rest } = input + const resolvedRegion = region ?? credentials?.region ?? "us-east-1" + return BedrockConverse.route.with({ + ...rest, + provider: id, + endpoint: { baseURL: baseURL ?? bedrockBaseURL(resolvedRegion) }, + auth: apiKey === undefined ? BedrockConverse.sigV4Auth(credentials) : Auth.bearer(apiKey), + }) +} + +export const configure = (input: Config = {}) => { + const route = configuredRoute(input) + return { + id, + model: (modelID: string | ModelID) => route.model({ id: modelID }), + configure, + } +} + +export const provider = configure() +export const model = provider.model diff --git a/packages/llm/src/providers/anthropic.ts b/packages/llm/src/providers/anthropic.ts new file mode 100644 index 0000000000000000000000000000000000000000..0c9640af5ea65bda1c50f73e5f8fdf3b35922841 --- /dev/null +++ b/packages/llm/src/providers/anthropic.ts @@ -0,0 +1,35 @@ +import type { RouteDefaultsInput } from "../route/client" +import { Auth } from "../route/auth" +import type { ProviderAuthOption } from "../route/auth-options" +import { ProviderID, type ModelID } from "../schema" +import * as AnthropicMessages from "../protocols/anthropic-messages" + +export const id = ProviderID.make("anthropic") + +export const routes = [AnthropicMessages.route] + +export type Config = RouteDefaultsInput & ProviderAuthOption<"optional"> & { readonly baseURL?: string } + +const auth = (options: ProviderAuthOption<"optional">) => { + if ("auth" in options && options.auth) return options.auth + return Auth.optional("apiKey" in options ? options.apiKey : undefined, "apiKey") + .orElse(Auth.config("ANTHROPIC_API_KEY")) + .pipe(Auth.header("x-api-key")) +} + +const configuredRoute = (input: Config) => { + const { apiKey: _, auth: _auth, baseURL, ...rest } = input + return AnthropicMessages.route.with({ ...rest, endpoint: { baseURL }, auth: auth(input) }) +} + +export const configure = (input: Config = {}) => { + const route = configuredRoute(input) + return { + id, + model: (modelID: string | ModelID) => route.model({ id: modelID }), + configure, + } +} + +export const provider = configure() +export const model = provider.model diff --git a/packages/llm/src/providers/azure.ts b/packages/llm/src/providers/azure.ts new file mode 100644 index 0000000000000000000000000000000000000000..bfac2d1cad34a680b62b3047e440988e112cd483 --- /dev/null +++ b/packages/llm/src/providers/azure.ts @@ -0,0 +1,110 @@ +import { Auth } from "../route/auth" +import { type AtLeastOne, type ProviderAuthOption } from "../route/auth-options" +import type { Route as RouteDef, RouteDefaultsInput } from "../route/client" +import { ProviderID, type ModelID } from "../schema" +import * as OpenAIChat from "../protocols/openai-chat" +import * as OpenAIResponses from "../protocols/openai-responses" +import { withOpenAIOptions, type OpenAIProviderOptionsInput } from "./openai-options" + +export const id = ProviderID.make("azure") +const routeAuth = Auth.remove("authorization") + +// Azure needs the customer's resource URL; supply either `resourceName` +// (helper builds the URL) or `baseURL` directly. +type AzureURL = AtLeastOne<{ readonly resourceName: string; readonly baseURL: string }> + +export type ModelOptions = AzureURL & + RouteDefaultsInput & + ProviderAuthOption<"optional"> & { + readonly apiVersion?: string + readonly queryParams?: Record + readonly useCompletionUrls?: boolean + readonly providerOptions?: OpenAIProviderOptionsInput + } +export type Config = ModelOptions + +const resourceBaseURL = (resourceName: string) => `https://${resourceName.trim()}.openai.azure.com/openai/v1` + +const responsesRoute = OpenAIResponses.route.with({ + id: "azure-openai-responses", + provider: id, + auth: routeAuth, + endpoint: { + query: { "api-version": "v1" }, + }, +}) + +const chatRoute = OpenAIChat.route.with({ + id: "azure-openai-chat", + provider: id, + auth: routeAuth, + endpoint: { + query: { "api-version": "v1" }, + }, +}) + +export const routes = [responsesRoute, chatRoute] + +const defaults = (input: Config) => { + const { + apiKey: _, + apiVersion: _apiVersion, + resourceName: _resourceName, + useCompletionUrls: _useCompletionUrls, + baseURL: _baseURL, + queryParams: _queryParams, + ...rest + } = input + if ("auth" in rest) { + const { auth: _, ...withoutAuth } = rest + return withoutAuth + } + return rest +} + +const auth = (input: Config) => { + if ("auth" in input && input.auth) return input.auth + return Auth.remove("authorization").andThen( + Auth.optional("apiKey" in input ? input.apiKey : undefined, "apiKey") + .orElse(Auth.config("AZURE_OPENAI_API_KEY")) + .pipe(Auth.header("api-key")), + ) +} + +const configuredRoute = (route: RouteDef, input: Config) => + route.with({ + auth: auth(input), + endpoint: { + // AtLeastOne guarantees at least one is set; baseURL wins if both are. + baseURL: input.baseURL ?? resourceBaseURL(input.resourceName!), + query: { + ...(input.apiVersion ? { "api-version": input.apiVersion } : {}), + ...input.queryParams, + }, + }, + }) + +export const configure = (input: Config) => { + const configuredResponsesRoute = configuredRoute(responsesRoute, input) + const configuredChatRoute = configuredRoute(chatRoute, input) + const modelDefaults = defaults(input) + + const responses = (modelID: string | ModelID) => + configuredResponsesRoute.with(withOpenAIOptions(modelID, modelDefaults)).model({ id: modelID }) + + const chat = (modelID: string | ModelID) => + configuredChatRoute.with(withOpenAIOptions(modelID, modelDefaults)).model({ id: modelID }) + + return { + id, + model: (modelID: string | ModelID) => (input.useCompletionUrls === true ? chat(modelID) : responses(modelID)), + responses, + chat, + configure, + } +} + +export const provider = { + id, + configure, +} diff --git a/packages/llm/src/providers/cloudflare.ts b/packages/llm/src/providers/cloudflare.ts new file mode 100644 index 0000000000000000000000000000000000000000..a006152e9829cfc63bcd9a9ea5e27a0b2892aa04 --- /dev/null +++ b/packages/llm/src/providers/cloudflare.ts @@ -0,0 +1,127 @@ +import type { Config, Redacted } from "effect" +import * as OpenAICompatibleChat from "../protocols/openai-compatible-chat" +import { Auth } from "../route/auth" +import { AuthOptions, type AtLeastOne, type ProviderAuthOption } from "../route/auth-options" +import type { RouteDefaultsInput } from "../route/client" +import { ProviderID, type ModelID } from "../schema" + +export const aiGatewayID = ProviderID.make("cloudflare-ai-gateway") +export const workersAIID = ProviderID.make("cloudflare-workers-ai") +export const aiGatewayAuthEnvVars = ["CLOUDFLARE_API_TOKEN", "CF_AIG_TOKEN"] as const +export const workersAIAuthEnvVars = ["CLOUDFLARE_API_KEY", "CLOUDFLARE_WORKERS_AI_TOKEN"] as const + +type CloudflareSecret = string | Redacted.Redacted | Config.Config + +type GatewayURL = AtLeastOne<{ + readonly accountId: string + readonly baseURL: string +}> & { + readonly gatewayId?: string +} + +export type AIGatewayOptions = GatewayURL & + RouteDefaultsInput & + ProviderAuthOption<"optional"> & { + /** Cloudflare AI Gateway authentication token. Sent as `cf-aig-authorization`. */ + readonly gatewayApiKey?: CloudflareSecret + } + +type WorkersAIURL = AtLeastOne<{ + readonly accountId: string + readonly baseURL: string +}> + +export type WorkersAIOptions = WorkersAIURL & RouteDefaultsInput & ProviderAuthOption<"optional"> + +export const aiGatewayBaseURL = (input: GatewayURL) => { + if (input.baseURL) return input.baseURL + if (!input.accountId) throw new Error("CloudflareAIGateway.configure requires accountId unless baseURL is supplied") + return `https://gateway.ai.cloudflare.com/v1/${encodeURIComponent(input.accountId)}/${encodeURIComponent(input.gatewayId?.trim() || "default")}/compat` +} + +const aiGatewayAuth = (input: AIGatewayOptions) => { + if ("auth" in input && input.auth) return input.auth + const gateway = Auth.optional(input.gatewayApiKey, "gatewayApiKey") + .orElse(Auth.config("CLOUDFLARE_API_TOKEN")) + .orElse(Auth.config("CF_AIG_TOKEN")) + .pipe(Auth.bearerHeader("cf-aig-authorization")) + if (!("apiKey" in input) || input.apiKey === undefined) return gateway + if (input.gatewayApiKey === undefined) return Auth.bearer(input.apiKey) + return Auth.bearerHeader("cf-aig-authorization", input.gatewayApiKey).andThen(Auth.bearer(input.apiKey)) +} + +export const workersAIBaseURL = (input: WorkersAIURL) => { + if (input.baseURL) return input.baseURL + if (!input.accountId) throw new Error("CloudflareWorkersAI.configure requires accountId unless baseURL is supplied") + return `https://api.cloudflare.com/client/v4/accounts/${encodeURIComponent(input.accountId)}/ai/v1` +} + +const workersAIAuth = (input: WorkersAIOptions) => { + return AuthOptions.bearer(input, workersAIAuthEnvVars) +} + +export const aiGatewayRoute = OpenAICompatibleChat.route.with({ + id: "cloudflare-ai-gateway", + provider: aiGatewayID, +}) + +export const workersAIRoute = OpenAICompatibleChat.route.with({ + id: "cloudflare-workers-ai", + provider: workersAIID, +}) + +export const routes = [aiGatewayRoute, workersAIRoute] + +const aiGatewayDefaults = (options: AIGatewayOptions) => { + const { + accountId: _accountId, + gatewayId: _gatewayId, + apiKey: _apiKey, + gatewayApiKey: _gatewayApiKey, + baseURL: _baseURL, + auth: _auth, + ...rest + } = options + return rest +} + +const workersAIDefaults = (options: WorkersAIOptions) => { + const { accountId: _accountId, apiKey: _apiKey, auth: _auth, baseURL: _baseURL, ...rest } = options + return rest +} + +const configureAIGateway = (options: AIGatewayOptions) => { + const route = aiGatewayRoute.with({ + ...aiGatewayDefaults(options), + endpoint: { baseURL: aiGatewayBaseURL(options) }, + auth: aiGatewayAuth(options), + }) + return { + id: aiGatewayID, + model: (modelID: string | ModelID) => route.model({ id: modelID }), + configure: configureAIGateway, + } +} + +const configureWorkersAI = (options: WorkersAIOptions) => { + const route = workersAIRoute.with({ + ...workersAIDefaults(options), + endpoint: { baseURL: workersAIBaseURL(options) }, + auth: workersAIAuth(options), + }) + return { + id: workersAIID, + model: (modelID: string | ModelID) => route.model({ id: modelID }), + configure: configureWorkersAI, + } +} + +export const CloudflareAIGateway = { + id: aiGatewayID, + configure: configureAIGateway, +} + +export const CloudflareWorkersAI = { + id: workersAIID, + configure: configureWorkersAI, +} diff --git a/packages/llm/src/providers/github-copilot.ts b/packages/llm/src/providers/github-copilot.ts new file mode 100644 index 0000000000000000000000000000000000000000..cc776d3b15f4eab10df21cbd48243b647c395ba4 --- /dev/null +++ b/packages/llm/src/providers/github-copilot.ts @@ -0,0 +1,69 @@ +import { AuthOptions, type ProviderAuthOption } from "../route/auth-options" +import type { RouteDefaultsInput } from "../route/client" +import { ProviderID, type ModelID } from "../schema" +import * as OpenAIChat from "../protocols/openai-chat" +import * as OpenAIResponses from "../protocols/openai-responses" +import { withOpenAIOptions, type OpenAIProviderOptionsInput } from "./openai-options" + +export const id = ProviderID.make("github-copilot") + +// GitHub Copilot has no canonical public URL — callers (opencode, etc.) must +// supply `baseURL` explicitly. +export type ModelOptions = Omit & + ProviderAuthOption<"optional"> & { + readonly baseURL: string + readonly endpoint?: "chat" | "responses" + readonly providerOptions?: OpenAIProviderOptionsInput + } + +export const shouldUseResponsesApi = (modelID: string | ModelID, endpoint?: ModelOptions["endpoint"]) => { + if (endpoint) return endpoint === "responses" + const model = String(modelID) + const match = /^gpt-(\d+)/.exec(model) + if (!match) return false + return Number(match[1]) >= 5 && !model.startsWith("gpt-5-mini") +} + +export const routes = [OpenAIResponses.route, OpenAIChat.route] + +const chatRoute = OpenAIChat.route.with({ provider: id }) +const responsesRoute = OpenAIResponses.route.with({ provider: id }) + +const defaults = (options: ModelOptions) => { + const { apiKey: _, auth: _auth, baseURL: _baseURL, endpoint: _endpoint, ...rest } = options + return rest +} + +const configuredResponsesRoute = (options: ModelOptions) => + responsesRoute.with({ + endpoint: { baseURL: options.baseURL }, + auth: AuthOptions.bearer(options, []), + }) + +const configuredChatRoute = (options: ModelOptions) => + chatRoute.with({ + endpoint: { baseURL: options.baseURL }, + auth: AuthOptions.bearer(options, []), + }) + +export const configure = (options: ModelOptions) => { + const responsesRoute = configuredResponsesRoute(options) + const chatRoute = configuredChatRoute(options) + const responses = (modelID: string | ModelID) => + responsesRoute.with(withOpenAIOptions(modelID, defaults(options))).model({ id: modelID }) + const chat = (modelID: string | ModelID) => + chatRoute.with(withOpenAIOptions(modelID, defaults(options))).model({ id: modelID }) + return { + id, + model: (modelID: string | ModelID) => + shouldUseResponsesApi(modelID, options.endpoint) ? responses(modelID) : chat(modelID), + responses, + chat, + configure, + } +} + +export const provider = { + id, + configure, +} diff --git a/packages/llm/src/providers/google.ts b/packages/llm/src/providers/google.ts new file mode 100644 index 0000000000000000000000000000000000000000..c8a72c31f671fb0b82686c8e082d4456c8001604 --- /dev/null +++ b/packages/llm/src/providers/google.ts @@ -0,0 +1,35 @@ +import type { RouteDefaultsInput } from "../route/client" +import { Auth } from "../route/auth" +import type { ProviderAuthOption } from "../route/auth-options" +import { ProviderID, type ModelID } from "../schema" +import * as Gemini from "../protocols/gemini" + +export const id = ProviderID.make("google") + +export const routes = [Gemini.route] + +export type Config = RouteDefaultsInput & ProviderAuthOption<"optional"> & { readonly baseURL?: string } + +const auth = (options: ProviderAuthOption<"optional">) => { + if ("auth" in options && options.auth) return options.auth + return Auth.optional("apiKey" in options ? options.apiKey : undefined, "apiKey") + .orElse(Auth.config("GOOGLE_GENERATIVE_AI_API_KEY")) + .pipe(Auth.header("x-goog-api-key")) +} + +const configuredRoute = (input: Config) => { + const { apiKey: _, auth: _auth, baseURL, ...rest } = input + return Gemini.route.with({ ...rest, endpoint: { baseURL }, auth: auth(input) }) +} + +export const configure = (input: Config = {}) => { + const route = configuredRoute(input) + return { + id, + model: (modelID: string | ModelID) => route.model({ id: modelID }), + configure, + } +} + +export const provider = configure() +export const model = provider.model diff --git a/packages/llm/src/providers/index.ts b/packages/llm/src/providers/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..774274cf2d4a7dac165793b273d470d61a748b63 --- /dev/null +++ b/packages/llm/src/providers/index.ts @@ -0,0 +1,11 @@ +export * as Anthropic from "./anthropic" +export * as AmazonBedrock from "./amazon-bedrock" +export * as Azure from "./azure" +export * as Cloudflare from "./cloudflare" +export { CloudflareAIGateway, CloudflareWorkersAI } from "./cloudflare" +export * as GitHubCopilot from "./github-copilot" +export * as Google from "./google" +export * as OpenAI from "./openai" +export * as OpenAICompatible from "./openai-compatible" +export * as OpenRouter from "./openrouter" +export * as XAI from "./xai" diff --git a/packages/llm/src/providers/openai-compatible-profile.ts b/packages/llm/src/providers/openai-compatible-profile.ts new file mode 100644 index 0000000000000000000000000000000000000000..30770c9671cdc546c98c213cc24a2fe500f7574e --- /dev/null +++ b/packages/llm/src/providers/openai-compatible-profile.ts @@ -0,0 +1,20 @@ +export interface OpenAICompatibleProfile { + readonly provider: string + readonly baseURL: string +} + +export const profiles = { + baseten: { provider: "baseten", baseURL: "https://inference.baseten.co/v1" }, + cerebras: { provider: "cerebras", baseURL: "https://api.cerebras.ai/v1" }, + deepinfra: { provider: "deepinfra", baseURL: "https://api.deepinfra.com/v1/openai" }, + deepseek: { provider: "deepseek", baseURL: "https://api.deepseek.com/v1" }, + fireworks: { provider: "fireworks", baseURL: "https://api.fireworks.ai/inference/v1" }, + groq: { provider: "groq", baseURL: "https://api.groq.com/openai/v1" }, + openrouter: { provider: "openrouter", baseURL: "https://openrouter.ai/api/v1" }, + togetherai: { provider: "togetherai", baseURL: "https://api.together.xyz/v1" }, + xai: { provider: "xai", baseURL: "https://api.x.ai/v1" }, +} as const satisfies Record + +export const byProvider: Record = Object.fromEntries( + Object.values(profiles).map((profile) => [profile.provider, profile]), +) diff --git a/packages/llm/src/providers/openai-compatible.ts b/packages/llm/src/providers/openai-compatible.ts new file mode 100644 index 0000000000000000000000000000000000000000..a79f65f6dfe070c292ffe40722cf9d6a021a4ea7 --- /dev/null +++ b/packages/llm/src/providers/openai-compatible.ts @@ -0,0 +1,65 @@ +import { ProviderID, type ModelID } from "../schema" +import * as OpenAICompatibleChat from "../protocols/openai-compatible-chat" +import type { RouteDefaultsInput } from "../route/client" +import { AuthOptions, type ProviderAuthOption } from "../route/auth-options" +import { profiles, type OpenAICompatibleProfile } from "./openai-compatible-profile" + +export const id = ProviderID.make("openai-compatible") + +type GenericModelOptions = RouteDefaultsInput & + ProviderAuthOption<"optional"> & { + readonly provider?: string + readonly baseURL: string + } + +export type FamilyModelOptions = RouteDefaultsInput & + ProviderAuthOption<"optional"> & { + readonly baseURL?: string + } + +export const routes = [OpenAICompatibleChat.route] + +export const configure = (input: GenericModelOptions) => { + const provider = input.provider ?? "openai-compatible" + const { provider: _, baseURL, apiKey: _apiKey, auth: _auth, ...rest } = input + const route = OpenAICompatibleChat.route.with({ + ...rest, + provider, + endpoint: { baseURL }, + auth: AuthOptions.bearer(input, []), + }) + return { + id: ProviderID.make(provider), + model: (modelID: string | ModelID) => route.model({ id: modelID, provider: ProviderID.make(provider) }), + configure, + } +} + +const define = (profile: OpenAICompatibleProfile) => { + const configureProfile = (input: FamilyModelOptions = {}) => { + const facade = configure({ + ...input, + baseURL: input.baseURL ?? profile.baseURL, + provider: profile.provider, + }) + return { + id: ProviderID.make(profile.provider), + model: facade.model, + configure: configureProfile, + } + } + return configureProfile() +} + +export const provider = { + id, + configure, +} + +export const baseten = define(profiles.baseten) +export const cerebras = define(profiles.cerebras) +export const deepinfra = define(profiles.deepinfra) +export const deepseek = define(profiles.deepseek) +export const fireworks = define(profiles.fireworks) +export const groq = define(profiles.groq) +export const togetherai = define(profiles.togetherai) diff --git a/packages/llm/src/providers/openai-options.ts b/packages/llm/src/providers/openai-options.ts new file mode 100644 index 0000000000000000000000000000000000000000..fb548dd79726bb7eca588882b02a72008a7bc4d1 --- /dev/null +++ b/packages/llm/src/providers/openai-options.ts @@ -0,0 +1,83 @@ +import type { ProviderOptions, ReasoningEffort, TextVerbosity } from "../schema" +import { mergeProviderOptions } from "../schema" +import type { OpenAIResponseIncludable, OpenAIServiceTier } from "../protocols/utils/openai-options" + +export type { OpenAIResponseIncludable, OpenAIServiceTier } from "../protocols/utils/openai-options" + +export interface OpenAIOptionsInput { + readonly [key: string]: unknown + readonly store?: boolean + readonly promptCacheKey?: string + readonly reasoningEffort?: ReasoningEffort + readonly reasoningSummary?: "auto" + // OpenAI Responses `include` wire field. Mirrors the official SDK's + // `ResponseIncludable[]` union exactly so AI SDK callers and direct + // native-SDK callers share one shape and no translation is required. + readonly include?: ReadonlyArray + readonly textVerbosity?: TextVerbosity + readonly serviceTier?: OpenAIServiceTier +} + +export type OpenAIProviderOptionsInput = ProviderOptions & { + readonly openai?: OpenAIOptionsInput +} + +const definedEntries = (input: Record) => + Object.entries(input).filter((entry) => entry[1] !== undefined) + +const openAIProviderOptions = (options: OpenAIOptionsInput | undefined): ProviderOptions | undefined => { + const openai = Object.fromEntries( + definedEntries({ + store: options?.store, + promptCacheKey: options?.promptCacheKey, + reasoningEffort: options?.reasoningEffort, + reasoningSummary: options?.reasoningSummary, + include: options?.include, + textVerbosity: options?.textVerbosity, + serviceTier: options?.serviceTier, + }), + ) + if (Object.keys(openai).length === 0) return undefined + return { openai } +} + +export const gpt5DefaultOptions = ( + modelID: string, + options: { readonly textVerbosity?: boolean } = {}, +): ProviderOptions | undefined => { + const id = modelID.toLowerCase() + if (!id.includes("gpt-5") || id.includes("gpt-5-chat") || id.includes("gpt-5-pro")) return undefined + return openAIProviderOptions({ + reasoningEffort: "medium", + reasoningSummary: "auto", + // GPT-5 reasoning models are configured stateless (`store: false`) by + // `openAIDefaultOptions` below, so the only way a follow-up turn can + // carry reasoning state is via the encrypted reasoning include. Without + // this, callers using the default model facade get reasoning summaries + // they cannot replay statelessly. + include: ["reasoning.encrypted_content"], + textVerbosity: + options.textVerbosity === true && id.includes("gpt-5.") && !id.includes("codex") && !id.includes("-chat") + ? "low" + : undefined, + }) +} + +export const openAIDefaultOptions = ( + modelID: string, + options: { readonly textVerbosity?: boolean } = {}, +): ProviderOptions | undefined => + mergeProviderOptions(openAIProviderOptions({ store: false }), gpt5DefaultOptions(modelID, options)) + +export const withOpenAIOptions = ( + modelID: string, + options: Options, + defaults: { readonly textVerbosity?: boolean } = {}, +): Omit & { readonly providerOptions?: ProviderOptions } => { + return { + ...options, + providerOptions: mergeProviderOptions(openAIDefaultOptions(modelID, defaults), options.providerOptions), + } +} + +export * as OpenAIProviderOptions from "./openai-options" diff --git a/packages/llm/src/providers/openai.ts b/packages/llm/src/providers/openai.ts new file mode 100644 index 0000000000000000000000000000000000000000..098cad84939bc46c5d15ea2ebcdc396feb1edfeb --- /dev/null +++ b/packages/llm/src/providers/openai.ts @@ -0,0 +1,63 @@ +import { AuthOptions, type ProviderAuthOption } from "../route/auth-options" +import type { Route, RouteDefaultsInput } from "../route/client" +import { ProviderID, type ModelID } from "../schema" +import * as OpenAIChat from "../protocols/openai-chat" +import * as OpenAIResponses from "../protocols/openai-responses" +import { withOpenAIOptions, type OpenAIProviderOptionsInput } from "./openai-options" + +export type { OpenAIOptionsInput, OpenAIResponseIncludable } from "./openai-options" + +export const id = ProviderID.make("openai") + +export const routes = [OpenAIResponses.route, OpenAIResponses.webSocketRoute, OpenAIChat.route] + +// This provider facade wraps the lower-level Responses and Chat model factories +// with OpenAI-specific conveniences: typed options, API-key sugar, env fallback, +// and default option normalization. +export type Config = RouteDefaultsInput & + ProviderAuthOption<"optional"> & { + readonly baseURL?: string + readonly queryParams?: Record + readonly providerOptions?: OpenAIProviderOptionsInput + } + +const auth = (options: ProviderAuthOption<"optional">) => AuthOptions.bearer(options, "OPENAI_API_KEY") + +const defaults = (input: Config) => { + const { apiKey: _, auth: _auth, baseURL: _baseURL, queryParams: _queryParams, ...rest } = input + return rest +} + +const configuredRoute = (route: Route, input: Config) => + route.with({ + auth: auth(input), + endpoint: { baseURL: input.baseURL, query: input.queryParams }, + }) + +export const configure = (input: Config = {}) => { + const responsesRoute = configuredRoute(OpenAIResponses.route, input) + const responsesWebSocketRoute = configuredRoute(OpenAIResponses.webSocketRoute, input) + const chatRoute = configuredRoute(OpenAIChat.route, input) + const modelDefaults = defaults(input) + const responses = (id: string | ModelID) => + responsesRoute.with(withOpenAIOptions(id, modelDefaults, { textVerbosity: true })).model({ id }) + const responsesWebSocket = (id: string | ModelID) => + responsesWebSocketRoute.with(withOpenAIOptions(id, modelDefaults, { textVerbosity: true })).model({ id }) + const chat = (id: string | ModelID) => chatRoute.with(withOpenAIOptions(id, modelDefaults)).model({ id }) + + return { + id, + model: responses, + responses, + responsesWebSocket, + chat, + configure, + } +} + +export const provider = configure() + +export const model = provider.model +export const responses = provider.responses +export const responsesWebSocket = provider.responsesWebSocket +export const chat = provider.chat diff --git a/packages/llm/src/providers/openrouter.ts b/packages/llm/src/providers/openrouter.ts new file mode 100644 index 0000000000000000000000000000000000000000..914d7c0a0badbef0a6898588c497ca8c422935b5 --- /dev/null +++ b/packages/llm/src/providers/openrouter.ts @@ -0,0 +1,98 @@ +import { Effect, Schema } from "effect" +import { Route, type RouteDefaultsInput } from "../route/client" +import { Endpoint } from "../route/endpoint" +import { Framing } from "../route/framing" +import { Protocol } from "../route/protocol" +import { AuthOptions, type ProviderAuthOption } from "../route/auth-options" +import { ProviderID, type ModelID, type ProviderOptions } from "../schema" +import * as OpenAICompatibleProfiles from "./openai-compatible-profile" +import * as OpenAIChat from "../protocols/openai-chat" +import { isRecord } from "../protocols/shared" + +export const profile = OpenAICompatibleProfiles.profiles.openrouter +export const id = ProviderID.make(profile.provider) +const ADAPTER = "openrouter" + +export interface OpenRouterOptions { + readonly [key: string]: unknown + readonly usage?: boolean | Record + readonly reasoning?: Record + readonly promptCacheKey?: string +} + +export type OpenRouterProviderOptionsInput = ProviderOptions & { + readonly openrouter?: OpenRouterOptions +} + +export type ModelOptions = Omit & + ProviderAuthOption<"optional"> & { + readonly baseURL?: string + readonly providerOptions?: OpenRouterProviderOptionsInput + } + +const OpenRouterBody = Schema.StructWithRest(Schema.Struct(OpenAIChat.bodyFields), [ + Schema.Record(Schema.String, Schema.Any), +]) +export type OpenRouterBody = Schema.Schema.Type + +export const protocol = Protocol.make({ + id: "openrouter-chat", + body: { + schema: OpenRouterBody, + from: (request) => + OpenAIChat.protocol.body.from(request).pipe( + Effect.map( + (body) => + ({ + ...body, + ...bodyOptions(request.providerOptions?.openrouter), + }) as OpenRouterBody, + ), + ), + }, + stream: OpenAIChat.protocol.stream, +}) + +const bodyOptions = (input: unknown) => { + const openrouter = isRecord(input) ? input : {} + return { + ...(openrouter.usage === true + ? { usage: { include: true } } + : isRecord(openrouter.usage) + ? { usage: openrouter.usage } + : {}), + ...(isRecord(openrouter.reasoning) ? { reasoning: openrouter.reasoning } : {}), + ...(typeof openrouter.promptCacheKey === "string" ? { prompt_cache_key: openrouter.promptCacheKey } : {}), + } +} + +export const route = Route.make({ + id: ADAPTER, + provider: profile.provider, + protocol, + endpoint: Endpoint.path("/chat/completions", { baseURL: profile.baseURL }), + framing: Framing.sse, +}) + +export const routes = [route] + +const configuredRoute = (input: ModelOptions) => { + const { apiKey: _, auth: _auth, baseURL, ...rest } = input + return route.with({ + ...rest, + endpoint: { baseURL: baseURL ?? profile.baseURL }, + auth: AuthOptions.bearer(input, "OPENROUTER_API_KEY"), + }) +} + +export const configure = (input: ModelOptions = {}) => { + const route = configuredRoute(input) + return { + id, + model: (modelID: string | ModelID) => route.model({ id: modelID }), + configure, + } +} + +export const provider = configure() +export const model = provider.model diff --git a/packages/llm/src/providers/xai.ts b/packages/llm/src/providers/xai.ts new file mode 100644 index 0000000000000000000000000000000000000000..321db97db1049166f21f5aedc650894fe6f7bcd1 --- /dev/null +++ b/packages/llm/src/providers/xai.ts @@ -0,0 +1,56 @@ +import { AuthOptions, type ProviderAuthOption } from "../route/auth-options" +import type { RouteDefaultsInput } from "../route/client" +import { ProviderID, type ModelID } from "../schema" +import * as OpenAICompatibleProfiles from "./openai-compatible-profile" +import * as OpenAICompatibleChat from "../protocols/openai-compatible-chat" +import * as OpenAIResponses from "../protocols/openai-responses" + +export const id = ProviderID.make("xai") + +export type ModelOptions = RouteDefaultsInput & + ProviderAuthOption<"optional"> & { + readonly baseURL?: string + } + +export const routes = [OpenAIResponses.route, OpenAICompatibleChat.route] + +const auth = (options: ProviderAuthOption<"optional">) => AuthOptions.bearer(options, "XAI_API_KEY") + +const configuredResponsesRoute = (input: ModelOptions) => { + const { apiKey: _, auth: _auth, baseURL, ...rest } = input + return OpenAIResponses.route.with({ + ...rest, + provider: id, + endpoint: { baseURL: baseURL ?? OpenAICompatibleProfiles.profiles.xai.baseURL }, + auth: auth(input), + }) +} + +const configuredChatRoute = (input: ModelOptions) => { + const { apiKey: _, auth: _auth, baseURL, ...rest } = input + return OpenAICompatibleChat.route.with({ + ...rest, + provider: id, + endpoint: { baseURL: baseURL ?? OpenAICompatibleProfiles.profiles.xai.baseURL }, + auth: auth(input), + }) +} + +export const configure = (input: ModelOptions = {}) => { + const responsesRoute = configuredResponsesRoute(input) + const chatRoute = configuredChatRoute(input) + const responses = (modelID: string | ModelID) => responsesRoute.model({ id: modelID }) + const chat = (modelID: string | ModelID) => chatRoute.model({ id: modelID }) + return { + id, + model: responses, + responses, + chat, + configure, + } +} + +export const provider = configure() +export const model = provider.model +export const responses = provider.responses +export const chat = provider.chat diff --git a/packages/llm/src/route/auth-options.ts b/packages/llm/src/route/auth-options.ts new file mode 100644 index 0000000000000000000000000000000000000000..7e40aa12a2103369a1ee9f884fdef42e7ebb5b33 --- /dev/null +++ b/packages/llm/src/route/auth-options.ts @@ -0,0 +1,57 @@ +import type { Config, Redacted } from "effect" +import { Auth } from "./auth" + +export type ApiKeyMode = "optional" | "required" + +export type AuthOverride = { + readonly auth: Auth + readonly apiKey?: never +} + +export type OptionalApiKeyAuth = { + readonly apiKey?: string | Redacted.Redacted | Config.Config> + readonly auth?: never +} + +export type RequiredApiKeyAuth = { + readonly apiKey: string | Redacted.Redacted | Config.Config> + readonly auth?: never +} + +export type ProviderAuthOption = + | AuthOverride + | (Mode extends "optional" ? OptionalApiKeyAuth : RequiredApiKeyAuth) + +export type ModelOptions = Omit & ProviderAuthOption + +export type ModelArgs = Mode extends "optional" + ? readonly [options?: ModelOptions] + : readonly [options: ModelOptions] + +export type ModelFactory = (id: string, ...args: ModelArgs) => Model + +/** + * Require at least one of the keys in `T`. Use for option shapes where any + * subset of fields is acceptable but at least one must be present (e.g. Azure + * accepts `resourceName` or `baseURL`). + */ +export type AtLeastOne = { + [K in keyof T]: Required> & Partial> +}[keyof T] + +/** + * Standard bearer-auth resolution for providers: honor an explicit `auth` + * override, otherwise resolve `apiKey` (option > config var) and apply it as + * a bearer token. + */ +export const bearer = (options: ProviderAuthOption<"optional">, envVar: string | ReadonlyArray): Auth => { + if ("auth" in options && options.auth) return options.auth + return (Array.isArray(envVar) ? envVar : [envVar]) + .reduce( + (auth, name) => auth.orElse(Auth.config(name)), + Auth.optional("apiKey" in options ? options.apiKey : undefined, "apiKey"), + ) + .bearer() +} + +export * as AuthOptions from "./auth-options" diff --git a/packages/llm/src/route/auth.ts b/packages/llm/src/route/auth.ts new file mode 100644 index 0000000000000000000000000000000000000000..32871c04547e7b4cdb3fccc8adf1050f2fb6b110 --- /dev/null +++ b/packages/llm/src/route/auth.ts @@ -0,0 +1,156 @@ +import { Config, Effect, Redacted } from "effect" +import { Headers } from "effect/unstable/http" +import { AuthenticationReason, InvalidRequestReason, LLMError, type LLMRequest } from "../schema" + +export class MissingCredentialError extends Error { + readonly _tag = "MissingCredentialError" + + constructor(readonly source: string) { + super(`Missing auth credential: ${source}`) + } +} + +export type CredentialError = MissingCredentialError | Config.ConfigError +export type AuthError = CredentialError | LLMError +type Secret = string | Redacted.Redacted | Config.Config + +export interface AuthInput { + readonly request: LLMRequest + readonly method: "POST" | "GET" + readonly url: string + readonly body: string + readonly headers: Headers.Headers +} + +export interface Credential { + readonly load: Effect.Effect + readonly orElse: (that: Credential) => Credential + readonly bearer: () => Auth + readonly header: (name: string) => Auth + readonly pipe: (f: (self: Credential) => A) => A +} + +export interface Auth { + readonly apply: (input: AuthInput) => Effect.Effect + readonly andThen: (that: Auth) => Auth + readonly orElse: (that: Auth) => Auth + readonly pipe: (f: (self: Auth) => A) => A +} + +export const isAuth = (input: unknown): input is Auth => + typeof input === "object" && input !== null && "apply" in input && typeof input.apply === "function" + +const credential = (load: Effect.Effect): Credential => { + const self: Credential = { + load, + orElse: (that) => credential(load.pipe(Effect.catch(() => that.load))), + bearer: () => fromCredential(self, (secret) => ({ authorization: `Bearer ${secret}` })), + header: (name) => fromCredential(self, (secret) => ({ [name]: secret })), + pipe: (f) => f(self), + } + return self +} + +const auth = (apply: Auth["apply"]): Auth => { + const self: Auth = { + apply, + andThen: (that) => + auth((input) => apply(input).pipe(Effect.flatMap((headers) => that.apply({ ...input, headers })))), + orElse: (that) => auth((input) => apply(input).pipe(Effect.catch(() => that.apply(input)))), + pipe: (f) => f(self), + } + return self +} + +const fromCredential = (source: Credential, render: (secret: string) => Headers.Input) => + auth((input) => + source.load.pipe(Effect.map((secret) => Headers.setAll(input.headers, render(Redacted.value(secret))))), + ) + +const secretEffect = (secret: string | Redacted.Redacted, source: string) => { + const redacted = typeof secret === "string" ? Redacted.make(secret) : secret + if (Redacted.value(redacted) === "") return Effect.fail(new MissingCredentialError(source)) + return Effect.succeed(redacted) +} + +const credentialFromSecret = (secret: Secret, source: string) => { + if (typeof secret === "string" || Redacted.isRedacted(secret)) return credential(secretEffect(secret, source)) + return credential( + Effect.gen(function* () { + return yield* secretEffect(yield* secret, source) + }), + ) +} + +export const value = (secret: string, source = "value") => credentialFromSecret(secret, source) + +export const optional = (secret: Secret | undefined, source = "optional value") => + secret === undefined + ? credential(Effect.fail(new MissingCredentialError(source))) + : credentialFromSecret(secret, source) + +export const config = (name: string) => credentialFromSecret(Config.redacted(name), name) + +export const effect = (load: Effect.Effect) => credential(load) + +export const none = auth((input) => Effect.succeed(input.headers)) + +export const headers = (input: Headers.Input) => + auth((inputAuth) => Effect.succeed(Headers.setAll(inputAuth.headers, input))) + +export const remove = (name: string) => auth((input) => Effect.succeed(Headers.remove(input.headers, name))) + +export const custom = (apply: (input: AuthInput) => Effect.Effect) => auth(apply) + +export const passthrough = none + +const credentialInput = (source: Secret | Credential) => + typeof source === "string" || Redacted.isRedacted(source) || Config.isConfig(source) + ? credentialFromSecret(source, "value") + : source + +export function bearer(source: Secret | Credential): Auth +export function bearer(source: Secret | Credential) { + return credentialInput(source).bearer() +} + +export const apiKey = bearer + +export function header(name: string): (source: Secret | Credential) => Auth +export function header(name: string, source: Secret | Credential): Auth +export function header(name: string, source?: Secret | Credential) { + if (source === undefined) { + return (next: Secret | Credential) => credentialInput(next).header(name) + } + return credentialInput(source).header(name) +} + +export function bearerHeader(name: string): (source: Secret | Credential) => Auth +export function bearerHeader(name: string, source: Secret | Credential): Auth +export function bearerHeader(name: string, source?: Secret | Credential) { + const render = (input: Secret | Credential) => + fromCredential(credentialInput(input), (secret) => ({ [name]: `Bearer ${secret}` })) + if (source === undefined) return render + return render(source) +} + +const toLLMError = (error: AuthError): LLMError => { + if (error instanceof MissingCredentialError || error instanceof Config.ConfigError) { + return new LLMError({ + module: "Auth", + method: "apply", + reason: + error instanceof MissingCredentialError + ? new AuthenticationReason({ message: error.message, kind: "missing" }) + : new InvalidRequestReason({ message: `Failed to resolve auth config: ${error.message}` }), + }) + } + return error +} + +export const toEffect = + (input: Auth) => + (authInput: AuthInput): Effect.Effect => + input.apply(authInput).pipe(Effect.mapError(toLLMError)) + +export * as Auth from "./auth" diff --git a/packages/llm/src/route/client.ts b/packages/llm/src/route/client.ts new file mode 100644 index 0000000000000000000000000000000000000000..d3b41f5817f14a6dc082fd92b37becda33408ea1 --- /dev/null +++ b/packages/llm/src/route/client.ts @@ -0,0 +1,436 @@ +import { Cause, Context, Effect, Layer, Schema, Stream } from "effect" +import * as Option from "effect/Option" +import { Auth, type Auth as AuthDef } from "./auth" +import { Endpoint, type EndpointPatch } from "./endpoint" +import { RequestExecutor } from "./executor" +import type { Framing } from "./framing" +import { HttpTransport } from "./transport" +import type { Transport, TransportRuntime } from "./transport" +import { WebSocketExecutor } from "./transport" +import type { Protocol } from "./protocol" +import { applyCachePolicy } from "../cache-policy" +import * as ProviderShared from "../protocols/shared" +import type { LLMError, LLMEvent, PreparedRequestOf, ProtocolID, ProviderOptions } from "../schema" +import { + GenerationOptions, + HttpOptions, + LLMRequest, + LLMResponse, + Model, + ModelLimits, + LLMError as LLMErrorClass, + PreparedRequest, + ProviderID, + mergeGenerationOptions, + mergeHttpOptions, + mergeProviderOptions, +} from "../schema" + +export interface RouteBody { + /** Schema for the validated provider-native body sent as the JSON request. */ + readonly schema: Schema.Codec + /** Build the provider-native body from a common `LLMRequest`. */ + readonly from: (request: LLMRequest) => Effect.Effect +} + +export interface Route { + readonly id: string + readonly provider?: ProviderID + readonly protocol: ProtocolID + readonly endpoint: Endpoint + readonly auth: AuthDef + readonly transport: Transport + readonly defaults: RouteDefaults + readonly body: RouteBody + readonly with: (patch: RoutePatch) => Route + readonly model: (input: RouteMappedModelInput) => Model + readonly prepareTransport: (body: Body, request: LLMRequest) => Effect.Effect + readonly streamPrepared: ( + prepared: Prepared, + request: LLMRequest, + runtime: TransportRuntime, + ) => Stream.Stream +} + +// Route registries intentionally erase body generics after construction. +// Normal call sites use `OpenAIChat.route`; callers only need body types +// when preparing a request with a protocol-specific type assertion. +// oxlint-disable-next-line typescript-eslint/no-explicit-any +export type AnyRoute = Route + +export type HttpOptionsInput = HttpOptions.Input + +export type RouteModelInput = Omit + +export type RouteRoutedModelInput = Omit + +export interface RouteDefaults { + readonly headers?: Record + readonly limits?: ModelLimits + readonly generation?: GenerationOptions + readonly providerOptions?: ProviderOptions + readonly http?: HttpOptions +} + +export interface RouteDefaultsInput { + readonly headers?: Record + readonly limits?: ModelLimits.Input + readonly generation?: GenerationOptions.Input + readonly providerOptions?: ProviderOptions + readonly http?: HttpOptions.Input +} + +export interface RoutePatch extends RouteDefaultsInput { + readonly id?: string + readonly provider?: string | ProviderID + readonly auth?: AuthDef + readonly transport?: Transport + readonly endpoint?: EndpointPatch +} + +type RouteMappedModelInput = RouteModelInput | RouteRoutedModelInput + +const makeRouteModel = (route: AnyRoute, mapped: RouteMappedModelInput) => { + const provider = route.provider ?? ("provider" in mapped ? mapped.provider : undefined) + if (!provider) throw new Error(`Route.model(${route.id}) requires a provider`) + if (!endpointBaseURL(route.endpoint)) + throw new Error(`Route.model(${route.id}) requires an endpoint baseURL — configure it on the route first`) + return Model.make({ + ...mapped, + provider, + route, + }) +} + +const mergeRouteDefaults = (base: RouteDefaults | undefined, patch: RouteDefaultsInput): RouteDefaults => { + const headers = mergeHeaders(base?.headers, patch.headers) + return { + ...base, + ...patch, + headers, + limits: patch.limits === undefined ? base?.limits : ModelLimits.make(patch.limits), + generation: mergeGenerationOptions(generationOptions(base?.generation), generationOptions(patch.generation)), + providerOptions: mergeProviderOptions(base?.providerOptions, patch.providerOptions), + http: mergeHttpOptions( + base?.http, + httpOptions(patch.http), + headers === undefined ? undefined : new HttpOptions({ headers }), + ), + } +} + +const endpointBaseURL = (endpoint: Endpoint) => + typeof endpoint.baseURL === "string" ? endpoint.baseURL : undefined + +const mergeHeaders = (...items: ReadonlyArray | undefined>) => { + const entries = items.flatMap((item) => + item === undefined ? [] : Object.entries(item).filter((entry): entry is [string, string] => entry[1] !== undefined), + ) + if (entries.length === 0) return undefined + return Object.fromEntries(entries) +} + +export const generationOptions = (input: GenerationOptions.Input | undefined) => + input === undefined ? undefined : GenerationOptions.make(input) + +export const httpOptions = (input: HttpOptionsInput | undefined) => { + if (input === undefined) return input + return HttpOptions.make(input) +} + +export interface Interface { + /** + * Compile a request through protocol body construction, validation, and HTTP + * preparation without sending it. Returns the prepared request including the + * provider-native body. + * + * Pass a `Body` type argument to statically expose the route's body + * shape (e.g. `prepare(...)`) — the runtime body is + * identical, so this is a type-level assertion the caller makes about which + * route the request will resolve to. + */ + readonly prepare: (request: LLMRequest) => Effect.Effect, LLMError> + readonly stream: StreamMethod + readonly generate: GenerateMethod +} + +export interface StreamMethod { + (request: LLMRequest): Stream.Stream +} + +export interface GenerateMethod { + (request: LLMRequest): Effect.Effect +} + +export class Service extends Context.Service()("@opencode/LLMClient") {} + +const resolveRequestOptions = (request: LLMRequest) => { + const routeDefaults = request.model.route.defaults + const modelDefaults = request.model.defaults + const generation = mergeGenerationOptions(routeDefaults.generation, modelDefaults?.generation, request.generation) + return LLMRequest.update(request, { + generation: generation ?? new GenerationOptions({}), + providerOptions: mergeProviderOptions( + routeDefaults.providerOptions, + modelDefaults?.providerOptions, + request.providerOptions, + ), + http: mergeHttpOptions(routeDefaults.http, modelDefaults?.http, request.http), + }) +} + +export interface MakeInput { + /** Route id used in diagnostics and prepared request metadata. */ + readonly id: string + /** Provider identity for route-owned model construction. */ + readonly provider?: string | ProviderID + /** Semantic API contract — owns body construction, body schema, and parsing. */ + readonly protocol: Protocol + /** Where the request is sent. */ + readonly endpoint: Endpoint + /** Per-request transport auth. Provider facades override this via `route.with(...)`. */ + readonly auth?: AuthDef + /** Stream framing — bytes -> frames before `protocol.stream.event` decoding. */ + readonly framing: Framing + /** Static / per-request headers added before `auth` runs. */ + readonly headers?: (input: { readonly request: LLMRequest }) => Record + /** Route/request defaults used when compiling requests for this route. */ + readonly defaults?: RouteDefaultsInput +} + +export interface MakeTransportInput { + /** Route id used in diagnostics and prepared request metadata. */ + readonly id: string + /** Provider identity for route-owned model construction. */ + readonly provider?: string | ProviderID + /** Semantic API contract — owns body construction, body schema, and parsing. */ + readonly protocol: Protocol + /** Where the request is sent. */ + readonly endpoint: Endpoint + /** Per-request transport auth. Provider facades override this via `route.with(...)`. */ + readonly auth?: AuthDef + /** Static / per-request headers added before `auth` runs. */ + readonly headers?: (input: { readonly request: LLMRequest }) => Record + /** Runnable transport route. */ + readonly transport: Transport + /** Route/request defaults used when compiling requests for this route. */ + readonly defaults?: RouteDefaultsInput +} + +const streamError = (route: string, message: string, cause: Cause.Cause) => { + const failed = cause.reasons.find(Cause.isFailReason)?.error + if (failed instanceof LLMErrorClass) return failed + return ProviderShared.eventError(route, message, Cause.pretty(cause)) +} + +function makeFromTransport( + input: MakeTransportInput, +): Route { + const protocol = input.protocol + const encodeBody = Schema.encodeSync(Schema.fromJsonString(protocol.body.schema)) + const decodeEventEffect = Schema.decodeUnknownEffect(protocol.stream.event) + const decodeEvent = (route: string) => (frame: Frame) => + decodeEventEffect(frame).pipe( + Effect.mapError(() => + ProviderShared.eventError( + input.id, + `Invalid ${route} stream event`, + typeof frame === "string" ? frame : ProviderShared.encodeJson(frame), + ), + ), + ) + + type BuiltRouteInput = Omit, "defaults"> & { + readonly defaults?: RouteDefaults + } + + const build = (routeInput: BuiltRouteInput): Route => { + const route: Route = { + id: routeInput.id, + provider: routeInput.provider === undefined ? undefined : ProviderID.make(routeInput.provider), + protocol: protocol.id, + endpoint: routeInput.endpoint, + auth: routeInput.auth ?? Auth.none, + transport: routeInput.transport, + defaults: routeInput.defaults ?? {}, + body: protocol.body, + with: (patch: RoutePatch) => { + const { id, provider, auth, transport, endpoint, ...defaults } = patch + return build({ + ...routeInput, + id: id ?? routeInput.id, + provider: provider ?? routeInput.provider, + auth: auth ?? routeInput.auth, + endpoint: endpoint ? Endpoint.merge(routeInput.endpoint, endpoint) : routeInput.endpoint, + transport: (transport as Transport | undefined) ?? routeInput.transport, + defaults: mergeRouteDefaults(route.defaults, defaults), + }) + }, + model: (input) => makeRouteModel(route, input), + prepareTransport: (body, request) => + routeInput.transport.prepare({ + body, + request, + endpoint: routeInput.endpoint, + auth: routeInput.auth ?? Auth.none, + encodeBody, + headers: routeInput.headers, + }), + streamPrepared: (prepared: Prepared, request: LLMRequest, runtime: TransportRuntime) => { + const route = `${request.model.provider}/${request.model.route.id}` + const events = routeInput.transport + .frames(prepared, request, runtime) + .pipe( + Stream.mapEffect(decodeEvent(route)), + protocol.stream.terminal ? Stream.takeUntil(protocol.stream.terminal) : (stream) => stream, + ) + return events.pipe( + Stream.mapAccumEffect( + () => protocol.stream.initial(request), + protocol.stream.step, + protocol.stream.onHalt ? { onHalt: protocol.stream.onHalt } : undefined, + ), + Stream.catchCause((cause) => Stream.fail(streamError(route, `Failed to read ${route} stream`, cause))), + ) + }, + } satisfies Route + return route + } + + return build({ ...input, defaults: mergeRouteDefaults(undefined, input.defaults ?? {}) }) +} + +export function make( + input: MakeTransportInput, +): Route +/** + * Build a `Route` by composing the four orthogonal pieces of a deployment: + * + * - `Protocol` — what is the API I'm speaking? + * - `Endpoint` — where do I send the request? + * - `Auth` — how do I authenticate it? + * - `Framing` — how do I cut the response stream into protocol frames? + * + * Plus optional `headers` for cross-cutting deployment concerns (provider + * version pins, per-deployment quirks). + * + * This is the canonical route constructor. If a new route does not fit + * this four-axis model, add a purpose-built constructor rather than widening + * the public surface preemptively. + */ +export function make( + input: MakeInput, +): Route> +export function make( + input: MakeInput | MakeTransportInput, +): Route | Route> { + if ("transport" in input) return makeFromTransport(input) + const protocol = input.protocol + return makeFromTransport({ + id: input.id, + provider: input.provider, + protocol, + endpoint: input.endpoint, + auth: input.auth, + headers: input.headers, + transport: HttpTransport.httpJson({ framing: input.framing }), + defaults: input.defaults, + }) +} + +// `compile` is the important boundary: it turns a common `LLMRequest` into a +// validated provider body plus transport-private prepared data, but does not +// execute transport. +const compile = Effect.fn("LLM.compile")(function* (request: LLMRequest) { + const resolved = applyCachePolicy(resolveRequestOptions(request)) + const route = resolved.model.route + + const body = yield* route.body + .from(resolved) + .pipe(Effect.flatMap(ProviderShared.validateWith(Schema.decodeUnknownEffect(route.body.schema)))) + const prepared = yield* route.prepareTransport(body, resolved) + + return { + request: resolved, + route, + body, + prepared, + } +}) + +const prepareWith = Effect.fn("LLMClient.prepare")(function* (request: LLMRequest) { + const compiled = yield* compile(request) + + return new PreparedRequest({ + id: compiled.request.id ?? "request", + route: compiled.route.id, + protocol: compiled.route.protocol, + model: compiled.request.model, + body: compiled.body, + metadata: { transport: compiled.route.transport.id }, + }) +}) + +const streamRequestWith = (runtime: TransportRuntime) => (request: LLMRequest) => + Stream.unwrap( + Effect.gen(function* () { + const compiled = yield* compile(request) + return compiled.route.streamPrepared(compiled.prepared, compiled.request, runtime) + }), + ) + +const generateWith = (stream: Interface["stream"]) => + Effect.fn("LLM.generate")(function* (request: LLMRequest) { + const state = yield* stream(request).pipe(Stream.runFold(LLMResponse.empty, LLMResponse.reduce)) + const response = LLMResponse.complete(state) + if (response) return response + return yield* ProviderShared.eventError( + `${request.model.provider}/${request.model.route.id}`, + "Provider stream ended without a terminal finish event", + ) + }) + +export const prepare = (request: LLMRequest) => + prepareWith(request) as Effect.Effect, LLMError> + +export function stream(request: LLMRequest): Stream.Stream { + return Stream.unwrap( + Effect.gen(function* () { + return (yield* Service).stream(request) + }), + ) as Stream.Stream +} + +export function generate(request: LLMRequest): Effect.Effect { + return Effect.gen(function* () { + return yield* (yield* Service).generate(request) + }) as Effect.Effect +} + +export const streamRequest = (request: LLMRequest) => + Stream.unwrap( + Effect.gen(function* () { + return (yield* Service).stream(request) + }), + ) + +export const layer: Layer.Layer = Layer.effect( + Service, + Effect.gen(function* () { + const stream = streamRequestWith({ + http: yield* RequestExecutor.Service, + webSocket: Option.getOrUndefined(yield* Effect.serviceOption(WebSocketExecutor.Service)), + }) + return Service.of({ prepare: prepareWith as Interface["prepare"], stream, generate: generateWith(stream) }) + }), +) + +export const Route = { make } as const + +export const LLMClient = { + Service, + layer, + prepare, + stream, + generate, +} as const diff --git a/packages/llm/src/route/endpoint.ts b/packages/llm/src/route/endpoint.ts new file mode 100644 index 0000000000000000000000000000000000000000..accbe5324311a8d3e9105fbb6a2c03a7e206fabe --- /dev/null +++ b/packages/llm/src/route/endpoint.ts @@ -0,0 +1,53 @@ +import type { LLMRequest } from "../schema" +import * as ProviderShared from "../protocols/shared" + +export interface EndpointInput { + readonly request: LLMRequest + readonly body: Body +} + +export type EndpointPart = string | ((input: EndpointInput) => string) + +/** + * Declarative URL construction for one route. + * + * `Endpoint` carries URL construction for one route. Routes with a canonical + * host put `baseURL` here; provider helpers can override it by configuring the + * route before selecting a model. + * + * `path` may be a string or a function of `EndpointInput`, for routes whose + * URL embeds the model id, region, or another body field (e.g. Bedrock, + * Gemini). + */ +export interface Endpoint { + readonly baseURL?: string + readonly path: EndpointPart + readonly query?: Record +} + +export type EndpointPatch = Partial> + +/** Construct an `Endpoint` from a path string or path function. */ +export const path = (value: EndpointPart, options: Omit, "path"> = {}): Endpoint => ({ + ...options, + path: value, +}) + +export const merge = (base: Endpoint, patch: EndpointPatch): Endpoint => ({ + ...base, + ...patch, + baseURL: patch.baseURL ?? base.baseURL, + path: patch.path ?? base.path, + query: patch.query === undefined ? base.query : { ...base.query, ...patch.query }, +}) + +const renderPart = (part: EndpointPart, input: EndpointInput) => + typeof part === "function" ? part(input) : part + +export const render = (endpoint: Endpoint, input: EndpointInput) => { + const url = new URL(`${ProviderShared.trimBaseUrl(endpoint.baseURL ?? "")}${renderPart(endpoint.path, input)}`) + for (const [key, value] of Object.entries(endpoint.query ?? {})) url.searchParams.set(key, value) + return url +} + +export * as Endpoint from "./endpoint" diff --git a/packages/llm/src/route/executor.ts b/packages/llm/src/route/executor.ts new file mode 100644 index 0000000000000000000000000000000000000000..b2f679c683547f78e5c7067649d1fd4ced1ab1c3 --- /dev/null +++ b/packages/llm/src/route/executor.ts @@ -0,0 +1,385 @@ +import { Cause, Context, Effect, Layer, Random } from "effect" +import { + FetchHttpClient, + Headers, + HttpClient, + HttpClientError, + HttpClientRequest, + HttpClientResponse, +} from "effect/unstable/http" +import { + AuthenticationReason, + ContentPolicyReason, + HttpContext, + HttpRateLimitDetails, + HttpRequestDetails, + HttpResponseDetails, + InvalidRequestReason, + LLMError, + ProviderInternalReason, + QuotaExceededReason, + RateLimitReason, + TransportReason, + UnknownProviderReason, +} from "../schema" +import { isContextOverflow } from "../provider-error" + +export interface Interface { + readonly execute: ( + request: HttpClientRequest.HttpClientRequest, + ) => Effect.Effect +} + +export class Service extends Context.Service()("@opencode/LLM/RequestExecutor") {} + +const BODY_LIMIT = 16_384 +const MAX_RETRIES = 2 +const BASE_DELAY_MS = 500 +const MAX_DELAY_MS = 10_000 +const REDACTED = "" + +// One source of truth for what counts as a sensitive name across headers, +// URL query keys, and field names embedded inside request/response bodies. +// +// `SENSITIVE_NAME` is used as both a substring matcher (for free-form header +// names like `Authorization` / `X-API-Key`) and as the body-field alternation +// list. `SHORT_QUERY_NAME` covers anchored short keys like `?key=…` / `?sig=…` +// that are too generic to redact substring-style without false positives. +const SENSITIVE_NAME_SOURCE = + "authorization|api[-_]?key|access[-_]?token|refresh[-_]?token|id[-_]?token|token|secret|credential|signature|x-amz-signature" +const SENSITIVE_NAME = new RegExp(SENSITIVE_NAME_SOURCE, "i") +const SHORT_QUERY_NAME = /^(key|sig)$/i +const SENSITIVE_BODY_FIELD = new RegExp(`(?:${SENSITIVE_NAME_SOURCE}|key)`, "i") +const REDACT_JSON_FIELD = new RegExp(`("(?:${SENSITIVE_BODY_FIELD.source})"\\s*:\\s*)"[^"]*"`, "gi") +const REDACT_QUERY_FIELD = new RegExp(`((?:${SENSITIVE_BODY_FIELD.source})=)[^&\\s"]+`, "gi") + +const isSensitiveHeaderName = (name: string) => SENSITIVE_NAME.test(name) + +const isSensitiveQueryName = (name: string) => isSensitiveHeaderName(name) || SHORT_QUERY_NAME.test(name) + +const redactHeaders = (headers: Headers.Headers, redactedNames: ReadonlyArray) => + Object.fromEntries( + Object.entries(Headers.redact(headers, [...redactedNames, SENSITIVE_NAME])).map(([name, value]) => [ + name, + String(value), + ]), + ) + +const redactUrl = (value: string) => { + if (!URL.canParse(value)) return REDACTED + const url = new URL(value) + url.searchParams.forEach((_, key) => { + if (isSensitiveQueryName(key)) url.searchParams.set(key, REDACTED) + }) + return url.toString() +} + +const normalizedHeaders = (headers: Headers.Headers) => + Object.fromEntries(Object.entries(headers).map(([key, value]) => [key.toLowerCase(), value])) + +const requestId = (headers: Record) => { + return ( + headers["x-request-id"] ?? + headers["request-id"] ?? + headers["x-amzn-requestid"] ?? + headers["x-amz-request-id"] ?? + headers["x-goog-request-id"] ?? + headers["cf-ray"] + ) +} + +const retryableStatus = (status: number) => status === 429 || status === 503 || status === 504 || status === 529 + +const retryAfterMs = (headers: Record) => { + const millis = Number(headers["retry-after-ms"]) + if (Number.isFinite(millis)) return Math.max(0, millis) + + const value = headers["retry-after"] + if (!value) return undefined + + const seconds = Number(value) + if (Number.isFinite(seconds)) return Math.max(0, seconds * 1000) + + const date = Date.parse(value) + if (!Number.isNaN(date)) return Math.max(0, date - Date.now()) + return undefined +} + +const addRateLimitValue = (target: Record, key: string, value: string) => { + if (key.length > 0) target[key] = value +} + +const rateLimitDetails = (headers: Record, retryAfter: number | undefined) => { + const limit: Record = {} + const remaining: Record = {} + const reset: Record = {} + + Object.entries(headers).forEach(([name, value]) => { + const openaiLimit = /^x-ratelimit-limit-(.+)$/.exec(name)?.[1] + if (openaiLimit) return addRateLimitValue(limit, openaiLimit, value) + + const openaiRemaining = /^x-ratelimit-remaining-(.+)$/.exec(name)?.[1] + if (openaiRemaining) return addRateLimitValue(remaining, openaiRemaining, value) + + const openaiReset = /^x-ratelimit-reset-(.+)$/.exec(name)?.[1] + if (openaiReset) return addRateLimitValue(reset, openaiReset, value) + + const anthropic = /^anthropic-ratelimit-(.+)-(limit|remaining|reset)$/.exec(name) + if (!anthropic) return + if (anthropic[2] === "limit") return addRateLimitValue(limit, anthropic[1], value) + if (anthropic[2] === "remaining") return addRateLimitValue(remaining, anthropic[1], value) + return addRateLimitValue(reset, anthropic[1], value) + }) + + if ( + retryAfter === undefined && + Object.keys(limit).length === 0 && + Object.keys(remaining).length === 0 && + Object.keys(reset).length === 0 + ) + return undefined + + return new HttpRateLimitDetails({ + retryAfterMs: retryAfter, + limit: Object.keys(limit).length === 0 ? undefined : limit, + remaining: Object.keys(remaining).length === 0 ? undefined : remaining, + reset: Object.keys(reset).length === 0 ? undefined : reset, + }) +} + +const requestDetails = (request: HttpClientRequest.HttpClientRequest, redactedNames: ReadonlyArray) => + new HttpRequestDetails({ + method: request.method, + url: redactUrl(request.url), + headers: redactHeaders(request.headers, redactedNames), + }) + +const responseDetails = ( + response: HttpClientResponse.HttpClientResponse, + redactedNames: ReadonlyArray, +) => + new HttpResponseDetails({ + status: response.status, + headers: redactHeaders(response.headers, redactedNames), + }) + +const secretValues = (request: HttpClientRequest.HttpClientRequest) => { + const values = new Set() + const add = (value: string) => { + if (value.length < 4) return + values.add(value) + values.add(encodeURIComponent(value)) + } + + Object.entries(request.headers).forEach(([name, value]) => { + if (!isSensitiveHeaderName(name)) return + add(value) + const bearer = /^Bearer\s+(.+)$/i.exec(value)?.[1] + if (bearer) add(bearer) + }) + + if (!URL.canParse(request.url)) return values + new URL(request.url).searchParams.forEach((value, key) => { + if (isSensitiveQueryName(key)) add(value) + }) + return values +} + +// Two passes: structural (redact `"name": "value"` and `name=value` patterns +// for any field name that looks sensitive) plus literal (replace any actual +// secret values we sent in the request, in case the response echoes one back). +const redactBody = (body: string, request: HttpClientRequest.HttpClientRequest) => + Array.from(secretValues(request)).reduce( + (text, secret) => text.split(secret).join(REDACTED), + body.replace(REDACT_JSON_FIELD, `$1"${REDACTED}"`).replace(REDACT_QUERY_FIELD, `$1${REDACTED}`), + ) + +const responseBody = (body: string | void, request: HttpClientRequest.HttpClientRequest) => { + if (body === undefined) return {} + const redacted = redactBody(body, request) + if (redacted.length <= BODY_LIMIT) return { body: redacted } + return { body: redacted.slice(0, BODY_LIMIT), bodyTruncated: true } +} + +const providerMessage = (status: number, body: { readonly body?: string }) => { + if (body.body && body.body.length <= 500) return `Provider request failed with HTTP ${status}: ${body.body}` + return `Provider request failed with HTTP ${status}` +} + +const responseHttp = (input: { + readonly request: HttpClientRequest.HttpClientRequest + readonly response: HttpClientResponse.HttpClientResponse + readonly redactedNames: ReadonlyArray + readonly body: ReturnType + readonly requestId?: string | undefined + readonly rateLimit?: HttpRateLimitDetails | undefined +}) => + new HttpContext({ + request: requestDetails(input.request, input.redactedNames), + response: responseDetails(input.response, input.redactedNames), + ...input.body, + requestId: input.requestId, + rateLimit: input.rateLimit, + }) + +const statusReason = (input: { + readonly status: number + readonly message: string + readonly retryAfterMs?: number | undefined + readonly rateLimit?: HttpRateLimitDetails | undefined + readonly http: HttpContext +}) => { + const body = input.http.body ?? "" + if (/content[-_\s]?policy|content_filter|safety/i.test(body)) { + return new ContentPolicyReason({ message: input.message, http: input.http }) + } + if (input.status === 401) { + return new AuthenticationReason({ message: input.message, kind: "invalid", http: input.http }) + } + if (input.status === 403) { + return new AuthenticationReason({ message: input.message, kind: "insufficient-permissions", http: input.http }) + } + if (input.status === 429) { + if (/insufficient[-_\s]?quota|quota[-_\s]?exceeded/i.test(body)) { + return new QuotaExceededReason({ message: input.message, http: input.http }) + } + return new RateLimitReason({ + message: input.message, + retryAfterMs: input.retryAfterMs, + rateLimit: input.rateLimit, + http: input.http, + }) + } + if ( + input.status === 400 || + input.status === 404 || + input.status === 409 || + input.status === 413 || + input.status === 422 + ) { + return new InvalidRequestReason({ + message: input.message, + classification: isContextOverflow(body) ? "context-overflow" : undefined, + http: input.http, + }) + } + if (input.status >= 500 || retryableStatus(input.status)) { + return new ProviderInternalReason({ + message: input.message, + status: input.status, + retryAfterMs: input.retryAfterMs, + http: input.http, + }) + } + return new UnknownProviderReason({ message: input.message, status: input.status, http: input.http }) +} + +const statusError = + (request: HttpClientRequest.HttpClientRequest, redactedNames: ReadonlyArray) => + (response: HttpClientResponse.HttpClientResponse) => + Effect.gen(function* () { + if (response.status < 400) return response + const body = yield* response.text.pipe(Effect.catch(() => Effect.void)) + const headers = normalizedHeaders(response.headers) + const retryAfter = retryAfterMs(headers) + const rateLimit = rateLimitDetails(headers, retryAfter) + const details = responseBody(body, request) + return yield* new LLMError({ + module: "RequestExecutor", + method: "execute", + reason: statusReason({ + status: response.status, + message: providerMessage(response.status, details), + retryAfterMs: retryAfter, + rateLimit, + http: responseHttp({ + request, + response, + redactedNames, + body: details, + requestId: requestId(headers), + rateLimit, + }), + }), + }) + }) + +const toHttpError = (redactedNames: ReadonlyArray) => (error: unknown) => { + const transportError = (input: { + readonly message: string + readonly kind?: string | undefined + readonly request?: HttpClientRequest.HttpClientRequest | undefined + }) => + new LLMError({ + module: "RequestExecutor", + method: "execute", + reason: new TransportReason({ + message: input.message, + kind: input.kind, + url: input.request ? redactUrl(input.request.url) : undefined, + http: input.request ? new HttpContext({ request: requestDetails(input.request, redactedNames) }) : undefined, + }), + }) + + if (Cause.isTimeoutError(error)) { + return transportError({ message: error.message, kind: "Timeout" }) + } + if (!HttpClientError.isHttpClientError(error)) { + return transportError({ message: "HTTP transport failed" }) + } + const request = "request" in error ? error.request : undefined + if (error.reason._tag === "TransportError") { + return transportError({ + message: error.reason.description ?? "HTTP transport failed", + kind: error.reason._tag, + request, + }) + } + return transportError({ + message: `HTTP transport failed: ${error.reason._tag}`, + kind: error.reason._tag, + request, + }) +} + +const retryDelay = (error: LLMError, attempt: number) => { + if (error.retryAfterMs !== undefined) return Effect.succeed(Math.min(error.retryAfterMs, MAX_DELAY_MS)) + return Random.nextBetween( + Math.min(BASE_DELAY_MS * 2 ** attempt * 0.8, MAX_DELAY_MS), + Math.min(BASE_DELAY_MS * 2 ** attempt * 1.2, MAX_DELAY_MS), + ).pipe(Effect.map((delay) => Math.round(delay))) +} + +const retryStatusFailures = ( + effect: Effect.Effect, + retries = MAX_RETRIES, + attempt = 0, +): Effect.Effect => + Effect.catchTag(effect, "LLM.Error", (error): Effect.Effect => { + if (!error.retryable || retries <= 0) return Effect.fail(error) + return retryDelay(error, attempt).pipe( + Effect.flatMap((delay) => Effect.sleep(delay)), + Effect.flatMap(() => retryStatusFailures(effect, retries - 1, attempt + 1)), + ) + }) + +export const layer: Layer.Layer = Layer.effect( + Service, + Effect.gen(function* () { + const http = yield* HttpClient.HttpClient + const executeOnce = (request: HttpClientRequest.HttpClientRequest) => + Effect.gen(function* () { + const redactedNames = yield* Headers.CurrentRedactedNames + return yield* http + .execute(request) + .pipe(Effect.mapError(toHttpError(redactedNames)), Effect.flatMap(statusError(request, redactedNames))) + }) + return Service.of({ + execute: (request) => retryStatusFailures(executeOnce(request)), + }) + }), +) + +export const fetchLayer = layer.pipe(Layer.provide(FetchHttpClient.layer)) + +export * as RequestExecutor from "./executor" diff --git a/packages/llm/src/route/framing.ts b/packages/llm/src/route/framing.ts new file mode 100644 index 0000000000000000000000000000000000000000..ef4855817d081a5dea636f659c9ee6645ef31b73 --- /dev/null +++ b/packages/llm/src/route/framing.ts @@ -0,0 +1,27 @@ +import type { Stream } from "effect" +import * as ProviderShared from "../protocols/shared" +import type { LLMError } from "../schema" + +/** + * Decode a streaming HTTP response body into provider-protocol frames. + * + * `Framing` is the byte-stream-shaped seam between transport and protocol: + * + * - SSE (`Framing.sse`) — UTF-8 decode the body, run the SSE channel decoder, + * drop empty / `[DONE]` keep-alives. Each emitted frame is the JSON `data:` + * payload of one event. + * - AWS event stream — length-prefixed binary frames with CRC checksums. + * Each emitted frame is one parsed binary event record. + * + * The frame type is opaque to this layer; the protocol's `decode` step turns + * a frame into a typed chunk. + */ +export interface Framing { + readonly id: string + readonly frame: (bytes: Stream.Stream) => Stream.Stream +} + +/** Server-Sent Events framing. Used by every JSON-streaming HTTP provider. */ +export const sse: Framing = { id: "sse", frame: ProviderShared.sseFraming } + +export * as Framing from "./framing" diff --git a/packages/llm/src/route/index.ts b/packages/llm/src/route/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..48f4b7bc33929c464d37e87425bfe0ffb18a9b71 --- /dev/null +++ b/packages/llm/src/route/index.ts @@ -0,0 +1,25 @@ +export { Route, LLMClient } from "./client" +export type { + Route as RouteShape, + RouteModelInput, + RouteRoutedModelInput, + RouteDefaults, + RouteDefaultsInput, + AnyRoute, + Interface as LLMClientShape, + Service as LLMClientService, +} from "./client" +export * from "./executor" +export { Auth } from "./auth" +export { AuthOptions } from "./auth-options" +export { Endpoint } from "./endpoint" +export { Framing } from "./framing" +export { Protocol } from "./protocol" +export { HttpTransport, WebSocketExecutor, WebSocketTransport } from "./transport" +export * as Transport from "./transport" +export type { Auth as AuthShape, AuthInput, Credential, CredentialError } from "./auth" +export type { ApiKeyMode, AuthOverride, ProviderAuthOption } from "./auth-options" +export type { Endpoint as EndpointFn, EndpointInput } from "./endpoint" +export type { Framing as FramingDef } from "./framing" +export type { Protocol as ProtocolDef } from "./protocol" +export type { Transport as TransportDef, TransportRuntime } from "./transport" diff --git a/packages/llm/src/route/protocol.ts b/packages/llm/src/route/protocol.ts new file mode 100644 index 0000000000000000000000000000000000000000..acb1e78c67bb156c2cf83f89b436159476d1bcef --- /dev/null +++ b/packages/llm/src/route/protocol.ts @@ -0,0 +1,84 @@ +import { Schema, type Effect } from "effect" +import type { LLMError, LLMEvent, LLMRequest, ProtocolID } from "../schema" + +/** + * The semantic API contract of one model server family. + * + * A `Protocol` owns the parts of a route that are intrinsic to "what does + * this API look like": how a common `LLMRequest` becomes a provider-native + * body, what schema that body must satisfy before it is JSON-encoded, and + * how the streaming response decodes back into common `LLMEvent`s. + * + * Examples: + * + * - `OpenAIChat.protocol` — chat completions style + * - `OpenAIResponses.protocol` — responses API + * - `AnthropicMessages.protocol` — messages API with content blocks + * - `Gemini.protocol` — generateContent + * - `BedrockConverse.protocol` — Converse with binary event-stream framing + * + * A `Protocol` is **not** a deployment. It does not know which URL, which + * headers, or which auth scheme to use. Those are deployment concerns owned + * by `Route.make(...)` along with the chosen `Endpoint`, `Auth`, + * and `Framing`. This separation is what lets DeepSeek, TogetherAI, Cerebras, + * etc. all reuse `OpenAIChat.protocol` without forking 300 lines per provider. + * + * The four type parameters reflect the pipeline: + * + * - `Body` — provider-native request body candidate. `Route.make(...)` + * validates and JSON-encodes it with `body.schema`. + * - `Frame` — one unit of the framed response stream. SSE: a JSON data + * string. AWS event stream: a parsed binary frame. + * - `Event` — schema-decoded provider event produced from one frame. + * - `State` — accumulator threaded through `stream.step` to translate event + * sequences into `LLMEvent` sequences. + */ +export interface Protocol { + /** Stable id for the wire protocol implementation. */ + readonly id: ProtocolID + /** Request side: schema for the provider-native body and how to build it. */ + readonly body: ProtocolBody + /** Response side: streaming state machine. */ + readonly stream: ProtocolStream +} + +export interface ProtocolBody { + /** Schema for the validated provider-native body sent as the JSON request. */ + readonly schema: Schema.Codec + /** Build the provider-native body from a common `LLMRequest`. */ + readonly from: (request: LLMRequest) => Effect.Effect +} + +export interface ProtocolStream { + /** Schema for one decoded streaming event, decoded from a transport frame. */ + readonly event: Schema.Codec + /** Initial parser state. Called once per response with the resolved request. */ + readonly initial: (request: LLMRequest) => State + /** Translate one event into emitted `LLMEvent`s plus the next state. */ + readonly step: (state: State, event: Event) => Effect.Effect], LLMError> + /** Optional request-completion signal for transports that do not end naturally. */ + readonly terminal?: (event: Event) => boolean + /** Optional flush emitted when the framed stream ends. */ + readonly onHalt?: (state: State) => ReadonlyArray +} + +/** + * Construct a `Protocol` from its body and stream pieces: + * + * - `body.schema` infers the provider-native request body shape. + * - `body.from` ties the common `LLMRequest` to the provider body. + * - `stream.event` infers the decoded streaming event and the wire frame. + * - `stream.initial`, `stream.step`, and `stream.onHalt` infer the parser state. + * + * Provider implementations should usually call `Protocol.make({ ... })` + * without explicit type arguments; the schemas and parser functions are the + * source of truth. The constructor remains as the public seam for future + * cross-cutting concerns such as tracing or instrumentation. + */ +export const make = ( + input: Protocol, +): Protocol => input + +export const jsonEvent = (schema: S) => Schema.fromJsonString(schema) + +export * as Protocol from "./protocol" diff --git a/packages/llm/src/route/transport/http.ts b/packages/llm/src/route/transport/http.ts new file mode 100644 index 0000000000000000000000000000000000000000..acc52c6ea173c28f92247b0fd31d58ea688df7e5 --- /dev/null +++ b/packages/llm/src/route/transport/http.ts @@ -0,0 +1,155 @@ +import { Effect, Stream } from "effect" +import { Headers, HttpClientRequest } from "effect/unstable/http" +import { Auth } from "../auth" +import { render as renderEndpoint } from "../endpoint" +import { Framing, type Framing as FramingDef } from "../framing" +import type { Transport, TransportPrepareInput } from "./index" +import * as ProviderShared from "../../protocols/shared" +import { mergeJsonRecords, type LLMRequest } from "../../schema" + +export type JsonRequestInput = TransportPrepareInput + +export interface JsonRequestParts { + readonly url: string + readonly jsonBody: Body | Record + readonly bodyText: string + readonly headers: Headers.Headers +} + +export interface HttpPrepared { + readonly request: HttpClientRequest.HttpClientRequest + readonly framing: FramingDef +} + +const applyQuery = (url: string, query: Record | undefined) => { + if (!query) return url + const next = new URL(url) + Object.entries(query).forEach(([key, value]) => next.searchParams.set(key, value)) + return next.toString() +} + +const PROTOCOL_BODY_OVERLAY_DENYLIST = new Set([ + "content", + "contents", + "frequencyPenalty", + "frequency_penalty", + "generationConfig", + "inferenceConfig", + "input", + "maxTokens", + "max_tokens", + "messages", + "model", + "presencePenalty", + "presence_penalty", + "responseFormat", + "response_format", + "seed", + "stop", + "stopSequences", + "stop_sequences", + "stream", + "streamOptions", + "stream_options", + "system", + "systemInstruction", + "system_instruction", + "temperature", + "thinking", + "toolChoice", + "toolConfig", + "tool_choice", + "tool_config", + "tools", + "topK", + "topP", + "top_k", + "top_p", +]) + +const forbiddenBodyOverlayKeys = (body: Record) => + Object.keys(body).filter((key) => PROTOCOL_BODY_OVERLAY_DENYLIST.has(key)) + +const bodyWithOverlay = (body: Body, request: LLMRequest, encodeBody: (body: Body) => string) => + Effect.gen(function* () { + if (request.http?.body === undefined) return { jsonBody: body, bodyText: encodeBody(body) } + const forbiddenKeys = forbiddenBodyOverlayKeys(request.http.body) + if (forbiddenKeys.length > 0) + return yield* ProviderShared.invalidRequest( + `http.body cannot overlay protocol-owned field(s): ${forbiddenKeys.join(", ")}`, + ) + if (ProviderShared.isRecord(body)) { + const overlaid = mergeJsonRecords(body, request.http.body) ?? {} + return { jsonBody: overlaid, bodyText: ProviderShared.encodeJson(overlaid) } + } + return yield* ProviderShared.invalidRequest("http.body can only overlay JSON object request bodies") + }) + +export const jsonRequestParts = (input: JsonRequestInput) => + Effect.gen(function* () { + const url = applyQuery( + renderEndpoint(input.endpoint, { request: input.request, body: input.body }).toString(), + input.request.http?.query, + ) + const body = yield* bodyWithOverlay(input.body, input.request, input.encodeBody) + const headers = yield* Auth.toEffect(input.auth)({ + request: input.request, + method: "POST", + url, + body: body.bodyText, + headers: Headers.fromInput({ + ...input.headers?.({ request: input.request }), + ...input.request.http?.headers, + }), + }) + return { url, jsonBody: body.jsonBody, bodyText: body.bodyText, headers } + }) + +export interface HttpJsonInput<_Body, Frame> { + readonly framing: FramingDef +} + +export type HttpJsonPatch = Partial> + +export interface HttpJsonTransport extends Transport, Frame> { + readonly with: (patch: HttpJsonPatch) => HttpJsonTransport +} + +export const httpJson = (input: HttpJsonInput): HttpJsonTransport => ({ + id: "http-json", + with: (patch) => httpJson({ ...input, ...patch }), + prepare: (prepareInput) => + jsonRequestParts({ + ...prepareInput, + }).pipe( + Effect.map((parts) => ({ + request: ProviderShared.jsonPost({ url: parts.url, body: parts.bodyText, headers: parts.headers }), + framing: input.framing, + })), + ), + frames: (prepared, request, runtime) => + Stream.unwrap( + runtime.http + .execute(prepared.request) + .pipe( + Effect.map((response) => + prepared.framing.frame( + response.stream.pipe( + Stream.mapError((error) => + ProviderShared.eventError( + `${request.model.provider}/${request.model.route.id}`, + `Failed to read ${request.model.provider}/${request.model.route.id} stream`, + ProviderShared.errorText(error), + ), + ), + ), + ), + ), + ), + ), +}) + +export const sseJson = { + id: "http-json/sse", + with: () => httpJson({ framing: Framing.sse }), +} as const diff --git a/packages/llm/src/route/transport/index.ts b/packages/llm/src/route/transport/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..fde9d6c4154aaff56c7f908d153f03a0a5a7617a --- /dev/null +++ b/packages/llm/src/route/transport/index.ts @@ -0,0 +1,33 @@ +import type { Effect, Stream } from "effect" +import type { Endpoint } from "../endpoint" +import type { Auth } from "../auth" +import type { Interface as RequestExecutorInterface } from "../executor" +import type { Interface as WebSocketExecutorInterface } from "./websocket" +import type { LLMError, LLMRequest } from "../../schema" + +export interface TransportRuntime { + readonly http: RequestExecutorInterface + readonly webSocket?: WebSocketExecutorInterface +} + +export interface Transport { + readonly id: string + readonly prepare: (input: TransportPrepareInput) => Effect.Effect + readonly frames: ( + prepared: Prepared, + request: LLMRequest, + runtime: TransportRuntime, + ) => Stream.Stream +} + +export interface TransportPrepareInput { + readonly body: Body + readonly request: LLMRequest + readonly endpoint: Endpoint + readonly auth: Auth + readonly encodeBody: (body: Body) => string + readonly headers?: (input: { readonly request: LLMRequest }) => Record +} + +export * as HttpTransport from "./http" +export { WebSocketExecutor, WebSocketTransport } from "./websocket" diff --git a/packages/llm/src/route/transport/websocket.ts b/packages/llm/src/route/transport/websocket.ts new file mode 100644 index 0000000000000000000000000000000000000000..310121420c4f9082c8a6b2f77dbfd5ef784ad79f --- /dev/null +++ b/packages/llm/src/route/transport/websocket.ts @@ -0,0 +1,280 @@ +import { Cause, Context, Effect, Layer, Queue, Stream } from "effect" +import { Headers } from "effect/unstable/http" +import { LLMError, TransportReason } from "../../schema" +import * as HttpTransport from "./http" +import type { Transport } from "./index" + +export interface WebSocketRequest { + readonly url: string + readonly headers: Headers.Headers +} + +export interface WebSocketConnection { + readonly sendText: (message: string) => Effect.Effect + readonly messages: Stream.Stream + readonly close: Effect.Effect +} + +export interface Interface { + readonly open: (input: WebSocketRequest) => Effect.Effect +} + +type WebSocketConstructorWithHeaders = new ( + url: string, + options?: { readonly headers?: Headers.Headers }, +) => globalThis.WebSocket + +export class Service extends Context.Service()("@opencode/LLM/WebSocketExecutor") {} + +const transportError = ( + method: string, + message: string, + input: { readonly url?: string; readonly kind?: string } = {}, +) => + new LLMError({ + module: "WebSocketExecutor", + method, + reason: new TransportReason({ message, url: input.url, kind: input.kind }), + }) + +const eventMessage = (event: Event) => { + if ("message" in event && typeof event.message === "string") return event.message + return event.type +} + +const binaryMessage = (data: unknown) => { + if (data instanceof Uint8Array) return data + if (data instanceof ArrayBuffer) return new Uint8Array(data) + if (ArrayBuffer.isView(data)) return new Uint8Array(data.buffer, data.byteOffset, data.byteLength) + return undefined +} + +const waitOpen = (ws: globalThis.WebSocket, input: WebSocketRequest) => { + if (ws.readyState === globalThis.WebSocket.OPEN) return Effect.void + if (ws.readyState === globalThis.WebSocket.CLOSING || ws.readyState === globalThis.WebSocket.CLOSED) { + return Effect.fail( + transportError("open", `WebSocket closed before opening (state ${ws.readyState})`, { + url: input.url, + kind: "open", + }), + ) + } + return Effect.callback((resume, signal) => { + const cleanup = () => { + ws.removeEventListener("open", onOpen) + ws.removeEventListener("error", onError) + ws.removeEventListener("close", onClose) + signal.removeEventListener("abort", onAbort) + } + const onAbort = () => { + cleanup() + if (ws.readyState !== globalThis.WebSocket.CLOSED && ws.readyState !== globalThis.WebSocket.CLOSING) + ws.close(1000) + } + const onOpen = () => { + cleanup() + resume(Effect.void) + } + const onError = (event: Event) => { + cleanup() + resume( + Effect.fail( + transportError("open", `Failed to open WebSocket: ${eventMessage(event)}`, { url: input.url, kind: "open" }), + ), + ) + } + const onClose = (event: CloseEvent) => { + cleanup() + resume( + Effect.fail( + transportError("open", `WebSocket closed before opening with code ${event.code}`, { + url: input.url, + kind: "open", + }), + ), + ) + } + ws.addEventListener("open", onOpen, { once: true }) + ws.addEventListener("error", onError, { once: true }) + ws.addEventListener("close", onClose, { once: true }) + signal.addEventListener("abort", onAbort, { once: true }) + }) +} + +const webSocketUrl = (value: string) => + Effect.try({ + try: () => { + const url = new URL(value) + if (url.protocol === "https:") { + url.protocol = "wss:" + return url.toString() + } + if (url.protocol === "http:") { + url.protocol = "ws:" + return url.toString() + } + throw new Error(`Unsupported WebSocket URL protocol ${url.protocol}`) + }, + catch: (error) => + transportError("prepare", error instanceof Error ? error.message : "Invalid WebSocket URL", { + url: value, + kind: "websocket", + }), + }) + +export const open = (input: WebSocketRequest) => + Effect.try({ + try: () => + new (globalThis.WebSocket as unknown as WebSocketConstructorWithHeaders)(input.url, { headers: input.headers }), + catch: (error) => + transportError("open", error instanceof Error ? error.message : "Failed to construct WebSocket", { + url: input.url, + kind: "open", + }), + }).pipe(Effect.flatMap((ws) => fromWebSocket(ws, input))) + +export const layer: Layer.Layer = Layer.succeed(Service, Service.of({ open })) + +export const fromWebSocket = ( + ws: globalThis.WebSocket, + input: WebSocketRequest, +): Effect.Effect => + Effect.gen(function* () { + yield* waitOpen(ws, input) + const messages = yield* Queue.bounded>(128) + + const onMessage = (event: MessageEvent) => { + if (typeof event.data === "string") return Queue.offerUnsafe(messages, event.data) + const binary = binaryMessage(event.data) + if (binary) return Queue.offerUnsafe(messages, binary) + Queue.failCauseUnsafe( + messages, + Cause.fail( + transportError("message", "Unsupported WebSocket message payload", { url: input.url, kind: "message" }), + ), + ) + } + const onError = (event: Event) => { + Queue.failCauseUnsafe( + messages, + Cause.fail( + transportError("message", `WebSocket error: ${eventMessage(event)}`, { url: input.url, kind: "message" }), + ), + ) + } + const onClose = (event: CloseEvent) => { + if (event.code === 1000 || event.code === 1005) return Queue.endUnsafe(messages) + Queue.failCauseUnsafe( + messages, + Cause.fail( + transportError("message", `WebSocket closed with code ${event.code}`, { url: input.url, kind: "close" }), + ), + ) + } + const cleanup = Effect.sync(() => { + ws.removeEventListener("message", onMessage) + ws.removeEventListener("error", onError) + ws.removeEventListener("close", onClose) + }).pipe(Effect.andThen(Queue.shutdown(messages))) + + ws.addEventListener("message", onMessage) + ws.addEventListener("error", onError) + ws.addEventListener("close", onClose) + + return { + sendText: (message) => + Effect.try({ + try: () => ws.send(message), + catch: (error) => + transportError("sendText", error instanceof Error ? error.message : "Failed to send WebSocket message", { + url: input.url, + kind: "write", + }), + }), + messages: Stream.fromQueue(messages), + close: cleanup.pipe( + Effect.andThen( + Effect.sync(() => { + if (ws.readyState === globalThis.WebSocket.CLOSED || ws.readyState === globalThis.WebSocket.CLOSING) return + ws.close(1000) + }), + ), + ), + } + }) + +export const messageText = (message: string | Uint8Array, decoder: TextDecoder) => + typeof message === "string" ? message : decoder.decode(message) + +export interface JsonPrepared { + readonly url: string + readonly headers: Headers.Headers + readonly message: string +} + +export interface JsonInput { + readonly toMessage: (body: Body | Record) => Effect.Effect + readonly encodeMessage: (message: Message) => string +} + +export type JsonPatch = Partial> + +export interface JsonTransport extends Transport { + readonly with: (patch: JsonPatch) => JsonTransport +} + +export const json = (input: JsonInput): JsonTransport => ({ + id: "websocket-json", + with: (patch) => json({ ...input, ...patch }), + prepare: (prepareInput) => + Effect.gen(function* () { + const parts = yield* HttpTransport.jsonRequestParts({ + ...prepareInput, + }) + return { + url: yield* webSocketUrl(parts.url), + headers: parts.headers, + message: input.encodeMessage(yield* input.toMessage(parts.jsonBody)), + } + }), + frames: (prepared, _request, runtime) => { + const webSocket = runtime.webSocket + if (!webSocket) { + return Stream.fail( + transportError("json", "WebSocket JSON transport requires WebSocketExecutor.Service", { + url: prepared.url, + kind: "websocket", + }), + ) + } + const decoder = new TextDecoder() + return Stream.unwrap( + Effect.gen(function* () { + const connection = yield* Effect.acquireRelease( + webSocket.open({ url: prepared.url, headers: prepared.headers }), + (connection) => connection.close, + ) + yield* connection.sendText(prepared.message) + return connection.messages.pipe(Stream.map((message) => messageText(message, decoder))) + }), + ) + }, +}) + +export const jsonTransport = { + id: "websocket-json", + with: json, +} as const + +export const WebSocketExecutor = { + Service, + layer, + open, + fromWebSocket, + messageText, +} as const + +export const WebSocketTransport = { + json, + jsonTransport, +} as const diff --git a/packages/llm/src/schema/errors.ts b/packages/llm/src/schema/errors.ts new file mode 100644 index 0000000000000000000000000000000000000000..072e4e83892890ba48b25e45eb3cd2592afc9f86 --- /dev/null +++ b/packages/llm/src/schema/errors.ts @@ -0,0 +1,207 @@ +import { Schema } from "effect" +import { ModelID, ProviderID, ProviderMetadata, RouteID } from "./ids" + +export const ProviderFailureClassification = Schema.Literal("context-overflow") +export type ProviderFailureClassification = typeof ProviderFailureClassification.Type + +export class HttpRequestDetails extends Schema.Class("LLM.HttpRequestDetails")({ + method: Schema.String, + url: Schema.String, + headers: Schema.Record(Schema.String, Schema.String), +}) {} + +export class HttpResponseDetails extends Schema.Class("LLM.HttpResponseDetails")({ + status: Schema.Number, + headers: Schema.Record(Schema.String, Schema.String), +}) {} + +export class HttpRateLimitDetails extends Schema.Class("LLM.HttpRateLimitDetails")({ + retryAfterMs: Schema.optional(Schema.Number), + limit: Schema.optional(Schema.Record(Schema.String, Schema.String)), + remaining: Schema.optional(Schema.Record(Schema.String, Schema.String)), + reset: Schema.optional(Schema.Record(Schema.String, Schema.String)), +}) {} + +export class HttpContext extends Schema.Class("LLM.HttpContext")({ + request: HttpRequestDetails, + response: Schema.optional(HttpResponseDetails), + body: Schema.optional(Schema.String), + bodyTruncated: Schema.optional(Schema.Boolean), + requestId: Schema.optional(Schema.String), + rateLimit: Schema.optional(HttpRateLimitDetails), +}) {} + +export class InvalidRequestReason extends Schema.Class("LLM.Error.InvalidRequest")({ + _tag: Schema.tag("InvalidRequest"), + message: Schema.String, + parameter: Schema.optional(Schema.String), + classification: Schema.optional(ProviderFailureClassification), + providerMetadata: Schema.optional(ProviderMetadata), + http: Schema.optional(HttpContext), +}) { + get retryable() { + return false + } +} + +export class NoRouteReason extends Schema.Class("LLM.Error.NoRoute")({ + _tag: Schema.tag("NoRoute"), + route: RouteID, + provider: ProviderID, + model: ModelID, +}) { + get retryable() { + return false + } + + get message() { + return `No LLM route for ${this.provider}/${this.model} using ${this.route}` + } +} + +export class AuthenticationReason extends Schema.Class("LLM.Error.Authentication")({ + _tag: Schema.tag("Authentication"), + message: Schema.String, + kind: Schema.Literals(["missing", "invalid", "expired", "insufficient-permissions", "unknown"]), + providerMetadata: Schema.optional(ProviderMetadata), + http: Schema.optional(HttpContext), +}) { + get retryable() { + return false + } +} + +export class RateLimitReason extends Schema.Class("LLM.Error.RateLimit")({ + _tag: Schema.tag("RateLimit"), + message: Schema.String, + retryAfterMs: Schema.optional(Schema.Number), + rateLimit: Schema.optional(HttpRateLimitDetails), + providerMetadata: Schema.optional(ProviderMetadata), + http: Schema.optional(HttpContext), +}) { + get retryable() { + return true + } +} + +export class QuotaExceededReason extends Schema.Class("LLM.Error.QuotaExceeded")({ + _tag: Schema.tag("QuotaExceeded"), + message: Schema.String, + providerMetadata: Schema.optional(ProviderMetadata), + http: Schema.optional(HttpContext), +}) { + get retryable() { + return false + } +} + +export class ContentPolicyReason extends Schema.Class("LLM.Error.ContentPolicy")({ + _tag: Schema.tag("ContentPolicy"), + message: Schema.String, + providerMetadata: Schema.optional(ProviderMetadata), + http: Schema.optional(HttpContext), +}) { + get retryable() { + return false + } +} + +export class ProviderInternalReason extends Schema.Class("LLM.Error.ProviderInternal")({ + _tag: Schema.tag("ProviderInternal"), + message: Schema.String, + status: Schema.Number, + retryAfterMs: Schema.optional(Schema.Number), + providerMetadata: Schema.optional(ProviderMetadata), + http: Schema.optional(HttpContext), +}) { + get retryable() { + return true + } +} + +export class TransportReason extends Schema.Class("LLM.Error.Transport")({ + _tag: Schema.tag("Transport"), + message: Schema.String, + kind: Schema.optional(Schema.String), + url: Schema.optional(Schema.String), + http: Schema.optional(HttpContext), +}) { + get retryable() { + return false + } +} + +export class InvalidProviderOutputReason extends Schema.Class( + "LLM.Error.InvalidProviderOutput", +)({ + _tag: Schema.tag("InvalidProviderOutput"), + message: Schema.String, + route: Schema.optional(Schema.String), + raw: Schema.optional(Schema.String), + providerMetadata: Schema.optional(ProviderMetadata), +}) { + get retryable() { + return false + } +} + +export class UnknownProviderReason extends Schema.Class("LLM.Error.UnknownProvider")({ + _tag: Schema.tag("UnknownProvider"), + message: Schema.String, + status: Schema.optional(Schema.Number), + providerMetadata: Schema.optional(ProviderMetadata), + http: Schema.optional(HttpContext), +}) { + get retryable() { + return false + } +} + +export const LLMErrorReason = Schema.Union([ + InvalidRequestReason, + NoRouteReason, + AuthenticationReason, + RateLimitReason, + QuotaExceededReason, + ContentPolicyReason, + ProviderInternalReason, + TransportReason, + InvalidProviderOutputReason, + UnknownProviderReason, +]).pipe(Schema.toTaggedUnion("_tag")) +export type LLMErrorReason = Schema.Schema.Type + +export class LLMError extends Schema.TaggedErrorClass()("LLM.Error", { + module: Schema.String, + method: Schema.String, + reason: LLMErrorReason, +}) { + override readonly cause = this.reason + + get retryable() { + return this.reason.retryable + } + + get retryAfterMs() { + return "retryAfterMs" in this.reason ? this.reason.retryAfterMs : undefined + } + + override get message() { + return `${this.module}.${this.method}: ${this.reason.message}` + } +} + +/** + * Failure type for tool execute handlers. Handlers must map their internal + * errors to this shape; the runtime catches `ToolFailure`s and surfaces them + * as `tool-error` events plus a `tool-result` of `type: "error"` so the model + * can self-correct. + * + * Anything thrown or yielded by a handler that is not a `ToolFailure` is + * treated as a defect and fails the stream. + */ +export class ToolFailure extends Schema.TaggedErrorClass()("LLM.ToolFailure", { + message: Schema.String, + error: Schema.optional(Schema.Defect()), + metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)), +}) {} diff --git a/packages/llm/src/schema/events.ts b/packages/llm/src/schema/events.ts new file mode 100644 index 0000000000000000000000000000000000000000..98fcc9a24d417e70589e529fa1a1dd21ef35769c --- /dev/null +++ b/packages/llm/src/schema/events.ts @@ -0,0 +1,618 @@ +import { Schema } from "effect" +import { ContentBlockID, FinishReason, ProtocolID, ProviderMetadata, RouteID, ToolCallID } from "./ids" +import { ModelSchema } from "./options" +import { Message, ToolCallPart, ToolOutput, ToolResultPart, ToolResultValue, type ContentPart } from "./messages" +import { ProviderFailureClassification } from "./errors" + +/** + * Token usage reported by an LLM provider. + * + * **Inclusive totals** (match AI SDK / OpenAI / LangChain convention — a + * reader from any of those ecosystems sees the number they expect): + * + * - `inputTokens` — total prompt tokens, *including* cached reads/writes. + * - `outputTokens` — total output tokens, *including* reasoning. + * - `totalTokens` — provider-supplied total, or `inputTokens + outputTokens`. + * + * **Non-overlapping breakdown** (every field is independently meaningful; + * consumers never have to subtract): + * + * - `nonCachedInputTokens` — the "fresh" portion of the prompt. + * - `cacheReadInputTokens` — input tokens served from cache. + * - `cacheWriteInputTokens` — input tokens written to cache. + * - `reasoningTokens` — subset of `outputTokens` spent on hidden reasoning. + * + * **Invariant**: `nonCachedInputTokens + cacheReadInputTokens + + * cacheWriteInputTokens = inputTokens`, and `reasoningTokens ≤ outputTokens`. + * Each protocol mapper computes whichever side it doesn't get natively, + * with `Math.max(0, …)` clamping for defense against provider bugs. Because + * every breakdown field is stored independently, downstream consumers can + * read whatever they need (cost-by-category, context-pressure, AI-SDK-style + * inclusive total) without ever subtracting — eliminating the underflow + * class of bug where a clamped difference would silently store the wrong + * value. + * + * **Semantics by provider**: + * + * - OpenAI Chat / Responses / Gemini / Bedrock: provider reports inclusive + * `inputTokens` and an inclusive `outputTokens`; mapper subtracts to + * derive the breakdown. + * - Anthropic: provider reports the breakdown natively (`input_tokens` is + * non-cached only); mapper sums to derive the inclusive `inputTokens`. + * Anthropic does *not* break extended-thinking out of `output_tokens`, so + * `reasoningTokens` is `undefined` and `outputTokens` carries the + * combined total — a documented limitation of the Anthropic API. + * + * `providerMetadata` always carries the provider's raw usage payload — + * keyed by provider name (`{ openai: ... }`, `{ anthropic: ... }`, etc.) + * — for fields we don't normalize and for billing-level audit trails. + * Matches the same escape-hatch field on `LLMEvent`. + */ +export class Usage extends Schema.Class("LLM.Usage")({ + inputTokens: Schema.optional(Schema.Number), + outputTokens: Schema.optional(Schema.Number), + nonCachedInputTokens: Schema.optional(Schema.Number), + cacheReadInputTokens: Schema.optional(Schema.Number), + cacheWriteInputTokens: Schema.optional(Schema.Number), + reasoningTokens: Schema.optional(Schema.Number), + totalTokens: Schema.optional(Schema.Number), + providerMetadata: Schema.optional(ProviderMetadata), +}) { + /** + * Visible output tokens — `outputTokens` minus `reasoningTokens`, clamped + * to zero. The one place subtraction happens in this contract; the clamp + * means a provider reporting `reasoningTokens > outputTokens` produces a + * harmless zero rather than a negative that crashes downstream schemas. + */ + get visibleOutputTokens() { + return Math.max(0, (this.outputTokens ?? 0) - (this.reasoningTokens ?? 0)) + } + + static from(input: UsageInput) { + return input instanceof Usage ? input : new Usage(input) + } +} + +export type UsageInput = Usage | ConstructorParameters[0] + +export const StepStart = Schema.Struct({ + type: Schema.tag("step-start"), + index: Schema.Number, +}).annotate({ identifier: "LLM.Event.StepStart" }) +export type StepStart = Schema.Schema.Type + +export const TextStart = Schema.Struct({ + type: Schema.tag("text-start"), + id: ContentBlockID, + providerMetadata: Schema.optional(ProviderMetadata), +}).annotate({ identifier: "LLM.Event.TextStart" }) +export type TextStart = Schema.Schema.Type + +export const TextDelta = Schema.Struct({ + type: Schema.tag("text-delta"), + id: ContentBlockID, + text: Schema.String, + providerMetadata: Schema.optional(ProviderMetadata), +}).annotate({ identifier: "LLM.Event.TextDelta" }) +export type TextDelta = Schema.Schema.Type + +export const TextEnd = Schema.Struct({ + type: Schema.tag("text-end"), + id: ContentBlockID, + providerMetadata: Schema.optional(ProviderMetadata), +}).annotate({ identifier: "LLM.Event.TextEnd" }) +export type TextEnd = Schema.Schema.Type + +export const ReasoningStart = Schema.Struct({ + type: Schema.tag("reasoning-start"), + id: ContentBlockID, + providerMetadata: Schema.optional(ProviderMetadata), +}).annotate({ identifier: "LLM.Event.ReasoningStart" }) +export type ReasoningStart = Schema.Schema.Type + +export const ReasoningDelta = Schema.Struct({ + type: Schema.tag("reasoning-delta"), + id: ContentBlockID, + text: Schema.String, + providerMetadata: Schema.optional(ProviderMetadata), +}).annotate({ identifier: "LLM.Event.ReasoningDelta" }) +export type ReasoningDelta = Schema.Schema.Type + +export const ReasoningEnd = Schema.Struct({ + type: Schema.tag("reasoning-end"), + id: ContentBlockID, + providerMetadata: Schema.optional(ProviderMetadata), +}).annotate({ identifier: "LLM.Event.ReasoningEnd" }) +export type ReasoningEnd = Schema.Schema.Type + +export const ToolInputStart = Schema.Struct({ + type: Schema.tag("tool-input-start"), + id: ToolCallID, + name: Schema.String, + providerMetadata: Schema.optional(ProviderMetadata), +}).annotate({ identifier: "LLM.Event.ToolInputStart" }) +export type ToolInputStart = Schema.Schema.Type + +export const ToolInputDelta = Schema.Struct({ + type: Schema.tag("tool-input-delta"), + id: ToolCallID, + name: Schema.String, + text: Schema.String, +}).annotate({ identifier: "LLM.Event.ToolInputDelta" }) +export type ToolInputDelta = Schema.Schema.Type + +export const ToolInputEnd = Schema.Struct({ + type: Schema.tag("tool-input-end"), + id: ToolCallID, + name: Schema.String, + providerMetadata: Schema.optional(ProviderMetadata), +}).annotate({ identifier: "LLM.Event.ToolInputEnd" }) +export type ToolInputEnd = Schema.Schema.Type + +export const ToolCall = Schema.Struct({ + type: Schema.tag("tool-call"), + id: ToolCallID, + name: Schema.String, + input: Schema.Unknown, + providerExecuted: Schema.optional(Schema.Boolean), + providerMetadata: Schema.optional(ProviderMetadata), +}).annotate({ identifier: "LLM.Event.ToolCall" }) +export type ToolCall = Schema.Schema.Type + +export const ToolResult = Schema.Struct({ + type: Schema.tag("tool-result"), + id: ToolCallID, + name: Schema.String, + result: ToolResultValue, + output: Schema.optional(ToolOutput), + providerExecuted: Schema.optional(Schema.Boolean), + providerMetadata: Schema.optional(ProviderMetadata), +}).annotate({ identifier: "LLM.Event.ToolResult" }) +export type ToolResult = Schema.Schema.Type + +export const ToolError = Schema.Struct({ + type: Schema.tag("tool-error"), + id: ToolCallID, + name: Schema.String, + message: Schema.String, + error: Schema.optional(Schema.Defect()), + providerMetadata: Schema.optional(ProviderMetadata), +}).annotate({ identifier: "LLM.Event.ToolError" }) +export type ToolError = Schema.Schema.Type + +export const StepFinish = Schema.Struct({ + type: Schema.tag("step-finish"), + index: Schema.Number, + reason: FinishReason, + usage: Schema.optional(Usage), + providerMetadata: Schema.optional(ProviderMetadata), +}).annotate({ identifier: "LLM.Event.StepFinish" }) +export type StepFinish = Schema.Schema.Type + +export const Finish = Schema.Struct({ + type: Schema.tag("finish"), + reason: FinishReason, + usage: Schema.optional(Usage), + providerMetadata: Schema.optional(ProviderMetadata), +}).annotate({ identifier: "LLM.Event.Finish" }) +export type Finish = Schema.Schema.Type + +export const ProviderErrorEvent = Schema.Struct({ + type: Schema.tag("provider-error"), + message: Schema.String, + classification: Schema.optional(ProviderFailureClassification), + retryable: Schema.optional(Schema.Boolean), + providerMetadata: Schema.optional(ProviderMetadata), +}).annotate({ identifier: "LLM.Event.ProviderError" }) +export type ProviderErrorEvent = Schema.Schema.Type + +const llmEventTagged = Schema.Union([ + StepStart, + TextStart, + TextDelta, + TextEnd, + ReasoningStart, + ReasoningDelta, + ReasoningEnd, + ToolInputStart, + ToolInputDelta, + ToolInputEnd, + ToolCall, + ToolResult, + ToolError, + StepFinish, + Finish, + ProviderErrorEvent, +]).pipe(Schema.toTaggedUnion("type")) + +type WithID = Omit & { readonly id: ID | string } +type WithUsage = Omit & { + readonly usage?: UsageInput +} + +const contentBlockID = (value: ContentBlockID | string) => ContentBlockID.make(value) +const toolCallID = (value: ToolCallID | string) => ToolCallID.make(value) + +/** + * camelCase aliases for `LLMEvent.guards` (provided by `Schema.toTaggedUnion`). + * Lets consumers write `events.filter(LLMEvent.is.toolCall)` instead of + * `events.filter(LLMEvent.guards["tool-call"])`. + */ +export const LLMEvent = Object.assign(llmEventTagged, { + stepStart: StepStart.make, + textStart: (input: WithID) => TextStart.make({ ...input, id: contentBlockID(input.id) }), + textDelta: (input: WithID) => TextDelta.make({ ...input, id: contentBlockID(input.id) }), + textEnd: (input: WithID) => TextEnd.make({ ...input, id: contentBlockID(input.id) }), + reasoningStart: (input: WithID) => + ReasoningStart.make({ ...input, id: contentBlockID(input.id) }), + reasoningDelta: (input: WithID) => + ReasoningDelta.make({ ...input, id: contentBlockID(input.id) }), + reasoningEnd: (input: WithID) => + ReasoningEnd.make({ ...input, id: contentBlockID(input.id) }), + toolInputStart: (input: WithID) => + ToolInputStart.make({ ...input, id: toolCallID(input.id) }), + toolInputDelta: (input: WithID) => + ToolInputDelta.make({ ...input, id: toolCallID(input.id) }), + toolInputEnd: (input: WithID) => ToolInputEnd.make({ ...input, id: toolCallID(input.id) }), + toolCall: (input: WithID) => ToolCall.make({ ...input, id: toolCallID(input.id) }), + toolResult: (input: WithID) => + ToolResult.make({ + ...input, + id: toolCallID(input.id), + output: input.output === undefined ? undefined : ToolOutput.make(input.output.structured, input.output.content), + }), + toolError: (input: WithID) => ToolError.make({ ...input, id: toolCallID(input.id) }), + stepFinish: (input: WithUsage) => + StepFinish.make({ + ...input, + usage: input.usage === undefined ? undefined : Usage.from(input.usage), + }), + finish: (input: WithUsage) => + Finish.make({ + ...input, + usage: input.usage === undefined ? undefined : Usage.from(input.usage), + }), + providerError: ProviderErrorEvent.make, + is: { + stepStart: llmEventTagged.guards["step-start"], + textStart: llmEventTagged.guards["text-start"], + textDelta: llmEventTagged.guards["text-delta"], + textEnd: llmEventTagged.guards["text-end"], + reasoningStart: llmEventTagged.guards["reasoning-start"], + reasoningDelta: llmEventTagged.guards["reasoning-delta"], + reasoningEnd: llmEventTagged.guards["reasoning-end"], + toolInputStart: llmEventTagged.guards["tool-input-start"], + toolInputDelta: llmEventTagged.guards["tool-input-delta"], + toolInputEnd: llmEventTagged.guards["tool-input-end"], + toolCall: llmEventTagged.guards["tool-call"], + toolResult: llmEventTagged.guards["tool-result"], + toolError: llmEventTagged.guards["tool-error"], + stepFinish: llmEventTagged.guards["step-finish"], + finish: llmEventTagged.guards.finish, + providerError: llmEventTagged.guards["provider-error"], + }, +}) +export type LLMEvent = Schema.Schema.Type + +export class PreparedRequest extends Schema.Class("LLM.PreparedRequest")({ + id: Schema.String, + route: RouteID, + protocol: ProtocolID, + model: ModelSchema, + body: Schema.Unknown, + metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)), +}) {} + +/** + * A `PreparedRequest` whose `body` is typed as `Body`. Use with the generic + * on `LLMClient.prepare(...)` when the caller knows which route their + * request will resolve to and wants its native shape statically exposed + * (debug UIs, request previews, plan rendering). + * + * The runtime body is identical — the route still emits `body: unknown` — so + * this is a type-level assertion the caller makes about what they expect to + * find. The prepare runtime does not validate the assertion. + */ +export type PreparedRequestOf = Omit & { + readonly body: Body +} + +const responseText = (events: ReadonlyArray) => + events + .filter(LLMEvent.is.textDelta) + .map((event) => event.text) + .join("") + +const responseReasoning = (events: ReadonlyArray) => + events + .filter(LLMEvent.is.reasoningDelta) + .map((event) => event.text) + .join("") + +const responseUsage = (events: ReadonlyArray) => + events.reduce( + (usage, event) => ("usage" in event && event.usage !== undefined ? event.usage : usage), + undefined, + ) + +interface ContentAssembly { + readonly contentIndex: number + readonly text: string + readonly providerMetadata?: ProviderMetadata +} + +interface ToolInputAssembly { + readonly name: string + readonly text: string + readonly providerMetadata?: ProviderMetadata +} + +interface ResponseState { + readonly events: ReadonlyArray + readonly message: Message + readonly usage?: Usage + readonly finishReason?: FinishReason + readonly textParts: Readonly> + readonly reasoningParts: Readonly> + readonly toolInputs: Readonly> +} + +const emptyResponseState = (): ResponseState => ({ + events: [], + message: Message.assistant([]), + textParts: {}, + reasoningParts: {}, + toolInputs: {}, +}) + +const appendEvent = (state: ResponseState, event: LLMEvent): ResponseState => { + const events = [...state.events, event] + if (LLMEvent.is.finish(event)) { + return { + ...state, + events, + usage: event.usage ?? state.usage, + finishReason: event.reason, + } + } + if (LLMEvent.is.providerError(event)) { + return { + ...state, + events, + finishReason: state.finishReason ?? "error", + } + } + return { + ...state, + events, + usage: "usage" in event && event.usage !== undefined ? event.usage : state.usage, + } +} + +const textContent = (text: string, providerMetadata: ProviderMetadata | undefined): ContentPart => + providerMetadata === undefined ? { type: "text", text } : { type: "text", text, providerMetadata } + +const reasoningContent = (text: string, providerMetadata: ProviderMetadata | undefined): ContentPart => + providerMetadata === undefined ? { type: "reasoning", text } : { type: "reasoning", text, providerMetadata } + +const contentWith = (state: ResponseState, content: ReadonlyArray): ResponseState => ({ + ...state, + message: Message.assistant(content), +}) + +const appendContent = (state: ResponseState, part: ContentPart) => contentWith(state, [...state.message.content, part]) + +const replaceContent = (state: ResponseState, index: number, part: ContentPart) => + contentWith( + state, + state.message.content.map((item, itemIndex) => (itemIndex === index ? part : item)), + ) + +const ensureText = (state: ResponseState, id: string, providerMetadata?: ProviderMetadata): ResponseState => { + if (state.textParts[id]) return state + return { + ...appendContent(state, textContent("", providerMetadata)), + textParts: { + ...state.textParts, + [id]: { contentIndex: state.message.content.length, text: "", providerMetadata }, + }, + } +} + +const reduceTextDelta = (state: ResponseState, event: TextDelta): ResponseState => { + const started = ensureText(state, event.id, event.providerMetadata) + const current = started.textParts[event.id] + if (!current) return started + const text = current.text + event.text + const providerMetadata = event.providerMetadata ?? current.providerMetadata + return { + ...replaceContent(started, current.contentIndex, textContent(text, providerMetadata)), + textParts: { ...started.textParts, [event.id]: { ...current, text, providerMetadata } }, + } +} + +const reduceTextEnd = (state: ResponseState, event: TextEnd): ResponseState => { + const current = state.textParts[event.id] + if (!current) return state + const providerMetadata = event.providerMetadata ?? current.providerMetadata + return { + ...replaceContent(state, current.contentIndex, textContent(current.text, providerMetadata)), + textParts: { ...state.textParts, [event.id]: { ...current, providerMetadata } }, + } +} + +const ensureReasoning = (state: ResponseState, id: string, providerMetadata?: ProviderMetadata): ResponseState => { + if (state.reasoningParts[id]) return state + return { + ...appendContent(state, reasoningContent("", providerMetadata)), + reasoningParts: { + ...state.reasoningParts, + [id]: { contentIndex: state.message.content.length, text: "", providerMetadata }, + }, + } +} + +const reduceReasoningDelta = (state: ResponseState, event: ReasoningDelta): ResponseState => { + const started = ensureReasoning(state, event.id, event.providerMetadata) + const current = started.reasoningParts[event.id] + if (!current) return started + const text = current.text + event.text + const providerMetadata = event.providerMetadata ?? current.providerMetadata + return { + ...replaceContent(started, current.contentIndex, reasoningContent(text, providerMetadata)), + reasoningParts: { ...started.reasoningParts, [event.id]: { ...current, text, providerMetadata } }, + } +} + +const reduceReasoningEnd = (state: ResponseState, event: ReasoningEnd): ResponseState => { + const current = state.reasoningParts[event.id] + if (!current) return state + const providerMetadata = event.providerMetadata ?? current.providerMetadata + return { + ...replaceContent(state, current.contentIndex, reasoningContent(current.text, providerMetadata)), + reasoningParts: { ...state.reasoningParts, [event.id]: { ...current, providerMetadata } }, + } +} + +const reduceToolInputStart = (state: ResponseState, event: ToolInputStart): ResponseState => ({ + ...state, + toolInputs: { + ...state.toolInputs, + [event.id]: { name: event.name, text: "", providerMetadata: event.providerMetadata }, + }, +}) + +const reduceToolInputDelta = (state: ResponseState, event: ToolInputDelta): ResponseState => { + const current = state.toolInputs[event.id] ?? { name: event.name, text: "" } + return { + ...state, + toolInputs: { ...state.toolInputs, [event.id]: { ...current, text: current.text + event.text } }, + } +} + +const reduceToolInputEnd = (state: ResponseState, event: ToolInputEnd): ResponseState => { + const current = state.toolInputs[event.id] ?? { name: event.name, text: "" } + return { + ...state, + toolInputs: { + ...state.toolInputs, + [event.id]: { + ...current, + name: event.name, + providerMetadata: event.providerMetadata ?? current.providerMetadata, + }, + }, + } +} + +const toolCallContent = (event: ToolCall): ContentPart => + ToolCallPart.make({ + id: event.id, + name: event.name, + input: event.input, + ...(event.providerExecuted === undefined ? {} : { providerExecuted: event.providerExecuted }), + ...(event.providerMetadata === undefined ? {} : { providerMetadata: event.providerMetadata }), + }) + +const toolResultContent = (event: ToolResult): ContentPart => + ToolResultPart.make({ + id: event.id, + name: event.name, + result: event.result, + ...(event.providerExecuted === undefined ? {} : { providerExecuted: event.providerExecuted }), + ...(event.providerMetadata === undefined ? {} : { providerMetadata: event.providerMetadata }), + }) + +const reduceToolCall = (state: ResponseState, event: ToolCall): ResponseState => { + const { [event.id]: _finished, ...toolInputs } = state.toolInputs + return { ...appendContent(state, toolCallContent(event)), toolInputs } +} + +const reduceResponseState = (state: ResponseState, event: LLMEvent): ResponseState => { + const next = appendEvent(state, event) + switch (event.type) { + case "text-start": + return ensureText(next, event.id, event.providerMetadata) + case "text-delta": + return reduceTextDelta(next, event) + case "text-end": + return reduceTextEnd(next, event) + case "reasoning-start": + return ensureReasoning(next, event.id, event.providerMetadata) + case "reasoning-delta": + return reduceReasoningDelta(next, event) + case "reasoning-end": + return reduceReasoningEnd(next, event) + case "tool-input-start": + return reduceToolInputStart(next, event) + case "tool-input-delta": + return reduceToolInputDelta(next, event) + case "tool-input-end": + return reduceToolInputEnd(next, event) + case "tool-call": + return reduceToolCall(next, event) + case "tool-result": + return appendContent(next, toolResultContent(event)) + default: + return next + } +} + +export class LLMResponse extends Schema.Class("LLM.Response")({ + message: Message, + events: Schema.Array(LLMEvent), + usage: Schema.optional(Usage), + finishReason: FinishReason, +}) { + /** Concatenated assistant text assembled from streamed `text-delta` events. */ + get text() { + return responseText(this.events) + } + + /** Concatenated reasoning text assembled from streamed `reasoning-delta` events. */ + get reasoning() { + return responseReasoning(this.events) + } + + /** Completed tool calls emitted by the provider. */ + get toolCalls() { + return this.events.filter(LLMEvent.is.toolCall) + } +} + +export namespace LLMResponse { + export type State = ResponseState + export type Output = LLMResponse | { readonly events: ReadonlyArray; readonly usage?: Usage } + + /** Initial reducer state for assembling one provider attempt. */ + export const empty = emptyResponseState + + /** Purely fold one provider-neutral event into the attempt assembly state. */ + export const reduce = reduceResponseState + + /** Return a completed response only after a terminal finish or provider error. */ + export const complete = (state: State): LLMResponse | undefined => + state.finishReason === undefined + ? undefined + : new LLMResponse({ + message: state.message, + events: [...state.events], + usage: state.usage, + finishReason: state.finishReason, + }) + + /** Convenience reducer for callers that already have a collected event list. */ + export const fromEvents = (events: ReadonlyArray) => complete(events.reduce(reduce, empty())) + + /** Concatenate assistant text from a response or collected event list. */ + export const text = (response: Output) => responseText(response.events) + + /** Return response usage, falling back to the latest usage-bearing event. */ + export const usage = (response: Output) => response.usage ?? responseUsage(response.events) + + /** Return completed tool calls from a response or collected event list. */ + export const toolCalls = (response: Output) => response.events.filter(LLMEvent.is.toolCall) + + /** Concatenate reasoning text from a response or collected event list. */ + export const reasoning = (response: Output) => responseReasoning(response.events) +} diff --git a/packages/llm/src/schema/ids.ts b/packages/llm/src/schema/ids.ts new file mode 100644 index 0000000000000000000000000000000000000000..7eb7409802cd9bc4c79682276eb8b7f6d42aa1ef --- /dev/null +++ b/packages/llm/src/schema/ids.ts @@ -0,0 +1,43 @@ +import { Schema } from "effect" +import { ProviderMetadata } from "@opencode-ai/schema/llm" + +export { ProviderMetadata } + +/** Stable string identifier for a protocol implementation. */ +export const ProtocolID = Schema.String +export type ProtocolID = Schema.Schema.Type + +/** Stable string identifier for the runnable route. */ +export const RouteID = Schema.String +export type RouteID = Schema.Schema.Type + +export const ModelID = Schema.String.pipe(Schema.brand("LLM.ModelID")) +export type ModelID = typeof ModelID.Type + +export const ProviderID = Schema.String.pipe(Schema.brand("LLM.ProviderID")) +export type ProviderID = typeof ProviderID.Type + +export const ResponseID = Schema.String +export type ResponseID = Schema.Schema.Type + +export const ContentBlockID = Schema.String +export type ContentBlockID = Schema.Schema.Type + +export const ToolCallID = Schema.String +export type ToolCallID = Schema.Schema.Type + +export const ReasoningEfforts = ["none", "minimal", "low", "medium", "high", "xhigh", "max"] as const +export const ReasoningEffort = Schema.Literals(ReasoningEfforts) +export type ReasoningEffort = Schema.Schema.Type + +export const TextVerbosity = Schema.Literals(["low", "medium", "high"]) +export type TextVerbosity = Schema.Schema.Type + +export const MessageRole = Schema.Literals(["system", "user", "assistant", "tool"]) +export type MessageRole = Schema.Schema.Type + +export const FinishReason = Schema.Literals(["stop", "length", "tool-calls", "content-filter", "error", "unknown"]) +export type FinishReason = Schema.Schema.Type + +export const JsonSchema = Schema.Record(Schema.String, Schema.Unknown) +export type JsonSchema = Schema.Schema.Type diff --git a/packages/llm/src/schema/index.ts b/packages/llm/src/schema/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..0c0fede8fa79366bd6b086551889e0aaea9e7e8d --- /dev/null +++ b/packages/llm/src/schema/index.ts @@ -0,0 +1,5 @@ +export * from "./ids" +export * from "./options" +export * from "./messages" +export * from "./events" +export * from "./errors" diff --git a/packages/llm/src/schema/messages.ts b/packages/llm/src/schema/messages.ts new file mode 100644 index 0000000000000000000000000000000000000000..4a9de3a735e48faa98e8bdf26a4409743d8328cf --- /dev/null +++ b/packages/llm/src/schema/messages.ts @@ -0,0 +1,312 @@ +import { Schema } from "effect" +import { ToolContent, ToolFileContent, ToolTextContent } from "@opencode-ai/schema/llm" +import { JsonSchema, MessageRole, ProviderMetadata } from "./ids" +import { CacheHint, CachePolicy, GenerationOptions, HttpOptions, ModelSchema, ProviderOptions } from "./options" +import { isRecord } from "../utils/record" + +const systemPartSchema = Schema.Struct({ + type: Schema.Literal("text"), + text: Schema.String, + cache: Schema.optional(CacheHint), + metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)), +}).annotate({ identifier: "LLM.SystemPart" }) +export type SystemPart = Schema.Schema.Type + +const makeSystemPart = (text: string): SystemPart => ({ type: "text", text }) + +export const SystemPart = Object.assign(systemPartSchema, { + make: makeSystemPart, + content: (input?: string | SystemPart | ReadonlyArray) => { + if (input === undefined) return [] + return typeof input === "string" ? [makeSystemPart(input)] : Array.isArray(input) ? [...input] : [input] + }, +}) + +export const TextPart = Schema.Struct({ + type: Schema.Literal("text"), + text: Schema.String, + cache: Schema.optional(CacheHint), + metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)), + providerMetadata: Schema.optional(ProviderMetadata), +}).annotate({ identifier: "LLM.Content.Text" }) +export type TextPart = Schema.Schema.Type + +export const MediaPart = Schema.Struct({ + type: Schema.Literal("media"), + mediaType: Schema.String, + data: Schema.Union([Schema.String, Schema.Uint8Array]), + filename: Schema.optional(Schema.String), + metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)), +}).annotate({ identifier: "LLM.Content.Media" }) +export type MediaPart = Schema.Schema.Type + +export { ToolContent, ToolFileContent, ToolTextContent } + +const isToolResultValue = (value: unknown): value is ToolResultValue => + isRecord(value) && + (value.type === "text" || value.type === "json" || value.type === "error" || value.type === "content") && + "value" in value + +export const ToolResultValue = Object.assign( + Schema.Union([ + Schema.Struct({ + type: Schema.Literal("json"), + value: Schema.Unknown, + }), + Schema.Struct({ + type: Schema.Literal("text"), + value: Schema.Unknown, + }), + Schema.Struct({ + type: Schema.Literal("error"), + value: Schema.Unknown, + }), + Schema.Struct({ + type: Schema.Literal("content"), + value: Schema.Array(ToolContent), + }), + ]).annotate({ identifier: "LLM.ToolResult" }), + { + is: isToolResultValue, + make: (value: unknown, type: ToolResultValue["type"] = "json"): ToolResultValue => { + if (isToolResultValue(value)) return value + if (type === "content") return { type, value: Array.isArray(value) ? value : [] } + return { type, value } + }, + }, +) +export type ToolResultValue = Schema.Schema.Type + +export interface ToolOutput { + readonly structured: unknown + readonly content: ReadonlyArray +} + +export const ToolOutput = Object.assign( + Schema.Struct({ + structured: Schema.Unknown, + content: Schema.Array(ToolContent), + }).annotate({ identifier: "LLM.ToolOutput" }), + { + make: (structured: unknown, content: ReadonlyArray = []): ToolOutput => ({ structured, content }), + fromResultValue: (result: ToolResultValue): ToolOutput | undefined => { + switch (result.type) { + case "json": + return { structured: result.value, content: [] } + case "text": + return { structured: {}, content: [{ type: "text", text: toolResultText(result.value) }] } + case "content": + return { structured: {}, content: result.value } + case "error": + return undefined + } + }, + toResultValue: (output: ToolOutput): ToolResultValue => { + if (output.content.length === 0) return { type: "json", value: output.structured } + if (output.content.length === 1 && output.content[0]?.type === "text") + return { type: "text", value: output.content[0].text } + return { type: "content", value: output.content } + }, + }, +) + +const toolResultText = (value: unknown) => { + if (typeof value === "string") return value + try { + return JSON.stringify(value) ?? String(value) + } catch { + return String(value) + } +} + +export const ToolCallPart = Object.assign( + Schema.Struct({ + type: Schema.Literal("tool-call"), + id: Schema.String, + name: Schema.String, + input: Schema.Unknown, + providerExecuted: Schema.optional(Schema.Boolean), + metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)), + providerMetadata: Schema.optional(ProviderMetadata), + }).annotate({ identifier: "LLM.Content.ToolCall" }), + { + make: (input: Omit): ToolCallPart => ({ type: "tool-call", ...input }), + }, +) +export type ToolCallPart = Schema.Schema.Type + +export const ToolResultPart = Object.assign( + Schema.Struct({ + type: Schema.Literal("tool-result"), + id: Schema.String, + name: Schema.String, + result: ToolResultValue, + providerExecuted: Schema.optional(Schema.Boolean), + cache: Schema.optional(CacheHint), + metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)), + providerMetadata: Schema.optional(ProviderMetadata), + }).annotate({ identifier: "LLM.Content.ToolResult" }), + { + make: ( + input: Omit & { + readonly result: unknown + readonly resultType?: ToolResultValue["type"] + }, + ): ToolResultPart => ({ + type: "tool-result", + id: input.id, + name: input.name, + result: ToolResultValue.make(input.result, input.resultType), + providerExecuted: input.providerExecuted, + cache: input.cache, + metadata: input.metadata, + providerMetadata: input.providerMetadata, + }), + }, +) +export type ToolResultPart = Schema.Schema.Type + +export const ReasoningPart = Schema.Struct({ + type: Schema.Literal("reasoning"), + text: Schema.String, + encrypted: Schema.optional(Schema.String), + metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)), + providerMetadata: Schema.optional(ProviderMetadata), +}).annotate({ identifier: "LLM.Content.Reasoning" }) +export type ReasoningPart = Schema.Schema.Type + +export const ContentPart = Schema.Union([TextPart, MediaPart, ToolCallPart, ToolResultPart, ReasoningPart]).pipe( + Schema.toTaggedUnion("type"), +) +export type ContentPart = Schema.Schema.Type + +export class Message extends Schema.Class("LLM.Message")({ + id: Schema.optional(Schema.String), + role: MessageRole, + content: Schema.Array(ContentPart), + metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)), + native: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)), +}) {} + +export namespace Message { + export type ContentInput = string | ContentPart | ReadonlyArray + export type SystemContentInput = string | TextPart | ReadonlyArray + export type Input = Omit[0], "content"> & { + readonly content: ContentInput + } + + export const text = (value: string): ContentPart => ({ type: "text", text: value }) + + export const content = (input: ContentInput) => + typeof input === "string" ? [text(input)] : Array.isArray(input) ? [...input] : [input] + + export const make = (input: Message | Input) => { + if (input instanceof Message) return input + return new Message({ ...input, content: content(input.content) }) + } + + export const user = (content: ContentInput) => make({ role: "user", content }) + + export const assistant = (content: ContentInput) => make({ role: "assistant", content }) + + /** + * Add an operator-authored instruction at this chronological point in the + * conversation. This is distinct from the initial `LLMRequest.system` + * prompt. Keep raw retrieved, tool, and web content out of privileged system + * updates; pass that untrusted content through ordinary user/tool channels. + */ + export const system = (content: SystemContentInput) => make({ role: "system", content }) + + export const tool = (result: ToolResultPart | Parameters[0]) => + make({ role: "tool", content: ["type" in result ? result : ToolResultPart.make(result)] }) +} + +export class ToolDefinition extends Schema.Class("LLM.ToolDefinition")({ + name: Schema.String, + description: Schema.String, + inputSchema: JsonSchema, + outputSchema: Schema.optional(JsonSchema), + cache: Schema.optional(CacheHint), + metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)), + native: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)), +}) {} + +export namespace ToolDefinition { + export type Input = ToolDefinition | ConstructorParameters[0] + + /** Normalize tool definition input into the canonical `ToolDefinition` class. */ + export const make = (input: Input) => (input instanceof ToolDefinition ? input : new ToolDefinition(input)) +} + +export class ToolChoice extends Schema.Class("LLM.ToolChoice")({ + type: Schema.Literals(["auto", "none", "required", "tool"]), + name: Schema.optional(Schema.String), +}) {} + +export namespace ToolChoice { + export type Mode = Exclude + export type Input = ToolChoice | ConstructorParameters[0] | ToolDefinition | string + + const isMode = (value: string): value is Mode => value === "auto" || value === "none" || value === "required" + + /** Select a specific named tool. */ + export const named = (value: string) => new ToolChoice({ type: "tool", name: value }) + + /** Normalize ergonomic tool-choice inputs into the canonical `ToolChoice` class. */ + export const make = (input: Input) => { + if (input instanceof ToolChoice) return input + if (input instanceof ToolDefinition) return named(input.name) + if (typeof input === "string") return isMode(input) ? new ToolChoice({ type: input }) : named(input) + return new ToolChoice(input) + } +} + +export const ResponseFormat = Schema.Union([ + Schema.Struct({ type: Schema.Literal("text") }), + Schema.Struct({ type: Schema.Literal("json"), schema: JsonSchema }), + Schema.Struct({ type: Schema.Literal("tool"), tool: ToolDefinition }), +]).pipe(Schema.toTaggedUnion("type")) +export type ResponseFormat = Schema.Schema.Type + +export class LLMRequest extends Schema.Class("LLM.Request")({ + id: Schema.optional(Schema.String), + model: ModelSchema, + system: Schema.Array(SystemPart), + messages: Schema.Array(Message), + tools: Schema.Array(ToolDefinition), + toolChoice: Schema.optional(ToolChoice), + generation: Schema.optional(GenerationOptions), + providerOptions: Schema.optional(ProviderOptions), + http: Schema.optional(HttpOptions), + responseFormat: Schema.optional(ResponseFormat), + cache: Schema.optional(CachePolicy), + metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)), +}) {} + +export namespace LLMRequest { + export type Input = ConstructorParameters[0] + + export const input = (request: LLMRequest): Input => ({ + id: request.id, + model: request.model, + system: request.system, + messages: request.messages, + tools: request.tools, + toolChoice: request.toolChoice, + generation: request.generation, + providerOptions: request.providerOptions, + http: request.http, + responseFormat: request.responseFormat, + cache: request.cache, + metadata: request.metadata, + }) + + export const update = (request: LLMRequest, patch: Partial) => { + if (Object.keys(patch).length === 0) return request + return new LLMRequest({ + ...input(request), + ...patch, + model: patch.model ?? request.model, + }) + } +} diff --git a/packages/llm/src/schema/options.ts b/packages/llm/src/schema/options.ts new file mode 100644 index 0000000000000000000000000000000000000000..6d11333b536de11b99657edc15791247fa1aaa3c --- /dev/null +++ b/packages/llm/src/schema/options.ts @@ -0,0 +1,276 @@ +import { Schema } from "effect" +import { JsonSchema, ModelID, ProviderID } from "./ids" +import type { AnyRoute } from "../route/client" +import { isRecord } from "../utils/record" + +export const mergeJsonRecords = ( + ...items: ReadonlyArray | undefined> +): Record | undefined => { + const defined = items.filter((item): item is Record => item !== undefined) + if (defined.length === 0) return undefined + if (defined.length === 1 && Object.values(defined[0]).every((value) => value !== undefined)) return defined[0] + const result: Record = {} + for (const item of defined) { + for (const [key, value] of Object.entries(item)) { + if (value === undefined) continue + result[key] = isRecord(result[key]) && isRecord(value) ? mergeJsonRecords(result[key], value) : value + } + } + return Object.keys(result).length === 0 ? undefined : result +} + +const mergeStringRecords = ( + ...items: ReadonlyArray | undefined> +): Record | undefined => { + const defined = items.filter((item): item is Record => item !== undefined) + if (defined.length === 0) return undefined + if (defined.length === 1) return defined[0] + const result = Object.fromEntries( + defined.flatMap((item) => + Object.entries(item).filter((entry): entry is [string, string] => entry[1] !== undefined), + ), + ) + return Object.keys(result).length === 0 ? undefined : result +} + +export const ProviderOptions = Schema.Record(Schema.String, Schema.Record(Schema.String, Schema.Unknown)) +export type ProviderOptions = Schema.Schema.Type + +export const mergeProviderOptions = ( + ...items: ReadonlyArray +): ProviderOptions | undefined => { + const result: Record> = {} + for (const item of items) { + if (!item) continue + for (const [provider, options] of Object.entries(item)) { + const merged = mergeJsonRecords(result[provider], options) + if (merged) result[provider] = merged + } + } + return Object.keys(result).length === 0 ? undefined : result +} + +export class HttpOptions extends Schema.Class("LLM.HttpOptions")({ + body: Schema.optional(JsonSchema), + headers: Schema.optional(Schema.Record(Schema.String, Schema.String)), + query: Schema.optional(Schema.Record(Schema.String, Schema.String)), +}) {} + +export namespace HttpOptions { + export type Input = HttpOptions | ConstructorParameters[0] + + /** Normalize HTTP option input into the canonical `HttpOptions` class. */ + export const make = (input: Input) => (input instanceof HttpOptions ? input : new HttpOptions(input)) +} + +export const mergeHttpOptions = (...items: ReadonlyArray): HttpOptions | undefined => { + const body = mergeJsonRecords(...items.map((item) => item?.body)) + const headers = mergeStringRecords(...items.map((item) => item?.headers)) + const query = mergeStringRecords(...items.map((item) => item?.query)) + if (!body && !headers && !query) return undefined + return new HttpOptions({ body, headers, query }) +} + +export class GenerationOptions extends Schema.Class("LLM.GenerationOptions")({ + maxTokens: Schema.optional(Schema.Number), + temperature: Schema.optional(Schema.Number), + topP: Schema.optional(Schema.Number), + topK: Schema.optional(Schema.Number), + frequencyPenalty: Schema.optional(Schema.Number), + presencePenalty: Schema.optional(Schema.Number), + seed: Schema.optional(Schema.Number), + stop: Schema.optional(Schema.Array(Schema.String)), +}) {} + +export namespace GenerationOptions { + export type Input = GenerationOptions | ConstructorParameters[0] + + /** Normalize generation option input into the canonical `GenerationOptions` class. */ + export const make = (input: Input = {}) => (input instanceof GenerationOptions ? input : new GenerationOptions(input)) +} + +export type GenerationOptionsFields = { + readonly maxTokens?: number + readonly temperature?: number + readonly topP?: number + readonly topK?: number + readonly frequencyPenalty?: number + readonly presencePenalty?: number + readonly seed?: number + readonly stop?: ReadonlyArray +} + +export type GenerationOptionsInput = GenerationOptions | GenerationOptionsFields + +const latestGeneration = ( + items: ReadonlyArray, + key: Key, +) => items.findLast((item) => item?.[key] !== undefined)?.[key] + +export const mergeGenerationOptions = (...items: ReadonlyArray) => { + const result = new GenerationOptions({ + maxTokens: latestGeneration(items, "maxTokens"), + temperature: latestGeneration(items, "temperature"), + topP: latestGeneration(items, "topP"), + topK: latestGeneration(items, "topK"), + frequencyPenalty: latestGeneration(items, "frequencyPenalty"), + presencePenalty: latestGeneration(items, "presencePenalty"), + seed: latestGeneration(items, "seed"), + stop: latestGeneration(items, "stop"), + }) + return Object.values(result).some((value) => value !== undefined) ? result : undefined +} + +export class ModelLimits extends Schema.Class("LLM.ModelLimits")({ + context: Schema.optional(Schema.Number), + output: Schema.optional(Schema.Number), +}) {} + +export namespace ModelLimits { + export type Input = ModelLimits | ConstructorParameters[0] + + /** Normalize model limit input into the canonical `ModelLimits` class. */ + export const make = (input: Input | undefined) => + input instanceof ModelLimits ? input : new ModelLimits(input ?? {}) +} + +export class ModelDefaults extends Schema.Class("LLM.ModelDefaults")({ + limits: Schema.optional(ModelLimits), + generation: Schema.optional(GenerationOptions), + providerOptions: Schema.optional(ProviderOptions), + http: Schema.optional(HttpOptions), +}) {} + +export namespace ModelDefaults { + export type Input = + | ModelDefaults + | { + readonly limits?: ModelLimits.Input + readonly generation?: GenerationOptions.Input + readonly providerOptions?: ProviderOptions + readonly http?: HttpOptions.Input + } + + /** Normalize selected-model request defaults without applying precedence. */ + export const make = (input: Input) => { + if (input instanceof ModelDefaults) return input + return new ModelDefaults({ + limits: input.limits === undefined ? undefined : ModelLimits.make(input.limits), + generation: input.generation === undefined ? undefined : GenerationOptions.make(input.generation), + providerOptions: input.providerOptions, + http: input.http === undefined ? undefined : HttpOptions.make(input.http), + }) + } +} + +export const ModelToolSchemaCompatibility = Schema.Literals(["gemini", "moonshot"]) +export type ModelToolSchemaCompatibility = Schema.Schema.Type + +export class ModelCompatibility extends Schema.Class("LLM.ModelCompatibility")({ + toolSchema: Schema.optional(ModelToolSchemaCompatibility), +}) {} + +export namespace ModelCompatibility { + export type Input = ModelCompatibility | ConstructorParameters[0] + + /** Normalize model/upstream compatibility metadata without projecting requests. */ + export const make = (input: Input) => (input instanceof ModelCompatibility ? input : new ModelCompatibility(input)) +} + +export class Model { + readonly id: ModelID + readonly provider: ProviderID + readonly route: AnyRoute + readonly defaults?: ModelDefaults + readonly compatibility?: ModelCompatibility + + constructor(input: Model.ConstructorInput) { + this.id = input.id + this.provider = input.provider + this.route = input.route + this.defaults = input.defaults + this.compatibility = input.compatibility + } + + static make(input: Model.Input) { + return new Model({ + id: ModelID.make(input.id), + provider: ProviderID.make(input.provider), + route: input.route, + defaults: input.defaults === undefined ? undefined : ModelDefaults.make(input.defaults), + compatibility: input.compatibility === undefined ? undefined : ModelCompatibility.make(input.compatibility), + }) + } + + static input(model: Model): Model.ConstructorInput { + return { + id: model.id, + provider: model.provider, + route: model.route, + defaults: model.defaults, + compatibility: model.compatibility, + } + } + + static update(model: Model, patch: Partial) { + if (Object.keys(patch).length === 0) return model + return Model.make({ + ...Model.input(model), + ...patch, + }) + } +} + +export namespace Model { + export type ConstructorInput = { + readonly id: ModelID + readonly provider: ProviderID + readonly route: AnyRoute + readonly defaults?: ModelDefaults + readonly compatibility?: ModelCompatibility + } + + export type Input = Omit & { + readonly id: string | ModelID + readonly provider: string | ProviderID + readonly defaults?: ModelDefaults.Input + readonly compatibility?: ModelCompatibility.Input + } +} + +export type ModelInput = Model.Input + +export const ModelSchema = Schema.declare((value): value is Model => value instanceof Model, { expected: "LLM.Model" }) + +export class CacheHint extends Schema.Class("LLM.CacheHint")({ + type: Schema.Literals(["ephemeral", "persistent"]), + ttlSeconds: Schema.optional(Schema.Number), +}) {} + +// Auto-placement policy for prompt caching. The protocol-neutral lowering step +// reads this and injects `CacheHint`s at the configured boundaries; the +// per-protocol body builders then translate those hints into wire markers as +// usual. `"auto"` is the recommended default for agent loops — it places one +// breakpoint at the last tool definition, one at the last system part, and one +// at the latest user message. The combination of provider invalidation +// hierarchy (tools → system → messages) and Anthropic/Bedrock's 20-block +// lookback means three trailing breakpoints reliably cover the static prefix. +// +// Pass `"none"` to opt out entirely (the legacy behavior). Pass the granular +// object form to override individual choices. +export const CachePolicyObject = Schema.Struct({ + tools: Schema.optional(Schema.Boolean), + system: Schema.optional(Schema.Boolean), + messages: Schema.optional( + Schema.Union([ + Schema.Literal("latest-user-message"), + Schema.Literal("latest-assistant"), + Schema.Struct({ tail: Schema.Number }), + ]), + ), + ttlSeconds: Schema.optional(Schema.Number), +}) +export type CachePolicyObject = Schema.Schema.Type + +export const CachePolicy = Schema.Union([Schema.Literal("auto"), Schema.Literal("none"), CachePolicyObject]) +export type CachePolicy = Schema.Schema.Type diff --git a/packages/llm/src/tool-runtime.ts b/packages/llm/src/tool-runtime.ts new file mode 100644 index 0000000000000000000000000000000000000000..d69bbb9d478ca532a1481e8d7502c8e5c2b55dc6 --- /dev/null +++ b/packages/llm/src/tool-runtime.ts @@ -0,0 +1,78 @@ +import { Effect } from "effect" +import { + LLMEvent, + type ToolCallPart, + ToolFailure, + ToolOutput, + ToolResultValue, + type ToolOutput as ToolOutputType, + type ToolResultValue as ToolResultValueType, +} from "./schema" +import { type AnyTool, type Tools } from "./tool" + +export interface ToolSettlement { + readonly result: ToolResultValueType + readonly output?: ToolOutputType +} + +export interface DispatchResult extends ToolSettlement { + readonly events: ReadonlyArray +} + +/** Execute one canonical tool call without owning provider IO or continuation. */ +export const dispatch = (tools: Tools, call: ToolCallPart): Effect.Effect => { + const tool = tools[call.name] + if (!tool) return Effect.succeed(result(call, { type: "error", value: `Unknown tool: ${call.name}` })) + if (!tool.execute) + return Effect.succeed(result(call, { type: "error", value: `Tool has no execute handler: ${call.name}` })) + + return decodeAndExecute(tool, call).pipe( + Effect.map((value) => result(call, value)), + Effect.catchTag("LLM.ToolFailure", (failure) => + Effect.succeed(result(call, { type: "error", value: failure.message }, failure.error)), + ), + ) +} + +const decodeAndExecute = (tool: AnyTool, call: ToolCallPart): Effect.Effect => + tool._decode(call.input).pipe( + Effect.mapError((error) => new ToolFailure({ message: `Invalid tool input: ${error.message}` })), + Effect.flatMap((decoded) => + tool.execute!(decoded, { id: call.id, name: call.name }).pipe( + Effect.flatMap((value) => + tool._encode(value).pipe( + Effect.mapError( + (error) => + new ToolFailure({ + message: `Tool returned an invalid value for its success schema: ${error.message}`, + }), + ), + ), + ), + Effect.map((encoded) => { + if (tool._legacyResult && ToolResultValue.is(encoded)) + return { result: encoded, output: ToolOutput.fromResultValue(encoded) } + const output = tool._project(decoded, call.id, encoded) + const result = ToolOutput.toResultValue(output) + return result.type === "error" ? { result } : { result, output } + }), + ), + ), + ) + +const result = (call: ToolCallPart, value: ToolResultValueType | ToolSettlement, error?: unknown): DispatchResult => { + const settlement = ToolResultValue.is(value) ? { result: value } : value + return { + result: settlement.result, + output: settlement.output, + events: + settlement.result.type === "error" + ? [ + LLMEvent.toolError({ id: call.id, name: call.name, message: String(settlement.result.value), error }), + LLMEvent.toolResult({ id: call.id, name: call.name, result: settlement.result }), + ] + : [LLMEvent.toolResult({ id: call.id, name: call.name, result: settlement.result, output: settlement.output })], + } +} + +export const ToolRuntime = { dispatch } as const diff --git a/packages/llm/src/tool.ts b/packages/llm/src/tool.ts new file mode 100644 index 0000000000000000000000000000000000000000..11ed9854ca38a9ef648705d1091aaa35e1660da0 --- /dev/null +++ b/packages/llm/src/tool.ts @@ -0,0 +1,253 @@ +import { Effect, JsonSchema, Schema } from "effect" +import type { + ToolCallPart, + ToolContent, + ToolDefinition as ToolDefinitionClass, + ToolOutput as ToolOutputType, +} from "./schema" +import { ToolDefinition, ToolFailure, ToolOutput } from "./schema" + +/** + * Schema constraint for tool parameters / success values: no decoding or + * encoding services are allowed. Tools should be self-contained — anything + * beyond pure data conversion belongs in the handler closure. + */ +export type ToolSchema = Schema.Codec +export interface ToolExecuteContext { + readonly id: ToolCallPart["id"] + readonly name: ToolCallPart["name"] +} + +export type ToolExecute, Success extends ToolSchema> = ( + params: Schema.Schema.Type, + context?: ToolExecuteContext, +) => Effect.Effect, ToolFailure> + +export interface ToolModelOutputInput { + readonly callID: ToolCallPart["id"] + readonly parameters: Parameters + readonly output: Output +} + +export type ToolToModelOutput, Success extends ToolSchema> = ( + input: ToolModelOutputInput, Success["Encoded"]>, +) => ReadonlyArray + +/** + * A type-safe LLM tool. Each tool bundles its own description, parameter + * Schema and success Schema. The execute handler is optional: omit it when you + * only want to expose a tool schema to the model and handle tool calls outside + * this package. + * + * Errors must be expressed as `ToolFailure`. Unmapped errors and defects fail + * the stream. + * + * Internally each tool also carries memoized codecs and a precomputed + * `ToolDefinition` so callers do not rebuild them per invocation. + */ +export interface Tool, Success extends ToolSchema> { + readonly description: string + readonly parameters: Parameters + readonly success: Success + readonly execute?: ToolExecute + readonly toModelOutput?: ToolToModelOutput + readonly toStructuredOutput?: (output: Success["Encoded"]) => unknown + /** @internal */ + readonly _decode: (input: unknown) => Effect.Effect, Schema.SchemaError> + /** @internal */ + readonly _encode: (value: Schema.Schema.Type) => Effect.Effect + /** @internal */ + readonly _project: ( + parameters: Schema.Schema.Type, + callID: ToolCallPart["id"], + output: unknown, + ) => ToolOutputType + /** @internal */ + readonly _legacyResult: boolean + /** @internal */ + readonly _definition: ToolDefinitionClass +} + +export type AnyTool = Tool + +export type ExecutableTool, Success extends ToolSchema> = Tool< + Parameters, + Success +> & { + readonly execute: ToolExecute +} + +export type AnyExecutableTool = ExecutableTool + +export type ExecutableTools = Record + +type TypedToolConfig = { + readonly description: string + readonly parameters: ToolSchema + readonly success: ToolSchema + readonly execute?: ToolExecute, ToolSchema> + readonly toModelOutput?: ToolToModelOutput, ToolSchema> + readonly toStructuredOutput?: (output: unknown) => unknown +} + +type DynamicToolConfig = { + readonly description: string + readonly jsonSchema: JsonSchema.JsonSchema + readonly outputSchema?: JsonSchema.JsonSchema + readonly execute?: (params: unknown, context?: ToolExecuteContext) => Effect.Effect + readonly toModelOutput?: (input: ToolModelOutputInput) => ReadonlyArray + readonly toStructuredOutput?: (output: unknown) => unknown +} + +/** + * Constructs a tool. Two input modes: + * + * 1. **Typed** — pass Effect `parameters` and `success` Schemas; inputs and + * outputs are statically typed and decoded/encoded automatically. + * + * ```ts + * Tool.make({ + * description: "Get current weather", + * parameters: Schema.Struct({ city: Schema.String }), + * success: Schema.Struct({ temperature: Schema.Number }), + * execute: ({ city }) => Effect.succeed({ temperature: 22 }), + * }) + * ``` + * + * 2. **Dynamic** — pass raw JSON Schema as `jsonSchema`. Use this when the + * schema comes from an external source (MCP server, plugin manifest, + * dynamic config) and is not known at compile time. Inputs are typed as + * `unknown`; the handler is responsible for any validation it needs. + * + * ```ts + * Tool.make({ + * description: "Look something up", + * jsonSchema: { type: "object", properties: { ... } }, + * execute: (params) => Effect.succeed(...), + * }) + * ``` + * + * In both modes the produced tool flows through `toDefinitions(...)` + * identically. + */ +export function make, Success extends ToolSchema>(config: { + readonly description: string + readonly parameters: Parameters + readonly success: Success + readonly execute: ToolExecute + readonly toModelOutput?: ToolToModelOutput + readonly toStructuredOutput?: (output: Success["Encoded"]) => unknown +}): ExecutableTool +export function make, Success extends ToolSchema>(config: { + readonly description: string + readonly parameters: Parameters + readonly success: Success + readonly execute?: undefined + readonly toModelOutput?: ToolToModelOutput + readonly toStructuredOutput?: (output: Success["Encoded"]) => unknown +}): Tool +export function make(config: { + readonly description: string + readonly jsonSchema: JsonSchema.JsonSchema + readonly outputSchema?: JsonSchema.JsonSchema + readonly execute: (params: unknown, context?: ToolExecuteContext) => Effect.Effect + readonly toModelOutput?: (input: ToolModelOutputInput) => ReadonlyArray + readonly toStructuredOutput?: (output: unknown) => unknown +}): AnyExecutableTool +export function make(config: { + readonly description: string + readonly jsonSchema: JsonSchema.JsonSchema + readonly outputSchema?: JsonSchema.JsonSchema + readonly execute?: undefined + readonly toModelOutput?: (input: ToolModelOutputInput) => ReadonlyArray + readonly toStructuredOutput?: (output: unknown) => unknown +}): AnyTool +export function make(config: TypedToolConfig | DynamicToolConfig): AnyTool { + if ("jsonSchema" in config) { + return { + description: config.description, + parameters: Schema.Unknown as ToolSchema, + success: Schema.Unknown as ToolSchema, + execute: config.execute, + toModelOutput: config.toModelOutput, + toStructuredOutput: config.toStructuredOutput, + _decode: Effect.succeed, + _encode: Effect.succeed, + _project: (parameters, callID, output) => + project(config.toModelOutput, config.toStructuredOutput, parameters, callID, output), + _legacyResult: config.toModelOutput === undefined && config.toStructuredOutput === undefined, + _definition: new ToolDefinition({ + name: "", + description: config.description, + inputSchema: config.jsonSchema, + outputSchema: config.outputSchema, + }), + } + } + return { + description: config.description, + parameters: config.parameters, + success: config.success, + execute: config.execute, + toModelOutput: config.toModelOutput, + toStructuredOutput: config.toStructuredOutput, + _decode: Schema.decodeUnknownEffect(config.parameters), + _encode: Schema.encodeEffect(config.success), + _project: (parameters, callID, output) => + project(config.toModelOutput, config.toStructuredOutput, parameters, callID, output), + _legacyResult: false, + _definition: new ToolDefinition({ + name: "", + description: config.description, + inputSchema: toJsonSchema(config.parameters), + outputSchema: toJsonSchema(config.success), + }), + } +} + +/** + * A record of named tools. The record key becomes the tool name on the wire. + */ +export type Tools = Record + +/** + * Convert a tools record into the `ToolDefinition[]` shape that + * `LLMRequest.tools` expects. + * + * Tool names come from the record keys, so the per-tool cached + * `_definition` is rebuilt with the correct name here. The JSON Schema body + * is reused. + */ +export const toDefinitions = (tools: Tools): ReadonlyArray => + Object.entries(tools).map( + ([name, item]) => + new ToolDefinition({ + name, + description: item._definition.description, + inputSchema: item._definition.inputSchema, + outputSchema: item._definition.outputSchema, + }), + ) + +const toJsonSchema = (schema: Schema.Top): JsonSchema.JsonSchema => { + const document = Schema.toJsonSchemaDocument(schema) + if (Object.keys(document.definitions).length === 0) return document.schema + return { ...document.schema, $defs: document.definitions } +} + +const project = ( + toModelOutput: ((input: ToolModelOutputInput) => ReadonlyArray) | undefined, + toStructuredOutput: ((output: unknown) => unknown) | undefined, + parameters: unknown, + callID: ToolCallPart["id"], + output: unknown, +): ToolOutputType => + ToolOutput.make( + toStructuredOutput?.(output) ?? output, + toModelOutput?.({ callID, parameters, output }) ?? + (typeof output === "string" ? [{ type: "text", text: output }] : []), + ) + +export { ToolFailure } + +export * as Tool from "./tool" diff --git a/packages/llm/src/utils/record.ts b/packages/llm/src/utils/record.ts new file mode 100644 index 0000000000000000000000000000000000000000..a121fbde11a543fc674b2037fc051124492c1775 --- /dev/null +++ b/packages/llm/src/utils/record.ts @@ -0,0 +1,3 @@ +/** Plain-record narrowing. Excludes arrays so JSON object checks don't accept tuples as key/value bags. */ +export const isRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value) diff --git a/packages/llm/test/adapter.test.ts b/packages/llm/test/adapter.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..bbbb29f37af72acaa4abe23425f3fff147aab052 --- /dev/null +++ b/packages/llm/test/adapter.test.ts @@ -0,0 +1,171 @@ +import { describe, expect } from "bun:test" +import { Effect, Schema, Stream } from "effect" +import { LLM, LLMResponse } from "../src" +import { Route, Endpoint, LLMClient, Protocol, type FramingDef } from "../src/route" +import { Model } from "../src/schema" +import { testEffect } from "./lib/effect" +import { dynamicResponse } from "./lib/http" + +const updateModel = (model: Model, patch: Partial) => Model.update(model, patch) + +const Json = Schema.fromJsonString(Schema.Unknown) +const encodeJson = Schema.encodeSync(Json) + +type FakeBody = { + readonly body: string +} + +const FakeEvent = Schema.Union([ + Schema.Struct({ type: Schema.Literal("text"), text: Schema.String }), + Schema.Struct({ type: Schema.Literal("finish"), reason: Schema.Literal("stop") }), +]) +type FakeEvent = Schema.Schema.Type +const decodeFakeEvents = Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Array(FakeEvent))) + +const fakeFraming: FramingDef = { + id: "fake-json-array", + frame: (bytes) => + Stream.fromEffect( + bytes.pipe( + Stream.decodeText(), + Stream.runFold( + () => "", + (text, event) => text + event, + ), + Effect.flatMap(decodeFakeEvents), + Effect.orDie, + ), + ).pipe(Stream.flatMap(Stream.fromIterable)), +} + +const raiseEvent = (event: FakeEvent): import("../src/schema").LLMEvent => + event.type === "finish" + ? { type: "finish", reason: event.reason } + : { type: "text-delta", id: "text-0", text: event.text } + +const fakeProtocol = Protocol.make({ + id: "fake", + body: { + schema: Schema.Struct({ + body: Schema.String, + }), + from: (request) => + Effect.succeed({ + body: [ + ...request.messages + .flatMap((message) => message.content) + .filter((part) => part.type === "text") + .map((part) => part.text), + ...request.tools.map((tool) => `tool:${tool.name}:${tool.description}`), + ].join("\n"), + }), + }, + stream: { + event: FakeEvent, + initial: () => undefined, + step: (state, event) => Effect.succeed([state, [raiseEvent(event)]] as const), + }, +}) + +const fake = Route.make({ + id: "fake", + protocol: fakeProtocol, + endpoint: Endpoint.path("/chat"), + framing: fakeFraming, +}) +const configuredFake = fake.with({ endpoint: { baseURL: "https://fake.local" } }) + +const gemini = Route.make({ + id: "gemini-fake", + protocol: fakeProtocol, + endpoint: Endpoint.path("/chat"), + framing: fakeFraming, +}) +const configuredGemini = gemini.with({ endpoint: { baseURL: "https://fake.local" } }) + +const request = LLM.request({ + id: "req_1", + model: Model.make({ + id: "fake-model", + provider: "fake-provider", + route: configuredFake, + }), + prompt: "hello", +}) + +const echoLayer = dynamicResponse(({ text, respond }) => + Effect.succeed( + respond( + encodeJson([ + { type: "text", text: `echo:${text}` }, + { type: "finish", reason: "stop" }, + ]), + ), + ), +) + +const it = testEffect(echoLayer) + +describe("llm route", () => { + it.effect("stream and generate use the route pipeline", () => + Effect.gen(function* () { + const llm = yield* LLMClient.Service + const events = Array.from(yield* llm.stream(request).pipe(Stream.runCollect)) + const response = yield* llm.generate(request) + const reduced = LLMResponse.fromEvents(events) + + expect(events.map((event) => event.type)).toEqual(["text-delta", "finish"]) + expect(reduced).toBeDefined() + if (!reduced) throw new Error("stream reducer did not produce a completed response") + expect(response.events).toEqual(events) + expect(response.message).toEqual(reduced.message) + expect(response.usage).toEqual(reduced.usage) + expect(response.finishReason).toEqual(reduced.finishReason) + expect(response.message.content).toEqual([{ type: "text", text: 'echo:{"body":"hello"}' }]) + }), + ) + + it.effect("selects routes by model route value", () => + Effect.gen(function* () { + const llm = yield* LLMClient.Service + const prepared = yield* llm.prepare( + LLM.updateRequest(request, { model: updateModel(request.model, { route: configuredGemini }) }), + ) + + expect(prepared.route).toBe("gemini-fake") + }), + ) + + it.effect("builds models from configured routes", () => + Effect.gen(function* () { + const configured = fake.with({ provider: "fake-provider", endpoint: { baseURL: "https://fake.local" } }) + + expect(configured.model({ id: "fake-model" })).toMatchObject({ + provider: "fake-provider", + }) + }), + ) + + it.effect("does not register duplicate route ids globally", () => + Effect.gen(function* () { + const duplicate = Route.make({ + id: "fake", + protocol: Protocol.make({ + ...fakeProtocol, + body: { + ...fakeProtocol.body, + from: () => Effect.succeed({ body: "late-default" }), + }, + }), + endpoint: Endpoint.path("/chat", { baseURL: "https://fake.local" }), + framing: fakeFraming, + }) + + const prepared = yield* (yield* LLMClient.Service).prepare( + LLM.updateRequest(request, { model: updateModel(request.model, { route: duplicate }) }), + ) + + expect(prepared.body).toEqual({ body: "late-default" }) + }), + ) +}) diff --git a/packages/llm/test/auth-options.types.ts b/packages/llm/test/auth-options.types.ts new file mode 100644 index 0000000000000000000000000000000000000000..18f9508c3ca505a6ffc33e1cea08fd3a2f26f5a7 --- /dev/null +++ b/packages/llm/test/auth-options.types.ts @@ -0,0 +1,168 @@ +import { Config } from "effect" +import type { Auth } from "../src/route/auth" +import type { ModelFactory } from "../src/route/auth-options" +import { Auth as RuntimeAuth } from "../src/route/auth" +import * as OpenAIChat from "../src/protocols/openai-chat" +import * as AmazonBedrock from "../src/providers/amazon-bedrock" +import * as Anthropic from "../src/providers/anthropic" +import * as Azure from "../src/providers/azure" +import * as Cloudflare from "../src/providers/cloudflare" +import * as GitHubCopilot from "../src/providers/github-copilot" +import * as Google from "../src/providers/google" +import * as OpenAI from "../src/providers/openai" +import * as OpenAICompatible from "../src/providers/openai-compatible" +import * as OpenRouter from "../src/providers/openrouter" +import * as XAI from "../src/providers/xai" + +type BaseOptions = { + readonly baseURL?: string + readonly headers?: Record +} + +type Model = { + readonly id: string +} + +declare const auth: Auth +declare const optionalAuthModel: ModelFactory +declare const requiredAuthModel: ModelFactory +const configApiKey = Config.redacted("OPENAI_API_KEY") + +OpenAIChat.route.model({ id: "gpt-4.1-mini" }) + +// @ts-expect-error route model selection does not configure endpoints. +OpenAIChat.route.model({ id: "gpt-4.1-mini", baseURL: "https://gateway.example.com/v1" }) + +// @ts-expect-error route model selection does not configure query params. +OpenAIChat.route.model({ id: "gpt-4.1-mini", queryParams: { debug: "1" } }) + +// @ts-expect-error route model selection does not configure auth. +OpenAIChat.route.model({ id: "gpt-4.1-mini", auth }) + +// @ts-expect-error route model selection does not configure api keys. +OpenAIChat.route.model({ id: "gpt-4.1-mini", apiKey: "sk-test" }) + +optionalAuthModel("gpt-4.1-mini") +optionalAuthModel("gpt-4.1-mini", {}) +optionalAuthModel("gpt-4.1-mini", { apiKey: "sk-test" }) +optionalAuthModel("gpt-4.1-mini", { apiKey: configApiKey }) +optionalAuthModel("gpt-4.1-mini", { auth }) +optionalAuthModel("gpt-4.1-mini", { auth, baseURL: "https://gateway.example.com/v1" }) +optionalAuthModel("gpt-4.1-mini", { apiKey: "sk-test", headers: { "x-source": "test" } }) + +// @ts-expect-error auth is an override, so apiKey cannot be supplied with it. +optionalAuthModel("gpt-4.1-mini", { apiKey: "sk-test", auth }) + +requiredAuthModel("custom-model", { apiKey: "key" }) +requiredAuthModel("custom-model", { apiKey: configApiKey }) +requiredAuthModel("custom-model", { auth }) +requiredAuthModel("custom-model", { auth, headers: { "x-tenant-id": "tenant" } }) + +// @ts-expect-error providers without config fallback need apiKey or auth. +requiredAuthModel("custom-model") + +// @ts-expect-error providers without config fallback need apiKey or auth. +requiredAuthModel("custom-model", {}) + +// @ts-expect-error auth is an override, so apiKey cannot be supplied with it. +requiredAuthModel("custom-model", { apiKey: "key", auth }) + +OpenAI.responses("gpt-4.1-mini") +OpenAI.configure({}).responses("gpt-4.1-mini") +OpenAI.configure({ apiKey: "sk-test" }).responses("gpt-4.1-mini") +OpenAI.configure({ apiKey: configApiKey }).responses("gpt-4.1-mini") +OpenAI.configure({ auth: RuntimeAuth.bearer("oauth-token") }).responses("gpt-4.1-mini") +OpenAI.configure({ + auth: RuntimeAuth.headers({ authorization: "Bearer gateway" }), + baseURL: "https://gateway.example.com/v1", +}).responses("gpt-4.1-mini") +OpenAI.configure({ + generation: { maxTokens: 100 }, + providerOptions: { openai: { store: false } }, +}).responses("gpt-4.1-mini") + +// @ts-expect-error OpenAI model selectors only accept model ids. +OpenAI.configure({ apiKey: "sk-test" }).responses("gpt-4.1-mini", {}) + +// @ts-expect-error apiKey only accepts string, Redacted, or Config>. +OpenAI.configure({ apiKey: 123 }) + +// @ts-expect-error provider helpers reject unknown top-level options. +OpenAI.configure({ bogus: true }) + +// @ts-expect-error common generation options remain typed. +OpenAI.configure({ generation: { maxTokens: "many" } }) + +// @ts-expect-error provider-native options remain typed. +OpenAI.configure({ providerOptions: { openai: { store: "false" } } }) + +// @ts-expect-error auth is an override, so OpenAI rejects apiKey with auth. +OpenAI.configure({ apiKey: "sk-test", auth: RuntimeAuth.bearer("oauth-token") }) + +OpenAI.chat("gpt-4.1-mini") +OpenAI.configure({ apiKey: "sk-test" }).chat("gpt-4.1-mini") +OpenAI.configure({ apiKey: configApiKey }).chat("gpt-4.1-mini") +OpenAI.configure({ auth: RuntimeAuth.bearer("oauth-token") }).chat("gpt-4.1-mini") + +// @ts-expect-error OpenAI chat selectors only accept model ids. +OpenAI.configure({ apiKey: "sk-test" }).chat("gpt-4.1-mini", {}) + +// @ts-expect-error auth is an override, so OpenAI Chat rejects apiKey with auth. +OpenAI.configure({ apiKey: "sk-test", auth: RuntimeAuth.bearer("oauth-token") }) + +// @ts-expect-error Azure requires at least one of `resourceName` or `baseURL`. +Azure.configure() +Azure.configure({ apiKey: "azure-key", resourceName: "resource" }).responses("deployment") +Azure.configure({ apiKey: configApiKey, resourceName: "resource" }).responses("deployment") +Azure.configure({ auth: RuntimeAuth.header("api-key", "azure-key"), resourceName: "resource" }).responses("deployment") + +// @ts-expect-error Azure model selectors only accept deployment ids. +Azure.configure({ apiKey: "azure-key", resourceName: "resource" }).responses("deployment", {}) + +// @ts-expect-error auth is an override, so Azure rejects apiKey with auth. +Azure.configure({ resourceName: "resource", apiKey: "azure-key", auth: RuntimeAuth.header("api-key", "override") }) + +Azure.configure({ apiKey: "azure-key", resourceName: "resource" }).chat("deployment") +Azure.configure({ apiKey: configApiKey, resourceName: "resource" }).chat("deployment") +Azure.configure({ auth: RuntimeAuth.header("api-key", "azure-key"), resourceName: "resource" }).chat("deployment") + +// @ts-expect-error Azure chat model selectors only accept deployment ids. +Azure.configure({ apiKey: "azure-key", resourceName: "resource" }).chat("deployment", {}) + +// @ts-expect-error auth is an override, so Azure Chat rejects apiKey with auth. +Azure.configure({ resourceName: "resource", apiKey: "azure-key", auth: RuntimeAuth.header("api-key", "override") }) + +Anthropic.configure({ apiKey: "anthropic-key" }).model("claude-haiku") +// @ts-expect-error Anthropic model selectors only accept model ids. +Anthropic.configure({ apiKey: "anthropic-key" }).model("claude-haiku", {}) + +Google.configure({ apiKey: "google-key" }).model("gemini-2.5-flash") +// @ts-expect-error Google model selectors only accept model ids. +Google.configure({ apiKey: "google-key" }).model("gemini-2.5-flash", {}) + +AmazonBedrock.configure({ apiKey: "bedrock-key" }).model("anthropic.claude") +// @ts-expect-error Bedrock model selectors only accept model ids. +AmazonBedrock.configure({ apiKey: "bedrock-key" }).model("anthropic.claude", {}) + +OpenRouter.configure({ apiKey: "openrouter-key" }).model("openai/gpt-4o-mini") +// @ts-expect-error OpenRouter model selectors only accept model ids. +OpenRouter.configure({ apiKey: "openrouter-key" }).model("openai/gpt-4o-mini", {}) + +XAI.configure({ apiKey: "xai-key" }).responses("grok-4") +XAI.configure({ apiKey: "xai-key" }).chat("grok-4") +// @ts-expect-error xAI Responses selectors only accept model ids. +XAI.configure({ apiKey: "xai-key" }).responses("grok-4", {}) +// @ts-expect-error xAI Chat selectors only accept model ids. +XAI.configure({ apiKey: "xai-key" }).chat("grok-4", {}) + +OpenAICompatible.deepseek.configure({ apiKey: "deepseek-key" }).model("deepseek-chat") +// @ts-expect-error OpenAI-compatible family selectors only accept model ids. +OpenAICompatible.deepseek.configure({ apiKey: "deepseek-key" }).model("deepseek-chat", {}) + +Cloudflare.CloudflareWorkersAI.configure({ accountId: "account", apiKey: "cf-key" }).model("@cf/meta/llama") +// @ts-expect-error Cloudflare Workers AI model selectors only accept model ids. +Cloudflare.CloudflareWorkersAI.configure({ accountId: "account", apiKey: "cf-key" }).model("@cf/meta/llama", {}) + +GitHubCopilot.configure({ baseURL: "https://copilot.test", apiKey: "copilot-key" }).model("gpt-4.1") +// @ts-expect-error GitHub Copilot model selectors only accept model ids. +GitHubCopilot.configure({ baseURL: "https://copilot.test", apiKey: "copilot-key" }).model("gpt-4.1", {}) diff --git a/packages/llm/test/auth.test.ts b/packages/llm/test/auth.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..1c7148dbbb4ebc28bc7a1d0318bcd040cfe259d3 --- /dev/null +++ b/packages/llm/test/auth.test.ts @@ -0,0 +1,103 @@ +import { describe, expect } from "bun:test" +import { ConfigProvider, Effect } from "effect" +import { Headers } from "effect/unstable/http" +import { LLM } from "../src" +import { Auth } from "../src/route/auth" +import * as OpenAIChat from "../src/protocols/openai-chat" +import { Model } from "../src/schema" +import { it } from "./lib/effect" + +const request = LLM.request({ + id: "req_auth", + model: Model.make({ id: "fake-model", provider: "fake", route: OpenAIChat.route }), + prompt: "hello", +}) + +const input = { + request, + method: "POST" as const, + url: "https://example.test/v1/chat", + body: "{}", + headers: Headers.fromInput({ "x-existing": "yes" }), +} + +const withEnv = (env: Record) => Effect.provide(ConfigProvider.layer(ConfigProvider.fromEnv({ env }))) + +describe("Auth", () => { + it.effect("renders a config credential as bearer auth", () => + Effect.gen(function* () { + const headers = yield* Auth.config("OPENAI_API_KEY") + .bearer() + .apply(input) + .pipe(withEnv({ OPENAI_API_KEY: "sk-test" })) + + expect(headers.authorization).toBe("Bearer sk-test") + expect(headers["x-existing"]).toBe("yes") + }), + ) + + it.effect("falls back between credential sources before rendering", () => + Effect.gen(function* () { + const headers = yield* Auth.config("PRIMARY_KEY") + .orElse(Auth.value("fallback-key")) + .pipe(Auth.header("x-api-key")) + .apply(input) + .pipe(withEnv({})) + + expect(headers["x-api-key"]).toBe("fallback-key") + expect(headers["x-existing"]).toBe("yes") + }), + ) + + it.effect("composes header auth in sequence", () => + Effect.gen(function* () { + const headers = yield* Auth.headers({ "x-tenant-id": "tenant-1" }) + .andThen(Auth.bearer("gateway-token")) + .apply(input) + + expect(headers["x-tenant-id"]).toBe("tenant-1") + expect(headers.authorization).toBe("Bearer gateway-token") + expect(headers["x-existing"]).toBe("yes") + }), + ) + + it.effect("renders a direct secret as a custom header", () => + Effect.gen(function* () { + const headers = yield* Auth.header("api-key", "direct-key").apply(input) + + expect(headers["api-key"]).toBe("direct-key") + expect(headers["x-existing"]).toBe("yes") + }), + ) + + it.effect("renders bearer auth into a custom header", () => + Effect.gen(function* () { + const headers = yield* Auth.bearerHeader("cf-aig-authorization", "gateway-token").apply(input) + + expect(headers["cf-aig-authorization"]).toBe("Bearer gateway-token") + expect(headers["x-existing"]).toBe("yes") + }), + ) + + it.effect("falls back between full auth values", () => + Effect.gen(function* () { + const headers = yield* Auth.config("OPENAI_API_KEY") + .bearer() + .orElse(Auth.headers({ authorization: "Bearer supplied" })) + .apply(input) + .pipe(withEnv({})) + + expect(headers.authorization).toBe("Bearer supplied") + expect(headers["x-existing"]).toBe("yes") + }), + ) + + it.effect("can intentionally leave auth untouched", () => + Effect.gen(function* () { + const headers = yield* Auth.none.apply(input) + + expect(headers.authorization).toBeUndefined() + expect(headers["x-existing"]).toBe("yes") + }), + ) +}) diff --git a/packages/llm/test/cache-policy.test.ts b/packages/llm/test/cache-policy.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..a126d9502c5e497f240f5e53fa50a51d426479e6 --- /dev/null +++ b/packages/llm/test/cache-policy.test.ts @@ -0,0 +1,262 @@ +import { describe, expect, test } from "bun:test" +import { Effect } from "effect" +import { CacheHint, LLM, Message } from "../src" +import { Auth, LLMClient } from "../src/route" +import { AmazonBedrock } from "../src/providers" +import * as AnthropicMessages from "../src/protocols/anthropic-messages" +import * as Gemini from "../src/protocols/gemini" +import * as OpenAIChat from "../src/protocols/openai-chat" +import { applyCachePolicy } from "../src/cache-policy" +import { it } from "./lib/effect" + +const anthropicModel = AnthropicMessages.route + .with({ endpoint: { baseURL: "https://api.anthropic.test/v1/" }, auth: Auth.header("x-api-key", "test") }) + .model({ id: "claude-sonnet-4-5" }) + +const bedrockModel = AmazonBedrock.configure({ + credentials: { region: "us-east-1", accessKeyId: "fixture", secretAccessKey: "fixture" }, +}).model("anthropic.claude-3-5-sonnet-20241022-v2:0") + +const openaiModel = OpenAIChat.route + .with({ endpoint: { baseURL: "https://api.openai.test/v1/" }, auth: Auth.bearer("test") }) + .model({ id: "gpt-4o-mini" }) + +const geminiModel = Gemini.route + .with({ + endpoint: { baseURL: "https://generativelanguage.test/v1beta/" }, + auth: Auth.header("x-goog-api-key", "test"), + }) + .model({ id: "gemini-2.5-flash" }) + +describe("applyCachePolicy", () => { + it.effect("undefined cache resolves to 'auto' (the recommended default)", () => + Effect.gen(function* () { + const prepared = yield* LLMClient.prepare( + LLM.request({ + model: anthropicModel, + system: "You are concise.", + prompt: "hi", + }), + ) + + // No explicit cache field → auto policy fires → last system part + latest + // user message both get cache_control markers. + expect(prepared.body).toMatchObject({ + system: [{ type: "text", text: "You are concise.", cache_control: { type: "ephemeral" } }], + messages: [{ role: "user", content: [{ type: "text", text: "hi", cache_control: { type: "ephemeral" } }] }], + }) + }), + ) + + it.effect("'auto' marks the last tool, last system part, and latest user message on Anthropic", () => + Effect.gen(function* () { + const prepared = yield* LLMClient.prepare( + LLM.request({ + model: anthropicModel, + system: "Sys A", + tools: [{ name: "t1", description: "t1", inputSchema: { type: "object", properties: {} } }], + messages: [ + Message.user("first user"), + Message.assistant("assistant reply"), + Message.user("latest user message"), + ], + cache: "auto", + }), + ) + + expect(prepared.body).toMatchObject({ + tools: [{ name: "t1", cache_control: { type: "ephemeral" } }], + system: [{ type: "text", text: "Sys A", cache_control: { type: "ephemeral" } }], + messages: [ + { role: "user", content: [{ type: "text", text: "first user" }] }, + { role: "assistant", content: [{ type: "text", text: "assistant reply" }] }, + { + role: "user", + content: [{ type: "text", text: "latest user message", cache_control: { type: "ephemeral" } }], + }, + ], + }) + }), + ) + + it.effect("'auto' is a no-op on OpenAI (implicit caching protocol)", () => + Effect.gen(function* () { + const prepared = yield* LLMClient.prepare( + LLM.request({ + model: openaiModel, + system: "Sys", + prompt: "hi", + cache: "auto", + }), + ) + + const body = prepared.body as { messages: Array<{ content: unknown }> } + // OpenAI doesn't accept cache_control on messages — policy must skip. + const flat = JSON.stringify(body) + expect(flat).not.toContain("cache_control") + expect(flat).not.toContain("cachePoint") + }), + ) + + it.effect("'auto' is a no-op on Gemini (out-of-band caching protocol)", () => + Effect.gen(function* () { + const prepared = yield* LLMClient.prepare( + LLM.request({ + model: geminiModel, + system: "Sys", + prompt: "hi", + cache: "auto", + }), + ) + + const flat = JSON.stringify(prepared.body) + expect(flat).not.toContain("cache_control") + expect(flat).not.toContain("cachePoint") + }), + ) + + it.effect("'auto' on Bedrock emits cachePoint markers in the right places", () => + Effect.gen(function* () { + const prepared = yield* LLMClient.prepare( + LLM.request({ + model: bedrockModel, + system: "Sys", + tools: [{ name: "t1", description: "t1", inputSchema: { type: "object", properties: {} } }], + messages: [Message.user("first user"), Message.assistant("reply"), Message.user("latest user")], + cache: "auto", + }), + ) + + expect(prepared.body).toMatchObject({ + toolConfig: { + tools: [{ toolSpec: { name: "t1" } }, { cachePoint: { type: "default" } }], + }, + system: [{ text: "Sys" }, { cachePoint: { type: "default" } }], + messages: [ + { role: "user", content: [{ text: "first user" }] }, + { role: "assistant", content: [{ text: "reply" }] }, + { role: "user", content: [{ text: "latest user" }, { cachePoint: { type: "default" } }] }, + ], + }) + }), + ) + + it.effect("'none' disables auto placement even when manual hints exist", () => + Effect.gen(function* () { + const prepared = yield* LLMClient.prepare( + LLM.request({ + model: anthropicModel, + system: "Sys", + tools: [{ name: "t1", description: "t1", inputSchema: { type: "object", properties: {} } }], + prompt: "hi", + cache: "none", + }), + ) + + expect(prepared.body).toMatchObject({ + tools: [{ name: "t1", cache_control: undefined }], + system: [{ type: "text", text: "Sys", cache_control: undefined }], + }) + }), + ) + + it.effect("granular object form: tools-only marks just tools", () => + Effect.gen(function* () { + const prepared = yield* LLMClient.prepare( + LLM.request({ + model: anthropicModel, + system: "Sys", + tools: [{ name: "t1", description: "t1", inputSchema: { type: "object", properties: {} } }], + prompt: "hi", + cache: { tools: true }, + }), + ) + + expect(prepared.body).toMatchObject({ + tools: [{ name: "t1", cache_control: { type: "ephemeral" } }], + system: [{ type: "text", text: "Sys", cache_control: undefined }], + }) + }), + ) + + it.effect("auto policy preserves manual CacheHints on other parts", () => + Effect.gen(function* () { + const prepared = yield* LLMClient.prepare( + LLM.request({ + model: anthropicModel, + system: [ + { type: "text", text: "first system", cache: new CacheHint({ type: "ephemeral", ttlSeconds: 3600 }) }, + { type: "text", text: "last system" }, + ], + prompt: "hi", + cache: "auto", + }), + ) + + const body = prepared.body as { system: Array<{ text: string; cache_control?: unknown }> } + expect(body.system[0]?.cache_control).toEqual({ type: "ephemeral", ttl: "1h" }) + expect(body.system[1]?.cache_control).toEqual({ type: "ephemeral" }) + }), + ) + + it.effect("ttlSeconds in the policy flows through to wire markers", () => + Effect.gen(function* () { + const prepared = yield* LLMClient.prepare( + LLM.request({ + model: anthropicModel, + system: "Sys", + prompt: "hi", + cache: { system: true, ttlSeconds: 3600 }, + }), + ) + + expect(prepared.body).toMatchObject({ + system: [{ type: "text", text: "Sys", cache_control: { type: "ephemeral", ttl: "1h" } }], + }) + }), + ) + + it.effect("messages: { tail: 2 } marks the last 2 message boundaries", () => + Effect.gen(function* () { + const prepared = yield* LLMClient.prepare( + LLM.request({ + model: anthropicModel, + messages: [Message.user("u1"), Message.assistant("a1"), Message.user("u2"), Message.assistant("a2")], + cache: { messages: { tail: 2 } }, + }), + ) + + const body = prepared.body as { messages: Array<{ content: Array<{ cache_control?: unknown }> }> } + expect(body.messages[0]?.content[0]?.cache_control).toBeUndefined() + expect(body.messages[1]?.content[0]?.cache_control).toBeUndefined() + expect(body.messages[2]?.content[0]?.cache_control).toEqual({ type: "ephemeral" }) + expect(body.messages[3]?.content[0]?.cache_control).toEqual({ type: "ephemeral" }) + }), + ) + + it.effect("'latest-assistant' marks the last assistant message", () => + Effect.gen(function* () { + const prepared = yield* LLMClient.prepare( + LLM.request({ + model: anthropicModel, + messages: [Message.user("u1"), Message.assistant("a1"), Message.user("u2")], + cache: { messages: "latest-assistant" }, + }), + ) + + const body = prepared.body as { messages: Array<{ content: Array<{ cache_control?: unknown }> }> } + expect(body.messages[0]?.content[0]?.cache_control).toBeUndefined() + expect(body.messages[1]?.content[0]?.cache_control).toEqual({ type: "ephemeral" }) + expect(body.messages[2]?.content[0]?.cache_control).toBeUndefined() + }), + ) + + test("returns the same request reference when policy is a no-op (pure function)", () => { + const request = LLM.request({ + model: anthropicModel, + prompt: "hi", + cache: "none", + }) + expect(applyCachePolicy(request)).toBe(request) + }) +}) diff --git a/packages/llm/test/continuation-scenarios.ts b/packages/llm/test/continuation-scenarios.ts new file mode 100644 index 0000000000000000000000000000000000000000..1bb1848b557eff88f816544e604a6a2759d4726b --- /dev/null +++ b/packages/llm/test/continuation-scenarios.ts @@ -0,0 +1,104 @@ +import { LLM, Message, ToolCallPart, ToolDefinition, ToolResultPart, type ContentPart, type Model } from "../src" + +export const basicContinuation = ["system", "user-text", "assistant-text", "user-follow-up"] as const +export const toolContinuation = ["tool-call", "tool-result"] as const +export const reasoningContinuation = ["assistant-reasoning", "encrypted-reasoning"] as const +export const mediaContinuation = ["user-image"] as const +export const maximalContinuation = [ + ...basicContinuation, + ...toolContinuation, + ...reasoningContinuation, + ...mediaContinuation, +] as const + +export type ContinuationFeature = (typeof maximalContinuation)[number] + +export const nativeOpenAIResponsesContinuation = [ + ...basicContinuation, + ...toolContinuation, + "encrypted-reasoning", + ...mediaContinuation, +] as const satisfies ReadonlyArray + +export const nativeAnthropicMessagesContinuation = [ + ...basicContinuation, + ...toolContinuation, + "assistant-reasoning", + ...mediaContinuation, +] as const satisfies ReadonlyArray + +export const continuationTool = ToolDefinition.make({ + name: "get_weather", + description: "Get current weather for a city.", + inputSchema: { + type: "object", + properties: { city: { type: "string" } }, + required: ["city"], + additionalProperties: false, + }, +}) + +export function continuationRequest(input: { + readonly id: string + readonly model: Model + readonly features: ReadonlyArray + readonly image?: string +}) { + const features = new Set(input.features) + const messages = [] + const firstUser: ContentPart[] = [] + const firstAssistant: ContentPart[] = [] + + if (features.has("user-text")) firstUser.push({ type: "text", text: "What is shown here?" }) + if (features.has("user-image")) + firstUser.push({ type: "media", mediaType: "image/png", data: input.image ?? "AAECAw==" }) + if (firstUser.length > 0) messages.push(Message.user(firstUser)) + + if (features.has("assistant-reasoning")) + firstAssistant.push({ + type: "reasoning", + text: "I inspected the previous turn.", + providerMetadata: { anthropic: { signature: "sig_continuation_1" } }, + }) + if (features.has("encrypted-reasoning")) + firstAssistant.push({ + type: "reasoning", + text: "I inspected the previous turn.", + providerMetadata: { + openai: { + itemId: "rs_continuation_1", + reasoningEncryptedContent: "encrypted-continuation-state", + }, + }, + }) + if (features.has("assistant-text")) firstAssistant.push({ type: "text", text: "It shows a small test image." }) + if (firstAssistant.length > 0) messages.push(Message.assistant(firstAssistant)) + + if (features.has("tool-call")) { + messages.push(Message.user("Check the weather in Paris before continuing.")) + messages.push( + Message.assistant([ToolCallPart.make({ id: "call_weather_1", name: "get_weather", input: { city: "Paris" } })]), + ) + } + if (features.has("tool-result")) { + messages.push( + Message.tool(ToolResultPart.make({ id: "call_weather_1", name: "get_weather", result: { temperature: 22 } })), + ) + if (features.has("assistant-text")) messages.push(Message.assistant("Paris is 22 degrees.")) + } + if (features.has("user-follow-up")) + messages.push(Message.user("Continue from this conversation in one short sentence.")) + + return LLM.request({ + id: input.id, + model: input.model, + system: features.has("system") ? "You are concise. Continue from the provided history." : undefined, + messages, + tools: features.has("tool-call") ? [continuationTool] : [], + cache: "none", + providerOptions: features.has("encrypted-reasoning") + ? { openai: { store: false, include: ["reasoning.encrypted_content"], reasoningSummary: "auto" } } + : undefined, + generation: { maxTokens: 80, temperature: 0 }, + }) +} diff --git a/packages/llm/test/endpoint.test.ts b/packages/llm/test/endpoint.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..504c9843c1be9ff33eaa5705ead4f7a29e6750e4 --- /dev/null +++ b/packages/llm/test/endpoint.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, test } from "bun:test" +import { LLM } from "../src" +import * as OpenAIChat from "../src/protocols/openai-chat" +import { Endpoint } from "../src/route" +import { Model } from "../src/schema" + +const request = () => + LLM.request({ + model: Model.make({ + id: "model-1", + provider: "test", + route: OpenAIChat.route, + }), + prompt: "hello", + }) + +describe("Endpoint", () => { + test("appends a static path to the model's baseURL", () => { + const url = Endpoint.render(Endpoint.path("/chat", { baseURL: "https://api.example.test/v1/" }), { + request: request(), + body: {}, + }) + + expect(url.toString()).toBe("https://api.example.test/v1/chat") + }) + + test("endpoint query params are appended to the rendered URL", () => { + const url = Endpoint.render( + Endpoint.path("/chat?alt=sse", { + baseURL: "https://custom.example.test/root/", + query: { "api-version": "2026-01-01", alt: "json" }, + }), + { + request: request(), + body: {}, + }, + ) + + expect(url.toString()).toBe("https://custom.example.test/root/chat?alt=json&api-version=2026-01-01") + }) + + test("path may be a function of the validated body", () => { + const url = Endpoint.render( + Endpoint.path<{ readonly modelId: string }>( + ({ body }) => `/model/${encodeURIComponent(body.modelId)}/converse-stream`, + { baseURL: "https://bedrock-runtime.us-east-1.amazonaws.com" }, + ), + { + request: request(), + body: { modelId: "us.amazon.nova-micro-v1:0" }, + }, + ) + + expect(url.toString()).toBe( + "https://bedrock-runtime.us-east-1.amazonaws.com/model/us.amazon.nova-micro-v1%3A0/converse-stream", + ) + }) +}) diff --git a/packages/llm/test/executor.test.ts b/packages/llm/test/executor.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..811f7a9ffe7b606a4f42fb34175d7f35325f8525 --- /dev/null +++ b/packages/llm/test/executor.test.ts @@ -0,0 +1,458 @@ +import { describe, expect } from "bun:test" +import { Effect, Fiber, Layer, Random, Ref } from "effect" +import * as TestClock from "effect/testing/TestClock" +import { Headers, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http" +import { LLM, LLMError } from "../src" +import { LLMClient, RequestExecutor } from "../src/route" +import * as OpenAIChat from "../src/protocols/openai-chat" +import { dynamicResponse } from "./lib/http" +import { deltaChunk } from "./lib/openai-chunks" +import { sseRaw } from "./lib/sse" +import { it } from "./lib/effect" + +const request = HttpClientRequest.post("https://provider.test/v1/chat?api_key=secret&key=secret&debug=1").pipe( + HttpClientRequest.setHeaders(Headers.fromInput({ authorization: "Bearer secret", "x-safe": "visible" })), +) + +const secretRequest = HttpClientRequest.post("https://provider.test/v1/chat?api_key=query-secret-123&debug=1").pipe( + HttpClientRequest.setHeaders(Headers.fromInput({ authorization: "Bearer header-secret-456" })), +) + +const responsesLayer = (responses: ReadonlyArray) => + RequestExecutor.layer.pipe( + Layer.provide( + Layer.unwrap( + Effect.gen(function* () { + const cursor = yield* Ref.make(0) + return Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => + Effect.gen(function* () { + const index = yield* Ref.getAndUpdate(cursor, (value) => value + 1) + return HttpClientResponse.fromWeb(request, responses[index] ?? responses[responses.length - 1]) + }), + ), + ) + }), + ), + ), + ) + +const countedResponsesLayer = (attempts: Ref.Ref, responses: ReadonlyArray) => + RequestExecutor.layer.pipe( + Layer.provide( + Layer.unwrap( + Effect.gen(function* () { + const cursor = yield* Ref.make(0) + return Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => + Effect.gen(function* () { + yield* Ref.update(attempts, (value) => value + 1) + const index = yield* Ref.getAndUpdate(cursor, (value) => value + 1) + return HttpClientResponse.fromWeb(request, responses[index] ?? responses[responses.length - 1]) + }), + ), + ) + }), + ), + ), + ) + +const randomMidpoint = { + nextDoubleUnsafe: () => 0.5, + nextIntUnsafe: () => 0, +} + +const expectLLMError = (error: unknown) => { + expect(error).toBeInstanceOf(LLMError) + if (!(error instanceof LLMError)) throw new Error("expected LLMError") + return error +} + +const errorHttp = (error: LLMError) => ("http" in error.reason ? error.reason.http : undefined) + +describe("RequestExecutor", () => { + it.effect("classifies context overflow responses", () => + Effect.gen(function* () { + const executor = yield* RequestExecutor.Service + const error = yield* executor.execute(request).pipe(Effect.flip) + + expectLLMError(error) + expect(error.reason).toMatchObject({ _tag: "InvalidRequest", classification: "context-overflow" }) + }).pipe( + Effect.provide( + responsesLayer([ + new Response('{"error":{"code":"context_length_exceeded","message":"prompt too long"}}', { + status: 400, + }), + ]), + ), + ), + ) + + it.effect("does not classify generic HTTP 413 payload errors as context overflow", () => + Effect.gen(function* () { + const executor = yield* RequestExecutor.Service + const error = yield* executor.execute(request).pipe(Effect.flip) + + expectLLMError(error) + expect(error.reason).toMatchObject({ _tag: "InvalidRequest" }) + expect("classification" in error.reason ? error.reason.classification : undefined).toBeUndefined() + }).pipe(Effect.provide(responsesLayer([new Response("request too large", { status: 413 })]))), + ) + + it.effect("does not classify ordinary invalid requests as context overflow", () => + Effect.gen(function* () { + const executor = yield* RequestExecutor.Service + const error = yield* executor.execute(request).pipe(Effect.flip) + + expectLLMError(error) + expect(error.reason).toMatchObject({ _tag: "InvalidRequest" }) + expect("classification" in error.reason ? error.reason.classification : undefined).toBeUndefined() + }).pipe(Effect.provide(responsesLayer([new Response("invalid parameter", { status: 400 })]))), + ) + + it.effect("returns redacted diagnostics for retryable rate limits", () => + Effect.gen(function* () { + const executor = yield* RequestExecutor.Service + const error = yield* executor.execute(request).pipe(Effect.flip) + + expectLLMError(error) + expect(error).toMatchObject({ + retryable: true, + retryAfterMs: 0, + reason: { + _tag: "RateLimit", + rateLimit: { retryAfterMs: 0 }, + http: { + requestId: "req_123", + request: { + method: "POST", + url: "https://provider.test/v1/chat?api_key=%3Credacted%3E&key=%3Credacted%3E&debug=1", + headers: { authorization: "", "x-safe": "visible" }, + }, + response: { + status: 429, + headers: { + "retry-after-ms": "0", + "x-request-id": "req_123", + "x-api-key": "", + }, + }, + }, + }, + }) + expect(errorHttp(error)?.body).toBe("rate limited") + }).pipe( + Effect.provide( + responsesLayer( + Array.from( + { length: 3 }, + () => + new Response("rate limited", { + status: 429, + headers: { "retry-after-ms": "0", "x-request-id": "req_123", "x-api-key": "secret" }, + }), + ), + ), + ), + ), + ) + + it.effect("honors current redacted header names in diagnostics", () => + Effect.gen(function* () { + const executor = yield* RequestExecutor.Service + const error = yield* executor.execute(request).pipe(Effect.flip) + + expectLLMError(error) + expect(errorHttp(error)?.request.headers["x-safe"]).toBe("") + expect(errorHttp(error)?.response?.headers["x-safe"]).toBe("") + }).pipe( + Effect.provide(responsesLayer([new Response("bad", { status: 400, headers: { "x-safe": "response-secret" } })])), + Effect.provideService(Headers.CurrentRedactedNames, ["x-safe"]), + ), + ) + + it.effect("extracts OpenAI-style rate-limit diagnostics", () => + Effect.gen(function* () { + const executor = yield* RequestExecutor.Service + const error = yield* executor.execute(request).pipe(Effect.flip) + + expectLLMError(error) + expect(error.reason).toMatchObject({ _tag: "RateLimit" }) + expect(error.reason._tag === "RateLimit" ? error.reason.rateLimit : undefined).toEqual({ + retryAfterMs: 0, + limit: { requests: "500", tokens: "30000" }, + remaining: { requests: "499", tokens: "29900" }, + reset: { requests: "1s", tokens: "10s" }, + }) + }).pipe( + Effect.provide( + responsesLayer( + Array.from( + { length: 3 }, + () => + new Response("rate limited", { + status: 429, + headers: { + "retry-after-ms": "0", + "x-ratelimit-limit-requests": "500", + "x-ratelimit-limit-tokens": "30000", + "x-ratelimit-remaining-requests": "499", + "x-ratelimit-remaining-tokens": "29900", + "x-ratelimit-reset-requests": "1s", + "x-ratelimit-reset-tokens": "10s", + }, + }), + ), + ), + ), + ), + ) + + it.effect("extracts Anthropic-style rate-limit diagnostics", () => + Effect.gen(function* () { + const executor = yield* RequestExecutor.Service + const error = yield* executor.execute(request).pipe(Effect.flip) + + expectLLMError(error) + expect(error.reason).toMatchObject({ _tag: "ProviderInternal" }) + expect(errorHttp(error)?.rateLimit).toEqual({ + retryAfterMs: 0, + limit: { requests: "100", "input-tokens": "10000" }, + remaining: { requests: "12", "input-tokens": "9000" }, + reset: { requests: "2026-05-06T12:00:00Z", "input-tokens": "2026-05-06T12:00:10Z" }, + }) + }).pipe( + Effect.provide( + responsesLayer( + Array.from( + { length: 3 }, + () => + new Response("overloaded", { + status: 529, + headers: { + "retry-after-ms": "0", + "anthropic-ratelimit-requests-limit": "100", + "anthropic-ratelimit-requests-remaining": "12", + "anthropic-ratelimit-requests-reset": "2026-05-06T12:00:00Z", + "anthropic-ratelimit-input-tokens-limit": "10000", + "anthropic-ratelimit-input-tokens-remaining": "9000", + "anthropic-ratelimit-input-tokens-reset": "2026-05-06T12:00:10Z", + }, + }), + ), + ), + ), + ), + ) + + it.effect("retries retryable status responses before returning the stream", () => + Effect.gen(function* () { + const executor = yield* RequestExecutor.Service + const response = yield* executor.execute(request) + + expect(response.status).toBe(200) + expect(yield* response.text).toBe("ok") + }).pipe( + Effect.provide( + responsesLayer([ + new Response("busy", { status: 503, headers: { "retry-after-ms": "0" } }), + new Response("ok", { status: 200 }), + ]), + ), + ), + ) + + it.effect("marks 504 and 529 status responses retryable", () => + Effect.gen(function* () { + const failWith = (status: number) => + Effect.gen(function* () { + const executor = yield* RequestExecutor.Service + const error = yield* executor.execute(request).pipe(Effect.flip) + + expectLLMError(error) + expect(error.reason).toMatchObject({ _tag: "ProviderInternal", status }) + expect(error.retryable).toBe(true) + }).pipe( + Effect.provide( + responsesLayer( + Array.from( + { length: 3 }, + () => + new Response("retry", { + status, + headers: { "retry-after-ms": "0" }, + }), + ), + ), + ), + ) + + yield* failWith(504) + yield* failWith(529) + }), + ) + + it.effect("does not retry non-retryable status responses and truncates large bodies", () => + Effect.gen(function* () { + const executor = yield* RequestExecutor.Service + const error = yield* executor.execute(request).pipe(Effect.flip) + + expectLLMError(error) + expect(error.reason).toMatchObject({ _tag: "Authentication" }) + expect(error.retryable).toBe(false) + expect(errorHttp(error)?.bodyTruncated).toBe(true) + expect(errorHttp(error)?.body).toHaveLength(16_384) + }).pipe( + Effect.provide( + responsesLayer([ + new Response("x".repeat(20_000), { status: 401 }), + new Response("should not retry", { status: 200 }), + ]), + ), + ), + ) + + it.effect("redacts common secret fields in response bodies", () => + Effect.gen(function* () { + const executor = yield* RequestExecutor.Service + const error = yield* executor.execute(request).pipe(Effect.flip) + + expectLLMError(error) + expect(errorHttp(error)?.body).toContain('"key":""') + expect(errorHttp(error)?.body).toContain("api_key=") + expect(errorHttp(error)?.body).not.toContain("body-secret") + expect(errorHttp(error)?.body).not.toContain("query-secret") + }).pipe( + Effect.provide( + responsesLayer([ + new Response('{"error":{"message":"bad","key":"body-secret","detail":"api_key=query-secret"}}', { + status: 400, + }), + ]), + ), + ), + ) + + it.effect("redacts echoed request secret values in response bodies", () => + Effect.gen(function* () { + const executor = yield* RequestExecutor.Service + const error = yield* executor.execute(secretRequest).pipe(Effect.flip) + + expectLLMError(error) + expect(errorHttp(error)?.body).toContain("provider echoed ") + expect(errorHttp(error)?.body).toContain("authorization ") + expect(errorHttp(error)?.body).not.toContain("query-secret-123") + expect(errorHttp(error)?.body).not.toContain("header-secret-456") + }).pipe( + Effect.provide( + responsesLayer([ + new Response("provider echoed query-secret-123 and authorization header-secret-456", { status: 400 }), + ]), + ), + ), + ) + + it.effect("honors Retry-After delta seconds before retrying", () => + Effect.gen(function* () { + const attempts = yield* Ref.make(0) + return yield* Effect.gen(function* () { + const executor = yield* RequestExecutor.Service + const fiber = yield* executor.execute(request).pipe(Effect.forkChild) + + yield* Effect.yieldNow + expect(yield* Ref.get(attempts)).toBe(1) + + yield* TestClock.adjust(1_999) + yield* Effect.yieldNow + expect(yield* Ref.get(attempts)).toBe(1) + + yield* TestClock.adjust(1) + const response = yield* Fiber.join(fiber) + + expect(response.status).toBe(200) + expect(yield* Ref.get(attempts)).toBe(2) + }).pipe( + Effect.provide( + countedResponsesLayer(attempts, [ + new Response("busy", { status: 503, headers: { "retry-after": "2" } }), + new Response("ok", { status: 200 }), + ]), + ), + ) + }), + ) + + it.effect("uses exponential jittered delay when retry-after is absent", () => + Effect.gen(function* () { + const attempts = yield* Ref.make(0) + return yield* Effect.gen(function* () { + const executor = yield* RequestExecutor.Service + const fiber = yield* executor.execute(request).pipe(Effect.flip, Effect.forkChild) + + yield* Effect.yieldNow + expect(yield* Ref.get(attempts)).toBe(1) + + yield* TestClock.adjust(499) + yield* Effect.yieldNow + expect(yield* Ref.get(attempts)).toBe(1) + + yield* TestClock.adjust(1) + yield* Effect.yieldNow + expect(yield* Ref.get(attempts)).toBe(2) + + yield* TestClock.adjust(999) + yield* Effect.yieldNow + expect(yield* Ref.get(attempts)).toBe(2) + + yield* TestClock.adjust(1) + const error = yield* Fiber.join(fiber) + + expectLLMError(error) + expect(error.reason).toMatchObject({ _tag: "ProviderInternal" }) + expect(yield* Ref.get(attempts)).toBe(3) + }).pipe( + Effect.provide( + countedResponsesLayer(attempts, [ + new Response("busy", { status: 503 }), + new Response("still busy", { status: 503 }), + new Response("done retrying", { status: 503 }), + ]), + ), + ) + }).pipe(Effect.provideService(Random.Random, randomMidpoint)), + ) + + it.effect("does not retry after a successful response reaches stream parsing", () => + Effect.gen(function* () { + const attempts = yield* Ref.make(0) + const model = OpenAIChat.route + .with({ endpoint: { baseURL: "https://api.openai.test/v1" } }) + .model({ id: "gpt-4o-mini" }) + const error = yield* LLMClient.generate(LLM.request({ model, prompt: "Say hello." })).pipe( + Effect.provide( + dynamicResponse((input) => + Ref.update(attempts, (value) => value + 1).pipe( + Effect.as( + input.respond( + sseRaw( + `data: ${JSON.stringify(deltaChunk({ role: "assistant", content: "Hello" }))}`, + "data: not-json", + ), + { headers: { "content-type": "text/event-stream" } }, + ), + ), + ), + ), + ), + Effect.flip, + ) + + expectLLMError(error) + expect(error.reason).toMatchObject({ _tag: "InvalidProviderOutput" }) + expect(yield* Ref.get(attempts)).toBe(1) + }), + ) +}) diff --git a/packages/llm/test/exports.test.ts b/packages/llm/test/exports.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..4bed7e2e1357338fa36f1a66471f1f8ecb97a4a1 --- /dev/null +++ b/packages/llm/test/exports.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, test } from "bun:test" +import { LLM, LLMClient, Provider } from "@opencode-ai/llm" +import { Route, Protocol } from "@opencode-ai/llm/route" +import { Provider as ProviderSubpath } from "@opencode-ai/llm/provider" +import { + CloudflareAIGateway, + CloudflareWorkersAI, + OpenAI, + OpenAICompatible, + OpenRouter, + XAI, +} from "@opencode-ai/llm/providers" +import * as GitHubCopilot from "@opencode-ai/llm/providers/github-copilot" +import { OpenAIChat, OpenAICompatibleChat, OpenAIResponses } from "@opencode-ai/llm/protocols" +import * as AnthropicMessages from "@opencode-ai/llm/protocols/anthropic-messages" + +describe("public exports", () => { + test("root exposes app-facing runtime APIs", () => { + expect(LLM.request).toBeFunction() + expect(LLMClient.Service).toBeFunction() + expect(LLMClient.layer).toBeDefined() + expect(Provider.make).toBeFunction() + expect(ProviderSubpath.make).toBe(Provider.make) + }) + + test("route barrel exposes route-authoring APIs", () => { + expect(Route.make).toBeFunction() + expect(Protocol.make).toBeFunction() + }) + + test("provider barrels expose user-facing facades", () => { + expect(OpenAI.model).toBeFunction() + expect(OpenAI.provider.model).toBe(OpenAI.model) + expect(OpenAI.provider.responses).toBe(OpenAI.responses) + expect(OpenAI.provider.responsesWebSocket).toBe(OpenAI.responsesWebSocket) + expect(OpenAI.configure({ apiKey: "fixture" }).responses).toBeFunction() + expect(OpenAICompatible.deepseek.model).toBeFunction() + expect(CloudflareAIGateway.configure).toBeFunction() + expect(CloudflareAIGateway.configure({ accountId: "fixture", gatewayApiKey: "fixture" }).model).toBeFunction() + expect(CloudflareWorkersAI.configure).toBeFunction() + expect(CloudflareWorkersAI.configure({ accountId: "fixture", apiKey: "fixture" }).model).toBeFunction() + expect(OpenRouter.model).toBeFunction() + expect(OpenRouter.provider.model).toBe(OpenRouter.model) + expect(XAI.model).toBeFunction() + expect(XAI.provider.model).toBe(XAI.model) + expect(XAI.provider.responses).toBe(XAI.responses) + expect(XAI.provider.chat).toBe(XAI.chat) + expect(XAI.configure({ apiKey: "fixture" }).responses("grok-4.3").route.id).toBe("openai-responses") + expect(XAI.configure({ apiKey: "fixture" }).chat("grok-4.3").route.id).toBe("openai-compatible-chat") + expect( + GitHubCopilot.configure({ baseURL: "https://api.githubcopilot.test", apiKey: "fixture" }).model, + ).toBeFunction() + expect( + GitHubCopilot.configure({ + baseURL: "https://api.githubcopilot.test", + apiKey: "fixture", + endpoint: "responses", + }).model("mai-code-1-flash-picker").route.id, + ).toBe("openai-responses") + expect( + GitHubCopilot.configure({ + baseURL: "https://api.githubcopilot.test", + apiKey: "fixture", + endpoint: "chat", + }).model("gpt-5").route.id, + ).toBe("openai-chat") + }) + + test("protocol barrels expose supported low-level routes", () => { + expect(OpenAIChat.route.id).toBe("openai-chat") + expect(OpenAICompatibleChat.route.id).toBe("openai-compatible-chat") + expect(OpenAIResponses.route.id).toBe("openai-responses") + expect(OpenAIResponses.webSocketRoute.id).toBe("openai-responses-websocket") + expect(AnthropicMessages.route.id).toBe("anthropic-messages") + }) +}) diff --git a/packages/llm/test/fixtures/media/restroom.png b/packages/llm/test/fixtures/media/restroom.png new file mode 100644 index 0000000000000000000000000000000000000000..52ed88afb0f06e915ede727b751fa5ea0d98ceb8 Binary files /dev/null and b/packages/llm/test/fixtures/media/restroom.png differ diff --git a/packages/llm/test/fixtures/recordings/anthropic-messages-cache/writes-then-reads-cache-control-on-identical-second-call.json b/packages/llm/test/fixtures/recordings/anthropic-messages-cache/writes-then-reads-cache-control-on-identical-second-call.json new file mode 100644 index 0000000000000000000000000000000000000000..8cf2be05c14ed6d86f3a72b77fa432af986aad10 --- /dev/null +++ b/packages/llm/test/fixtures/recordings/anthropic-messages-cache/writes-then-reads-cache-control-on-identical-second-call.json @@ -0,0 +1,48 @@ +{ + "version": 1, + "metadata": { + "name": "anthropic-messages-cache/writes-then-reads-cache-control-on-identical-second-call", + "recordedAt": "2026-05-11T01:52:54.319Z", + "tags": ["prefix:anthropic-messages-cache", "provider:anthropic", "protocol:anthropic-messages", "cache"] + }, + "interactions": [ + { + "transport": "http", + "request": { + "method": "POST", + "url": "https://api.anthropic.com/v1/messages", + "headers": { + "anthropic-version": "2023-06-01", + "content-type": "application/json" + }, + "body": "{\"model\":\"claude-haiku-4-5-20251001\",\"system\":[{\"type\":\"text\",\"text\":\"You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. \",\"cache_control\":{\"type\":\"ephemeral\"}}],\"messages\":[{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"Say hi.\"}]}],\"stream\":true,\"max_tokens\":16,\"temperature\":0}" + }, + "response": { + "status": 200, + "headers": { + "content-type": "text/event-stream; charset=utf-8" + }, + "body": "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"model\":\"claude-haiku-4-5-20251001\",\"id\":\"msg_01NSbhSJdF1R6Uz81RRKxd55\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"stop_reason\":null,\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":9,\"cache_creation_input_tokens\":5752,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":5752,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":2,\"service_tier\":\"standard\",\"inference_geo\":\"not_available\"}} }\n\nevent: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"\"} }\n\nevent: ping\ndata: {\"type\": \"ping\"}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"Hi.\"} }\n\nevent: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":0 }\n\nevent: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\",\"stop_sequence\":null,\"stop_details\":null},\"usage\":{\"input_tokens\":9,\"cache_creation_input_tokens\":5752,\"cache_read_input_tokens\":0,\"output_tokens\":5} }\n\nevent: message_stop\ndata: {\"type\":\"message_stop\" }\n\n" + } + }, + { + "transport": "http", + "request": { + "method": "POST", + "url": "https://api.anthropic.com/v1/messages", + "headers": { + "anthropic-version": "2023-06-01", + "content-type": "application/json" + }, + "body": "{\"model\":\"claude-haiku-4-5-20251001\",\"system\":[{\"type\":\"text\",\"text\":\"You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. \",\"cache_control\":{\"type\":\"ephemeral\"}}],\"messages\":[{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"Say hi.\"}]}],\"stream\":true,\"max_tokens\":16,\"temperature\":0}" + }, + "response": { + "status": 200, + "headers": { + "content-type": "text/event-stream; charset=utf-8" + }, + "body": "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"model\":\"claude-haiku-4-5-20251001\",\"id\":\"msg_01W9dNB2vnT7HoPQmDfKyniu\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"stop_reason\":null,\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":9,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":5752,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":2,\"service_tier\":\"standard\",\"inference_geo\":\"not_available\"}} }\n\nevent: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"\"} }\n\nevent: ping\ndata: {\"type\": \"ping\"}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"Hi.\"} }\n\nevent: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":0 }\n\nevent: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\",\"stop_sequence\":null,\"stop_details\":null},\"usage\":{\"input_tokens\":9,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":5752,\"output_tokens\":5} }\n\nevent: message_stop\ndata: {\"type\":\"message_stop\" }\n\n" + } + } + ] +} diff --git a/packages/llm/test/fixtures/recordings/anthropic-messages/accepts-malformed-assistant-tool-order-with-default-patch.json b/packages/llm/test/fixtures/recordings/anthropic-messages/accepts-malformed-assistant-tool-order-with-default-patch.json new file mode 100644 index 0000000000000000000000000000000000000000..7730485cb4d68d8d31eb5ddde417b1f59c723775 --- /dev/null +++ b/packages/llm/test/fixtures/recordings/anthropic-messages/accepts-malformed-assistant-tool-order-with-default-patch.json @@ -0,0 +1,29 @@ +{ + "version": 1, + "metadata": { + "name": "anthropic-messages/accepts-malformed-assistant-tool-order-with-default-patch", + "recordedAt": "2026-05-05T20:09:16.245Z", + "tags": ["prefix:anthropic-messages", "provider:anthropic", "protocol:anthropic-messages", "tool"] + }, + "interactions": [ + { + "transport": "http", + "request": { + "method": "POST", + "url": "https://api.anthropic.com/v1/messages", + "headers": { + "anthropic-version": "2023-06-01", + "content-type": "application/json" + }, + "body": "{\"model\":\"claude-haiku-4-5-20251001\",\"messages\":[{\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"I will check the weather.\"}]},{\"role\":\"assistant\",\"content\":[{\"type\":\"tool_use\",\"id\":\"call_1\",\"name\":\"get_weather\",\"input\":{\"city\":\"Paris\"}}]},{\"role\":\"user\",\"content\":[{\"type\":\"tool_result\",\"tool_use_id\":\"call_1\",\"content\":\"{\\\"temperature\\\":\\\"72F\\\"}\"}]},{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"Use that result to answer briefly.\",\"cache_control\":{\"type\":\"ephemeral\"}}]}],\"tools\":[{\"name\":\"get_weather\",\"description\":\"Get weather\",\"input_schema\":{\"type\":\"object\",\"properties\":{}}}],\"stream\":true,\"max_tokens\":4096}" + }, + "response": { + "status": 200, + "headers": { + "content-type": "text/event-stream; charset=utf-8" + }, + "body": "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"model\":\"claude-haiku-4-5-20251001\",\"id\":\"msg_01SikJVFaMR1XLMtavUhvuog\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"stop_reason\":null,\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":638,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":1,\"service_tier\":\"standard\",\"inference_geo\":\"not_available\"}} }\n\nevent: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"\"} }\n\nevent: ping\ndata: {\"type\": \"ping\"}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"The\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" weather in Paris is currently 72°F.\"} }\n\nevent: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":0}\n\nevent: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\",\"stop_sequence\":null,\"stop_details\":null},\"usage\":{\"input_tokens\":638,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"output_tokens\":14} }\n\nevent: message_stop\ndata: {\"type\":\"message_stop\" }\n\n" + } + } + ] +} diff --git a/packages/llm/test/fixtures/recordings/anthropic-messages/anthropic-opus-4-7-image-tool-result.json b/packages/llm/test/fixtures/recordings/anthropic-messages/anthropic-opus-4-7-image-tool-result.json new file mode 100644 index 0000000000000000000000000000000000000000..b1ba048d7a3bde6771180eba2194d0929dec4835 --- /dev/null +++ b/packages/llm/test/fixtures/recordings/anthropic-messages/anthropic-opus-4-7-image-tool-result.json @@ -0,0 +1,43 @@ +{ + "version": 1, + "metadata": { + "name": "anthropic-messages/anthropic-opus-4-7-image-tool-result", + "recordedAt": "2026-05-22T01:57:05.693Z", + "provider": "anthropic", + "route": "anthropic-messages", + "transport": "http", + "model": "claude-opus-4-7", + "tags": [ + "prefix:anthropic-messages", + "provider:anthropic", + "flagship", + "media", + "image", + "vision", + "tool", + "tool-result", + "golden" + ] + }, + "interactions": [ + { + "transport": "http", + "request": { + "method": "POST", + "url": "https://api.anthropic.com/v1/messages", + "headers": { + "anthropic-version": "2023-06-01", + "content-type": "application/json" + }, + "body": "{\"model\":\"claude-opus-4-7\",\"system\":[{\"type\":\"text\",\"text\":\"Read images carefully. Reply only with the visible text, lowercase, no punctuation.\"}],\"messages\":[{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"Use the read_screenshot tool, then reply with the words shown.\"}]},{\"role\":\"assistant\",\"content\":[{\"type\":\"tool_use\",\"id\":\"call_screenshot_1\",\"name\":\"read_screenshot\",\"input\":{}}]},{\"role\":\"user\",\"content\":[{\"type\":\"tool_result\",\"tool_use_id\":\"call_screenshot_1\",\"content\":[{\"type\":\"text\",\"text\":\"Image read successfully\"},{\"type\":\"image\",\"source\":{\"type\":\"base64\",\"media_type\":\"image/png\",\"data\":\"iVBORw0KGgoAAAANSUhEUgAAAnYAAACKCAYAAAAnmweyAAACKWlUWHRYTUw6Y29tLmFkb2JlLnhtcAAAAAAAPD94cGFja2V0IGJlZ2luPSLvu78iIGlkPSJXNU0wTXBDZWhpSHpyZVN6TlRjemtjOWQiPz4KPHg6eG1wbWV0YSB4bWxuczp4PSJhZG9iZTpuczptZXRhLyIgeDp4bXB0az0iWE1QIENvcmUgNi4wLjAiPgogPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4KICA8cmRmOkRlc2NyaXB0aW9uIHJkZjphYm91dD0iIgogICAgeG1sbnM6ZXhpZj0iaHR0cDovL25zLmFkb2JlLmNvbS9leGlmLzEuMC8iCiAgICB4bWxuczp0aWZmPSJodHRwOi8vbnMuYWRvYmUuY29tL3RpZmYvMS4wLyIKICAgZXhpZjpQaXhlbFhEaW1lbnNpb249IjYzMCIKICAgZXhpZjpVc2VyQ29tbWVudD0iU2NyZWVuc2hvdCIKICAgZXhpZjpQaXhlbFlEaW1lbnNpb249IjEzOCIKICAgdGlmZjpZUmVzb2x1dGlvbj0iMTQ0LzEiCiAgIHRpZmY6WFJlc29sdXRpb249IjE0NC8xIgogICB0aWZmOlJlc29sdXRpb25Vbml0PSIyIi8+CiA8L3JkZjpSREY+CjwveDp4bXBtZXRhPgo8P3hwYWNrZXQgZW5kPSJyIj8+at0SpgAACrhpQ0NQSUNDIFByb2ZpbGUAAEiJlZcHUFNZF8fvey+dhJYQASmh994CSAmhBVCQDjZCEiAQQkxBwa4sruBaUBHBsqKrIgo2qg0RxbYo9r4gi4iyLhZsqHwPGMLufvN933xn5s75zXnn/u+5d959cx4AFFOuRCKC1QHIFsul0SEBjMSkZAb+JcACTUACnoDK5ckkrKioCIDahP+7fbgLoFF/y25U69+f/1fT4AtkPACgKJRT+TJeNsonAIABTyKVA4CgDEwWyCWjfB9lmhQtEOWBUU4fY8yoDi11nGljObHRbJQtASCQuVxpOgBkVzTOyOWlozrkWJQdxXyhGOUClH2zs3P4KLehbInmSFAe1Wem/kUn/W+aqUpNLjddyeN7GTNCoFAmEXHz/s/j+N+WLVJMrGGBDnKGNDQa9Xrouf2elROuZHHqjMgJFvLH8sc4QxEaN8E8GTt5gmWiGM4E87mB4Uod0YyICU4TBitzhHJO7AQLZEExEyzNiVaumyZlsyaYK52sQZEVp4xnCDhK/fyM2IQJzhXGz1DWlhUTPpnDVsalimjlXgTikIDJdYOV55At+8vehRzlXHlGbKjyHLiT9QvErElNWaKyNr4gMGgyJ06ZL5EHKNeSiKKU+QJRiDIuy41RzpWjL+fk3CjlGWZyw6ImGMQAOVAAPhCCHMAAgaiXAQkQAS7IkwsWykc3xM6R5EmF6RlyBgu9dQIGR8yzt2U4Ozq7AzB6h8dfkXf0sbsJ0a9MxlZVAeDTNDIycnIyFnYDgKMpAJDqJmOWcwBQ7wPg0imeQpo7Hhu7a1j0y6AGaEAHGAATYAnsgDNwB97AHwSBMBAJYkESmAt4IANkAylYABaDFaAQFIMNYAsoB7vAHnAAHAbHQAM4Bc6Bi+AquAHugEegC/SCV2AQfADDEAThIQpEhXQgQ8gMsoGcISbkCwVBEVA0lASlQOmQGFJAi6FVUDFUApVDu6Eq6CjUBJ2DLkOd0AOoG+qH3kJfYAQmwzRYHzaHHWAmzILD4Vh4DpwOz4fz4QJ4HVwGV8KH4Hr4HHwVvgN3wa/gIQQgKggdMULsECbCRiKRZCQNkSJLkSKkFKlEapBmpB25hXQhA8hnDA5DxTAwdhhvTCgmDsPDzMcsxazFlGMOYOoxbZhbmG7MIOY7loLVw9pgvbAcbCI2HbsAW4gtxe7D1mEvYO9ge7EfcDgcHWeB88CF4pJwmbhFuLW4HbhaXAuuE9eDG8Lj8Tp4G7wPPhLPxcvxhfht+EP4s/ib+F78J4IKwZDgTAgmJBPEhJWEUsJBwhnCTUIfYZioTjQjehEjiXxiHnE9cS+xmXid2EscJmmQLEg+pFhSJmkFqYxUQ7pAekx6p6KiYqziqTJTRaiyXKVM5YjKJZVulc9kTbI1mU2eTVaQ15H3k1vID8jvKBSKOcWfkkyRU9ZRqijnKU8pn1SpqvaqHFW+6jLVCtV61Zuqr9WIamZqLLW5avlqpWrH1a6rDagT1c3V2epc9aXqFepN6vfUhzSoGk4akRrZGms1Dmpc1nihidc01wzS5GsWaO7RPK/ZQ0WoJlQ2lUddRd1LvUDtpeFoFjQOLZNWTDtM66ANamlquWrFay3UqtA6rdVFR+jmdA5dRF9PP0a/S/8yRX8Ka4pgypopNVNuTvmoPVXbX1ugXaRdq31H+4sOQydIJ0tno06DzhNdjK617kzdBbo7dS/oDkylTfWeyptaNPXY1Id6sJ61XrTeIr09etf0hvQN9EP0Jfrb9M/rDxjQDfwNMg02G5wx6DekGvoaCg03G541fMnQYrAYIkYZo40xaKRnFGqkMNpt1GE0bGxhHGe80rjW+IkJyYRpkmay2aTVZNDU0HS66WLTatOHZkQzplmG2VazdrOP5hbmCearzRvMX1hoW3As8i2qLR5bUiz9LOdbVlretsJZMa2yrHZY3bCGrd2sM6wrrK/bwDbuNkKbHTadtlhbT1uxbaXtPTuyHcsu167artuebh9hv9K+wf61g6lDssNGh3aH745ujiLHvY6PnDSdwpxWOjU7vXW2duY5VzjfdqG4BLssc2l0eeNq4ypw3el6343qNt1ttVur2zd3D3epe417v4epR4rHdo97TBozirmWeckT6xnguczzlOdnL3cvudcxrz+97byzvA96v5hmMU0wbe+0Hh9jH67Pbp8uX4Zviu/Pvl1+Rn5cv0q/Z/4m/nz/ff59LCtWJusQ63WAY4A0oC7gI9uLvYTdEogEhgQWBXYEaQbFBZUHPQ02Dk4Prg4eDHELWRTSEooNDQ/dGHqPo8/hcao4g2EeYUvC2sLJ4THh5eHPIqwjpBHN0+HpYdM3TX88w2yGeEZDJIjkRG6KfBJlETU/6uRM3MyomRUzn0c7RS+Obo+hxsyLORjzITYgdn3sozjLOEVca7xa/Oz4qviPCYEJJQldiQ6JSxKvJukmCZMak/HJ8cn7kodmBc3aMqt3ttvswtl351jMWTjn8lzduaK5p+epzePOO56CTUlIOZjylRvJreQOpXJSt6cO8ti8rbxXfH/+Zn6/wEdQIuhL80krSXuR7pO+Kb0/wy+jNGNAyBaWC99khmbuyvyYFZm1P2tElCCqzSZkp2Q3iTXFWeK2HIOchTmdEhtJoaRrvtf8LfMHpeHSfTJINkfWKKehzdI1haXiB0V3rm9uRe6nBfELji/UWCheeC3POm9NXl9+cP4vizCLeItaFxstXrG4ewlrye6l0NLUpa3LTJYVLOtdHrL8wArSiqwVv650XFmy8v2qhFXNBfoFywt6fgj5obpQtVBaeG+19+pdP2J+FP7YscZlzbY134v4RVeKHYtLi7+u5a298pPTT2U/jaxLW9ex3n39zg24DeINdzf6bTxQolGSX9Kzafqm+s2MzUWb32+Zt+VyqWvprq2krYqtXWURZY3bTLdt2Pa1PKP8TkVARe12ve1rtn/cwd9xc6f/zppd+ruKd335Wfjz/d0hu+srzStL9+D25O55vjd+b/svzF+q9unuK973bb94f9eB6ANtVR5VVQf1Dq6vhqsV1f2HZh+6cTjwcGONXc3uWnpt8RFwRHHk5dGUo3ePhR9rPc48XnPC7MT2OmpdUT1Un1c/2JDR0NWY1NjZFNbU2uzdXHfS/uT+U0anKk5rnV5/hnSm4MzI2fyzQy2SloFz6ed6Wue1PjqfeP5228y2jgvhFy5dDL54vp3VfvaSz6VTl70uN11hXmm46n61/prbtbpf3X6t63DvqL/ucb3xhueN5s5pnWdu+t08dyvw1sXbnNtX78y403k37u79e7Pvdd3n33/xQPTgzcPch8OPlj/GPi56ov6k9Kne08rfrH6r7XLvOt0d2H3tWcyzRz28nle/y37/2lvwnPK8tM+wr+qF84tT/cH9N17Oetn7SvJqeKDwD40/tr+2fH3iT/8/rw0mDva+kb4Zebv2nc67/e9d37cORQ09/ZD9Yfhj0SedTwc+Mz+3f0n40je84Cv+a9k3q2/N38O/Px7JHhmRcKXcsVYAQQeclgbA2/0AUJIAoKI9BGnWeI89ZtD4f8EYgf/E4334mKGdSw3qRtsjdgsAR9BhvhwANX8ARlujWH8Au7gox0Q/PNa7jxoO/Yup8UK0Vjk9ta0C/7Txvv4vdf/TA6Xq3/y/AOOhDyne6KAWAAAAimVYSWZNTQAqAAAACAAEARoABQAAAAEAAAA+ARsABQAAAAEAAABGASgAAwAAAAEAAgAAh2kABAAAAAEAAABOAAAAAAAAAJAAAAABAAAAkAAAAAEAA5KGAAcAAAASAAAAeKACAAQAAAABAAACdqADAAQAAAABAAAAigAAAABBU0NJSQAAAFNjAAAAAAAAAADxh4F4AAAAHGlET1QAAAACAAAAAAAAAEUAAAAoAAAARQAAAEUAAAbT33OL9AAABp9JREFUeAHs3F9olWUcB/DnLHCT/rgKQxbhtLwpkIqsLrxZQfQXKggEA/tjZuCFCRHR1Wg3XiyhoKgVeKFd1k1CFNGNRAhhkFAQFBlSkLhjbqtNbW3jeOB0dt6dHc905/d8dnXe5332nvf3+b7jfGXMUt+NG6aTLwIECBAgQIAAgY4XKCl2HZ+hAQgQIECAAAECcwKKnQeBAAECBAgQIBBEQLELEqQxCBAgQIAAAQKKnWeAAAECBAgQIBBEQLELEqQxCBAgQIAAAQKKnWeAAAECBAgQIBBEQLELEqQxCBAgQIAAAQKKnWeAAAECBAgQIBBEQLELEqQxCBAgQIAAAQKKnWeAAAECBAgQIBBEQLELEqQxCBAgQIAAAQKKnWeAAAECBAgQIBBEoPT+4NHp+WY58edPaeST1+c7ZY0AAQIECBAgQGAZCpQ+H/ln3mJXPnMy7R4eWIa37JYIECBAgAABAgTmE1Ds5lOxRoAAAQIECBDoQIFFF7uelb0dOKZbJkCAAAECBAjEF1Ds4mdsQgIECBAgQCATAcUuk6CNSYAAAQIECMQXUOziZ2xCAgQIECBAIBMBxS6ToI1JgAABAgQIxBdQ7OJnbEICBAgQIEAgEwHFLpOgjUmAAAECBAjEF1Ds4mdsQgIECBAgQCATAcUuk6CNSYAAAQIECMQXUOziZ2xCAgQIECBAIBMBxS6ToI1JgAABAgQIxBdQ7OJnbEICBAgQIEAgEwHFLpOgjUmAAAECBAjEF1Ds4mdsQgIECBAgQCATAcUuk6CNSYAAAQIECMQXUOziZ2xCAgQIECBAIBMBxS6ToI1JgAABAgQIxBdQ7OJnbEICBAgQIEAgEwHFLpOgjUmAAAECBAjEF1Ds4mdsQgIECBAgQCATAcUuk6CNSYAAAQIECMQXUOziZ2xCAgQIECBAIBMBxS6ToI1JgAABAgQIxBdQ7OJnbEICBAgQIEAgEwHFLpOgjUmAAAECBAjEF1Ds4mdsQgIECBAgQCATAcUuk6CNSYAAAQIECMQXUOziZ2xCAgQIECBAIBMBxS6ToI1JgAABAgQIxBdQ7OJnbEICBAgQIEAgEwHFLpOgjUmAAAECBAjEF1Ds4mdsQgIECBAgQCATAcUuk6CNSYAAAQIECMQXUOziZ2xCAgQIECBAIBMBxS6ToI1JgAABAgQIxBdQ7OJnbEICBAgQIEAgEwHFLpOgjUmAAAECBAjEF1Ds4mdsQgIECBAgQCATgYbFburcZNoxdFcdQ8/K3ro1CwQIECBAgAABApdfoGGx+3d6Oj03uLHuDhW7OhILBAgQIECAAIFlIaDYLYsY3AQBAgQIEMhLYOvWp5dk4IMHDyzJdTvloopdpyTlPgkQIECAQCABxW5pwlTslsbVVQkQIECAAIECAcWuAOciTil2F4HnWwkQIECAAIHWBBS71twW+i7FbiEh5wkQIECAAIG2Cyh2bSedu2DDYnf2/Nn0wht31r2rv4qtI7FAgAABAgQILFJAsVskWJPbGxa78pmTaffwQN1lFLs6EgsECBAgQIDAIgUUu0WCNbldsatA3XrbxnTfA4/VsH343r7098REzVrRQXd3d3p+58upq+uK6rYD+99N5dFT1WMvFhboX3dzevTxLdWN4+Nn0v6Rt9P0zP+t6IsAAQKdIuAzoTgpxa7Yp9Wzil1FbuD+B9OLu16pcdzxzJPpr9Ona9aKDtb2r0t7931Qs2Xv0Gvp6LdHatYcFAs89MgTadv2XTWbtm15OE1OTtasOSBAgMByFvCZUJyOYlfs0+pZxa4ip9g1foRW9fam/nW3NN4wc+b8uXPp2PffFe5p9qRi16yUfQQILGcBxa44HcWu2KfVs4pdRU6xa/wI3X3v5rTn1cHGGypn3hoeSl8f/mrBfQttUOwWEnKeAIFOEFDsilNS7Ip9Wj2r2FXk/l/spqYm085nn0oTE+NN20b9IW622I2882b68otDTXs12qjYNZKxXiSwevUNqdTVlaZmfmVfLo8WbXWOwCURiPqZ0C48xa5dkrXXUewqHrO/buzru6mqMzrzBw9//H6ietzMi+6enrR+/Yaarb8d/yWNjY3VrLXjYLb8XHnV1dVLfXbo44t6n7X969OmezZXr3f815/TkW8Ozx3ffsemtP2lPdVzF15cs2pVWrGi+8Jhalexu/a669OaNX3V686++PGHYx3xxxNFjjUDOWirQKlUSvs/+jTN/gyWy6fm/lHW1jdwMQItCFzKz4QWbu+yf4titzQR/AcAAP//YCg3bwAAJAVJREFU7V0HvNXE0x1p0osivYMgIB2xIXb5oyKIoCAovUuvgvTee5WOFBEQULBgAxERQUBAlN5BQOkKCvrNCW7e3tzklpd738v73gy/R5LdzWZzNjc5Ozsze9unb1/7l2zkwuVz1H7U4345KVNl9EuThLhFIGu27DRuyjvmRU8cP0LdOzanv//+20wLd6dVu+5U6bGnzdMmjxtK679aax7b7aRPn4Gmz11uZr09eTR9vna1eZwYd2KDY2LEKdL3nCVrNho/dYFR7fXr16l+7WcjfQmpTxAQBCKMQN269SJc463qFiyI+T5G5QIer/Q2IXYe7yGb5tWoVY9efrWhkXPz5k3q1a01HTywz6ZkaEkpUtzOBG0ZpUyZyjhh86YNNHpYn6AnC7HzhSi2OPrWErdHadOmpatXr9K//9qO7+K2MS6uVrb8A9S15yCjhvggdv9fcHTRBYnm1HTp0tHly5cTzf1G80aF2EUH3URD7PIXKES33XZbyCheu/YnnTxxPGD53HnyUvLkKQKWiS3hypkzN2VhzVzmzFkp0x13Egjc2TOn6fSpE9SmQw+6K2t247pLFs2h5UvmB2wDMlOlTk158uSnzHdlpTvvykKpUqWmSxfO06+nT1KuPPno1debGXVc5LQu7RrRpUuXgtYZCWKXNGkyypsvf8Br/fnnH3Tq5ImAZVRmnrz5KVmyZHSDtZdHjx5WycY2W/achOcAcsedd/G9n6Bf9uwK6yUdDRyNBrn4D23KzvdmJ+fOnaFLFy+aWbfffjs9VbkqFSlawsDirizZ6N0Fs+j9pbe0XWZBm50778zMfVWQUqZKRRkyZKKzZ0/T0SOH+Ln8NWximJKfv/wFClKWLPyM8zMJuXDhd7rIf6f4d3fixDGbFtgn4Z5q1m5AVau/bBbo0bmFuW/dOcbPhZ12Oy5wTJ48OeXKnZfy5i9EWfg3fP63c4zhAeNZ/fOPP6xNDek4Y6Y7KD/XlztfAUqaJClBg3/k8AE68+vpgP2C90omPhdy7OgRxuQvSp8hA5Up9wBdv3aNU/+lrd9vMtJRpmChIlSocFG6fOkC3bhxg3Zs3/JfOeRGT25PmZLwPoTg/fQbYwbB+6fYvaX4PZmDf/PJ6ejh/XRg/146//tvRn6g//AsZ8iYybbI8WNH6a+/rpt5KPt0lWpUoGBhfmbvprTp0lO/nu3pZ353WCWa3wT0c15+v+Hdf8cdmbmNf9H587/RxfO/0+FD+/n3c97anKDHbp/HLFmyGnjgQngX4LlQgmep0N1FCe3+959/jPbu2/uT8VyqMkLsFBKR3SYaYjd38RrCByBU2fvzLur9ZruAxcdOnkvZsucKWKZOjacCvlytJ5e770GqVqMOFb6nuDXL7/jo4YPUvVNz+od/NE6SkV9ez75Qi57mjzk+XMFkxOC3+GX+bbBiRn4kiB0+LlNmLgl4vT27f6R+b3UIWEZlTpy+iIlCFgIxb1DneSM5W/YcVLteE7r/wUp+5P7K5Us0Y+pY2rRxnarCdhtNHG0vGEZimbIVqFuvIbZnrHr/XVo4b7qR91DFx6hu/eZ0Z+YsPmU/WbOCZr89wSdNP6jwQEVq1LwdZcx4iwToedjH4GD8qIH8Uf3FmuV3DK1m5Wer8zNe2/wg+BXihLO/nqIftn5Hy96dx4OMGGKqyqJNjzz2DA9W8hkf9nAGbWs+WEbzZk1WVZnbaOKId0/9xm/Qo09UpqRJk5rXVDvQmH752Uc0d+ZEgsYxFCnOpKZl224mMbae8/tvZ2kSm1Ts3rndmmUcv1ynAdV4+TVjf1DfLnQb/2vTsSelY8KkZOeOrTRicC9qzP2Ptuty6MBeGtyva1gDI/38UPeL3FOM+g259Xyu//JTmjpxBL1StxH977katu/03Tu30dgR/QK2CwPZF158xbYJwwa8Sdt+2GwM2jFYqPbSq37XGTO8L3337dd+50fjmwBi9NIrr9MTT1XhZyeZ3zWRgOcH/bGF392hDPQj9Ty24uev0uPPGG3q2aWlQaxTp07DmNWhKs/XIPzedYGCYuXyRfQeKyTQZiF2OjqR2xdi54BlfBC71xu1omervuTQIv/kQwf30ZudnDUT2XPkpL6DxjmOTP1rJK6vOR06uN8uyy/Ny8QOjW1WvwZrRbJRj74jCC8bJ8HLplv7JnT8+FHbItHG0faiYSQGIiRbNm+kqROGUafuA6ho8ZK2tToRO2g+6zVowR/QF23P0xNv3rxB82dPpY9Xv68n++yDfGG6tEy5+33SAx307NLKljCCaDz9vxcCneqY9/W6z2jSWH8iHC0cc+XOQ+079zE0446N+i/jFGsqRw3t7fgsohhwfIkJGT72wQgtPp4rli00tLLWa+vE7tOPVtEjjz5lO/g7d/ZXR/IY6oyB9drhHOvEDu+848eOGG0NVMcZHhgMHdDdcdYlELED6f/5px+pQ9e+BI22ncQVscM7dvDIqcZg1a4d1rRQzBAi+TzqxG7siP70065t1Kv/KMqdt4C1aT7H0yePoi/WrhFi54NK5A4SDbHr1X8kTyMUC4icrtELhdgNH/M2ZbVMgel14GK1X3wy4DVVJrQpbTv1UofGFtNFZ8+cIkxFpk6VxpiatY7YnBwW8MIfOX4m5cyV16fOC6y6P8+q+yScn4nV+ekz+DrDYJTfummdkLSMkSB2GI3qjiCqsTqOe3bvYI1dR5UVcKs0dig0c9o4qvNaE5PUXblymV/Yu3ha+waVKlPetClE2Y0bvmKt0wDs+khc4OhzwVgclCpdjjoycVOiYwfN1xmewi9eoozKNraYdjvGUycXL16gbzd8aeso06RFB562vaX1VCfjmfydp3czZrqTMEWmCzTHnds2dPyYPlPlBWrUzFcLjg/ROW4fpr4wxYXnQTdvcCJ29Ru3pieefs68vH7PSAyk9dr49Rc0bdJI81y1Ew0ccS/jpsznqf/M6jLGFu374+oVw8zCJ4MPftmzk/r0aG9NNo9BtBs0ecM8VjuYgkydJq2fdgn5g/p0pp0/blNFja1O7FQGNN3QsiRJkkQlmdur/PvBVJs+hYlp305tGpllorGjEzu9fpBWmKZAMBth1ShD2ziob1f9FHMfNsrP8UyGEv352bZ1s2EmgGdcF7w/TvLgD1OeK5ctMLRTej72I/lNQH3tu/SmBx56FLumoB2/8W+QX9L8/s5k9Ifqr2DELtLPo07soIkry4M2Rerwnv1p1w7WnF6iIjwDpc8UwOyiRcNaQuzMXo3sTqIhdqHA1rFbP8IUDyQUYmdXZ3VW29eu19jMCnUqts/A0axRKWWet2LpQlr1/mL644+rZlr69OnZlqgh4QOpZNPG9ca0gzpWW0zT9BowWh2yXcMpg7js3xczXQbSUoy1OCCU+ssaNnawuQkmkSB2Ttfo0WcYlSxd3siOzVSstd5PPlpJSxbMNBwFkAfP4mFMzJXDCDQlHd5oYD2N4gJHv4u6THj4kSeMKTW7ajDlvO7zj2k3v3B1OyJrWdgjjpow25w2xIt4+sSRtH3b98bUP6YT87JNFzQf95Ysa57uRJBRoPeAUWwTVdosu3D+2/TpmpXGtLlKxDNZuEhRKn9/ReODBs1IKHaqtes2puo1XzWqCfZxU9cKto0Ejs+9UJNea9jSvBRIw9LFcwybKGiKYYdUslQ5atGmm2Ebqgo6mUSAgMD7V/1e8fGcxv2yY/v3bH92wegv2JnWqtOQ4FCi5CBPk/fs2tpnwGYldqdOHqeBvTsZpK43vzuUHS/qwHsGnvJoc9tOb7FZwyNG1ZHCWrXTbmtH7E6eOEojh/TyGUQ88FAlgle6Pv0X6gxE05Yd6clnYgYKqh0YyHy8ejlt/nY94d0JMhmuxPabgPuYMf99837wLZg8bgj9sOU7H/MblCtZuiyVr1CRSpQqawzMndoY6edRJ3b6NX/atZ2mc5QERbzxnsXvvwDbaSpp0bAmPfecP+Yq381WvGIl3In5/MQnsZuz6EOTZOAl3IOnoOwEH77xU98xpwhg39Su5S07Gb3889VqGdNoKm34wB6GzZI61rewnWnZJmZki2kqTFcFk4RC7EAgVi1f7Hc7TVvxy/w/rQ9e4K+/UsXvxR0XOPo1zGWCHSH5lTUbs6aPYwKwNaTa23XuTQ8+fEtTAGzat6xnGq3rFcD2cMS4maZdFj58bZrVoXPnzurFjP05Cz9gx4vUxj5e/P17dfIrE9uEuCJ24eCIe53Av1Vls3bu7BkOS9SUrly54nebsEeCFlLJ3p93s41vW3VobmF/Cy20kvmzp9DqVUvVoblNkyYNDR093XxPIGMwa69+ZC2WEiux07WjLd7oQo89+T9VlJq8Vs1s90MVHzfIncqELSs0fdESO2LXsvHLtk4S1ncZbPImjx8WtGl2xG7Xjz+w1n9syI5bTheJLbGDo9eQUdPMapcunktL2eY0thKN59GO2H3/3Tc0bmR/H0cKtLlipSfojQ49zeb3ebMNlS8XMyg0MyKwI8ROiJ35GMUXsQNZW7hsrWkvE0xbOGbSXMqe45bTBj407Vq9bt6D2qnJ9jc1a9dXhzwl0YV27vjBPNZ3rERg4pjBtGH953oR2/2EQOzg7QmvTzuB8bTyBkZ+3ZqVjWlavWxc4KhfLxL71v4ESZ8+aZTp3RjsGpjWmvXOKvN5XPfFJzRlwnDH0+AM0bBpGzPf6VmbtWCVOS0O78ZWTV4xNEDmiS524oLYhYtj+QoPUuc3B5p3NXxQT9a2bDKPrTuDR0w2NRrQzjSq+4K1CA0dNZXysWcmxE4Lp5+gh4BBupUEWomdbjYC8ggSqUTPK1GyDPXsFzOVDVtWOwcXda7brZXYbdn8LWvr3rKtFhrNabOXmgMIOJh17dDUtqyeaCV2E8cM4nfgF3qRWO/Hltjly1/QIOfqwnDWgAY7thKN59FK7DBgw/Q3NLtWKXR3ERo4PMZpCQONEvcWtRaLyLEQOyF25oMUX8QODRjJWg+EHYFA6zFj6hjDuFRX/cO+7rlqNenV12JeVNvYc3AYa+OsgmmJ9l36mMnwmMLUhQoVoDIwJdm911CTKCJdeTepMk7bhEDsAk2FW22V7IhdXODohG9s063Erm2Luj4hBoLVa9UUDBvIXoI8hegk1vJOdp86KUFdsIGaN2tSSNP+TtdW6XFB7MLFUdfC4XfckInaNbaXdRI4qkBDrETXkqm0We+sNOzocPzBindpwdxbHs8qX99aCTocW+bMmGgW0YkdbDHbtKhn5j3Kno7wuIWgzQ1erWrm3VP0Xuo7eJx5HNfEDs4N8Gx2ki49BhKiC0BgF9j4tepORc10ndj9zuFUMOiIlMSW2MHhC4MhJXiG1n78AU/lzw4pHJU6T22j8TxaiV3T16s7eiPDRGD42BmqOYYGWYidCUdEd8TGToMzPomdnaE6HB2OHD7ExtApjLhb+ThWlZrWUc2GBx1U31ZB7KUJHPpDGdUiHy+GA/t+ZsPya6wmZ/settnD6B8aQyWIf9Wtw62YdirNaet1Yvcl25JN49AIThIKsYsLHJ3aF9t0t8TOSmZhi3fk0H7H5qRNl8FnYAD70MVsz2gVK94qH/aNMOyHRx1CVcQm+KsXiV19dnCo8p9HMRwbMH0YSODlC29fJdYBFoIgz5i/UmWTE4E2C/DO9DnLTAcp6yBQJ3bo3268eo0SLxO7YNq0Zq06sWPNs+pWjLBHwaaKdWKHKfM32JwgUhJbYofr698k1R44KUFbu4t/M7v5NwOHMDhDBZNIP4+4nk7sgpFoIXbBeihy+ULsNCz1H1Gw6VDtNJ/d2P6I8dIeMHQiZf8vEKdPpQ4HiHsFt3Fdq6cXtfNC1POt+/hh9u/VkcnkQWuW7bHXid1HHy7nuGCTbNuORCvRsNPYoVy0ccQ1Iiluid0LHGNO1wqH27bPPvnQ0Dhbz4PDRa06DYwpPn0woZfDRwteoXAcCqQl1M/BvheJna45cnLO0e8DS/rB+F/JkP7daMe2LerQCCit21whduBG9mgOJLDHRSBkyH4ODvtWt5gp84RK7Ky4WO8fzmt4Dytpz6YqyohfpVm3XiV2GHzDsUZ3hLG2HcGkEXdvycKZPs4k1nKRfh5Rv07sgikFhNhZeyR6x0LsNGzjk9ihGXdxYN1GzTtwnK8KWqv8d+Hh+sGKJfTZJx84kjp1FpwD4CQQSPAxhTfj+0vmhRXxP7EQO2AXTRwD9U1s8twSO+uUYLhtgAfy7OnjHU+DpzFisN1TrKSPRtl6wtqPV/FU7ZSQtBFeJHb9Bo81VvjAfZ0+dZzat4qxebXeK46thv9dObYiovkrsdq2BdNc4byJ0xeaMeisSwUmVGIH+zrY2TmJ/iygjJOjhX6+V4mdaiPCDj31zPPGiiVOgyJ4KL/DzjRr+btgJ5F+HnENIXZ2SMd/mhA7rQ/im9ihKbrHGZYY2rrlW8NOBPGjECj0NIckwFI+IGOhij5q//zT1XSDQySk4PhaqA9LTu1hg9czvCxUuGIldsFsX8KpPxLhTiKlsVPtjhaOqv5Ibd0SO2tIBESy3/fLTyE3D0vfOQV71ivBmpvFS5Tlv9JGaJusvDSUVUINgKt/zCMVgsMtjnoMMjiLNOfwDoEEwckRpFyJ1dsUgWVHjp+tsmkmr5ji9BFXhXSbvA9XvkfvzJmqsiihEjvEIMRshZPoJA1G/PVqVQ46ANbP8dJUrPUe4YVeileaKVXmPirBYYaspjkoj+XO9vy003qqT0y8SDyPuIAQOz+YPZEgxE7rBi8Qu85vDuB4RA8ZrRrA06KIN+ZGdM+qcAL9hnJNrEwwf8nHpo2e9cMRSh1OZbxG7KKJoxMGsU13S0hgeI5pGyWhekmr8rHdYs3g1xq28omLF2oAXJ3YYRCEj7lbcYsjprMxra0kkGE5yui2YZd5GbWm7G2qC+KVzV282vy9WZ0h9LLYh33opBnvmsnQokKbqiShEjuE/EDoDyeBJzI8QCGhkrSEQuz0e4bmDlO0r3OcRD1QPjTdCM5ulUg/j6hfiJ0VZW8cC7HT+iG+iR28oKaxsTMWZoY0a8BhBLQF3LWmhryrhy1w+sGHXJlNwcn84VBR9SNJHL1G7KKNow20sU5yS0hy5WLNEAcnVuIUU03lR3ILz++ps5aYmohQtW/WsDQIfhqbRdH1e3GLI6bP4BSlZArHU1vHcdXsBKt4jGJtHNY5huzfu4ft4fxXl5g6+z1zhQXY7XXh6Vp94XW9bmsYmqH9uxsBplWZhErsjh89TJ3bxQSBV/eDLXA0wp1wQFxIqM9uQiR2xg3yfxgQDRsT420K21R4slslGs+jEDsryt44FmKn9UN8EzvdEw3NQtwieLDi7y+2n7jEyz9hibFjRw/xeolHg04voI4J0xb4BCmFsTU+ltc5oOi1P/80lqaB/Q/WYLQLnIo6Akn/IeOo8D33mkXe6trKiNBuJvy3A+eQv1mTAkPfUMRrxC7aOIaCSahl3BISaGInz3yXvaZjlptDwNFvv1nn2IQCBe82FmVfveo9R+cbxLFCQO1AXq/w4h41fpbpRATP8BaNAnuTolHWe0ZYj0Dr1jreiJZhrTPccCe6lhfVIjZd947NbEPPNG/dmR7nRd6VIG4g4gdapVP3/nTf/Q+byU5auxw5c9GQkdPMZd8Q77Jjm4Y+8cUSKrHDzTvFSqxcpRo1bBYT2Bne2fDSDiZeJHYgqblz5zV+TwgS7iRYhm/qrPfMbMRKRMxEq0TjeRRiZ0XZG8dC7LR+CIfYQbuWiX9QVqlWsy7Bu01JZ36Z6l6rsG/79fQple2zta5y4JNpOcAHb/Omb+iD9xfRWXbPtxNoP6bNWUpp06azy/ZJU96IWJgZwYn1NvsUtBxgzcUatWLiX8GzFtHaf2Q7QNSBtWof5Qj2lR57xlj6bOv3vkbPeCmlYSyt0qPvcHNtQRBa2I3ocpU/khd4zVur6GvFRsrGLi5wtN5HOMdwutGXUXq40pNUgxeJV4LR+xmHZ+4k22za9bWdswjsudZ9/pHhYIO1hhEkOwdr9x59vLK5Fu0AXpJq987t6tI+W9h74WP14/YfaNM3X7JjwEEeWJw1gtsivWDBwlT1xTo+zkNbNm804i/6VGRzYNVaIPYaAlNv5OtA6w17vrwcLqjCg5VoB3sQol6rRANH/Z2C6x3je/549Qra+8suXgLsPBW6uyiVYHspFRYFZbBcVue2jW3taLGMG1aU0A3oYQKxi2MCHtj/s6E9L1zkXsPjOyeTAiV2jhYJmdghBA+mG7d8t8FYJhD2vlg7+JW6jUxsMIBt3eRlvwErsMueIyfdxv+UwMEMzjxKOtksL4g8hBVxskeO9DcBfY1lDzEg+H7TBg5rtYG/HSf4fX/WiC2IwXIJXo4OnuY5cuZRTWfv2Nm0/L13zGN9J9LPoxA7HV3v7Aux0/pC1xIhntaA3p21XN/dQSMmUcFC9/gmhnBkDQSqn4IXDpbygXdcqIIXzdgR/clKmNT5CB7bq/8oM6ipSg+0xb0PHfCmETsvUDnkYekirF2ZJgTyaF3/Ekvc4GOvx9oLdj2V/xXHqJtqE6MuGsQO14w2juq+wt1aDerDPb9+7WcNDa71PDyLQ0ZOMVc5sOY7HQcjdlik3iogljpR0fOd4jTqZdT+m72HGkbl6thpixUksGyeLtHCEdPawzn4eDjP+OhhfXjQtkFvns9+m449DQ2lT2KAA8So696phR+BT8jETr9dzAJgYGAVJ+9sa7xA63mBjkGee3aJWfpNLxvpb4Iidvo11L7TbwaEF8oEJ/IZ6edRiJ3qEW9thdhp/aFPtwULbjts9DRDA6CdHtJuIGKHCvCBK1m6HAclTmloYVLwEjlp06WnrFlzGAvXI0gxjnXBi63jG/X9VpVQZbJlz2FozlKkSGHUiZdg5sxZKQt7IWLkmidvAb8P60ccpX6uFqVe1WW3xRRys9adeAHyZHbZZpqV2Fkjq5sFQ9iJa2KHJkUbxxBu26+IVVPlVyBIghOxw2nQYLXgNYSLlygTpJZb2dCsYhH5o2wDZSe6h6ZdvjUNWgdoH0IVtHcQk1F9CtnuXDtiF00cKzxQkTDVGmzwc4W13VMnDAsYygP3A01Ns9ZdCPUGE5hzTJ80wtBqWcsmVGIHu7nC9xS33o7P8aaN6w3ybhe4F9pRBOuNjQQidpH+JgQidnZthwfw2BH9bAPW6+Uj+TwKsdOR9c6+ELv/+gIhF6DZUrJo/gxauXyROvTbWm3L/Ao4JFiDhDoUc0yG/VP5Cg/zGqdNzcCjKAwNBD5YsREQlipVaxLsU5TAFqpdy5jpPJXutIVGCx8vTHdZtS8Iq7Jh3ee05sOlPs4gIJgz5q0wnUWc6rZLR9+gj6wyasIsg8QiPZyp2CuXLxleiHbTktZrOB1HAkenup3Scc0xk+b5Ye5UXk/HtHmzBi/52F3p+dhHXyJ+FkJxwPPOqnmC4f6BfXt4qaNVhI+pkyE/6sIg4v4HH6X7ebm7/LziiRJMmWGNTwjwx+LrCLFiF7JBneO0Bel5+dXGxsoD+K1Y5TcO74MYkFb7u2jjmDnzXWz71Y6KFithaM+hWVHT5zCr2PvLHpo3cyKHHzprbbLjMTRP6Jds3C/oJ1Un+uA42+F+zmYV6Bcn0ddKRiBkBP5VUpqnh7uzBhQSaEkxDCrh5IU+jJZY14rtxU4lOTiQe3U2e4E5AOzP0NfAAP0LB5X3Fs3x01Cq9sGWEe+q2IiT/RrqivQ3AfdTuEgxw3zgfjYhUI41uJbqa+yjv9fzPa9YtsDWfhNlrBKp5xErpeA5hIQboLhbhyZU8eEYe1FrG90cy1qxslYsL7mTgXr3H22u1Qp7s45sYxEsWrmbB8/tuU9XrkqNW8TYncGeCAvex1bwEpnC3ogZM96yG8Tor27NZ8KuDmQNBr9JkiTlF+2tcAO/83JKbghT2I2IxxMihWM83oLjpfHxxAcV2mR4biNQNtYejk3fpmSvxYyZMlGGDJnYhuiK8dzBVhQ2d3ZaFsdGOWSgH6DBAxkFaYSd3fnfzznaozpUE5VktCtlqlSseUtPJ44fNWwM3VwI95crdz7+2F/j31wSrvMYk/Ubbqr01Ll2xG4few2jjxH7MEOGjAbROc82t3Z2t566GReNwaAF7+cMbJd8mbXjMGvAoBnv13DimlqbEOnn0Vp/oOO6desFyo51nhC7RE7sSpQqS42atjW98PAkrf9qLU0ed2u0GusnK4onYtqodbselIeNa5UEi8auytltoTnAyF83PD7MXrKwyxEJHQHBMXSspKQgECoCTsQu1POlnHcREGIXnb5JVFOxWKsSxv7wICpavBSVu+8BKlS4mA+yWKwbITugiYhPAUmAJ19a/kvPWg1MOeTkUXmx4iUpd578Pk3DVE4nNpi9evWqT7p+gNEt7h0LtqdjGz2o9eGxmidfQSpVuryf8fHC+W/TquWL9SpknxEQHOUxEATiFgEhdnGLd1xeTYhddNBONMRuxLgZfoTICikCfg7u1zXOp2uwjFgjtlVIniw5JeXpLhBQEIhQBK7wMFg/eGCfT3F4NIIM3qovmZ9tlE9hywE0lgimGpspNktVCf5QcEzwXSg3kMAREGKXwDswQPOF2AUAx0VWoiF2cxevMQ20rXjBpgfG1CvYRi2Q1st6XqSOq1Z/herWbxZWdTBW/mTNCtaqLfSL04SKAt2v04Xg8bWEbfV2bN/qVCTRpQuOia7L5YY9hoAQO491SASbI8QugmBqVSVaYgdPql/27KQd276njV9/Ea9Tr6EQO0y3nmSN4pEjh4xgpFhDFt5qThKMkIDMwjkEhtZ7f95FO3ds4RUtjjhVl2jTBcdE2/Vy4x5BwLrEHUI7nTxx3COtk2a4QUCInRv0nM91JHZ/3bhOzQaW9zszZaqYZYb8Mj2cgKC/WEYLi2tfunTBcAuPpot+OFDAGaIgR6C/ceNvunnjprFFGIHLHILj8uWLdJE9oHAcjjzGqz1AbrIrPOoFkb169TIhrMclYMBegjLVGhxRwTE4RlJCEIg2ArA3hnkK3lmBlqSLdjuk/sgiIMQusniq2hyJ3T/8A2rUL2aJFXVCQiV2qv2yFQQEAUFAEBAEBIH4R0CIXXT6QIhddHCVWgUBQUAQEAQEAUFAEIhzBITYxTnkckFBQBAQBAQBQUAQEASig4AQu+jgKrUKAoKAICAICAKCgCAQ5wgIsYtzyOWCgoAgIAgIAoKAICAIRAcBR2L3982/qemAsn5XFecJP0gkQRAQBAQBQUAQEAQEAU8g4EjsLlw+R+1HPe7XSCF2fpBIgiAgCAgCgoAgIAgIAp5AQIidJ7pBGiEICAKCgCAgCAgCgoB7BITYucdQahAEBAFBQBAQBAQBQcATCAix80Q3SCMEAUFAEBAEBAFBQBBwj4AQO/cYSg2CgCAgCAgCgoAgIAh4AgEhdp7oBmmEICAICAKCgCAgCAgC7hEQYuceQ6lBEBAEBAFBQBAQBAQBTyAgxM4T3SCNEAQEAUFAEBAEBAFBwD0CQuzcYyg1CAKCgCAgCAgCgoAg4AkEhNh5ohukEYKAICAICAKCgCAgCLhHQIidewylBkFAEBAEBAFBQBAQBDyBgBA7T3SDNEIQEAQEAUFAEBAEBAH3CAixc4+h1CAICAKCgCAgCAgCgoAnEBBi54lukEYIAoKAICAICAKCgCDgHgEhdu4xlBoEAUFAEBAEBAFBQBDwBAJC7DzRDdIIQUAQEAQEAUFAEBAE3CMgxM49hlKDICAICAKCgCAgCAgCnkBAiJ0nukEaIQgIAoKAICAICAKCgHsEhNi5x1BqEAQEAUFAEBAEBAFBwBMICLHzRDdIIwQBQUAQEAQEAUFAEHCPgBA79xhKDYKAICAICAKCgCAgCHgCASF2nugGaYQgIAgIAoKAICAICALuERBi5x5DqUEQEAQEAUFAEBAEBAFPICDEzhPdII0QBAQBQUAQEAQEAUHAPQJC7NxjKDUIAoKAICAICAKCgCDgCQSE2HmiG6QRgoAgIAgIAoKAICAIuEdAiJ17DKUGQUAQEAQEAUFAEBAEPIGAEDtPdIM0QhAQBAQBQUAQEAQEAfcICLFzj6HUIAgIAoKAICAICAKCgCcQEGLniW6QRggCgoAgIAgIAoKAIOAeASF27jGUGgQBQUAQEAQEAUFAEPAEAkLsPNEN0ghBQBAQBAQBQUAQEATcIyDEzj2GUoMgIAgIAoKAICAICAKeQECInSe6QRohCAgCgoAgIAgIAoKAewQcid1fN65Ts4Hl/a6QMlVGvzRJEAQEAUFAEBAEBAFBQBCIfwT+D/zF7ZhlIKO3AAAAAElFTkSuQmCC\"}}]}]}],\"tools\":[{\"name\":\"read_screenshot\",\"description\":\"Capture a screenshot of the current screen.\",\"input_schema\":{\"type\":\"object\",\"properties\":{},\"additionalProperties\":false}}],\"stream\":true,\"max_tokens\":40}" + }, + "response": { + "status": 200, + "headers": { + "content-type": "text/event-stream; charset=utf-8" + }, + "body": "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"model\":\"claude-opus-4-7\",\"id\":\"msg_017z3Dpfd8nim5vfCmcAQMFS\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"stop_reason\":null,\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":1005,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":1,\"service_tier\":\"standard\",\"inference_geo\":\"global\"}} }\n\nevent: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"\"} }\n\nevent: ping\ndata: {\"type\": \"ping\"}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"j\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"iggling restroom prison\"} }\n\nevent: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":0 }\n\nevent: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\",\"stop_sequence\":null,\"stop_details\":null},\"usage\":{\"input_tokens\":1005,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"output_tokens\":13} }\n\nevent: message_stop\ndata: {\"type\":\"message_stop\" }\n\n" + } + } + ] +} diff --git a/packages/llm/test/fixtures/recordings/anthropic-messages/claude-opus-4-7-drives-a-tool-loop.json b/packages/llm/test/fixtures/recordings/anthropic-messages/claude-opus-4-7-drives-a-tool-loop.json new file mode 100644 index 0000000000000000000000000000000000000000..316f4308fc1d82c915c4714bee301331e563291d --- /dev/null +++ b/packages/llm/test/fixtures/recordings/anthropic-messages/claude-opus-4-7-drives-a-tool-loop.json @@ -0,0 +1,56 @@ +{ + "version": 1, + "metadata": { + "name": "anthropic-messages/claude-opus-4-7-drives-a-tool-loop", + "recordedAt": "2026-05-03T19:59:44.186Z", + "tags": [ + "prefix:anthropic-messages", + "provider:anthropic", + "protocol:anthropic-messages", + "tool", + "tool-loop", + "golden", + "flagship" + ] + }, + "interactions": [ + { + "transport": "http", + "request": { + "method": "POST", + "url": "https://api.anthropic.com/v1/messages", + "headers": { + "anthropic-version": "2023-06-01", + "content-type": "application/json" + }, + "body": "{\"model\":\"claude-opus-4-7\",\"system\":[{\"type\":\"text\",\"text\":\"Use the get_weather tool, then answer in one short sentence.\"}],\"messages\":[{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"What is the weather in Paris?\"}]}],\"tools\":[{\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"input_schema\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false}}],\"stream\":true,\"max_tokens\":80}" + }, + "response": { + "status": 200, + "headers": { + "content-type": "text/event-stream; charset=utf-8" + }, + "body": "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"model\":\"claude-opus-4-7\",\"id\":\"msg_01DgAEgLgB1ZhavZon4qGE1t\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"stop_reason\":null,\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":798,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":0,\"service_tier\":\"standard\",\"inference_geo\":\"global\"}} }\n\nevent: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"tool_use\",\"id\":\"toolu_01M8nJQQMxqpv1VaPYuJKT4j\",\"name\":\"get_weather\",\"input\":{},\"caller\":{\"type\":\"direct\"}} }\n\nevent: ping\ndata: {\"type\": \"ping\"}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"{\\\"city\\\": \"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"\\\"Pa\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"ris\\\"}\"} }\n\nevent: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":0 }\n\nevent: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"tool_use\",\"stop_sequence\":null,\"stop_details\":null},\"usage\":{\"input_tokens\":798,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"output_tokens\":66} }\n\nevent: message_stop\ndata: {\"type\":\"message_stop\" }\n\n" + } + }, + { + "transport": "http", + "request": { + "method": "POST", + "url": "https://api.anthropic.com/v1/messages", + "headers": { + "anthropic-version": "2023-06-01", + "content-type": "application/json" + }, + "body": "{\"model\":\"claude-opus-4-7\",\"system\":[{\"type\":\"text\",\"text\":\"Use the get_weather tool, then answer in one short sentence.\"}],\"messages\":[{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"What is the weather in Paris?\"}]},{\"role\":\"assistant\",\"content\":[{\"type\":\"tool_use\",\"id\":\"toolu_01M8nJQQMxqpv1VaPYuJKT4j\",\"name\":\"get_weather\",\"input\":{\"city\":\"Paris\"}}]},{\"role\":\"user\",\"content\":[{\"type\":\"tool_result\",\"tool_use_id\":\"toolu_01M8nJQQMxqpv1VaPYuJKT4j\",\"content\":\"{\\\"temperature\\\":22,\\\"condition\\\":\\\"sunny\\\"}\"}]}],\"tools\":[{\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"input_schema\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false}}],\"stream\":true,\"max_tokens\":80}" + }, + "response": { + "status": 200, + "headers": { + "content-type": "text/event-stream; charset=utf-8" + }, + "body": "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"model\":\"claude-opus-4-7\",\"id\":\"msg_011KJqj32QjkrUAiBFxhmEoG\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"stop_reason\":null,\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":895,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":5,\"service_tier\":\"standard\",\"inference_geo\":\"global\"}} }\n\nevent: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"\"} }\n\nevent: ping\ndata: {\"type\": \"ping\"}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"Paris is curr\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"ently sunny at 22°C.\"} }\n\nevent: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":0 }\n\nevent: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\",\"stop_sequence\":null,\"stop_details\":null},\"usage\":{\"input_tokens\":895,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"output_tokens\":19}}\n\nevent: message_stop\ndata: {\"type\":\"message_stop\"}\n\n" + } + } + ] +} diff --git a/packages/llm/test/fixtures/recordings/anthropic-messages/rejects-malformed-assistant-tool-order-without-patch.json b/packages/llm/test/fixtures/recordings/anthropic-messages/rejects-malformed-assistant-tool-order-without-patch.json new file mode 100644 index 0000000000000000000000000000000000000000..cd0990cec5cf0e035ec54d876c0d56ea2ac4cb36 --- /dev/null +++ b/packages/llm/test/fixtures/recordings/anthropic-messages/rejects-malformed-assistant-tool-order-without-patch.json @@ -0,0 +1,29 @@ +{ + "version": 1, + "metadata": { + "name": "anthropic-messages/rejects-malformed-assistant-tool-order-without-patch", + "recordedAt": "2026-05-05T20:08:42.597Z", + "tags": ["prefix:anthropic-messages", "provider:anthropic", "protocol:anthropic-messages", "tool", "sad-path"] + }, + "interactions": [ + { + "transport": "http", + "request": { + "method": "POST", + "url": "https://api.anthropic.com/v1/messages", + "headers": { + "anthropic-version": "2023-06-01", + "content-type": "application/json" + }, + "body": "{\"model\":\"claude-haiku-4-5-20251001\",\"messages\":[{\"role\":\"assistant\",\"content\":[{\"type\":\"tool_use\",\"id\":\"call_1\",\"name\":\"get_weather\",\"input\":{\"city\":\"Paris\"}},{\"type\":\"text\",\"text\":\"I will check the weather.\"}]},{\"role\":\"user\",\"content\":[{\"type\":\"tool_result\",\"tool_use_id\":\"call_1\",\"content\":\"{\\\"temperature\\\":\\\"72F\\\"}\"}]},{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"Use that result to answer briefly.\"}]}],\"tools\":[{\"name\":\"get_weather\",\"description\":\"Get weather\",\"input_schema\":{\"type\":\"object\",\"properties\":{}}}],\"stream\":true,\"max_tokens\":4096}" + }, + "response": { + "status": 400, + "headers": { + "content-type": "application/json" + }, + "body": "{\"type\":\"error\",\"error\":{\"type\":\"invalid_request_error\",\"message\":\"messages.1: `tool_use` ids were found without `tool_result` blocks immediately after: call_1. Each `tool_use` block must have a corresponding `tool_result` block in the next message.\"},\"request_id\":\"req_011Cak2XdJgnzxKCY2BC2Beh\"}" + } + } + ] +} diff --git a/packages/llm/test/fixtures/recordings/anthropic-messages/streams-text.json b/packages/llm/test/fixtures/recordings/anthropic-messages/streams-text.json new file mode 100644 index 0000000000000000000000000000000000000000..e80a0dac34b534235fb1a4bb50d4f86d6981b3e4 --- /dev/null +++ b/packages/llm/test/fixtures/recordings/anthropic-messages/streams-text.json @@ -0,0 +1,29 @@ +{ + "version": 1, + "metadata": { + "name": "anthropic-messages/streams-text", + "recordedAt": "2026-04-28T21:18:45.535Z", + "tags": ["prefix:anthropic-messages", "provider:anthropic", "protocol:anthropic-messages"] + }, + "interactions": [ + { + "transport": "http", + "request": { + "method": "POST", + "url": "https://api.anthropic.com/v1/messages", + "headers": { + "anthropic-version": "2023-06-01", + "content-type": "application/json" + }, + "body": "{\"model\":\"claude-haiku-4-5-20251001\",\"system\":[{\"type\":\"text\",\"text\":\"You are concise.\"}],\"messages\":[{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"Reply with exactly: Hello!\"}]}],\"stream\":true,\"max_tokens\":20,\"temperature\":0}" + }, + "response": { + "status": 200, + "headers": { + "content-type": "text/event-stream; charset=utf-8" + }, + "body": "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"model\":\"claude-haiku-4-5-20251001\",\"id\":\"msg_01UodR8c3ezAK8rAfi8HAs8g\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"stop_reason\":null,\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":18,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":2,\"service_tier\":\"standard\",\"inference_geo\":\"not_available\"}} }\n\nevent: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"\"} }\n\nevent: ping\ndata: {\"type\": \"ping\"}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"Hello!\"} }\n\nevent: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":0 }\n\nevent: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\",\"stop_sequence\":null,\"stop_details\":null},\"usage\":{\"input_tokens\":18,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"output_tokens\":5} }\n\nevent: message_stop\ndata: {\"type\":\"message_stop\" }\n\n" + } + } + ] +} diff --git a/packages/llm/test/fixtures/recordings/anthropic-messages/streams-tool-call.json b/packages/llm/test/fixtures/recordings/anthropic-messages/streams-tool-call.json new file mode 100644 index 0000000000000000000000000000000000000000..ef8f69c21d3f151de01008fbc9dead331ca90732 --- /dev/null +++ b/packages/llm/test/fixtures/recordings/anthropic-messages/streams-tool-call.json @@ -0,0 +1,29 @@ +{ + "version": 1, + "metadata": { + "name": "anthropic-messages/streams-tool-call", + "recordedAt": "2026-04-28T21:18:46.878Z", + "tags": ["prefix:anthropic-messages", "provider:anthropic", "protocol:anthropic-messages", "tool"] + }, + "interactions": [ + { + "transport": "http", + "request": { + "method": "POST", + "url": "https://api.anthropic.com/v1/messages", + "headers": { + "anthropic-version": "2023-06-01", + "content-type": "application/json" + }, + "body": "{\"model\":\"claude-haiku-4-5-20251001\",\"system\":[{\"type\":\"text\",\"text\":\"Call tools exactly as requested.\"}],\"messages\":[{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"Call get_weather with city exactly Paris.\"}]}],\"tools\":[{\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"input_schema\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false}}],\"tool_choice\":{\"type\":\"tool\",\"name\":\"get_weather\"},\"stream\":true,\"max_tokens\":80,\"temperature\":0}" + }, + "response": { + "status": 200, + "headers": { + "content-type": "text/event-stream; charset=utf-8" + }, + "body": "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"model\":\"claude-haiku-4-5-20251001\",\"id\":\"msg_01RYgU7NUPMK4B9v8S7gVpCS\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"stop_reason\":null,\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":677,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":16,\"service_tier\":\"standard\",\"inference_geo\":\"not_available\"}} }\n\nevent: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"tool_use\",\"id\":\"toolu_012rmAruviySvUXSjgCPWVRu\",\"name\":\"get_weather\",\"input\":{},\"caller\":{\"type\":\"direct\"}} }\n\nevent: ping\ndata: {\"type\": \"ping\"}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"{\\\"city\\\":\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\" \\\"Paris\\\"}\"} }\n\nevent: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":0 }\n\nevent: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"tool_use\",\"stop_sequence\":null,\"stop_details\":null},\"usage\":{\"input_tokens\":677,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"output_tokens\":33} }\n\nevent: message_stop\ndata: {\"type\":\"message_stop\" }\n\n" + } + } + ] +} diff --git a/packages/llm/test/fixtures/recordings/bedrock-converse/drives-a-tool-loop.json b/packages/llm/test/fixtures/recordings/bedrock-converse/drives-a-tool-loop.json new file mode 100644 index 0000000000000000000000000000000000000000..26eca01609a1175bf7bf1cca2b30603d901ca874 --- /dev/null +++ b/packages/llm/test/fixtures/recordings/bedrock-converse/drives-a-tool-loop.json @@ -0,0 +1,55 @@ +{ + "version": 1, + "metadata": { + "name": "bedrock-converse/drives-a-tool-loop", + "recordedAt": "2026-05-03T20:01:48.334Z", + "tags": [ + "prefix:bedrock-converse", + "provider:amazon-bedrock", + "protocol:bedrock-converse", + "tool", + "tool-loop", + "golden" + ] + }, + "interactions": [ + { + "transport": "http", + "request": { + "method": "POST", + "url": "https://bedrock-runtime.us-east-1.amazonaws.com/model/us.amazon.nova-micro-v1%3A0/converse-stream", + "headers": { + "content-type": "application/json" + }, + "body": "{\"modelId\":\"us.amazon.nova-micro-v1:0\",\"messages\":[{\"role\":\"user\",\"content\":[{\"text\":\"What is the weather in Paris?\"}]}],\"system\":[{\"text\":\"Use the get_weather tool, then answer in one short sentence.\"}],\"inferenceConfig\":{\"maxTokens\":80,\"temperature\":0},\"toolConfig\":{\"tools\":[{\"toolSpec\":{\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"inputSchema\":{\"json\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false}}}}]}}" + }, + "response": { + "status": 200, + "headers": { + "content-type": "application/vnd.amazon.eventstream" + }, + "body": "AAAAtwAAAFJCoDu1CzpldmVudC10eXBlBwAMbWVzc2FnZVN0YXJ0DTpjb250ZW50LXR5cGUHABBhcHBsaWNhdGlvbi9qc29uDTptZXNzYWdlLXR5cGUHAAVldmVudHsicCI6ImFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6QUJDREVGR0hJSktMTU5PUFFSU1RVVldYWVowMTIzNDUiLCJyb2xlIjoiYXNzaXN0YW50In1xBrKfAAAA0gAAAFdjGDcHCzpldmVudC10eXBlBwARY29udGVudEJsb2NrRGVsdGENOmNvbnRlbnQtdHlwZQcAEGFwcGxpY2F0aW9uL2pzb24NOm1lc3NhZ2UtdHlwZQcABWV2ZW50eyJjb250ZW50QmxvY2tJbmRleCI6MCwiZGVsdGEiOnsidGV4dCI6Ijx0aGlua2luZyJ9LCJwIjoiYWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXpBQkNERUZHSElKS0xNTk9QUVJTVFVWIn17Hkd0AAAAuQAAAFeN+nFbCzpldmVudC10eXBlBwARY29udGVudEJsb2NrRGVsdGENOmNvbnRlbnQtdHlwZQcAEGFwcGxpY2F0aW9uL2pzb24NOm1lc3NhZ2UtdHlwZQcABWV2ZW50eyJjb250ZW50QmxvY2tJbmRleCI6MCwiZGVsdGEiOnsidGV4dCI6Ij4ifSwicCI6ImFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6QUJDREUifXAgJvgAAADMAAAAV7zIHuQLOmV2ZW50LXR5cGUHABFjb250ZW50QmxvY2tEZWx0YQ06Y29udGVudC10eXBlBwAQYXBwbGljYXRpb24vanNvbg06bWVzc2FnZS10eXBlBwAFZXZlbnR7ImNvbnRlbnRCbG9ja0luZGV4IjowLCJkZWx0YSI6eyJ0ZXh0IjoiIFRvIn0sInAiOiJhYmNkZWZnaGlqa2xtbm9wcXJzdHV2d3h5ekFCQ0RFRkdISUpLTE1OT1BRUlNUVVYifaOASr0AAACrAAAAV5fatbkLOmV2ZW50LXR5cGUHABFjb250ZW50QmxvY2tEZWx0YQ06Y29udGVudC10eXBlBwAQYXBwbGljYXRpb24vanNvbg06bWVzc2FnZS10eXBlBwAFZXZlbnR7ImNvbnRlbnRCbG9ja0luZGV4IjowLCJkZWx0YSI6eyJ0ZXh0IjoiIGRldGVybWluZSJ9LCJwIjoiYWJjZGVmZ2gifQUyd0MAAADQAAAAVxnYZGcLOmV2ZW50LXR5cGUHABFjb250ZW50QmxvY2tEZWx0YQ06Y29udGVudC10eXBlBwAQYXBwbGljYXRpb24vanNvbg06bWVzc2FnZS10eXBlBwAFZXZlbnR7ImNvbnRlbnRCbG9ja0luZGV4IjowLCJkZWx0YSI6eyJ0ZXh0IjoiIHRoZSJ9LCJwIjoiYWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXpBQkNERUZHSElKS0xNTk9QUVJTVFVWV1hZIn0ZHcgRAAAAxwAAAFfLGC/1CzpldmVudC10eXBlBwARY29udGVudEJsb2NrRGVsdGENOmNvbnRlbnQtdHlwZQcAEGFwcGxpY2F0aW9uL2pzb24NOm1lc3NhZ2UtdHlwZQcABWV2ZW50eyJjb250ZW50QmxvY2tJbmRleCI6MCwiZGVsdGEiOnsidGV4dCI6IiB3ZWF0aGVyIn0sInAiOiJhYmNkZWZnaGlqa2xtbm9wcXJzdHV2d3h5ekFCQ0RFRkdISUpLTCJ9QpgceQAAALsAAABX9zoiOws6ZXZlbnQtdHlwZQcAEWNvbnRlbnRCbG9ja0RlbHRhDTpjb250ZW50LXR5cGUHABBhcHBsaWNhdGlvbi9qc29uDTptZXNzYWdlLXR5cGUHAAVldmVudHsiY29udGVudEJsb2NrSW5kZXgiOjAsImRlbHRhIjp7InRleHQiOiIgaW4ifSwicCI6ImFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6QUJDREUifRLNLa0AAACkAAAAVxWKImgLOmV2ZW50LXR5cGUHABFjb250ZW50QmxvY2tEZWx0YQ06Y29udGVudC10eXBlBwAQYXBwbGljYXRpb24vanNvbg06bWVzc2FnZS10eXBlBwAFZXZlbnR7ImNvbnRlbnRCbG9ja0luZGV4IjowLCJkZWx0YSI6eyJ0ZXh0IjoiIFBhcmlzIn0sInAiOiJhYmNkZSJ9QOSGZQAAAKgAAABX0HrPaQs6ZXZlbnQtdHlwZQcAEWNvbnRlbnRCbG9ja0RlbHRhDTpjb250ZW50LXR5cGUHABBhcHBsaWNhdGlvbi9qc29uDTptZXNzYWdlLXR5cGUHAAVldmVudHsiY29udGVudEJsb2NrSW5kZXgiOjAsImRlbHRhIjp7InRleHQiOiIsIn0sInAiOiJhYmNkZWZnaGlqa2xtbiJ9bgd/VgAAALAAAABXgOoTKgs6ZXZlbnQtdHlwZQcAEWNvbnRlbnRCbG9ja0RlbHRhDTpjb250ZW50LXR5cGUHABBhcHBsaWNhdGlvbi9qc29uDTptZXNzYWdlLXR5cGUHAAVldmVudHsiY29udGVudEJsb2NrSW5kZXgiOjAsImRlbHRhIjp7InRleHQiOiIgSSJ9LCJwIjoiYWJjZGVmZ2hpamtsbW5vcHFyc3R1In3RkbiWAAAA0QAAAFckuE3XCzpldmVudC10eXBlBwARY29udGVudEJsb2NrRGVsdGENOmNvbnRlbnQtdHlwZQcAEGFwcGxpY2F0aW9uL2pzb24NOm1lc3NhZ2UtdHlwZQcABWV2ZW50eyJjb250ZW50QmxvY2tJbmRleCI6MCwiZGVsdGEiOnsidGV4dCI6IiB3aWxsIn0sInAiOiJhYmNkZWZnaGlqa2xtbm9wcXJzdHV2d3h5ekFCQ0RFRkdISUpLTE1OT1BRUlNUVVZXWFkifa2kMpYAAACfAAAAV8N7q/8LOmV2ZW50LXR5cGUHABFjb250ZW50QmxvY2tEZWx0YQ06Y29udGVudC10eXBlBwAQYXBwbGljYXRpb24vanNvbg06bWVzc2FnZS10eXBlBwAFZXZlbnR7ImNvbnRlbnRCbG9ja0luZGV4IjowLCJkZWx0YSI6eyJ0ZXh0IjoiIHVzZSJ9LCJwIjoiYWIifWRVyJsAAADFAAAAV7HYfJULOmV2ZW50LXR5cGUHABFjb250ZW50QmxvY2tEZWx0YQ06Y29udGVudC10eXBlBwAQYXBwbGljYXRpb24vanNvbg06bWVzc2FnZS10eXBlBwAFZXZlbnR7ImNvbnRlbnRCbG9ja0luZGV4IjowLCJkZWx0YSI6eyJ0ZXh0IjoiIHRoZSJ9LCJwIjoiYWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXpBQkNERUZHSElKS0xNTiJ99QGTXwAAALwAAABXRRr+Kws6ZXZlbnQtdHlwZQcAEWNvbnRlbnRCbG9ja0RlbHRhDTpjb250ZW50LXR5cGUHABBhcHBsaWNhdGlvbi9qc29uDTptZXNzYWdlLXR5cGUHAAVldmVudHsiY29udGVudEJsb2NrSW5kZXgiOjAsImRlbHRhIjp7InRleHQiOiIgZ2V0In0sInAiOiJhYmNkZWZnaGlqa2xtbm9wcXJzdHV2d3h5ekFCQ0RFIn3A1pHkAAAArAAAAFcl+mmpCzpldmVudC10eXBlBwARY29udGVudEJsb2NrRGVsdGENOmNvbnRlbnQtdHlwZQcAEGFwcGxpY2F0aW9uL2pzb24NOm1lc3NhZ2UtdHlwZQcABWV2ZW50eyJjb250ZW50QmxvY2tJbmRleCI6MCwiZGVsdGEiOnsidGV4dCI6Il8ifSwicCI6ImFiY2RlZmdoaWprbG1ub3BxciJ9Jl4BhgAAAMwAAABXvMge5As6ZXZlbnQtdHlwZQcAEWNvbnRlbnRCbG9ja0RlbHRhDTpjb250ZW50LXR5cGUHABBhcHBsaWNhdGlvbi9qc29uDTptZXNzYWdlLXR5cGUHAAVldmVudHsiY29udGVudEJsb2NrSW5kZXgiOjAsImRlbHRhIjp7InRleHQiOiJ3ZWF0aGVyIn0sInAiOiJhYmNkZWZnaGlqa2xtbm9wcXJzdHV2d3h5ekFCQ0RFRkdISUpLTE1OT1BRUiJ9zDOXNgAAANMAAABXXngetws6ZXZlbnQtdHlwZQcAEWNvbnRlbnRCbG9ja0RlbHRhDTpjb250ZW50LXR5cGUHABBhcHBsaWNhdGlvbi9qc29uDTptZXNzYWdlLXR5cGUHAAVldmVudHsiY29udGVudEJsb2NrSW5kZXgiOjAsImRlbHRhIjp7InRleHQiOiIgdG9vbCJ9LCJwIjoiYWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXpBQkNERUZHSElKS0xNTk9QUVJTVFVWV1hZWjAifYuc7T0AAADXAAAAV6v4uHcLOmV2ZW50LXR5cGUHABFjb250ZW50QmxvY2tEZWx0YQ06Y29udGVudC10eXBlBwAQYXBwbGljYXRpb24vanNvbg06bWVzc2FnZS10eXBlBwAFZXZlbnR7ImNvbnRlbnRCbG9ja0luZGV4IjowLCJkZWx0YSI6eyJ0ZXh0IjoiIGFuZCJ9LCJwIjoiYWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXpBQkNERUZHSElKS0xNTk9QUVJTVFVWV1hZWjAxMjM0NSJ9Z1WRPAAAANYAAABXlpiRxws6ZXZlbnQtdHlwZQcAEWNvbnRlbnRCbG9ja0RlbHRhDTpjb250ZW50LXR5cGUHABBhcHBsaWNhdGlvbi9qc29uDTptZXNzYWdlLXR5cGUHAAVldmVudHsiY29udGVudEJsb2NrSW5kZXgiOjAsImRlbHRhIjp7InRleHQiOiIgcHJvdmlkZSJ9LCJwIjoiYWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXpBQkNERUZHSElKS0xNTk9QUVJTVFVWV1hZWjAifWuffy4AAACiAAAAV5rK18gLOmV2ZW50LXR5cGUHABFjb250ZW50QmxvY2tEZWx0YQ06Y29udGVudC10eXBlBwAQYXBwbGljYXRpb24vanNvbg06bWVzc2FnZS10eXBlBwAFZXZlbnR7ImNvbnRlbnRCbG9ja0luZGV4IjowLCJkZWx0YSI6eyJ0ZXh0IjoiIHRoZSJ9LCJwIjoiYWJjZGUifR59TKYAAADUAAAAV+xYwqcLOmV2ZW50LXR5cGUHABFjb250ZW50QmxvY2tEZWx0YQ06Y29udGVudC10eXBlBwAQYXBwbGljYXRpb24vanNvbg06bWVzc2FnZS10eXBlBwAFZXZlbnR7ImNvbnRlbnRCbG9ja0luZGV4IjowLCJkZWx0YSI6eyJ0ZXh0IjoiIGNpdHkifSwicCI6ImFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6QUJDREVGR0hJSktMTU5PUFFSU1RVVldYWVowMSJ9JF6q4AAAANQAAABX7FjCpws6ZXZlbnQtdHlwZQcAEWNvbnRlbnRCbG9ja0RlbHRhDTpjb250ZW50LXR5cGUHABBhcHBsaWNhdGlvbi9qc29uDTptZXNzYWdlLXR5cGUHAAVldmVudHsiY29udGVudEJsb2NrSW5kZXgiOjAsImRlbHRhIjp7InRleHQiOiIgYXMifSwicCI6ImFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6QUJDREVGR0hJSktMTU5PUFFSU1RVVldYWVowMTIzIn3T44iVAAAA1gAAAFeWmJHHCzpldmVudC10eXBlBwARY29udGVudEJsb2NrRGVsdGENOmNvbnRlbnQtdHlwZQcAEGFwcGxpY2F0aW9uL2pzb24NOm1lc3NhZ2UtdHlwZQcABWV2ZW50eyJjb250ZW50QmxvY2tJbmRleCI6MCwiZGVsdGEiOnsidGV4dCI6IiBcIiJ9LCJwIjoiYWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXpBQkNERUZHSElKS0xNTk9QUVJTVFVWV1hZWjAxMjM0NSJ9T89b0AAAANkAAABXFMgGFgs6ZXZlbnQtdHlwZQcAEWNvbnRlbnRCbG9ja0RlbHRhDTpjb250ZW50LXR5cGUHABBhcHBsaWNhdGlvbi9qc29uDTptZXNzYWdlLXR5cGUHAAVldmVudHsiY29udGVudEJsb2NrSW5kZXgiOjAsImRlbHRhIjp7InRleHQiOiJQYXJpcyJ9LCJwIjoiYWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXpBQkNERUZHSElKS0xNTk9QUVJTVFVWV1hZWjAxMjM0NTYifYX0tNEAAAClAAAAVyjqC9gLOmV2ZW50LXR5cGUHABFjb250ZW50QmxvY2tEZWx0YQ06Y29udGVudC10eXBlBwAQYXBwbGljYXRpb24vanNvbg06bWVzc2FnZS10eXBlBwAFZXZlbnR7ImNvbnRlbnRCbG9ja0luZGV4IjowLCJkZWx0YSI6eyJ0ZXh0IjoiXCIuIn0sInAiOiJhYmNkZWZnaGkifUbVohIAAAC9AAAAV3h615sLOmV2ZW50LXR5cGUHABFjb250ZW50QmxvY2tEZWx0YQ06Y29udGVudC10eXBlBwAQYXBwbGljYXRpb24vanNvbg06bWVzc2FnZS10eXBlBwAFZXZlbnR7ImNvbnRlbnRCbG9ja0luZGV4IjowLCJkZWx0YSI6eyJ0ZXh0IjoiIDwvIn0sInAiOiJhYmNkZWZnaGlqa2xtbm9wcXJzdHV2d3h5ekFCQ0RFRkcifU+fapUAAADEAAAAV4y4VSULOmV2ZW50LXR5cGUHABFjb250ZW50QmxvY2tEZWx0YQ06Y29udGVudC10eXBlBwAQYXBwbGljYXRpb24vanNvbg06bWVzc2FnZS10eXBlBwAFZXZlbnR7ImNvbnRlbnRCbG9ja0luZGV4IjowLCJkZWx0YSI6eyJ0ZXh0IjoidGhpbmtpbmcifSwicCI6ImFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6QUJDREVGR0hJIn0npV45AAAAoQAAAFfdaq0YCzpldmVudC10eXBlBwARY29udGVudEJsb2NrRGVsdGENOmNvbnRlbnQtdHlwZQcAEGFwcGxpY2F0aW9uL2pzb24NOm1lc3NhZ2UtdHlwZQcABWV2ZW50eyJjb250ZW50QmxvY2tJbmRleCI6MCwiZGVsdGEiOnsidGV4dCI6Ij5cbiJ9LCJwIjoiYWJjZGUifXpOZ6MAAACtAAAAVm+dcI8LOmV2ZW50LXR5cGUHABBjb250ZW50QmxvY2tTdG9wDTpjb250ZW50LXR5cGUHABBhcHBsaWNhdGlvbi9qc29uDTptZXNzYWdlLXR5cGUHAAVldmVudHsiY29udGVudEJsb2NrSW5kZXgiOjAsInAiOiJhYmNkZWZnaGlqa2xtbm9wcXJzdHV2d3h5ekFCQ0RFRkdISUpLTE1OTyJ9wp8EHgAAAQwAAABXnoElmgs6ZXZlbnQtdHlwZQcAEWNvbnRlbnRCbG9ja1N0YXJ0DTpjb250ZW50LXR5cGUHABBhcHBsaWNhdGlvbi9qc29uDTptZXNzYWdlLXR5cGUHAAVldmVudHsiY29udGVudEJsb2NrSW5kZXgiOjEsInAiOiJhYmNkZWZnaGlqa2xtbm9wcXJzdHV2d3h5ekFCQ0RFRkdISUpLTE1OT1BRUlNUVSIsInN0YXJ0Ijp7InRvb2xVc2UiOnsibmFtZSI6ImdldF93ZWF0aGVyIiwidG9vbFVzZUlkIjoidG9vbHVzZV9hOG5sZjJicUdMY1p2YVNvQnBRMXNIIn19fY7FuJUAAADLAAAAVw7owvQLOmV2ZW50LXR5cGUHABFjb250ZW50QmxvY2tEZWx0YQ06Y29udGVudC10eXBlBwAQYXBwbGljYXRpb24vanNvbg06bWVzc2FnZS10eXBlBwAFZXZlbnR7ImNvbnRlbnRCbG9ja0luZGV4IjoxLCJkZWx0YSI6eyJ0b29sVXNlIjp7ImlucHV0Ijoie1wiY2l0eVwiOlwiUGFyaXNcIn0ifX0sInAiOiJhYmNkZWZnaGlqa2xtbm9wcSJ9r3QETwAAALQAAABWAm2FfAs6ZXZlbnQtdHlwZQcAEGNvbnRlbnRCbG9ja1N0b3ANOmNvbnRlbnQtdHlwZQcAEGFwcGxpY2F0aW9uL2pzb24NOm1lc3NhZ2UtdHlwZQcABWV2ZW50eyJjb250ZW50QmxvY2tJbmRleCI6MSwicCI6ImFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6QUJDREVGR0hJSktMTU5PUFFSU1RVViJ9shQTDgAAAKUAAABRwYmu7Qs6ZXZlbnQtdHlwZQcAC21lc3NhZ2VTdG9wDTpjb250ZW50LXR5cGUHABBhcHBsaWNhdGlvbi9qc29uDTptZXNzYWdlLXR5cGUHAAVldmVudHsicCI6ImFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6QUJDREVGR0hJSiIsInN0b3BSZWFzb24iOiJ0b29sX3VzZSJ9i4+/2gAAAO4AAABOY6LKQAs6ZXZlbnQtdHlwZQcACG1ldGFkYXRhDTpjb250ZW50LXR5cGUHABBhcHBsaWNhdGlvbi9qc29uDTptZXNzYWdlLXR5cGUHAAVldmVudHsibWV0cmljcyI6eyJsYXRlbmN5TXMiOjQ5OX0sInAiOiJhYmNkZWZnaGlqa2xtbm9wcXJzdHV2dyIsInVzYWdlIjp7ImlucHV0VG9rZW5zIjo0MjUsIm91dHB1dFRva2VucyI6NDUsInNlcnZlclRvb2xVc2FnZSI6e30sInRvdGFsVG9rZW5zIjo0NzB9fSAjG74=", + "bodyEncoding": "base64" + } + }, + { + "transport": "http", + "request": { + "method": "POST", + "url": "https://bedrock-runtime.us-east-1.amazonaws.com/model/us.amazon.nova-micro-v1%3A0/converse-stream", + "headers": { + "content-type": "application/json" + }, + "body": "{\"modelId\":\"us.amazon.nova-micro-v1:0\",\"messages\":[{\"role\":\"user\",\"content\":[{\"text\":\"What is the weather in Paris?\"}]},{\"role\":\"assistant\",\"content\":[{\"text\":\" To determine the weather in Paris, I will use the get_weather tool and provide the city as \\\"Paris\\\". \\n\"},{\"toolUse\":{\"toolUseId\":\"tooluse_a8nlf2bqGLcZvaSoBpQ1sH\",\"name\":\"get_weather\",\"input\":{\"city\":\"Paris\"}}}]},{\"role\":\"user\",\"content\":[{\"toolResult\":{\"toolUseId\":\"tooluse_a8nlf2bqGLcZvaSoBpQ1sH\",\"content\":[{\"json\":{\"temperature\":22,\"condition\":\"sunny\"}}],\"status\":\"success\"}}]}],\"system\":[{\"text\":\"Use the get_weather tool, then answer in one short sentence.\"}],\"inferenceConfig\":{\"maxTokens\":80,\"temperature\":0},\"toolConfig\":{\"tools\":[{\"toolSpec\":{\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"inputSchema\":{\"json\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false}}}}]}}" + }, + "response": { + "status": 200, + "headers": { + "content-type": "application/vnd.amazon.eventstream" + }, + "body": "AAAAgQAAAFJswXaTCzpldmVudC10eXBlBwAMbWVzc2FnZVN0YXJ0DTpjb250ZW50LXR5cGUHABBhcHBsaWNhdGlvbi9qc29uDTptZXNzYWdlLXR5cGUHAAVldmVudHsicCI6ImFiY2QiLCJyb2xlIjoiYXNzaXN0YW50In31EqAFAAAAoQAAAFfdaq0YCzpldmVudC10eXBlBwARY29udGVudEJsb2NrRGVsdGENOmNvbnRlbnQtdHlwZQcAEGFwcGxpY2F0aW9uL2pzb24NOm1lc3NhZ2UtdHlwZQcABWV2ZW50eyJjb250ZW50QmxvY2tJbmRleCI6MCwiZGVsdGEiOnsidGV4dCI6IlRoZSJ9LCJwIjoiYWJjZGUifZ8hzYkAAACmAAAAV29KcQgLOmV2ZW50LXR5cGUHABFjb250ZW50QmxvY2tEZWx0YQ06Y29udGVudC10eXBlBwAQYXBwbGljYXRpb24vanNvbg06bWVzc2FnZS10eXBlBwAFZXZlbnR7ImNvbnRlbnRCbG9ja0luZGV4IjowLCJkZWx0YSI6eyJ0ZXh0IjoiIHdlYXRoZXIifSwicCI6ImFiY2RlIn0dzksTAAAAsQAAAFe9ijqaCzpldmVudC10eXBlBwARY29udGVudEJsb2NrRGVsdGENOmNvbnRlbnQtdHlwZQcAEGFwcGxpY2F0aW9uL2pzb24NOm1lc3NhZ2UtdHlwZQcABWV2ZW50eyJjb250ZW50QmxvY2tJbmRleCI6MCwiZGVsdGEiOnsidGV4dCI6IiBpbiJ9LCJwIjoiYWJjZGVmZ2hpamtsbW5vcHFyc3R1In1AJhvbAAAAqgAAAFequpwJCzpldmVudC10eXBlBwARY29udGVudEJsb2NrRGVsdGENOmNvbnRlbnQtdHlwZQcAEGFwcGxpY2F0aW9uL2pzb24NOm1lc3NhZ2UtdHlwZQcABWV2ZW50eyJjb250ZW50QmxvY2tJbmRleCI6MCwiZGVsdGEiOnsidGV4dCI6IiBQYXJpcyJ9LCJwIjoiYWJjZGVmZ2hpamsifQpyKMQAAADBAAAAV0RY2lULOmV2ZW50LXR5cGUHABFjb250ZW50QmxvY2tEZWx0YQ06Y29udGVudC10eXBlBwAQYXBwbGljYXRpb24vanNvbg06bWVzc2FnZS10eXBlBwAFZXZlbnR7ImNvbnRlbnRCbG9ja0luZGV4IjowLCJkZWx0YSI6eyJ0ZXh0IjoiIGlzIn0sInAiOiJhYmNkZWZnaGlqa2xtbm9wcXJzdHV2d3h5ekFCQ0RFRkdISUpLIn1gvC8JAAAA2QAAAFcUyAYWCzpldmVudC10eXBlBwARY29udGVudEJsb2NrRGVsdGENOmNvbnRlbnQtdHlwZQcAEGFwcGxpY2F0aW9uL2pzb24NOm1lc3NhZ2UtdHlwZQcABWV2ZW50eyJjb250ZW50QmxvY2tJbmRleCI6MCwiZGVsdGEiOnsidGV4dCI6IiBzdW5ueSJ9LCJwIjoiYWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXpBQkNERUZHSElKS0xNTk9QUVJTVFVWV1hZWjAxMjM0NSJ9j+j/gQAAAK8AAABXYloTeQs6ZXZlbnQtdHlwZQcAEWNvbnRlbnRCbG9ja0RlbHRhDTpjb250ZW50LXR5cGUHABBhcHBsaWNhdGlvbi9qc29uDTptZXNzYWdlLXR5cGUHAAVldmVudHsiY29udGVudEJsb2NrSW5kZXgiOjAsImRlbHRhIjp7InRleHQiOiIgd2l0aCJ9LCJwIjoiYWJjZGVmZ2hpamtsbW5vcHEifRRyjnsAAACyAAAAV/oqQEoLOmV2ZW50LXR5cGUHABFjb250ZW50QmxvY2tEZWx0YQ06Y29udGVudC10eXBlBwAQYXBwbGljYXRpb24vanNvbg06bWVzc2FnZS10eXBlBwAFZXZlbnR7ImNvbnRlbnRCbG9ja0luZGV4IjowLCJkZWx0YSI6eyJ0ZXh0IjoiIGEifSwicCI6ImFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3In2kLJI+AAAAuAAAAFewmljrCzpldmVudC10eXBlBwARY29udGVudEJsb2NrRGVsdGENOmNvbnRlbnQtdHlwZQcAEGFwcGxpY2F0aW9uL2pzb24NOm1lc3NhZ2UtdHlwZQcABWV2ZW50eyJjb250ZW50QmxvY2tJbmRleCI6MCwiZGVsdGEiOnsidGV4dCI6IiB0ZW1wZXJhdHVyZSJ9LCJwIjoiYWJjZGVmZ2hpamtsbW5vcHFycyJ9JuTWEQAAAKEAAABX3WqtGAs6ZXZlbnQtdHlwZQcAEWNvbnRlbnRCbG9ja0RlbHRhDTpjb250ZW50LXR5cGUHABBhcHBsaWNhdGlvbi9qc29uDTptZXNzYWdlLXR5cGUHAAVldmVudHsiY29udGVudEJsb2NrSW5kZXgiOjAsImRlbHRhIjp7InRleHQiOiIgb2YifSwicCI6ImFiY2RlIn1Uu0Z+AAAAmwAAAFc2+w0/CzpldmVudC10eXBlBwARY29udGVudEJsb2NrRGVsdGENOmNvbnRlbnQtdHlwZQcAEGFwcGxpY2F0aW9uL2pzb24NOm1lc3NhZ2UtdHlwZQcABWV2ZW50eyJjb250ZW50QmxvY2tJbmRleCI6MCwiZGVsdGEiOnsidGV4dCI6IiJ9LCJwIjoiYWIifaR9kNQAAAC4AAAAV7CaWOsLOmV2ZW50LXR5cGUHABFjb250ZW50QmxvY2tEZWx0YQ06Y29udGVudC10eXBlBwAQYXBwbGljYXRpb24vanNvbg06bWVzc2FnZS10eXBlBwAFZXZlbnR7ImNvbnRlbnRCbG9ja0luZGV4IjowLCJkZWx0YSI6eyJ0ZXh0IjoiIDIifSwicCI6ImFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6QUJDIn04fpEGAAAApQAAAFco6gvYCzpldmVudC10eXBlBwARY29udGVudEJsb2NrRGVsdGENOmNvbnRlbnQtdHlwZQcAEGFwcGxpY2F0aW9uL2pzb24NOm1lc3NhZ2UtdHlwZQcABWV2ZW50eyJjb250ZW50QmxvY2tJbmRleCI6MCwiZGVsdGEiOnsidGV4dCI6IjIifSwicCI6ImFiY2RlZmdoaWprIn0ws3/UAAAA1gAAAFeWmJHHCzpldmVudC10eXBlBwARY29udGVudEJsb2NrRGVsdGENOmNvbnRlbnQtdHlwZQcAEGFwcGxpY2F0aW9uL2pzb24NOm1lc3NhZ2UtdHlwZQcABWV2ZW50eyJjb250ZW50QmxvY2tJbmRleCI6MCwiZGVsdGEiOnsidGV4dCI6IiBkZWdyZWVzIn0sInAiOiJhYmNkZWZnaGlqa2xtbm9wcXJzdHV2d3h5ekFCQ0RFRkdISUpLTE1OT1BRUlNUVVZXWFlaMCJ9q7xKeQAAAJ8AAABXw3ur/ws6ZXZlbnQtdHlwZQcAEWNvbnRlbnRCbG9ja0RlbHRhDTpjb250ZW50LXR5cGUHABBhcHBsaWNhdGlvbi9qc29uDTptZXNzYWdlLXR5cGUHAAVldmVudHsiY29udGVudEJsb2NrSW5kZXgiOjAsImRlbHRhIjp7InRleHQiOiIuIn0sInAiOiJhYmNkZSJ9t7YAjQAAAMUAAABXsdh8lQs6ZXZlbnQtdHlwZQcAEWNvbnRlbnRCbG9ja0RlbHRhDTpjb250ZW50LXR5cGUHABBhcHBsaWNhdGlvbi9qc29uDTptZXNzYWdlLXR5cGUHAAVldmVudHsiY29udGVudEJsb2NrSW5kZXgiOjAsImRlbHRhIjp7InRleHQiOiIifSwicCI6ImFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6QUJDREVGR0hJSktMTU5PUFFSIn1NJJR+AAAAsQAAAFbKjQoMCzpldmVudC10eXBlBwAQY29udGVudEJsb2NrU3RvcA06Y29udGVudC10eXBlBwAQYXBwbGljYXRpb24vanNvbg06bWVzc2FnZS10eXBlBwAFZXZlbnR7ImNvbnRlbnRCbG9ja0luZGV4IjowLCJwIjoiYWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXpBQkNERUZHSElKS0xNTk9QUVJTIn1DzHT/AAAAiAAAAFH42EVYCzpldmVudC10eXBlBwALbWVzc2FnZVN0b3ANOmNvbnRlbnQtdHlwZQcAEGFwcGxpY2F0aW9uL2pzb24NOm1lc3NhZ2UtdHlwZQcABWV2ZW50eyJwIjoiYWJjZGVmZyIsInN0b3BSZWFzb24iOiJlbmRfdHVybiJ9rwP92gAAAOAAAABO3JJ0IQs6ZXZlbnQtdHlwZQcACG1ldGFkYXRhDTpjb250ZW50LXR5cGUHABBhcHBsaWNhdGlvbi9qc29uDTptZXNzYWdlLXR5cGUHAAVldmVudHsibWV0cmljcyI6eyJsYXRlbmN5TXMiOjM4MX0sInAiOiJhYmNkZWZnaGkiLCJ1c2FnZSI6eyJpbnB1dFRva2VucyI6NTEwLCJvdXRwdXRUb2tlbnMiOjE2LCJzZXJ2ZXJUb29sVXNhZ2UiOnt9LCJ0b3RhbFRva2VucyI6NTI2fX2ZCNET", + "bodyEncoding": "base64" + } + } + ] +} diff --git a/packages/llm/test/fixtures/recordings/bedrock-converse/streams-a-tool-call.json b/packages/llm/test/fixtures/recordings/bedrock-converse/streams-a-tool-call.json new file mode 100644 index 0000000000000000000000000000000000000000..4f22ce22da80929b2199169b5e8de068fd3c7bb2 --- /dev/null +++ b/packages/llm/test/fixtures/recordings/bedrock-converse/streams-a-tool-call.json @@ -0,0 +1,29 @@ +{ + "version": 1, + "metadata": { + "name": "bedrock-converse/streams-a-tool-call", + "recordedAt": "2026-04-28T21:18:46.929Z", + "tags": ["prefix:bedrock-converse", "provider:amazon-bedrock", "protocol:bedrock-converse", "tool"] + }, + "interactions": [ + { + "transport": "http", + "request": { + "method": "POST", + "url": "https://bedrock-runtime.us-east-1.amazonaws.com/model/us.amazon.nova-micro-v1%3A0/converse-stream", + "headers": { + "content-type": "application/json" + }, + "body": "{\"modelId\":\"us.amazon.nova-micro-v1:0\",\"messages\":[{\"role\":\"user\",\"content\":[{\"text\":\"Call get_weather with city exactly Paris.\"}]}],\"system\":[{\"text\":\"Call tools exactly as requested.\"}],\"inferenceConfig\":{\"maxTokens\":80,\"temperature\":0},\"toolConfig\":{\"tools\":[{\"toolSpec\":{\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"inputSchema\":{\"json\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false}}}}],\"toolChoice\":{\"tool\":{\"name\":\"get_weather\"}}}}" + }, + "response": { + "status": 200, + "headers": { + "content-type": "application/vnd.amazon.eventstream" + }, + "body": "AAAAuQAAAFL9kIXUCzpldmVudC10eXBlBwAMbWVzc2FnZVN0YXJ0DTpjb250ZW50LXR5cGUHABBhcHBsaWNhdGlvbi9qc29uDTptZXNzYWdlLXR5cGUHAAVldmVudHsicCI6ImFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6QUJDREVGR0hJSktMTU5PUFFSU1RVVldYWVowMTIzNDU2NyIsInJvbGUiOiJhc3Npc3RhbnQifWf51EkAAAEMAAAAV56BJZoLOmV2ZW50LXR5cGUHABFjb250ZW50QmxvY2tTdGFydA06Y29udGVudC10eXBlBwAQYXBwbGljYXRpb24vanNvbg06bWVzc2FnZS10eXBlBwAFZXZlbnR7ImNvbnRlbnRCbG9ja0luZGV4IjowLCJwIjoiYWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXpBQkNERUZHSElKS0xNTk9QUVJTVFUiLCJzdGFydCI6eyJ0b29sVXNlIjp7Im5hbWUiOiJnZXRfd2VhdGhlciIsInRvb2xVc2VJZCI6InRvb2x1c2VfNmExcFB2bmM5OUdMS08zS0drVUEyTiJ9fX2LR7PFAAAA4gAAAFfCOY+BCzpldmVudC10eXBlBwARY29udGVudEJsb2NrRGVsdGENOmNvbnRlbnQtdHlwZQcAEGFwcGxpY2F0aW9uL2pzb24NOm1lc3NhZ2UtdHlwZQcABWV2ZW50eyJjb250ZW50QmxvY2tJbmRleCI6MCwiZGVsdGEiOnsidG9vbFVzZSI6eyJpbnB1dCI6IntcImNpdHlcIjpcIlBhcmlzXCJ9In19LCJwIjoiYWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXpBQkNERUZHSElKS0xNTiJ9RkW+2gAAAIcAAABW5OxHKgs6ZXZlbnQtdHlwZQcAEGNvbnRlbnRCbG9ja1N0b3ANOmNvbnRlbnQtdHlwZQcAEGFwcGxpY2F0aW9uL2pzb24NOm1lc3NhZ2UtdHlwZQcABWV2ZW50eyJjb250ZW50QmxvY2tJbmRleCI6MCwicCI6ImFiYyJ9y6nrtwAAAK4AAABRtlmf/As6ZXZlbnQtdHlwZQcAC21lc3NhZ2VTdG9wDTpjb250ZW50LXR5cGUHABBhcHBsaWNhdGlvbi9qc29uDTptZXNzYWdlLXR5cGUHAAVldmVudHsicCI6ImFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6QUJDREVGR0hJSktMTU5PUFFSUyIsInN0b3BSZWFzb24iOiJ0b29sX3VzZSJ9MTlQawAAAOIAAABOplInQQs6ZXZlbnQtdHlwZQcACG1ldGFkYXRhDTpjb250ZW50LXR5cGUHABBhcHBsaWNhdGlvbi9qc29uDTptZXNzYWdlLXR5cGUHAAVldmVudHsibWV0cmljcyI6eyJsYXRlbmN5TXMiOjM1NX0sInAiOiJhYmNkZWZnaGlqayIsInVzYWdlIjp7ImlucHV0VG9rZW5zIjo0MTksIm91dHB1dFRva2VucyI6MTYsInNlcnZlclRvb2xVc2FnZSI6e30sInRvdGFsVG9rZW5zIjo0MzV9fU1tVJc=", + "bodyEncoding": "base64" + } + } + ] +} diff --git a/packages/llm/test/fixtures/recordings/bedrock-converse/streams-text.json b/packages/llm/test/fixtures/recordings/bedrock-converse/streams-text.json new file mode 100644 index 0000000000000000000000000000000000000000..7eaacec02baf306b53931004fd55d33a4956cb80 --- /dev/null +++ b/packages/llm/test/fixtures/recordings/bedrock-converse/streams-text.json @@ -0,0 +1,29 @@ +{ + "version": 1, + "metadata": { + "name": "bedrock-converse/streams-text", + "recordedAt": "2026-04-28T21:18:46.553Z", + "tags": ["prefix:bedrock-converse", "provider:amazon-bedrock", "protocol:bedrock-converse"] + }, + "interactions": [ + { + "transport": "http", + "request": { + "method": "POST", + "url": "https://bedrock-runtime.us-east-1.amazonaws.com/model/us.amazon.nova-micro-v1%3A0/converse-stream", + "headers": { + "content-type": "application/json" + }, + "body": "{\"modelId\":\"us.amazon.nova-micro-v1:0\",\"messages\":[{\"role\":\"user\",\"content\":[{\"text\":\"Say hello.\"}]}],\"system\":[{\"text\":\"Reply with the single word 'Hello'.\"}],\"inferenceConfig\":{\"maxTokens\":16,\"temperature\":0}}" + }, + "response": { + "status": 200, + "headers": { + "content-type": "application/vnd.amazon.eventstream" + }, + "body": "AAAAmQAAAFI8UarQCzpldmVudC10eXBlBwAMbWVzc2FnZVN0YXJ0DTpjb250ZW50LXR5cGUHABBhcHBsaWNhdGlvbi9qc29uDTptZXNzYWdlLXR5cGUHAAVldmVudHsicCI6ImFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6QUIiLCJyb2xlIjoiYXNzaXN0YW50In3SL1jNAAAAvQAAAFd4etebCzpldmVudC10eXBlBwARY29udGVudEJsb2NrRGVsdGENOmNvbnRlbnQtdHlwZQcAEGFwcGxpY2F0aW9uL2pzb24NOm1lc3NhZ2UtdHlwZQcABWV2ZW50eyJjb250ZW50QmxvY2tJbmRleCI6MCwiZGVsdGEiOnsidGV4dCI6IkhlbGxvIn0sInAiOiJhYmNkZWZnaGlqa2xtbm9wcXJzdHV2d3h5ekFCQ0RFIn2B0NR6AAAAxgAAAFf2eAZFCzpldmVudC10eXBlBwARY29udGVudEJsb2NrRGVsdGENOmNvbnRlbnQtdHlwZQcAEGFwcGxpY2F0aW9uL2pzb24NOm1lc3NhZ2UtdHlwZQcABWV2ZW50eyJjb250ZW50QmxvY2tJbmRleCI6MCwiZGVsdGEiOnsidGV4dCI6IiJ9LCJwIjoiYWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXpBQkNERUZHSElKS0xNTk9QUVJTIn3XaHMvAAAAhwAAAFbk7EcqCzpldmVudC10eXBlBwAQY29udGVudEJsb2NrU3RvcA06Y29udGVudC10eXBlBwAQYXBwbGljYXRpb24vanNvbg06bWVzc2FnZS10eXBlBwAFZXZlbnR7ImNvbnRlbnRCbG9ja0luZGV4IjowLCJwIjoiYWJjIn3Lqeu3AAAAjwAAAFFK+JlICzpldmVudC10eXBlBwALbWVzc2FnZVN0b3ANOmNvbnRlbnQtdHlwZQcAEGFwcGxpY2F0aW9uL2pzb24NOm1lc3NhZ2UtdHlwZQcABWV2ZW50eyJwIjoiYWJjZGVmZ2hpamtsbW4iLCJzdG9wUmVhc29uIjoiZW5kX3R1cm4ifZ+RQqEAAAECAAAATkXaMzsLOmV2ZW50LXR5cGUHAAhtZXRhZGF0YQ06Y29udGVudC10eXBlBwAQYXBwbGljYXRpb24vanNvbg06bWVzc2FnZS10eXBlBwAFZXZlbnR7Im1ldHJpY3MiOnsibGF0ZW5jeU1zIjozMDZ9LCJwIjoiYWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXpBQkNERUZHSElKS0xNTk9QUVJTVCIsInVzYWdlIjp7ImlucHV0VG9rZW5zIjoxMiwib3V0cHV0VG9rZW5zIjoyLCJzZXJ2ZXJUb29sVXNhZ2UiOnt9LCJ0b3RhbFRva2VucyI6MTR9fSnnkUk=", + "bodyEncoding": "base64" + } + } + ] +} diff --git a/packages/llm/test/fixtures/recordings/cloudflare-ai-gateway/cloudflare-ai-gateway-workers-ai-gpt-oss-20b-tools-tool-call.json b/packages/llm/test/fixtures/recordings/cloudflare-ai-gateway/cloudflare-ai-gateway-workers-ai-gpt-oss-20b-tools-tool-call.json new file mode 100644 index 0000000000000000000000000000000000000000..981c14f03edfa55958a93b7b046cec763609366c --- /dev/null +++ b/packages/llm/test/fixtures/recordings/cloudflare-ai-gateway/cloudflare-ai-gateway-workers-ai-gpt-oss-20b-tools-tool-call.json @@ -0,0 +1,32 @@ +{ + "version": 1, + "metadata": { + "name": "cloudflare-ai-gateway/cloudflare-ai-gateway-workers-ai-gpt-oss-20b-tools-tool-call", + "recordedAt": "2026-05-08T17:20:08.287Z", + "provider": "cloudflare-ai-gateway", + "route": "cloudflare-ai-gateway", + "transport": "http", + "model": "workers-ai/@cf/openai/gpt-oss-20b", + "tags": ["prefix:cloudflare-ai-gateway", "provider:cloudflare-ai-gateway", "tool", "tool-call", "golden"] + }, + "interactions": [ + { + "transport": "http", + "request": { + "method": "POST", + "url": "https://gateway.ai.cloudflare.com/v1/{account}/{gateway}/compat/chat/completions", + "headers": { + "content-type": "application/json" + }, + "body": "{\"model\":\"workers-ai/@cf/openai/gpt-oss-20b\",\"messages\":[{\"role\":\"system\",\"content\":\"Call tools exactly as requested.\"},{\"role\":\"user\",\"content\":\"Call get_weather with city exactly Paris.\"}],\"tools\":[{\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false}}}],\"tool_choice\":{\"type\":\"function\",\"function\":{\"name\":\"get_weather\"}},\"stream\":true,\"stream_options\":{\"include_usage\":true},\"max_tokens\":120,\"temperature\":0}" + }, + "response": { + "status": 200, + "headers": { + "content-type": "text/event-stream" + }, + "body": "data: {\"id\":\"id-1778260808196\",\"created\":1778260808,\"model\":\"@cf/openai/gpt-oss-20b\",\"object\":\"chat.completion.chunk\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"\"},\"logprobs\":null,\"finish_reason\":null}]}\n\ndata: {\"id\":\"id-1778260808196\",\"created\":1778260808,\"model\":\"@cf/openai/gpt-oss-20b\",\"object\":\"chat.completion.chunk\",\"choices\":[{\"index\":0,\"delta\":{\"reasoning_content\":\"\"},\"logprobs\":null,\"finish_reason\":null,\"token_ids\":null}]}\n\ndata: {\"id\":\"id-1778260808196\",\"created\":1778260808,\"model\":\"@cf/openai/gpt-oss-20b\",\"object\":\"chat.completion.chunk\",\"choices\":[{\"index\":0,\"delta\":{\"reasoning_content\":\"We\"},\"logprobs\":null,\"finish_reason\":null,\"token_ids\":null}]}\n\ndata: {\"id\":\"id-1778260808196\",\"created\":1778260808,\"model\":\"@cf/openai/gpt-oss-20b\",\"object\":\"chat.completion.chunk\",\"choices\":[{\"index\":0,\"delta\":{\"reasoning_content\":\" need\"},\"logprobs\":null,\"finish_reason\":null,\"token_ids\":null}]}\n\ndata: {\"id\":\"id-1778260808196\",\"created\":1778260808,\"model\":\"@cf/openai/gpt-oss-20b\",\"object\":\"chat.completion.chunk\",\"choices\":[{\"index\":0,\"delta\":{\"reasoning_content\":\" to\"},\"logprobs\":null,\"finish_reason\":null,\"token_ids\":null}]}\n\ndata: {\"id\":\"id-1778260808196\",\"created\":1778260808,\"model\":\"@cf/openai/gpt-oss-20b\",\"object\":\"chat.completion.chunk\",\"choices\":[{\"index\":0,\"delta\":{\"reasoning_content\":\" call\"},\"logprobs\":null,\"finish_reason\":null,\"token_ids\":null}]}\n\ndata: {\"id\":\"id-1778260808196\",\"created\":1778260808,\"model\":\"@cf/openai/gpt-oss-20b\",\"object\":\"chat.completion.chunk\",\"choices\":[{\"index\":0,\"delta\":{\"reasoning_content\":\" the\"},\"logprobs\":null,\"finish_reason\":null,\"token_ids\":null}]}\n\ndata: {\"id\":\"id-1778260808196\",\"created\":1778260808,\"model\":\"@cf/openai/gpt-oss-20b\",\"object\":\"chat.completion.chunk\",\"choices\":[{\"index\":0,\"delta\":{\"reasoning_content\":\" function\"},\"logprobs\":null,\"finish_reason\":null,\"token_ids\":null}]}\n\ndata: {\"id\":\"id-1778260808196\",\"created\":1778260808,\"model\":\"@cf/openai/gpt-oss-20b\",\"object\":\"chat.completion.chunk\",\"choices\":[{\"index\":0,\"delta\":{\"reasoning_content\":\" get\"},\"logprobs\":null,\"finish_reason\":null,\"token_ids\":null}]}\n\ndata: {\"id\":\"id-1778260808196\",\"created\":1778260808,\"model\":\"@cf/openai/gpt-oss-20b\",\"object\":\"chat.completion.chunk\",\"choices\":[{\"index\":0,\"delta\":{\"reasoning_content\":\"_weather\"},\"logprobs\":null,\"finish_reason\":null,\"token_ids\":null}]}\n\ndata: {\"id\":\"id-1778260808196\",\"created\":1778260808,\"model\":\"@cf/openai/gpt-oss-20b\",\"object\":\"chat.completion.chunk\",\"choices\":[{\"index\":0,\"delta\":{\"reasoning_content\":\" with\"},\"logprobs\":null,\"finish_reason\":null,\"token_ids\":null}]}\n\ndata: {\"id\":\"id-1778260808196\",\"created\":1778260808,\"model\":\"@cf/openai/gpt-oss-20b\",\"object\":\"chat.completion.chunk\",\"choices\":[{\"index\":0,\"delta\":{\"reasoning_content\":\" city\"},\"logprobs\":null,\"finish_reason\":null,\"token_ids\":null}]}\n\ndata: {\"id\":\"id-1778260808196\",\"created\":1778260808,\"model\":\"@cf/openai/gpt-oss-20b\",\"object\":\"chat.completion.chunk\",\"choices\":[{\"index\":0,\"delta\":{\"reasoning_content\":\" \\\"\"},\"logprobs\":null,\"finish_reason\":null,\"token_ids\":null}]}\n\ndata: {\"id\":\"id-1778260808196\",\"created\":1778260808,\"model\":\"@cf/openai/gpt-oss-20b\",\"object\":\"chat.completion.chunk\",\"choices\":[{\"index\":0,\"delta\":{\"reasoning_content\":\"Paris\"},\"logprobs\":null,\"finish_reason\":null,\"token_ids\":null}]}\n\ndata: {\"id\":\"id-1778260808196\",\"created\":1778260808,\"model\":\"@cf/openai/gpt-oss-20b\",\"object\":\"chat.completion.chunk\",\"choices\":[{\"index\":0,\"delta\":{\"reasoning_content\":\"\\\".\"},\"logprobs\":null,\"finish_reason\":null,\"token_ids\":null}]}\n\ndata: {\"id\":\"id-1778260808196\",\"created\":1778260808,\"model\":\"@cf/openai/gpt-oss-20b\",\"object\":\"chat.completion.chunk\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"id\":\"chatcmpl-tool-b975da5af1f843e095ba7062d8e108ba\",\"type\":\"function\",\"index\":0,\"function\":{\"name\":\"get_weather\",\"arguments\":\"\"}}]},\"logprobs\":null,\"finish_reason\":null,\"token_ids\":null}]}\n\ndata: {\"id\":\"id-1778260808196\",\"created\":1778260808,\"model\":\"@cf/openai/gpt-oss-20b\",\"object\":\"chat.completion.chunk\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"{\\\"\"}}]},\"logprobs\":null,\"finish_reason\":null,\"token_ids\":null}]}\n\ndata: {\"id\":\"id-1778260808196\",\"created\":1778260808,\"model\":\"@cf/openai/gpt-oss-20b\",\"object\":\"chat.completion.chunk\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"city\"}}]},\"logprobs\":null,\"finish_reason\":null,\"token_ids\":null}]}\n\ndata: {\"id\":\"id-1778260808196\",\"created\":1778260808,\"model\":\"@cf/openai/gpt-oss-20b\",\"object\":\"chat.completion.chunk\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"\\\":\\\"\"}}]},\"logprobs\":null,\"finish_reason\":null,\"token_ids\":null}]}\n\ndata: {\"id\":\"id-1778260808196\",\"created\":1778260808,\"model\":\"@cf/openai/gpt-oss-20b\",\"object\":\"chat.completion.chunk\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"Paris\"}}]},\"logprobs\":null,\"finish_reason\":null,\"token_ids\":null}]}\n\ndata: {\"id\":\"id-1778260808196\",\"created\":1778260808,\"model\":\"@cf/openai/gpt-oss-20b\",\"object\":\"chat.completion.chunk\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"\\\"}\"}}]},\"logprobs\":null,\"finish_reason\":null,\"token_ids\":null}]}\n\ndata: {\"id\":\"id-1778260808196\",\"created\":1778260808,\"model\":\"@cf/openai/gpt-oss-20b\",\"object\":\"chat.completion.chunk\",\"choices\":[{\"index\":0,\"delta\":{},\"logprobs\":null,\"finish_reason\":\"tool_calls\",\"stop_reason\":200012,\"token_ids\":null}]}\n\ndata: {\"id\":\"id-1778260808196\",\"object\":\"chat.completion.chunk\",\"created\":1778260808,\"model\":\"@cf/openai/gpt-oss-20b\",\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":\"tool_calls\"}],\"usage\":{\"prompt_tokens\":136,\"total_tokens\":173,\"completion_tokens\":37}}\n\ndata: {\"id\":\"id-1778260808196\",\"object\":\"chat.completion.chunk\",\"created\":1778260808,\"model\":\"@cf/openai/gpt-oss-20b\",\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":\"tool_calls\"}],\"usage\":{\"prompt_tokens\":136,\"completion_tokens\":37,\"total_tokens\":173,\"prompt_tokens_details\":{\"cached_tokens\":0}}}\n\ndata: [DONE]\n\n" + } + } + ] +} diff --git a/packages/llm/test/fixtures/recordings/cloudflare-ai-gateway/cloudflare-ai-gateway-workers-ai-llama-3-1-8b-text.json b/packages/llm/test/fixtures/recordings/cloudflare-ai-gateway/cloudflare-ai-gateway-workers-ai-llama-3-1-8b-text.json new file mode 100644 index 0000000000000000000000000000000000000000..6a8eff09d9773d410528fa45058f1ccabf64efe2 --- /dev/null +++ b/packages/llm/test/fixtures/recordings/cloudflare-ai-gateway/cloudflare-ai-gateway-workers-ai-llama-3-1-8b-text.json @@ -0,0 +1,32 @@ +{ + "version": 1, + "metadata": { + "name": "cloudflare-ai-gateway/cloudflare-ai-gateway-workers-ai-llama-3-1-8b-text", + "recordedAt": "2026-05-08T15:55:48.952Z", + "provider": "cloudflare-ai-gateway", + "route": "cloudflare-ai-gateway", + "transport": "http", + "model": "workers-ai/@cf/meta/llama-3.1-8b-instruct", + "tags": ["prefix:cloudflare-ai-gateway", "provider:cloudflare-ai-gateway", "text", "golden"] + }, + "interactions": [ + { + "transport": "http", + "request": { + "method": "POST", + "url": "https://gateway.ai.cloudflare.com/v1/{account}/{gateway}/compat/chat/completions", + "headers": { + "content-type": "application/json" + }, + "body": "{\"model\":\"workers-ai/@cf/meta/llama-3.1-8b-instruct\",\"messages\":[{\"role\":\"system\",\"content\":\"You are concise.\"},{\"role\":\"user\",\"content\":\"Reply exactly with: Hello!\"}],\"stream\":true,\"stream_options\":{\"include_usage\":true},\"max_tokens\":40,\"temperature\":0}" + }, + "response": { + "status": 200, + "headers": { + "content-type": "text/event-stream" + }, + "body": "data: {\"id\":\"id-1778255748911\",\"created\":1778255748,\"model\":\"@cf/meta/llama-3.1-8b-instruct\",\"object\":\"chat.completion.chunk\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"Hello\"}}]}\n\ndata: {\"id\":\"id-1778255748911\",\"created\":1778255748,\"model\":\"@cf/meta/llama-3.1-8b-instruct\",\"object\":\"chat.completion.chunk\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"!\"}}]}\n\ndata: {\"id\":\"id-1778255748911\",\"object\":\"chat.completion.chunk\",\"created\":1778255748,\"model\":\"@cf/meta/llama-3.1-8b-instruct\",\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":\"stop\"}],\"usage\":{\"prompt_tokens\":45,\"completion_tokens\":2,\"total_tokens\":47}}\n\ndata: {\"id\":\"id-1778255748911\",\"object\":\"chat.completion.chunk\",\"created\":1778255748,\"model\":\"@cf/meta/llama-3.1-8b-instruct\",\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":\"stop\"}],\"usage\":{\"prompt_tokens\":0,\"completion_tokens\":0,\"total_tokens\":0,\"prompt_tokens_details\":{\"cached_tokens\":0}}}\n\ndata: [DONE]\n\n" + } + } + ] +} diff --git a/packages/llm/test/fixtures/recordings/cloudflare-workers-ai/cloudflare-workers-ai-gpt-oss-20b-tools-tool-call.json b/packages/llm/test/fixtures/recordings/cloudflare-workers-ai/cloudflare-workers-ai-gpt-oss-20b-tools-tool-call.json new file mode 100644 index 0000000000000000000000000000000000000000..fa22f1ddb9e5b873720927f9c3db34cc4cd18e6e --- /dev/null +++ b/packages/llm/test/fixtures/recordings/cloudflare-workers-ai/cloudflare-workers-ai-gpt-oss-20b-tools-tool-call.json @@ -0,0 +1,32 @@ +{ + "version": 1, + "metadata": { + "name": "cloudflare-workers-ai/cloudflare-workers-ai-gpt-oss-20b-tools-tool-call", + "recordedAt": "2026-05-08T17:20:14.106Z", + "provider": "cloudflare-workers-ai", + "route": "cloudflare-workers-ai", + "transport": "http", + "model": "@cf/openai/gpt-oss-20b", + "tags": ["prefix:cloudflare-workers-ai", "provider:cloudflare-workers-ai", "tool", "tool-call", "golden"] + }, + "interactions": [ + { + "transport": "http", + "request": { + "method": "POST", + "url": "https://api.cloudflare.com/client/v4/accounts/{account}/ai/v1/chat/completions", + "headers": { + "content-type": "application/json" + }, + "body": "{\"model\":\"@cf/openai/gpt-oss-20b\",\"messages\":[{\"role\":\"system\",\"content\":\"Call tools exactly as requested.\"},{\"role\":\"user\",\"content\":\"Call get_weather with city exactly Paris.\"}],\"tools\":[{\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false}}}],\"tool_choice\":{\"type\":\"function\",\"function\":{\"name\":\"get_weather\"}},\"stream\":true,\"stream_options\":{\"include_usage\":true},\"max_tokens\":120,\"temperature\":0}" + }, + "response": { + "status": 200, + "headers": { + "content-type": "text/event-stream" + }, + "body": "data: {\"id\":\"id-1778260814069\",\"created\":1778260814,\"model\":\"@cf/openai/gpt-oss-20b\",\"object\":\"chat.completion.chunk\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"\"},\"logprobs\":null,\"finish_reason\":null}]}\n\ndata: {\"id\":\"id-1778260814069\",\"created\":1778260814,\"model\":\"@cf/openai/gpt-oss-20b\",\"object\":\"chat.completion.chunk\",\"choices\":[{\"index\":0,\"delta\":{\"reasoning_content\":\"\"},\"logprobs\":null,\"finish_reason\":null,\"token_ids\":null}]}\n\ndata: {\"id\":\"id-1778260814069\",\"created\":1778260814,\"model\":\"@cf/openai/gpt-oss-20b\",\"object\":\"chat.completion.chunk\",\"choices\":[{\"index\":0,\"delta\":{\"reasoning_content\":\"We\"},\"logprobs\":null,\"finish_reason\":null,\"token_ids\":null}]}\n\ndata: {\"id\":\"id-1778260814069\",\"created\":1778260814,\"model\":\"@cf/openai/gpt-oss-20b\",\"object\":\"chat.completion.chunk\",\"choices\":[{\"index\":0,\"delta\":{\"reasoning_content\":\" need\"},\"logprobs\":null,\"finish_reason\":null,\"token_ids\":null}]}\n\ndata: {\"id\":\"id-1778260814069\",\"created\":1778260814,\"model\":\"@cf/openai/gpt-oss-20b\",\"object\":\"chat.completion.chunk\",\"choices\":[{\"index\":0,\"delta\":{\"reasoning_content\":\" to\"},\"logprobs\":null,\"finish_reason\":null,\"token_ids\":null}]}\n\ndata: {\"id\":\"id-1778260814069\",\"created\":1778260814,\"model\":\"@cf/openai/gpt-oss-20b\",\"object\":\"chat.completion.chunk\",\"choices\":[{\"index\":0,\"delta\":{\"reasoning_content\":\" call\"},\"logprobs\":null,\"finish_reason\":null,\"token_ids\":null}]}\n\ndata: {\"id\":\"id-1778260814069\",\"created\":1778260814,\"model\":\"@cf/openai/gpt-oss-20b\",\"object\":\"chat.completion.chunk\",\"choices\":[{\"index\":0,\"delta\":{\"reasoning_content\":\" the\"},\"logprobs\":null,\"finish_reason\":null,\"token_ids\":null}]}\n\ndata: {\"id\":\"id-1778260814069\",\"created\":1778260814,\"model\":\"@cf/openai/gpt-oss-20b\",\"object\":\"chat.completion.chunk\",\"choices\":[{\"index\":0,\"delta\":{\"reasoning_content\":\" function\"},\"logprobs\":null,\"finish_reason\":null,\"token_ids\":null}]}\n\ndata: {\"id\":\"id-1778260814069\",\"created\":1778260814,\"model\":\"@cf/openai/gpt-oss-20b\",\"object\":\"chat.completion.chunk\",\"choices\":[{\"index\":0,\"delta\":{\"reasoning_content\":\" get\"},\"logprobs\":null,\"finish_reason\":null,\"token_ids\":null}]}\n\ndata: {\"id\":\"id-1778260814069\",\"created\":1778260814,\"model\":\"@cf/openai/gpt-oss-20b\",\"object\":\"chat.completion.chunk\",\"choices\":[{\"index\":0,\"delta\":{\"reasoning_content\":\"_weather\"},\"logprobs\":null,\"finish_reason\":null,\"token_ids\":null}]}\n\ndata: {\"id\":\"id-1778260814069\",\"created\":1778260814,\"model\":\"@cf/openai/gpt-oss-20b\",\"object\":\"chat.completion.chunk\",\"choices\":[{\"index\":0,\"delta\":{\"reasoning_content\":\" with\"},\"logprobs\":null,\"finish_reason\":null,\"token_ids\":null}]}\n\ndata: {\"id\":\"id-1778260814069\",\"created\":1778260814,\"model\":\"@cf/openai/gpt-oss-20b\",\"object\":\"chat.completion.chunk\",\"choices\":[{\"index\":0,\"delta\":{\"reasoning_content\":\" city\"},\"logprobs\":null,\"finish_reason\":null,\"token_ids\":null}]}\n\ndata: {\"id\":\"id-1778260814069\",\"created\":1778260814,\"model\":\"@cf/openai/gpt-oss-20b\",\"object\":\"chat.completion.chunk\",\"choices\":[{\"index\":0,\"delta\":{\"reasoning_content\":\" \\\"\"},\"logprobs\":null,\"finish_reason\":null,\"token_ids\":null}]}\n\ndata: {\"id\":\"id-1778260814069\",\"created\":1778260814,\"model\":\"@cf/openai/gpt-oss-20b\",\"object\":\"chat.completion.chunk\",\"choices\":[{\"index\":0,\"delta\":{\"reasoning_content\":\"Paris\"},\"logprobs\":null,\"finish_reason\":null,\"token_ids\":null}]}\n\ndata: {\"id\":\"id-1778260814069\",\"created\":1778260814,\"model\":\"@cf/openai/gpt-oss-20b\",\"object\":\"chat.completion.chunk\",\"choices\":[{\"index\":0,\"delta\":{\"reasoning_content\":\"\\\".\"},\"logprobs\":null,\"finish_reason\":null,\"token_ids\":null}]}\n\ndata: {\"id\":\"id-1778260814069\",\"created\":1778260814,\"model\":\"@cf/openai/gpt-oss-20b\",\"object\":\"chat.completion.chunk\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"id\":\"chatcmpl-tool-ed7127682c90443da222d0f8c607b5d5\",\"type\":\"function\",\"index\":0,\"function\":{\"name\":\"get_weather\",\"arguments\":\"\"}}]},\"logprobs\":null,\"finish_reason\":null,\"token_ids\":null}]}\n\ndata: {\"id\":\"id-1778260814069\",\"created\":1778260814,\"model\":\"@cf/openai/gpt-oss-20b\",\"object\":\"chat.completion.chunk\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"{\\\"\"}}]},\"logprobs\":null,\"finish_reason\":null,\"token_ids\":null}]}\n\ndata: {\"id\":\"id-1778260814069\",\"created\":1778260814,\"model\":\"@cf/openai/gpt-oss-20b\",\"object\":\"chat.completion.chunk\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"city\"}}]},\"logprobs\":null,\"finish_reason\":null,\"token_ids\":null}]}\n\ndata: {\"id\":\"id-1778260814069\",\"created\":1778260814,\"model\":\"@cf/openai/gpt-oss-20b\",\"object\":\"chat.completion.chunk\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"\\\":\\\"\"}}]},\"logprobs\":null,\"finish_reason\":null,\"token_ids\":null}]}\n\ndata: {\"id\":\"id-1778260814069\",\"created\":1778260814,\"model\":\"@cf/openai/gpt-oss-20b\",\"object\":\"chat.completion.chunk\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"Paris\"}}]},\"logprobs\":null,\"finish_reason\":null,\"token_ids\":null}]}\n\ndata: {\"id\":\"id-1778260814069\",\"created\":1778260814,\"model\":\"@cf/openai/gpt-oss-20b\",\"object\":\"chat.completion.chunk\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"\\\"}\"}}]},\"logprobs\":null,\"finish_reason\":null,\"token_ids\":null}]}\n\ndata: {\"id\":\"id-1778260814069\",\"created\":1778260814,\"model\":\"@cf/openai/gpt-oss-20b\",\"object\":\"chat.completion.chunk\",\"choices\":[{\"index\":0,\"delta\":{},\"logprobs\":null,\"finish_reason\":null,\"stop_reason\":200012,\"token_ids\":null}]}\n\ndata: {\"id\":\"id-1778260814069\",\"object\":\"chat.completion.chunk\",\"created\":1778260814,\"model\":\"@cf/openai/gpt-oss-20b\",\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":\"tool_calls\"}],\"usage\":{\"prompt_tokens\":136,\"total_tokens\":173,\"completion_tokens\":37}}\n\ndata: {\"id\":\"id-1778260814069\",\"object\":\"chat.completion.chunk\",\"created\":1778260814,\"model\":\"@cf/openai/gpt-oss-20b\",\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":\"tool_calls\"}],\"usage\":{\"prompt_tokens\":136,\"completion_tokens\":37,\"total_tokens\":173,\"prompt_tokens_details\":{\"cached_tokens\":0}}}\n\ndata: [DONE]\n\n" + } + } + ] +} diff --git a/packages/llm/test/fixtures/recordings/cloudflare-workers-ai/cloudflare-workers-ai-llama-3-1-8b-text.json b/packages/llm/test/fixtures/recordings/cloudflare-workers-ai/cloudflare-workers-ai-llama-3-1-8b-text.json new file mode 100644 index 0000000000000000000000000000000000000000..52cc25f86b325bf441be2b491ca6fdbd7b07e3db --- /dev/null +++ b/packages/llm/test/fixtures/recordings/cloudflare-workers-ai/cloudflare-workers-ai-llama-3-1-8b-text.json @@ -0,0 +1,32 @@ +{ + "version": 1, + "metadata": { + "name": "cloudflare-workers-ai/cloudflare-workers-ai-llama-3-1-8b-text", + "recordedAt": "2026-05-08T15:56:18.284Z", + "provider": "cloudflare-workers-ai", + "route": "cloudflare-workers-ai", + "transport": "http", + "model": "@cf/meta/llama-3.1-8b-instruct", + "tags": ["prefix:cloudflare-workers-ai", "provider:cloudflare-workers-ai", "text", "golden"] + }, + "interactions": [ + { + "transport": "http", + "request": { + "method": "POST", + "url": "https://api.cloudflare.com/client/v4/accounts/{account}/ai/v1/chat/completions", + "headers": { + "content-type": "application/json" + }, + "body": "{\"model\":\"@cf/meta/llama-3.1-8b-instruct\",\"messages\":[{\"role\":\"system\",\"content\":\"You are concise.\"},{\"role\":\"user\",\"content\":\"Reply exactly with: Hello!\"}],\"stream\":true,\"stream_options\":{\"include_usage\":true},\"max_tokens\":40,\"temperature\":0}" + }, + "response": { + "status": 200, + "headers": { + "content-type": "text/event-stream" + }, + "body": "data: {\"id\":\"id-1778255778230\",\"created\":1778255778,\"model\":\"@cf/meta/llama-3.1-8b-instruct\",\"object\":\"chat.completion.chunk\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"Hello\"}}]}\n\ndata: {\"id\":\"id-1778255778230\",\"created\":1778255778,\"model\":\"@cf/meta/llama-3.1-8b-instruct\",\"object\":\"chat.completion.chunk\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"!\"}}]}\n\ndata: {\"id\":\"id-1778255778230\",\"object\":\"chat.completion.chunk\",\"created\":1778255778,\"model\":\"@cf/meta/llama-3.1-8b-instruct\",\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":\"stop\"}],\"usage\":{\"prompt_tokens\":45,\"completion_tokens\":2,\"total_tokens\":47}}\n\ndata: {\"id\":\"id-1778255778230\",\"object\":\"chat.completion.chunk\",\"created\":1778255778,\"model\":\"@cf/meta/llama-3.1-8b-instruct\",\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":\"stop\"}],\"usage\":{\"prompt_tokens\":0,\"completion_tokens\":0,\"total_tokens\":0,\"prompt_tokens_details\":{\"cached_tokens\":0}}}\n\ndata: [DONE]\n\n" + } + } + ] +} diff --git a/packages/llm/test/fixtures/recordings/gemini-cache/reports-cachedcontenttokencount-on-identical-second-call.json b/packages/llm/test/fixtures/recordings/gemini-cache/reports-cachedcontenttokencount-on-identical-second-call.json new file mode 100644 index 0000000000000000000000000000000000000000..209aadfc1a43cdf5dcc9fbacccacb3d7e492a7e3 --- /dev/null +++ b/packages/llm/test/fixtures/recordings/gemini-cache/reports-cachedcontenttokencount-on-identical-second-call.json @@ -0,0 +1,46 @@ +{ + "version": 1, + "metadata": { + "name": "gemini-cache/reports-cachedcontenttokencount-on-identical-second-call", + "recordedAt": "2026-05-11T01:55:40.600Z", + "tags": ["prefix:gemini-cache", "provider:google", "protocol:gemini", "cache"] + }, + "interactions": [ + { + "transport": "http", + "request": { + "method": "POST", + "url": "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse", + "headers": { + "content-type": "application/json" + }, + "body": "{\"contents\":[{\"role\":\"user\",\"parts\":[{\"text\":\"Say hi.\"}]}],\"systemInstruction\":{\"parts\":[{\"text\":\"You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. \"}]},\"generationConfig\":{\"maxOutputTokens\":16,\"temperature\":0}}" + }, + "response": { + "status": 200, + "headers": { + "content-type": "text/event-stream" + }, + "body": "data: {\"candidates\":[{\"content\":{\"role\":\"model\",\"parts\":[{\"text\":\"Hi.\"}]},\"finishReason\":\"STOP\"}],\"usageMetadata\":{\"promptTokenCount\":1200,\"candidatesTokenCount\":2,\"totalTokenCount\":1202}}\n\n" + } + }, + { + "transport": "http", + "request": { + "method": "POST", + "url": "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse", + "headers": { + "content-type": "application/json" + }, + "body": "{\"contents\":[{\"role\":\"user\",\"parts\":[{\"text\":\"Say hi.\"}]}],\"systemInstruction\":{\"parts\":[{\"text\":\"You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. \"}]},\"generationConfig\":{\"maxOutputTokens\":16,\"temperature\":0}}" + }, + "response": { + "status": 200, + "headers": { + "content-type": "text/event-stream" + }, + "body": "data: {\"candidates\":[{\"content\":{\"role\":\"model\",\"parts\":[{\"text\":\"Hi.\"}]},\"finishReason\":\"STOP\"}],\"usageMetadata\":{\"cachedContentTokenCount\":1100,\"promptTokenCount\":1200,\"candidatesTokenCount\":2,\"totalTokenCount\":1202}}\n\n" + } + } + ] +} diff --git a/packages/llm/test/fixtures/recordings/gemini/gemini-2-5-flash-image.json b/packages/llm/test/fixtures/recordings/gemini/gemini-2-5-flash-image.json new file mode 100644 index 0000000000000000000000000000000000000000..8ab0cc2f1c9e0626881d958de6c4892074ca3b79 --- /dev/null +++ b/packages/llm/test/fixtures/recordings/gemini/gemini-2-5-flash-image.json @@ -0,0 +1,32 @@ +{ + "version": 1, + "metadata": { + "name": "gemini/gemini-2-5-flash-image", + "recordedAt": "2026-05-19T21:56:56.083Z", + "provider": "google", + "route": "gemini", + "transport": "http", + "model": "gemini-2.5-flash", + "tags": ["prefix:gemini", "provider:google", "media", "image", "vision", "golden"] + }, + "interactions": [ + { + "transport": "http", + "request": { + "method": "POST", + "url": "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse", + "headers": { + "content-type": "application/json" + }, + "body": "{\"contents\":[{\"role\":\"user\",\"parts\":[{\"text\":\"The image contains exactly three lowercase English words. Read them left to right and reply with only those words.\"},{\"inlineData\":{\"mimeType\":\"image/png\",\"data\":\"iVBORw0KGgoAAAANSUhEUgAAAnYAAACKCAYAAAAnmweyAAACKWlUWHRYTUw6Y29tLmFkb2JlLnhtcAAAAAAAPD94cGFja2V0IGJlZ2luPSLvu78iIGlkPSJXNU0wTXBDZWhpSHpyZVN6TlRjemtjOWQiPz4KPHg6eG1wbWV0YSB4bWxuczp4PSJhZG9iZTpuczptZXRhLyIgeDp4bXB0az0iWE1QIENvcmUgNi4wLjAiPgogPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4KICA8cmRmOkRlc2NyaXB0aW9uIHJkZjphYm91dD0iIgogICAgeG1sbnM6ZXhpZj0iaHR0cDovL25zLmFkb2JlLmNvbS9leGlmLzEuMC8iCiAgICB4bWxuczp0aWZmPSJodHRwOi8vbnMuYWRvYmUuY29tL3RpZmYvMS4wLyIKICAgZXhpZjpQaXhlbFhEaW1lbnNpb249IjYzMCIKICAgZXhpZjpVc2VyQ29tbWVudD0iU2NyZWVuc2hvdCIKICAgZXhpZjpQaXhlbFlEaW1lbnNpb249IjEzOCIKICAgdGlmZjpZUmVzb2x1dGlvbj0iMTQ0LzEiCiAgIHRpZmY6WFJlc29sdXRpb249IjE0NC8xIgogICB0aWZmOlJlc29sdXRpb25Vbml0PSIyIi8+CiA8L3JkZjpSREY+CjwveDp4bXBtZXRhPgo8P3hwYWNrZXQgZW5kPSJyIj8+at0SpgAACrhpQ0NQSUNDIFByb2ZpbGUAAEiJlZcHUFNZF8fvey+dhJYQASmh994CSAmhBVCQDjZCEiAQQkxBwa4sruBaUBHBsqKrIgo2qg0RxbYo9r4gi4iyLhZsqHwPGMLufvN933xn5s75zXnn/u+5d959cx4AFFOuRCKC1QHIFsul0SEBjMSkZAb+JcACTUACnoDK5ckkrKioCIDahP+7fbgLoFF/y25U69+f/1fT4AtkPACgKJRT+TJeNsonAIABTyKVA4CgDEwWyCWjfB9lmhQtEOWBUU4fY8yoDi11nGljObHRbJQtASCQuVxpOgBkVzTOyOWlozrkWJQdxXyhGOUClH2zs3P4KLehbInmSFAe1Wem/kUn/W+aqUpNLjddyeN7GTNCoFAmEXHz/s/j+N+WLVJMrGGBDnKGNDQa9Xrouf2elROuZHHqjMgJFvLH8sc4QxEaN8E8GTt5gmWiGM4E87mB4Uod0YyICU4TBitzhHJO7AQLZEExEyzNiVaumyZlsyaYK52sQZEVp4xnCDhK/fyM2IQJzhXGz1DWlhUTPpnDVsalimjlXgTikIDJdYOV55At+8vehRzlXHlGbKjyHLiT9QvErElNWaKyNr4gMGgyJ06ZL5EHKNeSiKKU+QJRiDIuy41RzpWjL+fk3CjlGWZyw6ImGMQAOVAAPhCCHMAAgaiXAQkQAS7IkwsWykc3xM6R5EmF6RlyBgu9dQIGR8yzt2U4Ozq7AzB6h8dfkXf0sbsJ0a9MxlZVAeDTNDIycnIyFnYDgKMpAJDqJmOWcwBQ7wPg0imeQpo7Hhu7a1j0y6AGaEAHGAATYAnsgDNwB97AHwSBMBAJYkESmAt4IANkAylYABaDFaAQFIMNYAsoB7vAHnAAHAbHQAM4Bc6Bi+AquAHugEegC/SCV2AQfADDEAThIQpEhXQgQ8gMsoGcISbkCwVBEVA0lASlQOmQGFJAi6FVUDFUApVDu6Eq6CjUBJ2DLkOd0AOoG+qH3kJfYAQmwzRYHzaHHWAmzILD4Vh4DpwOz4fz4QJ4HVwGV8KH4Hr4HHwVvgN3wa/gIQQgKggdMULsECbCRiKRZCQNkSJLkSKkFKlEapBmpB25hXQhA8hnDA5DxTAwdhhvTCgmDsPDzMcsxazFlGMOYOoxbZhbmG7MIOY7loLVw9pgvbAcbCI2HbsAW4gtxe7D1mEvYO9ge7EfcDgcHWeB88CF4pJwmbhFuLW4HbhaXAuuE9eDG8Lj8Tp4G7wPPhLPxcvxhfht+EP4s/ib+F78J4IKwZDgTAgmJBPEhJWEUsJBwhnCTUIfYZioTjQjehEjiXxiHnE9cS+xmXid2EscJmmQLEg+pFhSJmkFqYxUQ7pAekx6p6KiYqziqTJTRaiyXKVM5YjKJZVulc9kTbI1mU2eTVaQ15H3k1vID8jvKBSKOcWfkkyRU9ZRqijnKU8pn1SpqvaqHFW+6jLVCtV61Zuqr9WIamZqLLW5avlqpWrH1a6rDagT1c3V2epc9aXqFepN6vfUhzSoGk4akRrZGms1Dmpc1nihidc01wzS5GsWaO7RPK/ZQ0WoJlQ2lUddRd1LvUDtpeFoFjQOLZNWTDtM66ANamlquWrFay3UqtA6rdVFR+jmdA5dRF9PP0a/S/8yRX8Ka4pgypopNVNuTvmoPVXbX1ugXaRdq31H+4sOQydIJ0tno06DzhNdjK617kzdBbo7dS/oDkylTfWeyptaNPXY1Id6sJ61XrTeIr09etf0hvQN9EP0Jfrb9M/rDxjQDfwNMg02G5wx6DekGvoaCg03G541fMnQYrAYIkYZo40xaKRnFGqkMNpt1GE0bGxhHGe80rjW+IkJyYRpkmay2aTVZNDU0HS66WLTatOHZkQzplmG2VazdrOP5hbmCearzRvMX1hoW3As8i2qLR5bUiz9LOdbVlretsJZMa2yrHZY3bCGrd2sM6wrrK/bwDbuNkKbHTadtlhbT1uxbaXtPTuyHcsu167artuebh9hv9K+wf61g6lDssNGh3aH745ujiLHvY6PnDSdwpxWOjU7vXW2duY5VzjfdqG4BLssc2l0eeNq4ypw3el6343qNt1ttVur2zd3D3epe417v4epR4rHdo97TBozirmWeckT6xnguczzlOdnL3cvudcxrz+97byzvA96v5hmMU0wbe+0Hh9jH67Pbp8uX4Zviu/Pvl1+Rn5cv0q/Z/4m/nz/ff59LCtWJusQ63WAY4A0oC7gI9uLvYTdEogEhgQWBXYEaQbFBZUHPQ02Dk4Prg4eDHELWRTSEooNDQ/dGHqPo8/hcao4g2EeYUvC2sLJ4THh5eHPIqwjpBHN0+HpYdM3TX88w2yGeEZDJIjkRG6KfBJlETU/6uRM3MyomRUzn0c7RS+Obo+hxsyLORjzITYgdn3sozjLOEVca7xa/Oz4qviPCYEJJQldiQ6JSxKvJukmCZMak/HJ8cn7kodmBc3aMqt3ttvswtl351jMWTjn8lzduaK5p+epzePOO56CTUlIOZjylRvJreQOpXJSt6cO8ti8rbxXfH/+Zn6/wEdQIuhL80krSXuR7pO+Kb0/wy+jNGNAyBaWC99khmbuyvyYFZm1P2tElCCqzSZkp2Q3iTXFWeK2HIOchTmdEhtJoaRrvtf8LfMHpeHSfTJINkfWKKehzdI1haXiB0V3rm9uRe6nBfELji/UWCheeC3POm9NXl9+cP4vizCLeItaFxstXrG4ewlrye6l0NLUpa3LTJYVLOtdHrL8wArSiqwVv650XFmy8v2qhFXNBfoFywt6fgj5obpQtVBaeG+19+pdP2J+FP7YscZlzbY134v4RVeKHYtLi7+u5a298pPTT2U/jaxLW9ex3n39zg24DeINdzf6bTxQolGSX9Kzafqm+s2MzUWb32+Zt+VyqWvprq2krYqtXWURZY3bTLdt2Pa1PKP8TkVARe12ve1rtn/cwd9xc6f/zppd+ruKd335Wfjz/d0hu+srzStL9+D25O55vjd+b/svzF+q9unuK973bb94f9eB6ANtVR5VVQf1Dq6vhqsV1f2HZh+6cTjwcGONXc3uWnpt8RFwRHHk5dGUo3ePhR9rPc48XnPC7MT2OmpdUT1Un1c/2JDR0NWY1NjZFNbU2uzdXHfS/uT+U0anKk5rnV5/hnSm4MzI2fyzQy2SloFz6ed6Wue1PjqfeP5228y2jgvhFy5dDL54vp3VfvaSz6VTl70uN11hXmm46n61/prbtbpf3X6t63DvqL/ucb3xhueN5s5pnWdu+t08dyvw1sXbnNtX78y403k37u79e7Pvdd3n33/xQPTgzcPch8OPlj/GPi56ov6k9Kne08rfrH6r7XLvOt0d2H3tWcyzRz28nle/y37/2lvwnPK8tM+wr+qF84tT/cH9N17Oetn7SvJqeKDwD40/tr+2fH3iT/8/rw0mDva+kb4Zebv2nc67/e9d37cORQ09/ZD9Yfhj0SedTwc+Mz+3f0n40je84Cv+a9k3q2/N38O/Px7JHhmRcKXcsVYAQQeclgbA2/0AUJIAoKI9BGnWeI89ZtD4f8EYgf/E4334mKGdSw3qRtsjdgsAR9BhvhwANX8ARlujWH8Au7gox0Q/PNa7jxoO/Yup8UK0Vjk9ta0C/7Txvv4vdf/TA6Xq3/y/AOOhDyne6KAWAAAAimVYSWZNTQAqAAAACAAEARoABQAAAAEAAAA+ARsABQAAAAEAAABGASgAAwAAAAEAAgAAh2kABAAAAAEAAABOAAAAAAAAAJAAAAABAAAAkAAAAAEAA5KGAAcAAAASAAAAeKACAAQAAAABAAACdqADAAQAAAABAAAAigAAAABBU0NJSQAAAFNjAAAAAAAAAADxh4F4AAAAHGlET1QAAAACAAAAAAAAAEUAAAAoAAAARQAAAEUAAAbT33OL9AAABp9JREFUeAHs3F9olWUcB/DnLHCT/rgKQxbhtLwpkIqsLrxZQfQXKggEA/tjZuCFCRHR1Wg3XiyhoKgVeKFd1k1CFNGNRAhhkFAQFBlSkLhjbqtNbW3jeOB0dt6dHc905/d8dnXe5332nvf3+b7jfGXMUt+NG6aTLwIECBAgQIAAgY4XKCl2HZ+hAQgQIECAAAECcwKKnQeBAAECBAgQIBBEQLELEqQxCBAgQIAAAQKKnWeAAAECBAgQIBBEQLELEqQxCBAgQIAAAQKKnWeAAAECBAgQIBBEQLELEqQxCBAgQIAAAQKKnWeAAAECBAgQIBBEQLELEqQxCBAgQIAAAQKKnWeAAAECBAgQIBBEQLELEqQxCBAgQIAAAQKKnWeAAAECBAgQIBBEoPT+4NHp+WY58edPaeST1+c7ZY0AAQIECBAgQGAZCpQ+H/ln3mJXPnMy7R4eWIa37JYIECBAgAABAgTmE1Ds5lOxRoAAAQIECBDoQIFFF7uelb0dOKZbJkCAAAECBAjEF1Ds4mdsQgIECBAgQCATAcUuk6CNSYAAAQIECMQXUOziZ2xCAgQIECBAIBMBxS6ToI1JgAABAgQIxBdQ7OJnbEICBAgQIEAgEwHFLpOgjUmAAAECBAjEF1Ds4mdsQgIECBAgQCATAcUuk6CNSYAAAQIECMQXUOziZ2xCAgQIECBAIBMBxS6ToI1JgAABAgQIxBdQ7OJnbEICBAgQIEAgEwHFLpOgjUmAAAECBAjEF1Ds4mdsQgIECBAgQCATAcUuk6CNSYAAAQIECMQXUOziZ2xCAgQIECBAIBMBxS6ToI1JgAABAgQIxBdQ7OJnbEICBAgQIEAgEwHFLpOgjUmAAAECBAjEF1Ds4mdsQgIECBAgQCATAcUuk6CNSYAAAQIECMQXUOziZ2xCAgQIECBAIBMBxS6ToI1JgAABAgQIxBdQ7OJnbEICBAgQIEAgEwHFLpOgjUmAAAECBAjEF1Ds4mdsQgIECBAgQCATAcUuk6CNSYAAAQIECMQXUOziZ2xCAgQIECBAIBMBxS6ToI1JgAABAgQIxBdQ7OJnbEICBAgQIEAgEwHFLpOgjUmAAAECBAjEF1Ds4mdsQgIECBAgQCATAcUuk6CNSYAAAQIECMQXUOziZ2xCAgQIECBAIBMBxS6ToI1JgAABAgQIxBdQ7OJnbEICBAgQIEAgEwHFLpOgjUmAAAECBAjEF1Ds4mdsQgIECBAgQCATgYbFburcZNoxdFcdQ8/K3ro1CwQIECBAgAABApdfoGGx+3d6Oj03uLHuDhW7OhILBAgQIECAAIFlIaDYLYsY3AQBAgQIEMhLYOvWp5dk4IMHDyzJdTvloopdpyTlPgkQIECAQCABxW5pwlTslsbVVQkQIECAAIECAcWuAOciTil2F4HnWwkQIECAAIHWBBS71twW+i7FbiEh5wkQIECAAIG2Cyh2bSedu2DDYnf2/Nn0wht31r2rv4qtI7FAgAABAgQILFJAsVskWJPbGxa78pmTaffwQN1lFLs6EgsECBAgQIDAIgUUu0WCNbldsatA3XrbxnTfA4/VsH343r7098REzVrRQXd3d3p+58upq+uK6rYD+99N5dFT1WMvFhboX3dzevTxLdWN4+Nn0v6Rt9P0zP+t6IsAAQKdIuAzoTgpxa7Yp9Wzil1FbuD+B9OLu16pcdzxzJPpr9Ona9aKDtb2r0t7931Qs2Xv0Gvp6LdHatYcFAs89MgTadv2XTWbtm15OE1OTtasOSBAgMByFvCZUJyOYlfs0+pZxa4ip9g1foRW9fam/nW3NN4wc+b8uXPp2PffFe5p9qRi16yUfQQILGcBxa44HcWu2KfVs4pdRU6xa/wI3X3v5rTn1cHGGypn3hoeSl8f/mrBfQttUOwWEnKeAIFOEFDsilNS7Ip9Wj2r2FXk/l/spqYm085nn0oTE+NN20b9IW622I2882b68otDTXs12qjYNZKxXiSwevUNqdTVlaZmfmVfLo8WbXWOwCURiPqZ0C48xa5dkrXXUewqHrO/buzru6mqMzrzBw9//H6ietzMi+6enrR+/Yaarb8d/yWNjY3VrLXjYLb8XHnV1dVLfXbo44t6n7X969OmezZXr3f815/TkW8Ozx3ffsemtP2lPdVzF15cs2pVWrGi+8Jhalexu/a669OaNX3V686++PGHYx3xxxNFjjUDOWirQKlUSvs/+jTN/gyWy6fm/lHW1jdwMQItCFzKz4QWbu+yf4titzQR/AcAAP//YCg3bwAAJAVJREFU7V0HvNXE0x1p0osivYMgIB2xIXb5oyKIoCAovUuvgvTee5WOFBEQULBgAxERQUBAlN5BQOkKCvrNCW7e3tzklpd738v73gy/R5LdzWZzNjc5Ozsze9unb1/7l2zkwuVz1H7U4345KVNl9EuThLhFIGu27DRuyjvmRU8cP0LdOzanv//+20wLd6dVu+5U6bGnzdMmjxtK679aax7b7aRPn4Gmz11uZr09eTR9vna1eZwYd2KDY2LEKdL3nCVrNho/dYFR7fXr16l+7WcjfQmpTxAQBCKMQN269SJc463qFiyI+T5G5QIer/Q2IXYe7yGb5tWoVY9efrWhkXPz5k3q1a01HTywz6ZkaEkpUtzOBG0ZpUyZyjhh86YNNHpYn6AnC7HzhSi2OPrWErdHadOmpatXr9K//9qO7+K2MS6uVrb8A9S15yCjhvggdv9fcHTRBYnm1HTp0tHly5cTzf1G80aF2EUH3URD7PIXKES33XZbyCheu/YnnTxxPGD53HnyUvLkKQKWiS3hypkzN2VhzVzmzFkp0x13Egjc2TOn6fSpE9SmQw+6K2t247pLFs2h5UvmB2wDMlOlTk158uSnzHdlpTvvykKpUqWmSxfO06+nT1KuPPno1debGXVc5LQu7RrRpUuXgtYZCWKXNGkyypsvf8Br/fnnH3Tq5ImAZVRmnrz5KVmyZHSDtZdHjx5WycY2W/achOcAcsedd/G9n6Bf9uwK6yUdDRyNBrn4D23KzvdmJ+fOnaFLFy+aWbfffjs9VbkqFSlawsDirizZ6N0Fs+j9pbe0XWZBm50778zMfVWQUqZKRRkyZKKzZ0/T0SOH+Ln8NWximJKfv/wFClKWLPyM8zMJuXDhd7rIf6f4d3fixDGbFtgn4Z5q1m5AVau/bBbo0bmFuW/dOcbPhZ12Oy5wTJ48OeXKnZfy5i9EWfg3fP63c4zhAeNZ/fOPP6xNDek4Y6Y7KD/XlztfAUqaJClBg3/k8AE68+vpgP2C90omPhdy7OgRxuQvSp8hA5Up9wBdv3aNU/+lrd9vMtJRpmChIlSocFG6fOkC3bhxg3Zs3/JfOeRGT25PmZLwPoTg/fQbYwbB+6fYvaX4PZmDf/PJ6ejh/XRg/146//tvRn6g//AsZ8iYybbI8WNH6a+/rpt5KPt0lWpUoGBhfmbvprTp0lO/nu3pZ353WCWa3wT0c15+v+Hdf8cdmbmNf9H587/RxfO/0+FD+/n3c97anKDHbp/HLFmyGnjgQngX4LlQgmep0N1FCe3+959/jPbu2/uT8VyqMkLsFBKR3SYaYjd38RrCByBU2fvzLur9ZruAxcdOnkvZsucKWKZOjacCvlytJ5e770GqVqMOFb6nuDXL7/jo4YPUvVNz+od/NE6SkV9ez75Qi57mjzk+XMFkxOC3+GX+bbBiRn4kiB0+LlNmLgl4vT27f6R+b3UIWEZlTpy+iIlCFgIxb1DneSM5W/YcVLteE7r/wUp+5P7K5Us0Y+pY2rRxnarCdhtNHG0vGEZimbIVqFuvIbZnrHr/XVo4b7qR91DFx6hu/eZ0Z+YsPmU/WbOCZr89wSdNP6jwQEVq1LwdZcx4iwToedjH4GD8qIH8Uf3FmuV3DK1m5Wer8zNe2/wg+BXihLO/nqIftn5Hy96dx4OMGGKqyqJNjzz2DA9W8hkf9nAGbWs+WEbzZk1WVZnbaOKId0/9xm/Qo09UpqRJk5rXVDvQmH752Uc0d+ZEgsYxFCnOpKZl224mMbae8/tvZ2kSm1Ts3rndmmUcv1ynAdV4+TVjf1DfLnQb/2vTsSelY8KkZOeOrTRicC9qzP2Ptuty6MBeGtyva1gDI/38UPeL3FOM+g259Xyu//JTmjpxBL1StxH977katu/03Tu30dgR/QK2CwPZF158xbYJwwa8Sdt+2GwM2jFYqPbSq37XGTO8L3337dd+50fjmwBi9NIrr9MTT1XhZyeZ3zWRgOcH/bGF392hDPQj9Ty24uev0uPPGG3q2aWlQaxTp07DmNWhKs/XIPzedYGCYuXyRfQeKyTQZiF2OjqR2xdi54BlfBC71xu1omervuTQIv/kQwf30ZudnDUT2XPkpL6DxjmOTP1rJK6vOR06uN8uyy/Ny8QOjW1WvwZrRbJRj74jCC8bJ8HLplv7JnT8+FHbItHG0faiYSQGIiRbNm+kqROGUafuA6ho8ZK2tToRO2g+6zVowR/QF23P0xNv3rxB82dPpY9Xv68n++yDfGG6tEy5+33SAx307NLKljCCaDz9vxcCneqY9/W6z2jSWH8iHC0cc+XOQ+079zE0446N+i/jFGsqRw3t7fgsohhwfIkJGT72wQgtPp4rli00tLLWa+vE7tOPVtEjjz5lO/g7d/ZXR/IY6oyB9drhHOvEDu+848eOGG0NVMcZHhgMHdDdcdYlELED6f/5px+pQ9e+BI22ncQVscM7dvDIqcZg1a4d1rRQzBAi+TzqxG7siP70065t1Kv/KMqdt4C1aT7H0yePoi/WrhFi54NK5A4SDbHr1X8kTyMUC4icrtELhdgNH/M2ZbVMgel14GK1X3wy4DVVJrQpbTv1UofGFtNFZ8+cIkxFpk6VxpiatY7YnBwW8MIfOX4m5cyV16fOC6y6P8+q+yScn4nV+ekz+DrDYJTfummdkLSMkSB2GI3qjiCqsTqOe3bvYI1dR5UVcKs0dig0c9o4qvNaE5PUXblymV/Yu3ha+waVKlPetClE2Y0bvmKt0wDs+khc4OhzwVgclCpdjjoycVOiYwfN1xmewi9eoozKNraYdjvGUycXL16gbzd8aeso06RFB562vaX1VCfjmfydp3czZrqTMEWmCzTHnds2dPyYPlPlBWrUzFcLjg/ROW4fpr4wxYXnQTdvcCJ29Ru3pieefs68vH7PSAyk9dr49Rc0bdJI81y1Ew0ccS/jpsznqf/M6jLGFu374+oVw8zCJ4MPftmzk/r0aG9NNo9BtBs0ecM8VjuYgkydJq2fdgn5g/p0pp0/blNFja1O7FQGNN3QsiRJkkQlmdur/PvBVJs+hYlp305tGpllorGjEzu9fpBWmKZAMBth1ShD2ziob1f9FHMfNsrP8UyGEv352bZ1s2EmgGdcF7w/TvLgD1OeK5ctMLRTej72I/lNQH3tu/SmBx56FLumoB2/8W+QX9L8/s5k9Ifqr2DELtLPo07soIkry4M2Rerwnv1p1w7WnF6iIjwDpc8UwOyiRcNaQuzMXo3sTqIhdqHA1rFbP8IUDyQUYmdXZ3VW29eu19jMCnUqts/A0axRKWWet2LpQlr1/mL644+rZlr69OnZlqgh4QOpZNPG9ca0gzpWW0zT9BowWh2yXcMpg7js3xczXQbSUoy1OCCU+ssaNnawuQkmkSB2Ttfo0WcYlSxd3siOzVSstd5PPlpJSxbMNBwFkAfP4mFMzJXDCDQlHd5oYD2N4gJHv4u6THj4kSeMKTW7ajDlvO7zj2k3v3B1OyJrWdgjjpow25w2xIt4+sSRtH3b98bUP6YT87JNFzQf95Ysa57uRJBRoPeAUWwTVdosu3D+2/TpmpXGtLlKxDNZuEhRKn9/ReODBs1IKHaqtes2puo1XzWqCfZxU9cKto0Ejs+9UJNea9jSvBRIw9LFcwybKGiKYYdUslQ5atGmm2Ebqgo6mUSAgMD7V/1e8fGcxv2yY/v3bH92wegv2JnWqtOQ4FCi5CBPk/fs2tpnwGYldqdOHqeBvTsZpK43vzuUHS/qwHsGnvJoc9tOb7FZwyNG1ZHCWrXTbmtH7E6eOEojh/TyGUQ88FAlgle6Pv0X6gxE05Yd6clnYgYKqh0YyHy8ejlt/nY94d0JMhmuxPabgPuYMf99837wLZg8bgj9sOU7H/MblCtZuiyVr1CRSpQqawzMndoY6edRJ3b6NX/atZ2mc5QERbzxnsXvvwDbaSpp0bAmPfecP+Yq381WvGIl3In5/MQnsZuz6EOTZOAl3IOnoOwEH77xU98xpwhg39Su5S07Gb3889VqGdNoKm34wB6GzZI61rewnWnZJmZki2kqTFcFk4RC7EAgVi1f7Hc7TVvxy/w/rQ9e4K+/UsXvxR0XOPo1zGWCHSH5lTUbs6aPYwKwNaTa23XuTQ8+fEtTAGzat6xnGq3rFcD2cMS4maZdFj58bZrVoXPnzurFjP05Cz9gx4vUxj5e/P17dfIrE9uEuCJ24eCIe53Av1Vls3bu7BkOS9SUrly54nebsEeCFlLJ3p93s41vW3VobmF/Cy20kvmzp9DqVUvVoblNkyYNDR093XxPIGMwa69+ZC2WEiux07WjLd7oQo89+T9VlJq8Vs1s90MVHzfIncqELSs0fdESO2LXsvHLtk4S1ncZbPImjx8WtGl2xG7Xjz+w1n9syI5bTheJLbGDo9eQUdPMapcunktL2eY0thKN59GO2H3/3Tc0bmR/H0cKtLlipSfojQ49zeb3ebMNlS8XMyg0MyKwI8ROiJ35GMUXsQNZW7hsrWkvE0xbOGbSXMqe45bTBj407Vq9bt6D2qnJ9jc1a9dXhzwl0YV27vjBPNZ3rERg4pjBtGH953oR2/2EQOzg7QmvTzuB8bTyBkZ+3ZqVjWlavWxc4KhfLxL71v4ESZ8+aZTp3RjsGpjWmvXOKvN5XPfFJzRlwnDH0+AM0bBpGzPf6VmbtWCVOS0O78ZWTV4xNEDmiS524oLYhYtj+QoPUuc3B5p3NXxQT9a2bDKPrTuDR0w2NRrQzjSq+4K1CA0dNZXysWcmxE4Lp5+gh4BBupUEWomdbjYC8ggSqUTPK1GyDPXsFzOVDVtWOwcXda7brZXYbdn8LWvr3rKtFhrNabOXmgMIOJh17dDUtqyeaCV2E8cM4nfgF3qRWO/Hltjly1/QIOfqwnDWgAY7thKN59FK7DBgw/Q3NLtWKXR3ERo4PMZpCQONEvcWtRaLyLEQOyF25oMUX8QODRjJWg+EHYFA6zFj6hjDuFRX/cO+7rlqNenV12JeVNvYc3AYa+OsgmmJ9l36mMnwmMLUhQoVoDIwJdm911CTKCJdeTepMk7bhEDsAk2FW22V7IhdXODohG9s063Erm2Luj4hBoLVa9UUDBvIXoI8hegk1vJOdp86KUFdsIGaN2tSSNP+TtdW6XFB7MLFUdfC4XfckInaNbaXdRI4qkBDrETXkqm0We+sNOzocPzBindpwdxbHs8qX99aCTocW+bMmGgW0YkdbDHbtKhn5j3Kno7wuIWgzQ1erWrm3VP0Xuo7eJx5HNfEDs4N8Gx2ki49BhKiC0BgF9j4tepORc10ndj9zuFUMOiIlMSW2MHhC4MhJXiG1n78AU/lzw4pHJU6T22j8TxaiV3T16s7eiPDRGD42BmqOYYGWYidCUdEd8TGToMzPomdnaE6HB2OHD7ExtApjLhb+ThWlZrWUc2GBx1U31ZB7KUJHPpDGdUiHy+GA/t+ZsPya6wmZ/settnD6B8aQyWIf9Wtw62YdirNaet1Yvcl25JN49AIThIKsYsLHJ3aF9t0t8TOSmZhi3fk0H7H5qRNl8FnYAD70MVsz2gVK94qH/aNMOyHRx1CVcQm+KsXiV19dnCo8p9HMRwbMH0YSODlC29fJdYBFoIgz5i/UmWTE4E2C/DO9DnLTAcp6yBQJ3bo3268eo0SLxO7YNq0Zq06sWPNs+pWjLBHwaaKdWKHKfM32JwgUhJbYofr698k1R44KUFbu4t/M7v5NwOHMDhDBZNIP4+4nk7sgpFoIXbBeihy+ULsNCz1H1Gw6VDtNJ/d2P6I8dIeMHQiZf8vEKdPpQ4HiHsFt3Fdq6cXtfNC1POt+/hh9u/VkcnkQWuW7bHXid1HHy7nuGCTbNuORCvRsNPYoVy0ccQ1Iiluid0LHGNO1wqH27bPPvnQ0Dhbz4PDRa06DYwpPn0woZfDRwteoXAcCqQl1M/BvheJna45cnLO0e8DS/rB+F/JkP7daMe2LerQCCit21whduBG9mgOJLDHRSBkyH4ODvtWt5gp84RK7Ky4WO8fzmt4Dytpz6YqyohfpVm3XiV2GHzDsUZ3hLG2HcGkEXdvycKZPs4k1nKRfh5Rv07sgikFhNhZeyR6x0LsNGzjk9ihGXdxYN1GzTtwnK8KWqv8d+Hh+sGKJfTZJx84kjp1FpwD4CQQSPAxhTfj+0vmhRXxP7EQO2AXTRwD9U1s8twSO+uUYLhtgAfy7OnjHU+DpzFisN1TrKSPRtl6wtqPV/FU7ZSQtBFeJHb9Bo81VvjAfZ0+dZzat4qxebXeK46thv9dObYiovkrsdq2BdNc4byJ0xeaMeisSwUmVGIH+zrY2TmJ/iygjJOjhX6+V4mdaiPCDj31zPPGiiVOgyJ4KL/DzjRr+btgJ5F+HnENIXZ2SMd/mhA7rQ/im9ihKbrHGZYY2rrlW8NOBPGjECj0NIckwFI+IGOhij5q//zT1XSDQySk4PhaqA9LTu1hg9czvCxUuGIldsFsX8KpPxLhTiKlsVPtjhaOqv5Ibd0SO2tIBESy3/fLTyE3D0vfOQV71ivBmpvFS5Tlv9JGaJusvDSUVUINgKt/zCMVgsMtjnoMMjiLNOfwDoEEwckRpFyJ1dsUgWVHjp+tsmkmr5ji9BFXhXSbvA9XvkfvzJmqsiihEjvEIMRshZPoJA1G/PVqVQ46ANbP8dJUrPUe4YVeileaKVXmPirBYYaspjkoj+XO9vy003qqT0y8SDyPuIAQOz+YPZEgxE7rBi8Qu85vDuB4RA8ZrRrA06KIN+ZGdM+qcAL9hnJNrEwwf8nHpo2e9cMRSh1OZbxG7KKJoxMGsU13S0hgeI5pGyWhekmr8rHdYs3g1xq28omLF2oAXJ3YYRCEj7lbcYsjprMxra0kkGE5yui2YZd5GbWm7G2qC+KVzV282vy9WZ0h9LLYh33opBnvmsnQokKbqiShEjuE/EDoDyeBJzI8QCGhkrSEQuz0e4bmDlO0r3OcRD1QPjTdCM5ulUg/j6hfiJ0VZW8cC7HT+iG+iR28oKaxsTMWZoY0a8BhBLQF3LWmhryrhy1w+sGHXJlNwcn84VBR9SNJHL1G7KKNow20sU5yS0hy5WLNEAcnVuIUU03lR3ILz++ps5aYmohQtW/WsDQIfhqbRdH1e3GLI6bP4BSlZArHU1vHcdXsBKt4jGJtHNY5huzfu4ft4fxXl5g6+z1zhQXY7XXh6Vp94XW9bmsYmqH9uxsBplWZhErsjh89TJ3bxQSBV/eDLXA0wp1wQFxIqM9uQiR2xg3yfxgQDRsT420K21R4slslGs+jEDsryt44FmKn9UN8EzvdEw3NQtwieLDi7y+2n7jEyz9hibFjRw/xeolHg04voI4J0xb4BCmFsTU+ltc5oOi1P/80lqaB/Q/WYLQLnIo6Akn/IeOo8D33mkXe6trKiNBuJvy3A+eQv1mTAkPfUMRrxC7aOIaCSahl3BISaGInz3yXvaZjlptDwNFvv1nn2IQCBe82FmVfveo9R+cbxLFCQO1AXq/w4h41fpbpRATP8BaNAnuTolHWe0ZYj0Dr1jreiJZhrTPccCe6lhfVIjZd947NbEPPNG/dmR7nRd6VIG4g4gdapVP3/nTf/Q+byU5auxw5c9GQkdPMZd8Q77Jjm4Y+8cUSKrHDzTvFSqxcpRo1bBYT2Bne2fDSDiZeJHYgqblz5zV+TwgS7iRYhm/qrPfMbMRKRMxEq0TjeRRiZ0XZG8dC7LR+CIfYQbuWiX9QVqlWsy7Bu01JZ36Z6l6rsG/79fQple2zta5y4JNpOcAHb/Omb+iD9xfRWXbPtxNoP6bNWUpp06azy/ZJU96IWJgZwYn1NvsUtBxgzcUatWLiX8GzFtHaf2Q7QNSBtWof5Qj2lR57xlj6bOv3vkbPeCmlYSyt0qPvcHNtQRBa2I3ocpU/khd4zVur6GvFRsrGLi5wtN5HOMdwutGXUXq40pNUgxeJV4LR+xmHZ+4k22za9bWdswjsudZ9/pHhYIO1hhEkOwdr9x59vLK5Fu0AXpJq987t6tI+W9h74WP14/YfaNM3X7JjwEEeWJw1gtsivWDBwlT1xTo+zkNbNm804i/6VGRzYNVaIPYaAlNv5OtA6w17vrwcLqjCg5VoB3sQol6rRANH/Z2C6x3je/549Qra+8suXgLsPBW6uyiVYHspFRYFZbBcVue2jW3taLGMG1aU0A3oYQKxi2MCHtj/s6E9L1zkXsPjOyeTAiV2jhYJmdghBA+mG7d8t8FYJhD2vlg7+JW6jUxsMIBt3eRlvwErsMueIyfdxv+UwMEMzjxKOtksL4g8hBVxskeO9DcBfY1lDzEg+H7TBg5rtYG/HSf4fX/WiC2IwXIJXo4OnuY5cuZRTWfv2Nm0/L13zGN9J9LPoxA7HV3v7Aux0/pC1xIhntaA3p21XN/dQSMmUcFC9/gmhnBkDQSqn4IXDpbygXdcqIIXzdgR/clKmNT5CB7bq/8oM6ipSg+0xb0PHfCmETsvUDnkYekirF2ZJgTyaF3/Ekvc4GOvx9oLdj2V/xXHqJtqE6MuGsQO14w2juq+wt1aDerDPb9+7WcNDa71PDyLQ0ZOMVc5sOY7HQcjdlik3iogljpR0fOd4jTqZdT+m72HGkbl6thpixUksGyeLtHCEdPawzn4eDjP+OhhfXjQtkFvns9+m449DQ2lT2KAA8So696phR+BT8jETr9dzAJgYGAVJ+9sa7xA63mBjkGee3aJWfpNLxvpb4Iidvo11L7TbwaEF8oEJ/IZ6edRiJ3qEW9thdhp/aFPtwULbjts9DRDA6CdHtJuIGKHCvCBK1m6HAclTmloYVLwEjlp06WnrFlzGAvXI0gxjnXBi63jG/X9VpVQZbJlz2FozlKkSGHUiZdg5sxZKQt7IWLkmidvAb8P60ccpX6uFqVe1WW3xRRys9adeAHyZHbZZpqV2Fkjq5sFQ9iJa2KHJkUbxxBu26+IVVPlVyBIghOxw2nQYLXgNYSLlygTpJZb2dCsYhH5o2wDZSe6h6ZdvjUNWgdoH0IVtHcQk1F9CtnuXDtiF00cKzxQkTDVGmzwc4W13VMnDAsYygP3A01Ns9ZdCPUGE5hzTJ80wtBqWcsmVGIHu7nC9xS33o7P8aaN6w3ybhe4F9pRBOuNjQQidpH+JgQidnZthwfw2BH9bAPW6+Uj+TwKsdOR9c6+ELv/+gIhF6DZUrJo/gxauXyROvTbWm3L/Ao4JFiDhDoUc0yG/VP5Cg/zGqdNzcCjKAwNBD5YsREQlipVaxLsU5TAFqpdy5jpPJXutIVGCx8vTHdZtS8Iq7Jh3ee05sOlPs4gIJgz5q0wnUWc6rZLR9+gj6wyasIsg8QiPZyp2CuXLxleiHbTktZrOB1HAkenup3Scc0xk+b5Ye5UXk/HtHmzBi/52F3p+dhHXyJ+FkJxwPPOqnmC4f6BfXt4qaNVhI+pkyE/6sIg4v4HH6X7ebm7/LziiRJMmWGNTwjwx+LrCLFiF7JBneO0Bel5+dXGxsoD+K1Y5TcO74MYkFb7u2jjmDnzXWz71Y6KFithaM+hWVHT5zCr2PvLHpo3cyKHHzprbbLjMTRP6Jds3C/oJ1Un+uA42+F+zmYV6Bcn0ddKRiBkBP5VUpqnh7uzBhQSaEkxDCrh5IU+jJZY14rtxU4lOTiQe3U2e4E5AOzP0NfAAP0LB5X3Fs3x01Cq9sGWEe+q2IiT/RrqivQ3AfdTuEgxw3zgfjYhUI41uJbqa+yjv9fzPa9YtsDWfhNlrBKp5xErpeA5hIQboLhbhyZU8eEYe1FrG90cy1qxslYsL7mTgXr3H22u1Qp7s45sYxEsWrmbB8/tuU9XrkqNW8TYncGeCAvex1bwEpnC3ogZM96yG8Tor27NZ8KuDmQNBr9JkiTlF+2tcAO/83JKbghT2I2IxxMihWM83oLjpfHxxAcV2mR4biNQNtYejk3fpmSvxYyZMlGGDJnYhuiK8dzBVhQ2d3ZaFsdGOWSgH6DBAxkFaYSd3fnfzznaozpUE5VktCtlqlSseUtPJ44fNWwM3VwI95crdz7+2F/j31wSrvMYk/Ubbqr01Ll2xG4few2jjxH7MEOGjAbROc82t3Z2t566GReNwaAF7+cMbJd8mbXjMGvAoBnv13DimlqbEOnn0Vp/oOO6desFyo51nhC7RE7sSpQqS42atjW98PAkrf9qLU0ed2u0GusnK4onYtqodbselIeNa5UEi8auytltoTnAyF83PD7MXrKwyxEJHQHBMXSspKQgECoCTsQu1POlnHcREGIXnb5JVFOxWKsSxv7wICpavBSVu+8BKlS4mA+yWKwbITugiYhPAUmAJ19a/kvPWg1MOeTkUXmx4iUpd578Pk3DVE4nNpi9evWqT7p+gNEt7h0LtqdjGz2o9eGxmidfQSpVuryf8fHC+W/TquWL9SpknxEQHOUxEATiFgEhdnGLd1xeTYhddNBONMRuxLgZfoTICikCfg7u1zXOp2uwjFgjtlVIniw5JeXpLhBQEIhQBK7wMFg/eGCfT3F4NIIM3qovmZ9tlE9hywE0lgimGpspNktVCf5QcEzwXSg3kMAREGKXwDswQPOF2AUAx0VWoiF2cxevMQ20rXjBpgfG1CvYRi2Q1st6XqSOq1Z/herWbxZWdTBW/mTNCtaqLfSL04SKAt2v04Xg8bWEbfV2bN/qVCTRpQuOia7L5YY9hoAQO491SASbI8QugmBqVSVaYgdPql/27KQd276njV9/Ea9Tr6EQO0y3nmSN4pEjh4xgpFhDFt5qThKMkIDMwjkEhtZ7f95FO3ds4RUtjjhVl2jTBcdE2/Vy4x5BwLrEHUI7nTxx3COtk2a4QUCInRv0nM91JHZ/3bhOzQaW9zszZaqYZYb8Mj2cgKC/WEYLi2tfunTBcAuPpot+OFDAGaIgR6C/ceNvunnjprFFGIHLHILj8uWLdJE9oHAcjjzGqz1AbrIrPOoFkb169TIhrMclYMBegjLVGhxRwTE4RlJCEIg2ArA3hnkK3lmBlqSLdjuk/sgiIMQusniq2hyJ3T/8A2rUL2aJFXVCQiV2qv2yFQQEAUFAEBAEBIH4R0CIXXT6QIhddHCVWgUBQUAQEAQEAUFAEIhzBITYxTnkckFBQBAQBAQBQUAQEASig4AQu+jgKrUKAoKAICAICAKCgCAQ5wgIsYtzyOWCgoAgIAgIAoKAICAIRAcBR2L3982/qemAsn5XFecJP0gkQRAQBAQBQUAQEAQEAU8g4EjsLlw+R+1HPe7XSCF2fpBIgiAgCAgCgoAgIAgIAp5AQIidJ7pBGiEICAKCgCAgCAgCgoB7BITYucdQahAEBAFBQBAQBAQBQcATCAix80Q3SCMEAUFAEBAEBAFBQBBwj4AQO/cYSg2CgCAgCAgCgoAgIAh4AgEhdp7oBmmEICAICAKCgCAgCAgC7hEQYuceQ6lBEBAEBAFBQBAQBAQBTyAgxM4T3SCNEAQEAUFAEBAEBAFBwD0CQuzcYyg1CAKCgCAgCAgCgoAg4AkEhNh5ohukEYKAICAICAKCgCAgCLhHQIidewylBkFAEBAEBAFBQBAQBDyBgBA7T3SDNEIQEAQEAUFAEBAEBAH3CAixc4+h1CAICAKCgCAgCAgCgoAnEBBi54lukEYIAoKAICAICAKCgCDgHgEhdu4xlBoEAUFAEBAEBAFBQBDwBAJC7DzRDdIIQUAQEAQEAUFAEBAE3CMgxM49hlKDICAICAKCgCAgCAgCnkBAiJ0nukEaIQgIAoKAICAICAKCgHsEhNi5x1BqEAQEAUFAEBAEBAFBwBMICLHzRDdIIwQBQUAQEAQEAUFAEHCPgBA79xhKDYKAICAICAKCgCAgCHgCASF2nugGaYQgIAgIAoKAICAICALuERBi5x5DqUEQEAQEAUFAEBAEBAFPICDEzhPdII0QBAQBQUAQEAQEAUHAPQJC7NxjKDUIAoKAICAICAKCgCDgCQSE2HmiG6QRgoAgIAgIAoKAICAIuEdAiJ17DKUGQUAQEAQEAUFAEBAEPIGAEDtPdIM0QhAQBAQBQUAQEAQEAfcICLFzj6HUIAgIAoKAICAICAKCgCcQEGLniW6QRggCgoAgIAgIAoKAIOAeASF27jGUGgQBQUAQEAQEAUFAEPAEAkLsPNEN0ghBQBAQBAQBQUAQEATcIyDEzj2GUoMgIAgIAoKAICAICAKeQECInSe6QRohCAgCgoAgIAgIAoKAewQcid1fN65Ts4Hl/a6QMlVGvzRJEAQEAUFAEBAEBAFBQBCIfwT+D/zF7ZhlIKO3AAAAAElFTkSuQmCC\"}}]}],\"systemInstruction\":{\"parts\":[{\"text\":\"Read images carefully. Reply only with the visible text.\"}]},\"generationConfig\":{\"maxOutputTokens\":160,\"temperature\":0}}" + }, + "response": { + "status": 200, + "headers": { + "content-type": "text/event-stream" + }, + "body": "data: {\"candidates\": [{\"content\": {\"parts\": [{\"text\": \"jiggling restroom prison\"}],\"role\": \"model\"},\"finishReason\": \"STOP\",\"index\": 0}],\"usageMetadata\": {\"promptTokenCount\": 291,\"candidatesTokenCount\": 5,\"totalTokenCount\": 402,\"promptTokensDetails\": [{\"modality\": \"TEXT\",\"tokenCount\": 33},{\"modality\": \"IMAGE\",\"tokenCount\": 258}],\"thoughtsTokenCount\": 106,\"serviceTier\": \"standard\"},\"modelVersion\": \"gemini-2.5-flash\",\"responseId\": \"p9wMarTSBZy3_uMPvM_bGA\"}\r\n\r\n" + } + } + ] +} diff --git a/packages/llm/test/fixtures/recordings/gemini/streams-text.json b/packages/llm/test/fixtures/recordings/gemini/streams-text.json new file mode 100644 index 0000000000000000000000000000000000000000..7f0e6b390e48ad0d2123e380745bbce0d617e9b9 --- /dev/null +++ b/packages/llm/test/fixtures/recordings/gemini/streams-text.json @@ -0,0 +1,28 @@ +{ + "version": 1, + "metadata": { + "name": "gemini/streams-text", + "recordedAt": "2026-04-28T21:18:47.483Z", + "tags": ["prefix:gemini", "provider:google", "protocol:gemini"] + }, + "interactions": [ + { + "transport": "http", + "request": { + "method": "POST", + "url": "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse", + "headers": { + "content-type": "application/json" + }, + "body": "{\"contents\":[{\"role\":\"user\",\"parts\":[{\"text\":\"Reply with exactly: Hello!\"}]}],\"systemInstruction\":{\"parts\":[{\"text\":\"You are concise.\"}]},\"generationConfig\":{\"maxOutputTokens\":80,\"temperature\":0}}" + }, + "response": { + "status": 200, + "headers": { + "content-type": "text/event-stream" + }, + "body": "data: {\"candidates\": [{\"content\": {\"parts\": [{\"text\": \"Hello!\"}],\"role\": \"model\"},\"finishReason\": \"STOP\",\"index\": 0}],\"usageMetadata\": {\"promptTokenCount\": 11,\"candidatesTokenCount\": 2,\"totalTokenCount\": 29,\"promptTokensDetails\": [{\"modality\": \"TEXT\",\"tokenCount\": 11}],\"thoughtsTokenCount\": 16},\"modelVersion\": \"gemini-2.5-flash\",\"responseId\": \"NyTxaczMAZ-b_uMP6u--iQg\"}\r\n\r\n" + } + } + ] +} diff --git a/packages/llm/test/fixtures/recordings/gemini/streams-tool-call.json b/packages/llm/test/fixtures/recordings/gemini/streams-tool-call.json new file mode 100644 index 0000000000000000000000000000000000000000..a526910f0dafd841260f307bcd8ce2e9f832835e --- /dev/null +++ b/packages/llm/test/fixtures/recordings/gemini/streams-tool-call.json @@ -0,0 +1,28 @@ +{ + "version": 1, + "metadata": { + "name": "gemini/streams-tool-call", + "recordedAt": "2026-04-28T21:18:48.285Z", + "tags": ["prefix:gemini", "provider:google", "protocol:gemini", "tool"] + }, + "interactions": [ + { + "transport": "http", + "request": { + "method": "POST", + "url": "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse", + "headers": { + "content-type": "application/json" + }, + "body": "{\"contents\":[{\"role\":\"user\",\"parts\":[{\"text\":\"Call get_weather with city exactly Paris.\"}]}],\"systemInstruction\":{\"parts\":[{\"text\":\"Call tools exactly as requested.\"}]},\"tools\":[{\"functionDeclarations\":[{\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"parameters\":{\"required\":[\"city\"],\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}}}}]}],\"toolConfig\":{\"functionCallingConfig\":{\"mode\":\"ANY\",\"allowedFunctionNames\":[\"get_weather\"]}},\"generationConfig\":{\"maxOutputTokens\":80,\"temperature\":0}}" + }, + "response": { + "status": 200, + "headers": { + "content-type": "text/event-stream" + }, + "body": "data: {\"candidates\": [{\"content\": {\"parts\": [{\"functionCall\": {\"name\": \"get_weather\",\"args\": {\"city\": \"Paris\"}},\"thoughtSignature\": \"CiQBDDnWx5RcSsS1UMbykQ5HWlrMu6wrxXGUhmZ0uRKLaMhDZaEKXwEMOdbHVoJAlfbOQyKB378pDZ/gkjWr3HP+dWw1us1kMG22g4G3oJvuTq/SrWS+7KYtSlvOxCKhW2l/2/TczpyGyGmANmsusDcxF1SKOYA5/8Hg0nI24MAlT3+91V/MCoUBAQw51seClFLy3E71v2H44F1kpmjgz8FeTRZofrjbaazfrT+w8Yxgdr3UgGagLMY4OadZemQTWckq9IAqRum78hrBg6NGtQvn15SbtfTNqI4PcxX/+qPo4/g4/ZT5kVORDhVqO8BVP/RA5GQ3ce3sRK8hSkvQlXSoXIPpHh6x7hBezIGXzw==\"}],\"role\": \"model\"},\"finishReason\": \"STOP\",\"index\": 0,\"finishMessage\": \"Model generated function call(s).\"}],\"usageMetadata\": {\"promptTokenCount\": 55,\"candidatesTokenCount\": 15,\"totalTokenCount\": 115,\"promptTokensDetails\": [{\"modality\": \"TEXT\",\"tokenCount\": 55}],\"thoughtsTokenCount\": 45},\"modelVersion\": \"gemini-2.5-flash\",\"responseId\": \"NyTxaYuTJ_OW_uMPgIPKgAg\"}\r\n\r\n" + } + } + ] +} diff --git a/packages/llm/test/fixtures/recordings/openai-chat/continues-after-tool-result.json b/packages/llm/test/fixtures/recordings/openai-chat/continues-after-tool-result.json new file mode 100644 index 0000000000000000000000000000000000000000..7c02a93f0b4eab502467d6d59bcfda752967328b --- /dev/null +++ b/packages/llm/test/fixtures/recordings/openai-chat/continues-after-tool-result.json @@ -0,0 +1,28 @@ +{ + "version": 1, + "metadata": { + "name": "openai-chat/continues-after-tool-result", + "recordedAt": "2026-05-06T01:33:31.878Z", + "tags": ["prefix:openai-chat", "provider:openai", "protocol:openai-chat", "tool"] + }, + "interactions": [ + { + "transport": "http", + "request": { + "method": "POST", + "url": "https://api.openai.com/v1/chat/completions", + "headers": { + "content-type": "application/json" + }, + "body": "{\"model\":\"gpt-4o-mini\",\"messages\":[{\"role\":\"system\",\"content\":\"Answer using only the provided tool result.\"},{\"role\":\"user\",\"content\":\"What is the weather in Paris?\"},{\"role\":\"assistant\",\"content\":null,\"tool_calls\":[{\"id\":\"call_weather\",\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"arguments\":\"{\\\"city\\\":\\\"Paris\\\"}\"}}]},{\"role\":\"tool\",\"tool_call_id\":\"call_weather\",\"content\":\"{\\\"forecast\\\":\\\"sunny\\\",\\\"temperature_c\\\":22}\"}],\"stream\":true,\"stream_options\":{\"include_usage\":true},\"max_tokens\":40,\"temperature\":0}" + }, + "response": { + "status": 200, + "headers": { + "content-type": "text/event-stream; charset=utf-8" + }, + "body": "data: {\"id\":\"chatcmpl-DcLQhErGVsn8x3hNFmX5A0yM0T9Km\",\"object\":\"chat.completion.chunk\",\"created\":1778031211,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_57133166c6\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"\",\"refusal\":null},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"gJ6VDZ2ZE\"}\n\ndata: {\"id\":\"chatcmpl-DcLQhErGVsn8x3hNFmX5A0yM0T9Km\",\"object\":\"chat.completion.chunk\",\"created\":1778031211,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_57133166c6\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"The\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"B2pU6Neg\"}\n\ndata: {\"id\":\"chatcmpl-DcLQhErGVsn8x3hNFmX5A0yM0T9Km\",\"object\":\"chat.completion.chunk\",\"created\":1778031211,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_57133166c6\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" weather\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"sa2\"}\n\ndata: {\"id\":\"chatcmpl-DcLQhErGVsn8x3hNFmX5A0yM0T9Km\",\"object\":\"chat.completion.chunk\",\"created\":1778031211,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_57133166c6\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" in\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"ENFjAfta\"}\n\ndata: {\"id\":\"chatcmpl-DcLQhErGVsn8x3hNFmX5A0yM0T9Km\",\"object\":\"chat.completion.chunk\",\"created\":1778031211,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_57133166c6\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" Paris\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"E1Kbi\"}\n\ndata: {\"id\":\"chatcmpl-DcLQhErGVsn8x3hNFmX5A0yM0T9Km\",\"object\":\"chat.completion.chunk\",\"created\":1778031211,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_57133166c6\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" is\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"NWj8HasA\"}\n\ndata: {\"id\":\"chatcmpl-DcLQhErGVsn8x3hNFmX5A0yM0T9Km\",\"object\":\"chat.completion.chunk\",\"created\":1778031211,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_57133166c6\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" sunny\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"irmMg\"}\n\ndata: {\"id\":\"chatcmpl-DcLQhErGVsn8x3hNFmX5A0yM0T9Km\",\"object\":\"chat.completion.chunk\",\"created\":1778031211,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_57133166c6\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" with\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"3eCMq6\"}\n\ndata: {\"id\":\"chatcmpl-DcLQhErGVsn8x3hNFmX5A0yM0T9Km\",\"object\":\"chat.completion.chunk\",\"created\":1778031211,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_57133166c6\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" a\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"XKMqPUsnt\"}\n\ndata: {\"id\":\"chatcmpl-DcLQhErGVsn8x3hNFmX5A0yM0T9Km\",\"object\":\"chat.completion.chunk\",\"created\":1778031211,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_57133166c6\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" temperature\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"BFVrBA09z9Y3lAC\"}\n\ndata: {\"id\":\"chatcmpl-DcLQhErGVsn8x3hNFmX5A0yM0T9Km\",\"object\":\"chat.completion.chunk\",\"created\":1778031211,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_57133166c6\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" of\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"AwG4puOX\"}\n\ndata: {\"id\":\"chatcmpl-DcLQhErGVsn8x3hNFmX5A0yM0T9Km\",\"object\":\"chat.completion.chunk\",\"created\":1778031211,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_57133166c6\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" \"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"pKQU39KXN6\"}\n\ndata: {\"id\":\"chatcmpl-DcLQhErGVsn8x3hNFmX5A0yM0T9Km\",\"object\":\"chat.completion.chunk\",\"created\":1778031211,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_57133166c6\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"22\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"xeTNA1JuE\"}\n\ndata: {\"id\":\"chatcmpl-DcLQhErGVsn8x3hNFmX5A0yM0T9Km\",\"object\":\"chat.completion.chunk\",\"created\":1778031211,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_57133166c6\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"°C\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"kNilBK4Nm\"}\n\ndata: {\"id\":\"chatcmpl-DcLQhErGVsn8x3hNFmX5A0yM0T9Km\",\"object\":\"chat.completion.chunk\",\"created\":1778031211,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_57133166c6\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\".\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"BrXQlZOd1Q\"}\n\ndata: {\"id\":\"chatcmpl-DcLQhErGVsn8x3hNFmX5A0yM0T9Km\",\"object\":\"chat.completion.chunk\",\"created\":1778031211,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_57133166c6\",\"choices\":[{\"index\":0,\"delta\":{},\"logprobs\":null,\"finish_reason\":\"stop\"}],\"usage\":null,\"obfuscation\":\"lzLXy\"}\n\ndata: {\"id\":\"chatcmpl-DcLQhErGVsn8x3hNFmX5A0yM0T9Km\",\"object\":\"chat.completion.chunk\",\"created\":1778031211,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_57133166c6\",\"choices\":[],\"usage\":{\"prompt_tokens\":59,\"completion_tokens\":14,\"total_tokens\":73,\"prompt_tokens_details\":{\"cached_tokens\":0,\"audio_tokens\":0},\"completion_tokens_details\":{\"reasoning_tokens\":0,\"audio_tokens\":0,\"accepted_prediction_tokens\":0,\"rejected_prediction_tokens\":0}},\"obfuscation\":\"5z1JJjgtey\"}\n\ndata: [DONE]\n\n" + } + } + ] +} diff --git a/packages/llm/test/fixtures/recordings/openai-chat/drives-a-tool-loop-end-to-end.json b/packages/llm/test/fixtures/recordings/openai-chat/drives-a-tool-loop-end-to-end.json new file mode 100644 index 0000000000000000000000000000000000000000..fdc5fa7916b017e630e2df62a1435e6b84dd2b1c --- /dev/null +++ b/packages/llm/test/fixtures/recordings/openai-chat/drives-a-tool-loop-end-to-end.json @@ -0,0 +1,46 @@ +{ + "version": 1, + "metadata": { + "name": "openai-chat/drives-a-tool-loop-end-to-end", + "recordedAt": "2026-05-06T01:33:29.747Z", + "tags": ["prefix:openai-chat", "provider:openai", "protocol:openai-chat", "tool", "tool-loop"] + }, + "interactions": [ + { + "transport": "http", + "request": { + "method": "POST", + "url": "https://api.openai.com/v1/chat/completions", + "headers": { + "content-type": "application/json" + }, + "body": "{\"model\":\"gpt-4o-mini\",\"messages\":[{\"role\":\"system\",\"content\":\"Use the get_weather tool, then answer in one short sentence.\"},{\"role\":\"user\",\"content\":\"What is the weather in Paris?\"}],\"tools\":[{\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false}}}],\"stream\":true,\"stream_options\":{\"include_usage\":true},\"max_tokens\":80,\"temperature\":0}" + }, + "response": { + "status": 200, + "headers": { + "content-type": "text/event-stream; charset=utf-8" + }, + "body": "data: {\"id\":\"chatcmpl-DcLQeieQn9xQe2QqsLPi7rN15bnJF\",\"object\":\"chat.completion.chunk\",\"created\":1778031208,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_99cf176092\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":null,\"tool_calls\":[{\"index\":0,\"id\":\"call_tyZNHs2AudCbG4XJUEmX5Waw\",\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"arguments\":\"\"}}],\"refusal\":null},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"ayQl\"}\n\ndata: {\"id\":\"chatcmpl-DcLQeieQn9xQe2QqsLPi7rN15bnJF\",\"object\":\"chat.completion.chunk\",\"created\":1778031208,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_99cf176092\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"{\\\"\"}}]},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"TWZNUL5mYYtjWu\"}\n\ndata: {\"id\":\"chatcmpl-DcLQeieQn9xQe2QqsLPi7rN15bnJF\",\"object\":\"chat.completion.chunk\",\"created\":1778031208,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_99cf176092\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"city\"}}]},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"QidSCtgZRvDHL\"}\n\ndata: {\"id\":\"chatcmpl-DcLQeieQn9xQe2QqsLPi7rN15bnJF\",\"object\":\"chat.completion.chunk\",\"created\":1778031208,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_99cf176092\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"\\\":\\\"\"}}]},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"nupQO1L4GdWo\"}\n\ndata: {\"id\":\"chatcmpl-DcLQeieQn9xQe2QqsLPi7rN15bnJF\",\"object\":\"chat.completion.chunk\",\"created\":1778031208,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_99cf176092\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"Paris\"}}]},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"3W5B3hzGrFvl\"}\n\ndata: {\"id\":\"chatcmpl-DcLQeieQn9xQe2QqsLPi7rN15bnJF\",\"object\":\"chat.completion.chunk\",\"created\":1778031208,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_99cf176092\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"\\\"}\"}}]},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"JgscYuZR4Lmp5S\"}\n\ndata: {\"id\":\"chatcmpl-DcLQeieQn9xQe2QqsLPi7rN15bnJF\",\"object\":\"chat.completion.chunk\",\"created\":1778031208,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_99cf176092\",\"choices\":[{\"index\":0,\"delta\":{},\"logprobs\":null,\"finish_reason\":\"tool_calls\"}],\"usage\":null,\"obfuscation\":\"BtZF5TaQjX3UwLN\"}\n\ndata: {\"id\":\"chatcmpl-DcLQeieQn9xQe2QqsLPi7rN15bnJF\",\"object\":\"chat.completion.chunk\",\"created\":1778031208,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_99cf176092\",\"choices\":[],\"usage\":{\"prompt_tokens\":64,\"completion_tokens\":14,\"total_tokens\":78,\"prompt_tokens_details\":{\"cached_tokens\":0,\"audio_tokens\":0},\"completion_tokens_details\":{\"reasoning_tokens\":0,\"audio_tokens\":0,\"accepted_prediction_tokens\":0,\"rejected_prediction_tokens\":0}},\"obfuscation\":\"bZ51l7ptxM\"}\n\ndata: [DONE]\n\n" + } + }, + { + "transport": "http", + "request": { + "method": "POST", + "url": "https://api.openai.com/v1/chat/completions", + "headers": { + "content-type": "application/json" + }, + "body": "{\"model\":\"gpt-4o-mini\",\"messages\":[{\"role\":\"system\",\"content\":\"Use the get_weather tool, then answer in one short sentence.\"},{\"role\":\"user\",\"content\":\"What is the weather in Paris?\"},{\"role\":\"assistant\",\"content\":null,\"tool_calls\":[{\"id\":\"call_tyZNHs2AudCbG4XJUEmX5Waw\",\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"arguments\":\"{\\\"city\\\":\\\"Paris\\\"}\"}}]},{\"role\":\"tool\",\"tool_call_id\":\"call_tyZNHs2AudCbG4XJUEmX5Waw\",\"content\":\"{\\\"temperature\\\":22,\\\"condition\\\":\\\"sunny\\\"}\"}],\"tools\":[{\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false}}}],\"stream\":true,\"stream_options\":{\"include_usage\":true},\"max_tokens\":80,\"temperature\":0}" + }, + "response": { + "status": 200, + "headers": { + "content-type": "text/event-stream; charset=utf-8" + }, + "body": "data: {\"id\":\"chatcmpl-DcLQfUuhXefq7QDmGNhpEN5IqEKMM\",\"object\":\"chat.completion.chunk\",\"created\":1778031209,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_99cf176092\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"\",\"refusal\":null},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"SCCu2B8Ri\"}\n\ndata: {\"id\":\"chatcmpl-DcLQfUuhXefq7QDmGNhpEN5IqEKMM\",\"object\":\"chat.completion.chunk\",\"created\":1778031209,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_99cf176092\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"The\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"vuE4h8te\"}\n\ndata: {\"id\":\"chatcmpl-DcLQfUuhXefq7QDmGNhpEN5IqEKMM\",\"object\":\"chat.completion.chunk\",\"created\":1778031209,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_99cf176092\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" weather\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"uzt\"}\n\ndata: {\"id\":\"chatcmpl-DcLQfUuhXefq7QDmGNhpEN5IqEKMM\",\"object\":\"chat.completion.chunk\",\"created\":1778031209,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_99cf176092\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" in\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"4vVdGuJc\"}\n\ndata: {\"id\":\"chatcmpl-DcLQfUuhXefq7QDmGNhpEN5IqEKMM\",\"object\":\"chat.completion.chunk\",\"created\":1778031209,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_99cf176092\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" Paris\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"hAfFt\"}\n\ndata: {\"id\":\"chatcmpl-DcLQfUuhXefq7QDmGNhpEN5IqEKMM\",\"object\":\"chat.completion.chunk\",\"created\":1778031209,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_99cf176092\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" is\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"uuNXNXne\"}\n\ndata: {\"id\":\"chatcmpl-DcLQfUuhXefq7QDmGNhpEN5IqEKMM\",\"object\":\"chat.completion.chunk\",\"created\":1778031209,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_99cf176092\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" sunny\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"HRMlI\"}\n\ndata: {\"id\":\"chatcmpl-DcLQfUuhXefq7QDmGNhpEN5IqEKMM\",\"object\":\"chat.completion.chunk\",\"created\":1778031209,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_99cf176092\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" with\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"Ii1R2u\"}\n\ndata: {\"id\":\"chatcmpl-DcLQfUuhXefq7QDmGNhpEN5IqEKMM\",\"object\":\"chat.completion.chunk\",\"created\":1778031209,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_99cf176092\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" a\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"ay3ddthfT\"}\n\ndata: {\"id\":\"chatcmpl-DcLQfUuhXefq7QDmGNhpEN5IqEKMM\",\"object\":\"chat.completion.chunk\",\"created\":1778031209,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_99cf176092\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" temperature\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"PtxyVsfiluBGiWj\"}\n\ndata: {\"id\":\"chatcmpl-DcLQfUuhXefq7QDmGNhpEN5IqEKMM\",\"object\":\"chat.completion.chunk\",\"created\":1778031209,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_99cf176092\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" of\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"WuI4V7O6\"}\n\ndata: {\"id\":\"chatcmpl-DcLQfUuhXefq7QDmGNhpEN5IqEKMM\",\"object\":\"chat.completion.chunk\",\"created\":1778031209,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_99cf176092\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" \"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"Z5wHwpykrS\"}\n\ndata: {\"id\":\"chatcmpl-DcLQfUuhXefq7QDmGNhpEN5IqEKMM\",\"object\":\"chat.completion.chunk\",\"created\":1778031209,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_99cf176092\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"22\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"Fi66TTzMb\"}\n\ndata: {\"id\":\"chatcmpl-DcLQfUuhXefq7QDmGNhpEN5IqEKMM\",\"object\":\"chat.completion.chunk\",\"created\":1778031209,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_99cf176092\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"°C\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"AFnwTAm2P\"}\n\ndata: {\"id\":\"chatcmpl-DcLQfUuhXefq7QDmGNhpEN5IqEKMM\",\"object\":\"chat.completion.chunk\",\"created\":1778031209,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_99cf176092\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\".\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"xW7U4YToVK\"}\n\ndata: {\"id\":\"chatcmpl-DcLQfUuhXefq7QDmGNhpEN5IqEKMM\",\"object\":\"chat.completion.chunk\",\"created\":1778031209,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_99cf176092\",\"choices\":[{\"index\":0,\"delta\":{},\"logprobs\":null,\"finish_reason\":\"stop\"}],\"usage\":null,\"obfuscation\":\"O0Tks\"}\n\ndata: {\"id\":\"chatcmpl-DcLQfUuhXefq7QDmGNhpEN5IqEKMM\",\"object\":\"chat.completion.chunk\",\"created\":1778031209,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_99cf176092\",\"choices\":[],\"usage\":{\"prompt_tokens\":96,\"completion_tokens\":15,\"total_tokens\":111,\"prompt_tokens_details\":{\"cached_tokens\":0,\"audio_tokens\":0},\"completion_tokens_details\":{\"reasoning_tokens\":0,\"audio_tokens\":0,\"accepted_prediction_tokens\":0,\"rejected_prediction_tokens\":0}},\"obfuscation\":\"advcu5qYJ\"}\n\ndata: [DONE]\n\n" + } + } + ] +} diff --git a/packages/llm/test/fixtures/recordings/openai-chat/streams-text.json b/packages/llm/test/fixtures/recordings/openai-chat/streams-text.json new file mode 100644 index 0000000000000000000000000000000000000000..c86a29a462bdfb9d3c4e88affedcbbe7558e59cc --- /dev/null +++ b/packages/llm/test/fixtures/recordings/openai-chat/streams-text.json @@ -0,0 +1,28 @@ +{ + "version": 1, + "metadata": { + "name": "openai-chat/streams-text", + "recordedAt": "2026-05-06T01:33:30.542Z", + "tags": ["prefix:openai-chat", "provider:openai", "protocol:openai-chat"] + }, + "interactions": [ + { + "transport": "http", + "request": { + "method": "POST", + "url": "https://api.openai.com/v1/chat/completions", + "headers": { + "content-type": "application/json" + }, + "body": "{\"model\":\"gpt-4o-mini\",\"messages\":[{\"role\":\"system\",\"content\":\"You are concise.\"},{\"role\":\"user\",\"content\":\"Say hello in one short sentence.\"}],\"stream\":true,\"stream_options\":{\"include_usage\":true},\"max_tokens\":20,\"temperature\":0}" + }, + "response": { + "status": 200, + "headers": { + "content-type": "text/event-stream; charset=utf-8" + }, + "body": "data: {\"id\":\"chatcmpl-DcLQgbFetadY4JFl0fHK0g7OYsCOL\",\"object\":\"chat.completion.chunk\",\"created\":1778031210,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_57133166c6\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"\",\"refusal\":null},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"g9SWm2h6J\"}\n\ndata: {\"id\":\"chatcmpl-DcLQgbFetadY4JFl0fHK0g7OYsCOL\",\"object\":\"chat.completion.chunk\",\"created\":1778031210,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_57133166c6\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"Hello\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"lVzwlh\"}\n\ndata: {\"id\":\"chatcmpl-DcLQgbFetadY4JFl0fHK0g7OYsCOL\",\"object\":\"chat.completion.chunk\",\"created\":1778031210,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_57133166c6\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"!\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"onzhziaLGv\"}\n\ndata: {\"id\":\"chatcmpl-DcLQgbFetadY4JFl0fHK0g7OYsCOL\",\"object\":\"chat.completion.chunk\",\"created\":1778031210,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_57133166c6\",\"choices\":[{\"index\":0,\"delta\":{},\"logprobs\":null,\"finish_reason\":\"stop\"}],\"usage\":null,\"obfuscation\":\"LzUj1\"}\n\ndata: {\"id\":\"chatcmpl-DcLQgbFetadY4JFl0fHK0g7OYsCOL\",\"object\":\"chat.completion.chunk\",\"created\":1778031210,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_57133166c6\",\"choices\":[],\"usage\":{\"prompt_tokens\":22,\"completion_tokens\":2,\"total_tokens\":24,\"prompt_tokens_details\":{\"cached_tokens\":0,\"audio_tokens\":0},\"completion_tokens_details\":{\"reasoning_tokens\":0,\"audio_tokens\":0,\"accepted_prediction_tokens\":0,\"rejected_prediction_tokens\":0}},\"obfuscation\":\"emMuPcvvOkI\"}\n\ndata: [DONE]\n\n" + } + } + ] +} diff --git a/packages/llm/test/fixtures/recordings/openai-chat/streams-tool-call.json b/packages/llm/test/fixtures/recordings/openai-chat/streams-tool-call.json new file mode 100644 index 0000000000000000000000000000000000000000..fef4d8cd14a2cb66c2e04224fc2cb4eb306de92c --- /dev/null +++ b/packages/llm/test/fixtures/recordings/openai-chat/streams-tool-call.json @@ -0,0 +1,28 @@ +{ + "version": 1, + "metadata": { + "name": "openai-chat/streams-tool-call", + "recordedAt": "2026-05-06T01:33:31.127Z", + "tags": ["prefix:openai-chat", "provider:openai", "protocol:openai-chat", "tool"] + }, + "interactions": [ + { + "transport": "http", + "request": { + "method": "POST", + "url": "https://api.openai.com/v1/chat/completions", + "headers": { + "content-type": "application/json" + }, + "body": "{\"model\":\"gpt-4o-mini\",\"messages\":[{\"role\":\"system\",\"content\":\"Call tools exactly as requested.\"},{\"role\":\"user\",\"content\":\"Call get_weather with city exactly Paris.\"}],\"tools\":[{\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false}}}],\"tool_choice\":{\"type\":\"function\",\"function\":{\"name\":\"get_weather\"}},\"stream\":true,\"stream_options\":{\"include_usage\":true},\"max_tokens\":80,\"temperature\":0}" + }, + "response": { + "status": 200, + "headers": { + "content-type": "text/event-stream; charset=utf-8" + }, + "body": "data: {\"id\":\"chatcmpl-DcLQgGuIIwnMHqZMRCOwZMLir5SkK\",\"object\":\"chat.completion.chunk\",\"created\":1778031210,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_d0a1738203\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":null,\"tool_calls\":[{\"index\":0,\"id\":\"call_5wBV98AvGPwOyC6a2HtKh85w\",\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"arguments\":\"\"}}],\"refusal\":null},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"hrw8\"}\n\ndata: {\"id\":\"chatcmpl-DcLQgGuIIwnMHqZMRCOwZMLir5SkK\",\"object\":\"chat.completion.chunk\",\"created\":1778031210,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_d0a1738203\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"{\\\"\"}}]},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"MzOlaTohF20Sbb\"}\n\ndata: {\"id\":\"chatcmpl-DcLQgGuIIwnMHqZMRCOwZMLir5SkK\",\"object\":\"chat.completion.chunk\",\"created\":1778031210,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_d0a1738203\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"city\"}}]},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"QuYBQ5vYEUVxR\"}\n\ndata: {\"id\":\"chatcmpl-DcLQgGuIIwnMHqZMRCOwZMLir5SkK\",\"object\":\"chat.completion.chunk\",\"created\":1778031210,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_d0a1738203\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"\\\":\\\"\"}}]},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"spyXlsV2hl6l\"}\n\ndata: {\"id\":\"chatcmpl-DcLQgGuIIwnMHqZMRCOwZMLir5SkK\",\"object\":\"chat.completion.chunk\",\"created\":1778031210,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_d0a1738203\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"Paris\"}}]},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"Db1cjFKa6YAI\"}\n\ndata: {\"id\":\"chatcmpl-DcLQgGuIIwnMHqZMRCOwZMLir5SkK\",\"object\":\"chat.completion.chunk\",\"created\":1778031210,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_d0a1738203\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"\\\"}\"}}]},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"oPu35nrhXcjTL5\"}\n\ndata: {\"id\":\"chatcmpl-DcLQgGuIIwnMHqZMRCOwZMLir5SkK\",\"object\":\"chat.completion.chunk\",\"created\":1778031210,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_d0a1738203\",\"choices\":[{\"index\":0,\"delta\":{},\"logprobs\":null,\"finish_reason\":\"stop\"}],\"usage\":null,\"obfuscation\":\"63TVy\"}\n\ndata: {\"id\":\"chatcmpl-DcLQgGuIIwnMHqZMRCOwZMLir5SkK\",\"object\":\"chat.completion.chunk\",\"created\":1778031210,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_d0a1738203\",\"choices\":[],\"usage\":{\"prompt_tokens\":67,\"completion_tokens\":5,\"total_tokens\":72,\"prompt_tokens_details\":{\"cached_tokens\":0,\"audio_tokens\":0},\"completion_tokens_details\":{\"reasoning_tokens\":0,\"audio_tokens\":0,\"accepted_prediction_tokens\":0,\"rejected_prediction_tokens\":0}},\"obfuscation\":\"NxJjur40z4H\"}\n\ndata: [DONE]\n\n" + } + } + ] +} diff --git a/packages/llm/test/fixtures/recordings/openai-compatible-chat/deepseek-streams-text.json b/packages/llm/test/fixtures/recordings/openai-compatible-chat/deepseek-streams-text.json new file mode 100644 index 0000000000000000000000000000000000000000..a71b1121cb01f5037f1facce3276dceec15d5a44 --- /dev/null +++ b/packages/llm/test/fixtures/recordings/openai-compatible-chat/deepseek-streams-text.json @@ -0,0 +1,28 @@ +{ + "version": 1, + "metadata": { + "name": "openai-compatible-chat/deepseek-streams-text", + "recordedAt": "2026-04-28T21:18:49.498Z", + "tags": ["prefix:openai-compatible-chat", "protocol:openai-compatible-chat", "provider:deepseek"] + }, + "interactions": [ + { + "transport": "http", + "request": { + "method": "POST", + "url": "https://api.deepseek.com/v1/chat/completions", + "headers": { + "content-type": "application/json" + }, + "body": "{\"model\":\"deepseek-chat\",\"messages\":[{\"role\":\"system\",\"content\":\"You are concise.\"},{\"role\":\"user\",\"content\":\"Reply with exactly: Hello!\"}],\"stream\":true,\"stream_options\":{\"include_usage\":true},\"max_tokens\":20,\"temperature\":0}" + }, + "response": { + "status": 200, + "headers": { + "content-type": "text/event-stream; charset=utf-8" + }, + "body": "data: {\"id\":\"0c811926-1e0c-4160-baf8-6e71247c8ad7\",\"object\":\"chat.completion.chunk\",\"created\":1777411128,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_058df29938_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"\"},\"logprobs\":null,\"finish_reason\":null}]}\n\ndata: {\"id\":\"0c811926-1e0c-4160-baf8-6e71247c8ad7\",\"object\":\"chat.completion.chunk\",\"created\":1777411128,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_058df29938_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"Hello\"},\"logprobs\":null,\"finish_reason\":null}]}\n\ndata: {\"id\":\"0c811926-1e0c-4160-baf8-6e71247c8ad7\",\"object\":\"chat.completion.chunk\",\"created\":1777411128,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_058df29938_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"!\"},\"logprobs\":null,\"finish_reason\":null}]}\n\ndata: {\"id\":\"0c811926-1e0c-4160-baf8-6e71247c8ad7\",\"object\":\"chat.completion.chunk\",\"created\":1777411128,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_058df29938_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"\"},\"logprobs\":null,\"finish_reason\":\"stop\"}],\"usage\":{\"prompt_tokens\":14,\"completion_tokens\":2,\"total_tokens\":16,\"prompt_tokens_details\":{\"cached_tokens\":0},\"prompt_cache_hit_tokens\":0,\"prompt_cache_miss_tokens\":14}}\n\ndata: [DONE]\n\n" + } + } + ] +} diff --git a/packages/llm/test/fixtures/recordings/openai-compatible-chat/groq-llama-3-3-70b-drives-a-tool-loop.json b/packages/llm/test/fixtures/recordings/openai-compatible-chat/groq-llama-3-3-70b-drives-a-tool-loop.json new file mode 100644 index 0000000000000000000000000000000000000000..403260b88b2e7b3004402d9294fb668fca5bc9f6 --- /dev/null +++ b/packages/llm/test/fixtures/recordings/openai-compatible-chat/groq-llama-3-3-70b-drives-a-tool-loop.json @@ -0,0 +1,53 @@ +{ + "version": 1, + "metadata": { + "name": "openai-compatible-chat/groq-llama-3-3-70b-drives-a-tool-loop", + "recordedAt": "2026-05-06T01:35:06.032Z", + "tags": [ + "prefix:openai-compatible-chat", + "protocol:openai-compatible-chat", + "provider:groq", + "tool", + "tool-loop", + "golden" + ] + }, + "interactions": [ + { + "transport": "http", + "request": { + "method": "POST", + "url": "https://api.groq.com/openai/v1/chat/completions", + "headers": { + "content-type": "application/json" + }, + "body": "{\"model\":\"llama-3.3-70b-versatile\",\"messages\":[{\"role\":\"system\",\"content\":\"Use the get_weather tool, then answer in one short sentence.\"},{\"role\":\"user\",\"content\":\"What is the weather in Paris?\"}],\"tools\":[{\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false}}}],\"stream\":true,\"stream_options\":{\"include_usage\":true},\"max_tokens\":80,\"temperature\":0}" + }, + "response": { + "status": 200, + "headers": { + "content-type": "text/event-stream" + }, + "body": "data: {\"id\":\"chatcmpl-74a8ff95-296e-4c98-8e51-4b23d5d7f261\",\"object\":\"chat.completion.chunk\",\"created\":1778031305,\"model\":\"llama-3.3-70b-versatile\",\"system_fingerprint\":\"fp_43d97c5965\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":null},\"logprobs\":null,\"finish_reason\":null}],\"x_groq\":{\"id\":\"req_01kqxes90afm8r12en80ez1vhw\",\"seed\":1587279809}}\n\ndata: {\"id\":\"chatcmpl-74a8ff95-296e-4c98-8e51-4b23d5d7f261\",\"object\":\"chat.completion.chunk\",\"created\":1778031305,\"model\":\"llama-3.3-70b-versatile\",\"system_fingerprint\":\"fp_43d97c5965\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"id\":\"4vgxtgdfg\",\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"arguments\":\"{\\\"city\\\":\\\"Paris\\\"}\"},\"index\":0}]},\"logprobs\":null,\"finish_reason\":null}]}\n\ndata: {\"id\":\"chatcmpl-74a8ff95-296e-4c98-8e51-4b23d5d7f261\",\"object\":\"chat.completion.chunk\",\"created\":1778031305,\"model\":\"llama-3.3-70b-versatile\",\"system_fingerprint\":\"fp_43d97c5965\",\"choices\":[{\"index\":0,\"delta\":{},\"logprobs\":null,\"finish_reason\":\"tool_calls\"}],\"x_groq\":{\"id\":\"req_01kqxes90afm8r12en80ez1vhw\",\"usage\":{\"queue_time\":0.036768035,\"prompt_tokens\":237,\"prompt_time\":0.012356963,\"completion_tokens\":14,\"completion_time\":0.047052437,\"total_tokens\":251,\"total_time\":0.0594094}},\"usage\":{\"queue_time\":0.036768035,\"prompt_tokens\":237,\"prompt_time\":0.012356963,\"completion_tokens\":14,\"completion_time\":0.047052437,\"total_tokens\":251,\"total_time\":0.0594094}}\n\ndata: {\"id\":\"chatcmpl-74a8ff95-296e-4c98-8e51-4b23d5d7f261\",\"object\":\"chat.completion.chunk\",\"created\":1778031305,\"model\":\"llama-3.3-70b-versatile\",\"system_fingerprint\":\"fp_43d97c5965\",\"choices\":[],\"usage\":{\"queue_time\":0.036768035,\"prompt_tokens\":237,\"prompt_time\":0.012356963,\"completion_tokens\":14,\"completion_time\":0.047052437,\"total_tokens\":251,\"total_time\":0.0594094},\"service_tier\":\"on_demand\"}\n\ndata: [DONE]\n\n" + } + }, + { + "transport": "http", + "request": { + "method": "POST", + "url": "https://api.groq.com/openai/v1/chat/completions", + "headers": { + "content-type": "application/json" + }, + "body": "{\"model\":\"llama-3.3-70b-versatile\",\"messages\":[{\"role\":\"system\",\"content\":\"Use the get_weather tool, then answer in one short sentence.\"},{\"role\":\"user\",\"content\":\"What is the weather in Paris?\"},{\"role\":\"assistant\",\"content\":null,\"tool_calls\":[{\"id\":\"4vgxtgdfg\",\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"arguments\":\"{\\\"city\\\":\\\"Paris\\\"}\"}}]},{\"role\":\"tool\",\"tool_call_id\":\"4vgxtgdfg\",\"content\":\"{\\\"temperature\\\":22,\\\"condition\\\":\\\"sunny\\\"}\"}],\"tools\":[{\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false}}}],\"stream\":true,\"stream_options\":{\"include_usage\":true},\"max_tokens\":80,\"temperature\":0}" + }, + "response": { + "status": 200, + "headers": { + "content-type": "text/event-stream" + }, + "body": "data: {\"id\":\"chatcmpl-52c0acaf-3f4b-45c8-8aa5-93a3b6adb045\",\"object\":\"chat.completion.chunk\",\"created\":1778031305,\"model\":\"llama-3.3-70b-versatile\",\"system_fingerprint\":\"fp_43d97c5965\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"\"},\"logprobs\":null,\"finish_reason\":null}],\"x_groq\":{\"id\":\"req_01kqxes966fm8r4q94e70a83gn\",\"seed\":524268521}}\n\ndata: {\"id\":\"chatcmpl-52c0acaf-3f4b-45c8-8aa5-93a3b6adb045\",\"object\":\"chat.completion.chunk\",\"created\":1778031305,\"model\":\"llama-3.3-70b-versatile\",\"system_fingerprint\":\"fp_43d97c5965\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"The\"},\"logprobs\":null,\"finish_reason\":null}]}\n\ndata: {\"id\":\"chatcmpl-52c0acaf-3f4b-45c8-8aa5-93a3b6adb045\",\"object\":\"chat.completion.chunk\",\"created\":1778031305,\"model\":\"llama-3.3-70b-versatile\",\"system_fingerprint\":\"fp_43d97c5965\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" weather\"},\"logprobs\":null,\"finish_reason\":null}]}\n\ndata: {\"id\":\"chatcmpl-52c0acaf-3f4b-45c8-8aa5-93a3b6adb045\",\"object\":\"chat.completion.chunk\",\"created\":1778031305,\"model\":\"llama-3.3-70b-versatile\",\"system_fingerprint\":\"fp_43d97c5965\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" in\"},\"logprobs\":null,\"finish_reason\":null}]}\n\ndata: {\"id\":\"chatcmpl-52c0acaf-3f4b-45c8-8aa5-93a3b6adb045\",\"object\":\"chat.completion.chunk\",\"created\":1778031305,\"model\":\"llama-3.3-70b-versatile\",\"system_fingerprint\":\"fp_43d97c5965\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" Paris\"},\"logprobs\":null,\"finish_reason\":null}]}\n\ndata: {\"id\":\"chatcmpl-52c0acaf-3f4b-45c8-8aa5-93a3b6adb045\",\"object\":\"chat.completion.chunk\",\"created\":1778031305,\"model\":\"llama-3.3-70b-versatile\",\"system_fingerprint\":\"fp_43d97c5965\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" is\"},\"logprobs\":null,\"finish_reason\":null}]}\n\ndata: {\"id\":\"chatcmpl-52c0acaf-3f4b-45c8-8aa5-93a3b6adb045\",\"object\":\"chat.completion.chunk\",\"created\":1778031306,\"model\":\"llama-3.3-70b-versatile\",\"system_fingerprint\":\"fp_43d97c5965\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" sunny\"},\"logprobs\":null,\"finish_reason\":null}]}\n\ndata: {\"id\":\"chatcmpl-52c0acaf-3f4b-45c8-8aa5-93a3b6adb045\",\"object\":\"chat.completion.chunk\",\"created\":1778031306,\"model\":\"llama-3.3-70b-versatile\",\"system_fingerprint\":\"fp_43d97c5965\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" with\"},\"logprobs\":null,\"finish_reason\":null}]}\n\ndata: {\"id\":\"chatcmpl-52c0acaf-3f4b-45c8-8aa5-93a3b6adb045\",\"object\":\"chat.completion.chunk\",\"created\":1778031306,\"model\":\"llama-3.3-70b-versatile\",\"system_fingerprint\":\"fp_43d97c5965\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" a\"},\"logprobs\":null,\"finish_reason\":null}]}\n\ndata: {\"id\":\"chatcmpl-52c0acaf-3f4b-45c8-8aa5-93a3b6adb045\",\"object\":\"chat.completion.chunk\",\"created\":1778031306,\"model\":\"llama-3.3-70b-versatile\",\"system_fingerprint\":\"fp_43d97c5965\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" temperature\"},\"logprobs\":null,\"finish_reason\":null}]}\n\ndata: {\"id\":\"chatcmpl-52c0acaf-3f4b-45c8-8aa5-93a3b6adb045\",\"object\":\"chat.completion.chunk\",\"created\":1778031306,\"model\":\"llama-3.3-70b-versatile\",\"system_fingerprint\":\"fp_43d97c5965\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" of\"},\"logprobs\":null,\"finish_reason\":null}]}\n\ndata: {\"id\":\"chatcmpl-52c0acaf-3f4b-45c8-8aa5-93a3b6adb045\",\"object\":\"chat.completion.chunk\",\"created\":1778031306,\"model\":\"llama-3.3-70b-versatile\",\"system_fingerprint\":\"fp_43d97c5965\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" \"},\"logprobs\":null,\"finish_reason\":null}]}\n\ndata: {\"id\":\"chatcmpl-52c0acaf-3f4b-45c8-8aa5-93a3b6adb045\",\"object\":\"chat.completion.chunk\",\"created\":1778031306,\"model\":\"llama-3.3-70b-versatile\",\"system_fingerprint\":\"fp_43d97c5965\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"22\"},\"logprobs\":null,\"finish_reason\":null}]}\n\ndata: {\"id\":\"chatcmpl-52c0acaf-3f4b-45c8-8aa5-93a3b6adb045\",\"object\":\"chat.completion.chunk\",\"created\":1778031306,\"model\":\"llama-3.3-70b-versatile\",\"system_fingerprint\":\"fp_43d97c5965\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" degrees\"},\"logprobs\":null,\"finish_reason\":null}]}\n\ndata: {\"id\":\"chatcmpl-52c0acaf-3f4b-45c8-8aa5-93a3b6adb045\",\"object\":\"chat.completion.chunk\",\"created\":1778031306,\"model\":\"llama-3.3-70b-versatile\",\"system_fingerprint\":\"fp_43d97c5965\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\".\"},\"logprobs\":null,\"finish_reason\":null}]}\n\ndata: {\"id\":\"chatcmpl-52c0acaf-3f4b-45c8-8aa5-93a3b6adb045\",\"object\":\"chat.completion.chunk\",\"created\":1778031306,\"model\":\"llama-3.3-70b-versatile\",\"system_fingerprint\":\"fp_43d97c5965\",\"choices\":[{\"index\":0,\"delta\":{},\"logprobs\":null,\"finish_reason\":\"stop\"}],\"x_groq\":{\"id\":\"req_01kqxes966fm8r4q94e70a83gn\",\"usage\":{\"queue_time\":0.036680462,\"prompt_tokens\":270,\"prompt_time\":0.014468555,\"completion_tokens\":15,\"completion_time\":0.057896947,\"total_tokens\":285,\"total_time\":0.072365502}},\"usage\":{\"queue_time\":0.036680462,\"prompt_tokens\":270,\"prompt_time\":0.014468555,\"completion_tokens\":15,\"completion_time\":0.057896947,\"total_tokens\":285,\"total_time\":0.072365502}}\n\ndata: {\"id\":\"chatcmpl-52c0acaf-3f4b-45c8-8aa5-93a3b6adb045\",\"object\":\"chat.completion.chunk\",\"created\":1778031306,\"model\":\"llama-3.3-70b-versatile\",\"system_fingerprint\":\"fp_43d97c5965\",\"choices\":[],\"usage\":{\"queue_time\":0.036680462,\"prompt_tokens\":270,\"prompt_time\":0.014468555,\"completion_tokens\":15,\"completion_time\":0.057896947,\"total_tokens\":285,\"total_time\":0.072365502},\"service_tier\":\"on_demand\"}\n\ndata: [DONE]\n\n" + } + } + ] +} diff --git a/packages/llm/test/fixtures/recordings/openai-compatible-chat/groq-streams-text.json b/packages/llm/test/fixtures/recordings/openai-compatible-chat/groq-streams-text.json new file mode 100644 index 0000000000000000000000000000000000000000..561dbfda0629e7c8640428ebbf4e0a75d221acf1 --- /dev/null +++ b/packages/llm/test/fixtures/recordings/openai-compatible-chat/groq-streams-text.json @@ -0,0 +1,28 @@ +{ + "version": 1, + "metadata": { + "name": "openai-compatible-chat/groq-streams-text", + "recordedAt": "2026-05-06T01:35:05.532Z", + "tags": ["prefix:openai-compatible-chat", "protocol:openai-compatible-chat", "provider:groq"] + }, + "interactions": [ + { + "transport": "http", + "request": { + "method": "POST", + "url": "https://api.groq.com/openai/v1/chat/completions", + "headers": { + "content-type": "application/json" + }, + "body": "{\"model\":\"llama-3.3-70b-versatile\",\"messages\":[{\"role\":\"system\",\"content\":\"You are concise.\"},{\"role\":\"user\",\"content\":\"Reply with exactly: Hello!\"}],\"stream\":true,\"stream_options\":{\"include_usage\":true},\"max_tokens\":20,\"temperature\":0}" + }, + "response": { + "status": 200, + "headers": { + "content-type": "text/event-stream" + }, + "body": "data: {\"id\":\"chatcmpl-dd5aae9f-7032-44a7-aca8-01027903b4c9\",\"object\":\"chat.completion.chunk\",\"created\":1778031305,\"model\":\"llama-3.3-70b-versatile\",\"system_fingerprint\":\"fp_d42c28f9ce\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"\"},\"logprobs\":null,\"finish_reason\":null}],\"x_groq\":{\"id\":\"req_01kqxes8r3fmja0yhxvt665m6h\",\"seed\":687314058}}\n\ndata: {\"id\":\"chatcmpl-dd5aae9f-7032-44a7-aca8-01027903b4c9\",\"object\":\"chat.completion.chunk\",\"created\":1778031305,\"model\":\"llama-3.3-70b-versatile\",\"system_fingerprint\":\"fp_d42c28f9ce\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"Hello\"},\"logprobs\":null,\"finish_reason\":null}]}\n\ndata: {\"id\":\"chatcmpl-dd5aae9f-7032-44a7-aca8-01027903b4c9\",\"object\":\"chat.completion.chunk\",\"created\":1778031305,\"model\":\"llama-3.3-70b-versatile\",\"system_fingerprint\":\"fp_d42c28f9ce\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"!\"},\"logprobs\":null,\"finish_reason\":null}]}\n\ndata: {\"id\":\"chatcmpl-dd5aae9f-7032-44a7-aca8-01027903b4c9\",\"object\":\"chat.completion.chunk\",\"created\":1778031305,\"model\":\"llama-3.3-70b-versatile\",\"system_fingerprint\":\"fp_d42c28f9ce\",\"choices\":[{\"index\":0,\"delta\":{},\"logprobs\":null,\"finish_reason\":\"stop\"}],\"x_groq\":{\"id\":\"req_01kqxes8r3fmja0yhxvt665m6h\",\"usage\":{\"queue_time\":0.0381395,\"prompt_tokens\":45,\"prompt_time\":0.003985297,\"completion_tokens\":3,\"completion_time\":0.014171875,\"total_tokens\":48,\"total_time\":0.018157172}},\"usage\":{\"queue_time\":0.0381395,\"prompt_tokens\":45,\"prompt_time\":0.003985297,\"completion_tokens\":3,\"completion_time\":0.014171875,\"total_tokens\":48,\"total_time\":0.018157172}}\n\ndata: {\"id\":\"chatcmpl-dd5aae9f-7032-44a7-aca8-01027903b4c9\",\"object\":\"chat.completion.chunk\",\"created\":1778031305,\"model\":\"llama-3.3-70b-versatile\",\"system_fingerprint\":\"fp_d42c28f9ce\",\"choices\":[],\"usage\":{\"queue_time\":0.0381395,\"prompt_tokens\":45,\"prompt_time\":0.003985297,\"completion_tokens\":3,\"completion_time\":0.014171875,\"total_tokens\":48,\"total_time\":0.018157172},\"service_tier\":\"on_demand\"}\n\ndata: [DONE]\n\n" + } + } + ] +} diff --git a/packages/llm/test/fixtures/recordings/openai-compatible-chat/groq-streams-tool-call.json b/packages/llm/test/fixtures/recordings/openai-compatible-chat/groq-streams-tool-call.json new file mode 100644 index 0000000000000000000000000000000000000000..70e9a765d281af7ba78a5f3e5d5cf7497e5a2b4f --- /dev/null +++ b/packages/llm/test/fixtures/recordings/openai-compatible-chat/groq-streams-tool-call.json @@ -0,0 +1,28 @@ +{ + "version": 1, + "metadata": { + "name": "openai-compatible-chat/groq-streams-tool-call", + "recordedAt": "2026-05-06T01:35:05.706Z", + "tags": ["prefix:openai-compatible-chat", "protocol:openai-compatible-chat", "provider:groq", "tool"] + }, + "interactions": [ + { + "transport": "http", + "request": { + "method": "POST", + "url": "https://api.groq.com/openai/v1/chat/completions", + "headers": { + "content-type": "application/json" + }, + "body": "{\"model\":\"llama-3.3-70b-versatile\",\"messages\":[{\"role\":\"system\",\"content\":\"Call tools exactly as requested.\"},{\"role\":\"user\",\"content\":\"Call get_weather with city exactly Paris.\"}],\"tools\":[{\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false}}}],\"tool_choice\":{\"type\":\"function\",\"function\":{\"name\":\"get_weather\"}},\"stream\":true,\"stream_options\":{\"include_usage\":true},\"max_tokens\":80,\"temperature\":0}" + }, + "response": { + "status": 200, + "headers": { + "content-type": "text/event-stream" + }, + "body": "data: {\"id\":\"chatcmpl-05380361-f8e4-444a-ae80-296b4d1d46f7\",\"object\":\"chat.completion.chunk\",\"created\":1778031305,\"model\":\"llama-3.3-70b-versatile\",\"system_fingerprint\":\"fp_0761e44d7b\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":null},\"logprobs\":null,\"finish_reason\":null}],\"x_groq\":{\"id\":\"req_01kqxes8v4fm7baf4smt42f0qn\",\"seed\":1846647562}}\n\ndata: {\"id\":\"chatcmpl-05380361-f8e4-444a-ae80-296b4d1d46f7\",\"object\":\"chat.completion.chunk\",\"created\":1778031305,\"model\":\"llama-3.3-70b-versatile\",\"system_fingerprint\":\"fp_0761e44d7b\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"id\":\"mcf2d8nn1\",\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"arguments\":\"{\\\"city\\\":\\\"Paris\\\"}\"},\"index\":0}]},\"logprobs\":null,\"finish_reason\":null}]}\n\ndata: {\"id\":\"chatcmpl-05380361-f8e4-444a-ae80-296b4d1d46f7\",\"object\":\"chat.completion.chunk\",\"created\":1778031305,\"model\":\"llama-3.3-70b-versatile\",\"system_fingerprint\":\"fp_0761e44d7b\",\"choices\":[{\"index\":0,\"delta\":{},\"logprobs\":null,\"finish_reason\":\"tool_calls\"}],\"x_groq\":{\"id\":\"req_01kqxes8v4fm7baf4smt42f0qn\",\"usage\":{\"queue_time\":0.07684935,\"prompt_tokens\":249,\"prompt_time\":0.014815006,\"completion_tokens\":10,\"completion_time\":0.036435756,\"total_tokens\":259,\"total_time\":0.051250762}},\"usage\":{\"queue_time\":0.07684935,\"prompt_tokens\":249,\"prompt_time\":0.014815006,\"completion_tokens\":10,\"completion_time\":0.036435756,\"total_tokens\":259,\"total_time\":0.051250762}}\n\ndata: {\"id\":\"chatcmpl-05380361-f8e4-444a-ae80-296b4d1d46f7\",\"object\":\"chat.completion.chunk\",\"created\":1778031305,\"model\":\"llama-3.3-70b-versatile\",\"system_fingerprint\":\"fp_0761e44d7b\",\"choices\":[],\"usage\":{\"queue_time\":0.07684935,\"prompt_tokens\":249,\"prompt_time\":0.014815006,\"completion_tokens\":10,\"completion_time\":0.036435756,\"total_tokens\":259,\"total_time\":0.051250762},\"service_tier\":\"on_demand\"}\n\ndata: [DONE]\n\n" + } + } + ] +} diff --git a/packages/llm/test/fixtures/recordings/openai-compatible-chat/openrouter-claude-opus-4-7-drives-a-tool-loop.json b/packages/llm/test/fixtures/recordings/openai-compatible-chat/openrouter-claude-opus-4-7-drives-a-tool-loop.json new file mode 100644 index 0000000000000000000000000000000000000000..e67d280678c394760987e46d027393c628773126 --- /dev/null +++ b/packages/llm/test/fixtures/recordings/openai-compatible-chat/openrouter-claude-opus-4-7-drives-a-tool-loop.json @@ -0,0 +1,54 @@ +{ + "version": 1, + "metadata": { + "name": "openai-compatible-chat/openrouter-claude-opus-4-7-drives-a-tool-loop", + "recordedAt": "2026-05-06T01:35:14.282Z", + "tags": [ + "prefix:openai-compatible-chat", + "protocol:openai-compatible-chat", + "provider:openrouter", + "tool", + "tool-loop", + "golden", + "flagship" + ] + }, + "interactions": [ + { + "transport": "http", + "request": { + "method": "POST", + "url": "https://openrouter.ai/api/v1/chat/completions", + "headers": { + "content-type": "application/json" + }, + "body": "{\"model\":\"anthropic/claude-opus-4.7\",\"messages\":[{\"role\":\"system\",\"content\":\"Use the get_weather tool exactly once, then answer in one short sentence.\"},{\"role\":\"user\",\"content\":\"What is the weather in Paris?\"}],\"tools\":[{\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false}}}],\"stream\":true,\"stream_options\":{\"include_usage\":true},\"max_tokens\":80,\"temperature\":0}" + }, + "response": { + "status": 200, + "headers": { + "content-type": "text/event-stream" + }, + "body": ": OPENROUTER PROCESSING\n\n: OPENROUTER PROCESSING\n\n: OPENROUTER PROCESSING\n\ndata: {\"id\":\"gen-1778031311-S3NlfYGRwAnOoPoNrThK\",\"object\":\"chat.completion.chunk\",\"created\":1778031311,\"model\":\"anthropic/claude-4.7-opus-20260416\",\"provider\":\"Amazon Bedrock\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"role\":\"assistant\",\"tool_calls\":[{\"index\":0,\"id\":\"toolu_bdrk_01AVRkzbigpMbNJ3zjnuQ6ZE\",\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"arguments\":\"\"}}]},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1778031311-S3NlfYGRwAnOoPoNrThK\",\"object\":\"chat.completion.chunk\",\"created\":1778031311,\"model\":\"anthropic/claude-4.7-opus-20260416\",\"provider\":\"Amazon Bedrock\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"role\":\"assistant\",\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"\"}}]},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1778031311-S3NlfYGRwAnOoPoNrThK\",\"object\":\"chat.completion.chunk\",\"created\":1778031311,\"model\":\"anthropic/claude-4.7-opus-20260416\",\"provider\":\"Amazon Bedrock\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"role\":\"assistant\",\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"\"}}]},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1778031311-S3NlfYGRwAnOoPoNrThK\",\"object\":\"chat.completion.chunk\",\"created\":1778031311,\"model\":\"anthropic/claude-4.7-opus-20260416\",\"provider\":\"Amazon Bedrock\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"role\":\"assistant\",\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"{\\\"city\\\": \\\"P\"}}]},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1778031311-S3NlfYGRwAnOoPoNrThK\",\"object\":\"chat.completion.chunk\",\"created\":1778031311,\"model\":\"anthropic/claude-4.7-opus-20260416\",\"provider\":\"Amazon Bedrock\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"role\":\"assistant\",\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"ari\"}}]},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1778031311-S3NlfYGRwAnOoPoNrThK\",\"object\":\"chat.completion.chunk\",\"created\":1778031311,\"model\":\"anthropic/claude-4.7-opus-20260416\",\"provider\":\"Amazon Bedrock\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"role\":\"assistant\",\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"s\\\"}\"}}]},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1778031311-S3NlfYGRwAnOoPoNrThK\",\"object\":\"chat.completion.chunk\",\"created\":1778031311,\"model\":\"anthropic/claude-4.7-opus-20260416\",\"provider\":\"Amazon Bedrock\",\"service_tier\":\"standard\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"\",\"role\":\"assistant\"},\"finish_reason\":\"tool_calls\",\"native_finish_reason\":\"tool_use\"}]}\n\ndata: {\"id\":\"gen-1778031311-S3NlfYGRwAnOoPoNrThK\",\"object\":\"chat.completion.chunk\",\"created\":1778031311,\"model\":\"anthropic/claude-4.7-opus-20260416\",\"provider\":\"Amazon Bedrock\",\"service_tier\":\"standard\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"\",\"role\":\"assistant\"},\"finish_reason\":\"tool_calls\",\"native_finish_reason\":\"tool_use\"}],\"usage\":{\"prompt_tokens\":802,\"completion_tokens\":66,\"total_tokens\":868,\"cost\":0.00566,\"is_byok\":false,\"prompt_tokens_details\":{\"cached_tokens\":0,\"cache_write_tokens\":0,\"audio_tokens\":0,\"video_tokens\":0},\"cost_details\":{\"upstream_inference_cost\":0.00566,\"upstream_inference_prompt_cost\":0.00401,\"upstream_inference_completions_cost\":0.00165},\"completion_tokens_details\":{\"reasoning_tokens\":0,\"image_tokens\":0,\"audio_tokens\":0}}}\n\ndata: [DONE]\n\n" + } + }, + { + "transport": "http", + "request": { + "method": "POST", + "url": "https://openrouter.ai/api/v1/chat/completions", + "headers": { + "content-type": "application/json" + }, + "body": "{\"model\":\"anthropic/claude-opus-4.7\",\"messages\":[{\"role\":\"system\",\"content\":\"Use the get_weather tool exactly once, then answer in one short sentence.\"},{\"role\":\"user\",\"content\":\"What is the weather in Paris?\"},{\"role\":\"assistant\",\"content\":null,\"tool_calls\":[{\"id\":\"toolu_bdrk_01AVRkzbigpMbNJ3zjnuQ6ZE\",\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"arguments\":\"{\\\"city\\\":\\\"Paris\\\"}\"}}]},{\"role\":\"tool\",\"tool_call_id\":\"toolu_bdrk_01AVRkzbigpMbNJ3zjnuQ6ZE\",\"content\":\"{\\\"temperature\\\":22,\\\"condition\\\":\\\"sunny\\\"}\"}],\"tools\":[{\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false}}}],\"stream\":true,\"stream_options\":{\"include_usage\":true},\"max_tokens\":80,\"temperature\":0}" + }, + "response": { + "status": 200, + "headers": { + "content-type": "text/event-stream" + }, + "body": ": OPENROUTER PROCESSING\n\ndata: {\"id\":\"gen-1778031313-XM4XZGmFyt6jg3GZ772w\",\"object\":\"chat.completion.chunk\",\"created\":1778031313,\"model\":\"anthropic/claude-4.7-opus-20260416\",\"provider\":\"Amazon Bedrock\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"It\",\"role\":\"assistant\"},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1778031313-XM4XZGmFyt6jg3GZ772w\",\"object\":\"chat.completion.chunk\",\"created\":1778031313,\"model\":\"anthropic/claude-4.7-opus-20260416\",\"provider\":\"Amazon Bedrock\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"'s sunny and\",\"role\":\"assistant\"},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1778031313-XM4XZGmFyt6jg3GZ772w\",\"object\":\"chat.completion.chunk\",\"created\":1778031313,\"model\":\"anthropic/claude-4.7-opus-20260416\",\"provider\":\"Amazon Bedrock\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" 22°C in\",\"role\":\"assistant\"},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1778031313-XM4XZGmFyt6jg3GZ772w\",\"object\":\"chat.completion.chunk\",\"created\":1778031313,\"model\":\"anthropic/claude-4.7-opus-20260416\",\"provider\":\"Amazon Bedrock\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" Paris.\",\"role\":\"assistant\"},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1778031313-XM4XZGmFyt6jg3GZ772w\",\"object\":\"chat.completion.chunk\",\"created\":1778031313,\"model\":\"anthropic/claude-4.7-opus-20260416\",\"provider\":\"Amazon Bedrock\",\"service_tier\":\"standard\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"\",\"role\":\"assistant\"},\"finish_reason\":\"stop\",\"native_finish_reason\":\"end_turn\"}]}\n\ndata: {\"id\":\"gen-1778031313-XM4XZGmFyt6jg3GZ772w\",\"object\":\"chat.completion.chunk\",\"created\":1778031313,\"model\":\"anthropic/claude-4.7-opus-20260416\",\"provider\":\"Amazon Bedrock\",\"service_tier\":\"standard\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"\",\"role\":\"assistant\"},\"finish_reason\":\"stop\",\"native_finish_reason\":\"end_turn\"}],\"usage\":{\"prompt_tokens\":899,\"completion_tokens\":19,\"total_tokens\":918,\"cost\":0.00497,\"is_byok\":false,\"prompt_tokens_details\":{\"cached_tokens\":0,\"cache_write_tokens\":0,\"audio_tokens\":0,\"video_tokens\":0},\"cost_details\":{\"upstream_inference_cost\":0.00497,\"upstream_inference_prompt_cost\":0.004495,\"upstream_inference_completions_cost\":0.000475},\"completion_tokens_details\":{\"reasoning_tokens\":0,\"image_tokens\":0,\"audio_tokens\":0}}}\n\ndata: [DONE]\n\n" + } + } + ] +} diff --git a/packages/llm/test/fixtures/recordings/openai-compatible-chat/openrouter-gpt-4o-mini-drives-a-tool-loop.json b/packages/llm/test/fixtures/recordings/openai-compatible-chat/openrouter-gpt-4o-mini-drives-a-tool-loop.json new file mode 100644 index 0000000000000000000000000000000000000000..7883285e581abd2d2e0e52fb08096ffb38e5ace4 --- /dev/null +++ b/packages/llm/test/fixtures/recordings/openai-compatible-chat/openrouter-gpt-4o-mini-drives-a-tool-loop.json @@ -0,0 +1,53 @@ +{ + "version": 1, + "metadata": { + "name": "openai-compatible-chat/openrouter-gpt-4o-mini-drives-a-tool-loop", + "recordedAt": "2026-05-06T01:35:08.922Z", + "tags": [ + "prefix:openai-compatible-chat", + "protocol:openai-compatible-chat", + "provider:openrouter", + "tool", + "tool-loop", + "golden" + ] + }, + "interactions": [ + { + "transport": "http", + "request": { + "method": "POST", + "url": "https://openrouter.ai/api/v1/chat/completions", + "headers": { + "content-type": "application/json" + }, + "body": "{\"model\":\"openai/gpt-4o-mini\",\"messages\":[{\"role\":\"system\",\"content\":\"Use the get_weather tool exactly once, then answer in one short sentence.\"},{\"role\":\"user\",\"content\":\"What is the weather in Paris?\"}],\"tools\":[{\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false}}}],\"stream\":true,\"stream_options\":{\"include_usage\":true},\"max_tokens\":80,\"temperature\":0}" + }, + "response": { + "status": 200, + "headers": { + "content-type": "text/event-stream" + }, + "body": ": OPENROUTER PROCESSING\n\ndata: {\"id\":\"gen-1778031307-FcHCDYW9unDVyRRL841T\",\"object\":\"chat.completion.chunk\",\"created\":1778031307,\"model\":\"openai/gpt-4o-mini\",\"provider\":\"OpenAI\",\"system_fingerprint\":\"fp_7e69b4ef44\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"role\":\"assistant\",\"tool_calls\":[{\"index\":0,\"id\":\"call_S63bjYITINemSHZ4Uqns7PIu\",\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"arguments\":\"\"}}]},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1778031307-FcHCDYW9unDVyRRL841T\",\"object\":\"chat.completion.chunk\",\"created\":1778031307,\"model\":\"openai/gpt-4o-mini\",\"provider\":\"OpenAI\",\"system_fingerprint\":\"fp_7e69b4ef44\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"role\":\"assistant\",\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"\"}}]},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1778031307-FcHCDYW9unDVyRRL841T\",\"object\":\"chat.completion.chunk\",\"created\":1778031307,\"model\":\"openai/gpt-4o-mini\",\"provider\":\"OpenAI\",\"system_fingerprint\":\"fp_7e69b4ef44\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"role\":\"assistant\",\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"{\\\"\"}}]},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1778031307-FcHCDYW9unDVyRRL841T\",\"object\":\"chat.completion.chunk\",\"created\":1778031307,\"model\":\"openai/gpt-4o-mini\",\"provider\":\"OpenAI\",\"system_fingerprint\":\"fp_7e69b4ef44\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"role\":\"assistant\",\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"city\"}}]},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1778031307-FcHCDYW9unDVyRRL841T\",\"object\":\"chat.completion.chunk\",\"created\":1778031307,\"model\":\"openai/gpt-4o-mini\",\"provider\":\"OpenAI\",\"system_fingerprint\":\"fp_7e69b4ef44\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"role\":\"assistant\",\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"\\\":\\\"\"}}]},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1778031307-FcHCDYW9unDVyRRL841T\",\"object\":\"chat.completion.chunk\",\"created\":1778031307,\"model\":\"openai/gpt-4o-mini\",\"provider\":\"OpenAI\",\"system_fingerprint\":\"fp_7e69b4ef44\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"role\":\"assistant\",\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"Paris\"}}]},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1778031307-FcHCDYW9unDVyRRL841T\",\"object\":\"chat.completion.chunk\",\"created\":1778031307,\"model\":\"openai/gpt-4o-mini\",\"provider\":\"OpenAI\",\"system_fingerprint\":\"fp_7e69b4ef44\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"role\":\"assistant\",\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"\\\"}\"}}]},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1778031307-FcHCDYW9unDVyRRL841T\",\"object\":\"chat.completion.chunk\",\"created\":1778031307,\"model\":\"openai/gpt-4o-mini\",\"provider\":\"OpenAI\",\"system_fingerprint\":\"fp_7e69b4ef44\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"\",\"role\":\"assistant\"},\"finish_reason\":\"tool_calls\",\"native_finish_reason\":\"tool_calls\"}]}\n\ndata: {\"id\":\"gen-1778031307-FcHCDYW9unDVyRRL841T\",\"object\":\"chat.completion.chunk\",\"created\":1778031307,\"model\":\"openai/gpt-4o-mini\",\"provider\":\"OpenAI\",\"system_fingerprint\":\"fp_7e69b4ef44\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"\",\"role\":\"assistant\"},\"finish_reason\":\"tool_calls\",\"native_finish_reason\":\"tool_calls\"}],\"usage\":{\"prompt_tokens\":66,\"completion_tokens\":14,\"total_tokens\":80,\"cost\":0.0000183,\"is_byok\":false,\"prompt_tokens_details\":{\"cached_tokens\":0,\"cache_write_tokens\":0,\"audio_tokens\":0,\"video_tokens\":0},\"cost_details\":{\"upstream_inference_cost\":0.0000183,\"upstream_inference_prompt_cost\":0.0000099,\"upstream_inference_completions_cost\":0.0000084},\"completion_tokens_details\":{\"reasoning_tokens\":0,\"image_tokens\":0,\"audio_tokens\":0}}}\n\ndata: [DONE]\n\n" + } + }, + { + "transport": "http", + "request": { + "method": "POST", + "url": "https://openrouter.ai/api/v1/chat/completions", + "headers": { + "content-type": "application/json" + }, + "body": "{\"model\":\"openai/gpt-4o-mini\",\"messages\":[{\"role\":\"system\",\"content\":\"Use the get_weather tool exactly once, then answer in one short sentence.\"},{\"role\":\"user\",\"content\":\"What is the weather in Paris?\"},{\"role\":\"assistant\",\"content\":null,\"tool_calls\":[{\"id\":\"call_S63bjYITINemSHZ4Uqns7PIu\",\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"arguments\":\"{\\\"city\\\":\\\"Paris\\\"}\"}}]},{\"role\":\"tool\",\"tool_call_id\":\"call_S63bjYITINemSHZ4Uqns7PIu\",\"content\":\"{\\\"temperature\\\":22,\\\"condition\\\":\\\"sunny\\\"}\"}],\"tools\":[{\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false}}}],\"stream\":true,\"stream_options\":{\"include_usage\":true},\"max_tokens\":80,\"temperature\":0}" + }, + "response": { + "status": 200, + "headers": { + "content-type": "text/event-stream" + }, + "body": ": OPENROUTER PROCESSING\n\ndata: {\"id\":\"gen-1778031308-uNHYY6MdDXOs0BYMXXVb\",\"object\":\"chat.completion.chunk\",\"created\":1778031308,\"model\":\"openai/gpt-4o-mini\",\"provider\":\"OpenAI\",\"system_fingerprint\":\"fp_7e69b4ef44\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"The\",\"role\":\"assistant\"},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1778031308-uNHYY6MdDXOs0BYMXXVb\",\"object\":\"chat.completion.chunk\",\"created\":1778031308,\"model\":\"openai/gpt-4o-mini\",\"provider\":\"OpenAI\",\"system_fingerprint\":\"fp_7e69b4ef44\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" weather\",\"role\":\"assistant\"},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1778031308-uNHYY6MdDXOs0BYMXXVb\",\"object\":\"chat.completion.chunk\",\"created\":1778031308,\"model\":\"openai/gpt-4o-mini\",\"provider\":\"OpenAI\",\"system_fingerprint\":\"fp_7e69b4ef44\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" in\",\"role\":\"assistant\"},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1778031308-uNHYY6MdDXOs0BYMXXVb\",\"object\":\"chat.completion.chunk\",\"created\":1778031308,\"model\":\"openai/gpt-4o-mini\",\"provider\":\"OpenAI\",\"system_fingerprint\":\"fp_7e69b4ef44\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" Paris\",\"role\":\"assistant\"},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1778031308-uNHYY6MdDXOs0BYMXXVb\",\"object\":\"chat.completion.chunk\",\"created\":1778031308,\"model\":\"openai/gpt-4o-mini\",\"provider\":\"OpenAI\",\"system_fingerprint\":\"fp_7e69b4ef44\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" is\",\"role\":\"assistant\"},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1778031308-uNHYY6MdDXOs0BYMXXVb\",\"object\":\"chat.completion.chunk\",\"created\":1778031308,\"model\":\"openai/gpt-4o-mini\",\"provider\":\"OpenAI\",\"system_fingerprint\":\"fp_7e69b4ef44\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" sunny\",\"role\":\"assistant\"},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1778031308-uNHYY6MdDXOs0BYMXXVb\",\"object\":\"chat.completion.chunk\",\"created\":1778031308,\"model\":\"openai/gpt-4o-mini\",\"provider\":\"OpenAI\",\"system_fingerprint\":\"fp_7e69b4ef44\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" with\",\"role\":\"assistant\"},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1778031308-uNHYY6MdDXOs0BYMXXVb\",\"object\":\"chat.completion.chunk\",\"created\":1778031308,\"model\":\"openai/gpt-4o-mini\",\"provider\":\"OpenAI\",\"system_fingerprint\":\"fp_7e69b4ef44\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" a\",\"role\":\"assistant\"},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1778031308-uNHYY6MdDXOs0BYMXXVb\",\"object\":\"chat.completion.chunk\",\"created\":1778031308,\"model\":\"openai/gpt-4o-mini\",\"provider\":\"OpenAI\",\"system_fingerprint\":\"fp_7e69b4ef44\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" temperature\",\"role\":\"assistant\"},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1778031308-uNHYY6MdDXOs0BYMXXVb\",\"object\":\"chat.completion.chunk\",\"created\":1778031308,\"model\":\"openai/gpt-4o-mini\",\"provider\":\"OpenAI\",\"system_fingerprint\":\"fp_7e69b4ef44\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" of\",\"role\":\"assistant\"},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1778031308-uNHYY6MdDXOs0BYMXXVb\",\"object\":\"chat.completion.chunk\",\"created\":1778031308,\"model\":\"openai/gpt-4o-mini\",\"provider\":\"OpenAI\",\"system_fingerprint\":\"fp_7e69b4ef44\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" \",\"role\":\"assistant\"},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1778031308-uNHYY6MdDXOs0BYMXXVb\",\"object\":\"chat.completion.chunk\",\"created\":1778031308,\"model\":\"openai/gpt-4o-mini\",\"provider\":\"OpenAI\",\"system_fingerprint\":\"fp_7e69b4ef44\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"22\",\"role\":\"assistant\"},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1778031308-uNHYY6MdDXOs0BYMXXVb\",\"object\":\"chat.completion.chunk\",\"created\":1778031308,\"model\":\"openai/gpt-4o-mini\",\"provider\":\"OpenAI\",\"system_fingerprint\":\"fp_7e69b4ef44\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"°C\",\"role\":\"assistant\"},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1778031308-uNHYY6MdDXOs0BYMXXVb\",\"object\":\"chat.completion.chunk\",\"created\":1778031308,\"model\":\"openai/gpt-4o-mini\",\"provider\":\"OpenAI\",\"system_fingerprint\":\"fp_7e69b4ef44\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\".\",\"role\":\"assistant\"},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1778031308-uNHYY6MdDXOs0BYMXXVb\",\"object\":\"chat.completion.chunk\",\"created\":1778031308,\"model\":\"openai/gpt-4o-mini\",\"provider\":\"OpenAI\",\"system_fingerprint\":\"fp_7e69b4ef44\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"\",\"role\":\"assistant\"},\"finish_reason\":\"stop\",\"native_finish_reason\":\"stop\"}]}\n\ndata: {\"id\":\"gen-1778031308-uNHYY6MdDXOs0BYMXXVb\",\"object\":\"chat.completion.chunk\",\"created\":1778031308,\"model\":\"openai/gpt-4o-mini\",\"provider\":\"OpenAI\",\"system_fingerprint\":\"fp_7e69b4ef44\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"\",\"role\":\"assistant\"},\"finish_reason\":\"stop\",\"native_finish_reason\":\"stop\"}],\"usage\":{\"prompt_tokens\":98,\"completion_tokens\":15,\"total_tokens\":113,\"cost\":0.0000237,\"is_byok\":false,\"prompt_tokens_details\":{\"cached_tokens\":0,\"cache_write_tokens\":0,\"audio_tokens\":0,\"video_tokens\":0},\"cost_details\":{\"upstream_inference_cost\":0.0000237,\"upstream_inference_prompt_cost\":0.0000147,\"upstream_inference_completions_cost\":0.000009},\"completion_tokens_details\":{\"reasoning_tokens\":0,\"image_tokens\":0,\"audio_tokens\":0}}}\n\ndata: [DONE]\n\n" + } + } + ] +} diff --git a/packages/llm/test/fixtures/recordings/openai-compatible-chat/openrouter-gpt-5-5-drives-a-tool-loop.json b/packages/llm/test/fixtures/recordings/openai-compatible-chat/openrouter-gpt-5-5-drives-a-tool-loop.json new file mode 100644 index 0000000000000000000000000000000000000000..e1cbab70faac0358ae359b2892ea9d81dcd4c6a6 --- /dev/null +++ b/packages/llm/test/fixtures/recordings/openai-compatible-chat/openrouter-gpt-5-5-drives-a-tool-loop.json @@ -0,0 +1,54 @@ +{ + "version": 1, + "metadata": { + "name": "openai-compatible-chat/openrouter-gpt-5-5-drives-a-tool-loop", + "recordedAt": "2026-05-06T01:35:11.662Z", + "tags": [ + "prefix:openai-compatible-chat", + "protocol:openai-compatible-chat", + "provider:openrouter", + "tool", + "tool-loop", + "golden", + "flagship" + ] + }, + "interactions": [ + { + "transport": "http", + "request": { + "method": "POST", + "url": "https://openrouter.ai/api/v1/chat/completions", + "headers": { + "content-type": "application/json" + }, + "body": "{\"model\":\"openai/gpt-5.5\",\"messages\":[{\"role\":\"system\",\"content\":\"Use the get_weather tool exactly once, then answer in one short sentence.\"},{\"role\":\"user\",\"content\":\"What is the weather in Paris?\"}],\"tools\":[{\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false}}}],\"stream\":true,\"stream_options\":{\"include_usage\":true},\"max_tokens\":80,\"temperature\":0}" + }, + "response": { + "status": 200, + "headers": { + "content-type": "text/event-stream" + }, + "body": ": OPENROUTER PROCESSING\n\n: OPENROUTER PROCESSING\n\ndata: {\"id\":\"gen-1778031308-dVa9axcHcOlG9GcilZkz\",\"object\":\"chat.completion.chunk\",\"created\":1778031308,\"model\":\"openai/gpt-5.5-20260423\",\"provider\":\"OpenAI\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"role\":\"assistant\",\"tool_calls\":[{\"index\":0,\"id\":\"call_4A7V7UN36HXCUUn8qAOQaKGw\",\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"arguments\":\"\"}}]},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1778031308-dVa9axcHcOlG9GcilZkz\",\"object\":\"chat.completion.chunk\",\"created\":1778031308,\"model\":\"openai/gpt-5.5-20260423\",\"provider\":\"OpenAI\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"role\":\"assistant\",\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"\"}}]},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1778031308-dVa9axcHcOlG9GcilZkz\",\"object\":\"chat.completion.chunk\",\"created\":1778031308,\"model\":\"openai/gpt-5.5-20260423\",\"provider\":\"OpenAI\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"role\":\"assistant\",\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"{\\\"\"}}]},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1778031308-dVa9axcHcOlG9GcilZkz\",\"object\":\"chat.completion.chunk\",\"created\":1778031308,\"model\":\"openai/gpt-5.5-20260423\",\"provider\":\"OpenAI\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"role\":\"assistant\",\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"city\"}}]},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1778031308-dVa9axcHcOlG9GcilZkz\",\"object\":\"chat.completion.chunk\",\"created\":1778031308,\"model\":\"openai/gpt-5.5-20260423\",\"provider\":\"OpenAI\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"role\":\"assistant\",\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"\\\":\\\"\"}}]},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1778031308-dVa9axcHcOlG9GcilZkz\",\"object\":\"chat.completion.chunk\",\"created\":1778031308,\"model\":\"openai/gpt-5.5-20260423\",\"provider\":\"OpenAI\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"role\":\"assistant\",\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"Paris\"}}]},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1778031308-dVa9axcHcOlG9GcilZkz\",\"object\":\"chat.completion.chunk\",\"created\":1778031308,\"model\":\"openai/gpt-5.5-20260423\",\"provider\":\"OpenAI\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"role\":\"assistant\",\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"\\\"}\"}}]},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1778031308-dVa9axcHcOlG9GcilZkz\",\"object\":\"chat.completion.chunk\",\"created\":1778031308,\"model\":\"openai/gpt-5.5-20260423\",\"provider\":\"OpenAI\",\"service_tier\":\"default\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"\",\"role\":\"assistant\"},\"finish_reason\":\"tool_calls\",\"native_finish_reason\":\"completed\"}]}\n\ndata: {\"id\":\"gen-1778031308-dVa9axcHcOlG9GcilZkz\",\"object\":\"chat.completion.chunk\",\"created\":1778031308,\"model\":\"openai/gpt-5.5-20260423\",\"provider\":\"OpenAI\",\"service_tier\":\"default\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"\",\"role\":\"assistant\"},\"finish_reason\":\"tool_calls\",\"native_finish_reason\":\"completed\"}],\"usage\":{\"prompt_tokens\":69,\"completion_tokens\":18,\"total_tokens\":87,\"cost\":0.000885,\"is_byok\":false,\"prompt_tokens_details\":{\"cached_tokens\":0,\"cache_write_tokens\":0,\"audio_tokens\":0,\"video_tokens\":0},\"cost_details\":{\"upstream_inference_cost\":0.000885,\"upstream_inference_prompt_cost\":0.000345,\"upstream_inference_completions_cost\":0.00054},\"completion_tokens_details\":{\"reasoning_tokens\":0,\"image_tokens\":0,\"audio_tokens\":0}}}\n\ndata: [DONE]\n\n" + } + }, + { + "transport": "http", + "request": { + "method": "POST", + "url": "https://openrouter.ai/api/v1/chat/completions", + "headers": { + "content-type": "application/json" + }, + "body": "{\"model\":\"openai/gpt-5.5\",\"messages\":[{\"role\":\"system\",\"content\":\"Use the get_weather tool exactly once, then answer in one short sentence.\"},{\"role\":\"user\",\"content\":\"What is the weather in Paris?\"},{\"role\":\"assistant\",\"content\":null,\"tool_calls\":[{\"id\":\"call_4A7V7UN36HXCUUn8qAOQaKGw\",\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"arguments\":\"{\\\"city\\\":\\\"Paris\\\"}\"}}]},{\"role\":\"tool\",\"tool_call_id\":\"call_4A7V7UN36HXCUUn8qAOQaKGw\",\"content\":\"{\\\"temperature\\\":22,\\\"condition\\\":\\\"sunny\\\"}\"}],\"tools\":[{\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false}}}],\"stream\":true,\"stream_options\":{\"include_usage\":true},\"max_tokens\":80,\"temperature\":0}" + }, + "response": { + "status": 200, + "headers": { + "content-type": "text/event-stream" + }, + "body": ": OPENROUTER PROCESSING\n\n: OPENROUTER PROCESSING\n\ndata: {\"id\":\"gen-1778031310-JUYfFzDbun699uUYoA4N\",\"object\":\"chat.completion.chunk\",\"created\":1778031310,\"model\":\"openai/gpt-5.5-20260423\",\"provider\":\"OpenAI\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"Paris\",\"role\":\"assistant\"},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1778031310-JUYfFzDbun699uUYoA4N\",\"object\":\"chat.completion.chunk\",\"created\":1778031310,\"model\":\"openai/gpt-5.5-20260423\",\"provider\":\"OpenAI\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" is\",\"role\":\"assistant\"},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1778031310-JUYfFzDbun699uUYoA4N\",\"object\":\"chat.completion.chunk\",\"created\":1778031310,\"model\":\"openai/gpt-5.5-20260423\",\"provider\":\"OpenAI\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" sunny\",\"role\":\"assistant\"},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1778031310-JUYfFzDbun699uUYoA4N\",\"object\":\"chat.completion.chunk\",\"created\":1778031310,\"model\":\"openai/gpt-5.5-20260423\",\"provider\":\"OpenAI\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" and\",\"role\":\"assistant\"},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1778031310-JUYfFzDbun699uUYoA4N\",\"object\":\"chat.completion.chunk\",\"created\":1778031310,\"model\":\"openai/gpt-5.5-20260423\",\"provider\":\"OpenAI\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" \",\"role\":\"assistant\"},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1778031310-JUYfFzDbun699uUYoA4N\",\"object\":\"chat.completion.chunk\",\"created\":1778031310,\"model\":\"openai/gpt-5.5-20260423\",\"provider\":\"OpenAI\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"22\",\"role\":\"assistant\"},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1778031310-JUYfFzDbun699uUYoA4N\",\"object\":\"chat.completion.chunk\",\"created\":1778031310,\"model\":\"openai/gpt-5.5-20260423\",\"provider\":\"OpenAI\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"°C\",\"role\":\"assistant\"},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1778031310-JUYfFzDbun699uUYoA4N\",\"object\":\"chat.completion.chunk\",\"created\":1778031310,\"model\":\"openai/gpt-5.5-20260423\",\"provider\":\"OpenAI\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\".\",\"role\":\"assistant\"},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1778031310-JUYfFzDbun699uUYoA4N\",\"object\":\"chat.completion.chunk\",\"created\":1778031310,\"model\":\"openai/gpt-5.5-20260423\",\"provider\":\"OpenAI\",\"service_tier\":\"default\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"\",\"role\":\"assistant\"},\"finish_reason\":\"stop\",\"native_finish_reason\":\"completed\"}]}\n\ndata: {\"id\":\"gen-1778031310-JUYfFzDbun699uUYoA4N\",\"object\":\"chat.completion.chunk\",\"created\":1778031310,\"model\":\"openai/gpt-5.5-20260423\",\"provider\":\"OpenAI\",\"service_tier\":\"default\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"\",\"role\":\"assistant\"},\"finish_reason\":\"stop\",\"native_finish_reason\":\"completed\"}],\"usage\":{\"prompt_tokens\":108,\"completion_tokens\":12,\"total_tokens\":120,\"cost\":0.0009,\"is_byok\":false,\"prompt_tokens_details\":{\"cached_tokens\":0,\"cache_write_tokens\":0,\"audio_tokens\":0,\"video_tokens\":0},\"cost_details\":{\"upstream_inference_cost\":0.0009,\"upstream_inference_prompt_cost\":0.00054,\"upstream_inference_completions_cost\":0.00036},\"completion_tokens_details\":{\"reasoning_tokens\":0,\"image_tokens\":0,\"audio_tokens\":0}}}\n\ndata: [DONE]\n\n" + } + } + ] +} diff --git a/packages/llm/test/fixtures/recordings/openai-compatible-chat/openrouter-streams-text.json b/packages/llm/test/fixtures/recordings/openai-compatible-chat/openrouter-streams-text.json new file mode 100644 index 0000000000000000000000000000000000000000..1a95146931ee7862f5c33a9f92bce969cb4789db --- /dev/null +++ b/packages/llm/test/fixtures/recordings/openai-compatible-chat/openrouter-streams-text.json @@ -0,0 +1,28 @@ +{ + "version": 1, + "metadata": { + "name": "openai-compatible-chat/openrouter-streams-text", + "recordedAt": "2026-05-06T01:35:06.767Z", + "tags": ["prefix:openai-compatible-chat", "protocol:openai-compatible-chat", "provider:openrouter"] + }, + "interactions": [ + { + "transport": "http", + "request": { + "method": "POST", + "url": "https://openrouter.ai/api/v1/chat/completions", + "headers": { + "content-type": "application/json" + }, + "body": "{\"model\":\"openai/gpt-4o-mini\",\"messages\":[{\"role\":\"system\",\"content\":\"You are concise.\"},{\"role\":\"user\",\"content\":\"Reply with exactly: Hello!\"}],\"stream\":true,\"stream_options\":{\"include_usage\":true},\"max_tokens\":20,\"temperature\":0}" + }, + "response": { + "status": 200, + "headers": { + "content-type": "text/event-stream" + }, + "body": ": OPENROUTER PROCESSING\n\ndata: {\"id\":\"gen-1778031306-UD7bR0I1JNCsPvVzlXat\",\"object\":\"chat.completion.chunk\",\"created\":1778031306,\"model\":\"openai/gpt-4o-mini\",\"provider\":\"Azure\",\"system_fingerprint\":\"fp_eb37e061ec\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"Hello\",\"role\":\"assistant\"},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1778031306-UD7bR0I1JNCsPvVzlXat\",\"object\":\"chat.completion.chunk\",\"created\":1778031306,\"model\":\"openai/gpt-4o-mini\",\"provider\":\"Azure\",\"system_fingerprint\":\"fp_eb37e061ec\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"!\",\"role\":\"assistant\"},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1778031306-UD7bR0I1JNCsPvVzlXat\",\"object\":\"chat.completion.chunk\",\"created\":1778031306,\"model\":\"openai/gpt-4o-mini\",\"provider\":\"Azure\",\"system_fingerprint\":\"fp_eb37e061ec\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"\",\"role\":\"assistant\"},\"finish_reason\":\"stop\",\"native_finish_reason\":\"stop\"}]}\n\ndata: {\"id\":\"gen-1778031306-UD7bR0I1JNCsPvVzlXat\",\"object\":\"chat.completion.chunk\",\"created\":1778031306,\"model\":\"openai/gpt-4o-mini\",\"provider\":\"Azure\",\"system_fingerprint\":\"fp_eb37e061ec\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"\",\"role\":\"assistant\"},\"finish_reason\":\"stop\",\"native_finish_reason\":\"stop\"}],\"usage\":{\"prompt_tokens\":21,\"completion_tokens\":3,\"total_tokens\":24,\"cost\":0.00000495,\"is_byok\":false,\"prompt_tokens_details\":{\"cached_tokens\":0,\"cache_write_tokens\":0,\"audio_tokens\":0,\"video_tokens\":0},\"cost_details\":{\"upstream_inference_cost\":0.00000495,\"upstream_inference_prompt_cost\":0.00000315,\"upstream_inference_completions_cost\":0.0000018},\"completion_tokens_details\":{\"reasoning_tokens\":0,\"image_tokens\":0,\"audio_tokens\":0}}}\n\ndata: [DONE]\n\n" + } + } + ] +} diff --git a/packages/llm/test/fixtures/recordings/openai-compatible-chat/openrouter-streams-tool-call.json b/packages/llm/test/fixtures/recordings/openai-compatible-chat/openrouter-streams-tool-call.json new file mode 100644 index 0000000000000000000000000000000000000000..36d0ad99c56b69db67dc259e523b8fb72f03aaf9 --- /dev/null +++ b/packages/llm/test/fixtures/recordings/openai-compatible-chat/openrouter-streams-tool-call.json @@ -0,0 +1,28 @@ +{ + "version": 1, + "metadata": { + "name": "openai-compatible-chat/openrouter-streams-tool-call", + "recordedAt": "2026-05-06T01:35:07.466Z", + "tags": ["prefix:openai-compatible-chat", "protocol:openai-compatible-chat", "provider:openrouter", "tool"] + }, + "interactions": [ + { + "transport": "http", + "request": { + "method": "POST", + "url": "https://openrouter.ai/api/v1/chat/completions", + "headers": { + "content-type": "application/json" + }, + "body": "{\"model\":\"openai/gpt-4o-mini\",\"messages\":[{\"role\":\"system\",\"content\":\"Call tools exactly as requested.\"},{\"role\":\"user\",\"content\":\"Call get_weather with city exactly Paris.\"}],\"tools\":[{\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false}}}],\"tool_choice\":{\"type\":\"function\",\"function\":{\"name\":\"get_weather\"}},\"stream\":true,\"stream_options\":{\"include_usage\":true},\"max_tokens\":80,\"temperature\":0}" + }, + "response": { + "status": 200, + "headers": { + "content-type": "text/event-stream" + }, + "body": ": OPENROUTER PROCESSING\n\ndata: {\"id\":\"gen-1778031306-HYzOq04JIk1hZQ4iaNjD\",\"object\":\"chat.completion.chunk\",\"created\":1778031306,\"model\":\"openai/gpt-4o-mini\",\"provider\":\"OpenAI\",\"system_fingerprint\":\"fp_b6580bbee1\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"role\":\"assistant\",\"tool_calls\":[{\"index\":0,\"id\":\"call_L7mHMq49ZSUTBHjLJfBIP2eT\",\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"arguments\":\"\"}}]},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1778031306-HYzOq04JIk1hZQ4iaNjD\",\"object\":\"chat.completion.chunk\",\"created\":1778031306,\"model\":\"openai/gpt-4o-mini\",\"provider\":\"OpenAI\",\"system_fingerprint\":\"fp_b6580bbee1\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"role\":\"assistant\",\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"\"}}]},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1778031306-HYzOq04JIk1hZQ4iaNjD\",\"object\":\"chat.completion.chunk\",\"created\":1778031306,\"model\":\"openai/gpt-4o-mini\",\"provider\":\"OpenAI\",\"system_fingerprint\":\"fp_b6580bbee1\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"role\":\"assistant\",\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"{\\\"\"}}]},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1778031306-HYzOq04JIk1hZQ4iaNjD\",\"object\":\"chat.completion.chunk\",\"created\":1778031306,\"model\":\"openai/gpt-4o-mini\",\"provider\":\"OpenAI\",\"system_fingerprint\":\"fp_b6580bbee1\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"role\":\"assistant\",\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"city\"}}]},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1778031306-HYzOq04JIk1hZQ4iaNjD\",\"object\":\"chat.completion.chunk\",\"created\":1778031306,\"model\":\"openai/gpt-4o-mini\",\"provider\":\"OpenAI\",\"system_fingerprint\":\"fp_b6580bbee1\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"role\":\"assistant\",\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"\\\":\\\"\"}}]},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1778031306-HYzOq04JIk1hZQ4iaNjD\",\"object\":\"chat.completion.chunk\",\"created\":1778031306,\"model\":\"openai/gpt-4o-mini\",\"provider\":\"OpenAI\",\"system_fingerprint\":\"fp_b6580bbee1\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"role\":\"assistant\",\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"Paris\"}}]},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1778031306-HYzOq04JIk1hZQ4iaNjD\",\"object\":\"chat.completion.chunk\",\"created\":1778031306,\"model\":\"openai/gpt-4o-mini\",\"provider\":\"OpenAI\",\"system_fingerprint\":\"fp_b6580bbee1\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"role\":\"assistant\",\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"\\\"}\"}}]},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1778031306-HYzOq04JIk1hZQ4iaNjD\",\"object\":\"chat.completion.chunk\",\"created\":1778031306,\"model\":\"openai/gpt-4o-mini\",\"provider\":\"OpenAI\",\"system_fingerprint\":\"fp_b6580bbee1\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"\",\"role\":\"assistant\"},\"finish_reason\":\"tool_calls\",\"native_finish_reason\":\"stop\"}]}\n\ndata: {\"id\":\"gen-1778031306-HYzOq04JIk1hZQ4iaNjD\",\"object\":\"chat.completion.chunk\",\"created\":1778031306,\"model\":\"openai/gpt-4o-mini\",\"provider\":\"OpenAI\",\"system_fingerprint\":\"fp_b6580bbee1\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"\",\"role\":\"assistant\"},\"finish_reason\":\"tool_calls\",\"native_finish_reason\":\"stop\"}],\"usage\":{\"prompt_tokens\":67,\"completion_tokens\":5,\"total_tokens\":72,\"cost\":0.00001305,\"is_byok\":false,\"prompt_tokens_details\":{\"cached_tokens\":0,\"cache_write_tokens\":0,\"audio_tokens\":0,\"video_tokens\":0},\"cost_details\":{\"upstream_inference_cost\":0.00001305,\"upstream_inference_prompt_cost\":0.00001005,\"upstream_inference_completions_cost\":0.000003},\"completion_tokens_details\":{\"reasoning_tokens\":0,\"image_tokens\":0,\"audio_tokens\":0}}}\n\ndata: [DONE]\n\n" + } + } + ] +} diff --git a/packages/llm/test/fixtures/recordings/openai-compatible-chat/togetherai-streams-text.json b/packages/llm/test/fixtures/recordings/openai-compatible-chat/togetherai-streams-text.json new file mode 100644 index 0000000000000000000000000000000000000000..640565b14faa903bc1958029a26f953bcb8a930d --- /dev/null +++ b/packages/llm/test/fixtures/recordings/openai-compatible-chat/togetherai-streams-text.json @@ -0,0 +1,28 @@ +{ + "version": 1, + "metadata": { + "name": "openai-compatible-chat/togetherai-streams-text", + "recordedAt": "2026-04-28T21:18:55.266Z", + "tags": ["prefix:openai-compatible-chat", "protocol:openai-compatible-chat", "provider:togetherai"] + }, + "interactions": [ + { + "transport": "http", + "request": { + "method": "POST", + "url": "https://api.together.xyz/v1/chat/completions", + "headers": { + "content-type": "application/json" + }, + "body": "{\"model\":\"meta-llama/Llama-3.3-70B-Instruct-Turbo\",\"messages\":[{\"role\":\"system\",\"content\":\"You are concise.\"},{\"role\":\"user\",\"content\":\"Reply with exactly: Hello!\"}],\"stream\":true,\"stream_options\":{\"include_usage\":true},\"max_tokens\":20,\"temperature\":0}" + }, + "response": { + "status": 200, + "headers": { + "content-type": "text/event-stream;charset=utf-8" + }, + "body": "data: {\"id\":\"ogzjdpL-6Ng1vN-9f391a08f8af75e1\",\"object\":\"chat.completion.chunk\",\"created\":1777411129,\"choices\":[{\"index\":0,\"text\":\"Hello\",\"logprobs\":null,\"finish_reason\":null,\"seed\":null,\"delta\":{\"token_id\":9906,\"role\":\"assistant\",\"content\":\"Hello\"}}],\"model\":\"meta-llama/Llama-3.3-70B-Instruct-Turbo\",\"usage\":null}\n\ndata: {\"id\":\"ogzjdpL-6Ng1vN-9f391a08f8af75e1\",\"object\":\"chat.completion.chunk\",\"created\":1777411129,\"choices\":[{\"index\":0,\"text\":\"!\",\"logprobs\":null,\"finish_reason\":null,\"seed\":null,\"delta\":{\"token_id\":null,\"role\":\"assistant\",\"content\":\"!\"}}],\"model\":\"meta-llama/Llama-3.3-70B-Instruct-Turbo\",\"usage\":null}\n\ndata: {\"id\":\"ogzjdpL-6Ng1vN-9f391a08f8af75e1\",\"object\":\"chat.completion.chunk\",\"created\":1777411129,\"choices\":[{\"index\":0,\"text\":\"\",\"logprobs\":null,\"finish_reason\":\"stop\",\"seed\":15924764223251450000,\"delta\":{\"token_id\":128009,\"role\":\"assistant\",\"content\":\"\"}}],\"model\":\"meta-llama/Llama-3.3-70B-Instruct-Turbo\",\"usage\":{\"prompt_tokens\":45,\"completion_tokens\":3,\"total_tokens\":48,\"cached_tokens\":0}}\n\ndata: [DONE]\n\n" + } + } + ] +} diff --git a/packages/llm/test/fixtures/recordings/openai-compatible-chat/togetherai-streams-tool-call.json b/packages/llm/test/fixtures/recordings/openai-compatible-chat/togetherai-streams-tool-call.json new file mode 100644 index 0000000000000000000000000000000000000000..6c1d9c1a7fc46fdcbc27ecb52de6ac562ad2ce23 --- /dev/null +++ b/packages/llm/test/fixtures/recordings/openai-compatible-chat/togetherai-streams-tool-call.json @@ -0,0 +1,28 @@ +{ + "version": 1, + "metadata": { + "name": "openai-compatible-chat/togetherai-streams-tool-call", + "recordedAt": "2026-04-28T21:18:59.123Z", + "tags": ["prefix:openai-compatible-chat", "protocol:openai-compatible-chat", "provider:togetherai", "tool"] + }, + "interactions": [ + { + "transport": "http", + "request": { + "method": "POST", + "url": "https://api.together.xyz/v1/chat/completions", + "headers": { + "content-type": "application/json" + }, + "body": "{\"model\":\"meta-llama/Llama-3.3-70B-Instruct-Turbo\",\"messages\":[{\"role\":\"system\",\"content\":\"Call tools exactly as requested.\"},{\"role\":\"user\",\"content\":\"Call get_weather with city exactly Paris.\"}],\"tools\":[{\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false}}}],\"tool_choice\":{\"type\":\"function\",\"function\":{\"name\":\"get_weather\"}},\"stream\":true,\"stream_options\":{\"include_usage\":true},\"max_tokens\":80,\"temperature\":0}" + }, + "response": { + "status": 200, + "headers": { + "content-type": "text/event-stream;charset=utf-8" + }, + "body": "data: {\"id\":\"ogzjfRD-6Ng1vN-9f391a2bb8ca75e1\",\"object\":\"chat.completion.chunk\",\"created\":1777411135,\"choices\":[{\"index\":0,\"role\":\"assistant\",\"text\":\"\",\"logprobs\":null,\"finish_reason\":null,\"delta\":{\"token_id\":null,\"role\":\"assistant\",\"content\":\"\"}}],\"model\":\"meta-llama/Llama-3.3-70B-Instruct-Turbo\"}\n\ndata: {\"id\":\"ogzjfRD-6Ng1vN-9f391a2bb8ca75e1\",\"object\":\"chat.completion.chunk\",\"created\":1777411135,\"choices\":[{\"index\":0,\"text\":\"\",\"logprobs\":null,\"finish_reason\":null,\"delta\":{\"token_id\":null,\"role\":\"assistant\",\"content\":\"\",\"tool_calls\":[{\"index\":0,\"id\":\"call_yu1mxtmex7x48nximi9c8jpo\",\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"arguments\":\"\"}}]}}],\"model\":\"meta-llama/Llama-3.3-70B-Instruct-Turbo\"}\n\ndata: {\"id\":\"ogzjfRD-6Ng1vN-9f391a2bb8ca75e1\",\"object\":\"chat.completion.chunk\",\"created\":1777411135,\"choices\":[{\"index\":0,\"text\":\"\",\"logprobs\":null,\"finish_reason\":\"tool_calls\",\"delta\":{\"token_id\":null,\"role\":\"assistant\",\"content\":\"\",\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"{\\\"city\\\":\\\"Paris\\\"}\"}}]}}],\"model\":\"meta-llama/Llama-3.3-70B-Instruct-Turbo\"}\n\ndata: {\"id\":\"ogzjfRD-6Ng1vN-9f391a2bb8ca75e1\",\"object\":\"chat.completion.chunk\",\"created\":1777411135,\"choices\":[{\"index\":0,\"text\":\"\",\"logprobs\":null,\"finish_reason\":\"tool_calls\",\"seed\":9033012299842426000,\"delta\":{\"token_id\":128009,\"role\":\"assistant\",\"content\":\"\"}}],\"model\":\"meta-llama/Llama-3.3-70B-Instruct-Turbo\",\"usage\":{\"prompt_tokens\":194,\"completion_tokens\":19,\"total_tokens\":213,\"cached_tokens\":0}}\n\ndata: [DONE]\n\n" + } + } + ] +} diff --git a/packages/llm/test/fixtures/recordings/openai-responses-cache/reports-cached-tokens-on-identical-second-call.json b/packages/llm/test/fixtures/recordings/openai-responses-cache/reports-cached-tokens-on-identical-second-call.json new file mode 100644 index 0000000000000000000000000000000000000000..2110a6a99ba0892d40f816f6ffea451b09ab2f5c --- /dev/null +++ b/packages/llm/test/fixtures/recordings/openai-responses-cache/reports-cached-tokens-on-identical-second-call.json @@ -0,0 +1,46 @@ +{ + "version": 1, + "metadata": { + "name": "openai-responses-cache/reports-cached-tokens-on-identical-second-call", + "recordedAt": "2026-05-11T01:41:58.951Z", + "tags": ["prefix:openai-responses-cache", "provider:openai", "protocol:openai-responses", "cache"] + }, + "interactions": [ + { + "transport": "http", + "request": { + "method": "POST", + "url": "https://api.openai.com/v1/responses", + "headers": { + "content-type": "application/json" + }, + "body": "{\"model\":\"gpt-4.1-mini\",\"input\":[{\"role\":\"system\",\"content\":\"You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. \"},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Say hi.\"}]}],\"prompt_cache_key\":\"recorded-cache-test\",\"max_output_tokens\":16,\"temperature\":0,\"stream\":true,\"store\":false}" + }, + "response": { + "status": 200, + "headers": { + "content-type": "text/event-stream; charset=utf-8" + }, + "body": "event: response.created\ndata: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_00b4acfe385b75d6006a0133e252e4819faecb37d96affd4bf\",\"object\":\"response\",\"created_at\":1778463714,\"status\":\"in_progress\",\"background\":false,\"completed_at\":null,\"error\":null,\"frequency_penalty\":0.0,\"incomplete_details\":null,\"instructions\":null,\"max_output_tokens\":16,\"max_tool_calls\":null,\"model\":\"gpt-4.1-mini-2025-04-14\",\"moderation\":null,\"output\":[],\"parallel_tool_calls\":true,\"presence_penalty\":0.0,\"previous_response_id\":null,\"prompt_cache_key\":\"recorded-cache-test\",\"prompt_cache_retention\":\"in_memory\",\"reasoning\":{\"effort\":null,\"summary\":null},\"safety_identifier\":null,\"service_tier\":\"auto\",\"store\":true,\"temperature\":0.0,\"text\":{\"format\":{\"type\":\"text\"},\"verbosity\":\"medium\"},\"tool_choice\":\"auto\",\"tools\":[],\"top_logprobs\":0,\"top_p\":1.0,\"truncation\":\"disabled\",\"usage\":null,\"user\":null,\"metadata\":{}},\"sequence_number\":0}\n\nevent: response.in_progress\ndata: {\"type\":\"response.in_progress\",\"response\":{\"id\":\"resp_00b4acfe385b75d6006a0133e252e4819faecb37d96affd4bf\",\"object\":\"response\",\"created_at\":1778463714,\"status\":\"in_progress\",\"background\":false,\"completed_at\":null,\"error\":null,\"frequency_penalty\":0.0,\"incomplete_details\":null,\"instructions\":null,\"max_output_tokens\":16,\"max_tool_calls\":null,\"model\":\"gpt-4.1-mini-2025-04-14\",\"moderation\":null,\"output\":[],\"parallel_tool_calls\":true,\"presence_penalty\":0.0,\"previous_response_id\":null,\"prompt_cache_key\":\"recorded-cache-test\",\"prompt_cache_retention\":\"in_memory\",\"reasoning\":{\"effort\":null,\"summary\":null},\"safety_identifier\":null,\"service_tier\":\"auto\",\"store\":true,\"temperature\":0.0,\"text\":{\"format\":{\"type\":\"text\"},\"verbosity\":\"medium\"},\"tool_choice\":\"auto\",\"tools\":[],\"top_logprobs\":0,\"top_p\":1.0,\"truncation\":\"disabled\",\"usage\":null,\"user\":null,\"metadata\":{}},\"sequence_number\":1}\n\nevent: response.output_item.added\ndata: {\"type\":\"response.output_item.added\",\"item\":{\"id\":\"msg_00b4acfe385b75d6006a0133e42ad8819f83824a88e1160e09\",\"type\":\"message\",\"status\":\"in_progress\",\"content\":[],\"role\":\"assistant\"},\"output_index\":0,\"sequence_number\":2}\n\nevent: response.content_part.added\ndata: {\"type\":\"response.content_part.added\",\"content_index\":0,\"item_id\":\"msg_00b4acfe385b75d6006a0133e42ad8819f83824a88e1160e09\",\"output_index\":0,\"part\":{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"\"},\"sequence_number\":3}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\"Hi\",\"item_id\":\"msg_00b4acfe385b75d6006a0133e42ad8819f83824a88e1160e09\",\"logprobs\":[],\"obfuscation\":\"NSLkknb2f6J7MB\",\"output_index\":0,\"sequence_number\":4}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\".\",\"item_id\":\"msg_00b4acfe385b75d6006a0133e42ad8819f83824a88e1160e09\",\"logprobs\":[],\"obfuscation\":\"ywmEAhs1uKOLkln\",\"output_index\":0,\"sequence_number\":5}\n\nevent: response.output_text.done\ndata: {\"type\":\"response.output_text.done\",\"content_index\":0,\"item_id\":\"msg_00b4acfe385b75d6006a0133e42ad8819f83824a88e1160e09\",\"logprobs\":[],\"output_index\":0,\"sequence_number\":6,\"text\":\"Hi.\"}\n\nevent: response.content_part.done\ndata: {\"type\":\"response.content_part.done\",\"content_index\":0,\"item_id\":\"msg_00b4acfe385b75d6006a0133e42ad8819f83824a88e1160e09\",\"output_index\":0,\"part\":{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"Hi.\"},\"sequence_number\":7}\n\nevent: response.output_item.done\ndata: {\"type\":\"response.output_item.done\",\"item\":{\"id\":\"msg_00b4acfe385b75d6006a0133e42ad8819f83824a88e1160e09\",\"type\":\"message\",\"status\":\"completed\",\"content\":[{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"Hi.\"}],\"role\":\"assistant\"},\"output_index\":0,\"sequence_number\":8}\n\nevent: response.completed\ndata: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_00b4acfe385b75d6006a0133e252e4819faecb37d96affd4bf\",\"object\":\"response\",\"created_at\":1778463714,\"status\":\"completed\",\"background\":false,\"completed_at\":1778463716,\"error\":null,\"frequency_penalty\":0.0,\"incomplete_details\":null,\"instructions\":null,\"max_output_tokens\":16,\"max_tool_calls\":null,\"model\":\"gpt-4.1-mini-2025-04-14\",\"moderation\":null,\"output\":[{\"id\":\"msg_00b4acfe385b75d6006a0133e42ad8819f83824a88e1160e09\",\"type\":\"message\",\"status\":\"completed\",\"content\":[{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"Hi.\"}],\"role\":\"assistant\"}],\"parallel_tool_calls\":true,\"presence_penalty\":0.0,\"previous_response_id\":null,\"prompt_cache_key\":\"recorded-cache-test\",\"prompt_cache_retention\":\"in_memory\",\"reasoning\":{\"effort\":null,\"summary\":null},\"safety_identifier\":null,\"service_tier\":\"default\",\"store\":true,\"temperature\":0.0,\"text\":{\"format\":{\"type\":\"text\"},\"verbosity\":\"medium\"},\"tool_choice\":\"auto\",\"tools\":[],\"top_logprobs\":0,\"top_p\":1.0,\"truncation\":\"disabled\",\"usage\":{\"input_tokens\":4765,\"input_tokens_details\":{\"cached_tokens\":0},\"output_tokens\":3,\"output_tokens_details\":{\"reasoning_tokens\":0},\"total_tokens\":4768},\"user\":null,\"metadata\":{}},\"sequence_number\":9}\n\n" + } + }, + { + "transport": "http", + "request": { + "method": "POST", + "url": "https://api.openai.com/v1/responses", + "headers": { + "content-type": "application/json" + }, + "body": "{\"model\":\"gpt-4.1-mini\",\"input\":[{\"role\":\"system\",\"content\":\"You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. \"},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Say hi.\"}]}],\"prompt_cache_key\":\"recorded-cache-test\",\"max_output_tokens\":16,\"temperature\":0,\"stream\":true,\"store\":false}" + }, + "response": { + "status": 200, + "headers": { + "content-type": "text/event-stream; charset=utf-8" + }, + "body": "event: response.created\ndata: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_06a66d5dbf005c28006a0133e48a28819d957163a92a5a56cc\",\"object\":\"response\",\"created_at\":1778463716,\"status\":\"in_progress\",\"background\":false,\"completed_at\":null,\"error\":null,\"frequency_penalty\":0.0,\"incomplete_details\":null,\"instructions\":null,\"max_output_tokens\":16,\"max_tool_calls\":null,\"model\":\"gpt-4.1-mini-2025-04-14\",\"moderation\":null,\"output\":[],\"parallel_tool_calls\":true,\"presence_penalty\":0.0,\"previous_response_id\":null,\"prompt_cache_key\":\"recorded-cache-test\",\"prompt_cache_retention\":\"in_memory\",\"reasoning\":{\"effort\":null,\"summary\":null},\"safety_identifier\":null,\"service_tier\":\"auto\",\"store\":true,\"temperature\":0.0,\"text\":{\"format\":{\"type\":\"text\"},\"verbosity\":\"medium\"},\"tool_choice\":\"auto\",\"tools\":[],\"top_logprobs\":0,\"top_p\":1.0,\"truncation\":\"disabled\",\"usage\":null,\"user\":null,\"metadata\":{}},\"sequence_number\":0}\n\nevent: response.in_progress\ndata: {\"type\":\"response.in_progress\",\"response\":{\"id\":\"resp_06a66d5dbf005c28006a0133e48a28819d957163a92a5a56cc\",\"object\":\"response\",\"created_at\":1778463716,\"status\":\"in_progress\",\"background\":false,\"completed_at\":null,\"error\":null,\"frequency_penalty\":0.0,\"incomplete_details\":null,\"instructions\":null,\"max_output_tokens\":16,\"max_tool_calls\":null,\"model\":\"gpt-4.1-mini-2025-04-14\",\"moderation\":null,\"output\":[],\"parallel_tool_calls\":true,\"presence_penalty\":0.0,\"previous_response_id\":null,\"prompt_cache_key\":\"recorded-cache-test\",\"prompt_cache_retention\":\"in_memory\",\"reasoning\":{\"effort\":null,\"summary\":null},\"safety_identifier\":null,\"service_tier\":\"auto\",\"store\":true,\"temperature\":0.0,\"text\":{\"format\":{\"type\":\"text\"},\"verbosity\":\"medium\"},\"tool_choice\":\"auto\",\"tools\":[],\"top_logprobs\":0,\"top_p\":1.0,\"truncation\":\"disabled\",\"usage\":null,\"user\":null,\"metadata\":{}},\"sequence_number\":1}\n\nevent: response.output_item.added\ndata: {\"type\":\"response.output_item.added\",\"item\":{\"id\":\"msg_06a66d5dbf005c28006a0133e6a2b0819d90b31eabe0bb0568\",\"type\":\"message\",\"status\":\"in_progress\",\"content\":[],\"role\":\"assistant\"},\"output_index\":0,\"sequence_number\":2}\n\nevent: response.content_part.added\ndata: {\"type\":\"response.content_part.added\",\"content_index\":0,\"item_id\":\"msg_06a66d5dbf005c28006a0133e6a2b0819d90b31eabe0bb0568\",\"output_index\":0,\"part\":{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"\"},\"sequence_number\":3}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\"Hi\",\"item_id\":\"msg_06a66d5dbf005c28006a0133e6a2b0819d90b31eabe0bb0568\",\"logprobs\":[],\"obfuscation\":\"qLgi78ygFGnuw7\",\"output_index\":0,\"sequence_number\":4}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\".\",\"item_id\":\"msg_06a66d5dbf005c28006a0133e6a2b0819d90b31eabe0bb0568\",\"logprobs\":[],\"obfuscation\":\"dyQaYugaXCUfkYH\",\"output_index\":0,\"sequence_number\":5}\n\nevent: response.output_text.done\ndata: {\"type\":\"response.output_text.done\",\"content_index\":0,\"item_id\":\"msg_06a66d5dbf005c28006a0133e6a2b0819d90b31eabe0bb0568\",\"logprobs\":[],\"output_index\":0,\"sequence_number\":6,\"text\":\"Hi.\"}\n\nevent: response.content_part.done\ndata: {\"type\":\"response.content_part.done\",\"content_index\":0,\"item_id\":\"msg_06a66d5dbf005c28006a0133e6a2b0819d90b31eabe0bb0568\",\"output_index\":0,\"part\":{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"Hi.\"},\"sequence_number\":7}\n\nevent: response.output_item.done\ndata: {\"type\":\"response.output_item.done\",\"item\":{\"id\":\"msg_06a66d5dbf005c28006a0133e6a2b0819d90b31eabe0bb0568\",\"type\":\"message\",\"status\":\"completed\",\"content\":[{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"Hi.\"}],\"role\":\"assistant\"},\"output_index\":0,\"sequence_number\":8}\n\nevent: response.completed\ndata: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_06a66d5dbf005c28006a0133e48a28819d957163a92a5a56cc\",\"object\":\"response\",\"created_at\":1778463716,\"status\":\"completed\",\"background\":false,\"completed_at\":1778463718,\"error\":null,\"frequency_penalty\":0.0,\"incomplete_details\":null,\"instructions\":null,\"max_output_tokens\":16,\"max_tool_calls\":null,\"model\":\"gpt-4.1-mini-2025-04-14\",\"moderation\":null,\"output\":[{\"id\":\"msg_06a66d5dbf005c28006a0133e6a2b0819d90b31eabe0bb0568\",\"type\":\"message\",\"status\":\"completed\",\"content\":[{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"Hi.\"}],\"role\":\"assistant\"}],\"parallel_tool_calls\":true,\"presence_penalty\":0.0,\"previous_response_id\":null,\"prompt_cache_key\":\"recorded-cache-test\",\"prompt_cache_retention\":\"in_memory\",\"reasoning\":{\"effort\":null,\"summary\":null},\"safety_identifier\":null,\"service_tier\":\"default\",\"store\":true,\"temperature\":0.0,\"text\":{\"format\":{\"type\":\"text\"},\"verbosity\":\"medium\"},\"tool_choice\":\"auto\",\"tools\":[],\"top_logprobs\":0,\"top_p\":1.0,\"truncation\":\"disabled\",\"usage\":{\"input_tokens\":4765,\"input_tokens_details\":{\"cached_tokens\":4608},\"output_tokens\":3,\"output_tokens_details\":{\"reasoning_tokens\":0},\"total_tokens\":4768},\"user\":null,\"metadata\":{}},\"sequence_number\":9}\n\n" + } + } + ] +} diff --git a/packages/llm/test/fixtures/recordings/openai-responses/gpt-5-5-drives-a-tool-loop.json b/packages/llm/test/fixtures/recordings/openai-responses/gpt-5-5-drives-a-tool-loop.json new file mode 100644 index 0000000000000000000000000000000000000000..9d441205fde9b9df3d5bee0325de4950d709f35c --- /dev/null +++ b/packages/llm/test/fixtures/recordings/openai-responses/gpt-5-5-drives-a-tool-loop.json @@ -0,0 +1,54 @@ +{ + "version": 1, + "metadata": { + "name": "openai-responses/gpt-5-5-drives-a-tool-loop", + "recordedAt": "2026-05-06T00:26:15.209Z", + "tags": [ + "prefix:openai-responses", + "provider:openai", + "protocol:openai-responses", + "tool", + "tool-loop", + "golden", + "flagship" + ] + }, + "interactions": [ + { + "transport": "http", + "request": { + "method": "POST", + "url": "https://api.openai.com/v1/responses", + "headers": { + "content-type": "application/json" + }, + "body": "{\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"system\",\"content\":\"Use the get_weather tool, then answer in one short sentence.\"},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"What is the weather in Paris?\"}]}],\"tools\":[{\"type\":\"function\",\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":false}],\"stream\":true,\"max_output_tokens\":80}" + }, + "response": { + "status": 200, + "headers": { + "content-type": "text/event-stream; charset=utf-8" + }, + "body": "event: response.created\ndata: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_01394305fdec6fdd0069fa8aa414cc81a1908662495e7c9bd9\",\"object\":\"response\",\"created_at\":1778027172,\"status\":\"in_progress\",\"background\":false,\"completed_at\":null,\"error\":null,\"frequency_penalty\":0.0,\"incomplete_details\":null,\"instructions\":null,\"max_output_tokens\":80,\"max_tool_calls\":null,\"model\":\"gpt-5.5-2026-04-23\",\"moderation\":null,\"output\":[],\"parallel_tool_calls\":true,\"presence_penalty\":0.0,\"previous_response_id\":null,\"prompt_cache_key\":null,\"prompt_cache_retention\":\"24h\",\"reasoning\":{\"effort\":\"medium\",\"summary\":null},\"safety_identifier\":null,\"service_tier\":\"auto\",\"store\":true,\"temperature\":1.0,\"text\":{\"format\":{\"type\":\"text\"},\"verbosity\":\"medium\"},\"tool_choice\":\"auto\",\"tools\":[{\"type\":\"function\",\"description\":\"Get current weather for a city.\",\"name\":\"get_weather\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":true}],\"top_logprobs\":0,\"top_p\":0.98,\"truncation\":\"disabled\",\"usage\":null,\"user\":null,\"metadata\":{}},\"sequence_number\":0}\n\nevent: response.in_progress\ndata: {\"type\":\"response.in_progress\",\"response\":{\"id\":\"resp_01394305fdec6fdd0069fa8aa414cc81a1908662495e7c9bd9\",\"object\":\"response\",\"created_at\":1778027172,\"status\":\"in_progress\",\"background\":false,\"completed_at\":null,\"error\":null,\"frequency_penalty\":0.0,\"incomplete_details\":null,\"instructions\":null,\"max_output_tokens\":80,\"max_tool_calls\":null,\"model\":\"gpt-5.5-2026-04-23\",\"moderation\":null,\"output\":[],\"parallel_tool_calls\":true,\"presence_penalty\":0.0,\"previous_response_id\":null,\"prompt_cache_key\":null,\"prompt_cache_retention\":\"24h\",\"reasoning\":{\"effort\":\"medium\",\"summary\":null},\"safety_identifier\":null,\"service_tier\":\"auto\",\"store\":true,\"temperature\":1.0,\"text\":{\"format\":{\"type\":\"text\"},\"verbosity\":\"medium\"},\"tool_choice\":\"auto\",\"tools\":[{\"type\":\"function\",\"description\":\"Get current weather for a city.\",\"name\":\"get_weather\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":true}],\"top_logprobs\":0,\"top_p\":0.98,\"truncation\":\"disabled\",\"usage\":null,\"user\":null,\"metadata\":{}},\"sequence_number\":1}\n\nevent: response.output_item.added\ndata: {\"type\":\"response.output_item.added\",\"item\":{\"id\":\"fc_01394305fdec6fdd0069fa8aa51a3881a1a2e74c58f5c368d4\",\"type\":\"function_call\",\"status\":\"in_progress\",\"arguments\":\"\",\"call_id\":\"call_JCuVTkQxVB3cCmFWx52adJKZ\",\"name\":\"get_weather\"},\"output_index\":0,\"sequence_number\":2}\n\nevent: response.function_call_arguments.delta\ndata: {\"type\":\"response.function_call_arguments.delta\",\"delta\":\"{\\\"\",\"item_id\":\"fc_01394305fdec6fdd0069fa8aa51a3881a1a2e74c58f5c368d4\",\"obfuscation\":\"5DTUG002eUNyAN\",\"output_index\":0,\"sequence_number\":3}\n\nevent: response.function_call_arguments.delta\ndata: {\"type\":\"response.function_call_arguments.delta\",\"delta\":\"city\",\"item_id\":\"fc_01394305fdec6fdd0069fa8aa51a3881a1a2e74c58f5c368d4\",\"obfuscation\":\"cbezJUlKOHJ8\",\"output_index\":0,\"sequence_number\":4}\n\nevent: response.function_call_arguments.delta\ndata: {\"type\":\"response.function_call_arguments.delta\",\"delta\":\"\\\":\\\"\",\"item_id\":\"fc_01394305fdec6fdd0069fa8aa51a3881a1a2e74c58f5c368d4\",\"obfuscation\":\"Du6y75R0eXTqj\",\"output_index\":0,\"sequence_number\":5}\n\nevent: response.function_call_arguments.delta\ndata: {\"type\":\"response.function_call_arguments.delta\",\"delta\":\"Paris\",\"item_id\":\"fc_01394305fdec6fdd0069fa8aa51a3881a1a2e74c58f5c368d4\",\"obfuscation\":\"dHUPwHp6aIB\",\"output_index\":0,\"sequence_number\":6}\n\nevent: response.function_call_arguments.delta\ndata: {\"type\":\"response.function_call_arguments.delta\",\"delta\":\"\\\"}\",\"item_id\":\"fc_01394305fdec6fdd0069fa8aa51a3881a1a2e74c58f5c368d4\",\"obfuscation\":\"4A6QSCyeBQa1fC\",\"output_index\":0,\"sequence_number\":7}\n\nevent: response.function_call_arguments.done\ndata: {\"type\":\"response.function_call_arguments.done\",\"arguments\":\"{\\\"city\\\":\\\"Paris\\\"}\",\"item_id\":\"fc_01394305fdec6fdd0069fa8aa51a3881a1a2e74c58f5c368d4\",\"output_index\":0,\"sequence_number\":8}\n\nevent: response.output_item.done\ndata: {\"type\":\"response.output_item.done\",\"item\":{\"id\":\"fc_01394305fdec6fdd0069fa8aa51a3881a1a2e74c58f5c368d4\",\"type\":\"function_call\",\"status\":\"completed\",\"arguments\":\"{\\\"city\\\":\\\"Paris\\\"}\",\"call_id\":\"call_JCuVTkQxVB3cCmFWx52adJKZ\",\"name\":\"get_weather\"},\"output_index\":0,\"sequence_number\":9}\n\nevent: response.completed\ndata: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_01394305fdec6fdd0069fa8aa414cc81a1908662495e7c9bd9\",\"object\":\"response\",\"created_at\":1778027172,\"status\":\"completed\",\"background\":false,\"completed_at\":1778027173,\"error\":null,\"frequency_penalty\":0.0,\"incomplete_details\":null,\"instructions\":null,\"max_output_tokens\":80,\"max_tool_calls\":null,\"model\":\"gpt-5.5-2026-04-23\",\"moderation\":null,\"output\":[{\"id\":\"fc_01394305fdec6fdd0069fa8aa51a3881a1a2e74c58f5c368d4\",\"type\":\"function_call\",\"status\":\"completed\",\"arguments\":\"{\\\"city\\\":\\\"Paris\\\"}\",\"call_id\":\"call_JCuVTkQxVB3cCmFWx52adJKZ\",\"name\":\"get_weather\"}],\"parallel_tool_calls\":true,\"presence_penalty\":0.0,\"previous_response_id\":null,\"prompt_cache_key\":null,\"prompt_cache_retention\":\"24h\",\"reasoning\":{\"effort\":\"medium\",\"summary\":null},\"safety_identifier\":null,\"service_tier\":\"default\",\"store\":true,\"temperature\":1.0,\"text\":{\"format\":{\"type\":\"text\"},\"verbosity\":\"medium\"},\"tool_choice\":\"auto\",\"tools\":[{\"type\":\"function\",\"description\":\"Get current weather for a city.\",\"name\":\"get_weather\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":true}],\"top_logprobs\":0,\"top_p\":0.98,\"truncation\":\"disabled\",\"usage\":{\"input_tokens\":67,\"input_tokens_details\":{\"cached_tokens\":0},\"output_tokens\":18,\"output_tokens_details\":{\"reasoning_tokens\":0},\"total_tokens\":85},\"user\":null,\"metadata\":{}},\"sequence_number\":10}\n\n" + } + }, + { + "transport": "http", + "request": { + "method": "POST", + "url": "https://api.openai.com/v1/responses", + "headers": { + "content-type": "application/json" + }, + "body": "{\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"system\",\"content\":\"Use the get_weather tool, then answer in one short sentence.\"},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"What is the weather in Paris?\"}]},{\"type\":\"function_call\",\"call_id\":\"call_JCuVTkQxVB3cCmFWx52adJKZ\",\"name\":\"get_weather\",\"arguments\":\"{\\\"city\\\":\\\"Paris\\\"}\"},{\"type\":\"function_call_output\",\"call_id\":\"call_JCuVTkQxVB3cCmFWx52adJKZ\",\"output\":\"{\\\"temperature\\\":22,\\\"condition\\\":\\\"sunny\\\"}\"}],\"tools\":[{\"type\":\"function\",\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":false}],\"stream\":true,\"max_output_tokens\":80}" + }, + "response": { + "status": 200, + "headers": { + "content-type": "text/event-stream; charset=utf-8" + }, + "body": "event: response.created\ndata: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_00daac70c40e5f4c0069fa8aa5a58c819db01baef7149e9043\",\"object\":\"response\",\"created_at\":1778027173,\"status\":\"in_progress\",\"background\":false,\"completed_at\":null,\"error\":null,\"frequency_penalty\":0.0,\"incomplete_details\":null,\"instructions\":null,\"max_output_tokens\":80,\"max_tool_calls\":null,\"model\":\"gpt-5.5-2026-04-23\",\"moderation\":null,\"output\":[],\"parallel_tool_calls\":true,\"presence_penalty\":0.0,\"previous_response_id\":null,\"prompt_cache_key\":null,\"prompt_cache_retention\":\"24h\",\"reasoning\":{\"effort\":\"medium\",\"summary\":null},\"safety_identifier\":null,\"service_tier\":\"auto\",\"store\":true,\"temperature\":1.0,\"text\":{\"format\":{\"type\":\"text\"},\"verbosity\":\"medium\"},\"tool_choice\":\"auto\",\"tools\":[{\"type\":\"function\",\"description\":\"Get current weather for a city.\",\"name\":\"get_weather\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":true}],\"top_logprobs\":0,\"top_p\":0.98,\"truncation\":\"disabled\",\"usage\":null,\"user\":null,\"metadata\":{}},\"sequence_number\":0}\n\nevent: response.in_progress\ndata: {\"type\":\"response.in_progress\",\"response\":{\"id\":\"resp_00daac70c40e5f4c0069fa8aa5a58c819db01baef7149e9043\",\"object\":\"response\",\"created_at\":1778027173,\"status\":\"in_progress\",\"background\":false,\"completed_at\":null,\"error\":null,\"frequency_penalty\":0.0,\"incomplete_details\":null,\"instructions\":null,\"max_output_tokens\":80,\"max_tool_calls\":null,\"model\":\"gpt-5.5-2026-04-23\",\"moderation\":null,\"output\":[],\"parallel_tool_calls\":true,\"presence_penalty\":0.0,\"previous_response_id\":null,\"prompt_cache_key\":null,\"prompt_cache_retention\":\"24h\",\"reasoning\":{\"effort\":\"medium\",\"summary\":null},\"safety_identifier\":null,\"service_tier\":\"auto\",\"store\":true,\"temperature\":1.0,\"text\":{\"format\":{\"type\":\"text\"},\"verbosity\":\"medium\"},\"tool_choice\":\"auto\",\"tools\":[{\"type\":\"function\",\"description\":\"Get current weather for a city.\",\"name\":\"get_weather\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":true}],\"top_logprobs\":0,\"top_p\":0.98,\"truncation\":\"disabled\",\"usage\":null,\"user\":null,\"metadata\":{}},\"sequence_number\":1}\n\nevent: response.output_item.added\ndata: {\"type\":\"response.output_item.added\",\"item\":{\"id\":\"msg_00daac70c40e5f4c0069fa8aa697a8819daf6660168cb19951\",\"type\":\"message\",\"status\":\"in_progress\",\"content\":[],\"phase\":\"final_answer\",\"role\":\"assistant\"},\"output_index\":0,\"sequence_number\":2}\n\nevent: response.content_part.added\ndata: {\"type\":\"response.content_part.added\",\"content_index\":0,\"item_id\":\"msg_00daac70c40e5f4c0069fa8aa697a8819daf6660168cb19951\",\"output_index\":0,\"part\":{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"\"},\"sequence_number\":3}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\"It\",\"item_id\":\"msg_00daac70c40e5f4c0069fa8aa697a8819daf6660168cb19951\",\"logprobs\":[],\"obfuscation\":\"chiK1sgLg8rTyK\",\"output_index\":0,\"sequence_number\":4}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\"’s\",\"item_id\":\"msg_00daac70c40e5f4c0069fa8aa697a8819daf6660168cb19951\",\"logprobs\":[],\"obfuscation\":\"ltAaX7wDQM1X8W\",\"output_index\":0,\"sequence_number\":5}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\" sunny\",\"item_id\":\"msg_00daac70c40e5f4c0069fa8aa697a8819daf6660168cb19951\",\"logprobs\":[],\"obfuscation\":\"a6nggmY4w0\",\"output_index\":0,\"sequence_number\":6}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\" and\",\"item_id\":\"msg_00daac70c40e5f4c0069fa8aa697a8819daf6660168cb19951\",\"logprobs\":[],\"obfuscation\":\"Fm6HNREc68IM\",\"output_index\":0,\"sequence_number\":7}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\" \",\"item_id\":\"msg_00daac70c40e5f4c0069fa8aa697a8819daf6660168cb19951\",\"logprobs\":[],\"obfuscation\":\"AvKNavT4eKhSpud\",\"output_index\":0,\"sequence_number\":8}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\"22\",\"item_id\":\"msg_00daac70c40e5f4c0069fa8aa697a8819daf6660168cb19951\",\"logprobs\":[],\"obfuscation\":\"xfJpoPh3ZBNXow\",\"output_index\":0,\"sequence_number\":9}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\"°C\",\"item_id\":\"msg_00daac70c40e5f4c0069fa8aa697a8819daf6660168cb19951\",\"logprobs\":[],\"obfuscation\":\"PbrlZXftzmtJBV\",\"output_index\":0,\"sequence_number\":10}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\" in\",\"item_id\":\"msg_00daac70c40e5f4c0069fa8aa697a8819daf6660168cb19951\",\"logprobs\":[],\"obfuscation\":\"PLrf8voVO2egp\",\"output_index\":0,\"sequence_number\":11}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\" Paris\",\"item_id\":\"msg_00daac70c40e5f4c0069fa8aa697a8819daf6660168cb19951\",\"logprobs\":[],\"obfuscation\":\"U4wLv1H29b\",\"output_index\":0,\"sequence_number\":12}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\".\",\"item_id\":\"msg_00daac70c40e5f4c0069fa8aa697a8819daf6660168cb19951\",\"logprobs\":[],\"obfuscation\":\"1n14oh7kAoCuo4f\",\"output_index\":0,\"sequence_number\":13}\n\nevent: response.output_text.done\ndata: {\"type\":\"response.output_text.done\",\"content_index\":0,\"item_id\":\"msg_00daac70c40e5f4c0069fa8aa697a8819daf6660168cb19951\",\"logprobs\":[],\"output_index\":0,\"sequence_number\":14,\"text\":\"It’s sunny and 22°C in Paris.\"}\n\nevent: response.content_part.done\ndata: {\"type\":\"response.content_part.done\",\"content_index\":0,\"item_id\":\"msg_00daac70c40e5f4c0069fa8aa697a8819daf6660168cb19951\",\"output_index\":0,\"part\":{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"It’s sunny and 22°C in Paris.\"},\"sequence_number\":15}\n\nevent: response.output_item.done\ndata: {\"type\":\"response.output_item.done\",\"item\":{\"id\":\"msg_00daac70c40e5f4c0069fa8aa697a8819daf6660168cb19951\",\"type\":\"message\",\"status\":\"completed\",\"content\":[{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"It’s sunny and 22°C in Paris.\"}],\"phase\":\"final_answer\",\"role\":\"assistant\"},\"output_index\":0,\"sequence_number\":16}\n\nevent: response.completed\ndata: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_00daac70c40e5f4c0069fa8aa5a58c819db01baef7149e9043\",\"object\":\"response\",\"created_at\":1778027173,\"status\":\"completed\",\"background\":false,\"completed_at\":1778027174,\"error\":null,\"frequency_penalty\":0.0,\"incomplete_details\":null,\"instructions\":null,\"max_output_tokens\":80,\"max_tool_calls\":null,\"model\":\"gpt-5.5-2026-04-23\",\"moderation\":null,\"output\":[{\"id\":\"msg_00daac70c40e5f4c0069fa8aa697a8819daf6660168cb19951\",\"type\":\"message\",\"status\":\"completed\",\"content\":[{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"It’s sunny and 22°C in Paris.\"}],\"phase\":\"final_answer\",\"role\":\"assistant\"}],\"parallel_tool_calls\":true,\"presence_penalty\":0.0,\"previous_response_id\":null,\"prompt_cache_key\":null,\"prompt_cache_retention\":\"24h\",\"reasoning\":{\"effort\":\"medium\",\"summary\":null},\"safety_identifier\":null,\"service_tier\":\"default\",\"store\":true,\"temperature\":1.0,\"text\":{\"format\":{\"type\":\"text\"},\"verbosity\":\"medium\"},\"tool_choice\":\"auto\",\"tools\":[{\"type\":\"function\",\"description\":\"Get current weather for a city.\",\"name\":\"get_weather\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":true}],\"top_logprobs\":0,\"top_p\":0.98,\"truncation\":\"disabled\",\"usage\":{\"input_tokens\":106,\"input_tokens_details\":{\"cached_tokens\":0},\"output_tokens\":14,\"output_tokens_details\":{\"reasoning_tokens\":0},\"total_tokens\":120},\"user\":null,\"metadata\":{}},\"sequence_number\":17}\n\n" + } + } + ] +} diff --git a/packages/llm/test/fixtures/recordings/openai-responses/gpt-5-5-streams-text.json b/packages/llm/test/fixtures/recordings/openai-responses/gpt-5-5-streams-text.json new file mode 100644 index 0000000000000000000000000000000000000000..92c7b7e0f1a0d6d9f884e4513a68368e107b51d7 --- /dev/null +++ b/packages/llm/test/fixtures/recordings/openai-responses/gpt-5-5-streams-text.json @@ -0,0 +1,28 @@ +{ + "version": 1, + "metadata": { + "name": "openai-responses/gpt-5-5-streams-text", + "recordedAt": "2026-05-06T00:26:10.447Z", + "tags": ["prefix:openai-responses", "provider:openai", "protocol:openai-responses", "flagship"] + }, + "interactions": [ + { + "transport": "http", + "request": { + "method": "POST", + "url": "https://api.openai.com/v1/responses", + "headers": { + "content-type": "application/json" + }, + "body": "{\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"system\",\"content\":\"You are concise.\"},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Reply with exactly: Hello!\"}]}],\"stream\":true,\"max_output_tokens\":80}" + }, + "response": { + "status": 200, + "headers": { + "content-type": "text/event-stream; charset=utf-8" + }, + "body": "event: response.created\ndata: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_0ea948e2f42449980069fa8aa0e4b4819ca3395b74c53c13fa\",\"object\":\"response\",\"created_at\":1778027168,\"status\":\"in_progress\",\"background\":false,\"completed_at\":null,\"error\":null,\"frequency_penalty\":0.0,\"incomplete_details\":null,\"instructions\":null,\"max_output_tokens\":80,\"max_tool_calls\":null,\"model\":\"gpt-5.5-2026-04-23\",\"moderation\":null,\"output\":[],\"parallel_tool_calls\":true,\"presence_penalty\":0.0,\"previous_response_id\":null,\"prompt_cache_key\":null,\"prompt_cache_retention\":\"24h\",\"reasoning\":{\"effort\":\"medium\",\"summary\":null},\"safety_identifier\":null,\"service_tier\":\"auto\",\"store\":true,\"temperature\":1.0,\"text\":{\"format\":{\"type\":\"text\"},\"verbosity\":\"medium\"},\"tool_choice\":\"auto\",\"tools\":[],\"top_logprobs\":0,\"top_p\":0.98,\"truncation\":\"disabled\",\"usage\":null,\"user\":null,\"metadata\":{}},\"sequence_number\":0}\n\nevent: response.in_progress\ndata: {\"type\":\"response.in_progress\",\"response\":{\"id\":\"resp_0ea948e2f42449980069fa8aa0e4b4819ca3395b74c53c13fa\",\"object\":\"response\",\"created_at\":1778027168,\"status\":\"in_progress\",\"background\":false,\"completed_at\":null,\"error\":null,\"frequency_penalty\":0.0,\"incomplete_details\":null,\"instructions\":null,\"max_output_tokens\":80,\"max_tool_calls\":null,\"model\":\"gpt-5.5-2026-04-23\",\"moderation\":null,\"output\":[],\"parallel_tool_calls\":true,\"presence_penalty\":0.0,\"previous_response_id\":null,\"prompt_cache_key\":null,\"prompt_cache_retention\":\"24h\",\"reasoning\":{\"effort\":\"medium\",\"summary\":null},\"safety_identifier\":null,\"service_tier\":\"auto\",\"store\":true,\"temperature\":1.0,\"text\":{\"format\":{\"type\":\"text\"},\"verbosity\":\"medium\"},\"tool_choice\":\"auto\",\"tools\":[],\"top_logprobs\":0,\"top_p\":0.98,\"truncation\":\"disabled\",\"usage\":null,\"user\":null,\"metadata\":{}},\"sequence_number\":1}\n\nevent: response.output_item.added\ndata: {\"type\":\"response.output_item.added\",\"item\":{\"id\":\"rs_0ea948e2f42449980069fa8aa1d588819cbbcb9b056624d27c\",\"type\":\"reasoning\",\"summary\":[]},\"output_index\":0,\"sequence_number\":2}\n\nevent: response.output_item.done\ndata: {\"type\":\"response.output_item.done\",\"item\":{\"id\":\"rs_0ea948e2f42449980069fa8aa1d588819cbbcb9b056624d27c\",\"type\":\"reasoning\",\"summary\":[]},\"output_index\":0,\"sequence_number\":3}\n\nevent: response.output_item.added\ndata: {\"type\":\"response.output_item.added\",\"item\":{\"id\":\"msg_0ea948e2f42449980069fa8aa20e38819cbf5be70e4d02a1c7\",\"type\":\"message\",\"status\":\"in_progress\",\"content\":[],\"phase\":\"final_answer\",\"role\":\"assistant\"},\"output_index\":1,\"sequence_number\":4}\n\nevent: response.content_part.added\ndata: {\"type\":\"response.content_part.added\",\"content_index\":0,\"item_id\":\"msg_0ea948e2f42449980069fa8aa20e38819cbf5be70e4d02a1c7\",\"output_index\":1,\"part\":{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"\"},\"sequence_number\":5}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\"Hello\",\"item_id\":\"msg_0ea948e2f42449980069fa8aa20e38819cbf5be70e4d02a1c7\",\"logprobs\":[],\"obfuscation\":\"VTjmFwAGgIo\",\"output_index\":1,\"sequence_number\":6}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\"!\",\"item_id\":\"msg_0ea948e2f42449980069fa8aa20e38819cbf5be70e4d02a1c7\",\"logprobs\":[],\"obfuscation\":\"PfjFymS7MZa7aYf\",\"output_index\":1,\"sequence_number\":7}\n\nevent: response.output_text.done\ndata: {\"type\":\"response.output_text.done\",\"content_index\":0,\"item_id\":\"msg_0ea948e2f42449980069fa8aa20e38819cbf5be70e4d02a1c7\",\"logprobs\":[],\"output_index\":1,\"sequence_number\":8,\"text\":\"Hello!\"}\n\nevent: response.content_part.done\ndata: {\"type\":\"response.content_part.done\",\"content_index\":0,\"item_id\":\"msg_0ea948e2f42449980069fa8aa20e38819cbf5be70e4d02a1c7\",\"output_index\":1,\"part\":{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"Hello!\"},\"sequence_number\":9}\n\nevent: response.output_item.done\ndata: {\"type\":\"response.output_item.done\",\"item\":{\"id\":\"msg_0ea948e2f42449980069fa8aa20e38819cbf5be70e4d02a1c7\",\"type\":\"message\",\"status\":\"completed\",\"content\":[{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"Hello!\"}],\"phase\":\"final_answer\",\"role\":\"assistant\"},\"output_index\":1,\"sequence_number\":10}\n\nevent: response.completed\ndata: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_0ea948e2f42449980069fa8aa0e4b4819ca3395b74c53c13fa\",\"object\":\"response\",\"created_at\":1778027168,\"status\":\"completed\",\"background\":false,\"completed_at\":1778027170,\"error\":null,\"frequency_penalty\":0.0,\"incomplete_details\":null,\"instructions\":null,\"max_output_tokens\":80,\"max_tool_calls\":null,\"model\":\"gpt-5.5-2026-04-23\",\"moderation\":null,\"output\":[{\"id\":\"rs_0ea948e2f42449980069fa8aa1d588819cbbcb9b056624d27c\",\"type\":\"reasoning\",\"summary\":[]},{\"id\":\"msg_0ea948e2f42449980069fa8aa20e38819cbf5be70e4d02a1c7\",\"type\":\"message\",\"status\":\"completed\",\"content\":[{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"Hello!\"}],\"phase\":\"final_answer\",\"role\":\"assistant\"}],\"parallel_tool_calls\":true,\"presence_penalty\":0.0,\"previous_response_id\":null,\"prompt_cache_key\":null,\"prompt_cache_retention\":\"24h\",\"reasoning\":{\"effort\":\"medium\",\"summary\":null},\"safety_identifier\":null,\"service_tier\":\"default\",\"store\":true,\"temperature\":1.0,\"text\":{\"format\":{\"type\":\"text\"},\"verbosity\":\"medium\"},\"tool_choice\":\"auto\",\"tools\":[],\"top_logprobs\":0,\"top_p\":0.98,\"truncation\":\"disabled\",\"usage\":{\"input_tokens\":20,\"input_tokens_details\":{\"cached_tokens\":0},\"output_tokens\":18,\"output_tokens_details\":{\"reasoning_tokens\":10},\"total_tokens\":38},\"user\":null,\"metadata\":{}},\"sequence_number\":11}\n\n" + } + } + ] +} diff --git a/packages/llm/test/fixtures/recordings/openai-responses/gpt-5-5-streams-tool-call.json b/packages/llm/test/fixtures/recordings/openai-responses/gpt-5-5-streams-tool-call.json new file mode 100644 index 0000000000000000000000000000000000000000..8aa5dedaaf77139a9b6ef8c1270b089acc6c214f --- /dev/null +++ b/packages/llm/test/fixtures/recordings/openai-responses/gpt-5-5-streams-tool-call.json @@ -0,0 +1,28 @@ +{ + "version": 1, + "metadata": { + "name": "openai-responses/gpt-5-5-streams-tool-call", + "recordedAt": "2026-05-06T00:26:12.011Z", + "tags": ["prefix:openai-responses", "provider:openai", "protocol:openai-responses", "tool", "flagship"] + }, + "interactions": [ + { + "transport": "http", + "request": { + "method": "POST", + "url": "https://api.openai.com/v1/responses", + "headers": { + "content-type": "application/json" + }, + "body": "{\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"system\",\"content\":\"Call tools exactly as requested.\"},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Call get_weather with city exactly Paris.\"}]}],\"tools\":[{\"type\":\"function\",\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":false}],\"tool_choice\":{\"type\":\"function\",\"name\":\"get_weather\"},\"stream\":true,\"max_output_tokens\":80}" + }, + "response": { + "status": 200, + "headers": { + "content-type": "text/event-stream; charset=utf-8" + }, + "body": "event: response.created\ndata: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_05200a06f78f5b310069fa8aa28134819eba958e34eb1db6ae\",\"object\":\"response\",\"created_at\":1778027170,\"status\":\"in_progress\",\"background\":false,\"completed_at\":null,\"error\":null,\"frequency_penalty\":0.0,\"incomplete_details\":null,\"instructions\":null,\"max_output_tokens\":80,\"max_tool_calls\":null,\"model\":\"gpt-5.5-2026-04-23\",\"moderation\":null,\"output\":[],\"parallel_tool_calls\":true,\"presence_penalty\":0.0,\"previous_response_id\":null,\"prompt_cache_key\":null,\"prompt_cache_retention\":\"24h\",\"reasoning\":{\"effort\":\"medium\",\"summary\":null},\"safety_identifier\":null,\"service_tier\":\"auto\",\"store\":true,\"temperature\":1.0,\"text\":{\"format\":{\"type\":\"text\"},\"verbosity\":\"medium\"},\"tool_choice\":{\"type\":\"function\",\"name\":\"get_weather\"},\"tools\":[{\"type\":\"function\",\"description\":\"Get current weather for a city.\",\"name\":\"get_weather\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":true}],\"top_logprobs\":0,\"top_p\":0.98,\"truncation\":\"disabled\",\"usage\":null,\"user\":null,\"metadata\":{}},\"sequence_number\":0}\n\nevent: response.in_progress\ndata: {\"type\":\"response.in_progress\",\"response\":{\"id\":\"resp_05200a06f78f5b310069fa8aa28134819eba958e34eb1db6ae\",\"object\":\"response\",\"created_at\":1778027170,\"status\":\"in_progress\",\"background\":false,\"completed_at\":null,\"error\":null,\"frequency_penalty\":0.0,\"incomplete_details\":null,\"instructions\":null,\"max_output_tokens\":80,\"max_tool_calls\":null,\"model\":\"gpt-5.5-2026-04-23\",\"moderation\":null,\"output\":[],\"parallel_tool_calls\":true,\"presence_penalty\":0.0,\"previous_response_id\":null,\"prompt_cache_key\":null,\"prompt_cache_retention\":\"24h\",\"reasoning\":{\"effort\":\"medium\",\"summary\":null},\"safety_identifier\":null,\"service_tier\":\"auto\",\"store\":true,\"temperature\":1.0,\"text\":{\"format\":{\"type\":\"text\"},\"verbosity\":\"medium\"},\"tool_choice\":{\"type\":\"function\",\"name\":\"get_weather\"},\"tools\":[{\"type\":\"function\",\"description\":\"Get current weather for a city.\",\"name\":\"get_weather\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":true}],\"top_logprobs\":0,\"top_p\":0.98,\"truncation\":\"disabled\",\"usage\":null,\"user\":null,\"metadata\":{}},\"sequence_number\":1}\n\nevent: response.output_item.added\ndata: {\"type\":\"response.output_item.added\",\"item\":{\"id\":\"fc_05200a06f78f5b310069fa8aa37ca8819e9f131e85e47bcff9\",\"type\":\"function_call\",\"status\":\"in_progress\",\"arguments\":\"\",\"call_id\":\"call_ZAbAwsIFeJSyPqz3HaHRXBSn\",\"name\":\"get_weather\"},\"output_index\":0,\"sequence_number\":2}\n\nevent: response.function_call_arguments.delta\ndata: {\"type\":\"response.function_call_arguments.delta\",\"delta\":\"{\\\"\",\"item_id\":\"fc_05200a06f78f5b310069fa8aa37ca8819e9f131e85e47bcff9\",\"obfuscation\":\"X7dp3R85iTgHxP\",\"output_index\":0,\"sequence_number\":3}\n\nevent: response.function_call_arguments.delta\ndata: {\"type\":\"response.function_call_arguments.delta\",\"delta\":\"city\",\"item_id\":\"fc_05200a06f78f5b310069fa8aa37ca8819e9f131e85e47bcff9\",\"obfuscation\":\"ECfxJgedKWUn\",\"output_index\":0,\"sequence_number\":4}\n\nevent: response.function_call_arguments.delta\ndata: {\"type\":\"response.function_call_arguments.delta\",\"delta\":\"\\\":\\\"\",\"item_id\":\"fc_05200a06f78f5b310069fa8aa37ca8819e9f131e85e47bcff9\",\"obfuscation\":\"BYRjhhZxbw5AR\",\"output_index\":0,\"sequence_number\":5}\n\nevent: response.function_call_arguments.delta\ndata: {\"type\":\"response.function_call_arguments.delta\",\"delta\":\"Paris\",\"item_id\":\"fc_05200a06f78f5b310069fa8aa37ca8819e9f131e85e47bcff9\",\"obfuscation\":\"lmbnKOW4qyI\",\"output_index\":0,\"sequence_number\":6}\n\nevent: response.function_call_arguments.delta\ndata: {\"type\":\"response.function_call_arguments.delta\",\"delta\":\"\\\"}\",\"item_id\":\"fc_05200a06f78f5b310069fa8aa37ca8819e9f131e85e47bcff9\",\"obfuscation\":\"2PHhvsR2H0PNaP\",\"output_index\":0,\"sequence_number\":7}\n\nevent: response.function_call_arguments.done\ndata: {\"type\":\"response.function_call_arguments.done\",\"arguments\":\"{\\\"city\\\":\\\"Paris\\\"}\",\"item_id\":\"fc_05200a06f78f5b310069fa8aa37ca8819e9f131e85e47bcff9\",\"output_index\":0,\"sequence_number\":8}\n\nevent: response.output_item.done\ndata: {\"type\":\"response.output_item.done\",\"item\":{\"id\":\"fc_05200a06f78f5b310069fa8aa37ca8819e9f131e85e47bcff9\",\"type\":\"function_call\",\"status\":\"completed\",\"arguments\":\"{\\\"city\\\":\\\"Paris\\\"}\",\"call_id\":\"call_ZAbAwsIFeJSyPqz3HaHRXBSn\",\"name\":\"get_weather\"},\"output_index\":0,\"sequence_number\":9}\n\nevent: response.completed\ndata: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_05200a06f78f5b310069fa8aa28134819eba958e34eb1db6ae\",\"object\":\"response\",\"created_at\":1778027170,\"status\":\"completed\",\"background\":false,\"completed_at\":1778027171,\"error\":null,\"frequency_penalty\":0.0,\"incomplete_details\":null,\"instructions\":null,\"max_output_tokens\":80,\"max_tool_calls\":null,\"model\":\"gpt-5.5-2026-04-23\",\"moderation\":null,\"output\":[{\"id\":\"fc_05200a06f78f5b310069fa8aa37ca8819e9f131e85e47bcff9\",\"type\":\"function_call\",\"status\":\"completed\",\"arguments\":\"{\\\"city\\\":\\\"Paris\\\"}\",\"call_id\":\"call_ZAbAwsIFeJSyPqz3HaHRXBSn\",\"name\":\"get_weather\"}],\"parallel_tool_calls\":true,\"presence_penalty\":0.0,\"previous_response_id\":null,\"prompt_cache_key\":null,\"prompt_cache_retention\":\"24h\",\"reasoning\":{\"effort\":\"medium\",\"summary\":null},\"safety_identifier\":null,\"service_tier\":\"default\",\"store\":true,\"temperature\":1.0,\"text\":{\"format\":{\"type\":\"text\"},\"verbosity\":\"medium\"},\"tool_choice\":{\"type\":\"function\",\"name\":\"get_weather\"},\"tools\":[{\"type\":\"function\",\"description\":\"Get current weather for a city.\",\"name\":\"get_weather\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":true}],\"top_logprobs\":0,\"top_p\":0.98,\"truncation\":\"disabled\",\"usage\":{\"input_tokens\":61,\"input_tokens_details\":{\"cached_tokens\":0},\"output_tokens\":18,\"output_tokens_details\":{\"reasoning_tokens\":0},\"total_tokens\":79},\"user\":null,\"metadata\":{}},\"sequence_number\":10}\n\n" + } + } + ] +} diff --git a/packages/llm/test/fixtures/recordings/openai-responses/openai-responses-gpt-5-5-image-tool-result.json b/packages/llm/test/fixtures/recordings/openai-responses/openai-responses-gpt-5-5-image-tool-result.json new file mode 100644 index 0000000000000000000000000000000000000000..4f65f43b5766c6d5a635893be6e9eed261a83067 --- /dev/null +++ b/packages/llm/test/fixtures/recordings/openai-responses/openai-responses-gpt-5-5-image-tool-result.json @@ -0,0 +1,42 @@ +{ + "version": 1, + "metadata": { + "name": "openai-responses/openai-responses-gpt-5-5-image-tool-result", + "recordedAt": "2026-05-23T23:19:19.231Z", + "provider": "openai", + "route": "openai-responses", + "transport": "http", + "model": "gpt-5.5", + "tags": [ + "prefix:openai-responses", + "provider:openai", + "flagship", + "media", + "image", + "vision", + "tool", + "tool-result", + "golden" + ] + }, + "interactions": [ + { + "transport": "http", + "request": { + "method": "POST", + "url": "https://api.openai.com/v1/responses", + "headers": { + "content-type": "application/json" + }, + "body": "{\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"system\",\"content\":\"Read images carefully. Reply only with the visible text, lowercase, no punctuation.\"},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Use the read_screenshot tool, then reply with the words shown.\"}]},{\"type\":\"function_call\",\"call_id\":\"call_screenshot_1\",\"name\":\"read_screenshot\",\"arguments\":\"{}\"},{\"type\":\"function_call_output\",\"call_id\":\"call_screenshot_1\",\"output\":[{\"type\":\"input_text\",\"text\":\"Image read successfully\"},{\"type\":\"input_image\",\"image_url\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAnYAAACKCAYAAAAnmweyAAACKWlUWHRYTUw6Y29tLmFkb2JlLnhtcAAAAAAAPD94cGFja2V0IGJlZ2luPSLvu78iIGlkPSJXNU0wTXBDZWhpSHpyZVN6TlRjemtjOWQiPz4KPHg6eG1wbWV0YSB4bWxuczp4PSJhZG9iZTpuczptZXRhLyIgeDp4bXB0az0iWE1QIENvcmUgNi4wLjAiPgogPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4KICA8cmRmOkRlc2NyaXB0aW9uIHJkZjphYm91dD0iIgogICAgeG1sbnM6ZXhpZj0iaHR0cDovL25zLmFkb2JlLmNvbS9leGlmLzEuMC8iCiAgICB4bWxuczp0aWZmPSJodHRwOi8vbnMuYWRvYmUuY29tL3RpZmYvMS4wLyIKICAgZXhpZjpQaXhlbFhEaW1lbnNpb249IjYzMCIKICAgZXhpZjpVc2VyQ29tbWVudD0iU2NyZWVuc2hvdCIKICAgZXhpZjpQaXhlbFlEaW1lbnNpb249IjEzOCIKICAgdGlmZjpZUmVzb2x1dGlvbj0iMTQ0LzEiCiAgIHRpZmY6WFJlc29sdXRpb249IjE0NC8xIgogICB0aWZmOlJlc29sdXRpb25Vbml0PSIyIi8+CiA8L3JkZjpSREY+CjwveDp4bXBtZXRhPgo8P3hwYWNrZXQgZW5kPSJyIj8+at0SpgAACrhpQ0NQSUNDIFByb2ZpbGUAAEiJlZcHUFNZF8fvey+dhJYQASmh994CSAmhBVCQDjZCEiAQQkxBwa4sruBaUBHBsqKrIgo2qg0RxbYo9r4gi4iyLhZsqHwPGMLufvN933xn5s75zXnn/u+5d959cx4AFFOuRCKC1QHIFsul0SEBjMSkZAb+JcACTUACnoDK5ckkrKioCIDahP+7fbgLoFF/y25U69+f/1fT4AtkPACgKJRT+TJeNsonAIABTyKVA4CgDEwWyCWjfB9lmhQtEOWBUU4fY8yoDi11nGljObHRbJQtASCQuVxpOgBkVzTOyOWlozrkWJQdxXyhGOUClH2zs3P4KLehbInmSFAe1Wem/kUn/W+aqUpNLjddyeN7GTNCoFAmEXHz/s/j+N+WLVJMrGGBDnKGNDQa9Xrouf2elROuZHHqjMgJFvLH8sc4QxEaN8E8GTt5gmWiGM4E87mB4Uod0YyICU4TBitzhHJO7AQLZEExEyzNiVaumyZlsyaYK52sQZEVp4xnCDhK/fyM2IQJzhXGz1DWlhUTPpnDVsalimjlXgTikIDJdYOV55At+8vehRzlXHlGbKjyHLiT9QvErElNWaKyNr4gMGgyJ06ZL5EHKNeSiKKU+QJRiDIuy41RzpWjL+fk3CjlGWZyw6ImGMQAOVAAPhCCHMAAgaiXAQkQAS7IkwsWykc3xM6R5EmF6RlyBgu9dQIGR8yzt2U4Ozq7AzB6h8dfkXf0sbsJ0a9MxlZVAeDTNDIycnIyFnYDgKMpAJDqJmOWcwBQ7wPg0imeQpo7Hhu7a1j0y6AGaEAHGAATYAnsgDNwB97AHwSBMBAJYkESmAt4IANkAylYABaDFaAQFIMNYAsoB7vAHnAAHAbHQAM4Bc6Bi+AquAHugEegC/SCV2AQfADDEAThIQpEhXQgQ8gMsoGcISbkCwVBEVA0lASlQOmQGFJAi6FVUDFUApVDu6Eq6CjUBJ2DLkOd0AOoG+qH3kJfYAQmwzRYHzaHHWAmzILD4Vh4DpwOz4fz4QJ4HVwGV8KH4Hr4HHwVvgN3wa/gIQQgKggdMULsECbCRiKRZCQNkSJLkSKkFKlEapBmpB25hXQhA8hnDA5DxTAwdhhvTCgmDsPDzMcsxazFlGMOYOoxbZhbmG7MIOY7loLVw9pgvbAcbCI2HbsAW4gtxe7D1mEvYO9ge7EfcDgcHWeB88CF4pJwmbhFuLW4HbhaXAuuE9eDG8Lj8Tp4G7wPPhLPxcvxhfht+EP4s/ib+F78J4IKwZDgTAgmJBPEhJWEUsJBwhnCTUIfYZioTjQjehEjiXxiHnE9cS+xmXid2EscJmmQLEg+pFhSJmkFqYxUQ7pAekx6p6KiYqziqTJTRaiyXKVM5YjKJZVulc9kTbI1mU2eTVaQ15H3k1vID8jvKBSKOcWfkkyRU9ZRqijnKU8pn1SpqvaqHFW+6jLVCtV61Zuqr9WIamZqLLW5avlqpWrH1a6rDagT1c3V2epc9aXqFepN6vfUhzSoGk4akRrZGms1Dmpc1nihidc01wzS5GsWaO7RPK/ZQ0WoJlQ2lUddRd1LvUDtpeFoFjQOLZNWTDtM66ANamlquWrFay3UqtA6rdVFR+jmdA5dRF9PP0a/S/8yRX8Ka4pgypopNVNuTvmoPVXbX1ugXaRdq31H+4sOQydIJ0tno06DzhNdjK617kzdBbo7dS/oDkylTfWeyptaNPXY1Id6sJ61XrTeIr09etf0hvQN9EP0Jfrb9M/rDxjQDfwNMg02G5wx6DekGvoaCg03G541fMnQYrAYIkYZo40xaKRnFGqkMNpt1GE0bGxhHGe80rjW+IkJyYRpkmay2aTVZNDU0HS66WLTatOHZkQzplmG2VazdrOP5hbmCearzRvMX1hoW3As8i2qLR5bUiz9LOdbVlretsJZMa2yrHZY3bCGrd2sM6wrrK/bwDbuNkKbHTadtlhbT1uxbaXtPTuyHcsu167artuebh9hv9K+wf61g6lDssNGh3aH745ujiLHvY6PnDSdwpxWOjU7vXW2duY5VzjfdqG4BLssc2l0eeNq4ypw3el6343qNt1ttVur2zd3D3epe417v4epR4rHdo97TBozirmWeckT6xnguczzlOdnL3cvudcxrz+97byzvA96v5hmMU0wbe+0Hh9jH67Pbp8uX4Zviu/Pvl1+Rn5cv0q/Z/4m/nz/ff59LCtWJusQ63WAY4A0oC7gI9uLvYTdEogEhgQWBXYEaQbFBZUHPQ02Dk4Prg4eDHELWRTSEooNDQ/dGHqPo8/hcao4g2EeYUvC2sLJ4THh5eHPIqwjpBHN0+HpYdM3TX88w2yGeEZDJIjkRG6KfBJlETU/6uRM3MyomRUzn0c7RS+Obo+hxsyLORjzITYgdn3sozjLOEVca7xa/Oz4qviPCYEJJQldiQ6JSxKvJukmCZMak/HJ8cn7kodmBc3aMqt3ttvswtl351jMWTjn8lzduaK5p+epzePOO56CTUlIOZjylRvJreQOpXJSt6cO8ti8rbxXfH/+Zn6/wEdQIuhL80krSXuR7pO+Kb0/wy+jNGNAyBaWC99khmbuyvyYFZm1P2tElCCqzSZkp2Q3iTXFWeK2HIOchTmdEhtJoaRrvtf8LfMHpeHSfTJINkfWKKehzdI1haXiB0V3rm9uRe6nBfELji/UWCheeC3POm9NXl9+cP4vizCLeItaFxstXrG4ewlrye6l0NLUpa3LTJYVLOtdHrL8wArSiqwVv650XFmy8v2qhFXNBfoFywt6fgj5obpQtVBaeG+19+pdP2J+FP7YscZlzbY134v4RVeKHYtLi7+u5a298pPTT2U/jaxLW9ex3n39zg24DeINdzf6bTxQolGSX9Kzafqm+s2MzUWb32+Zt+VyqWvprq2krYqtXWURZY3bTLdt2Pa1PKP8TkVARe12ve1rtn/cwd9xc6f/zppd+ruKd335Wfjz/d0hu+srzStL9+D25O55vjd+b/svzF+q9unuK973bb94f9eB6ANtVR5VVQf1Dq6vhqsV1f2HZh+6cTjwcGONXc3uWnpt8RFwRHHk5dGUo3ePhR9rPc48XnPC7MT2OmpdUT1Un1c/2JDR0NWY1NjZFNbU2uzdXHfS/uT+U0anKk5rnV5/hnSm4MzI2fyzQy2SloFz6ed6Wue1PjqfeP5228y2jgvhFy5dDL54vp3VfvaSz6VTl70uN11hXmm46n61/prbtbpf3X6t63DvqL/ucb3xhueN5s5pnWdu+t08dyvw1sXbnNtX78y403k37u79e7Pvdd3n33/xQPTgzcPch8OPlj/GPi56ov6k9Kne08rfrH6r7XLvOt0d2H3tWcyzRz28nle/y37/2lvwnPK8tM+wr+qF84tT/cH9N17Oetn7SvJqeKDwD40/tr+2fH3iT/8/rw0mDva+kb4Zebv2nc67/e9d37cORQ09/ZD9Yfhj0SedTwc+Mz+3f0n40je84Cv+a9k3q2/N38O/Px7JHhmRcKXcsVYAQQeclgbA2/0AUJIAoKI9BGnWeI89ZtD4f8EYgf/E4334mKGdSw3qRtsjdgsAR9BhvhwANX8ARlujWH8Au7gox0Q/PNa7jxoO/Yup8UK0Vjk9ta0C/7Txvv4vdf/TA6Xq3/y/AOOhDyne6KAWAAAAimVYSWZNTQAqAAAACAAEARoABQAAAAEAAAA+ARsABQAAAAEAAABGASgAAwAAAAEAAgAAh2kABAAAAAEAAABOAAAAAAAAAJAAAAABAAAAkAAAAAEAA5KGAAcAAAASAAAAeKACAAQAAAABAAACdqADAAQAAAABAAAAigAAAABBU0NJSQAAAFNjAAAAAAAAAADxh4F4AAAAHGlET1QAAAACAAAAAAAAAEUAAAAoAAAARQAAAEUAAAbT33OL9AAABp9JREFUeAHs3F9olWUcB/DnLHCT/rgKQxbhtLwpkIqsLrxZQfQXKggEA/tjZuCFCRHR1Wg3XiyhoKgVeKFd1k1CFNGNRAhhkFAQFBlSkLhjbqtNbW3jeOB0dt6dHc905/d8dnXe5332nvf3+b7jfGXMUt+NG6aTLwIECBAgQIAAgY4XKCl2HZ+hAQgQIECAAAECcwKKnQeBAAECBAgQIBBEQLELEqQxCBAgQIAAAQKKnWeAAAECBAgQIBBEQLELEqQxCBAgQIAAAQKKnWeAAAECBAgQIBBEQLELEqQxCBAgQIAAAQKKnWeAAAECBAgQIBBEQLELEqQxCBAgQIAAAQKKnWeAAAECBAgQIBBEQLELEqQxCBAgQIAAAQKKnWeAAAECBAgQIBBEoPT+4NHp+WY58edPaeST1+c7ZY0AAQIECBAgQGAZCpQ+H/ln3mJXPnMy7R4eWIa37JYIECBAgAABAgTmE1Ds5lOxRoAAAQIECBDoQIFFF7uelb0dOKZbJkCAAAECBAjEF1Ds4mdsQgIECBAgQCATAcUuk6CNSYAAAQIECMQXUOziZ2xCAgQIECBAIBMBxS6ToI1JgAABAgQIxBdQ7OJnbEICBAgQIEAgEwHFLpOgjUmAAAECBAjEF1Ds4mdsQgIECBAgQCATAcUuk6CNSYAAAQIECMQXUOziZ2xCAgQIECBAIBMBxS6ToI1JgAABAgQIxBdQ7OJnbEICBAgQIEAgEwHFLpOgjUmAAAECBAjEF1Ds4mdsQgIECBAgQCATAcUuk6CNSYAAAQIECMQXUOziZ2xCAgQIECBAIBMBxS6ToI1JgAABAgQIxBdQ7OJnbEICBAgQIEAgEwHFLpOgjUmAAAECBAjEF1Ds4mdsQgIECBAgQCATAcUuk6CNSYAAAQIECMQXUOziZ2xCAgQIECBAIBMBxS6ToI1JgAABAgQIxBdQ7OJnbEICBAgQIEAgEwHFLpOgjUmAAAECBAjEF1Ds4mdsQgIECBAgQCATAcUuk6CNSYAAAQIECMQXUOziZ2xCAgQIECBAIBMBxS6ToI1JgAABAgQIxBdQ7OJnbEICBAgQIEAgEwHFLpOgjUmAAAECBAjEF1Ds4mdsQgIECBAgQCATAcUuk6CNSYAAAQIECMQXUOziZ2xCAgQIECBAIBMBxS6ToI1JgAABAgQIxBdQ7OJnbEICBAgQIEAgEwHFLpOgjUmAAAECBAjEF1Ds4mdsQgIECBAgQCATgYbFburcZNoxdFcdQ8/K3ro1CwQIECBAgAABApdfoGGx+3d6Oj03uLHuDhW7OhILBAgQIECAAIFlIaDYLYsY3AQBAgQIEMhLYOvWp5dk4IMHDyzJdTvloopdpyTlPgkQIECAQCABxW5pwlTslsbVVQkQIECAAIECAcWuAOciTil2F4HnWwkQIECAAIHWBBS71twW+i7FbiEh5wkQIECAAIG2Cyh2bSedu2DDYnf2/Nn0wht31r2rv4qtI7FAgAABAgQILFJAsVskWJPbGxa78pmTaffwQN1lFLs6EgsECBAgQIDAIgUUu0WCNbldsatA3XrbxnTfA4/VsH343r7098REzVrRQXd3d3p+58upq+uK6rYD+99N5dFT1WMvFhboX3dzevTxLdWN4+Nn0v6Rt9P0zP+t6IsAAQKdIuAzoTgpxa7Yp9Wzil1FbuD+B9OLu16pcdzxzJPpr9Ona9aKDtb2r0t7931Qs2Xv0Gvp6LdHatYcFAs89MgTadv2XTWbtm15OE1OTtasOSBAgMByFvCZUJyOYlfs0+pZxa4ip9g1foRW9fam/nW3NN4wc+b8uXPp2PffFe5p9qRi16yUfQQILGcBxa44HcWu2KfVs4pdRU6xa/wI3X3v5rTn1cHGGypn3hoeSl8f/mrBfQttUOwWEnKeAIFOEFDsilNS7Ip9Wj2r2FXk/l/spqYm085nn0oTE+NN20b9IW622I2882b68otDTXs12qjYNZKxXiSwevUNqdTVlaZmfmVfLo8WbXWOwCURiPqZ0C48xa5dkrXXUewqHrO/buzru6mqMzrzBw9//H6ietzMi+6enrR+/Yaarb8d/yWNjY3VrLXjYLb8XHnV1dVLfXbo44t6n7X969OmezZXr3f815/TkW8Ozx3ffsemtP2lPdVzF15cs2pVWrGi+8Jhalexu/a669OaNX3V686++PGHYx3xxxNFjjUDOWirQKlUSvs/+jTN/gyWy6fm/lHW1jdwMQItCFzKz4QWbu+yf4titzQR/AcAAP//YCg3bwAAJAVJREFU7V0HvNXE0x1p0osivYMgIB2xIXb5oyKIoCAovUuvgvTee5WOFBEQULBgAxERQUBAlN5BQOkKCvrNCW7e3tzklpd738v73gy/R5LdzWZzNjc5Ozsze9unb1/7l2zkwuVz1H7U4345KVNl9EuThLhFIGu27DRuyjvmRU8cP0LdOzanv//+20wLd6dVu+5U6bGnzdMmjxtK679aax7b7aRPn4Gmz11uZr09eTR9vna1eZwYd2KDY2LEKdL3nCVrNho/dYFR7fXr16l+7WcjfQmpTxAQBCKMQN269SJc463qFiyI+T5G5QIer/Q2IXYe7yGb5tWoVY9efrWhkXPz5k3q1a01HTywz6ZkaEkpUtzOBG0ZpUyZyjhh86YNNHpYn6AnC7HzhSi2OPrWErdHadOmpatXr9K//9qO7+K2MS6uVrb8A9S15yCjhvggdv9fcHTRBYnm1HTp0tHly5cTzf1G80aF2EUH3URD7PIXKES33XZbyCheu/YnnTxxPGD53HnyUvLkKQKWiS3hypkzN2VhzVzmzFkp0x13Egjc2TOn6fSpE9SmQw+6K2t247pLFs2h5UvmB2wDMlOlTk158uSnzHdlpTvvykKpUqWmSxfO06+nT1KuPPno1debGXVc5LQu7RrRpUuXgtYZCWKXNGkyypsvf8Br/fnnH3Tq5ImAZVRmnrz5KVmyZHSDtZdHjx5WycY2W/achOcAcsedd/G9n6Bf9uwK6yUdDRyNBrn4D23KzvdmJ+fOnaFLFy+aWbfffjs9VbkqFSlawsDirizZ6N0Fs+j9pbe0XWZBm50778zMfVWQUqZKRRkyZKKzZ0/T0SOH+Ln8NWximJKfv/wFClKWLPyM8zMJuXDhd7rIf6f4d3fixDGbFtgn4Z5q1m5AVau/bBbo0bmFuW/dOcbPhZ12Oy5wTJ48OeXKnZfy5i9EWfg3fP63c4zhAeNZ/fOPP6xNDek4Y6Y7KD/XlztfAUqaJClBg3/k8AE68+vpgP2C90omPhdy7OgRxuQvSp8hA5Up9wBdv3aNU/+lrd9vMtJRpmChIlSocFG6fOkC3bhxg3Zs3/JfOeRGT25PmZLwPoTg/fQbYwbB+6fYvaX4PZmDf/PJ6ejh/XRg/146//tvRn6g//AsZ8iYybbI8WNH6a+/rpt5KPt0lWpUoGBhfmbvprTp0lO/nu3pZ353WCWa3wT0c15+v+Hdf8cdmbmNf9H587/RxfO/0+FD+/n3c97anKDHbp/HLFmyGnjgQngX4LlQgmep0N1FCe3+959/jPbu2/uT8VyqMkLsFBKR3SYaYjd38RrCByBU2fvzLur9ZruAxcdOnkvZsucKWKZOjacCvlytJ5e770GqVqMOFb6nuDXL7/jo4YPUvVNz+od/NE6SkV9ez75Qi57mjzk+XMFkxOC3+GX+bbBiRn4kiB0+LlNmLgl4vT27f6R+b3UIWEZlTpy+iIlCFgIxb1DneSM5W/YcVLteE7r/wUp+5P7K5Us0Y+pY2rRxnarCdhtNHG0vGEZimbIVqFuvIbZnrHr/XVo4b7qR91DFx6hu/eZ0Z+YsPmU/WbOCZr89wSdNP6jwQEVq1LwdZcx4iwToedjH4GD8qIH8Uf3FmuV3DK1m5Wer8zNe2/wg+BXihLO/nqIftn5Hy96dx4OMGGKqyqJNjzz2DA9W8hkf9nAGbWs+WEbzZk1WVZnbaOKId0/9xm/Qo09UpqRJk5rXVDvQmH752Uc0d+ZEgsYxFCnOpKZl224mMbae8/tvZ2kSm1Ts3rndmmUcv1ynAdV4+TVjf1DfLnQb/2vTsSelY8KkZOeOrTRicC9qzP2Ptuty6MBeGtyva1gDI/38UPeL3FOM+g259Xyu//JTmjpxBL1StxH977katu/03Tu30dgR/QK2CwPZF158xbYJwwa8Sdt+2GwM2jFYqPbSq37XGTO8L3337dd+50fjmwBi9NIrr9MTT1XhZyeZ3zWRgOcH/bGF392hDPQj9Ty24uev0uPPGG3q2aWlQaxTp07DmNWhKs/XIPzedYGCYuXyRfQeKyTQZiF2OjqR2xdi54BlfBC71xu1omervuTQIv/kQwf30ZudnDUT2XPkpL6DxjmOTP1rJK6vOR06uN8uyy/Ny8QOjW1WvwZrRbJRj74jCC8bJ8HLplv7JnT8+FHbItHG0faiYSQGIiRbNm+kqROGUafuA6ho8ZK2tToRO2g+6zVowR/QF23P0xNv3rxB82dPpY9Xv68n++yDfGG6tEy5+33SAx307NLKljCCaDz9vxcCneqY9/W6z2jSWH8iHC0cc+XOQ+079zE0446N+i/jFGsqRw3t7fgsohhwfIkJGT72wQgtPp4rli00tLLWa+vE7tOPVtEjjz5lO/g7d/ZXR/IY6oyB9drhHOvEDu+848eOGG0NVMcZHhgMHdDdcdYlELED6f/5px+pQ9e+BI22ncQVscM7dvDIqcZg1a4d1rRQzBAi+TzqxG7siP70065t1Kv/KMqdt4C1aT7H0yePoi/WrhFi54NK5A4SDbHr1X8kTyMUC4icrtELhdgNH/M2ZbVMgel14GK1X3wy4DVVJrQpbTv1UofGFtNFZ8+cIkxFpk6VxpiatY7YnBwW8MIfOX4m5cyV16fOC6y6P8+q+yScn4nV+ekz+DrDYJTfummdkLSMkSB2GI3qjiCqsTqOe3bvYI1dR5UVcKs0dig0c9o4qvNaE5PUXblymV/Yu3ha+waVKlPetClE2Y0bvmKt0wDs+khc4OhzwVgclCpdjjoycVOiYwfN1xmewi9eoozKNraYdjvGUycXL16gbzd8aeso06RFB562vaX1VCfjmfydp3czZrqTMEWmCzTHnds2dPyYPlPlBWrUzFcLjg/ROW4fpr4wxYXnQTdvcCJ29Ru3pieefs68vH7PSAyk9dr49Rc0bdJI81y1Ew0ccS/jpsznqf/M6jLGFu374+oVw8zCJ4MPftmzk/r0aG9NNo9BtBs0ecM8VjuYgkydJq2fdgn5g/p0pp0/blNFja1O7FQGNN3QsiRJkkQlmdur/PvBVJs+hYlp305tGpllorGjEzu9fpBWmKZAMBth1ShD2ziob1f9FHMfNsrP8UyGEv352bZ1s2EmgGdcF7w/TvLgD1OeK5ctMLRTej72I/lNQH3tu/SmBx56FLumoB2/8W+QX9L8/s5k9Ifqr2DELtLPo07soIkry4M2Rerwnv1p1w7WnF6iIjwDpc8UwOyiRcNaQuzMXo3sTqIhdqHA1rFbP8IUDyQUYmdXZ3VW29eu19jMCnUqts/A0axRKWWet2LpQlr1/mL644+rZlr69OnZlqgh4QOpZNPG9ca0gzpWW0zT9BowWh2yXcMpg7js3xczXQbSUoy1OCCU+ssaNnawuQkmkSB2Ttfo0WcYlSxd3siOzVSstd5PPlpJSxbMNBwFkAfP4mFMzJXDCDQlHd5oYD2N4gJHv4u6THj4kSeMKTW7ajDlvO7zj2k3v3B1OyJrWdgjjpow25w2xIt4+sSRtH3b98bUP6YT87JNFzQf95Ysa57uRJBRoPeAUWwTVdosu3D+2/TpmpXGtLlKxDNZuEhRKn9/ReODBs1IKHaqtes2puo1XzWqCfZxU9cKto0Ejs+9UJNea9jSvBRIw9LFcwybKGiKYYdUslQ5atGmm2Ebqgo6mUSAgMD7V/1e8fGcxv2yY/v3bH92wegv2JnWqtOQ4FCi5CBPk/fs2tpnwGYldqdOHqeBvTsZpK43vzuUHS/qwHsGnvJoc9tOb7FZwyNG1ZHCWrXTbmtH7E6eOEojh/TyGUQ88FAlgle6Pv0X6gxE05Yd6clnYgYKqh0YyHy8ejlt/nY94d0JMhmuxPabgPuYMf99837wLZg8bgj9sOU7H/MblCtZuiyVr1CRSpQqawzMndoY6edRJ3b6NX/atZ2mc5QERbzxnsXvvwDbaSpp0bAmPfecP+Yq381WvGIl3In5/MQnsZuz6EOTZOAl3IOnoOwEH77xU98xpwhg39Su5S07Gb3889VqGdNoKm34wB6GzZI61rewnWnZJmZki2kqTFcFk4RC7EAgVi1f7Hc7TVvxy/w/rQ9e4K+/UsXvxR0XOPo1zGWCHSH5lTUbs6aPYwKwNaTa23XuTQ8+fEtTAGzat6xnGq3rFcD2cMS4maZdFj58bZrVoXPnzurFjP05Cz9gx4vUxj5e/P17dfIrE9uEuCJ24eCIe53Av1Vls3bu7BkOS9SUrly54nebsEeCFlLJ3p93s41vW3VobmF/Cy20kvmzp9DqVUvVoblNkyYNDR093XxPIGMwa69+ZC2WEiux07WjLd7oQo89+T9VlJq8Vs1s90MVHzfIncqELSs0fdESO2LXsvHLtk4S1ncZbPImjx8WtGl2xG7Xjz+w1n9syI5bTheJLbGDo9eQUdPMapcunktL2eY0thKN59GO2H3/3Tc0bmR/H0cKtLlipSfojQ49zeb3ebMNlS8XMyg0MyKwI8ROiJ35GMUXsQNZW7hsrWkvE0xbOGbSXMqe45bTBj407Vq9bt6D2qnJ9jc1a9dXhzwl0YV27vjBPNZ3rERg4pjBtGH953oR2/2EQOzg7QmvTzuB8bTyBkZ+3ZqVjWlavWxc4KhfLxL71v4ESZ8+aZTp3RjsGpjWmvXOKvN5XPfFJzRlwnDH0+AM0bBpGzPf6VmbtWCVOS0O78ZWTV4xNEDmiS524oLYhYtj+QoPUuc3B5p3NXxQT9a2bDKPrTuDR0w2NRrQzjSq+4K1CA0dNZXysWcmxE4Lp5+gh4BBupUEWomdbjYC8ggSqUTPK1GyDPXsFzOVDVtWOwcXda7brZXYbdn8LWvr3rKtFhrNabOXmgMIOJh17dDUtqyeaCV2E8cM4nfgF3qRWO/Hltjly1/QIOfqwnDWgAY7thKN59FK7DBgw/Q3NLtWKXR3ERo4PMZpCQONEvcWtRaLyLEQOyF25oMUX8QODRjJWg+EHYFA6zFj6hjDuFRX/cO+7rlqNenV12JeVNvYc3AYa+OsgmmJ9l36mMnwmMLUhQoVoDIwJdm911CTKCJdeTepMk7bhEDsAk2FW22V7IhdXODohG9s063Erm2Luj4hBoLVa9UUDBvIXoI8hegk1vJOdp86KUFdsIGaN2tSSNP+TtdW6XFB7MLFUdfC4XfckInaNbaXdRI4qkBDrETXkqm0We+sNOzocPzBindpwdxbHs8qX99aCTocW+bMmGgW0YkdbDHbtKhn5j3Kno7wuIWgzQ1erWrm3VP0Xuo7eJx5HNfEDs4N8Gx2ki49BhKiC0BgF9j4tepORc10ndj9zuFUMOiIlMSW2MHhC4MhJXiG1n78AU/lzw4pHJU6T22j8TxaiV3T16s7eiPDRGD42BmqOYYGWYidCUdEd8TGToMzPomdnaE6HB2OHD7ExtApjLhb+ThWlZrWUc2GBx1U31ZB7KUJHPpDGdUiHy+GA/t+ZsPya6wmZ/settnD6B8aQyWIf9Wtw62YdirNaet1Yvcl25JN49AIThIKsYsLHJ3aF9t0t8TOSmZhi3fk0H7H5qRNl8FnYAD70MVsz2gVK94qH/aNMOyHRx1CVcQm+KsXiV19dnCo8p9HMRwbMH0YSODlC29fJdYBFoIgz5i/UmWTE4E2C/DO9DnLTAcp6yBQJ3bo3268eo0SLxO7YNq0Zq06sWPNs+pWjLBHwaaKdWKHKfM32JwgUhJbYofr698k1R44KUFbu4t/M7v5NwOHMDhDBZNIP4+4nk7sgpFoIXbBeihy+ULsNCz1H1Gw6VDtNJ/d2P6I8dIeMHQiZf8vEKdPpQ4HiHsFt3Fdq6cXtfNC1POt+/hh9u/VkcnkQWuW7bHXid1HHy7nuGCTbNuORCvRsNPYoVy0ccQ1Iiluid0LHGNO1wqH27bPPvnQ0Dhbz4PDRa06DYwpPn0woZfDRwteoXAcCqQl1M/BvheJna45cnLO0e8DS/rB+F/JkP7daMe2LerQCCit21whduBG9mgOJLDHRSBkyH4ODvtWt5gp84RK7Ky4WO8fzmt4Dytpz6YqyohfpVm3XiV2GHzDsUZ3hLG2HcGkEXdvycKZPs4k1nKRfh5Rv07sgikFhNhZeyR6x0LsNGzjk9ihGXdxYN1GzTtwnK8KWqv8d+Hh+sGKJfTZJx84kjp1FpwD4CQQSPAxhTfj+0vmhRXxP7EQO2AXTRwD9U1s8twSO+uUYLhtgAfy7OnjHU+DpzFisN1TrKSPRtl6wtqPV/FU7ZSQtBFeJHb9Bo81VvjAfZ0+dZzat4qxebXeK46thv9dObYiovkrsdq2BdNc4byJ0xeaMeisSwUmVGIH+zrY2TmJ/iygjJOjhX6+V4mdaiPCDj31zPPGiiVOgyJ4KL/DzjRr+btgJ5F+HnENIXZ2SMd/mhA7rQ/im9ihKbrHGZYY2rrlW8NOBPGjECj0NIckwFI+IGOhij5q//zT1XSDQySk4PhaqA9LTu1hg9czvCxUuGIldsFsX8KpPxLhTiKlsVPtjhaOqv5Ibd0SO2tIBESy3/fLTyE3D0vfOQV71ivBmpvFS5Tlv9JGaJusvDSUVUINgKt/zCMVgsMtjnoMMjiLNOfwDoEEwckRpFyJ1dsUgWVHjp+tsmkmr5ji9BFXhXSbvA9XvkfvzJmqsiihEjvEIMRshZPoJA1G/PVqVQ46ANbP8dJUrPUe4YVeileaKVXmPirBYYaspjkoj+XO9vy003qqT0y8SDyPuIAQOz+YPZEgxE7rBi8Qu85vDuB4RA8ZrRrA06KIN+ZGdM+qcAL9hnJNrEwwf8nHpo2e9cMRSh1OZbxG7KKJoxMGsU13S0hgeI5pGyWhekmr8rHdYs3g1xq28omLF2oAXJ3YYRCEj7lbcYsjprMxra0kkGE5yui2YZd5GbWm7G2qC+KVzV282vy9WZ0h9LLYh33opBnvmsnQokKbqiShEjuE/EDoDyeBJzI8QCGhkrSEQuz0e4bmDlO0r3OcRD1QPjTdCM5ulUg/j6hfiJ0VZW8cC7HT+iG+iR28oKaxsTMWZoY0a8BhBLQF3LWmhryrhy1w+sGHXJlNwcn84VBR9SNJHL1G7KKNow20sU5yS0hy5WLNEAcnVuIUU03lR3ILz++ps5aYmohQtW/WsDQIfhqbRdH1e3GLI6bP4BSlZArHU1vHcdXsBKt4jGJtHNY5huzfu4ft4fxXl5g6+z1zhQXY7XXh6Vp94XW9bmsYmqH9uxsBplWZhErsjh89TJ3bxQSBV/eDLXA0wp1wQFxIqM9uQiR2xg3yfxgQDRsT420K21R4slslGs+jEDsryt44FmKn9UN8EzvdEw3NQtwieLDi7y+2n7jEyz9hibFjRw/xeolHg04voI4J0xb4BCmFsTU+ltc5oOi1P/80lqaB/Q/WYLQLnIo6Akn/IeOo8D33mkXe6trKiNBuJvy3A+eQv1mTAkPfUMRrxC7aOIaCSahl3BISaGInz3yXvaZjlptDwNFvv1nn2IQCBe82FmVfveo9R+cbxLFCQO1AXq/w4h41fpbpRATP8BaNAnuTolHWe0ZYj0Dr1jreiJZhrTPccCe6lhfVIjZd947NbEPPNG/dmR7nRd6VIG4g4gdapVP3/nTf/Q+byU5auxw5c9GQkdPMZd8Q77Jjm4Y+8cUSKrHDzTvFSqxcpRo1bBYT2Bne2fDSDiZeJHYgqblz5zV+TwgS7iRYhm/qrPfMbMRKRMxEq0TjeRRiZ0XZG8dC7LR+CIfYQbuWiX9QVqlWsy7Bu01JZ36Z6l6rsG/79fQple2zta5y4JNpOcAHb/Omb+iD9xfRWXbPtxNoP6bNWUpp06azy/ZJU96IWJgZwYn1NvsUtBxgzcUatWLiX8GzFtHaf2Q7QNSBtWof5Qj2lR57xlj6bOv3vkbPeCmlYSyt0qPvcHNtQRBa2I3ocpU/khd4zVur6GvFRsrGLi5wtN5HOMdwutGXUXq40pNUgxeJV4LR+xmHZ+4k22za9bWdswjsudZ9/pHhYIO1hhEkOwdr9x59vLK5Fu0AXpJq987t6tI+W9h74WP14/YfaNM3X7JjwEEeWJw1gtsivWDBwlT1xTo+zkNbNm804i/6VGRzYNVaIPYaAlNv5OtA6w17vrwcLqjCg5VoB3sQol6rRANH/Z2C6x3je/549Qra+8suXgLsPBW6uyiVYHspFRYFZbBcVue2jW3taLGMG1aU0A3oYQKxi2MCHtj/s6E9L1zkXsPjOyeTAiV2jhYJmdghBA+mG7d8t8FYJhD2vlg7+JW6jUxsMIBt3eRlvwErsMueIyfdxv+UwMEMzjxKOtksL4g8hBVxskeO9DcBfY1lDzEg+H7TBg5rtYG/HSf4fX/WiC2IwXIJXo4OnuY5cuZRTWfv2Nm0/L13zGN9J9LPoxA7HV3v7Aux0/pC1xIhntaA3p21XN/dQSMmUcFC9/gmhnBkDQSqn4IXDpbygXdcqIIXzdgR/clKmNT5CB7bq/8oM6ipSg+0xb0PHfCmETsvUDnkYekirF2ZJgTyaF3/Ekvc4GOvx9oLdj2V/xXHqJtqE6MuGsQO14w2juq+wt1aDerDPb9+7WcNDa71PDyLQ0ZOMVc5sOY7HQcjdlik3iogljpR0fOd4jTqZdT+m72HGkbl6thpixUksGyeLtHCEdPawzn4eDjP+OhhfXjQtkFvns9+m449DQ2lT2KAA8So696phR+BT8jETr9dzAJgYGAVJ+9sa7xA63mBjkGee3aJWfpNLxvpb4Iidvo11L7TbwaEF8oEJ/IZ6edRiJ3qEW9thdhp/aFPtwULbjts9DRDA6CdHtJuIGKHCvCBK1m6HAclTmloYVLwEjlp06WnrFlzGAvXI0gxjnXBi63jG/X9VpVQZbJlz2FozlKkSGHUiZdg5sxZKQt7IWLkmidvAb8P60ccpX6uFqVe1WW3xRRys9adeAHyZHbZZpqV2Fkjq5sFQ9iJa2KHJkUbxxBu26+IVVPlVyBIghOxw2nQYLXgNYSLlygTpJZb2dCsYhH5o2wDZSe6h6ZdvjUNWgdoH0IVtHcQk1F9CtnuXDtiF00cKzxQkTDVGmzwc4W13VMnDAsYygP3A01Ns9ZdCPUGE5hzTJ80wtBqWcsmVGIHu7nC9xS33o7P8aaN6w3ybhe4F9pRBOuNjQQidpH+JgQidnZthwfw2BH9bAPW6+Uj+TwKsdOR9c6+ELv/+gIhF6DZUrJo/gxauXyROvTbWm3L/Ao4JFiDhDoUc0yG/VP5Cg/zGqdNzcCjKAwNBD5YsREQlipVaxLsU5TAFqpdy5jpPJXutIVGCx8vTHdZtS8Iq7Jh3ee05sOlPs4gIJgz5q0wnUWc6rZLR9+gj6wyasIsg8QiPZyp2CuXLxleiHbTktZrOB1HAkenup3Scc0xk+b5Ye5UXk/HtHmzBi/52F3p+dhHXyJ+FkJxwPPOqnmC4f6BfXt4qaNVhI+pkyE/6sIg4v4HH6X7ebm7/LziiRJMmWGNTwjwx+LrCLFiF7JBneO0Bel5+dXGxsoD+K1Y5TcO74MYkFb7u2jjmDnzXWz71Y6KFithaM+hWVHT5zCr2PvLHpo3cyKHHzprbbLjMTRP6Jds3C/oJ1Un+uA42+F+zmYV6Bcn0ddKRiBkBP5VUpqnh7uzBhQSaEkxDCrh5IU+jJZY14rtxU4lOTiQe3U2e4E5AOzP0NfAAP0LB5X3Fs3x01Cq9sGWEe+q2IiT/RrqivQ3AfdTuEgxw3zgfjYhUI41uJbqa+yjv9fzPa9YtsDWfhNlrBKp5xErpeA5hIQboLhbhyZU8eEYe1FrG90cy1qxslYsL7mTgXr3H22u1Qp7s45sYxEsWrmbB8/tuU9XrkqNW8TYncGeCAvex1bwEpnC3ogZM96yG8Tor27NZ8KuDmQNBr9JkiTlF+2tcAO/83JKbghT2I2IxxMihWM83oLjpfHxxAcV2mR4biNQNtYejk3fpmSvxYyZMlGGDJnYhuiK8dzBVhQ2d3ZaFsdGOWSgH6DBAxkFaYSd3fnfzznaozpUE5VktCtlqlSseUtPJ44fNWwM3VwI95crdz7+2F/j31wSrvMYk/Ubbqr01Ll2xG4few2jjxH7MEOGjAbROc82t3Z2t566GReNwaAF7+cMbJd8mbXjMGvAoBnv13DimlqbEOnn0Vp/oOO6desFyo51nhC7RE7sSpQqS42atjW98PAkrf9qLU0ed2u0GusnK4onYtqodbselIeNa5UEi8auytltoTnAyF83PD7MXrKwyxEJHQHBMXSspKQgECoCTsQu1POlnHcREGIXnb5JVFOxWKsSxv7wICpavBSVu+8BKlS4mA+yWKwbITugiYhPAUmAJ19a/kvPWg1MOeTkUXmx4iUpd578Pk3DVE4nNpi9evWqT7p+gNEt7h0LtqdjGz2o9eGxmidfQSpVuryf8fHC+W/TquWL9SpknxEQHOUxEATiFgEhdnGLd1xeTYhddNBONMRuxLgZfoTICikCfg7u1zXOp2uwjFgjtlVIniw5JeXpLhBQEIhQBK7wMFg/eGCfT3F4NIIM3qovmZ9tlE9hywE0lgimGpspNktVCf5QcEzwXSg3kMAREGKXwDswQPOF2AUAx0VWoiF2cxevMQ20rXjBpgfG1CvYRi2Q1st6XqSOq1Z/herWbxZWdTBW/mTNCtaqLfSL04SKAt2v04Xg8bWEbfV2bN/qVCTRpQuOia7L5YY9hoAQO491SASbI8QugmBqVSVaYgdPql/27KQd276njV9/Ea9Tr6EQO0y3nmSN4pEjh4xgpFhDFt5qThKMkIDMwjkEhtZ7f95FO3ds4RUtjjhVl2jTBcdE2/Vy4x5BwLrEHUI7nTxx3COtk2a4QUCInRv0nM91JHZ/3bhOzQaW9zszZaqYZYb8Mj2cgKC/WEYLi2tfunTBcAuPpot+OFDAGaIgR6C/ceNvunnjprFFGIHLHILj8uWLdJE9oHAcjjzGqz1AbrIrPOoFkb169TIhrMclYMBegjLVGhxRwTE4RlJCEIg2ArA3hnkK3lmBlqSLdjuk/sgiIMQusniq2hyJ3T/8A2rUL2aJFXVCQiV2qv2yFQQEAUFAEBAEBIH4R0CIXXT6QIhddHCVWgUBQUAQEAQEAUFAEIhzBITYxTnkckFBQBAQBAQBQUAQEASig4AQu+jgKrUKAoKAICAICAKCgCAQ5wgIsYtzyOWCgoAgIAgIAoKAICAIRAcBR2L3982/qemAsn5XFecJP0gkQRAQBAQBQUAQEAQEAU8g4EjsLlw+R+1HPe7XSCF2fpBIgiAgCAgCgoAgIAgIAp5AQIidJ7pBGiEICAKCgCAgCAgCgoB7BITYucdQahAEBAFBQBAQBAQBQcATCAix80Q3SCMEAUFAEBAEBAFBQBBwj4AQO/cYSg2CgCAgCAgCgoAgIAh4AgEhdp7oBmmEICAICAKCgCAgCAgC7hEQYuceQ6lBEBAEBAFBQBAQBAQBTyAgxM4T3SCNEAQEAUFAEBAEBAFBwD0CQuzcYyg1CAKCgCAgCAgCgoAg4AkEhNh5ohukEYKAICAICAKCgCAgCLhHQIidewylBkFAEBAEBAFBQBAQBDyBgBA7T3SDNEIQEAQEAUFAEBAEBAH3CAixc4+h1CAICAKCgCAgCAgCgoAnEBBi54lukEYIAoKAICAICAKCgCDgHgEhdu4xlBoEAUFAEBAEBAFBQBDwBAJC7DzRDdIIQUAQEAQEAUFAEBAE3CMgxM49hlKDICAICAKCgCAgCAgCnkBAiJ0nukEaIQgIAoKAICAICAKCgHsEhNi5x1BqEAQEAUFAEBAEBAFBwBMICLHzRDdIIwQBQUAQEAQEAUFAEHCPgBA79xhKDYKAICAICAKCgCAgCHgCASF2nugGaYQgIAgIAoKAICAICALuERBi5x5DqUEQEAQEAUFAEBAEBAFPICDEzhPdII0QBAQBQUAQEAQEAUHAPQJC7NxjKDUIAoKAICAICAKCgCDgCQSE2HmiG6QRgoAgIAgIAoKAICAIuEdAiJ17DKUGQUAQEAQEAUFAEBAEPIGAEDtPdIM0QhAQBAQBQUAQEAQEAfcICLFzj6HUIAgIAoKAICAICAKCgCcQEGLniW6QRggCgoAgIAgIAoKAIOAeASF27jGUGgQBQUAQEAQEAUFAEPAEAkLsPNEN0ghBQBAQBAQBQUAQEATcIyDEzj2GUoMgIAgIAoKAICAICAKeQECInSe6QRohCAgCgoAgIAgIAoKAewQcid1fN65Ts4Hl/a6QMlVGvzRJEAQEAUFAEBAEBAFBQBCIfwT+D/zF7ZhlIKO3AAAAAElFTkSuQmCC\"}]}],\"tools\":[{\"type\":\"function\",\"name\":\"read_screenshot\",\"description\":\"Capture a screenshot of the current screen.\",\"parameters\":{\"type\":\"object\",\"properties\":{},\"additionalProperties\":false},\"strict\":false}],\"store\":false,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"},\"max_output_tokens\":40,\"stream\":true}" + }, + "response": { + "status": 200, + "headers": { + "content-type": "text/event-stream; charset=utf-8" + }, + "body": "event: response.created\ndata: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_06646cef0abb6407016a1235f665a88197a401db20cfc8c787\",\"object\":\"response\",\"created_at\":1779578358,\"status\":\"in_progress\",\"background\":false,\"completed_at\":null,\"error\":null,\"frequency_penalty\":0.0,\"incomplete_details\":null,\"instructions\":null,\"max_output_tokens\":40,\"max_tool_calls\":null,\"model\":\"gpt-5.5-2026-04-23\",\"moderation\":null,\"output\":[],\"parallel_tool_calls\":true,\"presence_penalty\":0.0,\"previous_response_id\":null,\"prompt_cache_key\":null,\"prompt_cache_retention\":\"24h\",\"reasoning\":{\"context\":\"current_turn\",\"effort\":\"medium\",\"summary\":\"detailed\"},\"safety_identifier\":null,\"service_tier\":\"auto\",\"store\":false,\"temperature\":1.0,\"text\":{\"format\":{\"type\":\"text\"},\"verbosity\":\"low\"},\"tool_choice\":\"auto\",\"tools\":[{\"type\":\"function\",\"description\":\"Capture a screenshot of the current screen.\",\"name\":\"read_screenshot\",\"parameters\":{\"type\":\"object\",\"properties\":{},\"additionalProperties\":false,\"required\":[]},\"strict\":true}],\"top_logprobs\":0,\"top_p\":0.98,\"truncation\":\"disabled\",\"usage\":null,\"user\":null,\"metadata\":{}},\"sequence_number\":0}\n\nevent: response.in_progress\ndata: {\"type\":\"response.in_progress\",\"response\":{\"id\":\"resp_06646cef0abb6407016a1235f665a88197a401db20cfc8c787\",\"object\":\"response\",\"created_at\":1779578358,\"status\":\"in_progress\",\"background\":false,\"completed_at\":null,\"error\":null,\"frequency_penalty\":0.0,\"incomplete_details\":null,\"instructions\":null,\"max_output_tokens\":40,\"max_tool_calls\":null,\"model\":\"gpt-5.5-2026-04-23\",\"moderation\":null,\"output\":[],\"parallel_tool_calls\":true,\"presence_penalty\":0.0,\"previous_response_id\":null,\"prompt_cache_key\":null,\"prompt_cache_retention\":\"24h\",\"reasoning\":{\"context\":\"current_turn\",\"effort\":\"medium\",\"summary\":\"detailed\"},\"safety_identifier\":null,\"service_tier\":\"auto\",\"store\":false,\"temperature\":1.0,\"text\":{\"format\":{\"type\":\"text\"},\"verbosity\":\"low\"},\"tool_choice\":\"auto\",\"tools\":[{\"type\":\"function\",\"description\":\"Capture a screenshot of the current screen.\",\"name\":\"read_screenshot\",\"parameters\":{\"type\":\"object\",\"properties\":{},\"additionalProperties\":false,\"required\":[]},\"strict\":true}],\"top_logprobs\":0,\"top_p\":0.98,\"truncation\":\"disabled\",\"usage\":null,\"user\":null,\"metadata\":{}},\"sequence_number\":1}\n\nevent: response.output_item.added\ndata: {\"type\":\"response.output_item.added\",\"item\":{\"id\":\"msg_06646cef0abb6407016a1235f703708197bd125c4f32fb7b69\",\"type\":\"message\",\"status\":\"in_progress\",\"content\":[],\"phase\":\"final_answer\",\"role\":\"assistant\"},\"output_index\":0,\"sequence_number\":2}\n\nevent: response.content_part.added\ndata: {\"type\":\"response.content_part.added\",\"content_index\":0,\"item_id\":\"msg_06646cef0abb6407016a1235f703708197bd125c4f32fb7b69\",\"output_index\":0,\"part\":{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"\"},\"sequence_number\":3}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\"j\",\"item_id\":\"msg_06646cef0abb6407016a1235f703708197bd125c4f32fb7b69\",\"logprobs\":[],\"obfuscation\":\"eWkUz3qb2ZTKrat\",\"output_index\":0,\"sequence_number\":4}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\"igg\",\"item_id\":\"msg_06646cef0abb6407016a1235f703708197bd125c4f32fb7b69\",\"logprobs\":[],\"obfuscation\":\"rPnd9lcMUqqob\",\"output_index\":0,\"sequence_number\":5}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\"ling\",\"item_id\":\"msg_06646cef0abb6407016a1235f703708197bd125c4f32fb7b69\",\"logprobs\":[],\"obfuscation\":\"IuJYWK4DiIvE\",\"output_index\":0,\"sequence_number\":6}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\" restroom\",\"item_id\":\"msg_06646cef0abb6407016a1235f703708197bd125c4f32fb7b69\",\"logprobs\":[],\"obfuscation\":\"v2MVmAR\",\"output_index\":0,\"sequence_number\":7}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\" prison\",\"item_id\":\"msg_06646cef0abb6407016a1235f703708197bd125c4f32fb7b69\",\"logprobs\":[],\"obfuscation\":\"AfKH50yJZ\",\"output_index\":0,\"sequence_number\":8}\n\nevent: response.output_text.done\ndata: {\"type\":\"response.output_text.done\",\"content_index\":0,\"item_id\":\"msg_06646cef0abb6407016a1235f703708197bd125c4f32fb7b69\",\"logprobs\":[],\"output_index\":0,\"sequence_number\":9,\"text\":\"jiggling restroom prison\"}\n\nevent: response.content_part.done\ndata: {\"type\":\"response.content_part.done\",\"content_index\":0,\"item_id\":\"msg_06646cef0abb6407016a1235f703708197bd125c4f32fb7b69\",\"output_index\":0,\"part\":{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"jiggling restroom prison\"},\"sequence_number\":10}\n\nevent: response.output_item.done\ndata: {\"type\":\"response.output_item.done\",\"item\":{\"id\":\"msg_06646cef0abb6407016a1235f703708197bd125c4f32fb7b69\",\"type\":\"message\",\"status\":\"completed\",\"content\":[{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"jiggling restroom prison\"}],\"phase\":\"final_answer\",\"role\":\"assistant\"},\"output_index\":0,\"sequence_number\":11}\n\nevent: response.completed\ndata: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_06646cef0abb6407016a1235f665a88197a401db20cfc8c787\",\"object\":\"response\",\"created_at\":1779578358,\"status\":\"completed\",\"background\":false,\"completed_at\":1779578359,\"error\":null,\"frequency_penalty\":0.0,\"incomplete_details\":null,\"instructions\":null,\"max_output_tokens\":40,\"max_tool_calls\":null,\"model\":\"gpt-5.5-2026-04-23\",\"moderation\":null,\"output\":[{\"id\":\"msg_06646cef0abb6407016a1235f703708197bd125c4f32fb7b69\",\"type\":\"message\",\"status\":\"completed\",\"content\":[{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"jiggling restroom prison\"}],\"phase\":\"final_answer\",\"role\":\"assistant\"}],\"parallel_tool_calls\":true,\"presence_penalty\":0.0,\"previous_response_id\":null,\"prompt_cache_key\":null,\"prompt_cache_retention\":\"24h\",\"reasoning\":{\"context\":\"current_turn\",\"effort\":\"medium\",\"summary\":\"detailed\"},\"safety_identifier\":null,\"service_tier\":\"default\",\"store\":false,\"temperature\":1.0,\"text\":{\"format\":{\"type\":\"text\"},\"verbosity\":\"low\"},\"tool_choice\":\"auto\",\"tools\":[{\"type\":\"function\",\"description\":\"Capture a screenshot of the current screen.\",\"name\":\"read_screenshot\",\"parameters\":{\"type\":\"object\",\"properties\":{},\"additionalProperties\":false,\"required\":[]},\"strict\":true}],\"top_logprobs\":0,\"top_p\":0.98,\"truncation\":\"disabled\",\"usage\":{\"input_tokens\":227,\"input_tokens_details\":{\"cached_tokens\":0},\"output_tokens\":9,\"output_tokens_details\":{\"reasoning_tokens\":0},\"total_tokens\":236},\"user\":null,\"metadata\":{}},\"sequence_number\":12}\n\n" + } + } + ] +} diff --git a/packages/llm/test/fixtures/recordings/openai-responses/openai-responses-gpt-5-5-reasoning-continuation.json b/packages/llm/test/fixtures/recordings/openai-responses/openai-responses-gpt-5-5-reasoning-continuation.json new file mode 100644 index 0000000000000000000000000000000000000000..47670c81272d237ef249909998fbc39c23415411 --- /dev/null +++ b/packages/llm/test/fixtures/recordings/openai-responses/openai-responses-gpt-5-5-reasoning-continuation.json @@ -0,0 +1,58 @@ +{ + "version": 1, + "metadata": { + "name": "openai-responses/openai-responses-gpt-5-5-reasoning-continuation", + "recordedAt": "2026-05-23T23:19:06.776Z", + "provider": "openai", + "route": "openai-responses", + "transport": "http", + "model": "gpt-5.5", + "tags": [ + "prefix:openai-responses", + "provider:openai", + "flagship", + "reasoning", + "continuation", + "encrypted-reasoning", + "golden" + ] + }, + "interactions": [ + { + "transport": "http", + "request": { + "method": "POST", + "url": "https://api.openai.com/v1/responses", + "headers": { + "content-type": "application/json" + }, + "body": "{\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"system\",\"content\":\"Show concise reasoning when the provider supports visible reasoning summaries.\"},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Think briefly, then reply exactly with: Hello!\"}]}],\"store\":false,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"low\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"},\"max_output_tokens\":120,\"stream\":true}" + }, + "response": { + "status": 200, + "headers": { + "content-type": "text/event-stream; charset=utf-8" + }, + "body": "event: response.created\ndata: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_0a0794dab3b8ec7d016a1235e74e148195beb46e1925d20292\",\"object\":\"response\",\"created_at\":1779578343,\"status\":\"in_progress\",\"background\":false,\"completed_at\":null,\"error\":null,\"frequency_penalty\":0.0,\"incomplete_details\":null,\"instructions\":null,\"max_output_tokens\":120,\"max_tool_calls\":null,\"model\":\"gpt-5.5-2026-04-23\",\"moderation\":null,\"output\":[],\"parallel_tool_calls\":true,\"presence_penalty\":0.0,\"previous_response_id\":null,\"prompt_cache_key\":null,\"prompt_cache_retention\":\"24h\",\"reasoning\":{\"context\":\"current_turn\",\"effort\":\"low\",\"summary\":\"detailed\"},\"safety_identifier\":null,\"service_tier\":\"auto\",\"store\":false,\"temperature\":1.0,\"text\":{\"format\":{\"type\":\"text\"},\"verbosity\":\"low\"},\"tool_choice\":\"auto\",\"tools\":[],\"top_logprobs\":0,\"top_p\":0.98,\"truncation\":\"disabled\",\"usage\":null,\"user\":null,\"metadata\":{}},\"sequence_number\":0}\n\nevent: response.in_progress\ndata: {\"type\":\"response.in_progress\",\"response\":{\"id\":\"resp_0a0794dab3b8ec7d016a1235e74e148195beb46e1925d20292\",\"object\":\"response\",\"created_at\":1779578343,\"status\":\"in_progress\",\"background\":false,\"completed_at\":null,\"error\":null,\"frequency_penalty\":0.0,\"incomplete_details\":null,\"instructions\":null,\"max_output_tokens\":120,\"max_tool_calls\":null,\"model\":\"gpt-5.5-2026-04-23\",\"moderation\":null,\"output\":[],\"parallel_tool_calls\":true,\"presence_penalty\":0.0,\"previous_response_id\":null,\"prompt_cache_key\":null,\"prompt_cache_retention\":\"24h\",\"reasoning\":{\"context\":\"current_turn\",\"effort\":\"low\",\"summary\":\"detailed\"},\"safety_identifier\":null,\"service_tier\":\"auto\",\"store\":false,\"temperature\":1.0,\"text\":{\"format\":{\"type\":\"text\"},\"verbosity\":\"low\"},\"tool_choice\":\"auto\",\"tools\":[],\"top_logprobs\":0,\"top_p\":0.98,\"truncation\":\"disabled\",\"usage\":null,\"user\":null,\"metadata\":{}},\"sequence_number\":1}\n\nevent: response.output_item.added\ndata: {\"type\":\"response.output_item.added\",\"item\":{\"id\":\"rs_0a0794dab3b8ec7d016a1235e7ce3881958a5eca32a36a14c5\",\"type\":\"reasoning\",\"encrypted_content\":\"gAAAAABqEjXnglldg7hhpTBATVqj7sThK5ATieOVR8sZGYPDW2zYopwpKxA3RyRccK_FPjRvvlzrvL-FitOxmdMGBaKa5jncrT9hHo5IMhsFsCEHkQ1x5tlrKPqtfwJ_LFexR0h_IpPogu8wlVAkHRoWQoq61o9vBxjMOEsq6dtXu09959gXnAvJA3jN_mqNkRZ7Yp6LaJJtLDAAtt_dhX8veoEFXZ412lCY4zcaMvC5o0yq6MPvLIN4NhHmfPKkVAy-j8wGlgA42KR4wd5-VeFXUdeSn32dlNLZZxBFa9w6iTgCQ9aF-3C7RB4OXeSY782QUD1dRyFybd7vJtjlptwXBntSHZ9wugoKSDEj0KnvQKG_WiCWuJvkGiOVno4MAs5QnCmKBnpak5OV1wOhPwX2ez6OmAYT4mMKIogdfivVvUxMrmdVJzgE85WoZEAU2ZporxVXkI7_8p0L6dxxwk_IKiKSCz-bZgsCtOP5Jsr5GeI831nVv272kZ3DugV-hcjGHAE5T9KhebzpFjsdxnJcfxuGY8SyRaLlUAHM_37H4veHsOzyhCoaG8mMaT3gIb4tAvM7ezd1xzLsFae89P5xCv_fNeoV7qmf2IWDWUi1vitIib5w9jsclWRqYaLVZR0GK6dYyNJ1DXDOOcWRdH7UJakv1m2koUbcYWBuxao7sc-af_9ySKAloWhb6QjiVElJHYtwraJBtX-CLBVHEYqAXmZgMUWVbz8NNRA6JS1TrOys7_LiQtXXubLWas_66LyaqmB-628LCUitUISYYc2wmq1uUm7gjPA53Wm4F7VU6g-PO7bt1O0Nd-jasisPXINTX3Z4hgC1APPEq29iEHwmEPnicO_Nu6U3JLfq4DD6r1oLK-RnIp3Ratw0P-Gwog86RBLGUWIEIKdFu6m9d1TI8rIBbAVaBA==\",\"summary\":[]},\"output_index\":0,\"sequence_number\":2}\n\nevent: response.output_item.done\ndata: {\"type\":\"response.output_item.done\",\"item\":{\"id\":\"rs_0a0794dab3b8ec7d016a1235e7ce3881958a5eca32a36a14c5\",\"type\":\"reasoning\",\"encrypted_content\":\"gAAAAABqEjXoGMCw3WDXpoD9151PEr2Lt8raW7KBKefQhZJGWx5f8jy152bApO6oE-Mr1BhUtfZNq3OPBVfSL4ioQ9bHREfujIBXgk9LUDBAz2Sle7KjOr9HaUV16A4HBiaFIRFjsHPS9G8yEySp1m6F1CD_WR6apyUGgugRh_y39EcOJmxPOzmiac5DVM6fraA1VpcGbqrZ1x2ANHFDOfnYTycPtPNTgzE7LjkYjDDWbT03uN1YxfP4pqjDVRzY14pA8bSZ8ys-pDv5kUFCAsw-OlU4jYKUXp-M8_6KTaRQP71LPwppt__zG_NJPfy-qUil4pOU8_NoxtxerHgLLXbfExZdzfpoGinoEjn7nj7BJDEtl-LNeNEb5c-1ZymNfVMp-Cs3fLEPkAV8rtHFtZ0MhE_07GKbGo7hTrOmkM4DydxmHsdWGNbXAG35cprslEA5P7p3GHFKnRs5hGs2eq-XcZ3yki64ZBOU_Tv6UR7nUH09gF1rdrJo3dpre6M00COwwdZ02zUP5KxCuI8FKu2jsZu9zgMVXDALsdtM5orTCVLXsn4rddWd111zE-vMjNmMMmktW2cHMjH7j1ooA-9P083koNVYiLi4UhMA64gTqgyl8MxkZekl7eFSMa7qk295NaHOKtFxzYYcZ9jdioCwSPSZ0ZZWLoNgrK7SWfRh0uaTHNcMZ3wq8ae6CguktIeVTCPTQAqJLQqd7AU0oOCKCJ7BWnC-L8UC6m7Pm9ZS958uUVeWBhgKHzMAGq9UeQB7IEeAcbMn3EDgOSfd8qCb8iwU9iG9dcu9axQwWU7pd7kd-T-He61W7z5wWgpx1KehWCxrN6kuKSo6p-uUfwVnJukreOn8BJNAzADQgz68bhmN9VGih7YcKVnLgwDwKditrjSd6-tfE0Baarj3jWENvT6ohY17R9FDrKS-2v8IIX6tGjoKJw8SRhaWLNv4vWlmxRgR0gdac3qumd0GKqsWSveNz01naA==\",\"summary\":[]},\"output_index\":0,\"sequence_number\":3}\n\nevent: response.output_item.added\ndata: {\"type\":\"response.output_item.added\",\"item\":{\"id\":\"msg_0a0794dab3b8ec7d016a1235e8d64c81959a41f8db3ea7b66c\",\"type\":\"message\",\"status\":\"in_progress\",\"content\":[],\"phase\":\"final_answer\",\"role\":\"assistant\"},\"output_index\":1,\"sequence_number\":4}\n\nevent: response.content_part.added\ndata: {\"type\":\"response.content_part.added\",\"content_index\":0,\"item_id\":\"msg_0a0794dab3b8ec7d016a1235e8d64c81959a41f8db3ea7b66c\",\"output_index\":1,\"part\":{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"\"},\"sequence_number\":5}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\"Hello\",\"item_id\":\"msg_0a0794dab3b8ec7d016a1235e8d64c81959a41f8db3ea7b66c\",\"logprobs\":[],\"obfuscation\":\"3nRhhCWA1H8\",\"output_index\":1,\"sequence_number\":6}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\"!\",\"item_id\":\"msg_0a0794dab3b8ec7d016a1235e8d64c81959a41f8db3ea7b66c\",\"logprobs\":[],\"obfuscation\":\"60NqChSEyXHKsoy\",\"output_index\":1,\"sequence_number\":7}\n\nevent: response.output_text.done\ndata: {\"type\":\"response.output_text.done\",\"content_index\":0,\"item_id\":\"msg_0a0794dab3b8ec7d016a1235e8d64c81959a41f8db3ea7b66c\",\"logprobs\":[],\"output_index\":1,\"sequence_number\":8,\"text\":\"Hello!\"}\n\nevent: response.content_part.done\ndata: {\"type\":\"response.content_part.done\",\"content_index\":0,\"item_id\":\"msg_0a0794dab3b8ec7d016a1235e8d64c81959a41f8db3ea7b66c\",\"output_index\":1,\"part\":{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"Hello!\"},\"sequence_number\":9}\n\nevent: response.output_item.done\ndata: {\"type\":\"response.output_item.done\",\"item\":{\"id\":\"msg_0a0794dab3b8ec7d016a1235e8d64c81959a41f8db3ea7b66c\",\"type\":\"message\",\"status\":\"completed\",\"content\":[{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"Hello!\"}],\"phase\":\"final_answer\",\"role\":\"assistant\"},\"output_index\":1,\"sequence_number\":10}\n\nevent: response.completed\ndata: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_0a0794dab3b8ec7d016a1235e74e148195beb46e1925d20292\",\"object\":\"response\",\"created_at\":1779578343,\"status\":\"completed\",\"background\":false,\"completed_at\":1779578344,\"error\":null,\"frequency_penalty\":0.0,\"incomplete_details\":null,\"instructions\":null,\"max_output_tokens\":120,\"max_tool_calls\":null,\"model\":\"gpt-5.5-2026-04-23\",\"moderation\":null,\"output\":[{\"id\":\"rs_0a0794dab3b8ec7d016a1235e7ce3881958a5eca32a36a14c5\",\"type\":\"reasoning\",\"encrypted_content\":\"gAAAAABqEjXoMO9Ci_Q06HKQ0YDBarUvbp9ulkR9W2RXWPbx7XKokNCrKUZX-pPPGpUg6r-vTXe8iEX-oED6TmjxZV_nyo838x6pmQJlDqz5JECs2axIrUbCjv9xBt3ob8eAyOizhKFjp3dJNu4i01c38MPZ5QYpD24uCKf69jzjUfydKIEjbo0VhP3K6SDG0V9ZUtua-e6WMqzIg-W5Zs3u64DxGw974ntmvNsx8lsuLR-bk9S5ZZ7zPlCG2Emwfph8UE5HJmIfmMxYlrY5qmXSWKDhse9hovQj-TrvbllP-0vLNQWEPLc3aUfVrWWR9i3NZZ-nxJZiIJPCF3xxIIyKaLh9a6Lh9J6Z-brsvVfbVJWXIGZhsu-uKk6Gwoqo56KqHdNaPF7lkPo5GAWfMrweCnJZ4o_j-oWm8BwTkXxrLib4XYKDO2JNqrNdbmy8rZ7UGgW_DVTiNyZi6LoRfSuvK45MWV2uzB_OJ9LBcqgscY4HyPvKrhGG4Peh4iXuBUCyQQ2IudM5GbeeMOAF3dnEzZff68SwE1H56CO6PtKhVQ6cFJMf7LwI5LFFio0qJnEDx-MejvU7PxmYW7R3MEbgjbsuEFU5KnRVYsgug3_Bq1vXdmP2qhebufFZwz26SwaFqyn3xjwCP8-GR7lWCZ2EvUvWtfxJ5_zgkZg06UsF4Eo_CWKFdp0ao43nemNJxOlMzFa6tPuCgplmD0oYoQ316f-bWK02-eJk56S7G4bZSk8cQfExfIZMjW2f-qrxvfxEpFiXsZF80BQwgRUOeKjsqidg2ihdldRkXGn3vX8p15mf1UgstU8y3DNd2_qJe1f_pEl6rWNXoxFdSRCTG7wTAqbCCmuDgCKGhNQY9tfJNsFqgWBIGkqKy88DN_HiWywJjJ-5u9aoe68yDK-E0TMDqs7ZrTely1wvmkl2yF0XQttaB30taxkIcRR-n0PRO-CRNA_9nJkw9ZsBb8oBjyqWH_mwSijT5g==\",\"summary\":[]},{\"id\":\"msg_0a0794dab3b8ec7d016a1235e8d64c81959a41f8db3ea7b66c\",\"type\":\"message\",\"status\":\"completed\",\"content\":[{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"Hello!\"}],\"phase\":\"final_answer\",\"role\":\"assistant\"}],\"parallel_tool_calls\":true,\"presence_penalty\":0.0,\"previous_response_id\":null,\"prompt_cache_key\":null,\"prompt_cache_retention\":\"24h\",\"reasoning\":{\"context\":\"current_turn\",\"effort\":\"low\",\"summary\":\"detailed\"},\"safety_identifier\":null,\"service_tier\":\"default\",\"store\":false,\"temperature\":1.0,\"text\":{\"format\":{\"type\":\"text\"},\"verbosity\":\"low\"},\"tool_choice\":\"auto\",\"tools\":[],\"top_logprobs\":0,\"top_p\":0.98,\"truncation\":\"disabled\",\"usage\":{\"input_tokens\":31,\"input_tokens_details\":{\"cached_tokens\":0},\"output_tokens\":20,\"output_tokens_details\":{\"reasoning_tokens\":12},\"total_tokens\":51},\"user\":null,\"metadata\":{}},\"sequence_number\":11}\n\n" + } + }, + { + "transport": "http", + "request": { + "method": "POST", + "url": "https://api.openai.com/v1/responses", + "headers": { + "content-type": "application/json" + }, + "body": "{\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Think briefly, then reply exactly with: Hello!\"}]},{\"type\":\"reasoning\",\"summary\":[],\"encrypted_content\":\"gAAAAABqEjXoGMCw3WDXpoD9151PEr2Lt8raW7KBKefQhZJGWx5f8jy152bApO6oE-Mr1BhUtfZNq3OPBVfSL4ioQ9bHREfujIBXgk9LUDBAz2Sle7KjOr9HaUV16A4HBiaFIRFjsHPS9G8yEySp1m6F1CD_WR6apyUGgugRh_y39EcOJmxPOzmiac5DVM6fraA1VpcGbqrZ1x2ANHFDOfnYTycPtPNTgzE7LjkYjDDWbT03uN1YxfP4pqjDVRzY14pA8bSZ8ys-pDv5kUFCAsw-OlU4jYKUXp-M8_6KTaRQP71LPwppt__zG_NJPfy-qUil4pOU8_NoxtxerHgLLXbfExZdzfpoGinoEjn7nj7BJDEtl-LNeNEb5c-1ZymNfVMp-Cs3fLEPkAV8rtHFtZ0MhE_07GKbGo7hTrOmkM4DydxmHsdWGNbXAG35cprslEA5P7p3GHFKnRs5hGs2eq-XcZ3yki64ZBOU_Tv6UR7nUH09gF1rdrJo3dpre6M00COwwdZ02zUP5KxCuI8FKu2jsZu9zgMVXDALsdtM5orTCVLXsn4rddWd111zE-vMjNmMMmktW2cHMjH7j1ooA-9P083koNVYiLi4UhMA64gTqgyl8MxkZekl7eFSMa7qk295NaHOKtFxzYYcZ9jdioCwSPSZ0ZZWLoNgrK7SWfRh0uaTHNcMZ3wq8ae6CguktIeVTCPTQAqJLQqd7AU0oOCKCJ7BWnC-L8UC6m7Pm9ZS958uUVeWBhgKHzMAGq9UeQB7IEeAcbMn3EDgOSfd8qCb8iwU9iG9dcu9axQwWU7pd7kd-T-He61W7z5wWgpx1KehWCxrN6kuKSo6p-uUfwVnJukreOn8BJNAzADQgz68bhmN9VGih7YcKVnLgwDwKditrjSd6-tfE0Baarj3jWENvT6ohY17R9FDrKS-2v8IIX6tGjoKJw8SRhaWLNv4vWlmxRgR0gdac3qumd0GKqsWSveNz01naA==\"},{\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"Hello!\"}]},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Now reply exactly with: Done.\"}]}],\"store\":false,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"low\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"},\"max_output_tokens\":40,\"stream\":true}" + }, + "response": { + "status": 200, + "headers": { + "content-type": "text/event-stream; charset=utf-8" + }, + "body": "event: response.created\ndata: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_0a0794dab3b8ec7d016a1235e991c88195a4d2f9766babd985\",\"object\":\"response\",\"created_at\":1779578345,\"status\":\"in_progress\",\"background\":false,\"completed_at\":null,\"error\":null,\"frequency_penalty\":0.0,\"incomplete_details\":null,\"instructions\":null,\"max_output_tokens\":40,\"max_tool_calls\":null,\"model\":\"gpt-5.5-2026-04-23\",\"moderation\":null,\"output\":[],\"parallel_tool_calls\":true,\"presence_penalty\":0.0,\"previous_response_id\":null,\"prompt_cache_key\":null,\"prompt_cache_retention\":\"24h\",\"reasoning\":{\"context\":\"current_turn\",\"effort\":\"low\",\"summary\":\"detailed\"},\"safety_identifier\":null,\"service_tier\":\"auto\",\"store\":false,\"temperature\":1.0,\"text\":{\"format\":{\"type\":\"text\"},\"verbosity\":\"low\"},\"tool_choice\":\"auto\",\"tools\":[],\"top_logprobs\":0,\"top_p\":0.98,\"truncation\":\"disabled\",\"usage\":null,\"user\":null,\"metadata\":{}},\"sequence_number\":0}\n\nevent: response.in_progress\ndata: {\"type\":\"response.in_progress\",\"response\":{\"id\":\"resp_0a0794dab3b8ec7d016a1235e991c88195a4d2f9766babd985\",\"object\":\"response\",\"created_at\":1779578345,\"status\":\"in_progress\",\"background\":false,\"completed_at\":null,\"error\":null,\"frequency_penalty\":0.0,\"incomplete_details\":null,\"instructions\":null,\"max_output_tokens\":40,\"max_tool_calls\":null,\"model\":\"gpt-5.5-2026-04-23\",\"moderation\":null,\"output\":[],\"parallel_tool_calls\":true,\"presence_penalty\":0.0,\"previous_response_id\":null,\"prompt_cache_key\":null,\"prompt_cache_retention\":\"24h\",\"reasoning\":{\"context\":\"current_turn\",\"effort\":\"low\",\"summary\":\"detailed\"},\"safety_identifier\":null,\"service_tier\":\"auto\",\"store\":false,\"temperature\":1.0,\"text\":{\"format\":{\"type\":\"text\"},\"verbosity\":\"low\"},\"tool_choice\":\"auto\",\"tools\":[],\"top_logprobs\":0,\"top_p\":0.98,\"truncation\":\"disabled\",\"usage\":null,\"user\":null,\"metadata\":{}},\"sequence_number\":1}\n\nevent: response.output_item.added\ndata: {\"type\":\"response.output_item.added\",\"item\":{\"id\":\"rs_0a0794dab3b8ec7d016a1235ea4dd88195a32179255ed6c532\",\"type\":\"reasoning\",\"encrypted_content\":\"gAAAAABqEjXqB-kOX_0QAeoEksgNjwSbtGmVEQuMj5ODcFV6b7Kp3E8RoHRRmSXtRH0rtNbZRbhKz5jM48DUpDI1WTeO2HqCd_A3fsSgFxp5ACGVFjPWjfvP2JMdDkpoOo5gu2zy7WsWY0fseocQQ5q_jfG6SWw0fyaeeqfdQ9HkcHyg6gVEl5skb4L8_2lD5nClmLlNVVh5JCuXRH9eYysrfO19NOZ29A2MVUX-XgB6mmK5uSb1jE43GhrEPPYrMbB5JyzM6B-yeB8rE4H2wx530hQqtxwSZREa8G03rzTJ49_KAPWl0djGDDtufUX-t4EpBHo6loA3PMuiZ3VsJTkkPpEqkm6QQyAVkQ_8AdRu12CqHbFdu73I-BnArzr33yW6reNUjnZjFV5bWDyxIMh6ljy3O_2nGk-qdTLt6bGJbEjTdPj1hi7icYZTVPqofPU4pjlo9BnIBheo-4u9pA26V9G9vDAtM4myDdMEe4pnieUztBUYPUOVMaG2U9gqtNs6iPehKo3BeKy4lhYPorL2OPmf1lVUQOCW1MBbwT5xt1kjOVw7LggnyjrBsXVvDBWg0AFcvm14r3ZQezPgLetQfSx56mVEJpui9BuVSUg2Xvqb5tCCip6TipUVvzZJKKkxN43o8N6UVXLIn6wgstAn9727JgBEsjMxzvuOWaaI-qM3dWMcFzFSGvKb6gTiF37AhSOzosf31hnsGx8AnGmLmbuW3IMhZXZZMHgVUHx4p8pNRPeoCE-Bv833KZbRfVxe6tbmkLadBjXYaCQqPXDHBR7qbpfu4_M9UAy0kpic-joepxQKsCT2t6vsNaThRYaN3PtIgjs5xxAfa9yKeIYak8yiP96CUlME9Y7zaOIydPWYBhWLf3phQsWMax9eKdLDh19f0Y0iAJPpk--1xd2OVTuDxKIMpA==\",\"summary\":[]},\"output_index\":0,\"sequence_number\":2}\n\nevent: response.output_item.done\ndata: {\"type\":\"response.output_item.done\",\"item\":{\"id\":\"rs_0a0794dab3b8ec7d016a1235ea4dd88195a32179255ed6c532\",\"type\":\"reasoning\",\"encrypted_content\":\"gAAAAABqEjXqW5MInCBPKulZ1lizyFtOaKUpKgHldAXVjTTs4XFE45gtxC1NbJoOi2tHoQhpfq-JGxtjSQEDTHnMCiLLhyqvQ4GlWVF4n51xcFVC_WgymkZqDxG6xPw8ITAsRI5vb8HiPO6EmmKt6xGIVXOjrrxRNAY3xtrByeYSvnCa6FDUHEkMeXmwllBalCeQPNDPl0Ub2ehuchNG0loMVLJoOjT-2KgDrXlOa6rCn3nUf4U1W5JA_kHytlgrD0IPbs7nY8wemdynJRXBoNSOT_U3nQSB6j-i4KIJAdLiUs9LVWMYleqmFQNs8S4dC3i5DfpHXWUMZ5Ai1d3gbvMP8bH7fsUyfIhyiDUvlgr6PZ9rfh8JqkjOpiQ7NFtSDuHQGdx__W3qi23WPDp3iQjKxVl1oUXfbMzsPE4bmNN9dnJ9qTQ43kvw8GyrGrSqRS8jCKuk9bxqeR_ibj4KoDdxvVbUeGMg3WKANfCRsNXlxwYtMpu3I4HxKm5EuMNKDg_e9RFH2wFEDm9wCKMZrC_5LShgKhSfhsk3yJ47Mit0zYdX27kyHGlZvpzLPYKdtHW1O15KNT4gFKBrIguCtjXz2Lb42ENM6Jo8BTY7BZbf0hXZ4A5mFNl_gyLVHWEHpR1GcYiJbs9RtQ-7qX_PTeZ11iFY3a7_jM7TK3WEN2IuG0OKbZVHvOkVcvyBEgIbzSzCzhtC-j584knI4WmYiqnltuwRcR2N3sxYY3vMcYGA2_AU5kYlZcJcztapTTW-aKbyGxPcw5D_dqb5mGpDqJgquye-qOufDt4Fd7cSc-g8awqR8QPsLz-ZDPLMB9JKQ3VqLQlNCKDUoDodGOAL3h-7EQG66osALfhpdsWcNmuVqlb0lNAXklrsZJtRKBU4pJ1UGCyVDwde7nv6I9PW19VumaRrJhc2cC52qUoyihvUo8xJsElaFp7-EHn5ymS4znZhRfyA_4UDL4rwj-3DqbMwNeDJrgBc3w==\",\"summary\":[]},\"output_index\":0,\"sequence_number\":3}\n\nevent: response.output_item.added\ndata: {\"type\":\"response.output_item.added\",\"item\":{\"id\":\"msg_0a0794dab3b8ec7d016a1235eaae648195ab7ad5385b641107\",\"type\":\"message\",\"status\":\"in_progress\",\"content\":[],\"phase\":\"final_answer\",\"role\":\"assistant\"},\"output_index\":1,\"sequence_number\":4}\n\nevent: response.content_part.added\ndata: {\"type\":\"response.content_part.added\",\"content_index\":0,\"item_id\":\"msg_0a0794dab3b8ec7d016a1235eaae648195ab7ad5385b641107\",\"output_index\":1,\"part\":{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"\"},\"sequence_number\":5}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\"Done\",\"item_id\":\"msg_0a0794dab3b8ec7d016a1235eaae648195ab7ad5385b641107\",\"logprobs\":[],\"obfuscation\":\"7tyE7hMvNOTM\",\"output_index\":1,\"sequence_number\":6}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\".\",\"item_id\":\"msg_0a0794dab3b8ec7d016a1235eaae648195ab7ad5385b641107\",\"logprobs\":[],\"obfuscation\":\"RGXvuTTSJS3AT5E\",\"output_index\":1,\"sequence_number\":7}\n\nevent: response.output_text.done\ndata: {\"type\":\"response.output_text.done\",\"content_index\":0,\"item_id\":\"msg_0a0794dab3b8ec7d016a1235eaae648195ab7ad5385b641107\",\"logprobs\":[],\"output_index\":1,\"sequence_number\":8,\"text\":\"Done.\"}\n\nevent: response.content_part.done\ndata: {\"type\":\"response.content_part.done\",\"content_index\":0,\"item_id\":\"msg_0a0794dab3b8ec7d016a1235eaae648195ab7ad5385b641107\",\"output_index\":1,\"part\":{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"Done.\"},\"sequence_number\":9}\n\nevent: response.output_item.done\ndata: {\"type\":\"response.output_item.done\",\"item\":{\"id\":\"msg_0a0794dab3b8ec7d016a1235eaae648195ab7ad5385b641107\",\"type\":\"message\",\"status\":\"completed\",\"content\":[{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"Done.\"}],\"phase\":\"final_answer\",\"role\":\"assistant\"},\"output_index\":1,\"sequence_number\":10}\n\nevent: response.completed\ndata: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_0a0794dab3b8ec7d016a1235e991c88195a4d2f9766babd985\",\"object\":\"response\",\"created_at\":1779578345,\"status\":\"completed\",\"background\":false,\"completed_at\":1779578346,\"error\":null,\"frequency_penalty\":0.0,\"incomplete_details\":null,\"instructions\":null,\"max_output_tokens\":40,\"max_tool_calls\":null,\"model\":\"gpt-5.5-2026-04-23\",\"moderation\":null,\"output\":[{\"id\":\"rs_0a0794dab3b8ec7d016a1235ea4dd88195a32179255ed6c532\",\"type\":\"reasoning\",\"encrypted_content\":\"gAAAAABqEjXq8oliF2VeqiOUi-jUdi49emjffD6wtbmxlwQWbJ6tSxXIjyXvCeclOqKx83G83GyDOJqvR4L_D8V_ebJtgG87ahWB8Rr9LEQDoLT24n4Vz279xtHMxEGgv7f0NmaXu2dGFeFY_s2RhH-DqNE7V4nEkS7odJOTkhxTKgEcxtz3dDlEnGU7IgN2sD1lh9y90BD3ysvARegy4Cs0DhUjLOvkx11G9lk5dQ3yo1ek8JhTHpnVSYrLDYIudCh6pfu1yP1tx8xbxDHUcwlNclU9Hp_9ils5FhZNWC_tiLDscXXvRPBgMF77jdOicCV6cyUV0Snsu1_KSRbm4rLtgXLXVMqFyYpxdyicsD577e4yZ0VVXT4Oo_af0eDh3I3ZPIWui38EmYuoRhvQuYZkqjhGd_xOkvjQF4_Tp6cyNO0XdAMGMoYG-5npHC0gcPpv56qYGX8ffj0P8ZyR9shn3H7kcQqE2YXXBa42VKK0poPbC996xSqFNW7ygePel41h493XlJ70wnP50vFY5s0raNFf9eLP3YYmLxiPks9gshayGwUQXNNwrSimoQv3OeJzRzihbzZNWTfhR4xKs53nlXMjwnnXwHRH5D07vJg_1zU7BQzJ-QRLZnsnhIOq3psHt1yuoCtsSTKBN6HPiR81F-snIttJiUAiYsgv_ajwPxxnKP0FnFXQfBuaUAtAOD5G_3MC1yECjzq-YI4MDOXj4dsIGnHkdzXo-DV2lXMl2WnPqytoUkugp14SWbJso-eDsN5QivqspnYc1VsdNAaOOgjBiHmi-bACI1CykrkuiYJm1nOHAH4L4IQjpd0pcNm-Dk7z9LGIE5lwKI07hLXp_ByhVXRT8xWuugl43pzoM1jgYD4LjTjScC3ymauqqvKjjoHfnt0Zma0eVDeQrnVT6W9RQ9wDt5KVebrrwJTqlaNV0HywZJo3gwFy-Qq5MfwAwwC-GdjMsER1TgXO_E5kFZZD4sNVgw==\",\"summary\":[]},{\"id\":\"msg_0a0794dab3b8ec7d016a1235eaae648195ab7ad5385b641107\",\"type\":\"message\",\"status\":\"completed\",\"content\":[{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"Done.\"}],\"phase\":\"final_answer\",\"role\":\"assistant\"}],\"parallel_tool_calls\":true,\"presence_penalty\":0.0,\"previous_response_id\":null,\"prompt_cache_key\":null,\"prompt_cache_retention\":\"24h\",\"reasoning\":{\"context\":\"current_turn\",\"effort\":\"low\",\"summary\":\"detailed\"},\"safety_identifier\":null,\"service_tier\":\"default\",\"store\":false,\"temperature\":1.0,\"text\":{\"format\":{\"type\":\"text\"},\"verbosity\":\"low\"},\"tool_choice\":\"auto\",\"tools\":[],\"top_logprobs\":0,\"top_p\":0.98,\"truncation\":\"disabled\",\"usage\":{\"input_tokens\":35,\"input_tokens_details\":{\"cached_tokens\":0},\"output_tokens\":20,\"output_tokens_details\":{\"reasoning_tokens\":12},\"total_tokens\":55},\"user\":null,\"metadata\":{}},\"sequence_number\":11}\n\n" + } + } + ] +} diff --git a/packages/llm/test/fixtures/recordings/openai-responses/openai-responses-gpt-5-5-reasoning.json b/packages/llm/test/fixtures/recordings/openai-responses/openai-responses-gpt-5-5-reasoning.json new file mode 100644 index 0000000000000000000000000000000000000000..107192d951e92acbf5ecc5f3f3b5cd1202436f42 --- /dev/null +++ b/packages/llm/test/fixtures/recordings/openai-responses/openai-responses-gpt-5-5-reasoning.json @@ -0,0 +1,32 @@ +{ + "version": 1, + "metadata": { + "name": "openai-responses/openai-responses-gpt-5-5-reasoning", + "recordedAt": "2026-05-23T23:19:03.175Z", + "provider": "openai", + "route": "openai-responses", + "transport": "http", + "model": "gpt-5.5", + "tags": ["prefix:openai-responses", "provider:openai", "flagship", "reasoning", "golden"] + }, + "interactions": [ + { + "transport": "http", + "request": { + "method": "POST", + "url": "https://api.openai.com/v1/responses", + "headers": { + "content-type": "application/json" + }, + "body": "{\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"system\",\"content\":\"Show concise reasoning when the provider supports visible reasoning summaries.\"},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Think briefly, then reply exactly with: Hello!\"}]}],\"store\":false,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"low\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"},\"max_output_tokens\":120,\"stream\":true}" + }, + "response": { + "status": 200, + "headers": { + "content-type": "text/event-stream; charset=utf-8" + }, + "body": "event: response.created\ndata: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_05f7e55dc1d2d743016a1235e5bb3c8193b4c0e30fa316700c\",\"object\":\"response\",\"created_at\":1779578341,\"status\":\"in_progress\",\"background\":false,\"completed_at\":null,\"error\":null,\"frequency_penalty\":0.0,\"incomplete_details\":null,\"instructions\":null,\"max_output_tokens\":120,\"max_tool_calls\":null,\"model\":\"gpt-5.5-2026-04-23\",\"moderation\":null,\"output\":[],\"parallel_tool_calls\":true,\"presence_penalty\":0.0,\"previous_response_id\":null,\"prompt_cache_key\":null,\"prompt_cache_retention\":\"24h\",\"reasoning\":{\"context\":\"current_turn\",\"effort\":\"low\",\"summary\":\"detailed\"},\"safety_identifier\":null,\"service_tier\":\"auto\",\"store\":false,\"temperature\":1.0,\"text\":{\"format\":{\"type\":\"text\"},\"verbosity\":\"low\"},\"tool_choice\":\"auto\",\"tools\":[],\"top_logprobs\":0,\"top_p\":0.98,\"truncation\":\"disabled\",\"usage\":null,\"user\":null,\"metadata\":{}},\"sequence_number\":0}\n\nevent: response.in_progress\ndata: {\"type\":\"response.in_progress\",\"response\":{\"id\":\"resp_05f7e55dc1d2d743016a1235e5bb3c8193b4c0e30fa316700c\",\"object\":\"response\",\"created_at\":1779578341,\"status\":\"in_progress\",\"background\":false,\"completed_at\":null,\"error\":null,\"frequency_penalty\":0.0,\"incomplete_details\":null,\"instructions\":null,\"max_output_tokens\":120,\"max_tool_calls\":null,\"model\":\"gpt-5.5-2026-04-23\",\"moderation\":null,\"output\":[],\"parallel_tool_calls\":true,\"presence_penalty\":0.0,\"previous_response_id\":null,\"prompt_cache_key\":null,\"prompt_cache_retention\":\"24h\",\"reasoning\":{\"context\":\"current_turn\",\"effort\":\"low\",\"summary\":\"detailed\"},\"safety_identifier\":null,\"service_tier\":\"auto\",\"store\":false,\"temperature\":1.0,\"text\":{\"format\":{\"type\":\"text\"},\"verbosity\":\"low\"},\"tool_choice\":\"auto\",\"tools\":[],\"top_logprobs\":0,\"top_p\":0.98,\"truncation\":\"disabled\",\"usage\":null,\"user\":null,\"metadata\":{}},\"sequence_number\":1}\n\nevent: response.output_item.added\ndata: {\"type\":\"response.output_item.added\",\"item\":{\"id\":\"rs_05f7e55dc1d2d743016a1235e65eac8193ba914b956e3a49e1\",\"type\":\"reasoning\",\"encrypted_content\":\"gAAAAABqEjXmpkIEr1Ox1CgTHFUzuGnLwOE9YZFf7lBDRcOgQiNuTk-CopgZ20-kaosULz5cy40Ga0zkuwMFxLHSH4Cyebm9mQCMMZmzZQybp-s-yfNGm9zz7kAh_y6NbaAQZiI2iaTY5_sQwIrMsdQ56-T3_--unhlXVjEqMwdbsQOLpTg0KpAigUOJkuAl4PgCHsr6p_oTIZ5ycChpihxh6Oyuyf_YzLz-bL63kWZeu2bZFYE3WftnJNzcY5VjaNEVDlYY5IBbgCqOCc8OhLrYarwaPdhiizu1FIuhreEDiYurEQOa5jSW3wN2KGUhfDPurzguIbZ-ZhSRW58w9UgrhYOS30bQkm3EYd7w9nI5MteZruw3hIVKR__tU_2T2o7GsTxYNYLgn7Q8WoBASZrBtDpXuBM5jO2JQzDEFPG9_S8f44_LDgKInHgh-t32eV1ZNW08Pg9l0tUutiqdEVbDFKFB68Eu5PCKRKqZ18VzXI2X_YeU0DW9ld_GoSesI88rmWCmOta2AQOrVNa2uehlMRXb0wGXKzS6t31di0_PQwPj9rBdVk-N3S-FOlF5fFEGDqWBR3E0lrEOtNMeIuR8u3gGDbEacqL-Sz22Mi7AJou7trOnVH5qFqbKpfOJnxpzjOeWUYVDpmVFD81Om7Fc6zlZsv0KIEt-w5IE_dXi2_guUpu0JhowDWFI9aFcRVZ6A9z7Mrwqtkrs9QqRJws2BRUde-6MOryUcKWGfwdkNMRkAVHSlC6T3Yq44UtLqb1gJ-vasc2K9BZwRtYBVy0jif1tjrMviiNy9ltdxPVlIJ-R_4JcYtcbRQc1ovd-aQ9MprDatYE3Vnmk_O_WSnzHRxzPbftBHb_IWwir4G3lTOTi7xopkmVFidgl03h72BfbmwkzKXlMmvClRYP5u-mYyZoG50h9ag==\",\"summary\":[]},\"output_index\":0,\"sequence_number\":2}\n\nevent: response.output_item.done\ndata: {\"type\":\"response.output_item.done\",\"item\":{\"id\":\"rs_05f7e55dc1d2d743016a1235e65eac8193ba914b956e3a49e1\",\"type\":\"reasoning\",\"encrypted_content\":\"gAAAAABqEjXn9Xij0jt39Rh_0pwIWNN4jY3e6aD8Z0BBuhH_kgkhr6VNIvsDQdD8q9ju8vkF4JnhDqcfSIbdtX_8r4HHiV7O6kFEHRSiW23Z775HdB6fggaELggnLAcBN4MxzN618I2CV-pnNStMVMeUZtbV22l6o0F6n00-_5vm7KkP44JQRFrYj_iypI9AY0S0OqItcAiB6ERLLdKTJELOAzGxyeHKpQeWH3kQY9ZbSFLfUVooZjZEEBBB3sLMkvkDq6J9iIyQFKXZeo_oFvayxWFD8BwW3fVuUFcg0waG5TJwHA90iPFjOCddkk4Kn07cIwoyuM8xYquLPQ2x3jBLninoP8UyRXrQaDFR3maPtQ4HbNBf9cz5nZmXmjmZnO9Ee78ELcU_LkvmTgtozd094XVxLHg9DN-Y_s-1GMD9SgiqBNePe-1f_Sf2FD-CQsE-vDIcpmlLwxrhkXkwZ8Ws4k8NhA1Kq8DA5KYmz7S2akl2Vg2wr5ZrdH1XQBarWUIwlhTFALhTipDPinqhytnxm5MEmu09MBGXYOP5qNQ2hT2F_ibH7kM4kBQV5zVLDl6U2ZsKDt_r7F9in8YU5ffLYAd_4M9FgsJQUvC1CxLRSSQyyPKbdy0mYCrBLTo68FGV36JIj_SQBYHp9I5IIXfMfw1d78SNN_jtgs8JweLVNhOzoOFqMEYgO4zO6xa6r7E1F6YtuQ3oQ8OIEjXIeWeLsRGn51hNYA9s2hfm8Qh14rq5TMV9QhDXj0a-7FO6PIqTUr2ZqfB6Oqeb_Ut708MWtIjZ0oRSYkDddS8QzcwGbg1AFwF9okVtZZqXyItZseC9hKQ7vjh9pWaMrsKT8pYxjbB1fwKPgwTZHGzQzhkLXsp7btR5DpRP4h1-YHTsiMDlEdjtZVF0Zn7OuKTQlanB14FsmaaJq-Ty0pktr9ejKji3VmmDeIWTWKmIMvnp9nc3_Wl_Cbvschquknk7Hd12KZkyRaWq-Q==\",\"summary\":[]},\"output_index\":0,\"sequence_number\":3}\n\nevent: response.output_item.added\ndata: {\"type\":\"response.output_item.added\",\"item\":{\"id\":\"msg_05f7e55dc1d2d743016a1235e7171481938e1402264186381c\",\"type\":\"message\",\"status\":\"in_progress\",\"content\":[],\"phase\":\"final_answer\",\"role\":\"assistant\"},\"output_index\":1,\"sequence_number\":4}\n\nevent: response.content_part.added\ndata: {\"type\":\"response.content_part.added\",\"content_index\":0,\"item_id\":\"msg_05f7e55dc1d2d743016a1235e7171481938e1402264186381c\",\"output_index\":1,\"part\":{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"\"},\"sequence_number\":5}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\"Hello\",\"item_id\":\"msg_05f7e55dc1d2d743016a1235e7171481938e1402264186381c\",\"logprobs\":[],\"obfuscation\":\"rArO0JBBk8l\",\"output_index\":1,\"sequence_number\":6}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\"!\",\"item_id\":\"msg_05f7e55dc1d2d743016a1235e7171481938e1402264186381c\",\"logprobs\":[],\"obfuscation\":\"WgBYGB6udiKMN2k\",\"output_index\":1,\"sequence_number\":7}\n\nevent: response.output_text.done\ndata: {\"type\":\"response.output_text.done\",\"content_index\":0,\"item_id\":\"msg_05f7e55dc1d2d743016a1235e7171481938e1402264186381c\",\"logprobs\":[],\"output_index\":1,\"sequence_number\":8,\"text\":\"Hello!\"}\n\nevent: response.content_part.done\ndata: {\"type\":\"response.content_part.done\",\"content_index\":0,\"item_id\":\"msg_05f7e55dc1d2d743016a1235e7171481938e1402264186381c\",\"output_index\":1,\"part\":{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"Hello!\"},\"sequence_number\":9}\n\nevent: response.output_item.done\ndata: {\"type\":\"response.output_item.done\",\"item\":{\"id\":\"msg_05f7e55dc1d2d743016a1235e7171481938e1402264186381c\",\"type\":\"message\",\"status\":\"completed\",\"content\":[{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"Hello!\"}],\"phase\":\"final_answer\",\"role\":\"assistant\"},\"output_index\":1,\"sequence_number\":10}\n\nevent: response.completed\ndata: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_05f7e55dc1d2d743016a1235e5bb3c8193b4c0e30fa316700c\",\"object\":\"response\",\"created_at\":1779578341,\"status\":\"completed\",\"background\":false,\"completed_at\":1779578343,\"error\":null,\"frequency_penalty\":0.0,\"incomplete_details\":null,\"instructions\":null,\"max_output_tokens\":120,\"max_tool_calls\":null,\"model\":\"gpt-5.5-2026-04-23\",\"moderation\":null,\"output\":[{\"id\":\"rs_05f7e55dc1d2d743016a1235e65eac8193ba914b956e3a49e1\",\"type\":\"reasoning\",\"encrypted_content\":\"gAAAAABqEjXnFOrSKMBad2Atutni2Bxu4oRnudNwadGzwBCliXn-ocbCrlL6URwbrS22k21EaNPztTRWQVOGMzldY1PrMyJQZxj479kaAPXFbXir2xv5QfL6ykVcNNb5yn8eV5flXuOPfRM1dkosOD5QtlEhOvFbW14i-WhbjU1fpTiDCbLIotPo8tkuIk-zJMSgA0jlE73Rmz2BQlT-ZSTqJA7f9hT93QMYjsWNCslk40WCbmqo4dJuuf19qg_2ktrXu5yjmde8QU-blxhPKiDsMlj4rFA1F6_pNqJ_oUgU6mwIuUDIVPLxIaV3IBDS9j9XjiBSzwtFAsotlqWv_D1vWGUKSGPQKPs31ylYvBVQMTOdXxQZ6TKPveYybka8XbAEVNTFAJjg5PDRKWikfqqH4b0W_FFVNCSSf1UIgLh2-Hf-xMRBXozAFkCoj8M_wZOqj-gYoIsaoLnm7Z15rMrNiL2aMgNc1-mB1Iw1rFTo7hdiLiEj7C0rmm77CrxkFfQgpWsVAw94xpbVO7oC4Xiy1UNB-VCCb5FLNAHyJyLd_wc0SVuEsxjllLZ-q3unHYDnZO-zuxhjZ2jodX_7tbM6PCKul5ETSxtWQztzZD3MZ8ED_kVagXNNth-BTuTB4W9678cQbkSZK0DzV50H2_NJuRgQdCROnVKiiA2mzAOl7LXzDLTdTlHOQYRKt3wvaFI1DkVD0T0_Wj3z9lI2kgx6ExtOvU9nkqnPv4gf1x2ce8Ao9uf8NgtQbqhpeGMoU0Km9AALkoCtiyIXYJWNVVYAi9JA9QS05K-S__J_GlgFg7J3_YiH8bcmMrfwryn9K6yelsLt3SBKCA9pkLVI-HFDRx19brkAQRZRlaflF7qQApps8MQBGGrvrKTui_4EAABhFmjsn8XVIGoz7f_MFoS6ijdi8LCHEf8MNquinKURmb8Cdg1Zekrbl6KGsdcPztftMKH-sO6bEWZeUWJb2KqtIDnH5jr-Qw==\",\"summary\":[]},{\"id\":\"msg_05f7e55dc1d2d743016a1235e7171481938e1402264186381c\",\"type\":\"message\",\"status\":\"completed\",\"content\":[{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"Hello!\"}],\"phase\":\"final_answer\",\"role\":\"assistant\"}],\"parallel_tool_calls\":true,\"presence_penalty\":0.0,\"previous_response_id\":null,\"prompt_cache_key\":null,\"prompt_cache_retention\":\"24h\",\"reasoning\":{\"context\":\"current_turn\",\"effort\":\"low\",\"summary\":\"detailed\"},\"safety_identifier\":null,\"service_tier\":\"default\",\"store\":false,\"temperature\":1.0,\"text\":{\"format\":{\"type\":\"text\"},\"verbosity\":\"low\"},\"tool_choice\":\"auto\",\"tools\":[],\"top_logprobs\":0,\"top_p\":0.98,\"truncation\":\"disabled\",\"usage\":{\"input_tokens\":31,\"input_tokens_details\":{\"cached_tokens\":0},\"output_tokens\":21,\"output_tokens_details\":{\"reasoning_tokens\":13},\"total_tokens\":52},\"user\":null,\"metadata\":{}},\"sequence_number\":11}\n\n" + } + } + ] +} diff --git a/packages/llm/test/generate-object.test.ts b/packages/llm/test/generate-object.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..9606f58f9840bea744efb5106be3a901ae7768c4 --- /dev/null +++ b/packages/llm/test/generate-object.test.ts @@ -0,0 +1,184 @@ +import { describe, expect, test } from "bun:test" +import { Effect, Schema } from "effect" +import { LLM } from "../src" +import * as OpenAIChat from "../src/protocols/openai-chat" +import { Auth } from "../src/route" +import { Tool, toDefinitions } from "../src/tool" +import { it } from "./lib/effect" +import { dynamicResponse } from "./lib/http" +import { finishChunk, toolCallChunk } from "./lib/openai-chunks" +import { sseEvents } from "./lib/sse" + +type OpenAIChatBody = { + readonly tool_choice?: unknown + readonly tools?: ReadonlyArray<{ + readonly function: { + readonly parameters: unknown + } + }> +} + +const model = OpenAIChat.route + .with({ endpoint: { baseURL: "https://api.openai.test/v1/" }, auth: Auth.bearer("test") }) + .model({ id: "gpt-4o-mini" }) + +const Json = Schema.fromJsonString(Schema.Unknown) +const decodeJson = Schema.decodeUnknownSync(Json) +const decodeBody = (text: string): OpenAIChatBody => decodeJson(text) as OpenAIChatBody + +describe("Tool.make (dynamic JSON Schema)", () => { + test("forwards JSON Schema and description through toDefinitions", () => { + const jsonSchema = { + type: "object" as const, + properties: { city: { type: "string" } }, + required: ["city"], + } + const lookup = Tool.make({ + description: "Look up something", + jsonSchema, + execute: () => Effect.succeed({ ok: true }), + }) + const [definition] = toDefinitions({ lookup }) + expect(definition?.name).toBe("lookup") + expect(definition?.description).toBe("Look up something") + expect(definition?.inputSchema).toEqual(jsonSchema) + }) + + test("execute receives the raw input untouched", async () => { + const seen: unknown[] = [] + const tool = Tool.make({ + description: "echo", + jsonSchema: { type: "object" }, + execute: (params) => + Effect.sync(() => { + seen.push(params) + return { ok: true } + }), + }) + const result = await Effect.runPromise(tool.execute({ hello: "world" })) + expect(seen).toEqual([{ hello: "world" }]) + expect(result).toEqual({ ok: true }) + }) +}) + +describe("LLM.generateObject", () => { + it.effect("forces a synthetic tool call and decodes the input", () => + Effect.gen(function* () { + const bodies: OpenAIChatBody[] = [] + const layer = dynamicResponse((input) => + Effect.sync(() => { + bodies.push(decodeBody(input.text)) + return input.respond( + sseEvents( + toolCallChunk("call_1", "generate_object", '{"city":"Paris","temp":22}'), + finishChunk("tool_calls"), + ), + { headers: { "content-type": "text/event-stream" } }, + ) + }), + ) + + const response = yield* LLM.generateObject({ + model, + prompt: "Return a structured weather report.", + schema: Schema.Struct({ city: Schema.String, temp: Schema.Number }), + }).pipe(Effect.provide(layer)) + + expect(response.object).toEqual({ city: "Paris", temp: 22 }) + expect(response.response.toolCalls).toHaveLength(1) + expect(bodies).toHaveLength(1) + expect(bodies[0].tool_choice).toEqual({ type: "function", function: { name: "generate_object" } }) + const tool = bodies[0].tools?.[0] + expect(bodies[0].tools).toHaveLength(1) + expect(tool).toMatchObject({ + type: "function", + function: { name: "generate_object" }, + }) + const params = tool?.function.parameters as { + readonly type?: unknown + readonly required?: unknown + readonly properties?: Record + } + expect(params.type).toBe("object") + expect(params.required).toEqual(["city", "temp"]) + expect(params.properties?.city).toMatchObject({ type: "string" }) + expect(params.properties?.temp).toBeDefined() + }), + ) + + it.effect("accepts a raw JSON Schema and returns the input untouched", () => + Effect.gen(function* () { + const bodies: OpenAIChatBody[] = [] + const layer = dynamicResponse((input) => + Effect.sync(() => { + bodies.push(decodeBody(input.text)) + return input.respond( + sseEvents(toolCallChunk("call_1", "generate_object", '{"name":"Ada","age":30}'), finishChunk("tool_calls")), + { headers: { "content-type": "text/event-stream" } }, + ) + }), + ) + + const response = yield* LLM.generateObject({ + model, + prompt: "Extract the user.", + jsonSchema: { + type: "object", + properties: { name: { type: "string" }, age: { type: "number" } }, + required: ["name", "age"], + }, + }).pipe(Effect.provide(layer)) + + expect(response.object).toEqual({ name: "Ada", age: 30 }) + expect(bodies[0].tools?.[0]?.function.parameters).toEqual({ + type: "object", + properties: { name: { type: "string" }, age: { type: "number" } }, + required: ["name", "age"], + }) + }), + ) + + it.effect("fails when the model does not call the synthetic tool", () => + Effect.gen(function* () { + const layer = dynamicResponse((input) => + Effect.sync(() => + input.respond(sseEvents({ id: "x", choices: [{ delta: { content: "no thanks" }, finish_reason: "stop" }] }), { + headers: { "content-type": "text/event-stream" }, + }), + ), + ) + + const exit = yield* LLM.generateObject({ + model, + prompt: "Return a structured value.", + schema: Schema.Struct({ value: Schema.Number }), + }).pipe(Effect.provide(layer), Effect.exit) + + expect(exit._tag).toBe("Failure") + }), + ) + + it.effect("fails with a decode error when the tool input does not match the schema", () => + Effect.gen(function* () { + const layer = dynamicResponse((input) => + Effect.sync(() => + input.respond( + sseEvents( + toolCallChunk("call_1", "generate_object", '{"value":"not-a-number"}'), + finishChunk("tool_calls"), + ), + { headers: { "content-type": "text/event-stream" } }, + ), + ), + ) + + const exit = yield* LLM.generateObject({ + model, + prompt: "Return a structured value.", + schema: Schema.Struct({ value: Schema.Number }), + }).pipe(Effect.provide(layer), Effect.exit) + + expect(exit._tag).toBe("Failure") + }), + ) +}) diff --git a/packages/llm/test/lib/effect.ts b/packages/llm/test/lib/effect.ts new file mode 100644 index 0000000000000000000000000000000000000000..05cf017b2be59295d7e612f744141274951b3a1e --- /dev/null +++ b/packages/llm/test/lib/effect.ts @@ -0,0 +1,50 @@ +import { test, type TestOptions } from "bun:test" +import { Cause, Effect, Exit, Layer } from "effect" +import type * as Scope from "effect/Scope" +import * as TestClock from "effect/testing/TestClock" +import * as TestConsole from "effect/testing/TestConsole" + +type Body = Effect.Effect | (() => Effect.Effect) + +const body = (value: Body) => Effect.suspend(() => (typeof value === "function" ? value() : value)) + +const run = (value: Body, layer: Layer.Layer) => + Effect.gen(function* () { + const exit = yield* body(value).pipe(Effect.scoped, Effect.provide(layer), Effect.exit) + if (Exit.isFailure(exit)) { + for (const err of Cause.prettyErrors(exit.cause)) { + yield* Effect.logError(err) + } + } + return yield* exit + }).pipe(Effect.runPromise) + +const make = (testLayer: Layer.Layer, liveLayer: Layer.Layer) => { + const effect = (name: string, value: Body, opts?: number | TestOptions) => + test(name, () => run(value, testLayer), opts) + + effect.only = (name: string, value: Body, opts?: number | TestOptions) => + test.only(name, () => run(value, testLayer), opts) + + effect.skip = (name: string, value: Body, opts?: number | TestOptions) => + test.skip(name, () => run(value, testLayer), opts) + + const live = (name: string, value: Body, opts?: number | TestOptions) => + test(name, () => run(value, liveLayer), opts) + + live.only = (name: string, value: Body, opts?: number | TestOptions) => + test.only(name, () => run(value, liveLayer), opts) + + live.skip = (name: string, value: Body, opts?: number | TestOptions) => + test.skip(name, () => run(value, liveLayer), opts) + + return { effect, live } +} + +const testEnv = Layer.mergeAll(TestConsole.layer, TestClock.layer()) +const liveEnv = TestConsole.layer + +export const it = make(testEnv, liveEnv) + +export const testEffect = (layer: Layer.Layer) => + make(Layer.provideMerge(layer, testEnv), Layer.provideMerge(layer, liveEnv)) diff --git a/packages/llm/test/lib/http.ts b/packages/llm/test/lib/http.ts new file mode 100644 index 0000000000000000000000000000000000000000..f6c600555b9389d715d8142477a5400864084f6b --- /dev/null +++ b/packages/llm/test/lib/http.ts @@ -0,0 +1,98 @@ +import { Effect, Layer, Ref } from "effect" +import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http" +import { LLMClient, RequestExecutor, WebSocketExecutor } from "../../src/route" +import type { Service as LLMClientService } from "../../src/route/client" +import type { Service as RequestExecutorService } from "../../src/route/executor" +import type { Service as WebSocketExecutorService } from "../../src/route/transport/websocket" + +export type HandlerInput = { + readonly request: HttpClientRequest.HttpClientRequest + readonly text: string + readonly respond: ( + body: ConstructorParameters[0], + init?: ResponseInit, + ) => HttpClientResponse.HttpClientResponse +} + +export type Handler = (input: HandlerInput) => Effect.Effect + +const handlerLayer = (handler: Handler): Layer.Layer => + Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => + Effect.gen(function* () { + const web = yield* HttpClientRequest.toWeb(request).pipe(Effect.orDie) + const text = yield* Effect.promise(() => web.text()) + return yield* handler({ + request, + text, + respond: (body, init) => HttpClientResponse.fromWeb(request, new Response(body, init)), + }) + }), + ), + ) + +export type RuntimeEnv = RequestExecutorService | WebSocketExecutorService | LLMClientService + +export const runtimeLayer = (layer: Layer.Layer): Layer.Layer => { + const requestExecutorLayer = RequestExecutor.layer.pipe(Layer.provide(layer)) + const deps = Layer.mergeAll(requestExecutorLayer, WebSocketExecutor.layer) + const llmClientLayer = LLMClient.layer.pipe(Layer.provide(deps)) + return Layer.mergeAll(deps, llmClientLayer) +} + +const SSE_HEADERS = { "content-type": "text/event-stream" } as const + +/** + * Layer that returns a single fixed response body. Use for stream-parser + * fixture tests where the request shape is irrelevant. The body type widens + * to whatever `Response` accepts so binary fixtures (`Uint8Array`, + * `ReadableStream`, etc.) flow through without casts. + */ +export const fixedResponse = ( + body: ConstructorParameters[0], + init: ResponseInit = { headers: SSE_HEADERS }, +) => runtimeLayer(handlerLayer((input) => Effect.succeed(input.respond(body, init)))) + +/** + * Layer that builds a response per request. Useful for echo servers. + */ +export const dynamicResponse = (handler: Handler) => runtimeLayer(handlerLayer(handler)) + +/** + * Layer that emits the supplied SSE chunks and then aborts mid-stream. Used to + * exercise transport errors that surface during parsing. + */ +export const truncatedStream = (chunks: ReadonlyArray) => + dynamicResponse((input) => + Effect.sync(() => { + const encoder = new TextEncoder() + const stream = new ReadableStream({ + start(controller) { + for (const chunk of chunks) controller.enqueue(encoder.encode(chunk)) + controller.error(new Error("connection reset")) + }, + }) + return input.respond(stream, { headers: SSE_HEADERS }) + }), + ) + +/** + * Layer that returns successive bodies on each request. Useful for scripting + * multi-step model exchanges (e.g. tool-call loops). The last body in the + * array is reused if the test makes more requests than scripted. + */ +export const scriptedResponses = (bodies: ReadonlyArray, init: ResponseInit = { headers: SSE_HEADERS }) => { + if (bodies.length === 0) throw new Error("scriptedResponses requires at least one body") + return Layer.unwrap( + Effect.gen(function* () { + const cursor = yield* Ref.make(0) + return dynamicResponse((input) => + Effect.gen(function* () { + const index = yield* Ref.getAndUpdate(cursor, (n) => n + 1) + return input.respond(bodies[index] ?? bodies[bodies.length - 1], init) + }), + ) + }), + ) +} diff --git a/packages/llm/test/lib/openai-chunks.ts b/packages/llm/test/lib/openai-chunks.ts new file mode 100644 index 0000000000000000000000000000000000000000..77a7c919e1a174c983eab0331738d5e274758d59 --- /dev/null +++ b/packages/llm/test/lib/openai-chunks.ts @@ -0,0 +1,27 @@ +/** + * Shared chunk shapes for OpenAI Chat / OpenAI-compatible Chat fixture tests. + * Multiple test files build the same `{ id, choices: [{ delta, finish_reason }], usage }` + * envelope; consolidating here keeps tool-call event shapes consistent. + */ + +const FIXTURE_ID = "chatcmpl_fixture" + +export const deltaChunk = (delta: object, finishReason: string | null = null) => ({ + id: FIXTURE_ID, + choices: [{ delta, finish_reason: finishReason }], + usage: null, +}) + +export const usageChunk = (usage: object) => ({ + id: FIXTURE_ID, + choices: [], + usage, +}) + +export const finishChunk = (reason: string) => deltaChunk({}, reason) + +export const toolCallChunk = (id: string, name: string, args: string, index = 0) => + deltaChunk({ + role: "assistant", + tool_calls: [{ index, id, function: { name, arguments: args } }], + }) diff --git a/packages/llm/test/lib/sse.ts b/packages/llm/test/lib/sse.ts new file mode 100644 index 0000000000000000000000000000000000000000..80b275d296e8eb9cdac73a99a0c81afbba6dce81 --- /dev/null +++ b/packages/llm/test/lib/sse.ts @@ -0,0 +1,17 @@ +/** + * Helpers for building deterministic SSE bodies in tests. + * + * Inline template-literal SSE strings are hard to write and review when chunks + * contain JSON; this helper accepts plain values and serializes them, so test + * authors only think about the chunk shapes, not the wire format. + */ +export const sseEvents = (...chunks: ReadonlyArray): string => + `${chunks.map(formatChunk).join("")}data: [DONE]\n\n` + +const formatChunk = (chunk: unknown) => `data: ${typeof chunk === "string" ? chunk : JSON.stringify(chunk)}\n\n` + +/** + * Build an SSE body from already-serialized strings (used when the chunk shape + * itself is part of what's being tested, e.g. malformed chunks). + */ +export const sseRaw = (...lines: ReadonlyArray): string => lines.map((line) => `${line}\n\n`).join("") diff --git a/packages/llm/test/lib/tool-runtime.ts b/packages/llm/test/lib/tool-runtime.ts new file mode 100644 index 0000000000000000000000000000000000000000..28ebc47c712b3331ff30ff5a89449d49c7ce8621 --- /dev/null +++ b/packages/llm/test/lib/tool-runtime.ts @@ -0,0 +1,146 @@ +import { Effect, Stream } from "effect" +import { LLMClient } from "../../src/route" +import { + LLMEvent, + LLMRequest, + Message, + type ContentPart, + type ProviderMetadata, + type ToolCallPart, + ToolResultPart, + type ToolResultValue, + type Usage, +} from "../../src/schema" +import { type Tools, toDefinitions } from "../../src/tool" +import { ToolRuntime } from "../../src/tool-runtime" + +interface RunOptions { + readonly request: LLMRequest + readonly tools: T + readonly maxSteps?: number +} + +/** Test-owned continuation loop. Production callers must own durable history. */ +export const runTools = (options: RunOptions) => + Stream.unwrap( + Effect.gen(function* () { + const names = new Set(Object.keys(options.tools)) + let request = LLMRequest.update(options.request, { + tools: [...options.request.tools.filter((tool) => !names.has(tool.name)), ...toDefinitions(options.tools)], + }) + let usage: Usage | undefined + const events: LLMEvent[] = [] + + for (let step = 0; step < (options.maxSteps ?? 10); step++) { + const streamed = Array.from(yield* LLMClient.stream(request).pipe(Stream.runCollect)) + const state = stepState(streamed) + usage = addUsage(usage, state.usage) + events.push(...streamed.filter((event) => event.type !== "finish").map((event) => indexStep(event, step))) + + if (state.toolCalls.length === 0) { + events.push(LLMEvent.finish({ reason: state.reason, usage, providerMetadata: state.providerMetadata })) + return Stream.fromIterable(events) + } + + const dispatched = yield* Effect.forEach( + state.toolCalls, + (call) => ToolRuntime.dispatch(options.tools, call).pipe(Effect.map((result) => [call, result] as const)), + { concurrency: 10 }, + ) + events.push(...dispatched.flatMap(([, result]) => result.events)) + + if (step + 1 >= (options.maxSteps ?? 10)) { + events.push(LLMEvent.finish({ reason: state.reason, usage, providerMetadata: state.providerMetadata })) + return Stream.fromIterable(events) + } + + request = LLMRequest.update(request, { + messages: [ + ...request.messages, + Message.assistant(state.assistantContent), + ...dispatched.map(([call, dispatched]) => + Message.tool({ id: call.id, name: call.name, result: dispatched.result }), + ), + ], + }) + } + + return Stream.fromIterable(events) + }), + ) + +const indexStep = (event: LLMEvent, index: number): LLMEvent => { + if (event.type === "step-start") return LLMEvent.stepStart({ index }) + if (event.type === "step-finish") return LLMEvent.stepFinish({ ...event, index }) + return event +} + +const stepState = (events: ReadonlyArray) => { + const assistantContent: ContentPart[] = [] + const toolCalls: ToolCallPart[] = [] + let reason: Extract["reason"] = "unknown" + let usage: Usage | undefined + let providerMetadata: ProviderMetadata | undefined + + for (const event of events) { + if (event.type === "text-delta" || event.type === "reasoning-delta") { + appendText(assistantContent, event.type === "text-delta" ? "text" : "reasoning", event.text) + } else if (event.type === "text-end" || event.type === "reasoning-end") { + appendText(assistantContent, event.type === "text-end" ? "text" : "reasoning", "", event.providerMetadata) + } else if (event.type === "tool-call") { + assistantContent.push(event) + if (!event.providerExecuted) toolCalls.push(event) + } else if (event.type === "tool-result" && event.providerExecuted && event.result !== undefined) { + assistantContent.push( + ToolResultPart.make({ + id: event.id, + name: event.name, + result: event.result, + providerExecuted: true, + providerMetadata: event.providerMetadata, + }), + ) + } else if (event.type === "finish") { + reason = event.reason + usage = event.usage + providerMetadata = event.providerMetadata + } + } + return { assistantContent, toolCalls, reason, usage, providerMetadata } +} + +const appendText = ( + content: ContentPart[], + type: "text" | "reasoning", + text: string, + providerMetadata?: ProviderMetadata, +) => { + const last = content.at(-1) + if (last?.type === type) { + content[content.length - 1] = { + ...last, + text: `${last.text}${text}`, + providerMetadata: providerMetadata ?? last.providerMetadata, + } + return + } + content.push({ type, text, providerMetadata }) +} + +const addUsage = (left: Usage | undefined, right: Usage | undefined): Usage | undefined => { + if (!left) return right + if (!right) return left + const sum = (key: keyof Usage) => + typeof left[key] !== "number" && typeof right[key] !== "number" + ? undefined + : ((left[key] as number | undefined) ?? 0) + ((right[key] as number | undefined) ?? 0) + return { + inputTokens: sum("inputTokens"), + outputTokens: sum("outputTokens"), + nonCachedInputTokens: sum("nonCachedInputTokens"), + cacheReadInputTokens: sum("cacheReadInputTokens"), + cacheWriteInputTokens: sum("cacheWriteInputTokens"), + reasoningTokens: sum("reasoningTokens"), + totalTokens: sum("totalTokens"), + } as Usage +} diff --git a/packages/llm/test/llm.test.ts b/packages/llm/test/llm.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..64346a8bb030e28f41c66181a9477f654dc056d8 --- /dev/null +++ b/packages/llm/test/llm.test.ts @@ -0,0 +1,199 @@ +import { describe, expect, test } from "bun:test" +import { CacheHint, LLM, LLMResponse } from "../src" +import * as OpenAIChat from "../src/protocols/openai-chat" +import * as OpenAIResponses from "../src/protocols/openai-responses" +import { LLMRequest, Message, Model, ToolCallPart, ToolChoice, ToolDefinition, ToolResultPart } from "../src/schema" + +const chatRoute = OpenAIChat.route +const responsesRoute = OpenAIResponses.route + +describe("llm constructors", () => { + test("builds canonical schema classes from ergonomic input", () => { + const request = LLM.request({ + id: "req_1", + model: Model.make({ id: "fake-model", provider: "fake", route: chatRoute }), + system: "You are concise.", + prompt: "Say hello.", + }) + + expect(request).toBeInstanceOf(LLMRequest) + expect(request.model).toBeInstanceOf(Model) + expect(request.messages[0]).toBeInstanceOf(Message) + expect(request.system).toEqual([{ type: "text", text: "You are concise." }]) + expect(request.messages[0]?.content).toEqual([{ type: "text", text: "Say hello." }]) + expect(request.generation).toBeUndefined() + expect(request.tools).toEqual([]) + }) + + test("updates requests without spreading schema class instances", () => { + const base = LLM.request({ + id: "req_1", + model: Model.make({ id: "fake-model", provider: "fake", route: chatRoute }), + prompt: "Say hello.", + }) + const updated = LLM.updateRequest(base, { + generation: { maxTokens: 20 }, + messages: [...base.messages, Message.assistant("Hi.")], + }) + + expect(updated).toBeInstanceOf(LLMRequest) + expect(updated.id).toBe("req_1") + expect(updated.model).toEqual(base.model) + expect(updated.generation).toEqual({ maxTokens: 20 }) + expect(updated.messages.map((message) => message.role)).toEqual(["user", "assistant"]) + }) + + test("keeps request options separate from route defaults", () => { + const request = LLM.request({ + model: Model.make({ + id: "fake-model", + provider: "fake", + route: chatRoute.with({ + generation: { maxTokens: 100, temperature: 1 }, + providerOptions: { openai: { store: false, metadata: { model: true } } }, + http: { body: { metadata: { model: true } }, headers: { "x-shared": "model" }, query: { model: "1" } }, + }), + }), + prompt: "Say hello.", + generation: { temperature: 0 }, + providerOptions: { openai: { store: true, metadata: { request: true } } }, + http: { body: { metadata: { request: true } }, headers: { "x-shared": "request" }, query: { request: "1" } }, + }) + + expect(request.generation).toEqual({ temperature: 0 }) + expect(request.providerOptions).toEqual({ openai: { store: true, metadata: { request: true } } }) + expect(request.http).toEqual({ + body: { metadata: { request: true } }, + headers: { "x-shared": "request" }, + query: { request: "1" }, + }) + }) + + test("updates canonical requests from the request datatype", () => { + const base = LLM.request({ + id: "req_1", + model: Model.make({ id: "fake-model", provider: "fake", route: chatRoute }), + prompt: "Say hello.", + }) + const updated = LLMRequest.update(base, { messages: [...base.messages, Message.assistant("Hi.")] }) + + expect(updated).toBeInstanceOf(LLMRequest) + expect(updated.id).toBe("req_1") + expect(LLMRequest.input(updated).id).toBe("req_1") + expect(updated.messages.map((message) => message.role)).toEqual(["user", "assistant"]) + expect(LLMRequest.update(updated, {})).toBe(updated) + }) + + test("updates canonical models from the model datatype", () => { + const base = Model.make({ + id: "fake-model", + provider: "fake", + route: chatRoute, + }) + const updated = Model.update(base, { + route: responsesRoute, + defaults: { generation: { maxTokens: 20 } }, + compatibility: { toolSchema: "gemini" }, + }) + const updatedInput = Model.input(updated) + + expect(updated).toBeInstanceOf(Model) + expect(String(updated.id)).toBe("fake-model") + expect(updated.route).toBe(responsesRoute) + expect(updated.defaults?.generation).toEqual({ maxTokens: 20 }) + expect(updated.compatibility).toEqual({ toolSchema: "gemini" }) + expect(updatedInput.defaults).toBe(updated.defaults) + expect(updatedInput.compatibility).toBe(updated.compatibility) + expect(String(updatedInput.provider)).toBe("fake") + expect(Model.update(updated, {})).toBe(updated) + }) + + test("carries model defaults and compatibility through route model selection", () => { + const model = chatRoute.model({ + id: "kimi-k2", + defaults: { + limits: { context: 128_000, output: 8_192 }, + generation: { maxTokens: 1_024, stop: ["END"] }, + providerOptions: { openai: { parallelToolCalls: false } }, + http: { body: { extra_body: true } }, + }, + compatibility: { toolSchema: "moonshot" }, + }) + const request = LLM.request({ model, prompt: "Say hello." }) + + expect(request.model.defaults?.limits).toEqual({ context: 128_000, output: 8_192 }) + expect(request.model.defaults?.generation).toEqual({ maxTokens: 1_024, stop: ["END"] }) + expect(request.model.defaults?.providerOptions).toEqual({ openai: { parallelToolCalls: false } }) + expect(request.model.defaults?.http).toEqual({ body: { extra_body: true } }) + expect(request.model.compatibility).toEqual({ toolSchema: "moonshot" }) + expect(request.generation).toBeUndefined() + expect(request.providerOptions).toBeUndefined() + expect(request.http).toBeUndefined() + }) + + test("builds tool choices from names and tools", () => { + const tool = ToolDefinition.make({ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } }) + + expect(tool).toBeInstanceOf(ToolDefinition) + expect(ToolChoice.make("lookup")).toEqual(new ToolChoice({ type: "tool", name: "lookup" })) + expect(ToolChoice.named("required")).toEqual(new ToolChoice({ type: "tool", name: "required" })) + expect(ToolChoice.make(tool)).toEqual(new ToolChoice({ type: "tool", name: "lookup" })) + }) + + test("builds tool choice modes from reserved strings", () => { + expect(ToolChoice.make("auto")).toEqual(new ToolChoice({ type: "auto" })) + expect(ToolChoice.make("none")).toEqual(new ToolChoice({ type: "none" })) + expect(ToolChoice.make("required")).toEqual(new ToolChoice({ type: "required" })) + expect( + LLM.request({ + model: Model.make({ + id: "fake-model", + provider: "fake", + route: chatRoute, + }), + prompt: "Use tools if needed.", + toolChoice: "required", + }).toolChoice, + ).toEqual(new ToolChoice({ type: "required" })) + }) + + test("builds assistant tool calls and tool result messages", () => { + const call = ToolCallPart.make({ id: "call_1", name: "lookup", input: { query: "weather" } }) + const result = ToolResultPart.make({ id: "call_1", name: "lookup", result: { temperature: 72 } }) + + expect(Message.assistant([call]).content).toEqual([call]) + expect(Message.tool(result).content).toEqual([ + { type: "tool-result", id: "call_1", name: "lookup", result: { type: "json", value: { temperature: 72 } } }, + ]) + }) + + test("builds chronological text-only system updates separately from the initial system prompt", () => { + const update = Message.system([ + { type: "text", text: "Use parameterized SQL.", cache: new CacheHint({ type: "ephemeral" }) }, + ]) + const request = LLM.request({ + model: Model.make({ id: "fake-model", provider: "fake", route: chatRoute }), + system: "Initial operator prompt.", + messages: [Message.user("Review this."), update], + }) + + expect(update).toBeInstanceOf(Message) + expect(update).toEqual({ + role: "system", + content: [{ type: "text", text: "Use parameterized SQL.", cache: { type: "ephemeral" } }], + }) + expect(request.system).toEqual([{ type: "text", text: "Initial operator prompt." }]) + expect(request.messages.map((message) => message.role)).toEqual(["user", "system"]) + }) + + test("extracts output text from response events", () => { + expect( + LLMResponse.text({ + events: [ + { type: "text-delta", id: "text-0", text: "hi" }, + { type: "finish", reason: "stop" }, + ], + }), + ).toBe("hi") + }) +}) diff --git a/packages/llm/test/prepare.test.ts b/packages/llm/test/prepare.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..6923c5a678ed4dbb0eb59b24bd96b6c480366aa7 --- /dev/null +++ b/packages/llm/test/prepare.test.ts @@ -0,0 +1,178 @@ +import { describe, expect, test } from "bun:test" +import { Effect, Schema } from "effect" +import { HttpClientRequest } from "effect/unstable/http" +import { LLM, mergeProviderOptions } from "../src" +import { AnthropicMessages, OpenAIChat } from "../src/protocols" +import { Auth, LLMClient } from "../src/route" +import { it } from "./lib/effect" +import { dynamicResponse } from "./lib/http" +import { deltaChunk } from "./lib/openai-chunks" +import { sseEvents } from "./lib/sse" + +const TargetJson = Schema.fromJsonString(Schema.Unknown) +const decodeJson = Schema.decodeUnknownSync(TargetJson) + +describe("request option precedence", () => { + test("deep-merges provider option records and replaces arrays, primitives, and null", () => { + const merged = mergeProviderOptions( + { + openai: { + include: ["route"], + metadata: { route: true, shared: "route" }, + nullable: "route", + primitive: "route", + }, + }, + { + openai: { + include: ["model"], + metadata: { model: true, shared: "model" }, + nullable: null, + primitive: "model", + }, + }, + { openai: { metadata: { request: true }, primitive: false } }, + ) + + expect(merged).toEqual({ + openai: { + include: ["model"], + metadata: { route: true, model: true, request: true, shared: "model" }, + nullable: null, + primitive: false, + }, + }) + }) + + it.effect("prepares bodies with route defaults, model defaults, and call options in order", () => + Effect.gen(function* () { + const route = OpenAIChat.route.with({ + endpoint: { baseURL: "https://api.openai.test/v1/" }, + auth: Auth.bearer("test"), + generation: { maxTokens: 10, temperature: 1, stop: ["route"] }, + providerOptions: { openai: { store: false, reasoningEffort: "low" } }, + }) + const model = route.model({ + id: "gpt-4o-mini", + defaults: { + generation: { maxTokens: 20, temperature: 0.5, frequencyPenalty: 0.25, stop: ["model"] }, + providerOptions: { openai: { reasoningEffort: "medium" } }, + }, + }) + const prepared = yield* LLMClient.prepare( + LLM.request({ + model, + prompt: "Say hello.", + generation: { maxTokens: 30, topP: 0.9, stop: ["request"] }, + providerOptions: { openai: { store: true } }, + }), + ) + + expect(prepared.body).toMatchObject({ + model: "gpt-4o-mini", + stream: true, + max_tokens: 30, + temperature: 0.5, + top_p: 0.9, + frequency_penalty: 0.25, + store: true, + reasoning_effort: "medium", + }) + expect(prepared.body.stop).toEqual(["request"]) + }), + ) + + it.effect("applies model HTTP defaults before request HTTP overlays", () => + LLMClient.generate( + LLM.request({ + model: OpenAIChat.route + .with({ + endpoint: { baseURL: "https://api.openai.test/v1/" }, + auth: Auth.bearer("fresh-key"), + http: { + body: { metadata: { route: true, shared: "route" }, value: "route" }, + headers: { "x-route": "route", "x-shared": "route" }, + query: { route: "1", shared: "route" }, + }, + }) + .model({ + id: "gpt-4o-mini", + defaults: { + http: { + body: { metadata: { model: true, shared: "model" }, value: "model" }, + headers: { "x-model": "model", "x-shared": "model" }, + query: { model: "1", shared: "model" }, + }, + }, + }), + prompt: "Say hello.", + http: { + body: { metadata: { request: true }, value: null }, + headers: { "x-request": "request" }, + query: { request: "1" }, + }, + }), + ).pipe( + Effect.provide( + dynamicResponse((input) => + Effect.gen(function* () { + const web = yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie) + expect(web.url).toBe("https://api.openai.test/v1/chat/completions?route=1&shared=model&model=1&request=1") + expect(web.headers.get("authorization")).toBe("Bearer fresh-key") + expect(web.headers.get("x-route")).toBe("route") + expect(web.headers.get("x-model")).toBe("model") + expect(web.headers.get("x-request")).toBe("request") + expect(web.headers.get("x-shared")).toBe("model") + expect(decodeJson(input.text)).toMatchObject({ + metadata: { route: true, model: true, request: true, shared: "model" }, + value: null, + }) + return input.respond(sseEvents(deltaChunk({}, "stop")), { + headers: { "content-type": "text/event-stream" }, + }) + }), + ), + ), + ), + ) + + it.effect("rejects raw body overlays for protocol-owned roots", () => + Effect.gen(function* () { + const model = OpenAIChat.route + .with({ endpoint: { baseURL: "https://api.openai.test/v1/" }, auth: Auth.bearer("test") }) + .model({ id: "gpt-4o-mini" }) + const error = yield* LLMClient.prepare( + LLM.request({ + model, + prompt: "Say hello.", + http: { body: { model: "gpt-5", messages: [], tools: [] } }, + }), + ).pipe(Effect.flip) + + expect(error.reason).toMatchObject({ + _tag: "InvalidRequest", + message: "http.body cannot overlay protocol-owned field(s): model, messages, tools", + }) + }), + ) + + it.effect("uses model output limits after route limits and before call maxTokens", () => + Effect.gen(function* () { + const route = AnthropicMessages.route.with({ + endpoint: { baseURL: "https://api.anthropic.test/v1/" }, + auth: Auth.header("x-api-key", "test"), + limits: { output: 128 }, + }) + const model = route.model({ id: "claude-sonnet-4-5", defaults: { limits: { output: 64 } } }) + const withoutMaxTokens = yield* LLMClient.prepare( + LLM.request({ model, prompt: "Say hello.", cache: "none" }), + ) + const withMaxTokens = yield* LLMClient.prepare( + LLM.request({ model, prompt: "Say hello.", cache: "none", generation: { maxTokens: 32 } }), + ) + + expect(withoutMaxTokens.body.max_tokens).toBe(64) + expect(withMaxTokens.body.max_tokens).toBe(32) + }), + ) +}) diff --git a/packages/llm/test/provider-error.test.ts b/packages/llm/test/provider-error.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..c4185969640a2b70ed50c75beae8f9c6358e3137 --- /dev/null +++ b/packages/llm/test/provider-error.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, test } from "bun:test" +import { isContextOverflow } from "../src" + +describe("provider error classification", () => { + test("classifies provider token limit messages as context overflow", () => { + const messages = [ + "tokens in request more than max tokens allowed", + '{"error":{"type":"request_too_large","message":"Request exceeds the maximum size"}}', + "Requested token count exceeds the model's maximum context length of 131072 tokens.", + "Input length (265330) exceeds model's maximum context length (262144).", + "Input length 131393 exceeds the maximum allowed input length of 131040 tokens.", + "The input (516368 tokens) is longer than the model's context length (262144 tokens).", + "Prompt has 5,958,968 tokens, but the configured context size is 256,000 tokens", + "Too many tokens", + "Token limit exceeded", + ] + + expect(messages.every(isContextOverflow)).toBe(true) + }) + + test("does not classify rate limits as context overflow", () => { + const messages = [ + "Throttling error: Too many tokens, please wait before trying again.", + "Rate limit exceeded, please retry after 30 seconds.", + "Too many requests. Please slow down.", + ] + + expect(messages.some(isContextOverflow)).toBe(false) + }) +}) diff --git a/packages/llm/test/provider.types.ts b/packages/llm/test/provider.types.ts new file mode 100644 index 0000000000000000000000000000000000000000..f8b46e375394eda46143b1c01412a4a16e20b2e8 --- /dev/null +++ b/packages/llm/test/provider.types.ts @@ -0,0 +1,41 @@ +import { Provider } from "../src/provider" +import { ProviderID, type Model } from "../src/schema" + +declare const model: (id: string) => Model +declare const requiredModel: (id: string, options: { readonly baseURL: string }) => Model +declare const chat: (id: string, options: { readonly apiKey: string }) => Model + +Provider.make({ + id: ProviderID.make("example"), + model, +}) + +Provider.make({ + id: ProviderID.make("bad"), + model, + // @ts-expect-error provider definitions should not grow accidental top-level fields. + routes: [], +}) + +const requiredProvider = Provider.make({ + id: ProviderID.make("required"), + model: requiredModel, +}) + +// Provider.make is advanced structural typing coverage; built-in providers use +// configure(...).model(id) facades instead of second-argument selectors. +requiredProvider.model("custom", { baseURL: "https://example.com/v1" }) + +// @ts-expect-error Provider.make preserves required model options. +requiredProvider.model("custom") + +const multiApiProvider = Provider.make({ + id: ProviderID.make("multi-api"), + model, + apis: { chat }, +}) + +multiApiProvider.apis.chat("chat-model", { apiKey: "key" }) + +// @ts-expect-error Provider.make preserves API-specific option types. +multiApiProvider.apis.chat("chat-model") diff --git a/packages/llm/test/provider/anthropic-messages-cache.recorded.test.ts b/packages/llm/test/provider/anthropic-messages-cache.recorded.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..910c13fbb22a2c1c9837b0f17b23548388d3a031 --- /dev/null +++ b/packages/llm/test/provider/anthropic-messages-cache.recorded.test.ts @@ -0,0 +1,53 @@ +import { describe, expect } from "bun:test" +import { Effect } from "effect" +import { CacheHint, LLM } from "../../src" +import { LLMClient } from "../../src/route" +import * as Anthropic from "../../src/providers/anthropic" +import { LARGE_CACHEABLE_SYSTEM } from "../recorded-scenarios" +import { recordedTests } from "../recorded-test" + +const model = Anthropic.configure({ + apiKey: process.env.ANTHROPIC_API_KEY ?? "fixture", +}).model("claude-haiku-4-5-20251001") + +// Two identical generations in a row. The first call writes the prefix into +// Anthropic's cache; the second should report a cache read against the same +// prefix. Cassette captures both interactions in order. +const cacheRequest = LLM.request({ + id: "recorded_anthropic_cache", + model, + system: [{ type: "text", text: LARGE_CACHEABLE_SYSTEM, cache: new CacheHint({ type: "ephemeral" }) }], + prompt: "Say hi.", + // Manual hint on the system part is the only marker we want here — skip the + // auto-policy's latest-user-message breakpoint so the cassette body matches. + cache: "none", + generation: { maxTokens: 16, temperature: 0 }, +}) + +const recorded = recordedTests({ + prefix: "anthropic-messages-cache", + provider: "anthropic", + protocol: "anthropic-messages", + requires: ["ANTHROPIC_API_KEY"], + // Two identical requests in one cassette — replay walks the cassette in + // recording order so the second call replays the cached-hit interaction. + options: { + redact: { allowRequestHeaders: ["anthropic-version"] }, + }, +}) + +describe("Anthropic Messages cache recorded", () => { + recorded.effect.with("writes then reads cache_control on identical second call", { tags: ["cache"] }, () => + Effect.gen(function* () { + const first = yield* LLMClient.generate(cacheRequest) + // The first call may write the cache (cacheWriteInputTokens > 0) or it + // may be a fresh miss (both fields 0) depending on whether the prefix is + // already warm on Anthropic's side. The assertion that matters is that + // the SECOND call reports a non-zero cache read. + expect(first.usage?.cacheReadInputTokens ?? 0).toBeGreaterThanOrEqual(0) + + const second = yield* LLMClient.generate(cacheRequest) + expect(second.usage?.cacheReadInputTokens ?? 0).toBeGreaterThan(0) + }), + ) +}) diff --git a/packages/llm/test/provider/anthropic-messages.recorded.test.ts b/packages/llm/test/provider/anthropic-messages.recorded.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..9b552550add402f6028386da07b5ba06aaafcdb7 --- /dev/null +++ b/packages/llm/test/provider/anthropic-messages.recorded.test.ts @@ -0,0 +1,45 @@ +import { describe, expect } from "bun:test" +import { Effect } from "effect" +import { LLM, LLMError, Message, ToolCallPart } from "../../src" +import { LLMClient } from "../../src/route" +import * as Anthropic from "../../src/providers/anthropic" +import { weatherToolName } from "../recorded-scenarios" +import { recordedTests } from "../recorded-test" + +const model = Anthropic.configure({ + apiKey: process.env.ANTHROPIC_API_KEY ?? "fixture", +}).model("claude-haiku-4-5-20251001") + +const malformedToolOrderRequest = LLM.request({ + id: "recorded_anthropic_malformed_tool_order", + model, + messages: [ + Message.assistant([ + ToolCallPart.make({ id: "call_1", name: weatherToolName, input: { city: "Paris" } }), + { type: "text", text: "I will check the weather." }, + ]), + Message.tool({ id: "call_1", name: weatherToolName, result: { temperature: "72F" } }), + Message.user("Use that result to answer briefly."), + ], + tools: [{ name: weatherToolName, description: "Get weather", inputSchema: { type: "object", properties: {} } }], +}) + +const recorded = recordedTests({ + prefix: "anthropic-messages", + provider: "anthropic", + protocol: "anthropic-messages", + requires: ["ANTHROPIC_API_KEY"], + options: { redact: { allowRequestHeaders: ["anthropic-version"] } }, +}) + +describe("Anthropic Messages sad-path recorded", () => { + recorded.effect.with("rejects malformed assistant tool order", { tags: ["tool", "sad-path"] }, () => + Effect.gen(function* () { + const error = yield* LLMClient.generate(malformedToolOrderRequest).pipe(Effect.flip) + + expect(error).toBeInstanceOf(LLMError) + expect(error.reason).toMatchObject({ _tag: "InvalidRequest" }) + expect(error.message).toContain("HTTP 400") + }), + ) +}) diff --git a/packages/llm/test/provider/anthropic-messages.test.ts b/packages/llm/test/provider/anthropic-messages.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..898931295849e3d7a91813b384b887ca0d8d8fd3 --- /dev/null +++ b/packages/llm/test/provider/anthropic-messages.test.ts @@ -0,0 +1,895 @@ +import { describe, expect } from "bun:test" +import { Effect } from "effect" +import { HttpClientRequest } from "effect/unstable/http" +import { CacheHint, LLM, LLMError, Message, ToolCallPart, Usage } from "../../src" +import { Auth, LLMClient } from "../../src/route" +import * as AnthropicMessages from "../../src/protocols/anthropic-messages" +import { continuationRequest, nativeAnthropicMessagesContinuation } from "../continuation-scenarios" +import { it } from "../lib/effect" +import { dynamicResponse, fixedResponse } from "../lib/http" +import { sseEvents } from "../lib/sse" + +const model = AnthropicMessages.route + .with({ endpoint: { baseURL: "https://api.anthropic.test/v1/" }, auth: Auth.header("x-api-key", "test") }) + .model({ id: "claude-sonnet-4-5" }) + +const opus48 = AnthropicMessages.route + .with({ endpoint: { baseURL: "https://api.anthropic.test/v1/" }, auth: Auth.header("x-api-key", "test") }) + .model({ id: "claude-opus-4-8" }) + +const request = LLM.request({ + id: "req_1", + model, + system: { type: "text", text: "You are concise.", cache: new CacheHint({ type: "ephemeral" }) }, + prompt: "Say hello.", + // This fixture predates the `cache: "auto"` default; pin the policy off so + // existing wire-shape assertions only see the manual hint on the system part. + cache: "none", + generation: { maxTokens: 20, temperature: 0 }, +}) + +type AnthropicToolResult = Extract< + AnthropicMessages.AnthropicMessagesBody["messages"][number]["content"][number], + { readonly type: "tool_result" } +> + +const expectToolResult = (body: AnthropicMessages.AnthropicMessagesBody): AnthropicToolResult => { + const result = body.messages + .flatMap((message) => (message.role === "user" ? message.content : [])) + .find((block): block is AnthropicToolResult => block.type === "tool_result") + expect(result).toBeDefined() + return result! +} + +describe("Anthropic Messages route", () => { + it.effect("prepares Anthropic Messages target", () => + Effect.gen(function* () { + const prepared = yield* LLMClient.prepare(request) + + expect(prepared.body).toEqual({ + model: "claude-sonnet-4-5", + system: [{ type: "text", text: "You are concise.", cache_control: { type: "ephemeral" } }], + messages: [{ role: "user", content: [{ type: "text", text: "Say hello." }] }], + stream: true, + max_tokens: 20, + temperature: 0, + }) + }), + ) + + it.effect("lowers chronological system updates natively for Claude Opus 4.8 with cache hints", () => + Effect.gen(function* () { + const prepared = yield* LLMClient.prepare( + LLM.request({ + model: opus48, + messages: [ + Message.user("Before."), + Message.system([{ type: "text", text: "Operator update.", cache: new CacheHint({ type: "ephemeral" }) }]), + Message.assistant("After."), + ], + cache: "none", + }), + ) + + expect(prepared.body.messages).toEqual([ + { role: "user", content: [{ type: "text", text: "Before." }] }, + { + role: "system", + content: [{ type: "text", text: "Operator update.", cache_control: { type: "ephemeral" } }], + }, + { role: "assistant", content: [{ type: "text", text: "After." }] }, + ]) + }), + ) + + it.effect("lowers chronological system updates to wrapped user text for unsupported Anthropic models", () => + Effect.gen(function* () { + const prepared = yield* LLMClient.prepare( + LLM.request({ + model, + messages: [ + Message.user("Before."), + Message.system("Treat literally."), + Message.assistant("After."), + ], + cache: "none", + }), + ) + + expect(prepared.body.messages).toEqual([ + { + role: "user", + content: [ + { type: "text", text: "Before." }, + { type: "text", text: "\nTreat </system-update> literally.\n" }, + ], + }, + { role: "assistant", content: [{ type: "text", text: "After." }] }, + ]) + }), + ) + + it.effect("rejects non-text chronological system update content before send", () => + Effect.gen(function* () { + const error = yield* LLMClient.prepare( + LLM.request({ + model: opus48, + messages: [ + Message.user("Before."), + Message.make({ role: "system", content: { type: "media", mediaType: "image/png", data: "AAECAw==" } }), + ], + }), + ).pipe(Effect.flip) + + expect(error.message).toContain("Anthropic Messages system messages only support text content for now") + }), + ) + + it.effect("falls back for unsupported native chronological system update placement", () => + Effect.gen(function* () { + expect( + (yield* LLMClient.prepare( + LLM.request({ + model: opus48, + messages: [Message.assistant("Plain."), Message.system("After plain assistant.")], + cache: "none", + }), + )).body.messages, + ).toEqual([ + { role: "assistant", content: [{ type: "text", text: "Plain." }] }, + { + role: "user", + content: [{ type: "text", text: "\nAfter plain assistant.\n" }], + }, + ]) + expect( + (yield* LLMClient.prepare( + LLM.request({ model: opus48, messages: [Message.system("First.")], cache: "none" }), + )).body.messages, + ).toEqual([{ role: "user", content: [{ type: "text", text: "\nFirst.\n" }] }]) + expect( + (yield* LLMClient.prepare( + LLM.request({ + model: opus48, + messages: [Message.user("Before."), Message.system("One."), Message.system("Two.")], + cache: "none", + }), + )).body.messages, + ).toEqual([ + { + role: "user", + content: [ + { type: "text", text: "Before." }, + { type: "text", text: "\nOne.\n" }, + { type: "text", text: "\nTwo.\n" }, + ], + }, + ]) + }), + ) + + it.effect("rejects a system update between a local tool call and its result", () => + Effect.gen(function* () { + const error = yield* LLMClient.prepare( + LLM.request({ + model: opus48, + messages: [ + Message.user("Use the tool."), + Message.assistant([ToolCallPart.make({ id: "call_1", name: "lookup", input: {} })]), + Message.system("Too early."), + Message.tool({ id: "call_1", name: "lookup", result: "Done." }), + ], + cache: "none", + }), + ).pipe(Effect.flip) + + expect(error.message).toContain("system updates cannot split a local tool call from its tool result") + }), + ) + + it.effect("prepares tool call and tool result messages", () => + Effect.gen(function* () { + const prepared = yield* LLMClient.prepare( + LLM.request({ + id: "req_tool_result", + model, + messages: [ + Message.user("What is the weather?"), + Message.assistant([ToolCallPart.make({ id: "call_1", name: "lookup", input: { query: "weather" } })]), + Message.tool({ id: "call_1", name: "lookup", result: { forecast: "sunny" } }), + ], + cache: "none", + }), + ) + + expect(prepared.body).toEqual({ + model: "claude-sonnet-4-5", + messages: [ + { role: "user", content: [{ type: "text", text: "What is the weather?" }] }, + { + role: "assistant", + content: [{ type: "tool_use", id: "call_1", name: "lookup", input: { query: "weather" } }], + }, + { role: "user", content: [{ type: "tool_result", tool_use_id: "call_1", content: '{"forecast":"sunny"}' }] }, + ], + stream: true, + max_tokens: 4096, + }) + }), + ) + + // Regression: screenshot/read tool results must stay structured so base64 + // image data is not JSON-stringified into `tool_result.content`. + it.effect("lowers image tool-result content as structured image blocks", () => + Effect.gen(function* () { + const prepared = yield* LLMClient.prepare( + LLM.request({ + id: "req_tool_result_image", + model, + messages: [ + Message.user("Show me the screenshot."), + Message.assistant([ToolCallPart.make({ id: "call_1", name: "read", input: { filePath: "shot.png" } })]), + Message.tool({ + id: "call_1", + name: "read", + resultType: "content", + result: [ + { type: "text", text: "Image read successfully" }, + { type: "file", uri: "data:image/png;base64,AAECAw==", mime: "image/png" }, + ], + }), + ], + cache: "none", + }), + ) + + expect(expectToolResult(prepared.body).content).toEqual([ + { type: "text", text: "Image read successfully" }, + { type: "image", source: { type: "base64", media_type: "image/png", data: "AAECAw==" } }, + ]) + }), + ) + + it.effect("lowers single-image tool-result content as a structured image block", () => + Effect.gen(function* () { + const prepared = yield* LLMClient.prepare( + LLM.request({ + id: "req_tool_result_image_only", + model, + messages: [ + Message.assistant([ToolCallPart.make({ id: "call_1", name: "screenshot", input: {} })]), + Message.tool({ + id: "call_1", + name: "screenshot", + resultType: "content", + result: [{ type: "file", uri: "data:image/jpeg;base64,/9j/AA==", mime: "image/jpeg" }], + }), + ], + cache: "none", + }), + ) + + expect(expectToolResult(prepared.body).content).toEqual([ + { type: "image", source: { type: "base64", media_type: "image/jpeg", data: "/9j/AA==" } }, + ]) + }), + ) + + it.effect("rejects non-image media in tool-result content with a clear error", () => + Effect.gen(function* () { + const error = yield* LLMClient.prepare( + LLM.request({ + id: "req_tool_result_unsupported_media", + model, + messages: [ + Message.assistant([ToolCallPart.make({ id: "call_1", name: "fetch", input: {} })]), + Message.tool({ + id: "call_1", + name: "fetch", + resultType: "content", + result: [{ type: "file", uri: "data:audio/mpeg;base64,AAECAw==", mime: "audio/mpeg" }], + }), + ], + cache: "none", + }), + ).pipe(Effect.flip) + + expect(error.message).toContain("Anthropic Messages") + expect(error.message).toContain("audio/mpeg") + }), + ) + + it.effect("prepares the composed native continuation request", () => + Effect.gen(function* () { + const prepared = yield* LLMClient.prepare( + continuationRequest({ + id: "req_native_continuation_anthropic", + model, + features: nativeAnthropicMessagesContinuation, + }), + ) + + expect(prepared.body).toMatchObject({ + system: [{ type: "text", text: "You are concise. Continue from the provided history." }], + messages: [ + { + role: "user", + content: [ + { type: "text", text: "What is shown here?" }, + { type: "image", source: { type: "base64", media_type: "image/png", data: "AAECAw==" } }, + ], + }, + { + role: "assistant", + content: [ + { type: "thinking", thinking: "I inspected the previous turn.", signature: "sig_continuation_1" }, + { type: "text", text: "It shows a small test image." }, + ], + }, + { role: "user", content: [{ type: "text", text: "Check the weather in Paris before continuing." }] }, + { + role: "assistant", + content: [{ type: "tool_use", id: "call_weather_1", name: "get_weather", input: { city: "Paris" } }], + }, + { + role: "user", + content: [{ type: "tool_result", tool_use_id: "call_weather_1", content: '{"temperature":22}' }], + }, + { role: "assistant", content: [{ type: "text", text: "Paris is 22 degrees." }] }, + { role: "user", content: [{ type: "text", text: "Continue from this conversation in one short sentence." }] }, + ], + }) + expect(prepared.body.tools).toEqual([expect.objectContaining({ name: "get_weather" })]) + }), + ) + + it.effect("lowers preserved Anthropic reasoning signature metadata", () => + Effect.gen(function* () { + const prepared = yield* LLMClient.prepare( + LLM.request({ + model, + messages: [ + Message.assistant([ + { type: "reasoning", text: "thinking", providerMetadata: { anthropic: { signature: "sig_1" } } }, + ]), + ], + }), + ) + + expect(prepared.body).toMatchObject({ + messages: [{ role: "assistant", content: [{ type: "thinking", thinking: "thinking", signature: "sig_1" }] }], + }) + }), + ) + + it.effect("parses text, reasoning, and usage stream fixtures", () => + Effect.gen(function* () { + const body = sseEvents( + { type: "message_start", message: { usage: { input_tokens: 5, cache_read_input_tokens: 1 } } }, + { type: "content_block_start", index: 0, content_block: { type: "text", text: "" } }, + { type: "content_block_delta", index: 0, delta: { type: "text_delta", text: "Hello" } }, + { type: "content_block_delta", index: 0, delta: { type: "text_delta", text: "!" } }, + { type: "content_block_stop", index: 0 }, + { type: "content_block_start", index: 1, content_block: { type: "thinking", thinking: "" } }, + { type: "content_block_delta", index: 1, delta: { type: "thinking_delta", thinking: "thinking" } }, + { type: "content_block_delta", index: 1, delta: { type: "signature_delta", signature: "sig_1" } }, + { type: "content_block_stop", index: 1 }, + { + type: "message_delta", + delta: { stop_reason: "end_turn", stop_sequence: "\n\nHuman:" }, + usage: { output_tokens: 2 }, + }, + { type: "message_stop" }, + ) + const response = yield* LLMClient.generate(request).pipe(Effect.provide(fixedResponse(body))) + + expect(response.text).toBe("Hello!") + expect(response.reasoning).toBe("thinking") + expect(response.usage).toMatchObject({ + inputTokens: 6, + outputTokens: 2, + nonCachedInputTokens: 5, + cacheReadInputTokens: 1, + totalTokens: 8, + }) + expect(response.events.find((event) => event.type === "reasoning-end")).toMatchObject({ + providerMetadata: { anthropic: { signature: "sig_1" } }, + }) + expect(response.message.content).toEqual([ + { type: "text", text: "Hello!" }, + { type: "reasoning", text: "thinking", providerMetadata: { anthropic: { signature: "sig_1" } } }, + ]) + expect(response.events.at(-1)).toMatchObject({ + type: "finish", + reason: "stop", + providerMetadata: { anthropic: { stopSequence: "\n\nHuman:" } }, + }) + }), + ) + + it.effect("assembles streamed tool call input", () => + Effect.gen(function* () { + const body = sseEvents( + { type: "message_start", message: { usage: { input_tokens: 5 } } }, + { type: "content_block_start", index: 0, content_block: { type: "tool_use", id: "call_1", name: "lookup" } }, + { type: "content_block_delta", index: 0, delta: { type: "input_json_delta", partial_json: '{"query"' } }, + { type: "content_block_delta", index: 0, delta: { type: "input_json_delta", partial_json: ':"weather"}' } }, + { type: "content_block_stop", index: 0 }, + { type: "message_delta", delta: { stop_reason: "tool_use" }, usage: { output_tokens: 1 } }, + ) + const response = yield* LLMClient.generate( + LLM.updateRequest(request, { + tools: [{ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } }], + }), + ).pipe(Effect.provide(fixedResponse(body))) + const usage = new Usage({ + inputTokens: 5, + outputTokens: 1, + nonCachedInputTokens: 5, + cacheReadInputTokens: undefined, + cacheWriteInputTokens: undefined, + totalTokens: 6, + providerMetadata: { anthropic: { input_tokens: 5, output_tokens: 1 } }, + }) + + expect(response.toolCalls).toEqual([ + { + type: "tool-call", + id: "call_1", + name: "lookup", + input: { query: "weather" }, + providerExecuted: undefined, + providerMetadata: undefined, + }, + ]) + expect(response.events).toEqual([ + { type: "step-start", index: 0 }, + { type: "tool-input-start", id: "call_1", name: "lookup" }, + { type: "tool-input-delta", id: "call_1", name: "lookup", text: '{"query"' }, + { type: "tool-input-delta", id: "call_1", name: "lookup", text: ':"weather"}' }, + { type: "tool-input-end", id: "call_1", name: "lookup", providerMetadata: undefined }, + { + type: "tool-call", + id: "call_1", + name: "lookup", + input: { query: "weather" }, + providerExecuted: undefined, + providerMetadata: undefined, + }, + { type: "step-finish", index: 0, reason: "tool-calls", usage, providerMetadata: undefined }, + { + type: "finish", + reason: "tool-calls", + providerMetadata: undefined, + usage, + }, + ]) + }), + ) + + it.effect("emits provider-error events for mid-stream provider errors", () => + Effect.gen(function* () { + const response = yield* LLMClient.generate(request).pipe( + Effect.provide( + fixedResponse(sseEvents({ type: "error", error: { type: "overloaded_error", message: "Overloaded" } })), + ), + ) + + // Prefix the error type so consumers can distinguish overloads, rate + // limits, and quota errors without parsing the message string. + expect(response.events).toEqual([{ type: "provider-error", message: "overloaded_error: Overloaded" }]) + }), + ) + + it.effect("classifies prompt-too-long provider errors", () => + Effect.gen(function* () { + const response = yield* LLMClient.generate(request).pipe( + Effect.provide( + fixedResponse( + sseEvents({ + type: "error", + error: { type: "invalid_request_error", message: "prompt is too long: 210000 tokens" }, + }), + ), + ), + ) + + expect(response.events).toEqual([ + { + type: "provider-error", + message: "invalid_request_error: prompt is too long: 210000 tokens", + classification: "context-overflow", + }, + ]) + }), + ) + + it.effect("falls back to error type when no message is present", () => + Effect.gen(function* () { + const response = yield* LLMClient.generate(request).pipe( + Effect.provide(fixedResponse(sseEvents({ type: "error", error: { type: "overloaded_error", message: "" } }))), + ) + + expect(response.events).toEqual([{ type: "provider-error", message: "overloaded_error" }]) + }), + ) + + it.effect("falls back to a stable default when error payload is absent", () => + Effect.gen(function* () { + const response = yield* LLMClient.generate(request).pipe( + Effect.provide(fixedResponse(sseEvents({ type: "error" }))), + ) + + expect(response.events).toEqual([{ type: "provider-error", message: "Anthropic Messages stream error" }]) + }), + ) + + it.effect("fails HTTP provider errors before stream parsing", () => + Effect.gen(function* () { + const error = yield* LLMClient.generate(request).pipe( + Effect.provide( + fixedResponse('{"type":"error","error":{"type":"invalid_request_error","message":"Bad request"}}', { + status: 400, + headers: { "content-type": "application/json" }, + }), + ), + Effect.flip, + ) + + expect(error).toBeInstanceOf(LLMError) + expect(error.reason).toMatchObject({ _tag: "InvalidRequest" }) + expect(error.message).toContain("HTTP 400") + }), + ) + + it.effect("decodes server_tool_use + web_search_tool_result as provider-executed events", () => + Effect.gen(function* () { + const body = sseEvents( + { type: "message_start", message: { usage: { input_tokens: 5 } } }, + { + type: "content_block_start", + index: 0, + content_block: { type: "server_tool_use", id: "srvtoolu_abc", name: "web_search" }, + }, + { + type: "content_block_delta", + index: 0, + delta: { type: "input_json_delta", partial_json: '{"query":"effect 4"}' }, + }, + { type: "content_block_stop", index: 0 }, + { + type: "content_block_start", + index: 1, + content_block: { + type: "web_search_tool_result", + tool_use_id: "srvtoolu_abc", + content: [{ type: "web_search_result", url: "https://example.com", title: "Example" }], + }, + }, + { type: "content_block_stop", index: 1 }, + { type: "content_block_start", index: 2, content_block: { type: "text", text: "" } }, + { type: "content_block_delta", index: 2, delta: { type: "text_delta", text: "Found it." } }, + { type: "content_block_stop", index: 2 }, + { type: "message_delta", delta: { stop_reason: "end_turn" }, usage: { output_tokens: 8 } }, + ) + const response = yield* LLMClient.generate( + LLM.updateRequest(request, { + tools: [{ name: "web_search", description: "Web search", inputSchema: { type: "object" } }], + }), + ).pipe(Effect.provide(fixedResponse(body))) + + const toolCall = response.events.find((event) => event.type === "tool-call") + expect(toolCall).toEqual({ + type: "tool-call", + id: "srvtoolu_abc", + name: "web_search", + input: { query: "effect 4" }, + providerExecuted: true, + }) + const toolResult = response.events.find((event) => event.type === "tool-result") + expect(toolResult).toEqual({ + type: "tool-result", + id: "srvtoolu_abc", + name: "web_search", + result: { type: "json", value: [{ type: "web_search_result", url: "https://example.com", title: "Example" }] }, + providerExecuted: true, + providerMetadata: { anthropic: { blockType: "web_search_tool_result" } }, + }) + expect(response.text).toBe("Found it.") + expect(response.events.at(-1)).toMatchObject({ type: "finish", reason: "stop" }) + }), + ) + + it.effect("decodes web_search_tool_result_error as provider-executed error result", () => + Effect.gen(function* () { + const body = sseEvents( + { type: "message_start", message: { usage: { input_tokens: 5 } } }, + { + type: "content_block_start", + index: 0, + content_block: { type: "server_tool_use", id: "srvtoolu_x", name: "web_search" }, + }, + { type: "content_block_delta", index: 0, delta: { type: "input_json_delta", partial_json: '{"query":"q"}' } }, + { type: "content_block_stop", index: 0 }, + { + type: "content_block_start", + index: 1, + content_block: { + type: "web_search_tool_result", + tool_use_id: "srvtoolu_x", + content: { type: "web_search_tool_result_error", error_code: "max_uses_exceeded" }, + }, + }, + { type: "content_block_stop", index: 1 }, + { type: "message_delta", delta: { stop_reason: "end_turn" }, usage: { output_tokens: 1 } }, + ) + const response = yield* LLMClient.generate( + LLM.updateRequest(request, { + tools: [{ name: "web_search", description: "Web search", inputSchema: { type: "object" } }], + }), + ).pipe(Effect.provide(fixedResponse(body))) + + const toolResult = response.events.find((event) => event.type === "tool-result") + expect(toolResult).toMatchObject({ + type: "tool-result", + id: "srvtoolu_x", + name: "web_search", + result: { type: "error" }, + providerExecuted: true, + }) + }), + ) + + it.effect("round-trips provider-executed assistant content into server tool blocks", () => + Effect.gen(function* () { + const prepared = yield* LLMClient.prepare( + LLM.request({ + id: "req_round_trip", + model, + messages: [ + Message.user("Search for something."), + Message.assistant([ + { + type: "tool-call", + id: "srvtoolu_abc", + name: "web_search", + input: { query: "effect 4" }, + providerExecuted: true, + }, + { + type: "tool-result", + id: "srvtoolu_abc", + name: "web_search", + result: { type: "json", value: [{ url: "https://example.com" }] }, + providerExecuted: true, + }, + { type: "text", text: "Found it." }, + ]), + Message.user("Thanks."), + ], + }), + ) + + expect(prepared.body).toMatchObject({ + messages: [ + { role: "user", content: [{ type: "text", text: "Search for something." }] }, + { + role: "assistant", + content: [ + { type: "server_tool_use", id: "srvtoolu_abc", name: "web_search", input: { query: "effect 4" } }, + { + type: "web_search_tool_result", + tool_use_id: "srvtoolu_abc", + content: [{ url: "https://example.com" }], + }, + { type: "text", text: "Found it." }, + ], + }, + { role: "user", content: [{ type: "text", text: "Thanks." }] }, + ], + }) + }), + ) + + it.effect("rejects round-trip for unknown server tool names", () => + Effect.gen(function* () { + const error = yield* LLMClient.prepare( + LLM.request({ + id: "req_unknown_server_tool", + model, + messages: [ + Message.assistant([ + { + type: "tool-result", + id: "srvtoolu_abc", + name: "future_server_tool", + result: { type: "json", value: {} }, + providerExecuted: true, + }, + ]), + ], + }), + ).pipe(Effect.flip) + + expect(error.message).toContain("future_server_tool") + }), + ) + + it.effect("continues a conversation with user image content", () => + Effect.gen(function* () { + const response = yield* LLMClient.generate( + LLM.request({ + id: "req_media", + model, + messages: [ + Message.user([ + { type: "text", text: "What is in this image?" }, + { type: "media", mediaType: "image/png", data: "AAECAw==" }, + ]), + ], + }), + ).pipe( + Effect.provide( + dynamicResponse((input) => + Effect.gen(function* () { + const web = yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie) + expect(yield* Effect.promise(() => web.json())).toMatchObject({ + messages: [ + { + role: "user", + content: [ + { type: "text", text: "What is in this image?" }, + { type: "image", source: { type: "base64", media_type: "image/png", data: "AAECAw==" } }, + ], + }, + ], + }) + return input.respond( + sseEvents( + { type: "content_block_start", index: 0, content_block: { type: "text", text: "" } }, + { type: "content_block_delta", index: 0, delta: { type: "text_delta", text: "An image." } }, + { type: "content_block_stop", index: 0 }, + { type: "message_delta", delta: { stop_reason: "end_turn" }, usage: { output_tokens: 3 } }, + { type: "message_stop" }, + ), + { headers: { "content-type": "text/event-stream" } }, + ) + }), + ), + ), + ) + + expect(response.text).toBe("An image.") + }), + ) + + it.effect("maps ttlSeconds >= 3600 to cache_control ttl: '1h'", () => + Effect.gen(function* () { + const prepared = yield* LLMClient.prepare( + LLM.request({ + model, + system: { type: "text", text: "system", cache: new CacheHint({ type: "ephemeral", ttlSeconds: 3600 }) }, + prompt: "hi", + }), + ) + + expect(prepared.body).toMatchObject({ + system: [{ type: "text", text: "system", cache_control: { type: "ephemeral", ttl: "1h" } }], + }) + }), + ) + + it.effect("emits cache_control on tool definitions and tool-result blocks", () => + Effect.gen(function* () { + const prepared = yield* LLMClient.prepare( + LLM.request({ + model, + tools: [ + { + name: "lookup", + description: "lookup tool", + inputSchema: { type: "object", properties: {} }, + cache: new CacheHint({ type: "ephemeral" }), + }, + ], + messages: [ + Message.user("What's the weather?"), + Message.assistant([ToolCallPart.make({ id: "call_1", name: "lookup", input: {} })]), + Message.tool({ + id: "call_1", + name: "lookup", + result: { temp: 72 }, + cache: new CacheHint({ type: "ephemeral" }), + }), + ], + }), + ) + + expect(prepared.body).toMatchObject({ + tools: [{ name: "lookup", cache_control: { type: "ephemeral" } }], + messages: [ + { role: "user", content: [{ type: "text", text: "What's the weather?" }] }, + { role: "assistant", content: [{ type: "tool_use", id: "call_1", name: "lookup" }] }, + { + role: "user", + content: [{ type: "tool_result", tool_use_id: "call_1", cache_control: { type: "ephemeral" } }], + }, + ], + }) + }), + ) + + it.effect("drops cache_control breakpoints past the 4-per-request cap", () => + Effect.gen(function* () { + const hint = new CacheHint({ type: "ephemeral" }) + const prepared = yield* LLMClient.prepare( + LLM.request({ + model, + system: [ + { type: "text", text: "a", cache: hint }, + { type: "text", text: "b", cache: hint }, + { type: "text", text: "c", cache: hint }, + { type: "text", text: "d", cache: hint }, + { type: "text", text: "e", cache: hint }, + { type: "text", text: "f", cache: hint }, + ], + prompt: "hi", + }), + ) + + const system = (prepared.body as { system: Array<{ cache_control?: unknown }> }).system + const marked = system.filter((part) => part.cache_control !== undefined) + expect(marked).toHaveLength(4) + expect(system[4]?.cache_control).toBeUndefined() + expect(system[5]?.cache_control).toBeUndefined() + }), + ) + + it.effect("spends breakpoint budget on tools before system before messages", () => + Effect.gen(function* () { + const hint = new CacheHint({ type: "ephemeral" }) + const prepared = yield* LLMClient.prepare( + LLM.request({ + model, + tools: [ + { + name: "t1", + description: "t1", + inputSchema: { type: "object", properties: {} }, + cache: hint, + }, + { + name: "t2", + description: "t2", + inputSchema: { type: "object", properties: {} }, + cache: hint, + }, + { + name: "t3", + description: "t3", + inputSchema: { type: "object", properties: {} }, + cache: hint, + }, + { + name: "t4", + description: "t4", + inputSchema: { type: "object", properties: {} }, + cache: hint, + }, + ], + system: [{ type: "text", text: "system-tail", cache: hint }], + messages: [Message.user([{ type: "text", text: "message-tail", cache: hint }])], + }), + ) + + const body = prepared.body as { + tools: Array<{ cache_control?: unknown }> + system: Array<{ cache_control?: unknown }> + messages: Array<{ content: Array<{ cache_control?: unknown }> }> + } + expect(body.tools.every((t) => t.cache_control !== undefined)).toBe(true) + expect(body.system[0]?.cache_control).toBeUndefined() + expect(body.messages[0]?.content[0]?.cache_control).toBeUndefined() + }), + ) +}) diff --git a/packages/llm/test/provider/bedrock-converse-cache.recorded.test.ts b/packages/llm/test/provider/bedrock-converse-cache.recorded.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..8702e4eb40343300dd96cbbd838de2ca8605391e --- /dev/null +++ b/packages/llm/test/provider/bedrock-converse-cache.recorded.test.ts @@ -0,0 +1,54 @@ +import { describe, expect } from "bun:test" +import { Effect } from "effect" +import { CacheHint, LLM } from "../../src" +import { LLMClient } from "../../src/route" +import { AmazonBedrock } from "../../src/providers" +import { LARGE_CACHEABLE_SYSTEM } from "../recorded-scenarios" +import { recordedTests } from "../recorded-test" + +const RECORDING_REGION = process.env.BEDROCK_RECORDING_REGION ?? "us-east-1" + +// Use a Claude model on Bedrock — Nova has automatic prefix caching that +// doesn't reliably surface `cacheRead`/`cacheWrite` in usage, so the second +// call wouldn't deterministically prove cache mapping works. Override with +// BEDROCK_CACHE_MODEL_ID if your account has access elsewhere. +const model = AmazonBedrock.configure({ + credentials: { + region: RECORDING_REGION, + accessKeyId: process.env.AWS_ACCESS_KEY_ID ?? "fixture", + secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY ?? "fixture", + sessionToken: process.env.AWS_SESSION_TOKEN, + }, +}).model(process.env.BEDROCK_CACHE_MODEL_ID ?? "us.anthropic.claude-haiku-4-5-20251001-v1:0") + +const cacheRequest = LLM.request({ + id: "recorded_bedrock_cache", + model, + system: [{ type: "text", text: LARGE_CACHEABLE_SYSTEM, cache: new CacheHint({ type: "ephemeral" }) }], + prompt: "Say hi.", + // Manual hint on the system part is the only marker we want here — skip the + // auto-policy's latest-user-message breakpoint so the cassette body matches. + cache: "none", + generation: { maxTokens: 16, temperature: 0 }, +}) + +const recorded = recordedTests({ + prefix: "bedrock-converse-cache", + provider: "amazon-bedrock", + protocol: "bedrock-converse", + requires: ["AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY"], + // Two identical requests in one cassette — replay walks the cassette in + // recording order so the second call replays the cached-hit interaction. +}) + +describe("Bedrock Converse cache recorded", () => { + recorded.effect.with("writes then reads cachePoint on identical second call", { tags: ["cache"] }, () => + Effect.gen(function* () { + const first = yield* LLMClient.generate(cacheRequest) + expect(first.usage?.cacheReadInputTokens ?? 0).toBeGreaterThanOrEqual(0) + + const second = yield* LLMClient.generate(cacheRequest) + expect(second.usage?.cacheReadInputTokens ?? 0).toBeGreaterThan(0) + }), + ) +}) diff --git a/packages/llm/test/provider/bedrock-converse.test.ts b/packages/llm/test/provider/bedrock-converse.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..46657331a344c446e38147fe1433377c6266d4b2 --- /dev/null +++ b/packages/llm/test/provider/bedrock-converse.test.ts @@ -0,0 +1,744 @@ +import { EventStreamCodec } from "@smithy/eventstream-codec" +import { fromUtf8, toUtf8 } from "@smithy/util-utf8" +import { describe, expect } from "bun:test" +import { Effect } from "effect" +import { CacheHint, LLM, Message, ToolCallPart, ToolChoice } from "../../src" +import { LLMClient } from "../../src/route" +import { AmazonBedrock } from "../../src/providers" +import * as BedrockConverse from "../../src/protocols/bedrock-converse" +import { it } from "../lib/effect" +import { fixedResponse } from "../lib/http" +import { + eventSummary, + expectWeatherToolLoop, + runWeatherToolLoop, + weatherTool, + weatherToolLoopRequest, + weatherToolName, +} from "../recorded-scenarios" +import { recordedTests } from "../recorded-test" + +const codec = new EventStreamCodec(toUtf8, fromUtf8) +const utf8Encoder = new TextEncoder() + +// Build a single AWS event-stream frame for a Converse stream event. Each +// frame carries `:message-type=event` + `:event-type=` headers and a +// JSON payload body. +const eventFrame = (type: string, payload: object) => + codec.encode({ + headers: { + ":message-type": { type: "string", value: "event" }, + ":event-type": { type: "string", value: type }, + ":content-type": { type: "string", value: "application/json" }, + }, + body: utf8Encoder.encode(JSON.stringify(payload)), + }) + +const concat = (frames: ReadonlyArray) => { + const total = frames.reduce((sum, frame) => sum + frame.length, 0) + const out = new Uint8Array(total) + let offset = 0 + for (const frame of frames) { + out.set(frame, offset) + offset += frame.length + } + return out +} + +const eventStreamBody = (...payloads: ReadonlyArray) => + concat(payloads.map(([type, payload]) => eventFrame(type, payload))) + +// Override the default SSE content-type with the binary event-stream type so +// the cassette layer treats the body as bytes when recording. +const fixedBytes = (bytes: Uint8Array) => + fixedResponse(bytes.slice().buffer, { headers: { "content-type": "application/vnd.amazon.eventstream" } }) + +const model = AmazonBedrock.configure({ + baseURL: "https://bedrock-runtime.test", + apiKey: "test-bearer", +}).model("anthropic.claude-3-5-sonnet-20240620-v1:0") + +const baseRequest = LLM.request({ + id: "req_1", + model, + system: "You are concise.", + prompt: "Say hello.", + // Wire-shape assertions in this file predate the `cache: "auto"` default; + // pin the policy off so they only exercise the lowering path itself. + cache: "none", + generation: { maxTokens: 64, temperature: 0 }, +}) + +describe("Bedrock Converse route", () => { + it.effect("prepares Converse target with system, inference config, and messages", () => + Effect.gen(function* () { + const prepared = yield* LLMClient.prepare(baseRequest) + + expect(prepared.body).toEqual({ + modelId: "anthropic.claude-3-5-sonnet-20240620-v1:0", + system: [{ text: "You are concise." }], + messages: [{ role: "user", content: [{ text: "Say hello." }] }], + inferenceConfig: { maxTokens: 64, temperature: 0 }, + }) + }), + ) + + it.effect("passes topK through additionalModelRequestFields as top_k", () => + Effect.gen(function* () { + const prepared = yield* LLMClient.prepare( + LLM.updateRequest(baseRequest, { generation: { maxTokens: 64, temperature: 0, topK: 40 } }), + ) + + // Converse's inferenceConfig has no topK; Anthropic/Nova read it from + // additionalModelRequestFields as top_k. + expect(prepared.body.inferenceConfig).toEqual({ maxTokens: 64, temperature: 0 }) + expect(prepared.body.additionalModelRequestFields).toEqual({ top_k: 40 }) + }), + ) + + it.effect("omits additionalModelRequestFields when topK is unset", () => + Effect.gen(function* () { + const prepared = yield* LLMClient.prepare(baseRequest) + expect(prepared.body.additionalModelRequestFields).toBeUndefined() + }), + ) + + it.effect("lowers chronological system updates to wrapped user text in order", () => + Effect.gen(function* () { + const prepared = yield* LLMClient.prepare( + LLM.request({ + model, + messages: [Message.user("Before."), Message.system("Update."), Message.assistant("After.")], + cache: "none", + }), + ) + + expect(prepared.body.messages).toEqual([ + { role: "user", content: [{ text: "Before." }, { text: "\nUpdate.\n" }] }, + { role: "assistant", content: [{ text: "After." }] }, + ]) + }), + ) + + it.effect("prepares tool config with toolSpec and toolChoice", () => + Effect.gen(function* () { + const prepared = yield* LLMClient.prepare( + LLM.updateRequest(baseRequest, { + tools: [ + { + name: "lookup", + description: "Lookup data", + inputSchema: { type: "object", properties: { query: { type: "string" } }, required: ["query"] }, + }, + ], + toolChoice: ToolChoice.make({ type: "required" }), + }), + ) + + expect(prepared.body).toMatchObject({ + toolConfig: { + tools: [ + { + toolSpec: { + name: "lookup", + description: "Lookup data", + inputSchema: { + json: { type: "object", properties: { query: { type: "string" } }, required: ["query"] }, + }, + }, + }, + ], + toolChoice: { any: {} }, + }, + }) + }), + ) + + it.effect("lowers assistant tool-call + tool-result message history", () => + Effect.gen(function* () { + const prepared = yield* LLMClient.prepare( + LLM.request({ + id: "req_history", + model, + messages: [ + Message.user("What is the weather?"), + Message.assistant([ToolCallPart.make({ id: "tool_1", name: "lookup", input: { query: "weather" } })]), + Message.tool({ id: "tool_1", name: "lookup", result: { forecast: "sunny" } }), + ], + cache: "none", + }), + ) + + expect(prepared.body).toMatchObject({ + messages: [ + { role: "user", content: [{ text: "What is the weather?" }] }, + { + role: "assistant", + content: [{ toolUse: { toolUseId: "tool_1", name: "lookup", input: { query: "weather" } } }], + }, + { + role: "user", + content: [ + { + toolResult: { + toolUseId: "tool_1", + content: [{ json: { forecast: "sunny" } }], + status: "success", + }, + }, + ], + }, + ], + }) + }), + ) + + it.effect("lowers image content in tool-result messages", () => + Effect.gen(function* () { + const prepared = yield* LLMClient.prepare( + LLM.request({ + id: "req_tool_image", + model, + messages: [ + Message.user("Capture the screen."), + Message.assistant([ToolCallPart.make({ id: "tool_1", name: "screenshot", input: {} })]), + Message.tool({ + id: "tool_1", + name: "screenshot", + result: { + type: "content", + value: [ + { type: "text", text: "Screenshot captured." }, + { type: "file", uri: "data:image/png;base64,AAAA", mime: "image/png" }, + ], + }, + }), + ], + cache: "none", + }), + ) + + expect(prepared.body).toMatchObject({ + messages: [ + { role: "user", content: [{ text: "Capture the screen." }] }, + { + role: "assistant", + content: [{ toolUse: { toolUseId: "tool_1", name: "screenshot", input: {} } }], + }, + { + role: "user", + content: [ + { + toolResult: { + toolUseId: "tool_1", + content: [{ text: "Screenshot captured." }, { image: { format: "png", source: { bytes: "AAAA" } } }], + status: "success", + }, + }, + ], + }, + ], + }) + }), + ) + + it.effect("decodes text-delta + messageStop + metadata usage from binary event stream", () => + Effect.gen(function* () { + const body = eventStreamBody( + ["messageStart", { role: "assistant" }], + ["contentBlockDelta", { contentBlockIndex: 0, delta: { text: "Hello" } }], + ["contentBlockDelta", { contentBlockIndex: 0, delta: { text: "!" } }], + ["contentBlockStop", { contentBlockIndex: 0 }], + ["messageStop", { stopReason: "end_turn" }], + ["metadata", { usage: { inputTokens: 5, outputTokens: 2, totalTokens: 7 } }], + ) + const response = yield* LLMClient.generate(baseRequest).pipe(Effect.provide(fixedBytes(body))) + + expect(response.text).toBe("Hello!") + const finishes = response.events.filter((event) => event.type === "finish") + // Bedrock splits the finish across `messageStop` (carries reason) and + // `metadata` (carries usage). We consolidate them into a single + // terminal `finish` event with both. + expect(finishes).toHaveLength(1) + expect(finishes[0]).toMatchObject({ type: "finish", reason: "stop" }) + expect(response.usage).toMatchObject({ + inputTokens: 5, + outputTokens: 2, + totalTokens: 7, + }) + }), + ) + + it.effect("assembles streamed tool call input", () => + Effect.gen(function* () { + const body = eventStreamBody( + ["messageStart", { role: "assistant" }], + [ + "contentBlockStart", + { + contentBlockIndex: 0, + start: { toolUse: { toolUseId: "tool_1", name: "lookup" } }, + }, + ], + ["contentBlockDelta", { contentBlockIndex: 0, delta: { toolUse: { input: '{"query"' } } }], + ["contentBlockDelta", { contentBlockIndex: 0, delta: { toolUse: { input: ':"weather"}' } } }], + ["contentBlockStop", { contentBlockIndex: 0 }], + ["messageStop", { stopReason: "tool_use" }], + ) + const response = yield* LLMClient.generate( + LLM.updateRequest(baseRequest, { + tools: [{ name: "lookup", description: "Lookup", inputSchema: { type: "object" } }], + }), + ).pipe(Effect.provide(fixedBytes(body))) + + expect(response.toolCalls).toEqual([ + { type: "tool-call", id: "tool_1", name: "lookup", input: { query: "weather" } }, + ]) + const events = response.events.filter((event) => event.type === "tool-input-delta") + expect(events).toEqual([ + { type: "tool-input-delta", id: "tool_1", name: "lookup", text: '{"query"' }, + { type: "tool-input-delta", id: "tool_1", name: "lookup", text: ':"weather"}' }, + ]) + expect(response.events.at(-1)).toMatchObject({ type: "finish", reason: "tool-calls" }) + }), + ) + + it.effect("decodes reasoning deltas", () => + Effect.gen(function* () { + const body = eventStreamBody( + ["messageStart", { role: "assistant" }], + ["contentBlockDelta", { contentBlockIndex: 0, delta: { reasoningContent: { text: "Let me think." } } }], + ["contentBlockStop", { contentBlockIndex: 0 }], + ["messageStop", { stopReason: "end_turn" }], + ) + const response = yield* LLMClient.generate(baseRequest).pipe(Effect.provide(fixedBytes(body))) + + expect(response.reasoning).toBe("Let me think.") + }), + ) + + it.effect("preserves streamed reasoning signatures for continuation lowering", () => + Effect.gen(function* () { + const body = eventStreamBody( + ["messageStart", { role: "assistant" }], + ["contentBlockDelta", { contentBlockIndex: 0, delta: { reasoningContent: { text: "Let me think." } } }], + ["contentBlockDelta", { contentBlockIndex: 0, delta: { reasoningContent: { signature: "sig_1" } } }], + ["contentBlockStop", { contentBlockIndex: 0 }], + ["messageStop", { stopReason: "end_turn" }], + ) + const response = yield* LLMClient.generate(baseRequest).pipe(Effect.provide(fixedBytes(body))) + const reasoning = response.events.find((event) => event.type === "reasoning-end") + + expect(reasoning).toEqual({ + type: "reasoning-end", + id: "reasoning-0", + providerMetadata: { bedrock: { signature: "sig_1" } }, + }) + + const prepared = yield* LLMClient.prepare( + LLM.request({ + model, + messages: [ + Message.assistant([ + { type: "reasoning", text: "Let me think.", providerMetadata: reasoning?.providerMetadata }, + ]), + ], + cache: "none", + }), + ) + expect(prepared.body.messages).toEqual([ + { + role: "assistant", + content: [{ reasoningContent: { reasoningText: { text: "Let me think.", signature: "sig_1" } } }], + }, + ]) + }), + ) + + it.effect("emits provider-error for throttlingException", () => + Effect.gen(function* () { + const body = eventStreamBody( + ["messageStart", { role: "assistant" }], + ["throttlingException", { message: "Slow down" }], + ) + const response = yield* LLMClient.generate(baseRequest).pipe(Effect.provide(fixedBytes(body))) + + expect(response.events.find((event) => event.type === "provider-error")).toEqual({ + type: "provider-error", + message: "Slow down", + retryable: true, + }) + }), + ) + + it.effect("classifies input-too-long validation exceptions", () => + Effect.gen(function* () { + const response = yield* LLMClient.generate(baseRequest).pipe( + Effect.provide( + fixedBytes(eventStreamBody(["validationException", { message: "Input is too long for requested model" }])), + ), + ) + + expect(response.events.find((event) => event.type === "provider-error")).toEqual({ + type: "provider-error", + message: "Input is too long for requested model", + classification: "context-overflow", + retryable: false, + }) + }), + ) + + it.effect("rejects requests with no auth path", () => + Effect.gen(function* () { + const unsignedModel = AmazonBedrock.configure({ + baseURL: "https://bedrock-runtime.test", + }).model("anthropic.claude-3-5-sonnet-20240620-v1:0") + const error = yield* LLMClient.generate(LLM.updateRequest(baseRequest, { model: unsignedModel })).pipe( + Effect.provide(fixedBytes(eventStreamBody(["messageStop", { stopReason: "end_turn" }]))), + Effect.flip, + ) + + expect(error.message).toContain("Bedrock Converse requires either route bearer auth or AWS credentials") + }), + ) + + it.effect("signs requests with SigV4 when AWS credentials are provided (deterministic plumbing check)", () => + Effect.gen(function* () { + const signed = AmazonBedrock.configure({ + baseURL: "https://bedrock-runtime.test", + credentials: { + region: "us-east-1", + accessKeyId: "AKIAIOSFODNN7EXAMPLE", + secretAccessKey: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + }, + }).model("anthropic.claude-3-5-sonnet-20240620-v1:0") + const prepared = yield* LLMClient.prepare(LLM.updateRequest(baseRequest, { model: signed })) + + expect(prepared.route).toBe("bedrock-converse") + expect(prepared.model).toBe(signed) + }), + ) + + it.effect("emits cachePoint markers after system, user-text, and assistant-text with cache hints", () => + Effect.gen(function* () { + const cache = new CacheHint({ type: "ephemeral" }) + const prepared = yield* LLMClient.prepare( + LLM.request({ + id: "req_cache", + model, + system: [{ type: "text", text: "System prefix.", cache }], + messages: [ + Message.user([{ type: "text", text: "User prefix.", cache }]), + Message.assistant([{ type: "text", text: "Assistant prefix.", cache }]), + ], + generation: { maxTokens: 16, temperature: 0 }, + }), + ) + + expect(prepared.body).toMatchObject({ + // System: text block followed by cachePoint marker. + system: [{ text: "System prefix." }, { cachePoint: { type: "default" } }], + messages: [ + { + role: "user", + content: [{ text: "User prefix." }, { cachePoint: { type: "default" } }], + }, + { + role: "assistant", + content: [{ text: "Assistant prefix." }, { cachePoint: { type: "default" } }], + }, + ], + }) + }), + ) + + it.effect("does not emit cachePoint when no cache hint is set", () => + Effect.gen(function* () { + const prepared = yield* LLMClient.prepare(baseRequest) + expect(prepared.body).toMatchObject({ + system: [{ text: "You are concise." }], + messages: [{ role: "user", content: [{ text: "Say hello." }] }], + }) + }), + ) + + it.effect("lowers image media into Bedrock image blocks", () => + Effect.gen(function* () { + const prepared = yield* LLMClient.prepare( + LLM.request({ + id: "req_image", + model, + messages: [ + Message.user([ + { type: "text", text: "What is in this image?" }, + { type: "media", mediaType: "image/png", data: "AAAA" }, + { type: "media", mediaType: "image/jpeg", data: "BBBB" }, + { type: "media", mediaType: "image/jpg", data: "CCCC" }, + { type: "media", mediaType: "image/webp", data: "DDDD" }, + ]), + ], + cache: "none", + }), + ) + + expect(prepared.body).toMatchObject({ + messages: [ + { + role: "user", + content: [ + { text: "What is in this image?" }, + { image: { format: "png", source: { bytes: "AAAA" } } }, + { image: { format: "jpeg", source: { bytes: "BBBB" } } }, + // image/jpg is a non-standard alias; we map it to jpeg. + { image: { format: "jpeg", source: { bytes: "CCCC" } } }, + { image: { format: "webp", source: { bytes: "DDDD" } } }, + ], + }, + ], + }) + }), + ) + + it.effect("base64-encodes Uint8Array image bytes", () => + Effect.gen(function* () { + const prepared = yield* LLMClient.prepare( + LLM.request({ + id: "req_image_bytes", + model, + messages: [Message.user([{ type: "media", mediaType: "image/png", data: new Uint8Array([1, 2, 3, 4, 5]) }])], + }), + ) + + // Buffer.from([1,2,3,4,5]).toString("base64") === "AQIDBAU=" + expect(prepared.body).toMatchObject({ + messages: [ + { + role: "user", + content: [{ image: { format: "png", source: { bytes: "AQIDBAU=" } } }], + }, + ], + }) + }), + ) + + it.effect("lowers document media into Bedrock document blocks with format and name", () => + Effect.gen(function* () { + const prepared = yield* LLMClient.prepare( + LLM.request({ + id: "req_doc", + model, + messages: [ + Message.user([ + { type: "media", mediaType: "application/pdf", data: "UERGREFUQQ==", filename: "report.pdf" }, + { type: "media", mediaType: "text/csv", data: "Q1NWREFUQQ==" }, + ]), + ], + }), + ) + + expect(prepared.body).toMatchObject({ + messages: [ + { + role: "user", + content: [ + // Filename round-trips when supplied. + { document: { format: "pdf", name: "report.pdf", source: { bytes: "UERGREFUQQ==" } } }, + // Falls back to a stable placeholder when filename is missing. + { document: { format: "csv", name: "document.csv", source: { bytes: "Q1NWREFUQQ==" } } }, + ], + }, + ], + }) + }), + ) + + it.effect("rejects unsupported image media types", () => + Effect.gen(function* () { + const error = yield* LLMClient.prepare( + LLM.request({ + id: "req_bad_image", + model, + messages: [Message.user([{ type: "media", mediaType: "image/svg+xml", data: "x" }])], + }), + ).pipe(Effect.flip) + + expect(error.message).toContain("Bedrock Converse does not support image media type image/svg+xml") + }), + ) + + it.effect("rejects unsupported document media types", () => + Effect.gen(function* () { + const error = yield* LLMClient.prepare( + LLM.request({ + id: "req_bad_doc", + model, + messages: [Message.user([{ type: "media", mediaType: "application/x-tar", data: "x", filename: "a.tar" }])], + }), + ).pipe(Effect.flip) + + expect(error.message).toContain("Bedrock Converse does not support media type application/x-tar") + }), + ) + + it.effect("maps ttlSeconds >= 3600 to cachePoint ttl: '1h'", () => + Effect.gen(function* () { + const cache = new CacheHint({ type: "ephemeral", ttlSeconds: 3600 }) + const prepared = yield* LLMClient.prepare( + LLM.request({ + model, + system: [{ type: "text", text: "system", cache }], + prompt: "hi", + }), + ) + + expect(prepared.body).toMatchObject({ + system: [{ text: "system" }, { cachePoint: { type: "default", ttl: "1h" } }], + }) + }), + ) + + it.effect("appends cachePoint after marked tool definitions and tool-result blocks", () => + Effect.gen(function* () { + const cache = new CacheHint({ type: "ephemeral" }) + const prepared = yield* LLMClient.prepare( + LLM.request({ + model, + tools: [{ name: "lookup", description: "lookup", inputSchema: { type: "object", properties: {} }, cache }], + messages: [ + Message.user("What's the weather?"), + Message.assistant([ToolCallPart.make({ id: "call_1", name: "lookup", input: {} })]), + Message.tool({ id: "call_1", name: "lookup", result: { temp: 72 }, cache }), + ], + cache: "none", + }), + ) + + expect(prepared.body).toMatchObject({ + toolConfig: { + tools: [{ toolSpec: { name: "lookup" } }, { cachePoint: { type: "default" } }], + }, + messages: [ + { role: "user", content: [{ text: "What's the weather?" }] }, + { role: "assistant", content: [{ toolUse: { toolUseId: "call_1" } }] }, + { + role: "user", + content: [{ toolResult: { toolUseId: "call_1" } }, { cachePoint: { type: "default" } }], + }, + ], + }) + }), + ) + + it.effect("drops cachePoint markers past the 4-per-request cap", () => + Effect.gen(function* () { + const cache = new CacheHint({ type: "ephemeral" }) + const prepared = yield* LLMClient.prepare( + LLM.request({ + model, + system: [ + { type: "text", text: "a", cache }, + { type: "text", text: "b", cache }, + { type: "text", text: "c", cache }, + { type: "text", text: "d", cache }, + { type: "text", text: "e", cache }, + { type: "text", text: "f", cache }, + ], + prompt: "hi", + }), + ) + + const system = (prepared.body as { system: Array<{ cachePoint?: unknown }> }).system + expect(system.filter((part) => "cachePoint" in part)).toHaveLength(4) + }), + ) +}) + +// Live recorded integration tests. Run with `RECORD=true AWS_ACCESS_KEY_ID=... +// AWS_SECRET_ACCESS_KEY=... [AWS_SESSION_TOKEN=...] bun run test ...` to refresh +// cassettes; replay is the default and works without credentials. +// +// Region is pinned to us-east-1 in tests so the request URL is stable across +// machines on replay. If you need to record from a different region (e.g. your +// account has access elsewhere), pass `BEDROCK_RECORDING_REGION=eu-west-1` — +// but then commit the resulting cassette and others should record from the +// same region too. +const RECORDING_REGION = process.env.BEDROCK_RECORDING_REGION ?? "us-east-1" + +const recordedModel = () => + AmazonBedrock.configure({ + // Most newer Anthropic models on Bedrock require a cross-region inference + // profile (`us.` prefix). Nova does not require an Anthropic use-case form + // and is on-demand-throughput accessible by default for most accounts. + credentials: { + region: RECORDING_REGION, + accessKeyId: process.env.AWS_ACCESS_KEY_ID ?? "fixture", + secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY ?? "fixture", + sessionToken: process.env.AWS_SESSION_TOKEN, + }, + }).model(process.env.BEDROCK_MODEL_ID ?? "us.amazon.nova-micro-v1:0") + +const recorded = recordedTests({ + prefix: "bedrock-converse", + provider: "amazon-bedrock", + protocol: "bedrock-converse", + requires: ["AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY"], +}) + +describe("Bedrock Converse recorded", () => { + recorded.effect("streams text", () => + Effect.gen(function* () { + const llm = yield* LLMClient.Service + const response = yield* llm.generate( + LLM.request({ + id: "recorded_bedrock_text", + model: recordedModel(), + system: "Reply with the single word 'Hello'.", + prompt: "Say hello.", + cache: "none", + generation: { maxTokens: 16, temperature: 0 }, + }), + ) + + expect(eventSummary(response.events)).toEqual([ + { type: "text", value: "Hello" }, + { type: "finish", reason: "stop", usage: { inputTokens: 12, outputTokens: 2, totalTokens: 14 } }, + ]) + }), + ) + + recorded.effect.with("streams a tool call", { tags: ["tool"] }, () => + Effect.gen(function* () { + const llm = yield* LLMClient.Service + const response = yield* llm.generate( + LLM.request({ + id: "recorded_bedrock_tool_call", + model: recordedModel(), + system: "Call tools exactly as requested.", + prompt: "Call get_weather with city exactly Paris.", + tools: [weatherTool], + toolChoice: ToolChoice.make(weatherTool), + cache: "none", + generation: { maxTokens: 80, temperature: 0 }, + }), + ) + + expect(eventSummary(response.events)).toEqual([ + { type: "tool-call", name: weatherToolName, input: { city: "Paris" } }, + { type: "finish", reason: "tool-calls", usage: { inputTokens: 419, outputTokens: 16, totalTokens: 435 } }, + ]) + }), + ) + + recorded.effect.with("drives a tool loop", { tags: ["tool", "tool-loop", "golden"] }, () => + Effect.gen(function* () { + expectWeatherToolLoop( + yield* runWeatherToolLoop( + weatherToolLoopRequest({ + id: "recorded_bedrock_tool_loop", + model: recordedModel(), + }), + ), + ) + }), + ) +}) diff --git a/packages/llm/test/provider/cloudflare.test.ts b/packages/llm/test/provider/cloudflare.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..acd63962946929acb37bafd60f69e7c2314a18e9 --- /dev/null +++ b/packages/llm/test/provider/cloudflare.test.ts @@ -0,0 +1,230 @@ +import { describe, expect } from "bun:test" +import { ConfigProvider, Effect, Schema } from "effect" +import { HttpClientRequest } from "effect/unstable/http" +import { LLM } from "../../src" +import { CloudflareAIGateway, CloudflareWorkersAI } from "../../src/providers/cloudflare" +import { LLMClient } from "../../src/route" +import { it } from "../lib/effect" +import { dynamicResponse } from "../lib/http" +import { sseEvents } from "../lib/sse" + +const Json = Schema.fromJsonString(Schema.Unknown) +const decodeJson = Schema.decodeUnknownSync(Json) +const withEnv = (env: Record) => Effect.provide(ConfigProvider.layer(ConfigProvider.fromEnv({ env }))) + +const deltaChunk = (delta: object, finishReason: string | null = null) => ({ + id: "chatcmpl_fixture", + choices: [{ delta, finish_reason: finishReason }], + usage: null, +}) + +describe("Cloudflare", () => { + it.effect("prepares AI Gateway models through the OpenAI-compatible Chat protocol", () => + Effect.gen(function* () { + const model = CloudflareAIGateway.configure({ + accountId: "test-account", + gatewayId: "test-gateway", + apiKey: "test-token", + }).model("workers-ai/@cf/meta/llama-3.3-70b-instruct") + + expect(model).toMatchObject({ + id: "workers-ai/@cf/meta/llama-3.3-70b-instruct", + provider: "cloudflare-ai-gateway", + route: { id: "cloudflare-ai-gateway" }, + }) + expect(model.route.endpoint.baseURL).toBe("https://gateway.ai.cloudflare.com/v1/test-account/test-gateway/compat") + + const prepared = yield* LLMClient.prepare(LLM.request({ model, prompt: "Say hello." })) + + expect(prepared.route).toBe("cloudflare-ai-gateway") + expect(prepared.body).toMatchObject({ + model: "workers-ai/@cf/meta/llama-3.3-70b-instruct", + messages: [{ role: "user", content: "Say hello." }], + stream: true, + }) + }), + ) + + it.effect("posts to the derived gateway endpoint with bearer auth", () => + Effect.gen(function* () { + const response = yield* LLM.generate( + LLM.request({ + model: CloudflareAIGateway.configure({ + accountId: "test-account", + gatewayId: "test-gateway", + apiKey: "test-token", + }).model("openai/gpt-4o-mini"), + prompt: "Say hello.", + }), + ).pipe( + Effect.provide( + dynamicResponse((input) => + Effect.gen(function* () { + const web = yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie) + expect(web.url).toBe( + "https://gateway.ai.cloudflare.com/v1/test-account/test-gateway/compat/chat/completions", + ) + expect(web.headers.get("authorization")).toBe("Bearer test-token") + expect(decodeJson(input.text)).toMatchObject({ + model: "openai/gpt-4o-mini", + stream: true, + messages: [{ role: "user", content: "Say hello." }], + }) + return input.respond( + sseEvents(deltaChunk({ role: "assistant", content: "Hello" }), deltaChunk({}, "stop")), + { headers: { "content-type": "text/event-stream" } }, + ) + }), + ), + ), + ) + + expect(response.text).toBe("Hello") + }), + ) + + it.effect("defaults AI Gateway id to default when omitted or blank", () => + Effect.gen(function* () { + expect( + CloudflareAIGateway.configure({ + accountId: "test-account", + gatewayId: "", + gatewayApiKey: "test-token", + }).model("workers-ai/@cf/meta/llama-3.3-70b-instruct").route.endpoint.baseURL, + ).toBe("https://gateway.ai.cloudflare.com/v1/test-account/default/compat") + }), + ) + + it.effect("supports authenticated AI Gateway plus upstream provider auth", () => + Effect.gen(function* () { + yield* LLM.generate( + LLM.request({ + model: CloudflareAIGateway.configure({ + accountId: "test-account", + gatewayApiKey: "gateway-token", + apiKey: "provider-token", + }).model("openai/gpt-4o-mini"), + prompt: "Say hello.", + }), + ).pipe( + Effect.provide( + dynamicResponse((input) => + Effect.gen(function* () { + const web = yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie) + expect(web.url).toBe("https://gateway.ai.cloudflare.com/v1/test-account/default/compat/chat/completions") + expect(web.headers.get("cf-aig-authorization")).toBe("Bearer gateway-token") + expect(web.headers.get("authorization")).toBe("Bearer provider-token") + return input.respond( + sseEvents(deltaChunk({ role: "assistant", content: "Hello" }), deltaChunk({}, "stop")), + { headers: { "content-type": "text/event-stream" } }, + ) + }), + ), + ), + ) + }), + ) + + it.effect("allows a fully configured baseURL override", () => + Effect.gen(function* () { + const prepared = yield* LLMClient.prepare( + LLM.request({ + model: CloudflareAIGateway.configure({ + baseURL: "https://gateway.proxy.test/v1/custom/compat", + apiKey: "test-token", + }).model("openai/gpt-4o-mini"), + prompt: "Say hello.", + }), + ) + + expect(prepared.model.route.endpoint.baseURL).toBe("https://gateway.proxy.test/v1/custom/compat") + }), + ) + + it.effect("prepares direct Workers AI models through the OpenAI-compatible Chat protocol", () => + Effect.gen(function* () { + const model = CloudflareWorkersAI.configure({ + accountId: "test-account", + apiKey: "test-token", + }).model("@cf/meta/llama-3.1-8b-instruct") + + expect(model).toMatchObject({ + id: "@cf/meta/llama-3.1-8b-instruct", + provider: "cloudflare-workers-ai", + route: { id: "cloudflare-workers-ai" }, + }) + expect(model.route.endpoint.baseURL).toBe("https://api.cloudflare.com/client/v4/accounts/test-account/ai/v1") + + const prepared = yield* LLMClient.prepare(LLM.request({ model, prompt: "Say hello." })) + + expect(prepared.route).toBe("cloudflare-workers-ai") + expect(prepared.body).toMatchObject({ + model: "@cf/meta/llama-3.1-8b-instruct", + messages: [{ role: "user", content: "Say hello." }], + stream: true, + }) + }), + ) + + it.effect("posts direct Workers AI requests to the account endpoint with bearer auth", () => + Effect.gen(function* () { + const response = yield* LLM.generate( + LLM.request({ + model: CloudflareWorkersAI.configure({ + accountId: "test-account", + apiKey: "test-token", + }).model("@cf/meta/llama-3.1-8b-instruct"), + prompt: "Say hello.", + }), + ).pipe( + Effect.provide( + dynamicResponse((input) => + Effect.gen(function* () { + const web = yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie) + expect(web.url).toBe("https://api.cloudflare.com/client/v4/accounts/test-account/ai/v1/chat/completions") + expect(web.headers.get("authorization")).toBe("Bearer test-token") + expect(decodeJson(input.text)).toMatchObject({ + model: "@cf/meta/llama-3.1-8b-instruct", + stream: true, + messages: [{ role: "user", content: "Say hello." }], + }) + return input.respond( + sseEvents(deltaChunk({ role: "assistant", content: "Hello" }), deltaChunk({}, "stop")), + { headers: { "content-type": "text/event-stream" } }, + ) + }), + ), + ), + ) + + expect(response.text).toBe("Hello") + }), + ) + + it.effect("supports direct Workers AI token aliases through auth config", () => + Effect.gen(function* () { + yield* LLM.generate( + LLM.request({ + model: CloudflareWorkersAI.configure({ + accountId: "test-account", + }).model("@cf/meta/llama-3.1-8b-instruct"), + prompt: "Say hello.", + }), + ).pipe( + withEnv({ CLOUDFLARE_WORKERS_AI_TOKEN: "test-token" }), + Effect.provide( + dynamicResponse((input) => + Effect.gen(function* () { + const web = yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie) + expect(web.headers.get("authorization")).toBe("Bearer test-token") + return input.respond( + sseEvents(deltaChunk({ role: "assistant", content: "Hello" }), deltaChunk({}, "stop")), + { headers: { "content-type": "text/event-stream" } }, + ) + }), + ), + ), + ) + }), + ) +}) diff --git a/packages/llm/test/provider/gemini-cache.recorded.test.ts b/packages/llm/test/provider/gemini-cache.recorded.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..d210c5c024f18403644e30a6b7513e9d1d564bc0 --- /dev/null +++ b/packages/llm/test/provider/gemini-cache.recorded.test.ts @@ -0,0 +1,48 @@ +import { describe, expect } from "bun:test" +import { Effect } from "effect" +import { LLM } from "../../src" +import { LLMClient } from "../../src/route" +import * as Google from "../../src/providers/google" +import { LARGE_CACHEABLE_SYSTEM } from "../recorded-scenarios" +import { recordedTests } from "../recorded-test" + +const model = Google.configure({ + apiKey: process.env.GOOGLE_GENERATIVE_AI_API_KEY ?? process.env.GEMINI_API_KEY ?? "fixture", +}).model("gemini-2.5-flash") + +// Gemini does implicit prefix caching on 2.5+ models above ~1024 tokens. The +// `CacheHint` is currently a no-op for Gemini (the explicit `CachedContent` +// API is out-of-band and intentionally not wired up). This test exists to +// pin the usage-parsing path: `cachedContentTokenCount` should surface as +// `cacheReadInputTokens` on the second identical call. +const cacheRequest = LLM.request({ + id: "recorded_gemini_cache", + model, + system: LARGE_CACHEABLE_SYSTEM, + prompt: "Say hi.", + generation: { maxTokens: 16, temperature: 0 }, +}) + +const recorded = recordedTests({ + prefix: "gemini-cache", + provider: "google", + protocol: "gemini", + requires: ["GOOGLE_GENERATIVE_AI_API_KEY"], + // Two identical requests in one cassette — replay walks the cassette in + // recording order so the second call replays the cached-hit interaction. +}) + +describe("Gemini cache recorded", () => { + recorded.effect.with("reports cachedContentTokenCount on identical second call", { tags: ["cache"] }, () => + Effect.gen(function* () { + const first = yield* LLMClient.generate(cacheRequest) + expect(first.usage?.cacheReadInputTokens ?? 0).toBeGreaterThanOrEqual(0) + + const second = yield* LLMClient.generate(cacheRequest) + // Implicit caching is best-effort on Gemini's side; we assert the field + // is at least populated and non-negative. When re-recording, verify the + // cassette shows > 0 in the second response's usage. + expect(second.usage?.cacheReadInputTokens ?? 0).toBeGreaterThanOrEqual(0) + }), + ) +}) diff --git a/packages/llm/test/provider/gemini.test.ts b/packages/llm/test/provider/gemini.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..1dc253c0ea88c7cf3ae1a17ad048d07be7fc2f2c --- /dev/null +++ b/packages/llm/test/provider/gemini.test.ts @@ -0,0 +1,584 @@ +import { describe, expect } from "bun:test" +import { Effect } from "effect" +import { LLM, LLMError, Message, ToolCallPart, Usage } from "../../src" +import { Auth, LLMClient } from "../../src/route" +import * as Gemini from "../../src/protocols/gemini" +import { ProviderShared } from "../../src/protocols/shared" +import { it } from "../lib/effect" +import { fixedResponse } from "../lib/http" +import { sseEvents, sseRaw } from "../lib/sse" + +const model = Gemini.route + .with({ + endpoint: { baseURL: "https://generativelanguage.test/v1beta/" }, + auth: Auth.header("x-goog-api-key", "test"), + }) + .model({ id: "gemini-2.5-flash" }) + +const request = LLM.request({ + id: "req_1", + model, + system: "You are concise.", + prompt: "Say hello.", + generation: { maxTokens: 20, temperature: 0 }, +}) + +describe("Gemini route", () => { + it.effect("prepares Gemini target", () => + Effect.gen(function* () { + const prepared = yield* LLMClient.prepare(request) + + expect(prepared.body).toEqual({ + contents: [{ role: "user", parts: [{ text: "Say hello." }] }], + systemInstruction: { parts: [{ text: "You are concise." }] }, + generationConfig: { maxOutputTokens: 20, temperature: 0 }, + }) + }), + ) + + it.effect("lowers chronological system updates to wrapped user text in order", () => + Effect.gen(function* () { + const prepared = yield* LLMClient.prepare( + LLM.request({ + model, + messages: [Message.user("Before."), Message.system("Update."), Message.assistant("After.")], + }), + ) + + expect(prepared.body.contents).toEqual([ + { role: "user", parts: [{ text: "Before." }, { text: "\nUpdate.\n" }] }, + { role: "model", parts: [{ text: "After." }] }, + ]) + }), + ) + + it.effect("prepares multimodal user input and tool history", () => + Effect.gen(function* () { + const prepared = yield* LLMClient.prepare( + LLM.request({ + id: "req_tool_result", + model, + tools: [ + { + name: "lookup", + description: "Lookup data", + inputSchema: { type: "object", properties: { query: { type: "string" } } }, + }, + ], + toolChoice: { type: "tool", name: "lookup" }, + messages: [ + Message.user([ + { type: "text", text: "What is in this image?" }, + { type: "media", mediaType: "image/png", data: "AAECAw==" }, + ]), + Message.assistant([ToolCallPart.make({ id: "call_1", name: "lookup", input: { query: "weather" } })]), + Message.tool({ id: "call_1", name: "lookup", result: { forecast: "sunny" } }), + ], + }), + ) + + expect(prepared.body).toEqual({ + contents: [ + { + role: "user", + parts: [{ text: "What is in this image?" }, { inlineData: { mimeType: "image/png", data: "AAECAw==" } }], + }, + { + role: "model", + parts: [{ functionCall: { name: "lookup", args: { query: "weather" } } }], + }, + { + role: "user", + parts: [ + { functionResponse: { name: "lookup", response: { name: "lookup", content: '{"forecast":"sunny"}' } } }, + ], + }, + ], + tools: [ + { + functionDeclarations: [ + { + name: "lookup", + description: "Lookup data", + parameters: { type: "object", properties: { query: { type: "string" } } }, + }, + ], + }, + ], + toolConfig: { functionCallingConfig: { mode: "ANY", allowedFunctionNames: ["lookup"] } }, + }) + }), + ) + + it.effect("continues image tool results as inline vision input without base64 text", () => + Effect.gen(function* () { + const prepared = yield* LLMClient.prepare( + LLM.request({ + model, + messages: [ + Message.assistant([ToolCallPart.make({ id: "call_image", name: "read", input: { path: "pixel.png" } })]), + Message.tool({ + id: "call_image", + name: "read", + result: { + type: "content", + value: [ + { type: "text", text: "Image read successfully" }, + { type: "file", uri: "data:image/png;base64,AAECAw==", mime: "image/png", name: "pixel.png" }, + ], + }, + }), + ], + }), + ) + + expect(prepared.body.contents).toEqual([ + { role: "model", parts: [{ functionCall: { name: "read", args: { path: "pixel.png" } } }] }, + { + role: "user", + parts: [ + { + functionResponse: { + name: "read", + response: { name: "read", content: "Image read successfully" }, + }, + }, + { inlineData: { mimeType: "image/png", data: "AAECAw==" } }, + ], + }, + ]) + expect(JSON.stringify(prepared.body.contents)).not.toContain('"content":"AAECAw=="') + }), + ) + + it.effect("strips matching data URLs to raw base64 inlineData", () => + Effect.gen(function* () { + const prepared = yield* LLMClient.prepare( + LLM.request({ + model, + messages: [ + Message.user({ type: "media", mediaType: "image/png", data: "data:image/png;base64,AAEC" }), + Message.tool({ + id: "call_image", + name: "read", + result: { + type: "content", + value: [{ type: "file", uri: "data:image/jpeg;base64,/9j/", mime: "image/jpeg" }], + }, + }), + ], + }), + ) + expect(prepared.body.contents).toEqual([ + { role: "user", parts: [{ inlineData: { mimeType: "image/png", data: "AAEC" } }] }, + { + role: "user", + parts: [ + { functionResponse: { name: "read", response: { name: "read", content: "" } } }, + { inlineData: { mimeType: "image/jpeg", data: "/9j/" } }, + ], + }, + ]) + }), + ) + + for (const [name, media] of [ + ["mismatched data URL MIME", { mediaType: "image/png", data: "data:image/jpeg;base64,/9j/" }], + ["malformed base64", { mediaType: "image/png", data: "%%%=" }], + ["unsupported SVG", { mediaType: "image/svg+xml", data: "PHN2Zz4=" }], + ] as const) + it.effect(`rejects ${name}`, () => + Effect.gen(function* () { + const error = yield* LLMClient.prepare( + LLM.request({ model, messages: [Message.user({ type: "media", ...media })] }), + ).pipe(Effect.flip) + expect(error.message).toMatch(/does not support|does not match|valid base64/) + }), + ) + + it.effect("rejects oversized image input", () => + Effect.gen(function* () { + const error = yield* LLMClient.prepare( + LLM.request({ + model, + messages: [ + Message.user({ + type: "media", + mediaType: "image/png", + data: "A".repeat(ProviderShared.MAX_MEDIA_ENCODED_BYTES + 4), + }), + ], + }), + ).pipe(Effect.flip) + expect(error.message).toContain("encoded limit") + }), + ) + + it.effect("omits tools when tool choice is none", () => + Effect.gen(function* () { + const prepared = yield* LLMClient.prepare( + LLM.request({ + id: "req_no_tools", + model, + prompt: "Say hello.", + tools: [{ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } }], + toolChoice: { type: "none" }, + }), + ) + + expect(prepared.body).toEqual({ + contents: [{ role: "user", parts: [{ text: "Say hello." }] }], + }) + }), + ) + + it.effect("sanitizes integer enums, dangling required, untyped arrays, and scalar object keys", () => + Effect.gen(function* () { + const prepared = yield* LLMClient.prepare( + LLM.request({ + id: "req_schema_patch", + model, + prompt: "Use the tool.", + tools: [ + { + name: "lookup", + description: "Lookup data", + inputSchema: { + type: "object", + required: ["status", "missing"], + properties: { + status: { type: "integer", enum: [1, 2] }, + tags: { type: "array" }, + name: { type: "string", properties: { ignored: { type: "string" } }, required: ["ignored"] }, + }, + }, + }, + ], + }), + ) + + expect(prepared.body).toMatchObject({ + tools: [ + { + functionDeclarations: [ + { + parameters: { + type: "object", + required: ["status"], + properties: { + status: { type: "string", enum: ["1", "2"] }, + tags: { type: "array", items: { type: "string" } }, + name: { type: "string" }, + }, + }, + }, + ], + }, + ], + }) + }), + ) + + it.effect("parses text, reasoning, and usage stream fixtures", () => + Effect.gen(function* () { + const body = sseEvents( + { + candidates: [ + { + content: { role: "model", parts: [{ text: "thinking", thought: true }] }, + }, + ], + }, + { + candidates: [ + { + content: { role: "model", parts: [{ text: "Hello" }] }, + }, + ], + }, + { + candidates: [ + { + content: { role: "model", parts: [{ text: "!" }] }, + finishReason: "STOP", + }, + ], + }, + { + usageMetadata: { + promptTokenCount: 5, + candidatesTokenCount: 2, + totalTokenCount: 7, + thoughtsTokenCount: 1, + cachedContentTokenCount: 1, + }, + }, + ) + const response = yield* LLMClient.generate(request).pipe(Effect.provide(fixedResponse(body))) + + expect(response.text).toBe("Hello!") + expect(response.reasoning).toBe("thinking") + expect(response.usage).toMatchObject({ + inputTokens: 5, + outputTokens: 3, + nonCachedInputTokens: 4, + cacheReadInputTokens: 1, + reasoningTokens: 1, + totalTokens: 7, + }) + const usage = new Usage({ + inputTokens: 5, + outputTokens: 3, + nonCachedInputTokens: 4, + cacheReadInputTokens: 1, + reasoningTokens: 1, + totalTokens: 7, + providerMetadata: { + google: { + promptTokenCount: 5, + candidatesTokenCount: 2, + totalTokenCount: 7, + thoughtsTokenCount: 1, + cachedContentTokenCount: 1, + }, + }, + }) + expect(response.events).toEqual([ + { type: "step-start", index: 0 }, + { type: "reasoning-start", id: "reasoning-0" }, + { type: "reasoning-delta", id: "reasoning-0", text: "thinking" }, + { type: "reasoning-end", id: "reasoning-0" }, + { type: "text-start", id: "text-0" }, + { type: "text-delta", id: "text-0", text: "Hello" }, + { type: "text-delta", id: "text-0", text: "!" }, + { type: "text-end", id: "text-0" }, + { type: "step-finish", index: 0, reason: "stop", usage, providerMetadata: undefined }, + { + type: "finish", + reason: "stop", + usage, + }, + ]) + }), + ) + + it.effect("preserves thoughtSignature for reasoning and tool-call continuation", () => + Effect.gen(function* () { + const body = sseEvents({ + candidates: [ + { + content: { + role: "model", + parts: [ + { text: "thinking", thought: true }, + { text: "", thought: true, thoughtSignature: "thought_sig" }, + { functionCall: { name: "lookup", args: { query: "weather" } }, thoughtSignature: "tool_sig" }, + ], + }, + finishReason: "STOP", + }, + ], + }) + const response = yield* LLMClient.generate( + LLM.updateRequest(request, { + tools: [{ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } }], + }), + ).pipe(Effect.provide(fixedResponse(body))) + const reasoning = response.events.find((event) => event.type === "reasoning-start") + const reasoningEnd = response.events.find((event) => event.type === "reasoning-end") + const toolCall = response.events.find((event) => event.type === "tool-call") + + expect(reasoning).toEqual({ + type: "reasoning-start", + id: "reasoning-0", + providerMetadata: undefined, + }) + expect(reasoningEnd).toEqual({ + type: "reasoning-end", + id: "reasoning-0", + providerMetadata: { google: { thoughtSignature: "thought_sig" } }, + }) + expect(toolCall).toMatchObject({ providerMetadata: { google: { thoughtSignature: "tool_sig" } } }) + expect(response.events.findIndex((event) => event.type === "reasoning-end")).toBeLessThan( + response.events.findIndex((event) => event.type === "tool-call"), + ) + + const prepared = yield* LLMClient.prepare( + LLM.request({ + model, + messages: [ + Message.assistant([ + { type: "reasoning", text: "thinking", providerMetadata: reasoningEnd?.providerMetadata }, + ToolCallPart.make({ + id: "tool_0", + name: "lookup", + input: { query: "weather" }, + providerMetadata: toolCall?.providerMetadata, + }), + ]), + ], + }), + ) + expect(prepared.body.contents).toEqual([ + { + role: "model", + parts: [ + { text: "thinking", thought: true, thoughtSignature: "thought_sig" }, + { functionCall: { name: "lookup", args: { query: "weather" } }, thoughtSignature: "tool_sig" }, + ], + }, + ]) + }), + ) + + it.effect("emits streamed tool calls and maps finish reason", () => + Effect.gen(function* () { + const body = sseEvents({ + candidates: [ + { + content: { + role: "model", + parts: [{ functionCall: { name: "lookup", args: { query: "weather" } } }], + }, + finishReason: "STOP", + }, + ], + usageMetadata: { promptTokenCount: 5, candidatesTokenCount: 1 }, + }) + const response = yield* LLMClient.generate( + LLM.updateRequest(request, { + tools: [{ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } }], + }), + ).pipe(Effect.provide(fixedResponse(body))) + const usage = new Usage({ + inputTokens: 5, + outputTokens: 1, + nonCachedInputTokens: 5, + cacheReadInputTokens: undefined, + reasoningTokens: undefined, + totalTokens: 6, + providerMetadata: { google: { promptTokenCount: 5, candidatesTokenCount: 1 } }, + }) + + expect(response.toolCalls).toEqual([ + { + type: "tool-call", + id: "tool_0", + name: "lookup", + input: { query: "weather" }, + providerExecuted: undefined, + providerMetadata: undefined, + }, + ]) + expect(response.events).toEqual([ + { type: "step-start", index: 0 }, + { + type: "tool-call", + id: "tool_0", + name: "lookup", + input: { query: "weather" }, + providerExecuted: undefined, + providerMetadata: undefined, + }, + { type: "step-finish", index: 0, reason: "tool-calls", usage, providerMetadata: undefined }, + { + type: "finish", + reason: "tool-calls", + usage, + }, + ]) + }), + ) + + it.effect("assigns unique ids to multiple streamed tool calls", () => + Effect.gen(function* () { + const body = sseEvents({ + candidates: [ + { + content: { + role: "model", + parts: [ + { functionCall: { name: "lookup", args: { query: "weather" } } }, + { functionCall: { name: "lookup", args: { query: "news" } } }, + ], + }, + finishReason: "STOP", + }, + ], + }) + const response = yield* LLMClient.generate( + LLM.updateRequest(request, { + tools: [{ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } }], + }), + ).pipe(Effect.provide(fixedResponse(body))) + + expect(response.toolCalls).toEqual([ + { type: "tool-call", id: "tool_0", name: "lookup", input: { query: "weather" } }, + { type: "tool-call", id: "tool_1", name: "lookup", input: { query: "news" } }, + ]) + expect(response.events.at(-1)).toMatchObject({ type: "finish", reason: "tool-calls" }) + }), + ) + + it.effect("maps length and content-filter finish reasons", () => + Effect.gen(function* () { + const length = yield* LLMClient.generate(request).pipe( + Effect.provide( + fixedResponse( + sseEvents({ candidates: [{ content: { role: "model", parts: [] }, finishReason: "MAX_TOKENS" }] }), + ), + ), + ) + const filtered = yield* LLMClient.generate(request).pipe( + Effect.provide( + fixedResponse(sseEvents({ candidates: [{ content: { role: "model", parts: [] }, finishReason: "SAFETY" }] })), + ), + ) + + expect(length.events.map((event) => event.type)).toEqual(["step-start", "step-finish", "finish"]) + expect(length.events.at(-1)).toMatchObject({ type: "finish", reason: "length" }) + expect(filtered.events.map((event) => event.type)).toEqual(["step-start", "step-finish", "finish"]) + expect(filtered.events.at(-1)).toMatchObject({ type: "finish", reason: "content-filter" }) + }), + ) + + it.effect("leaves total usage undefined when component counts are missing", () => + Effect.gen(function* () { + const response = yield* LLMClient.generate(request).pipe( + Effect.provide(fixedResponse(sseEvents({ usageMetadata: { thoughtsTokenCount: 1 } }))), + ) + + expect(response.usage).toMatchObject({ reasoningTokens: 1 }) + expect(response.usage?.totalTokens).toBeUndefined() + }), + ) + + it.effect("fails invalid stream events", () => + Effect.gen(function* () { + const error = yield* LLMClient.generate(request).pipe( + Effect.provide(fixedResponse(sseRaw("data: {not json}"))), + Effect.flip, + ) + + expect(error).toBeInstanceOf(LLMError) + expect(error.reason).toMatchObject({ _tag: "InvalidProviderOutput" }) + expect(error.message).toContain("Invalid google/gemini stream event") + }), + ) + + it.effect("rejects unsupported assistant media content", () => + Effect.gen(function* () { + const error = yield* LLMClient.prepare( + LLM.request({ + id: "req_media", + model, + messages: [Message.assistant({ type: "media", mediaType: "image/png", data: "AAECAw==" })], + }), + ).pipe(Effect.flip) + + expect(error.message).toContain( + "Gemini assistant messages only support text, reasoning, and tool-call content for now", + ) + }), + ) +}) diff --git a/packages/llm/test/provider/golden.recorded.test.ts b/packages/llm/test/provider/golden.recorded.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..ef67c866d8d8de8c900ccc05998ab33c6932a8e9 --- /dev/null +++ b/packages/llm/test/provider/golden.recorded.test.ts @@ -0,0 +1,223 @@ +import * as Anthropic from "../../src/providers/anthropic" +import { CloudflareAIGateway, CloudflareWorkersAI } from "../../src/providers/cloudflare" +import * as Google from "../../src/providers/google" +import * as OpenAI from "../../src/providers/openai" +import * as OpenAICompatible from "../../src/providers/openai-compatible" +import * as OpenRouter from "../../src/providers/openrouter" +import * as XAI from "../../src/providers/xai" +import { describeRecordedGoldenScenarios } from "../recorded-golden" + +const openAI = OpenAI.configure({ + apiKey: process.env.OPENAI_API_KEY ?? "fixture", +}) +const openAIChat = openAI.chat("gpt-4o-mini") +const openAIResponses = openAI.responses("gpt-5.5") +const openAIResponsesWebSocket = openAI.responsesWebSocket("gpt-4.1-mini") +const anthropic = Anthropic.configure({ + apiKey: process.env.ANTHROPIC_API_KEY ?? "fixture", +}) +const anthropicHaiku = anthropic.model("claude-haiku-4-5-20251001") +const anthropicOpus = anthropic.model("claude-opus-4-7") +const google = Google.configure({ apiKey: process.env.GOOGLE_GENERATIVE_AI_API_KEY ?? "fixture" }) +const gemini = google.model("gemini-2.5-flash") +const xai = XAI.configure({ apiKey: process.env.XAI_API_KEY ?? "fixture" }) +const xaiBasic = xai.model("grok-3-mini") +const xaiFlagship = xai.model("grok-4.3") +const cloudflareAIGateway = CloudflareAIGateway.configure({ + accountId: process.env.CLOUDFLARE_ACCOUNT_ID ?? "fixture-account", + gatewayId: + process.env.CLOUDFLARE_GATEWAY_ID && process.env.CLOUDFLARE_GATEWAY_ID !== process.env.CLOUDFLARE_ACCOUNT_ID + ? process.env.CLOUDFLARE_GATEWAY_ID + : undefined, + gatewayApiKey: process.env.CLOUDFLARE_API_TOKEN ?? "fixture", +}) +const cloudflareWorkers = CloudflareWorkersAI.configure({ + accountId: process.env.CLOUDFLARE_ACCOUNT_ID ?? "fixture-account", + apiKey: process.env.CLOUDFLARE_API_KEY ?? "fixture", +}) +const cloudflareAIGatewayWorkers = cloudflareAIGateway.model("workers-ai/@cf/meta/llama-3.1-8b-instruct") +const cloudflareAIGatewayWorkersTools = cloudflareAIGateway.model("workers-ai/@cf/openai/gpt-oss-20b") +const cloudflareWorkersAI = cloudflareWorkers.model("@cf/meta/llama-3.1-8b-instruct") +const cloudflareWorkersAITools = cloudflareWorkers.model("@cf/openai/gpt-oss-20b") +const deepseek = OpenAICompatible.deepseek + .configure({ apiKey: process.env.DEEPSEEK_API_KEY ?? "fixture" }) + .model("deepseek-chat") +const together = OpenAICompatible.togetherai + .configure({ + apiKey: process.env.TOGETHER_AI_API_KEY ?? "fixture", + }) + .model("meta-llama/Llama-3.3-70B-Instruct-Turbo") +const groq = OpenAICompatible.groq + .configure({ apiKey: process.env.GROQ_API_KEY ?? "fixture" }) + .model("llama-3.3-70b-versatile") +const openRouter = OpenRouter.configure({ apiKey: process.env.OPENROUTER_API_KEY ?? "fixture" }) +const openrouter = openRouter.model("openai/gpt-4o-mini") +const openrouterGpt55 = openRouter.model("openai/gpt-5.5") +const openrouterOpus = OpenRouter.configure({ + apiKey: process.env.OPENROUTER_API_KEY ?? "fixture", +}).model("anthropic/claude-opus-4.7") + +const redactCloudflareURL = (url: string) => + url + .replace(/\/client\/v4\/accounts\/[^/]+\/ai\/v1\//, "/client/v4/accounts/{account}/ai/v1/") + .replace(/\/v1\/[^/]+\/[^/]+\/compat\//, "/v1/{account}/{gateway}/compat/") + +const cloudflareOptions = { + redact: { url: redactCloudflareURL }, +} + +describeRecordedGoldenScenarios([ + { + name: "OpenAI Chat gpt-4o-mini", + prefix: "openai-chat", + model: openAIChat, + requires: ["OPENAI_API_KEY"], + scenarios: ["text", "tool-call", "tool-loop", { id: "image-tool-result", maxTokens: 40 }], + }, + { + name: "OpenAI Responses gpt-5.5", + prefix: "openai-responses", + model: openAIResponses, + requires: ["OPENAI_API_KEY"], + tags: ["flagship"], + scenarios: [ + { id: "text", temperature: false }, + { id: "reasoning", temperature: false }, + { id: "reasoning-continuation", temperature: false }, + { id: "tool-call", temperature: false }, + { id: "tool-loop", temperature: false }, + { id: "image-tool-result", temperature: false, maxTokens: 40 }, + ], + }, + { + name: "OpenAI Responses WebSocket gpt-4.1-mini", + prefix: "openai-responses-websocket", + model: openAIResponsesWebSocket, + transport: "websocket", + requires: ["OPENAI_API_KEY"], + scenarios: ["tool-loop"], + }, + { + name: "Anthropic Haiku 4.5", + prefix: "anthropic-messages", + model: anthropicHaiku, + requires: ["ANTHROPIC_API_KEY"], + options: { redact: { allowRequestHeaders: ["anthropic-version"] } }, + scenarios: ["text", "tool-call"], + }, + { + name: "Anthropic Opus 4.7", + prefix: "anthropic-messages", + model: anthropicOpus, + requires: ["ANTHROPIC_API_KEY"], + tags: ["flagship"], + options: { redact: { allowRequestHeaders: ["anthropic-version"] } }, + scenarios: [ + { id: "tool-loop", temperature: false }, + { id: "image-tool-result", temperature: false, maxTokens: 40 }, + ], + }, + { + name: "Gemini 2.5 Flash", + prefix: "gemini", + model: gemini, + requires: ["GOOGLE_GENERATIVE_AI_API_KEY"], + scenarios: [ + { id: "text", maxTokens: 80 }, + "tool-call", + { id: "image", maxTokens: 160 }, + { id: "image-tool-result", maxTokens: 40 }, + ], + }, + { + name: "xAI Grok 3 Mini", + prefix: "xai", + model: xaiBasic, + requires: ["XAI_API_KEY"], + scenarios: ["text", "tool-call"], + }, + { + name: "xAI Grok 4.3", + prefix: "xai", + model: xaiFlagship, + requires: ["XAI_API_KEY"], + tags: ["flagship"], + scenarios: [{ id: "tool-loop", timeout: 30_000 }], + }, + { + name: "Cloudflare AI Gateway Workers AI Llama 3.1 8B", + prefix: "cloudflare-ai-gateway", + model: cloudflareAIGatewayWorkers, + requires: ["CLOUDFLARE_ACCOUNT_ID", "CLOUDFLARE_API_TOKEN"], + options: cloudflareOptions, + scenarios: ["text"], + }, + { + name: "Cloudflare AI Gateway Workers AI GPT OSS 20B Tools", + prefix: "cloudflare-ai-gateway", + model: cloudflareAIGatewayWorkersTools, + requires: ["CLOUDFLARE_ACCOUNT_ID", "CLOUDFLARE_API_TOKEN"], + options: cloudflareOptions, + scenarios: [{ id: "tool-call", maxTokens: 120 }], + }, + { + name: "Cloudflare Workers AI Llama 3.1 8B", + prefix: "cloudflare-workers-ai", + model: cloudflareWorkersAI, + requires: ["CLOUDFLARE_ACCOUNT_ID", "CLOUDFLARE_API_KEY"], + options: cloudflareOptions, + scenarios: ["text"], + }, + { + name: "Cloudflare Workers AI GPT OSS 20B Tools", + prefix: "cloudflare-workers-ai", + model: cloudflareWorkersAITools, + requires: ["CLOUDFLARE_ACCOUNT_ID", "CLOUDFLARE_API_KEY"], + options: cloudflareOptions, + scenarios: [{ id: "tool-call", maxTokens: 120 }], + }, + { + name: "DeepSeek Chat", + prefix: "openai-compatible-chat", + model: deepseek, + requires: ["DEEPSEEK_API_KEY"], + scenarios: ["text"], + }, + { + name: "TogetherAI Llama 3.3 70B", + prefix: "openai-compatible-chat", + model: together, + requires: ["TOGETHER_AI_API_KEY"], + scenarios: ["text", "tool-call"], + }, + { + name: "Groq Llama 3.3 70B", + prefix: "openai-compatible-chat", + model: groq, + requires: ["GROQ_API_KEY"], + scenarios: ["text", "tool-call", { id: "tool-loop", timeout: 30_000 }], + }, + { + name: "OpenRouter gpt-4o-mini", + prefix: "openai-compatible-chat", + model: openrouter, + requires: ["OPENROUTER_API_KEY"], + scenarios: ["text", "tool-call", "tool-loop"], + }, + { + name: "OpenRouter gpt-5.5", + prefix: "openai-compatible-chat", + model: openrouterGpt55, + requires: ["OPENROUTER_API_KEY"], + tags: ["flagship"], + scenarios: ["tool-loop"], + }, + { + name: "OpenRouter Claude Opus 4.7", + prefix: "openai-compatible-chat", + model: openrouterOpus, + requires: ["OPENROUTER_API_KEY"], + tags: ["flagship"], + scenarios: ["tool-loop"], + }, +]) diff --git a/packages/llm/test/provider/openai-chat.test.ts b/packages/llm/test/provider/openai-chat.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..b736dc9dd33cafbd45dd33e73b275c67a7b3256d --- /dev/null +++ b/packages/llm/test/provider/openai-chat.test.ts @@ -0,0 +1,674 @@ +import { describe, expect } from "bun:test" +import { Effect, Schema, Stream } from "effect" +import { HttpClientRequest } from "effect/unstable/http" +import { LLM, LLMError, LLMEvent, Message, Model, ToolCallPart, Usage } from "../../src" +import * as Azure from "../../src/providers/azure" +import * as OpenAI from "../../src/providers/openai" +import * as OpenAIChat from "../../src/protocols/openai-chat" +import { ProviderShared } from "../../src/protocols/shared" +import { Auth, LLMClient } from "../../src/route" +import { it } from "../lib/effect" +import { dynamicResponse, fixedResponse, truncatedStream } from "../lib/http" +import { deltaChunk, usageChunk } from "../lib/openai-chunks" +import { sseEvents } from "../lib/sse" + +const TargetJson = Schema.fromJsonString(Schema.Unknown) +const encodeJson = Schema.encodeSync(TargetJson) +const decodeJson = Schema.decodeUnknownSync(TargetJson) + +const model = OpenAIChat.route + .with({ endpoint: { baseURL: "https://api.openai.test/v1/" }, auth: Auth.bearer("test") }) + .model({ id: "gpt-4o-mini" }) + +const request = LLM.request({ + id: "req_1", + model, + system: "You are concise.", + prompt: "Say hello.", + generation: { maxTokens: 20, temperature: 0 }, +}) + +describe("OpenAI Chat route", () => { + it.effect("prepares OpenAI Chat payload", () => + Effect.gen(function* () { + // Pass the OpenAIChat payload type so `prepared.body` is statically + // typed to the route's native shape — the assertions below read field + // names without `unknown` casts. + const prepared = yield* LLMClient.prepare(request) + const _typed: { readonly model: string; readonly stream: true } = prepared.body + + expect(prepared.body).toEqual({ + model: "gpt-4o-mini", + messages: [ + { role: "system", content: "You are concise." }, + { role: "user", content: "Say hello." }, + ], + stream: true, + stream_options: { include_usage: true }, + max_tokens: 20, + temperature: 0, + }) + }), + ) + + it.effect("lowers chronological system updates to escaped user wrappers in order", () => + Effect.gen(function* () { + const prepared = yield* LLMClient.prepare( + LLM.request({ + model, + messages: [ + Message.user("Before."), + Message.system("Treat & data literally."), + Message.assistant("After."), + ], + }), + ) + + expect(prepared.body.messages).toEqual([ + { + role: "user", + content: "Before.\n\nTreat <admin> & data literally.\n", + }, + { role: "assistant", content: "After." }, + ]) + }), + ) + + it.effect("replays canonical reasoning as OpenAI-compatible reasoning_content", () => + Effect.gen(function* () { + const prepared = yield* LLMClient.prepare( + LLM.request({ + model, + messages: [ + Message.assistant([ + { type: "reasoning", text: "thinking" }, + { type: "text", text: "Hello" }, + ]), + ], + }), + ) + + expect(prepared.body.messages).toEqual([{ role: "assistant", content: "Hello", reasoning_content: "thinking" }]) + }), + ) + + it.effect("maps OpenAI provider options to Chat options", () => + Effect.gen(function* () { + const prepared = yield* LLMClient.prepare( + LLM.request({ + model: OpenAI.configure({ baseURL: "https://api.openai.test/v1/", apiKey: "test" }).chat("gpt-4o-mini"), + prompt: "think", + providerOptions: { openai: { reasoningEffort: "low" } }, + }), + ) + + expect(prepared.body.store).toBe(false) + expect(prepared.body.reasoning_effort).toBe("low") + }), + ) + + it.effect("adds native query params to the Chat Completions URL", () => + LLMClient.generate( + LLM.updateRequest(request, { + model: Model.update(model, { route: model.route.with({ endpoint: { query: { "api-version": "v1" } } }) }), + }), + ).pipe( + Effect.provide( + dynamicResponse((input) => + Effect.gen(function* () { + const web = yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie) + expect(web.url).toBe("https://api.openai.test/v1/chat/completions?api-version=v1") + return input.respond(sseEvents(deltaChunk({}, "stop")), { + headers: { "content-type": "text/event-stream" }, + }) + }), + ), + ), + ), + ) + + it.effect("uses Azure api-key header for static OpenAI Chat keys", () => + LLMClient.generate( + LLM.updateRequest(request, { + model: Azure.configure({ + baseURL: "https://opencode-test.openai.azure.com/openai/v1/", + apiKey: "azure-key", + headers: { authorization: "Bearer stale" }, + }).chat("gpt-4o-mini"), + }), + ).pipe( + Effect.provide( + dynamicResponse((input) => + Effect.gen(function* () { + const web = yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie) + expect(web.url).toBe("https://opencode-test.openai.azure.com/openai/v1/chat/completions?api-version=v1") + expect(web.headers.get("api-key")).toBe("azure-key") + expect(web.headers.get("authorization")).toBeNull() + return input.respond(sseEvents(deltaChunk({}, "stop")), { + headers: { "content-type": "text/event-stream" }, + }) + }), + ), + ), + ), + ) + + it.effect("applies serializable HTTP overlays after payload lowering", () => + LLMClient.generate( + LLM.updateRequest(request, { + model: model.route + .with({ auth: Auth.bearer("fresh-key"), headers: { authorization: "Bearer stale" } }) + .model({ id: model.id }), + http: { + body: { metadata: { source: "test" } }, + headers: { authorization: "Bearer request", "x-custom": "yes" }, + query: { debug: "1" }, + }, + }), + ).pipe( + Effect.provide( + dynamicResponse((input) => + Effect.gen(function* () { + const web = yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie) + expect(web.url).toBe("https://api.openai.test/v1/chat/completions?debug=1") + expect(web.headers.get("authorization")).toBe("Bearer fresh-key") + expect(web.headers.get("x-custom")).toBe("yes") + expect(decodeJson(input.text)).toMatchObject({ + stream: true, + stream_options: { include_usage: true }, + metadata: { source: "test" }, + }) + return input.respond(sseEvents(deltaChunk({}, "stop")), { + headers: { "content-type": "text/event-stream" }, + }) + }), + ), + ), + ), + ) + + it.effect("prepares assistant tool-call and tool-result messages", () => + Effect.gen(function* () { + const prepared = yield* LLMClient.prepare( + LLM.request({ + id: "req_tool_result", + model, + messages: [ + Message.user("What is the weather?"), + Message.assistant([ToolCallPart.make({ id: "call_1", name: "lookup", input: { query: "weather" } })]), + Message.tool({ id: "call_1", name: "lookup", result: { forecast: "sunny" } }), + ], + }), + ) + + expect(prepared.body).toEqual({ + model: "gpt-4o-mini", + messages: [ + { role: "user", content: "What is the weather?" }, + { + role: "assistant", + content: null, + tool_calls: [ + { + id: "call_1", + type: "function", + function: { name: "lookup", arguments: encodeJson({ query: "weather" }) }, + }, + ], + }, + { role: "tool", tool_call_id: "call_1", content: encodeJson({ forecast: "sunny" }) }, + ], + stream: true, + stream_options: { include_usage: true }, + }) + }), + ) + + it.effect("preserves structured tool errors for the model", () => + Effect.gen(function* () { + const error = { error: { type: "unknown", message: "Tool execution interrupted" } } + const prepared = yield* LLMClient.prepare( + LLM.request({ + model, + messages: [ + Message.assistant([ToolCallPart.make({ id: "call_1", name: "bash", input: {} })]), + Message.tool({ id: "call_1", name: "bash", resultType: "error", result: error }), + ], + }), + ) + + expect(prepared.body.messages.at(-1)).toEqual({ + role: "tool", + tool_call_id: "call_1", + content: ProviderShared.encodeJson(error), + }) + }), + ) + + it.effect("continues image tool results as vision input without base64 text", () => + Effect.gen(function* () { + const prepared = yield* LLMClient.prepare( + LLM.request({ + model, + messages: [ + Message.assistant([ToolCallPart.make({ id: "call_image", name: "read", input: { path: "pixel.png" } })]), + Message.tool({ + id: "call_image", + name: "read", + result: { + type: "content", + value: [ + { type: "text", text: "Image read successfully" }, + { type: "file", uri: "data:image/png;base64,AAECAw==", mime: "image/png", name: "pixel.png" }, + ], + }, + }), + ], + }), + ) + + expect(prepared.body.messages).toEqual([ + { + role: "assistant", + content: null, + tool_calls: [ + { + id: "call_image", + type: "function", + function: { name: "read", arguments: encodeJson({ path: "pixel.png" }) }, + }, + ], + }, + { role: "tool", tool_call_id: "call_image", content: "Image read successfully" }, + { + role: "user", + content: [{ type: "image_url", image_url: { url: "data:image/png;base64,AAECAw==" } }], + }, + ]) + expect(JSON.stringify(prepared.body.messages)).not.toContain('"content":"AAECAw=="') + }), + ) + + it.effect("orders parallel tool responses before one aggregated vision message", () => + Effect.gen(function* () { + const prepared = yield* LLMClient.prepare( + LLM.request({ + model, + messages: [ + Message.assistant([ + ToolCallPart.make({ id: "call_1", name: "read", input: {} }), + ToolCallPart.make({ id: "call_2", name: "read", input: {} }), + ]), + Message.make({ + role: "tool", + content: [ + { + type: "tool-result", + id: "call_1", + name: "read", + result: { + type: "content", + value: [{ type: "file", uri: "data:image/png;base64,AAEC", mime: "image/png" }], + }, + }, + { + type: "tool-result", + id: "call_2", + name: "read", + result: { + type: "content", + value: [{ type: "file", uri: "data:image/jpeg;base64,/9j/", mime: "image/jpeg" }], + }, + }, + ], + }), + ], + }), + ) + expect(prepared.body.messages.slice(1)).toEqual([ + { role: "tool", tool_call_id: "call_1", content: "" }, + { role: "tool", tool_call_id: "call_2", content: "" }, + { + role: "user", + content: [ + { type: "image_url", image_url: { url: "data:image/png;base64,AAEC" } }, + { type: "image_url", image_url: { url: "data:image/jpeg;base64,/9j/" } }, + ], + }, + ]) + }), + ) + + it.effect("aggregates consecutive tool images with a following system update", () => + Effect.gen(function* () { + const prepared = yield* LLMClient.prepare( + LLM.request({ + model, + messages: [ + Message.tool({ + id: "call_1", + name: "read", + result: { + type: "content", + value: [{ type: "file", uri: "data:image/png;base64,AAEC", mime: "image/png" }], + }, + }), + Message.tool({ + id: "call_2", + name: "read", + result: { + type: "content", + value: [{ type: "file", uri: "data:image/webp;base64,UklG", mime: "image/webp" }], + }, + }), + Message.system("Inspect both images."), + ], + }), + ) + expect(prepared.body.messages).toEqual([ + { role: "tool", tool_call_id: "call_1", content: "" }, + { role: "tool", tool_call_id: "call_2", content: "" }, + { + role: "user", + content: [ + { type: "image_url", image_url: { url: "data:image/png;base64,AAEC" } }, + { type: "image_url", image_url: { url: "data:image/webp;base64,UklG" } }, + { type: "text", text: "\nInspect both images.\n" }, + ], + }, + ]) + }), + ) + + it.effect("appends system updates without replacing multipart user content", () => + Effect.gen(function* () { + const prepared = yield* LLMClient.prepare( + LLM.request({ + model, + messages: [ + Message.user({ type: "media", mediaType: "image/png", data: "AAEC" }), + Message.system("Keep the image."), + ], + }), + ) + expect(prepared.body.messages).toEqual([ + { + role: "user", + content: [ + { type: "image_url", image_url: { url: "data:image/png;base64,AAEC" } }, + { type: "text", text: "\nKeep the image.\n" }, + ], + }, + ]) + }), + ) + + for (const [name, media] of [ + ["mismatched data URL MIME", { mediaType: "image/png", data: "data:image/jpeg;base64,/9j/" }], + ["malformed base64", { mediaType: "image/png", data: "not-base64" }], + ["unsupported SVG", { mediaType: "image/svg+xml", data: "PHN2Zz4=" }], + ] as const) + it.effect(`rejects ${name}`, () => + Effect.gen(function* () { + const error = yield* LLMClient.prepare( + LLM.request({ model, messages: [Message.user({ type: "media", ...media })] }), + ).pipe(Effect.flip) + expect(error.message).toMatch(/does not support|does not match|valid base64/) + }), + ) + + it.effect("rejects oversized image input", () => + Effect.gen(function* () { + const error = yield* LLMClient.prepare( + LLM.request({ + model, + messages: [ + Message.user({ + type: "media", + mediaType: "image/png", + data: "A".repeat(ProviderShared.MAX_MEDIA_ENCODED_BYTES + 4), + }), + ], + }), + ).pipe(Effect.flip) + expect(error.message).toContain("encoded limit") + }), + ) + + it.effect("prepares raw and data URL image media as vision input", () => + Effect.gen(function* () { + const prepared = yield* LLMClient.prepare( + LLM.request({ + id: "req_media", + model, + messages: [ + Message.user([ + { type: "media", mediaType: "image/png", data: "AAECAw==" }, + { type: "media", mediaType: "image/jpeg", data: "data:image/jpeg;base64,/9j/" }, + ]), + ], + }), + ) + + expect(prepared.body.messages).toEqual([ + { + role: "user", + content: [ + { type: "image_url", image_url: { url: "data:image/png;base64,AAECAw==" } }, + { type: "image_url", image_url: { url: "data:image/jpeg;base64,/9j/" } }, + ], + }, + ]) + }), + ) + + it.effect("lowers reasoning-only assistant history", () => + Effect.gen(function* () { + const prepared = yield* LLMClient.prepare( + LLM.request({ + id: "req_reasoning", + model, + messages: [Message.assistant({ type: "reasoning", text: "hidden" })], + }), + ) + + expect(prepared.body.messages).toEqual([{ role: "assistant", content: null, reasoning_content: "hidden" }]) + }), + ) + + it.effect("parses text and usage stream fixtures", () => + Effect.gen(function* () { + const body = sseEvents( + deltaChunk({ role: "assistant", content: "Hello" }), + deltaChunk({ content: "!" }), + deltaChunk({}, "stop"), + usageChunk({ + prompt_tokens: 5, + completion_tokens: 2, + total_tokens: 7, + prompt_tokens_details: { cached_tokens: 1 }, + completion_tokens_details: { reasoning_tokens: 0 }, + }), + ) + const response = yield* LLMClient.generate(request).pipe(Effect.provide(fixedResponse(body))) + const usage = new Usage({ + inputTokens: 5, + outputTokens: 2, + nonCachedInputTokens: 4, + cacheReadInputTokens: 1, + reasoningTokens: 0, + totalTokens: 7, + providerMetadata: { + openai: { + prompt_tokens: 5, + completion_tokens: 2, + total_tokens: 7, + prompt_tokens_details: { cached_tokens: 1 }, + completion_tokens_details: { reasoning_tokens: 0 }, + }, + }, + }) + + expect(response.text).toBe("Hello!") + expect(response.events).toEqual([ + { type: "step-start", index: 0 }, + { type: "text-start", id: "text-0" }, + { type: "text-delta", id: "text-0", text: "Hello" }, + { type: "text-delta", id: "text-0", text: "!" }, + { type: "text-end", id: "text-0" }, + { type: "step-finish", index: 0, reason: "stop", usage, providerMetadata: undefined }, + { + type: "finish", + reason: "stop", + usage, + }, + ]) + }), + ) + + it.effect("parses OpenAI-compatible reasoning content deltas", () => + Effect.gen(function* () { + const body = sseEvents( + { choices: [{ delta: { reasoning_content: "thinking" } }] }, + { choices: [{ delta: { content: "Hello" } }] }, + { choices: [{ delta: {}, finish_reason: "stop" }] }, + ) + + const response = yield* LLMClient.generate(request).pipe(Effect.provide(fixedResponse(body))) + + expect(response.reasoning).toBe("thinking") + expect(response.text).toBe("Hello") + expect(response.events).toMatchObject([ + { type: "step-start", index: 0 }, + { type: "reasoning-start", id: "reasoning-0" }, + { type: "reasoning-delta", id: "reasoning-0", text: "thinking" }, + { type: "reasoning-end", id: "reasoning-0" }, + { type: "text-start", id: "text-0" }, + { type: "text-delta", id: "text-0", text: "Hello" }, + { type: "text-end", id: "text-0" }, + { type: "step-finish", index: 0, reason: "stop" }, + { type: "finish", reason: "stop" }, + ]) + }), + ) + + it.effect("assembles streamed tool call input", () => + Effect.gen(function* () { + const body = sseEvents( + deltaChunk({ + role: "assistant", + tool_calls: [{ index: 0, id: "call_1", function: { name: "lookup", arguments: '{"query"' } }], + }), + deltaChunk({ tool_calls: [{ index: 0, function: { arguments: ':"weather"}' } }] }), + deltaChunk({}, "tool_calls"), + ) + const response = yield* LLMClient.generate( + LLM.updateRequest(request, { + tools: [{ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } }], + }), + ).pipe(Effect.provide(fixedResponse(body))) + + expect(response.events).toEqual([ + { type: "step-start", index: 0 }, + { type: "tool-input-start", id: "call_1", name: "lookup", providerMetadata: undefined }, + { type: "tool-input-delta", id: "call_1", name: "lookup", text: '{"query"' }, + { type: "tool-input-delta", id: "call_1", name: "lookup", text: ':"weather"}' }, + { type: "tool-input-end", id: "call_1", name: "lookup", providerMetadata: undefined }, + { + type: "tool-call", + id: "call_1", + name: "lookup", + input: { query: "weather" }, + providerExecuted: undefined, + providerMetadata: undefined, + }, + { type: "step-finish", index: 0, reason: "tool-calls", usage: undefined, providerMetadata: undefined }, + { type: "finish", reason: "tool-calls", usage: undefined }, + ]) + }), + ) + + it.effect("does not finalize streamed tool calls without a finish reason", () => + Effect.gen(function* () { + const body = sseEvents( + deltaChunk({ + role: "assistant", + tool_calls: [{ index: 0, id: "call_1", function: { name: "lookup", arguments: '{"query"' } }], + }), + deltaChunk({ tool_calls: [{ index: 0, function: { arguments: ':"weather"}' } }] }), + ) + const input = LLM.updateRequest(request, { + tools: [{ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } }], + }) + const events = Array.from( + yield* LLMClient.stream(input).pipe(Stream.runCollect, Effect.provide(fixedResponse(body))), + ) + const error = yield* LLMClient.generate(input).pipe(Effect.provide(fixedResponse(body)), Effect.flip) + + expect(events).toEqual([ + { type: "step-start", index: 0 }, + { type: "tool-input-start", id: "call_1", name: "lookup", providerMetadata: undefined }, + { type: "tool-input-delta", id: "call_1", name: "lookup", text: '{"query"' }, + { type: "tool-input-delta", id: "call_1", name: "lookup", text: ':"weather"}' }, + ]) + expect(events.filter(LLMEvent.is.toolCall)).toEqual([]) + expect(error.message).toContain("Provider stream ended without a terminal finish event") + }), + ) + + it.effect("fails on malformed stream events", () => + Effect.gen(function* () { + const body = sseEvents(deltaChunk({ content: 123 })) + const error = yield* LLMClient.generate(request).pipe(Effect.provide(fixedResponse(body)), Effect.flip) + + expect(error.message).toContain("Invalid openai/openai-chat stream event") + }), + ) + + it.effect("surfaces transport errors that occur mid-stream", () => + Effect.gen(function* () { + const layer = truncatedStream([ + `data: ${JSON.stringify(deltaChunk({ role: "assistant", content: "Hello" }))}\n\n`, + ]) + const error = yield* LLMClient.generate(request).pipe(Effect.provide(layer), Effect.flip) + + expect(error.message).toContain("Failed to read openai/openai-chat stream") + }), + ) + + it.effect("fails HTTP provider errors before stream parsing", () => + Effect.gen(function* () { + const error = yield* LLMClient.generate(request).pipe( + Effect.provide( + fixedResponse('{"error":{"message":"Bad request","type":"invalid_request_error"}}', { + status: 400, + headers: { "content-type": "application/json" }, + }), + ), + Effect.flip, + ) + + expect(error).toBeInstanceOf(LLMError) + expect(error.reason).toMatchObject({ _tag: "InvalidRequest" }) + expect(error.message).toContain("HTTP 400") + }), + ) + + it.effect("short-circuits the upstream stream when the consumer takes a prefix", () => + Effect.gen(function* () { + // The body has more chunks than we'll consume. If `Stream.take(1)` did + // not interrupt the upstream HTTP body the test would hang waiting for + // the rest of the stream to drain. + const body = sseEvents( + deltaChunk({ role: "assistant", content: "Hello" }), + deltaChunk({ content: " world" }), + deltaChunk({}, "stop"), + ) + + const events = Array.from( + yield* LLMClient.stream(request).pipe(Stream.take(1), Stream.runCollect, Effect.provide(fixedResponse(body))), + ) + expect(events.map((event) => event.type)).toEqual(["step-start"]) + }), + ) +}) diff --git a/packages/llm/test/provider/openai-compatible-chat.test.ts b/packages/llm/test/provider/openai-compatible-chat.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..43ae283e9f7c2cfe38b3494e0bc8b13a207cb9b7 --- /dev/null +++ b/packages/llm/test/provider/openai-compatible-chat.test.ts @@ -0,0 +1,238 @@ +import { describe, expect } from "bun:test" +import { Effect, Schema } from "effect" +import { HttpClientRequest } from "effect/unstable/http" +import { LLM, Message, ToolCallPart } from "../../src" +import { Auth, LLMClient } from "../../src/route" +import * as OpenAICompatible from "../../src/providers/openai-compatible" +import * as OpenAICompatibleChat from "../../src/protocols/openai-compatible-chat" +import { it } from "../lib/effect" +import { dynamicResponse } from "../lib/http" +import { sseEvents } from "../lib/sse" + +const Json = Schema.fromJsonString(Schema.Unknown) +const decodeJson = Schema.decodeUnknownSync(Json) + +const model = OpenAICompatibleChat.route + .with({ + provider: "deepseek", + endpoint: { baseURL: "https://api.deepseek.test/v1/", query: { "api-version": "2026-01-01" } }, + auth: Auth.bearer("test-key"), + }) + .model({ id: "deepseek-chat" }) + +const request = LLM.request({ + id: "req_1", + model, + system: "You are concise.", + prompt: "Say hello.", + generation: { maxTokens: 20, temperature: 0 }, +}) + +const deltaChunk = (delta: object, finishReason: string | null = null) => ({ + id: "chatcmpl_fixture", + choices: [{ delta, finish_reason: finishReason }], + usage: null, +}) + +const usageChunk = (usage: object) => ({ + id: "chatcmpl_fixture", + choices: [], + usage, +}) + +const providerFamilies = [ + ["baseten", OpenAICompatible.baseten, "https://inference.baseten.co/v1"], + ["cerebras", OpenAICompatible.cerebras, "https://api.cerebras.ai/v1"], + ["deepinfra", OpenAICompatible.deepinfra, "https://api.deepinfra.com/v1/openai"], + ["deepseek", OpenAICompatible.deepseek, "https://api.deepseek.com/v1"], + ["fireworks", OpenAICompatible.fireworks, "https://api.fireworks.ai/inference/v1"], + ["togetherai", OpenAICompatible.togetherai, "https://api.together.xyz/v1"], +] as const + +describe("OpenAI-compatible Chat route", () => { + it.effect("prepares generic Chat target", () => + Effect.gen(function* () { + const prepared = yield* LLMClient.prepare( + LLM.updateRequest(request, { + tools: [{ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } }], + toolChoice: { type: "required" }, + }), + ) + + expect(prepared.route).toBe("openai-compatible-chat") + expect(prepared.model).toMatchObject({ + id: "deepseek-chat", + provider: "deepseek", + route: { id: "openai-compatible-chat" }, + }) + expect(prepared.model.route.endpoint).toMatchObject({ + baseURL: "https://api.deepseek.test/v1/", + query: { "api-version": "2026-01-01" }, + }) + expect(prepared.body).toEqual({ + model: "deepseek-chat", + messages: [ + { role: "system", content: "You are concise." }, + { role: "user", content: "Say hello." }, + ], + tools: [ + { + type: "function", + function: { name: "lookup", description: "Lookup data", parameters: { type: "object" } }, + }, + ], + tool_choice: "required", + stream: true, + stream_options: { include_usage: true }, + max_tokens: 20, + temperature: 0, + }) + }), + ) + + it.effect("provides model helpers for compatible provider families", () => + Effect.gen(function* () { + expect( + providerFamilies.map(([provider, family]) => { + const model = family.configure({ apiKey: "test-key" }).model(`${provider}-model`) + return { + id: String(model.id), + provider: String(model.provider), + route: model.route.id, + baseURL: model.route.endpoint.baseURL, + } + }), + ).toEqual( + providerFamilies.map(([provider, _, baseURL]) => ({ + id: `${provider}-model`, + provider, + route: "openai-compatible-chat", + baseURL, + })), + ) + + const custom = OpenAICompatible.deepseek + .configure({ + apiKey: "test-key", + baseURL: "https://custom.deepseek.test/v1", + }) + .model("deepseek-chat") + expect(custom).toMatchObject({ + provider: "deepseek", + route: { id: "openai-compatible-chat" }, + }) + expect(custom.route.endpoint.baseURL).toBe("https://custom.deepseek.test/v1") + }), + ) + + it.effect("matches AI SDK compatible basic request body fixture", () => + Effect.gen(function* () { + const prepared = yield* LLMClient.prepare(request) + + expect(prepared.body).toEqual({ + model: "deepseek-chat", + messages: [ + { role: "system", content: "You are concise." }, + { role: "user", content: "Say hello." }, + ], + stream: true, + stream_options: { include_usage: true }, + max_tokens: 20, + temperature: 0, + }) + }), + ) + + it.effect("matches AI SDK compatible tool request body fixture", () => + Effect.gen(function* () { + const prepared = yield* LLMClient.prepare( + LLM.request({ + id: "req_tool_parity", + model, + tools: [ + { + name: "lookup", + description: "Lookup data", + inputSchema: { type: "object", properties: { query: { type: "string" } }, required: ["query"] }, + }, + ], + toolChoice: "lookup", + messages: [ + Message.user("What is the weather?"), + Message.assistant([ToolCallPart.make({ id: "call_1", name: "lookup", input: { query: "weather" } })]), + Message.tool({ id: "call_1", name: "lookup", result: { forecast: "sunny" } }), + ], + }), + ) + + expect(prepared.body).toEqual({ + model: "deepseek-chat", + messages: [ + { role: "user", content: "What is the weather?" }, + { + role: "assistant", + content: null, + tool_calls: [ + { + id: "call_1", + type: "function", + function: { name: "lookup", arguments: '{"query":"weather"}' }, + }, + ], + }, + { role: "tool", tool_call_id: "call_1", content: '{"forecast":"sunny"}' }, + ], + tools: [ + { + type: "function", + function: { + name: "lookup", + description: "Lookup data", + parameters: { type: "object", properties: { query: { type: "string" } }, required: ["query"] }, + }, + }, + ], + tool_choice: { type: "function", function: { name: "lookup" } }, + stream: true, + stream_options: { include_usage: true }, + }) + }), + ) + + it.effect("posts to the configured compatible endpoint and parses text usage", () => + Effect.gen(function* () { + const response = yield* LLMClient.generate(request).pipe( + Effect.provide( + dynamicResponse((input) => + Effect.gen(function* () { + const web = yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie) + expect(web.url).toBe("https://api.deepseek.test/v1/chat/completions?api-version=2026-01-01") + expect(web.headers.get("authorization")).toBe("Bearer test-key") + expect(decodeJson(input.text)).toMatchObject({ + model: "deepseek-chat", + stream: true, + messages: [ + { role: "system", content: "You are concise." }, + { role: "user", content: "Say hello." }, + ], + }) + return input.respond( + sseEvents( + deltaChunk({ role: "assistant", content: "Hello" }), + deltaChunk({ content: "!" }), + deltaChunk({}, "stop"), + usageChunk({ prompt_tokens: 5, completion_tokens: 2, total_tokens: 7 }), + ), + { headers: { "content-type": "text/event-stream" } }, + ) + }), + ), + ), + ) + + expect(response.text).toBe("Hello!") + expect(response.usage).toMatchObject({ inputTokens: 5, outputTokens: 2, totalTokens: 7 }) + expect(response.events.at(-1)).toMatchObject({ type: "finish", reason: "stop" }) + }), + ) +}) diff --git a/packages/llm/test/provider/openai-responses-cache.recorded.test.ts b/packages/llm/test/provider/openai-responses-cache.recorded.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..638c30e6671b5261d263d3ca4d148a2a3091fcd5 --- /dev/null +++ b/packages/llm/test/provider/openai-responses-cache.recorded.test.ts @@ -0,0 +1,46 @@ +import { describe, expect } from "bun:test" +import { Effect } from "effect" +import { LLM } from "../../src" +import { LLMClient } from "../../src/route" +import * as OpenAI from "../../src/providers/openai" +import { LARGE_CACHEABLE_SYSTEM } from "../recorded-scenarios" +import { recordedTests } from "../recorded-test" + +const model = OpenAI.configure({ + apiKey: process.env.OPENAI_API_KEY ?? "fixture", +}).responses("gpt-4.1-mini") + +// OpenAI caches prefixes automatically once they cross the 1024-token threshold; +// `CacheHint` is a no-op for the wire body. The stable signal is the +// `prompt_cache_key` routing hint, which keeps repeated calls on the same shard +// so cache hits are observable. +const cacheRequest = LLM.request({ + id: "recorded_openai_responses_cache", + model, + system: LARGE_CACHEABLE_SYSTEM, + prompt: "Say hi.", + generation: { maxTokens: 16, temperature: 0 }, + providerOptions: { openai: { promptCacheKey: "recorded-cache-test" } }, +}) + +const recorded = recordedTests({ + prefix: "openai-responses-cache", + provider: "openai", + protocol: "openai-responses", + requires: ["OPENAI_API_KEY"], + // Two identical requests in one cassette — replay walks the cassette in + // recording order so the second call replays the cached-hit interaction, + // not the cold-miss one. +}) + +describe("OpenAI Responses cache recorded", () => { + recorded.effect.with("reports cached_tokens on identical second call", { tags: ["cache"] }, () => + Effect.gen(function* () { + const first = yield* LLMClient.generate(cacheRequest) + expect(first.usage?.cacheReadInputTokens ?? 0).toBeGreaterThanOrEqual(0) + + const second = yield* LLMClient.generate(cacheRequest) + expect(second.usage?.cacheReadInputTokens ?? 0).toBeGreaterThan(0) + }), + ) +}) diff --git a/packages/llm/test/provider/openai-responses.test.ts b/packages/llm/test/provider/openai-responses.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..cd8bad51af47b466738fae4b4621241dc2bda3c1 --- /dev/null +++ b/packages/llm/test/provider/openai-responses.test.ts @@ -0,0 +1,1472 @@ +import { describe, expect } from "bun:test" +import { ConfigProvider, Effect, Layer, Stream } from "effect" +import { Headers, HttpClientRequest } from "effect/unstable/http" +import { LLM, LLMError, Message, Model, ToolCallPart, Usage } from "../../src" +import { Auth, LLMClient, RequestExecutor, WebSocketExecutor } from "../../src/route" +import * as Azure from "../../src/providers/azure" +import * as OpenAI from "../../src/providers/openai" +import * as OpenAIResponses from "../../src/protocols/openai-responses" +import * as ProviderShared from "../../src/protocols/shared" +import { continuationRequest, nativeOpenAIResponsesContinuation } from "../continuation-scenarios" +import { it } from "../lib/effect" +import { dynamicResponse, fixedResponse } from "../lib/http" +import { sseEvents } from "../lib/sse" + +const model = OpenAIResponses.route + .with({ endpoint: { baseURL: "https://api.openai.test/v1/" }, auth: Auth.bearer("test") }) + .model({ id: "gpt-4.1-mini" }) + +const request = LLM.request({ + id: "req_1", + model, + system: "You are concise.", + prompt: "Say hello.", + generation: { maxTokens: 20, temperature: 0 }, +}) + +const configEnv = (env: Record) => Effect.provide(ConfigProvider.layer(ConfigProvider.fromEnv({ env }))) + +type OpenAIToolOutput = Extract< + OpenAIResponses.OpenAIResponsesBody["input"][number], + { readonly type: "function_call_output" } +> + +const expectToolOutput = (body: OpenAIResponses.OpenAIResponsesBody): OpenAIToolOutput => { + const output = body.input.find( + (item): item is OpenAIToolOutput => "type" in item && item.type === "function_call_output", + ) + expect(output).toBeDefined() + return output! +} + +describe("OpenAI Responses route", () => { + it.effect("prepares OpenAI Responses target", () => + Effect.gen(function* () { + const prepared = yield* LLMClient.prepare(request) + + expect(prepared.body).toEqual({ + model: "gpt-4.1-mini", + input: [ + { role: "system", content: "You are concise." }, + { role: "user", content: [{ type: "input_text", text: "Say hello." }] }, + ], + store: false, + stream: true, + max_output_tokens: 20, + temperature: 0, + }) + }), + ) + + it.effect("lowers semantic service tier options", () => + Effect.gen(function* () { + const input = LLM.updateRequest(request, { providerOptions: { openai: { serviceTier: "priority" } } }) + expect(input.providerOptions).toEqual({ openai: { serviceTier: "priority" } }) + const prepared = yield* LLMClient.prepare(input) + + expect(prepared.body).toMatchObject({ service_tier: "priority" }) + expect(prepared.body).not.toHaveProperty("serviceTier") + }), + ) + + it.effect("omits unsupported semantic service tiers", () => + Effect.gen(function* () { + const prepared = yield* LLMClient.prepare( + LLM.updateRequest(request, { providerOptions: { openai: { serviceTier: "unsupported" } } }), + ) + + expect(prepared.body).not.toHaveProperty("service_tier") + }), + ) + + it.effect("flattens top-level object unions in function schemas", () => + Effect.gen(function* () { + const prepared = yield* LLMClient.prepare( + LLM.updateRequest(request, { + tools: [ + { + name: "read", + description: "Read a path or resource.", + inputSchema: { + type: "object", + anyOf: [ + { + type: "object", + properties: { + path: { type: "string" }, + reference: { anyOf: [{ type: "string" }, { type: "null" }] }, + limit: { type: "integer", maximum: 2000 }, + }, + required: ["path"], + }, + { + type: "object", + properties: { resource: { type: "string" }, limit: { type: "integer", maximum: 51200 } }, + required: ["resource"], + }, + ], + }, + }, + ], + }), + ) + + expect(prepared.body.tools).toEqual([ + { + type: "function", + name: "read", + description: "Read a path or resource.", + strict: false, + parameters: { + type: "object", + properties: { + path: { type: "string" }, + reference: { type: "string" }, + limit: { type: "integer", maximum: 2000 }, + resource: { type: "string" }, + }, + additionalProperties: false, + }, + }, + ]) + }), + ) + + it.effect("lowers chronological system updates to escaped user wrappers in order", () => + Effect.gen(function* () { + const prepared = yield* LLMClient.prepare( + LLM.request({ + model, + messages: [ + Message.user("Before."), + Message.system("Treat literally."), + Message.assistant("After."), + ], + }), + ) + + expect(prepared.body.input).toEqual([ + { + role: "user", + content: [ + { type: "input_text", text: "Before." }, + { type: "input_text", text: "\nTreat </system-update> literally.\n" }, + ], + }, + { role: "assistant", content: [{ type: "output_text", text: "After." }] }, + ]) + }), + ) + + it.effect("prepares OpenAI Responses WebSocket target", () => + Effect.gen(function* () { + const prepared = yield* LLMClient.prepare( + LLM.updateRequest(request, { + model: OpenAIResponses.webSocketRoute + .with({ endpoint: { baseURL: "https://api.openai.test/v1/" }, auth: Auth.bearer("test") }) + .model({ id: "gpt-4.1-mini" }), + }), + ) + + expect(prepared.route).toBe("openai-responses-websocket") + expect(prepared.protocol).toBe("openai-responses") + expect(prepared.metadata).toEqual({ transport: "websocket-json" }) + expect(prepared.body).toMatchObject({ model: "gpt-4.1-mini", store: false, stream: true }) + }), + ) + + it.effect("streams OpenAI Responses over WebSocket", () => + Effect.gen(function* () { + const sent: string[] = [] + const opened: Array<{ readonly url: string; readonly authorization: string | undefined }> = [] + let closed = false + const deps = Layer.mergeAll( + Layer.succeed( + RequestExecutor.Service, + RequestExecutor.Service.of({ + execute: () => Effect.die("unexpected HTTP request"), + }), + ), + Layer.succeed( + WebSocketExecutor.Service, + WebSocketExecutor.Service.of({ + open: (input) => + Effect.succeed({ + sendText: (message) => + Effect.sync(() => { + opened.push({ url: input.url, authorization: input.headers.authorization }) + sent.push(message) + }), + messages: Stream.fromArray([ + ProviderShared.encodeJson({ type: "response.output_text.delta", item_id: "msg_1", delta: "Hi" }), + ProviderShared.encodeJson({ type: "response.completed", response: { id: "resp_ws" } }), + ]), + close: Effect.sync(() => { + closed = true + }), + }), + }), + ), + ) + const response = yield* LLMClient.generate( + LLM.request({ + model: OpenAI.configure({ baseURL: "https://api.openai.test/v1/", apiKey: "test" }).responsesWebSocket( + "gpt-4.1-mini", + ), + prompt: "Say hello.", + }), + ).pipe(Effect.provide(LLMClient.layer.pipe(Layer.provide(deps)))) + + expect(response.text).toBe("Hi") + expect(opened).toEqual([{ url: "wss://api.openai.test/v1/responses", authorization: "Bearer test" }]) + expect(closed).toBe(true) + expect(sent).toHaveLength(1) + expect(JSON.parse(sent[0])).toEqual({ + type: "response.create", + model: "gpt-4.1-mini", + input: [{ role: "user", content: [{ type: "input_text", text: "Say hello." }] }], + store: false, + }) + }), + ) + + it.effect("fails immediately when WebSocket is already closed", () => + Effect.gen(function* () { + const error = yield* WebSocketExecutor.fromWebSocket( + // oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- fromWebSocket reads readyState before touching WebSocket methods on this branch. + { readyState: globalThis.WebSocket.CLOSED } as globalThis.WebSocket, + { url: "wss://api.openai.test/v1/responses", headers: Headers.empty }, + ).pipe(Effect.flip) + + expect(error.message).toContain("closed before opening") + }), + ) + + it.effect("adds native query params to the Responses URL", () => + Effect.gen(function* () { + yield* LLMClient.generate( + LLM.updateRequest(request, { + model: Model.update(model, { route: model.route.with({ endpoint: { query: { "api-version": "v1" } } }) }), + }), + ).pipe( + Effect.provide( + dynamicResponse((input) => + Effect.gen(function* () { + const web = yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie) + expect(web.url).toBe("https://api.openai.test/v1/responses?api-version=v1") + return input.respond(sseEvents({ type: "response.completed", response: {} }), { + headers: { "content-type": "text/event-stream" }, + }) + }), + ), + ), + ) + }), + ) + + it.effect("uses Azure api-key header for static OpenAI Responses keys", () => + Effect.gen(function* () { + yield* LLMClient.generate( + LLM.updateRequest(request, { + model: Azure.configure({ + baseURL: "https://opencode-test.openai.azure.com/openai/v1/", + apiKey: "azure-key", + headers: { authorization: "Bearer stale" }, + }).responses("gpt-4.1-mini"), + }), + ).pipe( + Effect.provide( + dynamicResponse((input) => + Effect.gen(function* () { + const web = yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie) + expect(web.url).toBe("https://opencode-test.openai.azure.com/openai/v1/responses?api-version=v1") + expect(web.headers.get("api-key")).toBe("azure-key") + expect(web.headers.get("authorization")).toBeNull() + return input.respond(sseEvents({ type: "response.completed", response: {} }), { + headers: { "content-type": "text/event-stream" }, + }) + }), + ), + ), + ) + }), + ) + + it.effect("loads OpenAI default auth from Effect Config", () => + LLMClient.generate( + LLM.updateRequest(request, { + model: OpenAI.configure({ baseURL: "https://api.openai.test/v1/" }).responses("gpt-4.1-mini"), + }), + ).pipe( + configEnv({ OPENAI_API_KEY: "env-key" }), + Effect.provide( + dynamicResponse((input) => + Effect.gen(function* () { + const web = yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie) + expect(web.headers.get("authorization")).toBe("Bearer env-key") + return input.respond(sseEvents({ type: "response.completed", response: {} }), { + headers: { "content-type": "text/event-stream" }, + }) + }), + ), + ), + ), + ) + + it.effect("lets explicit auth override OpenAI default API key auth", () => + LLMClient.generate( + LLM.updateRequest(request, { + model: OpenAI.configure({ + baseURL: "https://api.openai.test/v1/", + auth: Auth.bearer("oauth-token"), + }).responses("gpt-4.1-mini"), + }), + ).pipe( + Effect.provide( + dynamicResponse((input) => + Effect.gen(function* () { + const web = yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie) + expect(web.headers.get("authorization")).toBe("Bearer oauth-token") + return input.respond(sseEvents({ type: "response.completed", response: {} }), { + headers: { "content-type": "text/event-stream" }, + }) + }), + ), + ), + ), + ) + + it.effect("prepares function call and function output input items", () => + Effect.gen(function* () { + const prepared = yield* LLMClient.prepare( + LLM.request({ + id: "req_tool_result", + model, + messages: [ + Message.user("What is the weather?"), + Message.assistant([ToolCallPart.make({ id: "call_1", name: "lookup", input: { query: "weather" } })]), + Message.tool({ id: "call_1", name: "lookup", result: { forecast: "sunny" } }), + ], + }), + ) + + expect(prepared.body).toEqual({ + model: "gpt-4.1-mini", + input: [ + { role: "user", content: [{ type: "input_text", text: "What is the weather?" }] }, + { type: "function_call", call_id: "call_1", name: "lookup", arguments: '{"query":"weather"}' }, + { type: "function_call_output", call_id: "call_1", output: '{"forecast":"sunny"}' }, + ], + store: false, + stream: true, + max_output_tokens: undefined, + temperature: undefined, + tool_choice: undefined, + tools: undefined, + top_p: undefined, + }) + }), + ) + + it.effect("preserves structured tool errors for the model", () => + Effect.gen(function* () { + const error = { + error: { type: "unknown", message: "Tool execution interrupted" }, + content: [], + structured: {}, + } + const prepared = yield* LLMClient.prepare( + LLM.request({ + model, + messages: [ + Message.assistant([ToolCallPart.make({ id: "call_1", name: "bash", input: { command: "sleep 10" } })]), + Message.tool({ + id: "call_1", + name: "bash", + resultType: "error", + result: error, + }), + ], + }), + ) + + expect(expectToolOutput(prepared.body).output).toBe(ProviderShared.encodeJson(error)) + }), + ) + + it.effect("keeps primitive tool errors as plain text", () => + Effect.gen(function* () { + const prepared = yield* LLMClient.prepare( + LLM.request({ + model, + messages: [ + Message.assistant([ToolCallPart.make({ id: "call_1", name: "bash", input: {} })]), + Message.tool({ id: "call_1", name: "bash", resultType: "error", result: 503 }), + ], + }), + ) + + expect(expectToolOutput(prepared.body).output).toBe("503") + }), + ) + + it.effect("keeps non-JSON tool errors as plain text", () => + Effect.gen(function* () { + const prepared = yield* LLMClient.prepare( + LLM.request({ + model, + messages: [ + Message.assistant([ToolCallPart.make({ id: "call_1", name: "bash", input: {} })]), + Message.tool({ id: "call_1", name: "bash", resultType: "error", result: new Error("boom") }), + ], + }), + ) + + expect(expectToolOutput(prepared.body).output).toBe("Error: boom") + }), + ) + + // Regression: screenshot/read tool results must stay structured so base64 + // image data is not JSON-stringified into `function_call_output.output`. + it.effect("lowers image tool-result content as structured input_image items", () => + Effect.gen(function* () { + const prepared = yield* LLMClient.prepare( + LLM.request({ + id: "req_tool_result_image", + model, + messages: [ + Message.user("Show me the screenshot."), + Message.assistant([ToolCallPart.make({ id: "call_1", name: "read", input: { filePath: "shot.png" } })]), + Message.tool({ + id: "call_1", + name: "read", + resultType: "content", + result: [ + { type: "text", text: "Image read successfully" }, + { type: "file", uri: "data:image/png;base64,AAECAw==", mime: "image/png" }, + ], + }), + ], + }), + ) + + expect(expectToolOutput(prepared.body).output).toEqual([ + { type: "input_text", text: "Image read successfully" }, + { type: "input_image", image_url: "data:image/png;base64,AAECAw==" }, + ]) + }), + ) + + it.effect("lowers single-image tool-result content as structured input_image array", () => + Effect.gen(function* () { + const prepared = yield* LLMClient.prepare( + LLM.request({ + id: "req_tool_result_image_only", + model, + messages: [ + Message.assistant([ToolCallPart.make({ id: "call_1", name: "screenshot", input: {} })]), + Message.tool({ + id: "call_1", + name: "screenshot", + resultType: "content", + result: [{ type: "file", uri: "data:image/png;base64,AAECAw==", mime: "image/png" }], + }), + ], + }), + ) + + expect(expectToolOutput(prepared.body).output).toEqual([ + { type: "input_image", image_url: "data:image/png;base64,AAECAw==" }, + ]) + }), + ) + + it.effect("rejects non-image media in tool-result content with a clear error", () => + Effect.gen(function* () { + const error = yield* LLMClient.prepare( + LLM.request({ + id: "req_tool_result_unsupported_media", + model, + messages: [ + Message.assistant([ToolCallPart.make({ id: "call_1", name: "fetch", input: {} })]), + Message.tool({ + id: "call_1", + name: "fetch", + resultType: "content", + result: [{ type: "file", uri: "data:audio/mpeg;base64,AAECAw==", mime: "audio/mpeg" }], + }), + ], + }), + ).pipe(Effect.flip) + + expect(error.message).toContain("OpenAI Responses") + expect(error.message).toContain("audio/mpeg") + }), + ) + + it.effect("prepares the composed native continuation request", () => + Effect.gen(function* () { + const prepared = yield* LLMClient.prepare( + continuationRequest({ + id: "req_native_continuation_openai", + model, + features: nativeOpenAIResponsesContinuation, + }), + ) + + expect(prepared.body).toMatchObject({ + input: [ + { role: "system", content: "You are concise. Continue from the provided history." }, + { + role: "user", + content: [ + { type: "input_text", text: "What is shown here?" }, + { type: "input_image", image_url: "data:image/png;base64,AAECAw==" }, + ], + }, + { + type: "reasoning", + encrypted_content: "encrypted-continuation-state", + summary: [{ type: "summary_text", text: "I inspected the previous turn." }], + }, + { role: "assistant", content: [{ type: "output_text", text: "It shows a small test image." }] }, + { role: "user", content: [{ type: "input_text", text: "Check the weather in Paris before continuing." }] }, + { type: "function_call", call_id: "call_weather_1", name: "get_weather", arguments: '{"city":"Paris"}' }, + { type: "function_call_output", call_id: "call_weather_1", output: '{"temperature":22}' }, + { role: "assistant", content: [{ type: "output_text", text: "Paris is 22 degrees." }] }, + { + role: "user", + content: [{ type: "input_text", text: "Continue from this conversation in one short sentence." }], + }, + ], + include: ["reasoning.encrypted_content"], + store: false, + }) + expect(prepared.body.tools).toEqual([expect.objectContaining({ type: "function", name: "get_weather" })]) + }), + ) + + it.effect("maps OpenAI provider options to Responses options", () => + Effect.gen(function* () { + const prepared = yield* LLMClient.prepare( + LLM.request({ + model: OpenAI.configure({ baseURL: "https://api.openai.test/v1/", apiKey: "test" }).model("gpt-5.2"), + prompt: "think", + providerOptions: { + openai: { + promptCacheKey: "session_123", + reasoningEffort: "high", + reasoningSummary: "auto", + include: ["reasoning.encrypted_content"], + }, + }, + }), + ) + + expect(prepared.body.store).toBe(false) + expect(prepared.body.prompt_cache_key).toBe("session_123") + expect(prepared.body.include).toEqual(["reasoning.encrypted_content"]) + expect(prepared.body.reasoning).toEqual({ effort: "high", summary: "auto" }) + expect(prepared.body.text).toEqual({ verbosity: "low" }) + }), + ) + + it.effect("accepts the full ResponseIncludable union", () => + Effect.gen(function* () { + const prepared = yield* LLMClient.prepare( + LLM.request({ + model, + prompt: "hi", + providerOptions: { + openai: { + include: ["reasoning.encrypted_content", "code_interpreter_call.outputs", "web_search_call.results"], + }, + }, + }), + ) + + expect(prepared.body.include).toEqual([ + "reasoning.encrypted_content", + "code_interpreter_call.outputs", + "web_search_call.results", + ]) + }), + ) + + it.effect("filters unknown includable values out of the include array", () => + Effect.gen(function* () { + const prepared = yield* LLMClient.prepare( + LLM.request({ + model, + prompt: "hi", + // The user passed one invalid entry alongside a valid one. Keep the + // valid one so the request still succeeds rather than failing on a + // typo from upstream config. + providerOptions: { openai: { include: ["reasoning.encrypted_content", "bogus.thing"] } }, + }), + ) + + expect(prepared.body.include).toEqual(["reasoning.encrypted_content"]) + }), + ) + + it.effect("treats an explicit empty include as no include at all", () => + Effect.gen(function* () { + const prepared = yield* LLMClient.prepare( + LLM.request({ model, prompt: "hi", providerOptions: { openai: { include: [] } } }), + ) + + expect(prepared.body.include).toBeUndefined() + }), + ) + + it.effect("treats an all-invalid include as no include at all", () => + Effect.gen(function* () { + const prepared = yield* LLMClient.prepare( + LLM.request({ model, prompt: "hi", providerOptions: { openai: { include: ["bogus.thing"] } } }), + ) + + expect(prepared.body.include).toBeUndefined() + }), + ) + + it.effect("omits include when no include is set", () => + Effect.gen(function* () { + const prepared = yield* LLMClient.prepare( + LLM.request({ model, prompt: "hi", providerOptions: { openai: { store: false } } }), + ) + + expect(prepared.body.include).toBeUndefined() + }), + ) + + it.effect("requests encrypted reasoning by default for GPT-5 reasoning models", () => + Effect.gen(function* () { + // The native OpenAI facade configures GPT-5 stateless (store: false) with + // reasoningSummary: "auto" by default. Without `include`, a follow-up + // turn cannot replay reasoning state, so the facade also opts into + // `reasoning.encrypted_content` automatically. + const prepared = yield* LLMClient.prepare( + LLM.request({ + model: OpenAI.configure({ baseURL: "https://api.openai.test/v1/", apiKey: "test" }).responses("gpt-5.2"), + prompt: "hi", + }), + ) + + expect(prepared.body.store).toBe(false) + expect(prepared.body.include).toEqual(["reasoning.encrypted_content"]) + expect(prepared.body.reasoning).toEqual({ effort: "medium", summary: "auto" }) + }), + ) + + it.effect("lets callers opt out of the GPT-5 default include", () => + Effect.gen(function* () { + const prepared = yield* LLMClient.prepare( + LLM.request({ + model: OpenAI.configure({ baseURL: "https://api.openai.test/v1/", apiKey: "test" }).responses("gpt-5.2"), + prompt: "hi", + providerOptions: { openai: { include: [] } }, + }), + ) + + expect(prepared.body.include).toBeUndefined() + }), + ) + + it.effect("request OpenAI provider options override route defaults", () => + Effect.gen(function* () { + const prepared = yield* LLMClient.prepare( + LLM.request({ + model: OpenAI.configure({ + baseURL: "https://api.openai.test/v1/", + apiKey: "test", + providerOptions: { openai: { promptCacheKey: "model_cache" } }, + }).model("gpt-4.1-mini"), + prompt: "no cache", + providerOptions: { openai: { promptCacheKey: "request_cache" } }, + }), + ) + + expect(prepared.body.prompt_cache_key).toBe("request_cache") + }), + ) + + it.effect("parses text and usage stream fixtures", () => + Effect.gen(function* () { + const body = sseEvents( + { type: "response.output_text.delta", item_id: "msg_1", delta: "Hello" }, + { type: "response.output_text.delta", item_id: "msg_1", delta: "!" }, + { + type: "response.completed", + response: { + id: "resp_1", + service_tier: "default", + usage: { + input_tokens: 5, + output_tokens: 2, + total_tokens: 7, + input_tokens_details: { cached_tokens: 1 }, + output_tokens_details: { reasoning_tokens: 0 }, + }, + }, + }, + ) + const response = yield* LLMClient.generate(request).pipe(Effect.provide(fixedResponse(body))) + const usage = new Usage({ + inputTokens: 5, + outputTokens: 2, + nonCachedInputTokens: 4, + cacheReadInputTokens: 1, + reasoningTokens: 0, + totalTokens: 7, + providerMetadata: { + openai: { + input_tokens: 5, + output_tokens: 2, + total_tokens: 7, + input_tokens_details: { cached_tokens: 1 }, + output_tokens_details: { reasoning_tokens: 0 }, + }, + }, + }) + + expect(response.text).toBe("Hello!") + expect(response.events).toEqual([ + { type: "step-start", index: 0 }, + { type: "text-start", id: "msg_1" }, + { type: "text-delta", id: "msg_1", text: "Hello" }, + { type: "text-delta", id: "msg_1", text: "!" }, + { type: "text-end", id: "msg_1" }, + { + type: "step-finish", + index: 0, + reason: "stop", + providerMetadata: { openai: { responseId: "resp_1", serviceTier: "default" } }, + usage, + }, + { + type: "finish", + reason: "stop", + providerMetadata: { openai: { responseId: "resp_1", serviceTier: "default" } }, + usage, + }, + ]) + }), + ) + + it.effect("parses reasoning summary stream fixtures", () => + Effect.gen(function* () { + const body = sseEvents( + { type: "response.reasoning_summary_text.delta", item_id: "rs_1", delta: "thinking" }, + { type: "response.output_text.delta", item_id: "msg_1", delta: "Hello" }, + { type: "response.reasoning_summary_text.done", item_id: "rs_1" }, + { type: "response.completed", response: { id: "resp_1" } }, + ) + + const response = yield* LLMClient.generate(request).pipe(Effect.provide(fixedResponse(body))) + + expect(response.reasoning).toBe("thinking") + expect(response.text).toBe("Hello") + expect(response.events).toMatchObject([ + { type: "step-start", index: 0 }, + { type: "reasoning-start", id: "rs_1" }, + { type: "reasoning-delta", id: "rs_1", text: "thinking" }, + { type: "text-start", id: "msg_1" }, + { type: "text-delta", id: "msg_1", text: "Hello" }, + { type: "reasoning-end", id: "rs_1" }, + { type: "text-end", id: "msg_1" }, + { type: "step-finish", index: 0, reason: "stop" }, + { type: "finish", reason: "stop" }, + ]) + expect(response.events.filter((event) => event.type === "finish")).toHaveLength(1) + expect(response.message.content).toEqual([ + { type: "reasoning", text: "thinking" }, + { type: "text", text: "Hello" }, + ]) + }), + ) + + it.effect("preserves encrypted reasoning metadata for continuation", () => + Effect.gen(function* () { + const response = yield* LLMClient.generate(request).pipe( + Effect.provide( + fixedResponse( + sseEvents( + { type: "response.reasoning_summary_text.delta", item_id: "rs_1", delta: "thinking" }, + { + type: "response.output_item.done", + item: { + type: "reasoning", + id: "rs_1", + encrypted_content: "encrypted-state", + summary: [{ type: "summary_text", text: "thinking" }], + }, + }, + { type: "response.completed", response: { id: "resp_1" } }, + ), + ), + ), + ) + + expect(response.events).toContainEqual( + expect.objectContaining({ + type: "reasoning-end", + id: "rs_1", + providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } }, + }), + ) + }), + ) + + it.effect("streams each reasoning summary part as a separate block", () => + Effect.gen(function* () { + const response = yield* LLMClient.generate( + LLM.updateRequest(request, { providerOptions: { openai: { store: false } } }), + ).pipe( + Effect.provide( + fixedResponse( + sseEvents( + { + type: "response.output_item.added", + item: { type: "reasoning", id: "rs_1", encrypted_content: null }, + }, + { type: "response.reasoning_summary_part.added", item_id: "rs_1", summary_index: 0 }, + { type: "response.reasoning_summary_text.delta", item_id: "rs_1", summary_index: 0, delta: "First" }, + { type: "response.reasoning_summary_part.done", item_id: "rs_1", summary_index: 0 }, + { type: "response.reasoning_summary_part.added", item_id: "rs_1", summary_index: 1 }, + { type: "response.reasoning_summary_text.delta", item_id: "rs_1", summary_index: 1, delta: "Second" }, + { type: "response.reasoning_summary_part.done", item_id: "rs_1", summary_index: 1 }, + { + type: "response.output_item.done", + item: { type: "reasoning", id: "rs_1", encrypted_content: "encrypted-state" }, + }, + { type: "response.completed", response: { id: "resp_1" } }, + ), + ), + ), + ) + + expect(response.reasoning).toBe("FirstSecond") + expect(response.events).toMatchObject([ + { type: "step-start", index: 0 }, + { + type: "reasoning-start", + id: "rs_1:0", + providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: null } }, + }, + { type: "reasoning-delta", id: "rs_1:0", text: "First" }, + { type: "reasoning-end", id: "rs_1:0", providerMetadata: { openai: { itemId: "rs_1" } } }, + { + type: "reasoning-start", + id: "rs_1:1", + providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: null } }, + }, + { type: "reasoning-delta", id: "rs_1:1", text: "Second" }, + { + type: "reasoning-end", + id: "rs_1:1", + providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } }, + }, + { type: "step-finish", index: 0, reason: "stop" }, + { type: "finish", reason: "stop" }, + ]) + }), + ) + + it.effect("closes reasoning summary parts when storage is not disabled", () => + Effect.gen(function* () { + const response = yield* LLMClient.generate( + LLM.updateRequest(request, { providerOptions: { openai: { store: true } } }), + ).pipe( + Effect.provide( + fixedResponse( + sseEvents( + { + type: "response.output_item.added", + item: { type: "reasoning", id: "rs_1", encrypted_content: null }, + }, + { type: "response.reasoning_summary_part.added", item_id: "rs_1", summary_index: 0 }, + { type: "response.reasoning_summary_text.delta", item_id: "rs_1", summary_index: 0, delta: "First" }, + { type: "response.reasoning_summary_part.done", item_id: "rs_1", summary_index: 0 }, + { type: "response.reasoning_summary_part.added", item_id: "rs_1", summary_index: 1 }, + { type: "response.reasoning_summary_text.delta", item_id: "rs_1", summary_index: 1, delta: "Second" }, + { type: "response.reasoning_summary_part.done", item_id: "rs_1", summary_index: 1 }, + { + type: "response.output_item.done", + item: { type: "reasoning", id: "rs_1", encrypted_content: null }, + }, + { type: "response.completed", response: { id: "resp_1" } }, + ), + ), + ), + ) + + expect(response.events.filter((event) => event.type === "reasoning-end")).toEqual([ + { type: "reasoning-end", id: "rs_1:0", providerMetadata: { openai: { itemId: "rs_1" } } }, + { type: "reasoning-end", id: "rs_1:1", providerMetadata: { openai: { itemId: "rs_1" } } }, + ]) + }), + ) + + it.effect("continues a stateless reasoning conversation", () => + Effect.gen(function* () { + const response = yield* LLMClient.generate( + LLM.request({ + id: "req_reasoning_continue", + model, + messages: [ + Message.user("What changed?"), + Message.assistant([ + { + type: "reasoning", + text: "Checked the previous diff.", + providerMetadata: { + openai: { + itemId: "rs_1", + reasoningEncryptedContent: "encrypted-state", + }, + }, + }, + { type: "text", text: "The parser changed." }, + ]), + Message.user("Summarize it."), + ], + providerOptions: { openai: { store: false } }, + }), + ).pipe( + Effect.provide( + dynamicResponse((input) => + Effect.gen(function* () { + const web = yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie) + const body = yield* Effect.promise(() => web.json()) + expect(body).toMatchObject({ + input: [ + { role: "user", content: [{ type: "input_text", text: "What changed?" }] }, + { + type: "reasoning", + encrypted_content: "encrypted-state", + summary: [{ type: "summary_text", text: "Checked the previous diff." }], + }, + { role: "assistant", content: [{ type: "output_text", text: "The parser changed." }] }, + { role: "user", content: [{ type: "input_text", text: "Summarize it." }] }, + ], + }) + expect(body.input[1]).not.toHaveProperty("id") + return input.respond( + sseEvents( + { type: "response.output_text.delta", item_id: "msg_1", delta: "Parser now round-trips reasoning." }, + { type: "response.completed", response: { id: "resp_1" } }, + ), + { headers: { "content-type": "text/event-stream" } }, + ) + }), + ), + ), + ) + + expect(response.text).toBe("Parser now round-trips reasoning.") + }), + ) + + it.effect("preserves assistant content order around reasoning items", () => + Effect.gen(function* () { + const prepared = yield* LLMClient.prepare( + LLM.request({ + id: "req_reasoning_order", + model, + messages: [ + Message.assistant([ + { type: "text", text: "Before." }, + { + type: "reasoning", + text: "Checked order.", + providerMetadata: { + openai: { + itemId: "rs_1", + reasoningEncryptedContent: "encrypted-state", + }, + }, + }, + { type: "text", text: "After." }, + ]), + ], + providerOptions: { openai: { store: false } }, + }), + ) + + expect(prepared.body.input).toEqual([ + { role: "assistant", content: [{ type: "output_text", text: "Before." }] }, + { + type: "reasoning", + encrypted_content: "encrypted-state", + summary: [{ type: "summary_text", text: "Checked order." }], + }, + { role: "assistant", content: [{ type: "output_text", text: "After." }] }, + ]) + }), + ) + + it.effect("references stored reasoning items by id", () => + Effect.gen(function* () { + const prepared = yield* LLMClient.prepare( + LLM.request({ + model, + messages: [ + Message.assistant([ + { + type: "reasoning", + text: "Checked the previous diff.", + providerMetadata: { openai: { itemId: "rs_1" } }, + }, + ]), + ], + providerOptions: { openai: { store: true } }, + }), + ) + + expect(prepared.body.input).toEqual([{ type: "item_reference", id: "rs_1" }]) + }), + ) + + it.effect("references stored provider-executed hosted tool results by id", () => + Effect.gen(function* () { + const prepared = yield* LLMClient.prepare( + LLM.request({ + model, + messages: [ + Message.assistant([ + ToolCallPart.make({ + id: "ws_1", + name: "web_search", + input: { query: "effect 4" }, + providerExecuted: true, + providerMetadata: { openai: { itemId: "ws_1" } }, + }), + { + type: "tool-result", + id: "ws_1", + name: "web_search", + result: { type: "json", value: { type: "web_search_call", id: "ws_1", status: "completed" } }, + providerExecuted: true, + providerMetadata: { openai: { itemId: "ws_1" } }, + }, + ]), + Message.user("Continue."), + ], + providerOptions: { openai: { store: true } }, + }), + ) + + expect(prepared.body.input).toEqual([ + { type: "item_reference", id: "ws_1" }, + { role: "user", content: [{ type: "input_text", text: "Continue." }] }, + ]) + }), + ) + + it.effect("joins streamed summary blocks into one continuation reasoning item", () => + Effect.gen(function* () { + const prepared = yield* LLMClient.prepare( + LLM.request({ + id: "req_multi_summary_continuation", + model, + messages: [ + Message.assistant([ + { + type: "reasoning", + text: "First", + providerMetadata: { openai: { itemId: "rs_1" } }, + }, + { + type: "reasoning", + text: "Second", + providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } }, + }, + ]), + ], + providerOptions: { openai: { store: false } }, + }), + ) + + expect(prepared.body.input).toEqual([ + { + type: "reasoning", + encrypted_content: "encrypted-state", + summary: [ + { type: "summary_text", text: "First" }, + { type: "summary_text", text: "Second" }, + ], + }, + ]) + }), + ) + + it.effect("skips non-persisted reasoning ids without encrypted state", () => + Effect.gen(function* () { + const prepared = yield* LLMClient.prepare( + LLM.request({ + id: "req_reasoning_without_encrypted_state", + model, + messages: [ + Message.user("What changed?"), + Message.assistant([ + { + type: "reasoning", + text: "Checked the previous diff.", + providerMetadata: { + openai: { + itemId: "rs_1", + reasoningEncryptedContent: null, + }, + }, + }, + { type: "text", text: "The parser changed." }, + ]), + Message.user("Summarize it."), + ], + providerOptions: { openai: { store: false } }, + }), + ) + + expect(prepared.body).toMatchObject({ + input: [ + { role: "user", content: [{ type: "input_text", text: "What changed?" }] }, + { role: "assistant", content: [{ type: "output_text", text: "The parser changed." }] }, + { role: "user", content: [{ type: "input_text", text: "Summarize it." }] }, + ], + store: false, + }) + }), + ) + + it.effect("assembles streamed function call input", () => + Effect.gen(function* () { + const body = sseEvents( + { + type: "response.output_item.added", + item: { type: "function_call", id: "item_1", call_id: "call_1", name: "lookup", arguments: "" }, + }, + { type: "response.function_call_arguments.delta", item_id: "item_1", delta: '{"query"' }, + { type: "response.function_call_arguments.delta", item_id: "item_1", delta: ':"weather"}' }, + { + type: "response.output_item.done", + item: { + type: "function_call", + id: "item_1", + call_id: "call_1", + name: "lookup", + arguments: '{"query":"weather"}', + }, + }, + { type: "response.completed", response: { usage: { input_tokens: 5, output_tokens: 1 } } }, + ) + const response = yield* LLMClient.generate( + LLM.updateRequest(request, { + tools: [{ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } }], + }), + ).pipe(Effect.provide(fixedResponse(body))) + const usage = new Usage({ + inputTokens: 5, + outputTokens: 1, + nonCachedInputTokens: 5, + cacheReadInputTokens: undefined, + reasoningTokens: undefined, + totalTokens: 6, + providerMetadata: { openai: { input_tokens: 5, output_tokens: 1 } }, + }) + + expect(response.events).toEqual([ + { type: "step-start", index: 0 }, + { + type: "tool-input-start", + id: "call_1", + name: "lookup", + providerMetadata: { openai: { itemId: "item_1" } }, + }, + { + type: "tool-input-delta", + id: "call_1", + name: "lookup", + text: '{"query"', + }, + { + type: "tool-input-delta", + id: "call_1", + name: "lookup", + text: ':"weather"}', + }, + { + type: "tool-input-end", + id: "call_1", + name: "lookup", + providerMetadata: { openai: { itemId: "item_1" } }, + }, + { + type: "tool-call", + id: "call_1", + name: "lookup", + input: { query: "weather" }, + providerExecuted: undefined, + providerMetadata: { openai: { itemId: "item_1" } }, + }, + { type: "step-finish", index: 0, reason: "tool-calls", usage, providerMetadata: undefined }, + { + type: "finish", + reason: "tool-calls", + providerMetadata: undefined, + usage, + }, + ]) + }), + ) + + it.effect("decodes web_search_call as provider-executed tool-call + tool-result", () => + Effect.gen(function* () { + const item = { + type: "web_search_call", + id: "ws_1", + status: "completed", + action: { type: "search", query: "effect 4" }, + } + const body = sseEvents( + { type: "response.output_item.added", item }, + { type: "response.output_item.done", item }, + { type: "response.completed", response: { usage: { input_tokens: 5, output_tokens: 1 } } }, + ) + const response = yield* LLMClient.generate(request).pipe(Effect.provide(fixedResponse(body))) + + const callsAndResults = response.events.filter( + (event) => event.type === "tool-call" || event.type === "tool-result", + ) + expect(callsAndResults).toEqual([ + { + type: "tool-call", + id: "ws_1", + name: "web_search", + input: { type: "search", query: "effect 4" }, + providerExecuted: true, + providerMetadata: { openai: { itemId: "ws_1" } }, + }, + { + type: "tool-result", + id: "ws_1", + name: "web_search", + result: { type: "json", value: item }, + providerExecuted: true, + providerMetadata: { openai: { itemId: "ws_1" } }, + }, + ]) + }), + ) + + it.effect("decodes code_interpreter_call as provider-executed events with code input", () => + Effect.gen(function* () { + const item = { + type: "code_interpreter_call", + id: "ci_1", + status: "completed", + code: "print(1+1)", + container_id: "cnt_xyz", + outputs: [{ type: "logs", logs: "2\n" }], + } + const body = sseEvents( + { type: "response.output_item.done", item }, + { type: "response.completed", response: { usage: { input_tokens: 5, output_tokens: 1 } } }, + ) + const response = yield* LLMClient.generate(request).pipe(Effect.provide(fixedResponse(body))) + + const toolCall = response.events.find((event) => event.type === "tool-call") + expect(toolCall).toEqual({ + type: "tool-call", + id: "ci_1", + name: "code_interpreter", + input: { code: "print(1+1)", container_id: "cnt_xyz" }, + providerExecuted: true, + providerMetadata: { openai: { itemId: "ci_1" } }, + }) + const toolResult = response.events.find((event) => event.type === "tool-result") + expect(toolResult).toEqual({ + type: "tool-result", + id: "ci_1", + name: "code_interpreter", + result: { type: "json", value: item }, + providerExecuted: true, + providerMetadata: { openai: { itemId: "ci_1" } }, + }) + }), + ) + + it.effect("lowers user image content", () => + Effect.gen(function* () { + const prepared = yield* LLMClient.prepare( + LLM.request({ + id: "req_media", + model, + messages: [Message.user({ type: "media", mediaType: "image/png", data: "AAECAw==" })], + }), + ) + + expect(prepared.body.input).toEqual([ + { + role: "user", + content: [{ type: "input_image", image_url: "data:image/png;base64,AAECAw==" }], + }, + ]) + }), + ) + + it.effect("rejects unsupported user media content", () => + Effect.gen(function* () { + const error = yield* LLMClient.prepare( + LLM.request({ + id: "req_media", + model, + messages: [Message.user({ type: "media", mediaType: "application/pdf", data: "AAECAw==" })], + }), + ).pipe(Effect.flip) + + expect(error.message).toContain("OpenAI Responses does not support media type application/pdf") + }), + ) + + it.effect("emits provider-error events for mid-stream provider errors", () => + Effect.gen(function* () { + const response = yield* LLMClient.generate(request).pipe( + Effect.provide(fixedResponse(sseEvents({ type: "error", code: "rate_limit_exceeded", message: "Slow down" }))), + ) + + // Prefix the code so consumers see the failure mode, not just the + // sometimes-generic provider message. The bare message alone meant + // production errors like rate limits were indistinguishable from + // unrelated stream failures. + expect(response.events).toEqual([{ type: "provider-error", message: "rate_limit_exceeded: Slow down" }]) + }), + ) + + it.effect("falls back to error code when no message is present", () => + Effect.gen(function* () { + const response = yield* LLMClient.generate(request).pipe( + Effect.provide(fixedResponse(sseEvents({ type: "error", code: "internal_error" }))), + ) + + expect(response.events).toEqual([{ type: "provider-error", message: "internal_error" }]) + }), + ) + + it.effect("falls back to error code when message is empty", () => + Effect.gen(function* () { + const response = yield* LLMClient.generate(request).pipe( + Effect.provide(fixedResponse(sseEvents({ type: "error", code: "internal_error", message: "" }))), + ) + + expect(response.events).toEqual([{ type: "provider-error", message: "internal_error" }]) + }), + ) + + // Regression: `response.failed` carries the failure details under + // `response.error`, not at the top level. The previous handler only + // checked top-level `message`/`code` and so always emitted the bare + // "OpenAI Responses response failed" string, hiding the real cause. + it.effect("surfaces response.failed details from response.error", () => + Effect.gen(function* () { + const response = yield* LLMClient.generate(request).pipe( + Effect.provide( + fixedResponse( + sseEvents({ + type: "response.failed", + response: { + id: "resp_failed_1", + error: { code: "server_error", message: "Upstream model unavailable" }, + }, + }), + ), + ), + ) + + expect(response.events).toEqual([{ type: "provider-error", message: "server_error: Upstream model unavailable" }]) + }), + ) + + it.effect("surfaces response.failed code when no nested message is present", () => + Effect.gen(function* () { + const response = yield* LLMClient.generate(request).pipe( + Effect.provide( + fixedResponse( + sseEvents({ + type: "response.failed", + response: { id: "resp_failed_2", error: { code: "invalid_prompt" } }, + }), + ), + ), + ) + + expect(response.events).toEqual([{ type: "provider-error", message: "invalid_prompt" }]) + }), + ) + + it.effect("surfaces error event details even when they arrive nested under response.error", () => + Effect.gen(function* () { + // Some OpenAI-compatible proxies and older SDK versions wrap the + // top-level error fields into a nested `response.error` payload + // when they bubble up an HTTP error as an SSE `error` event. Honour + // both shapes so the user still sees the underlying cause instead + // of the catch-all string. + const response = yield* LLMClient.generate(request).pipe( + Effect.provide( + fixedResponse( + sseEvents({ + type: "error", + response: { error: { code: "context_length_exceeded", message: "prompt too long" } }, + }), + ), + ), + ) + + expect(response.events).toEqual([ + { + type: "provider-error", + message: "context_length_exceeded: prompt too long", + classification: "context-overflow", + }, + ]) + }), + ) + + it.effect("falls back to a stable default when both error and response are absent", () => + Effect.gen(function* () { + const response = yield* LLMClient.generate(request).pipe( + Effect.provide(fixedResponse(sseEvents({ type: "error" }))), + ) + + expect(response.events).toEqual([{ type: "provider-error", message: "OpenAI Responses stream error" }]) + }), + ) + + it.effect("falls back to a stable default when response.failed has no error payload", () => + Effect.gen(function* () { + const response = yield* LLMClient.generate(request).pipe( + Effect.provide(fixedResponse(sseEvents({ type: "response.failed", response: { id: "resp_failed_3" } }))), + ) + + expect(response.events).toEqual([{ type: "provider-error", message: "OpenAI Responses response failed" }]) + }), + ) + + it.effect("fails HTTP provider errors before stream parsing", () => + Effect.gen(function* () { + const error = yield* LLMClient.generate(request).pipe( + Effect.provide( + fixedResponse('{"error":{"type":"invalid_request_error","message":"Bad request"}}', { + status: 400, + headers: { "content-type": "application/json" }, + }), + ), + Effect.flip, + ) + + expect(error).toBeInstanceOf(LLMError) + expect(error.reason).toMatchObject({ _tag: "InvalidRequest" }) + expect(error.message).toContain("HTTP 400") + }), + ) +}) diff --git a/packages/llm/test/provider/openrouter.test.ts b/packages/llm/test/provider/openrouter.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..86d1317b3e64f65f21cfc067828eb5133eebcbe5 --- /dev/null +++ b/packages/llm/test/provider/openrouter.test.ts @@ -0,0 +1,56 @@ +import { describe, expect } from "bun:test" +import { Effect } from "effect" +import { LLM } from "../../src" +import { LLMClient } from "../../src/route" +import * as OpenRouter from "../../src/providers/openrouter" +import { it } from "../lib/effect" + +describe("OpenRouter", () => { + it.effect("prepares OpenRouter models through the OpenAI-compatible Chat route", () => + Effect.gen(function* () { + const model = OpenRouter.configure({ apiKey: "test-key" }).model("openai/gpt-4o-mini") + + expect(model).toMatchObject({ + id: "openai/gpt-4o-mini", + provider: "openrouter", + route: { id: "openrouter" }, + }) + expect(model.route.endpoint.baseURL).toBe("https://openrouter.ai/api/v1") + + const prepared = yield* LLMClient.prepare(LLM.request({ model, prompt: "Say hello." })) + + expect(prepared.route).toBe("openrouter") + expect(prepared.body).toMatchObject({ + model: "openai/gpt-4o-mini", + messages: [{ role: "user", content: "Say hello." }], + stream: true, + }) + }), + ) + + it.effect("applies OpenRouter payload options from the model helper", () => + Effect.gen(function* () { + const prepared = yield* LLMClient.prepare( + LLM.request({ + model: OpenRouter.configure({ + apiKey: "test-key", + providerOptions: { + openrouter: { + usage: true, + reasoning: { effort: "high" }, + promptCacheKey: "session_123", + }, + }, + }).model("anthropic/claude-3.7-sonnet:thinking"), + prompt: "Think briefly.", + }), + ) + + expect(prepared.body).toMatchObject({ + usage: { include: true }, + reasoning: { effort: "high" }, + prompt_cache_key: "session_123", + }) + }), + ) +}) diff --git a/packages/llm/test/recorded-golden.ts b/packages/llm/test/recorded-golden.ts new file mode 100644 index 0000000000000000000000000000000000000000..540662b2993ec294ae4b757f85eeabcfa2766180 --- /dev/null +++ b/packages/llm/test/recorded-golden.ts @@ -0,0 +1,97 @@ +import type { HttpRecorder } from "@opencode-ai/http-recorder" +import { describe } from "bun:test" +import { Effect } from "effect" +import type { Model } from "../src" +import { goldenScenarioTags, goldenScenarioTitle, runGoldenScenario, type GoldenScenarioID } from "./recorded-scenarios" +import { recordedTests } from "./recorded-test" +import { kebab } from "./recorded-utils" + +type Transport = "http" | "websocket" + +type ScenarioInput = + | GoldenScenarioID + | { + readonly id: GoldenScenarioID + readonly name?: string + readonly cassette?: string + readonly tags?: ReadonlyArray + readonly maxTokens?: number + readonly temperature?: number | false + readonly timeout?: number + } + +type TargetInput = { + readonly name: string + readonly model: Model + readonly protocol?: string + readonly requires?: ReadonlyArray + readonly transport?: Transport + readonly prefix?: string + readonly tags?: ReadonlyArray + readonly metadata?: Record + readonly options?: HttpRecorder.RecorderOptions + readonly scenarios: ReadonlyArray +} + +const scenarioInput = (input: ScenarioInput) => (typeof input === "string" ? { id: input } : input) + +const defaultPrefix = (target: TargetInput) => { + if (target.prefix) return target.prefix + const transport = target.transport === "websocket" ? "-websocket" : "" + return `${target.model.provider}-${target.protocol ?? target.model.route.id}${transport}` +} + +const metadata = (target: TargetInput) => ({ + provider: target.model.provider, + protocol: target.protocol, + route: target.model.route.id, + transport: target.transport ?? "http", + model: target.model.id, + ...target.metadata, +}) + +const tags = (target: TargetInput) => [ + ...(target.transport === "websocket" ? ["transport:websocket"] : []), + ...(target.tags ?? []), +] + +const runTarget = (target: TargetInput) => { + const recorded = recordedTests({ + prefix: defaultPrefix(target), + provider: target.model.provider, + protocol: target.protocol, + requires: target.requires, + tags: tags(target), + metadata: metadata(target), + options: target.options, + }) + + describe(`${target.name} recorded`, () => { + target.scenarios.forEach((raw) => { + const input = scenarioInput(raw) + const name = input.name ?? goldenScenarioTitle(input.id) + recorded.effect.with( + name, + { + cassette: input.cassette, + id: `${kebab(target.name)}-${input.id}`, + tags: [...goldenScenarioTags(input.id), ...(input.tags ?? [])], + }, + () => + Effect.gen(function* () { + yield* runGoldenScenario(input.id, { + id: `recorded_${kebab(target.name).replaceAll("-", "_")}_${input.id.replaceAll("-", "_")}`, + model: target.model, + maxTokens: input.maxTokens, + temperature: input.temperature, + }) + }), + input.timeout, + ) + }) + }) +} + +export const describeRecordedGoldenScenarios = (targets: ReadonlyArray) => { + targets.forEach(runTarget) +} diff --git a/packages/llm/test/recorded-runner.ts b/packages/llm/test/recorded-runner.ts new file mode 100644 index 0000000000000000000000000000000000000000..97d9b03f54624ab89288051283ed9c9e7d84d735 --- /dev/null +++ b/packages/llm/test/recorded-runner.ts @@ -0,0 +1,100 @@ +import { test, type TestOptions } from "bun:test" +import { Effect, type Layer } from "effect" +import { testEffect } from "./lib/effect" +import { cassetteName, classifiedTags, matchesSelected, missingEnv, unique } from "./recorded-utils" + +export type RecordedBody = Effect.Effect | (() => Effect.Effect) + +export type RecordedGroupOptions = { + readonly prefix: string + readonly provider?: string + readonly protocol?: string + readonly requires?: ReadonlyArray + readonly tags?: ReadonlyArray + readonly metadata?: Record +} + +export type RecordedCaseOptions = { + readonly cassette?: string + readonly id?: string + readonly provider?: string + readonly protocol?: string + readonly requires?: ReadonlyArray + readonly tags?: ReadonlyArray + readonly metadata?: Record +} + +export const recordedEffectGroup = < + R, + E, + Options extends RecordedGroupOptions, + CaseOptions extends RecordedCaseOptions, +>(input: { + readonly duplicateLabel: string + readonly options: Options + readonly cassetteExists: (cassette: string) => boolean + readonly layer: (input: { + readonly cassette: string + readonly tags: ReadonlyArray + readonly metadata: Record + readonly recording: boolean + readonly options: Options + readonly caseOptions: CaseOptions + }) => Layer.Layer +}) => { + const cassettes = new Set() + + const run = ( + name: string, + caseOptions: CaseOptions, + body: RecordedBody, + testOptions?: number | TestOptions, + ) => { + const cassette = cassetteName(input.options.prefix, name, caseOptions) + if (cassettes.has(cassette)) throw new Error(`Duplicate ${input.duplicateLabel} "${cassette}"`) + cassettes.add(cassette) + const tags = unique([ + ...classifiedTags(input.options), + ...classifiedTags({ + provider: caseOptions.provider, + protocol: caseOptions.protocol, + tags: caseOptions.tags, + }), + ]) + + if (!matchesSelected({ prefix: input.options.prefix, name, cassette, tags })) + return test.skip(name, () => {}, testOptions) + + const recording = process.env.RECORD === "true" + if (recording) { + if (missingEnv([...(input.options.requires ?? []), ...(caseOptions.requires ?? [])]).length > 0) { + return test.skip(name, () => {}, testOptions) + } + } else if (!input.cassetteExists(cassette)) { + return test.skip(name, () => {}, testOptions) + } + + return testEffect( + input.layer({ + cassette, + tags, + metadata: { ...input.options.metadata, ...caseOptions.metadata, tags }, + recording, + options: input.options, + caseOptions, + }), + ).live(name, body, testOptions) + } + + const effect = (name: string, body: RecordedBody, testOptions?: number | TestOptions) => + run(name, {} as CaseOptions, body, testOptions) + + effect.with = ( + name: string, + caseOptions: CaseOptions, + body: RecordedBody, + testOptions?: number | TestOptions, + ) => run(name, caseOptions, body, testOptions) + + return { effect } +} diff --git a/packages/llm/test/recorded-scenarios.ts b/packages/llm/test/recorded-scenarios.ts new file mode 100644 index 0000000000000000000000000000000000000000..cac348da659010055f9cf9ef3da2551a96a6e321 --- /dev/null +++ b/packages/llm/test/recorded-scenarios.ts @@ -0,0 +1,531 @@ +import { expect } from "bun:test" +import { Effect, Schema } from "effect" +import { + LLM, + LLMEvent, + LLMResponse, + Message, + ToolRuntime, + ToolChoice, + ToolDefinition, + toDefinitions, + type ContentPart, + type FinishReason, + type LLMRequest, + type Model, +} from "../src" +import { LLMClient } from "../src/route" +import { Tool } from "../src/tool" + +export const weatherToolName = "get_weather" + +// A deterministic system prompt long enough to clear every supported provider's +// minimum cacheable-prefix threshold (Anthropic Haiku 3.5: 2048 tokens; Anthropic +// Opus/Haiku 4.5: 4096 tokens; OpenAI/Gemini/Bedrock: lower). Built by repeating +// a fixed sentence — the cassette replays bit-for-bit, so the exact text matters +// only when re-recording with `RECORD=true`. +export const LARGE_CACHEABLE_SYSTEM = (() => { + const sentence = "You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. " + // ~100 chars per sentence × 250 repeats ≈ 25,000 chars ≈ 5k+ tokens, safely + // above every provider's threshold. + return sentence.repeat(250) +})() + +export const weatherTool = ToolDefinition.make({ + name: weatherToolName, + description: "Get current weather for a city.", + inputSchema: { + type: "object", + properties: { city: { type: "string" } }, + required: ["city"], + additionalProperties: false, + }, +}) + +export const weatherRuntimeTool = Tool.make({ + description: weatherTool.description, + parameters: Schema.Struct({ city: Schema.String }), + success: Schema.Struct({ temperature: Schema.Number, condition: Schema.String }), + execute: ({ city }) => + Effect.succeed( + city === "Paris" ? { temperature: 22, condition: "sunny" } : { temperature: 0, condition: "unknown" }, + ), +}) + +export const weatherToolLoopRequest = (input: { + readonly id: string + readonly model: Model + readonly system?: string + readonly maxTokens?: number + readonly temperature?: number | false +}) => + LLM.request({ + id: input.id, + model: input.model, + system: input.system ?? "Use the get_weather tool, then answer in one short sentence.", + prompt: "What is the weather in Paris?", + cache: "none", + generation: + input.temperature === false + ? { maxTokens: input.maxTokens ?? 80 } + : { maxTokens: input.maxTokens ?? 80, temperature: input.temperature ?? 0 }, + }) + +export const goldenWeatherToolLoopRequest = (input: { + readonly id: string + readonly model: Model + readonly maxTokens?: number + readonly temperature?: number | false +}) => + weatherToolLoopRequest({ + ...input, + system: "Use the get_weather tool exactly once. After the tool result, reply exactly: Paris is sunny.", + }) + +const RESTROOM_IMAGE_TEXT = "jiggling restroom prison" +const restroomImage = () => + Effect.promise(() => Bun.file(new URL("./fixtures/media/restroom.png", import.meta.url)).bytes()).pipe( + Effect.map((bytes) => Buffer.from(bytes).toString("base64")), + ) + +export const runWeatherToolLoop = (request: LLMRequest) => + Effect.gen(function* () { + const tools = { [weatherToolName]: weatherRuntimeTool } + let next = LLM.updateRequest(request, { tools: toDefinitions(tools) }) + const events: LLMEvent[] = [] + + for (let step = 0; step < 10; step++) { + const response = yield* LLMClient.generate(next) + events.push(...response.events.filter((event) => event.type !== "finish")) + const calls = response.events.filter(LLMEvent.is.toolCall).filter((call) => !call.providerExecuted) + if (calls.length === 0) { + const finish = response.events.find(LLMEvent.is.finish) + if (finish) events.push(finish) + return events + } + + const dispatched = yield* Effect.forEach(calls, (call) => + ToolRuntime.dispatch(tools, call).pipe(Effect.map((result) => [call, result] as const)), + ) + events.push(...dispatched.flatMap(([, result]) => result.events)) + next = LLM.updateRequest(next, { + messages: [ + ...next.messages, + Message.assistant(assistantContent(response.events)), + ...dispatched.map(([call, result]) => Message.tool({ id: call.id, name: call.name, result: result.result })), + ], + }) + } + + throw new Error("Weather tool loop exceeded 10 steps") + }) + +const assistantContent = (events: ReadonlyArray) => { + const content: ContentPart[] = [] + for (const event of events) { + if (event.type === "text-delta" || event.type === "reasoning-delta") { + const type = event.type === "text-delta" ? "text" : "reasoning" + const last = content.at(-1) + if (last?.type === type) { + content[content.length - 1] = { ...last, text: `${last.text}${event.text}` } + } else { + content.push({ type, text: event.text }) + } + continue + } + if (event.type === "text-end" || event.type === "reasoning-end") { + const type = event.type === "text-end" ? "text" : "reasoning" + const last = content.at(-1) + if (last?.type === type) content[content.length - 1] = { ...last, providerMetadata: event.providerMetadata } + continue + } + if (event.type === "tool-call") content.push(event) + } + return content +} + +export const expectFinish = ( + events: ReadonlyArray, + reason: Extract["reason"], +) => expect(events.at(-1)).toMatchObject({ type: "finish", reason }) + +export const expectWeatherToolCall = (response: LLMResponse) => + expect(response.toolCalls).toMatchObject([ + { type: "tool-call", id: expect.any(String), name: weatherToolName, input: { city: "Paris" } }, + ]) + +export const expectWeatherToolLoop = (events: ReadonlyArray) => { + const finishes = events.filter(LLMEvent.is.finish) + expect(finishes).toHaveLength(1) + expect(finishes[0]?.reason).toBe("stop") + + const stepFinishes = events.filter(LLMEvent.is.stepFinish) + expect(stepFinishes.map((event) => event.reason)).toEqual(["tool-calls", "stop"]) + + const toolCalls = events.filter(LLMEvent.is.toolCall) + expect(toolCalls).toHaveLength(1) + expect(toolCalls[0]).toMatchObject({ type: "tool-call", name: weatherToolName, input: { city: "Paris" } }) + + const toolResults = events.filter(LLMEvent.is.toolResult) + expect(toolResults).toHaveLength(1) + expect(toolResults[0]).toMatchObject({ + type: "tool-result", + name: weatherToolName, + result: { type: "json", value: { temperature: 22, condition: "sunny" } }, + }) + + const output = LLMResponse.text({ events }) + expect(output).toContain("Paris") + expect(output.trim().length).toBeGreaterThan(0) +} + +export const expectGoldenWeatherToolLoop = (events: ReadonlyArray) => { + expectWeatherToolLoop(events) + expect(LLMResponse.text({ events }).trim()).toMatch(/^Paris is sunny\.?$/) +} + +export interface GoldenScenarioContext { + readonly id: string + readonly model: Model + readonly maxTokens?: number + readonly temperature?: number | false +} + +const generate = (request: LLMRequest) => LLMClient.generate(request) + +const generation = (context: GoldenScenarioContext, maxTokens: number) => + context.temperature === false ? { maxTokens } : { maxTokens, temperature: context.temperature ?? 0 } + +const normalizeImageText = (value: string) => + value + .toLowerCase() + .replace(/[^a-z\s]/g, "") + .replace(/\s+/g, " ") + .trim() + +const encryptedReasoningOptions = { + openai: { + store: false, + include: ["reasoning.encrypted_content"], + reasoningEffort: "low", + reasoningSummary: "auto", + }, +} as const + +type AssistantTextExpectation = string | RegExp + +type UserStep = { readonly type: "user"; readonly content: Message.ContentInput } +type AssistantStep = { + readonly type: "assistant" + readonly text?: AssistantTextExpectation + readonly toolCall?: { readonly name: string; readonly input: unknown } + readonly reasoning?: "openai-encrypted" + readonly id?: string + readonly system?: string + readonly maxTokens?: number + readonly finish?: FinishReason + readonly tools?: LLM.RequestInput["tools"] + readonly toolChoice?: LLM.RequestInput["toolChoice"] + readonly providerOptions?: LLMRequest["providerOptions"] + readonly assert?: (response: LLMResponse) => void +} +type ConversationStep = UserStep | AssistantStep + +const user = (content: Message.ContentInput): ConversationStep => ({ type: "user", content }) + +const assistant = { + expectText: ( + text: AssistantTextExpectation, + options?: Omit, + ): ConversationStep => ({ type: "assistant", text, ...options }), + expectToolCall: ( + name: string, + input: unknown, + options?: Omit, + ): ConversationStep => ({ type: "assistant", toolCall: { name, input }, finish: "tool-calls", ...options }), + expectEncryptedReasoningText: ( + text: AssistantTextExpectation, + options?: Omit, + ): ConversationStep => ({ + type: "assistant", + text, + reasoning: "openai-encrypted", + providerOptions: encryptedReasoningOptions, + ...options, + }), +} + +const assertAssistantText = (actual: string, expected: AssistantTextExpectation) => { + if (typeof expected === "string") { + expect(actual.trim()).toBe(expected) + return + } + expect(actual.trim()).toMatch(expected) +} + +const assertAssistantToolCall = (response: LLMResponse, expected: NonNullable) => { + expect(response.toolCalls).toMatchObject([ + { type: "tool-call", id: expect.any(String), name: expected.name, input: expected.input }, + ]) +} + +// The generated golden scenarios only model one assistant shape at a time: +// encrypted reasoning + text, text, or tool call. Keep mixed interleavings in +// focused protocol tests where event order can be asserted directly. +const assistantMessageFromResponse = (response: LLMResponse, step: AssistantStep) => { + const content: ContentPart[] = [] + if (step.reasoning === "openai-encrypted") { + const reasoning = response.events.find( + (event): event is Extract => + LLMEvent.is.reasoningEnd(event) && typeof event.providerMetadata?.openai?.itemId === "string", + ) + if (!reasoning) throw new Error("OpenAI Responses did not return reasoning metadata") + expect(reasoning.providerMetadata?.openai?.reasoningEncryptedContent).toEqual(expect.any(String)) + content.push({ type: "reasoning", text: response.reasoning, providerMetadata: reasoning.providerMetadata }) + } + + if (response.text.length > 0) content.push({ type: "text", text: response.text }) + content.push(...response.toolCalls) + return Message.assistant(content) +} + +const runGeneratedConversation = (context: GoldenScenarioContext, steps: ReadonlyArray) => + Effect.gen(function* () { + const messages: Message[] = [] + let generated = 0 + for (const step of steps) { + if (step.type === "user") { + messages.push(Message.user(step.content)) + continue + } + + generated += 1 + const response = yield* generate( + LLM.request({ + id: step.id ? `${context.id}_${step.id}` : `${context.id}_${generated}`, + model: context.model, + system: step.system, + cache: "none", + messages, + tools: step.tools, + toolChoice: step.toolChoice, + providerOptions: step.providerOptions, + generation: generation(context, step.maxTokens ?? context.maxTokens ?? 80), + }), + ) + if (step.text !== undefined) assertAssistantText(response.text, step.text) + if (step.toolCall) assertAssistantToolCall(response, step.toolCall) + step.assert?.(response) + expectFinish(response.events, step.finish ?? "stop") + messages.push(assistantMessageFromResponse(response, step)) + } + }) + +const runTextScenario = (context: GoldenScenarioContext) => + runGeneratedConversation(context, [ + user("Reply exactly with: Hello!"), + assistant.expectText(/^Hello!?$/, { + system: "You are concise.", + maxTokens: context.maxTokens ?? 40, + providerOptions: + context.model.route.id === "gemini" ? { gemini: { thinkingConfig: { thinkingBudget: 0 } } } : undefined, + }), + ]) + +const runToolCallScenario = (context: GoldenScenarioContext) => + runGeneratedConversation(context, [ + user("Call get_weather with city exactly Paris."), + assistant.expectToolCall( + weatherToolName, + { city: "Paris" }, + { + system: "Call tools exactly as requested.", + tools: [weatherTool], + toolChoice: ToolChoice.make(weatherTool), + maxTokens: context.maxTokens ?? 80, + }, + ), + ]) + +const runImageScenario = (context: GoldenScenarioContext) => + Effect.gen(function* () { + yield* runGeneratedConversation(context, [ + user([ + { + type: "text", + text: "The image contains exactly three lowercase English words. Read them left to right and reply with only those words.", + }, + { type: "media", mediaType: "image/png", data: yield* restroomImage() }, + ]), + assistant.expectText(/.+/, { + system: "Read images carefully. Reply only with the visible text.", + maxTokens: context.maxTokens ?? 20, + assert: (response) => expect(normalizeImageText(response.text)).toBe(RESTROOM_IMAGE_TEXT), + }), + ]) + }) + +// Reproduces a tool-result image round trip: a tool returns image bytes, and +// the next model turn must receive provider-native image content instead of a +// JSON-stringified base64 blob. +const screenshotToolName = "read_screenshot" +const runImageToolResultScenario = (context: GoldenScenarioContext) => + Effect.gen(function* () { + const image = yield* restroomImage() + const response = yield* generate( + LLM.request({ + id: `${context.id}_image_tool_result`, + model: context.model, + system: "Read images carefully. Reply only with the visible text, lowercase, no punctuation.", + cache: "none", + generation: generation(context, context.maxTokens ?? 40), + messages: [ + Message.user("Use the read_screenshot tool, then reply with the words shown."), + Message.assistant([{ type: "tool-call", id: "call_screenshot_1", name: screenshotToolName, input: {} }]), + Message.tool({ + id: "call_screenshot_1", + name: screenshotToolName, + resultType: "content", + result: [ + { type: "text", text: "Image read successfully" }, + { type: "file", uri: `data:image/png;base64,${image}`, mime: "image/png" }, + ], + }), + ], + tools: [ + ToolDefinition.make({ + name: screenshotToolName, + description: "Capture a screenshot of the current screen.", + inputSchema: { type: "object", properties: {}, additionalProperties: false }, + }), + ], + }), + ) + + expectFinish(response.events, "stop") + expect(normalizeImageText(response.text)).toBe(RESTROOM_IMAGE_TEXT) + }) + +const runReasoningScenario = (context: GoldenScenarioContext) => + runGeneratedConversation(context, [ + user("Think briefly, then reply exactly with: Hello!"), + assistant.expectText(/^Hello!?$/, { + system: "Show concise reasoning when the provider supports visible reasoning summaries.", + providerOptions: { openai: { reasoningEffort: "low", reasoningSummary: "auto" } }, + maxTokens: context.maxTokens ?? 120, + assert: (response) => expect(response.usage?.reasoningTokens ?? 0).toBeGreaterThan(0), + }), + ]) + +const runReasoningContinuationScenario = (context: GoldenScenarioContext) => + runGeneratedConversation(context, [ + user("Think briefly, then reply exactly with: Hello!"), + assistant.expectEncryptedReasoningText(/^Hello!?$/, { + id: "first", + system: "Show concise reasoning when the provider supports visible reasoning summaries.", + maxTokens: context.maxTokens ?? 120, + }), + user("Now reply exactly with: Done."), + assistant.expectText(/^Done\.?$/, { id: "second", maxTokens: 40, providerOptions: encryptedReasoningOptions }), + ]) + +const runToolLoopScenario = (context: GoldenScenarioContext) => + Effect.gen(function* () { + expectGoldenWeatherToolLoop( + yield* runWeatherToolLoop( + goldenWeatherToolLoopRequest({ + id: context.id, + model: context.model, + maxTokens: context.maxTokens ?? 80, + temperature: context.temperature, + }), + ), + ) + }) + +const goldenScenarios = { + text: { title: "streams text", tags: ["text", "golden"], run: runTextScenario }, + "tool-call": { title: "streams tool call", tags: ["tool", "tool-call", "golden"], run: runToolCallScenario }, + "tool-loop": { title: "drives a tool loop", tags: ["tool", "tool-loop", "golden"], run: runToolLoopScenario }, + image: { title: "reads image text", tags: ["media", "image", "vision", "golden"], run: runImageScenario }, + "image-tool-result": { + title: "reads image returned from tool result", + tags: ["media", "image", "vision", "tool", "tool-result", "golden"], + run: runImageToolResultScenario, + }, + reasoning: { title: "uses reasoning", tags: ["reasoning", "golden"], run: runReasoningScenario }, + "reasoning-continuation": { + title: "continues encrypted reasoning", + tags: ["reasoning", "continuation", "encrypted-reasoning", "golden"], + run: runReasoningContinuationScenario, + }, +} as const + +export type GoldenScenarioID = keyof typeof goldenScenarios +export const goldenScenarioTitle = (id: GoldenScenarioID) => goldenScenarios[id].title +export const goldenScenarioTags = (id: GoldenScenarioID) => [...goldenScenarios[id].tags] +export const runGoldenScenario = (id: GoldenScenarioID, context: GoldenScenarioContext) => + goldenScenarios[id].run(context) + +const usageSummary = (usage: LLMResponse["usage"] | undefined) => { + if (!usage) return undefined + return Object.fromEntries( + [ + ["inputTokens", usage.inputTokens], + ["outputTokens", usage.outputTokens], + ["reasoningTokens", usage.reasoningTokens], + ["cacheReadInputTokens", usage.cacheReadInputTokens], + ["cacheWriteInputTokens", usage.cacheWriteInputTokens], + ["totalTokens", usage.totalTokens], + ].filter((entry) => entry[1] !== undefined), + ) +} + +const pushText = (summary: Array>, type: "text" | "reasoning", value: string) => { + const last = summary.at(-1) + if (last?.type === type) { + last.value = `${typeof last.value === "string" ? last.value : ""}${value}` + return + } + summary.push({ type, value }) +} + +export const eventSummary = (events: ReadonlyArray) => { + const summary: Array> = [] + for (const event of events) { + if (event.type === "text-delta") { + pushText(summary, "text", event.text) + continue + } + if (event.type === "reasoning-delta") { + pushText(summary, "reasoning", event.text) + continue + } + if (event.type === "tool-call") { + summary.push({ + type: "tool-call", + name: event.name, + input: event.input, + providerExecuted: event.providerExecuted, + }) + continue + } + if (event.type === "tool-result") { + summary.push({ + type: "tool-result", + name: event.name, + result: event.result, + providerExecuted: event.providerExecuted, + }) + continue + } + if (event.type === "tool-error") { + summary.push({ type: "tool-error", name: event.name, message: event.message }) + continue + } + if (event.type === "finish") { + summary.push({ type: "finish", reason: event.reason, usage: usageSummary(event.usage) }) + } + } + return summary.map((item) => Object.fromEntries(Object.entries(item).filter((entry) => entry[1] !== undefined))) +} diff --git a/packages/llm/test/recorded-test.ts b/packages/llm/test/recorded-test.ts new file mode 100644 index 0000000000000000000000000000000000000000..669b8de5c5ac094709aadd27519accbae259ea83 --- /dev/null +++ b/packages/llm/test/recorded-test.ts @@ -0,0 +1,94 @@ +import { NodeFileSystem } from "@effect/platform-node" +import { HttpRecorder } from "@opencode-ai/http-recorder" +import { HttpRecorderInternal } from "@opencode-ai/http-recorder/internal" +import { Layer } from "effect" +import { FetchHttpClient } from "effect/unstable/http" +import * as path from "node:path" +import { fileURLToPath } from "node:url" +import { LLMClient, RequestExecutor } from "../src/route" +import type { Service as LLMClientService } from "../src/route/client" +import type { Service as RequestExecutorService } from "../src/route/executor" +import type { Service as WebSocketExecutorService } from "../src/route/transport/websocket" +import { + recordedEffectGroup, + type RecordedCaseOptions as RunnerCaseOptions, + type RecordedGroupOptions, +} from "./recorded-runner" +import { webSocketCassetteLayer } from "./recorded-websocket" + +const __dirname = path.dirname(fileURLToPath(import.meta.url)) +const FIXTURES_DIR = path.resolve(__dirname, "fixtures", "recordings") + +type RecordedEnv = RequestExecutorService | WebSocketExecutorService | LLMClientService + +type RecordedTestsOptions = RecordedGroupOptions & { + readonly options?: HttpRecorder.RecorderOptions +} + +type RecordedCaseOptions = RunnerCaseOptions & { + readonly options?: HttpRecorder.RecorderOptions +} + +const mergeOptions = ( + base: HttpRecorder.RecorderOptions | undefined, + override: HttpRecorder.RecorderOptions | undefined, +) => { + if (!base) return override + if (!override) return base + return { + ...base, + ...override, + metadata: base.metadata || override.metadata ? { ...base.metadata, ...override.metadata } : undefined, + redact: + base.redact || override.redact + ? { + ...base.redact, + ...override.redact, + headers: [...(base.redact?.headers ?? []), ...(override.redact?.headers ?? [])], + allowRequestHeaders: [ + ...(base.redact?.allowRequestHeaders ?? []), + ...(override.redact?.allowRequestHeaders ?? []), + ], + allowResponseHeaders: [ + ...(base.redact?.allowResponseHeaders ?? []), + ...(override.redact?.allowResponseHeaders ?? []), + ], + queryParameters: [...(base.redact?.queryParameters ?? []), ...(override.redact?.queryParameters ?? [])], + jsonFields: [...(base.redact?.jsonFields ?? []), ...(override.redact?.jsonFields ?? [])], + } + : undefined, + } +} + +export const recordedTests = (options: RecordedTestsOptions) => + recordedEffectGroup({ + duplicateLabel: "recorded cassette", + options, + cassetteExists: (cassette) => HttpRecorderInternal.hasCassetteSync(cassette, { directory: FIXTURES_DIR }), + layer: ({ cassette, metadata, options, caseOptions, recording }) => { + const recorderOptions = mergeOptions(options.options, caseOptions.options) + const recorderMetadata = { + ...recorderOptions?.metadata, + ...metadata, + } + const mode = recording ? "record" : "replay" + const cassetteService = HttpRecorderInternal.Cassette.fileSystem({ directory: FIXTURES_DIR }).pipe( + Layer.provide(NodeFileSystem.layer), + ) + const requestExecutor = RequestExecutor.layer.pipe( + Layer.provide( + HttpRecorderInternal.recordingLayer(cassette, { + mode, + metadata: recorderMetadata, + redactor: HttpRecorderInternal.Redactor.make(recorderOptions?.redact), + match: recorderOptions?.match, + }).pipe(Layer.provide(FetchHttpClient.layer)), + ), + ) + const deps = Layer.mergeAll( + requestExecutor, + webSocketCassetteLayer(cassette, { metadata: recorderMetadata, mode }), + ) + return Layer.mergeAll(deps, LLMClient.layer.pipe(Layer.provide(deps))).pipe(Layer.provide(cassetteService)) + }, + }) diff --git a/packages/llm/test/recorded-utils.ts b/packages/llm/test/recorded-utils.ts new file mode 100644 index 0000000000000000000000000000000000000000..513b2f819ce462ec7d43fe66626b5c290c568640 --- /dev/null +++ b/packages/llm/test/recorded-utils.ts @@ -0,0 +1,56 @@ +export const kebab = (value: string) => + value + .trim() + .replace(/['"]/g, "") + .replace(/[^a-zA-Z0-9]+/g, "-") + .replace(/^-|-$/g, "") + .toLowerCase() + +export const missingEnv = (names: ReadonlyArray) => names.filter((name) => !process.env[name]) + +export const envList = (name: string) => + (process.env[name] ?? "") + .split(",") + .map((item) => item.trim().toLowerCase()) + .filter((item) => item !== "") + +export const unique = (items: ReadonlyArray) => Array.from(new Set(items)) + +export const classifiedTags = (input: { + readonly prefix?: string + readonly provider?: string + readonly protocol?: string + readonly tags?: ReadonlyArray +}) => + unique([ + ...(input.prefix ? [`prefix:${input.prefix}`] : []), + ...(input.provider ? [`provider:${input.provider}`] : []), + ...(input.protocol ? [`protocol:${input.protocol}`] : []), + ...(input.tags ?? []), + ]) + +export const matchesSelected = (input: { + readonly prefix: string + readonly name: string + readonly cassette: string + readonly tags: ReadonlyArray +}) => { + const prefixes = envList("RECORDED_PREFIX") + const providers = envList("RECORDED_PROVIDER") + const requiredTags = envList("RECORDED_TAGS") + const tests = envList("RECORDED_TEST") + const tags = input.tags.map((tag) => tag.toLowerCase()) + const names = [input.name, kebab(input.name), input.cassette].map((item) => item.toLowerCase()) + + if (prefixes.length > 0 && !prefixes.includes(input.prefix.toLowerCase())) return false + if (providers.length > 0 && !providers.some((provider) => tags.includes(`provider:${provider}`))) return false + if (requiredTags.length > 0 && !requiredTags.every((tag) => tags.includes(tag))) return false + if (tests.length > 0 && !tests.some((test) => names.some((name) => name.includes(test)))) return false + return true +} + +export const cassetteName = ( + prefix: string, + name: string, + options: { readonly cassette?: string; readonly id?: string }, +) => options.cassette ?? `${prefix}/${options.id ?? kebab(name)}` diff --git a/packages/llm/test/recorded-websocket.ts b/packages/llm/test/recorded-websocket.ts new file mode 100644 index 0000000000000000000000000000000000000000..afeee09b77e62a36306f9f7a465a1257ab846d6f --- /dev/null +++ b/packages/llm/test/recorded-websocket.ts @@ -0,0 +1,26 @@ +import { HttpRecorderInternal } from "@opencode-ai/http-recorder/internal" +import { Effect, Layer } from "effect" +import { WebSocketExecutor } from "../src/route" +import type { Service as WebSocketExecutorService } from "../src/route/transport/websocket" + +const liveWebSocket = WebSocketExecutor.open + +export const webSocketCassetteLayer = ( + cassette: string, + input: { readonly metadata?: Record; readonly mode: HttpRecorderInternal.RecordReplayMode }, +): Layer.Layer => + Layer.effect( + WebSocketExecutor.Service, + Effect.gen(function* () { + const cassetteService = yield* HttpRecorderInternal.Cassette.Service + const executor = yield* HttpRecorderInternal.makeWebSocketExecutor({ + name: cassette, + mode: input.mode, + metadata: input.metadata, + cassette: cassetteService, + live: { open: liveWebSocket }, + compareClientMessagesAsJson: true, + }) + return WebSocketExecutor.Service.of(executor) + }), + ) diff --git a/packages/llm/test/response.test.ts b/packages/llm/test/response.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..5e48e5ef457504eb23f2b65fdc2a955dec1494ed --- /dev/null +++ b/packages/llm/test/response.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, test } from "bun:test" +import { LLMEvent, LLMResponse } from "../src" + +const reduce = (events: ReadonlyArray) => events.reduce(LLMResponse.reduce, LLMResponse.empty()) +const finishEvents = (events: ReadonlyArray) => events.filter(LLMEvent.is.finish) + +describe("LLMResponse reducer", () => { + test("assembles interleaved reasoning and text with end metadata", () => { + const events = [ + LLMEvent.reasoningStart({ id: "r1" }), + LLMEvent.reasoningDelta({ id: "r1", text: "I should " }), + LLMEvent.textStart({ id: "t1" }), + LLMEvent.reasoningDelta({ id: "r1", text: "compare..." }), + LLMEvent.reasoningEnd({ id: "r1", providerMetadata: { anthropic: { signature: "sig" } } }), + LLMEvent.textDelta({ id: "t1", text: "Answer" }), + LLMEvent.textEnd({ id: "t1" }), + LLMEvent.finish({ reason: "stop", usage: { outputTokens: 5 } }), + ] + const response = LLMResponse.fromEvents(events) + + expect(response?.finishReason).toBe("stop") + expect(response?.usage).toMatchObject({ outputTokens: 5 }) + expect(response?.events).toEqual(events) + expect(response?.events.map((event) => event.type)).toEqual([ + "reasoning-start", + "reasoning-delta", + "text-start", + "reasoning-delta", + "reasoning-end", + "text-delta", + "text-end", + "finish", + ]) + expect(finishEvents(response?.events ?? [])).toHaveLength(1) + expect(response?.message.content).toEqual([ + { + type: "reasoning", + text: "I should compare...", + providerMetadata: { anthropic: { signature: "sig" } }, + }, + { type: "text", text: "Answer" }, + ]) + }) + + test("preserves partial content without completing a failed stream", () => { + const state = reduce([LLMEvent.textStart({ id: "t1" }), LLMEvent.textDelta({ id: "t1", text: "partial" })]) + + expect(LLMResponse.complete(state)).toBeUndefined() + expect(state.message.content).toEqual([{ type: "text", text: "partial" }]) + }) + + test("does not complete ended content without a terminal finish", () => { + const state = reduce([ + LLMEvent.textStart({ id: "t1" }), + LLMEvent.textDelta({ id: "t1", text: "partial" }), + LLMEvent.textEnd({ id: "t1" }), + ]) + + expect(LLMResponse.complete(state)).toBeUndefined() + expect(state.message.content).toEqual([{ type: "text", text: "partial" }]) + }) + + test("uses terminal usage when present and keeps prior usage when finish omits it", () => { + const withFinishUsage = LLMResponse.fromEvents([ + LLMEvent.stepFinish({ index: 0, reason: "stop", usage: { inputTokens: 3 } }), + LLMEvent.finish({ reason: "stop", usage: { outputTokens: 2 } }), + ]) + const withoutFinishUsage = LLMResponse.fromEvents([ + LLMEvent.stepFinish({ index: 0, reason: "stop", usage: { inputTokens: 3 } }), + LLMEvent.finish({ reason: "stop" }), + ]) + + expect(withFinishUsage?.usage).toMatchObject({ outputTokens: 2 }) + expect(withoutFinishUsage?.usage).toMatchObject({ inputTokens: 3 }) + }) + + test("assembles tool-call content only after the completed tool call event", () => { + const pending = reduce([ + LLMEvent.toolInputStart({ id: "call_1", name: "lookup" }), + LLMEvent.toolInputDelta({ id: "call_1", name: "lookup", text: '{"query"' }), + ]) + + expect(pending.message.content).toEqual([]) + expect(pending.toolInputs.call_1?.text).toBe('{"query"') + + const response = LLMResponse.fromEvents([ + ...pending.events, + LLMEvent.toolInputDelta({ id: "call_1", name: "lookup", text: ':"weather"}' }), + LLMEvent.toolInputEnd({ id: "call_1", name: "lookup" }), + LLMEvent.toolCall({ id: "call_1", name: "lookup", input: { query: "weather" } }), + LLMEvent.finish({ reason: "tool-calls" }), + ]) + + expect(response?.message.content).toEqual([ + { type: "tool-call", id: "call_1", name: "lookup", input: { query: "weather" } }, + ]) + }) +}) diff --git a/packages/llm/test/route.test.ts b/packages/llm/test/route.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..681583bc9e475b173f33016689dccee6088186f4 --- /dev/null +++ b/packages/llm/test/route.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, test } from "bun:test" +import * as OpenAIChat from "../src/protocols/openai-chat" +import { Auth } from "../src/route" + +describe("Route.with", () => { + test("merges endpoint query and header defaults while replacing auth and id", () => { + const auth = Auth.headers({ "x-auth": "new" }) + const route = OpenAIChat.route + .with({ + id: "base-chat", + endpoint: { + baseURL: "https://api.example.test/v1", + query: { keep: "base", base: "1" }, + }, + headers: { "x-base": "base", "x-override": "base" }, + auth: Auth.headers({ "x-auth": "old" }), + }) + .with({ + id: "patched-chat", + endpoint: { query: { keep: "patch", patch: "1" } }, + headers: { "x-override": "patch", "x-patch": "patch" }, + auth, + }) + + expect(route.id).toBe("patched-chat") + expect(route.auth).toBe(auth) + expect(route.endpoint).toMatchObject({ + baseURL: "https://api.example.test/v1", + path: "/chat/completions", + query: { keep: "patch", base: "1", patch: "1" }, + }) + expect(route.defaults.headers).toEqual({ + "x-base": "base", + "x-override": "patch", + "x-patch": "patch", + }) + expect(route.defaults.http?.headers).toEqual({ + "x-base": "base", + "x-override": "patch", + "x-patch": "patch", + }) + }) +}) diff --git a/packages/llm/test/schema.test.ts b/packages/llm/test/schema.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..3c6628c2e511539b7b34236cddefcfadfc7bc2e5 --- /dev/null +++ b/packages/llm/test/schema.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, test } from "bun:test" +import { Schema } from "effect" +import * as OpenAIChat from "../src/protocols/openai-chat" +import * as OpenAIResponses from "../src/protocols/openai-responses" +import { ContentPart, LLMEvent, LLMRequest, Model, ModelID, ProviderID, Usage } from "../src/schema" +import { ProviderShared } from "../src/protocols/shared" + +const model = new Model({ + id: ModelID.make("fake-model"), + provider: ProviderID.make("fake-provider"), + route: OpenAIChat.route, +}) + +const decodeLLMRequest = Schema.decodeUnknownSync(LLMRequest as unknown as Schema.Decoder) +const decodeLLMEvent = Schema.decodeUnknownSync(LLMEvent as unknown as Schema.Decoder) + +describe("llm schema", () => { + test("decodes a minimal request", () => { + const input: unknown = { + id: "req_1", + model, + system: [{ type: "text", text: "You are terse." }], + messages: [{ role: "user", content: [{ type: "text", text: "hi" }] }], + tools: [], + generation: {}, + } + + const decoded = decodeLLMRequest(input) + + expect(decoded.id).toBe("req_1") + expect(decoded.messages[0]?.content[0]?.type).toBe("text") + }) + + test("accepts custom route ids", () => { + const decoded = decodeLLMRequest({ + model: Model.update(model, { route: OpenAIResponses.route }), + system: [], + messages: [], + tools: [], + generation: {}, + }) + + expect(decoded.model.route.id).toBe("openai-responses") + }) + + test("rejects invalid event type", () => { + expect(() => decodeLLMEvent({ type: "bogus" })).toThrow() + }) + + test("finish constructors accept usage input", () => { + expect(LLMEvent.stepFinish({ index: 0, reason: "stop", usage: { inputTokens: 1 } }).usage).toBeInstanceOf(Usage) + expect(LLMEvent.finish({ reason: "stop", usage: { outputTokens: 2 } }).usage).toBeInstanceOf(Usage) + }) + + test("content part tagged union exposes guards", () => { + expect(ContentPart.guards.text({ type: "text", text: "hi" })).toBe(true) + expect(ContentPart.guards.media({ type: "text", text: "hi" })).toBe(false) + }) +}) + +describe("LLM.Usage", () => { + test("subtractTokens clamps non-sensical breakdowns to zero", () => { + // Defense against a provider reporting cached_tokens > prompt_tokens or + // reasoning_tokens > completion_tokens — the negative would otherwise + // round-trip through the pipeline and crash strict downstream schemas. + expect(ProviderShared.subtractTokens(5, 3)).toBe(2) + expect(ProviderShared.subtractTokens(5, 10)).toBe(0) + expect(ProviderShared.subtractTokens(5, undefined)).toBe(5) + expect(ProviderShared.subtractTokens(undefined, 3)).toBeUndefined() + expect(ProviderShared.subtractTokens(undefined, undefined)).toBeUndefined() + }) + + test("sumTokens returns undefined only when every input is undefined", () => { + expect(ProviderShared.sumTokens(1, 2, 3)).toBe(6) + expect(ProviderShared.sumTokens(1, undefined, 3)).toBe(4) + expect(ProviderShared.sumTokens(undefined, undefined, undefined)).toBeUndefined() + expect(ProviderShared.sumTokens()).toBeUndefined() + }) + + test("visibleOutputTokens clamps reasoning > output to zero", () => { + expect(new Usage({ outputTokens: 10, reasoningTokens: 4 }).visibleOutputTokens).toBe(6) + expect(new Usage({ outputTokens: 10 }).visibleOutputTokens).toBe(10) + expect(new Usage({ outputTokens: 4, reasoningTokens: 10 }).visibleOutputTokens).toBe(0) + expect(new Usage({}).visibleOutputTokens).toBe(0) + }) +}) diff --git a/packages/llm/test/tool-runtime.test.ts b/packages/llm/test/tool-runtime.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..c03a18bd8aa271d33775cc033096e9e09be4cdee --- /dev/null +++ b/packages/llm/test/tool-runtime.test.ts @@ -0,0 +1,818 @@ +import { describe, expect } from "bun:test" +import { Effect, Schema, Stream } from "effect" +import { + GenerationOptions, + LLM, + LLMEvent, + LLMRequest, + LLMResponse, + ToolChoice, + ToolContent, + ToolOutput, + toDefinitions, +} from "../src" +import { Auth, LLMClient } from "../src/route" +import * as AnthropicMessages from "../src/protocols/anthropic-messages" +import * as OpenAIChat from "../src/protocols/openai-chat" +import * as OpenAIResponses from "../src/protocols/openai-responses" +import { Tool, ToolFailure, type ToolExecuteContext } from "../src/tool" +import { ToolRuntime } from "../src/tool-runtime" +import { it } from "./lib/effect" +import * as TestToolRuntime from "./lib/tool-runtime" +import { dynamicResponse, scriptedResponses } from "./lib/http" +import { deltaChunk, finishChunk, toolCallChunk } from "./lib/openai-chunks" +import { sseEvents } from "./lib/sse" + +const model = OpenAIChat.route + .with({ endpoint: { baseURL: "https://api.openai.test/v1/" }, auth: Auth.bearer("test") }) + .model({ id: "gpt-4o-mini" }) +const Json = Schema.fromJsonString(Schema.Unknown) +const decodeJson = Schema.decodeUnknownSync(Json) + +const baseRequest = LLM.request({ + id: "req_1", + model, + prompt: "Use the tool.", +}) +const weatherFailureCause = new Error("weather lookup denied") + +const get_weather = Tool.make({ + description: "Get current weather for a city.", + parameters: Schema.Struct({ city: Schema.String }), + success: Schema.Struct({ temperature: Schema.Number, condition: Schema.String }), + execute: ({ city }) => + Effect.gen(function* () { + if (city === "FAIL") + return yield* new ToolFailure({ message: `Weather lookup failed for ${city}`, error: weatherFailureCause }) + return { temperature: 22, condition: "sunny" } + }), +}) + +const schema_only_weather = Tool.make({ + description: "Get current weather for a city.", + parameters: Schema.Struct({ city: Schema.String }), + success: Schema.Struct({ temperature: Schema.Number, condition: Schema.String }), +}) + +describe("LLMClient tools", () => { + it.effect("uses the registered model route when adding runtime tools", () => + Effect.gen(function* () { + const layer = scriptedResponses([ + sseEvents(deltaChunk({ role: "assistant", content: "Done." }), finishChunk("stop")), + ]) + + const events = Array.from( + yield* TestToolRuntime.runTools({ request: baseRequest, tools: { get_weather } }).pipe( + Stream.runCollect, + Effect.provide(layer), + ), + ) + + expect(LLMResponse.text({ events })).toBe("Done.") + }), + ) + + it.effect("sends tool-call history and request options on the follow-up request", () => + Effect.gen(function* () { + const bodies: unknown[] = [] + const responses = [ + sseEvents(toolCallChunk("call_1", "get_weather", '{"city":"Paris"}'), finishChunk("tool_calls")), + sseEvents(deltaChunk({ role: "assistant", content: "It's sunny in Paris." }), finishChunk("stop")), + ] + const layer = dynamicResponse((input) => + Effect.sync(() => { + bodies.push(decodeJson(input.text)) + return input.respond(responses[bodies.length - 1] ?? responses[responses.length - 1], { + headers: { "content-type": "text/event-stream" }, + }) + }), + ) + + yield* TestToolRuntime.runTools({ + request: LLMRequest.update(baseRequest, { + generation: GenerationOptions.make({ maxTokens: 50 }), + toolChoice: ToolChoice.make("auto"), + }), + tools: { get_weather }, + }).pipe(Stream.runCollect, Effect.provide(layer)) + + const second = bodies[1] + if (!second || typeof second !== "object") throw new Error("Expected second request body") + const messages = Reflect.get(second, "messages") + const tools = Reflect.get(second, "tools") + + expect(Reflect.get(second, "max_tokens")).toBe(50) + expect(Reflect.get(second, "tool_choice")).toBe("auto") + expect(tools).toHaveLength(1) + expect( + Array.isArray(messages) + ? messages.map((message) => + message && typeof message === "object" ? Reflect.get(message, "role") : undefined, + ) + : undefined, + ).toEqual(["user", "assistant", "tool"]) + expect(Array.isArray(messages) ? messages[1] : undefined).toMatchObject({ + role: "assistant", + content: null, + tool_calls: [{ id: "call_1", type: "function", function: { name: "get_weather" } }], + }) + expect(Array.isArray(messages) ? messages[2] : undefined).toMatchObject({ + role: "tool", + tool_call_id: "call_1", + content: '{"temperature":22,"condition":"sunny"}', + }) + }), + ) + + it.effect("dispatches a tool call, appends results, and resumes streaming", () => + Effect.gen(function* () { + const layer = scriptedResponses([ + sseEvents(toolCallChunk("call_1", "get_weather", '{"city":"Paris"}'), finishChunk("tool_calls")), + sseEvents(deltaChunk({ role: "assistant", content: "It's sunny in Paris." }), finishChunk("stop")), + ]) + + const events = Array.from( + yield* TestToolRuntime.runTools({ request: baseRequest, tools: { get_weather } }).pipe( + Stream.runCollect, + Effect.provide(layer), + ), + ) + + const result = events.find(LLMEvent.is.toolResult) + expect(result).toMatchObject({ + type: "tool-result", + id: "call_1", + name: "get_weather", + result: { type: "json", value: { temperature: 22, condition: "sunny" } }, + }) + expect(events.at(-1)?.type).toBe("finish") + expect(LLMResponse.text({ events })).toBe("It's sunny in Paris.") + }), + ) + + it.effect("projects encoded typed tool success into canonical model content", () => + Effect.gen(function* () { + const calls: unknown[] = [] + const projected = Tool.make({ + description: "Project an encoded success.", + parameters: Schema.Struct({ prefix: Schema.String }), + success: Schema.Struct({ count: Schema.NumberFromString }), + execute: () => Effect.succeed({ count: 2 }), + toModelOutput: (input) => { + calls.push(input) + return [{ type: "text", text: `${input.parameters.prefix}:${input.output.count}` }] + }, + }) + + const dispatched = yield* ToolRuntime.dispatch( + { projected }, + LLMEvent.toolCall({ id: "call_projected", name: "projected", input: { prefix: "count" } }), + ) + + expect(calls).toEqual([{ callID: "call_projected", parameters: { prefix: "count" }, output: { count: "2" } }]) + expect(dispatched.result).toEqual({ type: "text", value: "count:2" }) + expect(dispatched.output).toEqual({ structured: { count: "2" }, content: [{ type: "text", text: "count:2" }] }) + expect(dispatched.events).toEqual([ + LLMEvent.toolResult({ + id: "call_projected", + name: "projected", + result: { type: "text", value: "count:2" }, + output: { structured: { count: "2" }, content: [{ type: "text", text: "count:2" }] }, + }), + ]) + }), + ) + + it.effect("uses the narrow default projection for encoded typed success", () => + Effect.gen(function* () { + const text = Tool.make({ + description: "Return text.", + parameters: Schema.Struct({}), + success: Schema.String, + execute: () => Effect.succeed("hello"), + }) + const json = Tool.make({ + description: "Return JSON.", + parameters: Schema.Struct({}), + success: Schema.Struct({ ok: Schema.Boolean }), + execute: () => Effect.succeed({ ok: true }), + }) + + expect( + (yield* ToolRuntime.dispatch({ text }, LLMEvent.toolCall({ id: "call_text", name: "text", input: {} }))).output, + ).toEqual({ structured: "hello", content: [{ type: "text", text: "hello" }] }) + expect( + (yield* ToolRuntime.dispatch({ json }, LLMEvent.toolCall({ id: "call_json", name: "json", input: {} }))).output, + ).toEqual({ structured: { ok: true }, content: [] }) + }), + ) + + it.effect("can retain model media while redacting duplicated structured payloads", () => + Effect.gen(function* () { + const image = Tool.make({ + description: "Return an image.", + parameters: Schema.Struct({}), + success: Schema.Struct({ mime: Schema.String, data: Schema.String }), + execute: () => Effect.succeed({ mime: "image/png", data: "AAECAw==" }), + toStructuredOutput: (output) => ({ mime: output.mime }), + toModelOutput: ({ output }) => [ + { type: "file", uri: `data:${output.mime};base64,${output.data}`, mime: output.mime }, + ], + }) + + const dispatched = yield* ToolRuntime.dispatch( + { image }, + LLMEvent.toolCall({ id: "call_image", name: "image", input: {} }), + ) + + expect(dispatched.output).toEqual({ + structured: { mime: "image/png" }, + content: [{ type: "file", uri: "data:image/png;base64,AAECAw==", mime: "image/png" }], + }) + }), + ) + + it.effect("models canonical tool files with URIs", () => + Effect.sync(() => { + const decode = Schema.decodeUnknownSync(ToolContent) + + expect(decode({ type: "file", uri: "data:image/png;base64,AAAA", mime: "image/png" })).toEqual({ + type: "file", + uri: "data:image/png;base64,AAAA", + mime: "image/png", + }) + expect(decode({ type: "file", uri: "https://example.test/image.png", mime: "image/png" })).toEqual({ + type: "file", + uri: "https://example.test/image.png", + mime: "image/png", + }) + expect(decode({ type: "file", uri: "file:///tmp/image.png", mime: "image/png" })).toEqual({ + type: "file", + uri: "file:///tmp/image.png", + mime: "image/png", + }) + }), + ) + + it.effect("preserves canonical tool file URIs", () => + Effect.sync(() => { + expect( + ToolOutput.toResultValue( + ToolOutput.make({}, [{ type: "file", uri: "data:image/png;base64,AAAA", mime: "image/png" }]), + ), + ).toEqual({ + type: "content", + value: [{ type: "file", uri: "data:image/png;base64,AAAA", mime: "image/png" }], + }) + expect( + ToolOutput.toResultValue( + ToolOutput.make({}, [{ type: "file", uri: "https://example.test/image.png", mime: "image/png" }]), + ), + ).toEqual({ + type: "content", + value: [{ type: "file", uri: "https://example.test/image.png", mime: "image/png" }], + }) + expect( + ToolOutput.toResultValue( + ToolOutput.make({}, [{ type: "file", uri: "file:///tmp/image.png", mime: "image/png" }]), + ), + ).toEqual({ + type: "content", + value: [{ type: "file", uri: "file:///tmp/image.png", mime: "image/png" }], + }) + expect( + ToolOutput.fromResultValue({ + type: "content", + value: [{ type: "file", uri: "https://example.test/image.png", mime: "image/png" }], + }), + ).toEqual({ + structured: {}, + content: [{ type: "file", uri: "https://example.test/image.png", mime: "image/png" }], + }) + }), + ) + + it.effect("settles projected URL files as canonical tool results", () => + Effect.gen(function* () { + const remote = Tool.make({ + description: "Return a remote file.", + parameters: Schema.Struct({}), + success: Schema.Struct({ ok: Schema.Boolean }), + execute: () => Effect.succeed({ ok: true }), + toModelOutput: () => [{ type: "file", uri: "https://example.test/image.png", mime: "image/png" }], + }) + + const dispatched = yield* ToolRuntime.dispatch( + { remote }, + LLMEvent.toolCall({ id: "call_remote", name: "remote", input: {} }), + ) + + expect(dispatched.output).toEqual({ + structured: { ok: true }, + content: [{ type: "file", uri: "https://example.test/image.png", mime: "image/png" }], + }) + expect(dispatched.result).toEqual({ + type: "content", + value: [{ type: "file", uri: "https://example.test/image.png", mime: "image/png" }], + }) + expect(dispatched.events.map((event) => event.type)).toEqual(["tool-result"]) + }), + ) + + it.effect("derives typed output schemas and preserves dynamic output schemas", () => + Effect.sync(() => { + const [typed] = toDefinitions({ get_weather }) + const schema = { type: "object", properties: { result: { type: "string" } } } as const + const [dynamic] = toDefinitions({ + dynamic: Tool.make({ description: "Dynamic tool.", jsonSchema: { type: "object" }, outputSchema: schema }), + }) + + expect(typed?.outputSchema).toMatchObject({ + type: "object", + properties: { condition: { type: "string" } }, + required: ["temperature", "condition"], + additionalProperties: false, + }) + expect(Reflect.get(Reflect.get(typed?.outputSchema ?? {}, "properties") as object, "temperature")).toBeDefined() + expect(dynamic?.outputSchema).toEqual(schema) + }), + ) + + it.effect("preserves content tool results from dynamic tools", () => + Effect.gen(function* () { + const screenshot = Tool.make({ + description: "Capture a screenshot.", + jsonSchema: { type: "object", properties: {} }, + execute: () => + Effect.succeed({ + type: "content" as const, + value: [ + { type: "text" as const, text: "Screenshot captured." }, + { type: "file" as const, uri: "data:image/png;base64,AAAA", mime: "image/png" }, + ], + }), + }) + + const events = Array.from( + yield* TestToolRuntime.runTools({ request: baseRequest, tools: { screenshot }, maxSteps: 1 }).pipe( + Stream.runCollect, + Effect.provide( + scriptedResponses([sseEvents(toolCallChunk("call_1", "screenshot", "{}"), finishChunk("tool_calls"))]), + ), + ), + ) + + expect(events.find(LLMEvent.is.toolResult)).toMatchObject({ + type: "tool-result", + id: "call_1", + name: "screenshot", + result: { + type: "content", + value: [ + { type: "text", text: "Screenshot captured." }, + { type: "file", uri: "data:image/png;base64,AAAA", mime: "image/png" }, + ], + }, + }) + }), + ) + + it.effect("does not mistake dynamic tool output fields for dispatcher state", () => + Effect.gen(function* () { + const callerOwned = { type: "json" as const, value: { ok: true }, events: ["caller-owned"] } + const eventful = Tool.make({ + description: "Return an events field.", + jsonSchema: { type: "object", properties: {} }, + execute: () => Effect.succeed(callerOwned), + }) + + const dispatched = yield* ToolRuntime.dispatch( + { eventful }, + LLMEvent.toolCall({ id: "call_1", name: "eventful", input: {} }), + ) + + expect(dispatched.result).toEqual(callerOwned) + expect(dispatched.events).toEqual([ + LLMEvent.toolResult({ + id: "call_1", + name: "eventful", + result: callerOwned, + output: { structured: { ok: true }, content: [] }, + }), + ]) + }), + ) + + it.effect("executes tool calls for one step without looping by default", () => + Effect.gen(function* () { + const layer = scriptedResponses([ + sseEvents(toolCallChunk("call_1", "get_weather", '{"city":"Paris"}'), finishChunk("tool_calls")), + sseEvents(deltaChunk({ role: "assistant", content: "Should not run." }), finishChunk("stop")), + ]) + + const events = Array.from( + yield* TestToolRuntime.runTools({ request: baseRequest, tools: { get_weather }, maxSteps: 1 }).pipe( + Stream.runCollect, + Effect.provide(layer), + ), + ) + + expect(events.filter(LLMEvent.is.finish)).toHaveLength(1) + expect(events.find(LLMEvent.is.toolResult)).toMatchObject({ type: "tool-result", id: "call_1" }) + }), + ) + + it.effect("passes tool call context to execute", () => + Effect.gen(function* () { + let context: ToolExecuteContext | undefined + const contextual = Tool.make({ + description: "Capture tool context.", + parameters: Schema.Struct({ value: Schema.String }), + success: Schema.Struct({ ok: Schema.Boolean }), + execute: (_params, ctx) => + Effect.sync(() => { + context = ctx + return { ok: true } + }), + }) + const events = Array.from( + yield* TestToolRuntime.runTools({ request: baseRequest, tools: { contextual } }).pipe( + Stream.runCollect, + Effect.provide( + scriptedResponses([ + sseEvents(toolCallChunk("call_ctx", "contextual", '{"value":"x"}'), finishChunk("tool_calls")), + ]), + ), + ), + ) + + expect(events.some(LLMEvent.is.toolResult)).toBe(true) + expect(context).toEqual({ id: "call_ctx", name: "contextual" }) + }), + ) + + it.effect("can expose tool schemas without executing tool calls", () => + Effect.gen(function* () { + const layer = scriptedResponses([ + sseEvents(toolCallChunk("call_1", "get_weather", '{"city":"Paris"}'), finishChunk("tool_calls")), + ]) + + const events = Array.from( + yield* LLMClient.stream( + LLMRequest.update(baseRequest, { tools: toDefinitions({ get_weather: schema_only_weather }) }), + ).pipe(Stream.runCollect, Effect.provide(layer)), + ) + + expect(events.find(LLMEvent.is.toolCall)).toMatchObject({ type: "tool-call", id: "call_1" }) + expect(events.find(LLMEvent.is.toolResult)).toBeUndefined() + }), + ) + + it.effect("preserves provider metadata when folding streamed assistant content into follow-up history", () => + Effect.gen(function* () { + const bodies: unknown[] = [] + const layer = dynamicResponse((input) => + Effect.sync(() => { + bodies.push(decodeJson(input.text)) + return input.respond( + bodies.length === 1 + ? sseEvents( + { type: "message_start", message: { usage: { input_tokens: 5 } } }, + { type: "content_block_start", index: 0, content_block: { type: "thinking", thinking: "" } }, + { type: "content_block_delta", index: 0, delta: { type: "thinking_delta", thinking: "thinking" } }, + { type: "content_block_delta", index: 0, delta: { type: "signature_delta", signature: "sig_1" } }, + { type: "content_block_stop", index: 0 }, + { + type: "content_block_start", + index: 1, + content_block: { type: "tool_use", id: "call_1", name: "get_weather" }, + }, + { + type: "content_block_delta", + index: 1, + delta: { type: "input_json_delta", partial_json: '{"city":"Paris"}' }, + }, + { type: "content_block_stop", index: 1 }, + { type: "message_delta", delta: { stop_reason: "tool_use" }, usage: { output_tokens: 5 } }, + ) + : sseEvents( + { type: "message_start", message: { usage: { input_tokens: 5 } } }, + { type: "content_block_start", index: 0, content_block: { type: "text", text: "" } }, + { type: "content_block_delta", index: 0, delta: { type: "text_delta", text: "Done." } }, + { type: "content_block_stop", index: 0 }, + { type: "message_delta", delta: { stop_reason: "end_turn" }, usage: { output_tokens: 1 } }, + ), + { headers: { "content-type": "text/event-stream" } }, + ) + }), + ) + + yield* TestToolRuntime.runTools({ + request: LLM.updateRequest(baseRequest, { + model: AnthropicMessages.route + .with({ auth: Auth.header("x-api-key", "test") }) + .model({ id: "claude-sonnet-4-5" }), + }), + tools: { get_weather }, + }).pipe(Stream.runCollect, Effect.provide(layer)) + + expect(bodies[1]).toMatchObject({ + messages: [ + { role: "user" }, + { + role: "assistant", + content: [ + { type: "thinking", thinking: "thinking", signature: "sig_1" }, + { type: "tool_use", id: "call_1", name: "get_weather", input: { city: "Paris" } }, + ], + }, + { role: "user", content: [{ type: "tool_result", tool_use_id: "call_1" }] }, + ], + }) + }), + ) + + it.effect("replays encrypted OpenAI reasoning items with tool outputs", () => + Effect.gen(function* () { + const bodies: unknown[] = [] + const layer = dynamicResponse((input) => + Effect.sync(() => { + bodies.push(decodeJson(input.text)) + return input.respond( + bodies.length === 1 + ? sseEvents( + { + type: "response.output_item.added", + item: { type: "reasoning", id: "rs_1", encrypted_content: null }, + }, + { type: "response.reasoning_summary_part.added", item_id: "rs_1", summary_index: 0 }, + { type: "response.reasoning_summary_part.done", item_id: "rs_1", summary_index: 0 }, + { + type: "response.output_item.done", + item: { type: "reasoning", id: "rs_1", encrypted_content: "encrypted-state" }, + }, + { + type: "response.output_item.added", + item: { + type: "function_call", + id: "item_1", + call_id: "call_1", + name: "get_weather", + arguments: "", + }, + }, + { type: "response.function_call_arguments.delta", item_id: "item_1", delta: '{"city":"Paris"}' }, + { + type: "response.output_item.done", + item: { + type: "function_call", + id: "item_1", + call_id: "call_1", + name: "get_weather", + arguments: '{"city":"Paris"}', + }, + }, + { type: "response.completed", response: {} }, + ) + : sseEvents( + { type: "response.output_text.delta", item_id: "msg_1", delta: "Done." }, + { type: "response.completed", response: {} }, + ), + { headers: { "content-type": "text/event-stream" } }, + ) + }), + ) + + yield* TestToolRuntime.runTools({ + request: LLM.request({ + model: OpenAIResponses.route + .with({ endpoint: { baseURL: "https://api.openai.test/v1/" }, auth: Auth.bearer("test") }) + .model({ id: "gpt-5.5" }), + prompt: "Use the tool.", + providerOptions: { openai: { store: false, include: ["reasoning.encrypted_content"] } }, + }), + tools: { get_weather }, + }).pipe(Stream.runCollect, Effect.provide(layer)) + + expect(bodies[1]).toMatchObject({ + include: ["reasoning.encrypted_content"], + input: [ + { role: "user" }, + { type: "reasoning", summary: [], encrypted_content: "encrypted-state" }, + { type: "function_call", call_id: "call_1", name: "get_weather" }, + { type: "function_call_output", call_id: "call_1" }, + ], + }) + }), + ) + + it.effect("emits tool-error for unknown tools so the model can self-correct", () => + Effect.gen(function* () { + const layer = scriptedResponses([ + sseEvents(toolCallChunk("call_1", "missing_tool", "{}"), finishChunk("tool_calls")), + sseEvents(deltaChunk({ role: "assistant", content: "Sorry." }), finishChunk("stop")), + ]) + + const events = Array.from( + yield* TestToolRuntime.runTools({ request: baseRequest, tools: { get_weather } }).pipe( + Stream.runCollect, + Effect.provide(layer), + ), + ) + + const toolError = events.find(LLMEvent.is.toolError) + expect(toolError).toMatchObject({ type: "tool-error", id: "call_1", name: "missing_tool" }) + expect(toolError?.message).toContain("Unknown tool") + expect(events.find(LLMEvent.is.toolResult)).toMatchObject({ + type: "tool-result", + id: "call_1", + name: "missing_tool", + result: { type: "error", value: "Unknown tool: missing_tool" }, + }) + }), + ) + + it.effect("emits tool-error when the LLM input fails the parameters schema", () => + Effect.gen(function* () { + const layer = scriptedResponses([ + sseEvents(toolCallChunk("call_1", "get_weather", '{"city":42}'), finishChunk("tool_calls")), + sseEvents(deltaChunk({ role: "assistant", content: "Done." }), finishChunk("stop")), + ]) + + const events = Array.from( + yield* TestToolRuntime.runTools({ request: baseRequest, tools: { get_weather } }).pipe( + Stream.runCollect, + Effect.provide(layer), + ), + ) + + const toolError = events.find(LLMEvent.is.toolError) + expect(toolError).toMatchObject({ type: "tool-error", id: "call_1", name: "get_weather" }) + expect(toolError?.message).toContain("Invalid tool input") + }), + ) + + it.effect("emits tool-error when the handler returns a ToolFailure", () => + Effect.gen(function* () { + const layer = scriptedResponses([ + sseEvents(toolCallChunk("call_1", "get_weather", '{"city":"FAIL"}'), finishChunk("tool_calls")), + sseEvents(deltaChunk({ role: "assistant", content: "Sorry." }), finishChunk("stop")), + ]) + + const events = Array.from( + yield* TestToolRuntime.runTools({ request: baseRequest, tools: { get_weather } }).pipe( + Stream.runCollect, + Effect.provide(layer), + ), + ) + + const toolError = events.find(LLMEvent.is.toolError) + expect(toolError).toMatchObject({ type: "tool-error", id: "call_1", name: "get_weather" }) + expect(toolError?.message).toBe("Weather lookup failed for FAIL") + expect(toolError?.error).toBe(weatherFailureCause) + }), + ) + + it.effect("stops when the model finishes without requesting more tools", () => + Effect.gen(function* () { + const layer = scriptedResponses([ + sseEvents(deltaChunk({ role: "assistant", content: "Done." }), finishChunk("stop")), + ]) + + const events = Array.from( + yield* TestToolRuntime.runTools({ request: baseRequest, tools: { get_weather } }).pipe( + Stream.runCollect, + Effect.provide(layer), + ), + ) + + expect(events.map((event) => event.type)).toEqual([ + "step-start", + "text-start", + "text-delta", + "text-end", + "step-finish", + "finish", + ]) + expect(LLMResponse.text({ events })).toBe("Done.") + }), + ) + + it.effect("respects maxSteps and stops the loop", () => + Effect.gen(function* () { + // Every script entry asks for another tool call. With maxSteps: 2 the + // runtime should run at most two model rounds and then exit even though + // the model still wants to keep going. + const toolCallStep = sseEvents( + toolCallChunk("call_x", "get_weather", '{"city":"Paris"}'), + finishChunk("tool_calls"), + ) + const layer = scriptedResponses([toolCallStep, toolCallStep, toolCallStep]) + + const events = Array.from( + yield* TestToolRuntime.runTools({ request: baseRequest, tools: { get_weather }, maxSteps: 2 }).pipe( + Stream.runCollect, + Effect.provide(layer), + ), + ) + + expect(events.filter(LLMEvent.is.finish)).toHaveLength(1) + expect(events.filter(LLMEvent.is.stepStart).map((event) => event.index)).toEqual([0, 1]) + expect(events.filter(LLMEvent.is.stepFinish).map((event) => event.index)).toEqual([0, 1]) + }), + ) + + it.effect("does not dispatch provider-executed tool calls", () => + Effect.gen(function* () { + let streams = 0 + const layer = dynamicResponse((input) => + Effect.sync(() => { + streams++ + return input.respond( + sseEvents( + { type: "message_start", message: { usage: { input_tokens: 5 } } }, + { + type: "content_block_start", + index: 0, + content_block: { type: "server_tool_use", id: "srvtoolu_abc", name: "web_search" }, + }, + { + type: "content_block_delta", + index: 0, + delta: { type: "input_json_delta", partial_json: '{"query":"x"}' }, + }, + { type: "content_block_stop", index: 0 }, + { + type: "content_block_start", + index: 1, + content_block: { + type: "web_search_tool_result", + tool_use_id: "srvtoolu_abc", + content: [{ type: "web_search_result", url: "https://example.com", title: "Example" }], + }, + }, + { type: "content_block_stop", index: 1 }, + { type: "content_block_start", index: 2, content_block: { type: "text", text: "" } }, + { type: "content_block_delta", index: 2, delta: { type: "text_delta", text: "Done." } }, + { type: "content_block_stop", index: 2 }, + { type: "message_delta", delta: { stop_reason: "end_turn" }, usage: { output_tokens: 8 } }, + ), + { headers: { "content-type": "text/event-stream" } }, + ) + }), + ) + const events = Array.from( + yield* TestToolRuntime.runTools({ + request: LLM.updateRequest(baseRequest, { + model: AnthropicMessages.route + .with({ auth: Auth.header("x-api-key", "test") }) + .model({ id: "claude-sonnet-4-5" }), + }), + tools: {}, + }).pipe(Stream.runCollect, Effect.provide(layer)), + ) + + expect(streams).toBe(1) + expect(events.find(LLMEvent.is.toolError)).toBeUndefined() + expect(events.filter(LLMEvent.is.toolCall)).toEqual([ + { + type: "tool-call", + id: "srvtoolu_abc", + name: "web_search", + input: { query: "x" }, + providerExecuted: true, + }, + ]) + expect(LLMResponse.text({ events })).toBe("Done.") + }), + ) + + it.effect("dispatches multiple tool calls in one step concurrently", () => + Effect.gen(function* () { + const layer = scriptedResponses([ + sseEvents( + deltaChunk({ + role: "assistant", + tool_calls: [ + { index: 0, id: "c1", function: { name: "get_weather", arguments: '{"city":"Paris"}' } }, + { index: 1, id: "c2", function: { name: "get_weather", arguments: '{"city":"Tokyo"}' } }, + ], + }), + finishChunk("tool_calls"), + ), + sseEvents(deltaChunk({ role: "assistant", content: "Both done." }), finishChunk("stop")), + ]) + + const events = Array.from( + yield* TestToolRuntime.runTools({ request: baseRequest, tools: { get_weather } }).pipe( + Stream.runCollect, + Effect.provide(layer), + ), + ) + + const results = events.filter(LLMEvent.is.toolResult) + expect(results).toHaveLength(2) + expect(results.map((event) => event.id).toSorted()).toEqual(["c1", "c2"]) + }), + ) +}) diff --git a/packages/llm/test/tool-schema-projection.test.ts b/packages/llm/test/tool-schema-projection.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..a9df815dafabcb2b5bbf41ab6dc02e329baaff0f --- /dev/null +++ b/packages/llm/test/tool-schema-projection.test.ts @@ -0,0 +1,117 @@ +import { describe, expect, test } from "bun:test" +import { Effect } from "effect" +import { LLM } from "../src" +import { OpenAIChat } from "../src/protocols" +import { ToolSchemaProjection } from "../src/protocols/utils/tool-schema" +import { Auth, LLMClient } from "../src/route" +import { it } from "./lib/effect" + +describe("tool schema projections", () => { + test("moonshot strips $ref siblings and converts tuple arrays to a schema object", () => { + expect( + ToolSchemaProjection.moonshot({ + type: "object", + properties: { + linked: { $ref: "#/$defs/Linked", description: "drop me" }, + tuple: { type: "array", items: [{ type: "string" }, { type: "number" }] }, + prefixTuple: { type: "array", prefixItems: [{ type: "boolean" }, { type: "string" }] }, + }, + }), + ).toEqual({ + type: "object", + properties: { + linked: { $ref: "#/$defs/Linked" }, + tuple: { type: "array", items: { anyOf: [{ type: "string" }, { type: "number" }] } }, + prefixTuple: { type: "array", items: { anyOf: [{ type: "boolean" }, { type: "string" }] } }, + }, + }) + }) + + test("gemini handles numeric enums, dangling required fields, untyped arrays, and scalar object keys", () => { + expect( + ToolSchemaProjection.gemini({ + type: "object", + required: ["status", "missing"], + properties: { + status: { type: "integer", enum: [1, 2] }, + tags: { type: "array" }, + name: { type: "string", properties: { ignored: { type: "string" } }, required: ["ignored"] }, + }, + }), + ).toEqual({ + type: "object", + required: ["status"], + properties: { + status: { type: "string", enum: ["1", "2"] }, + tags: { type: "array", items: { type: "string" } }, + name: { type: "string" }, + }, + }) + }) + + test("openai keeps one flat object top-level schema", () => { + expect( + ToolSchemaProjection.openAI({ + anyOf: [ + { + type: "object", + properties: { + path: { type: "string" }, + maybe: { anyOf: [{ type: "string" }, { type: "null" }] }, + }, + }, + { type: "object", properties: { resource: { type: "string" } } }, + ], + }), + ).toEqual({ + type: "object", + properties: { + path: { type: "string" }, + maybe: { type: "string" }, + resource: { type: "string" }, + }, + additionalProperties: false, + }) + }) + + it.effect("applies model compatibility before protocol projection", () => + Effect.gen(function* () { + const model = OpenAIChat.route + .with({ endpoint: { baseURL: "https://api.openai.test/v1/" }, auth: Auth.bearer("test") }) + .model({ id: "kimi-k2", compatibility: { toolSchema: "moonshot" } }) + const prepared = yield* LLMClient.prepare( + LLM.request({ + model, + prompt: "Use the tool.", + tools: [ + { + name: "lookup", + description: "Lookup data.", + inputSchema: { + type: "object", + anyOf: [ + { + type: "object", + properties: { + tuple: { type: "array", items: [{ type: "string" }, { type: "number" }] }, + linked: { $ref: "#/$defs/Linked", description: "drop me" }, + }, + }, + ], + }, + }, + ], + }), + ) + + expect(prepared.body.tools?.[0]?.function.parameters).toEqual({ + type: "object", + properties: { + tuple: { type: "array", items: { anyOf: [{ type: "string" }, { type: "number" }] } }, + linked: { $ref: "#/$defs/Linked" }, + }, + additionalProperties: false, + }) + }), + ) +}) diff --git a/packages/llm/test/tool-stream.test.ts b/packages/llm/test/tool-stream.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..b005d2666c8fbf2b9801f2866468930213e9b4f5 --- /dev/null +++ b/packages/llm/test/tool-stream.test.ts @@ -0,0 +1,99 @@ +import { describe, expect } from "bun:test" +import { Effect } from "effect" +import { LLMError } from "../src/schema" +import { ToolStream } from "../src/protocols/utils/tool-stream" +import { it } from "./lib/effect" + +const ADAPTER = "test-route" + +describe("ToolStream", () => { + it.effect("starts from OpenAI-style deltas and finalizes parsed input", () => + Effect.gen(function* () { + const first = ToolStream.appendOrStart( + ADAPTER, + ToolStream.empty(), + 0, + { id: "call_1", name: "lookup", text: '{"query"' }, + "missing tool", + ) + if (ToolStream.isError(first)) return yield* first + const second = ToolStream.appendOrStart(ADAPTER, first.tools, 0, { text: ':"weather"}' }, "missing tool") + if (ToolStream.isError(second)) return yield* second + const finished = yield* ToolStream.finish(ADAPTER, second.tools, 0) + + expect(first.events).toEqual([ + { type: "tool-input-start", id: "call_1", name: "lookup" }, + { type: "tool-input-delta", id: "call_1", name: "lookup", text: '{"query"' }, + ]) + expect(second.events).toEqual([{ type: "tool-input-delta", id: "call_1", name: "lookup", text: ':"weather"}' }]) + expect(finished).toEqual({ + tools: {}, + events: [ + { type: "tool-input-end", id: "call_1", name: "lookup" }, + { type: "tool-call", id: "call_1", name: "lookup", input: { query: "weather" } }, + ], + }) + }), + ) + + it.effect("fails appendExisting when the provider skipped the tool start", () => + Effect.gen(function* () { + const error = ToolStream.appendExisting(ADAPTER, ToolStream.empty(), 0, "{}", "missing tool") + + expect(error).toBeInstanceOf(LLMError) + if (ToolStream.isError(error)) expect(error.reason.message).toBe("missing tool") + }), + ) + + it.effect("uses final input override without losing accumulated deltas", () => + Effect.gen(function* () { + const tools = ToolStream.start(ToolStream.empty(), "item_1", { + id: "call_1", + name: "lookup", + input: '{"query":"partial"}', + }) + const finished = yield* ToolStream.finishWithInput(ADAPTER, tools, "item_1", '{"query":"final"}') + + expect(finished).toEqual({ + tools: {}, + events: [ + { type: "tool-input-end", id: "call_1", name: "lookup" }, + { type: "tool-call", id: "call_1", name: "lookup", input: { query: "final" } }, + ], + }) + }), + ) + + it.effect("preserves providerExecuted and clears all tools", () => + Effect.gen(function* () { + const first: ToolStream.State = ToolStream.start(ToolStream.empty(), 0, { + id: "call_1", + name: "lookup", + input: "{}", + }) + const tools = ToolStream.start(first, 1, { + id: "call_2", + name: "web_search", + input: '{"query":"docs"}', + providerExecuted: true, + }) + const finished = yield* ToolStream.finishAll(ADAPTER, tools) + + expect(finished).toEqual({ + tools: {}, + events: [ + { type: "tool-input-end", id: "call_1", name: "lookup" }, + { type: "tool-call", id: "call_1", name: "lookup", input: {} }, + { type: "tool-input-end", id: "call_2", name: "web_search" }, + { + type: "tool-call", + id: "call_2", + name: "web_search", + input: { query: "docs" }, + providerExecuted: true, + }, + ], + }) + }), + ) +}) diff --git a/packages/llm/test/tool.types.ts b/packages/llm/test/tool.types.ts new file mode 100644 index 0000000000000000000000000000000000000000..2bd33df545f9d3f5ec526b8a3dc48ea5f0258953 --- /dev/null +++ b/packages/llm/test/tool.types.ts @@ -0,0 +1,40 @@ +import { Effect, Schema } from "effect" +import { LLM, LLMRequest, ToolRuntime, toDefinitions } from "../src" +import * as OpenAIChat from "../src/protocols/openai-chat" +import { Auth } from "../src/route" +import { Tool } from "../src/tool" + +const request = LLM.request({ + model: OpenAIChat.route.with({ auth: Auth.bearer("fixture") }).model({ id: "gpt-4o-mini" }), + prompt: "Use the tool.", +}) + +const executable = Tool.make({ + description: "Get weather.", + parameters: Schema.Struct({ city: Schema.String }), + success: Schema.Struct({ forecast: Schema.String }), + execute: (input) => Effect.succeed({ forecast: input.city }), +}) + +const schemaOnly = Tool.make({ + description: "Get weather.", + parameters: Schema.Struct({ city: Schema.String }), + success: Schema.Struct({ forecast: Schema.String }), +}) + +Tool.make({ + description: "Encode success before projection.", + parameters: Schema.Struct({ city: Schema.String }), + success: Schema.Struct({ forecast: Schema.NumberFromString }), + execute: () => Effect.succeed({ forecast: 1 }), + toModelOutput: ({ callID, parameters, output }) => [ + { type: "text", text: `${callID}:${parameters.city}:${output.forecast}` }, + ], +}) + +LLM.stream(request) +LLM.generate(LLMRequest.update(request, { tools: toDefinitions({ schemaOnly }) })) +ToolRuntime.dispatch({ executable }, { type: "tool-call", id: "call_1", name: "executable", input: { city: "Paris" } }) + +// @ts-expect-error High-level tool orchestration overloads are intentionally not supported. +LLM.stream({ request, tools: { schemaOnly } }) diff --git a/packages/storybook/.storybook/main.ts b/packages/storybook/.storybook/main.ts new file mode 100644 index 0000000000000000000000000000000000000000..3fb9dae29a85b6057c22d3e1fe728ea5ad725902 --- /dev/null +++ b/packages/storybook/.storybook/main.ts @@ -0,0 +1,74 @@ +import { defineMain } from "storybook-solidjs-vite" +import path from "node:path" +import { fileURLToPath } from "node:url" +import tailwindcss from "@tailwindcss/vite" +import { playgroundCss } from "./playground-css-plugin" + +const here = path.dirname(fileURLToPath(import.meta.url)) +const ui = path.resolve(here, "../../ui") +const sessionUi = path.resolve(here, "../../session-ui") +const app = path.resolve(here, "../../app/src") +const mocks = path.resolve(here, "./mocks") + +export default defineMain({ + framework: { + name: "storybook-solidjs-vite", + options: {}, + }, + addons: [ + "@storybook/addon-onboarding", + "@storybook/addon-docs", + "@storybook/addon-links", + "@storybook/addon-a11y", + "@storybook/addon-vitest", + ], + stories: [ + "../../ui/src/**/*.stories.@(js|jsx|mjs|ts|tsx)", + "../../session-ui/src/**/*.stories.@(js|jsx|mjs|ts|tsx)", + "../../app/src/**/*.stories.@(js|jsx|mjs|ts|tsx)", + ], + async viteFinal(config) { + const { mergeConfig, searchForWorkspaceRoot } = await import("vite") + return mergeConfig(config, { + plugins: [tailwindcss(), playgroundCss()], + resolve: { + dedupe: ["solid-js", "solid-js/web", "@solidjs/meta"], + alias: [ + { find: "@solidjs/router", replacement: path.resolve(mocks, "solid-router.tsx") }, + { find: /^@\/context\/local$/, replacement: path.resolve(mocks, "app/context/local.ts") }, + { find: /^@\/context\/file$/, replacement: path.resolve(mocks, "app/context/file.ts") }, + { find: /^@\/context\/prompt$/, replacement: path.resolve(mocks, "app/context/prompt.ts") }, + { find: /^@\/context\/layout$/, replacement: path.resolve(mocks, "app/context/layout.ts") }, + { find: /^@\/context\/sdk$/, replacement: path.resolve(mocks, "app/context/sdk.ts") }, + { find: /^@\/context\/sync$/, replacement: path.resolve(mocks, "app/context/sync.ts") }, + { find: /^@\/context\/comments$/, replacement: path.resolve(mocks, "app/context/comments.ts") }, + { find: /^@\/context\/command$/, replacement: path.resolve(mocks, "app/context/command.ts") }, + { find: /^@\/context\/permission$/, replacement: path.resolve(mocks, "app/context/permission.ts") }, + { find: /^@\/context\/language$/, replacement: path.resolve(mocks, "app/context/language.ts") }, + { find: /^@\/context\/platform$/, replacement: path.resolve(mocks, "app/context/platform.ts") }, + { find: /^@\/context\/global-sync$/, replacement: path.resolve(mocks, "app/context/global-sync.ts") }, + { find: /^@\/context\/server-sync$/, replacement: path.resolve(mocks, "app/context/server-sync.ts") }, + { find: /^@\/context\/server-sdk$/, replacement: path.resolve(mocks, "app/context/server-sdk.ts") }, + { find: /^@\/hooks\/use-providers$/, replacement: path.resolve(mocks, "app/hooks/use-providers.ts") }, + { + find: /^@\/components\/dialog-select-model$/, + replacement: path.resolve(mocks, "app/components/dialog-select-model.tsx"), + }, + { + find: /^@\/components\/dialog-select-model-unpaid$/, + replacement: path.resolve(mocks, "app/components/dialog-select-model-unpaid.tsx"), + }, + { find: "@", replacement: app }, + ], + }, + worker: { + format: "es", + }, + server: { + fs: { + allow: [searchForWorkspaceRoot(process.cwd()), ui, sessionUi, app, mocks], + }, + }, + }) + }, +}) diff --git a/packages/storybook/.storybook/manager.ts b/packages/storybook/.storybook/manager.ts new file mode 100644 index 0000000000000000000000000000000000000000..9af9ba0a828081f27e455b9b7a7d49bd0d529556 --- /dev/null +++ b/packages/storybook/.storybook/manager.ts @@ -0,0 +1,11 @@ +import { addons, types } from "storybook/manager-api" +import { ThemeTool } from "./theme-tool" + +addons.register("opencode/theme-toggle", () => { + addons.add("opencode/theme-toggle/tool", { + type: types.TOOL, + title: "Theme", + match: ({ viewMode }) => viewMode === "story" || viewMode === "docs", + render: ThemeTool, + }) +}) diff --git a/packages/storybook/.storybook/mocks/app/components/dialog-select-model-unpaid.tsx b/packages/storybook/.storybook/mocks/app/components/dialog-select-model-unpaid.tsx new file mode 100644 index 0000000000000000000000000000000000000000..8496c59a714f96663631f22a2d00c6256ddd8850 --- /dev/null +++ b/packages/storybook/.storybook/mocks/app/components/dialog-select-model-unpaid.tsx @@ -0,0 +1,3 @@ +export function DialogSelectModelUnpaid() { + return
Select model
+} diff --git a/packages/storybook/.storybook/mocks/app/components/dialog-select-model.tsx b/packages/storybook/.storybook/mocks/app/components/dialog-select-model.tsx new file mode 100644 index 0000000000000000000000000000000000000000..0741f7e4ef9e88a0ecf6fcc5bb1213c8f53461cc --- /dev/null +++ b/packages/storybook/.storybook/mocks/app/components/dialog-select-model.tsx @@ -0,0 +1,8 @@ +import { splitProps, type JSX } from "solid-js" + +export function ModelSelectorPopover(props: { trigger: (props: Record) => JSX.Element }) { + const [local] = splitProps(props, ["trigger"]) + return <>{local.trigger({})} +} + +export const ModelSelectorPopoverV2 = ModelSelectorPopover diff --git a/packages/storybook/.storybook/mocks/app/context/command.ts b/packages/storybook/.storybook/mocks/app/context/command.ts new file mode 100644 index 0000000000000000000000000000000000000000..16ff08bef4dbb6a99a842557068aaf3a4ad6a4fe --- /dev/null +++ b/packages/storybook/.storybook/mocks/app/context/command.ts @@ -0,0 +1,29 @@ +const keybinds: Record = { + "file.attach": "mod+u", + "prompt.mode.shell": "mod+shift+x", + "prompt.mode.normal": "mod+shift+e", + "permissions.autoaccept": "mod+shift+a", + "agent.cycle": "mod+.", + "model.choose": "mod+m", + "model.variant.cycle": "mod+shift+m", +} + +export function formatKeybind(config: string) { + return config === "none" ? "" : config +} + +export function useCommand() { + return { + options: [], + register() { + return () => undefined + }, + trigger() {}, + keybind(id: string) { + return keybinds[id] + }, + keybindParts(id: string) { + return keybinds[id]?.split("+") ?? [] + }, + } +} diff --git a/packages/storybook/.storybook/mocks/app/context/comments.ts b/packages/storybook/.storybook/mocks/app/context/comments.ts new file mode 100644 index 0000000000000000000000000000000000000000..6c01d203b811a9f76f1971d558b88d9103b1c8e8 --- /dev/null +++ b/packages/storybook/.storybook/mocks/app/context/comments.ts @@ -0,0 +1,34 @@ +import { createSignal } from "solid-js" + +type Comment = { + id: string + file: string + selection: { start: number; end: number } + comment: string + time: number +} + +const [list, setList] = createSignal([]) +const [focus, setFocus] = createSignal<{ file: string; id: string } | null>(null) +const [active, setActive] = createSignal<{ file: string; id: string } | null>(null) + +export function useComments() { + return { + all: list, + replace(next: Comment[]) { + setList(next) + }, + remove(file: string, id: string) { + setList((current) => current.filter((item) => !(item.file === file && item.id === id))) + }, + clear() { + setList([]) + setFocus(null) + setActive(null) + }, + focus, + setFocus, + active, + setActive, + } +} diff --git a/packages/storybook/.storybook/mocks/app/context/file.ts b/packages/storybook/.storybook/mocks/app/context/file.ts new file mode 100644 index 0000000000000000000000000000000000000000..db2158a5cd56b89697464d7798da37675531d792 --- /dev/null +++ b/packages/storybook/.storybook/mocks/app/context/file.ts @@ -0,0 +1,47 @@ +export type FileSelection = { + startLine: number + startChar: number + endLine: number + endChar: number +} + +export type SelectedLineRange = { + start: number + end: number +} + +export function selectionFromLines(selection?: SelectedLineRange): FileSelection | undefined { + if (!selection) return undefined + return { + startLine: selection.start, + startChar: 0, + endLine: selection.end, + endChar: 0, + } +} + +const pool = [ + "src/session/timeline.tsx", + "src/session/composer.tsx", + "src/components/prompt-input.tsx", + "src/components/session-todo-dock.tsx", + "README.md", +] + +export function useFile() { + return { + tab(path: string) { + return `file:${path}` + }, + pathFromTab(tab: string) { + if (!tab.startsWith("file:")) return "" + return tab.slice(5) + }, + load: async () => undefined, + async searchFilesAndDirectories(query: string) { + const text = query.trim().toLowerCase() + if (!text) return pool + return pool.filter((path) => path.toLowerCase().includes(text)) + }, + } +} diff --git a/packages/storybook/.storybook/mocks/app/context/global-sync.ts b/packages/storybook/.storybook/mocks/app/context/global-sync.ts new file mode 100644 index 0000000000000000000000000000000000000000..c538168bed03bee3d640bae1181f21d08acdb7b3 --- /dev/null +++ b/packages/storybook/.storybook/mocks/app/context/global-sync.ts @@ -0,0 +1,55 @@ +import { createStore } from "solid-js/store" + +const provider = { + all: [ + { + id: "anthropic", + models: { + "claude-3-7-sonnet": { + id: "claude-3-7-sonnet", + name: "Claude 3.7 Sonnet", + cost: { input: 1, output: 1 }, + }, + }, + }, + ], + connected: ["anthropic"], + default: { anthropic: "claude-3-7-sonnet" }, +} + +const [store, setStore] = createStore({ + todo: {} as Record, + provider, + session: [] as any[], + config: { permission: {} }, +}) + +export function useServerSync() { + return { + data: { + provider, + session_todo: store.todo, + }, + child() { + return [store, setStore] as const + }, + todo: { + set(sessionID: string, todos: any[]) { + setStore("todo", sessionID, todos) + }, + }, + } +} + +export function useQueryOptions() { + return { + agents: (directory: string) => ({ + queryKey: [directory, "agents"], + queryFn: async () => [], + }), + providers: (directory: string | null) => ({ + queryKey: [directory, "providers"], + queryFn: async () => provider, + }), + } +} diff --git a/packages/storybook/.storybook/mocks/app/context/language.ts b/packages/storybook/.storybook/mocks/app/context/language.ts new file mode 100644 index 0000000000000000000000000000000000000000..c48763b5b43d3620c0fdea6fa3352802de06c828 --- /dev/null +++ b/packages/storybook/.storybook/mocks/app/context/language.ts @@ -0,0 +1,150 @@ +const dict: Record = { + "session.todo.title": "Todos", + "session.todo.collapse": "Collapse todos", + "session.todo.expand": "Expand todos", + "session.revertDock.summary.one": "{{count}} rolled back message", + "session.revertDock.summary.other": "{{count}} rolled back messages", + "session.revertDock.collapse": "Collapse rolled back messages", + "session.revertDock.expand": "Expand rolled back messages", + "session.revertDock.restore": "Restore message", + "prompt.loading": "Loading prompt...", + "prompt.placeholder.normal": "Ask anything...", + "prompt.placeholder.simple": "Ask anything...", + "prompt.placeholder.shell": "Enter shell command... {{example}}", + "prompt.placeholder.summarizeComment": "Summarize this comment", + "prompt.placeholder.summarizeComments": "Summarize these comments", + "prompt.action.attachFile": "Attach files", + "prompt.action.send": "Send", + "prompt.action.stop": "Stop", + "prompt.attachment.remove": "Remove attachment", + "prompt.dropzone.label": "Drop image to attach", + "prompt.dropzone.file.label": "Drop file to attach", + "prompt.mode.shell": "Shell", + "prompt.mode.normal": "Prompt", + "dialog.model.select.title": "Select model", + "dialog.model.unpaid.freeModels.title": "Free models provided by OpenCode", + "dialog.model.unpaid.addMore.title": "Add more models from popular providers", + "dialog.model.unpaid.viewMoreProviders": "See 70+ more providers", + "dialog.provider.opencode.tagline": "Reliable optimized models", + "dialog.provider.opencodeGo.tagline": "Low cost subscription for everyone", + "dialog.provider.custom.label": "Custom OpenAI-compatible provider", + "dialog.provider.search.placeholder": "Search providers", + "dialog.provider.empty": "No providers found", + "dialog.provider.group.popular": "Popular", + "dialog.provider.group.other": "Other", + "dialog.provider.tag.recommended": "Recommended", + "settings.providers.tag.custom": "Custom", + "command.provider.connect": "Connect provider", + "provider.connect.title": "Connect {{provider}}", + "provider.connect.selectMethod": "Select login method for {{provider}}.", + "provider.connect.method.apiKey": "API key", + "provider.connect.apiKey.description": + "Enter your {{provider}} API key to connect your account and use {{provider}} models in OpenCode.", + "provider.connect.apiKey.label": "{{provider}} API key", + "provider.connect.apiKey.placeholder": "API key", + "provider.connect.apiKey.required": "API key is required", + "provider.connect.opencodeZen.line1": + "OpenCode Zen gives you access to a curated set of reliable optimized models for coding agents.", + "provider.connect.opencodeZen.line2": + "With a single API key you'll get access to models such as Claude, GPT, Gemini, GLM and more.", + "provider.connect.opencodeZen.visit.prefix": "Visit ", + "provider.connect.opencodeZen.visit.link": "opencode.ai/zen", + "provider.connect.opencodeZen.visit.suffix": " to collect your API key.", + "provider.connect.oauth.code.visit.prefix": "Visit ", + "provider.connect.oauth.code.visit.link": "this link", + "provider.connect.oauth.code.visit.suffix": + " to collect your authorization code to connect your account and use {{provider}} models in OpenCode.", + "provider.connect.oauth.code.label": "{{method}} authorization code", + "provider.connect.oauth.code.placeholder": "Authorization code", + "provider.connect.oauth.code.required": "Authorization code is required", + "provider.connect.oauth.code.invalid": "Invalid authorization code", + "provider.connect.oauth.auto.visit.prefix": "Visit ", + "provider.connect.oauth.auto.visit.link": "this link", + "provider.connect.oauth.auto.visit.suffix": + " and enter the code below to connect your account and use {{provider}} models in OpenCode.", + "provider.connect.oauth.auto.confirmationCode": "Confirmation code", + "provider.connect.status.inProgress": "Authorization in progress...", + "provider.connect.status.waiting": "Waiting for authorization...", + "provider.connect.status.failed": "Authorization failed: {{error}}", + "provider.connect.toast.connected.title": "{{provider}} connected", + "provider.connect.toast.connected.description": "{{provider}} models are now available to use.", + "common.continue": "Continue", + "model.tag.free": "Free", + "model.tag.latest": "Latest", + "model.input.text": "text", + "model.input.image": "image", + "model.input.audio": "audio", + "model.input.video": "video", + "model.input.pdf": "pdf", + "model.tooltip.context.label": "Context", + "model.tooltip.inputs": "Inputs", + "model.tooltip.model": "Model", + "model.tooltip.provider": "Provider", + "model.tooltip.reasoning": "Reasoning", + "model.tooltip.reasoning.allowed": "Allows reasoning", + "model.tooltip.reasoning.none": "No reasoning", + "common.close": "Close", + "common.goBack": "Go back", + "common.default": "Default", + "common.key.esc": "Esc", + "command.category.file": "File", + "command.category.session": "Session", + "command.agent.cycle": "Cycle agent", + "command.model.choose": "Choose model", + "command.model.variant.cycle": "Cycle model variant", + "command.prompt.mode.shell": "Switch to shell mode", + "command.prompt.mode.normal": "Switch to prompt mode", + "command.permissions.autoaccept.enable": "Enable auto-accept", + "command.permissions.autoaccept.disable": "Disable auto-accept", + "prompt.example.1": "Refactor this function and keep behavior the same", + "prompt.example.2": "Find the root cause of this error", + "prompt.example.3": "Write tests for this module", + "prompt.example.4": "Explain this diff", + "prompt.example.5": "Optimize this query", + "prompt.example.6": "Clean up this component", + "prompt.example.7": "Summarize the recent changes", + "prompt.example.8": "Add accessibility checks", + "prompt.example.9": "Review this API design", + "prompt.example.10": "Generate migration notes", + "prompt.example.11": "Patch this bug", + "prompt.example.12": "Make this animation smoother", + "prompt.example.13": "Improve error handling", + "prompt.example.14": "Document this feature", + "prompt.example.15": "Refine these styles", + "prompt.example.16": "Check edge cases", + "prompt.example.17": "Help me write a commit message", + "prompt.example.18": "Reduce re-renders in this component", + "prompt.example.19": "Verify keyboard navigation", + "prompt.example.20": "Make this copy clearer", + "prompt.example.21": "Add telemetry for this flow", + "prompt.example.22": "Compare these two implementations", + "prompt.example.23": "Create a minimal reproduction", + "prompt.example.24": "Suggest naming improvements", + "prompt.example.25": "What should we test next?", +} + +const plurals = new Intl.PluralRules("en-US") + +function render(template: string, params?: Record) { + if (!params) return template + return template.replace(/\{\{([^}]+)\}\}/g, (_, key: string) => { + const value = params[key.trim()] + if (value === undefined || value === null) return "" + // oxlint-disable-next-line no-base-to-string -- value is Record, always coerced intentionally + return String(value) + }) +} + +export function useLanguage() { + return { + locale: () => "en" as const, + intl: () => "en-US", + t(key: string, params?: Record) { + return render(dict[key] ?? key, params) + }, + plural(key: string, count: number, params?: Record) { + const value = dict[`${key}.${plurals.select(count)}`] ?? dict[`${key}.other`] ?? key + return render(value, { ...params, count }) + }, + } +} diff --git a/packages/storybook/.storybook/mocks/app/context/layout.ts b/packages/storybook/.storybook/mocks/app/context/layout.ts new file mode 100644 index 0000000000000000000000000000000000000000..0c5d6e97c9097c052ad5274ea3e15ffa522be7c0 --- /dev/null +++ b/packages/storybook/.storybook/mocks/app/context/layout.ts @@ -0,0 +1,41 @@ +import { createSignal } from "solid-js" + +const [all, setAll] = createSignal([]) +const [active, setActive] = createSignal(undefined) +const [reviewOpen, setReviewOpen] = createSignal(false) + +const tabs = { + all, + active, + open(tab: string) { + setAll((current) => (current.includes(tab) ? current : [...current, tab])) + }, + setActive(tab: string) { + if (!all().includes(tab)) { + tabs.open(tab) + } + setActive(tab) + }, +} + +const view = { + reviewPanel: { + opened: reviewOpen, + open() { + setReviewOpen(true) + }, + }, +} + +export function useLayout() { + return { + tabs: () => tabs, + view: () => view, + fileTree: { + setTab() {}, + }, + handoff: { + setTabs() {}, + }, + } +} diff --git a/packages/storybook/.storybook/mocks/app/context/local.ts b/packages/storybook/.storybook/mocks/app/context/local.ts new file mode 100644 index 0000000000000000000000000000000000000000..d1f02e5bb321e9d535ea8e24b4ddc5c133b429ff --- /dev/null +++ b/packages/storybook/.storybook/mocks/app/context/local.ts @@ -0,0 +1,41 @@ +import { createSignal } from "solid-js" + +const model = { + id: "claude-3-7-sonnet", + name: "Claude 3.7 Sonnet", + provider: { id: "anthropic" }, + variants: { fast: {}, thinking: {} }, +} + +const agents = [{ name: "build" }, { name: "review" }, { name: "plan" }] + +const [agent, setAgent] = createSignal(agents[0].name) +const [variant, setVariant] = createSignal(undefined) + +export function useLocal() { + return { + slug: () => "c3Rvcnk=", + agent: { + list: () => agents, + current: () => agents.find((item) => item.name === agent()) ?? agents[0], + set(value?: string) { + if (!value) { + setAgent(agents[0].name) + return + } + const hit = agents.find((item) => item.name === value) + setAgent(hit?.name ?? agents[0].name) + }, + }, + model: { + current: () => model, + variant: { + list: () => Object.keys(model.variants), + current: () => variant(), + set(next?: string) { + setVariant(next) + }, + }, + }, + } +} diff --git a/packages/storybook/.storybook/mocks/app/context/permission.ts b/packages/storybook/.storybook/mocks/app/context/permission.ts new file mode 100644 index 0000000000000000000000000000000000000000..bfae134a7fd10f50e1d755c527d85f7e85534206 --- /dev/null +++ b/packages/storybook/.storybook/mocks/app/context/permission.ts @@ -0,0 +1,27 @@ +const accepted = new Set() + +function key(sessionID: string, directory?: string) { + return `${directory ?? ""}:${sessionID}` +} + +export function usePermission() { + return { + autoResponds() { + return false + }, + isAutoAccepting(sessionID: string, directory?: string) { + return accepted.has(key(sessionID, directory)) + }, + isAutoAcceptingDirectory() { + return false + }, + toggleAutoAccept(sessionID: string, directory?: string) { + const next = key(sessionID, directory) + if (accepted.has(next)) { + accepted.delete(next) + return + } + accepted.add(next) + }, + } +} diff --git a/packages/storybook/.storybook/mocks/app/context/platform.ts b/packages/storybook/.storybook/mocks/app/context/platform.ts new file mode 100644 index 0000000000000000000000000000000000000000..bab923bff5545727cd544d3828e1bb5b90b48fce --- /dev/null +++ b/packages/storybook/.storybook/mocks/app/context/platform.ts @@ -0,0 +1,13 @@ +import type { Platform } from "../../../../../app/src/context/platform" + +const value: Platform = { + platform: "web", + openExternal() {}, + restart: async () => {}, + notify: async () => {}, + fetch: globalThis.fetch.bind(globalThis), +} + +export function usePlatform() { + return value +} diff --git a/packages/storybook/.storybook/mocks/app/context/prompt.ts b/packages/storybook/.storybook/mocks/app/context/prompt.ts new file mode 100644 index 0000000000000000000000000000000000000000..c41ba553febbb5d3240e6fbabadb4262fd0107fb --- /dev/null +++ b/packages/storybook/.storybook/mocks/app/context/prompt.ts @@ -0,0 +1,131 @@ +import { createStore } from "solid-js/store" + +interface PartBase { + content: string + start: number + end: number +} + +export interface TextPart extends PartBase { + type: "text" +} + +export interface FileAttachmentPart extends PartBase { + type: "file" + path: string +} + +export interface AgentPart extends PartBase { + type: "agent" + name: string +} + +export interface ImageAttachmentPart { + type: "image" + id: string + filename: string + mime: string + dataUrl: string +} + +export type ContentPart = TextPart | FileAttachmentPart | AgentPart | ImageAttachmentPart +export type Prompt = ContentPart[] + +type ContextItem = { + key: string + type: "file" + path: string + selection?: { startLine: number; startChar: number; endLine: number; endChar: number } + comment?: string + commentID?: string + commentOrigin?: "review" | "file" + preview?: string +} + +export const DEFAULT_PROMPT: Prompt = [{ type: "text", content: "", start: 0, end: 0 }] + +function clonePart(part: ContentPart): ContentPart { + if (part.type === "image") return { ...part } + if (part.type === "agent") return { ...part } + if (part.type === "file") return { ...part } + return { ...part } +} + +function clonePrompt(prompt: Prompt) { + return prompt.map(clonePart) +} + +export function isPromptEqual(a: Prompt, b: Prompt) { + if (a.length !== b.length) return false + return a.every((part, i) => JSON.stringify(part) === JSON.stringify(b[i])) +} + +export function isCommentItem(item: ContextItem) { + return !!item.comment?.trim() +} + +export function createPromptState() { + const [store, setStore] = createStore({ + prompt: clonePrompt(DEFAULT_PROMPT), + cursor: 0, + items: [] as ContextItem[], + }) + let index = 0 + const ready = Object.assign(() => true, { promise: Promise.resolve(true) }) + const withKey = (item: Omit & { key?: string }): ContextItem => ({ + ...item, + key: item.key ?? `ctx:${++index}`, + }) + + const value = { + ready, + current: () => store.prompt, + cursor: () => store.cursor, + dirty: () => !isPromptEqual(store.prompt, DEFAULT_PROMPT), + set(next: Prompt, cursorPosition?: number) { + setStore("prompt", clonePrompt(next)) + if (cursorPosition !== undefined) setStore("cursor", cursorPosition) + }, + reset() { + setStore("prompt", clonePrompt(DEFAULT_PROMPT)) + setStore("cursor", 0) + setStore("items", (current) => current.filter((item) => !!item.comment?.trim())) + }, + capture: () => value, + context: { + items: () => store.items, + add(item: Omit & { key?: string }) { + const next = withKey(item) + if (store.items.some((current) => current.key === next.key)) return + setStore("items", (current) => [...current, next]) + }, + remove(key: string) { + setStore("items", (current) => current.filter((item) => item.key !== key)) + }, + removeComment(path: string, commentID: string) { + setStore("items", (current) => + current.filter((item) => !(item.type === "file" && item.path === path && item.commentID === commentID)), + ) + }, + updateComment(path: string, commentID: string, next: Partial) { + setStore("items", (current) => + current.map((item) => { + if (item.type !== "file" || item.path !== path || item.commentID !== commentID) return item + return withKey({ ...item, ...next }) + }), + ) + }, + replaceComments(next: Array & { key?: string }>) { + const nonComment = store.items.filter((item) => !item.comment?.trim()) + setStore("items", [...nonComment, ...next.map(withKey)]) + }, + }, + } + return value +} + +const prompt = createPromptState() + +export function usePrompt() { + return prompt +} diff --git a/packages/storybook/.storybook/mocks/app/context/sdk.ts b/packages/storybook/.storybook/mocks/app/context/sdk.ts new file mode 100644 index 0000000000000000000000000000000000000000..749772bb3e13e2db1de470d6032d7a497b59ca67 --- /dev/null +++ b/packages/storybook/.storybook/mocks/app/context/sdk.ts @@ -0,0 +1,27 @@ +const make = (directory: string) => ({ + session: { + create: async () => ({ data: { id: "story-session" } }), + prompt: async () => ({ data: undefined }), + shell: async () => ({ data: undefined }), + command: async () => ({ data: undefined }), + abort: async () => ({ data: undefined }), + }, + worktree: { + create: async () => ({ data: { directory: `${directory}/worktree-1` } }), + }, +}) + +const root = "/tmp/story" +const sdk = { + directory: root, + scope: "story-server", + url: "http://localhost:4096", + client: make(root), + createClient(input: { directory: string }) { + return make(input.directory) + }, +} + +export function useSDK() { + return () => sdk +} diff --git a/packages/storybook/.storybook/mocks/app/context/server-sdk.ts b/packages/storybook/.storybook/mocks/app/context/server-sdk.ts new file mode 100644 index 0000000000000000000000000000000000000000..d0026dcff8f389bdc61a5bfd8dbd526ad25f9cba --- /dev/null +++ b/packages/storybook/.storybook/mocks/app/context/server-sdk.ts @@ -0,0 +1,47 @@ +const providers = [ + "opencode", + "opencode-go", + "anthropic", + "openai", + "google", + "openrouter", + "vercel", + "github-copilot", + "302ai", + "abacus", + "abliteration", + "alibaba", + "alibaba-cn", + "alibaba-coding-plan", +] + +const client = { + provider: { + auth: async () => ({ + data: Object.fromEntries(providers.map((provider) => [provider, [{ type: "api", label: "API key" }]])), + }), + oauth: { + authorize: async (input: { method?: number }) => ({ + data: { + url: "https://example.com/oauth", + method: input.method === 1 ? ("code" as const) : ("auto" as const), + instructions: input.method === 1 ? "Paste the authorization code" : "Confirmation code: ABCD-EFGH", + }, + }), + callback: async (input: { method?: number }) => { + if (input.method === 0) return new Promise(() => {}) + return { data: undefined } + }, + }, + }, + auth: { + set: async () => ({ data: true }), + }, + global: { + dispose: async () => ({ data: true }), + }, +} + +export function useServerSDK() { + return () => ({ client }) +} diff --git a/packages/storybook/.storybook/mocks/app/context/server-sync.ts b/packages/storybook/.storybook/mocks/app/context/server-sync.ts new file mode 100644 index 0000000000000000000000000000000000000000..1e835afdb8826e18e17f34193be5212cb76c1be0 --- /dev/null +++ b/packages/storybook/.storybook/mocks/app/context/server-sync.ts @@ -0,0 +1,33 @@ +import type { ProviderAuthMethod } from "@opencode-ai/sdk/v2/client" + +const data = { + provider: { + all: new Map(), + connected: [], + default: {}, + }, + provider_auth: {} as Record, + config: { disabled_providers: [] as string[] }, +} + +export function mockProviderAuth(provider: string, methods: ProviderAuthMethod[]) { + const previous = data.provider_auth[provider] + data.provider_auth[provider] = methods + return () => { + if (previous) { + data.provider_auth[provider] = previous + return + } + delete data.provider_auth[provider] + } +} + +export function useServerSync() { + return () => ({ + data, + set(key: "provider_auth", value: typeof data.provider_auth) { + data[key] = value + }, + updateConfig: async () => {}, + }) +} diff --git a/packages/storybook/.storybook/mocks/app/context/sync.ts b/packages/storybook/.storybook/mocks/app/context/sync.ts new file mode 100644 index 0000000000000000000000000000000000000000..1942927a3e7795d31ac396f9e18ad19477d6d0ba --- /dev/null +++ b/packages/storybook/.storybook/mocks/app/context/sync.ts @@ -0,0 +1,37 @@ +import { createStore } from "solid-js/store" + +const [data, setData] = createStore({ + session: [] as Array<{ id: string; parentID?: string }>, + permission: {} as Record>, + question: {} as Record>, + session_diff: {} as Record>, + message: { + "story-session": [] as Array<{ id: string; role: string }>, + } as Record>, + session_status: {} as Record, + session_working: () => false, + agent: [{ name: "build", mode: "task", hidden: false }], + command: [{ name: "fix", description: "Run fix command", source: "project" }], + reference: [], + mcp_resource: {}, +}) + +const sync = { + data, + set(...input: unknown[]) { + ;(setData as (...args: unknown[]) => void)(...input) + }, + session: { + get(id: string) { + return { id } + }, + optimistic: { + add() {}, + remove() {}, + }, + }, +} + +export function useSync() { + return () => sync +} diff --git a/packages/storybook/.storybook/mocks/app/hooks/use-providers.ts b/packages/storybook/.storybook/mocks/app/hooks/use-providers.ts new file mode 100644 index 0000000000000000000000000000000000000000..64ada6421b882da7b8cd42ccc8526158df1eceaa --- /dev/null +++ b/packages/storybook/.storybook/mocks/app/hooks/use-providers.ts @@ -0,0 +1,56 @@ +const model_id = "claude-3-7-sonnet" + +export const popularProviders = [ + "opencode", + "opencode-go", + "anthropic", + "github-copilot", + "openai", + "google", + "openrouter", + "vercel", +] + +const provider = { + id: "anthropic", + name: "Anthropic", + models: { + [model_id]: { + id: model_id, + name: "Claude 3.7 Sonnet", + cost: { input: 1, output: 1 }, + variants: { fast: {}, thinking: {} }, + }, + }, +} + +const popular = [ + { id: "opencode", name: "OpenCode Zen", models: {} }, + { id: "opencode-go", name: "OpenCode Go", models: {} }, + { id: "openai", name: "OpenAI", models: {} }, + provider, + { id: "google", name: "Google", models: {} }, + { id: "github-copilot", name: "GitHub Copilot", models: {} }, +] + +const catalog = [ + ...popular, + { id: "openrouter", name: "OpenRouter", models: {} }, + { id: "vercel", name: "Vercel AI Gateway", models: {} }, + { id: "302ai", name: "302.AI", models: {} }, + { id: "abacus", name: "Abacus", models: {} }, + { id: "abliteration", name: "abliteration.ai", models: {} }, + { id: "alibaba", name: "Alibaba", models: {} }, + { id: "alibaba-cn", name: "Alibaba (China)", models: {} }, + { id: "alibaba-coding-plan", name: "Alibaba Coding Plan", models: {} }, +] + +export function useProviders() { + return { + all: () => new Map(catalog.map((item) => [item.id, item])), + default: () => ({ anthropic: model_id }), + connected: () => [provider], + paid: () => [provider], + popular: () => popular, + } +} diff --git a/packages/storybook/.storybook/mocks/solid-router.tsx b/packages/storybook/.storybook/mocks/solid-router.tsx new file mode 100644 index 0000000000000000000000000000000000000000..6a2b53feb09412ddd2987c5446b9103a6212ebe4 --- /dev/null +++ b/packages/storybook/.storybook/mocks/solid-router.tsx @@ -0,0 +1,32 @@ +import type { ParentProps } from "solid-js" + +export function useParams() { + return { + dir: "c3Rvcnk=", + id: "story-session", + } +} + +export function useNavigate() { + return () => undefined +} + +export function useSearchParams>() { + return [{} as Partial, () => undefined] as const +} + +export function useLocation() { + return { + pathname: "/story/session/story-session", + search: "", + hash: "", + } +} + +export function MemoryRouter(props: ParentProps) { + return props.children +} + +export function Route(props: ParentProps) { + return props.children +} diff --git a/packages/storybook/.storybook/playground-css-plugin.ts b/packages/storybook/.storybook/playground-css-plugin.ts new file mode 100644 index 0000000000000000000000000000000000000000..567ea9a5fcf26f2e80e8b352683b643c32b0b41e --- /dev/null +++ b/packages/storybook/.storybook/playground-css-plugin.ts @@ -0,0 +1,136 @@ +/** + * Vite plugin that exposes a POST endpoint for the timeline playground + * to write CSS changes back to source files on disk. + * + * POST /__playground/apply-css + * Body: { edits: Array<{ file: string; anchor: string; prop: string; value: string }> } + * + * For each edit the plugin finds `anchor` in the file, then locates the + * next `prop: ;` after it and replaces the value portion. + * `file` is a basename resolved against the UI component packages. + */ +import type { Plugin } from "vite" +import type { IncomingMessage, ServerResponse } from "node:http" +import fs from "node:fs" +import path from "node:path" +import { fileURLToPath } from "node:url" + +const here = path.dirname(fileURLToPath(import.meta.url)) +const roots = [path.resolve(here, "../../session-ui/src/components"), path.resolve(here, "../../ui/src/components")] + +const ENDPOINT = "/__playground/apply-css" + +type Edit = { file: string; anchor: string; prop: string; value: string } +type Result = { file: string; prop: string; ok: boolean; error?: string } + +function applyEdits(content: string, edits: Edit[]): { content: string; results: Result[] } { + const results: Result[] = [] + let out = content + + for (const edit of edits) { + const name = edit.file + const idx = out.indexOf(edit.anchor) + if (idx === -1) { + results.push({ file: name, prop: edit.prop, ok: false, error: `Anchor not found: ${edit.anchor.slice(0, 50)}` }) + continue + } + + // From the anchor position, find the next occurrence of `prop: ` + // We match `prop:` followed by any value up to `;` + const after = out.slice(idx) + const re = new RegExp(`(${escapeRegex(edit.prop)}\\s*:\\s*)([^;]+)(;)`) + const match = re.exec(after) + if (!match) { + results.push({ file: name, prop: edit.prop, ok: false, error: `Property "${edit.prop}" not found after anchor` }) + continue + } + + const start = idx + match.index + match[1].length + const end = start + match[2].length + out = out.slice(0, start) + edit.value + out.slice(end) + results.push({ file: name, prop: edit.prop, ok: true }) + } + + return { content: out, results } +} + +function escapeRegex(s: string) { + return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") +} + +export function playgroundCss(): Plugin { + return { + name: "playground-css", + configureServer(server) { + server.middlewares.use((req: IncomingMessage, res: ServerResponse, next: () => void) => { + if (req.url !== ENDPOINT) return next() + if (req.method !== "POST") { + res.statusCode = 405 + res.setHeader("Content-Type", "application/json") + res.end(JSON.stringify({ error: "Method not allowed" })) + return + } + + let data = "" + req.on("data", (chunk: Buffer) => { + data += chunk.toString() + }) + req.on("end", () => { + let payload: { edits: Edit[] } + try { + payload = JSON.parse(data) + } catch { + res.statusCode = 400 + res.setHeader("Content-Type", "application/json") + res.end(JSON.stringify({ error: "Invalid JSON" })) + return + } + + if (!Array.isArray(payload.edits)) { + res.statusCode = 400 + res.setHeader("Content-Type", "application/json") + res.end(JSON.stringify({ error: "Missing edits array" })) + return + } + + // Group by file + const grouped = new Map() + for (const edit of payload.edits) { + if (!edit.file || !edit.anchor || !edit.prop || edit.value === undefined) continue + const abs = roots.map((root) => path.resolve(root, edit.file)).find((file) => fs.existsSync(file)) + if (!abs || !roots.some((root) => abs.startsWith(root))) continue + const key = abs + if (!grouped.has(key)) grouped.set(key, []) + grouped.get(key)!.push(edit) + } + + const results: Result[] = [] + + for (const [abs, edits] of grouped) { + const name = path.basename(abs) + if (!fs.existsSync(abs)) { + for (const e of edits) results.push({ file: name, prop: e.prop, ok: false, error: "File not found" }) + continue + } + + try { + const content = fs.readFileSync(abs, "utf-8") + const applied = applyEdits(content, edits) + results.push(...applied.results) + + if (applied.results.some((r) => r.ok)) { + fs.writeFileSync(abs, applied.content, "utf-8") + } + } catch (err) { + for (const e of edits) results.push({ file: name, prop: e.prop, ok: false, error: String(err) }) + } + } + + res.statusCode = 200 + res.setHeader("Content-Type", "application/json") + res.end(JSON.stringify({ results })) + }) + }) + }, + } +} diff --git a/packages/storybook/.storybook/preview.tsx b/packages/storybook/.storybook/preview.tsx new file mode 100644 index 0000000000000000000000000000000000000000..7c13eb979d3790b37f43b2058059b11d4b09a4ec --- /dev/null +++ b/packages/storybook/.storybook/preview.tsx @@ -0,0 +1,112 @@ +import "@opencode-ai/ui/styles/tailwind" +import "@opencode-ai/session-ui/styles" +import "@opencode-ai/ui/v2/styles/tailwind.css" + +import { createEffect, onCleanup, onMount } from "solid-js" +import addonA11y from "@storybook/addon-a11y" +import addonDocs from "@storybook/addon-docs" +import { MetaProvider } from "@solidjs/meta" +import { addons } from "storybook/preview-api" +import { GLOBALS_UPDATED } from "storybook/internal/core-events" +import { createJSXDecorator, definePreview } from "storybook-solidjs-vite" +import { DialogProvider } from "@opencode-ai/ui/context/dialog" +import { MarkedProvider } from "@opencode-ai/ui/context/marked" +import { ThemeProvider, useTheme, type ColorScheme } from "@opencode-ai/ui/theme" +import { Font } from "@opencode-ai/ui/font" + +function resolveScheme(value: unknown): ColorScheme { + if (value === "light" || value === "dark" || value === "system") return value + return "system" +} + +const channel = addons.getChannel() + +const Scheme = (props: { value?: unknown }) => { + const theme = useTheme() + const apply = (value?: unknown) => { + theme.setColorScheme(resolveScheme(value)) + } + createEffect(() => { + apply(props.value) + }) + createEffect(() => { + const root = document.documentElement + root.classList.remove("light", "dark") + root.classList.add(theme.mode()) + }) + onMount(() => { + const handler = (event: { globals?: Record }) => { + apply(event.globals?.theme) + } + channel.on(GLOBALS_UPDATED, handler) + onCleanup(() => channel.off(GLOBALS_UPDATED, handler)) + }) + return null +} + +const NewLayout = () => { + // Mirror app.tsx BodyDesignClass so stories render with v2 (new-layout) styles + // instead of the legacy `body:not([data-new-layout])` branch. + onMount(() => { + document.body.toggleAttribute("data-new-layout", true) + document.body.classList.add("font-(family-name:--font-family-text)", "text-[13px]", "font-[440]") + document.body.classList.remove("text-12-regular") + }) + return null +} + +const frame = createJSXDecorator((Story, context) => { + const override = context.parameters?.themes?.themeOverride + const selected = context.globals?.theme + const pick = override === "light" || override === "dark" ? override : selected + const scheme = resolveScheme(pick) + return ( + + + + + + + +
+ +
+
+
+
+
+ ) +}) + +export default definePreview({ + addons: [addonDocs(), addonA11y()], + decorators: [frame], + globalTypes: { + theme: { + name: "Theme", + description: "Global theme", + defaultValue: "light", + }, + }, + parameters: { + actions: { + argTypesRegex: "^on.*", + }, + controls: { + matchers: { + color: /(background|color)$/i, + date: /Date$/i, + }, + }, + a11y: { + test: "todo", + }, + }, +}) diff --git a/packages/storybook/.storybook/theme-tool.ts b/packages/storybook/.storybook/theme-tool.ts new file mode 100644 index 0000000000000000000000000000000000000000..3dac777cd7dfad1554178425393d2e72e5942c37 --- /dev/null +++ b/packages/storybook/.storybook/theme-tool.ts @@ -0,0 +1,21 @@ +import { createElement } from "react" +import { useGlobals } from "storybook/manager-api" +import { ToggleButton } from "storybook/internal/components" + +export function ThemeTool() { + const [globals, updateGlobals] = useGlobals() + const mode = globals.theme === "dark" ? "dark" : "light" + const toggle = () => { + const next = mode === "dark" ? "light" : "dark" + updateGlobals({ theme: next }) + } + return createElement( + ToggleButton, + { + title: "Toggle theme", + active: mode === "dark", + onClick: toggle, + }, + mode === "dark" ? "Dark" : "Light", + ) +} diff --git a/packages/ui/script/build-oc2-v2-overrides.ts b/packages/ui/script/build-oc2-v2-overrides.ts new file mode 100644 index 0000000000000000000000000000000000000000..e5dae76ef374a90e940e32812715b939c69c0c1d --- /dev/null +++ b/packages/ui/script/build-oc2-v2-overrides.ts @@ -0,0 +1,32 @@ +#!/usr/bin/env bun + +import { V2_PRIMITIVES_DEFAULT } from "../src/theme/v2/default-primitives" +import type { DesktopTheme } from "../src/theme/types" + +const themePath = import.meta.dir + "/../src/theme/themes/oc-2.json" +const theme = (await Bun.file(themePath).json()) as DesktopTheme +const css = await Bun.file(import.meta.dir + "/../src/v2/styles/theme.css").text() + +const light = { ...V2_PRIMITIVES_DEFAULT, ...readTokens("light") } +const dark = { ...V2_PRIMITIVES_DEFAULT, ...readTokens("dark") } + +const next: DesktopTheme = { + ...theme, + light: { ...theme.light, v2Overrides: light }, + dark: { ...theme.dark, v2Overrides: dark }, +} + +await Bun.write(themePath, JSON.stringify(next, null, 2) + "\n") +console.log("Updated oc-2.json v2Overrides", Object.keys(light).length, "tokens per mode") + +function readTokens(mode: "light" | "dark") { + const selector = mode === "light" ? ":root" : `\\[data-color-scheme="${mode}"\\]` + const block = css.match(new RegExp(`${selector} \\{([\\s\\S]*?)\\n \\}`))?.[1] + if (!block) throw new Error(`Missing ${mode} OC-2 tokens`) + return Object.fromEntries( + [...block.matchAll(/--(v2-[\w-]+):\s*([^;]+);/g)] + // Fonts and the fixed avatar foreground remain global CSS rather than theme overrides. + .filter(([, key]) => key !== "v2-avatar-fg" && key !== "v2-font-family-sans") + .map(([, key, value]) => [key, value!.replace(/\s+/g, " ").trim()]), + ) +} diff --git a/packages/ui/script/colors.txt b/packages/ui/script/colors.txt new file mode 100644 index 0000000000000000000000000000000000000000..c49959e11748077f6bd84324fd8c1b254986e83b --- /dev/null +++ b/packages/ui/script/colors.txt @@ -0,0 +1,286 @@ +--background-base: #F8F7F7; +--background-weak: var(--smoke-light-3); +--background-strong: var(--smoke-light-1); +--background-stronger: #FCFCFC; +--surface-base: var(--smoke-light-alpha-2); +--base: var(--smoke-light-alpha-2); +--surface-base-hover: #0500000F; +--surface-base-active: var(--smoke-light-alpha-3); +--surface-base-interactive-active: var(--cobalt-light-alpha-3); +--base2: var(--smoke-light-alpha-2); +--base3: var(--smoke-light-alpha-2); +--surface-inset-base: var(--smoke-light-alpha-2); +--surface-inset-base-hover: var(--smoke-light-alpha-3); +--surface-inset-strong: #1F000017; +--surface-inset-strong-hover: #1F000017; +--surface-raised-base: var(--smoke-light-alpha-1); +--surface-float-base: var(--smoke-dark-1); +--surface-float-base-hover: var(--smoke-dark-2); +--surface-raised-base-hover: var(--smoke-light-alpha-2); +--surface-raised-base-active: var(--smoke-light-alpha-3); +--surface-raised-strong: var(--smoke-light-1); +--surface-raised-strong-hover: var(--white); +--surface-raised-stronger: var(--white); +--surface-raised-stronger-hover: var(--white); +--surface-weak: var(--smoke-light-alpha-3); +--surface-weaker: var(--smoke-light-alpha-4); +--surface-strong: #FFFFFF; +--surface-raised-stronger-non-alpha: var(--white); +--surface-brand-base: var(--yuzu-light-9); +--surface-brand-hover: var(--yuzu-light-10); +--surface-interactive-base: var(--cobalt-light-3); +--surface-interactive-hover: var(--cobalt-light-4); +--surface-interactive-weak: var(--cobalt-light-2); +--surface-interactive-weak-hover: var(--cobalt-light-3); +--surface-success-base: var(--apple-light-3); +--surface-success-weak: var(--apple-light-2); +--surface-success-strong: var(--apple-light-9); +--surface-warning-base: var(--solaris-light-3); +--surface-warning-weak: var(--solaris-light-2); +--surface-warning-strong: var(--solaris-light-9); +--surface-critical-base: var(--ember-light-3); +--surface-critical-weak: var(--ember-light-2); +--surface-critical-strong: var(--ember-light-9); +--surface-info-base: var(--lilac-light-3); +--surface-info-weak: var(--lilac-light-2); +--surface-info-strong: var(--lilac-light-9); +--surface-diff-unchanged-base: #FFFFFF00; +--surface-diff-skip-base: var(--smoke-light-2); +--surface-diff-hidden-base: var(--blue-light-3); +--surface-diff-hidden-weak: var(--blue-light-2); +--surface-diff-hidden-weaker: var(--blue-light-1); +--surface-diff-hidden-strong: var(--blue-light-5); +--surface-diff-hidden-stronger: var(--blue-light-9); +--surface-diff-add-base: var(--mint-light-3); +--surface-diff-add-weak: var(--mint-light-2); +--surface-diff-add-weaker: var(--mint-light-1); +--surface-diff-add-strong: var(--mint-light-5); +--surface-diff-add-stronger: var(--mint-light-9); +--surface-diff-delete-base: var(--ember-light-3); +--surface-diff-delete-weak: var(--ember-light-2); +--surface-diff-delete-weaker: var(--ember-light-1); +--surface-diff-delete-strong: var(--ember-light-6); +--surface-diff-delete-stronger: var(--ember-light-9); +--text-base: var(--smoke-light-11); +--input-base: var(--smoke-light-1); +--input-hover: var(--smoke-light-2); +--input-active: var(--cobalt-light-1); +--input-selected: var(--cobalt-light-4); +--input-focus: var(--cobalt-light-1); +--input-disabled: var(--smoke-light-4); +--text-weak: var(--smoke-light-9); +--text-weaker: var(--smoke-light-8); +--text-strong: var(--smoke-light-12); +--text-interactive-base: var(--cobalt-light-9); +--text-on-brand-base: var(--smoke-light-alpha-11); +--text-on-interactive-base: var(--smoke-light-1); +--text-on-interactive-weak: var(--smoke-dark-alpha-11); +--text-on-success-base: var(--apple-light-10); +--text-on-critical-base: var(--ember-light-10); +--text-on-critical-weak: var(--ember-light-8); +--text-on-critical-strong: var(--ember-light-12); +--text-on-warning-base: var(--smoke-dark-alpha-11); +--text-on-info-base: var(--smoke-dark-alpha-11); +--text-diff-add-base: var(--mint-light-11); +--text-diff-delete-base: var(--ember-light-10); +--text-diff-delete-strong: var(--ember-light-12); +--text-diff-add-strong: var(--mint-light-12); +--text-on-info-weak: var(--smoke-dark-alpha-9); +--text-on-info-strong: var(--smoke-dark-alpha-12); +--text-on-warning-weak: var(--smoke-dark-alpha-9); +--text-on-warning-strong: var(--smoke-dark-alpha-12); +--text-on-success-weak: var(--apple-light-6); +--text-on-success-strong: var(--apple-light-12); +--text-on-brand-weak: var(--smoke-light-alpha-9); +--text-on-brand-weaker: var(--smoke-light-alpha-8); +--text-on-brand-strong: var(--smoke-light-alpha-12); +--button-secondary-base: #FDFCFC; +--button-secondary-hover: #FAF9F9; +--border-base: var(--smoke-light-alpha-7); +--border-hover: var(--smoke-light-alpha-8); +--border-active: var(--smoke-light-alpha-9); +--border-selected: var(--cobalt-light-alpha-9); +--border-disabled: var(--smoke-light-alpha-8); +--border-focus: var(--smoke-light-alpha-9); +--border-weak-base: var(--smoke-light-alpha-5); +--border-strong-base: var(--smoke-light-alpha-7); +--border-strong-hover: var(--smoke-light-alpha-8); +--border-strong-active: var(--smoke-light-alpha-7); +--border-strong-selected: var(--cobalt-light-alpha-6); +--border-strong-disabled: var(--smoke-light-alpha-6); +--border-strong-focus: var(--smoke-light-alpha-7); +--border-weak-hover: var(--smoke-light-alpha-6); +--border-weak-active: var(--smoke-light-alpha-7); +--border-weak-selected: var(--cobalt-light-alpha-5); +--border-weak-disabled: var(--smoke-light-alpha-6); +--border-weak-focus: var(--smoke-light-alpha-7); +--border-weaker-base: var(--smoke-light-alpha-3); +--border-interactive-base: var(--cobalt-light-7); +--border-interactive-hover: var(--cobalt-light-8); +--border-interactive-active: var(--cobalt-light-9); +--border-interactive-selected: var(--cobalt-light-9); +--border-interactive-disabled: var(--smoke-light-8); +--border-interactive-focus: var(--cobalt-light-9); +--border-success-base: var(--apple-light-6); +--border-success-hover: var(--apple-light-7); +--border-success-selected: var(--apple-light-9); +--border-warning-base: var(--solaris-light-6); +--border-warning-hover: var(--solaris-light-7); +--border-warning-selected: var(--solaris-light-9); +--border-critical-base: var(--ember-light-6); +--border-critical-hover: var(--ember-light-7); +--border-critical-selected: var(--ember-light-9); +--border-info-base: var(--lilac-light-6); +--border-info-hover: var(--lilac-light-7); +--border-info-selected: var(--lilac-light-9); +--icon-base: var(--smoke-light-9); +--icon-hover: var(--smoke-light-11); +--icon-active: var(--smoke-light-12); +--icon-selected: var(--smoke-light-12); +--icon-disabled: var(--smoke-light-8); +--icon-focus: var(--smoke-light-12); +--icon-invert-base: #FFFFFF; +--icon-weak-base: var(--smoke-light-7); +--icon-weak-hover: var(--smoke-light-8); +--icon-weak-active: var(--smoke-light-9); +--icon-weak-selected: var(--smoke-light-10); +--icon-weak-disabled: var(--smoke-light-6); +--icon-weak-focus: var(--smoke-light-9); +--icon-strong-base: var(--smoke-light-12); +--icon-strong-hover: #151313; +--icon-strong-active: #020202; +--icon-strong-selected: #020202; +--icon-strong-disabled: var(--smoke-light-8); +--icon-strong-focus: #020202; +--icon-brand-base: var(--smoke-light-12); +--icon-interactive-base: var(--cobalt-light-9); +--icon-success-base: var(--apple-light-7); +--icon-success-hover: var(--apple-light-8); +--icon-success-active: var(--apple-light-11); +--icon-warning-base: var(--amber-light-7); +--icon-warning-hover: var(--amber-light-8); +--icon-warning-active: var(--amber-light-11); +--icon-critical-base: var(--ember-light-10); +--icon-critical-hover: var(--ember-light-11); +--icon-critical-active: var(--ember-light-12); +--icon-info-base: var(--lilac-light-7); +--icon-info-hover: var(--lilac-light-8); +--icon-info-active: var(--lilac-light-11); +--icon-on-brand-base: var(--smoke-light-alpha-11); +--icon-on-brand-hover: var(--smoke-light-alpha-12); +--icon-on-brand-selected: var(--smoke-light-alpha-12); +--icon-on-interactive-base: var(--smoke-light-1); +--icon-agent-plan-base: var(--purple-light-9); +--icon-agent-docs-base: var(--amber-light-9); +--icon-agent-ask-base: var(--cyan-light-9); +--icon-agent-build-base: var(--cobalt-light-9); +--icon-on-success-base: var(--apple-light-alpha-9); +--icon-on-success-hover: var(--apple-light-alpha-10); +--icon-on-success-selected: var(--apple-light-alpha-11); +--icon-on-warning-base: var(--amber-lightalpha-9); +--icon-on-warning-hover: var(--amber-lightalpha-10); +--icon-on-warning-selected: var(--amber-lightalpha-11); +--icon-on-critical-base: var(--ember-light-alpha-9); +--icon-on-critical-hover: var(--ember-light-alpha-10); +--icon-on-critical-selected: var(--ember-light-alpha-11); +--icon-on-info-base: var(--lilac-light-9); +--icon-on-info-hover: var(--lilac-light-alpha-10); +--icon-on-info-selected: var(--lilac-light-alpha-11); +--icon-diff-add-base: var(--mint-light-11); +--icon-diff-add-hover: var(--mint-light-12); +--icon-diff-add-active: var(--mint-light-12); +--icon-diff-delete-base: var(--ember-light-10); +--icon-diff-delete-hover: var(--ember-light-11); +--syntax-comment: var(--text-weaker); +--syntax-regexp: var(--text-base); +--syntax-string: #007663; +--syntax-keyword: var(--text-weak); +--syntax-primitive: #FB7F51; +--syntax-operator: var(--text-weak); +--syntax-variable: var(--text-strong); +--syntax-property: #EC6CC8; +--syntax-type: #738400; +--syntax-constant: #00B2B9; +--syntax-punctuation: var(--text-weaker); +--syntax-object: var(--text-strong); +--syntax-success: var(--apple-light-10); +--syntax-warning: var(--amber-light-10); +--syntax-critical: var(--ember-light-9); +--syntax-info: #0091A7; +--syntax-diff-add: var(--mint-light-11); +--syntax-diff-delete: var(--ember-light-11); +--syntax-diff-unknown: #FF0000; +--markdown-heading: #D68C27; +--markdown-text: #1A1A1A; +--markdown-link: #3B7DD8; +--markdown-link-text: #318795; +--markdown-code: #3D9A57; +--markdown-block-quote: #B0851F; +--markdown-emph: #B0851F; +--markdown-strong: #D68C27; +--markdown-horizontal-rule: #8A8A8A; +--markdown-list-item: #3B7DD8; +--markdown-list-enumeration: #318795; +--markdown-image: #3B7DD8; +--markdown-image-text: #318795; +--markdown-code-block: #1A1A1A; +--border-color: #FFFFFF; +--button-ghost-hover: var(--smoke-light-alpha-2); +--button-ghost-hover2: var(--smoke-light-alpha-3); + +--v2-background-bg-base: var(--v2-grey-50); +--v2-background-bg-deep: var(--v2-grey-100); +--v2-background-bg-layer-01: var(--v2-grey-100); +--v2-background-bg-layer-02: var(--v2-grey-200); +--v2-background-bg-layer-03: var(--v2-grey-300); +--v2-background-bg-layer-04: var(--v2-grey-400); +--v2-background-bg-inverse: var(--v2-grey-1100); +--v2-background-bg-contrast: var(--v2-grey-1000); +--v2-background-bg-button-neutral: var(--v2-grey-50); +--v2-background-bg-accent: var(--v2-blue-600); + +--v2-text-text-base: var(--v2-grey-1100); +--v2-text-text-muted: var(--v2-grey-700); +--v2-text-text-faint: var(--v2-grey-600); +--v2-text-text-inverse: var(--v2-grey-50); +--v2-text-text-contrast: var(--v2-grey-50); +--v2-text-text-accent: var(--v2-blue-600); +--v2-text-text-accent-hover: var(--v2-blue-700); +--v2-text-text-code-accent: var(--v2-blue-900); + +--v2-icon-icon-base: var(--v2-grey-800); +--v2-icon-icon-muted: var(--v2-grey-600); +--v2-icon-icon-inverse: var(--v2-grey-50); +--v2-icon-icon-contrast: var(--v2-grey-100); +--v2-icon-icon-accent: var(--v2-blue-600); +--v2-icon-icon-accent-hover: var(--v2-blue-700); + +--v2-border-border-muted: var(--v2-alpha-dark-8); +--v2-border-border-base: var(--v2-alpha-dark-10); +--v2-border-border-strong: var(--v2-alpha-dark-20); +--v2-border-border-inverse: var(--v2-grey-1000); +--v2-border-border-focus: var(--v2-blue-500); + +--v2-overlay-simple-overlay-hover: var(--v2-alpha-dark-4); +--v2-overlay-simple-overlay-pressed: var(--v2-alpha-dark-8); +--v2-overlay-simple-overlay-contrast-hover: var(--v2-alpha-light-12); +--v2-overlay-simple-overlay-contrast-pressed: var(--v2-alpha-light-24); +--v2-overlay-simple-overlay-scrim: var(--v2-alpha-dark-40); +--v2-overlay-gradient-depth-overlay-depth-top: var(--v2-alpha-light-100); +--v2-overlay-gradient-depth-overlay-depth-bot: var(--v2-alpha-light-0); +--v2-overlay-simple-tab-active-scrim: #fafafa00; +--v2-overlay-simple-tab-hover-scrim: #eeeeee00; +--v2-overlay-simple-tab-scrim: #fafafa00; + +--v2-state-bg-success: var(--v2-green-100); +--v2-state-fg-success: var(--v2-green-800); +--v2-state-border-success: var(--v2-green-300); +--v2-state-bg-warning: var(--v2-yellow-100); +--v2-state-fg-warning: var(--v2-yellow-800); +--v2-state-border-warning: var(--v2-yellow-300); +--v2-state-bg-danger: var(--v2-red-100); +--v2-state-fg-danger: var(--v2-red-800); +--v2-state-border-danger: var(--v2-red-300); +--v2-state-bg-info: var(--v2-blue-100); +--v2-state-fg-info: var(--v2-blue-800); + --v2-state-border-info: var(--v2-blue-300); diff --git a/packages/ui/script/pack.ts b/packages/ui/script/pack.ts new file mode 100644 index 0000000000000000000000000000000000000000..078fb6d7741330d5bc87bd8cc3fe51ea450e2f7a --- /dev/null +++ b/packages/ui/script/pack.ts @@ -0,0 +1,40 @@ +#!/usr/bin/env bun + +import { $ } from "bun" +import { rm } from "node:fs/promises" +import path from "node:path" + +export async function pack() { + const original = await Bun.file("package.json").text() + const pkg = JSON.parse(original) as { + name: string + version: string + exports: Record + } + const tarball = path.resolve(`${pkg.name.replace("@", "").replace("/", "-")}-${pkg.version}.tgz`) + + await $`bun run build` + pkg.exports = Object.fromEntries( + Object.entries(pkg.exports).map(([key, value]) => { + if (typeof value !== "string" || (!value.endsWith(".ts") && !value.endsWith(".tsx"))) return [key, value] + return [ + key, + { + types: value.replace("./src/", "./dist/").replace(/\.tsx?$/, ".d.ts"), + import: value, + }, + ] + }), + ) + + await rm(tarball, { force: true }) + await Bun.write("package.json", JSON.stringify(pkg, null, 2) + "\n") + try { + await $`bun pm pack` + return tarball + } finally { + await Bun.write("package.json", original) + } +} + +if (import.meta.main) console.log(await pack()) diff --git a/packages/ui/script/publish.ts b/packages/ui/script/publish.ts new file mode 100644 index 0000000000000000000000000000000000000000..ba81ab7dfee881a9a5984983c3fd68d231818b8b --- /dev/null +++ b/packages/ui/script/publish.ts @@ -0,0 +1,26 @@ +#!/usr/bin/env bun + +import { Script } from "@opencode-ai/script" +import { $ } from "bun" +import { rm } from "node:fs/promises" +import { fileURLToPath } from "node:url" +import { pack } from "./pack" + +process.chdir(fileURLToPath(new URL("..", import.meta.url))) + +const pkg = (await Bun.file("package.json").json()) as { name: string; version: string } +const tarball = `${pkg.name.replace("@", "").replace("/", "-")}-${pkg.version}.tgz` + +if ((await $`npm view ${pkg.name}@${pkg.version} version`.nothrow()).exitCode === 0) { + console.log(`already published ${pkg.name}@${pkg.version}`) + process.exit(0) +} + +try { + await $`bun run typecheck` + await $`bun run test` + await pack() + await $`npm publish ${tarball} --access public --tag ${Script.channel}` +} finally { + await rm(tarball, { force: true }) +} diff --git a/packages/ui/script/tailwind.ts b/packages/ui/script/tailwind.ts new file mode 100644 index 0000000000000000000000000000000000000000..6e558a8fa11cc024b71d2e311f4d27ebc89c0cf8 --- /dev/null +++ b/packages/ui/script/tailwind.ts @@ -0,0 +1,23 @@ +#!/usr/bin/env bun + +const colors = await Bun.file(import.meta.dir + "/colors.txt").text() + +const variables = [] +for (const line of colors.split("\n")) { + if (!line.trim()) continue + const [variable] = line.trim().split(":") + const name = variable!.trim().substring(2) + variables.push(`--color-${name}: var(--${name});`) +} + +const output = ` +/* Generated by script/tailwind.ts */ +/* Do not edit this file manually */ + +@theme { + --color-*: initial; + ${variables.join("\n ")} +} +` + +await Bun.file(import.meta.dir + "/../src/styles/tailwind/colors.css").write(output.trim()) diff --git a/packages/ui/src/assets/icons/file-types/abap.svg b/packages/ui/src/assets/icons/file-types/abap.svg new file mode 100644 index 0000000000000000000000000000000000000000..0a9b08390fe0135000f043e54f29461b169320e7 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/abap.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/abc.svg b/packages/ui/src/assets/icons/file-types/abc.svg new file mode 100644 index 0000000000000000000000000000000000000000..7c7cb534c352ad66be8664ff7616c5f1c5ae3c49 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/abc.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/ada.svg b/packages/ui/src/assets/icons/file-types/ada.svg new file mode 100644 index 0000000000000000000000000000000000000000..613646fa1200a6197cbeb2655d6a8c6f7676f1dd --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/ada.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/adobe-illustrator.svg b/packages/ui/src/assets/icons/file-types/adobe-illustrator.svg new file mode 100644 index 0000000000000000000000000000000000000000..e0a334bb4db0e2ec678ae507be18bed3e2230c9a --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/adobe-illustrator.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/adobe-swc.svg b/packages/ui/src/assets/icons/file-types/adobe-swc.svg new file mode 100644 index 0000000000000000000000000000000000000000..fda5c181a03d16f486a73fc900bd2a7e3f66428f --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/adobe-swc.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/adonis.svg b/packages/ui/src/assets/icons/file-types/adonis.svg new file mode 100644 index 0000000000000000000000000000000000000000..f854f018e760c115b5cc5d1516fc5f7a5a1148e7 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/adonis.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/angular.svg b/packages/ui/src/assets/icons/file-types/angular.svg new file mode 100644 index 0000000000000000000000000000000000000000..a28075e93c13bc61739fd3bf2b941cc1d798e0ff --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/angular.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/apiblueprint.svg b/packages/ui/src/assets/icons/file-types/apiblueprint.svg new file mode 100644 index 0000000000000000000000000000000000000000..08462673afae6ee566b46081f68dcfd474a25512 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/apiblueprint.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/applescript.svg b/packages/ui/src/assets/icons/file-types/applescript.svg new file mode 100644 index 0000000000000000000000000000000000000000..d883e90da9ff6669aa9adfa19729a6d7d494f9eb --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/applescript.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/appveyor.svg b/packages/ui/src/assets/icons/file-types/appveyor.svg new file mode 100644 index 0000000000000000000000000000000000000000..0dd0a5cb0f1cc20b97af150581ff534c067ae78c --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/appveyor.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/architecture.svg b/packages/ui/src/assets/icons/file-types/architecture.svg new file mode 100644 index 0000000000000000000000000000000000000000..ee7de18239e6dcfd419fee34d5de5a92adfbc059 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/architecture.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/astro-config.svg b/packages/ui/src/assets/icons/file-types/astro-config.svg new file mode 100644 index 0000000000000000000000000000000000000000..1c12c5e8b9c098f0025d36dfe9d771bb1cb743d2 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/astro-config.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/astyle.svg b/packages/ui/src/assets/icons/file-types/astyle.svg new file mode 100644 index 0000000000000000000000000000000000000000..6643432b5f611365e40a4598a7c64c96546872cb --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/astyle.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/autoit.svg b/packages/ui/src/assets/icons/file-types/autoit.svg new file mode 100644 index 0000000000000000000000000000000000000000..350519f25ed375950d343244064ce2f73f4f666b --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/autoit.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/azure-pipelines.svg b/packages/ui/src/assets/icons/file-types/azure-pipelines.svg new file mode 100644 index 0000000000000000000000000000000000000000..f460d2079c17285765f1d661e541a60d71fbe714 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/azure-pipelines.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/bench-js.svg b/packages/ui/src/assets/icons/file-types/bench-js.svg new file mode 100644 index 0000000000000000000000000000000000000000..c2ba0ca6e97606ffd1554571f206ff0e9aed6ce5 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/bench-js.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/bench-ts.svg b/packages/ui/src/assets/icons/file-types/bench-ts.svg new file mode 100644 index 0000000000000000000000000000000000000000..f9c2af9e0a86771b1a3c23a4b7bf28ecb9a07369 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/bench-ts.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/bicep.svg b/packages/ui/src/assets/icons/file-types/bicep.svg new file mode 100644 index 0000000000000000000000000000000000000000..dc959e7be26ed20ae6ce9cf5213fa7ee12f75ded --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/bicep.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/biome.svg b/packages/ui/src/assets/icons/file-types/biome.svg new file mode 100644 index 0000000000000000000000000000000000000000..2f255fc2b3b0c6231b08998a7ec0f9dc15dc31de --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/biome.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/bitbucket.svg b/packages/ui/src/assets/icons/file-types/bitbucket.svg new file mode 100644 index 0000000000000000000000000000000000000000..ba572f09f464cf898fd41072793e210e9b006cd2 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/bitbucket.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/blink_light.svg b/packages/ui/src/assets/icons/file-types/blink_light.svg new file mode 100644 index 0000000000000000000000000000000000000000..380d8c762c96a6afaf8f16faeb69b62b76eaddae --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/blink_light.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/blitz.svg b/packages/ui/src/assets/icons/file-types/blitz.svg new file mode 100644 index 0000000000000000000000000000000000000000..147ccc1a7c8366ee5da87dd93a18bce8727ba91a --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/blitz.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/brainfuck.svg b/packages/ui/src/assets/icons/file-types/brainfuck.svg new file mode 100644 index 0000000000000000000000000000000000000000..6a2422c9bd51051d1a6ce0e2e16aaae49aad4b21 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/brainfuck.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/browserlist.svg b/packages/ui/src/assets/icons/file-types/browserlist.svg new file mode 100644 index 0000000000000000000000000000000000000000..d2e0d0a38c63fda88371884a70dd6c303a8a9c3e --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/browserlist.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/buck.svg b/packages/ui/src/assets/icons/file-types/buck.svg new file mode 100644 index 0000000000000000000000000000000000000000..a5a31bc465d2507e5c8c10b1a350b3e14bacfe7e --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/buck.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/bucklescript.svg b/packages/ui/src/assets/icons/file-types/bucklescript.svg new file mode 100644 index 0000000000000000000000000000000000000000..d67a7843d3b14c5d1532dda4e1464276f03b62dc --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/bucklescript.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/buildkite.svg b/packages/ui/src/assets/icons/file-types/buildkite.svg new file mode 100644 index 0000000000000000000000000000000000000000..32a4995558897ee080f443af531287d94cf35054 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/buildkite.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/bun_light.svg b/packages/ui/src/assets/icons/file-types/bun_light.svg new file mode 100644 index 0000000000000000000000000000000000000000..d49bac7b238fbcf0e4e842e7741c521aee150d6f --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/bun_light.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/caddy.svg b/packages/ui/src/assets/icons/file-types/caddy.svg new file mode 100644 index 0000000000000000000000000000000000000000..997c11962f3cb7963bc90bb191a137ef847caeef --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/caddy.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/certificate.svg b/packages/ui/src/assets/icons/file-types/certificate.svg new file mode 100644 index 0000000000000000000000000000000000000000..64ddcf3b6e5542fd691ee7e2d3c68e76534e3941 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/certificate.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/changelog.svg b/packages/ui/src/assets/icons/file-types/changelog.svg new file mode 100644 index 0000000000000000000000000000000000000000..b4b1a0717aecf2a0150d1f88b5747f222d7942ba --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/changelog.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/chess.svg b/packages/ui/src/assets/icons/file-types/chess.svg new file mode 100644 index 0000000000000000000000000000000000000000..85bede301044c37ad6f15f324dfa8ccf7b7d09d1 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/chess.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/circleci_light.svg b/packages/ui/src/assets/icons/file-types/circleci_light.svg new file mode 100644 index 0000000000000000000000000000000000000000..cd45d352c00608a4498b1c15d6fd0ec16a4afdf7 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/circleci_light.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/citation.svg b/packages/ui/src/assets/icons/file-types/citation.svg new file mode 100644 index 0000000000000000000000000000000000000000..eb7fcaa9259c3a96ead96e2420d83e9f751b5724 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/citation.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/cline.svg b/packages/ui/src/assets/icons/file-types/cline.svg new file mode 100644 index 0000000000000000000000000000000000000000..c41f59d834de4cc1f0a59701c9e09d95a8b2fbea --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/cline.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/coconut.svg b/packages/ui/src/assets/icons/file-types/coconut.svg new file mode 100644 index 0000000000000000000000000000000000000000..98355a66281eb3d5398afbbfc02bfb939833a5fa --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/coconut.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/code-climate.svg b/packages/ui/src/assets/icons/file-types/code-climate.svg new file mode 100644 index 0000000000000000000000000000000000000000..97cbb4e8fc17ac58ca0093ad65400e06c0f70984 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/code-climate.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/code-climate_light.svg b/packages/ui/src/assets/icons/file-types/code-climate_light.svg new file mode 100644 index 0000000000000000000000000000000000000000..dd18ba52b9f259a4e46e65154ae7e08ec765da83 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/code-climate_light.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/codeowners.svg b/packages/ui/src/assets/icons/file-types/codeowners.svg new file mode 100644 index 0000000000000000000000000000000000000000..553c60f5ad7afffac1142acc3f72519dacf1551f --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/codeowners.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/coderabbit-ai.svg b/packages/ui/src/assets/icons/file-types/coderabbit-ai.svg new file mode 100644 index 0000000000000000000000000000000000000000..5d1b6c9c27ca8b8b98af21d44e74445019711b0c --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/coderabbit-ai.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/coloredpetrinets.svg b/packages/ui/src/assets/icons/file-types/coloredpetrinets.svg new file mode 100644 index 0000000000000000000000000000000000000000..bd612618ec5a69699c9d3f21e324c068b4e745b0 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/coloredpetrinets.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/commitizen.svg b/packages/ui/src/assets/icons/file-types/commitizen.svg new file mode 100644 index 0000000000000000000000000000000000000000..2467d2c71dc698d28805ba00aab04cf87c44feae --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/commitizen.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/commitlint.svg b/packages/ui/src/assets/icons/file-types/commitlint.svg new file mode 100644 index 0000000000000000000000000000000000000000..c42144a428abcbf574067fa95bf025013e845a17 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/commitlint.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/concourse.svg b/packages/ui/src/assets/icons/file-types/concourse.svg new file mode 100644 index 0000000000000000000000000000000000000000..c34f23eb84ddfbd131a96dd0bf2cffe5a7d71936 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/concourse.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/contentlayer.svg b/packages/ui/src/assets/icons/file-types/contentlayer.svg new file mode 100644 index 0000000000000000000000000000000000000000..441f6904eafe84a198d740c5bfe3bb5e73bf68f7 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/contentlayer.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/contributing.svg b/packages/ui/src/assets/icons/file-types/contributing.svg new file mode 100644 index 0000000000000000000000000000000000000000..13666a0201da50addd1a1f24ed824f42303606fa --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/contributing.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/copilot.svg b/packages/ui/src/assets/icons/file-types/copilot.svg new file mode 100644 index 0000000000000000000000000000000000000000..24e89af0b0ca51e4768d3b6d6b7fff459e3abfe4 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/copilot.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/copilot_light.svg b/packages/ui/src/assets/icons/file-types/copilot_light.svg new file mode 100644 index 0000000000000000000000000000000000000000..9bc56ea84416f2b500a503df822a366f07d497ab --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/copilot_light.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/cpp.svg b/packages/ui/src/assets/icons/file-types/cpp.svg new file mode 100644 index 0000000000000000000000000000000000000000..16534acac2615687a1f57017b3319a35558a34bd --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/cpp.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/craco.svg b/packages/ui/src/assets/icons/file-types/craco.svg new file mode 100644 index 0000000000000000000000000000000000000000..96ba4584693363cc0b5df28a2085b5e52b5c167d --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/craco.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/crystal_light.svg b/packages/ui/src/assets/icons/file-types/crystal_light.svg new file mode 100644 index 0000000000000000000000000000000000000000..ca387f4e95dffb74d8cfd8d5f2dac75dc083a696 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/crystal_light.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/css-map.svg b/packages/ui/src/assets/icons/file-types/css-map.svg new file mode 100644 index 0000000000000000000000000000000000000000..55b74c088244a4e1b5f67c61913b0a399adb5b96 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/css-map.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/cuda.svg b/packages/ui/src/assets/icons/file-types/cuda.svg new file mode 100644 index 0000000000000000000000000000000000000000..cc57a60fcad9a5c58fd4c2bf74e640c9579303f1 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/cuda.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/dart.svg b/packages/ui/src/assets/icons/file-types/dart.svg new file mode 100644 index 0000000000000000000000000000000000000000..04b22d09c1a99a605cfc72121e7bc17421b42f43 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/dart.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/database.svg b/packages/ui/src/assets/icons/file-types/database.svg new file mode 100644 index 0000000000000000000000000000000000000000..b10723460e29c6af4772ef403c53b43f29812a9d --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/database.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/dependabot.svg b/packages/ui/src/assets/icons/file-types/dependabot.svg new file mode 100644 index 0000000000000000000000000000000000000000..3b101a12f34bfa623ba90d67ae7f25a5244c0280 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/dependabot.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/dhall.svg b/packages/ui/src/assets/icons/file-types/dhall.svg new file mode 100644 index 0000000000000000000000000000000000000000..0be94119d365efed40604a766af25a1eddd11c1e --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/dhall.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/diff.svg b/packages/ui/src/assets/icons/file-types/diff.svg new file mode 100644 index 0000000000000000000000000000000000000000..ea3068c60f028f64558742ecc2a93350d219b642 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/diff.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/dinophp.svg b/packages/ui/src/assets/icons/file-types/dinophp.svg new file mode 100644 index 0000000000000000000000000000000000000000..8e6ef29af88f0a6d1d1d19ed709d70d8162061b5 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/dinophp.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/dll.svg b/packages/ui/src/assets/icons/file-types/dll.svg new file mode 100644 index 0000000000000000000000000000000000000000..0646cbb0374531370280cddbf5a3bbad9a815cfd --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/dll.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/docker.svg b/packages/ui/src/assets/icons/file-types/docker.svg new file mode 100644 index 0000000000000000000000000000000000000000..7d6a1a5252d7eaf159e0d6fead6a691e65425cfc --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/docker.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/doctex-installer.svg b/packages/ui/src/assets/icons/file-types/doctex-installer.svg new file mode 100644 index 0000000000000000000000000000000000000000..5bdb4439f4aafdaa2593e61cf178aa728208a9f3 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/doctex-installer.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/drizzle.svg b/packages/ui/src/assets/icons/file-types/drizzle.svg new file mode 100644 index 0000000000000000000000000000000000000000..72f1b21aa026a01ef9fecbf55eecacd687623407 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/drizzle.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/edge.svg b/packages/ui/src/assets/icons/file-types/edge.svg new file mode 100644 index 0000000000000000000000000000000000000000..298b558900ee16d314c9b3fde62ed43fbcd91415 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/edge.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/ejs.svg b/packages/ui/src/assets/icons/file-types/ejs.svg new file mode 100644 index 0000000000000000000000000000000000000000..6ead40ebad29357f2b7ee837d76882a585aeaec0 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/ejs.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/elixir.svg b/packages/ui/src/assets/icons/file-types/elixir.svg new file mode 100644 index 0000000000000000000000000000000000000000..d40f90b47dd006519b2e63c9f05a155426bf2140 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/elixir.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/email.svg b/packages/ui/src/assets/icons/file-types/email.svg new file mode 100644 index 0000000000000000000000000000000000000000..a603e14773afd38722a9a770b5f185faf03513db --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/email.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/firebase.svg b/packages/ui/src/assets/icons/file-types/firebase.svg new file mode 100644 index 0000000000000000000000000000000000000000..bb3b63cb812a0685399a32eb2e999e6b2c3747c3 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/firebase.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/flow.svg b/packages/ui/src/assets/icons/file-types/flow.svg new file mode 100644 index 0000000000000000000000000000000000000000..05919810f769cb9f4ae7152f6980bf4ce4d08018 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/flow.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/folder-android-open.svg b/packages/ui/src/assets/icons/file-types/folder-android-open.svg new file mode 100644 index 0000000000000000000000000000000000000000..cdd8376fffa52a6bc4d19d40dd998a463f42eed4 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/folder-android-open.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/folder-android.svg b/packages/ui/src/assets/icons/file-types/folder-android.svg new file mode 100644 index 0000000000000000000000000000000000000000..7ee8a46795f53524a41b172d650e7dd1ac1226f1 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/folder-android.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/folder-angular-open.svg b/packages/ui/src/assets/icons/file-types/folder-angular-open.svg new file mode 100644 index 0000000000000000000000000000000000000000..60c604e84587ea88caaf76208ea8b34088af1cda --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/folder-angular-open.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/folder-animation-open.svg b/packages/ui/src/assets/icons/file-types/folder-animation-open.svg new file mode 100644 index 0000000000000000000000000000000000000000..637a3af6bb500d69ca898e05b68b4fb30e419f83 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/folder-animation-open.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/folder-ansible-open.svg b/packages/ui/src/assets/icons/file-types/folder-ansible-open.svg new file mode 100644 index 0000000000000000000000000000000000000000..96df458dd37d16ee0130ecb82621bed98e2fdb35 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/folder-ansible-open.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/folder-apollo.svg b/packages/ui/src/assets/icons/file-types/folder-apollo.svg new file mode 100644 index 0000000000000000000000000000000000000000..7eb610781fdd52072130dcc22017fbe598e543e6 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/folder-apollo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/folder-archive-open.svg b/packages/ui/src/assets/icons/file-types/folder-archive-open.svg new file mode 100644 index 0000000000000000000000000000000000000000..6af2a9f0a003c984cc85fd4b0d0349ecf95fe63a --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/folder-archive-open.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/folder-attachment-open.svg b/packages/ui/src/assets/icons/file-types/folder-attachment-open.svg new file mode 100644 index 0000000000000000000000000000000000000000..7a9af66f139b6dc33348f8e469155c129fed3d85 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/folder-attachment-open.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/folder-attachment.svg b/packages/ui/src/assets/icons/file-types/folder-attachment.svg new file mode 100644 index 0000000000000000000000000000000000000000..3b9992e3120b5b6a0d9ae8db8f6391795d6423a8 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/folder-attachment.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/folder-aurelia.svg b/packages/ui/src/assets/icons/file-types/folder-aurelia.svg new file mode 100644 index 0000000000000000000000000000000000000000..61ee59ed386d8bcfacf4957e343b888c44105be5 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/folder-aurelia.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/folder-aws-open.svg b/packages/ui/src/assets/icons/file-types/folder-aws-open.svg new file mode 100644 index 0000000000000000000000000000000000000000..9e530d4cfd83b6aa991e938d4b206b92d466a5ef --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/folder-aws-open.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/folder-aws.svg b/packages/ui/src/assets/icons/file-types/folder-aws.svg new file mode 100644 index 0000000000000000000000000000000000000000..769755d140b8ddcf5cb0131bcc74f2a0c99c2865 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/folder-aws.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/folder-azure-pipelines.svg b/packages/ui/src/assets/icons/file-types/folder-azure-pipelines.svg new file mode 100644 index 0000000000000000000000000000000000000000..a0fef25f9a458305c8f85396d51dff17e2e38bb9 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/folder-azure-pipelines.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/folder-base-open.svg b/packages/ui/src/assets/icons/file-types/folder-base-open.svg new file mode 100644 index 0000000000000000000000000000000000000000..e84bc36c19cb73e840cc84bd6dc88a797ec3b407 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/folder-base-open.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/folder-base.svg b/packages/ui/src/assets/icons/file-types/folder-base.svg new file mode 100644 index 0000000000000000000000000000000000000000..1944100830db979edc18f34fa4430306fbbfe8f6 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/folder-base.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/folder-batch-open.svg b/packages/ui/src/assets/icons/file-types/folder-batch-open.svg new file mode 100644 index 0000000000000000000000000000000000000000..1db45e1816b21ce7fce46cccd95166b5f70665a6 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/folder-batch-open.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/folder-benchmark.svg b/packages/ui/src/assets/icons/file-types/folder-benchmark.svg new file mode 100644 index 0000000000000000000000000000000000000000..8291d6847234b15e737d49e97161e1e5c8e58429 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/folder-benchmark.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/folder-bibliography-open.svg b/packages/ui/src/assets/icons/file-types/folder-bibliography-open.svg new file mode 100644 index 0000000000000000000000000000000000000000..81b6cde63df2561e6c2a81eeb0dedbd829e8bee8 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/folder-bibliography-open.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/folder-bibliography.svg b/packages/ui/src/assets/icons/file-types/folder-bibliography.svg new file mode 100644 index 0000000000000000000000000000000000000000..aa1e92a91ec170bb5787460c781f1eedb518b6da --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/folder-bibliography.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/folder-bicep.svg b/packages/ui/src/assets/icons/file-types/folder-bicep.svg new file mode 100644 index 0000000000000000000000000000000000000000..b336ff5beeb64ec3f36aa63a567e04876cc33939 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/folder-bicep.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/folder-blender-open.svg b/packages/ui/src/assets/icons/file-types/folder-blender-open.svg new file mode 100644 index 0000000000000000000000000000000000000000..1c80d73a671978a034ff612751883c23f3189b8c --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/folder-blender-open.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/folder-blender.svg b/packages/ui/src/assets/icons/file-types/folder-blender.svg new file mode 100644 index 0000000000000000000000000000000000000000..6f56dce4dbe10cfd9885d0d54b81e970c6494c78 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/folder-blender.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/folder-bloc-open.svg b/packages/ui/src/assets/icons/file-types/folder-bloc-open.svg new file mode 100644 index 0000000000000000000000000000000000000000..8833e5f3c34c0047589722d5434a7ab4e0cae362 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/folder-bloc-open.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/folder-buildkite-open.svg b/packages/ui/src/assets/icons/file-types/folder-buildkite-open.svg new file mode 100644 index 0000000000000000000000000000000000000000..872db64459e07a8156715806198d1813468c6d13 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/folder-buildkite-open.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/folder-cart-open.svg b/packages/ui/src/assets/icons/file-types/folder-cart-open.svg new file mode 100644 index 0000000000000000000000000000000000000000..4471a778e61fbba7984f180c033695583daf3908 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/folder-cart-open.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/folder-changesets-open.svg b/packages/ui/src/assets/icons/file-types/folder-changesets-open.svg new file mode 100644 index 0000000000000000000000000000000000000000..c3892333168eb99158dba2f52612be77c69227d5 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/folder-changesets-open.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/folder-ci-open.svg b/packages/ui/src/assets/icons/file-types/folder-ci-open.svg new file mode 100644 index 0000000000000000000000000000000000000000..57ac1ba8f8cd1fa0799a56ed37f42cc13b55d3f7 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/folder-ci-open.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/folder-ci.svg b/packages/ui/src/assets/icons/file-types/folder-ci.svg new file mode 100644 index 0000000000000000000000000000000000000000..4fdc2edea227fd988a739f58a5199fe47b9af272 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/folder-ci.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/folder-circleci.svg b/packages/ui/src/assets/icons/file-types/folder-circleci.svg new file mode 100644 index 0000000000000000000000000000000000000000..ef3251857948f49f59ae471c9b81edce12124aaa --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/folder-circleci.svg @@ -0,0 +1,7 @@ + + + + + diff --git a/packages/ui/src/assets/icons/file-types/folder-class-open.svg b/packages/ui/src/assets/icons/file-types/folder-class-open.svg new file mode 100644 index 0000000000000000000000000000000000000000..9c5b1017a6e9ce39fcbebedcd4c854a022aa1ef3 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/folder-class-open.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/folder-cline.svg b/packages/ui/src/assets/icons/file-types/folder-cline.svg new file mode 100644 index 0000000000000000000000000000000000000000..8fec96d74c184a9c8d5e4e10f20464a49c1d06fb --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/folder-cline.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/folder-cloudflare-open.svg b/packages/ui/src/assets/icons/file-types/folder-cloudflare-open.svg new file mode 100644 index 0000000000000000000000000000000000000000..d7022abf8daa7c776ae349bdfba96c7262b4fabe --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/folder-cloudflare-open.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/folder-cluster.svg b/packages/ui/src/assets/icons/file-types/folder-cluster.svg new file mode 100644 index 0000000000000000000000000000000000000000..77f5b8a39f1ee6662023a858ef9b43bf3a418e7f --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/folder-cluster.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/folder-cobol-open.svg b/packages/ui/src/assets/icons/file-types/folder-cobol-open.svg new file mode 100644 index 0000000000000000000000000000000000000000..0f5e3152894e11da55ec26c33b18c3324231c157 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/folder-cobol-open.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/folder-cobol.svg b/packages/ui/src/assets/icons/file-types/folder-cobol.svg new file mode 100644 index 0000000000000000000000000000000000000000..ea0f54d13d83a0b5520cf2bc0c59fb0c632eb3e3 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/folder-cobol.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/folder-command-open.svg b/packages/ui/src/assets/icons/file-types/folder-command-open.svg new file mode 100644 index 0000000000000000000000000000000000000000..ca9d4dff0a6f7bb387066471102cc5d8e97f6d18 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/folder-command-open.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/folder-config-open.svg b/packages/ui/src/assets/icons/file-types/folder-config-open.svg new file mode 100644 index 0000000000000000000000000000000000000000..3b4ec5ae0ba8cbbb80053452ba8e31cdbaa0cec8 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/folder-config-open.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/folder-connection-open.svg b/packages/ui/src/assets/icons/file-types/folder-connection-open.svg new file mode 100644 index 0000000000000000000000000000000000000000..4d14f09624158a434370862de087881b8c448310 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/folder-connection-open.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/folder-connection.svg b/packages/ui/src/assets/icons/file-types/folder-connection.svg new file mode 100644 index 0000000000000000000000000000000000000000..f46d5264ccde5e3305739763fc7f37d7c77829be --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/folder-connection.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/folder-console-open.svg b/packages/ui/src/assets/icons/file-types/folder-console-open.svg new file mode 100644 index 0000000000000000000000000000000000000000..99384a80939d0c672e862374d806f48ff1b70b72 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/folder-console-open.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/folder-constant.svg b/packages/ui/src/assets/icons/file-types/folder-constant.svg new file mode 100644 index 0000000000000000000000000000000000000000..99a22917ac3d59fd5411a48b2e9ac57103ac4489 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/folder-constant.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/folder-content.svg b/packages/ui/src/assets/icons/file-types/folder-content.svg new file mode 100644 index 0000000000000000000000000000000000000000..23f57d243a0fc4a70f8cfe7c032b9408a25d6e3f --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/folder-content.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/folder-contract-open.svg b/packages/ui/src/assets/icons/file-types/folder-contract-open.svg new file mode 100644 index 0000000000000000000000000000000000000000..6878c76f67f8a1e55360acb38474429e91cc11a5 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/folder-contract-open.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/folder-contract.svg b/packages/ui/src/assets/icons/file-types/folder-contract.svg new file mode 100644 index 0000000000000000000000000000000000000000..2ea0abb117ec13e771e272da966620ea4b8ab260 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/folder-contract.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/folder-controller-open.svg b/packages/ui/src/assets/icons/file-types/folder-controller-open.svg new file mode 100644 index 0000000000000000000000000000000000000000..a732ed1a0dc982725dc0e8a7f469abdcd73f1791 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/folder-controller-open.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/folder-controller.svg b/packages/ui/src/assets/icons/file-types/folder-controller.svg new file mode 100644 index 0000000000000000000000000000000000000000..f98cd6feaa08ee81f9583e0f22c109c42b29b118 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/folder-controller.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/folder-core-open.svg b/packages/ui/src/assets/icons/file-types/folder-core-open.svg new file mode 100644 index 0000000000000000000000000000000000000000..34e7a82d2057b036f32953d69b31c5d777bddfb0 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/folder-core-open.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/folder-coverage.svg b/packages/ui/src/assets/icons/file-types/folder-coverage.svg new file mode 100644 index 0000000000000000000000000000000000000000..7a75f7166f97fc0a5e35f74246bb7b6e6e859ee6 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/folder-coverage.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/folder-cursor-open_light.svg b/packages/ui/src/assets/icons/file-types/folder-cursor-open_light.svg new file mode 100644 index 0000000000000000000000000000000000000000..c960112deeceda8b4b77a1cdcc55434a1a000ebd --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/folder-cursor-open_light.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/folder-cursor.svg b/packages/ui/src/assets/icons/file-types/folder-cursor.svg new file mode 100644 index 0000000000000000000000000000000000000000..46726088c49d1edfc66fc74e7c989d78639040a7 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/folder-cursor.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/folder-cursor_light.svg b/packages/ui/src/assets/icons/file-types/folder-cursor_light.svg new file mode 100644 index 0000000000000000000000000000000000000000..391be56afeb6b418635ba9cf6128b3dc7ea34a9a --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/folder-cursor_light.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/folder-custom-open.svg b/packages/ui/src/assets/icons/file-types/folder-custom-open.svg new file mode 100644 index 0000000000000000000000000000000000000000..fe747d217c8f960aaadd10760e048732b9b00aac --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/folder-custom-open.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/folder-cypress.svg b/packages/ui/src/assets/icons/file-types/folder-cypress.svg new file mode 100644 index 0000000000000000000000000000000000000000..39460e2293c996c062a8e09d48e3aa192e185b20 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/folder-cypress.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/folder-decorators-open.svg b/packages/ui/src/assets/icons/file-types/folder-decorators-open.svg new file mode 100644 index 0000000000000000000000000000000000000000..ff42ddeecf20411e76f7d031d3752856f5b93a0a --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/folder-decorators-open.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/folder-decorators.svg b/packages/ui/src/assets/icons/file-types/folder-decorators.svg new file mode 100644 index 0000000000000000000000000000000000000000..fcc746dc57bd9abd875835f0b6945b89f62478ef --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/folder-decorators.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/folder-desktop-open.svg b/packages/ui/src/assets/icons/file-types/folder-desktop-open.svg new file mode 100644 index 0000000000000000000000000000000000000000..880ca769b66879eb84353cebf0d7708c90842955 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/folder-desktop-open.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/folder-directive.svg b/packages/ui/src/assets/icons/file-types/folder-directive.svg new file mode 100644 index 0000000000000000000000000000000000000000..4197c680349551c0a18e4c10085bb3c40427e04a --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/folder-directive.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/folder-dist.svg b/packages/ui/src/assets/icons/file-types/folder-dist.svg new file mode 100644 index 0000000000000000000000000000000000000000..995580fdabd986e37a5cb15bc0232fc87485ef19 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/folder-dist.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/folder-docs-open.svg b/packages/ui/src/assets/icons/file-types/folder-docs-open.svg new file mode 100644 index 0000000000000000000000000000000000000000..357776702e7e45de08dbc3527b85f71a534c07be --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/folder-docs-open.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/folder-docs.svg b/packages/ui/src/assets/icons/file-types/folder-docs.svg new file mode 100644 index 0000000000000000000000000000000000000000..246a05d2e216f3ae9bba5bb172bf59bd5201f51e --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/folder-docs.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/folder-drizzle-open.svg b/packages/ui/src/assets/icons/file-types/folder-drizzle-open.svg new file mode 100644 index 0000000000000000000000000000000000000000..5f0cd591a9d190f08e4c2e28099515aa2190d59c --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/folder-drizzle-open.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/folder-dump-open.svg b/packages/ui/src/assets/icons/file-types/folder-dump-open.svg new file mode 100644 index 0000000000000000000000000000000000000000..b4de7f861d59dfda561827780dee8e06e995ffb8 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/folder-dump-open.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/folder-element.svg b/packages/ui/src/assets/icons/file-types/folder-element.svg new file mode 100644 index 0000000000000000000000000000000000000000..d67a85aded7cf5a7e8027bfd5c4eda8d8443ecf4 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/folder-element.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/folder-environment-open.svg b/packages/ui/src/assets/icons/file-types/folder-environment-open.svg new file mode 100644 index 0000000000000000000000000000000000000000..3b56abb186b7f9bec165690a7d35005cc09f82cc --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/folder-environment-open.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/folder-error.svg b/packages/ui/src/assets/icons/file-types/folder-error.svg new file mode 100644 index 0000000000000000000000000000000000000000..3bd1d85d630c3a3293798160bdda5cc3d669ef3b --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/folder-error.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/folder-event-open.svg b/packages/ui/src/assets/icons/file-types/folder-event-open.svg new file mode 100644 index 0000000000000000000000000000000000000000..28c018d44dc260b28ecbe6070759f07a51afa241 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/folder-event-open.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/folder-examples-open.svg b/packages/ui/src/assets/icons/file-types/folder-examples-open.svg new file mode 100644 index 0000000000000000000000000000000000000000..78c77a918e95ca14c78680e8d7b1d32c1fea96a8 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/folder-examples-open.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/folder-fastlane.svg b/packages/ui/src/assets/icons/file-types/folder-fastlane.svg new file mode 100644 index 0000000000000000000000000000000000000000..eb9056694eb602ac3e2f503cca24deb804d1a5eb --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/folder-fastlane.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/folder-firebase-open.svg b/packages/ui/src/assets/icons/file-types/folder-firebase-open.svg new file mode 100644 index 0000000000000000000000000000000000000000..7149b48f611c875c7e548cfd25466408311208c6 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/folder-firebase-open.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/folder-flow-open.svg b/packages/ui/src/assets/icons/file-types/folder-flow-open.svg new file mode 100644 index 0000000000000000000000000000000000000000..a72dd76b7f2222147b08e25a682aaa4ef4376d0d --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/folder-flow-open.svg @@ -0,0 +1,6 @@ + + + + diff --git a/packages/ui/src/assets/icons/file-types/folder-flutter-open.svg b/packages/ui/src/assets/icons/file-types/folder-flutter-open.svg new file mode 100644 index 0000000000000000000000000000000000000000..b95a8cee737666daeeaefc82da1f80376608accb --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/folder-flutter-open.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/folder-flutter.svg b/packages/ui/src/assets/icons/file-types/folder-flutter.svg new file mode 100644 index 0000000000000000000000000000000000000000..e5ffced14d3e4457975aa65465d94e162c3d5824 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/folder-flutter.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/folder-font-open.svg b/packages/ui/src/assets/icons/file-types/folder-font-open.svg new file mode 100644 index 0000000000000000000000000000000000000000..1a91f0b19f29882d6cd4e3dd9a4cb48b29189865 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/folder-font-open.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/folder-functions-open.svg b/packages/ui/src/assets/icons/file-types/folder-functions-open.svg new file mode 100644 index 0000000000000000000000000000000000000000..00d6dc44218a3ccbead40c0dc80e58465a5413e9 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/folder-functions-open.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/folder-gamemaker.svg b/packages/ui/src/assets/icons/file-types/folder-gamemaker.svg new file mode 100644 index 0000000000000000000000000000000000000000..625feb3828400a57c86c435e42963ec7e30c2eaa --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/folder-gamemaker.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/folder-generator.svg b/packages/ui/src/assets/icons/file-types/folder-generator.svg new file mode 100644 index 0000000000000000000000000000000000000000..5446582e93c2c72ba15149755ad582c1604cf9b6 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/folder-generator.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/folder-global-open.svg b/packages/ui/src/assets/icons/file-types/folder-global-open.svg new file mode 100644 index 0000000000000000000000000000000000000000..13e72e070b0ec3185b1f1afd9316532ff57fd8d2 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/folder-global-open.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/folder-global.svg b/packages/ui/src/assets/icons/file-types/folder-global.svg new file mode 100644 index 0000000000000000000000000000000000000000..8ada6a6ddacf43e9c82079d8daa1b63b697c3888 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/folder-global.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/folder-gradle-open.svg b/packages/ui/src/assets/icons/file-types/folder-gradle-open.svg new file mode 100644 index 0000000000000000000000000000000000000000..51725e7228729be23820a89fe68a102f43aec55e --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/folder-gradle-open.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/folder-gradle.svg b/packages/ui/src/assets/icons/file-types/folder-gradle.svg new file mode 100644 index 0000000000000000000000000000000000000000..93e843d2b4264835bb2af3f193e06dfec3d1f585 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/folder-gradle.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/folder-gulp-open.svg b/packages/ui/src/assets/icons/file-types/folder-gulp-open.svg new file mode 100644 index 0000000000000000000000000000000000000000..556e7399a9fb12aab616445dc5ae2302ed02a07f --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/folder-gulp-open.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/folder-gulp.svg b/packages/ui/src/assets/icons/file-types/folder-gulp.svg new file mode 100644 index 0000000000000000000000000000000000000000..33952313840d56d31bfd4d29016fd78778abdfdb --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/folder-gulp.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/folder-helper-open.svg b/packages/ui/src/assets/icons/file-types/folder-helper-open.svg new file mode 100644 index 0000000000000000000000000000000000000000..6fca39118ca104f862dfe7110d87efad61106d60 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/folder-helper-open.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/folder-helper.svg b/packages/ui/src/assets/icons/file-types/folder-helper.svg new file mode 100644 index 0000000000000000000000000000000000000000..27a20d43cdf656bd98d9ac43354a2a4d3f07bfa8 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/folder-helper.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/folder-husky.svg b/packages/ui/src/assets/icons/file-types/folder-husky.svg new file mode 100644 index 0000000000000000000000000000000000000000..1bbdc4c370cc63047820ce7e4c08402f62832319 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/folder-husky.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/folder-i18n.svg b/packages/ui/src/assets/icons/file-types/folder-i18n.svg new file mode 100644 index 0000000000000000000000000000000000000000..6ef0283771f5c8bd656559a50254fabc02419fa5 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/folder-i18n.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/folder-images-open.svg b/packages/ui/src/assets/icons/file-types/folder-images-open.svg new file mode 100644 index 0000000000000000000000000000000000000000..44a673b14d3489785ee58921241917b6ac31772c --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/folder-images-open.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/folder-intellij-open.svg b/packages/ui/src/assets/icons/file-types/folder-intellij-open.svg new file mode 100644 index 0000000000000000000000000000000000000000..5839a2b1cc273808011824cba9e8a3ecbce80de3 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/folder-intellij-open.svg @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/ui/src/assets/icons/file-types/folder-javascript-open.svg b/packages/ui/src/assets/icons/file-types/folder-javascript-open.svg new file mode 100644 index 0000000000000000000000000000000000000000..581f3a273679549684ad6aa90f2d5061d0c46b93 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/folder-javascript-open.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/folder-jinja-open.svg b/packages/ui/src/assets/icons/file-types/folder-jinja-open.svg new file mode 100644 index 0000000000000000000000000000000000000000..9c0b2b6eb8d3139de7bea008dbd0472b6dc05aca --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/folder-jinja-open.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/folder-jinja-open_light.svg b/packages/ui/src/assets/icons/file-types/folder-jinja-open_light.svg new file mode 100644 index 0000000000000000000000000000000000000000..ffc940faa1ddd0ffab6480b0e4280b0a61ddfc9c --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/folder-jinja-open_light.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/folder-jinja.svg b/packages/ui/src/assets/icons/file-types/folder-jinja.svg new file mode 100644 index 0000000000000000000000000000000000000000..687efe3d23e5a815357291dabd045b2ee4069025 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/folder-jinja.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/folder-job.svg b/packages/ui/src/assets/icons/file-types/folder-job.svg new file mode 100644 index 0000000000000000000000000000000000000000..9135aff330fa959d15534a2cc1a967d3d9014282 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/folder-job.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/folder-keys-open.svg b/packages/ui/src/assets/icons/file-types/folder-keys-open.svg new file mode 100644 index 0000000000000000000000000000000000000000..783b16e98ce9005804b7212246bbc0347ff90b75 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/folder-keys-open.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/folder-kubernetes-open.svg b/packages/ui/src/assets/icons/file-types/folder-kubernetes-open.svg new file mode 100644 index 0000000000000000000000000000000000000000..022be4de7dd276ee6e38c2ec50dea8de448d7bc5 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/folder-kubernetes-open.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/folder-kusto.svg b/packages/ui/src/assets/icons/file-types/folder-kusto.svg new file mode 100644 index 0000000000000000000000000000000000000000..fa71096a4c684df6aecdb53f35b5b49c97e633fd --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/folder-kusto.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/folder-layout.svg b/packages/ui/src/assets/icons/file-types/folder-layout.svg new file mode 100644 index 0000000000000000000000000000000000000000..3d773bc4f60cb5e8e111a0458c2f1e08800916c0 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/folder-layout.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/folder-lefthook-open.svg b/packages/ui/src/assets/icons/file-types/folder-lefthook-open.svg new file mode 100644 index 0000000000000000000000000000000000000000..a2694ba6968d0175fbd311abac4ae2563031184f --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/folder-lefthook-open.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/folder-less.svg b/packages/ui/src/assets/icons/file-types/folder-less.svg new file mode 100644 index 0000000000000000000000000000000000000000..b6abc5ecd8ec7d89df6f84034fe6f1880426e19d --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/folder-less.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/folder-linux-open.svg b/packages/ui/src/assets/icons/file-types/folder-linux-open.svg new file mode 100644 index 0000000000000000000000000000000000000000..8517b35dfb687bab8aeb1059f01c859aa1b3c474 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/folder-linux-open.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/folder-lottie-open.svg b/packages/ui/src/assets/icons/file-types/folder-lottie-open.svg new file mode 100644 index 0000000000000000000000000000000000000000..adca025416e5b0af4207e1260d35bc7d7c7b4176 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/folder-lottie-open.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/folder-lottie.svg b/packages/ui/src/assets/icons/file-types/folder-lottie.svg new file mode 100644 index 0000000000000000000000000000000000000000..4d7fe341dd8db12cf731888a0b7a570ed0a8fe82 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/folder-lottie.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/folder-luau-open.svg b/packages/ui/src/assets/icons/file-types/folder-luau-open.svg new file mode 100644 index 0000000000000000000000000000000000000000..2b113b4731e908184be89696a21e9d05ae92c699 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/folder-luau-open.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/folder-macos-open.svg b/packages/ui/src/assets/icons/file-types/folder-macos-open.svg new file mode 100644 index 0000000000000000000000000000000000000000..8d0280ae902b573ff27119183b9d5d1a22d943f6 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/folder-macos-open.svg @@ -0,0 +1,6 @@ + + + + diff --git a/packages/ui/src/assets/icons/file-types/folder-mail-open.svg b/packages/ui/src/assets/icons/file-types/folder-mail-open.svg new file mode 100644 index 0000000000000000000000000000000000000000..27774cf1d81ec9206dd99bbdda09e7dd8b631f18 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/folder-mail-open.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/folder-mappings-open.svg b/packages/ui/src/assets/icons/file-types/folder-mappings-open.svg new file mode 100644 index 0000000000000000000000000000000000000000..510d06b76080c652da827f64905716b02e1a9f6b --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/folder-mappings-open.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/folder-markdown.svg b/packages/ui/src/assets/icons/file-types/folder-markdown.svg new file mode 100644 index 0000000000000000000000000000000000000000..5df5d0a5997f18f0ae8f6f6cc130ffe622179e0d --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/folder-markdown.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/folder-mercurial-open.svg b/packages/ui/src/assets/icons/file-types/folder-mercurial-open.svg new file mode 100644 index 0000000000000000000000000000000000000000..74bbb9da1f69c8260851d3b80b6d2d1bb13c0850 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/folder-mercurial-open.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/folder-messages-open.svg b/packages/ui/src/assets/icons/file-types/folder-messages-open.svg new file mode 100644 index 0000000000000000000000000000000000000000..2701529cfe0fc0fa613f52e1e24b2695e94cd81f --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/folder-messages-open.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/folder-messages.svg b/packages/ui/src/assets/icons/file-types/folder-messages.svg new file mode 100644 index 0000000000000000000000000000000000000000..ab3e2f8c18257d87f1c735541432590f8532fd31 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/folder-messages.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/folder-meta-open.svg b/packages/ui/src/assets/icons/file-types/folder-meta-open.svg new file mode 100644 index 0000000000000000000000000000000000000000..de1fd82ad72b7693398da9da09c6775b034e3a29 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/folder-meta-open.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/folder-middleware.svg b/packages/ui/src/assets/icons/file-types/folder-middleware.svg new file mode 100644 index 0000000000000000000000000000000000000000..f12c99de28445753ff740c3fffb1287537bb0b8c --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/folder-middleware.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/folder-mjml-open.svg b/packages/ui/src/assets/icons/file-types/folder-mjml-open.svg new file mode 100644 index 0000000000000000000000000000000000000000..81843f0e5f7687841f1bfe64a7773320902daf4a --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/folder-mjml-open.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/folder-mobile.svg b/packages/ui/src/assets/icons/file-types/folder-mobile.svg new file mode 100644 index 0000000000000000000000000000000000000000..03aab13335e168c0cfbb665649c4607fe59bdf27 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/folder-mobile.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/folder-mock.svg b/packages/ui/src/assets/icons/file-types/folder-mock.svg new file mode 100644 index 0000000000000000000000000000000000000000..22f88e55ae20122bea360077e6df5f34c31b6e46 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/folder-mock.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/folder-moon-open.svg b/packages/ui/src/assets/icons/file-types/folder-moon-open.svg new file mode 100644 index 0000000000000000000000000000000000000000..f2da8ddd054ab0eff8a43d186336a7d1221cff90 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/folder-moon-open.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/folder-next-open.svg b/packages/ui/src/assets/icons/file-types/folder-next-open.svg new file mode 100644 index 0000000000000000000000000000000000000000..c8709cac308beaae3b165f4fd286fcd9078cfbea --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/folder-next-open.svg @@ -0,0 +1,6 @@ + + + + diff --git a/packages/ui/src/assets/icons/file-types/folder-next.svg b/packages/ui/src/assets/icons/file-types/folder-next.svg new file mode 100644 index 0000000000000000000000000000000000000000..cab1e8fca1a76f2a006841eb86df68d688ce43c3 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/folder-next.svg @@ -0,0 +1,6 @@ + + + + diff --git a/packages/ui/src/assets/icons/file-types/folder-node.svg b/packages/ui/src/assets/icons/file-types/folder-node.svg new file mode 100644 index 0000000000000000000000000000000000000000..fb47492b9ca9734624579441de7146daff854293 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/folder-node.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/folder-obsidian-open.svg b/packages/ui/src/assets/icons/file-types/folder-obsidian-open.svg new file mode 100644 index 0000000000000000000000000000000000000000..f7d1305eab9446e803eccf264164a690c5a11acf --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/folder-obsidian-open.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/folder-open.svg b/packages/ui/src/assets/icons/file-types/folder-open.svg new file mode 100644 index 0000000000000000000000000000000000000000..eac89185e84f0c47fa6c683e0bf875e3f9942201 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/folder-open.svg @@ -0,0 +1,5 @@ + + + diff --git a/packages/ui/src/assets/icons/file-types/folder-other.svg b/packages/ui/src/assets/icons/file-types/folder-other.svg new file mode 100644 index 0000000000000000000000000000000000000000..df3d27f2656b73df6764edb67cbd8a53bb8cad84 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/folder-other.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/folder-pdm-open.svg b/packages/ui/src/assets/icons/file-types/folder-pdm-open.svg new file mode 100644 index 0000000000000000000000000000000000000000..6145f798b6ad53b1e312c88e4427a102197e4418 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/folder-pdm-open.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/folder-pipe-open.svg b/packages/ui/src/assets/icons/file-types/folder-pipe-open.svg new file mode 100644 index 0000000000000000000000000000000000000000..8aacef08d39bf57c33498b3bfbbfcc1087551646 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/folder-pipe-open.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/folder-pipe.svg b/packages/ui/src/assets/icons/file-types/folder-pipe.svg new file mode 100644 index 0000000000000000000000000000000000000000..9ba5d0adb24c968b068ce8b7502b4837c3afc790 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/folder-pipe.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/folder-plastic.svg b/packages/ui/src/assets/icons/file-types/folder-plastic.svg new file mode 100644 index 0000000000000000000000000000000000000000..5e595f3206c0ac215f2e682581186dc447771471 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/folder-plastic.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/folder-policy-open.svg b/packages/ui/src/assets/icons/file-types/folder-policy-open.svg new file mode 100644 index 0000000000000000000000000000000000000000..c2b51d4594bd24e0c003c564a0c87809b03e5179 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/folder-policy-open.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/folder-powershell.svg b/packages/ui/src/assets/icons/file-types/folder-powershell.svg new file mode 100644 index 0000000000000000000000000000000000000000..6f28098de00f6a23c0baa61ce7bc38eda2f5cb1a --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/folder-powershell.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/folder-project-open.svg b/packages/ui/src/assets/icons/file-types/folder-project-open.svg new file mode 100644 index 0000000000000000000000000000000000000000..9da28620eb19ea02291224c66d745e29e170ea54 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/folder-project-open.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/folder-public-open.svg b/packages/ui/src/assets/icons/file-types/folder-public-open.svg new file mode 100644 index 0000000000000000000000000000000000000000..04449ed5555f9314df072f6656881f46348d0790 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/folder-public-open.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/folder-pytorch-open.svg b/packages/ui/src/assets/icons/file-types/folder-pytorch-open.svg new file mode 100644 index 0000000000000000000000000000000000000000..46f664f59fe78d79f7c3ba0847f80329d95f73cb --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/folder-pytorch-open.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/folder-pytorch.svg b/packages/ui/src/assets/icons/file-types/folder-pytorch.svg new file mode 100644 index 0000000000000000000000000000000000000000..2616b6bc0b6ee7a08f20ff7900a96d6f85d6fc5b --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/folder-pytorch.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/folder-queue-open.svg b/packages/ui/src/assets/icons/file-types/folder-queue-open.svg new file mode 100644 index 0000000000000000000000000000000000000000..5afa82181e5ee538ca2b5ea72929c0d81c9907f8 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/folder-queue-open.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/folder-react-components.svg b/packages/ui/src/assets/icons/file-types/folder-react-components.svg new file mode 100644 index 0000000000000000000000000000000000000000..5f117a70500d846a679c6f85010ee7ac685e54a6 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/folder-react-components.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/folder-resolver.svg b/packages/ui/src/assets/icons/file-types/folder-resolver.svg new file mode 100644 index 0000000000000000000000000000000000000000..c59a6b41fab51a14411c1e8160237a6f71907276 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/folder-resolver.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/folder-review-open.svg b/packages/ui/src/assets/icons/file-types/folder-review-open.svg new file mode 100644 index 0000000000000000000000000000000000000000..2384601dc9082597bad51b7d005c62d98e072cde --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/folder-review-open.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/folder-routes.svg b/packages/ui/src/assets/icons/file-types/folder-routes.svg new file mode 100644 index 0000000000000000000000000000000000000000..2fb204ddafac31b1e737cc777e437d47696094ec --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/folder-routes.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/folder-rules-open.svg b/packages/ui/src/assets/icons/file-types/folder-rules-open.svg new file mode 100644 index 0000000000000000000000000000000000000000..1f9c01f25f44c1ad0d97847f164125b1b3157638 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/folder-rules-open.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/folder-rust-open.svg b/packages/ui/src/assets/icons/file-types/folder-rust-open.svg new file mode 100644 index 0000000000000000000000000000000000000000..65be154ef32569bf1e3b16fcd5a638d806ee6e96 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/folder-rust-open.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/folder-sass-open.svg b/packages/ui/src/assets/icons/file-types/folder-sass-open.svg new file mode 100644 index 0000000000000000000000000000000000000000..0a2a82e949e45829b82b28fa8212f163018845da --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/folder-sass-open.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/folder-scala.svg b/packages/ui/src/assets/icons/file-types/folder-scala.svg new file mode 100644 index 0000000000000000000000000000000000000000..d78a0742089cc4a9adb15eb84672f4a3dcafe324 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/folder-scala.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/folder-scons-open.svg b/packages/ui/src/assets/icons/file-types/folder-scons-open.svg new file mode 100644 index 0000000000000000000000000000000000000000..db89612108851ce8159efad82adfb4d0532a83a2 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/folder-scons-open.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/folder-scons.svg b/packages/ui/src/assets/icons/file-types/folder-scons.svg new file mode 100644 index 0000000000000000000000000000000000000000..aae02b46ca1f52136c73b891dd30a9c8d8abe12e --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/folder-scons.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/folder-secure-open.svg b/packages/ui/src/assets/icons/file-types/folder-secure-open.svg new file mode 100644 index 0000000000000000000000000000000000000000..163f7da403a0de3840506e3d3aac4b1f67f50ca4 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/folder-secure-open.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/folder-seeders.svg b/packages/ui/src/assets/icons/file-types/folder-seeders.svg new file mode 100644 index 0000000000000000000000000000000000000000..cd59776adbca7d87038a0139761c39b800d1d609 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/folder-seeders.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/folder-server-open.svg b/packages/ui/src/assets/icons/file-types/folder-server-open.svg new file mode 100644 index 0000000000000000000000000000000000000000..706b8af360aba38670767f971a6f2581667a27aa --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/folder-server-open.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/folder-serverless.svg b/packages/ui/src/assets/icons/file-types/folder-serverless.svg new file mode 100644 index 0000000000000000000000000000000000000000..226f89d41a1fb82b614380e0b14bfd616859a65e --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/folder-serverless.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/folder-shader.svg b/packages/ui/src/assets/icons/file-types/folder-shader.svg new file mode 100644 index 0000000000000000000000000000000000000000..57772b323bc58f41e3c6755c79bd74128b722384 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/folder-shader.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/folder-shared.svg b/packages/ui/src/assets/icons/file-types/folder-shared.svg new file mode 100644 index 0000000000000000000000000000000000000000..01e7a17dd0dd993d33bd9c03a48e0a573f36b956 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/folder-shared.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/folder-src-open.svg b/packages/ui/src/assets/icons/file-types/folder-src-open.svg new file mode 100644 index 0000000000000000000000000000000000000000..8cd9ee3c1acc7216faeec86699e96697074d6c0d --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/folder-src-open.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/folder-src-tauri.svg b/packages/ui/src/assets/icons/file-types/folder-src-tauri.svg new file mode 100644 index 0000000000000000000000000000000000000000..727790c81babb713fd5228b5903ee9420dec54e3 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/folder-src-tauri.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/folder-src.svg b/packages/ui/src/assets/icons/file-types/folder-src.svg new file mode 100644 index 0000000000000000000000000000000000000000..8d45da99259b6fa259c74f3fbd03eab2eaebb612 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/folder-src.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/folder-stack-open.svg b/packages/ui/src/assets/icons/file-types/folder-stack-open.svg new file mode 100644 index 0000000000000000000000000000000000000000..cfd8bd057bac3f865afe6e3d3d55e075de43eb4d --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/folder-stack-open.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/folder-stencil-open.svg b/packages/ui/src/assets/icons/file-types/folder-stencil-open.svg new file mode 100644 index 0000000000000000000000000000000000000000..6dea078a7248e4ab2d3ac43f2149209d265913ae --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/folder-stencil-open.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/folder-store-open.svg b/packages/ui/src/assets/icons/file-types/folder-store-open.svg new file mode 100644 index 0000000000000000000000000000000000000000..13e415bc558e53625b591cd837c6314807e5bf96 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/folder-store-open.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/folder-store.svg b/packages/ui/src/assets/icons/file-types/folder-store.svg new file mode 100644 index 0000000000000000000000000000000000000000..ae29c03de7941fa2d3e995e588a6c1adf5df3860 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/folder-store.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/folder-storybook.svg b/packages/ui/src/assets/icons/file-types/folder-storybook.svg new file mode 100644 index 0000000000000000000000000000000000000000..26e6246f0b0b6cc6c19aba318072e817e350e370 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/folder-storybook.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/folder-stylus-open.svg b/packages/ui/src/assets/icons/file-types/folder-stylus-open.svg new file mode 100644 index 0000000000000000000000000000000000000000..9615173c5875b262422d8b56c29f5bd0f1d59cd4 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/folder-stylus-open.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/folder-stylus.svg b/packages/ui/src/assets/icons/file-types/folder-stylus.svg new file mode 100644 index 0000000000000000000000000000000000000000..68ae158fba2d784a15471244a4231a931a5437c8 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/folder-stylus.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/folder-svg.svg b/packages/ui/src/assets/icons/file-types/folder-svg.svg new file mode 100644 index 0000000000000000000000000000000000000000..320b9eb59b44dda5e39864d30d5817bd5e1c5518 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/folder-svg.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/folder-syntax.svg b/packages/ui/src/assets/icons/file-types/folder-syntax.svg new file mode 100644 index 0000000000000000000000000000000000000000..be4ab16193ea0336ba9685ff4676d33dd7a8d8e6 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/folder-syntax.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/folder-target-open.svg b/packages/ui/src/assets/icons/file-types/folder-target-open.svg new file mode 100644 index 0000000000000000000000000000000000000000..0004bf8668aa32b0d35feffd060fa258eb985fec --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/folder-target-open.svg @@ -0,0 +1,10 @@ + + + + + + diff --git a/packages/ui/src/assets/icons/file-types/folder-taskfile-open.svg b/packages/ui/src/assets/icons/file-types/folder-taskfile-open.svg new file mode 100644 index 0000000000000000000000000000000000000000..fc2c501483e4177a8b51ae287e5cf5f83ab01098 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/folder-taskfile-open.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/folder-tasks.svg b/packages/ui/src/assets/icons/file-types/folder-tasks.svg new file mode 100644 index 0000000000000000000000000000000000000000..1a9ef8ad6533eff5b2e63a738c4d1f5d39af054c --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/folder-tasks.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/folder-television-open.svg b/packages/ui/src/assets/icons/file-types/folder-television-open.svg new file mode 100644 index 0000000000000000000000000000000000000000..33c21d8bc640ae51904f4ab322f79cb13c72b69d --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/folder-television-open.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/folder-temp.svg b/packages/ui/src/assets/icons/file-types/folder-temp.svg new file mode 100644 index 0000000000000000000000000000000000000000..3002a86c230e1e5275fc0edc64b8d71d5c0cfbe5 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/folder-temp.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/folder-template.svg b/packages/ui/src/assets/icons/file-types/folder-template.svg new file mode 100644 index 0000000000000000000000000000000000000000..1d158370444666aa9409cff07755839204de665f --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/folder-template.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/folder-terraform-open.svg b/packages/ui/src/assets/icons/file-types/folder-terraform-open.svg new file mode 100644 index 0000000000000000000000000000000000000000..fff197bb0aeaf0d866a6b5c2f99905b06bf09cca --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/folder-terraform-open.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/folder-test-open.svg b/packages/ui/src/assets/icons/file-types/folder-test-open.svg new file mode 100644 index 0000000000000000000000000000000000000000..f3fefb357a4188df8eb08e7f9c76e073dbe6b8ff --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/folder-test-open.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/folder-test.svg b/packages/ui/src/assets/icons/file-types/folder-test.svg new file mode 100644 index 0000000000000000000000000000000000000000..92bee1623c57151a0b4c6777e9841e71469b82b7 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/folder-test.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/folder-theme.svg b/packages/ui/src/assets/icons/file-types/folder-theme.svg new file mode 100644 index 0000000000000000000000000000000000000000..88efa9551de7de580fac1595fee2377789b31172 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/folder-theme.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/folder-tools-open.svg b/packages/ui/src/assets/icons/file-types/folder-tools-open.svg new file mode 100644 index 0000000000000000000000000000000000000000..77ecaa886bdf4de6c54ccbf921ddec89ccac0bd6 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/folder-tools-open.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/folder-trash-open.svg b/packages/ui/src/assets/icons/file-types/folder-trash-open.svg new file mode 100644 index 0000000000000000000000000000000000000000..add51b82d38e445f9783345d02ed44c68f942335 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/folder-trash-open.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/folder-trigger.svg b/packages/ui/src/assets/icons/file-types/folder-trigger.svg new file mode 100644 index 0000000000000000000000000000000000000000..cfe23c1bf49728c3fcbe5d965000568ccc82c429 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/folder-trigger.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/folder-turborepo.svg b/packages/ui/src/assets/icons/file-types/folder-turborepo.svg new file mode 100644 index 0000000000000000000000000000000000000000..ea203360bd4b159fc44583b5385466330b93bc3a --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/folder-turborepo.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + diff --git a/packages/ui/src/assets/icons/file-types/folder-typescript.svg b/packages/ui/src/assets/icons/file-types/folder-typescript.svg new file mode 100644 index 0000000000000000000000000000000000000000..df26f8937ed5c32d1e4e3a81f8cab9a355efedf9 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/folder-typescript.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/folder-update-open.svg b/packages/ui/src/assets/icons/file-types/folder-update-open.svg new file mode 100644 index 0000000000000000000000000000000000000000..a6d18a9a79390564b93ca3fff79aa928d8c465bd --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/folder-update-open.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/folder-update.svg b/packages/ui/src/assets/icons/file-types/folder-update.svg new file mode 100644 index 0000000000000000000000000000000000000000..65eaf57d71d80465d3b817b9ceaf0dc42fe0acee --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/folder-update.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/folder-utils-open.svg b/packages/ui/src/assets/icons/file-types/folder-utils-open.svg new file mode 100644 index 0000000000000000000000000000000000000000..b894eff0b9c5dbbebecdacf48c9aee70dc535b09 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/folder-utils-open.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/folder-vercel-open.svg b/packages/ui/src/assets/icons/file-types/folder-vercel-open.svg new file mode 100644 index 0000000000000000000000000000000000000000..c571c63f3d6601ba1c2f298593b833d06e4dab2d --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/folder-vercel-open.svg @@ -0,0 +1,5 @@ + + + + diff --git a/packages/ui/src/assets/icons/file-types/folder-vercel.svg b/packages/ui/src/assets/icons/file-types/folder-vercel.svg new file mode 100644 index 0000000000000000000000000000000000000000..51384813030cba9c3d9c3ef96f77604319026c21 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/folder-vercel.svg @@ -0,0 +1,5 @@ + + + + diff --git a/packages/ui/src/assets/icons/file-types/folder-verdaccio.svg b/packages/ui/src/assets/icons/file-types/folder-verdaccio.svg new file mode 100644 index 0000000000000000000000000000000000000000..8e78ba79872efd7915c6937da6b18f8be736ce83 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/folder-verdaccio.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/folder-views-open.svg b/packages/ui/src/assets/icons/file-types/folder-views-open.svg new file mode 100644 index 0000000000000000000000000000000000000000..1c785e4ce2d441956562409b3ebfdf04c8000263 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/folder-views-open.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/folder-views.svg b/packages/ui/src/assets/icons/file-types/folder-views.svg new file mode 100644 index 0000000000000000000000000000000000000000..5d41f10b05e34d36bacf73e7a150e66c1ac2db42 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/folder-views.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/folder-vscode-open.svg b/packages/ui/src/assets/icons/file-types/folder-vscode-open.svg new file mode 100644 index 0000000000000000000000000000000000000000..82e3a21e04a801e500557d3b98784f497eb603d8 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/folder-vscode-open.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/folder-vue-directives-open.svg b/packages/ui/src/assets/icons/file-types/folder-vue-directives-open.svg new file mode 100644 index 0000000000000000000000000000000000000000..341354bbc4c9697f27f641ae3a8c904f0745744b --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/folder-vue-directives-open.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/folder-vuepress.svg b/packages/ui/src/assets/icons/file-types/folder-vuepress.svg new file mode 100644 index 0000000000000000000000000000000000000000..42fb0dc4bcac5df16cf30e983ab6c7068d42ec08 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/folder-vuepress.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/folder-webpack-open.svg b/packages/ui/src/assets/icons/file-types/folder-webpack-open.svg new file mode 100644 index 0000000000000000000000000000000000000000..acd1e1919e77acb56f78cfdc02ddaeef665a2ee9 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/folder-webpack-open.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/folder-webpack.svg b/packages/ui/src/assets/icons/file-types/folder-webpack.svg new file mode 100644 index 0000000000000000000000000000000000000000..3ac887a2a92db866c59fa245320edad911a50940 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/folder-webpack.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/folder-windows.svg b/packages/ui/src/assets/icons/file-types/folder-windows.svg new file mode 100644 index 0000000000000000000000000000000000000000..184de3100774447d85209b22b353fe8eb5d60052 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/folder-windows.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/folder-wordpress-open.svg b/packages/ui/src/assets/icons/file-types/folder-wordpress-open.svg new file mode 100644 index 0000000000000000000000000000000000000000..8cb4006dbf4729f7097778ec58e5e981a53e1a91 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/folder-wordpress-open.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/folder-yarn-open.svg b/packages/ui/src/assets/icons/file-types/folder-yarn-open.svg new file mode 100644 index 0000000000000000000000000000000000000000..ddbb988942b807b26ba440ef9fda8827e20f126d --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/folder-yarn-open.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/fortran.svg b/packages/ui/src/assets/icons/file-types/fortran.svg new file mode 100644 index 0000000000000000000000000000000000000000..235db1a060fa390c51796054424637169b2baf55 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/fortran.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/foxpro.svg b/packages/ui/src/assets/icons/file-types/foxpro.svg new file mode 100644 index 0000000000000000000000000000000000000000..e2d5eb00b087b67db159baae9cee5f83158a7ed3 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/foxpro.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/fsharp.svg b/packages/ui/src/assets/icons/file-types/fsharp.svg new file mode 100644 index 0000000000000000000000000000000000000000..1e5b7cfdc8c8021c7d9a053b3b46efa80f27dffe --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/fsharp.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/fusebox.svg b/packages/ui/src/assets/icons/file-types/fusebox.svg new file mode 100644 index 0000000000000000000000000000000000000000..a4ad3d666e7dc786914a4bb6b76b8f974d6156cf --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/fusebox.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/gatsby.svg b/packages/ui/src/assets/icons/file-types/gatsby.svg new file mode 100644 index 0000000000000000000000000000000000000000..c2674692e5cb9f22d8debefb8639bfee53b1a977 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/gatsby.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/gcp.svg b/packages/ui/src/assets/icons/file-types/gcp.svg new file mode 100644 index 0000000000000000000000000000000000000000..62be904145e88191c34e19ff5d34e82fc6548802 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/gcp.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/git.svg b/packages/ui/src/assets/icons/file-types/git.svg new file mode 100644 index 0000000000000000000000000000000000000000..c1e08fd4eb810a79db5b61c0ad4c838eb6a6fd8b --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/git.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/github-actions-workflow.svg b/packages/ui/src/assets/icons/file-types/github-actions-workflow.svg new file mode 100644 index 0000000000000000000000000000000000000000..1c724c5fff32a2ee954b1fc65c98bff5df53eefa --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/github-actions-workflow.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/gitlab.svg b/packages/ui/src/assets/icons/file-types/gitlab.svg new file mode 100644 index 0000000000000000000000000000000000000000..ceeabaf9a7a31ac957aae91da4b0bca75936e9e6 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/gitlab.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/gleam.svg b/packages/ui/src/assets/icons/file-types/gleam.svg new file mode 100644 index 0000000000000000000000000000000000000000..76e0d0c5ede01524a4a31ef2dc4e83732fc7761f --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/gleam.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/haml.svg b/packages/ui/src/assets/icons/file-types/haml.svg new file mode 100644 index 0000000000000000000000000000000000000000..bf08db53ae2b48a9c8d7576ceac1ce8f415e9804 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/haml.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/haskell.svg b/packages/ui/src/assets/icons/file-types/haskell.svg new file mode 100644 index 0000000000000000000000000000000000000000..ae44927a92f8986f0105866c0d5befd9c97b303b --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/haskell.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/hcl.svg b/packages/ui/src/assets/icons/file-types/hcl.svg new file mode 100644 index 0000000000000000000000000000000000000000..71edfb4af78ec75a3963b21573b2293b86089862 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/hcl.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/helm.svg b/packages/ui/src/assets/icons/file-types/helm.svg new file mode 100644 index 0000000000000000000000000000000000000000..58aa4a82d59db58bd01e31de66ffae64a5c269bb --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/helm.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/heroku.svg b/packages/ui/src/assets/icons/file-types/heroku.svg new file mode 100644 index 0000000000000000000000000000000000000000..d9d1ab03db5403278e62142d4425df57db4a0fd8 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/heroku.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/hosts.svg b/packages/ui/src/assets/icons/file-types/hosts.svg new file mode 100644 index 0000000000000000000000000000000000000000..f88e7c6c8d3b07db461bcc343fc6449a64c2e412 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/hosts.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/hosts_light.svg b/packages/ui/src/assets/icons/file-types/hosts_light.svg new file mode 100644 index 0000000000000000000000000000000000000000..613a25e390237cdae6e9ab74710c7b7e8b79b129 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/hosts_light.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/http.svg b/packages/ui/src/assets/icons/file-types/http.svg new file mode 100644 index 0000000000000000000000000000000000000000..94574d4a5c52acef6640422e55ecd96cec063bd0 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/http.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/huff.svg b/packages/ui/src/assets/icons/file-types/huff.svg new file mode 100644 index 0000000000000000000000000000000000000000..2232914150a530249896c39ee35a7d772eb452f2 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/huff.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/huff_light.svg b/packages/ui/src/assets/icons/file-types/huff_light.svg new file mode 100644 index 0000000000000000000000000000000000000000..43889e0369bd8ac332d07979571762c31db53211 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/huff_light.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/ifanr-cloud.svg b/packages/ui/src/assets/icons/file-types/ifanr-cloud.svg new file mode 100644 index 0000000000000000000000000000000000000000..c356b1691b9ff03834bb8e9ca15f19908a2f707a --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/ifanr-cloud.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/image.svg b/packages/ui/src/assets/icons/file-types/image.svg new file mode 100644 index 0000000000000000000000000000000000000000..0ca446bb91ebd3bcaa2f13a7ca2b753d5ecc7e5d --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/image.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/imba.svg b/packages/ui/src/assets/icons/file-types/imba.svg new file mode 100644 index 0000000000000000000000000000000000000000..60b06154cf203114c6ba0c59397c69ce709292b4 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/imba.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/jar.svg b/packages/ui/src/assets/icons/file-types/jar.svg new file mode 100644 index 0000000000000000000000000000000000000000..1c81c48c5ada713b2e800adb33867418e0e3959b --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/jar.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/java.svg b/packages/ui/src/assets/icons/file-types/java.svg new file mode 100644 index 0000000000000000000000000000000000000000..0950bc402e98a574b15ec9cbd4767087c32ec4f1 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/java.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/javascript-map.svg b/packages/ui/src/assets/icons/file-types/javascript-map.svg new file mode 100644 index 0000000000000000000000000000000000000000..a1fcc227302ce60b6a33778e18a3e28a0d1319cc --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/javascript-map.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/jsconfig.svg b/packages/ui/src/assets/icons/file-types/jsconfig.svg new file mode 100644 index 0000000000000000000000000000000000000000..5aef48128a4835fdc7fec3d181c418713ed78d5e --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/jsconfig.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/json.svg b/packages/ui/src/assets/icons/file-types/json.svg new file mode 100644 index 0000000000000000000000000000000000000000..2590b943f06a04f3d8cb96d530a29f32654f86e5 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/json.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/jsr.svg b/packages/ui/src/assets/icons/file-types/jsr.svg new file mode 100644 index 0000000000000000000000000000000000000000..739f6574e863ad4cb2473d8e07eb7304743b5a48 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/jsr.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/jsr_light.svg b/packages/ui/src/assets/icons/file-types/jsr_light.svg new file mode 100644 index 0000000000000000000000000000000000000000..c93d452242aaacc5df63f548f7922e1ab30b73c0 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/jsr_light.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/julia.svg b/packages/ui/src/assets/icons/file-types/julia.svg new file mode 100644 index 0000000000000000000000000000000000000000..39fca635143812e67423189393ab1d19edbd5a16 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/julia.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/jupyter.svg b/packages/ui/src/assets/icons/file-types/jupyter.svg new file mode 100644 index 0000000000000000000000000000000000000000..770bffbc61f138e806ceb06d33ef5096b9f43ee7 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/jupyter.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/just.svg b/packages/ui/src/assets/icons/file-types/just.svg new file mode 100644 index 0000000000000000000000000000000000000000..7fc754314491636fbe1a87bf8601e63ed6965fb0 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/just.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/karma.svg b/packages/ui/src/assets/icons/file-types/karma.svg new file mode 100644 index 0000000000000000000000000000000000000000..0db4ab60b6798e8c7b8c51efd438f5d903240ff7 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/karma.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/kcl.svg b/packages/ui/src/assets/icons/file-types/kcl.svg new file mode 100644 index 0000000000000000000000000000000000000000..4f10c602eafb1b933a24ddc95b657ac6c37b17cb --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/kcl.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/key.svg b/packages/ui/src/assets/icons/file-types/key.svg new file mode 100644 index 0000000000000000000000000000000000000000..08f67af4ce988883ab7660887374ff25da6b20a4 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/key.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/keystatic.svg b/packages/ui/src/assets/icons/file-types/keystatic.svg new file mode 100644 index 0000000000000000000000000000000000000000..087b65872756f928a31051492bdede3241c504e6 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/keystatic.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/kivy.svg b/packages/ui/src/assets/icons/file-types/kivy.svg new file mode 100644 index 0000000000000000000000000000000000000000..2a1a35c4aceb67e76a01c06d39ae90b5b0e05264 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/kivy.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/kotlin.svg b/packages/ui/src/assets/icons/file-types/kotlin.svg new file mode 100644 index 0000000000000000000000000000000000000000..740505c196a012af32712d89050d4be1e49a1ed8 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/kotlin.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/kubernetes.svg b/packages/ui/src/assets/icons/file-types/kubernetes.svg new file mode 100644 index 0000000000000000000000000000000000000000..6726dcc86f408902a79a992f1dc35e660fe05f14 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/kubernetes.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/label.svg b/packages/ui/src/assets/icons/file-types/label.svg new file mode 100644 index 0000000000000000000000000000000000000000..28abeacd718696b37ca1a7ea26125674831ac8ae --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/label.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/laravel.svg b/packages/ui/src/assets/icons/file-types/laravel.svg new file mode 100644 index 0000000000000000000000000000000000000000..95ee92351f8c61a935b64687949c06e4939bf459 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/laravel.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/lbx.svg b/packages/ui/src/assets/icons/file-types/lbx.svg new file mode 100644 index 0000000000000000000000000000000000000000..c66f15715d17e0aec6d5b90ee767bffda511809c --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/lbx.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/lerna.svg b/packages/ui/src/assets/icons/file-types/lerna.svg new file mode 100644 index 0000000000000000000000000000000000000000..4128d6b9d294dd2386349b5b87a5a22f5635cc5b --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/lerna.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/liara.svg b/packages/ui/src/assets/icons/file-types/liara.svg new file mode 100644 index 0000000000000000000000000000000000000000..2fd408c6ec7c6e2dd04eb47224a38626e53f2876 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/liara.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/lighthouse.svg b/packages/ui/src/assets/icons/file-types/lighthouse.svg new file mode 100644 index 0000000000000000000000000000000000000000..02292441080e67d1fdd9a977684ef1c5f4496316 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/lighthouse.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/lilypond.svg b/packages/ui/src/assets/icons/file-types/lilypond.svg new file mode 100644 index 0000000000000000000000000000000000000000..a12aa2cc2cc8e0524d3115a88d6dad692d8c952a --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/lilypond.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/lintstaged.svg b/packages/ui/src/assets/icons/file-types/lintstaged.svg new file mode 100644 index 0000000000000000000000000000000000000000..fbf94678e1eed0240b3441b0b25e5947f41c9ba5 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/lintstaged.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/lisp.svg b/packages/ui/src/assets/icons/file-types/lisp.svg new file mode 100644 index 0000000000000000000000000000000000000000..76e4f465bf4abdfd06ff3c0593f489af796a3cfd --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/lisp.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/livescript.svg b/packages/ui/src/assets/icons/file-types/livescript.svg new file mode 100644 index 0000000000000000000000000000000000000000..d7dcb37c3f2a4eebb5dcfd88e8ae943a3d482c34 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/livescript.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/lock.svg b/packages/ui/src/assets/icons/file-types/lock.svg new file mode 100644 index 0000000000000000000000000000000000000000..ca49d02c34087d21947c7b1165c052d2338474c7 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/lock.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/lyric.svg b/packages/ui/src/assets/icons/file-types/lyric.svg new file mode 100644 index 0000000000000000000000000000000000000000..06bb43e4db6626b7c7dfb268d33706dd35217074 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/lyric.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/markdoc-config.svg b/packages/ui/src/assets/icons/file-types/markdoc-config.svg new file mode 100644 index 0000000000000000000000000000000000000000..13913c38c332c3ae6c0b56c768357e8afce1ed0a --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/markdoc-config.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/mathematica.svg b/packages/ui/src/assets/icons/file-types/mathematica.svg new file mode 100644 index 0000000000000000000000000000000000000000..08c25084b5e1781a15d9cc4db739ef5f346563df --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/mathematica.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/mdsvex.svg b/packages/ui/src/assets/icons/file-types/mdsvex.svg new file mode 100644 index 0000000000000000000000000000000000000000..34b252af1d40ab7abfbfcb8b385651bec22be99f --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/mdsvex.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/mdx.svg b/packages/ui/src/assets/icons/file-types/mdx.svg new file mode 100644 index 0000000000000000000000000000000000000000..b2ab5611a5afc19f2dd79184fe6c6937a8f0a863 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/mdx.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/mercurial.svg b/packages/ui/src/assets/icons/file-types/mercurial.svg new file mode 100644 index 0000000000000000000000000000000000000000..41f701e23c82169e44b947414277579ce77e09f4 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/mercurial.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/mermaid.svg b/packages/ui/src/assets/icons/file-types/mermaid.svg new file mode 100644 index 0000000000000000000000000000000000000000..b1f520d812b362d4d9fb6650b8f7e2390a7e2a99 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/mermaid.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/minecraft-fabric.svg b/packages/ui/src/assets/icons/file-types/minecraft-fabric.svg new file mode 100644 index 0000000000000000000000000000000000000000..4c0985b9c5e68487a85e8d4352e72c86a1969bda --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/minecraft-fabric.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/mint.svg b/packages/ui/src/assets/icons/file-types/mint.svg new file mode 100644 index 0000000000000000000000000000000000000000..659340a8c5596790f8d1fb151425b544d8b794e4 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/mint.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/mjml.svg b/packages/ui/src/assets/icons/file-types/mjml.svg new file mode 100644 index 0000000000000000000000000000000000000000..5580ca09617003388bf8067ce9585fdc16980916 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/mjml.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/modernizr.svg b/packages/ui/src/assets/icons/file-types/modernizr.svg new file mode 100644 index 0000000000000000000000000000000000000000..b340bec1b9efc2550043491e2b622d94c790a491 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/modernizr.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/mojo.svg b/packages/ui/src/assets/icons/file-types/mojo.svg new file mode 100644 index 0000000000000000000000000000000000000000..505a8f529cd17deea4b8facf87b6b70b979ce6bb --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/mojo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/moonscript.svg b/packages/ui/src/assets/icons/file-types/moonscript.svg new file mode 100644 index 0000000000000000000000000000000000000000..1d7f7ee9f61009a72d545c78ba635ad413a6b9c0 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/moonscript.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/nano-staged.svg b/packages/ui/src/assets/icons/file-types/nano-staged.svg new file mode 100644 index 0000000000000000000000000000000000000000..6e6cd07514eb061c74af3ea9190d7c1cfc3d16f1 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/nano-staged.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/ndst.svg b/packages/ui/src/assets/icons/file-types/ndst.svg new file mode 100644 index 0000000000000000000000000000000000000000..1941313856bf45efcfd86a006a56ec44b4891ad0 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/ndst.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/netlify.svg b/packages/ui/src/assets/icons/file-types/netlify.svg new file mode 100644 index 0000000000000000000000000000000000000000..27c837fcc5fc9598d5fd780acf2c93eeafcf7c2c --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/netlify.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/next_light.svg b/packages/ui/src/assets/icons/file-types/next_light.svg new file mode 100644 index 0000000000000000000000000000000000000000..6e5fb272ebec559e5028fbbb34476ce919278123 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/next_light.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/nim.svg b/packages/ui/src/assets/icons/file-types/nim.svg new file mode 100644 index 0000000000000000000000000000000000000000..d985bb40ee0d7f9782fc29d3312835b6bce4cb26 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/nim.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/nix.svg b/packages/ui/src/assets/icons/file-types/nix.svg new file mode 100644 index 0000000000000000000000000000000000000000..a507609698b7251a9486252b36f5f7b9a570766e --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/nix.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/nodejs.svg b/packages/ui/src/assets/icons/file-types/nodejs.svg new file mode 100644 index 0000000000000000000000000000000000000000..ba7390153ce9e0d5246ae5b1280f90dff785f761 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/nodejs.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/npm.svg b/packages/ui/src/assets/icons/file-types/npm.svg new file mode 100644 index 0000000000000000000000000000000000000000..87aa58368ae569fe7745a3932142170d54d1e5a6 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/npm.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/nuxt.svg b/packages/ui/src/assets/icons/file-types/nuxt.svg new file mode 100644 index 0000000000000000000000000000000000000000..babf91945dd4992b6adc2978ab997f3770c58e13 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/nuxt.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/nx.svg b/packages/ui/src/assets/icons/file-types/nx.svg new file mode 100644 index 0000000000000000000000000000000000000000..8db832302a272831f029bc89b67484ce8cff8003 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/nx.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/objective-c.svg b/packages/ui/src/assets/icons/file-types/objective-c.svg new file mode 100644 index 0000000000000000000000000000000000000000..7a69f91d6afac33c47ca0bd8de0aeb8279cac9f7 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/objective-c.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/objective-cpp.svg b/packages/ui/src/assets/icons/file-types/objective-cpp.svg new file mode 100644 index 0000000000000000000000000000000000000000..cd55d1ea5606ed4ed28498c255f58af6e08bda15 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/objective-cpp.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/ocaml.svg b/packages/ui/src/assets/icons/file-types/ocaml.svg new file mode 100644 index 0000000000000000000000000000000000000000..cb6eb6b9488fa6e1b3720508caa3326cf12bf62e --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/ocaml.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/opam.svg b/packages/ui/src/assets/icons/file-types/opam.svg new file mode 100644 index 0000000000000000000000000000000000000000..70f1b7f02f9ff58242ad4df4cbbd70196479b511 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/opam.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/otne.svg b/packages/ui/src/assets/icons/file-types/otne.svg new file mode 100644 index 0000000000000000000000000000000000000000..8670a615bd71b8e60c52915896c7fa4dae75ac50 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/otne.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/palette.svg b/packages/ui/src/assets/icons/file-types/palette.svg new file mode 100644 index 0000000000000000000000000000000000000000..cc27f66ae12137be966c254a2eebfc9fee6ac19b --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/palette.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/pascal.svg b/packages/ui/src/assets/icons/file-types/pascal.svg new file mode 100644 index 0000000000000000000000000000000000000000..b0a2993ebc0ca46bd440163810cf45a2b8487a48 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/pascal.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/payload.svg b/packages/ui/src/assets/icons/file-types/payload.svg new file mode 100644 index 0000000000000000000000000000000000000000..8e1e82abdfed53e76630519dd761c608cd0a4f86 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/payload.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/payload_light.svg b/packages/ui/src/assets/icons/file-types/payload_light.svg new file mode 100644 index 0000000000000000000000000000000000000000..7a4e9c7d37b91246d3b6c5cf8dda14bc6d3873a3 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/payload_light.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/percy.svg b/packages/ui/src/assets/icons/file-types/percy.svg new file mode 100644 index 0000000000000000000000000000000000000000..6d0f8973b40c35be401c1c2a29c34b692d9257f4 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/percy.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/perl.svg b/packages/ui/src/assets/icons/file-types/perl.svg new file mode 100644 index 0000000000000000000000000000000000000000..0534cade2fcd3ed5085e67571ce22260908355a3 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/perl.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/phpstan.svg b/packages/ui/src/assets/icons/file-types/phpstan.svg new file mode 100644 index 0000000000000000000000000000000000000000..34b612fe917864a408d3d6e328301a73246c5471 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/phpstan.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/phpunit.svg b/packages/ui/src/assets/icons/file-types/phpunit.svg new file mode 100644 index 0000000000000000000000000000000000000000..21322005ec1a0b2ccb99d137e3cc3f8395d3c7cf --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/phpunit.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/pinejs.svg b/packages/ui/src/assets/icons/file-types/pinejs.svg new file mode 100644 index 0000000000000000000000000000000000000000..44c0020b20c53ee4bf5244bed53e7c66598f98bb --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/pinejs.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/pkl.svg b/packages/ui/src/assets/icons/file-types/pkl.svg new file mode 100644 index 0000000000000000000000000000000000000000..3f31ead5de49f77cfdca34f36f4d9e622e65bd18 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/pkl.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/plastic.svg b/packages/ui/src/assets/icons/file-types/plastic.svg new file mode 100644 index 0000000000000000000000000000000000000000..cc00e5a7003b4c30b5e6ad7841b7ce87888e4898 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/plastic.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/playwright.svg b/packages/ui/src/assets/icons/file-types/playwright.svg new file mode 100644 index 0000000000000000000000000000000000000000..cae0b24aa16beb87bbe534d4ad3bcfb909377b7b --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/playwright.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/plop.svg b/packages/ui/src/assets/icons/file-types/plop.svg new file mode 100644 index 0000000000000000000000000000000000000000..85e3bd2f0fc2d397c954d39aa66bdcf36088b3f7 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/plop.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/powerpoint.svg b/packages/ui/src/assets/icons/file-types/powerpoint.svg new file mode 100644 index 0000000000000000000000000000000000000000..eaba916fbb4b335849cee1a4280c6b252589885a --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/powerpoint.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/prettier.svg b/packages/ui/src/assets/icons/file-types/prettier.svg new file mode 100644 index 0000000000000000000000000000000000000000..a6cda341bf73876d425b8f12dac9be2ffc2e90ee --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/prettier.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/processing.svg b/packages/ui/src/assets/icons/file-types/processing.svg new file mode 100644 index 0000000000000000000000000000000000000000..8a960abd8d94a8ce1c977d76bf370127e9d81549 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/processing.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/prolog.svg b/packages/ui/src/assets/icons/file-types/prolog.svg new file mode 100644 index 0000000000000000000000000000000000000000..7eda09071bf74335f0c5a8214389d362f369ec93 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/prolog.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/prompt.svg b/packages/ui/src/assets/icons/file-types/prompt.svg new file mode 100644 index 0000000000000000000000000000000000000000..aa37366b7d154e46d21fa47e7ef72551ea172cb3 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/prompt.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/pug.svg b/packages/ui/src/assets/icons/file-types/pug.svg new file mode 100644 index 0000000000000000000000000000000000000000..62a36027ca81a339016432c1e6cac6ec3f5e1426 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/pug.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/puppet.svg b/packages/ui/src/assets/icons/file-types/puppet.svg new file mode 100644 index 0000000000000000000000000000000000000000..3e1e9c12bc4a1efae9218d6bacb69ed5fd955177 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/puppet.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/puppeteer.svg b/packages/ui/src/assets/icons/file-types/puppeteer.svg new file mode 100644 index 0000000000000000000000000000000000000000..b553df3926b27eba430d6a85b0bee8d9f11b627a --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/puppeteer.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/python-misc.svg b/packages/ui/src/assets/icons/file-types/python-misc.svg new file mode 100644 index 0000000000000000000000000000000000000000..44fb730e5dccd73de7171be6c7ff57d48f2e5681 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/python-misc.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/python.svg b/packages/ui/src/assets/icons/file-types/python.svg new file mode 100644 index 0000000000000000000000000000000000000000..20c2508a26bf12f8f37a0b55975c334a5a1b0621 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/python.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/quasar.svg b/packages/ui/src/assets/icons/file-types/quasar.svg new file mode 100644 index 0000000000000000000000000000000000000000..fa02ff08102248c806ae68e9aa07a6c92feeece4 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/quasar.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/quokka.svg b/packages/ui/src/assets/icons/file-types/quokka.svg new file mode 100644 index 0000000000000000000000000000000000000000..bf368de32d4e154b2de36131d3ce77b4e1c59fd2 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/quokka.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/r.svg b/packages/ui/src/assets/icons/file-types/r.svg new file mode 100644 index 0000000000000000000000000000000000000000..5703dd0f9fc771b6044b218c3dc5ca5b7d0aed54 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/r.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/razor.svg b/packages/ui/src/assets/icons/file-types/razor.svg new file mode 100644 index 0000000000000000000000000000000000000000..4e99091f12a29abbc2a71445d6329df661e10d75 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/razor.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/readme.svg b/packages/ui/src/assets/icons/file-types/readme.svg new file mode 100644 index 0000000000000000000000000000000000000000..943d08f3c56fe74f87b1f61467695ace76ada95f --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/readme.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/red.svg b/packages/ui/src/assets/icons/file-types/red.svg new file mode 100644 index 0000000000000000000000000000000000000000..608423166717372f9a66ac6288b93d86431c4f6f --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/red.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/redux-action.svg b/packages/ui/src/assets/icons/file-types/redux-action.svg new file mode 100644 index 0000000000000000000000000000000000000000..a4872e7dbb494799a75ae27ba72a952db7d403aa --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/redux-action.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/redux-reducer.svg b/packages/ui/src/assets/icons/file-types/redux-reducer.svg new file mode 100644 index 0000000000000000000000000000000000000000..cfcca98a1c8b83570252b1b712b6b012fc6d6dbb --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/redux-reducer.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/redux-selector.svg b/packages/ui/src/assets/icons/file-types/redux-selector.svg new file mode 100644 index 0000000000000000000000000000000000000000..073c286df8fffd246a2f25fcf1750f693dc19bf0 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/redux-selector.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/remark.svg b/packages/ui/src/assets/icons/file-types/remark.svg new file mode 100644 index 0000000000000000000000000000000000000000..9d6a918315fda3ab25cc8d49506f0a29fb156ae9 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/remark.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/rescript-interface.svg b/packages/ui/src/assets/icons/file-types/rescript-interface.svg new file mode 100644 index 0000000000000000000000000000000000000000..db305536579486d69ef1e71bf237af3ef7e2b302 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/rescript-interface.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/restql.svg b/packages/ui/src/assets/icons/file-types/restql.svg new file mode 100644 index 0000000000000000000000000000000000000000..a056fe910f9bfa430b79dcca31c9462d6aea1209 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/restql.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/roadmap.svg b/packages/ui/src/assets/icons/file-types/roadmap.svg new file mode 100644 index 0000000000000000000000000000000000000000..2279eadd02fb599ef5969a13fc6b70f47afb6416 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/roadmap.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/rojo.svg b/packages/ui/src/assets/icons/file-types/rojo.svg new file mode 100644 index 0000000000000000000000000000000000000000..37c46ea0eba7cefc418f1e13ab6d66f6733dddb0 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/rojo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/rome.svg b/packages/ui/src/assets/icons/file-types/rome.svg new file mode 100644 index 0000000000000000000000000000000000000000..8f5de92d289228b4af39657fa49502e53b04cf62 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/rome.svg @@ -0,0 +1,6 @@ + + + + diff --git a/packages/ui/src/assets/icons/file-types/rubocop.svg b/packages/ui/src/assets/icons/file-types/rubocop.svg new file mode 100644 index 0000000000000000000000000000000000000000..e6a24a2355b64473ea94053185ef1da3c463baa9 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/rubocop.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/rubocop_light.svg b/packages/ui/src/assets/icons/file-types/rubocop_light.svg new file mode 100644 index 0000000000000000000000000000000000000000..689c023b51fa8f152dd3f78a9f2fc7ed87e253a2 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/rubocop_light.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/ruby.svg b/packages/ui/src/assets/icons/file-types/ruby.svg new file mode 100644 index 0000000000000000000000000000000000000000..2e3215d750d1b27703cfc42b302be595bbd1910a --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/ruby.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/ruff.svg b/packages/ui/src/assets/icons/file-types/ruff.svg new file mode 100644 index 0000000000000000000000000000000000000000..a526788a83156b2234b319450561e123d52f19ca --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/ruff.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/rust.svg b/packages/ui/src/assets/icons/file-types/rust.svg new file mode 100644 index 0000000000000000000000000000000000000000..b382aa4b12146af3fdf4529e4047d1022655e30d --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/rust.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/sas.svg b/packages/ui/src/assets/icons/file-types/sas.svg new file mode 100644 index 0000000000000000000000000000000000000000..d47c8bdef35686db7a27a3f2e84049994e392d34 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/sas.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/sass.svg b/packages/ui/src/assets/icons/file-types/sass.svg new file mode 100644 index 0000000000000000000000000000000000000000..6f39acb0296f3a2f1e24ab3162ba12aaee126342 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/sass.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/sbt.svg b/packages/ui/src/assets/icons/file-types/sbt.svg new file mode 100644 index 0000000000000000000000000000000000000000..37587c5cae96405b58fc8e04c157ad4d2a1ae515 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/sbt.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/scala.svg b/packages/ui/src/assets/icons/file-types/scala.svg new file mode 100644 index 0000000000000000000000000000000000000000..08e0c2d345e3fbf69689b24c83eed4d61d49d657 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/scala.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/scheme.svg b/packages/ui/src/assets/icons/file-types/scheme.svg new file mode 100644 index 0000000000000000000000000000000000000000..c8f986e86522223332a2af6b69c2adfa51be05f0 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/scheme.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/scons.svg b/packages/ui/src/assets/icons/file-types/scons.svg new file mode 100644 index 0000000000000000000000000000000000000000..d584ea832ce473722de9ff9486f9ed994acaea59 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/scons.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/scons_light.svg b/packages/ui/src/assets/icons/file-types/scons_light.svg new file mode 100644 index 0000000000000000000000000000000000000000..31f88d565adac34bc5c43b848ca3d92102f04432 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/scons_light.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/search.svg b/packages/ui/src/assets/icons/file-types/search.svg new file mode 100644 index 0000000000000000000000000000000000000000..3d35c8e249835ab7e8a33e5a46537665e47c50b0 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/search.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/semantic-release.svg b/packages/ui/src/assets/icons/file-types/semantic-release.svg new file mode 100644 index 0000000000000000000000000000000000000000..17187e89a1c5f187aaba59b9eb972566f435ea7a --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/semantic-release.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/sentry.svg b/packages/ui/src/assets/icons/file-types/sentry.svg new file mode 100644 index 0000000000000000000000000000000000000000..319e60a74598dc7e52290744c05a3f2bbc3a4adf --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/sentry.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/sequelize.svg b/packages/ui/src/assets/icons/file-types/sequelize.svg new file mode 100644 index 0000000000000000000000000000000000000000..0e4c7888fc17d750a51428f07dd8aa09a2ee9ca5 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/sequelize.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/serverless.svg b/packages/ui/src/assets/icons/file-types/serverless.svg new file mode 100644 index 0000000000000000000000000000000000000000..92ccca84adcb649f01828cd8c48466447d931b17 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/serverless.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/settings.svg b/packages/ui/src/assets/icons/file-types/settings.svg new file mode 100644 index 0000000000000000000000000000000000000000..dc701ae87b2ee65fb13bd8214b99bac7cf81dfe8 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/settings.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/siyuan.svg b/packages/ui/src/assets/icons/file-types/siyuan.svg new file mode 100644 index 0000000000000000000000000000000000000000..7a7488dd2de305ec7f26d50c2914c72af3ed7f74 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/siyuan.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/sml.svg b/packages/ui/src/assets/icons/file-types/sml.svg new file mode 100644 index 0000000000000000000000000000000000000000..8f92a33ba86eeb7a931c1106d6622767eee75687 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/sml.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/snowpack.svg b/packages/ui/src/assets/icons/file-types/snowpack.svg new file mode 100644 index 0000000000000000000000000000000000000000..7941faefb184fce6124388799cddf62eaefae234 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/snowpack.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/snyk.svg b/packages/ui/src/assets/icons/file-types/snyk.svg new file mode 100644 index 0000000000000000000000000000000000000000..90791eeec739207503e1833f12aaad12768d8570 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/snyk.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/solidity.svg b/packages/ui/src/assets/icons/file-types/solidity.svg new file mode 100644 index 0000000000000000000000000000000000000000..6ae9873d8498f022c42b1060e5de4e0d3b8af50e --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/solidity.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/stackblitz.svg b/packages/ui/src/assets/icons/file-types/stackblitz.svg new file mode 100644 index 0000000000000000000000000000000000000000..f1806a8b28373bf9056459c37cf753cbaf8898c0 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/stackblitz.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/steadybit.svg b/packages/ui/src/assets/icons/file-types/steadybit.svg new file mode 100644 index 0000000000000000000000000000000000000000..4871bbd76a5c0380c3f4601f448174d40463cb7b --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/steadybit.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/stitches_light.svg b/packages/ui/src/assets/icons/file-types/stitches_light.svg new file mode 100644 index 0000000000000000000000000000000000000000..8001d9dfc9cd3de0630606d55b6d960a9703f492 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/stitches_light.svg @@ -0,0 +1,10 @@ + + + + + + + diff --git a/packages/ui/src/assets/icons/file-types/stryker.svg b/packages/ui/src/assets/icons/file-types/stryker.svg new file mode 100644 index 0000000000000000000000000000000000000000..05d45e6145d9053eec898e4d209f482edd9b573d --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/stryker.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/subtitles.svg b/packages/ui/src/assets/icons/file-types/subtitles.svg new file mode 100644 index 0000000000000000000000000000000000000000..15eebd61f23be0fcd97507bd9fd85edfe3c52d86 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/subtitles.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/supabase.svg b/packages/ui/src/assets/icons/file-types/supabase.svg new file mode 100644 index 0000000000000000000000000000000000000000..78bfef7d9dceac3bb7619708e2ae853e5b5a60cc --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/supabase.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/svelte.svg b/packages/ui/src/assets/icons/file-types/svelte.svg new file mode 100644 index 0000000000000000000000000000000000000000..4b14a6f71380e6c8b345474aa66ae3cf25edd884 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/svelte.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/svgo.svg b/packages/ui/src/assets/icons/file-types/svgo.svg new file mode 100644 index 0000000000000000000000000000000000000000..4b9cb89047d23a9db2581360bf34714ee4cb5536 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/svgo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/swagger.svg b/packages/ui/src/assets/icons/file-types/swagger.svg new file mode 100644 index 0000000000000000000000000000000000000000..1f79152d1eda080f11b44c945b6b16fb9fa10c5b --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/swagger.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/sway.svg b/packages/ui/src/assets/icons/file-types/sway.svg new file mode 100644 index 0000000000000000000000000000000000000000..adca3282b8f5b05e29cd08961956bd194cf1fb3a --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/sway.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/syncpack.svg b/packages/ui/src/assets/icons/file-types/syncpack.svg new file mode 100644 index 0000000000000000000000000000000000000000..9c64e31846f513f2593a7bec170ec54cf7a5461f --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/syncpack.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/systemd.svg b/packages/ui/src/assets/icons/file-types/systemd.svg new file mode 100644 index 0000000000000000000000000000000000000000..943b77f2660b617369d8011298bc931bc942f803 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/systemd.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/systemd_light.svg b/packages/ui/src/assets/icons/file-types/systemd_light.svg new file mode 100644 index 0000000000000000000000000000000000000000..39e81f63b385e6a23f3959212c60235b2fd603e2 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/systemd_light.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/table.svg b/packages/ui/src/assets/icons/file-types/table.svg new file mode 100644 index 0000000000000000000000000000000000000000..040b3839e82d92581a278ed7adc89b409ed87232 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/table.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/taskfile.svg b/packages/ui/src/assets/icons/file-types/taskfile.svg new file mode 100644 index 0000000000000000000000000000000000000000..99a775f69bd902c3d89fefdd09ae7f03fb17b402 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/taskfile.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/taze.svg b/packages/ui/src/assets/icons/file-types/taze.svg new file mode 100644 index 0000000000000000000000000000000000000000..c6e3a3fc8cead2c5389214cd6bf44be41eee7843 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/taze.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/tcl.svg b/packages/ui/src/assets/icons/file-types/tcl.svg new file mode 100644 index 0000000000000000000000000000000000000000..3c196a696d0d1068614ada80329ab024924657bc --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/tcl.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/templ.svg b/packages/ui/src/assets/icons/file-types/templ.svg new file mode 100644 index 0000000000000000000000000000000000000000..5b79cfe99d16dbbbed2031f6a1fc3232aa099ff7 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/templ.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/terraform.svg b/packages/ui/src/assets/icons/file-types/terraform.svg new file mode 100644 index 0000000000000000000000000000000000000000..d072809bfcb0e8e29837adca89f5fa790b77c939 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/terraform.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/test-ts.svg b/packages/ui/src/assets/icons/file-types/test-ts.svg new file mode 100644 index 0000000000000000000000000000000000000000..0b4ec71be22ce81179685bf468c46d2be68d37cd --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/test-ts.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/tex.svg b/packages/ui/src/assets/icons/file-types/tex.svg new file mode 100644 index 0000000000000000000000000000000000000000..83fc24ad00315388a3e83fd247ca9e91585b32b3 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/tex.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/textlint.svg b/packages/ui/src/assets/icons/file-types/textlint.svg new file mode 100644 index 0000000000000000000000000000000000000000..a619bf04dab82abeb9ca782d079e5a401307bfb8 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/textlint.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/tilt.svg b/packages/ui/src/assets/icons/file-types/tilt.svg new file mode 100644 index 0000000000000000000000000000000000000000..0ab8428516561ae9c86a2b2888118cf7dfa6c68b --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/tilt.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/tldraw.svg b/packages/ui/src/assets/icons/file-types/tldraw.svg new file mode 100644 index 0000000000000000000000000000000000000000..c4e6d6b8fbb2249eece949c8934a8b0e23f3d4ea --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/tldraw.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/tobimake.svg b/packages/ui/src/assets/icons/file-types/tobimake.svg new file mode 100644 index 0000000000000000000000000000000000000000..0ba3b3e7f2693111b94acbdeedd0af7b3c86d3f7 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/tobimake.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/todo.svg b/packages/ui/src/assets/icons/file-types/todo.svg new file mode 100644 index 0000000000000000000000000000000000000000..281ed659958b8c04170de8f2ce1e916713ecab5c --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/todo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/travis.svg b/packages/ui/src/assets/icons/file-types/travis.svg new file mode 100644 index 0000000000000000000000000000000000000000..37a69a8d3285df3b083bf9485cbb7261c49d2efb --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/travis.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/tsconfig.svg b/packages/ui/src/assets/icons/file-types/tsconfig.svg new file mode 100644 index 0000000000000000000000000000000000000000..817fb8dbe1418eedcd78c75527c4d5502eb268c3 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/tsconfig.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/tsdoc.svg b/packages/ui/src/assets/icons/file-types/tsdoc.svg new file mode 100644 index 0000000000000000000000000000000000000000..e7e04d0e13e0ad78f33698aa6811637276371ee3 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/tsdoc.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/tsil.svg b/packages/ui/src/assets/icons/file-types/tsil.svg new file mode 100644 index 0000000000000000000000000000000000000000..261d7cdfec4cdb6baeceab14aa42f409ea665508 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/tsil.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/umi.svg b/packages/ui/src/assets/icons/file-types/umi.svg new file mode 100644 index 0000000000000000000000000000000000000000..7479a4bbb1a374b1b989868d0359e1ea66b1cdde --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/umi.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/varnish.svg b/packages/ui/src/assets/icons/file-types/varnish.svg new file mode 100644 index 0000000000000000000000000000000000000000..6b504af71f062a739a1459c4f645c5ba1503fa46 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/varnish.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/vedic.svg b/packages/ui/src/assets/icons/file-types/vedic.svg new file mode 100644 index 0000000000000000000000000000000000000000..3dccbeb01308519e179d89075b91ce9324deb9cc --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/vedic.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/vercel_light.svg b/packages/ui/src/assets/icons/file-types/vercel_light.svg new file mode 100644 index 0000000000000000000000000000000000000000..314b78cd66f31618765dd6699558a1a2e7ea2be5 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/vercel_light.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/verified.svg b/packages/ui/src/assets/icons/file-types/verified.svg new file mode 100644 index 0000000000000000000000000000000000000000..0c861c559241be29b4d2b5eedd118153eb643698 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/verified.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/vfl.svg b/packages/ui/src/assets/icons/file-types/vfl.svg new file mode 100644 index 0000000000000000000000000000000000000000..3c371b4adc30870eb2c4370c2c8e25f2434a5b77 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/vfl.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/visualstudio.svg b/packages/ui/src/assets/icons/file-types/visualstudio.svg new file mode 100644 index 0000000000000000000000000000000000000000..15328de801e2d3b91399ecb93360909d5c534d12 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/visualstudio.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/vitest.svg b/packages/ui/src/assets/icons/file-types/vitest.svg new file mode 100644 index 0000000000000000000000000000000000000000..0a634e997b2b600f62687ce6212186783189617a --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/vitest.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/vscode.svg b/packages/ui/src/assets/icons/file-types/vscode.svg new file mode 100644 index 0000000000000000000000000000000000000000..bb3772afd1562707e521b9e6a18c99e03a391973 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/vscode.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/vuex-store.svg b/packages/ui/src/assets/icons/file-types/vuex-store.svg new file mode 100644 index 0000000000000000000000000000000000000000..c98a851ccd743c9a87815d16f11f0b830e9f3965 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/vuex-store.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/wakatime.svg b/packages/ui/src/assets/icons/file-types/wakatime.svg new file mode 100644 index 0000000000000000000000000000000000000000..66b8a6f71c0ad9867204d37dc72378b0a4a5fc57 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/wakatime.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/wallaby.svg b/packages/ui/src/assets/icons/file-types/wallaby.svg new file mode 100644 index 0000000000000000000000000000000000000000..0e7ce6ef089fe367d1a27da300527b3a093cd483 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/wallaby.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/watchman.svg b/packages/ui/src/assets/icons/file-types/watchman.svg new file mode 100644 index 0000000000000000000000000000000000000000..74773cd14fff72ccba79a301a057ed6ed2f36a90 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/watchman.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/webhint.svg b/packages/ui/src/assets/icons/file-types/webhint.svg new file mode 100644 index 0000000000000000000000000000000000000000..fdaa668db85f990a88fa91742ebcb7e2a39fcef3 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/webhint.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/wepy.svg b/packages/ui/src/assets/icons/file-types/wepy.svg new file mode 100644 index 0000000000000000000000000000000000000000..bed1ad034161086a99dc101d029421532fe72506 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/wepy.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/werf.svg b/packages/ui/src/assets/icons/file-types/werf.svg new file mode 100644 index 0000000000000000000000000000000000000000..7a89a1fb15420429fc765f28a6f9dfc9124cf243 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/werf.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/word.svg b/packages/ui/src/assets/icons/file-types/word.svg new file mode 100644 index 0000000000000000000000000000000000000000..a90b88f9cdc18e25566fa6a319620037da204e53 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/word.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/wrangler.svg b/packages/ui/src/assets/icons/file-types/wrangler.svg new file mode 100644 index 0000000000000000000000000000000000000000..51a7983a09076e86c07404e980c0b4265e4fe989 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/wrangler.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/wxt.svg b/packages/ui/src/assets/icons/file-types/wxt.svg new file mode 100644 index 0000000000000000000000000000000000000000..d43b74283165ad4eca6f3550601f7a775f576cbd --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/wxt.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/xaml.svg b/packages/ui/src/assets/icons/file-types/xaml.svg new file mode 100644 index 0000000000000000000000000000000000000000..0b7e865a7411f4cfa712bd9602560a66436044b8 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/xaml.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/xmake.svg b/packages/ui/src/assets/icons/file-types/xmake.svg new file mode 100644 index 0000000000000000000000000000000000000000..47b3ce8aec3448b2fb246fa9b97ce34ef3441ebb --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/xmake.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/xml.svg b/packages/ui/src/assets/icons/file-types/xml.svg new file mode 100644 index 0000000000000000000000000000000000000000..c3a1eaf45395f2c2a7043cfce8aa140c2b308c54 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/xml.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/yarn.svg b/packages/ui/src/assets/icons/file-types/yarn.svg new file mode 100644 index 0000000000000000000000000000000000000000..9af575c110037c49de82db9d03afeb34fc86f0aa --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/yarn.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/zeabur.svg b/packages/ui/src/assets/icons/file-types/zeabur.svg new file mode 100644 index 0000000000000000000000000000000000000000..37b0ea8b7477cd12fe3f7f94df830be1cf9c5536 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/zeabur.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/zig.svg b/packages/ui/src/assets/icons/file-types/zig.svg new file mode 100644 index 0000000000000000000000000000000000000000..b5604dfe77dbb71917a065c95e4927750a476a1d --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/zig.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/zip.svg b/packages/ui/src/assets/icons/file-types/zip.svg new file mode 100644 index 0000000000000000000000000000000000000000..1056c60bde4c3e3405465597b74eb73acee56fd3 --- /dev/null +++ b/packages/ui/src/assets/icons/file-types/zip.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/provider/aihubmix.svg b/packages/ui/src/assets/icons/provider/aihubmix.svg new file mode 100644 index 0000000000000000000000000000000000000000..33164b78b3e544adc97341a42d1be3191ed74fb6 --- /dev/null +++ b/packages/ui/src/assets/icons/provider/aihubmix.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/ui/src/assets/icons/provider/alibaba-cn.svg b/packages/ui/src/assets/icons/provider/alibaba-cn.svg new file mode 100644 index 0000000000000000000000000000000000000000..5d8355c18e49ebe83f600528cee8f0750b6a6549 --- /dev/null +++ b/packages/ui/src/assets/icons/provider/alibaba-cn.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/ui/src/assets/icons/provider/alibaba.svg b/packages/ui/src/assets/icons/provider/alibaba.svg new file mode 100644 index 0000000000000000000000000000000000000000..b3a2edc3c02a4cc86ce336046b4540dc5df6e628 --- /dev/null +++ b/packages/ui/src/assets/icons/provider/alibaba.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/ui/src/assets/icons/provider/amazon-bedrock.svg b/packages/ui/src/assets/icons/provider/amazon-bedrock.svg new file mode 100644 index 0000000000000000000000000000000000000000..1f185ef53195424b81f1e4a4c3dc84eec1a74f85 --- /dev/null +++ b/packages/ui/src/assets/icons/provider/amazon-bedrock.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/ui/src/assets/icons/provider/anthropic.svg b/packages/ui/src/assets/icons/provider/anthropic.svg new file mode 100644 index 0000000000000000000000000000000000000000..aaa01fcdb2e418c6e1c7ab90dbb8a1e9b241118c --- /dev/null +++ b/packages/ui/src/assets/icons/provider/anthropic.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/provider/azure-cognitive-services.svg b/packages/ui/src/assets/icons/provider/azure-cognitive-services.svg new file mode 100644 index 0000000000000000000000000000000000000000..086e9aa1fca10f541ca4ec89b328e4756141b33d --- /dev/null +++ b/packages/ui/src/assets/icons/provider/azure-cognitive-services.svg @@ -0,0 +1,24 @@ + + + + + diff --git a/packages/ui/src/assets/icons/provider/azure.svg b/packages/ui/src/assets/icons/provider/azure.svg new file mode 100644 index 0000000000000000000000000000000000000000..07c6519ba4f3d275e836bb2f19a4814207bf0571 --- /dev/null +++ b/packages/ui/src/assets/icons/provider/azure.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/ui/src/assets/icons/provider/bailing.svg b/packages/ui/src/assets/icons/provider/bailing.svg new file mode 100644 index 0000000000000000000000000000000000000000..b8ed486a86d1bffe82ef443dd81fe4756e6df9e4 --- /dev/null +++ b/packages/ui/src/assets/icons/provider/bailing.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/ui/src/assets/icons/provider/berget.svg b/packages/ui/src/assets/icons/provider/berget.svg new file mode 100644 index 0000000000000000000000000000000000000000..831547a59ed0a4a7abad5cc41666cb5f6a454114 --- /dev/null +++ b/packages/ui/src/assets/icons/provider/berget.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/provider/cerebras.svg b/packages/ui/src/assets/icons/provider/cerebras.svg new file mode 100644 index 0000000000000000000000000000000000000000..b167596729fe30eae58482379a0e82b7322a2abb --- /dev/null +++ b/packages/ui/src/assets/icons/provider/cerebras.svg @@ -0,0 +1,4 @@ + + + + diff --git a/packages/ui/src/assets/icons/provider/chutes.svg b/packages/ui/src/assets/icons/provider/chutes.svg new file mode 100644 index 0000000000000000000000000000000000000000..086e9aa1fca10f541ca4ec89b328e4756141b33d --- /dev/null +++ b/packages/ui/src/assets/icons/provider/chutes.svg @@ -0,0 +1,24 @@ + + + + + diff --git a/packages/ui/src/assets/icons/provider/clarifai.svg b/packages/ui/src/assets/icons/provider/clarifai.svg new file mode 100644 index 0000000000000000000000000000000000000000..086e9aa1fca10f541ca4ec89b328e4756141b33d --- /dev/null +++ b/packages/ui/src/assets/icons/provider/clarifai.svg @@ -0,0 +1,24 @@ + + + + + diff --git a/packages/ui/src/assets/icons/provider/cloudferro-sherlock.svg b/packages/ui/src/assets/icons/provider/cloudferro-sherlock.svg new file mode 100644 index 0000000000000000000000000000000000000000..6f09a794e6ce205c1ab0ecf97bef6be2458dce5b --- /dev/null +++ b/packages/ui/src/assets/icons/provider/cloudferro-sherlock.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/provider/cloudflare-ai-gateway.svg b/packages/ui/src/assets/icons/provider/cloudflare-ai-gateway.svg new file mode 100644 index 0000000000000000000000000000000000000000..02c7e51d3e0d87f84dc9b3fa74242b158fbb56d7 --- /dev/null +++ b/packages/ui/src/assets/icons/provider/cloudflare-ai-gateway.svg @@ -0,0 +1,4 @@ + + + + diff --git a/packages/ui/src/assets/icons/provider/cloudflare-workers-ai.svg b/packages/ui/src/assets/icons/provider/cloudflare-workers-ai.svg new file mode 100644 index 0000000000000000000000000000000000000000..02c7e51d3e0d87f84dc9b3fa74242b158fbb56d7 --- /dev/null +++ b/packages/ui/src/assets/icons/provider/cloudflare-workers-ai.svg @@ -0,0 +1,4 @@ + + + + diff --git a/packages/ui/src/assets/icons/provider/cohere.svg b/packages/ui/src/assets/icons/provider/cohere.svg new file mode 100644 index 0000000000000000000000000000000000000000..cfeaa60028dfb77f611ceecd50adba7e435337d8 --- /dev/null +++ b/packages/ui/src/assets/icons/provider/cohere.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/packages/ui/src/assets/icons/provider/deepinfra.svg b/packages/ui/src/assets/icons/provider/deepinfra.svg new file mode 100644 index 0000000000000000000000000000000000000000..c35ab7183c17eb96ca993875722d27bb8754ede1 --- /dev/null +++ b/packages/ui/src/assets/icons/provider/deepinfra.svg @@ -0,0 +1,4 @@ + + + + diff --git a/packages/ui/src/assets/icons/provider/deepseek.svg b/packages/ui/src/assets/icons/provider/deepseek.svg new file mode 100644 index 0000000000000000000000000000000000000000..5d6efa991b7d2cfb10f0b44e1665bb85c6af6b66 --- /dev/null +++ b/packages/ui/src/assets/icons/provider/deepseek.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/ui/src/assets/icons/provider/digitalocean.svg b/packages/ui/src/assets/icons/provider/digitalocean.svg new file mode 100644 index 0000000000000000000000000000000000000000..5be390b9d3489ef02862e8f5a91058020708f0ee --- /dev/null +++ b/packages/ui/src/assets/icons/provider/digitalocean.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/packages/ui/src/assets/icons/provider/dinference.svg b/packages/ui/src/assets/icons/provider/dinference.svg new file mode 100644 index 0000000000000000000000000000000000000000..e045c96fb3552b160558c8d54d6898b150a23b80 --- /dev/null +++ b/packages/ui/src/assets/icons/provider/dinference.svg @@ -0,0 +1 @@ + diff --git a/packages/ui/src/assets/icons/provider/drun.svg b/packages/ui/src/assets/icons/provider/drun.svg new file mode 100644 index 0000000000000000000000000000000000000000..472dee9122e37a10d442b3cab205c2cf45084de8 --- /dev/null +++ b/packages/ui/src/assets/icons/provider/drun.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/packages/ui/src/assets/icons/provider/evroc.svg b/packages/ui/src/assets/icons/provider/evroc.svg new file mode 100644 index 0000000000000000000000000000000000000000..7597820a192d09a01c8c6751e51f7daea6b4b9ad --- /dev/null +++ b/packages/ui/src/assets/icons/provider/evroc.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/provider/fastrouter.svg b/packages/ui/src/assets/icons/provider/fastrouter.svg new file mode 100644 index 0000000000000000000000000000000000000000..ec73dc49cdf0bc0c4891e101dc5d4b7f57435f40 --- /dev/null +++ b/packages/ui/src/assets/icons/provider/fastrouter.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/ui/src/assets/icons/provider/fireworks-ai.svg b/packages/ui/src/assets/icons/provider/fireworks-ai.svg new file mode 100644 index 0000000000000000000000000000000000000000..72cc91f094c27c702801c2fe442b9d2f8146ce7c --- /dev/null +++ b/packages/ui/src/assets/icons/provider/fireworks-ai.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/ui/src/assets/icons/provider/firmware.svg b/packages/ui/src/assets/icons/provider/firmware.svg new file mode 100644 index 0000000000000000000000000000000000000000..baa524ba2d42c6c7394fd78c0237420af6004ee9 --- /dev/null +++ b/packages/ui/src/assets/icons/provider/firmware.svg @@ -0,0 +1,18 @@ + + + + + + diff --git a/packages/ui/src/assets/icons/provider/friendli.svg b/packages/ui/src/assets/icons/provider/friendli.svg new file mode 100644 index 0000000000000000000000000000000000000000..8acb7632df09fe4c19a3741cb8371b4ab5f145da --- /dev/null +++ b/packages/ui/src/assets/icons/provider/friendli.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/ui/src/assets/icons/provider/github-copilot.svg b/packages/ui/src/assets/icons/provider/github-copilot.svg new file mode 100644 index 0000000000000000000000000000000000000000..2d426f265cf37c3925d36a18cc708e30e78182a5 --- /dev/null +++ b/packages/ui/src/assets/icons/provider/github-copilot.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/ui/src/assets/icons/provider/github-models.svg b/packages/ui/src/assets/icons/provider/github-models.svg new file mode 100644 index 0000000000000000000000000000000000000000..39689d95c0c6853d211241826fb155ac4c7120fd --- /dev/null +++ b/packages/ui/src/assets/icons/provider/github-models.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/ui/src/assets/icons/provider/google-vertex.svg b/packages/ui/src/assets/icons/provider/google-vertex.svg new file mode 100644 index 0000000000000000000000000000000000000000..fda56b4479b5bc64b3d6a180007b65bfb75da0e0 --- /dev/null +++ b/packages/ui/src/assets/icons/provider/google-vertex.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/packages/ui/src/assets/icons/provider/google.svg b/packages/ui/src/assets/icons/provider/google.svg new file mode 100644 index 0000000000000000000000000000000000000000..4ebfcfd2b4c0a741c732ab494b1dd5757a55eb47 --- /dev/null +++ b/packages/ui/src/assets/icons/provider/google.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/ui/src/assets/icons/provider/groq.svg b/packages/ui/src/assets/icons/provider/groq.svg new file mode 100644 index 0000000000000000000000000000000000000000..fdd22ed7dfdc1012c4b953cc9c9663df430a7c1e --- /dev/null +++ b/packages/ui/src/assets/icons/provider/groq.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/ui/src/assets/icons/provider/helicone.svg b/packages/ui/src/assets/icons/provider/helicone.svg new file mode 100644 index 0000000000000000000000000000000000000000..8a4bd43ad3efe7c66a04f937e5edc45282d1b7b2 --- /dev/null +++ b/packages/ui/src/assets/icons/provider/helicone.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/packages/ui/src/assets/icons/provider/iflowcn.svg b/packages/ui/src/assets/icons/provider/iflowcn.svg new file mode 100644 index 0000000000000000000000000000000000000000..6f35a7d591ae518f54c3ea81e8b04a676e4b50a8 --- /dev/null +++ b/packages/ui/src/assets/icons/provider/iflowcn.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/ui/src/assets/icons/provider/inception.svg b/packages/ui/src/assets/icons/provider/inception.svg new file mode 100644 index 0000000000000000000000000000000000000000..f70ffbc785b39a7d4efd71b73ae54f64758fd4f5 --- /dev/null +++ b/packages/ui/src/assets/icons/provider/inception.svg @@ -0,0 +1,4 @@ + + + + diff --git a/packages/ui/src/assets/icons/provider/inference.svg b/packages/ui/src/assets/icons/provider/inference.svg new file mode 100644 index 0000000000000000000000000000000000000000..c17f6657448fb8dcd462a9f26d304806e5c8eeca --- /dev/null +++ b/packages/ui/src/assets/icons/provider/inference.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/ui/src/assets/icons/provider/kilo.svg b/packages/ui/src/assets/icons/provider/kilo.svg new file mode 100644 index 0000000000000000000000000000000000000000..0a761347a8e24ab1c307daff1231e7266d4c8069 --- /dev/null +++ b/packages/ui/src/assets/icons/provider/kilo.svg @@ -0,0 +1,4 @@ + + + + diff --git a/packages/ui/src/assets/icons/provider/kimi-for-coding.svg b/packages/ui/src/assets/icons/provider/kimi-for-coding.svg new file mode 100644 index 0000000000000000000000000000000000000000..8f2af02e63e1e336ab247bd5bd39c44a5717eb67 --- /dev/null +++ b/packages/ui/src/assets/icons/provider/kimi-for-coding.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/ui/src/assets/icons/provider/kuae-cloud-coding-plan.svg b/packages/ui/src/assets/icons/provider/kuae-cloud-coding-plan.svg new file mode 100644 index 0000000000000000000000000000000000000000..3d0d0c4557371d70a7a8df302020c898bf7ec9a2 --- /dev/null +++ b/packages/ui/src/assets/icons/provider/kuae-cloud-coding-plan.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/ui/src/assets/icons/provider/llama.svg b/packages/ui/src/assets/icons/provider/llama.svg new file mode 100644 index 0000000000000000000000000000000000000000..3053b251fccecaa6157d3be600977abb2858d8a4 --- /dev/null +++ b/packages/ui/src/assets/icons/provider/llama.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/ui/src/assets/icons/provider/lmstudio.svg b/packages/ui/src/assets/icons/provider/lmstudio.svg new file mode 100644 index 0000000000000000000000000000000000000000..086e9aa1fca10f541ca4ec89b328e4756141b33d --- /dev/null +++ b/packages/ui/src/assets/icons/provider/lmstudio.svg @@ -0,0 +1,24 @@ + + + + + diff --git a/packages/ui/src/assets/icons/provider/lucidquery.svg b/packages/ui/src/assets/icons/provider/lucidquery.svg new file mode 100644 index 0000000000000000000000000000000000000000..6420a042eab68582e21930dda672c54dac4fcae2 --- /dev/null +++ b/packages/ui/src/assets/icons/provider/lucidquery.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/ui/src/assets/icons/provider/merge-gateway.svg b/packages/ui/src/assets/icons/provider/merge-gateway.svg new file mode 100644 index 0000000000000000000000000000000000000000..a219b55bf96504a6486eb9ac30d0bfc4b83d9f14 --- /dev/null +++ b/packages/ui/src/assets/icons/provider/merge-gateway.svg @@ -0,0 +1,4 @@ + + + + diff --git a/packages/ui/src/assets/icons/provider/minimax-cn-coding-plan.svg b/packages/ui/src/assets/icons/provider/minimax-cn-coding-plan.svg new file mode 100644 index 0000000000000000000000000000000000000000..086e9aa1fca10f541ca4ec89b328e4756141b33d --- /dev/null +++ b/packages/ui/src/assets/icons/provider/minimax-cn-coding-plan.svg @@ -0,0 +1,24 @@ + + + + + diff --git a/packages/ui/src/assets/icons/provider/minimax-coding-plan.svg b/packages/ui/src/assets/icons/provider/minimax-coding-plan.svg new file mode 100644 index 0000000000000000000000000000000000000000..086e9aa1fca10f541ca4ec89b328e4756141b33d --- /dev/null +++ b/packages/ui/src/assets/icons/provider/minimax-coding-plan.svg @@ -0,0 +1,24 @@ + + + + + diff --git a/packages/ui/src/assets/icons/provider/minimax.svg b/packages/ui/src/assets/icons/provider/minimax.svg new file mode 100644 index 0000000000000000000000000000000000000000..44c5eec21d3eb79b14ccf4375fda95668de6531e --- /dev/null +++ b/packages/ui/src/assets/icons/provider/minimax.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/ui/src/assets/icons/provider/mistral.svg b/packages/ui/src/assets/icons/provider/mistral.svg new file mode 100644 index 0000000000000000000000000000000000000000..966e474bc0875fc91346a7d434c238d95393f7c8 --- /dev/null +++ b/packages/ui/src/assets/icons/provider/mistral.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/ui/src/assets/icons/provider/moark.svg b/packages/ui/src/assets/icons/provider/moark.svg new file mode 100644 index 0000000000000000000000000000000000000000..dc84a9191c78fb5d79c0002c9a90fd3dd3a7a652 --- /dev/null +++ b/packages/ui/src/assets/icons/provider/moark.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/ui/src/assets/icons/provider/modelscope.svg b/packages/ui/src/assets/icons/provider/modelscope.svg new file mode 100644 index 0000000000000000000000000000000000000000..94a894a555b6a38c088056cf39f1228d24238140 --- /dev/null +++ b/packages/ui/src/assets/icons/provider/modelscope.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/packages/ui/src/assets/icons/provider/moonshotai-cn.svg b/packages/ui/src/assets/icons/provider/moonshotai-cn.svg new file mode 100644 index 0000000000000000000000000000000000000000..3cdf7c868125a2b2da7bfc865003f895ffc45472 --- /dev/null +++ b/packages/ui/src/assets/icons/provider/moonshotai-cn.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/ui/src/assets/icons/provider/moonshotai.svg b/packages/ui/src/assets/icons/provider/moonshotai.svg new file mode 100644 index 0000000000000000000000000000000000000000..3cdf7c868125a2b2da7bfc865003f895ffc45472 --- /dev/null +++ b/packages/ui/src/assets/icons/provider/moonshotai.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/ui/src/assets/icons/provider/morph.svg b/packages/ui/src/assets/icons/provider/morph.svg new file mode 100644 index 0000000000000000000000000000000000000000..086e9aa1fca10f541ca4ec89b328e4756141b33d --- /dev/null +++ b/packages/ui/src/assets/icons/provider/morph.svg @@ -0,0 +1,24 @@ + + + + + diff --git a/packages/ui/src/assets/icons/provider/nova.svg b/packages/ui/src/assets/icons/provider/nova.svg new file mode 100644 index 0000000000000000000000000000000000000000..9fcae228c0aeacb7ffe22f09c4467957d813914f --- /dev/null +++ b/packages/ui/src/assets/icons/provider/nova.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/ui/src/assets/icons/provider/nvidia.svg b/packages/ui/src/assets/icons/provider/nvidia.svg new file mode 100644 index 0000000000000000000000000000000000000000..1f53eefca7ce989cc50588b618cf594ddbd820d2 --- /dev/null +++ b/packages/ui/src/assets/icons/provider/nvidia.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/ui/src/assets/icons/provider/ollama-cloud.svg b/packages/ui/src/assets/icons/provider/ollama-cloud.svg new file mode 100644 index 0000000000000000000000000000000000000000..08c05cf28c50555db923dcc91f433e945e6a329b --- /dev/null +++ b/packages/ui/src/assets/icons/provider/ollama-cloud.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/packages/ui/src/assets/icons/provider/openai.svg b/packages/ui/src/assets/icons/provider/openai.svg new file mode 100644 index 0000000000000000000000000000000000000000..000f65c34aa5298910e8b986cfc2cbc41906a4f1 --- /dev/null +++ b/packages/ui/src/assets/icons/provider/openai.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/ui/src/assets/icons/provider/opencode-go.svg b/packages/ui/src/assets/icons/provider/opencode-go.svg new file mode 100644 index 0000000000000000000000000000000000000000..e0833b9230f8f272ff8fad6d178723331f70f84d --- /dev/null +++ b/packages/ui/src/assets/icons/provider/opencode-go.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/ui/src/assets/icons/provider/opencode.svg b/packages/ui/src/assets/icons/provider/opencode.svg new file mode 100644 index 0000000000000000000000000000000000000000..95084658210b965a7357207d68909eabdfc4f853 --- /dev/null +++ b/packages/ui/src/assets/icons/provider/opencode.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/ui/src/assets/icons/provider/openrouter.svg b/packages/ui/src/assets/icons/provider/openrouter.svg new file mode 100644 index 0000000000000000000000000000000000000000..8cd31630131b08c56c145065101211c23491d6b0 --- /dev/null +++ b/packages/ui/src/assets/icons/provider/openrouter.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/ui/src/assets/icons/provider/perplexity-agent.svg b/packages/ui/src/assets/icons/provider/perplexity-agent.svg new file mode 100644 index 0000000000000000000000000000000000000000..a0f38862a4a019b91c4ac2ee377863704abe03f4 --- /dev/null +++ b/packages/ui/src/assets/icons/provider/perplexity-agent.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/ui/src/assets/icons/provider/perplexity.svg b/packages/ui/src/assets/icons/provider/perplexity.svg new file mode 100644 index 0000000000000000000000000000000000000000..a0f38862a4a019b91c4ac2ee377863704abe03f4 --- /dev/null +++ b/packages/ui/src/assets/icons/provider/perplexity.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/ui/src/assets/icons/provider/poe.svg b/packages/ui/src/assets/icons/provider/poe.svg new file mode 100644 index 0000000000000000000000000000000000000000..a5ab62d725aba16f50274f9d84124d1d8f3492cc --- /dev/null +++ b/packages/ui/src/assets/icons/provider/poe.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/packages/ui/src/assets/icons/provider/privatemode-ai.svg b/packages/ui/src/assets/icons/provider/privatemode-ai.svg new file mode 100644 index 0000000000000000000000000000000000000000..edb5a6d76481ab11ef3a948c09eb377dcf6343d7 --- /dev/null +++ b/packages/ui/src/assets/icons/provider/privatemode-ai.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/provider/qihang-ai.svg b/packages/ui/src/assets/icons/provider/qihang-ai.svg new file mode 100644 index 0000000000000000000000000000000000000000..3b356637a12111c3ea895d64bf4dd225f6ed7a2d --- /dev/null +++ b/packages/ui/src/assets/icons/provider/qihang-ai.svg @@ -0,0 +1,9 @@ + + + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/provider/requesty.svg b/packages/ui/src/assets/icons/provider/requesty.svg new file mode 100644 index 0000000000000000000000000000000000000000..086e9aa1fca10f541ca4ec89b328e4756141b33d --- /dev/null +++ b/packages/ui/src/assets/icons/provider/requesty.svg @@ -0,0 +1,24 @@ + + + + + diff --git a/packages/ui/src/assets/icons/provider/sap-ai-core.svg b/packages/ui/src/assets/icons/provider/sap-ai-core.svg new file mode 100644 index 0000000000000000000000000000000000000000..086e9aa1fca10f541ca4ec89b328e4756141b33d --- /dev/null +++ b/packages/ui/src/assets/icons/provider/sap-ai-core.svg @@ -0,0 +1,24 @@ + + + + + diff --git a/packages/ui/src/assets/icons/provider/siliconflow-cn.svg b/packages/ui/src/assets/icons/provider/siliconflow-cn.svg new file mode 100644 index 0000000000000000000000000000000000000000..13cac22b9d3097a5744edca76dec15d6de42400a --- /dev/null +++ b/packages/ui/src/assets/icons/provider/siliconflow-cn.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/ui/src/assets/icons/provider/siliconflow.svg b/packages/ui/src/assets/icons/provider/siliconflow.svg new file mode 100644 index 0000000000000000000000000000000000000000..13cac22b9d3097a5744edca76dec15d6de42400a --- /dev/null +++ b/packages/ui/src/assets/icons/provider/siliconflow.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/ui/src/assets/icons/provider/stackit.svg b/packages/ui/src/assets/icons/provider/stackit.svg new file mode 100644 index 0000000000000000000000000000000000000000..0d78b781acf65d9b83a218d0d6438b34b2a93bad --- /dev/null +++ b/packages/ui/src/assets/icons/provider/stackit.svg @@ -0,0 +1,4 @@ + + + + diff --git a/packages/ui/src/assets/icons/provider/stepfun.svg b/packages/ui/src/assets/icons/provider/stepfun.svg new file mode 100644 index 0000000000000000000000000000000000000000..086e9aa1fca10f541ca4ec89b328e4756141b33d --- /dev/null +++ b/packages/ui/src/assets/icons/provider/stepfun.svg @@ -0,0 +1,24 @@ + + + + + diff --git a/packages/ui/src/assets/icons/provider/submodel.svg b/packages/ui/src/assets/icons/provider/submodel.svg new file mode 100644 index 0000000000000000000000000000000000000000..5bef03c6492cab2b5ba9cd03739011d7c8689139 --- /dev/null +++ b/packages/ui/src/assets/icons/provider/submodel.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/ui/src/assets/icons/provider/synthetic.svg b/packages/ui/src/assets/icons/provider/synthetic.svg new file mode 100644 index 0000000000000000000000000000000000000000..086e9aa1fca10f541ca4ec89b328e4756141b33d --- /dev/null +++ b/packages/ui/src/assets/icons/provider/synthetic.svg @@ -0,0 +1,24 @@ + + + + + diff --git a/packages/ui/src/assets/icons/provider/tencent-coding-plan.svg b/packages/ui/src/assets/icons/provider/tencent-coding-plan.svg new file mode 100644 index 0000000000000000000000000000000000000000..502e51a5be08a89e399fd2ebe14b11d6fa236807 --- /dev/null +++ b/packages/ui/src/assets/icons/provider/tencent-coding-plan.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/packages/ui/src/assets/icons/provider/togetherai.svg b/packages/ui/src/assets/icons/provider/togetherai.svg new file mode 100644 index 0000000000000000000000000000000000000000..68413386c0e3e954d2518b8bc972888f41affdb6 --- /dev/null +++ b/packages/ui/src/assets/icons/provider/togetherai.svg @@ -0,0 +1,4 @@ + + + + diff --git a/packages/ui/src/assets/icons/provider/upstage.svg b/packages/ui/src/assets/icons/provider/upstage.svg new file mode 100644 index 0000000000000000000000000000000000000000..086e9aa1fca10f541ca4ec89b328e4756141b33d --- /dev/null +++ b/packages/ui/src/assets/icons/provider/upstage.svg @@ -0,0 +1,24 @@ + + + + + diff --git a/packages/ui/src/assets/icons/provider/v0.svg b/packages/ui/src/assets/icons/provider/v0.svg new file mode 100644 index 0000000000000000000000000000000000000000..09f3b411e3be1d0a54fe1fb3e5d59feb2a952a33 --- /dev/null +++ b/packages/ui/src/assets/icons/provider/v0.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/ui/src/assets/icons/provider/venice.svg b/packages/ui/src/assets/icons/provider/venice.svg new file mode 100644 index 0000000000000000000000000000000000000000..3d5809e3bf192d2073068c9672c9cfc216724fea --- /dev/null +++ b/packages/ui/src/assets/icons/provider/venice.svg @@ -0,0 +1,4 @@ + + + + diff --git a/packages/ui/src/assets/icons/provider/vercel.svg b/packages/ui/src/assets/icons/provider/vercel.svg new file mode 100644 index 0000000000000000000000000000000000000000..a99425f2a9455931b73eb4737a93ba42a899d59b --- /dev/null +++ b/packages/ui/src/assets/icons/provider/vercel.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/ui/src/assets/icons/provider/vivgrid.svg b/packages/ui/src/assets/icons/provider/vivgrid.svg new file mode 100644 index 0000000000000000000000000000000000000000..928fa3ff1ed2101188c0a4d93f8b9f13983b0d6d --- /dev/null +++ b/packages/ui/src/assets/icons/provider/vivgrid.svg @@ -0,0 +1,4 @@ + + + + diff --git a/packages/ui/src/assets/icons/provider/wandb.svg b/packages/ui/src/assets/icons/provider/wandb.svg new file mode 100644 index 0000000000000000000000000000000000000000..086e9aa1fca10f541ca4ec89b328e4756141b33d --- /dev/null +++ b/packages/ui/src/assets/icons/provider/wandb.svg @@ -0,0 +1,24 @@ + + + + + diff --git a/packages/ui/src/assets/icons/provider/xai.svg b/packages/ui/src/assets/icons/provider/xai.svg new file mode 100644 index 0000000000000000000000000000000000000000..ccd22443c4919a5d39bcf6a3ea976179baee1aad --- /dev/null +++ b/packages/ui/src/assets/icons/provider/xai.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/ui/src/assets/icons/provider/zai-coding-plan.svg b/packages/ui/src/assets/icons/provider/zai-coding-plan.svg new file mode 100644 index 0000000000000000000000000000000000000000..d7da9b7c5f35cdd4b96caeec3321a829277ce614 --- /dev/null +++ b/packages/ui/src/assets/icons/provider/zai-coding-plan.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/ui/src/assets/icons/provider/zenmux.svg b/packages/ui/src/assets/icons/provider/zenmux.svg new file mode 100644 index 0000000000000000000000000000000000000000..9eb8045e453a1ad252d4a16bfea11e30a31d29d5 --- /dev/null +++ b/packages/ui/src/assets/icons/provider/zenmux.svg @@ -0,0 +1,4 @@ + + + + diff --git a/packages/ui/src/assets/icons/provider/zhipuai-coding-plan.svg b/packages/ui/src/assets/icons/provider/zhipuai-coding-plan.svg new file mode 100644 index 0000000000000000000000000000000000000000..3d0d0c4557371d70a7a8df302020c898bf7ec9a2 --- /dev/null +++ b/packages/ui/src/assets/icons/provider/zhipuai-coding-plan.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/ui/src/assets/icons/provider/zhipuai.svg b/packages/ui/src/assets/icons/provider/zhipuai.svg new file mode 100644 index 0000000000000000000000000000000000000000..d7da9b7c5f35cdd4b96caeec3321a829277ce614 --- /dev/null +++ b/packages/ui/src/assets/icons/provider/zhipuai.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/ui/src/components/accordion.css b/packages/ui/src/components/accordion.css new file mode 100644 index 0000000000000000000000000000000000000000..8f4de6edacf51a7c3a07e87c2a74bbb910b92fb6 --- /dev/null +++ b/packages/ui/src/components/accordion.css @@ -0,0 +1,147 @@ +[data-component="accordion"] { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 0px; + align-self: stretch; + + [data-slot="accordion-item"] { + width: 100%; + display: flex; + flex-direction: column; + align-items: flex-start; + align-self: stretch; + overflow: visible; + + & + [data-slot="accordion-item"] { + margin-top: -1px; + } + + [data-slot="accordion-header"] { + width: 100%; + display: flex; + align-items: center; + margin: 0; + padding: 0; + + [data-slot="accordion-trigger"] { + width: 100%; + display: flex; + height: 32px; + padding: 8px 12px; + justify-content: space-between; + align-items: center; + align-self: stretch; + cursor: default; + user-select: none; + + background-color: var(--v2-background-bg-base); + border: 0.5px solid var(--v2-border-border-base); + border-radius: 0; + box-shadow: none; + overflow: clip; + color: var(--v2-text-text-base); + transition: background-color 0.15s ease; + + /* text-12-regular */ + font-family: var(--font-family-sans); + font-size: var(--font-size-small); + font-style: normal; + font-weight: var(--font-weight-regular); + line-height: var(--line-height-large); /* 166.667% */ + letter-spacing: var(--letter-spacing-normal); + + &:hover:not([data-disabled]) { + background-color: var(--v2-overlay-simple-overlay-hover); + } + &:active:not([data-disabled]) { + background-color: var(--v2-overlay-simple-overlay-pressed); + } + &:focus-visible { + outline: none; + } + &[data-disabled] { + cursor: not-allowed; + } + } + } + + &:first-child { + [data-slot="accordion-header"] [data-slot="accordion-trigger"] { + border-top-left-radius: var(--radius-lg); + border-top-right-radius: var(--radius-lg); + } + } + + &:last-child:not([data-expanded]) { + [data-slot="accordion-header"] [data-slot="accordion-trigger"] { + border-bottom-left-radius: var(--radius-lg); + border-bottom-right-radius: var(--radius-lg); + } + } + + &[data-expanded] { + [data-slot="accordion-content"] { + border: 0.5px solid var(--v2-border-border-base); + border-top: 0; + background-color: var(--v2-background-bg-layer-01); + } + } + + &:last-child[data-expanded] { + [data-slot="accordion-content"] { + border-bottom-left-radius: var(--radius-lg); + border-bottom-right-radius: var(--radius-lg); + } + } + + [data-slot="accordion-content"] { + overflow: hidden; + width: 100%; + border: 0; + background-color: transparent; + } + } +} + +body:not([data-new-layout]) [data-component="accordion"] { + [data-slot="accordion-item"] [data-slot="accordion-header"] [data-slot="accordion-trigger"] { + background-color: var(--background-stronger); + border-width: 1px; + border-color: var(--border-weak-base); + color: var(--text-strong); + + &:hover:not([data-disabled]) { + background-color: var(--surface-base-hover); + } + + &:active:not([data-disabled]) { + background-color: var(--surface-base-active); + } + } + + [data-slot="accordion-item"][data-expanded] [data-slot="accordion-content"] { + border-width: 1px; + border-color: var(--border-weak-base); + border-top: 0; + background-color: var(--background-stronger); + } +} + +@keyframes slideDown { + from { + height: 0; + } + to { + height: var(--kb-accordion-content-height); + } +} + +@keyframes slideUp { + from { + height: var(--kb-accordion-content-height); + } + to { + height: 0; + } +} diff --git a/packages/ui/src/components/accordion.stories.tsx b/packages/ui/src/components/accordion.stories.tsx new file mode 100644 index 0000000000000000000000000000000000000000..c53b6d3da9a8d2b737686897b658a23487c90be6 --- /dev/null +++ b/packages/ui/src/components/accordion.stories.tsx @@ -0,0 +1,149 @@ +// @ts-nocheck +import { createEffect, createSignal } from "solid-js" +import * as mod from "./accordion" +import { create } from "../storybook/scaffold" + +const docs = `### Overview +Accordion for collapsible content sections with optional multi-open behavior. + +Use one trigger per item; keep content concise. + +### API +- Root supports Kobalte Accordion props: \`value\`, \`multiple\`, \`collapsible\`, \`onChange\`. +- Compose with \`Accordion.Item\`, \`Header\`, \`Trigger\`, \`Content\`. + +### Variants and states +- Single or multiple open items. +- Collapsible or fixed-open behavior. + +### Behavior +- Controlled via \`value\`/\`onChange\` when provided. + +### Accessibility +- TODO: confirm keyboard navigation from Kobalte Accordion. + +### Theming/tokens +- Uses \`data-component="accordion"\` and slot data attributes. + +` + +const story = create({ title: "UI/Accordion", mod }) +export default { + title: "UI/Accordion", + id: "components-accordion", + component: story.meta.component, + tags: ["autodocs"], + parameters: { + docs: { + description: { + component: docs, + }, + }, + }, +} +export const Basic = { + args: { + collapsible: true, + multiple: false, + value: "first", + }, + argTypes: { + collapsible: { control: "boolean" }, + multiple: { control: "boolean" }, + value: { + control: "select", + options: ["first", "second", "none"], + mapping: { + none: undefined, + }, + }, + }, + render: (props) => { + const [value, setValue] = createSignal(props.value) + createEffect(() => { + setValue(props.value) + }) + + const current = () => { + if (props.multiple) { + if (Array.isArray(value())) return value() + if (value()) return [value()] + return [] + } + + if (Array.isArray(value())) return value()[0] + return value() + } + + return ( +
+ + + + First + + +
Accordion content.
+
+
+ + + Second + + +
More content.
+
+
+
+
+ ) + }, +} + +export const Multiple = { + args: { + collapsible: true, + multiple: true, + value: ["first", "second"], + }, + render: (props) => ( + + + + First + + +
Accordion content.
+
+
+ + + Second + + +
More content.
+
+
+
+ ), +} + +export const NonCollapsible = { + args: { + collapsible: false, + multiple: false, + value: "first", + }, + render: (props) => ( + + + + First + + +
Accordion content.
+
+
+
+ ), +} diff --git a/packages/ui/src/components/animated-number.tsx b/packages/ui/src/components/animated-number.tsx new file mode 100644 index 0000000000000000000000000000000000000000..28edd7b706867a9b526c2cbed0609a034c7f34b9 --- /dev/null +++ b/packages/ui/src/components/animated-number.tsx @@ -0,0 +1,109 @@ +import { For, Index, createEffect, createMemo, on } from "solid-js" +import { createStore } from "solid-js/store" + +const TRACK = Array.from({ length: 30 }, (_, index) => index % 10) +const DURATION = 600 + +function normalize(value: number) { + return ((value % 10) + 10) % 10 +} + +function spin(from: number, to: number, direction: 1 | -1) { + if (from === to) return 0 + if (direction > 0) return (to - from + 10) % 10 + return -((from - to + 10) % 10) +} + +function Digit(props: { value: number; direction: 1 | -1 }) { + const [state, setState] = createStore({ + step: props.value + 10, + animating: false, + }) + const step = () => state.step + const animating = () => state.animating + let last = props.value + + createEffect( + on( + () => props.value, + (next) => { + const delta = spin(last, next, props.direction) + last = next + if (!delta) { + setState("animating", false) + setState("step", next + 10) + return + } + + setState("animating", true) + setState("step", (value) => value + delta) + }, + { defer: true }, + ), + ) + + return ( + + { + setState("animating", false) + setState("step", (value) => normalize(value) + 10) + }} + style={{ + "--animated-number-offset": `${step()}`, + "--animated-number-duration": `var(--tool-motion-odometer-ms, ${DURATION}ms)`, + }} + > + {(value) => {value}} + + + ) +} + +export function AnimatedNumber(props: { value: number; class?: string }) { + const target = createMemo(() => { + if (!Number.isFinite(props.value)) return 0 + return Math.max(0, Math.round(props.value)) + }) + + const [state, setState] = createStore({ + value: target(), + direction: 1 as 1 | -1, + }) + const value = () => state.value + const direction = () => state.direction + + createEffect( + on( + target, + (next) => { + const current = value() + if (next === current) return + + setState("direction", next > current ? 1 : -1) + setState("value", next) + }, + { defer: true }, + ), + ) + + const label = createMemo(() => value().toString()) + const digits = createMemo(() => + Array.from(label(), (char) => { + const code = char.charCodeAt(0) - 48 + if (code < 0 || code > 9) return 0 + return code + }).reverse(), + ) + const width = createMemo(() => `${digits().length}ch`) + + return ( + + + {(digit) => } + + + ) +} diff --git a/packages/ui/src/components/app-icon.stories.tsx b/packages/ui/src/components/app-icon.stories.tsx new file mode 100644 index 0000000000000000000000000000000000000000..24460b6da228bf498313ebc7fc1284be419ab420 --- /dev/null +++ b/packages/ui/src/components/app-icon.stories.tsx @@ -0,0 +1,69 @@ +// @ts-nocheck +import { iconNames } from "./app-icons/types" +import * as mod from "./app-icon" +import { create } from "../storybook/scaffold" + +const docs = `### Overview +Application icon renderer for known editor/terminal apps. + +Use in provider or app selection lists. + +### API +- Required: \`id\` (app icon name). +- Accepts standard img props except \`src\`. + +### Variants and states +- Auto-switches themed icons when available. + +### Behavior +- Watches color scheme changes to swap themed assets. + +### Accessibility +- Provide \`alt\` text when the icon conveys meaning. + +### Theming/tokens +- Uses \`data-component="app-icon"\`. + +` + +const story = create({ title: "UI/AppIcon", mod, args: { id: "vscode" } }) +export default { + title: "UI/AppIcon", + id: "components-app-icon", + component: story.meta.component, + tags: ["autodocs"], + parameters: { + docs: { + description: { + component: docs, + }, + }, + }, + argTypes: { + id: { + control: "select", + options: iconNames, + }, + }, +} + +export const Basic = story.Basic + +export const AllIcons = { + render: () => ( +
+ {iconNames.map((id) => ( +
+ +
{id}
+
+ ))} +
+ ), +} diff --git a/packages/ui/src/components/avatar.stories.tsx b/packages/ui/src/components/avatar.stories.tsx new file mode 100644 index 0000000000000000000000000000000000000000..044224ae8c33da8688b8a06979077144759acb02 --- /dev/null +++ b/packages/ui/src/components/avatar.stories.tsx @@ -0,0 +1,76 @@ +// @ts-nocheck +import * as mod from "./avatar" +import { create } from "../storybook/scaffold" + +const docs = `### Overview +User avatar with image fallback to initials. + +Use in user lists and headers. + +### API +- Required: \`fallback\` string. +- Optional: \`src\`, \`background\`, \`foreground\`, \`size\`. + +### Variants and states +- Sizes: small, normal, large. +- Image vs fallback state. + +### Behavior +- Uses grapheme-aware fallback rendering. + +### Accessibility +- TODO: provide alt text when using images; currently image is decorative. + +### Theming/tokens +- Uses \`data-component="avatar"\` with size and image state attributes. + +` + +const story = create({ title: "UI/Avatar", mod, args: { fallback: "A" } }) + +export default { + title: "UI/Avatar", + id: "components-avatar", + component: story.meta.component, + tags: ["autodocs"], + parameters: { + docs: { + description: { + component: docs, + }, + }, + }, + argTypes: { + size: { + control: "select", + options: ["small", "normal", "large"], + }, + }, +} + +export const Basic = story.Basic + +export const WithImage = { + args: { + src: "https://placehold.co/80x80/png", + fallback: "J", + }, +} + +export const Sizes = { + render: () => ( +
+ + + +
+ ), +} + +export const CustomColors = { + args: { + fallback: "C", + background: "#1f2a44", + foreground: "#f2f5ff", + }, +} diff --git a/packages/ui/src/components/button.stories.tsx b/packages/ui/src/components/button.stories.tsx new file mode 100644 index 0000000000000000000000000000000000000000..24fad5c8a0fa6ee01e3bf4558857cb58a09c1a54 --- /dev/null +++ b/packages/ui/src/components/button.stories.tsx @@ -0,0 +1,108 @@ +// @ts-nocheck +import { Button } from "./button" + +const docs = `### Overview +Primary action button with size, variant, and optional icon support. + +Use \`IconButton\` for icon-only actions. + +### API +- \`variant\`: "primary" | "secondary" | "ghost". +- \`size\`: "small" | "normal" | "large". +- \`icon\`: Icon name for a leading icon. +- Inherits Kobalte Button props and native button attributes. + +### Variants and states +- Variants: primary, secondary, ghost. +- States: disabled. + +### Behavior +- Renders an Icon when \`icon\` is set. + +### Accessibility +- Provide clear label text; use \`aria-label\` for icon-only buttons. + +### Theming/tokens +- Uses \`data-component="button"\` with size/variant data attributes. + +` + +export default { + title: "UI/Button", + id: "components-button", + component: Button, + tags: ["autodocs"], + parameters: { + docs: { + description: { + component: docs, + }, + }, + }, + args: { + children: "Button", + variant: "secondary", + size: "normal", + }, + argTypes: { + variant: { + control: "select", + options: ["primary", "secondary", "ghost"], + }, + size: { + control: "select", + options: ["small", "normal", "large"], + }, + icon: { + control: "select", + options: ["none", "check", "plus", "arrow-right"], + mapping: { + none: undefined, + }, + }, + }, +} + +export const Primary = { + args: { + variant: "primary", + }, +} + +export const Secondary = {} + +export const Ghost = { + args: { + variant: "ghost", + }, +} + +export const WithIcon = { + args: { + children: "Continue", + icon: "arrow-right", + }, +} + +export const Disabled = { + args: { + variant: "primary", + disabled: true, + }, +} + +export const Sizes = { + render: () => ( +
+ + + +
+ ), +} diff --git a/packages/ui/src/components/card.css b/packages/ui/src/components/card.css new file mode 100644 index 0000000000000000000000000000000000000000..371901f4ee5b0e7e374aff771ef00cd34fa1bfe8 --- /dev/null +++ b/packages/ui/src/components/card.css @@ -0,0 +1,115 @@ +[data-component="card"] { + --card-pad-y: 10px; + --card-pad-r: 12px; + --card-pad-l: 10px; + + width: 100%; + display: flex; + flex-direction: column; + position: relative; + background: transparent; + border: none; + border-radius: var(--radius-md); + padding: var(--card-pad-y) var(--card-pad-r) var(--card-pad-y) var(--card-pad-l); + + /* text-14-regular */ + font-family: var(--font-family-sans); + font-size: var(--font-size-base); + font-style: normal; + font-weight: var(--font-weight-regular); + line-height: var(--line-height-large); + letter-spacing: var(--letter-spacing-normal); + color: var(--v2-text-text-base); + + --card-gap: 8px; + --card-icon: 16px; + --card-indent: 0px; + --card-line-pad: 8px; + + --card-accent: var(--v2-icon-icon-base); + + &:has([data-slot="card-title"]) { + gap: 8px; + } + + &:has([data-slot="card-title-icon"]) { + --card-indent: calc(var(--card-icon) + var(--card-gap)); + } + + &::before { + content: ""; + position: absolute; + left: 0; + top: var(--card-line-pad); + bottom: var(--card-line-pad); + width: 2px; + border-radius: 2px; + background-color: var(--card-accent); + } + + :where([data-card="title"], [data-slot="card-title"]) { + color: var(--v2-text-text-base); + font-weight: var(--font-weight-medium); + } + + :where([data-slot="card-title"]) { + display: flex; + align-items: center; + gap: var(--card-gap); + } + + :where([data-slot="card-title"]) [data-component="icon"] { + color: var(--card-accent); + } + + :where([data-slot="card-title-icon"]) { + display: inline-flex; + align-items: center; + justify-content: center; + width: var(--card-icon); + height: var(--card-icon); + flex: 0 0 auto; + } + + :where([data-slot="card-title-icon"][data-placeholder]) [data-component="icon"] { + color: var(--v2-text-text-faint); + } + + :where([data-slot="card-title-icon"]) + [data-slot="icon-svg"] + :is(path, line, polyline, polygon, rect, circle, ellipse)[stroke] { + stroke-width: 1.5px !important; + } + + :where([data-card="description"], [data-slot="card-description"]) { + color: var(--v2-text-text-muted); + white-space: pre-wrap; + overflow-wrap: anywhere; + word-break: break-word; + } + + :where([data-card="actions"], [data-slot="card-actions"]) { + padding-left: var(--card-indent); + } +} + +body:not([data-new-layout]) [data-component="card"] { + color: var(--text-strong); + --card-accent: var(--icon-active); + + :where([data-card="title"], [data-slot="card-title"]) { + color: var(--text-strong); + } + + :where([data-slot="card-title-icon"][data-placeholder]) [data-component="icon"] { + color: var(--text-weak); + } + + :where([data-card="description"], [data-slot="card-description"]) { + color: var(--text-base); + } + + &[data-variant="error"] { + --card-accent: var(--icon-critical-base) !important; + } +} diff --git a/packages/ui/src/components/card.stories.tsx b/packages/ui/src/components/card.stories.tsx new file mode 100644 index 0000000000000000000000000000000000000000..5b9cf3830e5893273c64a4d108d7cef76c938dc7 --- /dev/null +++ b/packages/ui/src/components/card.stories.tsx @@ -0,0 +1,88 @@ +// @ts-nocheck +import { Card, CardActions, CardDescription, CardTitle } from "./card" +import { Button } from "./button" + +const docs = `### Overview +Surface container for grouping related content and actions. + +Pair with \`Button\` or \`Tag\` for quick actions. + +### API +- Optional: \`variant\` (normal, error, warning, success, info). +- Accepts standard div props. + +### Variants and states +- Semantic variants for status-driven messaging. + +### Behavior +- Pure presentational container. + +### Accessibility +- Provide headings or aria labels when used in isolation. + +### Theming/tokens +- Uses \`data-component="card"\` with variant data attributes. + +` + +export default { + title: "UI/Card", + id: "components-card", + component: Card, + tags: ["autodocs"], + parameters: { + docs: { + description: { + component: docs, + }, + }, + }, + args: { + variant: "normal", + }, + argTypes: { + variant: { + control: "select", + options: ["normal", "error", "warning", "success", "info"], + }, + }, + render: (props: { variant?: "normal" | "error" | "warning" | "success" | "info" }) => { + return ( + + Card title + Small supporting text. + + + + + ) + }, +} + +export const Normal = {} + +export const Error = { + args: { + variant: "error", + }, +} + +export const Warning = { + args: { + variant: "warning", + }, +} + +export const Success = { + args: { + variant: "success", + }, +} + +export const Info = { + args: { + variant: "info", + }, +} diff --git a/packages/ui/src/components/card.tsx b/packages/ui/src/components/card.tsx new file mode 100644 index 0000000000000000000000000000000000000000..917644348f8e5285eee82a96e9b6f002ec65f306 --- /dev/null +++ b/packages/ui/src/components/card.tsx @@ -0,0 +1,123 @@ +import { type ComponentProps, splitProps } from "solid-js" +import { Icon, type IconProps } from "./icon" + +type Variant = "normal" | "error" | "warning" | "success" | "info" + +export interface CardProps extends ComponentProps<"div"> { + variant?: Variant +} + +export interface CardTitleProps extends ComponentProps<"div"> { + variant?: Variant + + /** + * Optional title icon. + * + * - `undefined`: picks a default icon based on `variant` (error/warning/success/info) + * - `false`/`null`: disables the icon + * - `Icon` name: forces a specific icon + */ + icon?: IconProps["name"] | false | null +} + +function pick(variant: Variant) { + if (variant === "error") return "circle-ban-sign" as const + if (variant === "warning") return "warning" as const + if (variant === "success") return "circle-check" as const + if (variant === "info") return "help" as const + return +} + +function mix(style: ComponentProps<"div">["style"], value?: string) { + if (!value) return style + if (!style) return { "--card-accent": value } + if (typeof style === "string") return `${style};--card-accent:${value};` + return { ...(style as Record), "--card-accent": value } +} + +export function Card(props: CardProps) { + const [split, rest] = splitProps(props, ["variant", "style", "class", "classList"]) + const variant = () => split.variant ?? "normal" + const accent = () => { + const v = variant() + if (v === "error") return "var(--v2-state-fg-danger)" + if (v === "warning") return "var(--icon-warning-active)" + if (v === "success") return "var(--icon-success-active)" + if (v === "info") return "var(--icon-info-active)" + return + } + return ( +
+ {props.children} +
+ ) +} + +export function CardTitle(props: CardTitleProps) { + const [split, rest] = splitProps(props, ["variant", "icon", "class", "classList", "children"]) + const show = () => split.icon !== false && split.icon !== null + const name = () => { + if (split.icon === false || split.icon === null) return + if (typeof split.icon === "string") return split.icon + return pick(split.variant ?? "normal") + } + const placeholder = () => !name() + return ( +
+ {show() ? ( + + + + ) : null} + {split.children} +
+ ) +} + +export function CardDescription(props: ComponentProps<"div">) { + const [split, rest] = splitProps(props, ["class", "classList", "children"]) + return ( +
+ {split.children} +
+ ) +} + +export function CardActions(props: ComponentProps<"div">) { + const [split, rest] = splitProps(props, ["class", "classList", "children"]) + return ( +
+ {split.children} +
+ ) +} diff --git a/packages/ui/src/components/checkbox.stories.tsx b/packages/ui/src/components/checkbox.stories.tsx new file mode 100644 index 0000000000000000000000000000000000000000..ceb09f103e54c8fb55650c7bac5e1cec9503d385 --- /dev/null +++ b/packages/ui/src/components/checkbox.stories.tsx @@ -0,0 +1,71 @@ +// @ts-nocheck +import { Icon } from "./icon" +import * as mod from "./checkbox" +import { create } from "../storybook/scaffold" + +const docs = `### Overview +Checkbox control for multi-select or agreement inputs. + +Use in forms and multi-select lists. + +### API +- Uses Kobalte Checkbox props (\`checked\`, \`defaultChecked\`, \`onChange\`). +- Optional: \`hideLabel\`, \`description\`, \`icon\`. +- Children render as the label. + +### Variants and states +- Checked/unchecked, indeterminate, disabled (via Kobalte). + +### Behavior +- Controlled or uncontrolled usage. + +### Accessibility +- TODO: confirm aria attributes from Kobalte. + +### Theming/tokens +- Uses \`data-component="checkbox"\` and related slots. + +` + +const story = create({ title: "UI/Checkbox", mod, args: { children: "Checkbox", defaultChecked: true } }) +export default { + title: "UI/Checkbox", + id: "components-checkbox", + component: story.meta.component, + tags: ["autodocs"], + parameters: { + docs: { + description: { + component: docs, + }, + }, + }, +} + +export const Basic = story.Basic + +export const States = { + render: () => ( +
+ Checked + Unchecked + Disabled + With description +
+ ), +} + +export const CustomIcon = { + render: () => ( + } defaultChecked> + Custom icon + + ), +} + +export const HiddenLabel = { + args: { + children: "Hidden label", + hideLabel: true, + }, +} diff --git a/packages/ui/src/components/dialog.stories.tsx b/packages/ui/src/components/dialog.stories.tsx new file mode 100644 index 0000000000000000000000000000000000000000..60cd0a1c19199bbfc5f8e001f34d5f8f84a9a6ae --- /dev/null +++ b/packages/ui/src/components/dialog.stories.tsx @@ -0,0 +1,173 @@ +// @ts-nocheck +import { onMount } from "solid-js" +import * as mod from "./dialog" +import { Button } from "./button" +import { useDialog } from "../context/dialog" + +const docs = `### Overview +Dialog content wrapper used with the DialogProvider for modal flows. + +Provide concise title/description and keep body focused. + +### API +- Optional: \`title\`, \`description\`, \`action\`. +- \`size\`: normal | large | x-large. +- \`fit\` and \`transition\` control layout and animation. + +### Variants and states +- Sizes and optional header/action controls. + +### Behavior +- Intended to be rendered via \`useDialog().show\`. + +### Accessibility +- TODO: confirm focus trapping and aria attributes from Kobalte Dialog. + +### Theming/tokens +- Uses \`data-component="dialog"\` and slot attributes. + +` + +export default { + title: "UI/Dialog", + id: "components-dialog", + component: mod.Dialog, + tags: ["autodocs"], + parameters: { + docs: { + description: { + component: docs, + }, + }, + }, +} + +export const Basic = { + render: () => { + const dialog = useDialog() + const open = () => + dialog.show(() => ( + + Dialog body content. + + )) + + onMount(open) + + return ( + + ) + }, +} + +export const Sizes = { + render: () => { + const dialog = useDialog() + return ( +
+ + + +
+ ) + }, +} + +export const Transition = { + render: () => { + const dialog = useDialog() + return ( + + ) + }, +} + +export const CustomAction = { + render: () => { + const dialog = useDialog() + return ( + } + > + Dialog body content. + + )) + } + > + Open action dialog + + ) + }, +} + +export const Fit = { + render: () => { + const dialog = useDialog() + return ( + + ) + }, +} diff --git a/packages/ui/src/components/hover-card.css b/packages/ui/src/components/hover-card.css new file mode 100644 index 0000000000000000000000000000000000000000..02d1f10ad1d958de22ab1c8e19f00f8a5598d450 --- /dev/null +++ b/packages/ui/src/components/hover-card.css @@ -0,0 +1,61 @@ +[data-slot="hover-card-trigger"] { + display: flex; + width: 100%; + min-width: 0; +} + +[data-component="hover-card-content"] { + z-index: 50; + min-width: 200px; + max-width: 320px; + max-height: calc(100vh - 1rem); + border-radius: 8px; + background-color: var(--surface-raised-stronger-non-alpha); + pointer-events: auto; + + border: 1px solid color-mix(in oklch, var(--border-base) 50%, transparent); + background-clip: padding-box; + box-shadow: var(--shadow-md); + + transform-origin: var(--kb-hovercard-content-transform-origin); + + &:focus-within { + outline: none; + } + + &[data-closed] { + animation: hover-card-close 0.15s ease-out; + } + + &[data-expanded] { + animation: hover-card-open 0.15s ease-out; + } + + [data-slot="hover-card-body"] { + padding: 4px; + max-height: inherit; + overflow: hidden; + } +} + +@keyframes hover-card-open { + from { + opacity: 0; + transform: scale(0.96); + } + to { + opacity: 1; + transform: scale(1); + } +} + +@keyframes hover-card-close { + from { + opacity: 1; + transform: scale(1); + } + to { + opacity: 0; + transform: scale(0.96); + } +} diff --git a/packages/ui/src/components/hover-card.stories.tsx b/packages/ui/src/components/hover-card.stories.tsx new file mode 100644 index 0000000000000000000000000000000000000000..3f5cf1028184308f621ca0c826d4ff46e15c69a0 --- /dev/null +++ b/packages/ui/src/components/hover-card.stories.tsx @@ -0,0 +1,70 @@ +// @ts-nocheck +import { createSignal } from "solid-js" +import * as mod from "./hover-card" + +const docs = `### Overview +Hover-triggered card for lightweight previews and metadata. + +Use for short summaries; avoid dense interactive controls. + +### API +- Required: \`trigger\` element. +- Children render inside the hover card body. + +### Variants and states +- None; content and trigger are fully composable. + +### Behavior +- Opens on hover/focus over the trigger. + +### Accessibility +- TODO: confirm focus and hover intent behavior from Kobalte. + +### Theming/tokens +- Uses \`data-component="hover-card-content"\` and slots for styling. + +` + +export default { + title: "UI/HoverCard", + id: "components-hover-card", + component: mod.HoverCard, + tags: ["autodocs"], + parameters: { + docs: { + description: { + component: docs, + }, + }, + }, +} + +export const Basic = { + render: () => ( + Hover me}> +
+
Preview
+
Short supporting text.
+
+
+ ), +} + +export const InlineMount = { + render: () => { + const [mount, setMount] = createSignal(undefined) + return ( +
+ Hover me} + > +
+
Mounted inside
+
Uses custom mount node.
+
+
+
+ ) + }, +} diff --git a/packages/ui/src/components/hover-card.tsx b/packages/ui/src/components/hover-card.tsx new file mode 100644 index 0000000000000000000000000000000000000000..4e6647313f53d755fa307362997d3763b17ba492 --- /dev/null +++ b/packages/ui/src/components/hover-card.tsx @@ -0,0 +1,32 @@ +import { HoverCard as Kobalte } from "@kobalte/core/hover-card" +import { ComponentProps, JSXElement, ParentProps, splitProps } from "solid-js" + +export interface HoverCardProps extends ParentProps, Omit, "children"> { + trigger: JSXElement + mount?: HTMLElement + class?: ComponentProps<"div">["class"] + classList?: ComponentProps<"div">["classList"] +} + +export function HoverCard(props: HoverCardProps) { + const [local, rest] = splitProps(props, ["trigger", "mount", "class", "classList", "children"]) + + return ( + + + {local.trigger} + + + +
{local.children}
+
+
+
+ ) +} diff --git a/packages/ui/src/components/icon-button.stories.tsx b/packages/ui/src/components/icon-button.stories.tsx new file mode 100644 index 0000000000000000000000000000000000000000..9782759f823abf54cae8d7cb24d0eb1090037973 --- /dev/null +++ b/packages/ui/src/components/icon-button.stories.tsx @@ -0,0 +1,74 @@ +// @ts-nocheck +import * as mod from "./icon-button" +import { create } from "../storybook/scaffold" + +const docs = `### Overview +Compact icon-only button with size and variant control. + +Use \`Button\` for text labels and primary actions. + +### API +- Required: \`icon\` icon name. +- Optional: \`size\`, \`iconSize\`, \`variant\`. +- Inherits Kobalte Button props and native button attributes. + +### Variants and states +- Variants: primary, secondary, ghost. +- Sizes: small, normal, large. + +### Behavior +- Icon size adapts to button size unless overridden. + +### Accessibility +- Provide \`aria-label\` when there is no visible text. + +### Theming/tokens +- Uses \`data-component="icon-button"\` and size/variant data attributes. + +` + +const story = create({ title: "UI/IconButton", mod, args: { icon: "check", "aria-label": "Icon" } }) +export default { + title: "UI/IconButton", + id: "components-icon-button", + component: story.meta.component, + tags: ["autodocs"], + parameters: { + docs: { + description: { + component: docs, + }, + }, + }, +} + +export const Basic = story.Basic + +export const Sizes = { + render: () => ( +
+ + + +
+ ), +} + +export const Variants = { + render: () => ( +
+ + + +
+ ), +} + +export const IconSizeOverride = { + render: () => ( +
+ + +
+ ), +} diff --git a/packages/ui/src/components/icon.css b/packages/ui/src/components/icon.css new file mode 100644 index 0000000000000000000000000000000000000000..e386ecf1457d5b4d2539dd71353bb57d19626e02 --- /dev/null +++ b/packages/ui/src/components/icon.css @@ -0,0 +1,39 @@ +[data-component="icon"] { + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + /* resize: both; */ + aspect-ratio: 1/1; + color: var(--icon-base); + + &[data-size="small"] { + width: 16px; + height: 16px; + } + + &[data-size="normal"] { + width: 20px; + height: 20px; + } + + &[data-size="medium"] { + width: 24px; + height: 24px; + } + + &[data-size="large"] { + width: 24px; + height: 24px; + } + + [data-slot="icon-svg"] { + width: 100%; + height: auto; + } +} + +:dir(rtl) [data-component="icon"][data-directional] [data-slot="icon-svg"], +:dir(rtl) [data-slot="menu-v2-item-chevron"] { + transform: scaleX(-1); +} diff --git a/packages/ui/src/components/icon.stories.tsx b/packages/ui/src/components/icon.stories.tsx new file mode 100644 index 0000000000000000000000000000000000000000..60c5312988180bc770cb817014c6569666ef8d9a --- /dev/null +++ b/packages/ui/src/components/icon.stories.tsx @@ -0,0 +1,171 @@ +// @ts-nocheck +import * as mod from "./icon" +import { create } from "../storybook/scaffold" + +const docs = `### Overview +Inline icon renderer using the built-in OpenCode icon set. + +Use with \`Button\`, \`IconButton\`, and menu items. + +### API +- Required: \`name\` (icon key). +- Optional: \`size\` (small | normal | medium | large). +- Accepts standard SVG props. + +### Variants and states +- Size variants only. + +### Behavior +- Uses an internal SVG path map. + +### Accessibility +- Icons are aria-hidden by default; wrap with accessible text when needed. + +### Theming/tokens +- Uses \`data-component="icon"\` with size data attributes. + +` + +const names = [ + "align-right", + "arrow-up", + "arrow-left", + "arrow-right", + "archive", + "bubble-5", + "prompt", + "brain", + "bullet-list", + "check-small", + "chevron-down", + "chevron-left", + "chevron-right", + "chevron-grabber-vertical", + "chevron-double-right", + "circle-x", + "close", + "close-small", + "checklist", + "console", + "expand", + "collapse", + "code", + "code-lines", + "circle-ban-sign", + "edit-small-2", + "eye", + "enter", + "folder", + "file-tree", + "file-tree-active", + "magnifying-glass", + "plus-small", + "plus", + "new-session", + "pencil-line", + "mcp", + "glasses", + "magnifying-glass-menu", + "window-cursor", + "task", + "subagent", + "stop", + "layout-left", + "layout-left-partial", + "layout-left-full", + "layout-right", + "layout-right-partial", + "layout-right-full", + "square-arrow-top-right", + "open-file", + "speech-bubble", + "comment", + "folder-add-left", + "github", + "discord", + "layout-bottom", + "layout-bottom-partial", + "layout-bottom-full", + "dot-grid", + "circle-check", + "copy", + "check", + "photo", + "share", + "download", + "menu", + "server", + "branch", + "edit", + "help", + "settings-gear", + "dash", + "cloud-upload", + "trash", + "sliders", + "keyboard", + "selector", + "arrow-down-to-line", + "warning", + "link", + "providers", + "models", +] + +const story = create({ title: "UI/Icon", mod, args: { name: "check" } }) + +export default { + title: "UI/Icon", + id: "components-icon", + component: story.meta.component, + tags: ["autodocs"], + parameters: { + docs: { + description: { + component: docs, + }, + }, + }, + argTypes: { + name: { + control: "select", + options: names, + }, + size: { + control: "select", + options: ["small", "normal", "medium", "large"], + }, + }, +} + +export const Basic = story.Basic + +export const Sizes = { + render: () => ( +
+ + + + +
+ ), +} + +export const Gallery = { + render: () => ( +
+ {names.map((name) => ( +
+ +
{name}
+
+ ))} +
+ ), +} diff --git a/packages/ui/src/components/icon.tsx b/packages/ui/src/components/icon.tsx new file mode 100644 index 0000000000000000000000000000000000000000..a16fad0c32aa337b7bb394438f1dfd8d4d814ef0 --- /dev/null +++ b/packages/ui/src/components/icon.tsx @@ -0,0 +1,181 @@ +import { onMount, splitProps, type ComponentProps } from "solid-js" + +const icons = { + "align-right": ``, + "arrow-up": ``, + "arrow-left": ``, + "arrow-right": ``, + archive: ``, + "bubble-5": ``, + prompt: ``, + brain: ``, + fork: ``, + "bullet-list": ``, + "check-small": ``, + "chevron-down": ``, + "chevron-left": ``, + "chevron-right": ``, + "chevron-grabber-vertical": ``, + "chevron-double-right": ``, + "circle-x": ``, + close: ``, + "close-small": ``, + checklist: ``, + console: ``, + terminal: ``, + "terminal-active": ` +`, + review: ``, + "review-active": ` +`, + expand: ``, + collapse: ``, + code: ``, + "code-lines": ``, + "circle-ban-sign": ``, + "edit-small-2": ``, + eye: ``, + enter: ``, + folder: ``, + "file-tree": ``, + "file-tree-active": ` +`, + "magnifying-glass": ``, + "plus-small": ``, + plus: ``, + "new-session": ``, + "new-session-active": ` +`, + "pencil-line": ``, + mcp: ``, + glasses: ``, + "magnifying-glass-menu": ``, + "window-cursor": ``, + task: ``, + subagent: ``, + stop: ``, + status: ``, + "status-active": ` + +`, + sidebar: ``, + "sidebar-active": ` +`, + "layout-left": ``, + "layout-left-partial": ``, + "layout-left-full": ``, + "layout-right": ``, + "layout-right-partial": ``, + "layout-right-full": ``, + "square-arrow-top-right": ``, + "open-file": ``, + "speech-bubble": ``, + comment: ``, + "folder-add-left": ``, + github: ``, + discord: ``, + "layout-bottom": ``, + "layout-bottom-partial": ``, + "layout-bottom-full": ``, + "dot-grid": ``, + "circle-check": ``, + copy: ``, + check: ``, + photo: ``, + share: ``, + shield: ``, + download: ``, + menu: ``, + server: ``, + branch: ``, + edit: ``, + help: ``, + "settings-gear": ``, + dash: ``, + "cloud-upload": ``, + trash: ``, + sliders: ``, + keyboard: ``, + selector: ``, + "arrow-down-to-line": ``, + warning: ``, + reset: ``, + link: ``, + providers: ``, + models: ``, + "arrow-undo-down": ``, +} + +const spriteID = "opencode-icon-sprite" +const symbol = (name: keyof typeof icons) => `opencode-icon-${name}` +let spriteInserted = false + +function viewBox(name: keyof typeof icons) { + return name === "magnifying-glass" || name === "arrow-undo-down" || name === "subagent" ? "0 0 16 16" : "0 0 20 20" +} + +function ensureSprite() { + if (spriteInserted) return + if (typeof document === "undefined") return + if (document.getElementById(spriteID)) { + spriteInserted = true + return + } + const body = document.body as HTMLElement | null + if (!body) return + + const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg") + svg.id = spriteID + svg.setAttribute("aria-hidden", "true") + svg.setAttribute("width", "0") + svg.setAttribute("height", "0") + svg.style.position = "absolute" + svg.style.overflow = "hidden" + svg.innerHTML = Object.entries(icons) + .map(([name, path]) => { + const key = name as keyof typeof icons + return `${path}` + }) + .join("") + body.insertBefore(svg, body.firstChild) + spriteInserted = true +} + +export interface IconProps extends ComponentProps<"svg"> { + name: keyof typeof icons + size?: "small" | "normal" | "medium" | "large" +} + +export function Icon(props: IconProps) { + const [local, others] = splitProps(props, ["name", "size", "class", "classList"]) + onMount(ensureSprite) + + return ( +
+ +
+ ) +} diff --git a/packages/ui/src/components/inline-input.stories.tsx b/packages/ui/src/components/inline-input.stories.tsx new file mode 100644 index 0000000000000000000000000000000000000000..e364c896315fbedabb0e3d8344e4c85fbc6dedef --- /dev/null +++ b/packages/ui/src/components/inline-input.stories.tsx @@ -0,0 +1,50 @@ +// @ts-nocheck +import * as mod from "./inline-input" +import { create } from "../storybook/scaffold" + +const docs = `### Overview +Compact inline input for short values. + +Use inside text or table rows for quick edits. + +### API +- Optional: \`width\` to set a fixed width. +- Accepts standard input props. + +### Variants and states +- No built-in variants; style via class or width. + +### Behavior +- Uses inline width when provided. + +### Accessibility +- Provide a label or aria-label when used standalone. + +### Theming/tokens +- Uses \`data-component="inline-input"\`. + +` + +const story = create({ title: "UI/InlineInput", mod, args: { placeholder: "Type...", value: "Inline" } }) +export default { + title: "UI/InlineInput", + id: "components-inline-input", + component: story.meta.component, + tags: ["autodocs"], + parameters: { + docs: { + description: { + component: docs, + }, + }, + }, +} + +export const Basic = story.Basic + +export const FixedWidth = { + args: { + value: "80px", + width: "80px", + }, +} diff --git a/packages/ui/src/components/keybind.css b/packages/ui/src/components/keybind.css new file mode 100644 index 0000000000000000000000000000000000000000..420619ae4af72b5eb9be9f771f4c6288260c2179 --- /dev/null +++ b/packages/ui/src/components/keybind.css @@ -0,0 +1,18 @@ +[data-component="keybind"] { + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + height: 20px; + padding: 0 8px; + border-radius: 2px; + background: var(--surface-base); + box-shadow: var(--shadow-xxs-border); + + /* text-12-regular */ + font-family: var(--font-family-sans); + font-size: 12px; + font-weight: var(--font-weight-regular); + line-height: 1; + color: var(--text-weak); +} diff --git a/packages/ui/src/components/keybind.tsx b/packages/ui/src/components/keybind.tsx new file mode 100644 index 0000000000000000000000000000000000000000..5c347cb541cbc1e6dc578bea883c277c55bfcae9 --- /dev/null +++ b/packages/ui/src/components/keybind.tsx @@ -0,0 +1,20 @@ +import type { ComponentProps, ParentProps } from "solid-js" + +export interface KeybindProps extends ParentProps { + class?: string + classList?: ComponentProps<"span">["classList"] +} + +export function Keybind(props: KeybindProps) { + return ( + + {props.children} + + ) +} diff --git a/packages/ui/src/components/list.css b/packages/ui/src/components/list.css new file mode 100644 index 0000000000000000000000000000000000000000..a10df733030a88a3233f0aeafafa94c8c48d4db2 --- /dev/null +++ b/packages/ui/src/components/list.css @@ -0,0 +1,331 @@ +@property --bottom-fade { + syntax: ""; + inherits: false; + initial-value: 0px; +} + +@keyframes scroll { + 0% { + --bottom-fade: 20px; + } + 90% { + --bottom-fade: 20px; + } + 100% { + --bottom-fade: 0; + } +} + +[data-component="list"] { + display: flex; + flex-direction: column; + gap: 12px; + overflow: hidden; + /*padding: 0 12px;*/ + + [data-slot="list-search-wrapper"] { + display: flex; + flex-shrink: 0; + align-items: center; + gap: 8px; + align-self: stretch; + margin-bottom: 4px; + + > [data-component="icon-button"] { + width: 24px; + height: 24px; + flex-shrink: 0; + background-color: transparent; + opacity: 0.5; + transition: opacity 0.15s ease; + + &:hover:not(:disabled), + &:focus-visible:not(:disabled), + &:active:not(:disabled) { + background-color: transparent; + opacity: 0.7; + } + + &:hover:not(:disabled) [data-slot="icon-svg"] { + color: var(--icon-hover); + } + + &:active:not(:disabled) [data-slot="icon-svg"] { + color: var(--icon-active); + } + } + } + + [data-slot="list-search"] { + display: flex; + flex: 1; + padding: 8px; + align-items: center; + gap: 12px; + + border-radius: var(--radius-md); + background: var(--surface-base); + + [data-slot="list-search-container"] { + display: flex; + align-items: center; + gap: 8px; + flex: 1 0 0; + max-height: 20px; + + [data-slot="list-search-input"] { + width: 100%; + + &[data-slot="input-input"] { + line-height: 20px; + max-height: 20px; + } + } + } + + > [data-component="icon-button"] { + width: 20px; + height: 20px; + background-color: transparent; + opacity: 0.5; + transition: opacity 0.15s ease; + + &:hover:not(:disabled), + &:focus-visible:not(:disabled), + &:active:not(:disabled) { + background-color: transparent; + opacity: 0.7; + } + + &:hover:not(:disabled) [data-slot="icon-svg"] { + color: var(--icon-hover); + } + + &:active:not(:disabled) [data-slot="icon-svg"] { + color: var(--icon-active); + } + } + + > [data-component="icon-button"] { + background-color: transparent; + + &:hover:not(:disabled), + &:focus:not(:disabled), + &:active:not(:disabled) { + background-color: transparent; + } + + &:hover:not(:disabled) [data-slot="icon-svg"] { + color: var(--icon-hover); + } + + &:active:not(:disabled) [data-slot="icon-svg"] { + color: var(--icon-active); + } + } + } + + [data-slot="list-scroll"] { + display: flex; + flex-direction: column; + gap: 12px; + overflow-y: auto; + overscroll-behavior: contain; + mask: linear-gradient(to bottom, #ffff calc(100% - var(--bottom-fade)), #0000); + animation: scroll; + animation-timeline: --scroll; + scroll-timeline: --scroll y; + scrollbar-width: none; + -ms-overflow-style: none; + &::-webkit-scrollbar { + display: none; + } + + [data-slot="list-empty-state"] { + display: flex; + padding: 32px 48px; + flex-direction: column; + justify-content: center; + align-items: center; + gap: 8px; + align-self: stretch; + + [data-slot="list-message"] { + display: flex; + justify-content: center; + align-items: center; + gap: 2px; + max-width: 100%; + color: var(--text-weak); + white-space: nowrap; + + /* text-14-regular */ + font-family: var(--font-family-sans); + font-size: 14px; + font-style: normal; + font-weight: var(--font-weight-regular); + line-height: var(--line-height-large); /* 142.857% */ + letter-spacing: var(--letter-spacing-normal); + } + + [data-slot="list-filter"] { + color: var(--text-strong); + overflow: hidden; + text-overflow: ellipsis; + } + } + + [data-slot="list-group"] { + position: relative; + display: flex; + flex-direction: column; + + &:last-child { + padding-bottom: 12px; + } + + [data-slot="list-header"] { + display: flex; + z-index: 10; + padding: 8px 12px 8px 8px; + justify-content: space-between; + align-items: center; + align-self: stretch; + background: var(--surface-raised-stronger-non-alpha); + position: sticky; + top: 0; + + color: var(--text-weak); + + /* text-14-medium */ + font-family: var(--font-family-sans); + font-size: 14px; + font-style: normal; + font-weight: var(--font-weight-medium); + line-height: var(--line-height-large); /* 142.857% */ + letter-spacing: var(--letter-spacing-normal); + + &::after { + content: ""; + position: absolute; + top: 100%; + left: 0; + right: 0; + height: 16px; + background: linear-gradient(to bottom, var(--surface-raised-stronger-non-alpha), transparent); + pointer-events: none; + opacity: 0; + transition: opacity 0.15s ease; + } + + &[data-stuck="true"]::after { + opacity: 1; + } + } + + [data-slot="list-items"] { + display: flex; + flex-direction: column; + align-items: flex-start; + align-self: stretch; + + [data-slot="list-item"] { + display: flex; + position: relative; + width: 100%; + padding: 6px 8px 6px 8px; + align-items: center; + color: var(--text-strong); + scroll-margin-top: 28px; + + /* text-14-medium */ + font-family: var(--font-family-sans); + font-size: 14px; + font-style: normal; + font-weight: var(--font-weight-medium); + line-height: var(--line-height-large); /* 142.857% */ + letter-spacing: var(--letter-spacing-normal); + + [data-slot="list-item-selected-icon"] { + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + aspect-ratio: 1/1; + [data-component="icon"] { + color: var(--icon-strong-base); + } + } + [data-slot="list-item-active-icon"] { + display: none; + align-items: center; + justify-content: center; + flex-shrink: 0; + aspect-ratio: 1/1; + [data-component="icon"] { + color: var(--icon-strong-base); + } + } + + [data-slot="list-item-extra-icon"] { + color: var(--icon-base); + margin-left: -4px; + } + + [data-slot="list-item-divider"] { + position: absolute; + bottom: 0; + left: var(--list-divider-inset, 16px); + right: var(--list-divider-inset, 16px); + height: 1px; + background: var(--border-weak-base); + pointer-events: none; + } + + [data-slot="list-item"]:last-child [data-slot="list-item-divider"] { + display: none; + } + + &[data-active="true"] { + border-radius: var(--radius-md); + background: var(--surface-raised-base-hover); + [data-slot="list-item-active-icon"] { + display: inline-flex; + } + [data-slot="list-item-extra-icon"] { + display: block !important; + color: var(--icon-strong-base) !important; + } + } + &:active { + background: var(--surface-raised-base-active); + } + &:focus-visible { + outline: none; + } + } + + [data-slot="list-item-add"] { + display: flex; + position: relative; + width: 100%; + padding: 6px 8px 6px 8px; + align-items: center; + color: var(--text-strong); + + /* text-14-medium */ + font-family: var(--font-family-sans); + font-size: 14px; + font-style: normal; + font-weight: var(--font-weight-medium); + line-height: var(--line-height-large); /* 142.857% */ + letter-spacing: var(--letter-spacing-normal); + + [data-component="input"] { + width: 100%; + } + } + } + } + } +} diff --git a/packages/ui/src/components/list.tsx b/packages/ui/src/components/list.tsx new file mode 100644 index 0000000000000000000000000000000000000000..cc5fc0ce5dc5b84f56ba3a4845b015b206c94256 --- /dev/null +++ b/packages/ui/src/components/list.tsx @@ -0,0 +1,394 @@ +import { type FilteredListProps, useFilteredList } from "@opencode-ai/ui/hooks" +import { createEffect, For, type JSX, on, Show } from "solid-js" +import { createStore } from "solid-js/store" +import { makeEventListener } from "@solid-primitives/event-listener" +import { useI18n } from "../context/i18n" +import { Icon, type IconProps } from "./icon" +import { IconButton } from "./icon-button" +import { TextField } from "./text-field" + +function findByKey(container: HTMLElement, key: string) { + const nodes = container.querySelectorAll('[data-slot="list-item"][data-key]') + for (const node of nodes) { + if (node.getAttribute("data-key") === key) return node + } +} + +export interface ListSearchProps { + placeholder?: string + autofocus?: boolean + hideIcon?: boolean + class?: string + action?: JSX.Element +} + +export interface ListAddProps { + class?: string + render: () => JSX.Element +} + +export interface ListAddProps { + class?: string + render: () => JSX.Element +} + +export interface ListProps extends FilteredListProps { + class?: string + children: (item: T) => JSX.Element + emptyMessage?: string + loadingMessage?: string + onKeyEvent?: (event: KeyboardEvent, item: T | undefined) => void + onMove?: (item: T | undefined) => void + onFilter?: (value: string) => void + activeIcon?: IconProps["name"] + filter?: string + search?: ListSearchProps | boolean + itemWrapper?: (item: T, node: JSX.Element) => JSX.Element + divider?: boolean + add?: ListAddProps + groupHeader?: (group: { category: string; items: T[] }) => JSX.Element +} + +export interface ListRef { + onKeyDown: (e: KeyboardEvent) => void + setScrollRef: (el: HTMLDivElement | undefined) => void + setFilter: (value: string) => void +} + +export function List(props: ListProps & { ref?: (ref: ListRef) => void }) { + const i18n = useI18n() + let inputRef: HTMLInputElement | HTMLTextAreaElement | undefined + const [store, setStore] = createStore({ + mouseActive: false, + scrollRef: undefined as HTMLDivElement | undefined, + internalFilter: "", + }) + const scrollRef = () => store.scrollRef + const setScrollRef = (el: HTMLDivElement | undefined) => setStore("scrollRef", el) + const internalFilter = () => store.internalFilter + const setInternalFilter = (value: string) => setStore("internalFilter", value) + + const scrollIntoView = (container: HTMLDivElement, node: HTMLElement, block: "center" | "nearest") => { + const containerRect = container.getBoundingClientRect() + const nodeRect = node.getBoundingClientRect() + const top = nodeRect.top - containerRect.top + container.scrollTop + const bottom = top + nodeRect.height + const viewTop = container.scrollTop + const viewBottom = viewTop + container.clientHeight + const target = + block === "center" + ? top - container.clientHeight / 2 + nodeRect.height / 2 + : top < viewTop + ? top + : bottom > viewBottom + ? bottom - container.clientHeight + : viewTop + const max = Math.max(0, container.scrollHeight - container.clientHeight) + container.scrollTop = Math.max(0, Math.min(target, max)) + } + + const { filter, grouped, flat, active, setActive, onKeyDown, onInput, refetch } = useFilteredList(props) + + const searchProps = () => (typeof props.search === "object" ? props.search : {}) + const searchAction = () => searchProps().action + const addProps = () => props.add + const showAdd = () => !!addProps() + + const moved = (event: MouseEvent) => event.movementX !== 0 || event.movementY !== 0 + + const applyFilter = (value: string, options?: { ref?: boolean }) => { + const prev = filter() + setInternalFilter(value) + onInput(value) + props.onFilter?.(value) + + if (!options?.ref) return + + // Force a refetch even if the value is unchanged. + // This is important for programmatic changes like Tab completion. + if (prev === value) { + void refetch() + return + } + queueMicrotask(() => refetch()) + } + + createEffect(() => { + if (props.filter === undefined) return + if (props.filter === internalFilter()) return + setInternalFilter(props.filter) + onInput(props.filter) + }) + + createEffect( + on( + filter, + () => { + scrollRef()?.scrollTo(0, 0) + }, + { defer: true }, + ), + ) + + createEffect(() => { + const scroll = scrollRef() + if (!scroll) return + if (!props.current) return + const key = props.key(props.current) + requestAnimationFrame(() => { + const element = findByKey(scroll, key) + if (!element) return + scrollIntoView(scroll, element, "center") + }) + }) + + createEffect(() => { + const all = flat() + if (store.mouseActive || all.length === 0) return + const scroll = scrollRef() + if (!scroll) return + if (active() === props.key(all[0])) { + scroll.scrollTo(0, 0) + return + } + const key = active() + if (!key) return + const element = findByKey(scroll, key) + if (!element) return + scrollIntoView(scroll, element, "center") + }) + + createEffect(() => { + const all = flat() + const current = active() + const item = all.find((x) => props.key(x) === current) + props.onMove?.(item) + }) + + const handleSelect = (item: T | undefined, index: number) => { + props.onSelect?.(item, index) + } + + const handleKey = (e: KeyboardEvent) => { + setStore("mouseActive", false) + if (e.key === "Escape") return + + const all = flat() + const selected = all.find((x) => props.key(x) === active()) + const index = selected ? all.indexOf(selected) : -1 + props.onKeyEvent?.(e, selected) + + if (e.defaultPrevented) return + + if (e.key === "Enter" && !e.isComposing) { + e.preventDefault() + if (selected) handleSelect(selected, index) + } else if (props.search) { + if (e.ctrlKey && !e.metaKey && !e.altKey && !e.shiftKey && (e.key === "n" || e.key === "p")) { + onKeyDown(e) + return + } + if (e.key === "ArrowDown" || e.key === "ArrowUp") { + onKeyDown(e) + } + } else { + onKeyDown(e) + } + } + + props.ref?.({ + onKeyDown: handleKey, + setScrollRef, + setFilter: (value) => applyFilter(value, { ref: true }), + }) + + const renderAdd = () => { + const add = addProps() + if (!add) return null + return ( +
+ {add.render()} +
+ ) + } + + function GroupHeader(groupProps: { group: { category: string; items: T[] } }): JSX.Element { + const [state, setState] = createStore({ + stuck: false, + header: undefined as HTMLDivElement | undefined, + }) + + createEffect(() => { + const scroll = scrollRef() + const node = state.header + if (!scroll || !node) return + + const handler = () => { + const rect = node.getBoundingClientRect() + const scrollRect = scroll.getBoundingClientRect() + setState("stuck", rect.top <= scrollRect.top + 1 && scroll.scrollTop > 0) + } + + makeEventListener(scroll, "scroll", handler, { passive: true }) + handler() + }) + + return ( +
setState("header", el)}> + {props.groupHeader?.(groupProps.group) ?? groupProps.group.category} +
+ ) + } + + const emptyMessage = () => { + if (grouped.loading) return props.loadingMessage ?? i18n.t("ui.list.loading") + if (props.emptyMessage) return props.emptyMessage + + const query = filter() + if (!query) return i18n.t("ui.list.empty") + + const suffix = i18n.t("ui.list.emptyWithFilter.suffix") + return ( + <> + {i18n.t("ui.list.emptyWithFilter.prefix")} + "{query}" + + {suffix} + + + ) + } + + return ( +
+ +
+
{ + const container = event.currentTarget + if (!(container instanceof HTMLElement)) return + + const node = container.querySelector("input, textarea") + const input = node instanceof HTMLInputElement || node instanceof HTMLTextAreaElement ? node : inputRef + input?.focus() + + // Prevent global listeners (e.g. dnd sensors) from cancelling focus. + event.stopPropagation() + }} + > +
+ + + + { + inputRef = el + }} + value={internalFilter()} + onChange={(value) => applyFilter(value)} + onKeyDown={handleKey} + placeholder={searchProps().placeholder} + spellcheck={false} + autocorrect="off" + autocomplete="off" + autocapitalize="off" + /> +
+ + { + setInternalFilter("") + queueMicrotask(() => inputRef?.focus()) + }} + aria-label={i18n.t("ui.list.clearFilter")} + /> + +
+ {searchAction()} +
+
+
+ 0 || showAdd()} + fallback={ +
+
{emptyMessage()}
+
+ } + > + + {(group, groupIndex) => { + const isLastGroup = () => groupIndex() === grouped.latest.length - 1 + return ( +
+ + + +
+ + {(item, i) => { + const node = ( + + ) + if (props.itemWrapper) return props.itemWrapper(item, node) + return node + }} + + {renderAdd()} +
+
+ ) + }} +
+ +
+
{renderAdd()}
+
+
+
+
+
+ ) +} diff --git a/packages/ui/src/components/motion-spring.tsx b/packages/ui/src/components/motion-spring.tsx new file mode 100644 index 0000000000000000000000000000000000000000..8fe97f0ee583d9c84d6873fe9d333132ad51fb80 --- /dev/null +++ b/packages/ui/src/components/motion-spring.tsx @@ -0,0 +1,58 @@ +import { attachSpring, motionValue } from "motion" +import type { SpringOptions } from "motion" +import { createComputed, createEffect, createSignal, onCleanup } from "solid-js" + +type Opt = Partial> +const eq = (a: Opt | undefined, b: Opt | undefined) => + a?.visualDuration === b?.visualDuration && + a?.bounce === b?.bounce && + a?.stiffness === b?.stiffness && + a?.damping === b?.damping && + a?.mass === b?.mass && + a?.velocity === b?.velocity + +export function useSpring(target: () => number, options?: Opt | (() => Opt), snapKey?: () => unknown) { + const read = () => (typeof options === "function" ? options() : options) + const [value, setValue] = createSignal(target()) + const source = motionValue(value()) + const spring = motionValue(value()) + let config = read() + let snapValue = snapKey?.() + let stop = attachSpring(spring, source, config) + let off = spring.on("change", (next: number) => setValue(next)) + + createComputed(() => { + const next = target() + const nextSnap = snapKey?.() + if (snapKey && nextSnap !== snapValue) { + // State boundaries should adopt their target without animating from the previous context. + snapValue = nextSnap + stop() + spring.jump(next) + source.jump(next) + stop = attachSpring(spring, source, config) + setValue(next) + return + } + source.set(next) + }) + + createEffect(() => { + if (!options) return + const next = read() + if (eq(config, next)) return + config = next + stop() + stop = attachSpring(spring, source, next) + setValue(spring.get()) + }) + + onCleanup(() => { + off() + stop() + spring.destroy() + source.destroy() + }) + + return value +} diff --git a/packages/ui/src/components/popover.css b/packages/ui/src/components/popover.css new file mode 100644 index 0000000000000000000000000000000000000000..b49542afd9b8c74ddc233334d765464dacabd8ce --- /dev/null +++ b/packages/ui/src/components/popover.css @@ -0,0 +1,98 @@ +[data-slot="popover-trigger"] { + display: inline-flex; +} + +[data-component="popover-content"] { + z-index: 50; + min-width: 200px; + max-width: 320px; + border-radius: var(--radius-md); + background-color: var(--surface-raised-stronger-non-alpha); + + border: 1px solid color-mix(in oklch, var(--border-base) 50%, transparent); + background-clip: padding-box; + box-shadow: var(--shadow-md); + + transform-origin: var(--kb-popover-content-transform-origin); + + &:focus-within { + outline: none; + } + + &[data-closed] { + animation: popover-close 0.15s ease-out; + } + + &[data-expanded] { + animation: popover-open 0.15s ease-out; + } + + [data-slot="popover-header"] { + display: flex; + padding: 12px; + padding-bottom: 0; + justify-content: space-between; + align-items: center; + gap: 8px; + + [data-slot="popover-title"] { + flex: 1; + color: var(--text-strong); + margin: 0; + + font-family: var(--font-family-sans); + font-size: var(--font-size-base); + font-style: normal; + font-weight: var(--font-weight-medium); + line-height: var(--line-height-large); + letter-spacing: var(--letter-spacing-normal); + } + + [data-slot="popover-close-button"] { + flex-shrink: 0; + } + } + + [data-slot="popover-description"] { + padding: 0 12px; + margin: 0; + color: var(--text-base); + + font-family: var(--font-family-sans); + font-size: var(--font-size-small); + font-style: normal; + font-weight: var(--font-weight-regular); + line-height: var(--line-height-large); + letter-spacing: var(--letter-spacing-normal); + } + + [data-slot="popover-body"] { + padding: 12px; + } + + [data-slot="popover-arrow"] { + fill: var(--surface-raised-stronger-non-alpha); + } +} + +@keyframes popover-open { + from { + opacity: 0; + transform: scale(0.96); + } + to { + opacity: 1; + transform: scale(1); + } +} + +@keyframes popover-close { + from { + opacity: 1; + transform: scale(1); + } + to { + opacity: 0; + transform: scale(0.96); + } +} diff --git a/packages/ui/src/components/popover.stories.tsx b/packages/ui/src/components/popover.stories.tsx new file mode 100644 index 0000000000000000000000000000000000000000..e5117b451b1504a75e8313926e28f109a6d276e1 --- /dev/null +++ b/packages/ui/src/components/popover.stories.tsx @@ -0,0 +1,87 @@ +// @ts-nocheck +import { createSignal } from "solid-js" +import * as mod from "./popover" +import { create } from "../storybook/scaffold" + +const docs = `### Overview +Composable popover with optional title, description, and close button. + +Use for small contextual details; avoid long forms. + +### API +- \`trigger\` and \`children\` define the anchor and content. +- Optional: \`title\`, \`description\`, \`portal\`, \`open\`, \`defaultOpen\`. + +### Variants and states +- Supports controlled and uncontrolled open state. + +### Behavior +- Closes on outside click or Escape by default. + +### Accessibility +- TODO: confirm focus management from Kobalte. + +### Theming/tokens +- Uses \`data-component="popover-content"\` and related slots. + +` + +const story = create({ + title: "UI/Popover", + mod, + args: { + trigger: "Open popover", + title: "Popover", + description: "Optional description", + defaultOpen: true, + children: "Popover content", + }, +}) + +export default { + title: "UI/Popover", + id: "components-popover", + component: story.meta.component, + tags: ["autodocs"], + parameters: { + docs: { + description: { + component: docs, + }, + }, + }, +} + +export const Basic = story.Basic + +export const NoHeader = { + args: { + title: undefined, + description: undefined, + children: "Popover body only", + }, +} + +export const Inline = { + args: { + portal: false, + defaultOpen: true, + }, +} + +export const Controlled = { + render: () => { + const [open, setOpen] = createSignal(true) + return ( + + Controlled content + + ) + }, +} diff --git a/packages/ui/src/components/popover.tsx b/packages/ui/src/components/popover.tsx new file mode 100644 index 0000000000000000000000000000000000000000..be5b78519188bb24115ca6368feca23f7bbb832a --- /dev/null +++ b/packages/ui/src/components/popover.tsx @@ -0,0 +1,153 @@ +import { Popover as Kobalte } from "@kobalte/core/popover" +import { ComponentProps, JSXElement, ParentProps, Show, createEffect, splitProps, ValidComponent } from "solid-js" +import { createStore } from "solid-js/store" +import { makeEventListener } from "@solid-primitives/event-listener" +import { useI18n } from "../context/i18n" +import { IconButton } from "./icon-button" + +export interface PopoverProps + extends ParentProps, + Omit, "children"> { + trigger?: JSXElement + triggerAs?: T + triggerProps?: ComponentProps + title?: JSXElement + description?: JSXElement + class?: ComponentProps<"div">["class"] + classList?: ComponentProps<"div">["classList"] + style?: ComponentProps<"div">["style"] + portal?: boolean +} + +export function Popover(props: PopoverProps) { + const i18n = useI18n() + const [local, rest] = splitProps(props, [ + "trigger", + "triggerAs", + "triggerProps", + "title", + "description", + "class", + "classList", + "style", + "children", + "portal", + "open", + "defaultOpen", + "onOpenChange", + "modal", + ]) + + const [state, setState] = createStore({ + contentRef: undefined as HTMLElement | undefined, + triggerRef: undefined as HTMLElement | undefined, + dismiss: null as "escape" | "outside" | null, + uncontrolledOpen: local.defaultOpen ?? false, + }) + + const controlled = () => local.open !== undefined + const opened = () => { + if (controlled()) return local.open ?? false + return state.uncontrolledOpen + } + + const onOpenChange = (next: boolean) => { + if (next) setState("dismiss", null) + if (local.onOpenChange) local.onOpenChange(next) + if (controlled()) return + setState("uncontrolledOpen", next) + } + + createEffect(() => { + if (!opened()) return + + const inside = (node: Node | null | undefined) => { + if (!node) return false + const content = state.contentRef + if (content && content.contains(node)) return true + const trigger = state.triggerRef + if (trigger && trigger.contains(node)) return true + return false + } + + const close = (reason: "escape" | "outside") => { + setState("dismiss", reason) + onOpenChange(false) + } + + const onKeyDown = (event: KeyboardEvent) => { + if (event.key !== "Escape") return + close("escape") + event.preventDefault() + event.stopPropagation() + } + + const onPointerDown = (event: PointerEvent) => { + const target = event.target + if (!(target instanceof Node)) return + if (inside(target)) return + close("outside") + } + + const onFocusIn = (event: FocusEvent) => { + const target = event.target + if (!(target instanceof Node)) return + if (inside(target)) return + close("outside") + } + + makeEventListener(window, "keydown", onKeyDown, { capture: true }) + makeEventListener(window, "pointerdown", onPointerDown, { capture: true }) + makeEventListener(window, "focusin", onFocusIn, { capture: true }) + }) + + const content = () => ( + setState("contentRef", el)} + data-component="popover-content" + classList={{ + ...local.classList, + [local.class ?? ""]: !!local.class, + }} + style={local.style} + onCloseAutoFocus={(event: Event) => { + if (state.dismiss === "outside") event.preventDefault() + setState("dismiss", null) + }} + > + {/* */} + +
+ {local.title} + +
+
+ + {local.description} + +
{local.children}
+
+ ) + + return ( + + setState("triggerRef", el)} + as={local.triggerAs ?? "div"} + data-slot="popover-trigger" + {...(local.triggerProps as any)} + > + {local.trigger} + + + {content()} + + + ) +} diff --git a/packages/ui/src/components/progress.css b/packages/ui/src/components/progress.css new file mode 100644 index 0000000000000000000000000000000000000000..c728912f76dbe50cab8690175e5fe35d7921f998 --- /dev/null +++ b/packages/ui/src/components/progress.css @@ -0,0 +1,63 @@ +[data-component="progress"] { + display: flex; + flex-direction: column; + gap: 4px; + + [data-slot="progress-header"] { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + } + + [data-slot="progress-label"], + [data-slot="progress-value-label"] { + font-family: var(--font-family-sans); + font-size: var(--font-size-small); + font-weight: var(--font-weight-regular); + line-height: var(--line-height-large); + letter-spacing: var(--letter-spacing-normal); + } + + [data-slot="progress-label"] { + color: var(--text-base); + } + + [data-slot="progress-value-label"] { + color: var(--text-weak); + font-variant-numeric: tabular-nums; + } + + [data-slot="progress-track"] { + position: relative; + width: 100%; + height: 8px; + overflow: hidden; + border-radius: 999px; + border: 1px solid var(--border-weak-base); + background-color: var(--surface-base); + } + + [data-slot="progress-fill"] { + height: 100%; + width: var(--kb-progress-fill-width); + border-radius: inherit; + background-color: var(--border-active); + transition: width 200ms ease; + } + + &[data-indeterminate] [data-slot="progress-fill"] { + width: 35%; + animation: progress-indeterminate 1.3s ease-in-out infinite; + } +} + +@keyframes progress-indeterminate { + from { + transform: translateX(-100%); + } + + to { + transform: translateX(300%); + } +} diff --git a/packages/ui/src/components/progress.tsx b/packages/ui/src/components/progress.tsx new file mode 100644 index 0000000000000000000000000000000000000000..7cbe5d6bcb3056960267164f6c481c91d2413c8a --- /dev/null +++ b/packages/ui/src/components/progress.tsx @@ -0,0 +1,39 @@ +import { Progress as Kobalte } from "@kobalte/core/progress" +import { Show, splitProps } from "solid-js" +import type { ComponentProps, ParentProps } from "solid-js" + +export interface ProgressProps extends ParentProps> { + hideLabel?: boolean + showValueLabel?: boolean +} + +export function Progress(props: ProgressProps) { + const [local, others] = splitProps(props, ["children", "class", "classList", "hideLabel", "showValueLabel"]) + + return ( + + +
+ + + {local.children} + + + + + +
+
+ + + +
+ ) +} diff --git a/packages/ui/src/components/resize-handle.tsx b/packages/ui/src/components/resize-handle.tsx new file mode 100644 index 0000000000000000000000000000000000000000..52a0cb587173dc4a12ec2233b6987a6dd1eac034 --- /dev/null +++ b/packages/ui/src/components/resize-handle.tsx @@ -0,0 +1,102 @@ +import { splitProps, type JSX } from "solid-js" + +export interface ResizeHandleProps extends Omit, "onResize"> { + direction: "horizontal" | "vertical" + edge?: "start" | "end" + size: number + min: number + max: number + onResize: (size: number) => void + onCollapse?: () => void + /** Called while dragging when size crosses `collapseThreshold`. */ + onCollapseChange?: (collapsed: boolean) => void + collapseThreshold?: number +} + +export function ResizeHandle(props: ResizeHandleProps) { + const [local, rest] = splitProps(props, [ + "direction", + "edge", + "size", + "min", + "max", + "onResize", + "onCollapse", + "onCollapseChange", + "collapseThreshold", + "class", + "classList", + ]) + + const handleMouseDown = (e: MouseEvent) => { + if (e.detail > 1) return + e.preventDefault() + const edge = local.edge ?? (local.direction === "vertical" ? "start" : "end") + const start = local.direction === "horizontal" ? e.clientX : e.clientY + const rtl = + local.direction === "horizontal" && + e.currentTarget instanceof Element && + getComputedStyle(e.currentTarget).direction === "rtl" + const startSize = local.size + const min = local.min + const max = local.max + const threshold = local.collapseThreshold ?? 0 + const onResize = local.onResize + const onCollapse = local.onCollapse + const onCollapseChange = local.onCollapseChange + let current = startSize + let collapsed = false + + document.body.style.userSelect = "none" + document.body.style.overflow = "hidden" + + const onMouseMove = (moveEvent: MouseEvent) => { + const pos = local.direction === "horizontal" ? moveEvent.clientX : moveEvent.clientY + const delta = + local.direction === "vertical" + ? edge === "end" + ? pos - start + : start - pos + : (edge === "start") !== rtl + ? start - pos + : pos - start + current = startSize + delta + const nextCollapsed = threshold > 0 && current < threshold + if (nextCollapsed !== collapsed) { + collapsed = nextCollapsed + onCollapseChange?.(collapsed) + } + onResize(Math.min(max, Math.max(min, current))) + } + + const onMouseUp = () => { + document.body.style.userSelect = "" + document.body.style.overflow = "" + document.removeEventListener("mousemove", onMouseMove) + document.removeEventListener("mouseup", onMouseUp) + + if (collapsed) { + onCollapse?.() + return + } + onCollapseChange?.(false) + } + + document.addEventListener("mousemove", onMouseMove) + document.addEventListener("mouseup", onMouseUp) + } + + return ( +
+ ) +} diff --git a/packages/ui/src/components/sticky-accordion-header.tsx b/packages/ui/src/components/sticky-accordion-header.tsx new file mode 100644 index 0000000000000000000000000000000000000000..b877aa16e7b11394e57fe49aa35414b0d3056d01 --- /dev/null +++ b/packages/ui/src/components/sticky-accordion-header.tsx @@ -0,0 +1,18 @@ +import { Accordion } from "./accordion" +import { ParentProps } from "solid-js" + +export function StickyAccordionHeader( + props: ParentProps<{ class?: string; classList?: Record }>, +) { + return ( + + {props.children} + + ) +} diff --git a/packages/ui/src/components/switch.stories.tsx b/packages/ui/src/components/switch.stories.tsx new file mode 100644 index 0000000000000000000000000000000000000000..540e91e3654d662fd9146cec5e02f700f49ea7ec --- /dev/null +++ b/packages/ui/src/components/switch.stories.tsx @@ -0,0 +1,68 @@ +// @ts-nocheck +import * as mod from "./switch" +import { create } from "../storybook/scaffold" + +const docs = `### Overview +Toggle control for binary settings. + +Use in settings panels or forms. + +### API +- Uses Kobalte Switch props (\`checked\`, \`defaultChecked\`, \`onChange\`). +- Optional: \`hideLabel\`, \`description\`. +- Children render as the label. + +### Variants and states +- Checked/unchecked, disabled states. + +### Behavior +- Controlled or uncontrolled usage via Kobalte props. + +### Accessibility +- TODO: confirm aria attributes from Kobalte. + +### Theming/tokens +- Uses \`data-component="switch"\` and slot attributes. + +` + +const story = create({ + title: "UI/Switch", + mod, + args: { defaultChecked: true, children: "Enable notifications" }, +}) + +export default { + title: "UI/Switch", + id: "components-switch", + component: story.meta.component, + tags: ["autodocs"], + parameters: { + docs: { + description: { + component: docs, + }, + }, + }, +} + +export const Basic = story.Basic + +export const States = { + render: () => ( +
+ Enabled + Disabled + Disabled switch + With description +
+ ), +} + +export const HiddenLabel = { + args: { + children: "Hidden label", + hideLabel: true, + defaultChecked: true, + }, +} diff --git a/packages/ui/src/components/tabs.css b/packages/ui/src/components/tabs.css new file mode 100644 index 0000000000000000000000000000000000000000..bbdee36ff1ccbf2d07f83716449277af471dbe55 --- /dev/null +++ b/packages/ui/src/components/tabs.css @@ -0,0 +1,889 @@ +[data-component="tabs"] { + --tabs-bar-height: 48px; + --tabs-compact-pill-height: 24px; + --tabs-compact-pill-radius: 6px; + --tabs-compact-pill-padding-x: 4px; + + width: 100%; + height: 100%; + display: flex; + flex-direction: column; + background-color: var(--background-stronger); + overflow: clip; + + [data-slot="tabs-list"] { + height: 48px; + width: 100%; + position: relative; + display: flex; + align-items: center; + overflow-x: auto; + + /* Hide scrollbar */ + scrollbar-width: none; + -ms-overflow-style: none; + &::-webkit-scrollbar { + display: none; + } + + /* After element to fill remaining space */ + &::after { + content: ""; + display: block; + flex-grow: 1; + height: 100%; + border-bottom: 1px solid var(--border-weak-base); + background-color: var(--background-base); + } + + &:empty::after { + display: none; + } + } + + [data-slot="tabs-trigger-wrapper"] { + position: relative; + height: 100%; + display: flex; + align-items: center; + gap: 12px; + color: var(--text-base); + + /* text-14-medium */ + font-family: var(--font-family-sans); + font-size: 14px; + font-style: normal; + font-weight: var(--font-weight-medium); + line-height: var(--line-height-large); /* 142.857% */ + letter-spacing: var(--letter-spacing-normal); + + white-space: nowrap; + flex-shrink: 0; + max-width: 280px; + border-bottom: 1px solid var(--border-weak-base); + border-inline-end: 1px solid var(--border-weak-base); + background-color: var(--background-base); + + [data-slot="tabs-trigger"] { + display: flex; + align-items: center; + justify-content: center; + padding: 14px 24px 14px 12px; + outline: none; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + + &:focus-visible { + outline: none; + box-shadow: none; + } + } + + [data-slot="tabs-trigger-close-button"] { + display: flex; + align-items: center; + justify-content: center; + } + + [data-component="icon-button"] { + margin: -0.25rem; + } + + &:disabled { + pointer-events: none; + color: var(--text-weaker); + } + &:focus-visible { + outline: none; + box-shadow: none; + } + &:has([data-selected]) { + color: var(--text-strong); + background-color: transparent; + border-bottom-color: transparent; + [data-slot="tabs-trigger-close-button"] { + opacity: 1; + } + } + + &:hover:not(:disabled):not([data-selected]) { + color: var(--text-strong); + } + &:has([data-slot="tabs-trigger-close-button"]) { + padding-inline-end: 12px; + + [data-slot="tabs-trigger"] { + padding-inline-end: 0; + } + } + } + + [data-slot="tabs-content"] { + overflow-y: auto; + flex: 1; + + /* Hide scrollbar */ + scrollbar-width: none; + -ms-overflow-style: none; + &::-webkit-scrollbar { + display: none; + } + + &:focus-visible { + outline: none; + } + } + + #review-panel &[data-variant="normal"][data-orientation="horizontal"], + #terminal-panel &[data-variant="normal"][data-orientation="horizontal"] { + background-color: var(--background-stronger); + + [data-slot="tabs-list"] { + height: var(--tabs-bar-height); + padding-inline-start: 12px; + padding-inline-end: 0; + --tabs-review-gap: 16px; + --tabs-review-fade: 16px; + gap: var(--tabs-review-gap); + background-color: var(--background-stronger); + border-bottom: 1px solid var(--border-weaker-base); + + &::after { + display: none; + } + + > .sticky { + border-bottom: none; + background-color: var(--background-stronger); + + &::before { + content: ""; + position: absolute; + top: 0; + bottom: 0; + inset-inline-end: 100%; + width: var(--tabs-review-fade); + pointer-events: none; + background: linear-gradient(90deg, transparent, var(--background-stronger)); + } + + &:dir(rtl)::before { + background: linear-gradient(270deg, transparent, var(--background-stronger)); + } + } + } + + [data-slot="tabs-trigger-wrapper"] { + height: var(--tabs-compact-pill-height); + margin-block: 0; + max-width: 320px; + padding-inline: var(--tabs-compact-pill-padding-x); + box-sizing: border-box; + border: 1px solid transparent; + border-radius: var(--tabs-compact-pill-radius); + background-color: transparent; + gap: 8px; + color: var(--text-weak); + transition: + color 120ms ease, + background-color 120ms ease, + border-color 120ms ease; + + &::after { + content: ""; + position: absolute; + left: 0; + right: 0; + bottom: calc((var(--tabs-compact-pill-height) - var(--tabs-bar-height)) / 2); + height: 1px; + background-color: var(--text-strong); + opacity: 0; + transform: scaleX(0.75); + transform-origin: center; + transition: + opacity 120ms ease, + transform 120ms ease; + } + + &[data-value="review"] { + padding-left: 8px; + padding-right: 8px; + } + + [data-slot="tabs-trigger"] { + height: 100%; + padding: 0 !important; + } + + &:has([data-slot="tabs-trigger-close-button"]) { + padding-inline-end: 5px; + [data-slot="tabs-trigger"] { + padding-inline-end: 0 !important; + } + } + + &:has([data-selected]) { + color: var(--text-strong); + background-color: var(--surface-base-active); + border-color: var(--border-weak-base); + + &::after { + opacity: 1; + transform: scaleX(1); + } + } + + &:hover:not(:disabled):not(:has([data-selected])) { + color: var(--text-base); + background-color: var(--surface-base-hover); + } + + /* + File tabs: use monochrome icon by default. + Full-color icon is shown on hover/selected. + */ + [data-slot="tabs-trigger"] { + .tab-fileicon-color, + .tab-fileicon-mono { + pointer-events: none; + } + + .tab-fileicon-color { + display: none; + } + + .tab-fileicon-mono { + display: block; + color: currentColor; + } + + &[data-selected], + &:hover { + .tab-fileicon-color { + display: block; + } + + .tab-fileicon-mono { + display: none; + } + } + } + } + } + + #terminal-panel &[data-variant="normal"][data-orientation="horizontal"] { + [data-slot="tabs-list"] { + height: 52px; + padding-inline-end: 12px; + gap: 8px; + } + + [data-slot="tabs-trigger-wrapper"] { + height: 28px; + padding-inline: 8px; + border: 0; + background-color: transparent; + box-shadow: none; + + &::after { + display: none; + } + + [data-slot="tabs-trigger"] { + padding: 0 !important; + } + + &:has([data-slot="tabs-trigger-close-button"]) { + padding-inline-end: 8px; + } + + &:has([data-selected]) { + background-color: var(--v2-background-bg-layer-01); + box-shadow: inset 0 0 0 0.5px var(--v2-border-border-muted); + } + } + + [data-slot="terminal-tab-title"] { + font-family: Inter, sans-serif; + font-style: normal; + font-weight: 440; + font-size: 13px; + line-height: 16px; + letter-spacing: -0.04px; + color: var(--v2-text-text-base); + font-variation-settings: "slnt" 0; + font-variant-numeric: tabular-nums; + } + } + + &[data-variant="alt"] { + [data-slot="tabs-list"] { + padding-left: 24px; + padding-right: 24px; + gap: 12px; + border-bottom: 1px solid var(--border-weak-base); + background-color: transparent; + + &::after { + border: none; + background-color: transparent; + } + &:empty::after { + display: none; + } + } + + [data-slot="tabs-trigger-wrapper"] { + border: none; + color: var(--text-base); + background-color: transparent; + border-bottom-width: 2px; + border-bottom-style: solid; + border-bottom-color: transparent; + gap: 4px; + + /* text-14-regular */ + font-family: var(--font-family-sans); + font-size: var(--font-size-base); + font-style: normal; + font-weight: var(--font-weight-regular); + line-height: var(--line-height-x-large); /* 171.429% */ + letter-spacing: var(--letter-spacing-normal); + + [data-slot="tabs-trigger"] { + height: 100%; + padding: 4px; + background-color: transparent; + border-bottom-width: 2px; + border-bottom-color: transparent; + } + + [data-slot="tabs-trigger-close-button"] { + display: flex; + align-items: center; + justify-content: center; + } + + [data-component="icon-button"] { + width: 16px; + height: 16px; + margin: 0; + } + + &:has([data-selected]) { + color: var(--text-strong); + background-color: transparent; + border-bottom-color: var(--icon-strong-base); + } + + &:hover:not(:disabled):not([data-selected]) { + color: var(--text-strong); + } + + &:has([data-slot="tabs-trigger-close-button"]) { + padding-inline-end: 0; + [data-slot="tabs-trigger"] { + padding-inline-end: 0; + } + } + } + + /* [data-slot="tabs-content"] { */ + /* } */ + } + + &[data-variant="pill"][data-orientation="horizontal"] { + background-color: transparent; + + [data-slot="tabs-list"] { + height: auto; + padding: 6px 0; + gap: 4px; + background-color: var(--background-base); + + &::after { + display: none; + } + } + + [data-slot="tabs-trigger-wrapper"] { + height: 32px; + border: none; + border-radius: var(--radius-sm); + background-color: transparent; + gap: 0; + + /* text-13-medium */ + font-family: var(--font-family-sans); + font-size: var(--font-size-small); + font-style: normal; + font-weight: var(--font-weight-medium); + line-height: var(--line-height-large); + letter-spacing: var(--letter-spacing-normal); + + [data-slot="tabs-trigger"] { + height: 100%; + width: 100%; + padding: 0 12px; + background-color: transparent; + } + + &:hover:not(:disabled) { + background-color: var(--surface-base-hover); + color: var(--text-strong); + } + + &:active:not(:disabled) { + background-color: var(--surface-base-active); + } + + &:has([data-selected]) { + background-color: var(--surface-base-active); + color: var(--text-strong); + + &:hover:not(:disabled) { + background-color: var(--surface-base-active); + } + } + } + } + + &[data-variant="pill"][data-orientation="horizontal"][data-scope="filetree"] { + [data-slot="tabs-list"] { + height: 48px; + padding-inline: 12px; + gap: 8px; + align-items: center; + background-color: var(--background-stronger); + box-sizing: border-box; + border-bottom: 1px solid var(--border-weak-base); + } + + [data-slot="tabs-trigger-wrapper"] { + height: var(--tabs-compact-pill-height); + border-radius: var(--tabs-compact-pill-radius); + color: var(--text-weak); + box-sizing: border-box; + border: 1px solid transparent; + transition: + color 120ms ease, + background-color 120ms ease, + border-color 120ms ease; + + &:not(:has([data-selected])):hover:not(:disabled) { + color: var(--text-base); + } + + &:has([data-selected]) { + color: var(--text-strong); + border-color: var(--border-weak-base); + } + } + } + + &[data-orientation="vertical"] { + flex-direction: row; + + [data-slot="tabs-list"] { + flex-direction: column; + width: auto; + height: 100%; + overflow-x: hidden; + overflow-y: auto; + padding: 8px; + gap: 4px; + background-color: var(--background-base); + border-inline-end: 1px solid var(--border-weak-base); + + &::after { + display: none; + } + } + + [data-slot="tabs-trigger-wrapper"] { + width: 100%; + height: 32px; + border: none; + border-radius: 8px; + background-color: transparent; + + [data-slot="tabs-trigger"] { + height: 100%; + padding: 0 8px; + gap: 8px; + justify-content: flex-start; + } + + &:hover:not(:disabled) { + background-color: var(--surface-base-hover); + } + + &:has([data-selected]) { + background-color: var(--surface-base-active); + color: var(--text-strong); + } + } + + [data-slot="tabs-content"] { + overflow-x: auto; + overflow-y: auto; + } + + &[data-variant="alt"] { + [data-slot="tabs-list"] { + padding: 8px; + gap: 4px; + border: none; + + &::after { + display: none; + } + } + + [data-slot="tabs-trigger-wrapper"] { + height: 32px; + border: none; + border-radius: 8px; + + [data-slot="tabs-trigger"] { + border: none; + padding: 0 8px; + gap: 8px; + justify-content: flex-start; + } + + &:hover:not(:disabled) { + background-color: var(--surface-base-hover); + } + + &:has([data-selected]) { + background-color: var(--surface-base-hover); + color: var(--text-strong); + } + } + } + + &[data-variant="settings"] { + [data-slot="tabs-list"] { + width: 150px; + min-width: 150px; + + @media (min-width: 640px) { + width: 200px; + min-width: 200px; + } + padding: 12px; + gap: 0; + background-color: var(--background-base); + border-inline-end: 1px solid var(--border-weak-base); + + &::after { + display: none; + } + } + + [data-slot="tabs-section-title"] { + width: 100%; + padding: 0; + padding-inline-start: 4px; + font-family: var(--font-family-sans); + font-size: var(--font-size-small); + font-weight: var(--font-weight-medium); + color: var(--text-weak); + } + + [data-slot="tabs-trigger-wrapper"] { + height: 32px; + border: none; + border-radius: var(--radius-md); + + /* text-14-medium */ + font-family: var(--font-family-sans); + font-size: var(--font-size-base); + font-weight: var(--font-weight-medium); + line-height: var(--line-height-large); + + [data-slot="tabs-trigger"] { + border: none; + padding: 0 8px; + gap: 12px; + justify-content: flex-start; + width: 100%; + height: 100%; + } + + [data-component="icon"] { + color: var(--icon-base); + } + + &:hover:not(:disabled) { + background-color: var(--surface-base-hover); + } + + &:has([data-slot="tabs-trigger"]:focus-visible) { + background-color: var(--surface-base-hover); + box-shadow: var(--shadow-xs-border-focus); + } + + &:has([data-selected]) { + background-color: var(--surface-base-active); + color: var(--text-strong); + + [data-component="icon"] { + color: var(--icon-strong-base); + } + + &:hover:not(:disabled) { + background-color: var(--surface-base-active); + } + } + } + + [data-slot="tabs-content"] { + background-color: var(--surface-stronger-non-alpha); + } + } + } +} + +[data-component="tabs-drag-preview"] { + position: relative; + display: flex; + align-items: center; + height: var(--tabs-bar-height, 48px); + max-width: 320px; + padding-inline: var(--tabs-compact-pill-padding-x, 4px); + overflow: hidden; + color: var(--text-strong); + opacity: 0.6; +} + +[data-component="tabs-drag-preview"]::before { + content: ""; + position: absolute; + left: 0; + right: 0; + top: calc((var(--tabs-bar-height, 48px) - var(--tabs-compact-pill-height, 24px)) / 2); + height: var(--tabs-compact-pill-height, 24px); + border: 1px solid var(--border-weak-base); + border-radius: var(--tabs-compact-pill-radius, 6px); + background-color: var(--surface-base-active); +} + +[data-component="tabs-drag-preview"]::after { + content: ""; + position: absolute; + left: 0; + right: 0; + bottom: 0; + height: 1px; + background-color: var(--text-strong); +} + +[data-component="tabs-drag-preview"] > * { + position: relative; +} + +body[data-new-layout] #review-panel [data-component="tabs"], +body[data-new-layout] #terminal-panel [data-component="tabs"], +body[data-new-layout] #review-panel [data-component="tabs"][data-variant="normal"][data-orientation="horizontal"], +body[data-new-layout] #terminal-panel [data-component="tabs"][data-variant="normal"][data-orientation="horizontal"] { + background-color: var(--v2-background-bg-base); + + [data-slot="tabs-list"] { + background-color: var(--v2-background-bg-base); + + > .sticky { + background-color: var(--v2-background-bg-base); + + &::before { + background: linear-gradient(90deg, transparent, var(--v2-background-bg-base)); + } + + &:dir(rtl)::before { + background: linear-gradient(270deg, transparent, var(--v2-background-bg-base)); + } + } + } +} + +body[data-new-layout] #review-panel [data-component="tabs"][data-variant="normal"][data-orientation="horizontal"] { + [data-slot="tabs-list"] { + height: 52px; + padding-inline-end: 12px; + gap: 8px; + --tabs-review-fade: 8px; + } + + [data-slot="tabs-trigger-wrapper"] { + height: 28px; + padding-inline: 10px; + border: 0; + background-color: transparent; + box-shadow: none; + + &::after { + display: none; + } + + [data-slot="tabs-trigger"] { + padding: 0 !important; + } + + &:has([data-slot="tabs-trigger-close-button"]) { + padding-inline-end: 8px; + + [data-slot="tabs-trigger-close-button"] { + margin-inline-start: 2px; + } + } + + &:has([data-selected]) { + background-color: var(--v2-background-bg-layer-02); + box-shadow: none; + } + } + + [data-slot="tabs-trigger"] { + font-family: Inter, sans-serif; + font-style: normal; + font-weight: 440; + font-size: 13px; + line-height: 16px; + letter-spacing: -0.04px; + color: var(--v2-text-text-muted); + font-variation-settings: "slnt" 0; + font-variant-numeric: tabular-nums; + + &[data-selected] { + color: var(--v2-text-text-base); + } + + .tab-fileicon-color, + .tab-fileicon-mono, + [data-slot="icon-svg"] { + margin-inline-start: -2px; + } + } + + /* Preview/temporary tabs: beat trigger + .text-14-medium upright defaults */ + [data-slot="tabs-trigger"] .italic { + font-style: italic !important; + font-variation-settings: "slnt" -10 !important; + } +} + +body[data-new-layout] #review-panel [data-component="tabs"] .session-review-v2-tabs-bar { + width: 100%; + min-width: 0; + gap: 8px; + box-sizing: border-box; + border-bottom: 1px solid var(--border-weaker-base, var(--v2-border-border-weak)); + background-color: var(--v2-background-bg-base); +} + +body[data-new-layout] #review-panel [data-component="tabs"] .session-review-v2-tabs-bar [data-slot="tabs-list"], +body[data-new-layout] + #review-panel + [data-component="tabs"][data-variant="normal"][data-orientation="horizontal"] + .session-review-v2-tabs-bar + [data-slot="tabs-list"] { + flex: 1 1 0; + min-width: 0; + width: auto; + border-bottom: none; +} + +body[data-new-layout] + #review-panel + [data-component="tabs"] + .session-review-v2-tabs-bar + [data-slot="tabs-list"] + > .sticky { + padding-inline-end: 0; + + [data-component="icon-button-v2"] { + position: relative; + + &::after { + content: ""; + position: absolute; + inset-inline-start: 100%; + top: 50%; + transform: translateY(-50%); + width: 20px; + height: 28px; + background-color: var(--v2-background-bg-base); + pointer-events: none; + } + } +} + +body[data-new-layout] + #review-panel + [data-component="tabs"] + .session-review-v2-tabs-bar + [data-slot="tabs-list"] + > .sticky:not(.session-review-v2-sidebar-toggle-slot) { + left: auto; + right: auto; + inset-inline-end: 0; +} + +body[data-new-layout] + #review-panel + [data-component="tabs"] + .session-review-v2-tabs-bar + [data-slot="tabs-list"] + > .session-review-v2-sidebar-toggle-slot.sticky { + left: auto; + right: auto; + inset-inline-start: 0; + padding-inline-end: 0; + + &::before { + content: ""; + position: absolute; + top: 50%; + bottom: auto; + inset-inline-start: auto; + inset-inline-end: 100%; + transform: translateY(-50%); + width: 12px; + height: 28px; + background-color: var(--v2-background-bg-base); + background-image: none; + pointer-events: none; + } + + &::after { + content: ""; + position: absolute; + top: 50%; + inset-inline-start: 100%; + inset-inline-end: auto; + transform: translateY(-50%); + width: 8px; + height: 28px; + pointer-events: none; + background: linear-gradient(90deg, var(--v2-background-bg-base), transparent); + } + + &:dir(rtl)::after { + background: linear-gradient(270deg, var(--v2-background-bg-base), transparent); + } + + [data-component="icon-button-v2"]::after { + display: none; + } +} + +body[data-new-layout] #review-panel [data-component="tabs"] .session-review-v2-open-in-app-slot { + flex-shrink: 0; + margin-inline-start: auto; +} + +body[data-new-layout] #review-panel [data-component="tabs"] .session-review-v2-open-in-app { + flex-shrink: 0; +} diff --git a/packages/ui/src/components/tabs.tsx b/packages/ui/src/components/tabs.tsx new file mode 100644 index 0000000000000000000000000000000000000000..0dd5fb3e236e50db5f87a0794f4805597be2ec48 --- /dev/null +++ b/packages/ui/src/components/tabs.tsx @@ -0,0 +1,126 @@ +import { Tabs as Kobalte } from "@kobalte/core/tabs" +import { Show, splitProps, type JSX } from "solid-js" +import type { ComponentProps, ParentProps, Component } from "solid-js" + +export interface TabsProps extends ComponentProps { + variant?: "normal" | "alt" | "pill" | "settings" + orientation?: "horizontal" | "vertical" +} +export interface TabsListProps extends ComponentProps {} +export interface TabsTriggerProps extends ComponentProps { + classes?: { + button?: string + } + hideCloseButton?: boolean + closeButton?: JSX.Element + onMiddleClick?: () => void +} +export interface TabsContentProps extends ComponentProps {} + +function TabsRoot(props: TabsProps) { + const [split, rest] = splitProps(props, ["class", "classList", "variant", "orientation"]) + return ( + + ) +} + +function TabsList(props: TabsListProps) { + const [split, rest] = splitProps(props, ["class", "classList"]) + return ( + + ) +} + +function TabsTrigger(props: ParentProps) { + const [split, rest] = splitProps(props, [ + "class", + "classList", + "classes", + "children", + "closeButton", + "hideCloseButton", + "onMiddleClick", + ]) + return ( +
{ + if (e.button === 1 && split.onMiddleClick) { + e.preventDefault() + } + }} + onAuxClick={(e) => { + if (e.button === 1 && split.onMiddleClick) { + e.preventDefault() + split.onMiddleClick() + } + }} + > + + {split.children} + + + {(closeButton) => ( +
+ {closeButton()} +
+ )} +
+
+ ) +} + +function TabsContent(props: ParentProps) { + const [split, rest] = splitProps(props, ["class", "classList", "children"]) + return ( + + {split.children} + + ) +} + +const TabsSectionTitle: Component = (props) => { + return
{props.children}
+} + +export const Tabs = Object.assign(TabsRoot, { + List: TabsList, + Trigger: TabsTrigger, + Content: TabsContent, + SectionTitle: TabsSectionTitle, +}) diff --git a/packages/ui/src/components/tag.css b/packages/ui/src/components/tag.css new file mode 100644 index 0000000000000000000000000000000000000000..0e8b7b9f10475e2b39bc38f23a90db040480f00e --- /dev/null +++ b/packages/ui/src/components/tag.css @@ -0,0 +1,37 @@ +[data-component="tag"] { + display: inline-flex; + align-items: center; + justify-content: center; + user-select: none; + + border-radius: var(--radius-xs); + border: 0.5px solid var(--border-weak-base); + background: var(--surface-raised-base); + color: var(--text-base); + + &[data-size="normal"] { + height: 18px; + padding: 0 6px; + + /* text-12-medium */ + font-family: var(--font-family-sans); + font-size: var(--font-size-small); + font-style: normal; + font-weight: var(--font-weight-medium); + line-height: var(--line-height-large); /* 166.667% */ + letter-spacing: var(--letter-spacing-normal); + } + + &[data-size="large"] { + height: 22px; + padding: 0 8px; + + /* text-14-medium */ + font-family: var(--font-family-sans); + font-size: 14px; + font-style: normal; + font-weight: var(--font-weight-medium); + line-height: var(--line-height-large); /* 142.857% */ + letter-spacing: var(--letter-spacing-normal); + } +} diff --git a/packages/ui/src/components/tag.stories.tsx b/packages/ui/src/components/tag.stories.tsx new file mode 100644 index 0000000000000000000000000000000000000000..73ae880ba1c25e5a19e35eaf294af92d65e119c1 --- /dev/null +++ b/packages/ui/src/components/tag.stories.tsx @@ -0,0 +1,58 @@ +// @ts-nocheck +import * as mod from "./tag" +import { create } from "../storybook/scaffold" + +const docs = `### Overview +Small label tag for metadata and status chips. + +Use alongside headings or lists for quick metadata. + +### API +- Optional: \`size\` (normal | large). +- Accepts standard span props. + +### Variants and states +- Size variants only. + +### Behavior +- Inline element; size controls padding and font size via CSS. + +### Accessibility +- Ensure text conveys meaning; avoid color-only distinction. + +### Theming/tokens +- Uses \`data-component="tag"\` with size data attributes. + +` + +const story = create({ title: "UI/Tag", mod, args: { children: "Tag" } }) +export default { + title: "UI/Tag", + id: "components-tag", + component: story.meta.component, + tags: ["autodocs"], + parameters: { + docs: { + description: { + component: docs, + }, + }, + }, + argTypes: { + size: { + control: "select", + options: ["normal", "large"], + }, + }, +} + +export const Basic = story.Basic + +export const Sizes = { + render: () => ( +
+ Normal + Large +
+ ), +} diff --git a/packages/ui/src/components/text-field.css b/packages/ui/src/components/text-field.css new file mode 100644 index 0000000000000000000000000000000000000000..47c7f9f94dc4a56cd28cd8ea7ec1574bd6eee032 --- /dev/null +++ b/packages/ui/src/components/text-field.css @@ -0,0 +1,134 @@ +[data-component="input"] { + width: 100%; + + [data-slot="input-input"] { + width: 100%; + color: var(--text-strong); + + /* text-14-regular */ + font-family: var(--font-family-sans); + font-size: 14px; + font-style: normal; + font-weight: var(--font-weight-regular); + line-height: var(--line-height-large); /* 142.857% */ + letter-spacing: var(--letter-spacing-normal); + + &:focus { + outline: none; + } + + &::placeholder { + color: var(--text-weak); + } + } + + &[data-variant="normal"] { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 8px; + + [data-slot="input-label"] { + color: var(--text-weak); + + /* text-12-medium */ + font-family: var(--font-family-sans); + font-size: var(--font-size-small); + font-style: normal; + font-weight: var(--font-weight-medium); + line-height: 18px; /* 150% */ + letter-spacing: var(--letter-spacing-normal); + } + + [data-slot="input-wrapper"] { + display: flex; + align-items: start; + justify-content: space-between; + width: 100%; + padding-inline-end: 4px; + + border-radius: var(--radius-md); + border: 1px solid var(--border-weak-base); + background: var(--input-base); + + &:focus-within:not(:has([data-readonly])) { + border-color: transparent; + /* border/shadow-xs/select */ + box-shadow: + 0 0 0 3px var(--border-weak-selected), + 0 0 0 1px var(--border-selected), + 0 1px 2px -1px rgba(19, 16, 16, 0.25), + 0 1px 2px 0 rgba(19, 16, 16, 0.08), + 0 1px 3px 0 rgba(19, 16, 16, 0.12); + } + + &:has([data-invalid]) { + background: var(--surface-critical-weak); + border: 1px solid var(--border-critical-selected); + } + + &:not(:has([data-slot="input-copy-button"])) { + padding-inline-end: 0; + } + } + + [data-slot="input-input"] { + color: var(--text-strong); + + display: flex; + height: 32px; + padding: 2px 12px; + align-items: center; + flex: 1; + min-width: 0; + + background: transparent; + border: none; + + /* text-14-regular */ + font-family: var(--font-family-sans); + font-size: 14px; + font-style: normal; + font-weight: var(--font-weight-regular); + line-height: var(--line-height-large); /* 142.857% */ + letter-spacing: var(--letter-spacing-normal); + + &:focus { + outline: none; + } + + &::placeholder { + color: var(--text-weak); + } + } + + textarea[data-slot="input-input"] { + height: auto; + min-height: 32px; + padding: 6px 12px; + resize: none; + } + + [data-slot="input-copy-button"] { + flex-shrink: 0; + margin-top: 4px; + color: var(--icon-base); + + &:hover { + color: var(--icon-strong-base); + } + } + + [data-slot="input-error"] { + color: var(--text-on-critical-base); + + /* text-12-medium */ + font-family: var(--font-family-sans); + font-size: var(--font-size-small); + font-style: normal; + font-weight: var(--font-weight-medium); + line-height: 18px; /* 150% */ + letter-spacing: var(--letter-spacing-normal); + } + } +} diff --git a/packages/ui/src/components/text-field.stories.tsx b/packages/ui/src/components/text-field.stories.tsx new file mode 100644 index 0000000000000000000000000000000000000000..73f9006607b93c0ed6ee999870610904dc0ea26d --- /dev/null +++ b/packages/ui/src/components/text-field.stories.tsx @@ -0,0 +1,111 @@ +// @ts-nocheck +import * as mod from "./text-field" +import { create } from "../storybook/scaffold" + +const docs = `### Overview +Text input with label, description, and optional copy-to-clipboard action. + +Pair with \`Tooltip\` and \`IconButton\` for copy affordance (built in). + +### API +- Supports Kobalte TextField props: \`value\`, \`defaultValue\`, \`onChange\`, \`disabled\`, \`readOnly\`. +- Optional: \`label\`, \`description\`, \`error\`, \`variant\`, \`copyable\`, \`multiline\`. + +### Variants and states +- Normal and ghost variants. +- Supports multiline textarea. + +### Behavior +- When \`copyable\` is true, clicking copies the current value. + +### Accessibility +- Label is hidden when \`hideLabel\` is true (sr-only). + +### Theming/tokens +- Uses \`data-component="input"\` with slot attributes for styling. + +` + +const story = create({ + title: "UI/TextField", + mod, + args: { + label: "Label", + placeholder: "Type here...", + defaultValue: "Hello", + }, +}) + +export default { + title: "UI/TextField", + id: "components-text-field", + component: story.meta.component, + tags: ["autodocs"], + parameters: { + docs: { + description: { + component: docs, + }, + }, + }, +} + +export const Basic = story.Basic + +export const Variants = { + render: () => ( +
+ + +
+ ), +} + +export const Multiline = { + args: { + label: "Description", + multiline: true, + defaultValue: "Line one\nLine two", + }, +} + +export const Copyable = { + args: { + label: "Invite link", + defaultValue: "https://example.com/invite/abc", + copyable: true, + copyKind: "link", + }, +} + +export const Error = { + args: { + label: "Email", + defaultValue: "invalid@", + error: "Enter a valid email address", + }, +} + +export const Disabled = { + args: { + label: "Disabled", + defaultValue: "Readonly", + disabled: true, + }, +} + +export const ReadOnly = { + args: { + label: "Read only", + defaultValue: "Read only value", + readOnly: true, + }, +} + +export const HiddenLabel = { + args: { + label: "Hidden label", + hideLabel: true, + placeholder: "Hidden label", + }, +} diff --git a/packages/ui/src/components/text-field.tsx b/packages/ui/src/components/text-field.tsx new file mode 100644 index 0000000000000000000000000000000000000000..82be20f9ea19f5206d10a14cbe72ef4af451af3f --- /dev/null +++ b/packages/ui/src/components/text-field.tsx @@ -0,0 +1,128 @@ +import { TextField as Kobalte } from "@kobalte/core/text-field" +import { createSignal, Show, splitProps } from "solid-js" +import type { ComponentProps } from "solid-js" +import { useI18n } from "../context/i18n" +import { IconButton } from "./icon-button" +import { Tooltip } from "./tooltip" + +export interface TextFieldProps + extends ComponentProps, + Partial< + Pick< + ComponentProps, + | "name" + | "defaultValue" + | "value" + | "onChange" + | "onKeyDown" + | "validationState" + | "required" + | "disabled" + | "readOnly" + > + > { + label?: string + hideLabel?: boolean + description?: string + error?: string + variant?: "normal" | "ghost" + copyable?: boolean + copyKind?: "clipboard" | "link" + multiline?: boolean +} + +export function TextField(props: TextFieldProps) { + const i18n = useI18n() + const [local, others] = splitProps(props, [ + "name", + "defaultValue", + "value", + "onChange", + "onKeyDown", + "validationState", + "required", + "disabled", + "readOnly", + "class", + "label", + "hideLabel", + "description", + "error", + "variant", + "copyable", + "copyKind", + "multiline", + ]) + const [copied, setCopied] = createSignal(false) + + const label = () => { + if (copied()) return i18n.t("ui.textField.copied") + if (local.copyKind === "link") return i18n.t("ui.textField.copyLink") + return i18n.t("ui.textField.copyToClipboard") + } + + const icon = () => { + if (copied()) return "check" + if (local.copyKind === "link") return "link" + return "copy" + } + + async function handleCopy() { + const value = local.value ?? local.defaultValue ?? "" + await navigator.clipboard.writeText(value) + setCopied(true) + setTimeout(() => setCopied(false), 2000) + } + + function handleClick() { + if (local.copyable) void handleCopy() + } + + return ( + + + + {local.label} + + +
+ } + > + + + + + + + +
+ + {local.description} + + {local.error} +
+ ) +} diff --git a/packages/ui/src/components/text-reveal.tsx b/packages/ui/src/components/text-reveal.tsx new file mode 100644 index 0000000000000000000000000000000000000000..2d2a94e6a37e9fddf72ec5bf67e19e0844c36ea3 --- /dev/null +++ b/packages/ui/src/components/text-reveal.tsx @@ -0,0 +1,143 @@ +import { createEffect, on, onCleanup, onMount } from "solid-js" +import { createStore } from "solid-js/store" + +const px = (value: number | string | undefined, fallback: number) => { + if (typeof value === "number") return `${value}px` + if (typeof value === "string") return value + return `${fallback}px` +} + +const ms = (value: number | string | undefined, fallback: number) => { + if (typeof value === "number") return `${value}ms` + if (typeof value === "string") return value + return `${fallback}ms` +} + +const pct = (value: number | undefined, fallback: number) => { + const v = value ?? fallback + return `${v}%` +} + +export function TextReveal(props: { + text?: string + class?: string + duration?: number | string + /** Gradient edge softness as a percentage of the mask (0 = hard wipe, 17 = soft). */ + edge?: number + /** Optional small vertical travel for entering text (px). Default 0. */ + travel?: number | string + spring?: string + springSoft?: string + growOnly?: boolean + truncate?: boolean +}) { + const [state, setState] = createStore({ + cur: props.text, + old: undefined as string | undefined, + width: "auto", + ready: false, + swapping: false, + }) + const cur = () => state.cur + const old = () => state.old + const width = () => state.width + const ready = () => state.ready + const swapping = () => state.swapping + let inRef: HTMLSpanElement | undefined + let outRef: HTMLSpanElement | undefined + let rootRef: HTMLSpanElement | undefined + let frame: number | undefined + + const win = () => inRef?.scrollWidth ?? 0 + const wout = () => outRef?.scrollWidth ?? 0 + + const widen = (next: number) => { + if (next <= 0) return + if (props.growOnly ?? true) { + const prev = Number.parseFloat(width()) + if (Number.isFinite(prev) && next <= prev) return + } + setState("width", `${next}px`) + } + + createEffect( + on( + () => props.text, + (next, prev) => { + if (next === prev) return + if (typeof next === "string" && typeof prev === "string" && next.startsWith(prev)) { + setState("cur", next) + widen(win()) + return + } + setState("swapping", true) + setState("old", prev) + setState("cur", next) + + if (typeof requestAnimationFrame !== "function") { + widen(Math.max(win(), wout())) + rootRef?.offsetHeight + setState("swapping", false) + return + } + if (frame !== undefined && typeof cancelAnimationFrame === "function") cancelAnimationFrame(frame) + frame = requestAnimationFrame(() => { + widen(Math.max(win(), wout())) + rootRef?.offsetHeight + setState("swapping", false) + frame = undefined + }) + }, + ), + ) + + onMount(() => { + widen(win()) + const fonts = typeof document !== "undefined" ? document.fonts : undefined + if (typeof requestAnimationFrame !== "function") { + setState("ready", true) + return + } + if (!fonts) { + requestAnimationFrame(() => setState("ready", true)) + return + } + void fonts.ready.finally(() => { + widen(win()) + requestAnimationFrame(() => setState("ready", true)) + }) + }) + + onCleanup(() => { + if (frame === undefined || typeof cancelAnimationFrame !== "function") return + cancelAnimationFrame(frame) + }) + + return ( + + + + {cur() ?? "\u00A0"} + + + {old() ?? "\u00A0"} + + + + ) +} diff --git a/packages/ui/src/components/text-shimmer.stories.tsx b/packages/ui/src/components/text-shimmer.stories.tsx new file mode 100644 index 0000000000000000000000000000000000000000..a88a7158b1196fd568191574f9fcbc504720a5a4 --- /dev/null +++ b/packages/ui/src/components/text-shimmer.stories.tsx @@ -0,0 +1,92 @@ +// @ts-nocheck +import * as mod from "./text-shimmer" +import { useArgs } from "storybook/preview-api" +import { create } from "../storybook/scaffold" + +const docs = `### Overview +Animated shimmer effect for loading text placeholders. + +Use for pending states inside buttons or list rows. + +### API +- Required: \`text\` string. +- Optional: \`as\`, \`active\`, \`offset\`, \`class\`. + +### Variants and states +- Active/inactive state via \`active\`. + +### Behavior +- Uses a moving gradient sweep clipped to text. +- \`offset\` lets multiple shimmers run out-of-phase. + +### Accessibility +- Uses \`aria-label\` with the full text. + +### Theming/tokens +- Uses \`data-component="text-shimmer"\` and CSS custom properties for timing. + +` + +const defaults = { + text: "Loading...", + active: true, + class: "text-14-medium text-text-strong", + offset: 0, +} as const + +const story = create({ title: "UI/TextShimmer", mod, args: defaults }) + +export default { + title: "UI/TextShimmer", + id: "components-text-shimmer", + component: story.meta.component, + tags: ["autodocs"], + args: defaults, + argTypes: { + text: { control: "text" }, + class: { control: "text" }, + active: { control: "boolean" }, + offset: { control: { type: "range", min: 0, max: 80, step: 1 } }, + }, + parameters: { + docs: { + description: { + component: docs, + }, + }, + }, +} + +export const Basic = { + args: defaults, + render: (args) => { + const [, updateArgs] = useArgs() + const reset = () => updateArgs(defaults) + return ( +
+ + +
+ ) + }, +} + +export const Inactive = { + args: { + text: "Static text", + active: false, + }, +} diff --git a/packages/ui/src/components/text-strikethrough.css b/packages/ui/src/components/text-strikethrough.css new file mode 100644 index 0000000000000000000000000000000000000000..1be805468380a50f1c022f93439f775e70a9e6ad --- /dev/null +++ b/packages/ui/src/components/text-strikethrough.css @@ -0,0 +1,27 @@ +/* + * TextStrikethrough — spring-animated strikethrough line + * + * Draws a line-through from left to right using clip-path on a + * transparent-text overlay that carries the text-decoration. + * Grid stacking (grid-area: 1/1) layers the overlay on the base text. + * + * Key trick: -webkit-text-fill-color hides the glyph paint while + * keeping `color` (and therefore `currentColor` / text-decoration-color) + * set to the real inherited text color. + */ + +[data-component="text-strikethrough"] { + display: grid; +} + +[data-slot="text-strikethrough-line"] { + -webkit-text-fill-color: transparent; + text-decoration-line: line-through; + pointer-events: none; +} + +@media (prefers-reduced-motion: reduce) { + [data-slot="text-strikethrough-line"] { + clip-path: none !important; + } +} diff --git a/packages/ui/src/components/text-strikethrough.tsx b/packages/ui/src/components/text-strikethrough.tsx new file mode 100644 index 0000000000000000000000000000000000000000..958befff68a6fd17968d7f52c939d093b8b6d2b4 --- /dev/null +++ b/packages/ui/src/components/text-strikethrough.tsx @@ -0,0 +1,84 @@ +import type { JSX } from "solid-js" +import { onMount } from "solid-js" +import { createResizeObserver } from "@solid-primitives/resize-observer" +import { createStore } from "solid-js/store" +import { useSpring } from "./motion-spring" + +export function TextStrikethrough(props: { + /** Whether the strikethrough is active (line drawn across). */ + active: boolean + /** The text to display. Rendered twice internally (base + decoration overlay). */ + text: string + /** Spring visual duration in seconds. Default 0.35. */ + visualDuration?: number + class?: string + style?: JSX.CSSProperties +}) { + const progress = useSpring( + () => (props.active ? 1 : 0), + () => ({ visualDuration: props.visualDuration ?? 0.35, bounce: 0 }), + ) + + let baseRef: HTMLSpanElement | undefined + let containerRef: HTMLSpanElement | undefined + const [state, setState] = createStore({ + textWidth: 0, + containerWidth: 0, + }) + const textWidth = () => state.textWidth + const containerWidth = () => state.containerWidth + + const measure = () => { + if (baseRef) setState("textWidth", baseRef.scrollWidth) + if (containerRef) setState("containerWidth", containerRef.offsetWidth) + } + + onMount(measure) + createResizeObserver(() => containerRef, measure) + + // Revealed pixels from left = progress * textWidth + const revealedPx = () => { + const tw = textWidth() + return tw > 0 ? progress() * tw : 0 + } + + // Overlay clip: hide everything to the right of revealed area + const overlayClip = () => { + const cw = containerWidth() + const tw = textWidth() + if (cw <= 0 || tw <= 0) return `inset(0 ${(1 - progress()) * 100}% 0 0)` + const remaining = Math.max(0, cw - revealedPx()) + return `inset(0 ${remaining}px 0 0)` + } + + // Base clip: hide everything to the left of revealed area (complementary) + const baseClip = () => { + const px = revealedPx() + if (px <= 0.5) return "none" + return `inset(0 0 0 ${px}px)` + } + + return ( + + + {props.text} + + + + ) +} diff --git a/packages/ui/src/components/toast.stories.tsx b/packages/ui/src/components/toast.stories.tsx new file mode 100644 index 0000000000000000000000000000000000000000..ef9cbb68ef283f6e6e4c58fcc8b6a0200ba2920b --- /dev/null +++ b/packages/ui/src/components/toast.stories.tsx @@ -0,0 +1,138 @@ +// @ts-nocheck +import * as mod from "./toast" +import { Button } from "./button" + +const docs = `### Overview +Toast notifications with optional icons, actions, and progress. + +Use brief titles/descriptions; limit actions to 1-2. + +### API +- Use \`showToast\` or \`showPromiseToast\` to trigger toasts. +- Render \`Toast.Region\` once per page. +- \`Toast\` subcomponents compose the structure. + +### Variants and states +- Variants: default, success, error, loading. +- Optional actions and persistent toasts. + +### Behavior +- Toasts render in a portal and auto-dismiss unless persistent. + +### Accessibility +- TODO: confirm aria-live behavior from Kobalte Toast. + +### Theming/tokens +- Uses \`data-component="toast"\` and slot data attributes. + +` + +export default { + title: "UI/Toast", + id: "components-toast", + component: mod.Toast, + tags: ["autodocs"], + parameters: { + docs: { + description: { + component: docs, + }, + }, + }, +} + +export const Basic = { + render: () => ( +
+ + + +
+ ), +} + +export const Actions = { + render: () => ( +
+ + +
+ ), +} + +export const Promise = { + render: () => ( +
+ + +
+ ), +} + +export const Loading = { + render: () => ( +
+ + +
+ ), +} diff --git a/packages/ui/src/components/tooltip.css b/packages/ui/src/components/tooltip.css new file mode 100644 index 0000000000000000000000000000000000000000..f02c2ca639214f1f286887d98166da86aaf4ced2 --- /dev/null +++ b/packages/ui/src/components/tooltip.css @@ -0,0 +1,74 @@ +[data-component="tooltip-trigger"] { + display: flex; +} + +[data-slot="tooltip-keybind"] { + display: flex; + align-items: center; + gap: 12px; +} + +[data-slot="tooltip-keybind-key"] { + color: var(--text-invert-base); + font-size: var(--font-size-small); + font-weight: var(--font-weight-medium); + line-height: var(--line-height-large); +} + +[data-component="tooltip"] { + z-index: 1000; + max-width: 320px; + border-radius: var(--radius-sm); + background-color: var(--surface-float-base); + color: var(--text-invert-strong); + background: var(--surface-float-base); + padding: 2px 8px; + border: 1px solid var(--border-weak-base, rgba(0, 0, 0, 0.07)); + + box-shadow: var(--shadow-md); + pointer-events: none !important; + /* transition: all 150ms ease-out; */ + /* transform: translate3d(0, 0, 0); */ + /* transform-origin: var(--kb-tooltip-content-transform-origin); */ + + /* text-12-medium */ + font-family: var(--font-family-sans); + font-size: var(--font-size-small); + font-style: normal; + font-weight: var(--font-weight-medium); + line-height: var(--line-height-large); /* 166.667% */ + letter-spacing: var(--letter-spacing-normal); + + &[data-expanded] { + opacity: 1; + /* transform: translate3d(0, 0, 0); */ + } + + &[data-closed]:not([data-force-open="true"]) { + opacity: 0; + } + + /* &[data-placement="top"] { */ + /* &[data-closed] { */ + /* transform: translate3d(0, 4px, 0); */ + /* } */ + /* } */ + /**/ + /* &[data-placement="bottom"] { */ + /* &[data-closed] { */ + /* transform: translate3d(0, -4px, 0); */ + /* } */ + /* } */ + /**/ + /* &[data-placement="left"] { */ + /* &[data-closed] { */ + /* transform: translate3d(4px, 0, 0); */ + /* } */ + /* } */ + /**/ + /* &[data-placement="right"] { */ + /* &[data-closed] { */ + /* transform: translate3d(-4px, 0, 0); */ + /* } */ + /* } */ +} diff --git a/packages/ui/src/components/typewriter.css b/packages/ui/src/components/typewriter.css new file mode 100644 index 0000000000000000000000000000000000000000..e978312a9fbb80e87b97ce4f9092b933c7d61b13 --- /dev/null +++ b/packages/ui/src/components/typewriter.css @@ -0,0 +1,14 @@ +@keyframes blink { + 0%, + 50% { + opacity: 1; + } + 51%, + 100% { + opacity: 0; + } +} + +.blinking-cursor { + animation: blink 1s step-end infinite; +} diff --git a/packages/ui/src/components/typewriter.tsx b/packages/ui/src/components/typewriter.tsx new file mode 100644 index 0000000000000000000000000000000000000000..16c85a110fd22cc95c2ccdeb73eb60ed714df081 --- /dev/null +++ b/packages/ui/src/components/typewriter.tsx @@ -0,0 +1,55 @@ +import { createEffect, onCleanup, Show, type ValidComponent } from "solid-js" +import { createStore } from "solid-js/store" +import { Dynamic } from "solid-js/web" + +export const Typewriter = (props: { text?: string; class?: string; as?: T }) => { + const [store, setStore] = createStore({ + typing: false, + displayed: "", + cursor: true, + }) + + createEffect(() => { + const text = props.text + if (!text) return + + let i = 0 + const timeouts: ReturnType[] = [] + setStore("typing", true) + setStore("displayed", "") + setStore("cursor", true) + + const getTypingDelay = () => { + const random = Math.random() + if (random < 0.05) return 150 + Math.random() * 100 + if (random < 0.15) return 80 + Math.random() * 60 + return 30 + Math.random() * 50 + } + + const type = () => { + if (i < text.length) { + setStore("displayed", text.slice(0, i + 1)) + i++ + timeouts.push(setTimeout(type, getTypingDelay())) + } else { + setStore("typing", false) + timeouts.push(setTimeout(() => setStore("cursor", false), 2000)) + } + } + + timeouts.push(setTimeout(type, 200)) + + onCleanup(() => { + for (const timeout of timeouts) clearTimeout(timeout) + }) + }) + + return ( + + {store.displayed} + + + + + ) +} diff --git a/packages/ui/src/context/dialog.tsx b/packages/ui/src/context/dialog.tsx new file mode 100644 index 0000000000000000000000000000000000000000..c4020307952857e49f14a63d22b06dc687b5126b --- /dev/null +++ b/packages/ui/src/context/dialog.tsx @@ -0,0 +1,197 @@ +import { + createContext, + createEffect, + createRoot, + createSignal, + getOwner, + onCleanup, + type Owner, + type ParentProps, + runWithOwner, + useContext, + type JSX, + startTransition, + For, +} from "solid-js" +import { Dialog as Kobalte } from "@kobalte/core/dialog" +import { makeEventListener } from "@solid-primitives/event-listener" + +type DialogElement = () => JSX.Element + +type Active = { + id: string + node: JSX.Element + dispose: () => void + owner: Owner + onClose?: () => void + setClosing: (closing: boolean) => void +} + +const Context = createContext>() + +function init() { + const [stack, setStack] = createSignal([]) + const timer = { current: undefined as ReturnType | undefined } + const lock = { value: false } + + onCleanup(() => { + if (timer.current === undefined) return + clearTimeout(timer.current) + timer.current = undefined + }) + + const close = (id?: string) => { + const items = stack() + const current = id ? items.find((item) => item.id === id) : items.at(-1) + if (!current || lock.value) return + lock.value = true + current.onClose?.() + current.setClosing(true) + + const closed = current.id + if (timer.current !== undefined) { + clearTimeout(timer.current) + timer.current = undefined + } + + timer.current = setTimeout(() => { + timer.current = undefined + current.dispose() + setStack((items) => items.filter((item) => item.id !== closed)) + lock.value = false + }, 100) + } + + createEffect(() => { + if (stack().length === 0) return + + const onKeyDown = (event: KeyboardEvent) => { + if (event.key !== "Escape") return + close() + event.preventDefault() + event.stopPropagation() + } + + makeEventListener(window, "keydown", onKeyDown, { capture: true }) + }) + + const mount = (element: DialogElement, owner: Owner, onClose: (() => void) | undefined, layer: number) => { + const id = Math.random().toString(36).slice(2) + const zIndex = 50 + layer * 10 + let dispose: (() => void) | undefined + let setClosing: ((closing: boolean) => void) | undefined + + const node = runWithOwner(owner, () => + createRoot((d: () => void) => { + dispose = d + const [closing, setClosingSignal] = createSignal(false) + setClosing = setClosingSignal + return ( + { + if (open || stack().at(-1)?.id !== id) return + close(id) + }} + > + + close(id)} + /> +
+ {element()} +
+
+
+ ) + }), + ) + + if (!dispose || !setClosing) return + + const active: Active = { id, node, dispose, owner, onClose, setClosing } + setStack((items) => [...items, active]) + } + + const push = (element: DialogElement, owner: Owner, onClose?: () => void) => { + if (timer.current !== undefined) { + clearTimeout(timer.current) + timer.current = undefined + } + lock.value = false + mount(element, owner, onClose, stack().length) + } + + const show = (element: DialogElement, owner: Owner, onClose?: () => void) => { + for (const item of stack()) item.dispose() + setStack([]) + if (timer.current !== undefined) { + clearTimeout(timer.current) + timer.current = undefined + } + lock.value = false + mount(element, owner, onClose, 0) + } + + return { + stack, + close, + show, + push, + } +} + +export function DialogProvider(props: ParentProps) { + const ctx = init() + return ( + + {props.children} +
+ {(item) => item.node} +
+
+ ) +} + +export function useDialog() { + const ctx = useContext(Context) + const owner = getOwner() + + if (!owner) { + throw new Error("useDialog must be used within a DialogProvider") + } + if (!ctx) { + throw new Error("useDialog must be used within a DialogProvider") + } + + return { + get active() { + return ctx.stack().at(-1) + }, + show(element: DialogElement, onClose?: () => void) { + const base = ctx.stack().at(-1)?.owner ?? owner + return startTransition(() => ctx.show(element, base, onClose)) + }, + push(element: DialogElement, onClose?: () => void) { + const base = ctx.stack().at(-1)?.owner ?? owner + return startTransition(() => ctx.push(element, base, onClose)) + }, + close() { + ctx.close() + }, + } +} diff --git a/packages/ui/src/context/file.tsx b/packages/ui/src/context/file.tsx new file mode 100644 index 0000000000000000000000000000000000000000..f94368cb180c1f6be1581d5c054dab985e64db15 --- /dev/null +++ b/packages/ui/src/context/file.tsx @@ -0,0 +1,10 @@ +import type { ValidComponent } from "solid-js" +import { createSimpleContext } from "./helper" + +const ctx = createSimpleContext({ + name: "FileComponent", + init: (props) => props.component, +}) + +export const FileComponentProvider = ctx.provider +export const useFileComponent = ctx.use diff --git a/packages/ui/src/context/helper.tsx b/packages/ui/src/context/helper.tsx new file mode 100644 index 0000000000000000000000000000000000000000..172fed460ec63fc373e616fcbef40d8cb21be5ee --- /dev/null +++ b/packages/ui/src/context/helper.tsx @@ -0,0 +1,38 @@ +import { createContext, createMemo, Show, useContext, type ParentProps, type Accessor } from "solid-js" + +export function createSimpleContext>( + input: { + name: string + init: ((input: Props) => T) | (() => T) + } & (T extends { ready: unknown } ? { gate: boolean } : { gate?: boolean }), +) { + const ctx = createContext() + + return { + provider: (props: ParentProps) => { + const init = input.init(props) + const gate = input.gate ?? true + + if (!gate) { + return {props.children} + } + + // Access init.ready inside the memo to make it reactive for getter properties + const isReady = createMemo(() => { + // @ts-expect-error + const ready = init.ready as Accessor | boolean | undefined + return ready === undefined || (typeof ready === "function" ? ready() : ready) + }) + return ( + + {props.children} + + ) + }, + use() { + const value = useContext(ctx) + if (!value) throw new Error(`${input.name} context must be used within a context provider`) + return value + }, + } +} diff --git a/packages/ui/src/context/i18n.test.ts b/packages/ui/src/context/i18n.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..fe9bc0aaea38192958fedb9929ee33e51fa8fc65 --- /dev/null +++ b/packages/ui/src/context/i18n.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, test } from "bun:test" +import { pluralCategory } from "./i18n" + +describe("pluralCategory", () => { + test.each([ + ["en", 0, "other"], + ["en", 1, "one"], + ["fr", 0, "one"], + ["fr", 1_000_000, "many"], + ["ru", 1, "one"], + ["ru", 2, "few"], + ["ru", 5, "many"], + ["ru", 21, "one"], + ["ar", 0, "zero"], + ["ar", 1, "one"], + ["ar", 2, "two"], + ["ar", 3, "few"], + ["ar", 11, "many"], + ["ar", 100, "other"], + ["ja", 1, "other"], + ] as const)("selects %s for %d as %s", (locale, count, expected) => { + expect(pluralCategory(locale, count)).toBe(expected) + }) +}) diff --git a/packages/ui/src/context/i18n.tsx b/packages/ui/src/context/i18n.tsx new file mode 100644 index 0000000000000000000000000000000000000000..be7a4d4f1e958f27f46cc0c6f922b2c750d7e91d --- /dev/null +++ b/packages/ui/src/context/i18n.tsx @@ -0,0 +1,74 @@ +import { createContext, useContext, type Accessor, type ParentProps } from "solid-js" +import { I18nProvider } from "@kobalte/core/i18n" +import { dict as en } from "../i18n/en" + +export type UiI18nKey = keyof typeof en + +export const UI_PLURAL_KEYS = [ + "ui.sessionTurn.diffs.changed", + "ui.messagePart.context.read", + "ui.messagePart.context.search", + "ui.messagePart.context.list", +] as const +export type UiI18nPluralKey = (typeof UI_PLURAL_KEYS)[number] +export type UiPluralCategory = "zero" | "one" | "two" | "few" | "many" | "other" +export type UiI18nPluralLookupKey = `${UiI18nPluralKey}.${UiPluralCategory}` + +export type UiI18nParams = Record + +export type UiI18n = { + locale: Accessor + layoutLocale?: Accessor + t: (key: UiI18nKey, params?: UiI18nParams) => string + plural: (key: UiI18nPluralKey, count: number, params?: UiI18nParams) => string +} + +const rules = new Map() + +export function pluralCategory(locale: string, count: number): UiPluralCategory { + const cached = rules.get(locale) + if (cached) return cached.select(count) + const next = new Intl.PluralRules(locale) + if (rules.size >= 32) rules.delete(rules.keys().next().value!) + rules.set(locale, next) + return next.select(count) +} + +export function pluralKey(key: UiI18nPluralKey, category: UiPluralCategory) { + return `${key}.${category}` as UiI18nPluralLookupKey +} + +function resolveTemplate(text: string, params?: UiI18nParams) { + if (!params) return text + return text.replace(/{{\s*([^}]+?)\s*}}/g, (_, rawKey) => { + const key = String(rawKey) + const value = params[key] + return value === undefined ? "" : String(value) + }) +} + +const fallback: UiI18n = { + locale: () => "en", + t: (key, params) => { + const value = en[key] ?? String(key) + return resolveTemplate(value, params) + }, + plural: (key, count, params) => + fallback.t(pluralKey(key, pluralCategory(fallback.locale(), count)), { ...params, count }), +} + +const Context = createContext(fallback) + +function UiI18nProvider(props: ParentProps<{ value: UiI18n }>) { + return ( + + {props.children} + + ) +} + +export { UiI18nProvider as I18nProvider } + +export function useI18n() { + return useContext(Context) +} diff --git a/packages/ui/src/context/index.ts b/packages/ui/src/context/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..7cbf02c4ee001d8a4f480e721282fd47757441f3 --- /dev/null +++ b/packages/ui/src/context/index.ts @@ -0,0 +1,4 @@ +export * from "./helper" +export * from "./file" +export * from "./dialog" +export * from "./i18n" diff --git a/packages/ui/src/context/marked-parser.test.ts b/packages/ui/src/context/marked-parser.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..3c79e0afa7128ab644ee14e2a9386f41bcb1dc7e --- /dev/null +++ b/packages/ui/src/context/marked-parser.test.ts @@ -0,0 +1,19 @@ +import { expect, test } from "bun:test" +import { createMarkdownParser } from "./marked-parser" + +const parser = createMarkdownParser((code, language) => `
${code}
`) + +test("renders links with application attributes", async () => { + expect(await parser.parse("[OpenCode](https://opencode.ai)")).toBe( + '

OpenCode

\n', + ) +}) + +test("renders inline and block math", async () => { + expect(await parser.parse("\\(x^2\\)")).toContain('') + expect(await parser.parse("$$\nx^2\n$$\n")).toContain('') +}) + +test("uses the configured code highlighter", async () => { + expect(await parser.parse("```ts\nconst value = 1\n```\n")).toBe('
const value = 1
\n') +}) diff --git a/packages/ui/src/context/marked-parser.tsx b/packages/ui/src/context/marked-parser.tsx new file mode 100644 index 0000000000000000000000000000000000000000..71e48351ca53f4212be94a7599fed41989e708b3 --- /dev/null +++ b/packages/ui/src/context/marked-parser.tsx @@ -0,0 +1,68 @@ +import katex from "katex" +import { Marked, type MarkedExtension, type Tokens } from "marked" +import markedShiki from "marked-shiki" + +export function createMarkdownParser(highlight: (code: string, language: string) => string | Promise) { + return new Marked( + { + renderer: { + link({ href, title, text }) { + const titleAttr = title ? ` title="${title}"` : "" + return `${text}` + }, + }, + }, + katexExtension, + markedShiki({ highlight }), + ) +} + +const inlineMathRegex = /^\\\(((?:\\.|[^\\\n])*?)\\\)/ +const blockMathRegex = /^\$\$\n([\s\S]+?)\n\$\$(?:\n|$)/ + +const katexExtension: MarkedExtension = { + extensions: [ + { + name: "inlineKatex", + level: "inline", + start(src) { + const index = src.indexOf("\\(") + if (index === -1) return + return index + }, + tokenizer(src) { + const match = src.match(inlineMathRegex) + if (!match) return + return { + type: "inlineKatex", + raw: match[0], + text: match[1].trim(), + displayMode: false, + } + }, + renderer: renderKatexToken, + }, + { + name: "blockKatex", + level: "block", + tokenizer(src) { + const match = src.match(blockMathRegex) + if (!match) return + return { + type: "blockKatex", + raw: match[0], + text: match[1].trim(), + displayMode: true, + } + }, + renderer: renderKatexToken, + }, + ], +} + +function renderKatexToken(token: Tokens.Generic) { + return katex.renderToString(typeof token.text === "string" ? token.text : "", { + displayMode: token.displayMode === true, + throwOnError: false, + }) +} diff --git a/packages/ui/src/context/marked-regression.test.ts b/packages/ui/src/context/marked-regression.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..8861b930a073cc2e0b6b4aabf3aa23d2870a1ee7 --- /dev/null +++ b/packages/ui/src/context/marked-regression.test.ts @@ -0,0 +1,12 @@ +import { expect, test } from "bun:test" +import { Marked } from "marked" + +test("preserves code spans adjacent to tildes", async () => { + const marked = new Marked() + + expect(await marked.parse("~`0.1576` to measurement-window-only `0.00092`")).toBe( + "

~0.1576 to measurement-window-only 0.00092

\n", + ) + expect(await marked.parse("`before`~`after`")).toBe("

before~after

\n") + expect(await marked.parse("~~`deleted code`~~")).toBe("

deleted code

\n") +}) diff --git a/packages/ui/src/context/marked-theme-register.tsx b/packages/ui/src/context/marked-theme-register.tsx new file mode 100644 index 0000000000000000000000000000000000000000..135381eab8d2d0511f1ec439fbafb841d40a24a3 --- /dev/null +++ b/packages/ui/src/context/marked-theme-register.tsx @@ -0,0 +1,10 @@ +import { registerCustomTheme } from "@pierre/diffs" +import { OpenCodeTheme } from "./marked-theme" + +let registered = false + +export function registerOpenCodeTheme() { + if (registered) return + registered = true + registerCustomTheme("OpenCode", () => Promise.resolve(OpenCodeTheme)) +} diff --git a/packages/ui/src/context/marked-theme.tsx b/packages/ui/src/context/marked-theme.tsx new file mode 100644 index 0000000000000000000000000000000000000000..527a35e678f0648d040761153816953fa202f70f --- /dev/null +++ b/packages/ui/src/context/marked-theme.tsx @@ -0,0 +1,372 @@ +import type { ThemeRegistrationResolved } from "@pierre/diffs" + +export const OpenCodeTheme = { + name: "OpenCode", + bg: "var(--color-background-stronger)", + fg: "var(--text-base)", + colors: { + "editor.background": "var(--color-background-stronger)", + "editor.foreground": "var(--text-base)", + "gitDecoration.addedResourceForeground": "var(--syntax-diff-add)", + "gitDecoration.deletedResourceForeground": "var(--syntax-diff-delete)", + "gitDecoration.modifiedResourceForeground": "var(--syntax-diff-unknown)", + // "gitDecoration.conflictingResourceForeground": "#ffca00", + // "gitDecoration.modifiedResourceForeground": "#1a76d4", + // "gitDecoration.untrackedResourceForeground": "#00cab1", + // "gitDecoration.ignoredResourceForeground": "#84848A", + // "terminal.titleForeground": "#adadb1", + // "terminal.titleInactiveForeground": "#84848A", + // "terminal.background": "#141415", + // "terminal.foreground": "#adadb1", + // "terminal.ansiBlack": "#141415", + // "terminal.ansiRed": "#ff2e3f", + // "terminal.ansiGreen": "#0dbe4e", + // "terminal.ansiYellow": "#ffca00", + // "terminal.ansiBlue": "#008cff", + // "terminal.ansiMagenta": "#c635e4", + // "terminal.ansiCyan": "#08c0ef", + // "terminal.ansiWhite": "#c6c6c8", + // "terminal.ansiBrightBlack": "#141415", + // "terminal.ansiBrightRed": "#ff2e3f", + // "terminal.ansiBrightGreen": "#0dbe4e", + // "terminal.ansiBrightYellow": "#ffca00", + // "terminal.ansiBrightBlue": "#008cff", + // "terminal.ansiBrightMagenta": "#c635e4", + // "terminal.ansiBrightCyan": "#08c0ef", + // "terminal.ansiBrightWhite": "#c6c6c8", + }, + tokenColors: [ + { + scope: ["comment", "punctuation.definition.comment", "string.comment"], + settings: { + foreground: "var(--syntax-comment)", + }, + }, + { + scope: ["entity.other.attribute-name"], + settings: { + foreground: "var(--syntax-property)", // maybe attribute + }, + }, + { + scope: ["constant", "entity.name.constant", "variable.other.constant", "variable.language", "entity"], + settings: { + foreground: "var(--syntax-constant)", + }, + }, + { + scope: ["entity.name", "meta.export.default", "meta.definition.variable"], + settings: { + foreground: "var(--syntax-type)", + }, + }, + { + scope: ["meta.object.member"], + settings: { + foreground: "var(--syntax-primitive)", + }, + }, + { + scope: [ + "variable.parameter.function", + "meta.jsx.children", + "meta.block", + "meta.tag.attributes", + "entity.name.constant", + "meta.embedded.expression", + "meta.template.expression", + "string.other.begin.yaml", + "string.other.end.yaml", + ], + settings: { + foreground: "var(--syntax-punctuation)", + }, + }, + { + scope: ["entity.name.function", "support.type.primitive"], + settings: { + foreground: "var(--syntax-primitive)", + }, + }, + { + scope: ["support.class.component"], + settings: { + foreground: "var(--syntax-type)", + }, + }, + { + scope: "keyword", + settings: { + foreground: "var(--syntax-keyword)", + }, + }, + { + scope: [ + "keyword.operator", + "storage.type.function.arrow", + "punctuation.separator.key-value.css", + "entity.name.tag.yaml", + "punctuation.separator.key-value.mapping.yaml", + ], + settings: { + foreground: "var(--syntax-operator)", + }, + }, + { + scope: ["storage", "storage.type"], + settings: { + foreground: "var(--syntax-keyword)", + }, + }, + { + scope: ["storage.modifier.package", "storage.modifier.import", "storage.type.java"], + settings: { + foreground: "var(--syntax-primitive)", + }, + }, + { + scope: [ + "string", + "punctuation.definition.string", + "string punctuation.section.embedded source", + "entity.name.tag", + ], + settings: { + foreground: "var(--syntax-string)", + }, + }, + { + scope: "support", + settings: { + foreground: "var(--syntax-primitive)", + }, + }, + { + scope: ["support.type.object.module", "variable.other.object", "support.type.property-name.css"], + settings: { + foreground: "var(--syntax-object)", + }, + }, + { + scope: "meta.property-name", + settings: { + foreground: "var(--syntax-property)", + }, + }, + { + scope: "variable", + settings: { + foreground: "var(--syntax-variable)", + }, + }, + { + scope: "variable.other", + settings: { + foreground: "var(--syntax-variable)", + }, + }, + { + scope: [ + "invalid.broken", + "invalid.illegal", + "invalid.unimplemented", + "invalid.deprecated", + "message.error", + "markup.deleted", + "meta.diff.header.from-file", + "punctuation.definition.deleted", + "brackethighlighter.unmatched", + "token.error-token", + ], + settings: { + foreground: "var(--syntax-critical)", + }, + }, + { + scope: "carriage-return", + settings: { + foreground: "var(--syntax-keyword)", + }, + }, + { + scope: "string source", + settings: { + foreground: "var(--syntax-variable)", + }, + }, + { + scope: "string variable", + settings: { + foreground: "var(--syntax-constant)", + }, + }, + { + scope: [ + "source.regexp", + "string.regexp", + "string.regexp.character-class", + "string.regexp constant.character.escape", + "string.regexp source.ruby.embedded", + "string.regexp string.regexp.arbitrary-repitition", + "string.regexp constant.character.escape", + ], + settings: { + foreground: "var(--syntax-regexp)", + }, + }, + { + scope: "support.constant", + settings: { + foreground: "var(--syntax-primitive)", + }, + }, + { + scope: "support.variable", + settings: { + foreground: "var(--syntax-variable)", + }, + }, + { + scope: "meta.module-reference", + settings: { + foreground: "var(--syntax-info)", + }, + }, + { + scope: "punctuation.definition.list.begin.markdown", + settings: { + foreground: "var(--syntax-punctuation)", + }, + }, + { + scope: ["markup.heading", "markup.heading entity.name"], + settings: { + fontStyle: "bold", + foreground: "var(--syntax-info)", + }, + }, + { + scope: "markup.quote", + settings: { + foreground: "var(--syntax-info)", + }, + }, + { + scope: "markup.italic", + settings: { + fontStyle: "italic", + // foreground: "", + }, + }, + { + scope: "markup.bold", + settings: { + fontStyle: "bold", + foreground: "var(--text-strong)", + }, + }, + { + scope: [ + "markup.raw", + "markup.inserted", + "meta.diff.header.to-file", + "punctuation.definition.inserted", + "markup.changed", + "punctuation.definition.changed", + "markup.ignored", + "markup.untracked", + ], + settings: { + foreground: "var(--text-base)", + }, + }, + { + scope: "meta.diff.range", + settings: { + fontStyle: "bold", + foreground: "var(--syntax-unknown)", + }, + }, + { + scope: "meta.diff.header", + settings: { + foreground: "var(--syntax-unknown)", + }, + }, + { + scope: "meta.separator", + settings: { + fontStyle: "bold", + foreground: "var(--syntax-unknown)", + }, + }, + { + scope: "meta.output", + settings: { + foreground: "var(--syntax-unknown)", + }, + }, + { + scope: "meta.export.default", + settings: { + foreground: "var(--syntax-unknown)", + }, + }, + { + scope: [ + "brackethighlighter.tag", + "brackethighlighter.curly", + "brackethighlighter.round", + "brackethighlighter.square", + "brackethighlighter.angle", + "brackethighlighter.quote", + ], + settings: { + foreground: "var(--syntax-unknown)", + }, + }, + { + scope: ["constant.other.reference.link", "string.other.link"], + settings: { + fontStyle: "underline", + foreground: "var(--syntax-unknown)", + }, + }, + { + scope: "token.info-token", + settings: { + foreground: "var(--syntax-info)", + }, + }, + { + scope: "token.warn-token", + settings: { + foreground: "var(--syntax-warning)", + }, + }, + { + scope: "token.debug-token", + settings: { + foreground: "var(--syntax-info)", + }, + }, + ], + semanticTokenColors: { + comment: "var(--syntax-comment)", + string: "var(--syntax-string)", + number: "var(--syntax-constant)", + regexp: "var(--syntax-regexp)", + keyword: "var(--syntax-keyword)", + variable: "var(--syntax-variable)", + parameter: "var(--syntax-variable)", + property: "var(--syntax-property)", + function: "var(--syntax-primitive)", + method: "var(--syntax-primitive)", + type: "var(--syntax-type)", + class: "var(--syntax-type)", + namespace: "var(--syntax-type)", + enumMember: "var(--syntax-primitive)", + "variable.constant": "var(--syntax-constant)", + "variable.defaultLibrary": "var(--syntax-unknown)", + }, +} as unknown as ThemeRegistrationResolved diff --git a/packages/ui/src/context/marked.tsx b/packages/ui/src/context/marked.tsx new file mode 100644 index 0000000000000000000000000000000000000000..155682f718ae11069568dadf10d8e7d75d9cff93 --- /dev/null +++ b/packages/ui/src/context/marked.tsx @@ -0,0 +1,28 @@ +import { getSharedHighlighter } from "@pierre/diffs" +import { bundledLanguages, type BundledLanguage } from "shiki" +import { createSimpleContext } from "./helper" +import { createMarkdownParser } from "./marked-parser" +import { registerOpenCodeTheme } from "./marked-theme-register" + +export { OpenCodeTheme } from "./marked-theme" + +registerOpenCodeTheme() + +export const { use: useMarked, provider: MarkedProvider } = createSimpleContext({ + name: "Marked", + init: () => + createMarkdownParser(async (code, language) => { + const highlighter = await getSharedHighlighter({ + themes: ["OpenCode"], + langs: [], + preferredHighlighter: "shiki-wasm", + }) + const name = language in bundledLanguages ? language : "text" + if (!highlighter.getLoadedLanguages().includes(name)) await highlighter.loadLanguage(name as BundledLanguage) + return highlighter.codeToHtml(code, { + lang: name, + theme: "OpenCode", + tabindex: false, + }) + }), +}) diff --git a/packages/ui/src/context/worker-pool.tsx b/packages/ui/src/context/worker-pool.tsx new file mode 100644 index 0000000000000000000000000000000000000000..5f788f7866abfd25e701e37ea377d416f87a4e4f --- /dev/null +++ b/packages/ui/src/context/worker-pool.tsx @@ -0,0 +1,20 @@ +import type { WorkerPoolManager } from "@pierre/diffs/worker" +import { createSimpleContext } from "./helper" + +export type WorkerPools = { + unified: WorkerPoolManager | undefined + split: WorkerPoolManager | undefined +} + +const ctx = createSimpleContext({ + name: "WorkerPool", + init: (props) => props.pools, +}) + +export const WorkerPoolProvider = ctx.provider + +export function useWorkerPool(diffStyle: "unified" | "split" | undefined) { + const pools = ctx.use() + if (diffStyle === "split") return pools.split + return pools.unified +} diff --git a/packages/ui/src/custom-elements.d.ts b/packages/ui/src/custom-elements.d.ts new file mode 100644 index 0000000000000000000000000000000000000000..49ec4449fa209d7b5e60d8d4b735f9350dfc066f --- /dev/null +++ b/packages/ui/src/custom-elements.d.ts @@ -0,0 +1,17 @@ +import { DIFFS_TAG_NAME } from "@pierre/diffs" + +/** + * TypeScript declaration for the custom element. + * This tells TypeScript that is a valid JSX element in SolidJS. + * Required for using the @pierre/diffs web component in .tsx files. + */ + +declare module "solid-js" { + namespace JSX { + interface IntrinsicElements { + [DIFFS_TAG_NAME]: HTMLAttributes + } + } +} + +export {} diff --git a/packages/ui/src/storybook/fixtures.ts b/packages/ui/src/storybook/fixtures.ts new file mode 100644 index 0000000000000000000000000000000000000000..59d4129709a78c1ae140a2a47c09b7cc718face8 --- /dev/null +++ b/packages/ui/src/storybook/fixtures.ts @@ -0,0 +1,51 @@ +export const diff = { + before: { + name: "src/greet.ts", + contents: `export function greet(name: string) { + return \`Hello, \${name}!\` +} +`, + }, + after: { + name: "src/greet.ts", + contents: `export function greet(name: string, excited = false) { + const message = \`Hello, \${name}!\` + return excited ? \`\${message}!!\` : message +} +`, + }, +} + +export const code = { + name: "src/calc.ts", + contents: `export function sum(values: number[]) { + return values.reduce((total, value) => total + value, 0) +} + +export function average(values: number[]) { + if (values.length === 0) return 0 + return sum(values) / values.length +} +`, +} + +export const markdown = [ + "# Markdown", + "", + "Use **Markdown** for rich text.", + "", + "## Highlights", + "- Headings, lists, and code blocks", + "- Inline `code` and links", + "", + "```ts", + "export const value = 42", + "```", + "", + "More at https://example.com/docs", +].join("\n") + +export const changes = { + additions: 18, + deletions: 6, +} diff --git a/packages/ui/src/storybook/scaffold.tsx b/packages/ui/src/storybook/scaffold.tsx new file mode 100644 index 0000000000000000000000000000000000000000..2512aa09be560d6fd65dac319fc61f7dae714c54 --- /dev/null +++ b/packages/ui/src/storybook/scaffold.tsx @@ -0,0 +1,62 @@ +import { ErrorBoundary, type ValidComponent } from "solid-js" +import { Dynamic } from "solid-js/web" + +function fn(value: unknown): value is (...args: never[]) => unknown { + return typeof value === "function" +} + +function pick(mod: Record, name?: string) { + if (name && fn(mod[name])) return mod[name] + if (fn(mod.default)) return mod.default + + const preferred = Object.keys(mod) + .filter((k) => k[0] && k[0] === k[0].toUpperCase()) + .find((k) => fn(mod[k])) + if (preferred) return mod[preferred] + + const first = Object.keys(mod).find((k) => fn(mod[k])) + if (first) return mod[first] + + return () => { + return ( +
+
Missing component export.
+
Exports: {Object.keys(mod).join(", ") || "(none)"}
+
+ ) + } +} + +export function create(input: { + title: string + mod: Record + name?: string + args?: Record +}) { + const component = pick(input.mod, input.name) as unknown as ValidComponent + + return { + meta: { + title: input.title, + component, + }, + Basic: { + args: input.args ?? {}, + render: (args: Record) => { + return ( + { + return ( +
+                  {String(err)}
+                
+ ) + }} + > + +
+ ) + }, + }, + } +} diff --git a/packages/ui/src/styles/animations.css b/packages/ui/src/styles/animations.css new file mode 100644 index 0000000000000000000000000000000000000000..f9a09df379e144d030a601562f9ce8622673010a --- /dev/null +++ b/packages/ui/src/styles/animations.css @@ -0,0 +1,141 @@ +:root { + --animate-pulse: pulse-opacity 2s ease-in-out infinite; + --animate-pulse-scale: pulse-scale 1.2s ease-in-out infinite; +} + +@keyframes pulse-opacity { + 0%, + 100% { + opacity: 0.4; + } + 50% { + opacity: 1; + } +} + +@keyframes pulse-scale { + 0%, + 100% { + transform: scale(1); + } + 50% { + transform: scale(0.6666667); + } +} + +@keyframes pulse-opacity-dim { + 0%, + 100% { + opacity: 0.15; + } + 50% { + opacity: 0.35; + } +} + +@keyframes fadeUp { + from { + opacity: 0; + transform: translateY(5px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.fade-up-text { + animation: fadeUp 0.4s ease-out forwards; + opacity: 0; + + &:nth-child(1) { + animation-delay: 0.1s; + } + &:nth-child(2) { + animation-delay: 0.2s; + } + &:nth-child(3) { + animation-delay: 0.3s; + } + &:nth-child(4) { + animation-delay: 0.4s; + } + &:nth-child(5) { + animation-delay: 0.5s; + } + &:nth-child(6) { + animation-delay: 0.6s; + } + &:nth-child(7) { + animation-delay: 0.7s; + } + &:nth-child(8) { + animation-delay: 0.8s; + } + &:nth-child(9) { + animation-delay: 0.9s; + } + &:nth-child(10) { + animation-delay: 1s; + } + &:nth-child(11) { + animation-delay: 1.1s; + } + &:nth-child(12) { + animation-delay: 1.2s; + } + &:nth-child(13) { + animation-delay: 1.3s; + } + &:nth-child(14) { + animation-delay: 1.4s; + } + &:nth-child(15) { + animation-delay: 1.5s; + } + &:nth-child(16) { + animation-delay: 1.6s; + } + &:nth-child(17) { + animation-delay: 1.7s; + } + &:nth-child(18) { + animation-delay: 1.8s; + } + &:nth-child(19) { + animation-delay: 1.9s; + } + &:nth-child(20) { + animation-delay: 2s; + } + &:nth-child(21) { + animation-delay: 2.1s; + } + &:nth-child(22) { + animation-delay: 2.2s; + } + &:nth-child(23) { + animation-delay: 2.3s; + } + &:nth-child(24) { + animation-delay: 2.4s; + } + &:nth-child(25) { + animation-delay: 2.5s; + } + &:nth-child(26) { + animation-delay: 2.6s; + } + &:nth-child(27) { + animation-delay: 2.7s; + } + &:nth-child(28) { + animation-delay: 2.8s; + } + &:nth-child(29) { + animation-delay: 2.9s; + } + &:nth-child(30) { + animation-delay: 3s; + } +} diff --git a/packages/ui/src/styles/base.css b/packages/ui/src/styles/base.css new file mode 100644 index 0000000000000000000000000000000000000000..a032f9ea2db43b7e7b65f3d587085b8bf770d3fc --- /dev/null +++ b/packages/ui/src/styles/base.css @@ -0,0 +1,404 @@ +/* + 1. Prevent padding and border from affecting element width. (https://github.com/mozdevs/cssremedy/issues/4) + 2. Remove default margins and padding + 3. Reset all borders. +*/ + +*, +::after, +::before, +::backdrop, +::file-selector-button { + box-sizing: border-box; /* 1 */ + margin: 0; /* 2 */ + padding: 0; /* 2 */ + border: 0 solid; /* 3 */ +} + +/* + 1. Use a consistent sensible line-height in all browsers. + 2. Prevent adjustments of font size after orientation changes in iOS. + 3. Use a more readable tab size. + 4. Use the user's configured `sans` font-family by default. + 5. Use the user's configured `sans` font-feature-settings by default. + 6. Use the user's configured `sans` font-variation-settings by default. + 7. Disable tap highlights on iOS. +*/ + +html, +:host { + line-height: var(--line-height-large); /* 1 */ + -webkit-text-size-adjust: 100%; /* 2 */ + tab-size: 4; /* 3 */ + font-family: var(--font-family-sans); /* 4 */ + font-feature-settings: var(--font-family-sans--font-feature-settings, normal); /* 5 */ + font-variation-settings: var(--font-family-sans--font-variation-settings, normal); /* 6 */ + -webkit-tap-highlight-color: transparent; /* 7 */ +} + +/* + 1. Add the correct height in Firefox. + 2. Correct the inheritance of border color in Firefox. (https://bugzilla.mozilla.org/show_bug.cgi?id=190655) + 3. Reset the default border style to a 1px solid border. +*/ + +hr { + height: 0; /* 1 */ + color: inherit; /* 2 */ + border-top-width: 1px; /* 3 */ +} + +/* + Add the correct text decoration in Chrome, Edge, and Safari. +*/ + +abbr:where([title]) { + -webkit-text-decoration: underline dotted; + text-decoration: underline dotted; +} + +/* + Remove the default font size and weight for headings. +*/ + +h1, +h2, +h3, +h4, +h5, +h6 { + font-size: inherit; + font-weight: inherit; +} + +/* + Reset links to optimize for opt-in styling instead of opt-out. +*/ + +a { + color: inherit; + -webkit-text-decoration: inherit; + text-decoration: inherit; + cursor: default; +} + +#root:not([aria-hidden]) *[data-tauri-drag-region] { + app-region: drag; +} + +*[data-tauri-drag-region] button, +*[data-tauri-drag-region] a, +*[data-tauri-drag-region] input, +*[data-tauri-drag-region] textarea, +*[data-tauri-drag-region] select, +*[data-tauri-drag-region] [role="button"], +*[data-tauri-drag-region] [role="menuitem"], +*[data-tauri-drag-region] [contenteditable] { + app-region: no-drag; +} + +/* + Add the correct font weight in Edge and Safari. +*/ + +b, +strong { + font-weight: bolder; +} + +/* + 1. Use the user's configured `mono` font-family by default. + 2. Use the user's configured `mono` font-feature-settings by default. + 3. Use the user's configured `mono` font-variation-settings by default. + 4. Correct the odd `em` font sizing in all browsers. +*/ + +code, +kbd, +samp, +pre { + font-family: var(--font-family-mono); /* 1 */ + font-feature-settings: var(--font-family-mono--font-feature-settings, normal); /* 2 */ + font-variation-settings: var(--font-family-mono--font-variation-settings, normal); /* 3 */ + font-size: 1em; /* 4 */ +} + +/* + Add the correct font size in all browsers. +*/ + +small { + font-size: 80%; +} + +/* + Prevent `sub` and `sup` elements from affecting the line height in all browsers. +*/ + +sub, +sup { + font-size: 75%; + line-height: 0; + position: relative; + vertical-align: baseline; +} + +sub { + bottom: -0.25em; +} + +sup { + top: -0.5em; +} + +/* + 1. Remove text indentation from table contents in Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=999088, https://bugs.webkit.org/show_bug.cgi?id=201297) + 2. Correct table border color inheritance in all Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=935729, https://bugs.webkit.org/show_bug.cgi?id=195016) + 3. Remove gaps between table borders by default. +*/ + +table { + text-indent: 0; /* 1 */ + border-color: inherit; /* 2 */ + border-collapse: collapse; /* 3 */ +} + +/* + Use the modern Firefox focus style for all focusable elements. +*/ + +:-moz-focusring { + outline: auto; +} + +/* + Add the correct vertical alignment in Chrome and Firefox. +*/ + +progress { + vertical-align: baseline; +} + +/* + Add the correct display in Chrome and Safari. +*/ + +summary { + display: list-item; +} + +/* + Make lists unstyled by default. +*/ + +ol, +ul, +menu { + list-style: none; +} + +/* + 1. Make replaced elements `display: block` by default. (https://github.com/mozdevs/cssremedy/issues/14) + 2. Add `vertical-align: middle` to align replaced elements more sensibly by default. (https://github.com/jensimmons/cssremedy/issues/14#issuecomment-634934210) + This can trigger a poorly considered lint error in some tools but is included by design. +*/ + +img, +svg, +video, +canvas, +audio, +iframe, +embed, +object { + display: block; /* 1 */ + vertical-align: middle; /* 2 */ +} + +/* + Constrain images and videos to the parent width and preserve their intrinsic aspect ratio. (https://github.com/mozdevs/cssremedy/issues/14) +*/ + +img, +video { + max-width: 100%; + height: auto; +} + +/* + 1. Inherit font styles in all browsers. + 2. Remove border radius in all browsers. + 3. Remove background color in all browsers. + 4. Ensure consistent opacity for disabled states in all browsers. +*/ + +button, +input, +select, +optgroup, +textarea, +::file-selector-button { + font: inherit; /* 1 */ + font-feature-settings: inherit; /* 1 */ + font-variation-settings: inherit; /* 1 */ + letter-spacing: inherit; /* 1 */ + color: inherit; /* 1 */ + border-radius: 0; /* 2 */ + background-color: transparent; /* 3 */ + opacity: 1; /* 4 */ +} + +/* + Restore default font weight. +*/ + +:where(select:is([multiple], [size])) optgroup { + font-weight: bolder; +} + +/* + Restore indentation. +*/ + +:where(select:is([multiple], [size])) optgroup option { + padding-inline-start: 20px; +} + +/* + Restore space after button. +*/ + +::file-selector-button { + margin-inline-end: 4px; +} + +/* + Reset the default placeholder opacity in Firefox. (https://github.com/tailwindlabs/tailwindcss/issues/3300) +*/ + +::placeholder { + opacity: 1; +} + +/* + Set the default placeholder color to a semi-transparent version of the current text color in browsers that do not + crash when using `color-mix(…)` with `currentcolor`. (https://github.com/tailwindlabs/tailwindcss/issues/17194) +*/ + +@supports (not (-webkit-appearance: -apple-pay-button)) /* Not Safari */ or (contain-intrinsic-size: 1px) + /* Safari 17+ */ { + ::placeholder { + color: color-mix(in oklab, currentcolor 50%, transparent); + } +} + +/* + Prevent resizing textareas horizontally by default. +*/ + +textarea { + resize: vertical; +} + +/* + Remove the inner padding in Chrome and Safari on macOS. +*/ + +::-webkit-search-decoration { + -webkit-appearance: none; +} + +/* + 1. Ensure date/time inputs have the same height when empty in iOS Safari. + 2. Ensure text alignment can be changed on date/time inputs in iOS Safari. +*/ + +::-webkit-date-and-time-value { + min-height: 1lh; /* 1 */ + text-align: inherit; /* 2 */ +} + +/* + Prevent height from changing on date/time inputs in macOS Safari when the input is set to `display: block`. +*/ + +::-webkit-datetime-edit { + display: inline-flex; +} + +/* + Remove excess padding from pseudo-elements in date/time inputs to ensure consistent height across browsers. +*/ + +::-webkit-datetime-edit-fields-wrapper { + padding: 0; +} + +::-webkit-datetime-edit, +::-webkit-datetime-edit-year-field, +::-webkit-datetime-edit-month-field, +::-webkit-datetime-edit-day-field, +::-webkit-datetime-edit-hour-field, +::-webkit-datetime-edit-minute-field, +::-webkit-datetime-edit-second-field, +::-webkit-datetime-edit-millisecond-field, +::-webkit-datetime-edit-meridiem-field { + padding-block: 0; +} + +/* + Center dropdown marker shown on inputs with paired ``s in Chrome. (https://github.com/tailwindlabs/tailwindcss/issues/18499) +*/ + +::-webkit-calendar-picker-indicator { + line-height: 1; +} + +/* + Remove the additional `:invalid` styles in Firefox. (https://github.com/mozilla/gecko-dev/blob/2f9eacd9d3d995c937b4251a5557d95d494c9be1/layout/style/res/forms.css#L728-L737) +*/ + +:-moz-ui-invalid { + box-shadow: none; +} + +/* + Correct the inability to style the border radius in iOS Safari. +*/ + +button, +input:where([type="button"], [type="reset"], [type="submit"]), +::file-selector-button { + appearance: button; +} + +/* + Correct the cursor style of increment and decrement buttons in Safari. +*/ + +::-webkit-inner-spin-button, +::-webkit-outer-spin-button { + height: auto; +} + +/* + Make elements with the HTML hidden attribute stay hidden by default. +*/ + +[hidden]:where(:not([hidden="until-found"])) { + display: none !important; +} + +/* + Prevent iOS Safari from auto-zooming on input focus. + iOS WebKit zooms on any input with font-size < 16px as an accessibility feature. +*/ + +@media (hover: none) and (pointer: coarse) { + input, + select, + textarea, + [contenteditable="true"] { + font-size: 16px !important; + } +} diff --git a/packages/ui/src/styles/colors.css b/packages/ui/src/styles/colors.css new file mode 100644 index 0000000000000000000000000000000000000000..893f252aa99ca1b6c47861a2cb33cc92cff04507 --- /dev/null +++ b/packages/ui/src/styles/colors.css @@ -0,0 +1,772 @@ +:root { + --gray-dark-1: #161616; + --gray-dark-2: #1c1c1c; + --gray-dark-3: #232323; + --gray-dark-4: #282828; + --gray-dark-5: #2e2e2e; + --gray-dark-6: #343434; + --gray-dark-7: #3e3e3e; + --gray-dark-8: #505050; + --gray-dark-9: #707070; + --gray-dark-10: #7e7e7e; + --gray-dark-11: #a0a0a0; + --gray-dark-12: #ededed; + --gray-light-1: #fcfcfc; + --gray-light-2: #f8f8f8; + --gray-light-3: #f3f3f3; + --gray-light-4: #ededed; + --gray-light-5: #e8e8e8; + --gray-light-6: #e2e2e2; + --gray-light-7: #dbdbdb; + --gray-light-8: #c7c7c7; + --gray-light-9: #8f8f8f; + --gray-light-10: #858585; + --gray-light-11: #6f6f6f; + --gray-light-12: #171717; + --gray-dark-alpha-1: #00000000; + --gray-dark-alpha-2: #ffffff08; + --gray-dark-alpha-3: #ffffff0f; + --gray-dark-alpha-4: #ffffff14; + --gray-dark-alpha-5: #ffffff1a; + --gray-dark-alpha-6: #ffffff21; + --gray-dark-alpha-7: #ffffff2b; + --gray-dark-alpha-8: #ffffff40; + --gray-dark-alpha-9: #ffffff63; + --gray-dark-alpha-10: #ffffff73; + --gray-dark-alpha-11: #ffffff96; + --gray-dark-alpha-12: #ffffffeb; + --gray-light-alpha-1: #00000003; + --gray-light-alpha-2: #00000008; + --gray-light-alpha-3: #0000000d; + --gray-light-alpha-4: #00000012; + --gray-light-alpha-5: #00000017; + --gray-light-alpha-6: #0000001c; + --gray-light-alpha-7: #00000024; + --gray-light-alpha-8: #00000038; + --gray-light-alpha-9: #00000070; + --gray-light-alpha-10: #0000007a; + --gray-light-alpha-11: #0000008f; + --gray-light-alpha-12: #000000e8; + --gray-dark-1: #131010; + --gray-dark-2: #1b1818; + --gray-dark-3: #252121; + --gray-dark-4: #2d2828; + --gray-dark-5: #343030; + --gray-dark-6: #3e3939; + --gray-dark-7: #4b4646; + --gray-dark-8: #645f5f; + --gray-dark-9: #716c6b; + --gray-dark-10: #7f7979; + --gray-dark-11: #b7b1b1; + --gray-dark-12: #f1ecec; + --gray-light-1: #fdfcfc; + --gray-light-2: #f9f8f8; + --gray-light-3: #f1f0f0; + --gray-light-4: #e9e8e8; + --gray-light-5: #e2e0e0; + --gray-light-6: #dad9d9; + --gray-light-7: #cfcecd; + --gray-light-8: #bcbbbb; + --gray-light-9: #8e8b8b; + --gray-light-10: #848181; + --gray-light-11: #656363; + --gray-light-12: #211e1e; + --gray-dark-alpha-1: #82383803; + --gray-dark-alpha-2: #e6c6c60b; + --gray-dark-alpha-3: #edd5d516; + --gray-dark-alpha-4: #f2e1e11e; + --gray-dark-alpha-5: #f5e8e826; + --gray-dark-alpha-6: #f5e8e831; + --gray-dark-alpha-7: #f7ecec3f; + --gray-dark-alpha-8: #faf5f559; + --gray-dark-alpha-9: #faf5f467; + --gray-dark-alpha-10: #fbf5f576; + --gray-dark-alpha-11: #fcf9f9b2; + --gray-dark-alpha-12: #fdfbfbf0; + --gray-light-alpha-1: #55000003; + --gray-light-alpha-2: #25000007; + --gray-light-alpha-3: #1100000f; + --gray-light-alpha-4: #0c000017; + --gray-light-alpha-5: #1100001f; + --gray-light-alpha-6: #07000026; + --gray-light-alpha-7: #0b060032; + --gray-light-alpha-8: #04000044; + --gray-light-alpha-9: #07000074; + --gray-light-alpha-10: #0400009c; + --gray-light-alpha-11: #0700007e; + --gray-light-alpha-12: #020000df; + --smoke-dark-1: #131010; + --smoke-dark-2: #1b1818; + --smoke-dark-3: #252121; + --smoke-dark-4: #2d2828; + --smoke-dark-5: #343030; + --smoke-dark-6: #3e3939; + --smoke-dark-7: #4b4646; + --smoke-dark-8: #645f5f; + --smoke-dark-9: #716c6b; + --smoke-dark-10: #7f7979; + --smoke-dark-11: #b7b1b1; + --smoke-dark-12: #f1ecec; + --smoke-light-1: #fdfcfc; + --smoke-light-2: #f9f8f8; + --smoke-light-3: #f1f0f0; + --smoke-light-4: #e9e8e8; + --smoke-light-5: #e2e0e0; + --smoke-light-6: #dad9d9; + --smoke-light-7: #cfcecd; + --smoke-light-8: #bcbbbb; + --smoke-light-9: #8e8b8b; + --smoke-light-10: #848181; + --smoke-light-11: #656363; + --smoke-light-12: #211e1e; + --smoke-dark-alpha-1: #82383803; + --smoke-dark-alpha-2: #e6c6c60b; + --smoke-dark-alpha-3: #edd5d516; + --smoke-dark-alpha-4: #f2e1e11e; + --smoke-dark-alpha-5: #f5e8e826; + --smoke-dark-alpha-6: #f5e8e831; + --smoke-dark-alpha-7: #f7ecec3f; + --smoke-dark-alpha-8: #faf5f559; + --smoke-dark-alpha-9: #faf5f467; + --smoke-dark-alpha-10: #fbf5f576; + --smoke-dark-alpha-11: #fcf9f9b2; + --smoke-dark-alpha-12: #fdfbfbf0; + --smoke-light-alpha-1: #55000003; + --smoke-light-alpha-2: #25000007; + --smoke-light-alpha-3: #1100000f; + --smoke-light-alpha-4: #0c000017; + --smoke-light-alpha-5: #1100001f; + --smoke-light-alpha-6: #07000026; + --smoke-light-alpha-7: #0b060032; + --smoke-light-alpha-8: #04000044; + --smoke-light-alpha-9: #07000074; + --smoke-light-alpha-10: #0400009c; + --smoke-light-alpha-11: #0700007e; + --smoke-light-alpha-12: #020000df; + --yuzu-dark-1: #11120c; + --yuzu-light-1: #fdfdfb; + --yuzu-light-2: #fbfceb; + --yuzu-light-3: #f8fac5; + --yuzu-light-4: #f2f4a5; + --yuzu-light-5: #e9eb9a; + --yuzu-light-6: #dcde8e; + --yuzu-light-7: #cccd7e; + --yuzu-light-8: #b6b768; + --yuzu-light-9: #dcde8d; + --yuzu-light-10: #d2d384; + --yuzu-light-11: #7c7c2c; + --yuzu-light-12: #3d3d23; + --yuzu-dark-2: #181810; + --yuzu-dark-3: #262614; + --yuzu-dark-4: #313115; + --yuzu-dark-5: #3d3d18; + --yuzu-dark-6: #4a4a21; + --yuzu-dark-7: #5a5b2c; + --yuzu-dark-8: #6f6f36; + --yuzu-dark-9: #fdffca; + --yuzu-dark-10: #f4f6c1; + --yuzu-dark-11: #dbdda0; + --yuzu-dark-12: #eff1bd; + --yuzu-dark-alpha-1: #11910002; + --yuzu-dark-alpha-2: #f1f10008; + --yuzu-dark-alpha-3: #fafa3317; + --yuzu-dark-alpha-4: #fbfb2f23; + --yuzu-dark-alpha-5: #fbfb3730; + --yuzu-dark-alpha-6: #fcfc533e; + --yuzu-dark-alpha-7: #fafd6750; + --yuzu-dark-alpha-8: #ffff6f65; + --yuzu-dark-alpha-9: #fdffca; + --yuzu-dark-alpha-10: #fcfec7f6; + --yuzu-dark-alpha-11: #fdffb8db; + --yuzu-dark-alpha-12: #fdffc8f0; + --yuzu-light-alpha-1: #80800004; + --yuzu-light-alpha-2: #ccd90014; + --yuzu-light-alpha-3: #e1ea003a; + --yuzu-light-alpha-4: #dbe0015a; + --yuzu-light-alpha-5: #c8cd0065; + --yuzu-light-alpha-6: #b1b50071; + --yuzu-light-alpha-7: #9b9d0081; + --yuzu-light-alpha-8: #84860097; + --yuzu-light-alpha-9: #b1b60072; + --yuzu-light-alpha-10: #a2a4017b; + --yuzu-light-alpha-11: #616100d3; + --yuzu-light-alpha-12: #1e1e00dc; + --cobalt-dark-1: #091120; + --cobalt-dark-2: #0d172b; + --cobalt-dark-3: #0c2255; + --cobalt-dark-4: #0c2a74; + --cobalt-dark-5: #113489; + --cobalt-dark-6: #18409b; + --cobalt-dark-7: #204cb1; + --cobalt-dark-8: #2558d0; + --cobalt-dark-9: #034cff; + --cobalt-dark-10: #0038ee; + --cobalt-dark-11: #89b5ff; + --cobalt-dark-12: #cde2ff; + --cobalt-light-1: #fcfdff; + --cobalt-light-2: #f5faff; + --cobalt-light-3: #eaf2ff; + --cobalt-light-4: #daeaff; + --cobalt-light-5: #c8e0ff; + --cobalt-light-6: #b4d2ff; + --cobalt-light-7: #98bfff; + --cobalt-dark-alpha-1: #0011f211; + --cobalt-dark-alpha-2: #0048fe1c; + --cobalt-dark-alpha-3: #004dff49; + --cobalt-dark-alpha-4: #064dfd6b; + --cobalt-dark-alpha-5: #1157ff81; + --cobalt-dark-alpha-6: #1e62ff94; + --cobalt-dark-alpha-7: #2768feac; + --cobalt-dark-alpha-8: #2a6affcd; + --cobalt-dark-alpha-9: #034cff; + --cobalt-dark-alpha-10: #003bffed; + --cobalt-dark-alpha-11: #89b5ff; + --cobalt-light-8: #73a4ff; + --cobalt-dark-alpha-12: #cde2ff; + --cobalt-light-9: #034cff; + --cobalt-light-10: #0443de; + --cobalt-light-11: #1251ec; + --cobalt-light-12: #0f2b6c; + --cobalt-light-alpha-1: #0055ff03; + --cobalt-light-alpha-2: #0080ff0a; + --cobalt-light-alpha-3: #0062ff15; + --cobalt-light-alpha-4: #006fff25; + --cobalt-light-alpha-5: #0070ff37; + --cobalt-light-alpha-6: #0167ff4b; + --cobalt-light-alpha-7: #0061ff67; + --cobalt-light-alpha-8: #005aff8c; + --cobalt-light-alpha-9: #004afffc; + --cobalt-light-alpha-10: #0040ddfb; + --cobalt-light-alpha-11: #0044ebed; + --cobalt-light-alpha-12: #001e63f0; + --apple-dark-1: #0c140b; + --apple-light-1: #fafefa; + --apple-light-2: #f4fcf3; + --apple-light-3: #e1fade; + --apple-light-4: #cef6c9; + --apple-light-5: #b9efb3; + --apple-light-6: #9fe598; + --apple-light-7: #7dd676; + --apple-light-8: #43c23b; + --apple-light-9: #12c905; + --apple-light-10: #00bd00; + --apple-light-11: #008600; + --apple-light-12: #184115; + --apple-dark-2: #121b11; + --apple-dark-3: #152d13; + --apple-dark-4: #123d0f; + --apple-dark-5: #174b14; + --apple-dark-6: #1d5b19; + --apple-dark-7: #226c1e; + --apple-dark-8: #267f20; + --apple-dark-9: #12c905; + --apple-dark-10: #17bb0d; + --apple-dark-11: #37db2e; + --apple-dark-12: #aff7a8; + --apple-dark-alpha-1: #00d10004; + --apple-dark-alpha-2: #29f9120b; + --apple-dark-alpha-3: #33ff221e; + --apple-dark-alpha-4: #17fb0730; + --apple-dark-alpha-5: #2afc1e3f; + --apple-dark-alpha-6: #37fd2b50; + --apple-dark-alpha-7: #3efe3362; + --apple-dark-alpha-8: #3fff3276; + --apple-dark-alpha-9: #12fe02c6; + --apple-dark-alpha-10: #19fe0cb7; + --apple-dark-alpha-11: #3dfe33d9; + --apple-dark-alpha-12: #b4feacf7; + --apple-light-alpha-1: #00cc0005; + --apple-light-alpha-2: #16c0000c; + --apple-light-alpha-3: #18d90021; + --apple-light-alpha-4: #18d50036; + --apple-light-alpha-5: #15ca004c; + --apple-light-alpha-6: #12bf0067; + --apple-light-alpha-7: #0db30089; + --apple-light-alpha-8: #0bb000c4; + --apple-light-alpha-9: #0dc800fa; + --apple-light-alpha-10: #00bd00; + --apple-light-alpha-11: #008600; + --apple-light-alpha-12: #033000ea; + --ember-dark-1: #170f0d; + --ember-dark-2: #201412; + --ember-dark-3: #3c140d; + --ember-dark-4: #530e05; + --ember-dark-5: #631409; + --ember-dark-6: #742216; + --ember-dark-7: #8d3324; + --ember-dark-8: #b64330; + --ember-dark-9: #fc533a; + --ember-dark-10: #ee462d; + --ember-dark-11: #ff917b; + --ember-dark-12: #ffd1c8; + --ember-light-1: #fffcfb; + --ember-light-2: #fff6f3; + --ember-light-3: #ffe9e4; + --ember-light-4: #ffd7cc; + --ember-light-5: #ffc8ba; + --ember-light-6: #ffb7a6; + --ember-light-7: #ffa392; + --ember-light-8: #f68975; + --ember-light-9: #fc533a; + --ember-light-10: #ef442a; + --ember-light-11: #da3319; + --ember-light-12: #5c281f; + --ember-dark-alpha-1: #ec000007; + --ember-dark-alpha-2: #f23e2011; + --ember-dark-alpha-3: #fb22002f; + --ember-dark-alpha-4: #ff070047; + --ember-dark-alpha-5: #ff1a0058; + --ember-dark-alpha-6: #fd3a1d6b; + --ember-dark-alpha-7: #ff533685; + --ember-dark-alpha-8: #ff5a3eb1; + --ember-dark-alpha-9: #ff553bfc; + --ember-dark-alpha-10: #ff4a2fed; + --ember-dark-alpha-11: #ff917b; + --ember-dark-alpha-12: #ffd1c8; + --ember-light-alpha-1: #ff400004; + --ember-light-alpha-2: #ff40000c; + --ember-light-alpha-3: #ff30001b; + --ember-light-alpha-4: #ff370033; + --ember-light-alpha-5: #ff340045; + --ember-light-alpha-6: #ff310059; + --ember-light-alpha-7: #ff28006d; + --ember-light-alpha-8: #ef25008a; + --ember-light-alpha-9: #fb2200c5; + --ember-light-alpha-10: #ec1f00d5; + --ember-light-alpha-11: #d61d00e6; + --ember-light-alpha-12: #460a00e0; + --solaris-dark-1: #13110b; + --solaris-dark-2: #1b180f; + --solaris-dark-3: #2a2307; + --solaris-dark-4: #382b00; + --solaris-dark-5: #443500; + --solaris-dark-6: #514307; + --solaris-dark-7: #64551a; + --solaris-dark-8: #7f6c25; + --solaris-dark-9: #fcd53a; + --solaris-dark-10: #f2cb2a; + --solaris-dark-11: #fdd63c; + --solaris-dark-12: #faebb5; + --solaris-light-1: #fefdfa; + --solaris-light-2: #fffbea; + --solaris-light-3: #fff6be; + --solaris-light-4: #ffee9c; + --solaris-light-5: #ffe47c; + --solaris-light-6: #f2d775; + --solaris-light-7: #e0c76f; + --solaris-light-8: #cdb047; + --solaris-light-9: #ffdc17; + --solaris-light-10: #fad337; + --solaris-light-11: #917500; + --solaris-light-12: #433c22; + --solaris-dark-alpha-1: #bb110003; + --solaris-dark-alpha-2: #f9b4000b; + --solaris-dark-alpha-3: #febb001b; + --solaris-dark-alpha-4: #feaf002a; + --solaris-dark-alpha-5: #feb80037; + --solaris-dark-alpha-6: #feca0045; + --solaris-dark-alpha-7: #ffd42b59; + --solaris-dark-alpha-8: #ffd63d76; + --solaris-dark-alpha-9: #ffd83bfc; + --solaris-dark-alpha-10: #fed52bf2; + --solaris-dark-alpha-11: #ffd83cfd; + --solaris-dark-alpha-12: #fff0b9fa; + --solaris-light-alpha-1: #cc990005; + --solaris-light-alpha-2: #ffcf0015; + --solaris-light-alpha-3: #ffdc0041; + --solaris-light-alpha-4: #ffd40063; + --solaris-light-alpha-5: #ffcb0083; + --solaris-light-alpha-6: #e7b6008a; + --solaris-light-alpha-7: #c89c0090; + --solaris-light-alpha-8: #ba9200b8; + --solaris-light-alpha-9: #ffd900e8; + --solaris-light-alpha-10: #f9c700c8; + --solaris-light-alpha-11: #917500; + --solaris-light-alpha-12: #261e00dd; + --lilac-dark-1: #140f14; + --lilac-dark-2: #1d141d; + --lilac-dark-3: #2f1e31; + --lilac-dark-4: #3e2440; + --lilac-dark-5: #4a2c4c; + --lilac-dark-6: #573859; + --lilac-dark-7: #6c486e; + --lilac-dark-8: #8a5e8d; + --lilac-dark-9: #edb2f1; + --lilac-dark-10: #e2a8e6; + --lilac-dark-11: #dca2e0; + --lilac-dark-12: #edd8ef; + --lilac-light-1: #fffcff; + --lilac-light-2: #fdf7fe; + --lilac-light-3: #fceafd; + --lilac-light-4: #faddfb; + --lilac-light-5: #f5cff7; + --lilac-light-6: #eebff1; + --lilac-light-7: #e3a9e7; + --lilac-light-8: #d78bdd; + --lilac-light-9: #a753ae; + --lilac-light-10: #9946a0; + --lilac-light-11: #95429c; + --lilac-light-12: #590b60; + --lilac-dark-alpha-1: #d100d104; + --lilac-dark-alpha-2: #fd4cfd0d; + --lilac-dark-alpha-3: #ec70fb23; + --lilac-dark-alpha-4: #f270fc33; + --lilac-dark-alpha-5: #f57dfd40; + --lilac-dark-alpha-6: #f691fd4e; + --lilac-dark-alpha-7: #fa9eff64; + --lilac-dark-alpha-8: #f9a5ff85; + --lilac-dark-alpha-9: #fbbcfff0; + --lilac-dark-alpha-10: #f9b9fee5; + --lilac-dark-alpha-11: #fab8ffde; + --lilac-dark-alpha-12: #fde6ffee; + --lilac-light-alpha-1: #ff00ff03; + --lilac-light-alpha-2: #c000e008; + --lilac-light-alpha-3: #db00e715; + --lilac-light-alpha-4: #da00e122; + --lilac-light-alpha-5: #ca00d530; + --lilac-light-alpha-6: #bc00c840; + --lilac-light-alpha-7: #ac00b856; + --lilac-light-alpha-8: #a800b574; + --lilac-light-alpha-9: #7d0087ac; + --lilac-light-alpha-10: #73007cb9; + --lilac-light-alpha-11: #70007abd; + --lilac-light-alpha-12: #520059f4; + --coral-dark-1: #160f0e; + --coral-light-1: #fffcfc; + --coral-light-2: #fff8f7; + --coral-light-3: #ffebe8; + --coral-light-4: #ffdbd5; + --coral-light-5: #ffcdc5; + --coral-light-6: #f9beb5; + --coral-light-7: #e9aea6; + --coral-light-8: #d49b93; + --coral-light-9: #af7871; + --coral-light-10: #a26c65; + --coral-light-11: #9c665f; + --coral-light-12: #592a24; + --coral-dark-2: #1f1413; + --coral-dark-3: #391613; + --coral-dark-4: #481b17; + --coral-dark-5: #542621; + --coral-dark-6: #63332d; + --coral-dark-7: #77453f; + --coral-dark-8: #935e57; + --coral-dark-9: #ffd6d0; + --coral-dark-10: #f5ccc6; + --coral-dark-11: #e2a8a0; + --coral-dark-12: #fcd3cd; + --coral-dark-alpha-1: #e6000006; + --coral-dark-alpha-2: #ff44330f; + --coral-dark-alpha-3: #ff2f1d2b; + --coral-dark-alpha-4: #ff3d2b3b; + --coral-dark-alpha-5: #ff5c4a48; + --coral-dark-alpha-6: #ff746358; + --coral-dark-alpha-7: #fd897c6e; + --coral-dark-alpha-8: #fe9d908c; + --coral-dark-alpha-9: #ffd6d0; + --coral-dark-alpha-10: #fed3cdf5; + --coral-dark-alpha-11: #ffbdb4e0; + --coral-dark-alpha-12: #ffd6cffc; + --coral-light-alpha-1: #ff000003; + --coral-light-alpha-2: #ff200008; + --coral-light-alpha-3: #ff220017; + --coral-light-alpha-4: #ff25002a; + --coral-light-alpha-5: #ff24003a; + --coral-light-alpha-6: #eb20014a; + --coral-light-alpha-7: #c0170059; + --coral-light-alpha-8: #9a13006c; + --coral-light-alpha-9: #700d008e; + --coral-light-alpha-10: #650c009a; + --coral-light-alpha-11: #620b00a0; + --coral-light-alpha-12: #3e0700db; + --mint-dark-alpha-1: #00bb0003; + --mint-dark-alpha-2: #2bf72b0a; + --mint-dark-alpha-3: #66fe5d1b; + --mint-dark-alpha-4: #63ff5d2c; + --mint-dark-alpha-5: #6cff643b; + --mint-dark-alpha-6: #71ff6a4b; + --mint-dark-alpha-7: #74fd6f5d; + --mint-dark-alpha-8: #74ff6f72; + --mint-dark-alpha-9: #c8ffc4f5; + --mint-dark-alpha-10: #c6fec2f5; + --mint-dark-alpha-11: #b4ffafdc; + --mint-dark-alpha-12: #c7ffc3fb; + --mint-dark-1: #0d130c; + --mint-dark-2: #121a12; + --mint-dark-3: #1a2a19; + --mint-dark-4: #1f3a1e; + --mint-dark-5: #264824; + --mint-dark-6: #2d572b; + --mint-dark-7: #356733; + --mint-dark-8: #3d7b3b; + --mint-dark-9: #c8ffc4; + --mint-dark-10: #bff5bb; + --mint-dark-11: #9dde99; + --mint-dark-12: #c4fbc0; + --mint-light-1: #fafefa; + --mint-light-2: #f4fcf3; + --mint-light-3: #dbfdd8; + --mint-light-4: #c3fabf; + --mint-light-5: #adf2a8; + --mint-light-6: #96e692; + --mint-light-7: #81d47d; + --mint-light-8: #6abc67; + --mint-light-9: #9ff29a; + --mint-light-10: #98e793; + --mint-light-11: #318430; + --mint-light-12: #1f461d; + --mint-dark-alpha-1: #00bb0003; + --mint-dark-alpha-2: #2bf72b09; + --mint-dark-alpha-3: #66fe5d1b; + --mint-dark-alpha-4: #63ff5d2b; + --mint-dark-alpha-5: #6cff643b; + --mint-dark-alpha-6: #71ff6a4a; + --mint-dark-alpha-7: #74fd6f5c; + --mint-dark-alpha-8: #74ff6f72; + --mint-dark-alpha-9: #c8ffc4f5; + --mint-dark-alpha-10: #c6fec2f5; + --mint-dark-alpha-11: #b4ffafdb; + --mint-dark-alpha-12: #c7ffc3fa; + --black: #000000; + --white: #ffffff; + --mint-light-alpha-1: #00cc0005; + --mint-light-alpha-2: #16c0000c; + --mint-light-alpha-3: #14f20027; + --mint-light-alpha-4: #10ec0040; + --mint-light-alpha-5: #0fd90057; + --mint-light-alpha-6: #0ac5006d; + --mint-light-alpha-7: #08ab0082; + --mint-light-alpha-8: #058f0098; + --mint-light-alpha-9: #0ddf0065; + --mint-light-alpha-10: #0cc7006c; + --mint-light-alpha-11: #016800cf; + --mint-light-alpha-12: #022e00e2; + --blue-dark-1: #0e161f; + --blue-dark-2: #0f1b2d; + --blue-dark-3: #0f233c; + --blue-dark-4: #10294b; + --blue-dark-5: #0e2f57; + --blue-dark-6: #0c3768; + --blue-dark-7: #094280; + --blue-dark-8: #0854a4; + --blue-dark-9: #0091ff; + --blue-dark-10: #389eff; + --blue-dark-11: #51a8ff; + --blue-dark-12: #eaf6ff; + --blue-light-1: #f9fcff; + --blue-light-2: #f5faff; + --blue-light-3: #eaf4ff; + --blue-light-4: #e0efff; + --blue-light-5: #cde6fd; + --blue-light-6: #b9d9f8; + --blue-light-7: #96c7f2; + --blue-light-8: #5cafee; + --blue-light-9: #0091ff; + --blue-light-10: #007fef; + --blue-light-11: #0069db; + --blue-light-12: #00254d; + --blue-dark-alpha-1: #00000000; + --blue-dark-alpha-2: #0c58fc0f; + --blue-dark-alpha-3: #1576fd23; + --blue-dark-alpha-4: #1576fd33; + --blue-dark-alpha-5: #107bfd3f; + --blue-dark-alpha-6: #0a7cff51; + --blue-dark-alpha-7: #057dff70; + --blue-dark-alpha-8: #057dff99; + --blue-dark-alpha-9: #0094fff9; + --blue-dark-alpha-10: #38a2fff9; + --blue-dark-alpha-11: #51abfff9; + --blue-dark-alpha-12: #effbfff9; + --blue-light-alpha-1: #0582ff05; + --blue-light-alpha-2: #0582ff0a; + --blue-light-alpha-3: #007fff11; + --blue-light-alpha-4: #007fff1e; + --blue-light-alpha-5: #017fee30; + --blue-light-alpha-6: #0176e447; + --blue-light-alpha-7: #0077e068; + --blue-light-alpha-8: #0082e5a0; + --blue-light-alpha-9: #0090fff9; + --blue-light-alpha-10: #007feff9; + --blue-light-alpha-11: #0066dbf9; + --blue-light-alpha-12: #002047f9; + --ink-dark-1: #101313; + --ink-dark-2: #181b1b; + --ink-dark-3: #212525; + --ink-dark-4: #282d2d; + --ink-dark-5: #303434; + --ink-dark-6: #393e3e; + --ink-dark-7: #464b4b; + --ink-dark-8: #5f6464; + --ink-dark-9: #6b7171; + --ink-dark-10: #797f7f; + --ink-dark-11: #b1b7b7; + --ink-dark-12: #ecf1f1; + --ink-light-1: #fcfdfd; + --ink-light-2: #f8f9f9; + --ink-light-3: #f0f1f1; + --ink-light-4: #e8e9e9; + --ink-light-5: #e0e2e2; + --ink-light-6: #d9dada; + --ink-light-7: #cdcfcf; + --ink-light-8: #bbbcbc; + --ink-light-9: #8b8e8e; + --ink-light-10: #818484; + --ink-light-11: #636565; + --ink-light-12: #1e2121; + --ink-dark-alpha-1: #38828203; + --ink-dark-alpha-2: #c6e6e60b; + --ink-dark-alpha-3: #d5eded16; + --ink-dark-alpha-4: #e1f2f21e; + --ink-dark-alpha-5: #e8f5f526; + --ink-dark-alpha-6: #e8f5f531; + --ink-dark-alpha-7: #ecf7f73f; + --ink-dark-alpha-8: #f5fafa59; + --ink-dark-alpha-9: #f4fafa67; + --ink-dark-alpha-10: #f5fbfb76; + --ink-dark-alpha-11: #f9fcfcb2; + --ink-dark-alpha-12: #fbfdfdf0; + --ink-light-alpha-1: #00555503; + --ink-light-alpha-2: #00252507; + --ink-light-alpha-3: #0011110f; + --ink-light-alpha-4: #000c0c17; + --ink-light-alpha-5: #0011111f; + --ink-light-alpha-6: #00070726; + --ink-light-alpha-7: #000b0b32; + --ink-light-alpha-8: #00040444; + --ink-light-alpha-9: #00070774; + --ink-light-alpha-10: #0004049c; + --ink-light-alpha-11: #0007077e; + --ink-light-alpha-12: #000202df; + --amber-light-1: #fefdfb; + --amber-light-2: #fff9ed; + --amber-light-3: #fff4d5; + --amber-light-4: #ffecbc; + --amber-light-5: #ffe3a2; + --amber-light-6: #ffd386; + --amber-light-7: #f3ba63; + --amber-light-8: #ee9d2b; + --amber-light-9: #ffb224; + --amber-light-10: #ffa01c; + --amber-light-11: #ad5700; + --amber-light-12: #4e2009; + --amber-dark-1: #1f1300; + --amber-dark-2: #271700; + --amber-dark-3: #341c00; + --amber-dark-4: #3f2200; + --amber-dark-5: #4a2900; + --amber-dark-6: #573300; + --amber-dark-7: #693f05; + --amber-dark-8: #824e00; + --amber-dark-9: #ffb224; + --amber-dark-10: #ffcb47; + --amber-dark-11: #f1a10d; + --amber-dark-12: #fef3dd; + --amber-light-alpha-1: #c0820505; + --amber-light-alpha-2: #ffab0211; + --amber-light-alpha-3: #ffbb012b; + --amber-light-alpha-4: #ffb70042; + --amber-light-alpha-5: #ffb3005e; + --amber-light-alpha-6: #ffa20177; + --amber-light-alpha-7: #ec8d009b; + --amber-light-alpha-8: #ea8900d3; + --amber-light-alpha-9: #ffa600db; + --amber-light-alpha-10: #ff9500e2; + --amber-light-alpha-11: #ab5300f9; + --amber-light-alpha-12: #481800f4; + --amber-dark-alpha-1: #00000000; + --amber-dark-alpha-2: #fd83000a; + --amber-dark-alpha-3: #fe730016; + --amber-dark-alpha-4: #ff7b0023; + --amber-dark-alpha-5: #ff840030; + --amber-dark-alpha-6: #ff95003f; + --amber-dark-alpha-7: #ff970f54; + --amber-dark-alpha-8: #ff990070; + --amber-dark-alpha-9: #ffb625f9; + --amber-dark-alpha-10: #ffce48f9; + --amber-dark-alpha-11: #ffab0eef; + --amber-dark-alpha-12: #fff8e1f9; + + /* Legacy palette aliases (keeps older themes working) */ + --smoke-light-1: var(--gray-light-1); + --smoke-light-2: var(--gray-light-2); + --smoke-light-3: var(--gray-light-3); + --smoke-light-4: var(--gray-light-4); + --smoke-light-5: var(--gray-light-5); + --smoke-light-6: var(--gray-light-6); + --smoke-light-7: var(--gray-light-7); + --smoke-light-8: var(--gray-light-8); + --smoke-light-9: var(--gray-light-9); + --smoke-light-10: var(--gray-light-10); + --smoke-light-11: var(--gray-light-11); + --smoke-light-12: var(--gray-light-12); + + --smoke-dark-1: var(--gray-dark-1); + --smoke-dark-2: var(--gray-dark-2); + --smoke-dark-3: var(--gray-dark-3); + --smoke-dark-4: var(--gray-dark-4); + --smoke-dark-5: var(--gray-dark-5); + --smoke-dark-6: var(--gray-dark-6); + --smoke-dark-7: var(--gray-dark-7); + --smoke-dark-8: var(--gray-dark-8); + --smoke-dark-9: var(--gray-dark-9); + --smoke-dark-10: var(--gray-dark-10); + --smoke-dark-11: var(--gray-dark-11); + --smoke-dark-12: var(--gray-dark-12); + + --smoke-light-alpha-1: var(--gray-light-alpha-1); + --smoke-light-alpha-2: var(--gray-light-alpha-2); + --smoke-light-alpha-3: var(--gray-light-alpha-3); + --smoke-light-alpha-4: var(--gray-light-alpha-4); + --smoke-light-alpha-5: var(--gray-light-alpha-5); + --smoke-light-alpha-6: var(--gray-light-alpha-6); + --smoke-light-alpha-7: var(--gray-light-alpha-7); + --smoke-light-alpha-8: var(--gray-light-alpha-8); + --smoke-light-alpha-9: var(--gray-light-alpha-9); + --smoke-light-alpha-10: var(--gray-light-alpha-10); + --smoke-light-alpha-11: var(--gray-light-alpha-11); + --smoke-light-alpha-12: var(--gray-light-alpha-12); + + --smoke-dark-alpha-1: var(--gray-dark-alpha-1); + --smoke-dark-alpha-2: var(--gray-dark-alpha-2); + --smoke-dark-alpha-3: var(--gray-dark-alpha-3); + --smoke-dark-alpha-4: var(--gray-dark-alpha-4); + --smoke-dark-alpha-5: var(--gray-dark-alpha-5); + --smoke-dark-alpha-6: var(--gray-dark-alpha-6); + --smoke-dark-alpha-7: var(--gray-dark-alpha-7); + --smoke-dark-alpha-8: var(--gray-dark-alpha-8); + --smoke-dark-alpha-9: var(--gray-dark-alpha-9); + --smoke-dark-alpha-10: var(--gray-dark-alpha-10); + --smoke-dark-alpha-11: var(--gray-dark-alpha-11); + --smoke-dark-alpha-12: var(--gray-dark-alpha-12); + + --amber-lightalpha-1: var(--amber-light-alpha-1); + --amber-lightalpha-2: var(--amber-light-alpha-2); + --amber-lightalpha-3: var(--amber-light-alpha-3); + --amber-lightalpha-4: var(--amber-light-alpha-4); + --amber-lightalpha-5: var(--amber-light-alpha-5); + --amber-lightalpha-6: var(--amber-light-alpha-6); + --amber-lightalpha-7: var(--amber-light-alpha-7); + --amber-lightalpha-8: var(--amber-light-alpha-8); + --amber-lightalpha-9: var(--amber-light-alpha-9); + --amber-lightalpha-10: var(--amber-light-alpha-10); + --amber-lightalpha-11: var(--amber-light-alpha-11); + --amber-lightalpha-12: var(--amber-light-alpha-12); + + --amber-darkalpha-1: var(--amber-dark-alpha-1); + --amber-darkalpha-2: var(--amber-dark-alpha-2); + --amber-darkalpha-3: var(--amber-dark-alpha-3); + --amber-darkalpha-4: var(--amber-dark-alpha-4); + --amber-darkalpha-5: var(--amber-dark-alpha-5); + --amber-darkalpha-6: var(--amber-dark-alpha-6); + --amber-darkalpha-7: var(--amber-dark-alpha-7); + --amber-darkalpha-8: var(--amber-dark-alpha-8); + --amber-darkalpha-9: var(--amber-dark-alpha-9); + --amber-darkalpha-10: var(--amber-dark-alpha-10); + --amber-darkalpha-11: var(--amber-dark-alpha-11); + --amber-darkalpha-12: var(--amber-dark-alpha-12); + + --purple-light-9: var(--lilac-light-9); + --purple-dark-9: var(--lilac-dark-9); + --cyan-light-9: var(--blue-light-9); + --cyan-dark-9: var(--blue-dark-9); +} diff --git a/packages/ui/src/styles/index.css b/packages/ui/src/styles/index.css new file mode 100644 index 0000000000000000000000000000000000000000..729e19cd33d532cd7e54c9a6c795dc708d436f01 --- /dev/null +++ b/packages/ui/src/styles/index.css @@ -0,0 +1,53 @@ +@layer theme, base, components, utilities; + +@import "./colors.css" layer(theme); +@import "./theme.css" layer(theme); + +@import "./base.css" layer(base); +@import "katex/dist/katex.min.css" layer(base); + +@import "../components/accordion.css" layer(components); +@import "../components/animated-number.css" layer(components); +@import "../components/app-icon.css" layer(components); +@import "../components/avatar.css" layer(components); +@import "../components/button.css" layer(components); +@import "../components/card.css" layer(components); +@import "../components/checkbox.css" layer(components); +@import "../components/collapsible.css" layer(components); +@import "../components/diff-changes.css" layer(components); +@import "../components/context-menu.css" layer(components); +@import "../components/dropdown-menu.css" layer(components); +@import "../components/dialog.css" layer(components); +@import "../components/file-icon.css" layer(components); +@import "../components/hover-card.css" layer(components); +@import "../components/provider-icon.css" layer(components); +@import "../components/dock-surface.css" layer(components); +@import "../components/icon.css" layer(components); +@import "../components/icon-button.css" layer(components); +@import "../components/image-preview.css" layer(components); +@import "../components/keybind.css" layer(components); +@import "../components/text-field.css" layer(components); +@import "../components/inline-input.css" layer(components); +@import "../components/list.css" layer(components); +@import "../components/logo.css" layer(components); +@import "../components/popover.css" layer(components); +@import "../components/progress.css" layer(components); +@import "../components/progress-circle.css" layer(components); +@import "../components/radio-group.css" layer(components); +@import "../components/resize-handle.css" layer(components); +@import "../components/select.css" layer(components); +@import "../components/spinner.css" layer(components); +@import "../components/switch.css" layer(components); +@import "../components/scroll-view.css" layer(components); +@import "../components/sticky-accordion-header.css" layer(components); +@import "../components/tabs.css" layer(components); +@import "../components/tag.css" layer(components); +@import "../components/text-reveal.css" layer(components); +@import "../components/text-strikethrough.css" layer(components); +@import "../components/text-shimmer.css" layer(components); +@import "../components/toast.css" layer(components); +@import "../components/tooltip.css" layer(components); +@import "../components/typewriter.css" layer(components); + +@import "./utilities.css" layer(utilities); +@import "./animations.css" layer(utilities); diff --git a/packages/ui/src/styles/theme.css b/packages/ui/src/styles/theme.css new file mode 100644 index 0000000000000000000000000000000000000000..b652f9610d3620d824c76a1e41de5c704c5b2891 --- /dev/null +++ b/packages/ui/src/styles/theme.css @@ -0,0 +1,631 @@ +:root { + --font-family-sans: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + --font-family-sans--font-feature-settings: normal; + --font-family-mono: + ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; + --font-family-mono--font-feature-settings: normal; + + --font-size-small: 13px; + --font-size-base: 14px; + --font-size-large: 16px; + --font-size-x-large: 20px; + --font-weight-regular: 400; + --font-weight-medium: 500; + --line-height-normal: 130%; + --line-height-large: 150%; + --line-height-x-large: 180%; + --line-height-2x-large: 200%; + --letter-spacing-normal: 0; + --letter-spacing-tight: -0.1599999964237213; + --letter-spacing-tightest: -0.3199999928474426; + --paragraph-spacing-base: 0; + + --spacing: 0.25rem; + + --breakpoint-sm: 40rem; + --breakpoint-md: 48rem; + --breakpoint-lg: 64rem; + --breakpoint-xl: 80rem; + --breakpoint-2xl: 96rem; + + --container-3xs: 16rem; + --container-2xs: 18rem; + --container-xs: 20rem; + --container-sm: 24rem; + --container-md: 28rem; + --container-lg: 32rem; + --container-xl: 36rem; + --container-2xl: 42rem; + --container-3xl: 48rem; + --container-4xl: 56rem; + --container-5xl: 64rem; + --container-6xl: 72rem; + --container-7xl: 80rem; + + --radius-xs: 0.125rem; + --radius-sm: 0.25rem; + --radius-md: 0.375rem; + --radius-lg: 0.5rem; + --radius-xl: 0.625rem; + + --shadow-xs: + 0 1px 2px -0.5px light-dark(hsl(0 0% 0% / 0.04), hsl(0 0% 0% / 0.06)), + 0 0.5px 1.5px 0 light-dark(hsl(0 0% 0% / 0.025), hsl(0 0% 0% / 0.08)), + 0 1px 3px 0 light-dark(hsl(0 0% 0% / 0.05), hsl(0 0% 0% / 0.1)); + --shadow-md: + 0 6px 12px -2px light-dark(hsl(0 0% 0% / 0.075), hsl(0 0% 0% / 0.1)), + 0 4px 8px -2px light-dark(hsl(0 0% 0% / 0.075), hsl(0 0% 0% / 0.15)), + 0 1px 2px light-dark(hsl(0 0% 0% / 0.1), hsl(0 0% 0% / 0.15)); + --shadow-lg: + 0 16px 48px -6px light-dark(hsl(0 0% 0% / 0.05), hsl(0 0% 0% / 0.15)), + 0 6px 12px -2px light-dark(hsl(0 0% 0% / 0.025), hsl(0 0% 0% / 0.1)), + 0 1px 2.5px light-dark(hsl(0 0% 0% / 0.025), hsl(0 0% 0% / 0.1)); + --shadow-xxs-border: 0 0 0 0.5px var(--border-weak-base, rgba(0, 0, 0, 0.07)); + --shadow-xs-border: + 0 0 0 1px var(--border-base, rgba(11, 6, 0, 0.2)), 0 1px 2px -1px rgba(19, 16, 16, 0.04), + 0 1px 2px 0 rgba(19, 16, 16, 0.06), 0 1px 3px 0 rgba(19, 16, 16, 0.08); + --shadow-xs-border-base: + 0 0 0 1px var(--border-weak-base, rgba(17, 0, 0, 0.12)), 0 1px 2px -1px rgba(19, 16, 16, 0.04), + 0 1px 2px 0 rgba(19, 16, 16, 0.06), 0 1px 3px 0 rgba(19, 16, 16, 0.08); + --shadow-xs-border-select: + 0 0 0 3px var(--border-weak-selected, rgba(1, 103, 255, 0.29)), + 0 0 0 1px var(--border-selected, rgba(0, 74, 255, 0.99)), 0 1px 2px -1px rgba(19, 16, 16, 0.25), + 0 1px 2px 0 rgba(19, 16, 16, 0.08), 0 1px 3px 0 rgba(19, 16, 16, 0.12); + --shadow-xs-border-focus: + 0 0 0 1px var(--border-base, rgba(11, 6, 0, 0.2)), 0 1px 2px -1px rgba(19, 16, 16, 0.25), + 0 1px 2px 0 rgba(19, 16, 16, 0.08), 0 1px 3px 0 rgba(19, 16, 16, 0.12), 0 0 0 2px var(--background-weak, #f1f0f0), + 0 0 0 3px var(--border-selected, rgba(0, 74, 255, 0.99)); + --shadow-xs-border-hover: + 0 0 0 1px var(--border-weak-selected, rgba(0, 112, 255, 0.22)), 0 1px 2px -1px rgba(19, 16, 16, 0.04), + 0 1px 2px 0 rgba(19, 16, 16, 0.06), 0 1px 3px 0 rgba(19, 16, 16, 0.08); + --shadow-xs-border-critical-base: 0 0 0 1px var(--border-critical-selected, #fc543a); + --shadow-xs-border-critical-focus: + 0 0 0 3px var(--border-critical-weak, rgba(251, 34, 0, 0.18)), 0 0 0 1px var(--border-critical-selected, #fc543a), + 0 1px 2px -1px rgba(19, 16, 16, 0.25), 0 1px 2px 0 rgba(19, 16, 16, 0.08), 0 1px 3px 0 rgba(19, 16, 16, 0.12); + --shadow-lg-border-base: + 0 0 0 1px var(--border-weak-base, rgba(0, 0, 0, 0.07)), 0 36px 80px 0 rgba(0, 0, 0, 0.03), + 0 13.141px 29.201px 0 rgba(0, 0, 0, 0.04), 0 6.38px 14.177px 0 rgba(0, 0, 0, 0.05), + 0 3.127px 6.95px 0 rgba(0, 0, 0, 0.06), 0 1.237px 2.748px 0 rgba(0, 0, 0, 0.09); + + color-scheme: light; + --text-mix-blend-mode: multiply; + + /* OC-2 fallback variables (light) */ + --background-base: #f8f8f8; + --background-weak: #f3f3f3; + --background-strong: #fcfcfc; + --background-stronger: #fcfcfc; + --surface-base: rgba(0, 0, 0, 0.031); + --base: rgba(0, 0, 0, 0.034); + --surface-base-hover: rgba(0, 0, 0, 0.059); + --surface-base-active: rgba(0, 0, 0, 0.051); + --surface-base-interactive-active: rgba(3, 76, 255, 0.09); + --base2: rgba(0, 0, 0, 0.034); + --base3: rgba(0, 0, 0, 0.034); + --surface-inset-base: rgba(0, 0, 0, 0.034); + --surface-inset-base-hover: rgba(0, 0, 0, 0.055); + --surface-inset-strong: rgba(0, 0, 0, 0.09); + --surface-inset-strong-hover: rgba(0, 0, 0, 0.09); + --surface-raised-base: rgba(0, 0, 0, 0.031); + --surface-float-base: #161616; + --surface-float-base-hover: #1c1c1c; + --surface-raised-base-hover: rgba(0, 0, 0, 0.051); + --surface-raised-base-active: rgba(0, 0, 0, 0.09); + --surface-raised-strong: #fcfcfc; + --surface-raised-strong-hover: #ffffff; + --surface-raised-stronger: #ffffff; + --surface-raised-stronger-hover: #ffffff; + --surface-weak: rgba(0, 0, 0, 0.051); + --surface-weaker: rgba(0, 0, 0, 0.071); + --surface-strong: #ffffff; + --surface-stronger-non-alpha: var(--surface-raised-stronger-non-alpha); + --surface-raised-stronger-non-alpha: #ffffff; + --surface-brand-base: #dcde8d; + --surface-brand-hover: #d0d283; + --surface-interactive-base: #ecf3ff; + --surface-interactive-hover: #e0eaff; + --surface-interactive-weak: #f7faff; + --surface-interactive-weak-hover: #ecf3ff; + --surface-success-base: #dbfed7; + --surface-success-weak: #f0feee; + --surface-success-strong: #12c905; + --surface-warning-base: #fcf3cb; + --surface-warning-weak: #fdfaec; + --surface-warning-strong: #fbdd46; + --surface-critical-base: #fff2f0; + --surface-critical-weak: #fff8f6; + --surface-critical-strong: #fc533a; + --surface-info-base: #fdecfe; + --surface-info-weak: #fef7ff; + --surface-info-strong: #a753ae; + --surface-diff-unchanged-base: #ffffff00; + --surface-diff-skip-base: #f8f8f8; + --surface-diff-hidden-base: #eaf4ff; + --surface-diff-hidden-weak: #f6faff; + --surface-diff-hidden-weaker: #fbfdff; + --surface-diff-hidden-strong: #cae3ff; + --surface-diff-hidden-stronger: #2090f5; + --surface-diff-add-base: #e3fae1; + --surface-diff-add-weak: #f4fcf3; + --surface-diff-add-weaker: #fbfefb; + --surface-diff-add-strong: #c2eebf; + --surface-diff-add-stronger: #9ff29a; + --surface-diff-delete-base: #feefeb; + --surface-diff-delete-weak: #fff8f6; + --surface-diff-delete-weaker: #fffcfb; + --surface-diff-delete-strong: #fdc3b7; + --surface-diff-delete-stronger: #fc533a; + --input-base: #fcfcfc; + --input-hover: #f8f8f8; + --input-active: #fcfdff; + --input-selected: #e0eaff; + --input-focus: #fcfdff; + --input-disabled: #ededed; + --text-base: #6f6f6f; + --text-weak: #8f8f8f; + --text-weaker: #c7c7c7; + --text-strong: #171717; + --text-invert-base: #f8f8f8; + --text-invert-weak: #f3f3f3; + --text-invert-weaker: #ededed; + --text-invert-strong: #fcfcfc; + --text-interactive-base: #034cff; + --text-on-brand-base: rgba(0, 0, 0, 0.574); + --text-on-interactive-base: #fcfcfc; + --text-on-interactive-weak: rgba(0, 0, 0, 0.574); + --text-on-success-base: #2dba26; + --text-on-critical-base: #ed4831; + --text-on-critical-weak: #fe806a; + --text-on-critical-strong: #601a0f; + --text-on-warning-base: rgba(0, 0, 0, 0.574); + --text-on-info-base: rgba(0, 0, 0, 0.574); + --text-diff-add-base: #3a8437; + --text-diff-delete-base: #ed4831; + --text-diff-delete-strong: #601a0f; + --text-diff-add-strong: #1d3e1c; + --text-on-info-weak: rgba(0, 0, 0, 0.453); + --text-on-info-strong: rgba(0, 0, 0, 0.915); + --text-on-warning-weak: rgba(0, 0, 0, 0.453); + --text-on-warning-strong: rgba(0, 0, 0, 0.915); + --text-on-success-weak: #96ec8e; + --text-on-success-strong: #044202; + --text-on-brand-weak: rgba(0, 0, 0, 0.453); + --text-on-brand-weaker: rgba(0, 0, 0, 0.232); + --text-on-brand-strong: rgba(0, 0, 0, 0.915); + --button-primary-base: #171717; + --button-secondary-base: #fcfcfc; + --button-secondary-hover: #f8f8f8; + --button-ghost-hover: rgba(0, 0, 0, 0.031); + --button-ghost-hover2: rgba(0, 0, 0, 0.051); + --border-base: rgba(0, 0, 0, 0.162); + --border-hover: rgba(0, 0, 0, 0.236); + --border-active: rgba(0, 0, 0, 0.46); + --border-selected: rgba(3, 76, 255, 0.99); + --border-disabled: rgba(0, 0, 0, 0.236); + --border-focus: rgba(0, 0, 0, 0.46); + --border-weak-base: #e5e5e5; + --border-strong-base: rgba(0, 0, 0, 0.151); + --border-strong-hover: rgba(0, 0, 0, 0.232); + --border-strong-active: rgba(0, 0, 0, 0.151); + --border-strong-selected: rgba(3, 76, 255, 0.31); + --border-strong-disabled: rgba(0, 0, 0, 0.118); + --border-strong-focus: rgba(0, 0, 0, 0.151); + --border-weak-hover: rgba(0, 0, 0, 0.118); + --border-weak-active: rgba(0, 0, 0, 0.151); + --border-weak-selected: rgba(3, 76, 255, 0.24); + --border-weak-disabled: rgba(0, 0, 0, 0.118); + --border-weak-focus: rgba(0, 0, 0, 0.151); + --border-weaker-base: #f0f0f0; + --border-weaker-hover: rgba(0, 0, 0, 0.075); + --border-weaker-active: rgba(0, 0, 0, 0.118); + --border-weaker-selected: rgba(3, 76, 255, 0.16); + --border-weaker-disabled: rgba(0, 0, 0, 0.034); + --border-weaker-focus: rgba(0, 0, 0, 0.118); + --border-interactive-base: #a3c1fd; + --border-interactive-hover: #7ea9ff; + --border-interactive-active: #034cff; + --border-interactive-selected: #034cff; + --border-interactive-disabled: #c7c7c7; + --border-interactive-focus: #034cff; + --border-success-base: #96ec8e; + --border-success-hover: #7add71; + --border-success-selected: #12c905; + --border-warning-base: #e8d479; + --border-warning-hover: #d8c158; + --border-warning-selected: #fbdd46; + --border-critical-base: #fdc3b7; + --border-critical-hover: #ffa796; + --border-critical-selected: #fc533a; + --border-info-base: #f4bdf8; + --border-info-hover: #e6a8ea; + --border-info-selected: #a753ae; + --border-color: #ffffff; + --icon-base: #8f8f8f; + --icon-hover: #6f6f6f; + --icon-active: #171717; + --icon-selected: #171717; + --icon-disabled: #c7c7c7; + --icon-focus: #171717; + --icon-invert-base: #ffffff; + --icon-weak-base: #dbdbdb; + --icon-weak-hover: #c7c7c7; + --icon-weak-active: #8f8f8f; + --icon-weak-selected: #858585; + --icon-weak-disabled: #e2e2e2; + --icon-weak-focus: #8f8f8f; + --icon-strong-base: #171717; + --icon-strong-hover: #151313; + --icon-strong-active: #020202; + --icon-strong-selected: #020202; + --icon-strong-disabled: #c7c7c7; + --icon-strong-focus: #020202; + --icon-brand-base: #171717; + --icon-interactive-base: #034cff; + --icon-success-base: #7add71; + --icon-success-hover: #4cc944; + --icon-success-active: #078901; + --icon-warning-base: #ebb76e; + --icon-warning-hover: #da9e40; + --icon-warning-active: #95671b; + --icon-critical-base: #ed4831; + --icon-critical-hover: #ca2d17; + --icon-critical-active: #601a0f; + --icon-info-base: #e6a8ea; + --icon-info-hover: #d58cda; + --icon-info-active: #9b4da1; + --icon-on-brand-base: rgba(0, 0, 0, 0.574); + --icon-on-brand-hover: rgba(0, 0, 0, 0.915); + --icon-on-brand-selected: rgba(0, 0, 0, 0.915); + --icon-on-interactive-base: #fcfcfc; + --icon-agent-plan-base: #a753ae; + --icon-agent-docs-base: #fcb239; + --icon-agent-ask-base: #2090f5; + --icon-agent-build-base: #034cff; + --v2-agent-plan-solid: var(--v2-pink-800); + --v2-agent-plan-border: rgba(200, 61, 139, 0.2); + --v2-agent-plan-background: rgba(253, 236, 243, 0.1); + --v2-agent-build-solid: var(--v2-blue-800); + --v2-agent-build-border: rgba(44, 71, 200, 0.1); + --v2-agent-build-background: rgba(236, 241, 254, 0.1); + --v2-agent-explore-solid: var(--v2-yellow-900); + --v2-agent-explore-border: rgba(203, 159, 52, 0.2); + --v2-agent-explore-background: rgba(254, 250, 236, 0.1); + --v2-agent-review-solid: var(--v2-green-800); + --v2-agent-writer-solid: var(--v2-purple-700); + --icon-on-success-base: rgba(18, 201, 5, 0.9); + --icon-on-success-hover: rgba(45, 186, 38, 0.9); + --icon-on-success-selected: rgba(7, 137, 1, 0.9); + --icon-on-warning-base: rgba(252, 178, 57, 0.9); + --icon-on-warning-hover: rgba(239, 167, 46, 0.9); + --icon-on-warning-selected: rgba(149, 103, 27, 0.9); + --icon-on-critical-base: rgba(252, 83, 58, 0.9); + --icon-on-critical-hover: rgba(237, 72, 49, 0.9); + --icon-on-critical-selected: rgba(202, 45, 23, 0.9); + --icon-on-info-base: #a753ae; + --icon-on-info-hover: rgba(155, 73, 162, 0.9); + --icon-on-info-selected: rgba(155, 77, 161, 0.9); + --icon-diff-add-base: #3a8437; + --icon-diff-add-hover: #1d3e1c; + --icon-diff-add-active: #1d3e1c; + --icon-diff-delete-base: #ed4831; + --icon-diff-delete-hover: #ca2d17; + --icon-diff-modified-base: #ff8c00; + --syntax-comment: var(--text-weak); + --syntax-regexp: var(--text-base); + --syntax-string: #006656; + --syntax-keyword: var(--text-weak); + --syntax-primitive: #fb4804; + --syntax-operator: var(--text-base); + --syntax-variable: var(--text-strong); + --syntax-property: #ed6dc8; + --syntax-type: #596600; + --syntax-constant: #007b80; + --syntax-punctuation: var(--text-base); + --syntax-object: var(--text-strong); + --syntax-success: #2dba26; + --syntax-warning: #efa72e; + --syntax-critical: #ed4831; + --syntax-info: #0092a8; + --syntax-diff-add: #3a8437; + --syntax-diff-delete: #ca2d17; + --syntax-diff-unknown: #ff0000; + --markdown-heading: #d68c27; + --markdown-text: #1a1a1a; + --markdown-link: #3b7dd8; + --markdown-link-text: #318795; + --markdown-code: #3d9a57; + --markdown-block-quote: #b0851f; + --markdown-emph: #b0851f; + --markdown-strong: #d68c27; + --markdown-horizontal-rule: #8a8a8a; + --markdown-list-item: #3b7dd8; + --markdown-list-enumeration: #318795; + --markdown-image: #3b7dd8; + --markdown-image-text: #318795; + --markdown-code-block: #1a1a1a; + --avatar-background-pink: #feeef8; + --avatar-background-mint: #e1fbf4; + --avatar-background-orange: #fff1e7; + --avatar-background-purple: #f9f1fe; + --avatar-background-cyan: #e7f9fb; + --avatar-background-lime: #eefadc; + --avatar-text-pink: #cd1d8d; + --avatar-text-mint: #147d6f; + --avatar-text-orange: #ed5f00; + --avatar-text-purple: #8445bc; + --avatar-text-cyan: #0894b3; + --avatar-text-lime: #5d770d; + --text-stronger: #171717; + + @media (prefers-color-scheme: dark) { + color-scheme: dark; + --text-mix-blend-mode: plus-lighter; + + /* OC-2 fallback variables (dark) */ + --background-base: #101010; + --background-weak: #1e1e1e; + --background-strong: #121212; + --background-stronger: #151515; + --surface-base: rgba(255, 255, 255, 0.031); + --base: rgba(255, 255, 255, 0.034); + --surface-base-hover: rgba(255, 255, 255, 0.039); + --surface-base-active: rgba(255, 255, 255, 0.059); + --surface-base-interactive-active: rgba(3, 76, 255, 0.125); + --base2: rgba(255, 255, 255, 0.034); + --base3: rgba(255, 255, 255, 0.034); + --surface-inset-base: rgba(0, 0, 0, 0.5); + --surface-inset-base-hover: rgba(0, 0, 0, 0.5); + --surface-inset-strong: rgba(0, 0, 0, 0.8); + --surface-inset-strong-hover: rgba(0, 0, 0, 0.8); + --surface-raised-base: rgba(255, 255, 255, 0.059); + --surface-float-base: #161616; + --surface-float-base-hover: #1c1c1c; + --surface-raised-base-hover: rgba(255, 255, 255, 0.078); + --surface-raised-base-active: rgba(255, 255, 255, 0.102); + --surface-raised-strong: rgba(255, 255, 255, 0.078); + --surface-raised-strong-hover: rgba(255, 255, 255, 0.129); + --surface-raised-stronger: rgba(255, 255, 255, 0.129); + --surface-raised-stronger-hover: rgba(255, 255, 255, 0.169); + --surface-weak: rgba(255, 255, 255, 0.078); + --surface-weaker: rgba(255, 255, 255, 0.102); + --surface-strong: rgba(255, 255, 255, 0.169); + --surface-stronger-non-alpha: var(--surface-raised-stronger-non-alpha); + --surface-raised-stronger-non-alpha: #1c1c1c; + --surface-brand-base: #fab283; + --surface-brand-hover: #eda779; + --surface-interactive-base: #091f52; + --surface-interactive-hover: #091f52; + --surface-interactive-weak: #0b1730; + --surface-interactive-weak-hover: #ecf3ff; + --surface-success-base: #062d04; + --surface-success-weak: #0a1e08; + --surface-success-strong: #12c905; + --surface-warning-base: #fdf3cf; + --surface-warning-weak: #fdfaed; + --surface-warning-strong: #fcd53a; + --surface-critical-base: #1f0603; + --surface-critical-weak: #28110c; + --surface-critical-strong: #fc533a; + --surface-info-base: #feecfe; + --surface-info-weak: #fdf7fe; + --surface-info-strong: #edb2f1; + --surface-diff-unchanged-base: #161616; + --surface-diff-skip-base: #00000000; + --surface-diff-hidden-base: #0c1928; + --surface-diff-hidden-weak: #09131d; + --surface-diff-hidden-weaker: #082542; + --surface-diff-hidden-strong: #073966; + --surface-diff-hidden-stronger: #8ec2fc; + --surface-diff-add-base: #1a2919; + --surface-diff-add-weak: #1f351e; + --surface-diff-add-weaker: #1a2919; + --surface-diff-add-strong: #264024; + --surface-diff-add-stronger: #9bcd97; + --surface-diff-delete-base: #42120b; + --surface-diff-delete-weak: #580f06; + --surface-diff-delete-weaker: #42120b; + --surface-diff-delete-strong: #6a1206; + --surface-diff-delete-stronger: #faa494; + --input-base: #1c1c1c; + --input-hover: #1c1c1c; + --input-active: #091123; + --input-selected: #0b1730; + --input-focus: #091123; + --input-disabled: #282828; + --text-base: rgba(255, 255, 255, 0.618); + --text-weak: rgba(255, 255, 255, 0.422); + --text-weaker: rgba(255, 255, 255, 0.284); + --text-strong: rgba(255, 255, 255, 0.936); + --text-invert-base: #a0a0a0; + --text-invert-weak: #707070; + --text-invert-weaker: #505050; + --text-invert-strong: #ededed; + --text-interactive-base: #9dbefe; + --text-on-brand-base: rgba(255, 255, 255, 0.603); + --text-on-interactive-base: #ededed; + --text-on-interactive-weak: rgba(255, 255, 255, 0.603); + --text-on-success-base: #12c905; + --text-on-critical-base: #fc533a; + --text-on-critical-weak: #b72d1a; + --text-on-critical-strong: #ffe0da; + --text-on-warning-base: rgba(255, 255, 255, 0.603); + --text-on-info-base: rgba(255, 255, 255, 0.603); + --text-diff-add-base: #9bcd97; + --text-diff-delete-base: #fc533a; + --text-diff-delete-strong: #ffe0da; + --text-diff-add-strong: #4a7348; + --text-on-info-weak: rgba(255, 255, 255, 0.404); + --text-on-info-strong: rgba(255, 255, 255, 0.928); + --text-on-warning-weak: rgba(255, 255, 255, 0.404); + --text-on-warning-strong: rgba(255, 255, 255, 0.928); + --text-on-success-weak: #127d0d; + --text-on-success-strong: #bafdb3; + --text-on-brand-weak: rgba(255, 255, 255, 0.404); + --text-on-brand-weaker: rgba(255, 255, 255, 0.266); + --text-on-brand-strong: rgba(255, 255, 255, 0.928); + --button-primary-base: #ededed; + --button-secondary-base: #1c1c1c; + --button-secondary-hover: rgba(255, 255, 255, 0.039); + --button-ghost-hover: rgba(255, 255, 255, 0.031); + --button-ghost-hover2: rgba(255, 255, 255, 0.059); + --border-base: rgba(255, 255, 255, 0.195); + --border-hover: rgba(255, 255, 255, 0.284); + --border-active: rgba(255, 255, 255, 0.418); + --border-selected: #9dbefe; + --border-disabled: rgba(255, 255, 255, 0.284); + --border-focus: rgba(255, 255, 255, 0.418); + --border-weak-base: #282828; + --border-strong-base: rgba(255, 255, 255, 0.266); + --border-strong-hover: rgba(255, 255, 255, 0.266); + --border-strong-active: rgba(255, 255, 255, 0.266); + --border-strong-selected: rgba(3, 76, 255, 0.62); + --border-strong-disabled: rgba(255, 255, 255, 0.138); + --border-strong-focus: rgba(255, 255, 255, 0.266); + --border-weak-hover: rgba(255, 255, 255, 0.181); + --border-weak-active: rgba(255, 255, 255, 0.266); + --border-weak-selected: rgba(3, 76, 255, 0.62); + --border-weak-disabled: rgba(255, 255, 255, 0.138); + --border-weak-focus: rgba(255, 255, 255, 0.266); + --border-weaker-base: #202020; + --border-weaker-hover: rgba(255, 255, 255, 0.084); + --border-weaker-active: rgba(255, 255, 255, 0.138); + --border-weaker-selected: rgba(3, 76, 255, 0.32); + --border-weaker-disabled: rgba(255, 255, 255, 0.034); + --border-weaker-focus: rgba(255, 255, 255, 0.138); + --border-interactive-base: #a3c1fd; + --border-interactive-hover: #7ea9ff; + --border-interactive-active: #034cff; + --border-interactive-selected: #034cff; + --border-interactive-disabled: #505050; + --border-interactive-focus: #034cff; + --border-success-base: #96ec8e; + --border-success-hover: #7add71; + --border-success-selected: #12c905; + --border-warning-base: #e9d282; + --border-warning-hover: #dac063; + --border-warning-selected: #fcd53a; + --border-critical-base: #6a1206; + --border-critical-hover: #952414; + --border-critical-selected: #fc533a; + --border-info-base: #eac5ec; + --border-info-hover: #dab1dd; + --border-info-selected: #edb2f1; + --border-color: #ffffff; + --icon-base: #7e7e7e; + --icon-hover: #a0a0a0; + --icon-active: #ededed; + --icon-selected: #ededed; + --icon-disabled: #3e3e3e; + --icon-focus: #ededed; + --icon-invert-base: #161616; + --icon-weak-base: #343434; + --icon-weak-hover: #d9d9d9; + --icon-weak-active: #c8c8c8; + --icon-weak-selected: #707070; + --icon-weak-disabled: #ededed; + --icon-weak-focus: #707070; + --icon-strong-base: #ededed; + --icon-strong-hover: #f6f3f3; + --icon-strong-active: #fcfcfc; + --icon-strong-selected: #fdfcfc; + --icon-strong-disabled: #3e3e3e; + --icon-strong-focus: #fdfcfc; + --icon-brand-base: #ffffff; + --icon-interactive-base: #034cff; + --icon-success-base: #12c905; + --icon-success-hover: #35c02d; + --icon-success-active: #4de144; + --icon-warning-base: #fbb73c; + --icon-warning-hover: #885e08; + --icon-warning-active: #f1b13f; + --icon-critical-base: #fc533a; + --icon-critical-hover: #faa494; + --icon-critical-active: #ffe0da; + --icon-info-base: #68446b; + --icon-info-hover: #815484; + --icon-info-active: #dfa7e3; + --icon-on-brand-base: rgba(255, 255, 255, 0.603); + --icon-on-brand-hover: rgba(255, 255, 255, 0.928); + --icon-on-brand-selected: rgba(255, 255, 255, 0.928); + --icon-on-interactive-base: #ededed; + --icon-agent-plan-base: #edb2f1; + --icon-agent-docs-base: #fbb73c; + --icon-agent-ask-base: #2090f5; + --icon-agent-build-base: #9dbefe; + --v2-agent-plan-solid: var(--v2-pink-400); + --v2-agent-plan-border: rgba(247, 153, 198, 0.2); + --v2-agent-plan-background: rgba(170, 53, 118, 0.05); + --v2-agent-build-solid: var(--v2-blue-300); + --v2-agent-build-border: rgba(162, 188, 255, 0.2); + --v2-agent-build-background: rgba(38, 63, 169, 0.05); + --v2-agent-explore-solid: var(--v2-yellow-300); + --v2-agent-explore-border: rgba(243, 218, 155, 0.2); + --v2-agent-explore-background: rgba(172, 136, 51, 0.05); + --v2-agent-review-solid: var(--v2-green-300); + --v2-agent-writer-solid: var(--v2-purple-400); + --icon-on-success-base: rgba(18, 201, 5, 0.9); + --icon-on-success-hover: rgba(53, 192, 45, 0.9); + --icon-on-success-selected: rgba(77, 225, 68, 0.9); + --icon-on-warning-base: rgba(251, 183, 60, 0.9); + --icon-on-warning-hover: rgba(245, 178, 56, 0.9); + --icon-on-warning-selected: rgba(241, 177, 63, 0.9); + --icon-on-critical-base: rgba(252, 83, 58, 0.9); + --icon-on-critical-hover: rgba(245, 79, 54, 0.9); + --icon-on-critical-selected: rgba(250, 164, 148, 0.9); + --icon-on-info-base: #edb2f1; + --icon-on-info-hover: rgba(231, 173, 235, 0.9); + --icon-on-info-selected: rgba(223, 167, 227, 0.9); + --icon-diff-add-base: #9bcd97; + --icon-diff-add-hover: #c3f9bf; + --icon-diff-add-active: #9bcd97; + --icon-diff-delete-base: #fc533a; + --icon-diff-delete-hover: #f54f36; + --icon-diff-modified-base: #ffba92; + --syntax-comment: var(--text-weak); + --syntax-regexp: var(--text-base); + --syntax-string: #00ceb9; + --syntax-keyword: var(--text-weak); + --syntax-primitive: #ffba92; + --syntax-operator: var(--text-weak); + --syntax-variable: var(--text-strong); + --syntax-property: #ff9ae2; + --syntax-type: #ecf58c; + --syntax-constant: #93e9f6; + --syntax-punctuation: var(--text-weak); + --syntax-object: var(--text-strong); + --syntax-success: #35c02d; + --syntax-warning: #f5b238; + --syntax-critical: #f54f36; + --syntax-info: #93e9f6; + --syntax-diff-add: #9bcd97; + --syntax-diff-delete: #faa494; + --syntax-diff-unknown: #ff0000; + --markdown-heading: #9d7cd8; + --markdown-text: #eeeeee; + --markdown-link: #fab283; + --markdown-link-text: #56b6c2; + --markdown-code: #7fd88f; + --markdown-block-quote: #e5c07b; + --markdown-emph: #e5c07b; + --markdown-strong: #f5a742; + --markdown-horizontal-rule: #808080; + --markdown-list-item: #fab283; + --markdown-list-enumeration: #56b6c2; + --markdown-image: #fab283; + --markdown-image-text: #56b6c2; + --markdown-code-block: #eeeeee; + --avatar-background-pink: #501b3f; + --avatar-background-mint: #033a34; + --avatar-background-orange: #5f2a06; + --avatar-background-purple: #432155; + --avatar-background-cyan: #0f3058; + --avatar-background-lime: #2b3711; + --avatar-text-pink: #e34ba9; + --avatar-text-mint: #95f3d9; + --avatar-text-orange: #ff802b; + --avatar-text-purple: #9d5bd2; + --avatar-text-cyan: #369eff; + --avatar-text-lime: #c4f042; + --text-stronger: rgba(255, 255, 255, 0.936); + } +} diff --git a/packages/ui/src/styles/utilities.css b/packages/ui/src/styles/utilities.css new file mode 100644 index 0000000000000000000000000000000000000000..3a05a9515fa0ba1cda846049ae6eb09239861b9a --- /dev/null +++ b/packages/ui/src/styles/utilities.css @@ -0,0 +1,118 @@ +:root { + interpolate-size: allow-keywords; + + [data-popper-positioner] { + pointer-events: none; + } + + /* ::selection { */ + /* background-color: color-mix(in srgb, var(--color-primary) 33%, transparent); */ + /* background-color: var(--color-primary); */ + /* color: var(--color-background); */ + /* } */ +} + +.no-scrollbar { + &::-webkit-scrollbar { + display: none; + } + /* Hide scrollbar for IE, Edge and Firefox */ + & { + -ms-overflow-style: none; /* IE and Edge */ + scrollbar-width: none; /* Firefox */ + } +} + +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border-width: 0; +} + +.truncate-start { + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; + direction: rtl; + text-align: left; +} + +.text-12-regular { + font-family: var(--font-family-sans); + font-size: var(--font-size-small); + font-style: normal; + font-weight: var(--font-weight-regular); + line-height: var(--line-height-large); /* 166.667% */ + letter-spacing: var(--letter-spacing-normal); +} + +.text-12-medium { + font-family: var(--font-family-sans); + font-size: var(--font-size-small); + font-style: normal; + font-weight: var(--font-weight-medium); + line-height: var(--line-height-large); /* 166.667% */ + letter-spacing: var(--letter-spacing-normal); +} + +.text-12-mono { + font-family: var(--font-family-mono); + font-feature-settings: var(--font-feature-settings-mono); + font-size: var(--font-size-small); + font-style: normal; + font-weight: var(--font-weight-regular); + line-height: var(--line-height-large); /* 166.667% */ + letter-spacing: var(--letter-spacing-normal); +} + +.text-14-regular { + font-family: var(--font-family-sans); + font-size: var(--font-size-base); + font-style: normal; + font-weight: var(--font-weight-regular); + line-height: var(--line-height-x-large); /* 171.429% */ + letter-spacing: var(--letter-spacing-normal); +} + +.text-14-medium { + font-family: var(--font-family-sans); + font-size: var(--font-size-base); + font-style: normal; + font-weight: var(--font-weight-medium); + line-height: var(--line-height-large); /* 171.429% */ + letter-spacing: var(--letter-spacing-normal); +} + +.text-14-mono { + font-family: var(--font-family-mono); + font-feature-settings: var(--font-feature-settings-mono); + font-size: var(--font-size-base); + font-style: normal; + font-weight: var(--font-weight-regular); + line-height: var(--line-height-large); /* 171.429% */ + letter-spacing: var(--letter-spacing-normal); +} + +.text-16-medium { + font-family: var(--font-family-sans); + font-size: var(--font-size-large); + font-style: normal; + font-weight: var(--font-weight-medium); + line-height: var(--line-height-x-large); /* 150% */ + letter-spacing: var(--letter-spacing-tight); +} + +.text-20-medium { + font-family: var(--font-family-sans); + font-size: var(--font-size-x-large); + font-style: normal; + font-weight: var(--font-weight-medium); + line-height: var(--line-height-x-large); /* 120% */ + letter-spacing: var(--letter-spacing-tightest); +}