From 64e8e164aa8602a28bf5219a19d2c4ab1cc0e123 Mon Sep 17 00:00:00 2001 From: Maxim Harizanov Date: Wed, 18 Feb 2026 19:55:36 +0200 Subject: [PATCH] fix(copilot): mark internal background notifications as agent-initiated --- src/features/background-agent/manager.ts | 3 +- .../parent-session-notifier.ts | 4 +- src/plugin-interface.ts | 5 +- src/plugin/chat-headers.test.ts | 72 ++++++++++++++++ src/plugin/chat-headers.ts | 85 +++++++++++++++++++ src/shared/index.ts | 1 + src/shared/internal-initiator-marker.ts | 13 +++ 7 files changed, 179 insertions(+), 4 deletions(-) create mode 100644 src/plugin/chat-headers.test.ts create mode 100644 src/plugin/chat-headers.ts create mode 100644 src/shared/internal-initiator-marker.ts diff --git a/src/features/background-agent/manager.ts b/src/features/background-agent/manager.ts index 80a65ee5..4d0682e3 100644 --- a/src/features/background-agent/manager.ts +++ b/src/features/background-agent/manager.ts @@ -13,6 +13,7 @@ import { normalizeSDKResponse, promptWithModelSuggestionRetry, resolveInheritedPromptTools, + createInternalAgentTextPart, } from "../../shared" import { setSessionTools } from "../../shared/session-tools-store" import { ConcurrencyManager } from "./concurrency" @@ -1311,7 +1312,7 @@ Use \`background_output(task_id="${task.id}")\` to retrieve this result when rea ...(agent !== undefined ? { agent } : {}), ...(model !== undefined ? { model } : {}), ...(tools ? { tools } : {}), - parts: [{ type: "text", text: notification }], + parts: [createInternalAgentTextPart(notification)], }, }) log("[background-agent] Sent notification to parent session:", { diff --git a/src/features/background-agent/parent-session-notifier.ts b/src/features/background-agent/parent-session-notifier.ts index e94674d3..5116888d 100644 --- a/src/features/background-agent/parent-session-notifier.ts +++ b/src/features/background-agent/parent-session-notifier.ts @@ -1,7 +1,7 @@ import type { BackgroundTask } from "./types" import type { ResultHandlerContext } from "./result-handler-context" import { TASK_CLEANUP_DELAY_MS } from "./constants" -import { log } from "../../shared" +import { createInternalAgentTextPart, log } from "../../shared" import { getTaskToastManager } from "../task-toast-manager" import { formatDuration } from "./duration-formatter" import { buildBackgroundTaskNotificationText } from "./background-task-notification-template" @@ -72,7 +72,7 @@ export async function notifyParentSession( ...(agent !== undefined ? { agent } : {}), ...(model !== undefined ? { model } : {}), ...(tools ? { tools } : {}), - parts: [{ type: "text", text: notification }], + parts: [createInternalAgentTextPart(notification)], }, }) diff --git a/src/plugin-interface.ts b/src/plugin-interface.ts index 0bc24dda..19d5b600 100644 --- a/src/plugin-interface.ts +++ b/src/plugin-interface.ts @@ -2,6 +2,7 @@ import type { PluginContext, PluginInterface, ToolsRecord } from "./plugin/types import type { OhMyOpenCodeConfig } from "./config" import { createChatParamsHandler } from "./plugin/chat-params" +import { createChatHeadersHandler } from "./plugin/chat-headers" import { createChatMessageHandler } from "./plugin/chat-message" import { createMessagesTransformHandler } from "./plugin/messages-transform" import { createEventHandler } from "./plugin/event" @@ -30,11 +31,13 @@ export function createPluginInterface(args: { return { tool: tools, - "chat.params": async (input, output) => { + "chat.params": async (input: unknown, output: unknown) => { const handler = createChatParamsHandler({ anthropicEffort: hooks.anthropicEffort }) await handler(input, output) }, + "chat.headers": createChatHeadersHandler(), + "chat.message": createChatMessageHandler({ ctx, pluginConfig, diff --git a/src/plugin/chat-headers.test.ts b/src/plugin/chat-headers.test.ts new file mode 100644 index 00000000..1114f4fb --- /dev/null +++ b/src/plugin/chat-headers.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, test } from "bun:test" + +import { OMO_INTERNAL_INITIATOR_MARKER } from "../shared" +import { createChatHeadersHandler } from "./chat-headers" + +describe("createChatHeadersHandler", () => { + test("sets x-initiator=agent for Copilot internal synthetic marker messages", async () => { + const handler = createChatHeadersHandler() + const output: { headers: Record } = { headers: {} } + + await handler( + { + provider: { id: "github-copilot" }, + message: { + info: { role: "user" }, + parts: [ + { + type: "text", + text: `notification\n${OMO_INTERNAL_INITIATOR_MARKER}`, + synthetic: true, + }, + ], + }, + }, + output, + ) + + expect(output.headers["x-initiator"]).toBe("agent") + }) + + test("does not override non-copilot providers", async () => { + const handler = createChatHeadersHandler() + const output: { headers: Record } = { headers: {} } + + await handler( + { + provider: { id: "openai" }, + message: { + info: { role: "user" }, + parts: [ + { + type: "text", + text: `notification\n${OMO_INTERNAL_INITIATOR_MARKER}`, + synthetic: true, + }, + ], + }, + }, + output, + ) + + expect(output.headers["x-initiator"]).toBeUndefined() + }) + + test("does not override regular user messages", async () => { + const handler = createChatHeadersHandler() + const output: { headers: Record } = { headers: {} } + + await handler( + { + provider: { id: "github-copilot" }, + message: { + info: { role: "user" }, + parts: [{ type: "text", text: "normal user message" }], + }, + }, + output, + ) + + expect(output.headers["x-initiator"]).toBeUndefined() + }) +}) diff --git a/src/plugin/chat-headers.ts b/src/plugin/chat-headers.ts new file mode 100644 index 00000000..4e2a4225 --- /dev/null +++ b/src/plugin/chat-headers.ts @@ -0,0 +1,85 @@ +import { OMO_INTERNAL_INITIATOR_MARKER } from "../shared" + +type ChatHeadersInput = { + provider: { id: string } + message: { + info?: { role?: string } + parts?: Array<{ type?: string; text?: string; synthetic?: boolean }> + } +} + +type ChatHeadersOutput = { + headers: Record +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null +} + +function buildChatHeadersInput(raw: unknown): ChatHeadersInput | null { + if (!isRecord(raw)) return null + + const provider = raw.provider + const message = raw.message + + if (!isRecord(provider) || typeof provider.id !== "string") return null + if (!isRecord(message)) return null + + const info = isRecord(message.info) ? message.info : undefined + const rawParts = Array.isArray(message.parts) ? message.parts : undefined + + const parts = rawParts + ?.filter(isRecord) + .map((part) => ({ + type: typeof part.type === "string" ? part.type : undefined, + text: typeof part.text === "string" ? part.text : undefined, + synthetic: part.synthetic === true, + })) + + return { + provider: { id: provider.id }, + message: { + info: info ? { role: typeof info.role === "string" ? info.role : undefined } : undefined, + parts, + }, + } +} + +function isChatHeadersOutput(raw: unknown): raw is ChatHeadersOutput { + if (!isRecord(raw)) return false + if (!isRecord(raw.headers)) { + raw.headers = {} + } + return isRecord(raw.headers) +} + +function isCopilotProvider(providerID: string): boolean { + return providerID === "github-copilot" || providerID === "github-copilot-enterprise" +} + +function isOmoInternalMessage(input: ChatHeadersInput): boolean { + if (input.message.info?.role !== "user") { + return false + } + + return input.message.parts?.some((part) => { + if (part.type !== "text" || !part.text || part.synthetic !== true) { + return false + } + + return part.text.includes(OMO_INTERNAL_INITIATOR_MARKER) + }) ?? false +} + +export function createChatHeadersHandler(): (input: unknown, output: unknown) => Promise { + return async (input, output): Promise => { + const normalizedInput = buildChatHeadersInput(input) + if (!normalizedInput) return + if (!isChatHeadersOutput(output)) return + + if (!isCopilotProvider(normalizedInput.provider.id)) return + if (!isOmoInternalMessage(normalizedInput)) return + + output.headers["x-initiator"] = "agent" + } +} diff --git a/src/shared/index.ts b/src/shared/index.ts index dc7494e3..ce8e69be 100644 --- a/src/shared/index.ts +++ b/src/shared/index.ts @@ -57,3 +57,4 @@ export * from "./opencode-message-dir" export * from "./normalize-sdk-response" export * from "./session-directory-resolver" export * from "./prompt-tools" +export * from "./internal-initiator-marker" diff --git a/src/shared/internal-initiator-marker.ts b/src/shared/internal-initiator-marker.ts new file mode 100644 index 00000000..cc0b1001 --- /dev/null +++ b/src/shared/internal-initiator-marker.ts @@ -0,0 +1,13 @@ +export const OMO_INTERNAL_INITIATOR_MARKER = "" + +export function createInternalAgentTextPart(text: string): { + type: "text" + text: string + synthetic: true +} { + return { + type: "text", + text: `${text}\n${OMO_INTERNAL_INITIATOR_MARKER}`, + synthetic: true, + } +}