mirror of
https://github.com/anomalyco/opencode.git
synced 2026-04-03 12:34:42 +00:00
Compare commits
1 Commits
kit/dev-me
...
kit/ci-uni
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1244c31da1 |
38
.github/workflows/test.yml
vendored
38
.github/workflows/test.yml
vendored
@@ -15,6 +15,7 @@ concurrency:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
checks: write
|
||||
|
||||
jobs:
|
||||
unit:
|
||||
@@ -45,14 +46,39 @@ jobs:
|
||||
git config --global user.email "bot@opencode.ai"
|
||||
git config --global user.name "opencode"
|
||||
|
||||
- name: Cache Turbo
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: node_modules/.cache/turbo
|
||||
key: turbo-${{ runner.os }}-${{ hashFiles('turbo.json', '**/package.json') }}-${{ github.sha }}
|
||||
restore-keys: |
|
||||
turbo-${{ runner.os }}-${{ hashFiles('turbo.json', '**/package.json') }}-
|
||||
turbo-${{ runner.os }}-
|
||||
|
||||
- name: Run unit tests
|
||||
run: bun turbo test
|
||||
run: bun turbo test:ci
|
||||
env:
|
||||
# Bun 1.3.11 intermittently crashes on Windows during test teardown
|
||||
# inside the native @parcel/watcher binding. Unit CI does not rely on
|
||||
# the live watcher backend there, so disable it for that platform.
|
||||
OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: ${{ runner.os == 'Windows' && 'true' || 'false' }}
|
||||
|
||||
- name: Publish unit reports
|
||||
if: always()
|
||||
uses: mikepenz/action-junit-report@v6
|
||||
with:
|
||||
report_paths: packages/*/.artifacts/unit/junit.xml
|
||||
check_name: "unit results (${{ matrix.settings.name }})"
|
||||
detailed_summary: true
|
||||
include_time_in_summary: true
|
||||
fail_on_failure: false
|
||||
|
||||
- name: Upload unit artifacts
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: unit-${{ matrix.settings.name }}-${{ github.run_attempt }}
|
||||
if-no-files-found: ignore
|
||||
retention-days: 7
|
||||
path: packages/*/.artifacts/unit/junit.xml
|
||||
|
||||
e2e:
|
||||
name: e2e (${{ matrix.settings.name }})
|
||||
strategy:
|
||||
@@ -105,17 +131,15 @@ jobs:
|
||||
run: bun --cwd packages/app test:e2e:local
|
||||
env:
|
||||
CI: true
|
||||
PLAYWRIGHT_JUNIT_OUTPUT: e2e/junit-${{ matrix.settings.name }}.xml
|
||||
timeout-minutes: 30
|
||||
|
||||
- name: Upload Playwright artifacts
|
||||
if: always()
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: playwright-${{ matrix.settings.name }}-${{ github.run_attempt }}
|
||||
if-no-files-found: ignore
|
||||
retention-days: 7
|
||||
path: |
|
||||
packages/app/e2e/junit-*.xml
|
||||
packages/app/e2e/test-results
|
||||
packages/app/e2e/playwright-report
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-cMIblNlBgq3fJonaFywzT/VrusmFhrHThOKa5p6vIlw=",
|
||||
"aarch64-linux": "sha256-ougfUo4oqyyW2fBUK/i8U0//tqEvYnhNhnG2SR0s3B8=",
|
||||
"aarch64-darwin": "sha256-3n0X0GfEydQgbRTmXnFpnQTKFFE9bOjmHXaJpHji4JE=",
|
||||
"x86_64-darwin": "sha256-8KEV+Gy+UedqW25ene7O3M0aRPk8LdV8bAKrWCNfeLw="
|
||||
"x86_64-linux": "sha256-SQVfq41OQdGCgWuWqyqIN6aggL0r3Hzn2hJ9BwPJN+I=",
|
||||
"aarch64-linux": "sha256-4w/1HhxsTzPFTHNf4JlnKle6Boz1gVTEedWG64T8E/M=",
|
||||
"aarch64-darwin": "sha256-uMd+pU1u1yqP4OP/9461Tyy3zwwv/llr+rlllLjM98A=",
|
||||
"x86_64-darwin": "sha256-BhIW3FPqKkM2vGfCrxXUvj5tarey33Q7dxCuaj5A+yU="
|
||||
}
|
||||
}
|
||||
|
||||
@@ -205,7 +205,7 @@ export async function closeDialog(page: Page, dialog: Locator) {
|
||||
await expect(dialog).toHaveCount(0)
|
||||
}
|
||||
|
||||
async function isSidebarClosed(page: Page) {
|
||||
export async function isSidebarClosed(page: Page) {
|
||||
const button = await waitSidebarButton(page, "isSidebarClosed")
|
||||
return (await button.getAttribute("aria-expanded")) !== "true"
|
||||
}
|
||||
@@ -236,7 +236,7 @@ async function errorBoundaryText(page: Page) {
|
||||
return [title ? "Error boundary" : "", description ?? "", detail ?? ""].filter(Boolean).join("\n")
|
||||
}
|
||||
|
||||
async function assertHealthy(page: Page, context: string) {
|
||||
export async function assertHealthy(page: Page, context: string) {
|
||||
const text = await errorBoundaryText(page)
|
||||
if (!text) return
|
||||
console.log(`[e2e:error-boundary][${context}]\n${text}`)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
export const promptSelector = '[data-component="prompt-input"]'
|
||||
const terminalPanelSelector = '#terminal-panel[aria-hidden="false"]'
|
||||
export const terminalPanelSelector = '#terminal-panel[aria-hidden="false"]'
|
||||
export const terminalSelector = `${terminalPanelSelector} [data-component="terminal"]`
|
||||
export const sessionComposerDockSelector = '[data-component="session-prompt-dock"]'
|
||||
export const questionDockSelector = '[data-component="dock-prompt"][data-kind="question"]'
|
||||
@@ -24,7 +24,7 @@ export const settingsSoundsErrorsSelector = '[data-action="settings-sounds-error
|
||||
export const settingsUpdatesStartupSelector = '[data-action="settings-updates-startup"]'
|
||||
export const settingsReleaseNotesSelector = '[data-action="settings-release-notes"]'
|
||||
|
||||
const sidebarNavSelector = '[data-component="sidebar-nav-desktop"]'
|
||||
export const sidebarNavSelector = '[data-component="sidebar-nav-desktop"]'
|
||||
|
||||
export const projectSwitchSelector = (slug: string) =>
|
||||
`${sidebarNavSelector} [data-action="project-switch"][data-project="${slug}"]`
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
"build": "vite build",
|
||||
"serve": "vite preview",
|
||||
"test": "bun run test:unit",
|
||||
"test:ci": "bun test --preload ./happydom.ts ./src --reporter=junit --reporter-outfile=.artifacts/unit/junit.xml",
|
||||
"test:unit": "bun test --preload ./happydom.ts ./src",
|
||||
"test:unit:watch": "bun test --watch --preload ./happydom.ts ./src",
|
||||
"test:e2e": "playwright test",
|
||||
|
||||
@@ -7,11 +7,6 @@ const serverPort = process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"
|
||||
const command = `bun run dev -- --host 0.0.0.0 --port ${port}`
|
||||
const reuse = !process.env.CI
|
||||
const workers = Number(process.env.PLAYWRIGHT_WORKERS ?? (process.env.CI ? 5 : 0)) || undefined
|
||||
const reporter = [["html", { outputFolder: "e2e/playwright-report", open: "never" }], ["line"]] as const
|
||||
|
||||
if (process.env.PLAYWRIGHT_JUNIT_OUTPUT) {
|
||||
reporter.push(["junit", { outputFile: process.env.PLAYWRIGHT_JUNIT_OUTPUT }])
|
||||
}
|
||||
|
||||
export default defineConfig({
|
||||
testDir: "./e2e",
|
||||
@@ -24,7 +19,7 @@ export default defineConfig({
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
workers,
|
||||
reporter,
|
||||
reporter: [["html", { outputFolder: "e2e/playwright-report", open: "never" }], ["line"]],
|
||||
webServer: {
|
||||
command,
|
||||
url: baseURL,
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
"prepare": "effect-language-service patch || true",
|
||||
"typecheck": "tsgo --noEmit",
|
||||
"test": "bun test --timeout 30000",
|
||||
"test:ci": "bun test --timeout 30000 --reporter=junit --reporter-outfile=.artifacts/unit/junit.xml",
|
||||
"build": "bun run script/build.ts",
|
||||
"upgrade-opentui": "bun run script/upgrade-opentui.ts",
|
||||
"dev": "bun run --conditions=browser ./src/index.ts",
|
||||
|
||||
@@ -214,17 +214,17 @@ Fully migrated (single namespace, InstanceState where needed, flattened facade):
|
||||
- [x] `SessionProcessor` — `session/processor.ts`
|
||||
- [x] `SessionPrompt` — `session/prompt.ts`
|
||||
- [x] `SessionCompaction` — `session/compaction.ts`
|
||||
- [x] `SessionSummary` — `session/summary.ts`
|
||||
- [x] `SessionRevert` — `session/revert.ts`
|
||||
- [x] `Instruction` — `session/instruction.ts`
|
||||
- [x] `Provider` — `provider/provider.ts`
|
||||
- [x] `Storage` — `storage/storage.ts`
|
||||
|
||||
Still open:
|
||||
|
||||
- [ ] `SessionSummary` — `session/summary.ts`
|
||||
- [ ] `SessionTodo` — `session/todo.ts`
|
||||
- [ ] `SessionRevert` — `session/revert.ts`
|
||||
- [ ] `Instruction` — `session/instruction.ts`
|
||||
- [ ] `ShareNext` — `share/share-next.ts`
|
||||
- [ ] `SyncEvent` — `sync/index.ts`
|
||||
- [ ] `Storage` — `storage/storage.ts`
|
||||
- [ ] `Workspace` — `control-plane/workspace.ts`
|
||||
|
||||
## Tool interface → Effect
|
||||
@@ -238,7 +238,6 @@ Once individual tools are effectified, change `Tool.Info` (`tool/tool.ts`) so `i
|
||||
Individual tools, ordered by value:
|
||||
|
||||
- [ ] `apply_patch.ts` — HIGH: multi-step orchestration, error accumulation, Bus events
|
||||
- [ ] `bash.ts` — HIGH: shell orchestration, quoting, timeout handling, output capture
|
||||
- [ ] `read.ts` — HIGH: streaming I/O, readline, binary detection → FileSystem + Stream
|
||||
- [ ] `edit.ts` — HIGH: multi-step diff/format/publish pipeline, FileWatcher lock
|
||||
- [ ] `grep.ts` — MEDIUM: spawns ripgrep → ChildProcessSpawner, timeout handling
|
||||
@@ -248,42 +247,40 @@ Individual tools, ordered by value:
|
||||
- [ ] `websearch.ts` — MEDIUM: MCP over HTTP → HttpClient
|
||||
- [ ] `batch.ts` — MEDIUM: parallel execution, per-call error recovery → Effect.all
|
||||
- [ ] `task.ts` — MEDIUM: task state management
|
||||
- [ ] `ls.ts` — MEDIUM: bounded directory listing over ripgrep-backed traversal
|
||||
- [ ] `multiedit.ts` — MEDIUM: sequential edit orchestration over `edit.ts`
|
||||
- [ ] `glob.ts` — LOW: simple async generator
|
||||
- [ ] `lsp.ts` — LOW: dispatch switch over LSP operations
|
||||
- [ ] `question.ts` — LOW: prompt wrapper
|
||||
- [ ] `skill.ts` — LOW: skill tool adapter
|
||||
- [ ] `todo.ts` — LOW: todo persistence wrapper
|
||||
- [ ] `invalid.ts` — LOW: invalid-tool fallback
|
||||
- [ ] `plan.ts` — LOW: plan file operations
|
||||
|
||||
## Effect service adoption in already-migrated code
|
||||
|
||||
Some already-effectified areas still use raw `Filesystem.*` or `Process.spawn` in their implementation or helper modules. These are low-hanging fruit — the layers already exist, they just need the dependency swap.
|
||||
Some services are effectified but still use raw `Filesystem.*` or `Process.spawn` instead of the Effect equivalents. These are low-hanging fruit — the layers already exist, they just need the dependency swap.
|
||||
|
||||
### `Filesystem.*` → `AppFileSystem.Service` (yield in layer)
|
||||
|
||||
- [ ] `file/index.ts` — 1 remaining `Filesystem.readText()` call in untracked diff handling
|
||||
- [ ] `config/config.ts` — 5 remaining `Filesystem.*` calls in `installDependencies()`
|
||||
- [ ] `provider/provider.ts` — 1 remaining `Filesystem.readJson()` call for recent model state
|
||||
- [ ] `file/index.ts` — 11 calls (the File service itself)
|
||||
- [ ] `config/config.ts` — 7 calls
|
||||
- [ ] `auth/index.ts` — 3 calls
|
||||
- [ ] `skill/index.ts` — 3 calls
|
||||
- [ ] `file/time.ts` — 1 call
|
||||
|
||||
### `Process.spawn` → `ChildProcessSpawner` (yield in layer)
|
||||
|
||||
- [ ] `format/formatter.ts` — 2 remaining `Process.spawn()` checks (`air`, `uv`)
|
||||
- [ ] `lsp/server.ts` — multiple `Process.spawn()` installs/download helpers
|
||||
- [ ] `format/index.ts` — 1 call
|
||||
|
||||
## Filesystem consolidation
|
||||
|
||||
`util/filesystem.ts` (raw fs wrapper) is currently imported by **34 files**. The effectified `AppFileSystem` service (`filesystem/index.ts`) is currently imported by **15 files**. As services and tools are effectified, they should switch from `Filesystem.*` to yielding `AppFileSystem.Service` — this happens naturally during each migration, not as a separate effort.
|
||||
`util/filesystem.ts` (raw fs wrapper) is used by **64 files**. The effectified `AppFileSystem` service (`filesystem/index.ts`) exists but only has **8 consumers**. As services and tools are effectified, they should switch from `Filesystem.*` to yielding `AppFileSystem.Service` — this happens naturally during each migration, not as a separate effort.
|
||||
|
||||
Similarly, **21 files** still import raw `fs` or `fs/promises` directly. These should migrate to `AppFileSystem` or `Filesystem.*` as they're touched.
|
||||
Similarly, **28 files** still import raw `fs` or `fs/promises` directly. These should migrate to `AppFileSystem` or `Filesystem.*` as they're touched.
|
||||
|
||||
Current raw fs users that will convert during tool migration:
|
||||
|
||||
- `tool/read.ts` — fs.createReadStream, readline
|
||||
- `tool/apply_patch.ts` — fs/promises
|
||||
- `tool/bash.ts` — fs/promises
|
||||
- `file/ripgrep.ts` — fs/promises
|
||||
- `storage/storage.ts` — fs/promises
|
||||
- `patch/index.ts` — fs, fs/promises
|
||||
|
||||
## Primitives & utilities
|
||||
|
||||
@@ -16,7 +16,6 @@ import { win32DisableProcessedInput, win32InstallCtrlCGuard } from "./win32"
|
||||
import { TuiConfig } from "@/config/tui"
|
||||
import { Instance } from "@/project/instance"
|
||||
import { writeHeapSnapshot } from "v8"
|
||||
import { Memory } from "@/debug/memory"
|
||||
|
||||
declare global {
|
||||
const OPENCODE_WORKER_PATH: string
|
||||
@@ -130,7 +129,6 @@ export const TuiThreadCommand = cmd({
|
||||
return
|
||||
}
|
||||
const cwd = Filesystem.resolve(process.cwd())
|
||||
const stopMem = Memory.start("tui")
|
||||
|
||||
const worker = new Worker(file, {
|
||||
env: Object.fromEntries(
|
||||
@@ -163,7 +161,6 @@ export const TuiThreadCommand = cmd({
|
||||
process.off("uncaughtException", error)
|
||||
process.off("unhandledRejection", error)
|
||||
process.off("SIGUSR2", reload)
|
||||
stopMem()
|
||||
await withTimeout(client.call("shutdown", undefined), 5000).catch((error) => {
|
||||
Log.Default.warn("worker shutdown failed", {
|
||||
error: errorMessage(error),
|
||||
|
||||
@@ -13,7 +13,6 @@ import { Flag } from "@/flag/flag"
|
||||
import { setTimeout as sleep } from "node:timers/promises"
|
||||
import { writeHeapSnapshot } from "node:v8"
|
||||
import { WorkspaceID } from "@/control-plane/schema"
|
||||
import { Memory } from "@/debug/memory"
|
||||
|
||||
await Log.init({
|
||||
print: process.argv.includes("--print-logs"),
|
||||
@@ -36,8 +35,6 @@ process.on("uncaughtException", (e) => {
|
||||
})
|
||||
})
|
||||
|
||||
const stopMem = Memory.start("server")
|
||||
|
||||
// Subscribe to global events and forward them via RPC
|
||||
GlobalBus.on("event", (event) => {
|
||||
Rpc.emit("global.event", event)
|
||||
@@ -159,7 +156,6 @@ export const rpc = {
|
||||
},
|
||||
async shutdown() {
|
||||
Log.Default.info("worker shutting down")
|
||||
stopMem()
|
||||
if (eventStream.abort) eventStream.abort.abort()
|
||||
await Instance.disposeAll()
|
||||
if (server) await server.stop(true)
|
||||
|
||||
@@ -1,122 +0,0 @@
|
||||
import { Global } from "@/global"
|
||||
import { Installation } from "@/installation"
|
||||
import { stats } from "@/util/queue"
|
||||
import { Log } from "@/util/log"
|
||||
import { Filesystem } from "@/util/filesystem"
|
||||
import { appendFile, mkdir } from "fs/promises"
|
||||
import { writeHeapSnapshot } from "node:v8"
|
||||
import path from "path"
|
||||
|
||||
const log = Log.create({ service: "memory" })
|
||||
|
||||
const root = process.env.OPENCODE_DEBUG_DIR ?? path.join(Global.Path.state, "debug")
|
||||
const file = path.join(root, "memory.jsonl")
|
||||
const snap = path.join(root, "snapshots")
|
||||
|
||||
export namespace Memory {
|
||||
export function start(name: string) {
|
||||
if (!Installation.isLocal()) return () => {}
|
||||
|
||||
let busy = false
|
||||
let last = 0
|
||||
const every = num("OPENCODE_DEBUG_MEMORY_INTERVAL_MS") ?? 10_000
|
||||
const limit = (num("OPENCODE_DEBUG_MEMORY_RSS_MB") ?? 1_500) * 1024 * 1024
|
||||
const cool = num("OPENCODE_DEBUG_MEMORY_COOLDOWN_MS") ?? 5 * 60 * 1000
|
||||
|
||||
const tick = async (kind: "start" | "sample") => {
|
||||
if (busy) return
|
||||
busy = true
|
||||
|
||||
try {
|
||||
const now = Date.now()
|
||||
const mem = process.memoryUsage()
|
||||
const q = stats()
|
||||
.filter((item) => item.size > 0 || item.max > 0)
|
||||
.sort((a, b) => b.size - a.size || b.max - a.max)
|
||||
.slice(0, 10)
|
||||
const row = {
|
||||
kind: "sample",
|
||||
time: new Date(now).toISOString(),
|
||||
name,
|
||||
pid: process.pid,
|
||||
rss: mem.rss,
|
||||
heap: mem.heapUsed,
|
||||
ext: mem.external,
|
||||
buf: mem.arrayBuffers,
|
||||
queues: q,
|
||||
}
|
||||
|
||||
await line(row)
|
||||
|
||||
if (kind === "start" || mem.rss < limit || now - last < cool) return
|
||||
|
||||
last = now
|
||||
const tag = stamp(now)
|
||||
const heap = path.join(snap, `${tag}-${name}.heapsnapshot`)
|
||||
await mkdir(snap, { recursive: true })
|
||||
writeHeapSnapshot(heap)
|
||||
|
||||
const meta = {
|
||||
kind: "snapshot",
|
||||
time: row.time,
|
||||
name,
|
||||
pid: process.pid,
|
||||
trigger: {
|
||||
type: "rss",
|
||||
limit,
|
||||
value: mem.rss,
|
||||
},
|
||||
memory: mem,
|
||||
queues: q,
|
||||
}
|
||||
|
||||
await Filesystem.writeJson(path.join(snap, `${tag}-${name}.json`), meta)
|
||||
await line({ ...meta, heap })
|
||||
log.warn("memory snapshot written", {
|
||||
name,
|
||||
pid: process.pid,
|
||||
rss_mb: mb(mem.rss),
|
||||
limit_mb: mb(limit),
|
||||
heap,
|
||||
})
|
||||
} catch (err) {
|
||||
log.warn("memory monitor failed", {
|
||||
name,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
} finally {
|
||||
busy = false
|
||||
}
|
||||
}
|
||||
|
||||
const timer = setInterval(() => {
|
||||
void tick("sample")
|
||||
}, every)
|
||||
timer.unref?.()
|
||||
void tick("start")
|
||||
|
||||
return () => {
|
||||
clearInterval(timer)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function line(input: unknown) {
|
||||
await mkdir(root, { recursive: true })
|
||||
await appendFile(file, JSON.stringify(input) + "\n")
|
||||
}
|
||||
|
||||
function num(key: string) {
|
||||
const value = process.env[key]
|
||||
if (!value) return undefined
|
||||
const parsed = Number(value)
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined
|
||||
}
|
||||
|
||||
function mb(value: number) {
|
||||
return Math.round((value / 1024 / 1024) * 10) / 10
|
||||
}
|
||||
|
||||
function stamp(now: number) {
|
||||
return new Date(now).toISOString().replaceAll(":", "-").replaceAll(".", "-")
|
||||
}
|
||||
@@ -32,8 +32,8 @@ export const EventRoutes = () =>
|
||||
c.header("X-Accel-Buffering", "no")
|
||||
c.header("X-Content-Type-Options", "nosniff")
|
||||
return streamSSE(c, async (stream) => {
|
||||
const q = new AsyncQueue<string | null>({ name: "sse:event" })
|
||||
let closed = false
|
||||
const q = new AsyncQueue<string | null>()
|
||||
let done = false
|
||||
|
||||
q.push(
|
||||
JSON.stringify({
|
||||
@@ -53,12 +53,11 @@ export const EventRoutes = () =>
|
||||
}, 10_000)
|
||||
|
||||
const stop = () => {
|
||||
if (closed) return
|
||||
closed = true
|
||||
if (done) return
|
||||
done = true
|
||||
clearInterval(heartbeat)
|
||||
unsub()
|
||||
q.push(null)
|
||||
q.untrack()
|
||||
log.info("event disconnected")
|
||||
}
|
||||
|
||||
|
||||
@@ -17,10 +17,10 @@ const log = Log.create({ service: "server" })
|
||||
|
||||
export const GlobalDisposedEvent = BusEvent.define("global.disposed", z.object({}))
|
||||
|
||||
async function streamEvents(c: Context, name: string, subscribe: (q: AsyncQueue<string | null>) => () => void) {
|
||||
async function streamEvents(c: Context, subscribe: (q: AsyncQueue<string | null>) => () => void) {
|
||||
return streamSSE(c, async (stream) => {
|
||||
const q = new AsyncQueue<string | null>({ name })
|
||||
let closed = false
|
||||
const q = new AsyncQueue<string | null>()
|
||||
let done = false
|
||||
|
||||
q.push(
|
||||
JSON.stringify({
|
||||
@@ -44,12 +44,11 @@ async function streamEvents(c: Context, name: string, subscribe: (q: AsyncQueue<
|
||||
}, 10_000)
|
||||
|
||||
const stop = () => {
|
||||
if (closed) return
|
||||
closed = true
|
||||
if (done) return
|
||||
done = true
|
||||
clearInterval(heartbeat)
|
||||
unsub()
|
||||
q.push(null)
|
||||
q.untrack()
|
||||
log.info("global event disconnected")
|
||||
}
|
||||
|
||||
@@ -123,7 +122,7 @@ export const GlobalRoutes = lazy(() =>
|
||||
c.header("X-Accel-Buffering", "no")
|
||||
c.header("X-Content-Type-Options", "nosniff")
|
||||
|
||||
return streamEvents(c, "sse:global", (q) => {
|
||||
return streamEvents(c, (q) => {
|
||||
async function handler(event: any) {
|
||||
q.push(JSON.stringify(event))
|
||||
}
|
||||
@@ -162,7 +161,7 @@ export const GlobalRoutes = lazy(() =>
|
||||
c.header("Cache-Control", "no-cache, no-transform")
|
||||
c.header("X-Accel-Buffering", "no")
|
||||
c.header("X-Content-Type-Options", "nosniff")
|
||||
return streamEvents(c, "sse:sync", (q) => {
|
||||
return streamEvents(c, (q) => {
|
||||
return SyncEvent.subscribeAll(({ def, event }) => {
|
||||
// TODO: don't pass def, just pass the type (and it should
|
||||
// be versioned)
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { BusEvent } from "@/bus/bus-event"
|
||||
import { Bus } from "@/bus"
|
||||
import { makeRuntime } from "@/effect/run-service"
|
||||
import { SessionID } from "./schema"
|
||||
import { Effect, Layer, ServiceMap } from "effect"
|
||||
import z from "zod"
|
||||
import { Database, eq, asc } from "../storage/db"
|
||||
import { TodoTable } from "./session.sql"
|
||||
@@ -27,69 +25,33 @@ export namespace Todo {
|
||||
),
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly update: (input: { sessionID: SessionID; todos: Info[] }) => Effect.Effect<void>
|
||||
readonly get: (sessionID: SessionID) => Effect.Effect<Info[]>
|
||||
export function update(input: { sessionID: SessionID; todos: Info[] }) {
|
||||
Database.transaction((db) => {
|
||||
db.delete(TodoTable).where(eq(TodoTable.session_id, input.sessionID)).run()
|
||||
if (input.todos.length === 0) return
|
||||
db.insert(TodoTable)
|
||||
.values(
|
||||
input.todos.map((todo, position) => ({
|
||||
session_id: input.sessionID,
|
||||
content: todo.content,
|
||||
status: todo.status,
|
||||
priority: todo.priority,
|
||||
position,
|
||||
})),
|
||||
)
|
||||
.run()
|
||||
})
|
||||
Bus.publish(Event.Updated, input)
|
||||
}
|
||||
|
||||
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/SessionTodo") {}
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const bus = yield* Bus.Service
|
||||
|
||||
const update = Effect.fn("Todo.update")(function* (input: { sessionID: SessionID; todos: Info[] }) {
|
||||
yield* Effect.sync(() =>
|
||||
Database.transaction((db) => {
|
||||
db.delete(TodoTable).where(eq(TodoTable.session_id, input.sessionID)).run()
|
||||
if (input.todos.length === 0) return
|
||||
db.insert(TodoTable)
|
||||
.values(
|
||||
input.todos.map((todo, position) => ({
|
||||
session_id: input.sessionID,
|
||||
content: todo.content,
|
||||
status: todo.status,
|
||||
priority: todo.priority,
|
||||
position,
|
||||
})),
|
||||
)
|
||||
.run()
|
||||
}),
|
||||
)
|
||||
yield* bus.publish(Event.Updated, input)
|
||||
})
|
||||
|
||||
const get = Effect.fn("Todo.get")(function* (sessionID: SessionID) {
|
||||
const rows = yield* Effect.sync(() =>
|
||||
Database.use((db) =>
|
||||
db
|
||||
.select()
|
||||
.from(TodoTable)
|
||||
.where(eq(TodoTable.session_id, sessionID))
|
||||
.orderBy(asc(TodoTable.position))
|
||||
.all(),
|
||||
),
|
||||
)
|
||||
return rows.map((row) => ({
|
||||
content: row.content,
|
||||
status: row.status,
|
||||
priority: row.priority,
|
||||
}))
|
||||
})
|
||||
|
||||
return Service.of({ update, get })
|
||||
}),
|
||||
)
|
||||
|
||||
const defaultLayer = layer.pipe(Layer.provide(Bus.layer))
|
||||
const { runPromise } = makeRuntime(Service, defaultLayer)
|
||||
|
||||
export async function update(input: { sessionID: SessionID; todos: Info[] }) {
|
||||
return runPromise((svc) => svc.update(input))
|
||||
}
|
||||
|
||||
export async function get(sessionID: SessionID) {
|
||||
return runPromise((svc) => svc.get(sessionID))
|
||||
export function get(sessionID: SessionID) {
|
||||
const rows = Database.use((db) =>
|
||||
db.select().from(TodoTable).where(eq(TodoTable.session_id, sessionID)).orderBy(asc(TodoTable.position)).all(),
|
||||
)
|
||||
return rows.map((row) => ({
|
||||
content: row.content,
|
||||
status: row.status,
|
||||
priority: row.priority,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ export const TodoWriteTool = Tool.define("todowrite", {
|
||||
metadata: {},
|
||||
})
|
||||
|
||||
await Todo.update({
|
||||
Todo.update({
|
||||
sessionID: ctx.sessionID,
|
||||
todos: params.todos,
|
||||
})
|
||||
|
||||
@@ -1,89 +1,21 @@
|
||||
type Stat = {
|
||||
id: number
|
||||
name: string
|
||||
size: number
|
||||
max: number
|
||||
push: number
|
||||
pull: number
|
||||
wait: number
|
||||
}
|
||||
|
||||
const all = new Map<number, Stat>()
|
||||
let next = 0
|
||||
|
||||
export function stats() {
|
||||
return [...all.values()].map((item) => ({ ...item }))
|
||||
}
|
||||
|
||||
export class AsyncQueue<T> implements AsyncIterable<T> {
|
||||
private queue: T[] = []
|
||||
private resolvers: ((value: T) => void)[] = []
|
||||
private id: number | undefined
|
||||
|
||||
constructor(input?: { name?: string }) {
|
||||
if (!input?.name) return
|
||||
this.id = ++next
|
||||
all.set(this.id, {
|
||||
id: this.id,
|
||||
name: input.name,
|
||||
size: 0,
|
||||
max: 0,
|
||||
push: 0,
|
||||
pull: 0,
|
||||
wait: 0,
|
||||
})
|
||||
}
|
||||
|
||||
push(item: T) {
|
||||
const itemStat = this.item()
|
||||
if (itemStat) itemStat.push++
|
||||
const resolve = this.resolvers.shift()
|
||||
if (resolve) resolve(item)
|
||||
else this.queue.push(item)
|
||||
this.sync()
|
||||
}
|
||||
|
||||
async next(): Promise<T> {
|
||||
if (this.queue.length > 0) {
|
||||
const value = this.queue.shift()!
|
||||
const itemStat = this.item()
|
||||
if (itemStat) itemStat.pull++
|
||||
this.sync()
|
||||
return value
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
this.resolvers.push((value) => {
|
||||
const itemStat = this.item()
|
||||
if (itemStat) itemStat.pull++
|
||||
this.sync()
|
||||
resolve(value)
|
||||
})
|
||||
this.sync()
|
||||
})
|
||||
}
|
||||
|
||||
untrack() {
|
||||
if (this.id === undefined) return
|
||||
all.delete(this.id)
|
||||
if (this.queue.length > 0) return this.queue.shift()!
|
||||
return new Promise((resolve) => this.resolvers.push(resolve))
|
||||
}
|
||||
|
||||
async *[Symbol.asyncIterator]() {
|
||||
while (true) yield await this.next()
|
||||
}
|
||||
|
||||
private item() {
|
||||
if (this.id === undefined) return
|
||||
return all.get(this.id)
|
||||
}
|
||||
|
||||
private sync() {
|
||||
const itemStat = this.item()
|
||||
if (!itemStat) return
|
||||
itemStat.size = this.queue.length
|
||||
itemStat.max = Math.max(itemStat.max, itemStat.size)
|
||||
itemStat.wait = this.resolvers.length
|
||||
}
|
||||
}
|
||||
|
||||
export async function work<T>(concurrency: number, items: T[], fn: (item: T) => Promise<void>) {
|
||||
|
||||
314
packages/opencode/test/session/e2e-url-repro.test.ts
Normal file
314
packages/opencode/test/session/e2e-url-repro.test.ts
Normal file
@@ -0,0 +1,314 @@
|
||||
/**
|
||||
* Reproduction test for e2e LLM URL routing.
|
||||
*
|
||||
* Tests whether OPENCODE_E2E_LLM_URL correctly routes LLM calls
|
||||
* to the mock server when no explicit provider config is set.
|
||||
* This mimics the e2e `project` fixture path (vs. withMockOpenAI).
|
||||
*/
|
||||
import { expect } from "bun:test"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { Session } from "../../src/session"
|
||||
import { SessionPrompt } from "../../src/session/prompt"
|
||||
import { SessionSummary } from "../../src/session/summary"
|
||||
import { Log } from "../../src/util/log"
|
||||
import { provideTmpdirServer } from "../fixture/fixture"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { TestLLMServer } from "../lib/llm-server"
|
||||
|
||||
import { NodeFileSystem } from "@effect/platform-node"
|
||||
import { Agent as AgentSvc } from "../../src/agent/agent"
|
||||
import { Bus } from "../../src/bus"
|
||||
import { Command } from "../../src/command"
|
||||
import { Config } from "../../src/config/config"
|
||||
import { FileTime } from "../../src/file/time"
|
||||
import { LSP } from "../../src/lsp"
|
||||
import { MCP } from "../../src/mcp"
|
||||
import { Permission } from "../../src/permission"
|
||||
import { Plugin } from "../../src/plugin"
|
||||
import { Provider as ProviderSvc } from "../../src/provider/provider"
|
||||
import { ModelID, ProviderID } from "../../src/provider/schema"
|
||||
import { Server } from "../../src/server/server"
|
||||
import { SessionCompaction } from "../../src/session/compaction"
|
||||
import { Instruction } from "../../src/session/instruction"
|
||||
import { SessionProcessor } from "../../src/session/processor"
|
||||
import { SessionStatus } from "../../src/session/status"
|
||||
import { LLM } from "../../src/session/llm"
|
||||
import { Shell } from "../../src/shell/shell"
|
||||
import { Snapshot } from "../../src/snapshot"
|
||||
import { ToolRegistry } from "../../src/tool/registry"
|
||||
import { Truncate } from "../../src/tool/truncate"
|
||||
import { AppFileSystem } from "../../src/filesystem"
|
||||
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
|
||||
|
||||
Log.init({ print: false })
|
||||
|
||||
const mcp = Layer.succeed(
|
||||
MCP.Service,
|
||||
MCP.Service.of({
|
||||
status: () => Effect.succeed({}),
|
||||
clients: () => Effect.succeed({}),
|
||||
tools: () => Effect.succeed({}),
|
||||
prompts: () => Effect.succeed({}),
|
||||
resources: () => Effect.succeed({}),
|
||||
add: () => Effect.succeed({ status: { status: "disabled" as const } }),
|
||||
connect: () => Effect.void,
|
||||
disconnect: () => Effect.void,
|
||||
getPrompt: () => Effect.succeed(undefined),
|
||||
readResource: () => Effect.succeed(undefined),
|
||||
startAuth: () => Effect.die("unexpected MCP auth"),
|
||||
authenticate: () => Effect.die("unexpected MCP auth"),
|
||||
finishAuth: () => Effect.die("unexpected MCP auth"),
|
||||
removeAuth: () => Effect.void,
|
||||
supportsOAuth: () => Effect.succeed(false),
|
||||
hasStoredTokens: () => Effect.succeed(false),
|
||||
getAuthStatus: () => Effect.succeed("not_authenticated" as const),
|
||||
}),
|
||||
)
|
||||
|
||||
const lsp = Layer.succeed(
|
||||
LSP.Service,
|
||||
LSP.Service.of({
|
||||
init: () => Effect.void,
|
||||
status: () => Effect.succeed([]),
|
||||
hasClients: () => Effect.succeed(false),
|
||||
touchFile: () => Effect.void,
|
||||
diagnostics: () => Effect.succeed({}),
|
||||
hover: () => Effect.succeed(undefined),
|
||||
definition: () => Effect.succeed([]),
|
||||
references: () => Effect.succeed([]),
|
||||
implementation: () => Effect.succeed([]),
|
||||
documentSymbol: () => Effect.succeed([]),
|
||||
workspaceSymbol: () => Effect.succeed([]),
|
||||
prepareCallHierarchy: () => Effect.succeed([]),
|
||||
incomingCalls: () => Effect.succeed([]),
|
||||
outgoingCalls: () => Effect.succeed([]),
|
||||
}),
|
||||
)
|
||||
|
||||
const filetime = Layer.succeed(
|
||||
FileTime.Service,
|
||||
FileTime.Service.of({
|
||||
read: () => Effect.void,
|
||||
get: () => Effect.succeed(undefined),
|
||||
assert: () => Effect.void,
|
||||
withLock: (_filepath, fn) => Effect.promise(fn),
|
||||
}),
|
||||
)
|
||||
|
||||
const status = SessionStatus.layer.pipe(Layer.provideMerge(Bus.layer))
|
||||
const infra = Layer.mergeAll(NodeFileSystem.layer, CrossSpawnSpawner.defaultLayer)
|
||||
const patchModel = { providerID: ProviderID.make("openai"), modelID: ModelID.make("gpt-5.4") } as const
|
||||
|
||||
function makeHttp() {
|
||||
const deps = Layer.mergeAll(
|
||||
Session.defaultLayer,
|
||||
Snapshot.defaultLayer,
|
||||
LLM.defaultLayer,
|
||||
AgentSvc.defaultLayer,
|
||||
Command.defaultLayer,
|
||||
Permission.layer,
|
||||
Plugin.defaultLayer,
|
||||
Config.defaultLayer,
|
||||
ProviderSvc.defaultLayer,
|
||||
filetime,
|
||||
lsp,
|
||||
mcp,
|
||||
AppFileSystem.defaultLayer,
|
||||
status,
|
||||
).pipe(Layer.provideMerge(infra))
|
||||
const registry = ToolRegistry.layer.pipe(Layer.provideMerge(deps))
|
||||
const trunc = Truncate.layer.pipe(Layer.provideMerge(deps))
|
||||
const proc = SessionProcessor.layer.pipe(Layer.provideMerge(deps))
|
||||
const compact = SessionCompaction.layer.pipe(Layer.provideMerge(proc), Layer.provideMerge(deps))
|
||||
return Layer.mergeAll(
|
||||
TestLLMServer.layer,
|
||||
SessionPrompt.layer.pipe(
|
||||
Layer.provideMerge(compact),
|
||||
Layer.provideMerge(proc),
|
||||
Layer.provideMerge(registry),
|
||||
Layer.provideMerge(trunc),
|
||||
Layer.provide(Instruction.defaultLayer),
|
||||
Layer.provideMerge(deps),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
const it = testEffect(makeHttp())
|
||||
|
||||
it.live("e2eURL routes apply_patch through mock server", () =>
|
||||
provideTmpdirServer(
|
||||
Effect.fnUntraced(function* ({ dir, llm }) {
|
||||
// Set the env var to route all LLM calls through the mock
|
||||
const prev = process.env.OPENCODE_E2E_LLM_URL
|
||||
process.env.OPENCODE_E2E_LLM_URL = llm.url
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Effect.sync(() => {
|
||||
if (prev === undefined) delete process.env.OPENCODE_E2E_LLM_URL
|
||||
else process.env.OPENCODE_E2E_LLM_URL = prev
|
||||
}),
|
||||
)
|
||||
|
||||
const prompt = yield* SessionPrompt.Service
|
||||
const sessions = yield* Session.Service
|
||||
|
||||
const session = yield* sessions.create({
|
||||
title: "e2e url test",
|
||||
permission: [{ permission: "*", pattern: "*", action: "allow" }],
|
||||
})
|
||||
|
||||
const patch = ["*** Begin Patch", "*** Add File: e2e-test.txt", "+line 1", "+line 2", "*** End Patch"].join("\n")
|
||||
|
||||
// Queue mock response: match on system prompt, return apply_patch tool call
|
||||
yield* llm.toolMatch(
|
||||
(hit) => JSON.stringify(hit.body).includes("Your only valid response is one apply_patch tool call"),
|
||||
"apply_patch",
|
||||
{ patchText: patch },
|
||||
)
|
||||
// After tool execution, LLM gets called again with tool result — return "done"
|
||||
yield* llm.text("done")
|
||||
|
||||
// Seed user message
|
||||
yield* prompt.prompt({
|
||||
sessionID: session.id,
|
||||
agent: "build",
|
||||
model: patchModel,
|
||||
noReply: true,
|
||||
system: [
|
||||
"You are seeding deterministic e2e UI state.",
|
||||
"Your only valid response is one apply_patch tool call.",
|
||||
`Use this JSON input: ${JSON.stringify({ patchText: patch })}`,
|
||||
"Do not call any other tools.",
|
||||
"Do not output plain text.",
|
||||
].join("\n"),
|
||||
parts: [{ type: "text", text: "Apply the provided patch exactly once." }],
|
||||
})
|
||||
|
||||
// Run the agent loop
|
||||
const result = yield* prompt.loop({ sessionID: session.id })
|
||||
expect(result.info.role).toBe("assistant")
|
||||
|
||||
const calls = yield* llm.calls
|
||||
expect(calls).toBe(2)
|
||||
|
||||
const missed = yield* llm.misses
|
||||
expect(missed.length).toBe(0)
|
||||
|
||||
const content = yield* Effect.promise(() =>
|
||||
Bun.file(`${dir}/e2e-test.txt`)
|
||||
.text()
|
||||
.catch(() => "NOT FOUND"),
|
||||
)
|
||||
expect(content).toContain("line 1")
|
||||
|
||||
let diff: Awaited<ReturnType<typeof SessionSummary.diff>> = []
|
||||
for (let i = 0; i < 20; i++) {
|
||||
diff = yield* Effect.promise(() => SessionSummary.diff({ sessionID: session.id }))
|
||||
if (diff.length > 0) break
|
||||
yield* Effect.sleep("100 millis")
|
||||
}
|
||||
expect(diff.length).toBeGreaterThan(0)
|
||||
}),
|
||||
{
|
||||
git: true,
|
||||
config: () => ({
|
||||
model: "openai/gpt-5.4",
|
||||
agent: {
|
||||
build: {
|
||||
model: "openai/gpt-5.4",
|
||||
},
|
||||
},
|
||||
provider: {
|
||||
openai: {
|
||||
options: {
|
||||
apiKey: "test-openai-key",
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
it.live("server message route produces diff through mock server", () =>
|
||||
provideTmpdirServer(
|
||||
Effect.fnUntraced(function* ({ dir, llm }) {
|
||||
const prev = process.env.OPENCODE_E2E_LLM_URL
|
||||
process.env.OPENCODE_E2E_LLM_URL = llm.url
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Effect.sync(() => {
|
||||
if (prev === undefined) delete process.env.OPENCODE_E2E_LLM_URL
|
||||
else process.env.OPENCODE_E2E_LLM_URL = prev
|
||||
}),
|
||||
)
|
||||
|
||||
const sessions = yield* Session.Service
|
||||
const session = yield* sessions.create({
|
||||
title: "e2e route test",
|
||||
permission: [{ permission: "*", pattern: "*", action: "allow" }],
|
||||
})
|
||||
const app = Server.Default()
|
||||
const patch = ["*** Begin Patch", "*** Add File: route-test.txt", "+line 1", "+line 2", "*** End Patch"].join(
|
||||
"\n",
|
||||
)
|
||||
|
||||
yield* llm.toolMatch(
|
||||
(hit) => JSON.stringify(hit.body).includes("Your only valid response is one apply_patch tool call"),
|
||||
"apply_patch",
|
||||
{ patchText: patch },
|
||||
)
|
||||
yield* llm.text("done")
|
||||
|
||||
const res = yield* Effect.promise(() =>
|
||||
Promise.resolve(
|
||||
app.request(`/session/${session.id}/message`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
"x-opencode-directory": dir,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
agent: "build",
|
||||
system: [
|
||||
"You are seeding deterministic e2e UI state.",
|
||||
"Your only valid response is one apply_patch tool call.",
|
||||
`Use this JSON input: ${JSON.stringify({ patchText: patch })}`,
|
||||
"Do not call any other tools.",
|
||||
"Do not output plain text.",
|
||||
].join("\n"),
|
||||
parts: [{ type: "text", text: "Apply the provided patch exactly once." }],
|
||||
}),
|
||||
}),
|
||||
),
|
||||
)
|
||||
expect(res.status).toBe(200)
|
||||
yield* Effect.promise(() => res.json())
|
||||
|
||||
const calls = yield* llm.calls
|
||||
expect(calls).toBe(2)
|
||||
|
||||
const content = yield* Effect.promise(() =>
|
||||
Bun.file(`${dir}/route-test.txt`)
|
||||
.text()
|
||||
.catch(() => "NOT FOUND"),
|
||||
)
|
||||
expect(content).toContain("line 1")
|
||||
|
||||
let diff: Awaited<ReturnType<typeof SessionSummary.diff>> = []
|
||||
for (let i = 0; i < 30; i++) {
|
||||
diff = yield* Effect.promise(() => SessionSummary.diff({ sessionID: session.id }))
|
||||
if (diff.length > 0) break
|
||||
yield* Effect.sleep("100 millis")
|
||||
}
|
||||
|
||||
expect(diff.length).toBeGreaterThan(0)
|
||||
}),
|
||||
{
|
||||
git: true,
|
||||
config: () => ({
|
||||
model: "openai/gpt-5.4",
|
||||
agent: { build: { model: "openai/gpt-5.4" } },
|
||||
provider: { openai: { options: { apiKey: "test-openai-key" } } },
|
||||
}),
|
||||
},
|
||||
),
|
||||
)
|
||||
10
turbo.json
10
turbo.json
@@ -13,9 +13,19 @@
|
||||
"outputs": [],
|
||||
"passThroughEnv": ["*"]
|
||||
},
|
||||
"opencode#test:ci": {
|
||||
"dependsOn": ["^build"],
|
||||
"outputs": [".artifacts/unit/junit.xml"],
|
||||
"passThroughEnv": ["*"]
|
||||
},
|
||||
"@opencode-ai/app#test": {
|
||||
"dependsOn": ["^build"],
|
||||
"outputs": []
|
||||
},
|
||||
"@opencode-ai/app#test:ci": {
|
||||
"dependsOn": ["^build"],
|
||||
"outputs": [".artifacts/unit/junit.xml"],
|
||||
"passThroughEnv": ["*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user