Merge pull request #3501 from code-yeongyu/fix/perf-omo-in-tree

perf(plugin-init): de-slop in-tree launch — cold init 1.2s → 29ms (43× faster)
This commit is contained in:
YeonGyu-Kim
2026-04-18 17:35:09 +09:00
committed by GitHub
86 changed files with 2299 additions and 320 deletions
@@ -0,0 +1 @@
# fixture root
@@ -0,0 +1 @@
# fixture package
@@ -0,0 +1 @@
export const file16 = 16
@@ -0,0 +1 @@
export const file17 = 17
@@ -0,0 +1 @@
export const file18 = 18
@@ -0,0 +1 @@
export const file19 = 19
@@ -0,0 +1 @@
export const file20 = 20
@@ -0,0 +1 @@
# fixture src
@@ -0,0 +1 @@
export const file01 = 1
@@ -0,0 +1 @@
export const file02 = 2
@@ -0,0 +1 @@
export const file03 = 3
@@ -0,0 +1 @@
export const file04 = 4
@@ -0,0 +1 @@
export const file05 = 5
@@ -0,0 +1 @@
export const file06 = 6
@@ -0,0 +1 @@
export const file07 = 7
@@ -0,0 +1 @@
export const file08 = 8
@@ -0,0 +1 @@
export const file09 = 9
@@ -0,0 +1 @@
export const file10 = 10
@@ -0,0 +1 @@
export const file11 = 11
@@ -0,0 +1 @@
export const file12 = 12
@@ -0,0 +1 @@
export const file13 = 13
@@ -0,0 +1 @@
export const file14 = 14
@@ -0,0 +1 @@
export const file15 = 15
+121
View File
@@ -0,0 +1,121 @@
import { cpSync, mkdirSync, mkdtempSync, rmSync } from "node:fs"
import { tmpdir } from "node:os"
import { join } from "node:path"
import type { PluginInput } from "@opencode-ai/plugin"
import { createOpencodeClient } from "@opencode-ai/sdk"
import { describe, expect, it } from "bun:test"
type InitMetrics = {
coldMs: number
warmMs: [number, number]
medianMs: number
}
function getMedian(values: number[]): number {
const sorted = [...values].sort((left, right) => left - right)
return sorted[Math.floor(sorted.length / 2)] ?? 0
}
function createPluginInput(directory: string): PluginInput {
const client = createOpencodeClient({ directory })
return {
client,
project: {
id: `perf-${Date.now()}`,
worktree: directory,
time: { created: Date.now() },
},
directory,
worktree: directory,
serverUrl: new URL("http://localhost"),
$: Bun.$,
}
}
async function importFreshPluginModule(): Promise<(typeof import("../../index"))["default"]> {
const token = `${Date.now()}-${Math.random()}`
return (await import(`../../index?perf=${token}`)).default
}
async function measureInitMetrics(directory: string): Promise<InitMetrics> {
const pluginModule = await importFreshPluginModule()
const measurements: number[] = []
for (let index = 0; index < 3; index += 1) {
const input = createPluginInput(directory)
const start = performance.now()
await pluginModule.server(input, {})
measurements.push(performance.now() - start)
}
return {
coldMs: measurements[0] ?? 0,
warmMs: [measurements[1] ?? 0, measurements[2] ?? 0],
medianMs: getMedian(measurements),
}
}
async function measureScenario(
label: string,
populateDirectory: (directory: string) => void,
): Promise<InitMetrics> {
const rootDirectory = mkdtempSync(join(tmpdir(), "perf-d09-"))
const projectDirectory = join(rootDirectory, label)
const configDirectory = join(rootDirectory, "opencode-config")
const previousConfigDirectory = process.env.OPENCODE_CONFIG_DIR
mkdirSync(configDirectory, { recursive: true })
process.env.OPENCODE_CONFIG_DIR = configDirectory
try {
populateDirectory(projectDirectory)
return await measureInitMetrics(projectDirectory)
} finally {
if (previousConfigDirectory === undefined) {
delete process.env.OPENCODE_CONFIG_DIR
} else {
process.env.OPENCODE_CONFIG_DIR = previousConfigDirectory
}
rmSync(rootDirectory, { recursive: true, force: true })
}
}
function logMetrics(label: string, metrics: InitMetrics): void {
console.info(
`${label}: cold=${metrics.coldMs.toFixed(1)}ms warm=[${metrics.warmMs.map((value) => value.toFixed(1)).join(", ")}] median=${metrics.medianMs.toFixed(1)}ms`,
)
}
describe("plugin init performance", () => {
it("stays within the empty project init budget", async () => {
// given
const metrics = await measureScenario("empty-project", (directory) => {
mkdirSync(directory, { recursive: true })
})
// when
logMetrics("empty-project", metrics)
// then
// regression budget
expect(metrics.medianMs).toBeLessThan(500)
})
it("stays within the in-tree fixture init budget", async () => {
// given
const fixtureDirectory = new URL("./fixtures/in-tree/", import.meta.url)
const metrics = await measureScenario("in-tree-fixture", (directory) => {
cpSync(fixtureDirectory, directory, { recursive: true })
})
// when
logMetrics("in-tree-fixture", metrics)
// then
// regression budget
expect(metrics.medianMs).toBeLessThan(700)
})
})
@@ -0,0 +1,37 @@
import { promises as fs } from "fs"
import { resolve } from "path"
import type { CommandDefinition } from "./types"
const commandLoaderCache = new Map<string, Promise<Record<string, CommandDefinition>>>()
export async function getCommandLoaderCacheKey(directory?: string): Promise<string> {
const resolvedDirectory = resolve(directory ?? process.cwd())
try {
return await fs.realpath(resolvedDirectory)
} catch {
return resolvedDirectory
}
}
export function getCachedCommands(
cacheKey: string,
): Promise<Record<string, CommandDefinition>> | undefined {
return commandLoaderCache.get(cacheKey)
}
export function setCachedCommands(
cacheKey: string,
commands: Promise<Record<string, CommandDefinition>>,
): void {
commandLoaderCache.set(cacheKey, commands)
}
export function deleteCachedCommands(cacheKey: string): void {
commandLoaderCache.delete(cacheKey)
}
export function clearCommandLoaderCache(): void {
commandLoaderCache.clear()
}
@@ -1,9 +1,10 @@
import { execFileSync } from "node:child_process"
import { afterEach, beforeEach, describe, expect, it } from "bun:test"
import { promises as fs } from "node:fs"
import { afterEach, beforeEach, describe, expect, it, spyOn } from "bun:test"
import { mkdirSync, rmSync, writeFileSync } from "node:fs"
import { tmpdir } from "node:os"
import { join } from "node:path"
import { loadOpencodeGlobalCommands, loadOpencodeProjectCommands } from "./loader"
import * as loader from "./loader"
const TEST_DIR = join(tmpdir(), `claude-code-command-loader-${Date.now()}`)
@@ -16,19 +17,41 @@ function writeCommand(directory: string, name: string, description: string): voi
}
describe("claude-code command loader", () => {
let originalClaudeConfigDir: string | undefined
let originalOpencodeConfigDir: string | undefined
beforeEach(() => {
mkdirSync(TEST_DIR, { recursive: true })
originalClaudeConfigDir = process.env.CLAUDE_CONFIG_DIR
originalOpencodeConfigDir = process.env.OPENCODE_CONFIG_DIR
const claudeConfigDir = join(TEST_DIR, "claude-config")
const opencodeConfigDir = join(TEST_DIR, "opencode-config")
process.env.CLAUDE_CONFIG_DIR = claudeConfigDir
process.env.OPENCODE_CONFIG_DIR = opencodeConfigDir
if ("clearCommandLoaderCache" in loader && typeof loader.clearCommandLoaderCache === "function") {
loader.clearCommandLoaderCache()
}
})
afterEach(() => {
if (originalClaudeConfigDir === undefined) {
delete process.env.CLAUDE_CONFIG_DIR
} else {
process.env.CLAUDE_CONFIG_DIR = originalClaudeConfigDir
}
if (originalOpencodeConfigDir === undefined) {
delete process.env.OPENCODE_CONFIG_DIR
} else {
process.env.OPENCODE_CONFIG_DIR = originalOpencodeConfigDir
}
if ("clearCommandLoaderCache" in loader && typeof loader.clearCommandLoaderCache === "function") {
loader.clearCommandLoaderCache()
}
rmSync(TEST_DIR, { recursive: true, force: true })
})
@@ -39,7 +62,7 @@ describe("claude-code command loader", () => {
writeCommand(join(projectDir, ".opencode", "commands"), "ancestor", "Ancestor command")
// when
const commands = await loadOpencodeProjectCommands(childDir)
const commands = await loader.loadOpencodeProjectCommands(childDir)
// then
expect(commands.ancestor?.description).toBe("(opencode-project) Ancestor command")
@@ -50,7 +73,7 @@ describe("claude-code command loader", () => {
writeCommand(join(TEST_DIR, ".opencode", "command"), "singular", "Singular command")
// when
const commands = await loadOpencodeProjectCommands(TEST_DIR)
const commands = await loader.loadOpencodeProjectCommands(TEST_DIR)
// then
expect(commands.singular?.description).toBe("(opencode-project) Singular command")
@@ -66,7 +89,7 @@ describe("claude-code command loader", () => {
writeCommand(projectDir, "duplicate", "Nearest command")
// when
const commands = await loadOpencodeProjectCommands(childDir)
const commands = await loader.loadOpencodeProjectCommands(childDir)
// then
expect(commands.duplicate?.description).toBe("(opencode-project) Nearest command")
@@ -79,7 +102,7 @@ describe("claude-code command loader", () => {
writeCommand(join(opencodeConfigDir, "commands"), "global-plural", "Global plural command")
// when
const commands = await loadOpencodeGlobalCommands()
const commands = await loader.loadOpencodeGlobalCommands()
// then
expect(commands["global-plural"]?.description).toBe("(opencode) Global plural command")
@@ -94,7 +117,7 @@ describe("claude-code command loader", () => {
writeCommand(join(profileConfigDir, "commands"), "duplicate-global", "Profile global command")
// when
const commands = await loadOpencodeGlobalCommands()
const commands = await loader.loadOpencodeGlobalCommands()
// then
expect(commands["duplicate-global"]?.description).toBe("(opencode) Profile global command")
@@ -114,7 +137,7 @@ describe("claude-code command loader", () => {
writeCommand(join(TEST_DIR, ".opencode", "commands"), "outside", "Outside command")
// when
const commands = await loadOpencodeProjectCommands(nestedDirectory)
const commands = await loader.loadOpencodeProjectCommands(nestedDirectory)
// then
expect(commands["deploy/staging"]?.description).toBe("(opencode-project) Deploy staging")
@@ -122,4 +145,38 @@ describe("claude-code command loader", () => {
expect(commands.outside).toBeUndefined()
expect(commands["deploy:staging"]).toBeUndefined()
})
it("#given commands nested under an excluded basename #when loadProjectCommands is called #then it skips the excluded directory contents", async () => {
// given
writeCommand(join(TEST_DIR, ".claude", "commands"), "real", "Real command")
writeCommand(
join(TEST_DIR, ".claude", "commands", "node_modules"),
"fake",
"Fake command",
)
// when
const commands = await loader.loadProjectCommands(TEST_DIR)
// then
expect(commands.real?.description).toBe("(project) Real command")
expect(commands.fake).toBeUndefined()
})
it("#given a previously loaded directory #when loadAllCommands is called twice #then the second call reuses the cached result without readdir calls", async () => {
// given
writeCommand(join(TEST_DIR, ".claude", "commands"), "cached", "Cached command")
const readdirSpy = spyOn(fs, "readdir")
// when
const firstCommands = await loader.loadAllCommands(TEST_DIR)
const firstReaddirCount = readdirSpy.mock.calls.length
const secondCommands = await loader.loadAllCommands(TEST_DIR)
// then
expect(firstCommands.cached?.description).toBe("(project) Cached command")
expect(secondCommands).toEqual(firstCommands)
expect(firstReaddirCount).toBeGreaterThan(0)
expect(readdirSpy.mock.calls.length).toBe(firstReaddirCount)
})
})
@@ -4,13 +4,23 @@ import { parseFrontmatter } from "../../shared/frontmatter"
import { sanitizeModelField } from "../../shared/model-sanitizer"
import { isMarkdownFile } from "../../shared/file-utils"
import {
EXCLUDED_DIRS,
findProjectOpencodeCommandDirs,
getClaudeConfigDir,
getOpenCodeCommandDirs,
} from "../../shared"
import { log } from "../../shared/logger"
import {
clearCommandLoaderCache,
deleteCachedCommands,
getCachedCommands,
getCommandLoaderCacheKey,
setCachedCommands,
} from "./loader-cache"
import type { CommandScope, CommandDefinition, CommandFrontmatter, LoadedCommand } from "./types"
export { clearCommandLoaderCache }
async function loadCommandsFromDir(
commandsDir: string,
scope: CommandScope,
@@ -48,6 +58,7 @@ async function loadCommandsFromDir(
for (const entry of entries) {
if (entry.isDirectory()) {
if (EXCLUDED_DIRS.has(entry.name)) continue
if (entry.name.startsWith(".")) continue
const subDirPath = join(commandsDir, entry.name)
const subPrefix = prefix ? `${prefix}/${entry.name}` : entry.name
@@ -159,11 +170,26 @@ export async function loadOpencodeProjectCommands(directory?: string): Promise<R
}
export async function loadAllCommands(directory?: string): Promise<Record<string, CommandDefinition>> {
const [user, project, global, projectOpencode] = await Promise.all([
const cacheKey = await getCommandLoaderCacheKey(directory)
const cachedCommands = getCachedCommands(cacheKey)
if (cachedCommands) {
return cachedCommands
}
const loadCommandsPromise = Promise.all([
loadUserCommands(),
loadProjectCommands(directory),
loadOpencodeGlobalCommands(),
loadOpencodeProjectCommands(directory),
])
return { ...projectOpencode, ...global, ...project, ...user }
.then(([user, project, global, projectOpencode]) => {
return { ...projectOpencode, ...global, ...project, ...user }
})
.catch((error) => {
deleteCachedCommands(cacheKey)
throw error
})
setCachedCommands(cacheKey, loadCommandsPromise)
return loadCommandsPromise
}
@@ -3,12 +3,19 @@
import { describe, it, expect, beforeEach, afterEach } from "bun:test"
import { join } from "node:path"
import { tmpdir } from "node:os"
import { resolveSkillContent, resolveMultipleSkills, resolveSkillContentAsync, resolveMultipleSkillsAsync } from "./skill-content"
import {
clearSkillCache,
resolveSkillContent,
resolveMultipleSkills,
resolveSkillContentAsync,
resolveMultipleSkillsAsync,
} from "./skill-content"
let originalEnv: Record<string, string | undefined>
let testConfigDir: string
beforeEach(() => {
clearSkillCache()
originalEnv = {
CLAUDE_CONFIG_DIR: process.env.CLAUDE_CONFIG_DIR,
OPENCODE_CONFIG_DIR: process.env.OPENCODE_CONFIG_DIR,
@@ -20,6 +27,7 @@ beforeEach(() => {
})
afterEach(() => {
clearSkillCache()
for (const [key, value] of Object.entries(originalEnv)) {
if (value !== undefined) {
process.env[key] = value
@@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it } from "bun:test"
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"
import { tmpdir } from "node:os"
import { join } from "node:path"
import { clearCommandLoaderCache } from "../../features/claude-code-command-loader"
import { executeSlashCommand } from "./executor"
const ENV_KEYS = [
@@ -95,6 +96,7 @@ describe("auto-slash command executor plugin dispatch", () => {
let envSnapshot: EnvSnapshot
beforeEach(() => {
clearCommandLoaderCache()
tempDir = mkdtempSync(join(tmpdir(), "omo-executor-plugin-test-"))
envSnapshot = {
CLAUDE_CONFIG_DIR: process.env.CLAUDE_CONFIG_DIR,
@@ -106,6 +108,7 @@ describe("auto-slash command executor plugin dispatch", () => {
})
afterEach(() => {
clearCommandLoaderCache()
for (const key of ENV_KEYS) {
const previousValue = envSnapshot[key]
if (previousValue === undefined) {
@@ -2,6 +2,7 @@ import { describe, expect, it, beforeEach, afterEach, spyOn, mock } from "bun:te
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"
import { tmpdir } from "node:os"
import { join } from "node:path"
import { clearCommandLoaderCache } from "../../features/claude-code-command-loader"
import type { LoadedSkill } from "../../features/opencode-skill-loader/types"
import type {
AutoSlashCommandHookInput,
@@ -43,6 +44,7 @@ describe("createAutoSlashCommandHook", () => {
let createAutoSlashCommandHook: AutoSlashCommandModule["createAutoSlashCommandHook"]
beforeEach(async () => {
clearCommandLoaderCache()
mock.restore()
logCalls = []
spyOn(shared, "log").mockImplementation((message: string, data?: unknown) => {
@@ -56,6 +58,7 @@ describe("createAutoSlashCommandHook", () => {
})
afterEach(() => {
clearCommandLoaderCache()
process.chdir(originalWorkingDirectory)
rmSync(tempDir, { recursive: true, force: true })
mock.restore()
+261
View File
@@ -0,0 +1,261 @@
import type { PluginInput } from "@opencode-ai/plugin"
import { describe, expect, mock, test } from "bun:test"
type CreateAutoUpdateCheckerHook = typeof import("./hook").createAutoUpdateCheckerHook
type HookOptions = Parameters<CreateAutoUpdateCheckerHook>[1]
type HookDeps = NonNullable<Parameters<CreateAutoUpdateCheckerHook>[2]>
let latestVersionCallCount = 0
let scheduleDeferredStartupCheckCallCount = 0
const flushMicrotasks = async (count: number): Promise<void> => {
for (let index = 0; index < count; index += 1) {
await Promise.resolve()
}
}
const latestVersionMock = async () => {
latestVersionCallCount += 1
return "3.0.1"
}
const scheduleDeferredStartupCheckMock = (runCheck: () => void) => {
scheduleDeferredStartupCheckCallCount += 1
scheduledCheck = runCheck
}
let scheduledCheck: (() => void) | null = null
mock.module("./checker/latest-version", () => ({
getLatestVersion: latestVersionMock,
}))
mock.module("./hook/deferred-startup-check", () => ({
scheduleDeferredStartupCheck: scheduleDeferredStartupCheckMock,
}))
const createPluginInput = (): PluginInput => ({
client: {} as PluginInput["client"],
directory: "/tmp/project",
project: {} as PluginInput["project"],
worktree: "/tmp/project",
serverUrl: new URL("https://example.com"),
$: {} as PluginInput["$"],
} satisfies PluginInput)
const createDeps = (overrides: Partial<HookDeps> = {}) => {
const showConfigErrorsIfAny = mock(async () => undefined)
const updateAndShowConnectedProvidersCacheStatus = mock(async () => undefined)
const refreshModelCapabilitiesOnStartup = mock(async () => undefined)
const showModelCacheWarningIfNeeded = mock(async () => undefined)
const showLocalDevToast = mock(async () => undefined)
const showVersionToast = mock(async () => undefined)
const runBackgroundUpdateCheck = mock(async () => {
await latestVersionMock()
})
const deps: HookDeps = {
getCachedVersion: () => "3.0.0",
getLocalDevVersion: () => null,
showConfigErrorsIfAny,
updateAndShowConnectedProvidersCacheStatus,
refreshModelCapabilitiesOnStartup,
showModelCacheWarningIfNeeded,
showLocalDevToast,
showVersionToast,
runBackgroundUpdateCheck,
log: () => undefined,
...overrides,
}
return {
deps,
mocks: {
showConfigErrorsIfAny,
updateAndShowConnectedProvidersCacheStatus,
refreshModelCapabilitiesOnStartup,
showModelCacheWarningIfNeeded,
showLocalDevToast,
showVersionToast,
runBackgroundUpdateCheck,
},
}
}
const createHook = async (
options: HookOptions = {},
overrides: Partial<HookDeps> = {},
) => {
const module = await import("./hook")
const { deps, mocks } = createDeps(overrides)
return {
hook: module.createAutoUpdateCheckerHook(
createPluginInput(),
{
showStartupToast: true,
autoUpdate: false,
...options,
},
deps,
),
mocks,
}
}
const resetDeferredState = (): void => {
latestVersionCallCount = 0
scheduleDeferredStartupCheckCallCount = 0
scheduledCheck = null
}
const runScheduledCheck = async (): Promise<void> => {
scheduledCheck?.()
await flushMicrotasks(8)
}
const triggerSessionCreated = (
hook: ReturnType<CreateAutoUpdateCheckerHook>,
properties?: { info?: { parentID?: string } },
): void => {
hook.event({ event: { type: "session.created", properties } })
}
const triggerSessionIdle = (hook: ReturnType<CreateAutoUpdateCheckerHook>): void => {
hook.event({ event: { type: "session.idle" } })
}
describe("auto-update-checker hook", () => {
test("schedules deferred check on session.created without parentID", async () => {
// given
resetDeferredState()
const { hook, mocks } = await createHook()
// when
triggerSessionCreated(hook)
// then
expect(scheduleDeferredStartupCheckCallCount).toBe(1)
expect(mocks.showVersionToast).not.toHaveBeenCalled()
expect(mocks.runBackgroundUpdateCheck).not.toHaveBeenCalled()
expect(latestVersionCallCount).toBe(0)
// when
await runScheduledCheck()
// then
expect(mocks.showVersionToast).toHaveBeenCalledTimes(1)
expect(mocks.runBackgroundUpdateCheck).toHaveBeenCalledTimes(1)
expect(latestVersionCallCount).toBe(1)
})
test("does not schedule deferred check on session.created with parentID", async () => {
// given
resetDeferredState()
const { hook, mocks } = await createHook()
// when
triggerSessionCreated(hook, { info: { parentID: "parent-123" } })
// then
expect(scheduleDeferredStartupCheckCallCount).toBe(0)
expect(mocks.showVersionToast).not.toHaveBeenCalled()
expect(mocks.runBackgroundUpdateCheck).not.toHaveBeenCalled()
})
test("does not schedule deferred check on session.idle without session.created", async () => {
// given
resetDeferredState()
const { hook, mocks } = await createHook()
// when
triggerSessionIdle(hook)
// then
expect(scheduleDeferredStartupCheckCallCount).toBe(0)
expect(mocks.showVersionToast).not.toHaveBeenCalled()
expect(mocks.runBackgroundUpdateCheck).not.toHaveBeenCalled()
})
test("runs all startup checks after deferred session.created check executes", async () => {
// given
resetDeferredState()
const { hook, mocks } = await createHook()
// when
triggerSessionCreated(hook)
await runScheduledCheck()
// then
expect(mocks.showConfigErrorsIfAny).toHaveBeenCalledTimes(1)
expect(mocks.updateAndShowConnectedProvidersCacheStatus).toHaveBeenCalledTimes(1)
expect(mocks.refreshModelCapabilitiesOnStartup).toHaveBeenCalledTimes(1)
expect(mocks.showModelCacheWarningIfNeeded).toHaveBeenCalledTimes(1)
expect(mocks.showVersionToast).toHaveBeenCalledTimes(1)
expect(mocks.runBackgroundUpdateCheck).toHaveBeenCalledTimes(1)
})
test("guards double execution across repeated session.created events", async () => {
// given
resetDeferredState()
const { hook, mocks } = await createHook()
// when
triggerSessionCreated(hook)
triggerSessionCreated(hook)
// then
expect(scheduleDeferredStartupCheckCallCount).toBe(1)
// when
await runScheduledCheck()
triggerSessionCreated(hook)
// then
expect(scheduleDeferredStartupCheckCallCount).toBe(1)
expect(mocks.showConfigErrorsIfAny).toHaveBeenCalledTimes(1)
expect(mocks.updateAndShowConnectedProvidersCacheStatus).toHaveBeenCalledTimes(1)
expect(mocks.showModelCacheWarningIfNeeded).toHaveBeenCalledTimes(1)
expect(mocks.showVersionToast).toHaveBeenCalledTimes(1)
expect(mocks.runBackgroundUpdateCheck).toHaveBeenCalledTimes(1)
})
test("shows localDevToast when local dev version exists", async () => {
// given
resetDeferredState()
const { hook, mocks } = await createHook({}, {
getLocalDevVersion: () => "3.0.0-dev",
})
// when
triggerSessionCreated(hook)
await runScheduledCheck()
// then
expect(mocks.showConfigErrorsIfAny).toHaveBeenCalledTimes(1)
expect(mocks.updateAndShowConnectedProvidersCacheStatus).toHaveBeenCalledTimes(1)
expect(mocks.showModelCacheWarningIfNeeded).toHaveBeenCalledTimes(1)
expect(mocks.showLocalDevToast).toHaveBeenCalledTimes(1)
expect(mocks.showVersionToast).not.toHaveBeenCalled()
expect(mocks.runBackgroundUpdateCheck).not.toHaveBeenCalled()
expect(latestVersionCallCount).toBe(0)
})
test("passes correct toast message with sisyphus enabled", async () => {
// given
resetDeferredState()
const { hook, mocks } = await createHook({ isSisyphusEnabled: true })
// when
triggerSessionCreated(hook)
await runScheduledCheck()
// then
expect(mocks.showVersionToast).toHaveBeenCalledTimes(1)
expect(mocks.showVersionToast).toHaveBeenCalledWith(
expect.anything(),
"3.0.0",
expect.stringContaining("Sisyphus"),
)
})
})
+42 -25
View File
@@ -3,6 +3,7 @@ import { log } from "../../shared/logger"
import type { AutoUpdateCheckerOptions } from "./types"
import { getCachedVersion, getLocalDevVersion } from "./checker"
import { runBackgroundUpdateCheck } from "./hook/background-update-check"
import { scheduleDeferredStartupCheck } from "./hook/deferred-startup-check"
import { showConfigErrorsIfAny } from "./hook/config-errors-toast"
import { updateAndShowConnectedProvidersCacheStatus } from "./hook/connected-providers-status"
import { refreshModelCapabilitiesOnStartup } from "./hook/model-capabilities-status"
@@ -35,6 +36,20 @@ const defaultDeps: AutoUpdateCheckerDeps = {
log,
}
const isRecord = (value: unknown): value is Record<string, unknown> => {
return typeof value === "object" && value !== null
}
const getParentID = (properties: unknown): string | undefined => {
if (!isRecord(properties)) return undefined
const { info } = properties
if (!isRecord(info)) return undefined
const { parentID } = info
return typeof parentID === "string" && parentID.length > 0 ? parentID : undefined
}
export function createAutoUpdateCheckerHook(
ctx: PluginInput,
options: AutoUpdateCheckerOptions = {},
@@ -60,44 +75,46 @@ export function createAutoUpdateCheckerHook(
}
let hasChecked = false
let hasScheduled = false
return {
event: ({ event }: { event: { type: string; properties?: unknown } }) => {
if (event.type !== "session.created") return
if (isCliRunMode) return
if (hasChecked) return
if (hasChecked || hasScheduled) return
if (getParentID(event.properties)) return
const props = event.properties as { info?: { parentID?: string } } | undefined
if (props?.info?.parentID) return
hasScheduled = true
scheduleDeferredStartupCheck(() => {
hasChecked = true
void (async () => {
const cachedVersion = deps.getCachedVersion()
const localDevVersion = deps.getLocalDevVersion(ctx.directory)
const displayVersion = localDevVersion ?? cachedVersion
setTimeout(async () => {
const cachedVersion = deps.getCachedVersion()
const localDevVersion = deps.getLocalDevVersion(ctx.directory)
const displayVersion = localDevVersion ?? cachedVersion
await deps.showConfigErrorsIfAny(ctx)
await deps.updateAndShowConnectedProvidersCacheStatus(ctx)
await deps.refreshModelCapabilitiesOnStartup(modelCapabilities)
await deps.showModelCacheWarningIfNeeded(ctx)
await deps.showConfigErrorsIfAny(ctx)
await deps.updateAndShowConnectedProvidersCacheStatus(ctx)
await deps.refreshModelCapabilitiesOnStartup(modelCapabilities)
await deps.showModelCacheWarningIfNeeded(ctx)
if (localDevVersion) {
if (showStartupToast) {
deps.showLocalDevToast(ctx, displayVersion, isSisyphusEnabled).catch(() => {})
if (localDevVersion) {
if (showStartupToast) {
deps.showLocalDevToast(ctx, displayVersion, isSisyphusEnabled).catch(() => {})
}
deps.log("[auto-update-checker] Local development mode")
return
}
deps.log("[auto-update-checker] Local development mode")
return
}
if (showStartupToast) {
deps.showVersionToast(ctx, displayVersion, getToastMessage(false)).catch(() => {})
}
if (showStartupToast) {
deps.showVersionToast(ctx, displayVersion, getToastMessage(false)).catch(() => {})
}
deps.runBackgroundUpdateCheck(ctx, autoUpdate, getToastMessage).catch((err) => {
deps.log("[auto-update-checker] Background update check failed:", err)
})
}, 0)
deps.runBackgroundUpdateCheck(ctx, autoUpdate, getToastMessage).catch((err) => {
deps.log("[auto-update-checker] Background update check failed:", err)
})
})()
})
},
}
}
@@ -0,0 +1,4 @@
export function scheduleDeferredStartupCheck(runCheck: () => void): void {
const timeout = setTimeout(runCheck, 5000)
timeout.unref?.()
}
@@ -0,0 +1,51 @@
import { describe, expect, it, mock, afterAll } from "bun:test"
const startPendingCallCleanup = mock(() => {})
const initializeCommentCheckerCli = mock(() => {})
mock.module("./cli-runner", () => ({
initializeCommentCheckerCli,
getCommentCheckerCliPathPromise: () => Promise.resolve("/tmp/fake-comment-checker"),
isCliPathUsable: () => true,
processWithCli: async () => {},
processApplyPatchEditsWithCli: async () => {},
}))
mock.module("./pending-calls", () => ({
registerPendingCall: () => {},
startPendingCallCleanup,
stopPendingCallCleanup: () => {},
takePendingCall: () => undefined,
}))
afterAll(() => {
mock.restore()
})
const { createCommentCheckerHooks } = await import("./hook")
describe("comment-checker lazy initialization", () => {
it("initializes CLI and cleanup on first tool hook call only", async () => {
// given
const hooks = createCommentCheckerHooks()
const beforeHook = hooks["tool.execute.before"]
const input = { tool: "write", sessionID: "ses_test", callID: "call_test" }
const output = { args: { filePath: "src/a.ts" } }
// when
expect(startPendingCallCleanup).toHaveBeenCalledTimes(0)
expect(initializeCommentCheckerCli).toHaveBeenCalledTimes(0)
// then
await beforeHook(input, output)
expect(startPendingCallCleanup).toHaveBeenCalledTimes(1)
expect(initializeCommentCheckerCli).toHaveBeenCalledTimes(1)
// when
await beforeHook(input, output)
// then
expect(startPendingCallCleanup).toHaveBeenCalledTimes(1)
expect(initializeCommentCheckerCli).toHaveBeenCalledTimes(1)
})
})
+6 -3
View File
@@ -28,6 +28,7 @@ import {
stopPendingCallCleanup,
takePendingCall,
} from "./pending-calls"
import { ensureCommentCheckerInitialization } from "./initialization-gate"
import * as fs from "fs"
import { tmpdir } from "os"
@@ -48,14 +49,16 @@ function debugLog(...args: unknown[]) {
export function createCommentCheckerHooks(config?: CommentCheckerConfig) {
debugLog("createCommentCheckerHooks called", { config })
startPendingCallCleanup()
initializeCommentCheckerCli(debugLog)
return {
"tool.execute.before": async (
input: { tool: string; sessionID: string; callID: string },
output: { args: Record<string, unknown> },
): Promise<void> => {
ensureCommentCheckerInitialization(() => {
startPendingCallCleanup()
initializeCommentCheckerCli(debugLog)
})
debugLog("tool.execute.before:", {
tool: input.tool,
callID: input.callID,
@@ -0,0 +1,7 @@
let initialized = false
export function ensureCommentCheckerInitialization(initializer: () => void): void {
if (initialized) return
initialized = true
initializer()
}
@@ -1,4 +1,4 @@
import { existsSync } from "node:fs";
import { constants, promises as fsPromises } from "node:fs";
import { dirname, isAbsolute, join, resolve } from "node:path";
import { AGENTS_FILENAME } from "./constants";
@@ -9,10 +9,10 @@ export function resolveFilePath(rootDirectory: string, path: string): string | n
return resolve(rootDirectory, path);
}
export function findAgentsMdUp(input: {
export async function findAgentsMdUp(input: {
startDir: string;
rootDir: string;
}): string[] {
}): Promise<string[]> {
const found: string[] = [];
let current = input.startDir;
@@ -22,7 +22,11 @@ export function findAgentsMdUp(input: {
const isRootDir = current === input.rootDir;
if (!isRootDir) {
const agentsPath = join(current, AGENTS_FILENAME);
if (existsSync(agentsPath)) {
const exists = await fsPromises
.access(agentsPath, constants.F_OK)
.then(() => true)
.catch(() => false);
if (exists) {
found.push(agentsPath);
}
}
@@ -84,6 +84,23 @@ describe("processFilePathForAgentsInjection", () => {
expect(output.output).toContain(srcAgentsContent)
})
it("finds AGENTS.md files while walking up directories", async () => {
// given
const { findAgentsMdUp } = await import("./finder")
// when
const agentsPaths = await findAgentsMdUp({
startDir: componentsDirectory,
rootDir: testRoot,
})
// then
expect(agentsPaths).toEqual([
join(srcDirectory, "AGENTS.md"),
join(componentsDirectory, "AGENTS.md"),
])
})
it("skips root-level AGENTS.md", async () => {
// given
rmSync(join(srcDirectory, "AGENTS.md"), { force: true })
@@ -1,5 +1,5 @@
import type { PluginInput } from "@opencode-ai/plugin";
import { readFileSync } from "node:fs";
import { promises as fsPromises } from "node:fs";
import { dirname } from "node:path";
import type { createDynamicTruncator } from "../../shared/dynamic-truncator";
@@ -31,7 +31,7 @@ export async function processFilePathForAgentsInjection(input: {
const dir = dirname(resolved);
const cache = getSessionCache(input.sessionCaches, input.sessionID);
const agentsPaths = findAgentsMdUp({ startDir: dir, rootDir: input.ctx.directory });
const agentsPaths = await findAgentsMdUp({ startDir: dir, rootDir: input.ctx.directory });
let dirty = false;
for (const agentsPath of agentsPaths) {
@@ -39,7 +39,8 @@ export async function processFilePathForAgentsInjection(input: {
if (cache.has(agentsDir)) continue;
try {
const content = readFileSync(agentsPath, "utf-8");
const content = await fsPromises.readFile(agentsPath, "utf-8");
cache.add(agentsDir);
const { result, truncated } = await input.truncator.truncate(
input.sessionID,
content,
@@ -48,7 +49,6 @@ export async function processFilePathForAgentsInjection(input: {
? `\n\n[Note: Content was truncated to save context window space. For full context, please read the file directly: ${agentsPath}]`
: "";
input.output.output += `\n\n[Directory Context: ${agentsPath}]\n${result}${truncationNotice}`;
cache.add(agentsDir);
dirty = true;
} catch {}
}
@@ -1,4 +1,4 @@
import { existsSync } from "node:fs";
import { access } from "node:fs/promises";
import { dirname, isAbsolute, join, resolve } from "node:path";
import { README_FILENAME } from "./constants";
@@ -9,17 +9,19 @@ export function resolveFilePath(rootDirectory: string, path: string): string | n
return resolve(rootDirectory, path);
}
export function findReadmeMdUp(input: {
export async function findReadmeMdUp(input: {
startDir: string;
rootDir: string;
}): string[] {
}): Promise<string[]> {
const found: string[] = [];
let current = input.startDir;
while (true) {
const readmePath = join(current, README_FILENAME);
if (existsSync(readmePath)) {
try {
await access(readmePath);
found.push(readmePath);
} catch {
}
if (current === input.rootDir) break;
@@ -133,6 +133,32 @@ describe("processFilePathForReadmeInjection", () => {
expect(output.output).toContain("# Components README")
})
it("returns a promise and finds README.md files from temp fixtures", async () => {
// given
const sourceDirectory = join(testRoot, "src")
const componentsDirectory = join(sourceDirectory, "components")
mkdirSync(componentsDirectory, { recursive: true })
writeFileSync(join(testRoot, "README.md"), "# Root README")
writeFileSync(join(sourceDirectory, "README.md"), "# Src README")
writeFileSync(join(componentsDirectory, "README.md"), "# Components README")
const { findReadmeMdUp } = await import("./finder")
// when
const promise = findReadmeMdUp({
startDir: componentsDirectory,
rootDir: testRoot,
})
// then
expect(promise).toBeInstanceOf(Promise)
await expect(promise).resolves.toEqual([
join(testRoot, "README.md"),
join(sourceDirectory, "README.md"),
join(componentsDirectory, "README.md"),
])
})
it("does not re-inject already cached directories", async () => {
// given
const sourceDirectory = join(testRoot, "src")
@@ -1,5 +1,5 @@
import type { PluginInput } from "@opencode-ai/plugin";
import { readFileSync } from "node:fs";
import { readFile } from "node:fs/promises";
import { dirname } from "node:path";
import type { createDynamicTruncator } from "../../shared/dynamic-truncator";
@@ -31,7 +31,7 @@ export async function processFilePathForReadmeInjection(input: {
const dir = dirname(resolved);
const cache = getSessionCache(input.sessionCaches, input.sessionID);
const readmePaths = findReadmeMdUp({ startDir: dir, rootDir: input.ctx.directory });
const readmePaths = await findReadmeMdUp({ startDir: dir, rootDir: input.ctx.directory });
let dirty = false;
for (const readmePath of readmePaths) {
@@ -39,7 +39,7 @@ export async function processFilePathForReadmeInjection(input: {
if (cache.has(readmeDir)) continue;
try {
const content = readFileSync(readmePath, "utf-8");
const content = await readFile(readmePath, "utf-8");
const { result, truncated } = await input.truncator.truncate(
input.sessionID,
content,
+74
View File
@@ -0,0 +1,74 @@
import { afterEach, describe, expect, it } from "bun:test";
import { randomUUID } from "node:crypto";
import { existsSync } from "node:fs";
import { join } from "node:path";
import { createSessionCacheStore } from "./cache";
import { RULES_INJECTOR_STORAGE } from "./constants";
import { clearInjectedRules, saveInjectedRules } from "./storage";
const trackedSessionIDs: string[] = [];
function createSessionID(prefix: string): string {
const sessionID = `${prefix}-${randomUUID()}`;
trackedSessionIDs.push(sessionID);
return sessionID;
}
function getStoragePath(sessionID: string): string {
return join(RULES_INJECTOR_STORAGE, `${sessionID}.json`);
}
afterEach(() => {
for (const sessionID of trackedSessionIDs.splice(0)) {
clearInjectedRules(sessionID);
}
});
describe("createSessionCacheStore", () => {
it("keeps factory instances isolated for the same session", () => {
// given
const sessionID = createSessionID("cache-isolation");
const firstStore = createSessionCacheStore();
const secondStore = createSessionCacheStore();
const firstCache = firstStore.getSessionCache(sessionID);
// when
firstCache.contentHashes.add("hash:first");
firstCache.realPaths.add("/tmp/first-rule.md");
const secondCache = secondStore.getSessionCache(sessionID);
// then
expect([...secondCache.contentHashes]).toEqual([]);
expect([...secondCache.realPaths]).toEqual([]);
});
it("clears only the targeted session cache and persisted state", () => {
// given
const deletedSessionID = createSessionID("deleted-session");
const retainedSessionID = createSessionID("retained-session");
saveInjectedRules(deletedSessionID, {
contentHashes: new Set(["hash:deleted"]),
realPaths: new Set(["/tmp/deleted-rule.md"]),
});
saveInjectedRules(retainedSessionID, {
contentHashes: new Set(["hash:retained"]),
realPaths: new Set(["/tmp/retained-rule.md"]),
});
const store = createSessionCacheStore();
store.getSessionCache(deletedSessionID);
const retainedCache = store.getSessionCache(retainedSessionID);
// when
store.clearSessionCache(deletedSessionID);
const reloadedRetainedCache = store.getSessionCache(retainedSessionID);
// then
expect(existsSync(getStoragePath(deletedSessionID))).toBe(false);
expect(existsSync(getStoragePath(retainedSessionID))).toBe(true);
expect(reloadedRetainedCache).toBe(retainedCache);
expect([...reloadedRetainedCache.contentHashes]).toEqual(["hash:retained"]);
expect([...reloadedRetainedCache.realPaths]).toEqual(["/tmp/retained-rule.md"]);
});
});
+28
View File
@@ -1,4 +1,6 @@
import { clearInjectedRules, loadInjectedRules } from "./storage";
import { createRuleScanCache } from "./rule-scan-cache";
import type { RuleScanCache } from "./rule-scan-cache";
export type SessionInjectedRulesCache = {
contentHashes: Set<string>;
@@ -25,3 +27,29 @@ export function createSessionCacheStore(): {
return { getSessionCache, clearSessionCache };
}
export function createSessionRuleScanCacheStore(): {
getSessionRuleScanCache: (sessionID: string) => RuleScanCache;
clearSessionRuleScanCache: (sessionID: string) => void;
} {
const sessionCaches = new Map<string, RuleScanCache>();
function getSessionRuleScanCache(sessionID: string): RuleScanCache {
const existingCache = sessionCaches.get(sessionID);
if (existingCache) {
return existingCache;
}
const cache = createRuleScanCache();
sessionCaches.set(sessionID, cache);
return cache;
}
function clearSessionRuleScanCache(sessionID: string): void {
const cache = sessionCaches.get(sessionID);
cache?.clear();
sessionCaches.delete(sessionID);
}
return { getSessionRuleScanCache, clearSessionRuleScanCache };
}
+14 -3
View File
@@ -1,8 +1,9 @@
import type { PluginInput } from "@opencode-ai/plugin";
import { createDynamicTruncator } from "../../shared/dynamic-truncator";
import { getRuleInjectionFilePath } from "./output-path";
import { createSessionCacheStore } from "./cache";
import { createSessionCacheStore, createSessionRuleScanCacheStore } from "./cache";
import { createRuleInjectionProcessor } from "./injector";
import { clearProjectRootCache } from "./project-root-finder";
interface ToolExecuteInput {
tool: string;
@@ -36,15 +37,23 @@ export function createRulesInjectorHook(
) {
const truncator = createDynamicTruncator(ctx, modelCacheState);
const { getSessionCache, clearSessionCache } = createSessionCacheStore();
const { getSessionRuleScanCache, clearSessionRuleScanCache } =
createSessionRuleScanCacheStore();
const { processFilePathForInjection } = createRuleInjectionProcessor({
workspaceDirectory: ctx.directory,
truncator,
getSessionCache,
getSessionRuleScanCache,
ruleFinderOptions: options?.skipClaudeUserRules
? { skipClaudeUserRules: true }
: undefined,
});
function clearSessionState(sessionID: string): void {
clearSessionCache(sessionID);
clearSessionRuleScanCache(sessionID);
}
const toolExecuteAfter = async (
input: ToolExecuteInput,
output: ToolExecuteOutput
@@ -73,16 +82,18 @@ export function createRulesInjectorHook(
if (event.type === "session.deleted") {
const sessionInfo = props?.info as { id?: string } | undefined;
if (sessionInfo?.id) {
clearSessionCache(sessionInfo.id);
clearSessionState(sessionInfo.id);
}
clearProjectRootCache();
}
if (event.type === "session.compacted") {
const sessionID = (props?.sessionID ??
(props?.info as { id?: string } | undefined)?.id) as string | undefined;
if (sessionID) {
clearSessionCache(sessionID);
clearSessionState(sessionID);
}
clearProjectRootCache();
}
};
+11 -1
View File
@@ -12,6 +12,7 @@ import {
import { parseRuleFrontmatter } from "./parser";
import { saveInjectedRules } from "./storage";
import type { SessionInjectedRulesCache } from "./cache";
import type { RuleScanCache } from "./rule-scan-cache";
import type { RuleMetadata } from "./types";
type ToolExecuteOutput = {
@@ -56,6 +57,7 @@ export function createRuleInjectionProcessor(deps: {
workspaceDirectory: string;
truncator: DynamicTruncator;
getSessionCache: (sessionID: string) => SessionInjectedRulesCache;
getSessionRuleScanCache?: (sessionID: string) => RuleScanCache;
ruleFinderOptions?: FindRuleFilesOptions;
readFileSync?: typeof readFileSync;
statSync?: typeof statSync;
@@ -76,6 +78,7 @@ export function createRuleInjectionProcessor(deps: {
workspaceDirectory,
truncator,
getSessionCache,
getSessionRuleScanCache,
ruleFinderOptions,
readFileSync: readRuleFileSync = readFileSync,
statSync: statRuleSync = statSync,
@@ -121,9 +124,16 @@ export function createRuleInjectionProcessor(deps: {
const projectRoot = findProjectRoot(resolved);
const cache = getSessionCache(sessionID);
const ruleScanCache = getSessionRuleScanCache?.(sessionID);
const home = getHomeDir();
const ruleFileCandidates = findRuleFiles(projectRoot, home, resolved, ruleFinderOptions);
const ruleFileCandidates = findRuleFiles(
projectRoot,
home,
resolved,
ruleFinderOptions,
ruleScanCache,
);
const toInject: RuleToInject[] = [];
let dirty = false;
@@ -0,0 +1,47 @@
import { afterEach, describe, expect, it, mock } from "bun:test";
describe("findProjectRoot", () => {
afterEach(async () => {
const actualFileSystem = await import("node:fs");
mock.module("node:fs", () => actualFileSystem);
});
it("memoizes repeated lookups for the same start path and resets on cache clear", async () => {
// given
const actualFileSystem = await import("node:fs");
const projectRoot = "/workspace/project";
const startPath = `${projectRoot}/src/file.ts`;
const packageJsonPath = `${projectRoot}/package.json`;
const existsSyncSpy = mock((path: string) => path === packageJsonPath);
const statSyncSpy = mock(() => ({ isDirectory: () => false }));
mock.module("node:fs", () => ({
...actualFileSystem,
existsSync: existsSyncSpy,
statSync: statSyncSpy,
}));
const { clearProjectRootCache, findProjectRoot } = await import(
`./project-root-finder.ts?memoization=${Date.now()}`
);
// when
const firstResult = findProjectRoot(startPath);
const firstExistsSyncCallCount = existsSyncSpy.mock.calls.length;
const secondResult = findProjectRoot(startPath);
const secondExistsSyncCallCount = existsSyncSpy.mock.calls.length;
clearProjectRootCache();
const thirdResult = findProjectRoot(startPath);
// then
expect(firstResult).toBe(projectRoot);
expect(secondResult).toBe(projectRoot);
expect(thirdResult).toBe(projectRoot);
expect(firstExistsSyncCallCount).toBeGreaterThan(0);
expect(secondExistsSyncCallCount).toBe(firstExistsSyncCallCount);
expect(existsSyncSpy).toHaveBeenCalledTimes(firstExistsSyncCallCount * 2);
});
});
@@ -2,6 +2,12 @@ import { existsSync, statSync } from "node:fs";
import { dirname, join } from "node:path";
import { PROJECT_MARKERS } from "./constants";
const projectRootCache = new Map<string, string | null>();
export function clearProjectRootCache(): void {
projectRootCache.clear();
}
/**
* Find project root by walking up from startPath.
* Checks for PROJECT_MARKERS (.git, pyproject.toml, package.json, etc.)
@@ -10,6 +16,16 @@ import { PROJECT_MARKERS } from "./constants";
* @returns Project root path or null if not found
*/
export function findProjectRoot(startPath: string): string | null {
if (projectRootCache.has(startPath)) {
return projectRootCache.get(startPath) ?? null;
}
const projectRoot = findProjectRootWithoutCache(startPath);
projectRootCache.set(startPath, projectRoot);
return projectRoot;
}
function findProjectRootWithoutCache(startPath: string): string | null {
let current: string;
try {
+111 -75
View File
@@ -1,51 +1,108 @@
import { existsSync, statSync } from "node:fs";
import { dirname, join } from "node:path";
import { dirname, join, sep } from "node:path";
import {
OPENCODE_USER_RULE_DIRS,
PROJECT_RULE_FILES,
PROJECT_RULE_SUBDIRS,
USER_RULE_DIR,
OPENCODE_USER_RULE_DIRS,
} from "./constants";
import type { RuleFileCandidate } from "./types";
import type { RuleScanCache } from "./rule-scan-cache";
import { findRuleFilesRecursive, safeRealpathSync } from "./rule-file-scanner";
import type { RuleFileCandidate } from "./types";
export interface FindRuleFilesOptions {
/**
* When true, skip loading rules from ~/.claude/rules/.
* Use when claude_code integration is disabled to prevent
* Claude Code-specific instructions from leaking into non-Claude agents.
*/
skipClaudeUserRules?: boolean;
}
/**
* Find all rule files for a given context.
* Searches from currentFile upward to projectRoot for rule directories,
* then user-level directory (~/.claude/rules).
*
* IMPORTANT: This searches EVERY directory from file to project root.
* Not just the project root itself.
*
* @param projectRoot - Project root path (or null if outside any project)
* @param homeDir - User home directory
* @param currentFile - Current file being edited (for distance calculation)
* @returns Array of rule file candidates sorted by distance
*/
function getUserRuleDirs(homeDir: string, skipClaudeUserRules: boolean): string[] {
const userRuleDirs = OPENCODE_USER_RULE_DIRS.map((dir) => join(homeDir, dir));
if (!skipClaudeUserRules) {
userRuleDirs.push(join(homeDir, USER_RULE_DIR));
}
return userRuleDirs;
}
function createCacheKey(
projectRoot: string | null,
startDir: string,
skipClaudeUserRules: boolean,
): string {
return `${projectRoot ?? ""}|${startDir}|${skipClaudeUserRules ? "1" : "0"}`;
}
function createCachedCandidate(
filePath: string,
projectRoot: string | null,
startDir: string,
userRuleDirs: string[],
): RuleFileCandidate | undefined {
const realPath = safeRealpathSync(filePath);
for (const userRuleDir of userRuleDirs) {
if (filePath.startsWith(`${userRuleDir}${sep}`)) {
return { path: filePath, realPath, isGlobal: true, distance: 9999 };
}
}
if (projectRoot) {
for (const ruleFile of PROJECT_RULE_FILES) {
if (filePath === join(projectRoot, ruleFile)) {
return {
path: filePath,
realPath,
isGlobal: false,
distance: 0,
isSingleFile: true,
};
}
}
}
let currentDir = startDir;
let distance = 0;
while (true) {
for (const [parent, subdir] of PROJECT_RULE_SUBDIRS) {
const ruleDir = join(currentDir, parent, subdir);
if (filePath.startsWith(`${ruleDir}${sep}`)) {
return { path: filePath, realPath, isGlobal: false, distance };
}
}
if (projectRoot && currentDir === projectRoot) break;
const parentDir = dirname(currentDir);
if (parentDir === currentDir) break;
currentDir = parentDir;
distance += 1;
}
return undefined;
}
export function findRuleFiles(
projectRoot: string | null,
homeDir: string,
currentFile: string,
options?: FindRuleFilesOptions,
cache?: RuleScanCache,
): RuleFileCandidate[] {
const startDir = dirname(currentFile);
const skipClaudeUserRules = options?.skipClaudeUserRules ?? false;
const userRuleDirs = getUserRuleDirs(homeDir, skipClaudeUserRules);
const cacheKey = createCacheKey(projectRoot, startDir, skipClaudeUserRules);
const cachedPaths = cache?.get(cacheKey);
if (cachedPaths) {
return cachedPaths
.map((filePath) => createCachedCandidate(filePath, projectRoot, startDir, userRuleDirs))
.filter((candidate): candidate is RuleFileCandidate => candidate !== undefined);
}
const candidates: RuleFileCandidate[] = [];
const seenRealPaths = new Set<string>();
// Search from current file's directory up to project root
let currentDir = dirname(currentFile);
let currentDir = startDir;
let distance = 0;
while (true) {
// Search rule directories in current directory
for (const [parent, subdir] of PROJECT_RULE_SUBDIRS) {
const ruleDir = join(currentDir, parent, subdir);
const files: string[] = [];
@@ -55,60 +112,41 @@ export function findRuleFiles(
const realPath = safeRealpathSync(filePath);
if (seenRealPaths.has(realPath)) continue;
seenRealPaths.add(realPath);
candidates.push({
path: filePath,
realPath,
isGlobal: false,
distance,
});
candidates.push({ path: filePath, realPath, isGlobal: false, distance });
}
}
// Stop at project root or filesystem root
if (projectRoot && currentDir === projectRoot) break;
const parentDir = dirname(currentDir);
if (parentDir === currentDir) break;
currentDir = parentDir;
distance++;
distance += 1;
}
// Check for single-file rules at project root (e.g., .github/copilot-instructions.md)
if (projectRoot) {
for (const ruleFile of PROJECT_RULE_FILES) {
const filePath = join(projectRoot, ruleFile);
if (existsSync(filePath)) {
try {
const stat = statSync(filePath);
if (stat.isFile()) {
const realPath = safeRealpathSync(filePath);
if (!seenRealPaths.has(realPath)) {
seenRealPaths.add(realPath);
candidates.push({
path: filePath,
realPath,
isGlobal: false,
distance: 0,
isSingleFile: true,
});
}
}
} catch {
// Skip if file can't be read
}
if (!existsSync(filePath)) continue;
try {
const stat = statSync(filePath);
if (!stat.isFile()) continue;
const realPath = safeRealpathSync(filePath);
if (seenRealPaths.has(realPath)) continue;
seenRealPaths.add(realPath);
candidates.push({
path: filePath,
realPath,
isGlobal: false,
distance: 0,
isSingleFile: true,
});
} catch {
continue;
}
}
}
// Search user-level rule directories
// Always search OpenCode-native dirs (~/.sisyphus/rules, ~/.opencode/rules)
const userRuleDirs: string[] = OPENCODE_USER_RULE_DIRS.map((dir) => join(homeDir, dir));
// Only search ~/.claude/rules when claude_code integration is not disabled
if (!options?.skipClaudeUserRules) {
userRuleDirs.push(join(homeDir, USER_RULE_DIR));
}
for (const userRuleDir of userRuleDirs) {
const userFiles: string[] = [];
findRuleFilesRecursive(userRuleDir, userFiles);
@@ -117,23 +155,21 @@ export function findRuleFiles(
const realPath = safeRealpathSync(filePath);
if (seenRealPaths.has(realPath)) continue;
seenRealPaths.add(realPath);
candidates.push({
path: filePath,
realPath,
isGlobal: true,
distance: 9999, // Global rules always have max distance
});
candidates.push({ path: filePath, realPath, isGlobal: true, distance: 9999 });
}
}
// Sort by distance (closest first, then global rules last)
candidates.sort((a, b) => {
if (a.isGlobal !== b.isGlobal) {
return a.isGlobal ? 1 : -1;
candidates.sort((left, right) => {
if (left.isGlobal !== right.isGlobal) {
return left.isGlobal ? 1 : -1;
}
return a.distance - b.distance;
return left.distance - right.distance;
});
cache?.set(
cacheKey,
candidates.map((candidate) => candidate.path),
);
return candidates;
}
@@ -0,0 +1,42 @@
import { afterEach, describe, expect, test } from "bun:test";
import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { randomUUID } from "node:crypto";
import { findRuleFilesRecursive } from "./rule-file-scanner";
const createdDirectories: string[] = [];
afterEach(() => {
for (const directory of createdDirectories.splice(0)) {
if (existsSync(directory)) {
rmSync(directory, { recursive: true, force: true });
}
}
});
describe("findRuleFilesRecursive", () => {
test("returns rule files outside excluded nested directories", () => {
// given
const temporaryDirectory = join(tmpdir(), `perf-d01-${randomUUID()}`);
createdDirectories.push(temporaryDirectory);
const rulesDirectory = join(temporaryDirectory, ".sisyphus", "rules");
mkdirSync(join(rulesDirectory, "node_modules", "fake"), { recursive: true });
mkdirSync(join(rulesDirectory, ".git"), { recursive: true });
writeFileSync(join(rulesDirectory, "foo.md"), "root rule");
writeFileSync(
join(rulesDirectory, "node_modules", "fake", "x.md"),
"ignored node_modules rule",
);
writeFileSync(join(rulesDirectory, ".git", "x.md"), "ignored git rule");
const results: string[] = [];
// when
findRuleFilesRecursive(rulesDirectory, results);
// then
expect(results).toEqual([join(rulesDirectory, "foo.md")]);
});
});
@@ -1,5 +1,6 @@
import { existsSync, readdirSync, realpathSync } from "node:fs";
import { join } from "node:path";
import { EXCLUDED_DIRS } from "../../shared";
import { GITHUB_INSTRUCTIONS_PATTERN, RULE_EXTENSIONS } from "./constants";
function isGitHubInstructionsDir(dir: string): boolean {
@@ -28,6 +29,7 @@ export function findRuleFilesRecursive(dir: string, results: string[]): void {
const fullPath = join(dir, entry.name);
if (entry.isDirectory()) {
if (EXCLUDED_DIRS.has(entry.name)) continue;
findRuleFilesRecursive(fullPath, results);
} else if (entry.isFile()) {
if (isValidRuleFile(entry.name, dir)) {
@@ -0,0 +1,91 @@
import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test";
import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
function createImportSuffix(): string {
return `?test=${Date.now()}-${Math.random()}`;
}
describe("createRuleScanCache", () => {
afterEach(() => {
mock.restore();
});
it("returns undefined before set, returns stored value, and clears entries", async () => {
// given
const { createRuleScanCache } = await import(`./rule-scan-cache${createImportSuffix()}`);
const cache = createRuleScanCache();
const value = ["a", "b"];
// when
const initialValue = cache.get("k1");
cache.set("k1", value);
const storedValue = cache.get("k1");
cache.clear();
const clearedValue = cache.get("k1");
// then
expect(initialValue).toBeUndefined();
expect(storedValue).toEqual(value);
expect(clearedValue).toBeUndefined();
});
});
describe("findRuleFiles with scan cache", () => {
let testRoot = "";
let homeDir = "";
let projectRoot = "";
let currentFile = "";
let expectedRuleFile = "";
let expectedRuleDir = "";
beforeEach(() => {
testRoot = join(tmpdir(), `rule-scan-cache-test-${Date.now()}`);
homeDir = join(testRoot, "home");
projectRoot = join(testRoot, "project");
currentFile = join(projectRoot, "src", "index.ts");
expectedRuleDir = join(projectRoot, ".github", "instructions");
expectedRuleFile = join(expectedRuleDir, "typescript.instructions.md");
mkdirSync(join(projectRoot, ".git"), { recursive: true });
mkdirSync(join(projectRoot, "src"), { recursive: true });
mkdirSync(homeDir, { recursive: true });
writeFileSync(currentFile, "export const value = 1;\n");
});
afterEach(() => {
mock.restore();
if (existsSync(testRoot)) {
rmSync(testRoot, { recursive: true, force: true });
}
});
it("reuses cached directory scan results for identical inputs", async () => {
// given
const findRuleFilesRecursive = mock((directoryPath: string, results: string[]) => {
if (directoryPath === expectedRuleDir) {
results.push(expectedRuleFile);
}
});
mock.module("./rule-file-scanner", () => ({
findRuleFilesRecursive,
safeRealpathSync: (filePath: string) => filePath,
}));
const { createRuleScanCache } = await import(`./rule-scan-cache${createImportSuffix()}`);
const { findRuleFiles } = await import(`./rule-file-finder${createImportSuffix()}`);
const cache = createRuleScanCache();
// when
const firstCandidates = findRuleFiles(projectRoot, homeDir, currentFile, undefined, cache);
const firstInvocationCount = findRuleFilesRecursive.mock.calls.length;
const secondCandidates = findRuleFiles(projectRoot, homeDir, currentFile, undefined, cache);
// then
expect(firstCandidates).toEqual(secondCandidates);
expect(firstInvocationCount).toBeGreaterThan(0);
expect(findRuleFilesRecursive).toHaveBeenCalledTimes(firstInvocationCount);
});
});
@@ -0,0 +1,21 @@
export type RuleScanCache = {
get: (key: string) => string[] | undefined;
set: (key: string, value: string[]) => void;
clear: () => void;
};
export function createRuleScanCache(): RuleScanCache {
const cache = new Map<string, string[]>();
return {
get(key: string): string[] | undefined {
return cache.get(key);
},
set(key: string, value: string[]): void {
cache.set(key, value);
},
clear(): void {
cache.clear();
},
};
}
+57
View File
@@ -0,0 +1,57 @@
import { afterEach, describe, expect, it } from "bun:test";
import { randomUUID } from "node:crypto";
import { existsSync } from "node:fs";
import { join } from "node:path";
import { RULES_INJECTOR_STORAGE } from "./constants";
import {
clearInjectedRules,
loadInjectedRules,
saveInjectedRules,
} from "./storage";
const trackedSessionIDs: string[] = [];
function createSessionID(prefix: string): string {
const sessionID = `${prefix}-${randomUUID()}`;
trackedSessionIDs.push(sessionID);
return sessionID;
}
function getStoragePath(sessionID: string): string {
return join(RULES_INJECTOR_STORAGE, `${sessionID}.json`);
}
afterEach(() => {
for (const sessionID of trackedSessionIDs.splice(0)) {
clearInjectedRules(sessionID);
}
});
describe("storage", () => {
it("reads back only the requested session data from session-scoped files", () => {
// given
const firstSessionID = createSessionID("storage-first");
const secondSessionID = createSessionID("storage-second");
saveInjectedRules(firstSessionID, {
contentHashes: new Set(["hash:first"]),
realPaths: new Set(["/tmp/first-rule.md"]),
});
saveInjectedRules(secondSessionID, {
contentHashes: new Set(["hash:second"]),
realPaths: new Set(["/tmp/second-rule.md"]),
});
// when
const firstLoaded = loadInjectedRules(firstSessionID);
const secondLoaded = loadInjectedRules(secondSessionID);
// then
expect(existsSync(getStoragePath(firstSessionID))).toBe(true);
expect(existsSync(getStoragePath(secondSessionID))).toBe(true);
expect([...firstLoaded.contentHashes]).toEqual(["hash:first"]);
expect([...firstLoaded.realPaths]).toEqual(["/tmp/first-rule.md"]);
expect([...secondLoaded.contentHashes]).toEqual(["hash:second"]);
expect([...secondLoaded.realPaths]).toEqual(["/tmp/second-rule.md"]);
});
});
+2 -1
View File
@@ -107,9 +107,10 @@ describe("createRuntimeFallbackHook dispose", () => {
globalThis.clearTimeout = originalClearTimeout
})
test("#given runtime-fallback hook created #when dispose() is called #then cleanup interval is cleared", () => {
test("#given runtime-fallback hook handles its first event #when dispose() is called #then cleanup interval is cleared", async () => {
// given
const hook = createRuntimeFallbackHook(createMockContext(), { pluginConfig: {} })
await hook.event({ event: { type: "session.created", properties: {} } })
// when
hook.dispose?.()
@@ -0,0 +1,124 @@
import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"
import type { OhMyOpenCodeConfig } from "../../config"
import type { HookDeps, RuntimeFallbackInterval, RuntimeFallbackPluginInput } from "./types"
type RuntimeFallbackModule = typeof import("./hook")
const loadPluginConfigMock = mock(() => ({} satisfies OhMyOpenCodeConfig))
const createAutoRetryHelpersMock = mock((_deps: HookDeps) => {
void _deps
return {
abortSessionRequest: async () => {},
clearSessionFallbackTimeout: () => {},
scheduleSessionFallbackTimeout: () => {},
autoRetryWithFallback: async () => {},
resolveAgentForSessionFromContext: async () => undefined,
cleanupStaleSessions: () => {},
}
})
const createEventHandlerMock = mock(() => async () => {})
const createMessageUpdateHandlerMock = mock(() => async () => {})
const createChatMessageHandlerMock = mock(() => async () => {})
function registerModuleMocks(): void {
mock.module("../../plugin-config", () => ({
loadPluginConfig: loadPluginConfigMock,
}))
mock.module("./auto-retry", () => ({
createAutoRetryHelpers: createAutoRetryHelpersMock,
}))
mock.module("./event-handler", () => ({
createEventHandler: createEventHandlerMock,
}))
mock.module("./message-update-handler", () => ({
createMessageUpdateHandler: createMessageUpdateHandlerMock,
}))
mock.module("./chat-message-handler", () => ({
createChatMessageHandler: createChatMessageHandlerMock,
}))
}
function createMockContext(): RuntimeFallbackPluginInput {
return {
client: {
session: {
abort: async () => ({}),
messages: async () => ({}),
promptAsync: async () => ({}),
},
tui: {
showToast: async () => ({}),
},
},
directory: "/test",
}
}
function createMockInterval(): RuntimeFallbackInterval {
return {
unref: () => {},
}
}
describe("createRuntimeFallbackHook initialization", () => {
const originalSetInterval = globalThis.setInterval
let setIntervalCalls = 0
let createRuntimeFallbackHook: RuntimeFallbackModule["createRuntimeFallbackHook"]
beforeEach(async () => {
mock.restore()
registerModuleMocks()
loadPluginConfigMock.mockClear()
createAutoRetryHelpersMock.mockClear()
createEventHandlerMock.mockClear()
createMessageUpdateHandlerMock.mockClear()
createChatMessageHandlerMock.mockClear()
setIntervalCalls = 0
globalThis.setInterval = ((callback: Parameters<typeof originalSetInterval>[0], delay?: number) => {
void callback
void delay
setIntervalCalls += 1
return createMockInterval() as ReturnType<typeof globalThis.setInterval>
}) as typeof globalThis.setInterval
const cacheBuster = `${Date.now()}-${Math.random()}`
const runtimeFallbackModule: RuntimeFallbackModule = await import(`./hook?test=${cacheBuster}`)
createRuntimeFallbackHook = runtimeFallbackModule.createRuntimeFallbackHook
})
afterEach(() => {
globalThis.setInterval = originalSetInterval
mock.restore()
})
test("#given injected pluginConfig #when the hook factory runs #then loadPluginConfig is not called", () => {
// given
const pluginConfig = {} satisfies OhMyOpenCodeConfig
// when
createRuntimeFallbackHook(createMockContext(), { pluginConfig })
// then
expect(loadPluginConfigMock).not.toHaveBeenCalled()
})
test("#given a fresh hook #when the first event arrives #then cleanup interval starts only once", async () => {
// given
const hook = createRuntimeFallbackHook(createMockContext(), { pluginConfig: {} })
// when
expect(setIntervalCalls).toBe(0)
await hook.event({ event: { type: "session.created", properties: {} } })
expect(setIntervalCalls).toBe(1)
await hook.event({ event: { type: "session.error", properties: {} } })
// then
expect(setIntervalCalls).toBe(1)
})
})
+20 -16
View File
@@ -1,7 +1,5 @@
import type { HookDeps, RuntimeFallbackHook, RuntimeFallbackInterval, RuntimeFallbackOptions, RuntimeFallbackPluginInput, RuntimeFallbackTimeout } from "./types"
import { DEFAULT_CONFIG, HOOK_NAME } from "./constants"
import { log } from "../../shared/logger"
import { loadPluginConfig } from "../../plugin-config"
import { DEFAULT_CONFIG } from "./constants"
import { createAutoRetryHelpers } from "./auto-retry"
import { createEventHandler } from "./event-handler"
import { createMessageUpdateHandler } from "./message-update-handler"
@@ -24,20 +22,11 @@ export function createRuntimeFallbackHook(
notify_on_fallback: options?.config?.notify_on_fallback ?? DEFAULT_CONFIG.notify_on_fallback,
}
let pluginConfig = options?.pluginConfig
if (!pluginConfig) {
try {
pluginConfig = loadPluginConfig(ctx.directory, ctx)
} catch {
log(`[${HOOK_NAME}] Plugin config not available`)
}
}
const deps: HookDeps = {
ctx,
config,
options,
pluginConfig,
pluginConfig: options?.pluginConfig,
sessionStates: new Map(),
sessionLastAccess: new Map(),
sessionRetryInFlight: new Set(),
@@ -51,10 +40,23 @@ export function createRuntimeFallbackHook(
const messageUpdateHandler = createMessageUpdateHandler(deps, helpers)
const chatMessageHandler = createChatMessageHandler(deps)
const cleanupInterval = setInterval(helpers.cleanupStaleSessions, 5 * 60 * 1000)
cleanupInterval.unref()
let cleanupInterval: RuntimeFallbackInterval | null = null
let intervalStarted = false
const ensureInterval = (): void => {
if (intervalStarted) return
intervalStarted = true
cleanupInterval = setInterval(helpers.cleanupStaleSessions, 5 * 60 * 1000)
if (typeof cleanupInterval.unref === "function") {
cleanupInterval.unref()
}
}
const eventHandler = async ({ event }: { event: { type: string; properties?: unknown } }) => {
ensureInterval()
if (event.type === "message.updated") {
if (!config.enabled) return
const props = event.properties as Record<string, unknown> | undefined
@@ -65,7 +67,9 @@ export function createRuntimeFallbackHook(
}
const dispose = () => {
clearInterval(cleanupInterval)
if (cleanupInterval) {
clearInterval(cleanupInterval)
}
for (const fallbackTimeout of deps.sessionFallbackTimeouts.values()) {
clearTimeout(fallbackTimeout)
+31
View File
@@ -0,0 +1,31 @@
import type { Platform } from "./session-notification-sender"
import * as sessionNotificationSender from "./session-notification-sender"
import { startBackgroundCheck } from "./session-notification-utils"
export function createSessionNotificationInit() {
let platform: Platform | null = null
let defaultSoundPath: string | null = null
let started = false
function initialize(): { platform: Platform; defaultSoundPath: string } {
if (!platform) {
platform = sessionNotificationSender.detectPlatform()
}
if (!defaultSoundPath) {
defaultSoundPath = sessionNotificationSender.getDefaultSoundPath(platform)
}
if (!started) {
startBackgroundCheck(platform)
started = true
}
return {
platform,
defaultSoundPath,
}
}
return {
initialize,
}
}
@@ -93,6 +93,53 @@ describe("session-notification input-needed events", () => {
expect(notificationCalls).toHaveLength(1)
expect(notificationCalls[0]).toContain("Agent needs permission to continue")
})
test("lazily detects platform and starts background checks on first idle event", async () => {
const sessionID = "main-idle"
setMainSession(sessionID)
const detectPlatformSpy = spyOn(sender, "detectPlatform")
detectPlatformSpy.mockReturnValue("darwin")
const getDefaultSoundPathSpy = spyOn(sender, "getDefaultSoundPath")
getDefaultSoundPathSpy.mockReturnValue("/System/Library/Sounds/Glass.aiff")
const startBackgroundCheckSpy = spyOn(utils, "startBackgroundCheck")
startBackgroundCheckSpy.mockImplementation(() => {})
// given
const hook = createSessionNotification(createMockPluginInput(), { enforceMainSessionFilter: false })
// when
await hook({
event: {
type: "session.idle",
properties: {
sessionID,
},
},
})
// then
expect(detectPlatformSpy).toHaveBeenCalledTimes(1)
expect(getDefaultSoundPathSpy).toHaveBeenCalledTimes(1)
expect(startBackgroundCheckSpy).toHaveBeenCalledTimes(1)
// when
await hook({
event: {
type: "session.idle",
properties: {
sessionID,
},
},
})
// then
expect(detectPlatformSpy).toHaveBeenCalledTimes(1)
expect(getDefaultSoundPathSpy).toHaveBeenCalledTimes(1)
expect(startBackgroundCheckSpy).toHaveBeenCalledTimes(1)
})
})
export {}
+38 -56
View File
@@ -1,20 +1,12 @@
import type { PluginInput } from "@opencode-ai/plugin"
import { subagentSessions, getMainSessionID } from "../features/claude-code-session-state"
import {
startBackgroundCheck,
} from "./session-notification-utils"
import { buildReadyNotificationContent } from "./session-notification-content"
import {
type Platform,
} from "./session-notification-sender"
import { type Platform } from "./session-notification-sender"
import * as sessionNotificationSender from "./session-notification-sender"
import {
getEventToolName,
getQuestionText,
getSessionID,
} from "./session-notification-event-properties"
import { getEventToolName, getQuestionText, getSessionID } from "./session-notification-event-properties"
import { hasIncompleteTodos } from "./session-todo-status"
import { createIdleNotificationScheduler } from "./session-notification-scheduler"
import { createSessionNotificationInit } from "./session-notification-init"
interface SessionNotificationConfig {
title?: string
@@ -33,22 +25,15 @@ interface SessionNotificationConfig {
/** Grace period in ms to ignore late-arriving activity events after scheduling (default: 100) */
activityGracePeriodMs?: number
}
export function createSessionNotification(
ctx: PluginInput,
config: SessionNotificationConfig = {}
) {
const currentPlatform: Platform = sessionNotificationSender.detectPlatform()
const defaultSoundPath = sessionNotificationSender.getDefaultSoundPath(currentPlatform)
startBackgroundCheck(currentPlatform)
export function createSessionNotification(ctx: PluginInput, config: SessionNotificationConfig = {}) {
const mergedConfig = {
title: "OpenCode",
message: "Agent is ready for input",
questionMessage: "Agent is asking a question",
permissionMessage: "Agent needs permission to continue",
playSound: false,
soundPath: defaultSoundPath,
soundPath: "",
idleConfirmationDelay: 1500,
skipIfIncompleteTodos: true,
maxTrackedSessions: 100,
@@ -56,22 +41,18 @@ export function createSessionNotification(
...config,
}
const sessionNotificationInit = createSessionNotificationInit()
let currentPlatform: Platform | null = null
let defaultSoundPath = mergedConfig.soundPath
const scheduler = createIdleNotificationScheduler({
ctx,
platform: currentPlatform,
platform: "unsupported",
config: mergedConfig,
hasIncompleteTodos,
send: async (hookCtx, platform, sessionID) => {
if (
typeof hookCtx.client.session.get !== "function"
&& typeof hookCtx.client.session.messages !== "function"
) {
await sessionNotificationSender.sendSessionNotification(
hookCtx,
platform,
mergedConfig.title,
mergedConfig.message,
)
if (typeof hookCtx.client.session.get !== "function" && typeof hookCtx.client.session.messages !== "function") {
await sessionNotificationSender.sendSessionNotification(hookCtx, platform, mergedConfig.title, mergedConfig.message)
return
}
@@ -90,6 +71,15 @@ export function createSessionNotification(
const PERMISSION_EVENTS = new Set(["permission.ask", "permission.asked", "permission.updated", "permission.requested"])
const PERMISSION_HINT_PATTERN = /\b(permission|approve|approval|allow|deny|consent)\b/i
const ensureNotificationPlatform = (): Platform => {
if (currentPlatform) return currentPlatform
const initialized = sessionNotificationInit.initialize()
currentPlatform = initialized.platform
defaultSoundPath = initialized.defaultSoundPath || mergedConfig.soundPath
return currentPlatform
}
const shouldNotifyForSession = (sessionID: string): boolean => {
if (subagentSessions.has(sessionID)) return false
@@ -102,16 +92,12 @@ export function createSessionNotification(
}
return async ({ event }: { event: { type: string; properties?: unknown } }) => {
if (currentPlatform === "unsupported") return
const props = event.properties as Record<string, unknown> | undefined
if (event.type === "session.created") {
const info = props?.info as Record<string, unknown> | undefined
const sessionID = info?.id as string | undefined
if (sessionID) {
scheduler.markSessionActivity(sessionID)
}
if (sessionID) scheduler.markSessionActivity(sessionID)
return
}
@@ -119,6 +105,8 @@ export function createSessionNotification(
const sessionID = getSessionID(props)
if (!sessionID) return
const platform = ensureNotificationPlatform()
if (platform === "unsupported") return
if (!shouldNotifyForSession(sessionID)) return
scheduler.scheduleIdleNotification(sessionID)
@@ -128,26 +116,22 @@ export function createSessionNotification(
if (event.type === "message.updated") {
const info = props?.info as Record<string, unknown> | undefined
const sessionID = getSessionID({ ...props, info })
if (sessionID) {
scheduler.markSessionActivity(sessionID)
}
if (sessionID) scheduler.markSessionActivity(sessionID)
return
}
if (PERMISSION_EVENTS.has(event.type)) {
const sessionID = getSessionID(props)
if (!sessionID) return
const platform = ensureNotificationPlatform()
if (platform === "unsupported") return
if (!shouldNotifyForSession(sessionID)) return
scheduler.markSessionActivity(sessionID)
await sessionNotificationSender.sendSessionNotification(
ctx,
currentPlatform,
mergedConfig.title,
mergedConfig.permissionMessage,
)
if (mergedConfig.playSound && mergedConfig.soundPath) {
await sessionNotificationSender.playSessionNotificationSound(ctx, currentPlatform, mergedConfig.soundPath)
await sessionNotificationSender.sendSessionNotification(ctx, platform, mergedConfig.title, mergedConfig.permissionMessage)
if (mergedConfig.playSound && defaultSoundPath) {
await sessionNotificationSender.playSessionNotificationSound(ctx, platform, defaultSoundPath)
}
return
}
@@ -160,16 +144,16 @@ export function createSessionNotification(
if (event.type === "tool.execute.before") {
const toolName = getEventToolName(props)?.toLowerCase()
if (toolName && QUESTION_TOOLS.has(toolName)) {
const platform = ensureNotificationPlatform()
if (platform === "unsupported") return
if (!shouldNotifyForSession(sessionID)) return
const questionText = getQuestionText(props)
const message = PERMISSION_HINT_PATTERN.test(questionText)
? mergedConfig.permissionMessage
: mergedConfig.questionMessage
const message = PERMISSION_HINT_PATTERN.test(questionText) ? mergedConfig.permissionMessage : mergedConfig.questionMessage
await sessionNotificationSender.sendSessionNotification(ctx, currentPlatform, mergedConfig.title, message)
if (mergedConfig.playSound && mergedConfig.soundPath) {
await sessionNotificationSender.playSessionNotificationSound(ctx, currentPlatform, mergedConfig.soundPath)
await sessionNotificationSender.sendSessionNotification(ctx, platform, mergedConfig.title, message)
if (mergedConfig.playSound && defaultSoundPath) {
await sessionNotificationSender.playSessionNotificationSound(ctx, platform, defaultSoundPath)
}
}
}
@@ -179,9 +163,7 @@ export function createSessionNotification(
if (event.type === "session.deleted") {
const sessionInfo = props?.info as { id?: string } | undefined
if (sessionInfo?.id) {
scheduler.deleteSession(sessionInfo.id)
}
if (sessionInfo?.id) scheduler.deleteSession(sessionInfo.id)
}
}
}
@@ -8,6 +8,7 @@ declare module "bun:test" {
import { afterAll, afterEach, describe, expect, it, mock } from "bun:test"
import type { BackgroundManager } from "../../features/background-agent"
import * as actualSessionStateModule from "./session-state"
import type { SessionStateStore } from "./session-state"
@@ -37,6 +38,12 @@ function createMockPluginInput(): PluginInput {
} as PluginInput
}
function createMockBackgroundManager(): BackgroundManager {
return {
getTasksByParentSession: () => [{ status: "running" }],
} as BackgroundManager
}
function getCreatedSessionStateStore(): SessionStateStore {
if (!createdSessionStateStore) {
throw new Error("expected session state store to be created")
@@ -68,7 +75,7 @@ describe("todo-continuation-enforcer dispose", () => {
enforcer.dispose()
})
it("#given enforcer with active session states #when dispose is called #then internal session state store is shut down", () => {
it("#given enforcer with active session states #when dispose is called #then internal session state store is shut down", async () => {
// given
const originalClearInterval = globalThis.clearInterval
const clearIntervalCalls: Array<Parameters<typeof clearInterval>[0]> = []
@@ -78,9 +85,13 @@ describe("todo-continuation-enforcer dispose", () => {
}) as typeof clearInterval
try {
const enforcer = createTodoContinuationEnforcer(createMockPluginInput())
const enforcer = createTodoContinuationEnforcer(createMockPluginInput(), {
backgroundManager: createMockBackgroundManager(),
})
const sessionStateStore = getCreatedSessionStateStore()
await enforcer.handler({ event: { type: "session.idle", properties: { sessionID: "session-1" } } })
enforcer.markRecovering("session-1")
enforcer.markRecovering("session-2")
@@ -61,6 +61,7 @@ export function createTodoContinuationHandler(args: {
const sessionID = props?.sessionID as string | undefined
if (!sessionID) return
sessionStateStore.startPruneInterval()
await handleSessionIdle({
ctx,
sessionID,
@@ -31,6 +31,7 @@ export interface ContinuationProgressUpdate {
export interface SessionStateStore {
getState: (sessionID: string) => SessionState
getExistingState: (sessionID: string) => SessionState | undefined
startPruneInterval: () => void
recordActivity: (sessionID: string) => void
trackContinuationProgress: (
sessionID: string,
@@ -76,18 +77,26 @@ export function createSessionStateStore(): SessionStateStore {
// Periodic pruning of stale session states to prevent unbounded Map growth
let pruneInterval: TimerHandle | undefined
pruneInterval = setInterval(() => {
const now = Date.now()
for (const [sessionID, tracked] of sessions.entries()) {
if (now - tracked.lastAccessedAt > SESSION_STATE_TTL_MS) {
cancelCountdown(sessionID)
sessions.delete(sessionID)
}
let pruneIntervalStarted = false
function startPruneInterval(): void {
if (pruneIntervalStarted) {
return
}
pruneIntervalStarted = true
pruneInterval = setInterval(() => {
const now = Date.now()
for (const [sessionID, tracked] of sessions.entries()) {
if (now - tracked.lastAccessedAt > SESSION_STATE_TTL_MS) {
cancelCountdown(sessionID)
sessions.delete(sessionID)
}
}
}, SESSION_STATE_PRUNE_INTERVAL_MS)
if (typeof pruneInterval === "object" && typeof pruneInterval.unref === "function") {
pruneInterval.unref()
}
}, SESSION_STATE_PRUNE_INTERVAL_MS)
// Allow process to exit naturally even if interval is running
if (typeof pruneInterval === "object" && typeof pruneInterval.unref === "function") {
pruneInterval.unref()
}
function getTrackedSession(sessionID: string): TrackedSessionState {
@@ -272,6 +281,7 @@ export function createSessionStateStore(): SessionStateStore {
return {
getState,
getExistingState,
startPruneInterval,
recordActivity,
trackContinuationProgress,
resetContinuationProgress,
@@ -249,6 +249,33 @@ describe("todo-continuation-enforcer", () => {
_resetForTesting()
})
test("given the first idle event, starts the prune interval lazily", async () => {
// given
const originalSetInterval = globalThis.setInterval
let setIntervalCalls = 0
globalThis.setInterval = ((callback: TimerCallback, delay?: number, ...args: any[]) => {
setIntervalCalls += 1
return originalSetInterval(callback, delay, ...args)
}) as typeof setInterval
try {
const sessionID = "main-lazy-prune"
setMainSession(sessionID)
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {
backgroundManager: createMockBackgroundManager(true),
})
// when
await hook.handler({ event: { type: "session.idle", properties: { sessionID } } })
await hook.handler({ event: { type: "session.idle", properties: { sessionID } } })
// then
expect(setIntervalCalls).toBe(1)
} finally {
globalThis.setInterval = originalSetInterval
}
})
test("should inject continuation when idle with incomplete todos", async () => {
fakeTimers.restore()
// given - main session with incomplete todos
+10 -2
View File
@@ -76,7 +76,15 @@ export function isOverwriteEnabled(value: boolean | string | undefined): boolean
export function createWriteExistingFileGuardHook(ctx: PluginInput): Hooks {
const readPermissionsBySession = new Map<string, Set<string>>()
const sessionLastAccess = new Map<string, number>()
const canonicalSessionRoot = toCanonicalPath(resolveInputPath(ctx, ctx.directory))
let canonicalSessionRoot: string | undefined
function getCanonicalSessionRoot(): string {
if (!canonicalSessionRoot) {
canonicalSessionRoot = toCanonicalPath(resolveInputPath(ctx, ctx.directory))
}
return canonicalSessionRoot
}
return {
"tool.execute.before": async (input, output) => {
@@ -86,7 +94,7 @@ export function createWriteExistingFileGuardHook(ctx: PluginInput): Hooks {
output,
readPermissionsBySession,
sessionLastAccess,
canonicalSessionRoot,
getCanonicalSessionRoot,
maxTrackedSessions: MAX_TRACKED_SESSIONS,
})
},
@@ -0,0 +1,65 @@
import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"
import { tmpdir } from "node:os"
import { join } from "node:path"
const realFs = await import("node:fs")
const existsSyncMock = mock(realFs.existsSync)
const realpathNativeMock = mock(realFs.realpathSync.native)
mock.module("fs", () => ({
...realFs,
existsSync: existsSyncMock,
realpathSync: {
...realFs.realpathSync,
native: realpathNativeMock,
},
}))
const { createWriteExistingFileGuardHook } = await import("./index")
describe("createWriteExistingFileGuardHook", () => {
let tempDir = ""
beforeEach(() => {
// given
tempDir = mkdtempSync(join(tmpdir(), "write-existing-file-guard-lazy-"))
mkdirSync(tempDir, { recursive: true })
existsSyncMock.mockClear()
realpathNativeMock.mockClear()
})
afterEach(() => {
rmSync(tempDir, { recursive: true, force: true })
})
test("#given hook factory #when created #then defers fs canonical path calls until first tool invocation", async () => {
// given
const existingFile = join(tempDir, "existing.txt")
writeFileSync(existingFile, "content")
// when
const hook = createWriteExistingFileGuardHook({ directory: tempDir } as never)
// then
expect(existsSyncMock).toHaveBeenCalledTimes(0)
expect(realpathNativeMock).toHaveBeenCalledTimes(0)
// when
await expect(
hook["tool.execute.before"]?.(
{
tool: "write",
sessionID: "ses_lazy",
callID: "call_lazy",
} as never,
{ args: { filePath: existingFile, content: "updated" } } as never,
),
).rejects.toThrow("File already exists. Use edit tool instead.")
// then
expect(existsSyncMock).toHaveBeenCalledTimes(3)
expect(realpathNativeMock).toHaveBeenCalledTimes(2)
})
})
@@ -90,10 +90,10 @@ export async function handleWriteExistingFileGuardToolExecuteBefore(params: {
output: { args?: unknown }
readPermissionsBySession: Map<string, Set<string>>
sessionLastAccess: Map<string, number>
canonicalSessionRoot: string
getCanonicalSessionRoot: () => string
maxTrackedSessions: number
}): Promise<void> {
const { ctx, input, output, readPermissionsBySession, sessionLastAccess, canonicalSessionRoot, maxTrackedSessions } = params
const { ctx, input, output, readPermissionsBySession, sessionLastAccess, getCanonicalSessionRoot, maxTrackedSessions } = params
const toolName = input.tool?.toLowerCase()
if (toolName !== "write" && toolName !== "read") {
return
@@ -107,6 +107,7 @@ export async function handleWriteExistingFileGuardToolExecuteBefore(params: {
}
const resolvedPath = resolveInputPath(ctx, filePath)
const canonicalSessionRoot = getCanonicalSessionRoot()
const canonicalPath = toCanonicalPath(resolvedPath)
if (!isPathInsideDirectory(canonicalPath, canonicalSessionRoot)) {
return
+25 -5
View File
@@ -1,5 +1,13 @@
import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test"
import { createAutoUpdateCheckerHook } from "../auto-update-checker/hook"
let scheduledDeferredCheck: (() => void) | null = null
mock.module("../auto-update-checker/hook/deferred-startup-check", () => ({
scheduleDeferredStartupCheck: (runCheck: () => void) => {
scheduledDeferredCheck = runCheck
},
}))
const { createAutoUpdateCheckerHook } = await import("../auto-update-checker/hook")
const mockShowConfigErrorsIfAny = mock(async () => {})
const mockShowModelCacheWarningIfNeeded = mock(async () => {})
@@ -38,6 +46,12 @@ function runSessionCreatedEvent(
})
}
function drainDeferredCheck(): void {
const run = scheduledDeferredCheck
scheduledDeferredCheck = null
run?.()
}
beforeEach(() => {
mockShowConfigErrorsIfAny.mockClear()
mockShowModelCacheWarningIfNeeded.mockClear()
@@ -51,6 +65,8 @@ beforeEach(() => {
mockGetCachedVersion.mockReturnValue("3.6.0")
mockGetLocalDevVersion.mockReturnValue(null)
scheduledDeferredCheck = null
})
afterEach(() => {
@@ -108,8 +124,9 @@ describe("createAutoUpdateCheckerHook", () => {
log: () => {},
})
//#when - session.created event arrives on primary session
//#when - session.created schedules work and deferred check drains it
runSessionCreatedEvent(hook)
drainDeferredCheck()
await flushScheduledWork()
//#then - startup checks, toast, and background check run
@@ -165,9 +182,10 @@ describe("createAutoUpdateCheckerHook", () => {
log: () => {},
})
//#when - session.created event is fired twice
//#when - session.created fires twice and deferred check drains once
runSessionCreatedEvent(hook)
runSessionCreatedEvent(hook)
drainDeferredCheck()
await flushScheduledWork()
//#then - side effects execute only once
@@ -195,8 +213,9 @@ describe("createAutoUpdateCheckerHook", () => {
log: () => {},
})
//#when - session.created event arrives
//#when - session.created schedules and deferred check drains
runSessionCreatedEvent(hook)
drainDeferredCheck()
await flushScheduledWork()
//#then - local dev toast is shown and background check is skipped
@@ -259,8 +278,9 @@ describe("createAutoUpdateCheckerHook", () => {
log: () => {},
})
//#when - session.created event arrives
//#when - session.created schedules and deferred check drains
runSessionCreatedEvent(hook)
drainDeferredCheck()
await flushScheduledWork()
//#then - startup toast includes sisyphus wording
+50
View File
@@ -0,0 +1,50 @@
import { describe, expect, test } from "bun:test"
import { EXCLUDED_DIRS } from "./excluded-dirs"
import { EXCLUDED_DIRS as EXCLUDED_DIRS_FROM_BARREL } from "."
describe("EXCLUDED_DIRS", () => {
test("contains the well-known junk directories we never want to recurse into", () => {
// given
const expected = [
"node_modules",
".git",
"dist",
"build",
".next",
".sisyphus",
".omx",
".turbo",
"coverage",
"out",
".cache",
".vscode-test",
"target",
".local-ignore",
]
// when / then
for (const name of expected) {
expect(EXCLUDED_DIRS.has(name)).toBe(true)
}
})
test("does not contain commonly-wanted project directories", () => {
// given
const shouldBeAllowed = ["src", "lib", "tests", "test", "docs", ".github", ".cursor", ".claude", ".opencode"]
// when / then
for (const name of shouldBeAllowed) {
expect(EXCLUDED_DIRS.has(name)).toBe(false)
}
})
test("is frozen so consumers cannot mutate shared state", () => {
// given / when / then
expect(Object.isFrozen(EXCLUDED_DIRS)).toBe(true)
})
test("is re-exported from the shared barrel", () => {
// given / when / then
expect(EXCLUDED_DIRS_FROM_BARREL).toBe(EXCLUDED_DIRS)
})
})
+18
View File
@@ -0,0 +1,18 @@
const EXCLUDED_DIR_NAMES = [
"node_modules",
".git",
"dist",
"build",
".next",
".sisyphus",
".omx",
".turbo",
"coverage",
"out",
".cache",
".vscode-test",
"target",
".local-ignore",
] as const
export const EXCLUDED_DIRS: ReadonlySet<string> = Object.freeze(new Set<string>(EXCLUDED_DIR_NAMES))
+1
View File
@@ -79,3 +79,4 @@ export * from "./log-legacy-plugin-startup-warning"
export * from "./task-system-enabled"
export * from "./parse-tools-config"
export { parseModelString } from "./model-string-parser"
export { EXCLUDED_DIRS } from "./excluded-dirs"
@@ -0,0 +1,54 @@
import { afterEach, describe, expect, mock, spyOn, test } from "bun:test"
import * as fs from "node:fs"
import { join } from "node:path"
describe("detectPluginConfigFile memoization", () => {
const testDir = join(__dirname, ".test-detect-plugin-memoization")
afterEach(() => {
mock.restore()
})
test("returns cached result on repeated calls for the same directory", async () => {
// given
const existsSync = spyOn(fs, "existsSync").mockImplementation((filePath: fs.PathLike) => {
return String(filePath).endsWith("oh-my-openagent.jsonc")
})
const readdirSync = spyOn(fs, "readdirSync").mockImplementation(() => [])
spyOn(fs, "readFileSync").mockImplementation(() => "")
const parserModule = await import(`./jsonc-parser?memoization=${Date.now()}-${Math.random()}`)
// when
const firstResult = parserModule.detectPluginConfigFile(testDir)
const callsAfterFirstResult = existsSync.mock.calls.length
const secondResult = parserModule.detectPluginConfigFile(testDir)
// then
expect(firstResult).toEqual(secondResult)
expect(existsSync.mock.calls.length).toBe(callsAfterFirstResult)
expect(readdirSync).toHaveBeenCalledTimes(0)
})
test("clears cached result when requested", async () => {
// given
const existsSync = spyOn(fs, "existsSync").mockImplementation((filePath: fs.PathLike) => {
return String(filePath).endsWith("oh-my-openagent.jsonc")
})
const readdirSync = spyOn(fs, "readdirSync").mockImplementation(() => [])
spyOn(fs, "readFileSync").mockImplementation(() => "")
const parserModule = await import(`./jsonc-parser?memoization=${Date.now()}-${Math.random()}`)
parserModule.detectPluginConfigFile(testDir)
parserModule.clearPluginConfigFileDetectionCache()
const callsAfterClear = existsSync.mock.calls.length
// when
parserModule.detectPluginConfigFile(testDir)
// then
expect(existsSync.mock.calls.length).toBeGreaterThan(callsAfterClear)
expect(readdirSync).toHaveBeenCalledTimes(0)
})
})
+10 -2
View File
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test"
import { detectConfigFile, detectPluginConfigFile, parseJsonc, parseJsoncSafe, readJsoncFile } from "./jsonc-parser"
import { afterEach, beforeEach, describe, expect, test } from "bun:test"
import { clearPluginConfigFileDetectionCache, detectConfigFile, detectPluginConfigFile, parseJsonc, parseJsoncSafe, readJsoncFile } from "./jsonc-parser"
import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"
import { join } from "node:path"
@@ -330,6 +330,14 @@ describe("detectConfigFile", () => {
describe("detectPluginConfigFile", () => {
const testDir = join(__dirname, ".test-detect-plugin")
beforeEach(() => {
clearPluginConfigFileDetectionCache()
})
afterEach(() => {
clearPluginConfigFileDetectionCache()
})
test("prefers oh-my-openagent over oh-my-opencode when both jsonc files exist", () => {
// given
if (!existsSync(testDir)) mkdirSync(testDir, { recursive: true })
+28 -10
View File
@@ -9,6 +9,14 @@ export interface JsoncParseResult<T> {
errors: Array<{ message: string; offset: number; length: number }>
}
type DetectPluginConfigResult = {
format: "json" | "jsonc" | "none"
path: string
legacyPath?: string
}
const pluginConfigFileDetectionCache = new Map<string, DetectPluginConfigResult>()
function stripBom(content: string): string {
return content.charCodeAt(0) === 0xfeff ? content.slice(1) : content
}
@@ -75,24 +83,34 @@ export function detectConfigFile(basePath: string): {
return { format: "none", path: jsonPath }
}
export function detectPluginConfigFile(dir: string): {
format: "json" | "jsonc" | "none"
path: string
legacyPath?: string
} {
export function clearPluginConfigFileDetectionCache(): void {
pluginConfigFileDetectionCache.clear()
}
export function detectPluginConfigFile(dir: string): DetectPluginConfigResult {
const cachedResult = pluginConfigFileDetectionCache.get(dir)
if (cachedResult !== undefined) {
return cachedResult
}
const canonicalResult = detectConfigFile(join(dir, CONFIG_BASENAME))
const legacyResult = detectConfigFile(join(dir, LEGACY_CONFIG_BASENAME))
let detectionResult: DetectPluginConfigResult
if (canonicalResult.format !== "none") {
return {
detectionResult = {
...canonicalResult,
legacyPath: legacyResult.format !== "none" ? legacyResult.path : undefined,
}
} else if (legacyResult.format !== "none") {
detectionResult = legacyResult
} else {
detectionResult = { format: "none", path: join(dir, `${CONFIG_BASENAME}.json`) }
}
if (legacyResult.format !== "none") {
return legacyResult
}
pluginConfigFileDetectionCache.set(dir, detectionResult)
return { format: "none", path: join(dir, `${CONFIG_BASENAME}.json`) }
return detectionResult
}
+89
View File
@@ -0,0 +1,89 @@
/// <reference path="../../bun-test.d.ts" />
import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test"
import * as fs from "node:fs"
type LoadOpencodePluginsModule = {
loadOpencodePlugins: (directory: string) => string[]
clearOpencodePluginsCache?: () => void
}
const existsSyncMock = mock((_path: string) => true)
const readFileSyncMock = mock((_path: string, _encoding?: string) => `{
"plugin": ["plugin-a", "plugin-b"]
}`)
async function importFreshLoadOpencodePluginsModule(): Promise<LoadOpencodePluginsModule> {
const modulePath = `${new URL("./load-opencode-plugins.ts", import.meta.url).pathname}?test=${Date.now()}-${Math.random()}`
return import(modulePath)
}
describe("loadOpencodePlugins", () => {
beforeEach(() => {
existsSyncMock.mockReset()
existsSyncMock.mockImplementation((_path: string) => true)
readFileSyncMock.mockReset()
readFileSyncMock.mockImplementation((_path: string, _encoding?: string) => `{
"plugin": ["plugin-a", "plugin-b"]
}`)
mock.module("node:fs", () => ({
...fs,
existsSync: existsSyncMock,
readFileSync: readFileSyncMock,
}))
})
afterEach(() => {
mock.restore()
})
describe("#given the same directory is loaded twice", () => {
describe("#when loading plugins repeatedly", () => {
it("#then does not call readFileSync on the second load", async () => {
// given
const { loadOpencodePlugins } = await importFreshLoadOpencodePluginsModule()
// when
const firstResult = loadOpencodePlugins("/some/fake/dir")
const readCountAfterFirstLoad = readFileSyncMock.mock.calls.length
const secondResult = loadOpencodePlugins("/some/fake/dir")
const readCountAfterSecondLoad = readFileSyncMock.mock.calls.length
// then
expect(firstResult).toEqual(["plugin-a", "plugin-b"])
expect(secondResult).toEqual(["plugin-a", "plugin-b"])
expect(readCountAfterFirstLoad).toBeGreaterThan(0)
expect(readCountAfterSecondLoad - readCountAfterFirstLoad).toBe(0)
})
})
})
describe("#given the plugin cache was cleared", () => {
describe("#when loading the same directory again", () => {
it("#then re-reads plugin config files from disk", async () => {
// given
const { loadOpencodePlugins, clearOpencodePluginsCache } = await importFreshLoadOpencodePluginsModule()
if (typeof clearOpencodePluginsCache !== "function") {
throw new Error("clearOpencodePluginsCache export is missing")
}
// when
const firstResult = loadOpencodePlugins("/some/fake/dir")
const readCountAfterFirstLoad = readFileSyncMock.mock.calls.length
loadOpencodePlugins("/some/fake/dir")
const readCountAfterSecondLoad = readFileSyncMock.mock.calls.length
clearOpencodePluginsCache()
const thirdResult = loadOpencodePlugins("/some/fake/dir")
const readCountAfterThirdLoad = readFileSyncMock.mock.calls.length
// then
expect(firstResult).toEqual(["plugin-a", "plugin-b"])
expect(thirdResult).toEqual(["plugin-a", "plugin-b"])
expect(readCountAfterSecondLoad - readCountAfterFirstLoad).toBe(0)
expect(readCountAfterThirdLoad - readCountAfterSecondLoad).toBeGreaterThan(0)
})
})
})
})
+12
View File
@@ -8,6 +8,8 @@ interface OpencodeConfig {
plugin?: (string | [string, ...unknown[]])[]
}
const opencodePluginsCache = new Map<string, string[]>()
function getWindowsAppdataDir(): string | null {
return process.env.APPDATA || null
}
@@ -33,6 +35,11 @@ function getConfigPaths(directory: string): string[] {
}
export function loadOpencodePlugins(directory: string): string[] {
const cachedPluginEntries = opencodePluginsCache.get(directory)
if (cachedPluginEntries) {
return cachedPluginEntries
}
const pluginEntries: string[] = []
const seenPluginEntries = new Set<string>()
@@ -56,5 +63,10 @@ export function loadOpencodePlugins(directory: string): string[] {
}
}
opencodePluginsCache.set(directory, pluginEntries)
return pluginEntries
}
export function clearOpencodePluginsCache(): void {
opencodePluginsCache.clear()
}
+43 -11
View File
@@ -1,15 +1,10 @@
import { afterEach, beforeEach, describe, expect, it } from "bun:test"
import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test"
import { mkdirSync, realpathSync, rmSync } from "node:fs"
import { tmpdir } from "node:os"
import { join } from "node:path"
import {
findProjectAgentsSkillDirs,
findProjectClaudeSkillDirs,
findProjectOpencodeCommandDirs,
findProjectOpencodeSkillDirs,
} from "./project-discovery-dirs"
const TEST_DIR = join(tmpdir(), `project-discovery-dirs-${Date.now()}`)
let worktreeSpawnCount = 0
function canonicalPath(path: string): string {
return realpathSync(path)
@@ -24,7 +19,35 @@ describe("project-discovery-dirs", () => {
rmSync(TEST_DIR, { recursive: true, force: true })
})
it("#given nested .opencode skill directories #when finding project opencode skill dirs #then returns nearest-first with aliases", () => {
it("#given repeated worktree detection #when detecting twice #then reuses the cached result", async () => {
// given
worktreeSpawnCount = 0
mock.module("node:child_process", () => ({
execFileSync: () => {
worktreeSpawnCount += 1
return TEST_DIR
},
}))
const { clearWorktreeCache, detectWorktreePath } = await import("./project-discovery-dirs")
clearWorktreeCache()
// when
const firstPath = detectWorktreePath("/some/dir")
const secondPath = detectWorktreePath("/some/dir")
clearWorktreeCache()
const thirdPath = detectWorktreePath("/some/dir")
// then
expect(firstPath).toBe(TEST_DIR)
expect(secondPath).toBe(TEST_DIR)
expect(thirdPath).toBe(TEST_DIR)
expect(worktreeSpawnCount).toBe(2)
})
it("#given nested .opencode skill directories #when finding project opencode skill dirs #then returns nearest-first with aliases", async () => {
// given
const projectDir = join(TEST_DIR, "project")
const childDir = join(projectDir, "apps", "cli")
@@ -32,6 +55,8 @@ describe("project-discovery-dirs", () => {
mkdirSync(join(projectDir, ".opencode", "skills"), { recursive: true })
mkdirSync(join(TEST_DIR, ".opencode", "skills"), { recursive: true })
const { findProjectOpencodeSkillDirs } = await import("./project-discovery-dirs")
// when
const directories = findProjectOpencodeSkillDirs(childDir)
@@ -43,13 +68,15 @@ describe("project-discovery-dirs", () => {
])
})
it("#given nested .opencode command directories #when finding project opencode command dirs #then returns nearest-first with aliases", () => {
it("#given nested .opencode command directories #when finding project opencode command dirs #then returns nearest-first with aliases", async () => {
// given
const projectDir = join(TEST_DIR, "project")
const childDir = join(projectDir, "packages", "tool")
mkdirSync(join(projectDir, ".opencode", "commands"), { recursive: true })
mkdirSync(join(TEST_DIR, ".opencode", "command"), { recursive: true })
const { findProjectOpencodeCommandDirs } = await import("./project-discovery-dirs")
// when
const directories = findProjectOpencodeCommandDirs(childDir)
@@ -60,13 +87,15 @@ describe("project-discovery-dirs", () => {
])
})
it("#given ancestor claude and agents skill directories #when finding project compatibility dirs #then discovers both scopes", () => {
it("#given ancestor claude and agents skill directories #when finding project compatibility dirs #then discovers both scopes", async () => {
// given
const projectDir = join(TEST_DIR, "project")
const childDir = join(projectDir, "src", "nested")
mkdirSync(join(projectDir, ".claude", "skills"), { recursive: true })
mkdirSync(join(TEST_DIR, ".agents", "skills"), { recursive: true })
const { findProjectAgentsSkillDirs, findProjectClaudeSkillDirs } = await import("./project-discovery-dirs")
// when
const claudeDirectories = findProjectClaudeSkillDirs(childDir)
const agentsDirectories = findProjectAgentsSkillDirs(childDir)
@@ -76,17 +105,20 @@ describe("project-discovery-dirs", () => {
expect(agentsDirectories).toEqual([canonicalPath(join(TEST_DIR, ".agents", "skills"))])
})
it("#given a stop directory #when finding ancestor dirs #then it does not scan beyond the stop boundary", () => {
it("#given a stop directory #when finding ancestor dirs #then it does not scan beyond the stop boundary", async () => {
// given
const projectDir = join(TEST_DIR, "project")
const childDir = join(projectDir, "apps", "cli")
mkdirSync(join(projectDir, ".opencode", "skills"), { recursive: true })
mkdirSync(join(TEST_DIR, ".opencode", "skills"), { recursive: true })
const { findProjectOpencodeSkillDirs } = await import("./project-discovery-dirs")
// when
const directories = findProjectOpencodeSkillDirs(childDir, projectDir)
// then
expect(directories).toEqual([canonicalPath(join(projectDir, ".opencode", "skills"))])
})
})
+18 -3
View File
@@ -2,6 +2,8 @@ import { execFileSync } from "node:child_process"
import { existsSync, realpathSync } from "node:fs"
import { dirname, join, resolve } from "node:path"
const worktreePathCache = new Map<string, string | undefined>()
function normalizePath(path: string): string {
const resolvedPath = resolve(path)
if (!existsSync(resolvedPath)) {
@@ -49,15 +51,28 @@ function findAncestorDirectories(
}
}
function detectWorktreePath(directory: string): string | undefined {
export function clearWorktreeCache(): void {
worktreePathCache.clear()
}
export function detectWorktreePath(directory: string): string | undefined {
const resolvedDirectory = resolve(directory)
if (worktreePathCache.has(resolvedDirectory)) {
return worktreePathCache.get(resolvedDirectory)
}
try {
return execFileSync("git", ["rev-parse", "--show-toplevel"], {
cwd: directory,
const worktreePath = execFileSync("git", ["rev-parse", "--show-toplevel"], {
cwd: resolvedDirectory,
encoding: "utf-8",
timeout: 5000,
stdio: ["pipe", "pipe", "pipe"],
}).trim()
worktreePathCache.set(resolvedDirectory, worktreePath)
return worktreePath
} catch {
worktreePathCache.set(resolvedDirectory, undefined)
return undefined
}
}
+7 -4
View File
@@ -38,14 +38,17 @@ function formatSlashCommand(command: CommandInfo): string {
return lines.join("\n")
}
export function formatCombinedDescription(skills: SkillInfo[], commands: CommandInfo[]): string {
if (skills.length === 0 && commands.length === 0) {
export function formatCombinedDescription(skills?: SkillInfo[], commands?: CommandInfo[]): string {
const availableSkills = skills ?? []
const availableCommands = commands ?? []
if (availableSkills.length === 0 && availableCommands.length === 0) {
return TOOL_DESCRIPTION_NO_SKILLS
}
const availableItems = [
...sortByScopePriority(skills).map(formatSkillCommand),
...sortByScopePriority(commands).map(formatSlashCommand),
...sortByScopePriority(availableSkills).map(formatSkillCommand),
...sortByScopePriority(availableCommands).map(formatSlashCommand),
]
if (availableItems.length === 0) {
+10
View File
@@ -0,0 +1,10 @@
const seenSessionIDs = new Set<string>()
export function shouldInvalidateSkillCacheForSession(sessionID?: string): boolean {
if (!sessionID || seenSessionIDs.has(sessionID)) {
return false
}
seenSessionIDs.add(sessionID)
return true
}
+132
View File
@@ -0,0 +1,132 @@
/// <reference types="bun-types" />
import { afterEach, beforeEach, describe, expect, it, mock, spyOn } from "bun:test"
import type { ToolContext } from "@opencode-ai/plugin/tool"
import type { LoadedSkill } from "../../features/opencode-skill-loader/types"
import * as skillContent from "../../features/opencode-skill-loader/skill-content"
const discoverCommandsSync = mock(() => [])
mock.module("../slashcommand/command-discovery", () => ({
discoverCommandsSync,
}))
function createMockSkill(name: string): LoadedSkill {
return {
name,
definition: {
name,
description: `Test skill ${name}`,
template: `Test skill template for ${name}`,
},
scope: "config",
}
}
async function flushMicrotasks(): Promise<void> {
await Promise.resolve()
await Promise.resolve()
}
const loadedSkill = createMockSkill("lazy-skill")
const getAllSkills = mock(async () => [loadedSkill])
const clearSkillCache = mock(() => {})
const mockContext: ToolContext = {
sessionID: "test-session",
messageID: "msg-1",
agent: "test-agent",
directory: "/test",
worktree: "/test",
abort: new AbortController().signal,
metadata: () => {},
ask: async () => {},
}
function createMockContext(sessionID: string): ToolContext {
return {
...mockContext,
sessionID,
}
}
beforeEach(() => {
spyOn(skillContent, "getAllSkills").mockImplementation(getAllSkills)
spyOn(skillContent, "clearSkillCache").mockImplementation(clearSkillCache)
})
afterEach(async () => {
await flushMicrotasks()
mock.restore()
})
describe("createSkillTool", () => {
it("delays command discovery until the description getter is accessed", async () => {
// given
const baselineDiscoverCommandsSyncCalls = discoverCommandsSync.mock.calls.length
// when
const { createSkillTool } = await import("./tools")
const skillTool = createSkillTool({})
// then
expect(discoverCommandsSync.mock.calls.length).toBe(baselineDiscoverCommandsSyncCalls)
void skillTool.description
await flushMicrotasks()
expect(discoverCommandsSync.mock.calls.length).toBe(baselineDiscoverCommandsSyncCalls + 1)
})
it("delays skill loading until execute is invoked", async () => {
// given
const baselineGetAllSkillsCalls = getAllSkills.mock.calls.length
// when
const { createSkillTool } = await import("./tools")
const skillTool = createSkillTool({})
// then
expect(getAllSkills.mock.calls.length).toBe(baselineGetAllSkillsCalls)
await skillTool.execute({ name: "lazy-skill" }, mockContext)
expect(getAllSkills.mock.calls.length).toBe(baselineGetAllSkillsCalls + 1)
})
it("clears the shared skill cache once on first execute in a session", async () => {
// given
const baselineClearSkillCacheCalls = clearSkillCache.mock.calls.length
const sessionContext = createMockContext("session-clear-once")
// when
const { createSkillTool } = await import("./tools")
const skillTool = createSkillTool({})
void skillTool.description
await flushMicrotasks()
await skillTool.execute({ name: "lazy-skill" }, sessionContext)
await skillTool.execute({ name: "lazy-skill" }, sessionContext)
// then
expect(clearSkillCache.mock.calls.length).toBe(baselineClearSkillCacheCalls + 1)
})
it("clears the skill discovery cache once per session", async () => {
// given
const baselineClearSkillCacheCalls = clearSkillCache.mock.calls.length
const baselineGetAllSkillsCalls = getAllSkills.mock.calls.length
const sessionAContext = createMockContext("session-a")
const sessionBContext = createMockContext("session-b")
const { createSkillTool } = await import("./tools")
const skillTool = createSkillTool({})
// when
await skillTool.execute({ name: "lazy-skill" }, sessionAContext)
await skillTool.execute({ name: "lazy-skill" }, sessionAContext)
await skillTool.execute({ name: "lazy-skill" }, sessionBContext)
await skillTool.execute({ name: "lazy-skill" }, sessionBContext)
// then
expect(clearSkillCache.mock.calls.length).toBe(baselineClearSkillCacheCalls + 2)
expect(getAllSkills.mock.calls.length).toBe(baselineGetAllSkillsCalls + 4)
})
})
+11 -9
View File
@@ -2,9 +2,10 @@ import { dirname } from "node:path"
import { tool, type ToolDefinition } from "@opencode-ai/plugin"
import type { ToolContext } from "@opencode-ai/plugin/tool"
import { TOOL_DESCRIPTION_PREFIX } from "./constants"
import { shouldInvalidateSkillCacheForSession } from "./session-skill-cache"
import type { SkillArgs, SkillLoadOptions } from "./types"
import type { LoadedSkill } from "../../features/opencode-skill-loader"
import { getAllSkills, clearSkillCache } from "../../features/opencode-skill-loader/skill-content"
import { clearSkillCache, getAllSkills } from "../../features/opencode-skill-loader/skill-content"
import { injectGitMasterConfig } from "../../features/opencode-skill-loader/skill-content"
import { discoverCommandsSync } from "../slashcommand/command-discovery"
import type { CommandInfo } from "../slashcommand/types"
@@ -27,12 +28,15 @@ import {
export function createSkillTool(options: SkillLoadOptions = {}): ToolDefinition {
let cachedDescription: string | null = null
const getSkills = async (): Promise<LoadedSkill[]> => {
clearSkillCache()
const discovered = await getAllSkills({
const getSkills = async (context?: ToolContext): Promise<LoadedSkill[]> => {
if (shouldInvalidateSkillCacheForSession(context?.sessionID)) {
clearSkillCache()
}
const discovered = (await getAllSkills({
disabledSkills: options?.disabledSkills,
browserProvider: options?.browserProvider,
})
})) ?? []
const allSkills = !options.skills
? discovered
: [
@@ -57,7 +61,7 @@ export function createSkillTool(options: SkillLoadOptions = {}): ToolDefinition
return discoverCommandsSync(undefined, {
pluginsEnabled: options.pluginsEnabled,
enabledPluginsOverride: options.enabledPluginsOverride,
})
}) ?? []
}
const buildDescription = async (force = false): Promise<string> => {
@@ -92,8 +96,6 @@ export function createSkillTool(options: SkillLoadOptions = {}): ToolDefinition
}
} else if (options.commands !== undefined) {
cachedDescription = formatCombinedDescription([], options.commands)
} else {
void buildDescription()
}
return tool({
@@ -111,7 +113,7 @@ export function createSkillTool(options: SkillLoadOptions = {}): ToolDefinition
.describe("Optional arguments or context for command invocation. Example: name='publish', user_message='patch'"),
},
async execute(args: SkillArgs, ctx?: ToolContext) {
const skills = await getSkills()
const skills = await getSkills(ctx)
const commands = getCommands()
cachedDescription = formatCombinedDescription(skills.map(loadedSkillToInfo), commands)
@@ -1,7 +1,14 @@
/// <reference types="bun-types" />
declare const require: NodeJS.Require
import { afterAll, beforeEach, describe, expect, it, mock, spyOn } from "bun:test"
import type { ToolContext } from "@opencode-ai/plugin/tool"
import * as fs from "node:fs"
import { tmpdir } from "node:os"
import { join } from "node:path"
import { SkillMcpManager } from "../../../features/skill-mcp-manager"
import { clearSkillCache } from "../../../features/opencode-skill-loader/skill-content"
import type { LoadedSkill } from "../../../features/opencode-skill-loader/types"
import type { CommandInfo } from "../../slashcommand/types"
import type { Tool as McpTool } from "@modelcontextprotocol/sdk/types.js"
@@ -10,7 +17,24 @@ const originalReadFileSync = fs.readFileSync.bind(fs)
let createSkillTool: typeof import("../tools").createSkillTool
beforeEach(async () => {
function clearRequireCache(modulePath: string): void {
const resolvedPath = require.resolve(modulePath)
if (require.cache?.[resolvedPath]) {
delete require.cache[resolvedPath]
}
}
function requireFresh<TModule>(modulePath: string): TModule {
clearRequireCache(modulePath)
return require(modulePath) as TModule
}
beforeEach(() => {
mock.restore()
clearRequireCache("../tools")
clearRequireCache("../../../features/opencode-skill-loader/skill-content")
clearRequireCache("../../slashcommand/command-discovery")
mock.module("node:fs", () => ({
...fs,
readFileSync: (path: string, encoding?: string) => {
@@ -23,9 +47,8 @@ Test skill body content`
return originalReadFileSync(path, encoding as BufferEncoding)
},
}))
const module = await import("../tools")
createSkillTool = module.createSkillTool
createSkillTool = requireFresh<typeof import("../tools")>("../tools").createSkillTool
})
afterAll(() => {
@@ -548,16 +571,43 @@ describe("skill tool - ordering and priority", () => {
})
describe("skill tool - dynamic discovery", () => {
it("discovers skills from disk on every invocation instead of caching", async () => {
// given: tool created with initial skills
const initialSkills = [createMockSkill("initial-skill")]
const tool = createSkillTool({ skills: initialSkills })
it("caches discovered skills across tool instances until the shared cache resets", async () => {
// given
clearSkillCache()
const originalDirectory = process.cwd()
const temporaryDirectory = fs.mkdtempSync(join(tmpdir(), "skill-tool-cache-"))
const initialSkillDirectory = join(temporaryDirectory, ".opencode", "skills", "initial-skill")
const secondSkillDirectory = join(temporaryDirectory, ".opencode", "skills", "second-skill")
// when: executing with the initial skill name
const result = await tool.execute({ name: "initial-skill" }, mockContext)
fs.mkdirSync(initialSkillDirectory, { recursive: true })
fs.writeFileSync(join(initialSkillDirectory, "SKILL.md"), "---\ndescription: Initial skill\n---\nInitial skill body")
process.chdir(temporaryDirectory)
// then: initial skill found (merged from options.skills since not on disk)
expect(result).toContain("Skill: initial-skill")
try {
const firstTool = createSkillTool({})
// when
const initialResult = await firstTool.execute({ name: "initial-skill" }, mockContext)
fs.mkdirSync(secondSkillDirectory, { recursive: true })
fs.writeFileSync(join(secondSkillDirectory, "SKILL.md"), "---\ndescription: Second skill\n---\nSecond skill body")
const cachedTool = createSkillTool({})
// then
expect(initialResult).toContain("Skill: initial-skill")
let cachedError: Error | undefined
try {
await cachedTool.execute({ name: "second-skill" }, mockContext)
} catch (error) {
cachedError = error instanceof Error ? error : new Error(String(error))
}
expect(cachedError?.message).toContain('Skill or command "second-skill" not found.')
} finally {
process.chdir(originalDirectory)
clearSkillCache()
fs.rmSync(temporaryDirectory, { recursive: true, force: true })
}
})
it("merges pre-provided skills with dynamically discovered ones", async () => {
@@ -586,59 +636,66 @@ describe("skill tool - dynamic discovery", () => {
})
})
describe("skill tool - dynamic description cache invalidation", () => {
it("rebuilds description after execute() discovers new skills", async () => {
// given: tool created with initial skills (no pre-provided skills)
// This triggers lazy description building
it("keeps description available after execute misses a skill", async () => {
// given
const tool = createSkillTool({})
// Get initial description - it will build from empty or disk skills
// when
const initialDescription = tool.description
expect(initialDescription).toBeString()
// when: execute() is called, which clears cache AND gets fresh skills
// Note: In real scenario, execute() would discover new skills from disk
// For testing, we verify the mechanism: execute() should invalidate cachedDescription
// Execute any skill to trigger the cache clear + getSkills flow
// Using a non-existent skill name to trigger the error path which still goes through getSkills()
try {
await tool.execute({ name: "nonexistent-skill-12345" }, mockContext)
} catch (e) {
// Expected to fail - skill doesn't exist
} catch {
}
// then: cachedDescription should be invalidated, so next description access should rebuild
// We verify by checking that the description getter triggers a rebuild
// Since we can't easily mock getAllSkills in this test, we verify the cache invalidation mechanism
// The key assertion: after execute(), the description should be rebuildable
// If cachedDescription wasn't invalidated, it would still return old value
// We verify by checking that the tool still has valid description structure
// then
expect(tool.description).toBeDefined()
expect(typeof tool.description).toBe("string")
})
it("description reflects fresh skills after execute() clears cache", async () => {
// given: tool created without pre-provided skills (will use disk discovery)
const tool = createSkillTool({})
// when: execute() is called with a skill that exists on disk (via mock)
// This simulates the real scenario: execute() discovers skills, cache should be invalidated
// Execute to trigger the cache invalidation path
it("picks up new disk skills only after the shared skill cache resets", async () => {
// given
clearSkillCache()
const originalDirectory = process.cwd()
const temporaryDirectory = fs.mkdtempSync(join(tmpdir(), "skill-tool-refresh-"))
const initialSkillDirectory = join(temporaryDirectory, ".opencode", "skills", "initial-skill")
const secondSkillDirectory = join(temporaryDirectory, ".opencode", "skills", "second-skill")
fs.mkdirSync(initialSkillDirectory, { recursive: true })
fs.writeFileSync(join(initialSkillDirectory, "SKILL.md"), "---\ndescription: Initial skill\n---\nInitial skill body")
process.chdir(temporaryDirectory)
try {
// This will call getSkills() which clears cache
await tool.execute({ name: "nonexistent" }, mockContext)
} catch (e) {
// Expected
const initialTool = createSkillTool({})
await initialTool.execute({ name: "initial-skill" }, mockContext)
fs.mkdirSync(secondSkillDirectory, { recursive: true })
fs.writeFileSync(join(secondSkillDirectory, "SKILL.md"), "---\ndescription: Second skill\n---\nSecond skill body")
const cachedTool = createSkillTool({})
let cachedError: Error | undefined
try {
await cachedTool.execute({ name: "second-skill" }, mockContext)
} catch (error) {
cachedError = error instanceof Error ? error : new Error(String(error))
}
expect(cachedError?.message).toContain('Skill or command "second-skill" not found.')
clearSkillCache()
const refreshedTool = createSkillTool({})
// when
const refreshedResult = await refreshedTool.execute({ name: "second-skill" }, mockContext)
// then
expect(refreshedResult).toContain("Skill: second-skill")
expect(refreshedTool.description).toContain("second-skill")
} finally {
process.chdir(originalDirectory)
clearSkillCache()
fs.rmSync(temporaryDirectory, { recursive: true, force: true })
}
// then: description should still work and not be stale
// The bug would cause it to return old cached value forever
const desc = tool.description
// Verify description is a valid string (not stale/old)
expect(desc).toContain("skill")
})
})
@@ -326,4 +326,40 @@ describe("non-directory commands path", () => {
expect(testCmd).toBeDefined()
expect(testCmd?.content).toContain("Test command content.")
})
it("#given excluded subdirectories under .claude/commands #when discoverCommandsSync runs #then prunes commands beneath them", () => {
// given
const projectDir = join(testDir, "project")
const commandsDir = join(projectDir, ".claude", "commands")
mkdirSync(join(commandsDir, "node_modules", "fake-pkg"), { recursive: true })
mkdirSync(join(commandsDir, ".git", "branches"), { recursive: true })
mkdirSync(join(commandsDir, "dist"), { recursive: true })
writeFileSync(
join(commandsDir, "real-cmd.md"),
"---\ndescription: Real command\n---\nRun real command.\n",
)
writeFileSync(
join(commandsDir, "node_modules", "fake-pkg", "cmd.md"),
"---\ndescription: Nested command\n---\nRun nested command.\n",
)
writeFileSync(
join(commandsDir, ".git", "branches", "cmd.md"),
"---\ndescription: Git command\n---\nRun git command.\n",
)
writeFileSync(
join(commandsDir, "dist", "bundled-cmd.md"),
"---\ndescription: Bundled command\n---\nRun bundled command.\n",
)
// when
const commands = discoverCommandsSync(projectDir)
const names = commands.map((command) => command.name)
// then
expect(names).toContain("real-cmd")
expect(names).not.toContain("node_modules/fake-pkg/cmd")
expect(names).not.toContain(".git/branches/cmd")
expect(names).not.toContain("dist/bundled-cmd")
})
})
@@ -6,6 +6,7 @@ import {
findProjectOpencodeCommandDirs,
getOpenCodeCommandDirs,
discoverPluginCommandDefinitions,
EXCLUDED_DIRS,
} from "../../shared"
import type { CommandFrontmatter } from "../../features/claude-code-command-loader/types"
import { isMarkdownFile } from "../../shared/file-utils"
@@ -36,6 +37,7 @@ function discoverCommandsFromDir(
for (const entry of entries) {
if (entry.isDirectory()) {
if (EXCLUDED_DIRS.has(entry.name)) continue
if (entry.name.startsWith(".")) continue
const nestedPrefix = prefix
? `${prefix}${NESTED_COMMAND_SEPARATOR}${entry.name}`
@@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it } from "bun:test"
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"
import { tmpdir } from "node:os"
import { join } from "node:path"
import { clearCommandLoaderCache } from "../../features/claude-code-command-loader"
function requireFresh<T>(modulePath: string): T {
const resolvedPath = require.resolve(modulePath)
@@ -25,12 +26,14 @@ describe("slashcommand discovery and execution compatibility", () => {
let originalOpencodeConfigDir: string | undefined
beforeEach(() => {
clearCommandLoaderCache()
tempDir = mkdtempSync(join(tmpdir(), "omo-slashcommand-compat-test-"))
originalWorkingDirectory = process.cwd()
originalOpencodeConfigDir = process.env.OPENCODE_CONFIG_DIR
})
afterEach(() => {
clearCommandLoaderCache()
process.chdir(originalWorkingDirectory)
if (originalOpencodeConfigDir === undefined) {