From 64e8e164aa8602a28bf5219a19d2c4ab1cc0e123 Mon Sep 17 00:00:00 2001 From: Maxim Harizanov Date: Wed, 18 Feb 2026 19:55:36 +0200 Subject: [PATCH 1/4] 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 80a65ee5d..4d0682e30 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 e94674d37..5116888da 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 0bc24ddae..19d5b6004 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 000000000..1114f4fb8 --- /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 000000000..4e2a42252 --- /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 dc7494e30..ce8e69be9 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 000000000..cc0b10013 --- /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, + } +} From a85f7efb1d3e8c7335c0e0bebbe6f9522ae42e72 Mon Sep 17 00:00:00 2001 From: Maxim Harizanov Date: Wed, 18 Feb 2026 20:15:23 +0200 Subject: [PATCH 2/4] fix(copilot): keep notifications visible and detect marker via message lookup --- src/plugin-interface.ts | 2 +- src/plugin/chat-headers.test.ts | 81 ++++++++++++++++++------- src/plugin/chat-headers.ts | 69 +++++++++++++-------- src/shared/internal-initiator-marker.ts | 2 - 4 files changed, 104 insertions(+), 50 deletions(-) diff --git a/src/plugin-interface.ts b/src/plugin-interface.ts index 19d5b6004..e6849779e 100644 --- a/src/plugin-interface.ts +++ b/src/plugin-interface.ts @@ -36,7 +36,7 @@ export function createPluginInterface(args: { await handler(input, output) }, - "chat.headers": createChatHeadersHandler(), + "chat.headers": createChatHeadersHandler({ ctx }), "chat.message": createChatMessageHandler({ ctx, diff --git a/src/plugin/chat-headers.test.ts b/src/plugin/chat-headers.test.ts index 1114f4fb8..82653de8c 100644 --- a/src/plugin/chat-headers.test.ts +++ b/src/plugin/chat-headers.test.ts @@ -4,22 +4,34 @@ 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() + test("sets x-initiator=agent for Copilot internal marker messages", async () => { + const handler = createChatHeadersHandler({ + ctx: { + client: { + session: { + message: async () => ({ + data: { + parts: [ + { + type: "text", + text: `notification\n${OMO_INTERNAL_INITIATOR_MARKER}`, + }, + ], + }, + }), + }, + }, + } as never, + }) const output: { headers: Record } = { headers: {} } await handler( { + sessionID: "ses_1", provider: { id: "github-copilot" }, message: { - info: { role: "user" }, - parts: [ - { - type: "text", - text: `notification\n${OMO_INTERNAL_INITIATOR_MARKER}`, - synthetic: true, - }, - ], + id: "msg_1", + role: "user", }, }, output, @@ -29,21 +41,33 @@ describe("createChatHeadersHandler", () => { }) test("does not override non-copilot providers", async () => { - const handler = createChatHeadersHandler() + const handler = createChatHeadersHandler({ + ctx: { + client: { + session: { + message: async () => ({ + data: { + parts: [ + { + type: "text", + text: `notification\n${OMO_INTERNAL_INITIATOR_MARKER}`, + }, + ], + }, + }), + }, + }, + } as never, + }) const output: { headers: Record } = { headers: {} } await handler( { + sessionID: "ses_1", provider: { id: "openai" }, message: { - info: { role: "user" }, - parts: [ - { - type: "text", - text: `notification\n${OMO_INTERNAL_INITIATOR_MARKER}`, - synthetic: true, - }, - ], + id: "msg_1", + role: "user", }, }, output, @@ -53,15 +77,28 @@ describe("createChatHeadersHandler", () => { }) test("does not override regular user messages", async () => { - const handler = createChatHeadersHandler() + const handler = createChatHeadersHandler({ + ctx: { + client: { + session: { + message: async () => ({ + data: { + parts: [{ type: "text", text: "normal user message" }], + }, + }), + }, + }, + } as never, + }) const output: { headers: Record } = { headers: {} } await handler( { + sessionID: "ses_1", provider: { id: "github-copilot" }, message: { - info: { role: "user" }, - parts: [{ type: "text", text: "normal user message" }], + id: "msg_1", + role: "user", }, }, output, diff --git a/src/plugin/chat-headers.ts b/src/plugin/chat-headers.ts index 4e2a42252..d28c9267d 100644 --- a/src/plugin/chat-headers.ts +++ b/src/plugin/chat-headers.ts @@ -1,10 +1,12 @@ import { OMO_INTERNAL_INITIATOR_MARKER } from "../shared" +import type { PluginContext } from "./types" type ChatHeadersInput = { + sessionID: string provider: { id: string } message: { - info?: { role?: string } - parts?: Array<{ type?: string; text?: string; synthetic?: boolean }> + id?: string + role?: string } } @@ -19,28 +21,20 @@ function isRecord(value: unknown): value is Record { function buildChatHeadersInput(raw: unknown): ChatHeadersInput | null { if (!isRecord(raw)) return null + const sessionID = raw.sessionID const provider = raw.provider const message = raw.message + if (typeof sessionID !== "string") return null 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 { + sessionID, provider: { id: provider.id }, message: { - info: info ? { role: typeof info.role === "string" ? info.role : undefined } : undefined, - parts, + id: typeof message.id === "string" ? message.id : undefined, + role: typeof message.role === "string" ? message.role : undefined, }, } } @@ -57,28 +51,53 @@ function isCopilotProvider(providerID: string): boolean { return providerID === "github-copilot" || providerID === "github-copilot-enterprise" } -function isOmoInternalMessage(input: ChatHeadersInput): boolean { - if (input.message.info?.role !== "user") { +async function hasInternalMarker( + client: PluginContext["client"], + sessionID: string, + messageID: string, +): Promise { + try { + const response = await client.session.message({ + path: { id: sessionID, messageID }, + }) + + const data = response.data + if (!isRecord(data) || !Array.isArray(data.parts)) return false + + return data.parts.some((part) => { + if (!isRecord(part) || part.type !== "text" || typeof part.text !== "string") { + return false + } + + return part.text.includes(OMO_INTERNAL_INITIATOR_MARKER) + }) + } catch { + return false + } +} + +async function isOmoInternalMessage(input: ChatHeadersInput, client: PluginContext["client"]): Promise { + if (input.message.role !== "user") { return false } - return input.message.parts?.some((part) => { - if (part.type !== "text" || !part.text || part.synthetic !== true) { - return false - } + if (!input.message.id) { + return false + } - return part.text.includes(OMO_INTERNAL_INITIATOR_MARKER) - }) ?? false + return hasInternalMarker(client, input.sessionID, input.message.id) } -export function createChatHeadersHandler(): (input: unknown, output: unknown) => Promise { +export function createChatHeadersHandler(args: { ctx: PluginContext }): (input: unknown, output: unknown) => Promise { + const { ctx } = args + 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 + if (!(await isOmoInternalMessage(normalizedInput, ctx.client))) return output.headers["x-initiator"] = "agent" } diff --git a/src/shared/internal-initiator-marker.ts b/src/shared/internal-initiator-marker.ts index cc0b10013..3e19c5819 100644 --- a/src/shared/internal-initiator-marker.ts +++ b/src/shared/internal-initiator-marker.ts @@ -3,11 +3,9 @@ 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, } } From 850fb0378e26f98df8bcc0a0f7203eae66f02fae Mon Sep 17 00:00:00 2001 From: Maxim Harizanov Date: Wed, 18 Feb 2026 21:54:20 +0200 Subject: [PATCH 3/4] fix(copilot): mark internal hook injections as agent-initiated Apply the internal initiator marker to automated continuation, recovery, babysitter, stop-hook, and hook-message injections so Copilot attribution consistently sets x-initiator=agent for system-generated prompts. --- src/features/hook-message-injector/injector.ts | 4 ++-- src/hooks/atlas/boulder-continuation-injector.ts | 4 ++-- .../handlers/session-event-handler.ts | 4 ++-- src/hooks/ralph-loop/continuation-prompt-injector.ts | 8 ++++++-- src/hooks/session-recovery/resume.test.ts | 4 ++++ src/hooks/session-recovery/resume.ts | 4 ++-- .../continuation-injection.test.ts | 11 ++++++++++- .../continuation-injection.ts | 8 ++++++-- src/hooks/unstable-agent-babysitter/index.test.ts | 3 +++ .../unstable-agent-babysitter-hook.ts | 4 ++-- 10 files changed, 39 insertions(+), 15 deletions(-) diff --git a/src/features/hook-message-injector/injector.ts b/src/features/hook-message-injector/injector.ts index 97ee8a7fa..8f4e0d57b 100644 --- a/src/features/hook-message-injector/injector.ts +++ b/src/features/hook-message-injector/injector.ts @@ -5,7 +5,7 @@ import { MESSAGE_STORAGE, PART_STORAGE } from "./constants" import type { MessageMeta, OriginalMessageContext, TextPart, ToolPermission } from "./types" import { log } from "../../shared/logger" import { isSqliteBackend } from "../../shared/opencode-storage-detection" -import { normalizeSDKResponse } from "../../shared" +import { createInternalAgentTextPart, normalizeSDKResponse } from "../../shared" export interface StoredMessage { agent?: string @@ -331,7 +331,7 @@ export function injectHookMessage( const textPart: TextPart = { id: partID, type: "text", - text: hookContent, + text: createInternalAgentTextPart(hookContent).text, synthetic: true, time: { start: now, diff --git a/src/hooks/atlas/boulder-continuation-injector.ts b/src/hooks/atlas/boulder-continuation-injector.ts index 68ce30643..289668b4b 100644 --- a/src/hooks/atlas/boulder-continuation-injector.ts +++ b/src/hooks/atlas/boulder-continuation-injector.ts @@ -1,7 +1,7 @@ import type { PluginInput } from "@opencode-ai/plugin" import type { BackgroundManager } from "../../features/background-agent" import { log } from "../../shared/logger" -import { resolveInheritedPromptTools } from "../../shared" +import { createInternalAgentTextPart, resolveInheritedPromptTools } from "../../shared" import { HOOK_NAME } from "./hook-name" import { BOULDER_CONTINUATION_PROMPT } from "./system-reminder-templates" import { resolveRecentPromptContextForSession } from "./recent-model-resolver" @@ -53,7 +53,7 @@ export async function injectBoulderContinuation(input: { agent: agent ?? "atlas", ...(promptContext.model !== undefined ? { model: promptContext.model } : {}), ...(inheritedTools ? { tools: inheritedTools } : {}), - parts: [{ type: "text", text: prompt }], + parts: [createInternalAgentTextPart(prompt)], }, query: { directory: ctx.directory }, }) diff --git a/src/hooks/claude-code-hooks/handlers/session-event-handler.ts b/src/hooks/claude-code-hooks/handlers/session-event-handler.ts index 6c6d3a584..4c845004c 100644 --- a/src/hooks/claude-code-hooks/handlers/session-event-handler.ts +++ b/src/hooks/claude-code-hooks/handlers/session-event-handler.ts @@ -3,7 +3,7 @@ import { loadClaudeHooksConfig } from "../config" import { loadPluginExtendedConfig } from "../config-loader" import { executeStopHooks, type StopContext } from "../stop" import type { PluginConfig } from "../types" -import { isHookDisabled, log } from "../../../shared" +import { createInternalAgentTextPart, isHookDisabled, log } from "../../../shared" import { clearSessionHookState, sessionErrorState, @@ -94,7 +94,7 @@ export function createSessionEventHandler(ctx: PluginInput, config: PluginConfig .prompt({ path: { id: sessionID }, body: { - parts: [{ type: "text", text: stopResult.injectPrompt }], + parts: [createInternalAgentTextPart(stopResult.injectPrompt)], }, query: { directory: ctx.directory }, }) diff --git a/src/hooks/ralph-loop/continuation-prompt-injector.ts b/src/hooks/ralph-loop/continuation-prompt-injector.ts index 1d1260b5d..bfe18d83d 100644 --- a/src/hooks/ralph-loop/continuation-prompt-injector.ts +++ b/src/hooks/ralph-loop/continuation-prompt-injector.ts @@ -3,7 +3,11 @@ import { log } from "../../shared/logger" import { findNearestMessageWithFields } from "../../features/hook-message-injector" import { getMessageDir } from "./message-storage-directory" import { withTimeout } from "./with-timeout" -import { normalizeSDKResponse, resolveInheritedPromptTools } from "../../shared" +import { + createInternalAgentTextPart, + normalizeSDKResponse, + resolveInheritedPromptTools, +} from "../../shared" type MessageInfo = { agent?: string @@ -64,7 +68,7 @@ export async function injectContinuationPrompt( ...(agent !== undefined ? { agent } : {}), ...(model !== undefined ? { model } : {}), ...(inheritedTools ? { tools: inheritedTools } : {}), - parts: [{ type: "text", text: options.prompt }], + parts: [createInternalAgentTextPart(options.prompt)], }, query: { directory: options.directory }, }) diff --git a/src/hooks/session-recovery/resume.test.ts b/src/hooks/session-recovery/resume.test.ts index 20d76263d..fff669984 100644 --- a/src/hooks/session-recovery/resume.test.ts +++ b/src/hooks/session-recovery/resume.test.ts @@ -1,6 +1,7 @@ declare const require: (name: string) => any const { describe, expect, test } = require("bun:test") import { extractResumeConfig, resumeSession } from "./resume" +import { OMO_INTERNAL_INITIATOR_MARKER } from "../../shared/internal-initiator-marker" import type { MessageData } from "./types" describe("session-recovery resume", () => { @@ -44,5 +45,8 @@ describe("session-recovery resume", () => { // then expect(ok).toBe(true) expect(promptBody?.tools).toEqual({ question: false, bash: true }) + expect(Array.isArray(promptBody?.parts)).toBe(true) + const firstPart = (promptBody?.parts as Array<{ text?: string }>)?.[0] + expect(firstPart?.text).toContain(OMO_INTERNAL_INITIATOR_MARKER) }) }) diff --git a/src/hooks/session-recovery/resume.ts b/src/hooks/session-recovery/resume.ts index 2d5805b77..e5d187d79 100644 --- a/src/hooks/session-recovery/resume.ts +++ b/src/hooks/session-recovery/resume.ts @@ -1,6 +1,6 @@ import type { createOpencodeClient } from "@opencode-ai/sdk" import type { MessageData, ResumeConfig } from "./types" -import { resolveInheritedPromptTools } from "../../shared" +import { createInternalAgentTextPart, resolveInheritedPromptTools } from "../../shared" const RECOVERY_RESUME_TEXT = "[session recovered - continuing previous task]" @@ -30,7 +30,7 @@ export async function resumeSession(client: Client, config: ResumeConfig): Promi await client.session.promptAsync({ path: { id: config.sessionID }, body: { - parts: [{ type: "text", text: RECOVERY_RESUME_TEXT }], + parts: [createInternalAgentTextPart(RECOVERY_RESUME_TEXT)], agent: config.agent, model: config.model, ...(inheritedTools ? { tools: inheritedTools } : {}), diff --git a/src/hooks/todo-continuation-enforcer/continuation-injection.test.ts b/src/hooks/todo-continuation-enforcer/continuation-injection.test.ts index f8c019a97..0b628ca1e 100644 --- a/src/hooks/todo-continuation-enforcer/continuation-injection.test.ts +++ b/src/hooks/todo-continuation-enforcer/continuation-injection.test.ts @@ -2,18 +2,26 @@ declare const require: (name: string) => any const { describe, expect, test } = require("bun:test") import { injectContinuation } from "./continuation-injection" +import { OMO_INTERNAL_INITIATOR_MARKER } from "../../shared/internal-initiator-marker" describe("injectContinuation", () => { test("inherits tools from resolved message info when reinjecting", async () => { // given let capturedTools: Record | undefined + let capturedText: string | undefined const ctx = { directory: "/tmp/test", client: { session: { todo: async () => ({ data: [{ id: "1", content: "todo", status: "pending", priority: "high" }] }), - promptAsync: async (input: { body: { tools?: Record } }) => { + promptAsync: async (input: { + body: { + tools?: Record + parts?: Array<{ type: string; text: string }> + } + }) => { capturedTools = input.body.tools + capturedText = input.body.parts?.[0]?.text return {} }, }, @@ -37,5 +45,6 @@ describe("injectContinuation", () => { // then expect(capturedTools).toEqual({ question: false, bash: true }) + expect(capturedText).toContain(OMO_INTERNAL_INITIATOR_MARKER) }) }) diff --git a/src/hooks/todo-continuation-enforcer/continuation-injection.ts b/src/hooks/todo-continuation-enforcer/continuation-injection.ts index ba60d23b0..23b11ba68 100644 --- a/src/hooks/todo-continuation-enforcer/continuation-injection.ts +++ b/src/hooks/todo-continuation-enforcer/continuation-injection.ts @@ -1,7 +1,11 @@ import type { PluginInput } from "@opencode-ai/plugin" import type { BackgroundManager } from "../../features/background-agent" -import { normalizeSDKResponse, resolveInheritedPromptTools } from "../../shared" +import { + createInternalAgentTextPart, + normalizeSDKResponse, + resolveInheritedPromptTools, +} from "../../shared" import { findNearestMessageWithFields, findNearestMessageWithFieldsFromSDK, @@ -151,7 +155,7 @@ ${todoList}` agent: agentName, ...(model !== undefined ? { model } : {}), ...(inheritedTools ? { tools: inheritedTools } : {}), - parts: [{ type: "text", text: prompt }], + parts: [createInternalAgentTextPart(prompt)], }, query: { directory: ctx.directory }, }) diff --git a/src/hooks/unstable-agent-babysitter/index.test.ts b/src/hooks/unstable-agent-babysitter/index.test.ts index 355072b56..8dd6fa038 100644 --- a/src/hooks/unstable-agent-babysitter/index.test.ts +++ b/src/hooks/unstable-agent-babysitter/index.test.ts @@ -1,6 +1,7 @@ import { afterEach, describe, expect, test } from "bun:test" import { _resetForTesting, setMainSession } from "../../features/claude-code-session-state" import type { BackgroundTask } from "../../features/background-agent" +import { OMO_INTERNAL_INITIATOR_MARKER } from "../../shared/internal-initiator-marker" import { createUnstableAgentBabysitterHook } from "./index" const projectDir = process.cwd() @@ -93,6 +94,7 @@ describe("unstable-agent-babysitter hook", () => { expect(text).toContain("background_output") expect(text).toContain("background_cancel") expect(text).toContain("deep thought") + expect(text).toContain(OMO_INTERNAL_INITIATOR_MARKER) }) test("fires reminder for hung minimax task", async () => { @@ -128,6 +130,7 @@ describe("unstable-agent-babysitter hook", () => { expect(text).toContain("background_output") expect(text).toContain("background_cancel") expect(text).toContain("minimax thought") + expect(text).toContain(OMO_INTERNAL_INITIATOR_MARKER) }) test("does not remind stable model tasks", async () => { diff --git a/src/hooks/unstable-agent-babysitter/unstable-agent-babysitter-hook.ts b/src/hooks/unstable-agent-babysitter/unstable-agent-babysitter-hook.ts index dba65f8da..9bfdbb01a 100644 --- a/src/hooks/unstable-agent-babysitter/unstable-agent-babysitter-hook.ts +++ b/src/hooks/unstable-agent-babysitter/unstable-agent-babysitter-hook.ts @@ -1,7 +1,7 @@ import type { BackgroundManager } from "../../features/background-agent" import { getMainSessionID, getSessionAgent } from "../../features/claude-code-session-state" import { log } from "../../shared/logger" -import { resolveInheritedPromptTools } from "../../shared" +import { createInternalAgentTextPart, resolveInheritedPromptTools } from "../../shared" import { buildReminder, extractMessages, @@ -158,7 +158,7 @@ export function createUnstableAgentBabysitterHook(ctx: BabysitterContext, option ...(agent ? { agent } : {}), ...(model ? { model } : {}), ...(tools ? { tools } : {}), - parts: [{ type: "text", text: reminder }], + parts: [createInternalAgentTextPart(reminder)], }, query: { directory: ctx.directory }, }) From 6e82ef238452a09454462c506a25ee90ecf1c19d Mon Sep 17 00:00:00 2001 From: Maxim Harizanov Date: Thu, 19 Feb 2026 13:40:38 +0200 Subject: [PATCH 4/4] fix(types): restore CI compatibility for plugin hooks and tool context --- src/plugin/types.ts | 12 +++++++++++- src/tools/hashline-edit/tools.ts | 11 +++++++++-- 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/src/plugin/types.ts b/src/plugin/types.ts index 583255052..7b3a7cb39 100644 --- a/src/plugin/types.ts +++ b/src/plugin/types.ts @@ -2,7 +2,17 @@ import type { Plugin, ToolDefinition } from "@opencode-ai/plugin" export type PluginContext = Parameters[0] export type PluginInstance = Awaited> -export type PluginInterface = Omit + +type ChatHeadersHook = PluginInstance extends { "chat.headers"?: infer T } + ? T + : (input: unknown, output: unknown) => Promise + +export type PluginInterface = Omit< + PluginInstance, + "experimental.session.compacting" | "chat.headers" +> & { + "chat.headers"?: ChatHeadersHook +} export type ToolsRecord = Record diff --git a/src/tools/hashline-edit/tools.ts b/src/tools/hashline-edit/tools.ts index 61d8b916a..b912bfc26 100644 --- a/src/tools/hashline-edit/tools.ts +++ b/src/tools/hashline-edit/tools.ts @@ -16,6 +16,10 @@ type ToolContextWithCallID = ToolContext & { call_id?: string } +type ToolContextWithMetadata = ToolContextWithCallID & { + metadata?: (value: unknown) => void +} + function resolveToolCallID(ctx: ToolContextWithCallID): string | undefined { if (typeof ctx.callID === "string" && ctx.callID.trim() !== "") return ctx.callID if (typeof ctx.callId === "string" && ctx.callId.trim() !== "") return ctx.callId @@ -135,6 +139,7 @@ Use \\n in text to represent literal newlines.`, }, execute: async (args: HashlineEditArgs, context: ToolContext) => { try { + const metadataContext = context as ToolContextWithMetadata const filePath = args.filePath const { edits } = args @@ -179,9 +184,11 @@ Use \\n in text to represent literal newlines.`, }, } - context.metadata(meta) + if (typeof metadataContext.metadata === "function") { + metadataContext.metadata(meta) + } - const callID = resolveToolCallID(context) + const callID = resolveToolCallID(metadataContext) if (callID) { storeToolMetadata(context.sessionID, callID, meta) }