fix(tui): always dismiss stale forms

This commit is contained in:
Kit Langton 2026-07-12 21:29:17 -04:00
commit ad264cca37
6 changed files with 85 additions and 46 deletions

View file

@ -1073,9 +1073,7 @@ test("tracks session status from active sessions and execution events", async ()
return message?.type === "compaction" && message.status === "running" && message.summary === "Streamed summary"
})
expect(data.session.compaction.list("session-manual")).toEqual([])
const compactionRow = manualRows.find(
(row) => row.type === "message" && row.messageID === "message-compaction",
)
const compactionRow = manualRows.find((row) => row.type === "message" && row.messageID === "message-compaction")
emitEvent(events, {
id: "evt_manual_compaction_ended",
created: 3,
@ -1117,9 +1115,7 @@ test("tracks session status from active sessions and execution events", async ()
const message = data.session.message.get("session-live", "msg_compaction_started")
return message?.type === "compaction" && message.status === "running" && message.summary === "Live summary"
})
const autoCompactionRow = rows.find(
(row) => row.type === "message" && row.messageID === "msg_compaction_started",
)
const autoCompactionRow = rows.find((row) => row.type === "message" && row.messageID === "msg_compaction_started")
emitEvent(events, {
id: "evt_compaction_ended",
@ -1192,10 +1188,7 @@ test("restores queued compaction from durable pending input", async () => {
try {
await wait(() => data.session.compaction.list(sessionID).length === 2)
expect(data.session.compaction.list(sessionID)).toEqual([
"message-compaction-queued",
"message-compaction-later",
])
expect(data.session.compaction.list(sessionID)).toEqual(["message-compaction-queued", "message-compaction-later"])
await wait(() => rows.filter((row) => row.type === "compaction-queued").length === 2)
expect(rows.filter((row) => row.type === "compaction-queued")).toEqual([
{ type: "compaction-queued", inputID: "message-compaction-queued" },
@ -1736,9 +1729,20 @@ test("adds, dismisses, and refreshes form requests", async () => {
})
await wait(() => data.session.form.list("ses_1")?.length === 1)
data.session.form.dismiss("ses_1", "frm_1")
await wait(() => data.session.form.list("ses_1")?.length === 0)
emitEvent(events, {
id: "evt_form_created_after_local_dismiss",
created: 2,
type: "form.created",
data: { form: { id: "frm_1", sessionID: "ses_1", title: "Input requested", fields: formFields } },
})
await wait(() => data.session.form.list("ses_1")?.length === 1)
emitEvent(events, {
id: "evt_form_replied_1",
created: 2,
created: 3,
type: "form.replied",
data: { sessionID: "ses_1", id: "frm_1", answer: {} },
})
@ -1746,13 +1750,13 @@ test("adds, dismisses, and refreshes form requests", async () => {
emitEvent(events, {
id: "evt_form_created_2",
created: 3,
created: 4,
type: "form.created",
data: { form: { id: "frm_2", sessionID: "ses_1", title: "Input requested", fields: formFields } },
})
emitEvent(events, {
id: "evt_form_cancelled_2",
created: 4,
created: 5,
type: "form.cancelled",
data: { sessionID: "ses_1", id: "frm_2" },
})
@ -2357,8 +2361,7 @@ function sessionInfo(id: string, parentID: string | undefined, cost = 0) {
async function mountData(parents: Record<string, string>, costs: Record<string, number> = {}) {
const calls = createFetch((url) => {
const match = url.pathname.match(/^\/api\/session\/([^/]+)$/)
if (match && match[1] !== "active")
return json({ data: sessionInfo(match[1], parents[match[1]], costs[match[1]]) })
if (match && match[1] !== "active") return json({ data: sessionInfo(match[1], parents[match[1]], costs[match[1]]) })
})
let data!: ReturnType<typeof useData>
let ready!: () => void
@ -2427,10 +2430,7 @@ test("indexes arbitrarily deep nesting under a single root", async () => {
})
test("totals family cost for roots and keeps subagent cost scoped", async () => {
const { data, app } = await mountData(
{ grandchild: "child", child: "root" },
{ root: 1, child: 2, grandchild: 3 },
)
const { data, app } = await mountData({ grandchild: "child", child: "root" }, { root: 1, child: 2, grandchild: 3 })
try {
await data.session.refresh("grandchild")
await data.session.refresh("child")

View file

@ -4,7 +4,7 @@ import { testRender, useRenderer } from "@opentui/solid"
import { expect, test } from "bun:test"
import { mkdir } from "node:fs/promises"
import path from "node:path"
import { onCleanup } from "solid-js"
import { createSignal, onCleanup, Show } from "solid-js"
import { ClipboardProvider } from "../../../src/context/clipboard"
import type { FormWithLocation } from "../../../src/context/data"
import { KVProvider } from "../../../src/context/kv"
@ -24,16 +24,25 @@ async function mountForm(root: string, width = 80) {
await Bun.write(path.join(state, "kv.json"), "{}")
const replies: unknown[] = []
const cancellations: string[] = []
const copied: string[] = []
const events = createEventStream()
const transport = createFetch(
(url, request) =>
url.pathname === "/api/session/ses_test/form/frm_test/reply"
? request.json().then((answer) => {
replies.push(answer)
return new Response(null, { status: 204 })
})
: undefined,
url.pathname === "/api/session/ses_test/form/frm_test/cancel"
? (() => {
cancellations.push("frm_test")
return Response.json(
{ _tag: "FormNotFoundError", id: "frm_test", message: "Form not found: frm_test" },
{ status: 404 },
)
})()
: url.pathname === "/api/session/ses_test/form/frm_test/reply"
? request.json().then((answer) => {
replies.push(answer)
return new Response(null, { status: 204 })
})
: undefined,
events,
)
const config = createTuiResolvedConfig()
@ -57,6 +66,7 @@ async function mountForm(root: string, width = 80) {
const keymap = createDefaultOpenTuiKeymap(renderer)
const off = registerOpencodeKeymap(keymap, renderer, config)
onCleanup(off)
const [visible, setVisible] = createSignal(true)
return (
<TestTuiContexts
@ -81,7 +91,9 @@ async function mountForm(root: string, width = 80) {
<KVProvider>
<ThemeProvider mode="dark" source={{ discover: () => Promise.resolve({}) }}>
<ToastProvider>
<FormPrompt form={form} />
<Show when={visible()}>
<FormPrompt form={form} onDismiss={() => setVisible(false)} />
</Show>
</ToastProvider>
</ThemeProvider>
</KVProvider>
@ -96,9 +108,22 @@ async function mountForm(root: string, width = 80) {
const app = await testRender(() => <Harness />, { width, height: 20, kittyKeyboard: true })
app.renderer.start()
await app.waitForFrame((frame) => frame.includes("Authorization required"))
return { app, copied, replies }
return { app, cancellations, copied, replies }
}
test("dismisses locally when the server no longer has the form", async () => {
await using tmp = await tmpdir()
const prompt = await mountForm(tmp.path)
try {
prompt.app.mockInput.pressEscape()
await prompt.app.waitForFrame((frame) => !frame.includes("Authorization required"))
await prompt.app.waitFor(() => prompt.cancellations.length === 1)
expect(prompt.cancellations).toEqual(["frm_test"])
} finally {
prompt.app.renderer.destroy()
}
})
test("requires explicit acknowledgement before submitting an external field", async () => {
await using tmp = await tmpdir()
const prompt = await mountForm(tmp.path)