From 9317eba1c2460414662110559a1d70df3385efa3 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Tue, 15 Sep 2026 01:40:36 -0500 Subject: [PATCH 1/5] feat(core): append model identity to system prompts via plugin --- packages/core/src/plugin/identity.ts | 31 ++++++++ packages/core/src/plugin/internal.ts | 2 + .../openai-chat-streams-text.json | 2 +- packages/core/test/plugin/identity.test.ts | 79 +++++++++++++++++++ .../core/test/session-runner-recorded.test.ts | 2 + packages/core/test/session-runner.test.ts | 56 +++++++------ packages/core/test/session-title.test.ts | 26 +++++- 7 files changed, 169 insertions(+), 29 deletions(-) create mode 100644 packages/core/src/plugin/identity.ts create mode 100644 packages/core/test/plugin/identity.test.ts diff --git a/packages/core/src/plugin/identity.ts b/packages/core/src/plugin/identity.ts new file mode 100644 index 000000000000..66d3ec913e88 --- /dev/null +++ b/packages/core/src/plugin/identity.ts @@ -0,0 +1,31 @@ +export * as IdentityPlugin from "./identity.js" + +import { SystemPart } from "@opencode/ai" +import { define } from "@opencode/plugin/effect/plugin" +import type { SessionHooks } from "@opencode/plugin/effect/session" +import { Model } from "@opencode/schema/model" +import { Effect } from "effect" + +// Display name for humans, catalog ref for the exact route. +export function identity(model: { readonly name: string; readonly ref: Model.Ref }) { + return `You are powered by ${model.name} (${model.ref.providerID}/${model.ref.id}).` +} + +export const Plugin = define({ + id: "opencode.prompt.identity", + effect: Effect.fn("IdentityPlugin")(function* (ctx) { + const hook = (event: SessionHooks["context"] | SessionHooks["title"]) => + Effect.gen(function* () { + const model = + (yield* ctx.model.list()).data.find( + (model) => model.providerID === event.model.providerID && model.id === event.model.id, + ) ?? Model.Info.default(event.model.providerID, event.model.id) + // Insert after the agent prompt so family-prompt overrides of the first part preserve it. + event.system.splice(1, 0, SystemPart.make(identity({ name: model.name, ref: event.model }))) + }).pipe(Effect.catch(() => Effect.void)) + yield* ctx.session.hook("context", hook) + yield* ctx.session.hook("compaction", hook) + yield* ctx.session.hook("generate", hook) + yield* ctx.session.hook("title", hook) + }), +}) diff --git a/packages/core/src/plugin/internal.ts b/packages/core/src/plugin/internal.ts index 072148322dab..e450b82b77c5 100644 --- a/packages/core/src/plugin/internal.ts +++ b/packages/core/src/plugin/internal.ts @@ -84,6 +84,7 @@ import { WriteTool } from "../tool/plugin/write.js" import { AgentPlugin } from "./agent.js" import BrowserPlugin from "@opencode/plugin-browser" import { CommandPlugin } from "./command.js" +import { IdentityPlugin } from "./identity.js" import { PlanPlugin } from "./plan.js" import { ModelsDevPlugin } from "./models-dev.js" import { McpCodeModeExclusionPlugin } from "./mcp-codemode-exclusion.js" @@ -216,6 +217,7 @@ const pre = [ PatchTool.Plugin, // Render model prompts after the patch plugin selects the available editing tools. ...OptimizePlugin.Plugins, + IdentityPlugin.Plugin, EditTool.Plugin, GlobTool.Plugin, GrepTool.Plugin, diff --git a/packages/core/test/fixtures/recordings/session-runner/openai-chat-streams-text.json b/packages/core/test/fixtures/recordings/session-runner/openai-chat-streams-text.json index 24f75c60bd46..6691394d0fb5 100644 --- a/packages/core/test/fixtures/recordings/session-runner/openai-chat-streams-text.json +++ b/packages/core/test/fixtures/recordings/session-runner/openai-chat-streams-text.json @@ -13,7 +13,7 @@ "headers": { "content-type": "application/json" }, - "body": "{\"model\":\"gpt-4o-mini\",\"messages\":[{\"role\":\"system\",\"content\":\"You are an AI agent powered by OpenCode, a coding agent harness. Help the user accomplish their goals using the tools you have available.\\n\\n# Harness\\n- Responses are rendered as GitHub-flavored Markdown.\\n- `` blocks are harness instructions, not user-authored content. Read and follow them.\\n- Prefer parallelizing independent tool calls.\\n\\n\\n# Communication\\n\\nUse clear file paths when referring to files. Keep responses clear and concise, and avoid unnecessary technical jargon.\\n\\n## Intermediate Commentary\\n\\nAs you work, you send messages to the commentary channel. These are how you collaborate with the user while you work: stating assumptions and providing updates. Keep them concise and quickly scannable, and send them only when they add real information, such as a discovery, a tradeoff, or a blocker. Do not narrate routine reads, searches, or edits.\\n\\nBy default, treat new messages received during ongoing work as steering the active task rather than replacing it. Incorporate corrections and constraints, and answer questions briefly in commentary before continuing. Replace the task only when the user clearly cancels it or requests an incompatible objective.\\n\\nDo not put a final response, such as a blocking or clarifying question, in the commentary channel. The final answer must always be fully self-contained.\\n\\n## Final Answer\\n\\nIn the final answer, lead with the outcome, not the steps you took to reach it. Cover the most important information, use only as much structure as the answer needs, and avoid long-winded explanations unless necessary. Include technical detail only where it helps.\\n\\n# Working in codebases\\n- Keep changes consistent with the structure, naming, style, and patterns of the surrounding code.\\n- Treat unfamiliar files or changes as potential user work and investigate before deleting or overwriting them.\\n\\n# Delegation\\n\\nDo not spawn subagents unless the user or applicable AGENTS.md/skill instructions explicitly ask for subagents, delegation, or parallel agent work.\\n\\n# Destructive actions\\n\\nDo not revert, reset, or discard changes you did not make. Never run destructive commands such as `git reset --hard`, `git checkout --`, or recursive deletes on broad paths unless the user clearly asked for that operation; if the target or scope is unclear, ask first. Prefer non-interactive git commands.\\n\\n# Autonomy\\n\\nDo not infer authorization for work beyond the user's request. Assumptions that help you make progress are fine as long as they stay within the user's intent and the scope of the task.\\n\"},{\"role\":\"user\",\"content\":\"Say hello in one short sentence.\"}],\"stream\":true,\"stream_options\":{\"include_usage\":true},\"store\":false,\"prompt_cache_key\":\"ses_runner_recorded\",\"max_completion_tokens\":20,\"temperature\":0}" + "body": "{\"model\":\"gpt-4o-mini\",\"messages\":[{\"role\":\"system\",\"content\":\"You are an AI agent powered by OpenCode, a coding agent harness. Help the user accomplish their goals using the tools you have available.\\n\\n# Harness\\n- Responses are rendered as GitHub-flavored Markdown.\\n- `` blocks are harness instructions, not user-authored content. Read and follow them.\\n- Prefer parallelizing independent tool calls.\\n\\n\\n# Communication\\n\\nUse clear file paths when referring to files. Keep responses clear and concise, and avoid unnecessary technical jargon.\\n\\n## Intermediate Commentary\\n\\nAs you work, you send messages to the commentary channel. These are how you collaborate with the user while you work: stating assumptions and providing updates. Keep them concise and quickly scannable, and send them only when they add real information, such as a discovery, a tradeoff, or a blocker. Do not narrate routine reads, searches, or edits.\\n\\nBy default, treat new messages received during ongoing work as steering the active task rather than replacing it. Incorporate corrections and constraints, and answer questions briefly in commentary before continuing. Replace the task only when the user clearly cancels it or requests an incompatible objective.\\n\\nDo not put a final response, such as a blocking or clarifying question, in the commentary channel. The final answer must always be fully self-contained.\\n\\n## Final Answer\\n\\nIn the final answer, lead with the outcome, not the steps you took to reach it. Cover the most important information, use only as much structure as the answer needs, and avoid long-winded explanations unless necessary. Include technical detail only where it helps.\\n\\n# Working in codebases\\n- Keep changes consistent with the structure, naming, style, and patterns of the surrounding code.\\n- Treat unfamiliar files or changes as potential user work and investigate before deleting or overwriting them.\\n\\n# Delegation\\n\\nDo not spawn subagents unless the user or applicable AGENTS.md/skill instructions explicitly ask for subagents, delegation, or parallel agent work.\\n\\n# Destructive actions\\n\\nDo not revert, reset, or discard changes you did not make. Never run destructive commands such as `git reset --hard`, `git checkout --`, or recursive deletes on broad paths unless the user clearly asked for that operation; if the target or scope is unclear, ask first. Prefer non-interactive git commands.\\n\\n# Autonomy\\n\\nDo not infer authorization for work beyond the user's request. Assumptions that help you make progress are fine as long as they stay within the user's intent and the scope of the task.\\n\\nYou are powered by gpt-4o-mini (openai/gpt-4o-mini).\"},{\"role\":\"user\",\"content\":\"Say hello in one short sentence.\"}],\"stream\":true,\"stream_options\":{\"include_usage\":true},\"store\":false,\"prompt_cache_key\":\"ses_runner_recorded\",\"max_completion_tokens\":20,\"temperature\":0}" }, "response": { "status": 200, diff --git a/packages/core/test/plugin/identity.test.ts b/packages/core/test/plugin/identity.test.ts new file mode 100644 index 000000000000..cb46b1613bc1 --- /dev/null +++ b/packages/core/test/plugin/identity.test.ts @@ -0,0 +1,79 @@ +import { expect, test } from "bun:test" +import { SystemPart } from "@opencode/ai" +import { Agent } from "@opencode/core/agent" +import { IdentityPlugin } from "@opencode/core/plugin/identity" +import { Plugin } from "@opencode/core/plugin" +import { PluginHooks } from "@opencode/core/plugin/hooks" +import { PluginHost } from "@opencode/core/plugin/host" +import { Session } from "@opencode/core/session" +import type { SessionHooks } from "@opencode/plugin/effect/session" +import { Model } from "@opencode/schema/model" +import { Provider } from "@opencode/core/provider" +import { Effect } from "effect" +import { testEffect } from "../lib/effect" +import { PluginTestLayer } from "./fixture" + +const it = testEffect(PluginTestLayer) + +test("formats the model identity part", () => { + expect( + IdentityPlugin.identity({ + name: "GPT-4o mini", + ref: Model.Ref.make({ providerID: Provider.ID.make("openai"), id: Model.ID.make("gpt-4o-mini") }), + }), + ).toBe("You are powered by GPT-4o mini (openai/gpt-4o-mini).") +}) + +const context = (id: string): SessionHooks["context"] => ({ + sessionID: Session.ID.make("ses_model_identity"), + agent: Agent.ID.make("build"), + model: Model.Ref.make({ providerID: Provider.ID.make("test"), id: Model.ID.make(id) }), + system: [SystemPart.make("Agent prompt"), SystemPart.make("Initial context")], + messages: [], + tools: {}, + options: {}, +}) + +it.effect("inserts the catalog display name after the agent prompt", () => + Effect.gen(function* () { + const catalog = yield* Provider.Service + const hooks = yield* PluginHooks.Service + const plugins = yield* Plugin.Service + const pluginHost = yield* PluginHost.make(plugins) + yield* catalog.transform((editor) => { + editor.models.update(Provider.ID.make("test"), Model.ID.make("meta/muse-spark-1.1"), (model) => { + model.name = "Muse Spark" + }) + }) + yield* IdentityPlugin.Plugin.effect(pluginHost) + + const named = context("meta/muse-spark-1.1") + yield* hooks.trigger("session", "context", named) + expect(named.system.map((part) => part.text)).toEqual([ + "Agent prompt", + "You are powered by Muse Spark (test/meta/muse-spark-1.1).", + "Initial context", + ]) + + const fallback = context("unknown-model") + yield* hooks.trigger("session", "context", fallback) + expect(fallback.system.map((part) => part.text)).toEqual([ + "Agent prompt", + "You are powered by unknown-model (test/unknown-model).", + "Initial context", + ]) + + const title: SessionHooks["title"] = { + sessionID: Session.ID.make("ses_model_identity"), + model: Model.Ref.make({ providerID: Provider.ID.make("test"), id: Model.ID.make("meta/muse-spark-1.1") }), + system: [SystemPart.make("You are a title generator.")], + messages: [], + options: {}, + } + yield* hooks.trigger("session", "title", title) + expect(title.system.map((part) => part.text)).toEqual([ + "You are a title generator.", + "You are powered by Muse Spark (test/meta/muse-spark-1.1).", + ]) + }), +) diff --git a/packages/core/test/session-runner-recorded.test.ts b/packages/core/test/session-runner-recorded.test.ts index 9a0273be0943..0b10f1bf1b46 100644 --- a/packages/core/test/session-runner-recorded.test.ts +++ b/packages/core/test/session-runner-recorded.test.ts @@ -36,6 +36,7 @@ import { PluginSupervisor } from "@opencode/core/plugin/supervisor" import { Plugin } from "@opencode/core/plugin" import { PluginHooks } from "@opencode/core/plugin/hooks" import { OptimizePlugin } from "@opencode/core/plugin/optimize" +import { IdentityPlugin } from "@opencode/core/plugin/identity" import { describe, expect } from "bun:test" import { eq } from "drizzle-orm" import { Effect, Layer } from "effect" @@ -187,6 +188,7 @@ describe("SessionRunnerLLM recorded", () => { session: { hook: (name, callback) => hooks.register("session", name, callback) }, }) yield* Effect.forEach(OptimizePlugin.Plugins, (plugin) => plugin.effect(pluginHost), { discard: true }) + yield* IdentityPlugin.Plugin.effect(pluginHost) const { db } = yield* Database.Service yield* db .insert(ProjectTable) diff --git a/packages/core/test/session-runner.test.ts b/packages/core/test/session-runner.test.ts index 80a6bd652fef..8d9d1fc66528 100644 --- a/packages/core/test/session-runner.test.ts +++ b/packages/core/test/session-runner.test.ts @@ -55,6 +55,7 @@ import { PluginSupervisor } from "@opencode/core/plugin/supervisor" import { Plugin } from "@opencode/core/plugin" import { PluginHooks } from "@opencode/core/plugin/hooks" import { OptimizePlugin } from "@opencode/core/plugin/optimize" +import { IdentityPlugin } from "@opencode/core/plugin/identity" import { QuestionTool } from "@opencode/core/tool/plugin/question" import { Agent } from "@opencode/core/agent" import { Config } from "@opencode/core/config" @@ -119,6 +120,9 @@ const testModel = (id: string, limit: ModelLimit = defaultModelLimit) => { } const model = testModel("fake-model") const defaultSystem = SessionSystemPrompt.make([]) +const fakeIdentity = "You are powered by fake-model (fake/fake-model)." +const replacementIdentity = "You are powered by replacement (fake/replacement)." +const gptIdentity = "You are powered by gpt-5 (openai/gpt-5)." const replacementModel = testModel("replacement") const compactModel = testModel("compact", { context: 4_000, output: 50 }) const fullOutputModel = testModel("full-output", { context: 262_144, output: 262_144 }) @@ -520,6 +524,7 @@ const setup = Effect.gen(function* () { yield* Effect.forEach(OptimizePlugin.Plugins, (plugin) => plugin.effect(pluginHost), { discard: true, }) + yield* IdentityPlugin.Plugin.effect(pluginHost) yield* agents.transform((editor) => editor.update(Agent.ID.make("build"), (agent) => { agent.mode = "primary" @@ -1554,7 +1559,7 @@ describe("SessionRunnerLLM", () => { yield* s.session.prompt({ sessionID: forked.id, text: "Forked", resume: false }) yield* s.session.resume(forked.id) - expect(s.requests.at(-1)?.system.map((part) => part.text)).toEqual([defaultSystem, "Latest context"]) + expect(s.requests.at(-1)?.system.map((part) => part.text)).toEqual([defaultSystem, fakeIdentity, "Latest context"]) // Copied history keeps the frozen chronological update; no new update is emitted. expect(systemTexts(s.requests.at(-1)!)).toContain("Changed context") expect(systemTexts(s.requests.at(-1)!)).not.toContain("Latest context") @@ -1617,7 +1622,7 @@ describe("SessionRunnerLLM", () => { yield* s.resume expect(s.requests).toHaveLength(1) - expect(s.requests[0]?.system.map((part) => part.text)).toEqual([defaultSystem, "Initial context"]) + expect(s.requests[0]?.system.map((part) => part.text)).toEqual([defaultSystem, fakeIdentity, "Initial context"]) expect(messageRoles(s.requests[0])).toEqual(["user", "user"]) // The projected row is authoritative: a missing row admits a fresh baseline // instead of rebuilding from durable events. @@ -1653,8 +1658,8 @@ describe("SessionRunnerLLM", () => { expect(s.requests[0].messages).toHaveLength(1) expect(s.requests[1].messages.slice(0, 1)).toEqual([...s.requests[0].messages]) expect(s.requests.map((request) => request.system.map((part) => part.text))).toEqual([ - [defaultSystem, "Initial context"], - [defaultSystem, "Initial context"], + [defaultSystem, fakeIdentity, "Initial context"], + [defaultSystem, fakeIdentity, "Initial context"], ]) expect(messageRoles(s.requests[1])).toEqual(["user", "system", "user"]) expect(s.requests[1]?.messages.at(1)?.content).toEqual([Expected.text("Changed context")]) @@ -1693,6 +1698,7 @@ describe("SessionRunnerLLM", () => { expect(s.requests.at(-1)?.system.map((part) => part.text)).toEqual([ expect.stringContaining("# Delegation"), + gptIdentity, "Initial context", ]) }) @@ -1713,6 +1719,7 @@ describe("SessionRunnerLLM", () => { expect(s.requests.at(-1)?.system.map((part) => part.text)).toEqual([ expect.stringContaining("# Delegation"), + gptIdentity, "Initial context", ]) }) @@ -1730,7 +1737,7 @@ describe("SessionRunnerLLM", () => { yield* s.llm.push(TestLLM.text("Done", "text-build")) yield* s.resume - expect(s.requests.at(-1)?.system.map((part) => part.text)).toEqual(["Build agent instructions", "Initial context"]) + expect(s.requests.at(-1)?.system.map((part) => part.text)).toEqual(["Build agent instructions", fakeIdentity, "Initial context"]) }) scenario("uses the configured default agent system for omitted-agent sessions", function* (s) { @@ -1751,7 +1758,7 @@ describe("SessionRunnerLLM", () => { yield* s.llm.push(TestLLM.text("Done", "text-reviewer")) yield* s.resume - expect(s.requests.at(-1)?.system.map((part) => part.text)).toEqual(["Reviewer instructions", "Initial context"]) + expect(s.requests.at(-1)?.system.map((part) => part.text)).toEqual(["Reviewer instructions", fakeIdentity, "Initial context"]) expect((yield* s.messages)[0]).toMatchObject({ type: "assistant", agent: "reviewer" }) }) @@ -1774,7 +1781,7 @@ describe("SessionRunnerLLM", () => { yield* s.llm.push(TestLLM.text("Done", "text-selected")) yield* s.resume - expect(s.requests.at(-1)?.system.map((part) => part.text)).toEqual(["Reviewer instructions", "Initial context"]) + expect(s.requests.at(-1)?.system.map((part) => part.text)).toEqual(["Reviewer instructions", fakeIdentity, "Initial context"]) expect((yield* s.messages)[0]).toMatchObject({ type: "assistant", agent: "reviewer" }) }) @@ -1816,8 +1823,8 @@ describe("SessionRunnerLLM", () => { yield* s.runPrompt("Second") expect(s.requests.map((request) => request.system.map((part) => part.text))).toEqual([ - [defaultSystem, "Initial context\n\nBuild skills"], - [defaultSystem, "Initial context\n\nBuild skills"], + [defaultSystem, fakeIdentity, "Initial context\n\nBuild skills"], + [defaultSystem, fakeIdentity, "Initial context\n\nBuild skills"], ]) expect(systemTexts(s.requests[1])).toContainEqual(expect.stringContaining("Reviewer skills")) }) @@ -1839,7 +1846,7 @@ describe("SessionRunnerLLM", () => { yield* s.runPrompt("First") expect(s.requests.map((request) => request.system.map((part) => part.text))).toEqual([ - [defaultSystem, "Initial context\n\nBuild skills"], + [defaultSystem, fakeIdentity, "Initial context\n\nBuild skills"], ]) }) @@ -1858,7 +1865,7 @@ describe("SessionRunnerLLM", () => { yield* s.runPrompt("First") expect(s.requests.map((request) => request.model)).toEqual([model]) expect(s.requests.map((request) => request.system.map((part) => part.text))).toEqual([ - [defaultSystem, "Initial context"], + [defaultSystem, fakeIdentity, "Initial context"], ]) }) @@ -1882,6 +1889,7 @@ describe("SessionRunnerLLM", () => { // String values render verbatim inside the initial tagged block. expect(s.requests[0]?.system.map((part) => part.text)).toEqual([ defaultSystem, + fakeIdentity, ["Initial context", "", '', "production", ""].join("\n"), ]) @@ -1969,9 +1977,9 @@ describe("SessionRunnerLLM", () => { yield* s.runPrompt("Third") expect(s.requests.map((request) => request.system.map((part) => part.text))).toEqual([ - [defaultSystem, "Initial context"], - [defaultSystem, "Initial context"], - [defaultSystem, "Initial context"], + [defaultSystem, fakeIdentity, "Initial context"], + [defaultSystem, fakeIdentity, "Initial context"], + [defaultSystem, replacementIdentity, "Initial context"], ]) expect(messageRoles(s.requests[1])).toEqual(["user", "system", "user"]) expect(s.requests[2]?.messages.filter((message) => message.role === "system")).toHaveLength(2) @@ -2033,9 +2041,9 @@ describe("SessionRunnerLLM", () => { yield* s.runPrompt("Third") expect(s.requests.map((request) => request.system.map((part) => part.text))).toEqual([ - [defaultSystem, "Initial context"], - [defaultSystem, "Initial context"], - [defaultSystem, "Initial context"], + [defaultSystem, fakeIdentity, "Initial context"], + [defaultSystem, replacementIdentity, "Initial context"], + [defaultSystem, replacementIdentity, "Initial context"], ]) }) @@ -2051,9 +2059,9 @@ describe("SessionRunnerLLM", () => { yield* s.runPrompt("Second") expect(s.requests.map((request) => request.system.map((part) => part.text))).toEqual([ - [defaultSystem, "Initial context"], - [defaultSystem, "Initial context"], - [defaultSystem, "Initial context"], + [defaultSystem, fakeIdentity, "Initial context"], + [defaultSystem, fakeIdentity, "Initial context"], + [defaultSystem, fakeIdentity, "Initial context"], ]) expect(messageRoles(s.requests[2])).toEqual(["user", "system", "user"]) expect(s.requests[2]?.messages.at(1)?.content).toEqual([Expected.text("Replacement context")]) @@ -3015,7 +3023,7 @@ describe("SessionRunnerLLM", () => { expect(resolutions).toBe(2) expect(s.requests).toHaveLength(3) expect(s.requests[2]?.model).toBe(replacementModel) - expect(s.requests[2]?.system.map((part) => part.text)).toEqual([defaultSystem, "Initial context"]) + expect(s.requests[2]?.system.map((part) => part.text)).toEqual([defaultSystem, replacementIdentity, "Initial context"]) expect(systemTexts(s.requests[2])).toContain("Changed during compaction") expect(userTexts(s.requests[2])[0]).toContain("\n## Objective\n- Overflow summary\n") expect(userTexts(s.requests[2]).join("\n")).not.toContain("Queued during compaction") @@ -3206,7 +3214,7 @@ describe("SessionRunnerLLM", () => { yield* s.runPrompt("Third") // Compaction already moved current values into the new epoch before the unavailable read. - expect(s.requests.at(-1)?.system.map((part) => part.text)).toEqual([defaultSystem, "Changed context"]) + expect(s.requests.at(-1)?.system.map((part) => part.text)).toEqual([defaultSystem, fakeIdentity, "Changed context"]) expect(systemTexts(s.requests.at(-1)!)).not.toContain("Changed context") }) @@ -3343,8 +3351,8 @@ describe("SessionRunnerLLM", () => { expect(s.requests.map((request) => request.model)).toEqual([model, replacementModel]) expect(s.requests.map((request) => request.system.map((part) => part.text))).toEqual([ - [defaultSystem, "Initial context"], - [defaultSystem, "Initial context"], + [defaultSystem, fakeIdentity, "Initial context"], + [defaultSystem, replacementIdentity, "Initial context"], ]) expect(systemTexts(s.requests[1])).toContain("Replacement context") }) diff --git a/packages/core/test/session-title.test.ts b/packages/core/test/session-title.test.ts index 7cabf186d575..fa10d4fb39c0 100644 --- a/packages/core/test/session-title.test.ts +++ b/packages/core/test/session-title.test.ts @@ -23,6 +23,7 @@ import { SessionRunnerModel } from "@opencode/core/session/runner/model" import { SessionTable } from "@opencode/core/session/sql" import { SessionStore } from "@opencode/core/session/store" import { SessionTitle } from "@opencode/core/session/title" +import { IdentityPlugin } from "@opencode/core/plugin/identity" import { PluginHooks } from "@opencode/core/plugin/hooks" import { PluginSupervisor } from "@opencode/core/plugin/supervisor" import { Location } from "@opencode/core/location" @@ -36,6 +37,7 @@ import { AbsolutePath } from "@opencode/core/schema" import { Money } from "@opencode/schema/money" import { Deferred, Effect, Fiber, Layer, Stream } from "effect" import { testEffect } from "./lib/effect" +import { host, modelHost } from "./plugin/host" let requests: LLMRequest[] = [] let selectedSmall: Model.Info | undefined @@ -50,6 +52,7 @@ const smallModel = LanguageModel.make({ provider: "test", route: OpenAIChat.route, }) +const titleIdentity = "You are powered by title-model (test/title-model)." const cost = [ { input: Money.USDPerMillionTokens.make(1), @@ -102,6 +105,7 @@ const models = Layer.mock(SessionRunnerModel.Service)({ }) const smallModels = Layer.mock(Model.Service, { small: () => Effect.succeed(selectedSmall), + available: () => Effect.succeed([]), }) const it = testEffect( AppNodeBuilder.build( @@ -111,6 +115,7 @@ const it = testEffect( SessionProjector.node, SessionStore.node, Agent.node, + Model.node, PluginHooks.node, SessionTitle.node, ]), @@ -198,9 +203,20 @@ const enableTitleAgent = Effect.gen(function* () { }) }) +const enableIdentity = Effect.gen(function* () { + const models = yield* Model.Service + const hooks = yield* PluginHooks.Service + const pluginHost = host({ + model: modelHost(models), + session: { hook: (name, callback) => hooks.register("session", name, callback) }, + }) + yield* IdentityPlugin.Plugin.effect(pluginHost) +}) + it.effect("generates a title from the sole user message and renames the session", () => Effect.gen(function* () { yield* enableTitleAgent + yield* enableIdentity const sessionID = Session.ID.make("ses_title_generate") yield* insertSession(sessionID) yield* prompt(sessionID, "Help me debug the failing build") @@ -220,7 +236,7 @@ it.effect("generates a title from the sole user message and renames the session" }) expect(requests[0]?.promptCacheKey).toBe(sessionID) expect(requests[0]?.tools).toEqual([]) - expect(requests[0]?.system.map((part) => part.text)).toEqual(["You are a title generator."]) + expect(requests[0]?.system.map((part) => part.text)).toEqual(["You are a title generator.", titleIdentity]) expect(JSON.stringify(requests[0]?.messages)).toContain("Help me debug the failing build") const renamed = yield* store.get(sessionID) expect(renamed?.title).toBe("Generated Title") @@ -232,6 +248,7 @@ it.effect("generates a title from the sole user message and renames the session" it.effect("runs title hooks instead of context hooks", () => Effect.gen(function* () { yield* enableTitleAgent + yield* enableIdentity const sessionID = Session.ID.make("ses_title_hook") yield* insertSession(sessionID) yield* prompt(sessionID, "Redact this message") @@ -242,7 +259,7 @@ it.effect("runs title hooks instead of context hooks", () => yield* hooks.register("session", "title", (event) => Effect.sync(() => { expect(event.sessionID).toBe(sessionID) - expect(event.system.map((part) => part.text)).toEqual(["You are a title generator."]) + expect(event.system.map((part) => part.text)).toEqual(["You are a title generator.", titleIdentity]) event.system.push(SystemPart.make("Prefer short titles.")) event.messages = [Message.user("[redacted]")] event.options.maxTokens = 32 @@ -255,7 +272,7 @@ it.effect("runs title hooks instead of context hooks", () => expect(contexts).toBe(0) expect(requests).toHaveLength(1) - expect(requests[0]?.system.map((part) => part.text)).toEqual(["You are a title generator.", "Prefer short titles."]) + expect(requests[0]?.system.map((part) => part.text)).toEqual(["You are a title generator.", titleIdentity, "Prefer short titles."]) expect(JSON.stringify(requests[0]?.messages)).not.toContain("Redact this message") expect(requests[0]?.generation).toEqual(expect.objectContaining({ maxTokens: 32 })) expect(requests[0]?.providerOptions).toEqual({ reasoningEffort: "low" }) @@ -431,6 +448,7 @@ it.effect("does not generate when the title agent is removed", () => it.effect("regenerates an existing title using the title agent", () => Effect.gen(function* () { yield* enableTitleAgent + yield* enableIdentity const sessionID = Session.ID.make("ses_title_regenerate") yield* insertSession(sessionID, "Original title") yield* prompt(sessionID, "Investigate the login failure") @@ -463,7 +481,7 @@ it.effect("regenerates an existing title using the title agent", () => const store = yield* SessionStore.Service expect(requests).toHaveLength(1) - expect(requests[0]?.system.map((part) => part.text)).toEqual(["You are a title generator."]) + expect(requests[0]?.system.map((part) => part.text)).toEqual(["You are a title generator.", titleIdentity]) expect(JSON.stringify(requests[0]?.messages)).toContain("Investigate the login failure") expect(JSON.stringify(requests[0]?.messages)).toContain("The actual issue is expired OAuth credentials.") expect(JSON.stringify(requests[0]?.messages)).toContain("Switch to fixing OAuth token refresh") From 8fef15a4565aa7b36c04aafb8ba6d7d79f2a3d83 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Tue, 15 Sep 2026 01:48:26 -0500 Subject: [PATCH 2/5] fix(core): describe OpenCode as the harness the agent runs in --- packages/core/src/plugin/system-prompt/gpt-astra.txt | 2 +- packages/core/src/plugin/system-prompt/gpt.txt | 2 +- packages/core/src/session/runner/prompt/system.txt | 2 +- .../recordings/session-runner/openai-chat-streams-text.json | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/core/src/plugin/system-prompt/gpt-astra.txt b/packages/core/src/plugin/system-prompt/gpt-astra.txt index 0f63bc4056ff..cb48b61a984a 100644 --- a/packages/core/src/plugin/system-prompt/gpt-astra.txt +++ b/packages/core/src/plugin/system-prompt/gpt-astra.txt @@ -1,4 +1,4 @@ -You are an AI agent powered by OpenCode, a coding agent harness. Help the user accomplish their goals using the tools you have available. +You are an AI agent running in OpenCode, a coding agent harness. Help the user accomplish their goals using the tools you have available. # Harness - Responses are rendered as GitHub-flavored Markdown. diff --git a/packages/core/src/plugin/system-prompt/gpt.txt b/packages/core/src/plugin/system-prompt/gpt.txt index 84da1646b84c..4a30126f768e 100644 --- a/packages/core/src/plugin/system-prompt/gpt.txt +++ b/packages/core/src/plugin/system-prompt/gpt.txt @@ -1,4 +1,4 @@ -You are an AI agent powered by OpenCode, a coding agent harness. Help the user accomplish their goals using the tools you have available. +You are an AI agent running in OpenCode, a coding agent harness. Help the user accomplish their goals using the tools you have available. # Harness - Responses are rendered as GitHub-flavored Markdown. diff --git a/packages/core/src/session/runner/prompt/system.txt b/packages/core/src/session/runner/prompt/system.txt index f28057a4f91a..0e02ced78d7a 100644 --- a/packages/core/src/session/runner/prompt/system.txt +++ b/packages/core/src/session/runner/prompt/system.txt @@ -1,4 +1,4 @@ -You are an AI agent powered by OpenCode, a coding agent harness. Help the user accomplish their goals using the tools you have available. +You are an AI agent running in OpenCode, a coding agent harness. Help the user accomplish their goals using the tools you have available. # Harness - Responses are rendered as GitHub-flavored Markdown. diff --git a/packages/core/test/fixtures/recordings/session-runner/openai-chat-streams-text.json b/packages/core/test/fixtures/recordings/session-runner/openai-chat-streams-text.json index 6691394d0fb5..f437f9038850 100644 --- a/packages/core/test/fixtures/recordings/session-runner/openai-chat-streams-text.json +++ b/packages/core/test/fixtures/recordings/session-runner/openai-chat-streams-text.json @@ -13,7 +13,7 @@ "headers": { "content-type": "application/json" }, - "body": "{\"model\":\"gpt-4o-mini\",\"messages\":[{\"role\":\"system\",\"content\":\"You are an AI agent powered by OpenCode, a coding agent harness. Help the user accomplish their goals using the tools you have available.\\n\\n# Harness\\n- Responses are rendered as GitHub-flavored Markdown.\\n- `` blocks are harness instructions, not user-authored content. Read and follow them.\\n- Prefer parallelizing independent tool calls.\\n\\n\\n# Communication\\n\\nUse clear file paths when referring to files. Keep responses clear and concise, and avoid unnecessary technical jargon.\\n\\n## Intermediate Commentary\\n\\nAs you work, you send messages to the commentary channel. These are how you collaborate with the user while you work: stating assumptions and providing updates. Keep them concise and quickly scannable, and send them only when they add real information, such as a discovery, a tradeoff, or a blocker. Do not narrate routine reads, searches, or edits.\\n\\nBy default, treat new messages received during ongoing work as steering the active task rather than replacing it. Incorporate corrections and constraints, and answer questions briefly in commentary before continuing. Replace the task only when the user clearly cancels it or requests an incompatible objective.\\n\\nDo not put a final response, such as a blocking or clarifying question, in the commentary channel. The final answer must always be fully self-contained.\\n\\n## Final Answer\\n\\nIn the final answer, lead with the outcome, not the steps you took to reach it. Cover the most important information, use only as much structure as the answer needs, and avoid long-winded explanations unless necessary. Include technical detail only where it helps.\\n\\n# Working in codebases\\n- Keep changes consistent with the structure, naming, style, and patterns of the surrounding code.\\n- Treat unfamiliar files or changes as potential user work and investigate before deleting or overwriting them.\\n\\n# Delegation\\n\\nDo not spawn subagents unless the user or applicable AGENTS.md/skill instructions explicitly ask for subagents, delegation, or parallel agent work.\\n\\n# Destructive actions\\n\\nDo not revert, reset, or discard changes you did not make. Never run destructive commands such as `git reset --hard`, `git checkout --`, or recursive deletes on broad paths unless the user clearly asked for that operation; if the target or scope is unclear, ask first. Prefer non-interactive git commands.\\n\\n# Autonomy\\n\\nDo not infer authorization for work beyond the user's request. Assumptions that help you make progress are fine as long as they stay within the user's intent and the scope of the task.\\n\\nYou are powered by gpt-4o-mini (openai/gpt-4o-mini).\"},{\"role\":\"user\",\"content\":\"Say hello in one short sentence.\"}],\"stream\":true,\"stream_options\":{\"include_usage\":true},\"store\":false,\"prompt_cache_key\":\"ses_runner_recorded\",\"max_completion_tokens\":20,\"temperature\":0}" + "body": "{\"model\":\"gpt-4o-mini\",\"messages\":[{\"role\":\"system\",\"content\":\"You are an AI agent running in OpenCode, a coding agent harness. Help the user accomplish their goals using the tools you have available.\\n\\n# Harness\\n- Responses are rendered as GitHub-flavored Markdown.\\n- `` blocks are harness instructions, not user-authored content. Read and follow them.\\n- Prefer parallelizing independent tool calls.\\n\\n\\n# Communication\\n\\nUse clear file paths when referring to files. Keep responses clear and concise, and avoid unnecessary technical jargon.\\n\\n## Intermediate Commentary\\n\\nAs you work, you send messages to the commentary channel. These are how you collaborate with the user while you work: stating assumptions and providing updates. Keep them concise and quickly scannable, and send them only when they add real information, such as a discovery, a tradeoff, or a blocker. Do not narrate routine reads, searches, or edits.\\n\\nBy default, treat new messages received during ongoing work as steering the active task rather than replacing it. Incorporate corrections and constraints, and answer questions briefly in commentary before continuing. Replace the task only when the user clearly cancels it or requests an incompatible objective.\\n\\nDo not put a final response, such as a blocking or clarifying question, in the commentary channel. The final answer must always be fully self-contained.\\n\\n## Final Answer\\n\\nIn the final answer, lead with the outcome, not the steps you took to reach it. Cover the most important information, use only as much structure as the answer needs, and avoid long-winded explanations unless necessary. Include technical detail only where it helps.\\n\\n# Working in codebases\\n- Keep changes consistent with the structure, naming, style, and patterns of the surrounding code.\\n- Treat unfamiliar files or changes as potential user work and investigate before deleting or overwriting them.\\n\\n# Delegation\\n\\nDo not spawn subagents unless the user or applicable AGENTS.md/skill instructions explicitly ask for subagents, delegation, or parallel agent work.\\n\\n# Destructive actions\\n\\nDo not revert, reset, or discard changes you did not make. Never run destructive commands such as `git reset --hard`, `git checkout --`, or recursive deletes on broad paths unless the user clearly asked for that operation; if the target or scope is unclear, ask first. Prefer non-interactive git commands.\\n\\n# Autonomy\\n\\nDo not infer authorization for work beyond the user's request. Assumptions that help you make progress are fine as long as they stay within the user's intent and the scope of the task.\\n\\nYou are powered by gpt-4o-mini (openai/gpt-4o-mini).\"},{\"role\":\"user\",\"content\":\"Say hello in one short sentence.\"}],\"stream\":true,\"stream_options\":{\"include_usage\":true},\"store\":false,\"prompt_cache_key\":\"ses_runner_recorded\",\"max_completion_tokens\":20,\"temperature\":0}" }, "response": { "status": 200, From adc42f6a073d05795f73db62736ffc00b1457e42 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Tue, 15 Sep 2026 11:06:42 -0500 Subject: [PATCH 3/5] refactor(core): render model identity as a structured block --- packages/core/src/plugin/identity.ts | 19 +++++++++++++++---- .../openai-chat-streams-text.json | 2 +- packages/core/test/plugin/host.ts | 8 ++++++++ packages/core/test/plugin/identity.test.ts | 17 ++++++++++++----- .../core/test/session-runner-recorded.test.ts | 3 ++- packages/core/test/session-runner.test.ts | 11 +++++++---- packages/core/test/session-title.test.ts | 5 +++-- 7 files changed, 48 insertions(+), 17 deletions(-) diff --git a/packages/core/src/plugin/identity.ts b/packages/core/src/plugin/identity.ts index 66d3ec913e88..6ac908b3cc28 100644 --- a/packages/core/src/plugin/identity.ts +++ b/packages/core/src/plugin/identity.ts @@ -6,9 +6,13 @@ import type { SessionHooks } from "@opencode/plugin/effect/session" import { Model } from "@opencode/schema/model" import { Effect } from "effect" -// Display name for humans, catalog ref for the exact route. -export function identity(model: { readonly name: string; readonly ref: Model.Ref }) { - return `You are powered by ${model.name} (${model.ref.providerID}/${model.ref.id}).` +export function identity(model: { readonly provider: string; readonly name: string; readonly ref: Model.Ref }) { + return [ + "# Your Model", + `- Provider: ${model.provider}`, + `- Name: ${model.name}`, + `- ID: ${model.ref.providerID}/${model.ref.id}`, + ].join("\n") } export const Plugin = define({ @@ -20,8 +24,15 @@ export const Plugin = define({ (yield* ctx.model.list()).data.find( (model) => model.providerID === event.model.providerID && model.id === event.model.id, ) ?? Model.Info.default(event.model.providerID, event.model.id) + const provider = (yield* ctx.provider.list()).data.find((provider) => provider.id === event.model.providerID) // Insert after the agent prompt so family-prompt overrides of the first part preserve it. - event.system.splice(1, 0, SystemPart.make(identity({ name: model.name, ref: event.model }))) + event.system.splice( + 1, + 0, + SystemPart.make( + identity({ provider: provider?.name ?? event.model.providerID, name: model.name, ref: event.model }), + ), + ) }).pipe(Effect.catch(() => Effect.void)) yield* ctx.session.hook("context", hook) yield* ctx.session.hook("compaction", hook) diff --git a/packages/core/test/fixtures/recordings/session-runner/openai-chat-streams-text.json b/packages/core/test/fixtures/recordings/session-runner/openai-chat-streams-text.json index f437f9038850..53f8805931d3 100644 --- a/packages/core/test/fixtures/recordings/session-runner/openai-chat-streams-text.json +++ b/packages/core/test/fixtures/recordings/session-runner/openai-chat-streams-text.json @@ -13,7 +13,7 @@ "headers": { "content-type": "application/json" }, - "body": "{\"model\":\"gpt-4o-mini\",\"messages\":[{\"role\":\"system\",\"content\":\"You are an AI agent running in OpenCode, a coding agent harness. Help the user accomplish their goals using the tools you have available.\\n\\n# Harness\\n- Responses are rendered as GitHub-flavored Markdown.\\n- `` blocks are harness instructions, not user-authored content. Read and follow them.\\n- Prefer parallelizing independent tool calls.\\n\\n\\n# Communication\\n\\nUse clear file paths when referring to files. Keep responses clear and concise, and avoid unnecessary technical jargon.\\n\\n## Intermediate Commentary\\n\\nAs you work, you send messages to the commentary channel. These are how you collaborate with the user while you work: stating assumptions and providing updates. Keep them concise and quickly scannable, and send them only when they add real information, such as a discovery, a tradeoff, or a blocker. Do not narrate routine reads, searches, or edits.\\n\\nBy default, treat new messages received during ongoing work as steering the active task rather than replacing it. Incorporate corrections and constraints, and answer questions briefly in commentary before continuing. Replace the task only when the user clearly cancels it or requests an incompatible objective.\\n\\nDo not put a final response, such as a blocking or clarifying question, in the commentary channel. The final answer must always be fully self-contained.\\n\\n## Final Answer\\n\\nIn the final answer, lead with the outcome, not the steps you took to reach it. Cover the most important information, use only as much structure as the answer needs, and avoid long-winded explanations unless necessary. Include technical detail only where it helps.\\n\\n# Working in codebases\\n- Keep changes consistent with the structure, naming, style, and patterns of the surrounding code.\\n- Treat unfamiliar files or changes as potential user work and investigate before deleting or overwriting them.\\n\\n# Delegation\\n\\nDo not spawn subagents unless the user or applicable AGENTS.md/skill instructions explicitly ask for subagents, delegation, or parallel agent work.\\n\\n# Destructive actions\\n\\nDo not revert, reset, or discard changes you did not make. Never run destructive commands such as `git reset --hard`, `git checkout --`, or recursive deletes on broad paths unless the user clearly asked for that operation; if the target or scope is unclear, ask first. Prefer non-interactive git commands.\\n\\n# Autonomy\\n\\nDo not infer authorization for work beyond the user's request. Assumptions that help you make progress are fine as long as they stay within the user's intent and the scope of the task.\\n\\nYou are powered by gpt-4o-mini (openai/gpt-4o-mini).\"},{\"role\":\"user\",\"content\":\"Say hello in one short sentence.\"}],\"stream\":true,\"stream_options\":{\"include_usage\":true},\"store\":false,\"prompt_cache_key\":\"ses_runner_recorded\",\"max_completion_tokens\":20,\"temperature\":0}" + "body": "{\"model\":\"gpt-4o-mini\",\"messages\":[{\"role\":\"system\",\"content\":\"You are an AI agent running in OpenCode, a coding agent harness. Help the user accomplish their goals using the tools you have available.\\n\\n# Harness\\n- Responses are rendered as GitHub-flavored Markdown.\\n- `` blocks are harness instructions, not user-authored content. Read and follow them.\\n- Prefer parallelizing independent tool calls.\\n\\n\\n# Communication\\n\\nUse clear file paths when referring to files. Keep responses clear and concise, and avoid unnecessary technical jargon.\\n\\n## Intermediate Commentary\\n\\nAs you work, you send messages to the commentary channel. These are how you collaborate with the user while you work: stating assumptions and providing updates. Keep them concise and quickly scannable, and send them only when they add real information, such as a discovery, a tradeoff, or a blocker. Do not narrate routine reads, searches, or edits.\\n\\nBy default, treat new messages received during ongoing work as steering the active task rather than replacing it. Incorporate corrections and constraints, and answer questions briefly in commentary before continuing. Replace the task only when the user clearly cancels it or requests an incompatible objective.\\n\\nDo not put a final response, such as a blocking or clarifying question, in the commentary channel. The final answer must always be fully self-contained.\\n\\n## Final Answer\\n\\nIn the final answer, lead with the outcome, not the steps you took to reach it. Cover the most important information, use only as much structure as the answer needs, and avoid long-winded explanations unless necessary. Include technical detail only where it helps.\\n\\n# Working in codebases\\n- Keep changes consistent with the structure, naming, style, and patterns of the surrounding code.\\n- Treat unfamiliar files or changes as potential user work and investigate before deleting or overwriting them.\\n\\n# Delegation\\n\\nDo not spawn subagents unless the user or applicable AGENTS.md/skill instructions explicitly ask for subagents, delegation, or parallel agent work.\\n\\n# Destructive actions\\n\\nDo not revert, reset, or discard changes you did not make. Never run destructive commands such as `git reset --hard`, `git checkout --`, or recursive deletes on broad paths unless the user clearly asked for that operation; if the target or scope is unclear, ask first. Prefer non-interactive git commands.\\n\\n# Autonomy\\n\\nDo not infer authorization for work beyond the user's request. Assumptions that help you make progress are fine as long as they stay within the user's intent and the scope of the task.\\n\\n# Your Model\\n- Provider: openai\\n- Name: gpt-4o-mini\\n- ID: openai/gpt-4o-mini\"},{\"role\":\"user\",\"content\":\"Say hello in one short sentence.\"}],\"stream\":true,\"stream_options\":{\"include_usage\":true},\"store\":false,\"prompt_cache_key\":\"ses_runner_recorded\",\"max_completion_tokens\":20,\"temperature\":0}" }, "response": { "status": 200, diff --git a/packages/core/test/plugin/host.ts b/packages/core/test/plugin/host.ts index 776185947300..fd5b3b293a02 100644 --- a/packages/core/test/plugin/host.ts +++ b/packages/core/test/plugin/host.ts @@ -252,6 +252,14 @@ export function providerHost(providers: Provider.Interface): Plugin.Context["pro } } +/** Empty catalog for hosts that only need provider lookups to fall back. */ +export const noProviders: Plugin.Context["provider"] = { + list: () => Effect.succeed(located([])), + get: () => Effect.die("unused provider.get"), + transform: () => Effect.die("unused provider.transform"), + reload: () => Effect.die("unused provider.reload"), +} + export function modelHost(models: Model.Interface): Plugin.Context["model"] { return { list: () => models.available().pipe(Effect.map(located)), diff --git a/packages/core/test/plugin/identity.test.ts b/packages/core/test/plugin/identity.test.ts index cb46b1613bc1..6395f504a2da 100644 --- a/packages/core/test/plugin/identity.test.ts +++ b/packages/core/test/plugin/identity.test.ts @@ -18,12 +18,16 @@ const it = testEffect(PluginTestLayer) test("formats the model identity part", () => { expect( IdentityPlugin.identity({ + provider: "OpenAI", name: "GPT-4o mini", ref: Model.Ref.make({ providerID: Provider.ID.make("openai"), id: Model.ID.make("gpt-4o-mini") }), }), - ).toBe("You are powered by GPT-4o mini (openai/gpt-4o-mini).") + ).toBe(["# Your Model", "- Provider: OpenAI", "- Name: GPT-4o mini", "- ID: openai/gpt-4o-mini"].join("\n")) }) +const identity = (provider: string, name: string, id: string) => + ["# Your Model", `- Provider: ${provider}`, `- Name: ${name}`, `- ID: test/${id}`].join("\n") + const context = (id: string): SessionHooks["context"] => ({ sessionID: Session.ID.make("ses_model_identity"), agent: Agent.ID.make("build"), @@ -34,13 +38,16 @@ const context = (id: string): SessionHooks["context"] => ({ options: {}, }) -it.effect("inserts the catalog display name after the agent prompt", () => +it.effect("inserts the structured model block after the agent prompt", () => Effect.gen(function* () { const catalog = yield* Provider.Service const hooks = yield* PluginHooks.Service const plugins = yield* Plugin.Service const pluginHost = yield* PluginHost.make(plugins) yield* catalog.transform((editor) => { + editor.update(Provider.ID.make("test"), (provider) => { + provider.name = "Test Provider" + }) editor.models.update(Provider.ID.make("test"), Model.ID.make("meta/muse-spark-1.1"), (model) => { model.name = "Muse Spark" }) @@ -51,7 +58,7 @@ it.effect("inserts the catalog display name after the agent prompt", () => yield* hooks.trigger("session", "context", named) expect(named.system.map((part) => part.text)).toEqual([ "Agent prompt", - "You are powered by Muse Spark (test/meta/muse-spark-1.1).", + identity("Test Provider", "Muse Spark", "meta/muse-spark-1.1"), "Initial context", ]) @@ -59,7 +66,7 @@ it.effect("inserts the catalog display name after the agent prompt", () => yield* hooks.trigger("session", "context", fallback) expect(fallback.system.map((part) => part.text)).toEqual([ "Agent prompt", - "You are powered by unknown-model (test/unknown-model).", + identity("Test Provider", "unknown-model", "unknown-model"), "Initial context", ]) @@ -73,7 +80,7 @@ it.effect("inserts the catalog display name after the agent prompt", () => yield* hooks.trigger("session", "title", title) expect(title.system.map((part) => part.text)).toEqual([ "You are a title generator.", - "You are powered by Muse Spark (test/meta/muse-spark-1.1).", + identity("Test Provider", "Muse Spark", "meta/muse-spark-1.1"), ]) }), ) diff --git a/packages/core/test/session-runner-recorded.test.ts b/packages/core/test/session-runner-recorded.test.ts index 0b10f1bf1b46..3ca8cfa9ce4f 100644 --- a/packages/core/test/session-runner-recorded.test.ts +++ b/packages/core/test/session-runner-recorded.test.ts @@ -45,7 +45,7 @@ import { testEffect } from "./lib/effect" import { LocationServiceMap } from "@opencode/core/location-service-map" import { promptLocationNode } from "./fixture/prompt-location" import { permissionLayer } from "./lib/permission" -import { agentHost, modelHost, host } from "./plugin/host" +import { agentHost, modelHost, host, noProviders } from "./plugin/host" const cassetteName = "session-runner/openai-chat-streams-text" const cassetteDirectory = path.resolve(import.meta.dir, "fixtures/recordings") @@ -185,6 +185,7 @@ describe("SessionRunnerLLM recorded", () => { const pluginHost = host({ agent: agentHost(agents), model: modelHost(models), + provider: noProviders, session: { hook: (name, callback) => hooks.register("session", name, callback) }, }) yield* Effect.forEach(OptimizePlugin.Plugins, (plugin) => plugin.effect(pluginHost), { discard: true }) diff --git a/packages/core/test/session-runner.test.ts b/packages/core/test/session-runner.test.ts index 8d9d1fc66528..196cb37286e6 100644 --- a/packages/core/test/session-runner.test.ts +++ b/packages/core/test/session-runner.test.ts @@ -89,7 +89,7 @@ import { promptLocationNode } from "./fixture/prompt-location" import { LocationServiceMap } from "@opencode/core/location-service-map" import { Expected } from "./lib/session-message" import { permissionLayer } from "./lib/permission" -import { agentHost, modelHost, host } from "./plugin/host" +import { agentHost, modelHost, host, noProviders } from "./plugin/host" import { CodeModeInstructions } from "@opencode/core/codemode/instructions" const emptyCodeMode = `\n\n${CodeModeInstructions.render({ total: 0, shown: 0, namespaces: [] })}` @@ -120,9 +120,11 @@ const testModel = (id: string, limit: ModelLimit = defaultModelLimit) => { } const model = testModel("fake-model") const defaultSystem = SessionSystemPrompt.make([]) -const fakeIdentity = "You are powered by fake-model (fake/fake-model)." -const replacementIdentity = "You are powered by replacement (fake/replacement)." -const gptIdentity = "You are powered by gpt-5 (openai/gpt-5)." +const identity = (providerID: string, id: string) => + ["# Your Model", `- Provider: ${providerID}`, `- Name: ${id}`, `- ID: ${providerID}/${id}`].join("\n") +const fakeIdentity = identity("fake", "fake-model") +const replacementIdentity = identity("fake", "replacement") +const gptIdentity = identity("openai", "gpt-5") const replacementModel = testModel("replacement") const compactModel = testModel("compact", { context: 4_000, output: 50 }) const fullOutputModel = testModel("full-output", { context: 262_144, output: 262_144 }) @@ -519,6 +521,7 @@ const setup = Effect.gen(function* () { const pluginHost = host({ agent: agentHost(agents), model: modelHost(models), + provider: noProviders, session: { hook: (name, callback) => hooks.register("session", name, callback) }, }) yield* Effect.forEach(OptimizePlugin.Plugins, (plugin) => plugin.effect(pluginHost), { diff --git a/packages/core/test/session-title.test.ts b/packages/core/test/session-title.test.ts index fa10d4fb39c0..85a72426bed6 100644 --- a/packages/core/test/session-title.test.ts +++ b/packages/core/test/session-title.test.ts @@ -37,7 +37,7 @@ import { AbsolutePath } from "@opencode/core/schema" import { Money } from "@opencode/schema/money" import { Deferred, Effect, Fiber, Layer, Stream } from "effect" import { testEffect } from "./lib/effect" -import { host, modelHost } from "./plugin/host" +import { host, modelHost, noProviders } from "./plugin/host" let requests: LLMRequest[] = [] let selectedSmall: Model.Info | undefined @@ -52,7 +52,7 @@ const smallModel = LanguageModel.make({ provider: "test", route: OpenAIChat.route, }) -const titleIdentity = "You are powered by title-model (test/title-model)." +const titleIdentity = ["# Your Model", "- Provider: test", "- Name: title-model", "- ID: test/title-model"].join("\n") const cost = [ { input: Money.USDPerMillionTokens.make(1), @@ -208,6 +208,7 @@ const enableIdentity = Effect.gen(function* () { const hooks = yield* PluginHooks.Service const pluginHost = host({ model: modelHost(models), + provider: noProviders, session: { hook: (name, callback) => hooks.register("session", name, callback) }, }) yield* IdentityPlugin.Plugin.effect(pluginHost) From fcfe307b34c8d6ca1727a4cd4bf531d0120ffec8 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Tue, 15 Sep 2026 11:24:01 -0500 Subject: [PATCH 4/5] chore(core): drop identity insertion comment --- packages/core/src/plugin/identity.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/core/src/plugin/identity.ts b/packages/core/src/plugin/identity.ts index 6ac908b3cc28..e15069cd1219 100644 --- a/packages/core/src/plugin/identity.ts +++ b/packages/core/src/plugin/identity.ts @@ -25,7 +25,6 @@ export const Plugin = define({ (model) => model.providerID === event.model.providerID && model.id === event.model.id, ) ?? Model.Info.default(event.model.providerID, event.model.id) const provider = (yield* ctx.provider.list()).data.find((provider) => provider.id === event.model.providerID) - // Insert after the agent prompt so family-prompt overrides of the first part preserve it. event.system.splice( 1, 0, From 0cab610ef56dc6578e4bbdffd445566007f69f20 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Tue, 15 Sep 2026 11:34:39 -0500 Subject: [PATCH 5/5] fix(core): keep model identity out of title requests --- packages/core/src/plugin/identity.ts | 3 +-- packages/core/test/plugin/identity.test.ts | 13 ----------- packages/core/test/session-title.test.ts | 27 ++++------------------ 3 files changed, 5 insertions(+), 38 deletions(-) diff --git a/packages/core/src/plugin/identity.ts b/packages/core/src/plugin/identity.ts index e15069cd1219..0881db870475 100644 --- a/packages/core/src/plugin/identity.ts +++ b/packages/core/src/plugin/identity.ts @@ -18,7 +18,7 @@ export function identity(model: { readonly provider: string; readonly name: stri export const Plugin = define({ id: "opencode.prompt.identity", effect: Effect.fn("IdentityPlugin")(function* (ctx) { - const hook = (event: SessionHooks["context"] | SessionHooks["title"]) => + const hook = (event: SessionHooks["context"]) => Effect.gen(function* () { const model = (yield* ctx.model.list()).data.find( @@ -36,6 +36,5 @@ export const Plugin = define({ yield* ctx.session.hook("context", hook) yield* ctx.session.hook("compaction", hook) yield* ctx.session.hook("generate", hook) - yield* ctx.session.hook("title", hook) }), }) diff --git a/packages/core/test/plugin/identity.test.ts b/packages/core/test/plugin/identity.test.ts index 6395f504a2da..406cf1e837b2 100644 --- a/packages/core/test/plugin/identity.test.ts +++ b/packages/core/test/plugin/identity.test.ts @@ -69,18 +69,5 @@ it.effect("inserts the structured model block after the agent prompt", () => identity("Test Provider", "unknown-model", "unknown-model"), "Initial context", ]) - - const title: SessionHooks["title"] = { - sessionID: Session.ID.make("ses_model_identity"), - model: Model.Ref.make({ providerID: Provider.ID.make("test"), id: Model.ID.make("meta/muse-spark-1.1") }), - system: [SystemPart.make("You are a title generator.")], - messages: [], - options: {}, - } - yield* hooks.trigger("session", "title", title) - expect(title.system.map((part) => part.text)).toEqual([ - "You are a title generator.", - identity("Test Provider", "Muse Spark", "meta/muse-spark-1.1"), - ]) }), ) diff --git a/packages/core/test/session-title.test.ts b/packages/core/test/session-title.test.ts index 85a72426bed6..7cabf186d575 100644 --- a/packages/core/test/session-title.test.ts +++ b/packages/core/test/session-title.test.ts @@ -23,7 +23,6 @@ import { SessionRunnerModel } from "@opencode/core/session/runner/model" import { SessionTable } from "@opencode/core/session/sql" import { SessionStore } from "@opencode/core/session/store" import { SessionTitle } from "@opencode/core/session/title" -import { IdentityPlugin } from "@opencode/core/plugin/identity" import { PluginHooks } from "@opencode/core/plugin/hooks" import { PluginSupervisor } from "@opencode/core/plugin/supervisor" import { Location } from "@opencode/core/location" @@ -37,7 +36,6 @@ import { AbsolutePath } from "@opencode/core/schema" import { Money } from "@opencode/schema/money" import { Deferred, Effect, Fiber, Layer, Stream } from "effect" import { testEffect } from "./lib/effect" -import { host, modelHost, noProviders } from "./plugin/host" let requests: LLMRequest[] = [] let selectedSmall: Model.Info | undefined @@ -52,7 +50,6 @@ const smallModel = LanguageModel.make({ provider: "test", route: OpenAIChat.route, }) -const titleIdentity = ["# Your Model", "- Provider: test", "- Name: title-model", "- ID: test/title-model"].join("\n") const cost = [ { input: Money.USDPerMillionTokens.make(1), @@ -105,7 +102,6 @@ const models = Layer.mock(SessionRunnerModel.Service)({ }) const smallModels = Layer.mock(Model.Service, { small: () => Effect.succeed(selectedSmall), - available: () => Effect.succeed([]), }) const it = testEffect( AppNodeBuilder.build( @@ -115,7 +111,6 @@ const it = testEffect( SessionProjector.node, SessionStore.node, Agent.node, - Model.node, PluginHooks.node, SessionTitle.node, ]), @@ -203,21 +198,9 @@ const enableTitleAgent = Effect.gen(function* () { }) }) -const enableIdentity = Effect.gen(function* () { - const models = yield* Model.Service - const hooks = yield* PluginHooks.Service - const pluginHost = host({ - model: modelHost(models), - provider: noProviders, - session: { hook: (name, callback) => hooks.register("session", name, callback) }, - }) - yield* IdentityPlugin.Plugin.effect(pluginHost) -}) - it.effect("generates a title from the sole user message and renames the session", () => Effect.gen(function* () { yield* enableTitleAgent - yield* enableIdentity const sessionID = Session.ID.make("ses_title_generate") yield* insertSession(sessionID) yield* prompt(sessionID, "Help me debug the failing build") @@ -237,7 +220,7 @@ it.effect("generates a title from the sole user message and renames the session" }) expect(requests[0]?.promptCacheKey).toBe(sessionID) expect(requests[0]?.tools).toEqual([]) - expect(requests[0]?.system.map((part) => part.text)).toEqual(["You are a title generator.", titleIdentity]) + expect(requests[0]?.system.map((part) => part.text)).toEqual(["You are a title generator."]) expect(JSON.stringify(requests[0]?.messages)).toContain("Help me debug the failing build") const renamed = yield* store.get(sessionID) expect(renamed?.title).toBe("Generated Title") @@ -249,7 +232,6 @@ it.effect("generates a title from the sole user message and renames the session" it.effect("runs title hooks instead of context hooks", () => Effect.gen(function* () { yield* enableTitleAgent - yield* enableIdentity const sessionID = Session.ID.make("ses_title_hook") yield* insertSession(sessionID) yield* prompt(sessionID, "Redact this message") @@ -260,7 +242,7 @@ it.effect("runs title hooks instead of context hooks", () => yield* hooks.register("session", "title", (event) => Effect.sync(() => { expect(event.sessionID).toBe(sessionID) - expect(event.system.map((part) => part.text)).toEqual(["You are a title generator.", titleIdentity]) + expect(event.system.map((part) => part.text)).toEqual(["You are a title generator."]) event.system.push(SystemPart.make("Prefer short titles.")) event.messages = [Message.user("[redacted]")] event.options.maxTokens = 32 @@ -273,7 +255,7 @@ it.effect("runs title hooks instead of context hooks", () => expect(contexts).toBe(0) expect(requests).toHaveLength(1) - expect(requests[0]?.system.map((part) => part.text)).toEqual(["You are a title generator.", titleIdentity, "Prefer short titles."]) + expect(requests[0]?.system.map((part) => part.text)).toEqual(["You are a title generator.", "Prefer short titles."]) expect(JSON.stringify(requests[0]?.messages)).not.toContain("Redact this message") expect(requests[0]?.generation).toEqual(expect.objectContaining({ maxTokens: 32 })) expect(requests[0]?.providerOptions).toEqual({ reasoningEffort: "low" }) @@ -449,7 +431,6 @@ it.effect("does not generate when the title agent is removed", () => it.effect("regenerates an existing title using the title agent", () => Effect.gen(function* () { yield* enableTitleAgent - yield* enableIdentity const sessionID = Session.ID.make("ses_title_regenerate") yield* insertSession(sessionID, "Original title") yield* prompt(sessionID, "Investigate the login failure") @@ -482,7 +463,7 @@ it.effect("regenerates an existing title using the title agent", () => const store = yield* SessionStore.Service expect(requests).toHaveLength(1) - expect(requests[0]?.system.map((part) => part.text)).toEqual(["You are a title generator.", titleIdentity]) + expect(requests[0]?.system.map((part) => part.text)).toEqual(["You are a title generator."]) expect(JSON.stringify(requests[0]?.messages)).toContain("Investigate the login failure") expect(JSON.stringify(requests[0]?.messages)).toContain("The actual issue is expired OAuth credentials.") expect(JSON.stringify(requests[0]?.messages)).toContain("Switch to fixing OAuth token refresh")