Plugins using noReply seem to cause model to switch to agent default #2957

Open
opened 2026-02-16 17:38:00 -05:00 by yindo · 20 comments
Owner

Originally created by @omaclaren on GitHub (Nov 18, 2025).

Originally assigned to: @thdxr on GitHub.

Description

Update: see comments. Issue appears to affect plugins using noReply in general not just opencode-skill

Version: OpenCode 1.0.78
Plugin: opencode-skills 0.1.0

Issue: When calling a skill tool (e.g., skills_test_skill), Build mode switches from the manually selected model to its default (?) (in my case openai/gpt-oss-120b).

Expected: Model selection should remain stable when plugin tools execute.

Workaround: Explicitly set mode.build.model in opencode.json

Test skill

in e.g. ~/.opencode/skills/test-skill/SKILL.md):


name: test-skill
description: A minimal test skill to debug loading issues

Test Skill

This is a simple test skill. If you can see this, the skill loaded successfully.

Just respond with "Test skill loaded!" when called.

OpenCode version

1.0.78

Steps to reproduce

  1. Start OpenCode without explicit mode.build.model config
  2. Manually select Claude Haiku 4.5
  3. Verify Build indicator shows "claude-haiku-4-5"
  4. Call any skills_* tool
  5. Build mode indicator switches to "openai/gpt-oss-120b"

Screenshot and/or share link

Image

Operating System

macOS 23.6.0

Terminal

iTerm2

Originally created by @omaclaren on GitHub (Nov 18, 2025). Originally assigned to: @thdxr on GitHub. ### Description Update: see comments. Issue appears to affect plugins using noReply in general not just opencode-skill Version: OpenCode 1.0.78 Plugin: opencode-skills 0.1.0 Issue: When calling a skill tool (e.g., skills_test_skill), Build mode switches from the manually selected model to its default (?) (in my case openai/gpt-oss-120b). Expected: Model selection should remain stable when plugin tools execute. Workaround: Explicitly set mode.build.model in opencode.json ## Test skill in e.g. ~/.opencode/skills/test-skill/SKILL.md): --- name: test-skill description: A minimal test skill to debug loading issues --- # Test Skill This is a simple test skill. If you can see this, the skill loaded successfully. Just respond with "Test skill loaded!" when called. ### OpenCode version 1.0.78 ### Steps to reproduce 1. Start OpenCode without explicit mode.build.model config 2. Manually select Claude Haiku 4.5 3. Verify Build indicator shows "claude-haiku-4-5" 4. Call any skills_* tool 5. Build mode indicator switches to "openai/gpt-oss-120b" ### Screenshot and/or share link <img width="453" height="478" alt="Image" src="https://github.com/user-attachments/assets/22204abe-84d1-4f68-aff1-c5802b1b57f2" /> ### Operating System macOS 23.6.0 ### Terminal iTerm2
yindo added the bug label 2026-02-16 17:38:00 -05:00
Author
Owner

@rekram1-node commented on GitHub (Nov 19, 2025):

prolly because of the skill plugin setting the model in its request or not using the same model

@rekram1-node commented on GitHub (Nov 19, 2025): prolly because of the skill plugin setting the model in its request or not using the same model
Author
Owner

@omaclaren commented on GitHub (Nov 19, 2025):

Thanks but I don't think this is specific to this plugin (and I don't think the opencode-skills plugin sets a model in its requests).

To verify, here is a minimal test plugin that uses the same noReply pattern as opencode-skills.

// .opencode/plugin/test-noreply.ts
import type { Plugin } from "@opencode-ai/plugin";
import { tool } from "@opencode-ai/plugin";

export const TestNoReplyPlugin: Plugin = async (ctx) => {
  return {
    tool: {
      test_noreply: tool({
        description: "Minimal test of noReply pattern",
        args: {},
        async execute(args, toolCtx) {
          await ctx.client.session.prompt({
            path: { id: toolCtx.sessionID },
            body: {
              noReply: true,
              parts: [{ type: "text", text: "Test message" }]
            }
          });
          return "Test completed";
        }
      })
    }
  };
};

This minimal test plugin also seems to trigger the model switch to gpt-oss-120b.

So it is maybe OpenCode's handling of client.session.prompt({ noReply: true }) calls from plugins?

Built-in tools (bash, read, etc.) that don't use this pattern seem to work fine.

@omaclaren commented on GitHub (Nov 19, 2025): Thanks but I don't think this is specific to this plugin (and I don't think the opencode-skills plugin sets a model in its requests). To verify, here is a minimal test plugin that uses the same `noReply` pattern as opencode-skills. ```typescript // .opencode/plugin/test-noreply.ts import type { Plugin } from "@opencode-ai/plugin"; import { tool } from "@opencode-ai/plugin"; export const TestNoReplyPlugin: Plugin = async (ctx) => { return { tool: { test_noreply: tool({ description: "Minimal test of noReply pattern", args: {}, async execute(args, toolCtx) { await ctx.client.session.prompt({ path: { id: toolCtx.sessionID }, body: { noReply: true, parts: [{ type: "text", text: "Test message" }] } }); return "Test completed"; } }) } }; }; ``` This minimal test plugin also seems to trigger the model switch to gpt-oss-120b. So it is maybe OpenCode's handling of `client.session.prompt({ noReply: true })` calls from plugins? Built-in tools (bash, read, etc.) that don't use this pattern seem to work fine.
Author
Owner

@omaclaren commented on GitHub (Nov 19, 2025):

I think the issue is maybe that resolveModel doesn't check the current session's active model before falling back to defaults, so noReply calls inherit the default model instead of the session's current selection.

@omaclaren commented on GitHub (Nov 19, 2025): I think the issue is maybe that resolveModel doesn't check the current session's active model before falling back to defaults, so noReply calls inherit the default model instead of the session's current selection.
Author
Owner

@omaclaren commented on GitHub (Nov 19, 2025):

Updated title to reflect that this seems to affect all plugins using noReply, not just opencode-skills

@omaclaren commented on GitHub (Nov 19, 2025): Updated title to reflect that this seems to affect all plugins using `noReply`, not just opencode-skills
Author
Owner

@omaclaren commented on GitHub (Nov 19, 2025):

Another update.
For 1.0.68 both the minimal test_noreply plugin and skills_test_skill work correctly and use the session's selected model (here Claude Haiku 4.5). Seems to be an issue introduced by the agent loop refactor and/or subsequent fixes to this, hence doesn't work for 1.0.78

@omaclaren commented on GitHub (Nov 19, 2025): Another update. For 1.0.68 both the minimal `test_noreply` plugin and `skills_test_skill` work correctly and use the session's selected model (here Claude Haiku 4.5). Seems to be an issue introduced by the agent loop refactor and/or subsequent fixes to this, hence doesn't work for 1.0.78
Author
Owner

@arsham commented on GitHub (Nov 19, 2025):

Related #4439 I suppose. The same agent loop refactor caused both issues.

@arsham commented on GitHub (Nov 19, 2025): Related #4439 I suppose. The same agent loop refactor caused both issues.
Author
Owner

@rekram1-node commented on GitHub (Nov 19, 2025):

yeah i think ur right there is something going on there

@rekram1-node commented on GitHub (Nov 19, 2025): yeah i think ur right there is something going on there
Author
Owner

@thdxr commented on GitHub (Nov 19, 2025):

let me summarize the issue:

what is happening here is the loop now will use the model associated with the LAST user message

right now you can call prompt without specifying agent or model

and the current logic is to use the default model when that's the case

better logic would be to use the last known model from the last user message that has one - will fix

@thdxr commented on GitHub (Nov 19, 2025): let me summarize the issue: what is happening here is the loop now will use the model associated with the LAST user message right now you can call `prompt` without specifying agent or model and the current logic is to use the default model when that's the case better logic would be to use the last known model from the last user message that has one - will fix
Author
Owner

@thdxr commented on GitHub (Nov 19, 2025):

can you see if this is fixed in bunx opencode-ai@dev (should be deployed 5min after this comment)

@thdxr commented on GitHub (Nov 19, 2025): can you see if this is fixed in `bunx opencode-ai@dev` (should be deployed 5min after this comment)
Author
Owner

@wolfie82 commented on GitHub (Nov 19, 2025):

The fix is working, but one thing that is interesting to point out is that the prompts sent with noReply appear to be using the build agent, instead of the agent that started the session. So the model selection problem is gone (if there isnt a default configured in opencode.json), but is it intended that the noReply pattern will always be processed by the in-built build agent?

@wolfie82 commented on GitHub (Nov 19, 2025): The fix is working, but one thing that is interesting to point out is that the prompts sent with noReply appear to be using the `build` agent, instead of the agent that started the session. So the model selection problem is gone (if there isnt a default configured in opencode.json), but is it intended that the noReply pattern will always be processed by the in-built `build` agent?
Author
Owner

@wolfie82 commented on GitHub (Nov 19, 2025):

So I just tested this. When a prompt is sent with noReply, the agent switches to build. If the build agent decides to continue, but its now a different agent and trying to run commands it doesn't have access to. It bombs like the image below. /cc @thdxr @rekram1-node

Image
@wolfie82 commented on GitHub (Nov 19, 2025): So I just tested this. When a prompt is sent with noReply, the agent switches to build. If the build agent decides to continue, but its now a different agent and trying to run commands it doesn't have access to. It bombs like the image below. /cc @thdxr @rekram1-node <img width="1739" height="808" alt="Image" src="https://github.com/user-attachments/assets/3552349a-bad5-440f-b2f0-ea98ea9366eb" />
Author
Owner

@wolfie82 commented on GitHub (Nov 19, 2025):

This bug also appears to impact compaction. Compaction switches to build agent, doesn't return to the agent that initiated. Build agent tries to run a tool it doesn't have access to and bombs.

@wolfie82 commented on GitHub (Nov 19, 2025): This bug also appears to impact compaction. Compaction switches to build agent, doesn't return to the agent that initiated. Build agent tries to run a tool it doesn't have access to and bombs.
Author
Owner

@omaclaren commented on GitHub (Nov 19, 2025):

yeah, seems to fix the issue in build mode but if in plan mode it switches to build agent when using noReply

@omaclaren commented on GitHub (Nov 19, 2025): yeah, seems to fix the issue in build mode but if in plan mode it switches to build agent when using noReply
Author
Owner

@malhashemi commented on GitHub (Dec 6, 2025):

Hi @omaclaren maintainer of the plugin here, the default behavior was to not provide a target agetn, as you have already discovered, now the messages are going to the build agent if non is provided. I released an update to the plugin and now it explicitly pass the message to the the agent that called the tool, it should be all fixed now at least on the plugin end. Please feel free to open an issue on the plugin repo if the issue still persists, make sure you are on the latest version

@malhashemi commented on GitHub (Dec 6, 2025): Hi @omaclaren maintainer of the plugin here, the default behavior was to not provide a target agetn, as you have already discovered, now the messages are going to the build agent if non is provided. I released an update to the plugin and now it explicitly pass the message to the the agent that called the tool, it should be all fixed now at least on the plugin end. Please feel free to open an issue on the plugin repo if the issue still persists, make sure you are on the latest version
Author
Owner

@omaclaren commented on GitHub (Dec 7, 2025):

thanks @malhashemi -- I think this was a more general issue with opencode itself as well, but looks like there's lots of activity fixing it!

@omaclaren commented on GitHub (Dec 7, 2025): thanks @malhashemi -- I think this was a more general issue with opencode itself as well, but looks like there's lots of activity fixing it!
Author
Owner

@arsham commented on GitHub (Dec 7, 2025):

I've used this patch to fix the issue locally:

diff --git a/packages/opencode/src/session/index.ts b/packages/opencode/src/session/index.ts
index b8b7af742..d7498098b 100644
--- a/packages/opencode/src/session/index.ts
+++ b/packages/opencode/src/session/index.ts
@@ -54,6 +54,8 @@ export namespace Session {
         })
         .optional(),
       title: z.string(),
+      currentAgent: z.string().optional(),
+      agent: z.string().optional(),
       version: z.string(),
       time: z.object({
         created: z.number(),
diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts
index d5010bc47..0088d9336 100644
--- a/packages/opencode/src/session/prompt.ts
+++ b/packages/opencode/src/session/prompt.ts
@@ -242,7 +242,13 @@ export namespace SessionPrompt {
       })
     }
 
-    using _ = defer(() => cancel(sessionID))
+    using _ = defer(() => {
+      cancel(sessionID)
+      // Clear currentAgent when loop completes
+      Session.update(sessionID, (draft) => {
+        draft.currentAgent = undefined
+      }).catch(() => {})
+    })
 
     let step = 0
     while (true) {
@@ -437,6 +443,13 @@ export namespace SessionPrompt {
       // normal processing
       const cfg = await Config.get()
       const agent = await Agent.get(lastUser.agent)
+      // Track current agent in session state (only if changed)
+      const session = await Session.get(sessionID)
+      if (session.currentAgent !== agent.name) {
+        await Session.update(sessionID, (draft) => {
+          draft.currentAgent = agent.name
+        })
+      }
       const maxSteps = agent.maxSteps ?? Infinity
       const isLastStep = step >= maxSteps
       msgs = insertReminders({
@@ -825,7 +838,20 @@ export namespace SessionPrompt {
   }
 
   async function createUserMessage(input: PromptInput) {
-    const agent = await Agent.get(input.agent ?? "build")
+    const session = await Session.get(input.sessionID)
+
+    // If no agent specified, infer from last assistant message's mode
+    let agentName = input.agent
+    if (!agentName) {
+      const msgs = await MessageV2.filterCompacted(MessageV2.stream(input.sessionID))
+      const lastAssistant = msgs.findLast((m) => m.info.role === "assistant") as MessageV2.Assistant | undefined
+      // Updated fallback chain - add session.currentAgent before session.agent
+      agentName = lastAssistant?.mode ?? session.currentAgent ?? session.agent ?? "general"
+    }
+
+    const agent = (await Agent.get(agentName)) || (await Agent.get("general"))
+    if (!agent) throw new Error(`Agent not found: ${agentName}`)
+
     const info: MessageV2.Info = {
       id: input.messageID ?? Identifier.ascending("message"),
       role: "user",
@@ -937,7 +963,7 @@ export namespace SessionPrompt {
                     const result = await t.execute(args, {
                       sessionID: input.sessionID,
                       abort: new AbortController().signal,
-                      agent: input.agent!,
+                      agent: agent.name,
                       messageID: info.id,
                       extra: { bypassCwdCheck: true, model },
                       metadata: async () => {},
@@ -997,7 +1023,7 @@ export namespace SessionPrompt {
                   t.execute(args, {
                     sessionID: input.sessionID,
                     abort: new AbortController().signal,
-                    agent: input.agent!,
+                    agent: agent.name,
                     messageID: info.id,
                     extra: { bypassCwdCheck: true },
                     metadata: async () => {},

So far it is working well, but I will test it for a couple of days and if things go smoothly I will make a PR. I appreciate it if you try this patch as well and let me know if works for you too.

@arsham commented on GitHub (Dec 7, 2025): I've used this patch to fix the issue locally: ```patch diff --git a/packages/opencode/src/session/index.ts b/packages/opencode/src/session/index.ts index b8b7af742..d7498098b 100644 --- a/packages/opencode/src/session/index.ts +++ b/packages/opencode/src/session/index.ts @@ -54,6 +54,8 @@ export namespace Session { }) .optional(), title: z.string(), + currentAgent: z.string().optional(), + agent: z.string().optional(), version: z.string(), time: z.object({ created: z.number(), diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index d5010bc47..0088d9336 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -242,7 +242,13 @@ export namespace SessionPrompt { }) } - using _ = defer(() => cancel(sessionID)) + using _ = defer(() => { + cancel(sessionID) + // Clear currentAgent when loop completes + Session.update(sessionID, (draft) => { + draft.currentAgent = undefined + }).catch(() => {}) + }) let step = 0 while (true) { @@ -437,6 +443,13 @@ export namespace SessionPrompt { // normal processing const cfg = await Config.get() const agent = await Agent.get(lastUser.agent) + // Track current agent in session state (only if changed) + const session = await Session.get(sessionID) + if (session.currentAgent !== agent.name) { + await Session.update(sessionID, (draft) => { + draft.currentAgent = agent.name + }) + } const maxSteps = agent.maxSteps ?? Infinity const isLastStep = step >= maxSteps msgs = insertReminders({ @@ -825,7 +838,20 @@ export namespace SessionPrompt { } async function createUserMessage(input: PromptInput) { - const agent = await Agent.get(input.agent ?? "build") + const session = await Session.get(input.sessionID) + + // If no agent specified, infer from last assistant message's mode + let agentName = input.agent + if (!agentName) { + const msgs = await MessageV2.filterCompacted(MessageV2.stream(input.sessionID)) + const lastAssistant = msgs.findLast((m) => m.info.role === "assistant") as MessageV2.Assistant | undefined + // Updated fallback chain - add session.currentAgent before session.agent + agentName = lastAssistant?.mode ?? session.currentAgent ?? session.agent ?? "general" + } + + const agent = (await Agent.get(agentName)) || (await Agent.get("general")) + if (!agent) throw new Error(`Agent not found: ${agentName}`) + const info: MessageV2.Info = { id: input.messageID ?? Identifier.ascending("message"), role: "user", @@ -937,7 +963,7 @@ export namespace SessionPrompt { const result = await t.execute(args, { sessionID: input.sessionID, abort: new AbortController().signal, - agent: input.agent!, + agent: agent.name, messageID: info.id, extra: { bypassCwdCheck: true, model }, metadata: async () => {}, @@ -997,7 +1023,7 @@ export namespace SessionPrompt { t.execute(args, { sessionID: input.sessionID, abort: new AbortController().signal, - agent: input.agent!, + agent: agent.name, messageID: info.id, extra: { bypassCwdCheck: true }, metadata: async () => {}, ``` So far it is working well, but I will test it for a couple of days and if things go smoothly I will make a PR. I appreciate it if you try this patch as well and let me know if works for you too.
Author
Owner

@binajmen commented on GitHub (Dec 12, 2025):

god bless you @arsham, I am currently wasting premium requests because of this issue right now! I will see how i can apply the patch to opencode as it updates automatically nearly every day ;D

@binajmen commented on GitHub (Dec 12, 2025): god bless you @arsham, I am currently wasting premium requests because of this issue right now! I will see how i can apply the patch to opencode as it updates automatically nearly every day ;D
Author
Owner

@arsham commented on GitHub (Dec 12, 2025):

Just to report: I haven't had a single incident after this patch on top of #4622 PR.

@arsham commented on GitHub (Dec 12, 2025): Just to report: I haven't had a single incident after this patch on top of #4622 PR.
Author
Owner

@Nindaleth commented on GitHub (Dec 18, 2025):

@arsham Would you be willing to send your patch as an independent PR so it can be reviewed by the maintainers too?

@Nindaleth commented on GitHub (Dec 18, 2025): @arsham Would you be willing to send your patch as an independent PR so it can be reviewed by the maintainers too?
Author
Owner

@arsham commented on GitHub (Dec 18, 2025):

@Nindaleth I have updated the PR. I think they both are related. I could create a separate PR but it requires testing to see if the patch itself is the fix.

@arsham commented on GitHub (Dec 18, 2025): @Nindaleth I have updated the PR. I think they both are related. I could create a separate PR but it requires testing to see if the patch itself is the fix.
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: anomalyco/opencode#2957