CodeNomad/scripts/test-automation-native.mjs
Pascal André 1d28af7f0d
fix(startup): restore saved sessions promptly and stop plugin heartbeat reloads (#712)
## Summary

Restore the saved project/conversation promptly, preserve restored tabs
when later hydration fails, and remove a filesystem heartbeat that
repeatedly invalidated OpenCode configuration.

## Root cause and reproduction

PR #666 placed desktop plugin presence leases beneath the authenticated
OpenCode configuration discovery root. Updating their mtimes every two
seconds triggers recursive configuration watchers and expensive
revalidation across loaded locations.

With CodeNomad closed and the same shared OpenCode daemon left running,
updating only a temporary file in that directory reproduced the
slowdown:

- Quiet: 77–92 ms for the same 200-message request.
- File mtime updated every two seconds: 5.3–8.8 seconds.
- Updates stopped: 71–138 ms.

The pre-#666 server/UI comparison was only a partial baseline: native
reads recovered after startup, but the older UI also exhibited
restoration failures.

## Implementation

- Derive a sibling plugin storage namespace from the connected daemon's
authoritative discovery root. Move new heartbeats outside its
recursively watched configuration tree.
- Migrate existing managed inside-root storage while retaining read-only
observation of older backends' leases. Preserve existing outside-root
storage, independent backend lifetimes, and native WSL path translation.
- Seed saved selection identity before HTTP hydration, prioritize the
saved project, and hydrate its conversation directly. Preserve bound
project tabs across subsequent hydration errors/timeouts and respect
user selection changes.
- Load model/agent catalogues when the client becomes ready and retry
reads superseded by the initial stream connection.
- Publish session lists without waiting for runtime-status reads; merge
delayed status results without overwriting SSE updates or local
admissions. Use lightweight status reconciliation for liveness.
- Bound secondary reads across projects, coalesce Shell refreshes, and
prevent duplicate catalogue/Shell refreshes through the native
transcript projection.
- Preserve directory authorization while avoiding Git mutation-identity
resolution on reads; reject unrelated Git common directories before
native worktree inventory scans.

## Validation

- Targeted UI and server suites covering reconnect races, status
authority, background queue cancellation, projection isolation, Shell
refresh coalescing, and ownership enforcement.
- 19 plugin path/lifecycle/migration/presence tests passed.
- Six browser restoration scenarios covering Electron/Tauri, saved
selection, user selection, and hydration timeout behavior passed.
- Isolated OpenCode 2.0.9 integration fixture passed: native
provisioning, plugin availability, independent leases,
shutdown/reopening, and OS watcher verification that heartbeats do not
modify the configuration tree. A negative control using the old storage
placement fails that assertion.
- Typechecks, complete server/UI/plugin build, and `git diff --check`
passed.
- Installed usual-profile Tauri validation on the requested
conversation: launch-to-display 3.86 and 5.33 seconds; initial
200-message requests 178–239 ms; latest forced reload 218 ms. No
intermediate wrong conversation selection observed. Verified all three
saved projects and the real model menu.

## Remaining observed edge case

A forced reload racing restoration of an old scroll position can still
show the existing history retry control. Retrying succeeded. This PR
addresses the reproduced latency trigger and startup restoration races;
it does not claim that every scroll-restoration edge case is resolved.
2026-09-19 12:39:23 +02:00

233 lines
14 KiB
JavaScript

// Explicit isolated CLI only: no service discovery, real provider, or user database.
import assert from "node:assert/strict"
import { spawn, execFileSync } from "node:child_process"
import { createServer } from "node:http"
import { watch } from "node:fs"
import { mkdtemp, mkdir, readFile, realpath, rm, writeFile } from "node:fs/promises"
import os from "node:os"
import path from "node:path"
import { setTimeout as delay } from "node:timers/promises"
import { tsImport } from "tsx/esm/api"
import Fastify from "fastify"
const cli = process.argv[2]
if (!cli || !path.isAbsolute(cli)) throw new Error("Pass an absolute CLI executable path for isolated testing")
const version = execFileSync(cli, ["--version"], { encoding: "utf8" }).trim().replace(/^opencode2? v/, "")
const temporary = path.join(os.tmpdir(), "opencode")
await mkdir(temporary, { recursive: true })
const root = await realpath(await mkdtemp(path.join(temporary, "codenomad-automation-native-")))
const project = path.join(root, "arbitrary-project")
await mkdir(project)
const env = Object.fromEntries(Object.entries(process.env).filter(([key]) => !/^(OPENCODE_|XDG_|CODENOMAD_)/.test(key)))
for (const key of ["XDG_DATA_HOME", "XDG_CONFIG_HOME", "XDG_STATE_HOME", "XDG_CACHE_HOME"]) env[key] = path.join(root, key)
Object.assign(env, {
USERPROFILE: root, HOME: root, OPENCODE_TEST_HOME: root, LOCALAPPDATA: root, XDG_RUNTIME_DIR: root,
OPENCODE_CONFIG_DIR: path.join(root, "config"), OPENCODE_DB: path.join(root, "fixture.db"),
OPENCODE_SERVER_PASSWORD: "isolated-automation-fixture", OPENCODE_CONFIG_PROJECT_DISABLE: "1",
OPENCODE_DISABLE_MODELS_FETCH: "1", OPENCODE_DISABLE_FFF: "1",
})
delete env.WSL_DISTRO_NAME
await mkdir(env.OPENCODE_CONFIG_DIR)
const { DesktopPluginLifecycle, prepareDesktopPluginPresence } = await tsImport("../packages/server/src/opencode/desktop-plugin-lifecycle.ts", import.meta.url)
const { resolveDesktopPluginPaths } = await tsImport("../packages/server/src/opencode/desktop-plugin-paths.ts", import.meta.url)
const { createAutomationBridgeRegistration, publishAutomationBridge } = await tsImport("../packages/server/src/opencode/automation-plugin.ts", import.meta.url)
const { registerAutomationPluginRoute } = await tsImport("../packages/server/src/server/routes/automation-plugin.ts", import.meta.url)
const oldEnv = Object.fromEntries(["LOCALAPPDATA", "XDG_RUNTIME_DIR", "WSL_DISTRO_NAME",
"OPENCODE_CONFIG_DIR", "XDG_CONFIG_HOME", "XDG_DATA_HOME"].map(key => [key, process.env[key]]))
process.env.LOCALAPPDATA = root
process.env.XDG_RUNTIME_DIR = root
delete process.env.WSL_DISTRO_NAME
// The existing daemon has a different environment from this new backend and
// any proposed future-start overrides. Only its authenticated config API counts.
process.env.OPENCODE_CONFIG_DIR = path.join(root, "next-start-config")
process.env.XDG_CONFIG_HOME = path.join(root, "next-start-xdg-config")
process.env.XDG_DATA_HOME = path.join(root, "next-start-data")
const configFile = path.join(env.OPENCODE_CONFIG_DIR, "opencode.json")
await writeFile(configFile, "{}\n")
let client, developer = false, callBrowser = false, providerError
let observedTools = []
const nativeCalls = []
const bridge = Fastify()
let registration = createAutomationBridgeRegistration("http://127.0.0.1")
registerAutomationPluginRoute(bridge, {
bridgeToken: registration.token,
authManager: { isLoopbackRequest: request => request.ip === "127.0.0.1" },
workspaceManager: {
getSharedServiceClient: async () => client,
list: () => [{ id: "unrelated" }, { id: "fixture" }],
ownsLocation: async (id, location) => {
if (id === "unrelated") throw new Error("Synthetic unavailable unrelated native inventory")
return path.resolve(location.directory).toLowerCase() === project.toLowerCase()
},
},
nativeParent: {
available: true,
request: async (method, params) => {
if (method === "developer.status") return { status: developer
? { state: "ready", cdpUrl: "http://127.0.0.1:1", runId: "fixture", nativeIdentity: "fixture", windowId: "fixture" }
: { state: "stopped" } }
nativeCalls.push({ method, params })
return method === "browser.probe" ? { available: true } : { text: "NATIVE_BROWSER_FIXTURE" }
},
},
})
const provider = createServer(async (request, response) => {
try {
let raw = ""
for await (const chunk of request) raw += chunk
const body = JSON.parse(raw)
const primary = request.headers["x-automation-kind"] === "primary"
if (primary) observedTools = (body.tools ?? []).map(tool => tool.function?.name).filter(Boolean)
const invoke = primary && callBrowser
if (invoke) {
assert(observedTools.includes("codenomad_browser"), "Native model catalog must contain the browser tool")
callBrowser = false
}
if (!body.stream) {
response.setHeader("content-type", "application/json")
response.end(JSON.stringify({ id: "fixture", choices: [{ message: { role: "assistant", content: "Fixture" }, finish_reason: "stop" }] }))
return
}
response.setHeader("content-type", "text/event-stream")
const chunk = (delta, finish_reason = null) => response.write(`data: ${JSON.stringify({
id: "fixture", object: "chat.completion.chunk", model: "fixture", choices: [{ index: 0, delta, finish_reason }],
})}\n\n`)
chunk({ role: "assistant" })
if (invoke) chunk({ tool_calls: [{ index: 0, id: `fixture-${Date.now()}`, type: "function", function: {
name: "codenomad_browser", arguments: JSON.stringify({ action: "open", url: "https://example.com" }),
} }] })
else chunk({ content: "Done" })
chunk({}, invoke ? "tool_calls" : "stop")
response.end("data: [DONE]\n\n")
} catch (error) { providerError = error; response.destroy(error) }
})
let child, stopped, first, second, removeBridge, output = ""
async function until(predicate) {
for (let i = 0; i < 600; i++) {
if (providerError) throw providerError
if (await predicate()) return
if (child?.exitCode !== null && child?.exitCode !== undefined) throw new Error(`Isolated CLI exited: ${output}`)
await delay(100)
}
throw new Error(`Isolated condition timed out: ${output.slice(-6000)}`)
}
try {
await bridge.listen({ host: "127.0.0.1", port: 0 })
registration = { ...registration, url: `http://127.0.0.1:${bridge.server.address().port}/api/opencode-plugin/automation` }
removeBridge = await publishAutomationBridge(registration)
await new Promise(resolve => provider.listen(0, "127.0.0.1", resolve))
await mkdir(path.join(root, "fixture-plugin"))
const setupLog = path.join(root, "plugin-setup.log")
await writeFile(path.join(root, "fixture-plugin", "index.ts"), `import { appendFile } from 'node:fs/promises'
export default { id: 'automation-fixture', async setup(ctx) {
await appendFile(${JSON.stringify(setupLog)}, 'setup\\n')
await ctx.session.hook('http.request', event => event.request.headers.set('x-automation-kind', event.kind))
} }`)
env.OPENCODE_CONFIG_CONTENT = JSON.stringify({
model: "fixture/fixture", permissions: [{ action: "*", resource: "*", effect: "allow" }],
providers: { fixture: { package: `${version === "0.0.0-beta-19271" ? "@opencode-ai" : "@opencode"}/ai/providers/openai-compatible`,
settings: { baseURL: `http://127.0.0.1:${provider.address().port}/v1`, apiKey: "fixture" }, models: { fixture: {} } } },
plugins: [path.join(root, "fixture-plugin")],
})
child = spawn(cli, ["serve", "--hostname", "127.0.0.1", "--port", "0", "--print-logs"], { cwd: root, env, windowsHide: true })
stopped = new Promise(resolve => child.once("close", resolve))
child.stdout.on("data", data => { output += data })
child.stderr.on("data", data => { output += data })
await until(() => /http:\/\/127\.0\.0\.1:\d+/.test(output))
const baseUrl = output.match(/http:\/\/127\.0\.0\.1:\d+/)[0]
// Reuse production contract negotiation without ever performing service discovery.
const { OpenCodeCliService } = await tsImport("../packages/server/src/workspaces/opencode-cli-service.ts", import.meta.url)
const { OpenCodeSharedService } = await tsImport("../packages/server/src/workspaces/opencode-service.ts", import.meta.url)
const lifecycle = new OpenCodeCliService({ label: "Isolated automation", timeoutMs: 5000,
command: args => ({ command: cli, args, options: {} }),
}, { execFile: async (_file, args) => ({ stdout: args.join(" ") === "service get password" ? env.OPENCODE_SERVER_PASSWORD : baseUrl, stderr: "" }) })
const shared = new OpenCodeSharedService()
client = await shared.client({ kind: "lifecycle", identity: "isolated-automation", lifecycle })
const openPresence = async () => {
const backend = new OpenCodeSharedService()
const automation = new DesktopPluginLifecycle("automation")
const pruning = new DesktopPluginLifecycle("session-pruning")
try {
await backend.client({ kind: "lifecycle", identity: "isolated-backend", lifecycle,
prepareDesktopPlugins: async connection => {
const paths = await resolveDesktopPluginPaths(connection, { kind: "host", platform: process.platform, binary: cli })
assert.equal(paths.config, env.OPENCODE_CONFIG_DIR)
await prepareDesktopPluginPresence(paths, connection.assertCurrent, { pruning, automation })
return true
},
})
return async () => { await automation.stop(); await pruning.stop(); await backend.shutdown() }
} catch (error) {
await automation.stop(); await pruning.stop(); await backend.shutdown()
throw error
}
}
const location = { directory: project }
const session = await client.session.create({ location })
const sample = async () => {
await client.session.prompt({ sessionID: session.id, text: "Conclude briefly." })
await client.session.wait({ sessionID: session.id }, { signal: AbortSignal.timeout(20_000) })
if (providerError) throw providerError
return observedTools.filter(name => name.startsWith("codenomad_")).sort()
}
assert.deepEqual(await sample(), [], "No automation definitions before backend provisioning")
first = await openPresence()
assert.equal(await readFile(configFile, "utf8"), "{}\n", "Provisioning preserves the daemon's user configuration")
for (const directory of [process.env.OPENCODE_CONFIG_DIR, process.env.XDG_CONFIG_HOME, process.env.XDG_DATA_HOME]) {
await assert.rejects(readFile(path.join(directory, "plugins", "codenomad-automation.ts")), { code: "ENOENT" })
}
await until(async () => (await client.plugin.list({ location })).data.some(plugin => plugin.id === "codenomad.automation" && plugin.state.status === "active"))
await until(async () => (await client.plugin.list({ location })).data.some(plugin => plugin.id === "codenomad-session-pruning" && plugin.state.status === "active"))
await until(async () => (await sample()).includes("codenomad_browser"))
const allTools = ["codenomad_act", "codenomad_browser", "codenomad_inspect", "codenomad_screenshot"]
assert.deepEqual(await sample(), allTools, "Backend presence exposes all tools independently of Developer Mode")
// Keep project discovery disabled to exclude real ancestor configuration.
// That also disables OpenCode's config watcher, so explicitly observe the
// same directory with an OS watcher rather than accept a false-positive test.
await delay(2_500)
const setupBeforeHeartbeat = await readFile(setupLog, "utf8")
const configChanges = []
const configWatcher = watch(env.OPENCODE_CONFIG_DIR, { recursive: true }, (event, filename) => configChanges.push({ event, filename }))
try {
await delay(5_000)
assert.deepEqual(configChanges, [], "Backend heartbeats must not modify the watched configuration tree")
} finally {
configWatcher.close()
}
assert.equal(await readFile(setupLog, "utf8"), setupBeforeHeartbeat, "Backend heartbeats must not reload native configuration/plugins")
const skills = await client.skill.list({ location })
assert(JSON.stringify(skills).includes("codenomad-browser"), "Bundled skill is visible in an arbitrary project")
callBrowser = true
await sample()
assert.equal(nativeCalls.length, 1)
assert.equal(nativeCalls[0].method, "browser.execute")
assert.equal(nativeCalls[0].params.sessionID, session.id)
const denied = await bridge.inject({ method: "POST", url: "/api/opencode-plugin/automation",
headers: { "x-codenomad-automation-token": registration.token },
payload: { mode: "developer-execute", sessionID: session.id, command: { action: "inspect" } },
})
assert.equal(denied.statusCode, 404)
second = await openPresence()
await first(); first = undefined
await delay(2_100)
assert.deepEqual(await sample(), allTools)
await second(); second = undefined
await until(async () => (await sample()).length === 0)
assert(!JSON.stringify(await client.skill.list({ location })).includes("codenomad-browser"))
first = await openPresence()
await until(async () => (await sample()).length === 4)
assert.deepEqual(await sample(), allTools)
console.log(`PASS ${version}: connected-daemon discovery despite backend environment mismatch, automation and pruning provisioning, stable native plugins across heartbeats, late native discovery, arbitrary project, all definitions without Developer Mode, browser execution, native target fences, independent leases, clean shutdown and reopening`)
} finally {
await first?.(); await second?.(); await removeBridge?.()
child?.kill()
if (stopped) await stopped
await bridge.close()
await new Promise(resolve => provider.close(() => resolve()))
for (const [key, value] of Object.entries(oldEnv)) {
if (value === undefined) delete process.env[key]
else process.env[key] = value
}
await rm(root, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 })
}