feat(core): global form support (#35959)
This commit is contained in:
parent
79415f625a
commit
b44a981eef
17 changed files with 493 additions and 93 deletions
|
|
@ -526,7 +526,7 @@ type Endpoint13_2Request = Parameters<RawClient["server.form"]["session.form.cre
|
||||||
export type Endpoint13_2Input = {
|
export type Endpoint13_2Input = {
|
||||||
readonly sessionID: Endpoint13_2Request["params"]["sessionID"]
|
readonly sessionID: Endpoint13_2Request["params"]["sessionID"]
|
||||||
readonly id?: Endpoint13_2Request["payload"]["id"]
|
readonly id?: Endpoint13_2Request["payload"]["id"]
|
||||||
readonly title?: Endpoint13_2Request["payload"]["title"]
|
readonly title: Endpoint13_2Request["payload"]["title"]
|
||||||
readonly metadata?: Endpoint13_2Request["payload"]["metadata"]
|
readonly metadata?: Endpoint13_2Request["payload"]["metadata"]
|
||||||
readonly mode: Endpoint13_2Request["payload"]["mode"]
|
readonly mode: Endpoint13_2Request["payload"]["mode"]
|
||||||
readonly fields?: Endpoint13_2Request["payload"]["fields"]
|
readonly fields?: Endpoint13_2Request["payload"]["fields"]
|
||||||
|
|
|
||||||
|
|
@ -621,7 +621,7 @@ type Endpoint13_2Request = Parameters<RawClient["server.form"]["session.form.cre
|
||||||
type Endpoint13_2Input = {
|
type Endpoint13_2Input = {
|
||||||
readonly sessionID: Endpoint13_2Request["params"]["sessionID"]
|
readonly sessionID: Endpoint13_2Request["params"]["sessionID"]
|
||||||
readonly id?: Endpoint13_2Request["payload"]["id"]
|
readonly id?: Endpoint13_2Request["payload"]["id"]
|
||||||
readonly title?: Endpoint13_2Request["payload"]["title"]
|
readonly title: Endpoint13_2Request["payload"]["title"]
|
||||||
readonly metadata?: Endpoint13_2Request["payload"]["metadata"]
|
readonly metadata?: Endpoint13_2Request["payload"]["metadata"]
|
||||||
readonly mode: Endpoint13_2Request["payload"]["mode"]
|
readonly mode: Endpoint13_2Request["payload"]["mode"]
|
||||||
readonly fields?: Endpoint13_2Request["payload"]["fields"]
|
readonly fields?: Endpoint13_2Request["payload"]["fields"]
|
||||||
|
|
|
||||||
|
|
@ -2598,7 +2598,7 @@ export type FormRequestListOutput = {
|
||||||
| {
|
| {
|
||||||
readonly id: string
|
readonly id: string
|
||||||
readonly sessionID: string
|
readonly sessionID: string
|
||||||
readonly title?: string
|
readonly title: string
|
||||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||||
readonly mode: "form"
|
readonly mode: "form"
|
||||||
readonly fields: ReadonlyArray<
|
readonly fields: ReadonlyArray<
|
||||||
|
|
@ -2695,7 +2695,7 @@ export type FormRequestListOutput = {
|
||||||
| {
|
| {
|
||||||
readonly id: string
|
readonly id: string
|
||||||
readonly sessionID: string
|
readonly sessionID: string
|
||||||
readonly title?: string
|
readonly title: string
|
||||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||||
readonly mode: "url"
|
readonly mode: "url"
|
||||||
readonly url: string
|
readonly url: string
|
||||||
|
|
@ -2710,7 +2710,7 @@ export type FormListOutput = {
|
||||||
| {
|
| {
|
||||||
readonly id: string
|
readonly id: string
|
||||||
readonly sessionID: string
|
readonly sessionID: string
|
||||||
readonly title?: string
|
readonly title: string
|
||||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||||
readonly mode: "form"
|
readonly mode: "form"
|
||||||
readonly fields: ReadonlyArray<
|
readonly fields: ReadonlyArray<
|
||||||
|
|
@ -2807,7 +2807,7 @@ export type FormListOutput = {
|
||||||
| {
|
| {
|
||||||
readonly id: string
|
readonly id: string
|
||||||
readonly sessionID: string
|
readonly sessionID: string
|
||||||
readonly title?: string
|
readonly title: string
|
||||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||||
readonly mode: "url"
|
readonly mode: "url"
|
||||||
readonly url: string
|
readonly url: string
|
||||||
|
|
@ -2819,7 +2819,7 @@ export type FormCreateInput = {
|
||||||
readonly sessionID: { readonly sessionID: string }["sessionID"]
|
readonly sessionID: { readonly sessionID: string }["sessionID"]
|
||||||
readonly id?: {
|
readonly id?: {
|
||||||
readonly id?: string | null
|
readonly id?: string | null
|
||||||
readonly title?: string
|
readonly title: string
|
||||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||||
readonly mode: "form" | "url"
|
readonly mode: "form" | "url"
|
||||||
readonly fields?: ReadonlyArray<
|
readonly fields?: ReadonlyArray<
|
||||||
|
|
@ -2914,9 +2914,9 @@ export type FormCreateInput = {
|
||||||
> | null
|
> | null
|
||||||
readonly url?: string | null
|
readonly url?: string | null
|
||||||
}["id"]
|
}["id"]
|
||||||
readonly title?: {
|
readonly title: {
|
||||||
readonly id?: string | null
|
readonly id?: string | null
|
||||||
readonly title?: string
|
readonly title: string
|
||||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||||
readonly mode: "form" | "url"
|
readonly mode: "form" | "url"
|
||||||
readonly fields?: ReadonlyArray<
|
readonly fields?: ReadonlyArray<
|
||||||
|
|
@ -3013,7 +3013,7 @@ export type FormCreateInput = {
|
||||||
}["title"]
|
}["title"]
|
||||||
readonly metadata?: {
|
readonly metadata?: {
|
||||||
readonly id?: string | null
|
readonly id?: string | null
|
||||||
readonly title?: string
|
readonly title: string
|
||||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||||
readonly mode: "form" | "url"
|
readonly mode: "form" | "url"
|
||||||
readonly fields?: ReadonlyArray<
|
readonly fields?: ReadonlyArray<
|
||||||
|
|
@ -3110,7 +3110,7 @@ export type FormCreateInput = {
|
||||||
}["metadata"]
|
}["metadata"]
|
||||||
readonly mode: {
|
readonly mode: {
|
||||||
readonly id?: string | null
|
readonly id?: string | null
|
||||||
readonly title?: string
|
readonly title: string
|
||||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||||
readonly mode: "form" | "url"
|
readonly mode: "form" | "url"
|
||||||
readonly fields?: ReadonlyArray<
|
readonly fields?: ReadonlyArray<
|
||||||
|
|
@ -3207,7 +3207,7 @@ export type FormCreateInput = {
|
||||||
}["mode"]
|
}["mode"]
|
||||||
readonly fields?: {
|
readonly fields?: {
|
||||||
readonly id?: string | null
|
readonly id?: string | null
|
||||||
readonly title?: string
|
readonly title: string
|
||||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||||
readonly mode: "form" | "url"
|
readonly mode: "form" | "url"
|
||||||
readonly fields?: ReadonlyArray<
|
readonly fields?: ReadonlyArray<
|
||||||
|
|
@ -3304,7 +3304,7 @@ export type FormCreateInput = {
|
||||||
}["fields"]
|
}["fields"]
|
||||||
readonly url?: {
|
readonly url?: {
|
||||||
readonly id?: string | null
|
readonly id?: string | null
|
||||||
readonly title?: string
|
readonly title: string
|
||||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||||
readonly mode: "form" | "url"
|
readonly mode: "form" | "url"
|
||||||
readonly fields?: ReadonlyArray<
|
readonly fields?: ReadonlyArray<
|
||||||
|
|
@ -3406,7 +3406,7 @@ export type FormCreateOutput = {
|
||||||
| {
|
| {
|
||||||
readonly id: string
|
readonly id: string
|
||||||
readonly sessionID: string
|
readonly sessionID: string
|
||||||
readonly title?: string
|
readonly title: string
|
||||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||||
readonly mode: "form"
|
readonly mode: "form"
|
||||||
readonly fields: ReadonlyArray<
|
readonly fields: ReadonlyArray<
|
||||||
|
|
@ -3503,7 +3503,7 @@ export type FormCreateOutput = {
|
||||||
| {
|
| {
|
||||||
readonly id: string
|
readonly id: string
|
||||||
readonly sessionID: string
|
readonly sessionID: string
|
||||||
readonly title?: string
|
readonly title: string
|
||||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||||
readonly mode: "url"
|
readonly mode: "url"
|
||||||
readonly url: string
|
readonly url: string
|
||||||
|
|
@ -3520,7 +3520,7 @@ export type FormGetOutput = {
|
||||||
| {
|
| {
|
||||||
readonly id: string
|
readonly id: string
|
||||||
readonly sessionID: string
|
readonly sessionID: string
|
||||||
readonly title?: string
|
readonly title: string
|
||||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||||
readonly mode: "form"
|
readonly mode: "form"
|
||||||
readonly fields: ReadonlyArray<
|
readonly fields: ReadonlyArray<
|
||||||
|
|
@ -3617,7 +3617,7 @@ export type FormGetOutput = {
|
||||||
| {
|
| {
|
||||||
readonly id: string
|
readonly id: string
|
||||||
readonly sessionID: string
|
readonly sessionID: string
|
||||||
readonly title?: string
|
readonly title: string
|
||||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||||
readonly mode: "url"
|
readonly mode: "url"
|
||||||
readonly url: string
|
readonly url: string
|
||||||
|
|
@ -5339,7 +5339,7 @@ export type EventSubscribeOutput =
|
||||||
| {
|
| {
|
||||||
readonly id: string
|
readonly id: string
|
||||||
readonly sessionID: string
|
readonly sessionID: string
|
||||||
readonly title?: string
|
readonly title: string
|
||||||
readonly metadata?: { readonly [x: string]: unknown }
|
readonly metadata?: { readonly [x: string]: unknown }
|
||||||
readonly mode: "form"
|
readonly mode: "form"
|
||||||
readonly fields: ReadonlyArray<
|
readonly fields: ReadonlyArray<
|
||||||
|
|
@ -5436,7 +5436,7 @@ export type EventSubscribeOutput =
|
||||||
| {
|
| {
|
||||||
readonly id: string
|
readonly id: string
|
||||||
readonly sessionID: string
|
readonly sessionID: string
|
||||||
readonly title?: string
|
readonly title: string
|
||||||
readonly metadata?: { readonly [x: string]: unknown }
|
readonly metadata?: { readonly [x: string]: unknown }
|
||||||
readonly mode: "url"
|
readonly mode: "url"
|
||||||
readonly url: string
|
readonly url: string
|
||||||
|
|
|
||||||
|
|
@ -81,6 +81,7 @@ export const Plugin = {
|
||||||
forms
|
forms
|
||||||
.ask({
|
.ask({
|
||||||
sessionID: context.sessionID,
|
sessionID: context.sessionID,
|
||||||
|
title: "Questions",
|
||||||
metadata: {
|
metadata: {
|
||||||
kind: "question",
|
kind: "question",
|
||||||
tool: { messageID: context.assistantMessageID, callID: context.toolCallID },
|
tool: { messageID: context.assistantMessageID, callID: context.toolCallID },
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ const formID = Form.ID.create("frm_test")
|
||||||
const input = {
|
const input = {
|
||||||
id: formID,
|
id: formID,
|
||||||
sessionID: SessionSchema.ID.make("ses_test"),
|
sessionID: SessionSchema.ID.make("ses_test"),
|
||||||
|
title: "Test form",
|
||||||
mode: "form",
|
mode: "form",
|
||||||
fields: [{ key: "name", type: "string", required: true }],
|
fields: [{ key: "name", type: "string", required: true }],
|
||||||
} satisfies Form.CreateInput
|
} satisfies Form.CreateInput
|
||||||
|
|
@ -45,10 +46,12 @@ describe("Form", () => {
|
||||||
const service = yield* Form.Service
|
const service = yield* Form.Service
|
||||||
const created = yield* service.create({
|
const created = yield* service.create({
|
||||||
sessionID: "global",
|
sessionID: "global",
|
||||||
|
title: "MCP input",
|
||||||
mode: "form",
|
mode: "form",
|
||||||
fields: [{ key: "name", type: "string", required: true }],
|
fields: [{ key: "name", type: "string", required: true }],
|
||||||
})
|
})
|
||||||
expect(created.sessionID).toBe("global")
|
expect(created.sessionID).toBe("global")
|
||||||
|
expect(created.title).toBe("MCP input")
|
||||||
|
|
||||||
const owned = yield* service.list({ sessionID: "global" })
|
const owned = yield* service.list({ sessionID: "global" })
|
||||||
expect(owned.map((form) => form.id)).toEqual([created.id])
|
expect(owned.map((form) => form.id)).toEqual([created.id])
|
||||||
|
|
@ -64,6 +67,7 @@ describe("Form", () => {
|
||||||
const service = yield* Form.Service
|
const service = yield* Form.Service
|
||||||
const created = yield* service.create({
|
const created = yield* service.create({
|
||||||
sessionID: "global",
|
sessionID: "global",
|
||||||
|
title: "Conditional form",
|
||||||
mode: "form",
|
mode: "form",
|
||||||
fields: [
|
fields: [
|
||||||
{ key: "confirm", type: "boolean", required: true },
|
{ key: "confirm", type: "boolean", required: true },
|
||||||
|
|
@ -96,6 +100,7 @@ describe("Form", () => {
|
||||||
]
|
]
|
||||||
const created = yield* service.create({
|
const created = yield* service.create({
|
||||||
sessionID: "global",
|
sessionID: "global",
|
||||||
|
title: "Multiselect form",
|
||||||
mode: "form",
|
mode: "form",
|
||||||
fields: [
|
fields: [
|
||||||
{ key: "langs", type: "multiselect", options },
|
{ key: "langs", type: "multiselect", options },
|
||||||
|
|
@ -118,6 +123,7 @@ describe("Form", () => {
|
||||||
const service = yield* Form.Service
|
const service = yield* Form.Service
|
||||||
const created = yield* service.create({
|
const created = yield* service.create({
|
||||||
sessionID: "global",
|
sessionID: "global",
|
||||||
|
title: "Dependent form",
|
||||||
mode: "form",
|
mode: "form",
|
||||||
fields: [
|
fields: [
|
||||||
{ key: "a", type: "boolean" },
|
{ key: "a", type: "boolean" },
|
||||||
|
|
@ -160,6 +166,7 @@ describe("Form", () => {
|
||||||
]
|
]
|
||||||
const created = yield* service.create({
|
const created = yield* service.create({
|
||||||
sessionID: "global",
|
sessionID: "global",
|
||||||
|
title: "Selection form",
|
||||||
mode: "form",
|
mode: "form",
|
||||||
fields: [
|
fields: [
|
||||||
{ key: "langs", type: "multiselect", options },
|
{ key: "langs", type: "multiselect", options },
|
||||||
|
|
@ -191,6 +198,7 @@ describe("Form", () => {
|
||||||
const service = yield* Form.Service
|
const service = yield* Form.Service
|
||||||
const created = yield* service.create({
|
const created = yield* service.create({
|
||||||
sessionID: "global",
|
sessionID: "global",
|
||||||
|
title: "Cascading form",
|
||||||
mode: "form",
|
mode: "form",
|
||||||
fields: [
|
fields: [
|
||||||
{ key: "a", type: "boolean" },
|
{ key: "a", type: "boolean" },
|
||||||
|
|
@ -213,7 +221,7 @@ describe("Form", () => {
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const service = yield* Form.Service
|
const service = yield* Form.Service
|
||||||
const flipCreate = (fields: ReadonlyArray<Form.Field>) =>
|
const flipCreate = (fields: ReadonlyArray<Form.Field>) =>
|
||||||
service.create({ sessionID: "global", mode: "form", fields }).pipe(Effect.flip)
|
service.create({ sessionID: "global", title: "Invalid form", mode: "form", fields }).pipe(Effect.flip)
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
yield* flipCreate([
|
yield* flipCreate([
|
||||||
|
|
|
||||||
|
|
@ -156,6 +156,7 @@ describe("QuestionTool", () => {
|
||||||
expect(assertions).toMatchObject([{ sessionID, action: "question", resources: ["*"] }])
|
expect(assertions).toMatchObject([{ sessionID, action: "question", resources: ["*"] }])
|
||||||
expect(capturedInput()).toEqual({
|
expect(capturedInput()).toEqual({
|
||||||
sessionID,
|
sessionID,
|
||||||
|
title: "Questions",
|
||||||
metadata: { kind: "question", tool: { messageID: toolIdentity.assistantMessageID, callID: "call-question" } },
|
metadata: { kind: "question", tool: { messageID: toolIdentity.assistantMessageID, callID: "call-question" } },
|
||||||
mode: "form",
|
mode: "form",
|
||||||
fields: [
|
fields: [
|
||||||
|
|
@ -202,6 +203,7 @@ describe("QuestionTool", () => {
|
||||||
})
|
})
|
||||||
expect(capturedInput()).toEqual({
|
expect(capturedInput()).toEqual({
|
||||||
sessionID,
|
sessionID,
|
||||||
|
title: "Questions",
|
||||||
metadata: { kind: "question", tool: { messageID: toolIdentity.assistantMessageID, callID: "call-question" } },
|
metadata: { kind: "question", tool: { messageID: toolIdentity.assistantMessageID, callID: "call-question" } },
|
||||||
mode: "form",
|
mode: "form",
|
||||||
fields: [],
|
fields: [],
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ function ok<T>(data: T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function form(id: string, sessionID: string): FormInfo {
|
function form(id: string, sessionID: string): FormInfo {
|
||||||
return { id, sessionID, mode: "form", fields: [] }
|
return { id, sessionID, title: "Input requested", mode: "form", fields: [] }
|
||||||
}
|
}
|
||||||
|
|
||||||
function formCreated(info: FormInfo): V2Event {
|
function formCreated(info: FormInfo): V2Event {
|
||||||
|
|
|
||||||
|
|
@ -106,7 +106,7 @@ const InfoBase = {
|
||||||
// elicitations can be attributed to real sessions, revert this to SessionID. Do not rely
|
// elicitations can be attributed to real sessions, revert this to SessionID. Do not rely
|
||||||
// on non-session owners anywhere else.
|
// on non-session owners anywhere else.
|
||||||
sessionID: Schema.String,
|
sessionID: Schema.String,
|
||||||
title: Schema.String.pipe(optional),
|
title: Schema.String,
|
||||||
metadata: Metadata.pipe(optional),
|
metadata: Metadata.pipe(optional),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3556,7 +3556,7 @@ export type FormMultiselectField = {
|
||||||
export type FormFormInfo = {
|
export type FormFormInfo = {
|
||||||
id: string
|
id: string
|
||||||
sessionID: string
|
sessionID: string
|
||||||
title?: string
|
title: string
|
||||||
metadata?: FormMetadata
|
metadata?: FormMetadata
|
||||||
mode: "form"
|
mode: "form"
|
||||||
fields: Array<FormStringField | FormNumberField | FormIntegerField | FormBooleanField | FormMultiselectField>
|
fields: Array<FormStringField | FormNumberField | FormIntegerField | FormBooleanField | FormMultiselectField>
|
||||||
|
|
@ -3565,7 +3565,7 @@ export type FormFormInfo = {
|
||||||
export type FormUrlInfo = {
|
export type FormUrlInfo = {
|
||||||
id: string
|
id: string
|
||||||
sessionID: string
|
sessionID: string
|
||||||
title?: string
|
title: string
|
||||||
metadata?: FormMetadata
|
metadata?: FormMetadata
|
||||||
mode: "url"
|
mode: "url"
|
||||||
url: string
|
url: string
|
||||||
|
|
@ -5832,7 +5832,7 @@ export type ProjectCurrent = {
|
||||||
|
|
||||||
export type FormCreatePayload = {
|
export type FormCreatePayload = {
|
||||||
id?: string
|
id?: string
|
||||||
title?: string
|
title: string
|
||||||
metadata?: FormMetadata
|
metadata?: FormMetadata
|
||||||
mode: "form" | "url"
|
mode: "form" | "url"
|
||||||
fields?: Array<FormStringField | FormNumberField | FormIntegerField | FormBooleanField | FormMultiselectField>
|
fields?: Array<FormStringField | FormNumberField | FormIntegerField | FormBooleanField | FormMultiselectField>
|
||||||
|
|
@ -9878,7 +9878,7 @@ export type FormMultiselectFieldV2 = {
|
||||||
export type FormFormInfoV2 = {
|
export type FormFormInfoV2 = {
|
||||||
id: string
|
id: string
|
||||||
sessionID: string
|
sessionID: string
|
||||||
title?: string
|
title: string
|
||||||
metadata?: FormMetadata
|
metadata?: FormMetadata
|
||||||
mode: "form"
|
mode: "form"
|
||||||
fields: Array<FormStringFieldV2 | FormNumberField | FormIntegerField | FormBooleanField | FormMultiselectFieldV2>
|
fields: Array<FormStringFieldV2 | FormNumberField | FormIntegerField | FormBooleanField | FormMultiselectFieldV2>
|
||||||
|
|
@ -9887,7 +9887,7 @@ export type FormFormInfoV2 = {
|
||||||
export type FormUrlInfoV2 = {
|
export type FormUrlInfoV2 = {
|
||||||
id: string
|
id: string
|
||||||
sessionID: string
|
sessionID: string
|
||||||
title?: string
|
title: string
|
||||||
metadata?: FormMetadata
|
metadata?: FormMetadata
|
||||||
mode: "url"
|
mode: "url"
|
||||||
url: string
|
url: string
|
||||||
|
|
@ -9895,7 +9895,7 @@ export type FormUrlInfoV2 = {
|
||||||
|
|
||||||
export type FormCreatePayloadV2 = {
|
export type FormCreatePayloadV2 = {
|
||||||
id?: string | null
|
id?: string | null
|
||||||
title?: string
|
title: string
|
||||||
metadata?: FormMetadata
|
metadata?: FormMetadata
|
||||||
mode: "form" | "url"
|
mode: "form" | "url"
|
||||||
fields?: Array<
|
fields?: Array<
|
||||||
|
|
@ -10613,7 +10613,7 @@ export type FormMultiselectField1 = {
|
||||||
export type FormFormInfo1 = {
|
export type FormFormInfo1 = {
|
||||||
id: string
|
id: string
|
||||||
sessionID: string
|
sessionID: string
|
||||||
title?: string
|
title: string
|
||||||
metadata?: FormMetadata1
|
metadata?: FormMetadata1
|
||||||
mode: "form"
|
mode: "form"
|
||||||
fields: Array<FormStringField1 | FormNumberField1 | FormIntegerField1 | FormBooleanField1 | FormMultiselectField1>
|
fields: Array<FormStringField1 | FormNumberField1 | FormIntegerField1 | FormBooleanField1 | FormMultiselectField1>
|
||||||
|
|
@ -10622,7 +10622,7 @@ export type FormFormInfo1 = {
|
||||||
export type FormUrlInfo1 = {
|
export type FormUrlInfo1 = {
|
||||||
id: string
|
id: string
|
||||||
sessionID: string
|
sessionID: string
|
||||||
title?: string
|
title: string
|
||||||
metadata?: FormMetadata1
|
metadata?: FormMetadata1
|
||||||
mode: "url"
|
mode: "url"
|
||||||
url: string
|
url: string
|
||||||
|
|
|
||||||
|
|
@ -629,7 +629,7 @@ export function Prompt(props: PromptProps) {
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
if (!input || input.isDestroyed) return
|
if (!input || input.isDestroyed) return
|
||||||
if (props.visible === false || dialog.stack.length > 0) {
|
if (props.visible === false || props.disabled || dialog.stack.length > 0) {
|
||||||
if (input.focused) input.blur()
|
if (input.focused) input.blur()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -1452,7 +1452,10 @@ export function Prompt(props: PromptProps) {
|
||||||
input.cursorColor = theme.text
|
input.cursorColor = theme.text
|
||||||
}, 0)
|
}, 0)
|
||||||
}}
|
}}
|
||||||
onMouseDown={(r: MouseEvent) => r.target?.focus()}
|
onMouseDown={(r: MouseEvent) => {
|
||||||
|
if (props.disabled) return
|
||||||
|
r.target?.focus()
|
||||||
|
}}
|
||||||
focusedBackgroundColor={theme.backgroundElement}
|
focusedBackgroundColor={theme.backgroundElement}
|
||||||
cursorColor={props.disabled ? theme.backgroundElement : theme.text}
|
cursorColor={props.disabled ? theme.backgroundElement : theme.text}
|
||||||
syntaxStyle={syntax()}
|
syntaxStyle={syntax()}
|
||||||
|
|
|
||||||
|
|
@ -35,7 +35,10 @@ export type DataSessionStatus = "idle" | "running"
|
||||||
|
|
||||||
const messageIDFromEvent = (eventID: string) => eventID.replace(/^evt_/, "msg_")
|
const messageIDFromEvent = (eventID: string) => eventID.replace(/^evt_/, "msg_")
|
||||||
|
|
||||||
export type FormInfo = FormFormInfo | FormUrlInfo
|
// Global MCP elicitations temporarily use "global" instead of a real session ID, so the
|
||||||
|
// server cannot recover their Location when settling them. Preserve the event Location
|
||||||
|
// until MCP elicitations carry session ownership.
|
||||||
|
export type FormInfo = (FormFormInfo | FormUrlInfo) & { readonly location?: LocationRef }
|
||||||
|
|
||||||
type LocationData = {
|
type LocationData = {
|
||||||
agent?: AgentInfo[]
|
agent?: AgentInfo[]
|
||||||
|
|
@ -62,7 +65,7 @@ type Data = {
|
||||||
message: Record<string, SessionMessageInfo[]>
|
message: Record<string, SessionMessageInfo[]>
|
||||||
input: Record<string, string[]>
|
input: Record<string, string[]>
|
||||||
permission: Record<string, PermissionV2Request[]>
|
permission: Record<string, PermissionV2Request[]>
|
||||||
// Pending forms keyed by session ID.
|
// Pending forms keyed by owner: a session ID or the temporary "global" elicitation sentinel.
|
||||||
form: Record<string, FormInfo[]>
|
form: Record<string, FormInfo[]>
|
||||||
}
|
}
|
||||||
project: {
|
project: {
|
||||||
|
|
@ -734,7 +737,11 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||||
if (store.session.form[event.data.form.sessionID]?.some((form) => form.id === event.data.form.id)) break
|
if (store.session.form[event.data.form.sessionID]?.some((form) => form.id === event.data.form.id)) break
|
||||||
setStore("session", "form", event.data.form.sessionID, [
|
setStore("session", "form", event.data.form.sessionID, [
|
||||||
...(store.session.form[event.data.form.sessionID] ?? []),
|
...(store.session.form[event.data.form.sessionID] ?? []),
|
||||||
mutable(event.data.form),
|
mutable(
|
||||||
|
event.data.form.sessionID === "global"
|
||||||
|
? { ...event.data.form, location: event.location }
|
||||||
|
: event.data.form,
|
||||||
|
),
|
||||||
])
|
])
|
||||||
break
|
break
|
||||||
case "form.replied":
|
case "form.replied":
|
||||||
|
|
@ -849,10 +856,31 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
form: {
|
form: {
|
||||||
list(sessionID: string) {
|
list(sessionID: string, ref?: LocationRef) {
|
||||||
return store.session.form[sessionID]
|
const forms = store.session.form[sessionID]
|
||||||
|
if (sessionID !== "global") return forms
|
||||||
|
if (!ref) return
|
||||||
|
const key = locationKey(ref)
|
||||||
|
return forms?.filter((form) => form.location && locationKey(form.location) === key)
|
||||||
},
|
},
|
||||||
async refresh(sessionID: string) {
|
async refresh(sessionID: string, ref?: LocationRef) {
|
||||||
|
if (sessionID === "global") {
|
||||||
|
const response = await sdk.api.form.request.list({ location: locationQuery(ref ?? defaultLocation()) })
|
||||||
|
const location = {
|
||||||
|
directory: response.location.directory,
|
||||||
|
workspaceID: response.location.workspaceID,
|
||||||
|
}
|
||||||
|
const key = locationKey(location)
|
||||||
|
setStore("session", "form", sessionID, [
|
||||||
|
...(store.session.form[sessionID] ?? []).filter(
|
||||||
|
(form) => form.location && locationKey(form.location) !== key,
|
||||||
|
),
|
||||||
|
...mutable(
|
||||||
|
response.data.filter((form) => form.sessionID === "global").map((form) => ({ ...form, location })),
|
||||||
|
),
|
||||||
|
])
|
||||||
|
return
|
||||||
|
}
|
||||||
setStore("session", "form", sessionID, mutable(await sdk.api.form.list({ sessionID })))
|
setStore("session", "form", sessionID, mutable(await sdk.api.form.list({ sessionID })))
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
@ -1009,10 +1037,17 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||||
setStore("session", "permission", reconcile(permissions))
|
setStore("session", "permission", reconcile(permissions))
|
||||||
}),
|
}),
|
||||||
sdk.api.form.request.list({ location: locationQuery(defaultLocation()) }).then((response) => {
|
sdk.api.form.request.list({ location: locationQuery(defaultLocation()) }).then((response) => {
|
||||||
|
const location = {
|
||||||
|
directory: response.location.directory,
|
||||||
|
workspaceID: response.location.workspaceID,
|
||||||
|
}
|
||||||
const forms = mutable(response.data).reduce<Record<string, FormInfo[]>>(
|
const forms = mutable(response.data).reduce<Record<string, FormInfo[]>>(
|
||||||
(result, form) => ({
|
(result, form) => ({
|
||||||
...result,
|
...result,
|
||||||
[form.sessionID]: [...(result[form.sessionID] ?? []), form],
|
[form.sessionID]: [
|
||||||
|
...(result[form.sessionID] ?? []),
|
||||||
|
form.sessionID === "global" ? { ...form, location } : form,
|
||||||
|
],
|
||||||
}),
|
}),
|
||||||
{},
|
{},
|
||||||
)
|
)
|
||||||
|
|
@ -1029,9 +1064,20 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||||
result.location.skill.refresh(),
|
result.location.skill.refresh(),
|
||||||
result.shell.refresh(),
|
result.shell.refresh(),
|
||||||
])
|
])
|
||||||
.then((settled) => {
|
.then(async (settled) => {
|
||||||
for (const failure of settled.filter((item) => item.status === "rejected"))
|
for (const failure of settled.filter((item) => item.status === "rejected"))
|
||||||
console.error("Failed to refresh default location data", failure.reason)
|
console.error("Failed to refresh default location data", failure.reason)
|
||||||
|
const key = locationKey(defaultLocation())
|
||||||
|
const locations = new Map(
|
||||||
|
Object.values(store.session.info).map((session) => [locationKey(session.location), session.location] as const),
|
||||||
|
)
|
||||||
|
const refreshed = await Promise.allSettled(
|
||||||
|
Array.from(locations)
|
||||||
|
.filter(([location]) => location !== key)
|
||||||
|
.map(([, location]) => result.session.form.refresh("global", location)),
|
||||||
|
)
|
||||||
|
for (const failure of refreshed.filter((item) => item.status === "rejected"))
|
||||||
|
console.error("Failed to refresh global forms", failure.reason)
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
bootstrapping = undefined
|
bootstrapping = undefined
|
||||||
|
|
|
||||||
|
|
@ -6,11 +6,17 @@ const id = "internal:notifications"
|
||||||
|
|
||||||
type SessionError = Extract<V2Event, { type: "session.error" }>["data"]["error"]
|
type SessionError = Extract<V2Event, { type: "session.error" }>["data"]["error"]
|
||||||
|
|
||||||
function notify(api: TuiPluginApi, sessionID: string | undefined, message: string, sound: TuiAttentionSoundName) {
|
function notify(
|
||||||
|
api: TuiPluginApi,
|
||||||
|
sessionID: string | undefined,
|
||||||
|
message: string,
|
||||||
|
sound: TuiAttentionSoundName,
|
||||||
|
title?: string,
|
||||||
|
) {
|
||||||
const session = sessionID ? api.state.session.get(sessionID) : undefined
|
const session = sessionID ? api.state.session.get(sessionID) : undefined
|
||||||
const isSubagent = session?.parentID !== undefined
|
const isSubagent = session?.parentID !== undefined
|
||||||
void api.attention.notify({
|
void api.attention.notify({
|
||||||
title: session?.title,
|
title: title ?? session?.title,
|
||||||
message,
|
message,
|
||||||
notification: isSubagent ? false : { when: "blurred" },
|
notification: isSubagent ? false : { when: "blurred" },
|
||||||
sound: { name: sound, when: "always" },
|
sound: { name: sound, when: "always" },
|
||||||
|
|
@ -34,10 +40,15 @@ const tui: TuiPlugin = async (api) => {
|
||||||
const permissions = new Set<string>()
|
const permissions = new Set<string>()
|
||||||
|
|
||||||
api.event.on("form.created", (event) => {
|
api.event.on("form.created", (event) => {
|
||||||
if (event.data.form.sessionID === "global") return
|
|
||||||
if (forms.has(event.data.form.id)) return
|
if (forms.has(event.data.form.id)) return
|
||||||
forms.add(event.data.form.id)
|
forms.add(event.data.form.id)
|
||||||
notify(api, event.data.form.sessionID, "Input needs response", "question")
|
notify(
|
||||||
|
api,
|
||||||
|
event.data.form.sessionID,
|
||||||
|
"Input needs response",
|
||||||
|
"question",
|
||||||
|
event.data.form.title,
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
api.event.on("form.replied", (event) => {
|
api.event.on("form.replied", (event) => {
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { Prompt, type PromptRef } from "../component/prompt"
|
import { Prompt, type PromptRef } from "../component/prompt"
|
||||||
import { createEffect, createMemo, createSignal, onMount } from "solid-js"
|
import { createEffect, createMemo, createSignal, onMount, Show } from "solid-js"
|
||||||
import { Logo } from "../component/logo"
|
import { Logo } from "../component/logo"
|
||||||
import { useSync } from "../context/sync"
|
import { useSync } from "../context/sync"
|
||||||
import { Toast } from "../ui/toast"
|
import { Toast } from "../ui/toast"
|
||||||
|
|
@ -14,6 +14,7 @@ import { useTuiConfig } from "../config"
|
||||||
import { HomeSessionDestinationProvider } from "./home/session-destination"
|
import { HomeSessionDestinationProvider } from "./home/session-destination"
|
||||||
import { useData } from "../context/data"
|
import { useData } from "../context/data"
|
||||||
import { LocationProvider } from "../context/location"
|
import { LocationProvider } from "../context/location"
|
||||||
|
import { FormPrompt } from "./session/form"
|
||||||
|
|
||||||
let once = false
|
let once = false
|
||||||
const placeholder = {
|
const placeholder = {
|
||||||
|
|
@ -33,6 +34,8 @@ export function Home() {
|
||||||
const dimensions = useTerminalDimensions()
|
const dimensions = useTerminalDimensions()
|
||||||
const tuiConfig = useTuiConfig()
|
const tuiConfig = useTuiConfig()
|
||||||
const data = useData()
|
const data = useData()
|
||||||
|
// Global MCP elicitations can arrive without a session route, so keep them reachable from Home.
|
||||||
|
const forms = createMemo(() => data.session.form.list("global", data.location.default()) ?? [])
|
||||||
const promptMaxWidth = createMemo(() => {
|
const promptMaxWidth = createMemo(() => {
|
||||||
const configured = tuiConfig.prompt?.max_width
|
const configured = tuiConfig.prompt?.max_width
|
||||||
if (configured === "auto") return Math.max(75, Math.floor(dimensions().width * 0.7))
|
if (configured === "auto") return Math.max(75, Math.floor(dimensions().width * 0.7))
|
||||||
|
|
@ -84,7 +87,12 @@ export function Home() {
|
||||||
<box height={1} minHeight={0} flexShrink={1} />
|
<box height={1} minHeight={0} flexShrink={1} />
|
||||||
<box width="100%" maxWidth={promptMaxWidth()} zIndex={1000} paddingTop={1} flexShrink={0}>
|
<box width="100%" maxWidth={promptMaxWidth()} zIndex={1000} paddingTop={1} flexShrink={0}>
|
||||||
<pluginRuntime.Slot name="home_prompt" mode="replace" ref={bind}>
|
<pluginRuntime.Slot name="home_prompt" mode="replace" ref={bind}>
|
||||||
<Prompt ref={bind} right={<pluginRuntime.Slot name="home_prompt_right" />} placeholders={placeholder} />
|
<Prompt
|
||||||
|
ref={bind}
|
||||||
|
right={<pluginRuntime.Slot name="home_prompt_right" />}
|
||||||
|
placeholders={placeholder}
|
||||||
|
disabled={forms().length > 0}
|
||||||
|
/>
|
||||||
</pluginRuntime.Slot>
|
</pluginRuntime.Slot>
|
||||||
</box>
|
</box>
|
||||||
<pluginRuntime.Slot name="home_bottom" />
|
<pluginRuntime.Slot name="home_bottom" />
|
||||||
|
|
@ -94,6 +102,26 @@ export function Home() {
|
||||||
<box width="100%" flexShrink={0}>
|
<box width="100%" flexShrink={0}>
|
||||||
<pluginRuntime.Slot name="home_footer" mode="single_winner" />
|
<pluginRuntime.Slot name="home_footer" mode="single_winner" />
|
||||||
</box>
|
</box>
|
||||||
|
<Show when={forms()[0]?.id} keyed>
|
||||||
|
{(_) => {
|
||||||
|
const form = forms()[0]
|
||||||
|
return form ? (
|
||||||
|
<box
|
||||||
|
position="absolute"
|
||||||
|
zIndex={2000}
|
||||||
|
left={0}
|
||||||
|
right={0}
|
||||||
|
bottom={1}
|
||||||
|
paddingLeft={2}
|
||||||
|
paddingRight={2}
|
||||||
|
>
|
||||||
|
<box width="100%">
|
||||||
|
<FormPrompt form={form} />
|
||||||
|
</box>
|
||||||
|
</box>
|
||||||
|
) : null
|
||||||
|
}}
|
||||||
|
</Show>
|
||||||
</HomeSessionDestinationProvider>
|
</HomeSessionDestinationProvider>
|
||||||
</LocationProvider>
|
</LocationProvider>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -130,6 +130,16 @@ function display(field: Field, value: FormValue | undefined) {
|
||||||
return label(value)
|
return label(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function requestOptions(form: FormInfo) {
|
||||||
|
if (form.sessionID !== "global" || !form.location) return undefined
|
||||||
|
return {
|
||||||
|
headers: {
|
||||||
|
"x-opencode-directory": encodeURIComponent(form.location.directory),
|
||||||
|
...(form.location.workspaceID ? { "x-opencode-workspace": form.location.workspaceID } : {}),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function FormPrompt(props: { form: FormInfo }) {
|
export function FormPrompt(props: { form: FormInfo }) {
|
||||||
return props.form.mode === "url" ? <UrlPrompt form={props.form} /> : <FieldsPrompt form={props.form} />
|
return props.form.mode === "url" ? <UrlPrompt form={props.form} /> : <FieldsPrompt form={props.form} />
|
||||||
}
|
}
|
||||||
|
|
@ -154,7 +164,10 @@ function UrlPrompt(props: { form: FormInfo & { mode: "url" } }) {
|
||||||
title: "Dismiss form",
|
title: "Dismiss form",
|
||||||
category: "Form",
|
category: "Form",
|
||||||
run() {
|
run() {
|
||||||
void sdk.api.form.cancel({ sessionID: props.form.sessionID, formID: props.form.id })
|
void sdk.api.form.cancel(
|
||||||
|
{ sessionID: props.form.sessionID, formID: props.form.id },
|
||||||
|
requestOptions(props.form),
|
||||||
|
)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|
@ -172,7 +185,10 @@ function UrlPrompt(props: { form: FormInfo & { mode: "url" } }) {
|
||||||
desc: "Dismiss form",
|
desc: "Dismiss form",
|
||||||
group: "Form",
|
group: "Form",
|
||||||
cmd: () => {
|
cmd: () => {
|
||||||
void sdk.api.form.cancel({ sessionID: props.form.sessionID, formID: props.form.id })
|
void sdk.api.form.cancel(
|
||||||
|
{ sessionID: props.form.sessionID, formID: props.form.id },
|
||||||
|
requestOptions(props.form),
|
||||||
|
)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|
@ -186,7 +202,7 @@ function UrlPrompt(props: { form: FormInfo & { mode: "url" } }) {
|
||||||
customBorderChars={SplitBorder.customBorderChars}
|
customBorderChars={SplitBorder.customBorderChars}
|
||||||
>
|
>
|
||||||
<box gap={1} paddingLeft={2} paddingRight={3} paddingTop={1} paddingBottom={1}>
|
<box gap={1} paddingLeft={2} paddingRight={3} paddingTop={1} paddingBottom={1}>
|
||||||
<text fg={theme.text}>{props.form.title ?? "Input requested"}</text>
|
<text fg={theme.text}>{props.form.title}</text>
|
||||||
<Show when={message()}>
|
<Show when={message()}>
|
||||||
<text fg={theme.textMuted}>{message()}</text>
|
<text fg={theme.textMuted}>{message()}</text>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
@ -328,11 +344,14 @@ function FieldsPrompt(props: { form: FormInfo & { mode: "form" } }) {
|
||||||
|
|
||||||
function replySingle(field: Field, value: FormValue) {
|
function replySingle(field: Field, value: FormValue) {
|
||||||
sdk.api.form
|
sdk.api.form
|
||||||
.reply({
|
.reply(
|
||||||
sessionID: props.form.sessionID,
|
{
|
||||||
formID: props.form.id,
|
sessionID: props.form.sessionID,
|
||||||
answer: { [field.key]: value },
|
formID: props.form.id,
|
||||||
})
|
answer: { [field.key]: value },
|
||||||
|
},
|
||||||
|
requestOptions(props.form),
|
||||||
|
)
|
||||||
.catch((error: unknown) => {
|
.catch((error: unknown) => {
|
||||||
setStore(
|
setStore(
|
||||||
"error",
|
"error",
|
||||||
|
|
@ -532,7 +551,10 @@ function FieldsPrompt(props: { form: FormInfo & { mode: "form" } }) {
|
||||||
group: "Form",
|
group: "Form",
|
||||||
cmd: () => {
|
cmd: () => {
|
||||||
if (textual()) {
|
if (textual()) {
|
||||||
void sdk.api.form.cancel({ sessionID: props.form.sessionID, formID: props.form.id })
|
void sdk.api.form.cancel(
|
||||||
|
{ sessionID: props.form.sessionID, formID: props.form.id },
|
||||||
|
requestOptions(props.form),
|
||||||
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
setStore("editing", false)
|
setStore("editing", false)
|
||||||
|
|
@ -594,7 +616,10 @@ function FieldsPrompt(props: { form: FormInfo & { mode: "form" } }) {
|
||||||
title: "Dismiss form",
|
title: "Dismiss form",
|
||||||
category: "Form",
|
category: "Form",
|
||||||
run() {
|
run() {
|
||||||
void sdk.api.form.cancel({ sessionID: props.form.sessionID, formID: props.form.id })
|
void sdk.api.form.cancel(
|
||||||
|
{ sessionID: props.form.sessionID, formID: props.form.id },
|
||||||
|
requestOptions(props.form),
|
||||||
|
)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|
@ -638,16 +663,19 @@ function FieldsPrompt(props: { form: FormInfo & { mode: "form" } }) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
sdk.api.form
|
sdk.api.form
|
||||||
.reply({
|
.reply(
|
||||||
sessionID: props.form.sessionID,
|
{
|
||||||
formID: props.form.id,
|
sessionID: props.form.sessionID,
|
||||||
answer: Object.fromEntries(
|
formID: props.form.id,
|
||||||
fields().flatMap((field) => {
|
answer: Object.fromEntries(
|
||||||
const value = store.answers[field.key]
|
fields().flatMap((field) => {
|
||||||
return value === undefined ? [] : [[field.key, value] as const]
|
const value = store.answers[field.key]
|
||||||
}),
|
return value === undefined ? [] : [[field.key, value] as const]
|
||||||
),
|
}),
|
||||||
})
|
),
|
||||||
|
},
|
||||||
|
requestOptions(props.form),
|
||||||
|
)
|
||||||
.catch((error: unknown) => {
|
.catch((error: unknown) => {
|
||||||
setStore(
|
setStore(
|
||||||
"error",
|
"error",
|
||||||
|
|
@ -666,7 +694,10 @@ function FieldsPrompt(props: { form: FormInfo & { mode: "form" } }) {
|
||||||
desc: "Dismiss form",
|
desc: "Dismiss form",
|
||||||
group: "Form",
|
group: "Form",
|
||||||
cmd: () => {
|
cmd: () => {
|
||||||
void sdk.api.form.cancel({ sessionID: props.form.sessionID, formID: props.form.id })
|
void sdk.api.form.cancel(
|
||||||
|
{ sessionID: props.form.sessionID, formID: props.form.id },
|
||||||
|
requestOptions(props.form),
|
||||||
|
)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{ key: "up", desc: "Scroll review", group: "Form", cmd: () => review?.scrollBy(-1) },
|
{ key: "up", desc: "Scroll review", group: "Form", cmd: () => review?.scrollBy(-1) },
|
||||||
|
|
@ -715,7 +746,10 @@ function FieldsPrompt(props: { form: FormInfo & { mode: "form" } }) {
|
||||||
desc: "Dismiss form",
|
desc: "Dismiss form",
|
||||||
group: "Form",
|
group: "Form",
|
||||||
cmd: () => {
|
cmd: () => {
|
||||||
void sdk.api.form.cancel({ sessionID: props.form.sessionID, formID: props.form.id })
|
void sdk.api.form.cancel(
|
||||||
|
{ sessionID: props.form.sessionID, formID: props.form.id },
|
||||||
|
requestOptions(props.form),
|
||||||
|
)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
...tuiConfig.keybinds.get("app.exit"),
|
...tuiConfig.keybinds.get("app.exit"),
|
||||||
|
|
@ -732,11 +766,9 @@ function FieldsPrompt(props: { form: FormInfo & { mode: "form" } }) {
|
||||||
customBorderChars={SplitBorder.customBorderChars}
|
customBorderChars={SplitBorder.customBorderChars}
|
||||||
>
|
>
|
||||||
<box gap={1} paddingLeft={1} paddingRight={3} paddingTop={1} paddingBottom={1}>
|
<box gap={1} paddingLeft={1} paddingRight={3} paddingTop={1} paddingBottom={1}>
|
||||||
<Show when={props.form.title}>
|
<box paddingLeft={1}>
|
||||||
<box paddingLeft={1}>
|
<text fg={theme.textMuted}>{props.form.title}</text>
|
||||||
<text fg={theme.textMuted}>{props.form.title}</text>
|
</box>
|
||||||
</box>
|
|
||||||
</Show>
|
|
||||||
<Show when={!single() && !tabbed()}>
|
<Show when={!single() && !tabbed()}>
|
||||||
<box flexDirection="row" gap={1} paddingLeft={1}>
|
<box flexDirection="row" gap={1} paddingLeft={1}>
|
||||||
<text fg={theme.textMuted}>
|
<text fg={theme.textMuted}>
|
||||||
|
|
|
||||||
|
|
@ -185,8 +185,11 @@ export function Session() {
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
const forms = createMemo(() => {
|
const forms = createMemo(() => {
|
||||||
if (session()?.parentID) return []
|
const global = data.session.form.list("global", location()) ?? []
|
||||||
return data.session.form.list(route.sessionID) ?? []
|
if (session()?.parentID) return global
|
||||||
|
return [route.sessionID, ...descendantSessionIDs()]
|
||||||
|
.flatMap((sessionID) => data.session.form.list(sessionID) ?? [])
|
||||||
|
.concat(global)
|
||||||
})
|
})
|
||||||
const [composer, setComposer] = createStore({
|
const [composer, setComposer] = createStore({
|
||||||
open: false,
|
open: false,
|
||||||
|
|
@ -239,7 +242,12 @@ export function Session() {
|
||||||
|
|
||||||
createEffect(
|
createEffect(
|
||||||
on(descendantSessionIDs, (sessionIDs) => {
|
on(descendantSessionIDs, (sessionIDs) => {
|
||||||
void Promise.all(sessionIDs.map((sessionID) => data.session.permission.refresh(sessionID)))
|
void Promise.all(
|
||||||
|
sessionIDs.flatMap((sessionID) => [
|
||||||
|
data.session.permission.refresh(sessionID),
|
||||||
|
data.session.form.refresh(sessionID),
|
||||||
|
]),
|
||||||
|
)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -261,6 +269,15 @@ export function Session() {
|
||||||
navigate({ type: "home" })
|
navigate({ type: "home" })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
void data.session.form
|
||||||
|
.refresh("global", info.location)
|
||||||
|
.catch((error) =>
|
||||||
|
toast.show({
|
||||||
|
message: `Failed to refresh global forms: ${errorMessage(error)}`,
|
||||||
|
variant: "error",
|
||||||
|
duration: 5000,
|
||||||
|
}),
|
||||||
|
)
|
||||||
project.workspace.set(info.location.workspaceID)
|
project.workspace.set(info.location.workspaceID)
|
||||||
editor.reconnect(info.location.directory)
|
editor.reconnect(info.location.directory)
|
||||||
if (route.sessionID === sessionID && scroll) scroll.scrollBy(100_000)
|
if (route.sessionID === sessionID && scroll) scroll.scrollBy(100_000)
|
||||||
|
|
@ -957,12 +974,12 @@ export function Session() {
|
||||||
<box flexShrink={0}>
|
<box flexShrink={0}>
|
||||||
<Composer
|
<Composer
|
||||||
sessionID={route.sessionID}
|
sessionID={route.sessionID}
|
||||||
open={composer.open || !!session()?.parentID}
|
open={composer.open || (!!session()?.parentID && forms().length === 0)}
|
||||||
defaultTab={composer.tab ?? (session()?.parentID ? "subagents" : undefined)}
|
defaultTab={composer.tab ?? (session()?.parentID ? "subagents" : undefined)}
|
||||||
onClose={() => setComposer("open", false)}
|
onClose={() => setComposer("open", false)}
|
||||||
/>
|
/>
|
||||||
<Switch>
|
<Switch>
|
||||||
<Match when={composer.open || !!session()?.parentID}>{null}</Match>
|
<Match when={composer.open || (!!session()?.parentID && forms().length === 0)}>{null}</Match>
|
||||||
<Match when={permissions().length > 0}>
|
<Match when={permissions().length > 0}>
|
||||||
<PermissionPrompt request={permissions()[0]} directory={session()?.location.directory} />
|
<PermissionPrompt request={permissions()[0]} directory={session()?.location.directory} />
|
||||||
</Match>
|
</Match>
|
||||||
|
|
|
||||||
|
|
@ -77,6 +77,7 @@ function form(id: string, sessionID = "session"): Extract<V2Event, { type: "form
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
sessionID,
|
sessionID,
|
||||||
|
title: "Input requested",
|
||||||
mode: "form",
|
mode: "form",
|
||||||
fields: [],
|
fields: [],
|
||||||
}
|
}
|
||||||
|
|
@ -138,12 +139,22 @@ const questionNotification: TuiAttentionNotifyInput = {
|
||||||
}
|
}
|
||||||
|
|
||||||
const formNotification: TuiAttentionNotifyInput = {
|
const formNotification: TuiAttentionNotifyInput = {
|
||||||
title: "Demo session",
|
title: "Input requested",
|
||||||
message: "Input needs response",
|
message: "Input needs response",
|
||||||
notification: { when: "blurred" },
|
notification: { when: "blurred" },
|
||||||
sound: { name: "question", when: "always" },
|
sound: { name: "question", when: "always" },
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const titledFormNotification: TuiAttentionNotifyInput = {
|
||||||
|
...formNotification,
|
||||||
|
title: "Confirm deployment",
|
||||||
|
}
|
||||||
|
|
||||||
|
const globalFormNotification: TuiAttentionNotifyInput = {
|
||||||
|
...formNotification,
|
||||||
|
title: "demo-mcp is requesting input",
|
||||||
|
}
|
||||||
|
|
||||||
const permissionNotification: TuiAttentionNotifyInput = {
|
const permissionNotification: TuiAttentionNotifyInput = {
|
||||||
title: "Demo session",
|
title: "Demo session",
|
||||||
message: "Permission needs input",
|
message: "Permission needs input",
|
||||||
|
|
@ -155,19 +166,29 @@ describe("internal notifications TUI plugin", () => {
|
||||||
test("notifies for form, question, and permission requests with blurred notifications and always-on sounds", async () => {
|
test("notifies for form, question, and permission requests with blurred notifications and always-on sounds", async () => {
|
||||||
const harness = await setup()
|
const harness = await setup()
|
||||||
|
|
||||||
harness.emit({ id: "event-1", created: 0, type: "form.created", data: { form: form("form-1") } })
|
harness.emit({
|
||||||
|
id: "event-1",
|
||||||
|
created: 0,
|
||||||
|
type: "form.created",
|
||||||
|
data: { form: { ...form("form-1"), title: "Confirm deployment" } },
|
||||||
|
})
|
||||||
harness.emit({ id: "event-2", created: 0, type: "question.asked", data: question("question-1") })
|
harness.emit({ id: "event-2", created: 0, type: "question.asked", data: question("question-1") })
|
||||||
harness.emit({ id: "event-3", created: 0, type: "permission.asked", data: permission("permission-1") })
|
harness.emit({ id: "event-3", created: 0, type: "permission.asked", data: permission("permission-1") })
|
||||||
|
|
||||||
expect(harness.notifications).toEqual([formNotification, questionNotification, permissionNotification])
|
expect(harness.notifications).toEqual([titledFormNotification, questionNotification, permissionNotification])
|
||||||
})
|
})
|
||||||
|
|
||||||
test("ignores global forms until the TUI can render them", async () => {
|
test("notifies for global forms once the TUI can render them", async () => {
|
||||||
const harness = await setup()
|
const harness = await setup()
|
||||||
|
|
||||||
harness.emit({ id: "event-1", created: 0, type: "form.created", data: { form: form("form-1", "global") } })
|
harness.emit({
|
||||||
|
id: "event-1",
|
||||||
|
created: 0,
|
||||||
|
type: "form.created",
|
||||||
|
data: { form: { ...form("form-1", "global"), title: "demo-mcp is requesting input" } },
|
||||||
|
})
|
||||||
|
|
||||||
expect(harness.notifications).toEqual([])
|
expect(harness.notifications).toEqual([globalFormNotification])
|
||||||
})
|
})
|
||||||
|
|
||||||
test("dedupes pending forms, questions, and permissions until they are resolved", async () => {
|
test("dedupes pending forms, questions, and permissions until they are resolved", async () => {
|
||||||
|
|
@ -243,14 +264,14 @@ describe("internal notifications TUI plugin", () => {
|
||||||
id: "event-1",
|
id: "event-1",
|
||||||
created: 0,
|
created: 0,
|
||||||
type: "form.created",
|
type: "form.created",
|
||||||
data: { form: form("form-1", "subagent") },
|
data: { form: { ...form("form-1", "subagent"), title: "Questions" } },
|
||||||
})
|
})
|
||||||
harness.emit(executionStarted("event-2", "subagent"))
|
harness.emit(executionStarted("event-2", "subagent"))
|
||||||
harness.emit(executionSucceeded("event-3", "subagent"))
|
harness.emit(executionSucceeded("event-3", "subagent"))
|
||||||
|
|
||||||
expect(harness.notifications).toEqual([
|
expect(harness.notifications).toEqual([
|
||||||
{
|
{
|
||||||
title: "Subagent session",
|
title: "Questions",
|
||||||
message: "Input needs response",
|
message: "Input needs response",
|
||||||
notification: false,
|
notification: false,
|
||||||
sound: { name: "question", when: "always" },
|
sound: { name: "question", when: "always" },
|
||||||
|
|
|
||||||
|
|
@ -1485,7 +1485,9 @@ test("adds, dismisses, and refreshes form requests", async () => {
|
||||||
const events = createEventStream()
|
const events = createEventStream()
|
||||||
const calls = createFetch((url) => {
|
const calls = createFetch((url) => {
|
||||||
if (url.pathname !== "/api/session/ses_1/form") return
|
if (url.pathname !== "/api/session/ses_1/form") return
|
||||||
return json({ data: [{ id: "frm_remote", sessionID: "ses_1", mode: "form", fields: [] }] })
|
return json({
|
||||||
|
data: [{ id: "frm_remote", sessionID: "ses_1", title: "Input requested", mode: "form", fields: [] }],
|
||||||
|
})
|
||||||
}, events)
|
}, events)
|
||||||
let data!: ReturnType<typeof useData>
|
let data!: ReturnType<typeof useData>
|
||||||
let sdk!: ReturnType<typeof useSDK>
|
let sdk!: ReturnType<typeof useSDK>
|
||||||
|
|
@ -1514,13 +1516,13 @@ test("adds, dismisses, and refreshes form requests", async () => {
|
||||||
id: "evt_form_created_1",
|
id: "evt_form_created_1",
|
||||||
created: 0,
|
created: 0,
|
||||||
type: "form.created",
|
type: "form.created",
|
||||||
data: { form: { id: "frm_1", sessionID: "ses_1", mode: "form", fields: [] } },
|
data: { form: { id: "frm_1", sessionID: "ses_1", title: "Input requested", mode: "form", fields: [] } },
|
||||||
})
|
})
|
||||||
emitEvent(events, {
|
emitEvent(events, {
|
||||||
id: "evt_form_created_duplicate",
|
id: "evt_form_created_duplicate",
|
||||||
created: 1,
|
created: 1,
|
||||||
type: "form.created",
|
type: "form.created",
|
||||||
data: { form: { id: "frm_1", sessionID: "ses_1", mode: "form", fields: [] } },
|
data: { form: { id: "frm_1", sessionID: "ses_1", title: "Input requested", mode: "form", fields: [] } },
|
||||||
})
|
})
|
||||||
await wait(() => data.session.form.list("ses_1")?.length === 1)
|
await wait(() => data.session.form.list("ses_1")?.length === 1)
|
||||||
|
|
||||||
|
|
@ -1536,7 +1538,7 @@ test("adds, dismisses, and refreshes form requests", async () => {
|
||||||
id: "evt_form_created_2",
|
id: "evt_form_created_2",
|
||||||
created: 3,
|
created: 3,
|
||||||
type: "form.created",
|
type: "form.created",
|
||||||
data: { form: { id: "frm_2", sessionID: "ses_1", mode: "form", fields: [] } },
|
data: { form: { id: "frm_2", sessionID: "ses_1", title: "Input requested", mode: "form", fields: [] } },
|
||||||
})
|
})
|
||||||
emitEvent(events, {
|
emitEvent(events, {
|
||||||
id: "evt_form_cancelled_2",
|
id: "evt_form_cancelled_2",
|
||||||
|
|
@ -1553,11 +1555,238 @@ test("adds, dismisses, and refreshes form requests", async () => {
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("tracks global forms by location", async () => {
|
||||||
|
const events = createEventStream()
|
||||||
|
const calls = createFetch(undefined, events)
|
||||||
|
const other = { directory: "/tmp/opencode-other", workspaceID: "wrk_other" }
|
||||||
|
let data!: ReturnType<typeof useData>
|
||||||
|
let sdk!: ReturnType<typeof useSDK>
|
||||||
|
|
||||||
|
function Probe() {
|
||||||
|
data = useData()
|
||||||
|
sdk = useSDK()
|
||||||
|
return <box />
|
||||||
|
}
|
||||||
|
|
||||||
|
const app = await testRender(() => (
|
||||||
|
<TestTuiContexts>
|
||||||
|
<SDKProvider client={createClient(calls.fetch)} api={createApi(calls.fetch)}>
|
||||||
|
<ProjectProvider>
|
||||||
|
<DataProvider>
|
||||||
|
<Probe />
|
||||||
|
</DataProvider>
|
||||||
|
</ProjectProvider>
|
||||||
|
</SDKProvider>
|
||||||
|
</TestTuiContexts>
|
||||||
|
))
|
||||||
|
|
||||||
|
try {
|
||||||
|
await wait(() => sdk.connection.status() === "connected")
|
||||||
|
events.emit({
|
||||||
|
id: "evt_form_created_global_other",
|
||||||
|
created: 0,
|
||||||
|
location: other,
|
||||||
|
type: "form.created",
|
||||||
|
data: {
|
||||||
|
form: { id: "frm_other", sessionID: "global", title: "Input requested", mode: "form", fields: [] },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
await wait(() => data.session.form.list("global", other)?.length === 1)
|
||||||
|
expect(data.session.form.list("global", { directory }) ?? []).toEqual([])
|
||||||
|
|
||||||
|
events.emit({
|
||||||
|
id: "evt_form_created_global_default",
|
||||||
|
created: 1,
|
||||||
|
location: { directory },
|
||||||
|
type: "form.created",
|
||||||
|
data: {
|
||||||
|
form: { id: "frm_default", sessionID: "global", title: "Input requested", mode: "form", fields: [] },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
await wait(() => data.session.form.list("global", { directory })?.length === 1)
|
||||||
|
|
||||||
|
events.emit({
|
||||||
|
id: "evt_form_replied_global_other",
|
||||||
|
created: 2,
|
||||||
|
location: other,
|
||||||
|
type: "form.replied",
|
||||||
|
data: { id: "frm_other", sessionID: "global", answer: {} },
|
||||||
|
})
|
||||||
|
await wait(() => data.session.form.list("global", other)?.length === 0)
|
||||||
|
expect(data.session.form.list("global", { directory })?.map((form) => form.id)).toEqual(["frm_default"])
|
||||||
|
} finally {
|
||||||
|
app.renderer.destroy()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test("refreshes global forms for the requested location", async () => {
|
||||||
|
const events = createEventStream()
|
||||||
|
const requests: URL[] = []
|
||||||
|
const other = { directory: "/tmp/opencode-other", workspaceID: "wrk_other" }
|
||||||
|
const calls = createFetch((url) => {
|
||||||
|
if (url.pathname !== "/api/form/request") return
|
||||||
|
requests.push(url)
|
||||||
|
const requestedDirectory = url.searchParams.get("location[directory]") ?? directory
|
||||||
|
const requestedWorkspace = url.searchParams.get("location[workspace]") ?? undefined
|
||||||
|
return json({
|
||||||
|
location: {
|
||||||
|
directory: requestedDirectory,
|
||||||
|
workspaceID: requestedWorkspace,
|
||||||
|
project: { id: "proj_test", directory: requestedDirectory },
|
||||||
|
},
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
id: requestedDirectory === other.directory ? "frm_other" : "frm_default",
|
||||||
|
sessionID: "global",
|
||||||
|
title: "Input requested",
|
||||||
|
mode: "form",
|
||||||
|
fields: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
}, events)
|
||||||
|
let data!: ReturnType<typeof useData>
|
||||||
|
let sdk!: ReturnType<typeof useSDK>
|
||||||
|
|
||||||
|
function Probe() {
|
||||||
|
data = useData()
|
||||||
|
sdk = useSDK()
|
||||||
|
return <box />
|
||||||
|
}
|
||||||
|
|
||||||
|
const app = await testRender(() => (
|
||||||
|
<TestTuiContexts>
|
||||||
|
<SDKProvider client={createClient(calls.fetch)} api={createApi(calls.fetch)}>
|
||||||
|
<ProjectProvider>
|
||||||
|
<DataProvider>
|
||||||
|
<Probe />
|
||||||
|
</DataProvider>
|
||||||
|
</ProjectProvider>
|
||||||
|
</SDKProvider>
|
||||||
|
</TestTuiContexts>
|
||||||
|
))
|
||||||
|
|
||||||
|
try {
|
||||||
|
await wait(() => sdk.connection.status() === "connected" && requests.length > 0)
|
||||||
|
requests.length = 0
|
||||||
|
|
||||||
|
await data.session.form.refresh("global", { directory })
|
||||||
|
await data.session.form.refresh("global", other)
|
||||||
|
|
||||||
|
expect(requests).toHaveLength(2)
|
||||||
|
expect(requests[1]?.searchParams.get("location[directory]")).toBe(other.directory)
|
||||||
|
expect(requests[1]?.searchParams.get("location[workspace]")).toBe(other.workspaceID)
|
||||||
|
expect(data.session.form.list("global", other)?.map((form) => form.id)).toEqual(["frm_other"])
|
||||||
|
expect(data.session.form.list("global", { directory })?.map((form) => form.id)).toEqual(["frm_default"])
|
||||||
|
} finally {
|
||||||
|
app.renderer.destroy()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test("refreshes global forms once per loaded location after reconnect", async () => {
|
||||||
|
const events = createEventStream()
|
||||||
|
const requests: URL[] = []
|
||||||
|
const counts = new Map<string, number>()
|
||||||
|
const home = { directory: process.cwd() }
|
||||||
|
const other = { directory: "/tmp/opencode-other", workspaceID: "wrk_other" }
|
||||||
|
const calls = createFetch((url) => {
|
||||||
|
if (url.pathname === "/api/location")
|
||||||
|
return json({ ...home, project: { id: "proj_test", directory: home.directory } })
|
||||||
|
if (url.pathname === "/api/session")
|
||||||
|
return json({
|
||||||
|
data: [
|
||||||
|
{ id: "ses_default", title: "Default", location: home, time: { created: 0, updated: 0 } },
|
||||||
|
{ id: "ses_other_1", title: "Other one", location: other, time: { created: 0, updated: 0 } },
|
||||||
|
{ id: "ses_other_2", title: "Other two", location: other, time: { created: 0, updated: 0 } },
|
||||||
|
],
|
||||||
|
cursor: {},
|
||||||
|
})
|
||||||
|
if (url.pathname !== "/api/form/request") return
|
||||||
|
requests.push(url)
|
||||||
|
const requestedDirectory = url.searchParams.get("location[directory]") ?? home.directory
|
||||||
|
const requestedWorkspace = url.searchParams.get("location[workspace]") ?? undefined
|
||||||
|
const count = (counts.get(requestedDirectory) ?? 0) + 1
|
||||||
|
counts.set(requestedDirectory, count)
|
||||||
|
return json({
|
||||||
|
location: {
|
||||||
|
directory: requestedDirectory,
|
||||||
|
workspaceID: requestedWorkspace,
|
||||||
|
project: { id: "proj_test", directory: requestedDirectory },
|
||||||
|
},
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
id: `frm_${requestedDirectory === other.directory ? "other" : "default"}_${count}`,
|
||||||
|
sessionID: "global",
|
||||||
|
title: "Input requested",
|
||||||
|
mode: "form",
|
||||||
|
fields: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
}, events)
|
||||||
|
let data!: ReturnType<typeof useData>
|
||||||
|
|
||||||
|
function Probe() {
|
||||||
|
data = useData()
|
||||||
|
return <box />
|
||||||
|
}
|
||||||
|
|
||||||
|
const app = await testRender(() => (
|
||||||
|
<TestTuiContexts>
|
||||||
|
<SDKProvider client={createClient(calls.fetch)} api={createApi(calls.fetch)}>
|
||||||
|
<ProjectProvider>
|
||||||
|
<DataProvider>
|
||||||
|
<Probe />
|
||||||
|
</DataProvider>
|
||||||
|
</ProjectProvider>
|
||||||
|
</SDKProvider>
|
||||||
|
</TestTuiContexts>
|
||||||
|
))
|
||||||
|
|
||||||
|
try {
|
||||||
|
await wait(
|
||||||
|
() =>
|
||||||
|
data.session.form.list("global", home)?.[0]?.id === "frm_default_1" &&
|
||||||
|
data.session.form.list("global", other)?.[0]?.id === "frm_other_1",
|
||||||
|
)
|
||||||
|
expect(requests).toHaveLength(2)
|
||||||
|
requests.length = 0
|
||||||
|
|
||||||
|
events.disconnect()
|
||||||
|
|
||||||
|
await wait(
|
||||||
|
() =>
|
||||||
|
data.session.form.list("global", home)?.[0]?.id === "frm_default_2" &&
|
||||||
|
data.session.form.list("global", other)?.[0]?.id === "frm_other_2",
|
||||||
|
4000,
|
||||||
|
)
|
||||||
|
expect(requests).toHaveLength(2)
|
||||||
|
expect(
|
||||||
|
requests.map((url) => [
|
||||||
|
url.searchParams.get("location[directory]") ?? directory,
|
||||||
|
url.searchParams.get("location[workspace]") ?? undefined,
|
||||||
|
]),
|
||||||
|
).toEqual([
|
||||||
|
[home.directory, undefined],
|
||||||
|
[other.directory, other.workspaceID],
|
||||||
|
])
|
||||||
|
} finally {
|
||||||
|
app.renderer.destroy()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
test("reconciles all pending form requests when the event stream reconnects", async () => {
|
test("reconciles all pending form requests when the event stream reconnects", async () => {
|
||||||
const events = createEventStream()
|
const events = createEventStream()
|
||||||
let requests = [
|
let requests = [
|
||||||
{ id: "frm_old", sessionID: "ses_old", mode: "form" as const, fields: [] },
|
{ id: "frm_old", sessionID: "ses_old", title: "Input requested", mode: "form" as const, fields: [] },
|
||||||
{ id: "frm_keep", sessionID: "ses_keep", mode: "url" as const, url: "https://example.com" },
|
{
|
||||||
|
id: "frm_keep",
|
||||||
|
sessionID: "ses_keep",
|
||||||
|
title: "Input requested",
|
||||||
|
mode: "url" as const,
|
||||||
|
url: "https://example.com",
|
||||||
|
},
|
||||||
]
|
]
|
||||||
let calls = 0
|
let calls = 0
|
||||||
const fetch = createFetch((url) => {
|
const fetch = createFetch((url) => {
|
||||||
|
|
@ -1588,7 +1817,9 @@ test("reconciles all pending form requests when the event stream reconnects", as
|
||||||
await wait(() => data.session.form.list("ses_old")?.[0]?.id === "frm_old")
|
await wait(() => data.session.form.list("ses_old")?.[0]?.id === "frm_old")
|
||||||
expect(data.session.form.list("ses_keep")?.[0]?.id).toBe("frm_keep")
|
expect(data.session.form.list("ses_keep")?.[0]?.id).toBe("frm_keep")
|
||||||
|
|
||||||
requests = [{ id: "frm_new", sessionID: "ses_new", mode: "form" as const, fields: [] }]
|
requests = [
|
||||||
|
{ id: "frm_new", sessionID: "ses_new", title: "Input requested", mode: "form" as const, fields: [] },
|
||||||
|
]
|
||||||
events.disconnect()
|
events.disconnect()
|
||||||
|
|
||||||
await wait(() => calls === 2 && data.session.form.list("ses_new")?.[0]?.id === "frm_new")
|
await wait(() => calls === 2 && data.session.form.list("ses_new")?.[0]?.id === "frm_new")
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue