feat(core): global form support (#35959)
This commit is contained in:
parent
79415f625a
commit
b44a981eef
17 changed files with 493 additions and 93 deletions
|
|
@ -77,6 +77,7 @@ function form(id: string, sessionID = "session"): Extract<V2Event, { type: "form
|
|||
return {
|
||||
id,
|
||||
sessionID,
|
||||
title: "Input requested",
|
||||
mode: "form",
|
||||
fields: [],
|
||||
}
|
||||
|
|
@ -138,12 +139,22 @@ const questionNotification: TuiAttentionNotifyInput = {
|
|||
}
|
||||
|
||||
const formNotification: TuiAttentionNotifyInput = {
|
||||
title: "Demo session",
|
||||
title: "Input requested",
|
||||
message: "Input needs response",
|
||||
notification: { when: "blurred" },
|
||||
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 = {
|
||||
title: "Demo session",
|
||||
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 () => {
|
||||
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-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()
|
||||
|
||||
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 () => {
|
||||
|
|
@ -243,14 +264,14 @@ describe("internal notifications TUI plugin", () => {
|
|||
id: "event-1",
|
||||
created: 0,
|
||||
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(executionSucceeded("event-3", "subagent"))
|
||||
|
||||
expect(harness.notifications).toEqual([
|
||||
{
|
||||
title: "Subagent session",
|
||||
title: "Questions",
|
||||
message: "Input needs response",
|
||||
notification: false,
|
||||
sound: { name: "question", when: "always" },
|
||||
|
|
|
|||
|
|
@ -1485,7 +1485,9 @@ test("adds, dismisses, and refreshes form requests", async () => {
|
|||
const events = createEventStream()
|
||||
const calls = createFetch((url) => {
|
||||
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)
|
||||
let data!: ReturnType<typeof useData>
|
||||
let sdk!: ReturnType<typeof useSDK>
|
||||
|
|
@ -1514,13 +1516,13 @@ test("adds, dismisses, and refreshes form requests", async () => {
|
|||
id: "evt_form_created_1",
|
||||
created: 0,
|
||||
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, {
|
||||
id: "evt_form_created_duplicate",
|
||||
created: 1,
|
||||
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)
|
||||
|
||||
|
|
@ -1536,7 +1538,7 @@ test("adds, dismisses, and refreshes form requests", async () => {
|
|||
id: "evt_form_created_2",
|
||||
created: 3,
|
||||
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, {
|
||||
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 () => {
|
||||
const events = createEventStream()
|
||||
let requests = [
|
||||
{ id: "frm_old", sessionID: "ses_old", mode: "form" as const, fields: [] },
|
||||
{ id: "frm_keep", sessionID: "ses_keep", mode: "url" as const, url: "https://example.com" },
|
||||
{ id: "frm_old", sessionID: "ses_old", title: "Input requested", mode: "form" as const, fields: [] },
|
||||
{
|
||||
id: "frm_keep",
|
||||
sessionID: "ses_keep",
|
||||
title: "Input requested",
|
||||
mode: "url" as const,
|
||||
url: "https://example.com",
|
||||
},
|
||||
]
|
||||
let calls = 0
|
||||
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")
|
||||
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()
|
||||
|
||||
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