mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-10 19:06:24 +00:00
Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7c4662e449 | ||
|
|
6d0c46461c | ||
|
|
040d67e7ae | ||
|
|
87f24e459a | ||
|
|
114c96aa6d | ||
|
|
afafa2feda | ||
|
|
f97696cdd8 | ||
|
|
aafdb24f89 |
@@ -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.
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user