mirror of
https://github.com/anomalyco/opencode.git
synced 2026-11-15 04:27:20 +03:00
fix(genui): announce availability through a session instruction entry (#57647)
This commit is contained in:
committed by
GitHub
Unverified
parent
e877131159
commit
28dce534b7
@@ -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>()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user