mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-17 02:13:31 +00:00
feat(form): require external acknowledgements
This commit is contained in:
parent
8b53c84e0a
commit
f7b9962d1e
14 changed files with 477 additions and 95 deletions
|
|
@ -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 }
|
||||
>,
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}`
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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" }],
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -3546,6 +3546,7 @@ export type FormMultiselectField = {
|
|||
}
|
||||
|
||||
export type FormExternalField = {
|
||||
key: string
|
||||
type: "external"
|
||||
url: string
|
||||
title?: string
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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" }],
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
199
packages/tui/test/cli/tui/form.test.tsx
Normal file
199
packages/tui/test/cli/tui/form.test.tsx
Normal 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()
|
||||
}
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue