Compare commits

...
Author SHA1 Message Date
Kit Langton 7c4662e449 fix(tui): retain question outcomes across cache retirement
Capture reactive per-attempt submission values before acknowledgement and update them before retiring the lookup. This preserves canonical answers when hydration and sibling retirement share an SSE batch, independent of Solid effect order.
2026-08-28 20:26:56 -04:00
Kit Langton 6d0c46461c test(client): preserve focused test command routing 2026-08-28 18:35:11 -04:00
Kit Langton 040d67e7ae fix(client): publish question outcomes before retirement
Let pure host memos consume canonical or cleared terminal answers before the Solid retirement effect removes their bridge. Run the form projection suite with reactive browser exports while retaining Node conditions for service and import-boundary tests.
2026-08-28 18:29:45 -04:00
Kit Langton 87f24e459a fix(tui): retain scoped question reply previews
Ignore late form-state results after their submission owner disappears. Keep descendant answers in the host session and reconcile tool completion without hydrating child history.
2026-08-28 17:53:22 -04:00
Kit Langton 114c96aa6d fix(tui): scope question previews to tool invocations 2026-08-28 16:26:58 -04:00
Kit Langton afafa2feda fix(tui): render selected question answers immediately 2026-08-28 16:16:22 -04:00
Kit Langton f97696cdd8 fix(tui): submit fullscreen permissions inline 2026-08-28 15:48:36 -04:00
Kit Langton aafdb24f89 fix(tui): acknowledge question and permission submissions 2026-08-28 15:48:36 -04:00
15 changed files with 2107 additions and 68 deletions
+6
View File
@@ -25,3 +25,9 @@ yield *
})
yield * client.sessions.prompt({ sessionID, prompt: Prompt.make({ text: "Hello" }) })
```
## Testing
Run `bun run test` from this package. The standard API, service-fixture, and import-boundary suites retain Node export conditions, including focused commands such as `bun run test test/solid-data.test.ts`. One normally discovered wrapper test launches the question-form projection suite in an isolated Bun subprocess with `--conditions=browser`; its explicit `.browser.ts` path is not discovered recursively. Use `bun run test:browser` to run or filter that suite directly.
`createData` from `@opencode-ai/client/solid` expects a reactive Solid owner, as supplied by browser consumers or the TUI preload. Solid's Node exports are the SSR runtime, where effects do not execute; those exports cannot verify batched terminal publication, pure-memo observation, or effect-driven retirement. The reactive test condition is scoped to the projection suite rather than changing native dependency resolution or adding an SSR compatibility branch to runtime code.
+1
View File
@@ -30,6 +30,7 @@
"generate": "bun run script/build.ts",
"check:generated": "bun run generate && git diff --exit-code -- src/promise/generated src/effect/generated src/effect/api",
"test": "bun test --timeout 5000",
"test:browser": "bun test --conditions=browser --timeout 5000 ./test/solid-form.browser.ts",
"typecheck": "tsgo --noEmit"
},
"dependencies": {
+191 -1
View File
@@ -216,6 +216,22 @@ export function createData(config: CreateDataInput) {
)
const messageIndex = new Map<string, Map<string, number>>()
const sync = createSync()
// Display-only question answers bridge form acknowledgement to tool metadata.
// Keep in-flight confirmation until the POST settles, even if metadata arrives first.
const [formAnswers, setFormAnswers] = createStore<
Record<
string,
| {
form: FormWithLocation
tool: { id: string; messageID: string }
answer: FormReplyInput["answer"] | undefined
confirmed: boolean
posting: boolean
toolDone: boolean
}
| undefined
>
>({})
let activeUpdates: Map<string, DataSessionStatus | undefined> | undefined
function setSessionActive(sessionID: string, status: DataSessionStatus) {
@@ -266,16 +282,117 @@ export function createData(config: CreateDataInput) {
return true
}
function previewForm(
form: FormWithLocation | undefined,
answer: FormReplyInput["answer"] | undefined,
confirmed: boolean,
) {
const tool = form?.metadata?.tool
if (
!form ||
form.sessionID === "global" ||
form.metadata?.kind !== "question" ||
!tool ||
typeof tool !== "object" ||
!("id" in tool) ||
typeof tool.id !== "string" ||
!("messageID" in tool) ||
typeof tool.messageID !== "string"
)
return
const entry = formAnswers[form.id]
if (
confirmed &&
entry?.form.sessionID === form.sessionID &&
entry.tool.id === tool.id &&
entry.tool.messageID === tool.messageID
) {
setFormAnswers(form.id, { answer, confirmed })
return
}
// A new owner/attempt must not mutate a previous host's retained submission.
const next = {
form,
tool: { id: tool.id, messageID: tool.messageID },
answer,
confirmed,
posting: !confirmed,
toolDone: false,
}
setFormAnswers(
produce((draft) => {
draft[form.id] = next
}),
)
}
function removeFormAnswer(formID: string) {
setFormAnswers(
produce((draft) => {
delete draft[formID]
}),
)
}
function finishFormAnswer(formID: string, metadata: unknown, failed: boolean) {
const entry = formAnswers[formID]
if (!entry) return
const answer = Array.isArray(metadata)
? Object.fromEntries(
entry.form.fields.flatMap((field, index) => {
const raw = metadata[index]
const values = Array.isArray(raw) ? raw.filter((value): value is string => typeof value === "string") : []
const value = field.type === "multiselect" ? values : values[0]
return value === undefined ? [] : [[field.key, value] as const]
}),
)
: entry.answer
setFormAnswers(formID, { answer: failed ? undefined : answer, confirmed: true, toolDone: true })
removeForm(entry.form.sessionID, formID)
if (!entry.posting) removeFormAnswer(formID)
}
function settleForm(input: FormCancelInput, ref: LocationRef | undefined, request: Promise<void>) {
const submission = formAnswers[input.formID]
return request
.catch((error: unknown) => {
.catch(async (error: unknown) => {
const entry = formAnswers[input.formID]
if (submission && entry !== submission) return
if (entry?.form.sessionID === input.sessionID && entry.confirmed) return
if (entry?.form.sessionID === input.sessionID) {
// A lost POST or competing reply can settle the form before tool metadata arrives.
const state = await api()
.form.state(input, formRequestOptions(input.sessionID, ref))
.catch(() => undefined)
const current = formAnswers[input.formID]
if (current !== submission || current?.form.sessionID !== input.sessionID) return
if (current.confirmed) return
if (state?.status === "answered" || state?.status === "cancelled") {
setFormAnswers(input.formID, {
answer: state.status === "answered" ? state.answer : undefined,
confirmed: true,
})
return
}
removeFormAnswer(input.formID)
}
if ((!isFormNotFoundError(error) && !isFormAlreadySettledError(error)) || error.id !== input.formID) throw error
})
.then(() => {
const entry = formAnswers[input.formID]
if (entry && entry !== submission) return
if (formAnswers[input.formID]?.form.sessionID === input.sessionID)
setFormAnswers(input.formID, "confirmed", true)
if (!removeForm(input.sessionID, input.formID, ref)) return
result.session.form.invalidate(input.sessionID, ref)
void result.session.form.sync(input.sessionID, ref).catch(() => undefined)
})
.finally(() => {
const entry = formAnswers[input.formID]
if (entry !== submission || entry?.form.sessionID !== input.sessionID) return
setFormAnswers(input.formID, "posting", false)
if (entry.toolDone) removeFormAnswer(input.formID)
})
}
function updatePending(sessionID: string, inboxID: string, delivery: SessionInbox.Delivery) {
@@ -493,6 +610,11 @@ export function createData(config: CreateDataInput) {
}
function removeSession(sessionID: string) {
Object.entries(formAnswers).forEach(([id, entry]) => {
if (entry?.form.sessionID !== sessionID) return
setFormAnswers(id, "answer", undefined)
removeFormAnswer(id)
})
activeUpdates?.set(sessionID, undefined)
store.session.pending[sessionID]?.forEach((item) => outbox.delete(item.id))
messageIndex.delete(sessionID)
@@ -936,6 +1058,14 @@ export function createData(config: CreateDataInput) {
match.providerResultState = event.data.resultState
match.time.completed = event.created
})
Object.entries(formAnswers).forEach(([id, entry]) => {
if (
entry?.form.sessionID === event.data.sessionID &&
entry.tool.messageID === event.data.assistantMessageID &&
entry.tool.id === event.data.id
)
finishFormAnswer(id, event.data.metadata?.answers, false)
})
return
case "session.tool.failed":
message.update(event.data.sessionID, (draft, index) => {
@@ -955,6 +1085,14 @@ export function createData(config: CreateDataInput) {
match.providerResultState = event.data.resultState
match.time.completed = event.created
})
Object.entries(formAnswers).forEach(([id, entry]) => {
if (
entry?.form.sessionID === event.data.sessionID &&
entry.tool.messageID === event.data.assistantMessageID &&
entry.tool.id === event.data.id
)
finishFormAnswer(id, undefined, true)
})
return
case "session.reasoning.started":
message.update(event.data.sessionID, (draft, index) => {
@@ -1120,7 +1258,18 @@ export function createData(config: CreateDataInput) {
removePermission(event.data.sessionID, event.data.requestID)
return
case "form.replied":
previewForm(
formAnswers[event.data.id]?.form.sessionID === event.data.sessionID
? formAnswers[event.data.id]?.form
: store.session.form[event.data.sessionID]?.find((form) => form.id === event.data.id),
event.data.answer,
true,
)
removeForm(event.data.sessionID, event.data.id, event.location)
return
case "form.cancelled":
if (formAnswers[event.data.id]?.form.sessionID === event.data.sessionID)
setFormAnswers(event.data.id, { answer: undefined, confirmed: true })
removeForm(event.data.sessionID, event.data.id, event.location)
return
}
@@ -1621,6 +1770,25 @@ export function createData(config: CreateDataInput) {
},
},
form: {
submission(sessionID: string, formID: string) {
const entry = formAnswers[formID]
if (entry?.form.sessionID !== sessionID) return undefined
// Hosts retain this owner's reactive values even after the lookup is retired.
return {
get answer() {
return entry.answer
},
get confirmed() {
return entry.confirmed
},
}
},
answer(sessionID: string, messageID: string, toolID: string) {
return Object.values(formAnswers).find(
(entry) =>
entry?.form.sessionID === sessionID && entry.tool.messageID === messageID && entry.tool.id === toolID,
)?.answer
},
list(sessionID: string, ref?: LocationRef) {
const forms = store.session.form[sessionID]
if (sessionID !== "global") return forms
@@ -1657,9 +1825,19 @@ export function createData(config: CreateDataInput) {
)
},
reply(input: FormReplyInput, ref?: LocationRef) {
previewForm(
store.session.form[input.sessionID]?.find((form) => form.id === input.formID),
input.answer,
false,
)
return settleForm(input, ref, api().form.reply(input, formRequestOptions(input.sessionID, ref)))
},
cancel(input: FormCancelInput, ref?: LocationRef) {
previewForm(
store.session.form[input.sessionID]?.find((form) => form.id === input.formID),
undefined,
false,
)
return settleForm(input, ref, api().form.cancel(input, formRequestOptions(input.sessionID, ref)))
},
},
@@ -1981,6 +2159,18 @@ export function createData(config: CreateDataInput) {
sync.invalidate()
})
createEffect(() => {
Object.entries(formAnswers).forEach(([id, entry]) => {
if (!entry) return
const message = result.session.message.get(entry.form.sessionID, entry.tool.messageID)
if (message?.type !== "assistant") return
const part = message.content.find((part) => part.type === "tool" && part.id === entry.tool.id)
if (part?.type !== "tool" || part.state.status === "streaming") return
if (part.state.status !== "error" && !Array.isArray(part.state.metadata?.answers)) return
finishFormAnswer(id, part.state.metadata?.answers, part.state.status === "error")
})
})
onCleanup(
config.event.listen(({ details }) => {
handleEvent(details)
+237
View File
@@ -0,0 +1,237 @@
import { expect, test } from "bun:test"
import { batch, createMemo, createRoot } from "solid-js"
import { createData, type CreateDataInput } from "../src/solid"
import { OpenCode, type FormInfo, type FormState, type OpenCodeEvent } from "../src/promise"
function setup() {
const response = Promise.withResolvers<Response>()
const other = Promise.withResolvers<Response>()
const state = Promise.withResolvers<Response>()
const reading = Promise.withResolvers<void>()
const listeners = new Set<Parameters<CreateDataInput["event"]["listen"]>[0]>()
const form: FormInfo = {
id: "frm_question",
sessionID: "ses_question",
title: "Demo question",
metadata: { kind: "question", tool: { id: "tool_question", messageID: "msg_question" } },
fields: [{ key: "q0", type: "string", custom: true, options: [{ value: "Staging", label: "Staging" }] }],
}
let terminal = false
let reads = 0
let replies = 0
const api = OpenCode.make({
baseUrl: "http://opencode.local",
fetch: async (input, init) => {
const request = input instanceof Request ? input : new Request(input, init)
const path = new URL(request.url).pathname
if (path.endsWith("/form")) return Response.json({ data: terminal ? [] : [form] })
if (path.endsWith("/reply"))
return (replies++ === 0 ? response.promise : other.promise).then((response) => {
terminal = response.ok
return response
})
if (path.endsWith("/state")) {
reads++
reading.resolve()
return state.promise
}
throw new Error(`Unexpected request: ${path}`)
},
})
const root = createRoot((dispose) => {
const data = createData({
api: () => api,
directory: "/demo",
event: {
on: () => () => {},
listen(handler) {
listeners.add(handler)
return () => listeners.delete(handler)
},
},
})
const observed = createMemo(
(previous: ReturnType<typeof data.session.form.submission>) =>
data.session.form.submission(form.sessionID, form.id) ?? previous,
)
return { data, observed, dispose }
})
return {
...root,
form,
response,
other,
state,
reading,
reads: () => reads,
emit(event: OpenCodeEvent) {
batch(() => listeners.forEach((listener) => listener({ name: event.type, details: event })))
},
[Symbol.dispose]() {
root.dispose()
},
}
}
for (const status of ["answered", "cancelled"] as const) {
for (const replaced of [false, true]) {
test(`a late ${status} form state cannot recreate a deleted submission${replaced ? " or change its replacement owner" : ""}`, async () => {
using fixture = setup()
await fixture.data.session.form.sync(fixture.form.sessionID)
const reply = fixture.data.session.form.reply({
sessionID: fixture.form.sessionID,
formID: fixture.form.id,
answer: { q0: "Staging" },
})
const retained = fixture.data.session.form.submission(fixture.form.sessionID, fixture.form.id)
fixture.response.resolve(Response.json({}, { status: 500 }))
await fixture.reading.promise
fixture.emit({
id: "evt_deleted",
created: 0,
type: "session.deleted",
durable: { aggregateID: fixture.form.sessionID, seq: 0, version: 2 },
data: { sessionID: fixture.form.sessionID },
})
const replacement = replaced ? { ...fixture.form, sessionID: "ses_other" } : undefined
if (replacement)
fixture.emit({
id: "evt_created_other",
created: 0,
type: "form.created",
location: { directory: "/demo/other" },
data: { form: replacement },
})
const next = replacement
? fixture.data.session.form.reply({
sessionID: replacement.sessionID,
formID: replacement.id,
answer: { q0: "Other answer" },
})
: undefined
const terminal: FormState = status === "answered" ? { status, answer: { q0: "Old result" } } : { status }
fixture.state.resolve(Response.json({ data: terminal }))
await reply
expect(retained?.answer).toBeUndefined()
expect(retained?.confirmed).toBe(false)
expect(fixture.data.session.form.submission(fixture.form.sessionID, fixture.form.id)).toBeUndefined()
expect(fixture.data.session.form.answer(fixture.form.sessionID, "msg_question", "tool_question")).toBeUndefined()
expect(fixture.data.session.form.list(fixture.form.sessionID)).toBeUndefined()
if (replacement) {
expect(fixture.data.session.form.submission(replacement.sessionID, replacement.id)).toEqual({
answer: { q0: "Other answer" },
confirmed: false,
})
fixture.other.resolve(new Response(null, { status: 204 }))
await next
}
})
}
}
for (const sessionID of ["ses_question", "ses_other"]) {
test(`a fresh ${sessionID === "ses_question" ? "same-session attempt" : "different-session owner"} cannot mutate a retained submission or be settled by its late state`, async () => {
using fixture = setup()
await fixture.data.session.form.sync(fixture.form.sessionID)
const reply = fixture.data.session.form.reply({
sessionID: fixture.form.sessionID,
formID: fixture.form.id,
answer: { q0: "Staging" },
})
const retained = fixture.data.session.form.submission(fixture.form.sessionID, fixture.form.id)
fixture.response.resolve(Response.json({}, { status: 500 }))
await fixture.reading.promise
if (sessionID !== fixture.form.sessionID)
fixture.emit({
id: "evt_replacement",
created: 0,
type: "form.created",
location: { directory: "/demo/other" },
data: {
form: {
...fixture.form,
sessionID,
metadata: { kind: "question", tool: { id: "tool_other", messageID: "msg_other" } },
},
},
})
const next = fixture.data.session.form.reply({ sessionID, formID: fixture.form.id, answer: { q0: "Other answer" } })
fixture.state.resolve(Response.json({ data: { status: "answered", answer: { q0: "Old result" } } }))
await reply
expect(retained).toEqual({ answer: { q0: "Staging" }, confirmed: false })
expect(fixture.data.session.form.submission(sessionID, fixture.form.id)).toEqual({
answer: { q0: "Other answer" },
confirmed: false,
})
fixture.other.resolve(new Response(null, { status: 204 }))
await next
expect(retained).toEqual({ answer: { q0: "Staging" }, confirmed: false })
expect(fixture.data.session.form.submission(sessionID, fixture.form.id)?.confirmed).toBe(true)
})
}
for (const failed of [false, true]) {
for (const acknowledged of [false, true]) {
test(`unhydrated question tool ${failed ? "failure" : "success"} releases its preview ${acknowledged ? "after" : "before"} POST settlement`, async () => {
using fixture = setup()
await fixture.data.session.form.sync(fixture.form.sessionID)
const reply = fixture.data.session.form.reply({
sessionID: fixture.form.sessionID,
formID: fixture.form.id,
answer: { q0: "Staging" },
})
const retained = fixture.data.session.form.submission(fixture.form.sessionID, fixture.form.id)
if (acknowledged) {
fixture.response.resolve(new Response(null, { status: 204 }))
await reply
}
const event: OpenCodeEvent = failed
? {
id: "evt_failed",
created: 0,
type: "session.tool.failed",
durable: { aggregateID: fixture.form.sessionID, seq: 0, version: 2 },
data: {
sessionID: fixture.form.sessionID,
assistantMessageID: "msg_question",
id: "tool_question",
executed: true,
error: { type: "cancelled", message: "Demo question cancelled" },
},
}
: {
id: "evt_success",
created: 0,
type: "session.tool.success",
durable: { aggregateID: fixture.form.sessionID, seq: 0, version: 2 },
data: {
sessionID: fixture.form.sessionID,
assistantMessageID: "msg_question",
id: "tool_question",
executed: true,
metadata: { answers: [["Production"]] },
content: [{ type: "text", text: "Question response" }],
},
}
fixture.emit({ ...event, data: { ...event.data, assistantMessageID: "msg_unrelated" } })
expect(fixture.data.session.form.answer(fixture.form.sessionID, "msg_question", "tool_question")).toEqual({
q0: "Staging",
})
fixture.emit(event)
expect(fixture.data.session.message.get(fixture.form.sessionID, "msg_question")).toBeUndefined()
if (!acknowledged) {
expect(fixture.data.session.form.submission(fixture.form.sessionID, fixture.form.id)).toEqual({
answer: failed ? undefined : { q0: "Production" },
confirmed: true,
})
fixture.response.resolve(Response.json({}, { status: 500 }))
await reply
}
expect(fixture.data.session.form.submission(fixture.form.sessionID, fixture.form.id)).toBeUndefined()
expect(fixture.data.session.form.answer(fixture.form.sessionID, "msg_question", "tool_question")).toBeUndefined()
expect(fixture.observed()).toEqual({ answer: failed ? undefined : { q0: "Production" }, confirmed: true })
expect(retained).toEqual({ answer: failed ? undefined : { q0: "Production" }, confirmed: true })
expect(fixture.reads()).toBe(0)
})
}
}
+15
View File
@@ -0,0 +1,15 @@
import { expect, test } from "bun:test"
import { resolve } from "node:path"
test("question form projections use reactive Solid browser exports", async () => {
const child = Bun.spawn(
[process.execPath, "test", "--conditions=browser", "--timeout=5000", "./test/solid-form.browser.ts"],
{ cwd: resolve(import.meta.dir, ".."), stdout: "pipe", stderr: "pipe" },
)
const [exitCode, stdout, stderr] = await Promise.all([
child.exited,
new Response(child.stdout).text(),
new Response(child.stderr).text(),
])
expect(exitCode, stdout + stderr).toBe(0)
})
+69 -9
View File
@@ -40,7 +40,11 @@ function truncate(label: string, max: number) {
return label.length > max ? label.slice(0, max - 1).trimEnd() + "…" : label
}
export function FormPrompt(props: { form: FormWithLocation }) {
export function FormPrompt(props: {
form: FormWithLocation
answersVisible?: boolean
onReply?: (answer: FormAnswer) => Promise<void>
}) {
const data = useData()
const themes = useThemes()
const theme = useTheme("elevated")
@@ -57,6 +61,7 @@ export function FormPrompt(props: { form: FormWithLocation }) {
const [tabHover, setTabHover] = createSignal<number | "confirm" | null>(null)
const [reviewHeight, setReviewHeight] = createSignal(1)
const [reviewScrollable, setReviewScrollable] = createSignal(false)
const [submitting, setSubmitting] = createSignal<"reply" | "cancel">()
const [store, setStore] = createStore({
tab: 0,
answers: initial.answers,
@@ -219,6 +224,10 @@ export function FormPrompt(props: { form: FormWithLocation }) {
onCleanup(
keymap.intercept("key", ({ event, consume }) => {
if (keymap.mode.current() !== FORM_MODE) return
if (submitting()) {
consume()
return
}
if (textual() || !other() || (store.editing && renderer.currentFocusedEditor === textarea)) return
if (event.ctrl || event.meta || event.option || event.super || event.hyper) return
if ((!store.editing && event.sequence === " ") || !/^[^\p{C}\p{Zl}\p{Zp}]$/u.test(event.sequence)) return
@@ -243,9 +252,17 @@ export function FormPrompt(props: { form: FormWithLocation }) {
}
function reply(answer: FormAnswer) {
void data.session.form
.reply({ sessionID: props.form.sessionID, formID: props.form.id, answer }, props.form.location)
.catch(showError)
if (submitting()) return
setStore("error", "")
setSubmitting("reply")
void (
props.onReply
? props.onReply(answer)
: data.session.form.reply(
{ sessionID: props.form.sessionID, formID: props.form.id, answer },
props.form.location,
)
).catch(showError)
}
function replySingle(field: FormAnswerField, value: FormValue) {
@@ -329,6 +346,10 @@ export function FormPrompt(props: { form: FormWithLocation }) {
usePaste((event) => {
if (keymap.mode.current() !== FORM_MODE) return
if (submitting()) {
event.preventDefault()
return
}
const value = stripAnsiSequences(decodePasteBytes(event.bytes)).replace(/\r\n?/g, "\n")
if (store.editing && renderer.currentFocusedEditor === textarea) {
textarea.insertText(value)
@@ -343,6 +364,7 @@ export function FormPrompt(props: { form: FormWithLocation }) {
return clipboard
.read()
.then((content) => {
if (submitting()) return
if (content?.mime !== "text/plain") return
const value = stripAnsiSequences(content.data).replace(/\r\n?/g, "\n")
if (store.editing || textual()) {
@@ -441,13 +463,22 @@ export function FormPrompt(props: { form: FormWithLocation }) {
}
function cancel() {
if (submitting()) return
const editor = textarea?.focused ? textarea : undefined
editor?.blur()
setStore("error", "")
setSubmitting("cancel")
void data.session.form
.cancel({ sessionID: props.form.sessionID, formID: props.form.id }, props.form.location)
.catch(showError)
.catch((error: unknown) => {
showError(error)
if (editor && !editor.isDestroyed) editor.focus()
})
}
function showError(error: unknown) {
setStore("error", errorMessage(error))
setSubmitting(undefined)
}
function openExternal() {
@@ -742,12 +773,38 @@ export function FormPrompt(props: { form: FormWithLocation }) {
return (
<box
visible={!(submitting() === "reply" && props.answersVisible)}
backgroundColor={theme.background.default}
border={["left"]}
borderColor={theme.hue.interactive[themeMode() === "light" ? 800 : 200]}
customBorderChars={SplitBorder.customBorderChars}
>
<box gap={1} paddingLeft={1} paddingRight={3} paddingTop={1} paddingBottom={1}>
<Show when={submitting()}>
<box padding={1} paddingLeft={2} gap={1}>
<text fg={theme.text.subdued}>{props.form.title}</text>
<Show
when={submitting() === "reply"}
fallback={<text fg={theme.text.feedback.info.default}>Dismissing form...</text>}
>
<box id="session.form.answers" gap={1}>
<For each={fields()}>
{(field) => (
<box>
<text fg={theme.text.subdued}>{field.description ?? formLabel(field)}</text>
<text fg={theme.text.default}>
{field.type === "external"
? "Acknowledged"
: formDisplayValue(field, store.answers[field.key], "(no answer)")}
</text>
</box>
)}
</For>
</box>
</Show>
</box>
</Show>
{/* Keep the controls mounted so a failed request retains uncommitted editor text. */}
<box visible={!submitting()} gap={1} paddingLeft={1} paddingRight={3} paddingTop={1} paddingBottom={1}>
<box paddingLeft={1}>
<text fg={theme.text.subdued}>{props.form.title}</text>
</box>
@@ -1102,6 +1159,7 @@ export function FormPrompt(props: { form: FormWithLocation }) {
</Show>
</box>
<box
visible={!submitting()}
flexDirection="row"
flexShrink={0}
gap={1}
@@ -1145,10 +1203,12 @@ export function FormPrompt(props: { form: FormWithLocation }) {
esc <span style={{ fg: theme.text.subdued }}>{store.editing && !textual() ? "close" : "dismiss"}</span>
</text>
</box>
<Show when={store.error}>
<text fg={theme.text.feedback.error.default}>{store.error}</text>
</Show>
</box>
<Show when={store.error}>
<box paddingLeft={2} paddingRight={3} paddingBottom={1}>
<text fg={theme.text.feedback.error.default}>{store.error}</text>
</box>
</Show>
</box>
)
}
+28 -28
View File
@@ -72,7 +72,8 @@ import { deduplicateVisibleImages } from "../../prompt/attachment"
import { useEpilogue } from "../../context/epilogue"
import { normalizePath } from "../../util/path"
import { PermissionPrompt } from "./permission"
import { FormPrompt } from "./form"
import { formQuestionAnswers, QuestionAnswers } from "./question-answers"
import { SessionForm } from "./session-form"
import { DialogExportOptions } from "../../ui/dialog-export-options"
import { DialogExportResult } from "../../ui/dialog-export-result"
import { sessionEpilogue } from "../../util/presentation"
@@ -1452,6 +1453,12 @@ export function Session(props: {
}}
visibleTerminalID={props.visibleTerminalID}
/>
<SessionForm
form={forms()[0]}
sessionID={route.sessionID}
promptID={messages().findLast((message) => message.type === "user")?.id}
visible={!composer.open && promptedPermissions().length === 0}
/>
<Switch>
<Match when={composer.open || (!!session()?.parentID && forms().length === 0)}>{null}</Match>
<Match when={promptedPermissions().length > 0}>
@@ -1464,14 +1471,7 @@ export function Session(props: {
}}
</Show>
</Match>
<Match when={forms().length > 0}>
<Show when={forms()[0]?.id} keyed>
{(_) => {
const form = forms()[0]
return form ? <FormPrompt form={form} /> : null
}}
</Show>
</Match>
<Match when={forms().length > 0}>{null}</Match>
<Match
when={
session() &&
@@ -1817,7 +1817,7 @@ function SessionPartView(props: { partRef: PartRef; message: (messageID: string)
/>
</Match>
<Match when={item().type === "tool"}>
<ToolPart part={item() as SessionMessageAssistantTool} />
<ToolPart part={item() as SessionMessageAssistantTool} messageID={props.partRef.messageID} />
</Match>
</Switch>
)}
@@ -2682,10 +2682,13 @@ function TextPart(props: { last: boolean; part: SessionMessageAssistantText; mes
// Pending messages moved to individual tool pending functions
function ToolPart(props: { part: SessionMessageAssistantTool; images?: boolean }) {
function ToolPart(props: { part: SessionMessageAssistantTool; messageID?: string; images?: boolean }) {
const display = createMemo(() => toolDisplay(props.part.name))
const toolprops = {
get messageID() {
return props.messageID
},
get metadata() {
return toolDisplayMetadata(props.part.state)
},
@@ -2835,6 +2838,7 @@ function inlineToolImages(part: SessionMessageAssistantTool) {
}
type ToolProps = {
messageID?: string
input: Record<string, unknown>
metadata: Record<string, unknown>
tool: string
@@ -3759,30 +3763,26 @@ function ApplyPatch(props: ToolProps) {
}
function Question(props: ToolProps) {
const theme = useTheme()
const ctx = use()
const data = useData()
const questions = createMemo(() => parseQuestions(props.input.questions))
const answers = createMemo(() => parseQuestionAnswers(props.metadata.answers))
const answers = createMemo(
() =>
parseQuestionAnswers(props.metadata.answers) ??
(props.messageID
? formQuestionAnswers(
data.session.form.answer(ctx.sessionID, props.messageID, props.part.id),
questions().length,
)
: undefined),
)
const count = createMemo(() => questions().length)
function format(answer?: ReadonlyArray<string>) {
if (!answer?.length) return "(no answer)"
return answer.join(", ")
}
return (
<Switch>
<Match when={answers()}>
<BlockTool title="# Questions" part={props.part}>
<box gap={1}>
<For each={questions()}>
{(q, i) => (
<box flexDirection="column">
<text fg={theme.text.subdued}>{q.question}</text>
<text fg={theme.text.default}>{format(answers()?.[i()])}</text>
</box>
)}
</For>
</box>
<QuestionAnswers questions={questions()} answers={answers() ?? []} />
</BlockTool>
</Match>
<Match when={true}>
+96 -22
View File
@@ -14,7 +14,7 @@ import { Keymap } from "../../context/keymap"
import { usePathFormatter } from "../../context/path-format"
import { SimulationSemantics } from "../../simulation/semantics"
import { PatchDiff } from "../../component/patch-diff"
import { useToast } from "../../ui/toast"
import { errorMessage } from "../../util/error"
type PermissionStage = "permission" | "always" | "reject"
@@ -111,9 +111,10 @@ function EditBody(props: { file?: string; diff?: string; patch?: string }) {
export function PermissionPrompt(props: { request: PermissionRequest; directory?: string }) {
const data = useData()
const toast = useToast()
const [store, setStore] = createStore({
stage: "permission" as PermissionStage,
submitting: undefined as PermissionReply | undefined,
error: "",
})
const pathFormatter = usePathFormatter()
const session = createMemo(() => data.session.get(props.request.sessionID))
@@ -131,11 +132,23 @@ export function PermissionPrompt(props: { request: PermissionRequest; directory?
})
const theme = useTheme()
const submitting = createMemo(() => {
if (store.submitting === "once") return "Sending approval..."
if (store.submitting === "always") return "Sending always-allow approval..."
if (store.submitting === "reject") return "Sending rejection..."
return undefined
})
function reply(value: PermissionReply, message?: string) {
if (store.submitting) return
setStore("error", "")
setStore("submitting", value)
void data.session.permission
.reply({ sessionID: props.request.sessionID, requestID: props.request.id, reply: value, message })
.catch((error: unknown) => toast.error(error))
.catch((error: unknown) => {
setStore("error", errorMessage(error))
setStore("submitting", undefined)
})
}
return (
@@ -154,9 +167,14 @@ export function PermissionPrompt(props: { request: PermissionRequest; directory?
}
options={{ confirm: permissionOptionLabel("confirm"), cancel: permissionOptionLabel("cancel") }}
escapeKey="cancel"
submitting={submitting()}
error={store.error}
onSelect={(option) => {
setStore("stage", "permission")
if (option === "cancel") return
if (store.submitting) return
if (option === "cancel") {
setStore("stage", "permission")
return
}
reply("always")
}}
/>
@@ -165,10 +183,13 @@ export function PermissionPrompt(props: { request: PermissionRequest; directory?
<RejectPrompt
action={props.request.action}
instance={props.request.id}
submitting={submitting()}
error={store.error}
onConfirm={(message) => {
reply("reject", message || undefined)
}}
onCancel={() => {
if (store.submitting) return
setStore("stage", "permission")
}}
/>
@@ -252,7 +273,10 @@ export function PermissionPrompt(props: { request: PermissionRequest; directory?
}
escapeKey="reject"
fullscreen
submitting={submitting()}
error={store.error}
onSelect={(option) => {
if (store.submitting) return
if (option === "always") {
setStore("stage", "always")
return
@@ -284,6 +308,8 @@ export function permissionSemanticLabel(action: string, title?: string) {
function RejectPrompt(props: {
action: string
instance: string
submitting?: string
error?: string
onConfirm: (message: string) => void
onCancel: () => void
}) {
@@ -300,6 +326,7 @@ function RejectPrompt(props: {
title: "Cancel permission rejection",
group: "Permission",
run(_input, event) {
if (props.submitting) return
if (event?.ctrl && event.name === "c" && input.plainText) {
input.setText("")
return
@@ -307,12 +334,21 @@ function RejectPrompt(props: {
props.onCancel()
},
},
{ bind: "escape", title: "Cancel permission rejection", group: "Permission", run: () => props.onCancel() },
{
bind: "escape",
title: "Cancel permission rejection",
group: "Permission",
run: () => {
if (!props.submitting) props.onCancel()
},
},
{
bind: "return",
title: "Confirm permission rejection",
group: "Permission",
run: () => props.onConfirm(input.plainText),
run: () => {
if (!props.submitting) props.onConfirm(input.plainText)
},
},
],
}))
@@ -360,11 +396,12 @@ function RejectPrompt(props: {
role: "textbox",
label: "Rejection reason",
focused: val.focused,
disabled: false,
disabled: !!props.submitting,
}))(val)
val.traits = { status: "REJECT" }
}}
focused
visible={!props.submitting}
focused={!props.submitting}
textColor={theme.text.default}
focusedTextColor={theme.text.default}
cursorColor={theme.text.default}
@@ -372,6 +409,7 @@ function RejectPrompt(props: {
/>
<box
id="session.permission.reject.actions"
visible={!props.submitting}
ref={SimulationSemantics.bind(() => ({
instance: props.instance,
role: "group",
@@ -387,7 +425,7 @@ function RejectPrompt(props: {
instance: props.instance,
role: "button",
label: "Confirm rejection",
disabled: false,
disabled: !!props.submitting,
}))}
onMouseUp={() => props.onConfirm(input.plainText)}
>
@@ -401,7 +439,7 @@ function RejectPrompt(props: {
instance: props.instance,
role: "button",
label: "Cancel rejection",
disabled: false,
disabled: !!props.submitting,
}))}
onMouseUp={props.onCancel}
>
@@ -410,7 +448,15 @@ function RejectPrompt(props: {
</text>
</box>
</box>
<Show when={props.submitting}>
<text fg={theme.text.feedback.info.default}>{props.submitting}</text>
</Show>
</box>
<Show when={props.error}>
<box paddingLeft={2} paddingRight={3} paddingBottom={1}>
<text fg={theme.text.feedback.error.default}>{props.error}</text>
</box>
</Show>
</box>
)
}
@@ -427,6 +473,8 @@ export function SessionQuestion<const T extends Record<string, string>>(props: {
options: T
escapeKey?: keyof T
fullscreen?: boolean
submitting?: string
error?: string
onSelect: (option: keyof T) => void
}) {
const theme = useTheme("elevated")
@@ -436,6 +484,7 @@ export function SessionQuestion<const T extends Record<string, string>>(props: {
selected: keys[0],
expanded: false,
})
const expanded = createMemo(() => store.expanded && !props.submitting)
const narrow = createMemo(() => dimensions().width < 80)
const shortcuts = Keymap.useShortcuts()
const id = () => props.id ?? "session.permission"
@@ -502,28 +551,41 @@ export function SessionQuestion<const T extends Record<string, string>>(props: {
run: () => props.onSelect(store.selected),
},
...(props.escapeKey ? [{ bind: "escape", title: "Reject permission", group: group(), run: dismiss }] : []),
],
].map((command) => ({
...command,
run: () => {
if (!props.submitting) command.run()
},
})),
bindings: [...(props.escapeKey ? ["app.exit"] : []), ...(props.fullscreen ? ["permission.prompt.fullscreen"] : [])],
}))
const hint = createMemo(() => (store.expanded ? "minimize" : "fullscreen"))
useRenderer()
const content = () => (
// Reparent one dialog tree so leaving fullscreen does not recreate retained controls.
const content = (
<box
id={id()}
ref={SimulationSemantics.bind(() => ({
instance: props.instance,
role: "dialog",
label: props.semanticLabel ?? props.title,
expanded: store.expanded,
expanded: expanded(),
}))}
backgroundColor={theme.background.default}
border={["left"]}
borderColor={theme.background.action.primary.focused}
customBorderChars={SplitBorder.customBorderChars}
{...(store.expanded
? { top: dimensions().height * -1 + 1, bottom: 1, left: 2, right: 2, position: "absolute" }
{...(expanded()
? {
top: dimensions().height * -1 + 1,
maxHeight: undefined,
bottom: 1,
left: 2,
right: 2,
position: "absolute",
}
: {
top: 0,
maxHeight: 15,
@@ -547,7 +609,7 @@ export function SessionQuestion<const T extends Record<string, string>>(props: {
{props.header}
</box>
</Show>
{props.body}
<box visible={!props.submitting}>{props.body}</box>
</box>
<box
flexDirection={narrow() ? "column" : "row"}
@@ -563,6 +625,7 @@ export function SessionQuestion<const T extends Record<string, string>>(props: {
>
<box
id={`${id()}.actions`}
visible={!props.submitting}
ref={SimulationSemantics.bind(() => ({
instance: props.instance,
role: "listbox",
@@ -582,7 +645,7 @@ export function SessionQuestion<const T extends Record<string, string>>(props: {
label: props.options[option],
focused: option === store.selected,
selected: option === store.selected,
disabled: false,
disabled: !!props.submitting,
}))}
paddingLeft={1}
paddingRight={1}
@@ -591,8 +654,11 @@ export function SessionQuestion<const T extends Record<string, string>>(props: {
? theme.background.action.primary.focused
: theme.background.action.primary.default
}
onMouseOver={() => setStore("selected", option)}
onMouseOver={() => {
if (!props.submitting) setStore("selected", option)
}}
onMouseUp={() => {
if (props.submitting) return
setStore("selected", option)
props.onSelect(option)
}}
@@ -606,7 +672,7 @@ export function SessionQuestion<const T extends Record<string, string>>(props: {
)}
</For>
</box>
<box flexDirection="row" gap={2} flexShrink={0}>
<box visible={!props.submitting} flexDirection="row" gap={2} flexShrink={0}>
<Show when={props.fullscreen}>
<text fg={theme.text.default}>
{shortcuts.get("permission.prompt.fullscreen")} <span style={{ fg: theme.text.subdued }}>{hint()}</span>
@@ -621,13 +687,21 @@ export function SessionQuestion<const T extends Record<string, string>>(props: {
enter <span style={{ fg: theme.text.subdued }}>confirm</span>
</text>
</box>
<Show when={props.submitting}>
<text fg={theme.text.feedback.info.default}>{props.submitting}</text>
</Show>
</box>
<Show when={props.error}>
<box paddingLeft={2} paddingRight={3} paddingBottom={1}>
<text fg={theme.text.feedback.error.default}>{props.error}</text>
</box>
</Show>
</box>
)
return (
<Show when={!store.expanded} fallback={<Portal>{content()}</Portal>}>
{content()}
<Show when={!expanded()} fallback={<Portal>{content}</Portal>}>
{content}
</Show>
)
}
@@ -0,0 +1,33 @@
import type { FormReplyInput } from "@opencode-ai/client"
import { For } from "solid-js"
import { useTheme } from "../../context/theme"
export function formQuestionAnswers(answer: FormReplyInput["answer"] | undefined, count: number) {
if (!answer) return undefined
return Array.from({ length: count }, (_, index) => {
const value = answer[`q${index}`]
if (value === undefined) return []
return Array.isArray(value) ? value : [String(value)]
})
}
export function QuestionAnswers(props: {
questions: readonly { question: string }[]
answers: readonly (readonly string[])[]
}) {
const theme = useTheme()
return (
<box gap={1}>
<For each={props.questions}>
{(question, index) => (
<box flexDirection="column">
<text fg={theme.text.subdued}>{question.question}</text>
<text fg={theme.text.default}>
{props.answers[index()]?.length ? props.answers[index()].join(", ") : "(no answer)"}
</text>
</box>
)}
</For>
</box>
)
}
@@ -0,0 +1,112 @@
import type { FormAnswer, FormReplyInput } from "@opencode-ai/client"
import { createEffect, createMemo, createSignal, on, Show } from "solid-js"
import { useData, type FormWithLocation } from "../../context/data"
import { useTheme } from "../../context/theme"
import { SplitBorder } from "../../ui/border"
import { formLabel } from "../../util/form"
import { FormPrompt } from "./form"
import { formQuestionAnswers, QuestionAnswers } from "./question-answers"
export function SessionForm(props: {
form?: FormWithLocation
sessionID: string
promptID?: string
visible: boolean
}) {
const data = useData()
const theme = useTheme("elevated")
const parentTheme = useTheme()
const [selected, setSelected] = createSignal<{
form: FormWithLocation
submission: { answer: FormReplyInput["answer"] | undefined }
}>()
createEffect(
on(
() => props.promptID,
() => setSelected(undefined),
{ defer: true },
),
)
createEffect(() => {
const current = selected()
if (current && props.form && (props.form.id !== current.form.id || props.form.sessionID !== current.form.sessionID))
setSelected(undefined)
})
function inTranscript(form: FormWithLocation) {
const tool = form.metadata?.tool
if (
form.metadata?.kind !== "question" ||
form.sessionID !== props.sessionID ||
!tool ||
typeof tool !== "object" ||
!("messageID" in tool) ||
typeof tool.messageID !== "string" ||
!("id" in tool)
)
return false
const message = data.session.message.get(form.sessionID, tool.messageID)
return (
message?.type === "assistant" &&
message.content.some((part) => part.type === "tool" && part.name === "question" && part.id === tool.id)
)
}
function reply(form: FormWithLocation, answer: FormAnswer) {
const request = data.session.form.reply({ sessionID: form.sessionID, formID: form.id, answer }, form.location)
// Capture the live owner before acknowledgement/terminal events can retire its lookup.
const current =
form.metadata?.kind === "question" && form.sessionID !== "global" && !inTranscript(form)
? { form, submission: data.session.form.submission(form.sessionID, form.id) ?? { answer } }
: undefined
if (current) setSelected(current)
return request.catch((error: unknown) => {
if (current && selected() === current) setSelected(undefined)
throw error
})
}
const local = createMemo(() => {
const current = selected()
return current?.submission.answer && !inTranscript(current.form) ? current : undefined
})
return (
<box visible={props.visible}>
<Show when={local()}>
<box
id="session.question.reply"
backgroundColor={theme.background.default}
border={["left"]}
borderColor={parentTheme.background.default}
customBorderChars={SplitBorder.customBorderChars}
paddingTop={1}
paddingBottom={1}
paddingLeft={2}
gap={1}
>
<text fg={theme.text.subdued}># Questions</text>
<QuestionAnswers
questions={local()?.form.fields.map((field) => ({ question: field.description ?? formLabel(field) })) ?? []}
answers={formQuestionAnswers(local()?.submission.answer, local()?.form.fields.length ?? 0) ?? []}
/>
</box>
</Show>
<Show when={props.visible && props.form?.id} keyed>
{(_) => {
const form = props.form
if (!form) return null
return (
<FormPrompt
form={form}
answersVisible={
inTranscript(form) || (selected()?.form.id === form.id && selected()?.form.sessionID === form.sessionID)
}
onReply={(answer) => reply(form, answer)}
/>
)
}}
</Show>
</box>
)
}
+159 -1
View File
@@ -21,7 +21,11 @@ async function mountForm(
fields?: FormWithLocation["fields"],
height = 20,
clipboardText?: string,
response?: { reply?: 404 | 409; cancel?: 404 | 409; syncFailure?: boolean },
response?: {
reply?: 404 | 409 | (() => Promise<Response>)
cancel?: 404 | 409 | (() => Promise<Response>)
syncFailure?: boolean
},
) {
const state = path.join(root, "state")
await mkdir(state, { recursive: true })
@@ -65,12 +69,23 @@ async function mountForm(
if (url.pathname === "/api/session/ses_test/form/frm_test/reply")
return request.json().then((answer) => {
replies.push(answer)
if (typeof response?.reply === "function")
return response.reply().then((result) => {
terminal = result.ok
return result
})
return response?.reply ? failure(response.reply) : new Response(null, { status: 204 })
})
if (url.pathname === "/api/session/ses_test/form/frm_test/cancel") {
cancellations.push(true)
if (typeof response?.cancel === "function")
return response.cancel().then((result) => {
terminal = result.ok
return result
})
return response?.cancel ? failure(response.cancel) : new Response(null, { status: 204 })
}
return undefined
}, events)
const { FormPrompt } = await import("../../../src/routes/session/form")
@@ -135,6 +150,149 @@ function mountRecoveringForm(root: string, response: { reply?: 404 | 409; cancel
)
}
for (const width of [48, 120]) {
test(`acknowledges a form reply before HTTP completes and retains its answer on failure at ${width} columns`, async () => {
await using tmp = await tmpdir()
const pending = Promise.withResolvers<Response>()
const prompt = await mountForm(
tmp.path,
width,
[{ key: "target", type: "string", options: [{ value: "staging", label: "Staging" }], custom: true }],
20,
undefined,
{ reply: () => pending.promise },
)
try {
await prompt.app.mockInput.pasteBracketedText("production west")
await prompt.app.waitFor(() => prompt.app.renderer.currentFocusedEditor?.plainText === "production west")
prompt.app.mockInput.pressEnter()
await prompt.app.waitForFrame((frame) => frame.includes("production west") && !frame.includes("enter submit"))
expect(prompt.app.captureCharFrame()).not.toContain("Sending answers")
expect(prompt.app.captureCharFrame()).not.toContain("Composer ready")
expect(prompt.app.captureCharFrame()).not.toContain("enter submit")
prompt.app.mockInput.pressEnter()
prompt.app.mockInput.pressEscape()
prompt.app.mockInput.pressKey("c", { ctrl: true })
await prompt.app.mockInput.pasteBracketedText("do not replace my answer")
await prompt.app.waitFor(() => prompt.replies.length === 1)
expect(prompt.cancellations).toEqual([])
pending.resolve(
json({ _tag: "FormInvalidAnswerError", id: "frm_test", message: "Reply failed" }, { status: 400 }),
)
await prompt.app.waitForFrame((frame) => frame.includes("Reply failed") && frame.includes("production west"))
expect(prompt.replies).toEqual([{ answer: { target: "production west" } }])
expect(prompt.app.captureCharFrame()).toContain("enter submit")
prompt.app.mockInput.pressEnter()
await prompt.app.waitFor(() => prompt.app.renderer.currentFocusedEditor?.plainText === "production west")
prompt.app.mockInput.pressEnter()
await prompt.app.waitFor(() => prompt.replies.length === 2)
expect(prompt.replies[1]).toEqual(prompt.replies[0])
} finally {
pending.resolve(new Response(null, { status: 204 }))
prompt.app.renderer.destroy()
}
})
}
test("a failed form cancellation restores its uncommitted text and focus", async () => {
await using tmp = await tmpdir()
const pending = Promise.withResolvers<Response>()
const prompt = await mountForm(tmp.path, 80, [{ key: "notes", type: "string" }], 20, undefined, {
cancel: () => pending.promise,
})
try {
await prompt.app.mockInput.typeText(" unfinished draft ")
const editor = prompt.app.renderer.currentFocusedEditor
prompt.app.mockInput.pressEscape()
await prompt.app.waitForFrame((frame) => frame.includes("Dismissing form..."))
expect(prompt.app.renderer.currentFocusedEditor).toBeNull()
prompt.app.mockInput.pressEscape()
prompt.app.mockInput.pressEnter()
prompt.app.mockInput.pressKey("c", { ctrl: true })
await prompt.app.mockInput.typeText("must not edit")
await prompt.app.waitFor(() => prompt.cancellations.length === 1)
expect(prompt.replies).toEqual([])
pending.resolve(json({ message: "Cancel failed" }, { status: 500 }))
await prompt.app.waitForFrame((frame) => frame.includes("UnexpectedStatus"))
expect(prompt.app.renderer.currentFocusedEditor).toBe(editor)
expect(editor?.plainText).toBe(" unfinished draft ")
await prompt.app.mockInput.typeText("!")
expect(editor?.plainText).toBe(" unfinished draft !")
prompt.app.mockInput.pressEscape()
await prompt.app.waitFor(() => prompt.cancellations.length === 2)
} finally {
pending.resolve(new Response(null, { status: 204 }))
prompt.app.renderer.destroy()
}
})
test("only restores the composer after the submitted form is acknowledged", async () => {
await using tmp = await tmpdir()
const pending = Promise.withResolvers<Response>()
const prompt = await mountForm(tmp.path, 80, [{ key: "target", type: "boolean" }], 20, undefined, {
reply: () => pending.promise,
})
try {
prompt.app.mockInput.pressEnter()
await prompt.app.waitForFrame((frame) => frame.includes("Yes") && !frame.includes("enter submit"))
expect(prompt.app.captureCharFrame()).not.toContain("Composer ready")
pending.resolve(new Response(null, { status: 204 }))
await prompt.app.waitForFrame((frame) => frame.includes("Composer ready"))
expect(prompt.replies).toHaveLength(1)
} finally {
pending.resolve(new Response(null, { status: 204 }))
prompt.app.renderer.destroy()
}
})
test("a failed review submission retains every answer and ignores competing mouse actions", async () => {
await using tmp = await tmpdir()
const pending = Promise.withResolvers<Response>()
const prompt = await mountForm(
tmp.path,
80,
[
{ key: "targets", type: "multiselect", options: [{ value: "staging", label: "Staging" }], default: ["staging"] },
{ key: "notes", type: "string", default: "Keep the existing config" },
],
25,
undefined,
{ reply: () => pending.promise },
)
try {
prompt.app.mockInput.pressArrow("right")
prompt.app.mockInput.pressEnter()
await prompt.app.waitForFrame((frame) => frame.includes("enter submit"))
const lines = prompt.app.captureCharFrame().split("\n")
const row = lines.findIndex((line) => line.includes("enter submit"))
await prompt.app.mockMouse.click(lines[row].indexOf("enter submit"), row)
await prompt.app.waitForFrame(
(frame) => frame.includes("Keep the existing config") && !frame.includes("enter submit"),
)
await prompt.app.mockMouse.click(lines[row].indexOf("enter submit"), row)
await prompt.app.mockMouse.click(lines[row].indexOf("esc dismiss"), row)
prompt.app.mockInput.pressEnter()
prompt.app.mockInput.pressEscape()
await prompt.app.waitFor(() => prompt.replies.length === 1)
expect(prompt.cancellations).toEqual([])
expect(prompt.replies).toEqual([{ answer: { targets: ["staging"], notes: "Keep the existing config" } }])
pending.resolve(json({ _tag: "FormInvalidAnswerError", id: "frm_test", message: "Reply failed" }, { status: 400 }))
await prompt.app.waitForFrame((frame) => frame.includes("Reply failed"))
expect(prompt.app.captureCharFrame()).toContain("targets: Staging")
expect(prompt.app.captureCharFrame()).toContain("notes: Keep the existing config")
prompt.app.mockInput.pressArrow("left")
await prompt.app.waitFor(() => prompt.app.renderer.currentFocusedEditor?.plainText === "Keep the existing config")
} finally {
pending.resolve(new Response(null, { status: 204 }))
prompt.app.renderer.destroy()
}
})
test("restores the composer when terminal-form revalidation fails", async () => {
await using tmp = await tmpdir()
const prompt = await mountRecoveringForm(tmp.path, { reply: 404, syncFailure: true })
@@ -1,7 +0,0 @@
import { expect, test } from "bun:test"
import { permissionSemanticLabel } from "../../../src/routes/session/permission"
test("uses the permission action when a surface has no display title", () => {
expect(permissionSemanticLabel("shell")).toBe("Permission required: shell")
expect(permissionSemanticLabel("edit", "Edit fixture.txt")).toBe("Permission required: Edit fixture.txt")
})
@@ -0,0 +1,228 @@
/** @jsxImportSource @opentui/solid */
import { testRender } from "@opentui/solid"
import { expect, test } from "bun:test"
import { onMount, Show } from "solid-js"
import type { PermissionRequest } from "@opencode-ai/client"
import { PermissionPrompt, permissionSemanticLabel } from "../../../src/routes/session/permission"
import { ConfigProvider } from "../../../src/config"
import { ClientProvider } from "../../../src/context/client"
import { DataProvider, useData } from "../../../src/context/data"
import { LocationProvider } from "../../../src/context/location"
import { Keymap } from "../../../src/context/keymap"
import { ThemeProvider } from "../../../src/context/theme"
import { emptyThemeSource } from "../../fixture/fixture"
import { TestTuiContexts } from "../../fixture/tui-environment"
import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
import { createApi, createEventStream, createFetch, json } from "../../fixture/tui-client"
test("uses the permission action when a surface has no display title", () => {
expect(permissionSemanticLabel("shell")).toBe("Permission required: shell")
expect(permissionSemanticLabel("edit", "Edit fixture.txt")).toBe("Permission required: Edit fixture.txt")
})
async function mountPermission(width: number, child = false) {
const pending = Promise.withResolvers<Response>()
const replies: unknown[] = []
const request = {
id: "per_test",
sessionID: "ses_test",
action: "read",
resources: ["README.md"],
save: ["*.md"],
} satisfies PermissionRequest
const events = createEventStream()
const transport = createFetch(async (url, init) => {
if (url.pathname === "/api/session/ses_test/permission") return json({ data: [request] })
if (url.pathname === "/api/session/ses_test/permission/per_test/reply") {
replies.push(await init.json())
return replies.length === 1 ? pending.promise : new Response(null, { status: 204 })
}
if (url.pathname === "/api/session/ses_test")
return json({
data: {
id: "ses_test",
parentID: child ? "ses_parent" : undefined,
projectID: "proj_test",
title: "Permission demo",
location: { directory: process.cwd() },
time: { created: 0, updated: 0 },
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
},
})
return undefined
}, events)
function CurrentPermission() {
const data = useData()
onMount(async () => {
await data.session.sync(request.sessionID)
await data.session.permission.sync(request.sessionID)
})
return (
<Show when={data.session.permission.list(request.sessionID)?.[0]} keyed fallback={<text>Composer ready</text>}>
{(current) => <PermissionPrompt request={current} />}
</Show>
)
}
const app = await testRender(
() => (
<TestTuiContexts>
<ConfigProvider config={createTuiResolvedConfig()}>
<Keymap.Provider>
<ClientProvider api={createApi(transport.fetch)}>
<DataProvider directory={process.cwd()}>
<LocationProvider>
<ThemeProvider mode="dark" source={emptyThemeSource}>
<box height="100%">
<box paddingTop={3}>
<text>Transcript stays visible</text>
</box>
<box flexGrow={1} />
<CurrentPermission />
</box>
</ThemeProvider>
</LocationProvider>
</DataProvider>
</ClientProvider>
</Keymap.Provider>
</ConfigProvider>
</TestTuiContexts>
),
{ width, height: 25, kittyKeyboard: true },
)
app.renderer.start()
await app.waitForFrame((frame) => frame.includes("Permission required"))
return { app, pending, replies }
}
for (const width of [48, 120]) {
test(`an expanded permission submits inline and restores its retained fullscreen controls on failure at ${width} columns`, async () => {
const prompt = await mountPermission(width)
try {
prompt.app.mockInput.pressKey("f", { ctrl: true })
await prompt.app.waitForFrame((frame) => frame.includes("minimize"))
expect(prompt.app.captureCharFrame()).not.toContain("Transcript stays visible")
const dialog = prompt.app.renderer.root.findDescendantById("session.permission")
const choice = prompt.app.renderer.root.findDescendantById("session.permission.action.once")
expect(dialog).toBeDefined()
expect(choice).toBeDefined()
expect(dialog?.height).toBeGreaterThan(15)
prompt.app.mockInput.pressEnter()
await prompt.app.waitForFrame((frame) => frame.includes("Sending approval..."))
expect(prompt.app.captureCharFrame()).toContain("Transcript stays visible")
expect(dialog?.height).toBeLessThan(15)
expect(prompt.app.renderer.root.findDescendantById("session.permission") === dialog).toBe(true)
expect(prompt.app.renderer.root.findDescendantById("session.permission.action.once") === choice).toBe(true)
prompt.app.mockInput.pressEscape()
prompt.app.mockInput.pressKey("f", { ctrl: true })
prompt.app.mockInput.pressEnter()
await prompt.app.waitFor(() => prompt.replies.length === 1)
expect(prompt.replies).toEqual([{ reply: "once" }])
prompt.pending.resolve(json({}, { status: 500 }))
await prompt.app.waitForFrame((frame) => frame.includes("UnexpectedStatus") && frame.includes("minimize"))
expect(prompt.app.captureCharFrame()).not.toContain("Transcript stays visible")
expect(prompt.app.captureCharFrame()).toContain("README.md")
expect(dialog?.height).toBeGreaterThan(15)
expect(prompt.app.renderer.root.findDescendantById("session.permission") === dialog).toBe(true)
expect(prompt.app.renderer.root.findDescendantById("session.permission.action.once") === choice).toBe(true)
expect(choice?.isDestroyed).toBe(false)
prompt.app.mockInput.pressEscape()
await prompt.app.waitForFrame((frame) => frame.includes("Transcript stays visible"))
prompt.app.mockInput.pressEnter()
await prompt.app.waitForFrame((frame) => frame.includes("Composer ready"))
expect(prompt.replies).toEqual([{ reply: "once" }, { reply: "once" }])
} finally {
prompt.pending.resolve(new Response(null, { status: 204 }))
prompt.app.renderer.destroy()
}
})
for (const reply of ["once", "always", "reject"] as const) {
test(`acknowledges ${reply} before HTTP completes and restores permission interaction on failure at ${width} columns`, async () => {
const prompt = await mountPermission(width)
try {
if (reply === "always") {
prompt.app.mockInput.pressArrow("right")
prompt.app.mockInput.pressEnter()
await prompt.app.waitForFrame((frame) => frame.includes("Always allow"))
}
if (reply === "reject") prompt.app.mockInput.pressEscape()
if (reply !== "reject") prompt.app.mockInput.pressEnter()
await prompt.app.waitForFrame((frame) => frame.includes("Sending"))
expect(prompt.app.captureCharFrame()).not.toContain("enter confirm")
expect(prompt.app.captureCharFrame()).not.toContain("Composer ready")
prompt.app.mockInput.pressEnter()
prompt.app.mockInput.pressEscape()
prompt.app.mockInput.pressArrow("right")
prompt.app.mockInput.pressKey("c", { ctrl: true })
await prompt.app.waitFor(() => prompt.replies.length === 1)
expect(prompt.replies).toEqual([{ reply }])
prompt.pending.resolve(json({}, { status: 500 }))
await prompt.app.waitForFrame((frame) => frame.includes("UnexpectedStatus"))
expect(prompt.app.captureCharFrame()).toContain("enter confirm")
if (reply === "always") expect(prompt.app.captureCharFrame()).toContain("Always allow")
if (reply === "reject") prompt.app.mockInput.pressEscape()
if (reply !== "reject") prompt.app.mockInput.pressEnter()
await prompt.app.waitForFrame((frame) => frame.includes("Composer ready"))
expect(prompt.replies).toEqual([{ reply }, { reply }])
} finally {
prompt.pending.resolve(new Response(null, { status: 204 }))
prompt.app.renderer.destroy()
}
})
}
}
test("a failed permission rejection retains the reason and restores its editor", async () => {
const prompt = await mountPermission(48, true)
try {
prompt.app.mockInput.pressEscape()
await prompt.app.waitFor(() => prompt.app.renderer.currentFocusedEditor !== null)
await prompt.app.mockInput.typeText("Keep the file unchanged")
const editor = prompt.app.renderer.currentFocusedEditor
prompt.app.mockInput.pressEnter()
await prompt.app.waitForFrame((frame) => frame.includes("Sending rejection..."))
expect(prompt.app.renderer.currentFocusedEditor).toBeNull()
prompt.app.mockInput.pressEnter()
prompt.app.mockInput.pressEscape()
prompt.app.mockInput.pressKey("c", { ctrl: true })
await prompt.app.mockInput.pasteBracketedText("must not replace the reason")
await prompt.app.waitFor(() => prompt.replies.length === 1)
expect(prompt.replies).toEqual([{ reply: "reject", message: "Keep the file unchanged" }])
prompt.pending.resolve(json({}, { status: 500 }))
await prompt.app.waitForFrame((frame) => frame.includes("UnexpectedStatus"))
expect(prompt.app.renderer.currentFocusedEditor).toBe(editor)
expect(editor?.plainText).toBe("Keep the file unchanged")
await prompt.app.mockInput.typeText("!")
prompt.app.mockInput.pressEnter()
await prompt.app.waitForFrame((frame) => frame.includes("Composer ready"))
expect(prompt.replies[1]).toEqual({ reply: "reject", message: "Keep the file unchanged!" })
} finally {
prompt.pending.resolve(new Response(null, { status: 204 }))
prompt.app.renderer.destroy()
}
})
test("only restores the composer after the permission reply is acknowledged", async () => {
const prompt = await mountPermission(120)
try {
prompt.app.mockInput.pressEnter()
await prompt.app.waitForFrame((frame) => frame.includes("Sending approval..."))
expect(prompt.app.captureCharFrame()).not.toContain("Composer ready")
prompt.pending.resolve(new Response(null, { status: 204 }))
await prompt.app.waitForFrame((frame) => frame.includes("Composer ready"))
expect(prompt.replies).toHaveLength(1)
} finally {
prompt.pending.resolve(new Response(null, { status: 204 }))
prompt.app.renderer.destroy()
}
})
@@ -0,0 +1,457 @@
/** @jsxImportSource @opentui/solid */
import { testRender } from "@opentui/solid"
import { CliRenderEvents } from "@opentui/core"
import { expect, test } from "bun:test"
import { createMemo, onMount, Show } from "solid-js"
import type { FormAnswer, FormState, SessionMessageAssistant } from "@opencode-ai/client"
import { FormPrompt } from "../../../src/routes/session/form"
import { formQuestionAnswers, QuestionAnswers } from "../../../src/routes/session/question-answers"
import { parseQuestionAnswers } from "../../../src/routes/session"
import { ConfigProvider } from "../../../src/config"
import { ClientProvider } from "../../../src/context/client"
import { DataProvider, useData, type FormWithLocation } from "../../../src/context/data"
import { Keymap } from "../../../src/context/keymap"
import { ThemeProvider } from "../../../src/context/theme"
import { ToastProvider } from "../../../src/ui/toast"
import { emptyThemeSource } from "../../fixture/fixture"
import { TestTuiContexts } from "../../fixture/tui-environment"
import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
import { createApi, createEventStream, createFetch, json } from "../../fixture/tui-client"
async function mountQuestion(
width: number,
options: { instant?: boolean; stateUnavailable?: boolean; multi?: boolean } = {},
) {
const pending = Promise.withResolvers<Response>()
const replies: unknown[] = []
const cancellations: unknown[] = []
const server: { state: FormState; reads: number } = { state: { status: "pending" }, reads: 0 }
const questions = [
{ question: "Where should the demo deploy?" },
...(options.multi ? [{ question: "Which checks should run?" }] : []),
]
const form: FormWithLocation = {
id: "frm_question",
sessionID: "ses_question",
title: "Questions",
metadata: { kind: "question", tool: { messageID: "msg_question", id: "tool_question" } },
fields: [
{
key: "q0",
type: "string",
title: "Target",
description: questions[0].question,
options: [
{ value: "Staging", label: "Staging" },
{ value: "Production", label: "Production" },
],
custom: true,
},
],
}
if (options.multi)
form.fields = [
{
key: "q0",
type: "multiselect",
title: "Target",
description: questions[0].question,
options: [{ value: "Staging", label: "Staging" }],
default: ["Staging"],
},
{
key: "q1",
type: "string",
title: "Checks",
description: questions[1].question,
options: [{ value: "Focused", label: "Focused" }],
},
]
const message: SessionMessageAssistant = {
id: "msg_question",
type: "assistant",
agent: "demo",
model: { id: "demo-model", providerID: "demo" },
time: { created: 0 },
content: [
{
type: "tool",
id: "tool_question",
name: "question",
time: { created: 0 },
state: { status: "running", input: { questions }, metadata: {} },
},
],
}
const events = createEventStream()
const transport = createFetch(async (url, request) => {
if (url.pathname === "/api/session/ses_question/form")
return json({ data: server.state.status === "pending" ? [form] : [] })
if (url.pathname === "/api/session/ses_question/message") return json({ data: [message], cursor: {} })
if (url.pathname === "/api/session/ses_question/form/frm_question/state") {
server.reads++
return options.stateUnavailable
? json({ _tag: "FormNotFoundError", id: form.id, message: "Form expired" }, { status: 404 })
: json({ data: server.state })
}
if (url.pathname === "/api/session/ses_question/form/frm_question/reply") {
const body: { answer: FormAnswer } = await request.json()
replies.push(body)
const result = options.instant || replies.length > 1 ? new Response(null, { status: 204 }) : await pending.promise
if (result.ok && server.state.status === "pending") server.state = { status: "answered", answer: body.answer }
return result
}
if (url.pathname === "/api/session/ses_question/form/frm_question/cancel") {
cancellations.push(true)
return new Response(null, { status: 204 })
}
return undefined
}, events)
let data: ReturnType<typeof useData> | undefined
function Surface() {
const current = useData()
data = current
onMount(async () => {
await current.session.message.sync(form.sessionID)
await current.session.form.sync(form.sessionID)
})
const answers = createMemo(() => {
const message = current.session.message.get(form.sessionID, "msg_question")
const part = message?.type === "assistant" ? message.content.find((part) => part.type === "tool") : undefined
return (
parseQuestionAnswers(
part?.type === "tool" && part.state.status !== "streaming" ? part.state.metadata?.answers : undefined,
) ??
formQuestionAnswers(
current.session.form.answer(form.sessionID, "msg_question", "tool_question"),
questions.length,
)
)
})
return (
<box>
<Show when={answers()} fallback={<text>Asked 1 question</text>}>
{(answers) => (
<box id="question-output">
<text># Questions</text>
<QuestionAnswers questions={questions} answers={answers()} />
</box>
)}
</Show>
<Show when={current.session.form.list(form.sessionID)?.[0]} keyed fallback={<text>Composer ready</text>}>
{(form) => <FormPrompt form={form} answersVisible />}
</Show>
</box>
)
}
const app = await testRender(
() => (
<TestTuiContexts>
<ConfigProvider config={createTuiResolvedConfig()}>
<Keymap.Provider>
<ClientProvider api={createApi(transport.fetch)}>
<DataProvider directory={process.cwd()}>
<ThemeProvider mode="dark" source={emptyThemeSource}>
<ToastProvider>
<Surface />
</ToastProvider>
</ThemeProvider>
</DataProvider>
</ClientProvider>
</Keymap.Provider>
</ConfigProvider>
</TestTuiContexts>
),
{ width, height: 25, kittyKeyboard: true },
)
app.renderer.start()
await app.waitForFrame((frame) => frame.includes(options.multi ? "enter toggle" : "enter submit"))
const frames: string[] = []
app.renderer.on(CliRenderEvents.FRAME, () => frames.push(app.captureCharFrame()))
return {
app,
pending,
replies,
cancellations,
server,
frames,
preview: (messageID = "msg_question") => data?.session.form.answer(form.sessionID, messageID, "tool_question"),
accepted(answer: FormAnswer) {
server.state = { status: "answered", answer }
events.emit({
id: "evt_form_replied",
created: 0,
type: "form.replied",
data: { id: form.id, sessionID: form.sessionID, answer },
})
},
completed(answers: string[][]) {
events.emit({
id: "evt_tool_success",
created: 1,
type: "session.tool.success",
durable: { aggregateID: form.sessionID, seq: 0, version: 2 },
data: {
sessionID: form.sessionID,
assistantMessageID: message.id,
id: "tool_question",
executed: true,
metadata: { answers },
content: [{ type: "text", text: "Question response" }],
},
})
},
failed() {
events.emit({
id: "evt_tool_failed",
created: 1,
type: "session.tool.failed",
durable: { aggregateID: form.sessionID, seq: 0, version: 2 },
data: {
sessionID: form.sessionID,
assistantMessageID: message.id,
id: "tool_question",
executed: true,
error: { type: "cancelled", message: "Demo question cancelled" },
},
})
},
}
}
for (const width of [48, 120]) {
test(`selected question output is stable before POST, after acknowledgement, and after tool metadata at ${width} columns`, async () => {
const prompt = await mountQuestion(width)
try {
prompt.app.mockInput.pressEnter()
await prompt.app.waitForFrame((frame) => frame.includes("# Questions") && !frame.includes("enter submit"))
const output = prompt.app.renderer.root.findDescendantById("question-output")
const selected = prompt.app.captureCharFrame().split("\n").slice(0, 3)
expect(selected.join("\n")).toContain("Staging")
expect(prompt.app.captureCharFrame()).not.toContain("Composer ready")
prompt.app.mockInput.pressEnter()
prompt.app.mockInput.pressEscape()
prompt.app.mockInput.pressKey("c", { ctrl: true })
await prompt.app.mockInput.pasteBracketedText("must not replace the answer")
await prompt.app.waitFor(() => prompt.replies.length === 1)
expect(prompt.cancellations).toEqual([])
prompt.pending.resolve(new Response(null, { status: 204 }))
await prompt.app.waitForFrame((frame) => frame.includes("Composer ready"))
expect(prompt.app.captureCharFrame().split("\n").slice(0, 3)).toEqual(selected)
expect(prompt.preview()).toEqual({ q0: "Staging" })
expect(prompt.server.reads).toBe(0)
prompt.completed([["Staging"]])
await prompt.app.waitFor(() => prompt.preview() === undefined)
expect(prompt.app.captureCharFrame().split("\n").slice(0, 3)).toEqual(selected)
expect(prompt.app.renderer.root.findDescendantById("question-output") === output).toBe(true)
expect(prompt.frames.every((frame) => !frame.includes("Sending answers"))).toBe(true)
} finally {
prompt.pending.resolve(new Response(null, { status: 204 }))
prompt.app.renderer.destroy()
}
})
test(`instant question acknowledgement never flashes a loading layout at ${width} columns`, async () => {
const prompt = await mountQuestion(width, { instant: true })
try {
prompt.app.mockInput.pressEnter()
await prompt.app.waitForFrame((frame) => frame.includes("Composer ready") && frame.includes("# Questions"))
const selected = prompt.app.captureCharFrame().split("\n").slice(0, 3)
prompt.completed([["Staging"]])
await prompt.app.waitFor(() => prompt.preview() === undefined)
expect(prompt.app.captureCharFrame().split("\n").slice(0, 3)).toEqual(selected)
expect(prompt.frames.every((frame) => !frame.includes("Sending answers"))).toBe(true)
expect(prompt.replies).toEqual([{ answer: { q0: "Staging" } }])
} finally {
prompt.app.renderer.destroy()
}
})
}
test("question reply failure rolls back only the preview and retains a custom answer for retry", async () => {
const prompt = await mountQuestion(48)
try {
await prompt.app.mockInput.pasteBracketedText("production west")
await prompt.app.waitFor(() => prompt.app.renderer.currentFocusedEditor?.plainText === "production west")
prompt.app.mockInput.pressEnter()
await prompt.app.waitForFrame((frame) => frame.includes("# Questions") && frame.includes("production west"))
prompt.pending.resolve(
json({ _tag: "FormInvalidAnswerError", id: "frm_question", message: "Reply failed" }, { status: 400 }),
)
await prompt.app.waitForFrame((frame) => frame.includes("Reply failed") && frame.includes("enter submit"))
expect(prompt.preview()).toBeUndefined()
expect(prompt.app.captureCharFrame()).not.toContain("# Questions")
prompt.app.mockInput.pressEnter()
await prompt.app.waitFor(() => prompt.app.renderer.currentFocusedEditor?.plainText === "production west")
prompt.app.mockInput.pressEnter()
await prompt.app.waitForFrame((frame) => frame.includes("Composer ready") && frame.includes("production west"))
expect(prompt.replies).toEqual([{ answer: { q0: "production west" } }, { answer: { q0: "production west" } }])
} finally {
prompt.pending.resolve(new Response(null, { status: 204 }))
prompt.app.renderer.destroy()
}
})
for (const metadataFirst of [false, true]) {
test(`another TUI's accepted answer wins over a late POST failure${metadataFirst ? " even after tool completion" : ""}`, async () => {
const prompt = await mountQuestion(120)
try {
prompt.app.mockInput.pressEnter()
await prompt.app.waitForFrame((frame) => frame.includes("# Questions"))
prompt.accepted({ q0: "Production" })
await prompt.app.waitForFrame((frame) => frame.includes("Composer ready") && frame.includes("Production"))
if (metadataFirst) prompt.completed([["Production"]])
prompt.pending.resolve(json({}, { status: 500 }))
await prompt.app.waitFor(() => prompt.replies.length === 1)
await prompt.app.renderOnce()
expect(prompt.app.captureCharFrame()).toContain("Production")
expect(prompt.app.captureCharFrame()).not.toContain("Staging")
expect(prompt.app.captureCharFrame()).not.toContain("UnexpectedStatus")
expect(prompt.server.reads).toBe(0)
if (!metadataFirst) prompt.completed([["Production"]])
await prompt.app.waitFor(() => prompt.preview() === undefined)
} finally {
prompt.pending.resolve(new Response(null, { status: 204 }))
prompt.app.renderer.destroy()
}
})
}
for (const status of [409, 500]) {
test(`form state reconciles the canonical answer after ${status} without an SSE acknowledgement`, async () => {
const prompt = await mountQuestion(48)
try {
prompt.app.mockInput.pressEnter()
await prompt.app.waitForFrame((frame) => frame.includes("# Questions"))
prompt.server.state = { status: "answered", answer: { q0: "Production" } }
prompt.pending.resolve(
json({ _tag: "FormAlreadySettledError", id: "frm_question", message: "Already answered" }, { status }),
)
await prompt.app.waitForFrame((frame) => frame.includes("Composer ready") && frame.includes("Production"))
expect(prompt.preview()).toEqual({ q0: "Production" })
expect(prompt.server.reads).toBe(1)
expect(prompt.app.captureCharFrame()).not.toContain("Already answered")
expect(prompt.app.captureCharFrame()).not.toContain("Staging")
} finally {
prompt.pending.resolve(new Response(null, { status: 204 }))
prompt.app.renderer.destroy()
}
})
}
test("an unavailable form state does not confirm an unsuccessful question reply", async () => {
const prompt = await mountQuestion(48, { stateUnavailable: true })
try {
prompt.app.mockInput.pressEnter()
await prompt.app.waitForFrame((frame) => frame.includes("# Questions"))
prompt.pending.resolve(json({}, { status: 500 }))
await prompt.app.waitForFrame((frame) => frame.includes("UnexpectedStatus") && frame.includes("enter submit"))
expect(prompt.preview()).toBeUndefined()
expect(prompt.app.captureCharFrame()).not.toContain("Composer ready")
} finally {
prompt.pending.resolve(new Response(null, { status: 204 }))
prompt.app.renderer.destroy()
}
})
test("reviewed multi-question answers survive resize, acknowledgement, and metadata without changing output", async () => {
const prompt = await mountQuestion(120, { multi: true })
try {
prompt.app.mockInput.pressArrow("right")
prompt.app.mockInput.pressEnter()
await prompt.app.waitForFrame((frame) => frame.includes("enter submit"))
prompt.app.mockInput.pressEnter()
await prompt.app.waitForFrame((frame) => frame.includes("# Questions") && frame.includes("Focused"))
prompt.app.resize(48, 25)
await prompt.app.waitForFrame((frame) => frame.includes("Which checks should run?") && frame.includes("Staging"))
const selected = prompt.app.captureCharFrame().split("\n").slice(0, 6)
prompt.pending.resolve(new Response(null, { status: 204 }))
await prompt.app.waitForFrame((frame) => frame.includes("Composer ready"))
expect(prompt.app.captureCharFrame().split("\n").slice(0, 6)).toEqual(selected)
expect(prompt.preview()).toEqual({ q0: ["Staging"], q1: "Focused" })
prompt.completed([["Staging"], ["Focused"]])
await prompt.app.waitFor(() => prompt.preview() === undefined)
expect(prompt.app.captureCharFrame().split("\n").slice(0, 6)).toEqual(selected)
expect(prompt.frames.every((frame) => !frame.includes("Sending answers"))).toBe(true)
} finally {
prompt.pending.resolve(new Response(null, { status: 204 }))
prompt.app.renderer.destroy()
}
})
test("authoritative tool answers win without form SSE and cannot be rolled back by a lost POST", async () => {
const prompt = await mountQuestion(48, { stateUnavailable: true })
try {
prompt.app.mockInput.pressEnter()
await prompt.app.waitForFrame((frame) => frame.includes("# Questions"))
prompt.completed([["Production"]])
await prompt.app.waitForFrame((frame) => frame.includes("Composer ready") && frame.includes("Production"))
prompt.pending.resolve(json({}, { status: 500 }))
await prompt.app.waitFor(() => prompt.preview() === undefined)
expect(prompt.app.captureCharFrame()).toContain("Production")
expect(prompt.app.captureCharFrame()).not.toContain("Staging")
expect(prompt.app.captureCharFrame()).not.toContain("UnexpectedStatus")
expect(prompt.server.reads).toBe(0)
} finally {
prompt.pending.resolve(new Response(null, { status: 204 }))
prompt.app.renderer.destroy()
}
})
test("a competing cancellation drops tentative question output rather than confirming its answers", async () => {
const prompt = await mountQuestion(48)
try {
prompt.app.mockInput.pressEnter()
await prompt.app.waitForFrame((frame) => frame.includes("# Questions"))
prompt.server.state = { status: "cancelled" }
prompt.pending.resolve(
json({ _tag: "FormAlreadySettledError", id: "frm_question", message: "Already settled" }, { status: 409 }),
)
await prompt.app.waitForFrame((frame) => frame.includes("Composer ready") && !frame.includes("# Questions"))
expect(prompt.preview()).toBeUndefined()
expect(prompt.server.reads).toBe(1)
expect(prompt.app.captureCharFrame()).not.toContain("Staging")
} finally {
prompt.pending.resolve(new Response(null, { status: 204 }))
prompt.app.renderer.destroy()
}
})
test("question previews do not leak to another assistant message with a reused tool-call ID", async () => {
const prompt = await mountQuestion(48)
try {
prompt.app.mockInput.pressEnter()
await prompt.app.waitForFrame((frame) => frame.includes("# Questions"))
expect(prompt.preview()).toEqual({ q0: "Staging" })
expect(prompt.preview("msg_previous_question")).toBeUndefined()
prompt.accepted({ q0: "Production" })
await prompt.app.waitForFrame((frame) => frame.includes("Composer ready") && frame.includes("Production"))
expect(prompt.preview("msg_previous_question")).toBeUndefined()
prompt.pending.resolve(new Response(null, { status: 204 }))
prompt.completed([["Production"]])
await prompt.app.waitFor(() => prompt.preview() === undefined)
} finally {
prompt.pending.resolve(new Response(null, { status: 204 }))
prompt.app.renderer.destroy()
}
})
test("a terminal tool error removes tentative answers without resurrecting a form on late POST failure", async () => {
const prompt = await mountQuestion(48)
try {
prompt.app.mockInput.pressEnter()
await prompt.app.waitForFrame((frame) => frame.includes("# Questions"))
prompt.failed()
await prompt.app.waitForFrame((frame) => frame.includes("Composer ready") && !frame.includes("# Questions"))
expect(prompt.app.captureCharFrame()).not.toContain("Staging")
prompt.pending.resolve(json({}, { status: 500 }))
await prompt.app.renderOnce()
expect(prompt.preview()).toBeUndefined()
expect(prompt.server.reads).toBe(0)
expect(prompt.app.captureCharFrame()).not.toContain("UnexpectedStatus")
} finally {
prompt.pending.resolve(new Response(null, { status: 204 }))
prompt.app.renderer.destroy()
}
})
+475
View File
@@ -0,0 +1,475 @@
import { expect, test } from "bun:test"
import { CliRenderEvents } from "@opentui/core"
import { createTestRenderer } from "@opentui/core/testing"
import { Effect, FileSystem } from "effect"
import { Global } from "@opencode-ai/util/global"
import type {
FormAnswer,
FormCreated,
FormInfo,
FormState,
SessionInfo,
SessionMessageAssistant,
} from "@opencode-ai/client"
import { createEventStream, createFetch, directory, json } from "./fixture/tui-client"
import { tmpdir } from "./fixture/fixture"
async function mountQuestionSession(state: string, width: number, child: boolean, instant = false, permission = false) {
const setup = await createTestRenderer({ width, height: 32, useThread: false, kittyKeyboard: true })
setup.renderer.start()
const root: SessionInfo = {
id: `ses_question_root_${crypto.randomUUID()}`,
title: "Question session fixture",
projectID: "proj_question",
location: { directory },
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 0, updated: 0 },
}
const descendant: SessionInfo = { ...root, id: `${root.id}_child`, parentID: root.id, title: "Child fixture" }
const owner = child ? descendant.id : root.id
const form: FormInfo = {
id: "frm_session_question",
sessionID: owner,
title: "Questions",
metadata: { kind: "question", tool: { messageID: "msg_session_question", id: "tool_session_question" } },
fields: [
{
key: "q0",
type: "string",
title: "Target",
description: "Where should the demo deploy?",
custom: true,
options: [
{ value: "Staging", label: "Staging" },
{ value: "Production", label: "Production" },
],
},
{
key: "q1",
type: "multiselect",
title: "Checks",
description: "Which checks should run?",
custom: true,
options: [
{ value: "Focused", label: "Focused" },
{ value: "Full", label: "Full" },
],
},
],
}
const message: SessionMessageAssistant = {
id: "msg_session_question",
type: "assistant",
agent: "build",
model: { providerID: "demo", id: "demo-model" },
time: { created: 1 },
content: [
{
type: "tool",
id: "tool_session_question",
name: "question",
time: { created: 1 },
state: {
status: "running",
metadata: {},
input: {
questions: [{ question: "Where should the demo deploy?" }, { question: "Which checks should run?" }],
},
},
},
],
}
const response = Promise.withResolvers<Response>()
const replies: { answer: FormAnswer }[] = []
const permissionReplies: unknown[] = []
const permissionState = { active: permission }
const serverState: { value: FormState; childMessages: number } = { value: { status: "pending" }, childMessages: 0 }
const events = createEventStream()
const calls = createFetch(async (url, request) => {
if (url.pathname === "/api/session") {
const parent = url.searchParams.get("parentID")
return json({ data: parent === root.id ? [descendant] : parent && parent !== "null" ? [] : [root], cursor: {} })
}
if (url.pathname === `/api/session/${root.id}`) return json({ data: root })
if (url.pathname === `/api/session/${descendant.id}`) return json({ data: descendant })
if (url.pathname === `/api/session/${root.id}/message`)
return json({
data: [
...(child ? [] : [message]),
{ id: "msg_root_user", type: "user", text: "Root transcript remains visible", time: { created: 0 } },
],
cursor: {},
})
if (url.pathname === `/api/session/${descendant.id}/message`) {
serverState.childMessages++
return json({ data: [], cursor: {} })
}
if (url.pathname === `/api/session/${owner}/form`)
return json({ data: serverState.value.status === "pending" ? [form] : [] })
if (url.pathname === `/api/session/${owner}/form/${form.id}/state`) return json({ data: serverState.value })
if (url.pathname === `/api/session/${owner}/form/${form.id}/reply`) {
const body: { answer: FormAnswer } = await request.json()
replies.push(body)
const result = instant || replies.length > 1 ? new Response(null, { status: 204 }) : await response.promise
if (result.ok && serverState.value.status === "pending")
serverState.value = { status: "answered", answer: body.answer }
return result
}
if (url.pathname === `/api/session/${root.id}/permission`)
return json({
data: permissionState.active
? [{ id: "per_question_session", sessionID: root.id, action: "read", resources: ["demo.md"] }]
: [],
})
if (url.pathname === `/api/session/${root.id}/permission/per_question_session/reply`) {
permissionReplies.push(await request.json())
permissionState.active = false
return new Response(null, { status: 204 })
}
if (/\/api\/session\/[^/]+\/(inbox|permission)$/.test(url.pathname)) return json({ data: [] })
return undefined
}, events)
const server = Bun.serve({ port: 0, idleTimeout: 0, fetch: (request) => calls.fetch(request) })
const { run } = await import("../src/app")
const task = Effect.runPromise(
run({
app: { name: "test", version: "test", channel: "test" },
server: { endpoint: { url: server.url.toString() } },
config: { get: async () => ({ animations: false, tabs: { enabled: false } }), update: async () => ({}) },
packages: { resolve: async () => undefined },
terminalHandoff: async () => ({ renderer: setup.renderer, mode: "dark", complete: () => {} }),
args: { sessionID: root.id },
log: () => {},
}).pipe(Effect.provide(Global.layerWith({ state })), Effect.provide(FileSystem.layerNoop({}))),
)
const close = async () => {
response.resolve(new Response(null, { status: 204 }))
if (!setup.renderer.isDestroyed) setup.renderer.destroy()
await task
await server.stop(true)
}
await setup
.waitForFrame((frame) =>
permission
? frame.includes("Permission required")
: frame.includes("Where should the demo deploy?") && frame.includes("1. Staging"),
)
.catch(async (error: unknown) => {
await close()
throw error
})
const frames: string[] = []
setup.renderer.on(CliRenderEvents.FRAME, () => frames.push(setup.captureCharFrame()))
return {
setup,
response,
replies,
permissionReplies,
frames,
serverState,
accepted(answer: FormAnswer) {
serverState.value = { status: "answered", answer }
events.emit({
id: "evt_session_form_replied",
created: 2,
type: "form.replied",
data: { sessionID: owner, id: form.id, answer },
})
},
completed(answers: string[][]) {
events.emit({
id: "evt_session_tool_success",
created: 3,
type: "session.tool.success",
durable: { aggregateID: owner, seq: 0, version: 2 },
data: {
sessionID: owner,
assistantMessageID: message.id,
id: "tool_session_question",
executed: true,
metadata: { answers },
content: [{ type: "text", text: "Demo answers" }],
},
})
},
failed() {
events.emit({
id: "evt_session_tool_failed",
created: 3,
type: "session.tool.failed",
durable: { aggregateID: owner, seq: 0, version: 2 },
data: {
sessionID: owner,
assistantMessageID: message.id,
id: "tool_session_question",
executed: true,
error: { type: "cancelled", message: "Demo question cancelled" },
},
})
},
hydrated(answers: string[][]) {
events.emit({
id: "evt_child_step",
created: 3,
type: "session.step.started",
durable: { aggregateID: owner, seq: 0, version: 1 },
data: {
sessionID: owner,
assistantMessageID: message.id,
agent: "build",
model: { providerID: "demo", id: "demo-model" },
},
})
events.emit({
id: "evt_child_tool_input",
created: 3,
type: "session.tool.input.started",
durable: { aggregateID: owner, seq: 1, version: 1 },
data: { sessionID: owner, assistantMessageID: message.id, id: "tool_session_question", name: "question" },
})
events.emit({
id: "evt_child_tool_called",
created: 3,
type: "session.tool.called",
durable: { aggregateID: owner, seq: 2, version: 1 },
data: {
sessionID: owner,
assistantMessageID: message.id,
id: "tool_session_question",
executed: true,
input: {},
},
})
events.emit({
id: "evt_child_tool_progress",
created: 3,
type: "session.tool.progress",
data: { sessionID: owner, assistantMessageID: message.id, id: "tool_session_question", metadata: { answers } },
})
},
sibling() {
const other: FormCreated["data"]["form"] = {
...form,
fields: [
{ key: "q0", type: "string", options: [{ value: "Staging", label: "Staging" }] },
{ key: "q1", type: "multiselect", options: [{ value: "Focused", label: "Focused" }] },
],
id: "frm_sibling_question",
metadata: { kind: "question", tool: { messageID: "msg_sibling_question", id: "tool_sibling_question" } },
}
events.emit({
id: "evt_sibling_form",
created: 3,
type: "form.created",
location: { directory },
data: { form: other },
})
events.emit({
id: "evt_sibling_reply",
created: 3,
type: "form.replied",
data: { sessionID: owner, id: other.id, answer: { q0: "Staging", q1: ["Focused"] } },
})
events.emit({
id: "evt_sibling_success",
created: 3,
type: "session.tool.success",
durable: { aggregateID: owner, seq: 0, version: 2 },
data: {
sessionID: owner,
assistantMessageID: "msg_sibling_question",
id: "tool_sibling_question",
executed: true,
metadata: { answers: [["Staging"], ["Focused"]] },
content: [{ type: "text", text: "Sibling response" }],
},
})
},
continued() {
events.emit({
id: "evt_root_followup",
created: 4,
type: "session.inbox.enqueued",
durable: { aggregateID: root.id, seq: 1, version: 1 },
data: {
sessionID: root.id,
inboxID: "msg_root_followup",
item: { type: "user", delivery: "steer", payload: { text: "New root input" } },
},
})
},
async select() {
setup.mockInput.pressEnter()
await setup.waitForFrame((frame) => frame.includes("Which checks should run?"))
setup.mockInput.pressEnter()
setup.mockInput.pressArrow("right")
await setup.waitForFrame((frame) => frame.includes("Checks: Focused") && frame.includes("enter submit"))
setup.mockInput.pressEnter()
},
close,
}
}
for (const child of [false, true]) {
for (const instant of [false, true]) {
for (const width of [48, 100]) {
test(`production Session renders ${child ? "descendant" : "own"} selected question answers through ${instant ? "instant" : "deferred"} acknowledgement at ${width} columns`, async () => {
await using state = await tmpdir()
const fixture = await mountQuestionSession(state.path, width, child, instant)
try {
await fixture.select()
await fixture.setup.waitForFrame(
(frame) =>
frame.includes("# Questions") &&
frame.includes("Staging") &&
frame.includes("Focused") &&
!frame.includes("enter submit"),
)
const selected = fixture.setup
.captureCharFrame()
.split("\n")
.filter((line) => /# Questions|Where should|Which checks|Staging|Focused/.test(line))
.map((line) => line.trim())
expect(selected.filter((line) => line.includes("Staging"))).toHaveLength(1)
if (!instant) {
fixture.setup.mockInput.pressEnter()
fixture.setup.mockInput.pressEscape()
fixture.setup.mockInput.pressKey("c", { ctrl: true })
await fixture.setup.waitFor(() => fixture.replies.length === 1)
fixture.response.resolve(new Response(null, { status: 204 }))
}
await fixture.setup.waitFor(() => fixture.setup.renderer.currentFocusedEditor !== null)
expect(fixture.setup.captureCharFrame()).toContain("Staging")
expect(fixture.setup.captureCharFrame()).toContain("Focused")
fixture.completed([["Staging"], ["Focused"]])
await fixture.setup.waitForVisualIdle()
expect(
fixture.setup
.captureCharFrame()
.split("\n")
.filter((line) => /# Questions|Where should|Which checks|Staging|Focused/.test(line))
.map((line) => line.trim()),
).toEqual(selected)
expect(fixture.frames.every((frame) => !frame.includes("Sending answers"))).toBe(true)
expect(fixture.replies).toEqual([{ answer: { q0: "Staging", q1: ["Focused"] } }])
await fixture.setup.mockInput.typeText("next user draft")
expect(fixture.setup.renderer.currentFocusedEditor?.plainText).toBe("next user draft")
if (child) {
expect(fixture.serverState.childMessages).toBe(0)
fixture.continued()
await fixture.setup.waitForFrame(
(frame) => frame.includes("New root input") && !frame.includes("# Questions"),
)
}
} finally {
await fixture.close()
}
})
}
}
}
test("production root Session keeps a descendant's canonical answer after a lost POST and unhydrated tool completion", async () => {
await using state = await tmpdir()
const fixture = await mountQuestionSession(state.path, 48, true)
try {
await fixture.select()
await fixture.setup.waitForFrame((frame) => frame.includes("# Questions") && frame.includes("Staging"))
fixture.accepted({ q0: "Production", q1: ["Focused"] })
await fixture.setup.waitForFrame((frame) => frame.includes("Production") && !frame.includes("Staging"))
fixture.completed([["Production"], ["Focused"]])
fixture.response.resolve(json({}, { status: 500 }))
await fixture.setup.waitForVisualIdle()
expect(fixture.setup.captureCharFrame()).toContain("Production")
expect(fixture.setup.captureCharFrame()).toContain("Focused")
expect(fixture.setup.captureCharFrame()).not.toContain("UnexpectedStatus")
expect(fixture.serverState.childMessages).toBe(0)
} finally {
await fixture.close()
}
})
test("production root Session restores a descendant form's retained answers after an unaccepted reply", async () => {
await using state = await tmpdir()
const fixture = await mountQuestionSession(state.path, 48, true)
try {
await fixture.select()
await fixture.setup.waitForFrame((frame) => frame.includes("# Questions") && frame.includes("Staging"))
fixture.response.resolve(
json({ _tag: "FormInvalidAnswerError", id: "frm_session_question", message: "Reply failed" }, { status: 400 }),
)
await fixture.setup.waitForFrame((frame) => frame.includes("Reply failed") && frame.includes("enter submit"))
expect(fixture.setup.captureCharFrame()).toContain("Target: Staging")
expect(fixture.setup.captureCharFrame()).toContain("Checks: Focused")
expect(fixture.setup.captureCharFrame()).not.toContain("# Questions")
fixture.setup.mockInput.pressEnter()
await fixture.setup.waitFor(() => fixture.setup.renderer.currentFocusedEditor !== null)
expect(fixture.setup.captureCharFrame()).toContain("# Questions")
expect(fixture.replies).toHaveLength(2)
} finally {
await fixture.close()
}
})
test("a production permission prompt keeps keyboard ownership while a descendant form is waiting", async () => {
await using state = await tmpdir()
const fixture = await mountQuestionSession(state.path, 48, true, true, true)
try {
fixture.setup.mockInput.pressEnter()
await fixture.setup.waitForFrame(
(frame) => frame.includes("Where should the demo deploy?") && frame.includes("1. Staging"),
)
expect(fixture.permissionReplies).toEqual([{ reply: "once" }])
expect(fixture.replies).toEqual([])
await fixture.select()
await fixture.setup.waitFor(() => fixture.setup.renderer.currentFocusedEditor !== null)
expect(fixture.setup.captureCharFrame()).toContain("Staging")
expect(fixture.setup.captureCharFrame()).toContain("Focused")
} finally {
await fixture.close()
}
})
test("production root Session removes a descendant answer panel when tool failure arrives after POST acknowledgement", async () => {
await using state = await tmpdir()
const fixture = await mountQuestionSession(state.path, 48, true, true)
try {
await fixture.select()
await fixture.setup.waitFor(() => fixture.setup.renderer.currentFocusedEditor !== null)
expect(fixture.setup.captureCharFrame()).toContain("Staging")
fixture.failed()
await fixture.setup.waitForVisualIdle()
expect(fixture.setup.captureCharFrame()).not.toContain("# Questions")
expect(fixture.setup.captureCharFrame()).not.toContain("Staging")
expect(fixture.setup.captureCharFrame()).not.toContain("Focused")
expect(fixture.serverState.childMessages).toBe(0)
} finally {
await fixture.close()
}
})
for (const mode of ["event", "effect", "batched-effect-before", "batched-effect-after"] as const) {
test(`production root Session retains changed canonical answers after POST acknowledgement (${mode})`, async () => {
await using state = await tmpdir()
const fixture = await mountQuestionSession(state.path, 48, true, true)
try {
await fixture.select()
await fixture.setup.waitFor(() => fixture.setup.renderer.currentFocusedEditor !== null)
expect(fixture.setup.captureCharFrame()).toContain("Staging")
if (mode === "batched-effect-before") fixture.sibling()
if (mode !== "event") fixture.hydrated([["Production"], ["Full"]])
if (mode === "batched-effect-after") fixture.sibling()
if (mode === "event") fixture.completed([["Production"], ["Full"]])
await fixture.setup.waitForVisualIdle()
expect(fixture.setup.captureCharFrame()).toContain("# Questions")
expect(fixture.setup.captureCharFrame()).toContain("Production")
expect(fixture.setup.captureCharFrame()).toContain("Full")
expect(fixture.setup.captureCharFrame()).not.toContain("Staging")
expect(fixture.setup.captureCharFrame()).not.toContain("Focused")
expect(fixture.serverState.childMessages).toBe(0)
} finally {
await fixture.close()
}
})
}