diff --git a/packages/core/test/session-runner.test.ts b/packages/core/test/session-runner.test.ts index 431e5148184..05e947bd859 100644 --- a/packages/core/test/session-runner.test.ts +++ b/packages/core/test/session-runner.test.ts @@ -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(''), + ) + 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:', + '', + "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.", + "", + ].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:', + '', + "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.", + "", + ].join("\n"), + ), + ) + }, + ) + scenario("runs a follow-up when synthetic input arrives during an active continuation", function* (s) { const secondStarted = yield* Deferred.make() const releaseSecond = yield* Deferred.make() diff --git a/packages/plugin-genui/src/session.ts b/packages/plugin-genui/src/session.ts index 335408d0270..ed0e3215210 100644 --- a/packages/plugin-genui/src/session.ts +++ b/packages/plugin-genui/src/session.ts @@ -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 = ` -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. -` +export const KEY = InstructionEntry.Key.make("genui") -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. -` +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 - readonly event: Context["event"] + readonly session: Pick }) { - // In memory only: after a restart a session counts as unknown, not left, until its next prompt. - const available = new Map() + 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) { - return messages.reduce((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) -} diff --git a/packages/plugin-genui/test/session.test.ts b/packages/plugin-genui/test/session.test.ts index 9f6d92820b7..05761e62802 100644 --- a/packages/plugin-genui/test/session.test.ts +++ b/packages/plugin-genui/test/session.test.ts @@ -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()) => { + const puts: Array<{ key: InstructionEntry.Key; value: Schema.Json }> = [] const hooks: { prompt?: (input: SessionPrompt) => Effect.Effect context?: (input: SessionContext) => Effect.Effect } = {} - const session: Pick = { + const session: Pick = { 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 if (name === "context") hooks.context = callback as (input: SessionContext) => Effect.Effect 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) => 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("") + 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() + 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) }) })