feat(form): require external acknowledgements

This commit is contained in:
Aiden Cline 2026-07-09 22:36:50 -05:00
parent 8b53c84e0a
commit f7b9962d1e
14 changed files with 477 additions and 95 deletions

View file

@ -2510,7 +2510,7 @@ export type FormRequestListOutput = {
custom?: boolean
default?: Array<string>
}
| { type: "external"; url: string; title?: string; description?: string }
| { key: string; type: "external"; url: string; title?: string; description?: string }
),
...Array<
| {
@ -2593,7 +2593,7 @@ export type FormRequestListOutput = {
custom?: boolean
default?: Array<string>
}
| { type: "external"; url: string; title?: string; description?: string }
| { key: string; type: "external"; url: string; title?: string; description?: string }
>,
]
}>
@ -2689,7 +2689,7 @@ export type FormListOutput = {
custom?: boolean
default?: Array<string>
}
| { type: "external"; url: string; title?: string; description?: string }
| { key: string; type: "external"; url: string; title?: string; description?: string }
),
...Array<
| {
@ -2772,7 +2772,7 @@ export type FormListOutput = {
custom?: boolean
default?: Array<string>
}
| { type: "external"; url: string; title?: string; description?: string }
| { key: string; type: "external"; url: string; title?: string; description?: string }
>,
]
}>
@ -2874,7 +2874,13 @@ export type FormCreateInput = {
readonly custom?: boolean
readonly default?: ReadonlyArray<string>
}
| { readonly type: "external"; readonly url: string; readonly title?: string; readonly description?: string }
| {
readonly key: string
readonly type: "external"
readonly url: string
readonly title?: string
readonly description?: string
}
),
...Array<
| {
@ -2965,7 +2971,13 @@ export type FormCreateInput = {
readonly custom?: boolean
readonly default?: ReadonlyArray<string>
}
| { readonly type: "external"; readonly url: string; readonly title?: string; readonly description?: string }
| {
readonly key: string
readonly type: "external"
readonly url: string
readonly title?: string
readonly description?: string
}
>,
]
}["id"]
@ -3063,7 +3075,13 @@ export type FormCreateInput = {
readonly custom?: boolean
readonly default?: ReadonlyArray<string>
}
| { readonly type: "external"; readonly url: string; readonly title?: string; readonly description?: string }
| {
readonly key: string
readonly type: "external"
readonly url: string
readonly title?: string
readonly description?: string
}
),
...Array<
| {
@ -3154,7 +3172,13 @@ export type FormCreateInput = {
readonly custom?: boolean
readonly default?: ReadonlyArray<string>
}
| { readonly type: "external"; readonly url: string; readonly title?: string; readonly description?: string }
| {
readonly key: string
readonly type: "external"
readonly url: string
readonly title?: string
readonly description?: string
}
>,
]
}["title"]
@ -3252,7 +3276,13 @@ export type FormCreateInput = {
readonly custom?: boolean
readonly default?: ReadonlyArray<string>
}
| { readonly type: "external"; readonly url: string; readonly title?: string; readonly description?: string }
| {
readonly key: string
readonly type: "external"
readonly url: string
readonly title?: string
readonly description?: string
}
),
...Array<
| {
@ -3343,7 +3373,13 @@ export type FormCreateInput = {
readonly custom?: boolean
readonly default?: ReadonlyArray<string>
}
| { readonly type: "external"; readonly url: string; readonly title?: string; readonly description?: string }
| {
readonly key: string
readonly type: "external"
readonly url: string
readonly title?: string
readonly description?: string
}
>,
]
}["metadata"]
@ -3441,7 +3477,13 @@ export type FormCreateInput = {
readonly custom?: boolean
readonly default?: ReadonlyArray<string>
}
| { readonly type: "external"; readonly url: string; readonly title?: string; readonly description?: string }
| {
readonly key: string
readonly type: "external"
readonly url: string
readonly title?: string
readonly description?: string
}
),
...Array<
| {
@ -3532,7 +3574,13 @@ export type FormCreateInput = {
readonly custom?: boolean
readonly default?: ReadonlyArray<string>
}
| { readonly type: "external"; readonly url: string; readonly title?: string; readonly description?: string }
| {
readonly key: string
readonly type: "external"
readonly url: string
readonly title?: string
readonly description?: string
}
>,
]
}["fields"]
@ -3626,7 +3674,7 @@ export type FormCreateOutput = {
custom?: boolean
default?: Array<string>
}
| { type: "external"; url: string; title?: string; description?: string }
| { key: string; type: "external"; url: string; title?: string; description?: string }
),
...Array<
| {
@ -3709,7 +3757,7 @@ export type FormCreateOutput = {
custom?: boolean
default?: Array<string>
}
| { type: "external"; url: string; title?: string; description?: string }
| { key: string; type: "external"; url: string; title?: string; description?: string }
>,
]
}
@ -3808,7 +3856,7 @@ export type FormGetOutput = {
custom?: boolean
default?: Array<string>
}
| { type: "external"; url: string; title?: string; description?: string }
| { key: string; type: "external"; url: string; title?: string; description?: string }
),
...Array<
| {
@ -3891,7 +3939,7 @@ export type FormGetOutput = {
custom?: boolean
default?: Array<string>
}
| { type: "external"; url: string; title?: string; description?: string }
| { key: string; type: "external"; url: string; title?: string; description?: string }
>,
]
}
@ -5429,7 +5477,7 @@ export type EventSubscribeOutput =
custom?: boolean
default?: Array<string>
}
| { type: "external"; url: string; title?: string; description?: string }
| { key: string; type: "external"; url: string; title?: string; description?: string }
),
...Array<
| {
@ -5492,7 +5540,7 @@ export type EventSubscribeOutput =
custom?: boolean
default?: Array<string>
}
| { type: "external"; url: string; title?: string; description?: string }
| { key: string; type: "external"; url: string; title?: string; description?: string }
>,
]
}

View file

@ -224,15 +224,16 @@ export const locationLayer = layer
export const node = makeLocationNode({ service: Service, layer, deps: [EventV2.node] })
function validateAnswer(form: Info, answer: Answer) {
const fields = new Map(
form.fields.flatMap((field) => (field.type === "external" ? [] : [[field.key, field] as const])),
)
const fields = new Map(form.fields.map((field) => [field.key, field] as const))
for (const key of Object.keys(answer)) {
if (!fields.has(key)) return `Unknown form field: ${key}`
}
for (const field of form.fields) {
if (field.type === "external") continue
const value = answer[field.key]
if (field.type === "external") {
if (value !== true) return `External form field must be acknowledged: ${field.key}`
continue
}
const active = isActive(field, answer)
if (value === undefined) {
if (field.required && active) return `Missing required form field: ${field.key}`
@ -266,9 +267,11 @@ function matches(when: Form.When, value: Form.Value | undefined) {
function validateFields(fields: ReadonlyArray<Form.Field>) {
if (fields.length === 0) return "Form must have at least one field"
const earlier = new Map<string, InputField>()
const keys = new Set<string>()
for (const field of fields) {
if (keys.has(field.key)) return `Duplicate form field key: ${field.key}`
keys.add(field.key)
if (field.type === "external") continue
if (earlier.has(field.key)) return `Duplicate form field key: ${field.key}`
for (const when of field.when ?? []) {
const target = earlier.get(when.key)
if (!target) return `Form field condition must reference an earlier field: ${field.key} -> ${when.key}`

View file

@ -311,7 +311,7 @@ export const layer = Layer.effect(
elicitationID: input.params.elicitationId,
message: input.params.message,
},
fields: [{ type: "external", url: input.params.url }],
fields: [{ key: "elicitation", type: "external", url: input.params.url }],
})
.pipe(
Effect.raceFirst(waitForAbort(input.signal)),
@ -355,7 +355,7 @@ export const layer = Layer.effect(
Effect.gen(function* () {
const formID = urlElicitations.get(input.server + "\u0000" + input.elicitationID)
if (!formID) return
yield* forms.reply({ id: formID, answer: {} }).pipe(Effect.ignore)
yield* forms.reply({ id: formID, answer: { elicitation: true } }).pipe(Effect.ignore)
}),
} satisfies MCPClient.ElicitationHandler

View file

@ -61,10 +61,10 @@ describe("Form", () => {
const externalOnly = yield* service.create({
sessionID: "global",
title: "External setup",
fields: [{ type: "external", url: "https://example.com/setup" }],
fields: [{ key: "setup", type: "external", url: "https://example.com/setup" }],
})
yield* service.reply({ id: externalOnly.id, answer: {} })
expect(yield* service.state(externalOnly.id)).toEqual({ status: "answered", answer: {} })
yield* service.reply({ id: externalOnly.id, answer: { setup: true } })
expect(yield* service.state(externalOnly.id)).toEqual({ status: "answered", answer: { setup: true } })
}),
)
@ -247,6 +247,13 @@ describe("Form", () => {
]),
).toEqual(new Form.InvalidFormError({ message: "Duplicate form field key: a" }))
expect(
yield* flipCreate([
{ key: "a", type: "external", url: "https://example.com" },
{ key: "a", type: "string" },
]),
).toEqual(new Form.InvalidFormError({ message: "Duplicate form field key: a" }))
expect(
yield* flipCreate([
{ key: "a", type: "boolean" },
@ -267,25 +274,37 @@ describe("Form", () => {
}),
)
it.effect("treats external fields as non-answerable", () =>
it.effect("requires external field acknowledgements", () =>
Effect.gen(function* () {
const service = yield* Form.Service
const created = yield* service.create({
sessionID: "global",
title: "External setup",
fields: [
{ type: "external", url: "https://example.com/setup", title: "Open setup" },
{ key: "authorization", type: "external", url: "https://example.com/setup", title: "Open setup" },
{ key: "name", type: "string", required: true },
],
})
const invalid = yield* service
.reply({ id: created.id, answer: { link: "opened", name: "Ava" } })
.pipe(Effect.flip)
expect(invalid).toEqual(new Form.InvalidAnswerError({ id: created.id, message: "Unknown form field: link" }))
const invalidAnswers: ReadonlyArray<Form.Answer> = [
{ name: "Ava" },
{ authorization: false, name: "Ava" },
{ authorization: "yes", name: "Ava" },
]
for (const answer of invalidAnswers) {
expect(yield* service.reply({ id: created.id, answer }).pipe(Effect.flip)).toEqual(
new Form.InvalidAnswerError({
id: created.id,
message: "External form field must be acknowledged: authorization",
}),
)
}
yield* service.reply({ id: created.id, answer: { name: "Ava" } })
expect(yield* service.state(created.id)).toEqual({ status: "answered", answer: { name: "Ava" } })
yield* service.reply({ id: created.id, answer: { authorization: true, name: "Ava" } })
expect(yield* service.state(created.id)).toEqual({
status: "answered",
answer: { authorization: true, name: "Ava" },
})
}),
)

View file

@ -47,7 +47,9 @@ type ResourceTemplatePage = {
nextCursor?: string
}
function resourceServer(input: { resources?: boolean; listChanged?: boolean; emptyElicitation?: boolean } = {}) {
function resourceServer(
input: { resources?: boolean; listChanged?: boolean; emptyElicitation?: boolean; urlElicitation?: boolean } = {},
) {
return Effect.acquireRelease(
Effect.promise(async () => {
const state = {
@ -75,7 +77,9 @@ function resourceServer(input: { resources?: boolean; listChanged?: boolean; emp
Promise.resolve({
tools: input.emptyElicitation
? [{ name: "empty-elicitation", inputSchema: { type: "object" as const, properties: {} } }]
: [],
: input.urlElicitation
? [{ name: "url-elicitation", inputSchema: { type: "object" as const, properties: {} } }]
: [],
}),
)
if (input.emptyElicitation) {
@ -91,6 +95,20 @@ function resourceServer(input: { resources?: boolean; listChanged?: boolean; emp
}
})
}
if (input.urlElicitation) {
protocol.setRequestHandler(CallToolRequestSchema, async () => {
const result = await protocol.elicitInput({
mode: "url",
message: "Authorize access",
url: "https://example.com/authorize",
elicitationId: "elicitation-test",
})
return {
content: [{ type: "text", text: JSON.stringify(result) }],
structuredContent: result,
}
})
}
if (input.resources !== false) {
protocol.setRequestHandler(ListResourcesRequestSchema, (request) => {
state.resourceLists += 1
@ -117,6 +135,7 @@ function resourceServer(input: { resources?: boolean; listChanged?: boolean; emp
state,
url: http.url.toString(),
sendResourceListChanged: () => protocol.sendResourceListChanged(),
completeElicitation: () => protocol.createElicitationCompletionNotifier("elicitation-test")(),
close: async () => {
await protocol.close().catch(() => {})
await http.stop(true)
@ -127,7 +146,10 @@ function resourceServer(input: { resources?: boolean; listChanged?: boolean; emp
)
}
function resourceMcpLayer(url: string) {
function resourceMcpLayer(
url: string,
form: Partial<Form.Interface> = { ask: () => Effect.die("Empty MCP elicitation must not create a form") },
) {
const directory = AbsolutePath.make(import.meta.dir)
const unusedIntegration = () => Effect.die("unused integration service")
return MCP.layer.pipe(
@ -159,9 +181,7 @@ function resourceMcpLayer(url: string) {
data,
} as EventV2.Payload<typeof definition>),
}),
Layer.mock(Form.Service, {
ask: () => Effect.die("Empty MCP elicitation must not create a form"),
}),
Layer.mock(Form.Service, form),
Layer.mock(Integration.Service, {
connection: {
active: unusedIntegration,
@ -527,6 +547,42 @@ test("accepts empty MCP elicitations without creating forms", async () => {
)
})
test("acknowledges completed MCP URL elicitations without returning internal content", async () => {
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
const server = yield* resourceServer({ resources: false, urlElicitation: true })
const asked = yield* Deferred.make<Form.CreateInput>()
const terminal = yield* Deferred.make<Form.TerminalState>()
const replied = yield* Deferred.make<Form.ReplyInput>()
const call = yield* Effect.gen(function* () {
const service = yield* MCP.Service
return yield* service.callTool({ server: "resources", name: "url-elicitation" })
}).pipe(
Effect.provide(
resourceMcpLayer(server.url, {
ask: (input) => Deferred.succeed(asked, input).pipe(Effect.andThen(Deferred.await(terminal))),
reply: (input) =>
Effect.gen(function* () {
yield* Deferred.succeed(replied, input)
yield* Deferred.succeed(terminal, { status: "answered", answer: input.answer })
}),
}),
),
Effect.forkScoped,
)
const form = yield* Deferred.await(asked)
expect(form.fields).toEqual([{ key: "elicitation", type: "external", url: "https://example.com/authorize" }])
yield* Effect.promise(server.completeElicitation)
expect((yield* Deferred.await(replied)).answer).toEqual({ elicitation: true })
expect((yield* Fiber.join(call)).structured).toEqual({ action: "accept" })
}),
),
)
})
test("loads and reads MCP resources", async () => {
await Effect.runPromise(
Effect.scoped(

View file

@ -14,7 +14,7 @@ function form(id: string, sessionID: string): FormInfo {
id,
sessionID,
title: "Input requested",
fields: [{ type: "external", url: "https://example.com/form" }],
fields: [{ key: "authorization", type: "external", url: "https://example.com/form" }],
}
}

View file

@ -840,7 +840,7 @@ const scenarios: Scenario[] = [
headers: ctx.headers(),
body: {
title: "External form",
fields: [{ type: "external", url: "https://example.com/form" }],
fields: [{ key: "authorization", type: "external", url: "https://example.com/form" }],
},
}))
.json(200, (body) => {

View file

@ -95,6 +95,7 @@ export const MultiselectField = Schema.Struct({
export interface MultiselectField extends Schema.Schema.Type<typeof MultiselectField> {}
export const ExternalField = Schema.Struct({
key: Schema.String,
type: Schema.Literal("external"),
url: Schema.String,
title: Schema.String.pipe(optional),

View file

@ -62,9 +62,17 @@ describe("contract hygiene", () => {
id: Form.ID.create(),
sessionID: "global",
title: "External form",
fields: [{ type: "external", url: "https://example.com" }],
fields: [{ key: "authorization", type: "external", url: "https://example.com" }],
}).fields,
).toHaveLength(1)
expect(() =>
Schema.decodeUnknownSync(Form.Info)({
id: Form.ID.create(),
sessionID: "global",
title: "External form",
fields: [{ type: "external", url: "https://example.com" }],
}),
).toThrow()
})
test("model defaults and provider overlays preserve public invariants", () => {

View file

@ -3546,6 +3546,7 @@ export type FormMultiselectField = {
}
export type FormExternalField = {
key: string
type: "external"
url: string
title?: string

View file

@ -178,6 +178,7 @@ function FieldsPrompt(props: { form: FormInfo }) {
return value === undefined ? [] : [[field.key, value]]
}),
) as Record<string, string>,
externalReady: {} as Record<string, boolean>,
selected: selectedRow(configuredFields()[0], configuredFields()[0]?.default),
editing: false,
error: "",
@ -219,7 +220,7 @@ function FieldsPrompt(props: { form: FormInfo }) {
})
const answered = createMemo(
() =>
answerable().filter((item) => {
fields().filter((item) => {
const value = store.answers[item.key]
return value !== undefined
}).length,
@ -262,8 +263,12 @@ function FieldsPrompt(props: { form: FormInfo }) {
})
const multi = createMemo(() => answerField()?.type === "multiselect")
const actionLabel = createMemo(() => {
if (confirm()) return answerable().length === 0 ? "I finished" : "submit"
if (externalField()) return "open link"
if (confirm()) return "submit"
const external = externalField()
if (external) {
if (store.answers[external.key] === true) return "continue"
return store.externalReady[external.key] ? "I finished" : "open link"
}
if (multi()) return "toggle"
if (single()) return "submit"
return "confirm"
@ -490,10 +495,9 @@ function FieldsPrompt(props: { form: FormInfo }) {
function openExternal() {
const current = externalField()
if (!current) return
const index = store.tab
setStore("error", "")
void open(current.url)
.then(() => selectTab(index + 1))
.then(() => setStore("externalReady", { ...store.externalReady, [current.key]: true }))
.catch(() => setStore("error", "Could not open the browser. Copy the URL and continue manually."))
}
@ -502,11 +506,34 @@ function FieldsPrompt(props: { form: FormInfo }) {
if (!current || !clipboard.write) return
void clipboard
.write(current.url)
.then(() => toast.show({ message: "Copied URL to clipboard", variant: "info" }))
.then(() => {
setStore("externalReady", { ...store.externalReady, [current.key]: true })
toast.show({ message: "Copied URL to clipboard", variant: "info" })
})
.catch(toast.error)
}
function acknowledgeExternal() {
const current = externalField()
if (!current) return
if (store.answers[current.key] === true) {
selectTab(store.tab + 1)
return
}
if (!store.externalReady[current.key]) {
openExternal()
return
}
answer(current.key, true)
selectTab(store.tab + 1)
}
function submit() {
const unacknowledged = fields().find((field) => field.type === "external" && store.answers[field.key] !== true)
if (unacknowledged) {
setStore("error", `External action must be acknowledged: ${fieldLabel(unacknowledged)}`)
return
}
const invalid = answerable().find((field) => validateValue(field, store.answers[field.key]))
if (invalid) {
setStore("error", validateValue(invalid, store.answers[invalid.key]) ?? "Invalid answer")
@ -518,7 +545,7 @@ function FieldsPrompt(props: { form: FormInfo }) {
sessionID: props.form.sessionID,
formID: props.form.id,
answer: Object.fromEntries(
answerable().flatMap((field) => {
fields().flatMap((field) => {
const value = store.answers[field.key]
return value === undefined ? [] : [[field.key, value] as const]
}),
@ -618,6 +645,7 @@ function FieldsPrompt(props: { form: FormInfo }) {
useBindings(() => {
const total = rows().length + (custom() ? 1 : 0)
const max = Math.min(total, 9)
const external = externalField()
return {
mode: FORM_MODE,
@ -657,9 +685,19 @@ function FieldsPrompt(props: { form: FormInfo }) {
group: "Form",
cmd: () => selectTab((store.tab - 1 + tabs()) % tabs()),
},
...(externalField()
...(external
? [
{ key: "return", desc: "Open link", group: "Form", cmd: openExternal },
{
key: "return",
desc:
store.answers[external.key] === true
? "Continue"
: store.externalReady[external.key]
? "Confirm completion"
: "Open link",
group: "Form",
cmd: acknowledgeExternal,
},
{ key: "c", desc: "Copy link", group: "Form", cmd: copyExternal },
{ key: "escape", desc: "Dismiss form", group: "Form", cmd: cancel },
...tuiConfig.keybinds.get("app.exit"),
@ -668,7 +706,7 @@ function FieldsPrompt(props: { form: FormInfo }) {
? [
{
key: "return",
desc: answerable().length === 0 ? "Finish" : "Submit form",
desc: "Submit form",
group: "Form",
cmd: submit,
},
@ -752,9 +790,9 @@ function FieldsPrompt(props: { form: FormInfo }) {
<text fg={theme.textMuted}>
{confirm() ? "Review" : `Field ${Math.min(store.tab, fields().length - 1) + 1} of ${fields().length}`}
</text>
<Show when={answerable().length > 0}>
<Show when={fields().length > 0}>
<text fg={theme.textMuted}>
· {answered()}/{answerable().length} answered
· {answered()}/{fields().length} completed
</text>
</Show>
</box>
@ -764,7 +802,7 @@ function FieldsPrompt(props: { form: FormInfo }) {
<For each={fields()}>
{(item, index) => {
const isTab = () => index() === store.tab
const isAnswered = () => item.type !== "external" && store.answers[item.key] !== undefined
const isAnswered = () => store.answers[item.key] !== undefined
return (
<box
paddingLeft={1}
@ -826,6 +864,13 @@ function FieldsPrompt(props: { form: FormInfo }) {
>
{external().url}
</text>
<text fg={store.answers[external().key] === true ? theme.success : theme.textMuted}>
{store.answers[external().key] === true
? "✓ Acknowledged"
: store.externalReady[external().key]
? "Complete the external action, then press enter to confirm."
: "Open or copy the URL, complete the external action, then confirm."}
</text>
</box>
)}
</Show>
@ -963,51 +1008,52 @@ function FieldsPrompt(props: { form: FormInfo }) {
</Show>
<Show when={confirm()}>
<Show when={tabbed() && answerable().length > 0}>
<Show when={tabbed()}>
<box paddingLeft={1}>
<text fg={theme.text}>Review</text>
</box>
</Show>
<Show
when={answerable().length > 0}
fallback={
<box paddingLeft={1}>
<text fg={theme.textMuted}>Complete the browser flow before continuing.</text>
</box>
}
<scrollbox
maxHeight={Math.min(fields().length, Math.max(3, dimensions().height - 14))}
scrollbarOptions={{ visible: false }}
ref={(r: ScrollBoxRenderable) => (review = r)}
>
<scrollbox
maxHeight={Math.min(answerable().length, Math.max(3, dimensions().height - 14))}
scrollbarOptions={{ visible: false }}
ref={(r: ScrollBoxRenderable) => (review = r)}
>
<For each={answerable()}>
{(item) => {
const value = () => display(item, store.answers[item.key])
const answered = () => {
const value = store.answers[item.key]
return value !== undefined
}
const missing = () => !answered() && item.required === true
const invalid = () => validateValue(item, store.answers[item.key])
<For each={fields()}>
{(item) => {
if (item.type === "external") {
const acknowledged = () => store.answers[item.key] === true
return (
<box paddingLeft={1}>
<text>
<span style={{ fg: theme.textMuted }}>{truncate(fieldLabel(item), 40)}:</span>{" "}
<span
style={{
fg: invalid() || missing() ? theme.error : answered() ? theme.text : theme.textMuted,
}}
>
{invalid() ?? (answered() ? value() : missing() ? "(required)" : "(not answered)")}
<span style={{ fg: acknowledged() ? theme.success : theme.error }}>
{acknowledged() ? "Acknowledged" : "(acknowledgement required)"}
</span>
</text>
</box>
)
}}
</For>
</scrollbox>
</Show>
}
const value = () => display(item, store.answers[item.key])
const answered = () => store.answers[item.key] !== undefined
const missing = () => !answered() && item.required === true
const invalid = () => validateValue(item, store.answers[item.key])
return (
<box paddingLeft={1}>
<text>
<span style={{ fg: theme.textMuted }}>{truncate(fieldLabel(item), 40)}:</span>{" "}
<span
style={{
fg: invalid() || missing() ? theme.error : answered() ? theme.text : theme.textMuted,
}}
>
{invalid() ?? (answered() ? value() : missing() ? "(required)" : "(not answered)")}
</span>
</text>
</box>
)
}}
</For>
</scrollbox>
</Show>
</box>
<box
@ -1030,7 +1076,7 @@ function FieldsPrompt(props: { form: FormInfo }) {
{"↑↓"} <span style={{ fg: theme.textMuted }}>select</span>
</text>
</Show>
<Show when={confirm() && answerable().length > 0}>
<Show when={confirm() && fields().length > 0}>
<text fg={theme.text}>
{"↑↓"} <span style={{ fg: theme.textMuted }}>scroll</span>
</text>
@ -1040,7 +1086,7 @@ function FieldsPrompt(props: { form: FormInfo }) {
onMouseUp={() => {
if (renderer.getSelection()?.getSelectedText()) return
if (confirm()) submit()
if (externalField()) openExternal()
if (externalField()) acknowledgeExternal()
}}
>
enter <span style={{ fg: theme.textMuted }}>{actionLabel()}</span>

View file

@ -82,7 +82,7 @@ function form(id: string, sessionID = "session"): Extract<OpenCodeEvent, { type:
id,
sessionID,
title: "Input requested",
fields: [{ type: "external", url: "https://example.com" }],
fields: [{ key: "authorization", type: "external", url: "https://example.com" }],
}
}

View file

@ -12,8 +12,9 @@ import { createSessionRows, type SessionRow } from "../../../src/routes/session/
import { createApi, createClient, createEventStream, createFetch, directory, json } from "../../fixture/tui-sdk"
import { TestTuiContexts } from "../../fixture/tui-environment"
const formFields = [{ type: "external", url: "https://example.com" }] satisfies [
const formFields = [{ key: "authorization", type: "external", url: "https://example.com" }] satisfies [
{
key: string
type: "external"
url: string
},
@ -1813,7 +1814,7 @@ test("reconciles all pending form requests when the event stream reconnects", as
id: "frm_keep",
sessionID: "ses_keep",
title: "Input requested",
fields: [{ type: "external" as const, url: "https://example.com" }],
fields: [{ key: "authorization", type: "external" as const, url: "https://example.com" }],
},
]
let calls = 0

View file

@ -0,0 +1,199 @@
/** @jsxImportSource @opentui/solid */
import { createDefaultOpenTuiKeymap } from "@opentui/keymap/opentui"
import { testRender, useRenderer } from "@opentui/solid"
import { beforeEach, expect, mock, test } from "bun:test"
import { mkdir } from "node:fs/promises"
import path from "node:path"
import { onCleanup } from "solid-js"
import { ClipboardProvider } from "../../../src/context/clipboard"
import type { FormInfo } from "../../../src/context/data"
import { KVProvider } from "../../../src/context/kv"
import { SDKProvider } from "../../../src/context/sdk"
import { ThemeProvider } from "../../../src/context/theme"
import { TuiConfigProvider } from "../../../src/config"
import { OpencodeKeymapProvider, registerOpencodeKeymap } from "../../../src/keymap"
import { ToastProvider } from "../../../src/ui/toast"
import { tmpdir } from "../../fixture/fixture"
import { TestTuiContexts } from "../../fixture/tui-environment"
import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
import { createApi, createClient, createEventStream, createFetch } from "../../fixture/tui-sdk"
const opened: string[] = []
let failOpen = false
await mock.module("open", () => ({
default: (url: string) => {
opened.push(url)
return failOpen ? Promise.reject(new Error("open failed")) : Promise.resolve()
},
}))
beforeEach(() => {
opened.length = 0
failOpen = false
})
async function wait(fn: () => boolean, timeout = 2000) {
const start = Date.now()
while (!fn()) {
if (Date.now() - start > timeout) throw new Error("timed out waiting for condition")
await Bun.sleep(10)
}
}
async function mountForm(width = 80) {
const tmp = await tmpdir()
const state = path.join(tmp.path, "state")
await mkdir(state, { recursive: true })
await Bun.write(path.join(state, "kv.json"), "{}")
const events = createEventStream()
const replies: unknown[] = []
const copied: string[] = []
const transport = createFetch(undefined, events)
const fetch = Object.assign(
async (input: RequestInfo | URL, init?: RequestInit) => {
const request = input instanceof Request ? input : new Request(input, init)
if (new URL(request.url).pathname === "/api/session/ses_test/form/frm_test/reply") {
replies.push(await request.clone().json())
return new Response(null, { status: 204 })
}
return transport.fetch(request)
},
{ preconnect: globalThis.fetch.preconnect },
)
const config = createTuiResolvedConfig()
const form = {
id: "frm_test",
sessionID: "ses_test",
title: "Authorization required",
fields: [
{
key: "authorization",
type: "external",
url: "https://example.com/authorize",
title: "Authorize access",
},
],
} satisfies FormInfo
const { FormPrompt } = await import("../../../src/routes/session/form")
function Harness() {
const renderer = useRenderer()
const keymap = createDefaultOpenTuiKeymap(renderer)
const off = registerOpencodeKeymap(keymap, renderer, config)
onCleanup(off)
return (
<TestTuiContexts
directory={tmp.path}
paths={{
home: tmp.path,
state,
worktree: tmp.path,
}}
>
<ClipboardProvider
value={{
write(text) {
copied.push(text)
return Promise.resolve()
},
}}
>
<OpencodeKeymapProvider keymap={keymap}>
<TuiConfigProvider config={config}>
<SDKProvider client={createClient(fetch)} api={createApi(fetch)}>
<KVProvider>
<ThemeProvider mode="dark" source={{ discover: () => Promise.resolve({}) }}>
<ToastProvider>
<FormPrompt form={form} />
</ToastProvider>
</ThemeProvider>
</KVProvider>
</SDKProvider>
</TuiConfigProvider>
</OpencodeKeymapProvider>
</ClipboardProvider>
</TestTuiContexts>
)
}
const app = await testRender(() => <Harness />, { width, height: 20, kittyKeyboard: true })
await wait(() => app.captureCharFrame().includes("Authorization required"))
return {
app,
copied,
replies,
async cleanup() {
app.renderer.destroy()
await tmp[Symbol.asyncDispose]()
},
}
}
test("requires explicit acknowledgement after a successful browser launch", async () => {
const prompt = await mountForm()
try {
prompt.app.mockInput.pressKey("right")
await wait(() => prompt.app.captureCharFrame().includes("(acknowledgement required)"))
prompt.app.mockInput.pressEnter()
await wait(() => prompt.app.captureCharFrame().includes("External action must be acknowledged"))
expect(prompt.replies).toEqual([])
expect(prompt.app.captureCharFrame()).toContain("External action must be acknowledged")
prompt.app.mockInput.pressKey("left")
failOpen = true
prompt.app.mockInput.pressEnter()
await wait(() => prompt.app.captureCharFrame().includes("Could not open the browser"))
expect(prompt.app.captureCharFrame()).not.toContain("press enter to confirm")
failOpen = false
prompt.app.mockInput.pressEnter()
await wait(() => prompt.app.captureCharFrame().includes("press enter to confirm"))
expect(opened).toEqual(["https://example.com/authorize", "https://example.com/authorize"])
expect(prompt.replies).toEqual([])
prompt.app.mockInput.pressEnter()
await wait(() => prompt.app.captureCharFrame().includes("Acknowledged"))
expect(prompt.replies).toEqual([])
prompt.app.mockInput.pressEnter()
await wait(() => prompt.replies.length === 1)
expect(prompt.replies).toEqual([{ answer: { authorization: true } }])
} finally {
await prompt.cleanup()
}
})
test("includes external acknowledgements in progress", async () => {
const prompt = await mountForm(32)
try {
expect(prompt.app.captureCharFrame()).toContain("0/1")
expect(prompt.replies).toEqual([])
} finally {
await prompt.cleanup()
}
})
test("requires explicit acknowledgement after copying an external URL", async () => {
const prompt = await mountForm()
try {
prompt.app.mockInput.pressKey("c")
await wait(() => prompt.copied.length === 1 && prompt.app.captureCharFrame().includes("press enter to confirm"))
expect(prompt.copied).toEqual(["https://example.com/authorize"])
expect(opened).toEqual([])
expect(prompt.app.captureCharFrame()).toContain("press enter to confirm")
expect(prompt.replies).toEqual([])
prompt.app.mockInput.pressEnter()
await wait(() => prompt.app.captureCharFrame().includes("Acknowledged"))
expect(prompt.replies).toEqual([])
prompt.app.mockInput.pressEnter()
await wait(() => prompt.replies.length === 1)
expect(prompt.replies).toEqual([{ answer: { authorization: true } }])
} finally {
await prompt.cleanup()
}
})