refactor(core): select system prompts through plugins (#37181)
This commit is contained in:
parent
b5177adb5b
commit
720f062cd0
29 changed files with 447 additions and 263 deletions
|
|
@ -21,6 +21,7 @@ export function host(overrides: Overrides = {}): PluginContext {
|
|||
return {
|
||||
options: {},
|
||||
agent: overrides.agent ?? {
|
||||
get: () => Effect.die("unused agent.get"),
|
||||
list: () => Effect.die("unused agent.list"),
|
||||
transform: () => Effect.die("unused agent.transform"),
|
||||
reload: () => Effect.die("unused agent.reload"),
|
||||
|
|
@ -34,6 +35,7 @@ export function host(overrides: Overrides = {}): PluginContext {
|
|||
get: () => Effect.die("unused catalog.provider.get"),
|
||||
},
|
||||
model: {
|
||||
get: () => Effect.die("unused catalog.model.get"),
|
||||
list: () => Effect.die("unused catalog.model.list"),
|
||||
default: () => Effect.die("unused catalog.model.default"),
|
||||
},
|
||||
|
|
@ -102,6 +104,7 @@ export function host(overrides: Overrides = {}): PluginContext {
|
|||
|
||||
export function agentHost(agent: AgentV2.Interface): PluginContext["agent"] {
|
||||
return {
|
||||
get: (id) => agent.get(AgentV2.ID.make(id)).pipe(Effect.map((value) => value && agentInfo(value))),
|
||||
list: () => Effect.die("unused agent.list"),
|
||||
reload: agent.reload,
|
||||
transform: (callback) =>
|
||||
|
|
@ -132,6 +135,10 @@ export function catalogHost(catalog: Catalog.Interface): PluginContext["catalog"
|
|||
get: () => Effect.die("unused catalog.provider.get"),
|
||||
},
|
||||
model: {
|
||||
get: (providerID, modelID) =>
|
||||
catalog.model
|
||||
.get(ProviderV2.ID.make(providerID), ModelV2.ID.make(modelID))
|
||||
.pipe(Effect.map((value) => value && modelInfo(value))),
|
||||
list: () => Effect.die("unused catalog.model.list"),
|
||||
default: () => Effect.die("unused catalog.model.default"),
|
||||
},
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@ import { describe, expect } from "bun:test"
|
|||
import { Message, SystemPart } from "@opencode-ai/ai"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { AgentV2 } from "@opencode-ai/core/agent"
|
||||
import { Catalog } from "@opencode-ai/core/catalog"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
import { PluginV2 } from "@opencode-ai/core/plugin"
|
||||
import { PluginHooks } from "@opencode-ai/core/plugin/hooks"
|
||||
import { PluginHost } from "@opencode-ai/core/plugin/host"
|
||||
|
|
@ -9,6 +11,7 @@ import { PluginPromise } from "@opencode-ai/core/plugin/promise"
|
|||
import { SessionV2 } from "@opencode-ai/core/session"
|
||||
import { SessionMessage } from "@opencode-ai/core/session/message"
|
||||
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { Plugin } from "@opencode-ai/plugin/v2"
|
||||
import type { SessionHooks } from "@opencode-ai/plugin/v2/effect/session"
|
||||
import { Model } from "@opencode-ai/schema/model"
|
||||
|
|
@ -48,6 +51,37 @@ describe("fromPromise", () => {
|
|||
}),
|
||||
)
|
||||
|
||||
it.effect("forwards direct agent and model reads", () =>
|
||||
Effect.gen(function* () {
|
||||
const agents = yield* AgentV2.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
const plugin = yield* PluginV2.Service
|
||||
const host = yield* PluginHost.make(plugin)
|
||||
yield* agents.transform((draft) =>
|
||||
draft.update(AgentV2.ID.make("reviewer"), (agent) => {
|
||||
agent.description = "Reviews code"
|
||||
}),
|
||||
)
|
||||
yield* catalog.transform((draft) =>
|
||||
draft.model.update(ProviderV2.ID.make("test"), ModelV2.ID.make("alias"), (model) => {
|
||||
model.modelID = ModelV2.ID.make("gpt-5")
|
||||
}),
|
||||
)
|
||||
|
||||
yield* PluginPromise.fromPromise(
|
||||
Plugin.define({
|
||||
id: "promise-direct-reads",
|
||||
setup: async (ctx) => {
|
||||
expect(await ctx.agent.get("reviewer")).toMatchObject({ description: "Reviews code" })
|
||||
expect(await ctx.agent.get("missing")).toBeUndefined()
|
||||
expect(await ctx.catalog.model.get("test", "alias")).toMatchObject({ modelID: "gpt-5" })
|
||||
expect(await ctx.catalog.model.get("test", "missing")).toBeUndefined()
|
||||
},
|
||||
}),
|
||||
).effect(host)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("loads a promise plugin and registers a transform hook", () =>
|
||||
Effect.gen(function* () {
|
||||
const agents = yield* AgentV2.Service
|
||||
|
|
|
|||
161
packages/core/test/plugin/system-prompt.test.ts
Normal file
161
packages/core/test/plugin/system-prompt.test.ts
Normal file
|
|
@ -0,0 +1,161 @@
|
|||
import { describe, expect, test } from "bun:test"
|
||||
import { SystemPart } from "@opencode-ai/ai"
|
||||
import { AgentV2 } from "@opencode-ai/core/agent"
|
||||
import { Catalog } from "@opencode-ai/core/catalog"
|
||||
import { PluginV2 } from "@opencode-ai/core/plugin"
|
||||
import { PluginHooks } from "@opencode-ai/core/plugin/hooks"
|
||||
import { PluginHost } from "@opencode-ai/core/plugin/host"
|
||||
import { SystemPromptPlugin } from "@opencode-ai/core/plugin/system-prompt"
|
||||
import { SessionV2 } from "@opencode-ai/core/session"
|
||||
import type { SessionHooks } from "@opencode-ai/plugin/v2/effect/session"
|
||||
import { Model } from "@opencode-ai/schema/model"
|
||||
import { Provider } from "@opencode-ai/schema/provider"
|
||||
import { Effect } from "effect"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { PluginTestLayer } from "./fixture"
|
||||
import PROMPT_META from "../../src/plugin/system-prompt/meta.txt"
|
||||
import PROMPT_DEFAULT from "../../src/session/runner/prompt/base.txt"
|
||||
|
||||
const it = testEffect(PluginTestLayer)
|
||||
const fallback = PROMPT_DEFAULT
|
||||
const makeHost = Effect.gen(function* () {
|
||||
const plugins = yield* PluginV2.Service
|
||||
return yield* PluginHost.make(plugins)
|
||||
})
|
||||
|
||||
const context = (id: string, system = fallback): SessionHooks["context"] => ({
|
||||
sessionID: SessionV2.ID.make("ses_system_prompt"),
|
||||
agent: AgentV2.ID.make("build"),
|
||||
model: Model.Ref.make({ providerID: Provider.ID.make("test"), id: Model.ID.make(id) }),
|
||||
system: [SystemPart.make(system)],
|
||||
messages: [],
|
||||
tools: {},
|
||||
})
|
||||
|
||||
describe("SystemPromptPlugin", () => {
|
||||
test("uses V2 vocabulary in the Meta prompt", () => {
|
||||
expect(PROMPT_META).toContain("webfetch tool")
|
||||
expect(PROMPT_META).toContain("subagent tool")
|
||||
expect(PROMPT_META).toContain("shell tool")
|
||||
expect(PROMPT_META).toContain("read for reading files")
|
||||
expect(PROMPT_META).toContain("edit for editing")
|
||||
expect(PROMPT_META).toContain("write for creating files")
|
||||
expect(PROMPT_META).toContain("https://v2.opencode.ai/llms.txt")
|
||||
expect(PROMPT_META).not.toMatch(
|
||||
/TodoWrite|Task tool|WebFetch|\bBash\b|Read for reading files|Edit for editing|Write for creating files|https:\/\/opencode\.ai\/docs/,
|
||||
)
|
||||
})
|
||||
|
||||
test("uses granular IDs with a common prefix", () => {
|
||||
expect(SystemPromptPlugin.Plugins.map((plugin) => plugin.id)).toEqual([
|
||||
"opencode.system-prompt.openai",
|
||||
"opencode.system-prompt.google",
|
||||
"opencode.system-prompt.anthropic",
|
||||
"opencode.system-prompt.kimi",
|
||||
"opencode.system-prompt.arcee",
|
||||
"opencode.system-prompt.meta",
|
||||
])
|
||||
})
|
||||
|
||||
it.effect("selects model-lab prompts through session context hooks", () =>
|
||||
Effect.gen(function* () {
|
||||
const hooks = yield* PluginHooks.Service
|
||||
const pluginHost = yield* makeHost
|
||||
yield* Effect.forEach(SystemPromptPlugin.Plugins, (plugin) => plugin.effect(pluginHost), {
|
||||
discard: true,
|
||||
})
|
||||
const cases = [
|
||||
["gpt-5", "You are OpenCode, You and the user share the same workspace"],
|
||||
["gpt-4.1", "You are OpenCode, You and the user share the same workspace"],
|
||||
["o3", "You are OpenCode, You and the user share the same workspace"],
|
||||
["gpt-5-codex", "## Editing constraints"],
|
||||
["gemini-2.5-pro", "# Core Mandates"],
|
||||
["claude-sonnet-4", "# Professional objectivity"],
|
||||
["kimi-k2", "# Prompt and Tool Use"],
|
||||
["trinity", "what command should I run to list files"],
|
||||
["meta/muse-spark-1.1", "OpenCode powered by Meta Muse Spark"],
|
||||
["llama-3.3", "You are opencode, an interactive CLI tool"],
|
||||
] as const
|
||||
|
||||
yield* Effect.forEach(
|
||||
cases,
|
||||
([id, expected]) => {
|
||||
const event = context(id)
|
||||
return hooks
|
||||
.trigger("session", "context", event)
|
||||
.pipe(Effect.tap(() => Effect.sync(() => expect(event.system[0]?.text).toContain(expected))))
|
||||
},
|
||||
{ discard: true },
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves an explicit agent system prompt", () =>
|
||||
Effect.gen(function* () {
|
||||
const agents = yield* AgentV2.Service
|
||||
const hooks = yield* PluginHooks.Service
|
||||
yield* agents.transform((draft) =>
|
||||
draft.update(AgentV2.ID.make("build"), (agent) => {
|
||||
agent.system = "Custom agent prompt"
|
||||
}),
|
||||
)
|
||||
const pluginHost = yield* makeHost
|
||||
yield* Effect.forEach(SystemPromptPlugin.Plugins, (plugin) => plugin.effect(pluginHost), {
|
||||
discard: true,
|
||||
})
|
||||
const event = context("gpt-5", "Custom agent prompt")
|
||||
|
||||
yield* hooks.trigger("session", "context", event)
|
||||
|
||||
expect(event.system.map((part) => part.text)).toEqual(["Custom agent prompt"])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("allows one model-lab prompt plugin to be enabled independently", () =>
|
||||
Effect.gen(function* () {
|
||||
const hooks = yield* PluginHooks.Service
|
||||
const pluginHost = yield* makeHost
|
||||
yield* SystemPromptPlugin.GooglePlugin.effect(pluginHost)
|
||||
const gemini = context("gemini-2.5-pro")
|
||||
const claude = context("claude-sonnet-4")
|
||||
|
||||
yield* hooks.trigger("session", "context", gemini)
|
||||
yield* hooks.trigger("session", "context", claude)
|
||||
|
||||
expect(gemini.system[0]?.text).toContain("# Core Mandates")
|
||||
expect(claude.system[0]?.text).toBe(fallback)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("selects against the catalog model ID instead of its alias", () =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* Catalog.Service
|
||||
const hooks = yield* PluginHooks.Service
|
||||
const pluginHost = yield* makeHost
|
||||
yield* catalog.transform((draft) => {
|
||||
draft.model.update(Provider.ID.make("test"), Model.ID.make("openai-alias"), (model) => {
|
||||
model.modelID = Model.ID.make("gpt-5")
|
||||
})
|
||||
draft.model.update(Provider.ID.make("test"), Model.ID.make("gpt-5-alias"), (model) => {
|
||||
model.modelID = Model.ID.make("custom-model")
|
||||
})
|
||||
draft.model.update(Provider.ID.make("test"), Model.ID.make("codex-family-alias"), (model) => {
|
||||
model.modelID = Model.ID.make("custom-deployment")
|
||||
model.family = Model.Family.make("gpt-codex")
|
||||
})
|
||||
})
|
||||
yield* SystemPromptPlugin.OpenAIPlugin.effect(pluginHost)
|
||||
const physicalOpenAI = context("openai-alias")
|
||||
const physicalCustom = context("gpt-5-alias")
|
||||
const familyOpenAI = context("codex-family-alias")
|
||||
|
||||
yield* hooks.trigger("session", "context", physicalOpenAI)
|
||||
yield* hooks.trigger("session", "context", physicalCustom)
|
||||
yield* hooks.trigger("session", "context", familyOpenAI)
|
||||
|
||||
expect(physicalOpenAI.system[0]?.text).toContain("You are OpenCode, You and the user share the same workspace")
|
||||
expect(physicalCustom.system[0]?.text).toBe(fallback)
|
||||
expect(familyOpenAI.system[0]?.text).toContain("## Editing constraints")
|
||||
}),
|
||||
)
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue