From ed32b280cd7611f4cc0675a0cbf9f002506539ba Mon Sep 17 00:00:00 2001 From: "Vicary A." Date: Fri, 13 Mar 2026 01:12:03 +0800 Subject: [PATCH 1/3] fix(session): skip synthetic delegation nudge in subagent sessions When a user message contains an @agent part, createUserMessage injects a synthetic text part that tells the model to call the task tool with that subagent. In a child/subagent session the orchestrator has already written an explicit task prompt, so injecting the nudge again causes recursive nesting bias: the subagent re-reads the @agent mention in its own context and tries to spawn another layer of delegation. Guard the injection behind a parentID check so only root sessions receive the synthetic delegation instruction. Fixes #17202 --- packages/opencode/src/session/prompt.ts | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 171c4b448fd..59a3e867793 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -1267,15 +1267,24 @@ export namespace SessionPrompt { } if (part.type === "agent") { + const agentPart = { + ...part, + messageID: info.id, + sessionID: input.sessionID, + } + // Only inject the delegation nudge in root sessions. Subagent sessions + // already have an explicit task prompt; injecting it there causes + // recursive nesting bias when the agent sees @agent mentions in its + // own context. + const currentSession = await Session.get(input.sessionID) + if (currentSession.parentID) { + return [agentPart] + } // Check if this agent would be denied by task permission const perm = PermissionNext.evaluate("task", part.name, agent.permission) const hint = perm.action === "deny" ? " . Invoked by user; guaranteed to exist." : "" return [ - { - ...part, - messageID: info.id, - sessionID: input.sessionID, - }, + agentPart, { messageID: info.id, sessionID: input.sessionID, From a7ae97df44d4c2b40aa6386020e8446a4f311b76 Mon Sep 17 00:00:00 2001 From: "Vicary A." Date: Fri, 13 Mar 2026 01:18:02 +0800 Subject: [PATCH 2/3] test(session): cover agent-part delegation nudge in root vs subagent sessions --- packages/opencode/test/session/prompt.test.ts | 263 ++++++++++++------ 1 file changed, 185 insertions(+), 78 deletions(-) diff --git a/packages/opencode/test/session/prompt.test.ts b/packages/opencode/test/session/prompt.test.ts index 3986271dab9..f3e3a59bf35 100644 --- a/packages/opencode/test/session/prompt.test.ts +++ b/packages/opencode/test/session/prompt.test.ts @@ -1,15 +1,15 @@ -import path from "path" -import { describe, expect, test } from "bun:test" -import { fileURLToPath } from "url" -import { Instance } from "../../src/project/instance" -import { ModelID, ProviderID } from "../../src/provider/schema" -import { Session } from "../../src/session" -import { MessageV2 } from "../../src/session/message-v2" -import { SessionPrompt } from "../../src/session/prompt" -import { Log } from "../../src/util/log" -import { tmpdir } from "../fixture/fixture" - -Log.init({ print: false }) +import path from "path"; +import { describe, expect, test } from "bun:test"; +import { fileURLToPath } from "url"; +import { Instance } from "../../src/project/instance"; +import { ModelID, ProviderID } from "../../src/provider/schema"; +import { Session } from "../../src/session"; +import { MessageV2 } from "../../src/session/message-v2"; +import { SessionPrompt } from "../../src/session/prompt"; +import { Log } from "../../src/util/log"; +import { tmpdir } from "../fixture/fixture"; + +Log.init({ print: false }); describe("session.prompt missing file", () => { test("does not fail the prompt when a file part is missing", async () => { @@ -22,14 +22,14 @@ describe("session.prompt missing file", () => { }, }, }, - }) + }); await Instance.provide({ directory: tmp.path, fn: async () => { - const session = await Session.create({}) + const session = await Session.create({}); - const missing = path.join(tmp.path, "does-not-exist.ts") + const missing = path.join(tmp.path, "does-not-exist.ts"); const msg = await SessionPrompt.prompt({ sessionID: session.id, agent: "build", @@ -43,19 +43,21 @@ describe("session.prompt missing file", () => { filename: "does-not-exist.ts", }, ], - }) + }); - if (msg.info.role !== "user") throw new Error("expected user message") + if (msg.info.role !== "user") throw new Error("expected user message"); const hasFailure = msg.parts.some( - (part) => part.type === "text" && part.synthetic && part.text.includes("Read tool failed to read"), - ) - expect(hasFailure).toBe(true) + (part) => + part.type === "text" && part.synthetic && + part.text.includes("Read tool failed to read"), + ); + expect(hasFailure).toBe(true); - await Session.remove(session.id) + await Session.remove(session.id); }, - }) - }) + }); + }); test("keeps stored part order stable when file resolution is async", async () => { await using tmp = await tmpdir({ @@ -67,14 +69,14 @@ describe("session.prompt missing file", () => { }, }, }, - }) + }); await Instance.provide({ directory: tmp.path, fn: async () => { - const session = await Session.create({}) + const session = await Session.create({}); - const missing = path.join(tmp.path, "still-missing.ts") + const missing = path.join(tmp.path, "still-missing.ts"); const msg = await SessionPrompt.prompt({ sessionID: session.id, agent: "build", @@ -88,70 +90,163 @@ describe("session.prompt missing file", () => { }, { type: "text", text: "after-file" }, ], - }) + }); - if (msg.info.role !== "user") throw new Error("expected user message") + if (msg.info.role !== "user") throw new Error("expected user message"); const stored = await MessageV2.get({ sessionID: session.id, messageID: msg.info.id, - }) - const text = stored.parts.filter((part) => part.type === "text").map((part) => part.text) + }); + const text = stored.parts.filter((part) => part.type === "text").map(( + part, + ) => part.text); - expect(text[0]?.startsWith("Called the Read tool with the following input:")).toBe(true) - expect(text[1]?.includes("Read tool failed to read")).toBe(true) - expect(text[2]).toBe("after-file") + expect( + text[0]?.startsWith("Called the Read tool with the following input:"), + ).toBe(true); + expect(text[1]?.includes("Read tool failed to read")).toBe(true); + expect(text[2]).toBe("after-file"); - await Session.remove(session.id) + await Session.remove(session.id); }, - }) - }) -}) + }); + }); +}); describe("session.prompt special characters", () => { test("handles filenames with # character", async () => { await using tmp = await tmpdir({ git: true, init: async (dir) => { - await Bun.write(path.join(dir, "file#name.txt"), "special content\n") + await Bun.write(path.join(dir, "file#name.txt"), "special content\n"); }, - }) + }); await Instance.provide({ directory: tmp.path, fn: async () => { - const session = await Session.create({}) - const template = "Read @file#name.txt" - const parts = await SessionPrompt.resolvePromptParts(template) - const fileParts = parts.filter((part) => part.type === "file") + const session = await Session.create({}); + const template = "Read @file#name.txt"; + const parts = await SessionPrompt.resolvePromptParts(template); + const fileParts = parts.filter((part) => part.type === "file"); - expect(fileParts.length).toBe(1) - expect(fileParts[0].filename).toBe("file#name.txt") - expect(fileParts[0].url).toContain("%23") + expect(fileParts.length).toBe(1); + expect(fileParts[0].filename).toBe("file#name.txt"); + expect(fileParts[0].url).toContain("%23"); - const decodedPath = fileURLToPath(fileParts[0].url) - expect(decodedPath).toBe(path.join(tmp.path, "file#name.txt")) + const decodedPath = fileURLToPath(fileParts[0].url); + expect(decodedPath).toBe(path.join(tmp.path, "file#name.txt")); const message = await SessionPrompt.prompt({ sessionID: session.id, parts, noReply: true, - }) - const stored = await MessageV2.get({ sessionID: session.id, messageID: message.info.id }) - const textParts = stored.parts.filter((part) => part.type === "text") - const hasContent = textParts.some((part) => part.text.includes("special content")) - expect(hasContent).toBe(true) + }); + const stored = await MessageV2.get({ + sessionID: session.id, + messageID: message.info.id, + }); + const textParts = stored.parts.filter((part) => part.type === "text"); + const hasContent = textParts.some((part) => + part.text.includes("special content") + ); + expect(hasContent).toBe(true); - await Session.remove(session.id) + await Session.remove(session.id); }, - }) - }) -}) + }); + }); +}); + +describe("session.prompt agent part", () => { + test("injects synthetic delegation nudge in root sessions", async () => { + await using tmp = await tmpdir({ git: true }); + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const session = await Session.create({}); + + const msg = await SessionPrompt.prompt({ + sessionID: session.id, + noReply: true, + parts: [ + { type: "text", text: "do something" }, + { type: "agent", name: "build" }, + ], + }); + + if (msg.info.role !== "user") throw new Error("expected user message"); + + const stored = await MessageV2.get({ + sessionID: session.id, + messageID: msg.info.id, + }); + const syntheticDelegation = stored.parts.find( + (part) => + part.type === "text" && + part.synthetic && + part.text.includes( + "Use the above message and context to generate a prompt and call the task tool with subagent:", + ), + ); + expect(syntheticDelegation).toBeDefined(); + + await Session.remove(session.id); + }, + }); + }); + + test("skips synthetic delegation nudge in subagent sessions", async () => { + await using tmp = await tmpdir({ git: true }); + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const root = await Session.create({}); + const child = await Session.create({ parentID: root.id }); + + const msg = await SessionPrompt.prompt({ + sessionID: child.id, + noReply: true, + parts: [ + { type: "text", text: "do something" }, + { type: "agent", name: "build" }, + ], + }); + + if (msg.info.role !== "user") throw new Error("expected user message"); + + const stored = await MessageV2.get({ + sessionID: child.id, + messageID: msg.info.id, + }); + const syntheticDelegation = stored.parts.find( + (part) => + part.type === "text" && + part.synthetic && + part.text.includes( + "Use the above message and context to generate a prompt and call the task tool with subagent:", + ), + ); + expect(syntheticDelegation).toBeUndefined(); + + // The agent part itself should still be present + const agentPart = stored.parts.find((part) => part.type === "agent"); + expect(agentPart).toBeDefined(); + + await Session.remove(child.id); + await Session.remove(root.id); + }, + }); + }); +}); describe("session.prompt agent variant", () => { test("applies agent variant only when using agent model", async () => { - const prev = process.env.OPENAI_API_KEY - process.env.OPENAI_API_KEY = "test-openai-key" + const prev = process.env.OPENAI_API_KEY; + process.env.OPENAI_API_KEY = "test-openai-key"; try { await using tmp = await tmpdir({ @@ -164,32 +259,42 @@ describe("session.prompt agent variant", () => { }, }, }, - }) + }); await Instance.provide({ directory: tmp.path, fn: async () => { - const session = await Session.create({}) + const session = await Session.create({}); const other = await SessionPrompt.prompt({ sessionID: session.id, agent: "build", - model: { providerID: ProviderID.make("opencode"), modelID: ModelID.make("kimi-k2.5-free") }, + model: { + providerID: ProviderID.make("opencode"), + modelID: ModelID.make("kimi-k2.5-free"), + }, noReply: true, parts: [{ type: "text", text: "hello" }], - }) - if (other.info.role !== "user") throw new Error("expected user message") - expect(other.info.variant).toBeUndefined() + }); + if (other.info.role !== "user") { + throw new Error("expected user message"); + } + expect(other.info.variant).toBeUndefined(); const match = await SessionPrompt.prompt({ sessionID: session.id, agent: "build", noReply: true, parts: [{ type: "text", text: "hello again" }], - }) - if (match.info.role !== "user") throw new Error("expected user message") - expect(match.info.model).toEqual({ providerID: ProviderID.make("openai"), modelID: ModelID.make("gpt-5.2") }) - expect(match.info.variant).toBe("xhigh") + }); + if (match.info.role !== "user") { + throw new Error("expected user message"); + } + expect(match.info.model).toEqual({ + providerID: ProviderID.make("openai"), + modelID: ModelID.make("gpt-5.2"), + }); + expect(match.info.variant).toBe("xhigh"); const override = await SessionPrompt.prompt({ sessionID: session.id, @@ -197,16 +302,18 @@ describe("session.prompt agent variant", () => { noReply: true, variant: "high", parts: [{ type: "text", text: "hello third" }], - }) - if (override.info.role !== "user") throw new Error("expected user message") - expect(override.info.variant).toBe("high") + }); + if (override.info.role !== "user") { + throw new Error("expected user message"); + } + expect(override.info.variant).toBe("high"); - await Session.remove(session.id) + await Session.remove(session.id); }, - }) + }); } finally { - if (prev === undefined) delete process.env.OPENAI_API_KEY - else process.env.OPENAI_API_KEY = prev + if (prev === undefined) delete process.env.OPENAI_API_KEY; + else process.env.OPENAI_API_KEY = prev; } - }) -}) + }); +}); From f76ead94c3d0a49be3c1f78216aaa951cf692f8a Mon Sep 17 00:00:00 2001 From: "Vicary A." Date: Fri, 13 Mar 2026 01:22:47 +0800 Subject: [PATCH 3/3] test(session): minimize prompt agent-part coverage diff --- packages/opencode/test/session/prompt.test.ts | 198 ++++++++---------- 1 file changed, 86 insertions(+), 112 deletions(-) diff --git a/packages/opencode/test/session/prompt.test.ts b/packages/opencode/test/session/prompt.test.ts index f3e3a59bf35..b36cfaea8db 100644 --- a/packages/opencode/test/session/prompt.test.ts +++ b/packages/opencode/test/session/prompt.test.ts @@ -1,15 +1,15 @@ -import path from "path"; -import { describe, expect, test } from "bun:test"; -import { fileURLToPath } from "url"; -import { Instance } from "../../src/project/instance"; -import { ModelID, ProviderID } from "../../src/provider/schema"; -import { Session } from "../../src/session"; -import { MessageV2 } from "../../src/session/message-v2"; -import { SessionPrompt } from "../../src/session/prompt"; -import { Log } from "../../src/util/log"; -import { tmpdir } from "../fixture/fixture"; - -Log.init({ print: false }); +import path from "path" +import { describe, expect, test } from "bun:test" +import { fileURLToPath } from "url" +import { Instance } from "../../src/project/instance" +import { ModelID, ProviderID } from "../../src/provider/schema" +import { Session } from "../../src/session" +import { MessageV2 } from "../../src/session/message-v2" +import { SessionPrompt } from "../../src/session/prompt" +import { Log } from "../../src/util/log" +import { tmpdir } from "../fixture/fixture" + +Log.init({ print: false }) describe("session.prompt missing file", () => { test("does not fail the prompt when a file part is missing", async () => { @@ -22,14 +22,14 @@ describe("session.prompt missing file", () => { }, }, }, - }); + }) await Instance.provide({ directory: tmp.path, fn: async () => { - const session = await Session.create({}); + const session = await Session.create({}) - const missing = path.join(tmp.path, "does-not-exist.ts"); + const missing = path.join(tmp.path, "does-not-exist.ts") const msg = await SessionPrompt.prompt({ sessionID: session.id, agent: "build", @@ -43,21 +43,19 @@ describe("session.prompt missing file", () => { filename: "does-not-exist.ts", }, ], - }); + }) - if (msg.info.role !== "user") throw new Error("expected user message"); + if (msg.info.role !== "user") throw new Error("expected user message") const hasFailure = msg.parts.some( - (part) => - part.type === "text" && part.synthetic && - part.text.includes("Read tool failed to read"), - ); - expect(hasFailure).toBe(true); + (part) => part.type === "text" && part.synthetic && part.text.includes("Read tool failed to read"), + ) + expect(hasFailure).toBe(true) - await Session.remove(session.id); + await Session.remove(session.id) }, - }); - }); + }) + }) test("keeps stored part order stable when file resolution is async", async () => { await using tmp = await tmpdir({ @@ -69,14 +67,14 @@ describe("session.prompt missing file", () => { }, }, }, - }); + }) await Instance.provide({ directory: tmp.path, fn: async () => { - const session = await Session.create({}); + const session = await Session.create({}) - const missing = path.join(tmp.path, "still-missing.ts"); + const missing = path.join(tmp.path, "still-missing.ts") const msg = await SessionPrompt.prompt({ sessionID: session.id, agent: "build", @@ -90,76 +88,71 @@ describe("session.prompt missing file", () => { }, { type: "text", text: "after-file" }, ], - }); + }) - if (msg.info.role !== "user") throw new Error("expected user message"); + if (msg.info.role !== "user") throw new Error("expected user message") const stored = await MessageV2.get({ sessionID: session.id, messageID: msg.info.id, - }); - const text = stored.parts.filter((part) => part.type === "text").map(( - part, - ) => part.text); + }) + const text = stored.parts.filter((part) => part.type === "text").map((part) => part.text) - expect( - text[0]?.startsWith("Called the Read tool with the following input:"), - ).toBe(true); - expect(text[1]?.includes("Read tool failed to read")).toBe(true); - expect(text[2]).toBe("after-file"); + expect(text[0]?.startsWith("Called the Read tool with the following input:")).toBe(true) + expect(text[1]?.includes("Read tool failed to read")).toBe(true) + expect(text[2]).toBe("after-file") - await Session.remove(session.id); + await Session.remove(session.id) }, - }); - }); -}); + }) + }) +}) describe("session.prompt special characters", () => { test("handles filenames with # character", async () => { await using tmp = await tmpdir({ git: true, init: async (dir) => { - await Bun.write(path.join(dir, "file#name.txt"), "special content\n"); + await Bun.write(path.join(dir, "file#name.txt"), "special content\n") }, - }); + }) await Instance.provide({ directory: tmp.path, fn: async () => { - const session = await Session.create({}); - const template = "Read @file#name.txt"; - const parts = await SessionPrompt.resolvePromptParts(template); - const fileParts = parts.filter((part) => part.type === "file"); + const session = await Session.create({}) + const template = "Read @file#name.txt" + const parts = await SessionPrompt.resolvePromptParts(template) + const fileParts = parts.filter((part) => part.type === "file") - expect(fileParts.length).toBe(1); - expect(fileParts[0].filename).toBe("file#name.txt"); - expect(fileParts[0].url).toContain("%23"); + expect(fileParts.length).toBe(1) + expect(fileParts[0].filename).toBe("file#name.txt") + expect(fileParts[0].url).toContain("%23") - const decodedPath = fileURLToPath(fileParts[0].url); - expect(decodedPath).toBe(path.join(tmp.path, "file#name.txt")); + const decodedPath = fileURLToPath(fileParts[0].url) + expect(decodedPath).toBe(path.join(tmp.path, "file#name.txt")) const message = await SessionPrompt.prompt({ sessionID: session.id, parts, noReply: true, - }); - const stored = await MessageV2.get({ - sessionID: session.id, - messageID: message.info.id, - }); - const textParts = stored.parts.filter((part) => part.type === "text"); - const hasContent = textParts.some((part) => - part.text.includes("special content") - ); - expect(hasContent).toBe(true); + }) + const stored = await MessageV2.get({ sessionID: session.id, messageID: message.info.id }) + const textParts = stored.parts.filter((part) => part.type === "text") + const hasContent = textParts.some((part) => part.text.includes("special content")) + expect(hasContent).toBe(true) - await Session.remove(session.id); + await Session.remove(session.id) }, - }); - }); -}); + }) + }) +}) + describe("session.prompt agent part", () => { + const delegationNudge = + "Use the above message and context to generate a prompt and call the task tool with subagent:"; + test("injects synthetic delegation nudge in root sessions", async () => { await using tmp = await tmpdir({ git: true }); @@ -185,11 +178,8 @@ describe("session.prompt agent part", () => { }); const syntheticDelegation = stored.parts.find( (part) => - part.type === "text" && - part.synthetic && - part.text.includes( - "Use the above message and context to generate a prompt and call the task tool with subagent:", - ), + part.type === "text" && part.synthetic && + part.text.includes(delegationNudge), ); expect(syntheticDelegation).toBeDefined(); @@ -224,15 +214,11 @@ describe("session.prompt agent part", () => { }); const syntheticDelegation = stored.parts.find( (part) => - part.type === "text" && - part.synthetic && - part.text.includes( - "Use the above message and context to generate a prompt and call the task tool with subagent:", - ), + part.type === "text" && part.synthetic && + part.text.includes(delegationNudge), ); expect(syntheticDelegation).toBeUndefined(); - // The agent part itself should still be present const agentPart = stored.parts.find((part) => part.type === "agent"); expect(agentPart).toBeDefined(); @@ -245,8 +231,8 @@ describe("session.prompt agent part", () => { describe("session.prompt agent variant", () => { test("applies agent variant only when using agent model", async () => { - const prev = process.env.OPENAI_API_KEY; - process.env.OPENAI_API_KEY = "test-openai-key"; + const prev = process.env.OPENAI_API_KEY + process.env.OPENAI_API_KEY = "test-openai-key" try { await using tmp = await tmpdir({ @@ -259,42 +245,32 @@ describe("session.prompt agent variant", () => { }, }, }, - }); + }) await Instance.provide({ directory: tmp.path, fn: async () => { - const session = await Session.create({}); + const session = await Session.create({}) const other = await SessionPrompt.prompt({ sessionID: session.id, agent: "build", - model: { - providerID: ProviderID.make("opencode"), - modelID: ModelID.make("kimi-k2.5-free"), - }, + model: { providerID: ProviderID.make("opencode"), modelID: ModelID.make("kimi-k2.5-free") }, noReply: true, parts: [{ type: "text", text: "hello" }], - }); - if (other.info.role !== "user") { - throw new Error("expected user message"); - } - expect(other.info.variant).toBeUndefined(); + }) + if (other.info.role !== "user") throw new Error("expected user message") + expect(other.info.variant).toBeUndefined() const match = await SessionPrompt.prompt({ sessionID: session.id, agent: "build", noReply: true, parts: [{ type: "text", text: "hello again" }], - }); - if (match.info.role !== "user") { - throw new Error("expected user message"); - } - expect(match.info.model).toEqual({ - providerID: ProviderID.make("openai"), - modelID: ModelID.make("gpt-5.2"), - }); - expect(match.info.variant).toBe("xhigh"); + }) + if (match.info.role !== "user") throw new Error("expected user message") + expect(match.info.model).toEqual({ providerID: ProviderID.make("openai"), modelID: ModelID.make("gpt-5.2") }) + expect(match.info.variant).toBe("xhigh") const override = await SessionPrompt.prompt({ sessionID: session.id, @@ -302,18 +278,16 @@ describe("session.prompt agent variant", () => { noReply: true, variant: "high", parts: [{ type: "text", text: "hello third" }], - }); - if (override.info.role !== "user") { - throw new Error("expected user message"); - } - expect(override.info.variant).toBe("high"); + }) + if (override.info.role !== "user") throw new Error("expected user message") + expect(override.info.variant).toBe("high") - await Session.remove(session.id); + await Session.remove(session.id) }, - }); + }) } finally { - if (prev === undefined) delete process.env.OPENAI_API_KEY; - else process.env.OPENAI_API_KEY = prev; + if (prev === undefined) delete process.env.OPENAI_API_KEY + else process.env.OPENAI_API_KEY = prev } - }); -}); + }) +})