mirror of
https://github.com/NeuralNomadsAI/CodeNomad.git
synced 2026-09-13 11:50:37 +00:00
## Summary Restore selective deletion of completed assistant tool/reasoning content after OpenCode removed `session.messageUpdate`, without forking OpenCode or opening a generic RPC proxy. The original draft's unconditional `maintenance_required` stub is replaced by a working **experimental, explicit opt-in** plugin path for the audited `0.0.0-beta-19419` runtime. The default remains read-only. Unknown runtimes/storage and active native execution fail closed. Nothing is installed or enabled on app startup. ## Implementation - Keep individual/message/group/session cleanup entry points. Send selected technical indices and a canonical SHA-256 revision, never arbitrary replacement content, SQL, database filenames or caller-selected locations. - Use an ownership-checked CodeNomad broker with a fixed RPC ID/method/location and worktree-deletion fence. Keep the removed PATCH and generic RPC routes blocked. - Bind the plugin's explicitly configured database connection to the actual daemon DB with a fresh `ctx.storage` challenge. Inside a synchronous SQLite write transaction, recheck directory/project/workspace ownership, native durable execution claim, staged revert/compaction state, event ownership and retained payloads. - Rely on the audited native **write-ahead execution claim before runner history reads**, not an `idle` check, plugin mutex or `BEGIN IMMEDIATE` alone. Never set or release the native claim ourselves. - CAS only the completed assistant's content array. Preserve IDs, ordering, text, snapshots, usage and metadata. Commit a content-free idempotency receipt atomically with the change; identical retries cannot delete a second shifted block. - Emit the custom invalidation after commit. Re-read via the native API, invalidate the SDK transcript and pending rotations, and fence late reads against newer invalidations. Add a public-API TUI cache companion and reconnect handling. - Correct native Windows path encoding; support more than 4,096 selected parts within explicit request/message budgets. - Provide an independently packable plugin with main and `./tui` entrypoints, deployment/safety docs, and a Windows/Linux/macOS native package CI matrix. Plugin installation remains manual and separately approved. ## Validation performed locally | Check | Result | |---|---| | Server/plugin/SQLite/broker/proxy tests | 72 passed | | UI actions, events, SDK projection and stale-read tests | 60 passed | | Plugin/SQLite/TUI-companion tests under Node 22 | 30 passed | | Server and UI TypeScript checks | Passed | | Production UI build | Passed; existing large-chunk warnings | | Official Windows x64 beta-19419 executable | Passed on a private daemon and generated DB | | Independently packed and installed plugin | Same native test passed, outside the checkout | The native integration test uses the real beta-19271 network client against the official beta-19419 runtime, with a local synthetic provider and no credentials. It verifies actual preview/prune RPC, refusal while a primary generation holds the native claim, both prompt/transaction orderings, idempotent retry, two event subscribers, native re-reading, the subsequent primary model payload, fork isolation, pre-compaction history without summary changes, and persistence after server restart. The new three-OS native CI matrix must pass on this head before merge. No CI result is inferred from the local Windows run. ## Boundaries and remaining validation - This is a reviewable opt-in implementation, **not general availability across OpenCode versions/providers/platforms**. Exact runtime gating is intentional; don't widen it without re-auditing/testing. - Interactive official TUI, two native CodeNomad windows/scroll behavior, WSL, provider-specific continuation state and budget/token estimates are not claimed as validated. TUI companion tests use its cache contract, not an interactive terminal. Two HTTP subscribers are not two GUI windows. - Existing bulk cleanup is per-message and reports failure counts; dedicated installation/capability and bulk progress/cancel UI are not added here. - Existing summaries/native checkpoints, fork copies and already assembled/sent requests are not retroactively scrubbed. Historical usage remains historical usage; mock token values are not a token-savings measurement. - This changes persisted V2 content, not only visibility. It frees reusable SQLite pages but does **not** promise a smaller physical file. No VACUUM, V1 cleanup, durable-event rewriting, automatic backup restoration or universal repair is included. - No active user DB was changed, no shared-daemon plugin installed, no shared OpenCode service stopped/upgraded, and no native desktop executable/profile rebuilt or replaced. Tests use isolated synthetic data only. ## Documentation - [Request flow and validation](dev-docs/SESSION_PRUNING_RPC.md) - [Audited safety boundary](dev-docs/SESSION_PRUNING_SAFETY.md) - [Explicit deployment and recovery](dev-docs/SESSION_PRUNING_DEPLOYMENT.md) References: anomalyco/opencode#44984, anomalyco/opencode#48043, anomalyco/opencode#48090 and the maintainer's plugin/RPC direction. No upstream API or distribution fork is introduced.
276 lines
16 KiB
JavaScript
276 lines
16 KiB
JavaScript
// Explicit opt-in integration test. Never uses service discovery or a user DB.
|
|
import assert from "node:assert/strict"
|
|
import { spawn, execFileSync } from "node:child_process"
|
|
import { createHash } from "node:crypto"
|
|
import { createServer } from "node:http"
|
|
import { mkdtemp, mkdir, writeFile } from "node:fs/promises"
|
|
import os from "node:os"
|
|
import path from "node:path"
|
|
import { DatabaseSync } from "node:sqlite"
|
|
import { setTimeout as delay } from "node:timers/promises"
|
|
import { fileURLToPath, pathToFileURL } from "node:url"
|
|
import { OpenCode } from "@opencode-ai/client"
|
|
import { tsImport } from "tsx/esm/api"
|
|
|
|
const cli = process.argv[2]
|
|
if (!cli || !path.isAbsolute(cli)) throw new Error("Pass an absolute path to the CLI executable to test in isolation")
|
|
const runtimeVersion = execFileSync(cli, ["--version"], { encoding: "utf8" }).trim().replace(/^opencode2 v/, "")
|
|
const ui = process.argv.includes("--ui")
|
|
const pluginArgument = process.argv[3] === "--ui" ? undefined : process.argv[3]
|
|
const pluginDirectory = pluginArgument ?? fileURLToPath(new URL("../packages/server/src/opencode/session-pruning/", import.meta.url))
|
|
if (!path.isAbsolute(pluginDirectory)) throw new Error("Plugin directory must be absolute")
|
|
const temporaryRoot = path.join(os.tmpdir(), "opencode")
|
|
await mkdir(temporaryRoot, { recursive: true })
|
|
const root = await mkdtemp(path.join(temporaryRoot, "codenomad-pruning-native-"))
|
|
const env = Object.fromEntries(Object.entries(process.env).filter(([key]) => !key.startsWith("OPENCODE_") && !key.startsWith("XDG_")))
|
|
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,
|
|
OPENCODE_CONFIG_DIR: path.join(root, "config"), OPENCODE_DB: path.join(root, "test.db"),
|
|
OPENCODE_SERVER_PASSWORD: "isolated-pruning-fixture", OPENCODE_CONFIG_PROJECT_DISABLE: "1",
|
|
OPENCODE_DISABLE_MODELS_FETCH: "1", OPENCODE_DISABLE_FFF: "1",
|
|
})
|
|
await mkdir(env.OPENCODE_CONFIG_DIR)
|
|
const bundled = !pluginArgument
|
|
let closePresence
|
|
let openPresence
|
|
if (bundled) {
|
|
const { installPruningPresence } = await tsImport("../packages/server/src/opencode/pruning-installation.ts", import.meta.url)
|
|
const { readFile } = await import("node:fs/promises")
|
|
const bundle = await readFile(new URL("../packages/server/dist/plugins/session-pruning/plugin.mjs", import.meta.url))
|
|
openPresence = () => installPruningPresence(bundle, { config: env.OPENCODE_CONFIG_DIR, data: path.join(root, "codenomad") })
|
|
} else {
|
|
await mkdir(path.join(env.OPENCODE_CONFIG_DIR, "plugins"))
|
|
await writeFile(path.join(env.OPENCODE_CONFIG_DIR, "plugins", "codenomad-session-pruning.ts"),
|
|
`export { default } from ${JSON.stringify(pathToFileURL(path.join(pluginDirectory, "index.ts")).href)}\n`)
|
|
}
|
|
await mkdir(path.join(root, "plugin"))
|
|
const requests = []
|
|
let primaryCount = 0
|
|
let toolSteps = 1
|
|
let probeName = "prune_probe"
|
|
let held
|
|
let releaseProvider
|
|
const provider = createServer(async (request, response) => {
|
|
let raw = ""
|
|
for await (const chunk of request) raw += chunk
|
|
const body = JSON.parse(raw)
|
|
const kind = request.headers["x-pruning-test-kind"]
|
|
const text = kind === "compaction"
|
|
? ["## Objective", "## Requirements", "## Decisions", "## Work State", "### Completed", "### Active", "### Blocked", "## Next Move", "## Relevant Files", "## Important Context"].map(heading => `${heading}\n- Fixture`).join("\n\n")
|
|
: "Retain this conclusion"
|
|
requests.push({ kind, body })
|
|
if (kind === "primary" && held) await held
|
|
if (!body.stream) {
|
|
response.setHeader("Content-Type", "application/json")
|
|
response.end(JSON.stringify({ id: "aux", choices: [{ message: { role: "assistant", content: text }, finish_reason: "stop" }], usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 } }))
|
|
return
|
|
}
|
|
const tool = kind === "primary" && primaryCount++ < toolSteps
|
|
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 }],
|
|
...(finish_reason ? { usage: { prompt_tokens: 30, completion_tokens: 5, total_tokens: 35 } } : {}),
|
|
})}\n\n`)
|
|
chunk({ role: "assistant" })
|
|
if (tool) {
|
|
chunk({ reasoning_content: "REASONING_PRUNING_CANARY" })
|
|
chunk({ tool_calls: Array.from({ length: probeName === "bash" ? 2 : 1 }, (_, index) => ({ index, id: `fixture-call-${primaryCount}-${index}`, type: "function", function: { name: probeName, arguments: "{}" } })) })
|
|
} else chunk({ content: text })
|
|
chunk({}, tool ? "tool_calls" : "stop")
|
|
response.end("data: [DONE]\n\n")
|
|
})
|
|
await new Promise(resolve => provider.listen(0, "127.0.0.1", resolve))
|
|
await writeFile(path.join(root, "plugin", "index.ts"), `
|
|
export default { id:'pruning-test-fixture', async setup(ctx) {
|
|
await ctx.session.hook('http.request', event => event.request.headers.set('x-pruning-test-kind', event.kind))
|
|
await ctx.tool.transform(editor => editor.add({ name:'prune_probe', description:'Fixture', input:{type:'object',properties:{}}, options:{codemode:false}, execute:async()=>({content:'TOOL_PRUNING_CANARY'}) }))
|
|
${ui ? "await ctx.tool.transform(editor => editor.add({ name:'bash', description:'Isolated inert fixture', input:{type:'object',properties:{}}, options:{codemode:false}, execute:async()=>({content:'TOOL_PRUNING_CANARY'}) }))" : ""}
|
|
} }
|
|
`)
|
|
env.OPENCODE_CONFIG_CONTENT = JSON.stringify({
|
|
model: "fixture/fixture",
|
|
providers: { fixture: { package: "@opencode/ai/providers/openai-compatible", settings: {
|
|
baseURL: `http://127.0.0.1:${provider.address().port}/v1`, apiKey: "fixture-only",
|
|
}, models: { fixture: {} } } },
|
|
plugins: [path.join(root, "plugin")],
|
|
})
|
|
let output = ""
|
|
function start() {
|
|
const child = spawn(cli, ["serve", "--hostname", "127.0.0.1", "--port", "0", "--log-level", "debug", "--print-logs"], { cwd: root, env, windowsHide: true })
|
|
const stopped = new Promise(resolve => child.once("close", resolve))
|
|
child.stdout.on("data", data => { output += data })
|
|
child.stderr.on("data", data => { output += data })
|
|
return { child, stopped }
|
|
}
|
|
let { child, stopped } = start()
|
|
let db
|
|
const streams = new AbortController()
|
|
const canonical = value => Array.isArray(value) ? value.map(canonical)
|
|
: value && typeof value === "object" ? Object.fromEntries(Object.entries(value).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0).map(([key, entry]) => [key, canonical(entry)])) : value
|
|
const revision = content => createHash("sha256").update(JSON.stringify(canonical(content))).digest("hex")
|
|
async function until(predicate) {
|
|
for (let attempt = 0; attempt < 3000; attempt++) {
|
|
if (await predicate()) return
|
|
if (child.exitCode !== null) throw new Error("Isolated server exited early")
|
|
await delay(20)
|
|
}
|
|
throw new Error("Isolated test condition timed out")
|
|
}
|
|
try {
|
|
await until(() => /http:\/\/127\.0\.0\.1:\d+/.test(output))
|
|
const baseUrl = output.match(/http:\/\/127\.0\.0\.1:\d+/)[0]
|
|
const makeClient = () => OpenCode.make({ baseUrl, headers: {
|
|
Authorization: `Basic ${Buffer.from("opencode:isolated-pruning-fixture").toString("base64")}`,
|
|
} })
|
|
const client = makeClient()
|
|
assert.equal((await client.health.get()).version, runtimeVersion)
|
|
console.log(`Testing official runtime ${runtimeVersion}`)
|
|
const location = { directory: root }
|
|
const session = await client.session.create({ location })
|
|
// Install after the daemon and location exist: desktop startup must not need a restart.
|
|
if (bundled) closePresence = await openPresence()
|
|
await until(async () => {
|
|
const plugins = (await client.plugin.list({ location })).data
|
|
const failed = plugins.filter(item => item.source.type !== "builtin" && item.state.status === "failed")
|
|
if (failed.length) throw new Error(JSON.stringify(failed))
|
|
return plugins.some(item => item.id === "codenomad-session-pruning" && item.state.status === "active")
|
|
})
|
|
const first = [], second = []
|
|
const observe = (subscriber, events) => (async () => {
|
|
for await (const event of subscriber.event.subscribe({ signal: streams.signal })) events.push(event)
|
|
})().catch(error => { if (!streams.signal.aborted) throw error })
|
|
const observers = [observe(client, first), observe(makeClient(), second)]
|
|
await until(() => first.length && second.length)
|
|
const wait = () => client.session.wait({ sessionID: session.id }, { signal: AbortSignal.timeout(20_000) })
|
|
await client.session.prompt({ sessionID: session.id, text: "Use prune_probe, then conclude" })
|
|
await wait()
|
|
const messages = (await client.message.list({ sessionID: session.id, limit: 100, order: "asc" })).data
|
|
const target = messages.find(message => message.type === "assistant" && message.content.some(part => part.type === "tool"))
|
|
assert(target)
|
|
if (ui) {
|
|
const { testPruningUI } = await import("./test-session-pruning-ui.mjs")
|
|
await testPruningUI({ client, baseUrl, root, location, busy: async (sessionID) => {
|
|
held = new Promise(resolve => { releaseProvider = resolve })
|
|
const before = requests.length
|
|
await client.session.prompt({ sessionID, text: "Keep this request in flight" })
|
|
await until(() => requests.slice(before).some(item => item.kind === "primary"))
|
|
return async () => {
|
|
releaseProvider(); held = undefined
|
|
await client.session.wait({ sessionID }, { signal: AbortSignal.timeout(20_000) })
|
|
}
|
|
}, generate: async (sessionID) => {
|
|
primaryCount = 0
|
|
toolSteps = 2
|
|
probeName = "bash"
|
|
try {
|
|
await client.session.prompt({ sessionID, text: "Use the inert fixture tools, then conclude" })
|
|
await client.session.wait({ sessionID }, { signal: AbortSignal.timeout(20_000) })
|
|
} finally { toolSteps = 1; probeName = "prune_probe" }
|
|
} })
|
|
}
|
|
const fork = await client.session.fork({ sessionID: session.id, boundary: { type: "before", messageID: messages.at(-1).id } })
|
|
const forkTarget = (await client.message.list({ sessionID: fork.id, limit: 100, order: "asc" })).data.find(message => message.type === "assistant")
|
|
assert.deepEqual(forkTarget.content, target.content)
|
|
const preview = (await client.rpc.call({ rpcID: "codenomad.session-pruning", method: "preview", location, input: { sessionID: session.id, messageID: target.id } })).output
|
|
assert.equal(preview.status, "preview")
|
|
assert.equal(preview.liveMutation, true)
|
|
assert.equal(preview.revision, revision(target.content))
|
|
assert.deepEqual(preview.parts.map(part => part.type), ["reasoning", "tool"])
|
|
const input = { sessionID: session.id, messageID: target.id, revision: preview.revision, indexes: [0] }
|
|
const prune = () => client.rpc.call({ rpcID: "codenomad.session-pruning", method: "prune", location, input })
|
|
db = new DatabaseSync(env.OPENCODE_DB)
|
|
const claim = () => db.prepare("SELECT time_suspended FROM session_v2 WHERE id=?").get(session.id).time_suspended
|
|
held = new Promise(resolve => { releaseProvider = resolve })
|
|
const beforeBusy = requests.length
|
|
await client.session.prompt({ sessionID: session.id, text: "In-flight proof" })
|
|
await until(() => requests.slice(beforeBusy).some(item => item.kind === "primary"))
|
|
assert.notEqual(claim(), null)
|
|
assert.deepEqual((await prune()).output, { status: "blocked", reason: "maintenance_required" })
|
|
releaseProvider(); held = undefined
|
|
await wait()
|
|
assert.equal(claim(), null)
|
|
const result = (await prune()).output
|
|
assert.equal(result.status, "pruned")
|
|
assert.deepEqual((await prune()).output, result)
|
|
await until(() => [first, second].every(events => events.some(event => event.type === "rpc.codenomad.session-pruning.pruned" && event.data.messageID === target.id)))
|
|
assert.deepEqual((await client.session.message({ sessionID: session.id, messageID: target.id })).content, target.content.slice(1))
|
|
// The second ordering: native admission begins while our write lock is held.
|
|
db.exec("BEGIN IMMEDIATE")
|
|
assert.equal(claim(), null)
|
|
const before = requests.length
|
|
const competing = client.session.prompt({ sessionID: session.id, text: "Reply after cleanup" })
|
|
await delay(150)
|
|
assert.equal(requests.length, before)
|
|
db.prepare("UPDATE session_message SET data=json_set(data,'$.content',json('[]')) WHERE session_id=? AND id=?").run(session.id, target.id)
|
|
db.exec("COMMIT")
|
|
await competing; await wait()
|
|
const next = requests.slice(before).filter(item => item.kind === "primary")
|
|
assert(next.length > 0)
|
|
assert(!JSON.stringify(next).includes("TOOL_PRUNING_CANARY"))
|
|
assert(!JSON.stringify(next).includes("REASONING_PRUNING_CANARY"))
|
|
assert(JSON.stringify(next).includes("Retain this conclusion"))
|
|
assert.deepEqual((await client.session.message({ sessionID: session.id, messageID: target.id })).content, [])
|
|
assert.deepEqual((await client.session.message({ sessionID: fork.id, messageID: forkTarget.id })).content, target.content, "parent cleanup does not alter a fork's independent copy")
|
|
await client.session.compact({ sessionID: fork.id })
|
|
await client.session.wait({ sessionID: fork.id }, { signal: AbortSignal.timeout(20_000) })
|
|
const contextBefore = await client.session.context({ sessionID: fork.id })
|
|
assert(contextBefore.some(message => message.type === "compaction" && message.status === "completed"))
|
|
assert(!contextBefore.some(message => message.id === forkTarget.id))
|
|
const historical = { sessionID: fork.id, messageID: forkTarget.id, revision: revision(forkTarget.content), indexes: [0, 1] }
|
|
assert.equal((await client.rpc.call({ rpcID: "codenomad.session-pruning", method: "prune", location, input: historical })).output.status, "pruned")
|
|
assert.deepEqual((await client.session.message({ sessionID: fork.id, messageID: forkTarget.id })).content, [])
|
|
assert.deepEqual(await client.session.context({ sessionID: fork.id }), contextBefore, "deleting pre-compaction content does not rewrite or re-expand a summary")
|
|
assert.equal(db.prepare("PRAGMA integrity_check").get().integrity_check, "ok")
|
|
streams.abort(); await Promise.all(observers)
|
|
db.close(); db = undefined
|
|
child.kill(); await stopped
|
|
await writeFile(path.join(root, "before-restart.log"), output)
|
|
output = ""
|
|
;({ child, stopped } = start())
|
|
await until(() => /http:\/\/127\.0\.0\.1:\d+/.test(output))
|
|
const restarted = OpenCode.make({ baseUrl: output.match(/http:\/\/127\.0\.0\.1:\d+/)[0], headers: {
|
|
Authorization: `Basic ${Buffer.from("opencode:isolated-pruning-fixture").toString("base64")}`,
|
|
} })
|
|
assert.deepEqual((await restarted.session.message({ sessionID: session.id, messageID: target.id })).content, [])
|
|
assert.deepEqual((await restarted.session.message({ sessionID: fork.id, messageID: forkTarget.id })).content, [])
|
|
assert.deepEqual(await restarted.session.context({ sessionID: fork.id }), contextBefore)
|
|
if (bundled) {
|
|
const preview = () => restarted.rpc.call({ rpcID: "codenomad.session-pruning", method: "preview", location, input: { sessionID: session.id, messageID: target.id } })
|
|
await preview()
|
|
const closeSecond = await openPresence()
|
|
await closePresence()
|
|
await delay(2_200)
|
|
await preview() // Another CodeNomad backend keeps RPC registered.
|
|
await closeSecond()
|
|
await until(async () => {
|
|
try { await preview(); return false } catch (error) {
|
|
assert.match(JSON.stringify(error), /rpc|not.found/i)
|
|
return true
|
|
}
|
|
})
|
|
closePresence = await openPresence()
|
|
await until(async () => { try { await preview(); return true } catch { return false } })
|
|
await closePresence()
|
|
const { writeFile, rm } = await import("node:fs/promises")
|
|
const crashLease = path.join(root, "codenomad", "session-pruning", "presence", "dead.lease")
|
|
await writeFile(crashLease, "") // A crashed backend leaves a lease with no heartbeat.
|
|
await preview()
|
|
await until(async () => { try { await preview(); return false } catch { return true } })
|
|
await rm(crashLease)
|
|
assert.deepEqual((await restarted.session.message({ sessionID: session.id, messageID: target.id })).content, [])
|
|
console.log("PASS: shipped bundle, automatic discovery, multiple backends, final close disposes RPC, reopening restores RPC")
|
|
console.log("PASS: installation on an already-running daemon and crash expiry without stopping OpenCode")
|
|
}
|
|
console.log("PASS: native plugin preview/prune RPC, active-claim refusal, idempotent retry, two subscribers, competing prompt, next model payload, fork isolation, pre-compaction history and restart")
|
|
} finally {
|
|
await closePresence?.()
|
|
streams.abort(); releaseProvider?.()
|
|
if (db?.isTransaction) db.exec("ROLLBACK")
|
|
db?.close()
|
|
child.kill(); await stopped
|
|
provider.closeAllConnections(); provider.close()
|
|
await writeFile(path.join(root, "server.log"), output)
|
|
console.log(`Isolated fixture retained at ${root}`)
|
|
}
|