fix(sessions): keep Stop and double Escape from restarting the parent (#770)

Match OpenCode's TUI by interrupting only the selected session with resume:true. The previous Stop/double-Escape action interrupted active descendants in parallel; a background child's completion notification could wake the parent after its interruption had already settled, producing new model activity after Stop.

Keep background children independently owned and directly interruptible when selected. Native control/steering input retains TUI resume semantics rather than introducing a separate CodeNomad cancellation policy.

Cover selected parent/child scope in store tests and both gestures through real Chromium composer and keyboard handlers using the generated native client. Include an isolated native fixture with a temporary HOME/database, private server and loopback provider that reproduces the cascade restart and verifies selected-session interruption. UI typecheck, production build and both browser regressions passed on the reviewed branch; full-CI failure triage is documented in the PR.
This commit is contained in:
Pascal André 2026-09-24 21:54:09 +02:00 • committed by GitHub
parent 66bd8a79ae
commit abbfcd3711
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 249 additions and 11 deletions

View file

@ -313,9 +313,9 @@ describe("session instruction sync", () => {
})
describe("session interruption", () => {
it("interrupts the selected session and its active descendants", async () => {
const interrupted: string[] = []
seed({ session: { interrupt: async ({ sessionID }: { sessionID: string }) => { interrupted.push(sessionID) } } })
it("matches TUI interruption without cancelling children that can wake the parent again", async () => {
const interrupted: unknown[] = []
seed({ session: { interrupt: async (input: unknown) => { interrupted.push(input) } } })
const root = sessions().get(instanceId)!.get(sessionId)!
setSessions(new Map([[instanceId, new Map([
[sessionId, root],
@ -326,7 +326,11 @@ describe("session interruption", () => {
await abortSession(instanceId, sessionId)
assert.deepEqual(interrupted.sort(), ["child-working", "grandchild-working", sessionId].sort())
assert.deepEqual(interrupted, [{ sessionID: sessionId, resume: true }])
// A child selected directly is still independently interruptible.
await abortSession(instanceId, "child-working")
assert.deepEqual(interrupted[1], { sessionID: "child-working", resume: true })
})
})

View file

@ -12,7 +12,7 @@ import { canonicalContent } from "../../../server/src/opencode/session-pruning/r
import type { ClientPart } from "../types/message"
import { addRecentModelPreference, getModelThinkingSelection, setAgentModelPreference } from "./preferences"
import { beginSessionGenerationAdmission, getDescendantSessions, providers, sessions, withSession } from "./session-state"
import { beginSessionGenerationAdmission, providers, sessions, withSession } from "./session-state"
import { isSessionBusy } from "./session-status"
import { getDefaultModel, isModelValid } from "./session-models"
import { updateSessionInfo } from "./message-v2/session-info"
@ -402,12 +402,11 @@ async function abortSession(instanceId: string, sessionId: string): Promise<void
log.info("abortSession", { instanceId, sessionId })
try {
const descendantIds = getDescendantSessions(instanceId, sessionId)
.filter((session) => isSessionBusy(instanceId, session.id))
.map((session) => session.id)
const sessionIds = [...descendantIds, sessionId]
log.info("session.interrupt", { instanceId, sessionIds })
await Promise.all(sessionIds.map((targetSessionId) => client.session.interrupt({ sessionID: targetSessionId })))
// Match the TUI: interrupt the selected execution, not its session tree.
// Cancelling background children can publish completion notifications that
// wake this parent again after its interruption has already settled.
log.info("session.interrupt", { instanceId, sessionId })
await client.session.interrupt({ sessionID: sessionId, resume: true })
log.info("abortSession complete", { instanceId, sessionId })
} catch (error) {
log.error("Failed to abort session", error)

View file

@ -0,0 +1,38 @@
import { createSignal } from "solid-js"
import { render } from "solid-js/web"
import PromptInput from "../../../src/components/prompt-input"
import { ConfigProvider } from "../../../src/stores/preferences"
import { I18nProvider } from "../../../src/lib/i18n"
import { ThemeProvider } from "../../../src/lib/theme"
import { getRootClient } from "../../../src/stores/opencode-client"
import { addInstance, instances, setActiveInstanceId } from "../../../src/stores/instances"
import { abortSession } from "../../../src/stores/session-actions"
import { sessions, setSessions } from "../../../src/stores/session-state"
import { useAppLifecycle } from "../../../src/lib/hooks/use-app-lifecycle"
import type { Session } from "../../../src/types/session"
import "../../../src/index.css"
const instanceId = "interrupt-instance"
const [selected, setSelected] = createSignal("ses_parent")
const client = getRootClient(instanceId)
addInstance({ id: instanceId, folder: "/fixture", port: 0, pid: 0, proxyPath: "", status: "ready", client })
setActiveInstanceId(instanceId)
const root = { id: "ses_parent", instanceId, title: "Parent", parentId: null, agent: "build",
model: { providerId: "fixture", modelId: "fixture" }, status: "working", time: { created: 1, updated: 1 } } as Session
setSessions(new Map([[instanceId, new Map([
[root.id, root],
["ses_child", { ...root, id: "ses_child", parentId: root.id }],
["ses_grandchild", { ...root, id: "ses_grandchild", parentId: "ses_child" }],
])]]))
function Fixture() {
useAppLifecycle({
setEscapeInDebounce: () => {}, handleNewInstanceRequest: () => {}, handleCloseActiveTab: async () => {},
handleNewSession: async () => {}, handleCloseSession: async () => {}, showFolderSelection: () => false,
setShowFolderSelection: () => {}, getActiveInstance: () => instances().get(instanceId)!,
getActiveSessionIdForInstance: selected,
})
return <PromptInput instanceId={instanceId} instanceFolder="/fixture" sessionId={selected()} isActive={true}
isSessionBusy={true} onAbortSession={() => abortSession(instanceId, selected())} onSend={async () => {}} />
}
render(() => <ConfigProvider><I18nProvider><ThemeProvider><Fixture /></ThemeProvider></I18nProvider></ConfigProvider>, document.getElementById("root")!)
;(window as any).fixture = { select: setSelected, sessionCount: () => sessions().get(instanceId)?.size }

View file

@ -0,0 +1,57 @@
import assert from "node:assert/strict"
import { after, before, test } from "node:test"
import { fileURLToPath } from "node:url"
import { chromium, type Browser } from "playwright"
import { createServer, type ViteDevServer } from "vite"
import solid from "vite-plugin-solid"
let server: ViteDevServer, browser: Browser, url: string
before(async () => {
server = await createServer({ configFile: false, root: fileURLToPath(new URL("../..", import.meta.url)), logLevel: "error",
plugins: [solid(), { name: "interrupt-fixture", configureServer(s) {
s.middlewares.use("/interrupt-fixture", async (_req, res) => {
res.setHeader("Content-Type", "text/html")
res.end(await s.transformIndexHtml("/interrupt-fixture", '<html><body><div id="root"></div><script type="module" src="/tests/browser/fixtures/session-interrupt.tsx"></script></body></html>'))
})
} }], resolve: { dedupe: ["solid-js"] }, optimizeDeps: { exclude: ["lucide-solid"] },
server: { host: "127.0.0.1", port: 0, hmr: false, watch: null },
})
await server.listen()
url = `http://127.0.0.1:${(server.httpServer!.address() as { port: number }).port}/interrupt-fixture`
browser = await chromium.launch({ executablePath: process.env.CODENOMAD_BROWSER_PATH || undefined })
})
after(async () => { await browser?.close(); await server?.close() })
for (const gesture of ["button", "double Escape"] as const) {
test(`${gesture} interrupts only the selected session using the TUI request`, async () => {
const page = await browser.newPage({ locale: "en-US" })
const requests: string[] = [], errors: string[] = []
page.on("pageerror", error => errors.push(error.message))
await page.route("**/api/**", async route => {
const url = new URL(route.request().url())
if (url.pathname.endsWith("/interrupt")) {
requests.push(url.pathname + url.search)
return route.fulfill({ json: { interrupted: true } })
}
return route.fulfill({ json: {} })
})
try {
await page.goto(url)
await page.waitForFunction(() => Boolean((window as any).fixture))
for (const id of ["ses_parent", "ses_child"]) {
await page.evaluate(id => (window as any).fixture.select(id), id)
const response = page.waitForResponse(response => response.url().includes(`/api/session/${id}/interrupt`))
if (gesture === "button") await page.getByRole("button", { name: "Stop session", exact: true }).click()
else {
await page.locator("textarea").first().focus()
await page.keyboard.press("Escape")
assert.equal(requests.length, id === "ses_parent" ? 0 : 1)
await page.keyboard.press("Escape")
}
await response
}
assert.deepEqual(requests, ["ses_parent", "ses_child"].map(id => `/workspaces/interrupt-instance/instance/api/session/${id}/interrupt?resume=true`))
assert.deepEqual(errors, [])
} finally { await page.close() }
})
}

View file

@ -0,0 +1,140 @@
// Explicit isolated native regression: no service discovery or real provider.
import assert from "node:assert/strict"
import { spawn } from "node:child_process"
import { createServer } from "node:http"
import { mkdtemp, mkdir, writeFile } from "node:fs/promises"
import os from "node:os"
import path from "node:path"
import { setTimeout as delay } from "node:timers/promises"
import { OpenCode } from "@opencode/client"
const cli = process.argv[2]
if (!cli || !path.isAbsolute(cli)) throw new Error("Pass an absolute CLI path; shared service discovery is forbidden")
const temporary = path.join(os.tmpdir(), "opencode")
await mkdir(temporary, { recursive: true })
const root = await mkdtemp(path.join(temporary, "interrupt-native-"))
const env = Object.fromEntries(Object.entries(process.env).filter(([key]) => !/^(OPENCODE_|XDG_)/i.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, {
HOME: root, USERPROFILE: root, OPENCODE_TEST_HOME: root,
OPENCODE_CONFIG_DIR: path.join(root, "config"), OPENCODE_DB: path.join(root, "test.db"),
OPENCODE_SERVER_PASSWORD: "interrupt-fixture", OPENCODE_CONFIG_PROJECT_DISABLE: "1",
OPENCODE_DISABLE_MODELS_FETCH: "1", OPENCODE_DISABLE_FFF: "1",
})
await mkdir(env.OPENCODE_CONFIG_DIR)
await mkdir(path.join(root, "plugin"))
await writeFile(path.join(root, "plugin", "index.ts"), `export default { id: 'interrupt-fixture', async setup(ctx) {
await ctx.session.hook('http.request', event => {
event.request.headers.set('x-fixture-kind', event.kind)
event.request.headers.set('x-fixture-session', event.sessionID)
})
} }`)
const requests = new Map()
let parentID
let providerError
const provider = createServer(async (request, response) => {
try {
let raw = ""
for await (const chunk of request) raw += chunk
const body = JSON.parse(raw)
if (request.headers["x-fixture-kind"] !== "primary") {
response.setHeader("Content-Type", "application/json")
response.end(JSON.stringify({ id: "aux", choices: [{ message: { role: "assistant", content: "Fixture" }, finish_reason: "stop" }] }))
return
}
const sessionID = request.headers["x-fixture-session"]
const count = (requests.get(sessionID) ?? 0) + 1
requests.set(sessionID, count)
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 (sessionID === parentID && count === 1) {
const tool = body.tools.find(tool => tool.function?.name === "subagent")
assert(tool, "Native subagent tool must be available")
chunk({ tool_calls: [{ index: 0, id: "fixture-child", type: "function", function: {
name: tool.function.name,
arguments: JSON.stringify({ agent: "general", description: "Isolated interruption fixture", prompt: "Hold the response", background: true }),
} }] })
chunk({}, "tool_calls")
response.end("data: [DONE]\n\n")
}
// Parent follow-up and child stream remain open until native interruption.
} catch (error) { providerError = error; response.destroy(error) }
})
await new Promise(resolve => provider.listen(0, "127.0.0.1", resolve))
env.OPENCODE_CONFIG_CONTENT = JSON.stringify({
model: "fixture/fixture", plugins: [path.join(root, "plugin")],
providers: { fixture: { package: "@opencode/ai/providers/openai-compatible", settings: {
baseURL: `http://127.0.0.1:${provider.address().port}/v1`, apiKey: "fixture-only",
}, models: { fixture: {} } } },
})
let output = ""
const child = spawn(cli, ["serve", "--hostname", "127.0.0.1", "--port", "0", "--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 })
async function until(predicate) {
for (let attempt = 0; attempt < 1000; attempt++) {
if (providerError) throw providerError
if (await predicate()) return
if (child.exitCode !== null) throw new Error("Isolated native server exited")
await delay(20)
}
throw new Error("Isolated condition timed out")
}
try {
await until(() => /http:\/\/127\.0\.0\.1:\d+/.test(output))
const client = OpenCode.make({ baseUrl: output.match(/http:\/\/127\.0\.0\.1:\d+/)[0], headers: {
Authorization: `Basic ${Buffer.from("opencode:interrupt-fixture").toString("base64")}`,
} })
console.log("Runtime", (await client.server.info()).version)
for (const cascade of [true, false]) {
const session = await client.session.create({ location: { directory: root }, model: { providerID: "fixture", id: "fixture" } })
parentID = session.id
await client.session.prompt({ sessionID: parentID, text: "Start the background fixture" })
await until(() => requests.get(parentID) === 2)
const list = await client.session.list({ location: { directory: root } })
const descendant = list.data.find(item => item.parentID === parentID)
assert(descendant)
await until(() => requests.has(descendant.id))
if (cascade) {
// Parallel HTTP requests need not reach the daemon in dispatch order:
// model a child request delayed by proxy ownership validation.
let parentSettled
const settled = new Promise(resolve => { parentSettled = resolve })
await Promise.all([descendant.id, parentID].map(async sessionID => {
if (sessionID === descendant.id) await settled
await client.session.interrupt({ sessionID })
if (sessionID === parentID) {
await client.session.wait({ sessionID }, { signal: AbortSignal.timeout(10_000) })
parentSettled()
}
}))
await until(() => requests.get(parentID) > 2)
console.log("REPRODUCED: descendant interruption wakes the stopped parent")
} else {
await client.session.interrupt({ sessionID: parentID, resume: true })
await client.session.wait({ sessionID: parentID }, { signal: AbortSignal.timeout(10_000) })
await delay(500)
assert.equal(requests.get(parentID), 2, "Selected-session stop must not restart parent")
assert((await client.session.active())[descendant.id], "Background child remains independently owned")
console.log("PASS: TUI-compatible selected-session interruption stays stopped")
}
await client.session.interrupt({ sessionID: descendant.id, resume: false })
await client.session.wait({ sessionID: descendant.id }, { signal: AbortSignal.timeout(10_000) })
await delay(200)
await client.session.interrupt({ sessionID: parentID, resume: false })
}
} catch (error) {
await writeFile(path.join(root, "native.log"), output)
console.error("Evidence:", root)
throw error
} finally {
child.kill()
await stopped
provider.closeAllConnections()
await new Promise(resolve => provider.close(resolve))
}