diff --git a/packages/app/src/components/terminal.tsx b/packages/app/src/components/terminal.tsx index d4eccbc828c..8f774d5f558 100644 --- a/packages/app/src/components/terminal.tsx +++ b/packages/app/src/components/terminal.tsx @@ -513,8 +513,10 @@ export const Terminal = (props: TerminalProps) => { url.searchParams.set("directory", sdk.directory) url.searchParams.set("cursor", String(seek)) url.protocol = url.protocol === "https:" ? "wss:" : "ws:" - url.username = server.current?.http.username ?? "opencode" - url.password = server.current?.http.password ?? "" + if (server.current?.http.password) { + url.username = server.current.http.username ?? "opencode" + url.password = server.current.http.password + } const socket = new WebSocket(url) socket.binaryType = "arraybuffer" diff --git a/packages/app/src/context/file/watcher.test.ts b/packages/app/src/context/file/watcher.test.ts index 9536b52536b..61efac1bc3d 100644 --- a/packages/app/src/context/file/watcher.test.ts +++ b/packages/app/src/context/file/watcher.test.ts @@ -1,6 +1,16 @@ -import { describe, expect, test } from "bun:test" +import { afterEach, beforeEach, describe, expect, jest, test } from "bun:test" import { invalidateFromWatcher } from "./watcher" +beforeEach(() => { + jest.useFakeTimers() +}) + +afterEach(() => { + // Flush any pending timers so module-level state is clean between tests + jest.runAllTimers() + jest.useRealTimers() +}) + describe("file watcher invalidation", () => { test("reloads open files and refreshes loaded parent on add", () => { const loads: string[] = [] @@ -23,6 +33,7 @@ describe("file watcher invalidation", () => { }, ) + jest.advanceTimersByTime(200) expect(loads).toEqual(["src/new.ts"]) expect(refresh).toEqual(["src"]) }) @@ -55,6 +66,7 @@ describe("file watcher invalidation", () => { }, ) + jest.advanceTimersByTime(200) expect(loads).toEqual(["src/open.ts"]) }) @@ -79,6 +91,9 @@ describe("file watcher invalidation", () => { }, ) + // Flush first event before the second, since the second uses different ops + jest.advanceTimersByTime(200) + invalidateFromWatcher( { type: "file.watcher.updated", @@ -103,6 +118,11 @@ describe("file watcher invalidation", () => { }, ) + // The second event targets a file (not a directory), so "change" on a file + // means node.type !== "directory" → dir is undefined → early return. + // No refreshDir should be called for the second event. + jest.advanceTimersByTime(200) + expect(refresh).toEqual(["src"]) }) @@ -144,6 +164,7 @@ describe("file watcher invalidation", () => { }, ) + jest.advanceTimersByTime(200) expect(refresh).toEqual([]) }) }) diff --git a/packages/app/src/context/file/watcher.ts b/packages/app/src/context/file/watcher.ts index fbf71992791..305e7f04863 100644 --- a/packages/app/src/context/file/watcher.ts +++ b/packages/app/src/context/file/watcher.ts @@ -15,6 +15,42 @@ type WatcherOps = { refreshDir: (path: string) => void } +// ── Debounced watcher ──────────────────────────────────────────────── +// Collect invalidation targets over a short window and flush them in a +// single batch. This avoids firing N parallel HTTP requests when an +// agent writes N files in quick succession. + +const DEBOUNCE_MS = 150 + +let pendingFiles = new Set() +let pendingDirs = new Set() +let timer: ReturnType | undefined +let lastOps: WatcherOps | undefined + +function flush() { + timer = undefined + const ops = lastOps + if (!ops) return + + const files = pendingFiles + const dirs = pendingDirs + pendingFiles = new Set() + pendingDirs = new Set() + + for (const file of files) { + ops.loadFile(file) + } + for (const dir of dirs) { + ops.refreshDir(dir) + } +} + +function schedule(ops: WatcherOps) { + lastOps = ops + if (timer !== undefined) return + timer = setTimeout(flush, DEBOUNCE_MS) +} + export function invalidateFromWatcher(event: WatcherEvent, ops: WatcherOps) { if (event.type !== "file.watcher.updated") return const props = @@ -29,7 +65,8 @@ export function invalidateFromWatcher(event: WatcherEvent, ops: WatcherOps) { if (path.startsWith(".git/")) return if (ops.hasFile(path) || ops.isOpen?.(path)) { - ops.loadFile(path) + pendingFiles.add(path) + schedule(ops) } if (kind === "change") { @@ -41,13 +78,14 @@ export function invalidateFromWatcher(event: WatcherEvent, ops: WatcherOps) { })() if (dir === undefined) return if (!ops.isDirLoaded(dir)) return - ops.refreshDir(dir) + pendingDirs.add(dir) + schedule(ops) return } if (kind !== "add" && kind !== "unlink") return const parent = path.split("/").slice(0, -1).join("/") if (!ops.isDirLoaded(parent)) return - - ops.refreshDir(parent) + pendingDirs.add(parent) + schedule(ops) } diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index 05b9f031fe6..9c5d45bd1dc 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -71,6 +71,19 @@ export namespace ProviderTransform { .filter((msg): msg is ModelMessage => msg !== undefined && msg.content !== "") } + // Anthropic rejects conversations ending with an assistant message + // ("This model does not support assistant message prefill"). + // Drop trailing assistant messages so the conversation ends with a user or tool turn. + if ( + model.api.npm === "@ai-sdk/anthropic" || + model.api.npm === "@ai-sdk/google-vertex/anthropic" || + model.api.npm === "@ai-sdk/amazon-bedrock" + ) { + while (msgs.length > 0 && msgs[msgs.length - 1].role === "assistant") { + msgs = msgs.slice(0, -1) + } + } + if (model.api.id.includes("claude")) { return msgs.map((msg) => { if ((msg.role === "assistant" || msg.role === "tool") && Array.isArray(msg.content)) { diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index 55bcf2dfce1..c7894ab358f 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -566,8 +566,15 @@ export namespace Server { }) response.headers.set( "Content-Security-Policy", - "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; media-src 'self' data:; connect-src 'self' data:", + "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; media-src 'self' data:; connect-src 'self' data: https://opencode.ai", ) + // Hashed asset paths are immutable; cache them aggressively. + // Everything else (index.html, manifests) gets a short revalidation window. + if (/^\/assets\//.test(path) && /\.[a-f0-9]{8,}\./.test(path)) { + response.headers.set("Cache-Control", "public, max-age=31536000, immutable") + } else if (!response.headers.has("Cache-Control")) { + response.headers.set("Cache-Control", "public, max-age=300") + } return response }) } @@ -601,7 +608,7 @@ export namespace Server { const app = createApp(opts) const args = { hostname: opts.hostname, - idleTimeout: 0, + idleTimeout: 120, fetch: app.fetch, websocket: websocket, } as const