Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions assets/oh-my-opencode.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -6693,6 +6693,33 @@
],
"additionalProperties": false
},
"compaction": {
"type": "object",
"properties": {
"preemptive_threshold": {
"default": 0.78,
"type": "number",
"minimum": 0,
"maximum": 1
},
"cooldown_ms": {
"default": 60000,
"type": "integer",
"minimum": 0,
"maximum": 9007199254740991
},
"enabled": {
"default": true,
"type": "boolean"
}
},
"required": [
"preemptive_threshold",
"cooldown_ms",
"enabled"
],
"additionalProperties": false
},
"git_master": {
"default": {
"commit_footer": true,
Expand Down
103 changes: 103 additions & 0 deletions packages/omo-opencode/src/config/schema/compaction.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import { describe, expect, test } from "bun:test"
import { ZodError } from "zod"

import { CompactionConfigSchema } from "./compaction"

describe("CompactionConfigSchema", () => {
describe("#given empty input", () => {
test("#when parsed #then it returns all compaction defaults", () => {
// given
const input = {}

// when
const result = CompactionConfigSchema.parse(input)

// then
expect(result).toEqual({
preemptive_threshold: 0.78,
cooldown_ms: 60000,
enabled: true,
})
})
})

describe("#given preemptive_threshold within range", () => {
test("#when parsed #then it preserves the provided value", () => {
// given
const input = { preemptive_threshold: 0.6 }

// when
const result = CompactionConfigSchema.parse(input)

// then
expect(result.preemptive_threshold).toBe(0.6)
})
})

describe("#given preemptive_threshold above maximum", () => {
test("#when parsed #then it throws ZodError", () => {
// given
const input = { preemptive_threshold: 2 }

// when
let thrownError: unknown
try {
CompactionConfigSchema.parse(input)
} catch (error) {
thrownError = error
}

// then
expect(thrownError).toBeInstanceOf(ZodError)
})
})

describe("#given cooldown_ms is negative", () => {
test("#when parsed #then it throws ZodError", () => {
// given
const input = { cooldown_ms: -1 }

// when
let thrownError: unknown
try {
CompactionConfigSchema.parse(input)
} catch (error) {
thrownError = error
}

// then
expect(thrownError).toBeInstanceOf(ZodError)
})
})

describe("#given cooldown_ms is fractional", () => {
test("#when parsed #then it throws ZodError", () => {
// given
const input = { cooldown_ms: 1.5 }

// when
let thrownError: unknown
try {
CompactionConfigSchema.parse(input)
} catch (error) {
thrownError = error
}

// then
expect(thrownError).toBeInstanceOf(ZodError)
})
})

describe("#given enabled is omitted", () => {
test("#when parsed #then enabled defaults to true", () => {
// given
const input = { preemptive_threshold: 0.5 }

// when
const result = CompactionConfigSchema.parse(input)

// then
expect(result.enabled).toBe(true)
})
})
})
9 changes: 9 additions & 0 deletions packages/omo-opencode/src/config/schema/compaction.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { z } from "zod"

export const CompactionConfigSchema = z.object({
preemptive_threshold: z.number().min(0).max(1).default(0.78),
cooldown_ms: z.number().int().min(0).default(60000),
enabled: z.boolean().default(true),
})

export type CompactionConfig = z.infer<typeof CompactionConfigSchema>
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { ClaudeCodeConfigSchema } from "./claude-code"
import { CodegraphConfigSchema } from "./codegraph"
import { CommentCheckerConfigSchema } from "./comment-checker"
import { BuiltinCommandNameSchema } from "./commands"
import { CompactionConfigSchema } from "./compaction"
import { DefaultModeConfigSchema } from "./default-mode"
import { ExperimentalConfigSchema } from "./experimental"
import { GitMasterConfigSchema } from "./git-master"
Expand Down Expand Up @@ -86,6 +87,7 @@ export const OhMyOpenCodeConfigSchema = z.object({
team_mode: TeamModeConfigSchema.optional(),
keyword_detector: KeywordDetectorConfigSchema.optional(),
babysitting: BabysittingConfigSchema.optional(),
compaction: CompactionConfigSchema.optional(),
git_master: GitMasterConfigSchema.default({
commit_footer: true,
include_co_authored_by: true,
Expand Down
170 changes: 170 additions & 0 deletions packages/omo-opencode/src/hooks/preemptive-compaction-trigger.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
import { describe, expect, it, mock, afterAll } from "bun:test"

import { applyProviderConfig } from "../plugin-handlers/provider-config-handler"
import { createModelCacheState } from "../plugin-state"
import type { OhMyOpenCodeConfig } from "../config"

import type { CachedCompactionState } from "./preemptive-compaction-types"

const logMock = mock(() => {})

mock.module("../shared/logger", () => ({
log: logMock,
}))

afterAll(() => { mock.restore() })

const { runPreemptiveCompactionIfNeeded } = await import("./preemptive-compaction-trigger")

const PROVIDER_ID = "opencode"
const MODEL_ID = "kimi-k2.5-free"
const CONTEXT_LIMIT = 200_000

function createMockCtx() {
return {
client: {
session: {
messages: mock(() => Promise.resolve({ data: [] })),
summarize: mock(() => Promise.resolve({})),
},
tui: {
showToast: mock(() => Promise.resolve()),
},
},
directory: "/tmp/test",
}
}

function createModelCacheWithLimit() {
const modelCacheState = createModelCacheState()
applyProviderConfig({
config: {
provider: {
[PROVIDER_ID]: {
models: {
[MODEL_ID]: { limit: { context: CONTEXT_LIMIT } },
},
},
},
},
modelCacheState,
})
return modelCacheState
}

function cachedStateWithRatio(ratio: number): CachedCompactionState {
return {
providerID: PROVIDER_ID,
modelID: MODEL_ID,
tokens: {
input: Math.round(CONTEXT_LIMIT * ratio),
output: 0,
reasoning: 0,
cache: { read: 0, write: 0 },
},
}
}

function createRunArgs(options: {
sessionID: string
ratio: number
pluginConfig: OhMyOpenCodeConfig
toolName?: string
}) {
return {
ctx: createMockCtx(),
pluginConfig: options.pluginConfig,
modelCacheState: createModelCacheWithLimit(),
sessionID: options.sessionID,
tokenCache: new Map<string, CachedCompactionState>([
[options.sessionID, cachedStateWithRatio(options.ratio)],
]),
compactionInProgress: new Set<string>(),
compactedSessions: new Set<string>(),
lastCompactionTime: new Map<string, number>(),
toolName: options.toolName,
}
}

describe("runPreemptiveCompactionIfNeeded config-driven trip point", () => {
it("compacts at the configured threshold (0.6) when usage reaches it", async () => {
// given
const args = createRunArgs({
sessionID: "ses_threshold_hit",
ratio: 0.6,
pluginConfig: { compaction: { preemptive_threshold: 0.6 } } as OhMyOpenCodeConfig,
})

// when
await runPreemptiveCompactionIfNeeded(args)

// then
expect(args.ctx.client.session.summarize).toHaveBeenCalledTimes(1)
})

it("does NOT compact below the configured threshold (0.6) at 0.5 usage", async () => {
// given
const args = createRunArgs({
sessionID: "ses_threshold_miss",
ratio: 0.5,
pluginConfig: { compaction: { preemptive_threshold: 0.6 } } as OhMyOpenCodeConfig,
})

// when
await runPreemptiveCompactionIfNeeded(args)

// then
expect(args.ctx.client.session.summarize).not.toHaveBeenCalled()
})
})

describe("runPreemptiveCompactionIfNeeded enabled gate", () => {
it("does NOT compact when compaction.enabled is false even above threshold", async () => {
// given
const args = createRunArgs({
sessionID: "ses_disabled",
ratio: 0.99,
pluginConfig: { compaction: { enabled: false } } as OhMyOpenCodeConfig,
})

// when
await runPreemptiveCompactionIfNeeded(args)

// then
expect(args.ctx.client.session.summarize).not.toHaveBeenCalled()
})
})

describe("runPreemptiveCompactionIfNeeded mid-collection guard", () => {
it("does NOT compact when the finished tool is background_output even above threshold", async () => {
// given
const args = createRunArgs({
sessionID: "ses_bg_output",
ratio: 0.99,
pluginConfig: {} as OhMyOpenCodeConfig,
toolName: "background_output",
})

// when
await runPreemptiveCompactionIfNeeded(args)

// then
expect(args.ctx.client.session.summarize).not.toHaveBeenCalled()
})

it("compacts above threshold for an ordinary tool with default config", async () => {
// given
const args = createRunArgs({
sessionID: "ses_ordinary_tool",
ratio: 0.99,
pluginConfig: {} as OhMyOpenCodeConfig,
toolName: "bash",
})

// when
await runPreemptiveCompactionIfNeeded(args)

// then
expect(args.ctx.client.session.summarize).toHaveBeenCalledTimes(1)
})
})
16 changes: 11 additions & 5 deletions packages/omo-opencode/src/hooks/preemptive-compaction-trigger.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { CompactionConfigSchema } from "../config/schema/compaction"
import type { OhMyOpenCodeConfig } from "../config"
import {
resolveActualContextLimit,
Expand All @@ -12,8 +13,6 @@ import type {
} from "./preemptive-compaction-types"

const PREEMPTIVE_COMPACTION_TIMEOUT_MS = 60_000
const PREEMPTIVE_COMPACTION_THRESHOLD = 0.78
const PREEMPTIVE_COMPACTION_COOLDOWN_MS = 60_000

declare function setTimeout(handler: () => void, timeout?: number): unknown
declare function clearTimeout(timeoutID: unknown): void
Expand Down Expand Up @@ -45,6 +44,7 @@ export async function runPreemptiveCompactionIfNeeded(args: {
compactionInProgress: Set<string>
compactedSessions: Set<string>
lastCompactionTime: Map<string, number>
toolName?: string
}): Promise<void> {
const {
ctx,
Expand All @@ -55,12 +55,18 @@ export async function runPreemptiveCompactionIfNeeded(args: {
compactionInProgress,
compactedSessions,
lastCompactionTime,
toolName,
} = args

const compactionConfig = CompactionConfigSchema.parse(pluginConfig.compaction ?? {})
if (!compactionConfig.enabled) return

if (compactedSessions.has(sessionID) || compactionInProgress.has(sessionID)) return

const lastTime = lastCompactionTime.get(sessionID)
if (lastTime && Date.now() - lastTime < PREEMPTIVE_COMPACTION_COOLDOWN_MS) return
if (lastTime && Date.now() - lastTime < compactionConfig.cooldown_ms) return

if (toolName === "background_output") return

const cached = tokenCache.get(sessionID)
if (!cached) return
Expand All @@ -81,7 +87,7 @@ export async function runPreemptiveCompactionIfNeeded(args: {

const totalInputTokens = (cached.tokens.input ?? 0) + (cached.tokens.cache?.read ?? 0)
const usageRatio = totalInputTokens / actualLimit
if (usageRatio < PREEMPTIVE_COMPACTION_THRESHOLD || !cached.modelID) return
if (usageRatio < compactionConfig.preemptive_threshold || !cached.modelID) return

compactionInProgress.add(sessionID)
lastCompactionTime.set(sessionID, Date.now())
Expand Down Expand Up @@ -116,7 +122,7 @@ export async function runPreemptiveCompactionIfNeeded(args: {
ctx.client.tui.showToast({
body: {
title: "Preemptive compaction failed",
message: `Context window is above ${Math.round(PREEMPTIVE_COMPACTION_THRESHOLD * 100)}% and auto-compaction could not run. The session may grow large. Error: ${errorMessage}`,
message: `Context window is above ${Math.round(compactionConfig.preemptive_threshold * 100)}% and auto-compaction could not run. The session may grow large. Error: ${errorMessage}`,
variant: "warning",
duration: 10000,
},
Expand Down
Loading