diff --git a/src/hooks/compaction-context-injector/index.test.ts b/src/hooks/compaction-context-injector/index.test.ts index 7c141f4d2..7b8627419 100644 --- a/src/hooks/compaction-context-injector/index.test.ts +++ b/src/hooks/compaction-context-injector/index.test.ts @@ -1,14 +1,4 @@ -import { describe, expect, it, mock, beforeEach } from "bun:test" - -// Mock dependencies before importing -const mockInjectHookMessage = mock(() => true) -mock.module("../../features/hook-message-injector", () => ({ - injectHookMessage: mockInjectHookMessage, -})) - -mock.module("../../shared/logger", () => ({ - log: () => {}, -})) +import { describe, expect, it, mock } from "bun:test" mock.module("../../shared/system-directive", () => ({ createSystemDirective: (type: string) => `[DIRECTIVE:${type}]`, @@ -25,78 +15,45 @@ mock.module("../../shared/system-directive", () => ({ })) import { createCompactionContextInjector } from "./index" -import type { SummarizeContext } from "./index" describe("createCompactionContextInjector", () => { - beforeEach(() => { - mockInjectHookMessage.mockClear() - }) - describe("Agent Verification State preservation", () => { it("includes Agent Verification State section in compaction prompt", async () => { - // given + //#given const injector = createCompactionContextInjector() - const context: SummarizeContext = { - sessionID: "test-session", - providerID: "anthropic", - modelID: "claude-sonnet-4-5", - usageRatio: 0.85, - directory: "/test/dir", - } - // when - await injector(context) + //#when + const prompt = injector() - // then - expect(mockInjectHookMessage).toHaveBeenCalledTimes(1) - const calls = mockInjectHookMessage.mock.calls as unknown as [string, string, unknown][] - const injectedPrompt = calls[0]?.[1] ?? "" - expect(injectedPrompt).toContain("Agent Verification State") - expect(injectedPrompt).toContain("Current Agent") - expect(injectedPrompt).toContain("Verification Progress") + //#then + expect(prompt).toContain("Agent Verification State") + expect(prompt).toContain("Current Agent") + expect(prompt).toContain("Verification Progress") }) - it("includes Momus-specific context for reviewer agents", async () => { - // given + it("includes reviewer-agent continuity fields", async () => { + //#given const injector = createCompactionContextInjector() - const context: SummarizeContext = { - sessionID: "test-session", - providerID: "anthropic", - modelID: "claude-sonnet-4-5", - usageRatio: 0.9, - directory: "/test/dir", - } - // when - await injector(context) + //#when + const prompt = injector() - // then - const calls = mockInjectHookMessage.mock.calls as unknown as [string, string, unknown][] - const injectedPrompt = calls[0]?.[1] ?? "" - expect(injectedPrompt).toContain("Previous Rejections") - expect(injectedPrompt).toContain("Acceptance Status") - expect(injectedPrompt).toContain("reviewer agents") + //#then + expect(prompt).toContain("Previous Rejections") + expect(prompt).toContain("Acceptance Status") + expect(prompt).toContain("reviewer agents") }) - it("preserves file verification progress in compaction prompt", async () => { - // given + it("preserves file verification progress fields", async () => { + //#given const injector = createCompactionContextInjector() - const context: SummarizeContext = { - sessionID: "test-session", - providerID: "anthropic", - modelID: "claude-sonnet-4-5", - usageRatio: 0.95, - directory: "/test/dir", - } - // when - await injector(context) + //#when + const prompt = injector() - // then - const calls = mockInjectHookMessage.mock.calls as unknown as [string, string, unknown][] - const injectedPrompt = calls[0]?.[1] ?? "" - expect(injectedPrompt).toContain("Pending Verifications") - expect(injectedPrompt).toContain("Files already verified") + //#then + expect(prompt).toContain("Pending Verifications") + expect(prompt).toContain("Files already verified") }) }) }) diff --git a/src/hooks/compaction-context-injector/index.ts b/src/hooks/compaction-context-injector/index.ts index 836e706e9..81b6f1c35 100644 --- a/src/hooks/compaction-context-injector/index.ts +++ b/src/hooks/compaction-context-injector/index.ts @@ -1,16 +1,6 @@ -import { injectHookMessage } from "../../features/hook-message-injector" -import { log } from "../../shared/logger" import { createSystemDirective, SystemDirectiveTypes } from "../../shared/system-directive" -export interface SummarizeContext { - sessionID: string - providerID: string - modelID: string - usageRatio: number - directory: string -} - -const SUMMARIZE_CONTEXT_PROMPT = `${createSystemDirective(SystemDirectiveTypes.COMPACTION_CONTEXT)} +const COMPACTION_CONTEXT_PROMPT = `${createSystemDirective(SystemDirectiveTypes.COMPACTION_CONTEXT)} When summarizing this session, you MUST include the following sections in your summary: @@ -58,19 +48,5 @@ This context is critical for maintaining continuity after compaction. ` export function createCompactionContextInjector() { - return async (ctx: SummarizeContext): Promise => { - log("[compaction-context-injector] injecting context", { sessionID: ctx.sessionID }) - - const success = injectHookMessage(ctx.sessionID, SUMMARIZE_CONTEXT_PROMPT, { - agent: "general", - model: { providerID: ctx.providerID, modelID: ctx.modelID }, - path: { cwd: ctx.directory }, - }) - - if (success) { - log("[compaction-context-injector] context injected", { sessionID: ctx.sessionID }) - } else { - log("[compaction-context-injector] injection failed", { sessionID: ctx.sessionID }) - } - } + return (): string => COMPACTION_CONTEXT_PROMPT } diff --git a/src/hooks/index.ts b/src/hooks/index.ts index bffb447e6..d99abf28f 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -34,7 +34,7 @@ export { createDelegateTaskRetryHook } from "./delegate-task-retry"; export { createQuestionLabelTruncatorHook } from "./question-label-truncator"; export { createSubagentQuestionBlockerHook } from "./subagent-question-blocker"; export { createStopContinuationGuardHook, type StopContinuationGuard } from "./stop-continuation-guard"; -export { createCompactionContextInjector, type SummarizeContext } from "./compaction-context-injector"; +export { createCompactionContextInjector } from "./compaction-context-injector"; export { createUnstableAgentBabysitterHook } from "./unstable-agent-babysitter"; export { createPreemptiveCompactionHook } from "./preemptive-compaction"; export { createTasksTodowriteDisablerHook } from "./tasks-todowrite-disabler"; diff --git a/src/hooks/preemptive-compaction.test.ts b/src/hooks/preemptive-compaction.test.ts index cb027ac7f..17f64afa5 100644 --- a/src/hooks/preemptive-compaction.test.ts +++ b/src/hooks/preemptive-compaction.test.ts @@ -60,6 +60,41 @@ describe("preemptive-compaction", () => { expect(summarize).toHaveBeenCalled() }) + test("triggers summarize for non-anthropic providers when usage exceeds threshold", async () => { + //#given + const messages = mock(() => + Promise.resolve({ + data: [ + { + info: { + role: "assistant", + providerID: "openai", + modelID: "gpt-5.2", + tokens: { + input: 180000, + output: 0, + reasoning: 0, + cache: { read: 0, write: 0 }, + }, + }, + }, + ], + }) + ) + const summarize = mock(() => Promise.resolve()) + const hook = createPreemptiveCompactionHook(createMockCtx({ messages, summarize })) + const output = { title: "", output: "", metadata: {} } + + //#when + await hook["tool.execute.after"]( + { tool: "Read", sessionID, callID: "call-3" }, + output + ) + + //#then + expect(summarize).toHaveBeenCalled() + }) + test("does not summarize when usage is below threshold", async () => { // #given const messages = mock(() => diff --git a/src/hooks/preemptive-compaction.ts b/src/hooks/preemptive-compaction.ts index 28c2a9220..e567a6b0d 100644 --- a/src/hooks/preemptive-compaction.ts +++ b/src/hooks/preemptive-compaction.ts @@ -1,8 +1,10 @@ +const DEFAULT_ACTUAL_LIMIT = 200_000 + const ANTHROPIC_ACTUAL_LIMIT = process.env.ANTHROPIC_1M_CONTEXT === "true" || process.env.VERTEX_ANTHROPIC_1M_CONTEXT === "true" ? 1_000_000 - : 200_000 + : DEFAULT_ACTUAL_LIMIT const PREEMPTIVE_COMPACTION_THRESHOLD = 0.78 @@ -59,11 +61,14 @@ export function createPreemptiveCompactionHook(ctx: PluginInput) { if (assistantMessages.length === 0) return const lastAssistant = assistantMessages[assistantMessages.length - 1] - if (lastAssistant.providerID !== "anthropic") return + const actualLimit = + lastAssistant.providerID === "anthropic" + ? ANTHROPIC_ACTUAL_LIMIT + : DEFAULT_ACTUAL_LIMIT const lastTokens = lastAssistant.tokens const totalInputTokens = (lastTokens?.input ?? 0) + (lastTokens?.cache?.read ?? 0) - const usageRatio = totalInputTokens / ANTHROPIC_ACTUAL_LIMIT + const usageRatio = totalInputTokens / actualLimit if (usageRatio < PREEMPTIVE_COMPACTION_THRESHOLD) return diff --git a/src/index.compaction-model-agnostic.static.test.ts b/src/index.compaction-model-agnostic.static.test.ts new file mode 100644 index 000000000..ccba44101 --- /dev/null +++ b/src/index.compaction-model-agnostic.static.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, test } from "bun:test" +import { readFileSync } from "node:fs" + +describe("experimental.session.compacting", () => { + test("does not hardcode a model and uses output.context", () => { + //#given + const indexUrl = new URL("./index.ts", import.meta.url) + const content = readFileSync(indexUrl, "utf-8") + const hookIndex = content.indexOf('"experimental.session.compacting"') + + //#when + const hookSlice = hookIndex >= 0 ? content.slice(hookIndex, hookIndex + 1200) : "" + + //#then + expect(hookIndex).toBeGreaterThanOrEqual(0) + expect(content.includes('modelID: "claude-opus-4-6"')).toBe(false) + expect(hookSlice.includes("output.context.push")).toBe(true) + expect(hookSlice.includes("providerID:")).toBe(false) + expect(hookSlice.includes("modelID:")).toBe(false) + }) +}) diff --git a/src/index.ts b/src/index.ts index 481240bbb..63a1ee899 100644 --- a/src/index.ts +++ b/src/index.ts @@ -107,6 +107,7 @@ import { OPENCODE_NATIVE_AGENTS_INJECTION_VERSION, injectServerAuthIntoClient, } from "./shared"; +import { filterDisabledTools } from "./shared/disabled-tools"; import { loadPluginConfig } from "./plugin-config"; import { createModelCacheState } from "./plugin-state"; import { createConfigHandler } from "./plugin-handlers"; @@ -121,7 +122,6 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => { const pluginConfig = loadPluginConfig(ctx.directory, ctx); const disabledHooks = new Set(pluginConfig.disabled_hooks ?? []); - const disabledTools = new Set(pluginConfig.disabled_tools ?? []); const firstMessageVariantGate = createFirstMessageVariantGate(); const tmuxConfig = { @@ -537,15 +537,10 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => { ...taskToolsRecord, }; - const filteredTools: Record = - disabledTools.size > 0 ? {} : allTools; - if (disabledTools.size > 0) { - for (const [toolName, toolDefinition] of Object.entries(allTools)) { - if (!disabledTools.has(toolName)) { - filteredTools[toolName] = toolDefinition; - } - } - } + const filteredTools: Record = filterDisabledTools( + allTools, + pluginConfig.disabled_tools, + ); return { tool: filteredTools, @@ -891,17 +886,14 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => { await taskResumeInfo["tool.execute.after"](input, output); }, - "experimental.session.compacting": async (input: { sessionID: string }) => { + "experimental.session.compacting": async ( + _input: { sessionID: string }, + output: { context: string[] }, + ): Promise => { if (!compactionContextInjector) { return; } - await compactionContextInjector({ - sessionID: input.sessionID, - providerID: "anthropic", - modelID: "claude-opus-4-6", - usageRatio: 0.8, - directory: ctx.directory, - }); + output.context.push(compactionContextInjector()); }, }; }; diff --git a/src/shared/disabled-tools.ts b/src/shared/disabled-tools.ts new file mode 100644 index 000000000..9e645e9b2 --- /dev/null +++ b/src/shared/disabled-tools.ts @@ -0,0 +1,19 @@ +import type { ToolDefinition } from "@opencode-ai/plugin" + +export function filterDisabledTools( + tools: Record, + disabledTools: readonly string[] | undefined +): Record { + if (!disabledTools || disabledTools.length === 0) { + return tools + } + + const disabledToolSet = new Set(disabledTools) + const filtered: Record = {} + for (const [toolName, toolDefinition] of Object.entries(tools)) { + if (!disabledToolSet.has(toolName)) { + filtered[toolName] = toolDefinition + } + } + return filtered +}