fix(genui): announce availability through a session instruction entry (#57647)

This commit is contained in:
Kit Langton
2026-10-20 23:25:21 -07:00
committed by GitHub
Unverified
parent e877131159
commit 28dce534b7
3 changed files with 179 additions and 100 deletions
+82
View File
@@ -87,6 +87,7 @@ import { Expected } from "./lib/session-message"
import { permissionLayer } from "./lib/permission"
import { agentHost, modelHost, host, noProviders } from "./plugin/host"
import { CodeModeInstructions } from "@opencode/core/codemode/instructions"
import GenUIPlugin from "@opencode/plugin-genui"
const emptyCodeMode = `${CodeModeInstructions.render({ total: 0, shown: 0, namespaces: [] })}\n\n`
type ToolBarrier = {
@@ -1202,6 +1203,87 @@ describe("SessionRunnerLLM", () => {
expect(yield* s.messages).toMatchObject([{ id: message.id, type: "user", text: "Run automatically" }])
})
scenario(
"toggles GenUI through instruction entries without extra turns or changing the cached request prefix",
function* (s) {
const hooks = yield* PluginHooks.Service
const tools = yield* Tool.Service
const entries = yield* InstructionEntry.Service
const registered = Effect.succeed({ dispose: Effect.void })
yield* GenUIPlugin.effect(
host({
session: {
hook: (name, callback) => hooks.register("session", name, callback),
instructions: {
entry: {
list: (input) => entries.list(input.sessionID),
put: entries.put,
remove: entries.remove,
},
},
},
tool: { ...host().tool, transform: tools.transform },
skill: { ...host().skill, transform: () => registered },
}),
)
yield* s.session.prompt({ sessionID, text: "Show me the build", metadata: { genui: true } })
yield* s.session.wait(sessionID)
expect(s.requests).toHaveLength(1)
expect(s.requests[0]?.tools.map((tool) => tool.name)).toContain("genui_render")
expect(s.requests[0]?.system.map((part) => part.text)).toContainEqual(
expect.stringContaining('<context key="genui">'),
)
expect(messageRoles(s.requests[0]!)).toEqual(["user"])
expect(userTexts(s.requests[0]!)).toEqual(["Show me the build"])
yield* s.session.prompt({ sessionID, text: "Text only now", metadata: { genui: false } })
yield* s.session.wait(sessionID)
expect(s.requests).toHaveLength(2)
for (const field of ["tools", "system"] as const) expect(s.requests[1]?.[field]).toEqual(s.requests[0]?.[field])
expect(s.requests[1]?.messages.slice(0, 1)).toEqual([...s.requests[0]!.messages])
expect(messageRoles(s.requests[1]!)).toEqual(["user", "system", "user"])
expect(s.requests[1]?.messages[1]).toEqual(
Message.system(
[
'The context under "genui" changed and supersedes the previous value:',
'<context key="genui">',
"genui_render is no longer available in this session, because the user is not in the OpenCode app or turned generated UI off. Answer in text.",
"</context>",
].join("\n"),
),
)
yield* s.session.prompt({ sessionID, text: "Still text" })
yield* s.session.wait(sessionID)
expect(s.requests).toHaveLength(3)
for (const field of ["tools", "system"] as const) expect(s.requests[2]?.[field]).toEqual(s.requests[0]?.[field])
expect(s.requests[2]?.messages.slice(0, 3)).toEqual([...s.requests[1]!.messages])
expect(messageRoles(s.requests[2]!)).toEqual(["user", "system", "user", "user"])
yield* s.session.prompt({ sessionID, text: "Back in app", metadata: { genui: true } })
yield* s.session.wait(sessionID)
expect(s.requests).toHaveLength(4)
for (const field of ["tools", "system"] as const) expect(s.requests[3]?.[field]).toEqual(s.requests[0]?.[field])
expect(s.requests[3]?.messages.slice(0, 4)).toEqual([...s.requests[2]!.messages])
expect(messageRoles(s.requests[3]!)).toEqual(["user", "system", "user", "user", "system", "user"])
expect(s.requests[3]?.messages[4]).toEqual(
Message.system(
[
'The context under "genui" changed and supersedes the previous value:',
'<context key="genui">',
"The user is viewing this session in the OpenCode app. For the occasional answer that is clearly better seen or clicked than read, such as a dashboard, a chart of real data or a form, genui_render draws interactive UI while you write it; load the genui skill first. Answer everything else in text as usual. When the in-app browser is connected, its tools are in your tool list and the browser skill explains them.",
"</context>",
].join("\n"),
),
)
},
)
scenario("runs a follow-up when synthetic input arrives during an active continuation", function* (s) {
const secondStarted = yield* Deferred.make<void>()
const releaseSecond = yield* Deferred.make<void>()
+39 -53
View File
@@ -1,18 +1,16 @@
export * as GenUISession from "./session.js"
import { Message } from "@opencode/ai"
import type { Context } from "@opencode/plugin/effect/plugin"
import { InstructionEntry } from "@opencode/schema/instruction-entry"
import type { Session } from "@opencode/schema/session"
import { Effect, Stream } from "effect"
import { Effect } from "effect"
import { GenUI } from "./genui.js"
const enter = `<system-reminder>
The user is viewing this session in the OpenCode app. For the occasional answer that is clearly better seen or clicked than read, such as a dashboard, a chart of real data or a form, ${GenUI.RENDER_TOOL} draws interactive UI while you write it; load the genui skill first. Answer everything else in text as usual. When the in-app browser is connected, its tools are in your tool list and the browser skill explains them.
</system-reminder>`
export const KEY = InstructionEntry.Key.make("genui")
const leave = `<system-reminder>
${GenUI.RENDER_TOOL} is no longer available in this session, because the user is not in the OpenCode app or turned generated UI off. Answer in text.
</system-reminder>`
const enter = `The user is viewing this session in the OpenCode app. For the occasional answer that is clearly better seen or clicked than read, such as a dashboard, a chart of real data or a form, ${GenUI.RENDER_TOOL} draws interactive UI while you write it; load the genui skill first. Answer everything else in text as usual. When the in-app browser is connected, its tools are in your tool list and the browser skill explains them.`
const leave = `${GenUI.RENDER_TOOL} is no longer available in this session, because the user is not in the OpenCode app or turned generated UI off. Answer in text.`
/**
* Tells the agent it may use the render tool only while the user can see generated UI: the app marks its prompts with
@@ -20,55 +18,43 @@ ${GenUI.RENDER_TOOL} is no longer available in this session, because the user is
* as subagents, never see the tool.
*/
export const register = Effect.fn("GenUISession.register")(function* (ctx: {
readonly session: Pick<Context["session"], "hook" | "synthetic">
readonly event: Context["event"]
readonly session: Pick<Context["session"], "hook" | "instructions">
}) {
// In memory only: after a restart a session counts as unknown, not left, until its next prompt.
const available = new Map<Session.ID, boolean>()
const state = Effect.fnUntraced(function* (sessionID: Session.ID) {
const entries = yield* ctx.session.instructions.entry
.list({ sessionID })
.pipe(Effect.orElseSucceed(() => [] as const))
const entry = entries.find((item) => item.key === KEY)
return entry === undefined ? undefined : entry.value === enter
})
yield* ctx.session.hook("prompt", (event) =>
Effect.sync(() => {
available.set(event.sessionID, event.metadata?.genui === true)
yield* ctx.session.hook(
"prompt",
Effect.fnUntraced(function* (event) {
const previous = yield* state(event.sessionID)
const next = event.metadata?.genui === true
if ((previous ?? false) === next) return
yield* ctx.session.instructions.entry
.put({
sessionID: event.sessionID,
key: KEY,
value: next ? enter : leave,
})
.pipe(
Effect.catchCause((cause) =>
Effect.logWarning("failed to update the GenUI instruction entry", { sessionID: event.sessionID, cause }),
),
)
}),
)
yield* ctx.session.hook("context", (event) => {
const offered = available.get(event.sessionID)
const reminder = lastReminder(event.messages)
// The tool list is the head of the cached prompt, so it must not flip with each prompt's source (app, phone,
// scheduled prompt) or reset on restart. Once a session has been offered generated UI it keeps the tool, and the
// reminders say whether it can be used.
if (offered !== true && reminder === undefined) delete event.tools[GenUI.RENDER_TOOL]
const text =
offered === true && reminder !== enter ? enter : offered === false && reminder === enter ? leave : undefined
if (!text) return Effect.void
// Before the user's prompt and near the tail, like Plan mode's reminders, so the cached prefix stays warm.
const at = event.messages.at(-1)?.role === "user" ? event.messages.length - 1 : event.messages.length
event.messages.splice(at, 0, Message.user(text))
return ctx.session
.synthetic({ sessionID: event.sessionID, text, resume: false })
.pipe(
Effect.catchCause((cause) =>
Effect.logWarning("failed to persist the GenUI reminder", { sessionID: event.sessionID, cause }),
),
)
})
yield* ctx.event.subscribe().pipe(
Stream.filter((event) => event.type === "session.deleted"),
Stream.runForEach((event) =>
Effect.sync(() => {
available.delete(event.data.sessionID)
}),
),
Effect.forkScoped({ startImmediately: true }),
yield* ctx.session.hook(
"context",
Effect.fnUntraced(function* (event) {
// The tool list is the head of the cached prompt, so it must not flip with each prompt's source (app, phone,
// scheduled prompt) or reset on restart. Once a session has been offered generated UI it keeps the tool, and the
// instruction entry says whether it can be used.
if ((yield* state(event.sessionID)) === undefined) delete event.tools[GenUI.RENDER_TOOL]
}),
)
})
function lastReminder(messages: ReadonlyArray<Message>) {
return messages.reduce<string | undefined>((found, message) => {
const part = message.role === "user" && message.content.length === 1 ? message.content[0] : undefined
if (part?.type !== "text") return found
return part.text === enter || part.text === leave ? part.text : found
}, undefined)
}
+58 -47
View File
@@ -2,49 +2,48 @@ import { describe, expect, test } from "bun:test"
import { Message } from "@opencode/ai"
import type { Context } from "@opencode/plugin/effect/plugin"
import type { SessionContext, SessionPrompt } from "@opencode/plugin/effect/session"
import { Agent } from "@opencode/schema/agent"
import type { InstructionEntry } from "@opencode/schema/instruction-entry"
import { Model } from "@opencode/schema/model"
import { Provider } from "@opencode/schema/provider"
import { Session } from "@opencode/schema/session"
import { SessionInbox } from "@opencode/schema/session-inbox"
import { SessionMessage } from "@opencode/schema/session-message"
import { Agent } from "@opencode/schema/agent"
import { DateTime, Effect, Stream } from "effect"
import { Effect, type Schema } from "effect"
import { GenUI } from "../src/genui"
import { GenUISession } from "../src/session"
const sessionID = Session.ID.make("ses_genui_test")
/** Registers the plugin's session hooks against stubs, recording the reminders it persists. */
const run = async () => {
const persisted: string[] = []
/** Registers the plugin's session hooks against an instruction-entry store, recording each put. */
const run = async (store = new Map<InstructionEntry.Key, Schema.Json>()) => {
const puts: Array<{ key: InstructionEntry.Key; value: Schema.Json }> = []
const hooks: {
prompt?: (input: SessionPrompt) => Effect.Effect<void>
context?: (input: SessionContext) => Effect.Effect<void>
} = {}
const session: Pick<Context["session"], "hook" | "synthetic"> = {
const session: Pick<Context["session"], "hook" | "instructions"> = {
hook: (name, callback) => {
// Hook names and callbacks are correlated, but TypeScript does not narrow this generic registration API.
if (name === "prompt") hooks.prompt = callback as (input: SessionPrompt) => Effect.Effect<void>
if (name === "context") hooks.context = callback as (input: SessionContext) => Effect.Effect<void>
return Effect.succeed({ dispose: Effect.void })
},
synthetic: (input) => {
persisted.push(input.text)
return Effect.succeed(
SessionInbox.Synthetic.make({
id: SessionMessage.ID.make("msg_genui_test"),
sessionID,
time: { created: DateTime.makeUnsafe(0) },
type: "synthetic",
payload: { text: input.text },
delivery: "steer",
}),
)
instructions: {
entry: {
list: () => Effect.sync(() => Array.from(store, ([key, value]) => ({ key, value }))),
put: (input) =>
Effect.sync(() => {
store.set(input.key, input.value)
puts.push({ key: input.key, value: input.value })
}),
remove: (input) =>
Effect.sync(() => {
store.delete(input.key)
}),
},
},
}
await Effect.runPromise(
GenUISession.register({ session, event: { subscribe: () => Stream.empty } }).pipe(Effect.scoped),
)
await Effect.runPromise(GenUISession.register({ session }).pipe(Effect.scoped))
const prompt = (metadata?: Record<string, unknown>) =>
Effect.runPromise(
hooks.prompt?.({
@@ -68,60 +67,72 @@ const run = async () => {
await Effect.runPromise(hooks.context?.(request) ?? Effect.void)
return request
}
return { persisted, prompt, context }
return { puts, store, prompt, context }
}
describe("GenUISession", () => {
test("offers the render tool and tells the agent once while the app shows generated UI", async () => {
test("offers the render tool and puts the instruction entry once while the app shows generated UI", async () => {
const plugin = await run()
await plugin.prompt({ genui: true })
const first = await plugin.context([Message.user("Show me the build")])
const initial = [Message.user("Show me the build")]
const first = await plugin.context([...initial])
expect(Object.keys(first.tools)).toEqual([GenUI.RENDER_TOOL, "read"])
expect(plugin.persisted).toHaveLength(1)
expect(first.messages.map((message) => message.content[0]?.type === "text" && message.content[0].text)).toEqual([
plugin.persisted[0],
"Show me the build",
])
expect(plugin.puts).toEqual([{ key: GenUISession.KEY, value: expect.stringContaining(GenUI.RENDER_TOOL) }])
expect(plugin.puts[0]?.value).not.toContain("<system-reminder>")
expect(first.messages).toEqual(initial)
const second = await plugin.context([Message.user(plugin.persisted[0] ?? ""), Message.user("Again")])
expect(plugin.persisted).toHaveLength(1)
await plugin.prompt({ genui: true })
const second = await plugin.context([Message.user("Show me the build"), Message.user("Again")])
expect(Object.keys(second.tools)).toEqual([GenUI.RENDER_TOOL, "read"])
expect(plugin.puts).toHaveLength(1)
expect(second.messages).toHaveLength(2)
})
test("says so once a prompt comes without generated UI, keeping the tool list stable for the prompt cache", async () => {
test("updates the instruction entry once when a prompt comes without generated UI, keeping the tool list stable", async () => {
const plugin = await run()
await plugin.prompt({ genui: true })
await plugin.context([Message.user("hi")])
await plugin.prompt({ genui: false })
const request = await plugin.context([Message.user(plugin.persisted[0] ?? ""), Message.user("Text only now")])
const request = await plugin.context([Message.user("hi"), Message.user("Text only now")])
expect(Object.keys(request.tools)).toEqual([GenUI.RENDER_TOOL, "read"])
expect(plugin.persisted).toHaveLength(2)
expect(plugin.persisted[1]).toContain("no longer available")
expect(plugin.puts).toHaveLength(2)
expect(plugin.puts[1]).toEqual({ key: GenUISession.KEY, value: expect.stringContaining("no longer available") })
await plugin.prompt(undefined)
await plugin.context([Message.user("hi"), Message.user("Text only now"), Message.user("Still text")])
expect(plugin.puts).toHaveLength(2)
})
test("keeps the tool list after a restart forgets which sessions the app prompted", async () => {
const before = await run()
test("keeps the tool list and availability state after a restart", async () => {
const store = new Map<InstructionEntry.Key, Schema.Json>()
const before = await run(store)
await before.prompt({ genui: true })
await before.context([Message.user("hi")])
const after = await run()
const request = await after.context([
Message.user(before.persisted[0] ?? ""),
Message.user("hi"),
Message.user("next"),
])
const after = await run(store)
const request = await after.context([Message.user("hi"), Message.user("next")])
expect(Object.keys(request.tools)).toEqual([GenUI.RENDER_TOOL, "read"])
expect(after.persisted).toEqual([])
expect(after.puts).toEqual([])
await after.prompt({ genui: true })
expect(after.puts).toEqual([])
await after.prompt({ genui: false })
const left = await after.context([Message.user("hi"), Message.user("next"), Message.user("off")])
expect(Object.keys(left.tools)).toEqual([GenUI.RENDER_TOOL, "read"])
expect(after.puts).toEqual([{ key: GenUISession.KEY, value: expect.stringContaining("no longer available") }])
})
test("never offers the render tool to a session the app has not prompted, such as a subagent", async () => {
test("never offers the render tool or writes an entry for a session the app has not prompted, such as a subagent", async () => {
const plugin = await run()
await plugin.prompt(undefined)
const request = await plugin.context([Message.user("Search the repo")])
expect(Object.keys(request.tools)).toEqual(["read"])
expect(plugin.persisted).toEqual([])
expect(plugin.puts).toEqual([])
expect(plugin.store.size).toBe(0)
})
})