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:
@@ -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
|
||||||
@@ -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 { 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 { mkdirSync, rmSync, writeFileSync } from "node:fs"
|
||||||
import { tmpdir } from "node:os"
|
import { tmpdir } from "node:os"
|
||||||
import { join } from "node:path"
|
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()}`)
|
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", () => {
|
describe("claude-code command loader", () => {
|
||||||
|
let originalClaudeConfigDir: string | undefined
|
||||||
let originalOpencodeConfigDir: string | undefined
|
let originalOpencodeConfigDir: string | undefined
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
mkdirSync(TEST_DIR, { recursive: true })
|
mkdirSync(TEST_DIR, { recursive: true })
|
||||||
|
originalClaudeConfigDir = process.env.CLAUDE_CONFIG_DIR
|
||||||
originalOpencodeConfigDir = process.env.OPENCODE_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(() => {
|
afterEach(() => {
|
||||||
|
if (originalClaudeConfigDir === undefined) {
|
||||||
|
delete process.env.CLAUDE_CONFIG_DIR
|
||||||
|
} else {
|
||||||
|
process.env.CLAUDE_CONFIG_DIR = originalClaudeConfigDir
|
||||||
|
}
|
||||||
|
|
||||||
if (originalOpencodeConfigDir === undefined) {
|
if (originalOpencodeConfigDir === undefined) {
|
||||||
delete process.env.OPENCODE_CONFIG_DIR
|
delete process.env.OPENCODE_CONFIG_DIR
|
||||||
} else {
|
} else {
|
||||||
process.env.OPENCODE_CONFIG_DIR = originalOpencodeConfigDir
|
process.env.OPENCODE_CONFIG_DIR = originalOpencodeConfigDir
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ("clearCommandLoaderCache" in loader && typeof loader.clearCommandLoaderCache === "function") {
|
||||||
|
loader.clearCommandLoaderCache()
|
||||||
|
}
|
||||||
|
|
||||||
rmSync(TEST_DIR, { recursive: true, force: true })
|
rmSync(TEST_DIR, { recursive: true, force: true })
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -39,7 +62,7 @@ describe("claude-code command loader", () => {
|
|||||||
writeCommand(join(projectDir, ".opencode", "commands"), "ancestor", "Ancestor command")
|
writeCommand(join(projectDir, ".opencode", "commands"), "ancestor", "Ancestor command")
|
||||||
|
|
||||||
// when
|
// when
|
||||||
const commands = await loadOpencodeProjectCommands(childDir)
|
const commands = await loader.loadOpencodeProjectCommands(childDir)
|
||||||
|
|
||||||
// then
|
// then
|
||||||
expect(commands.ancestor?.description).toBe("(opencode-project) Ancestor command")
|
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")
|
writeCommand(join(TEST_DIR, ".opencode", "command"), "singular", "Singular command")
|
||||||
|
|
||||||
// when
|
// when
|
||||||
const commands = await loadOpencodeProjectCommands(TEST_DIR)
|
const commands = await loader.loadOpencodeProjectCommands(TEST_DIR)
|
||||||
|
|
||||||
// then
|
// then
|
||||||
expect(commands.singular?.description).toBe("(opencode-project) Singular command")
|
expect(commands.singular?.description).toBe("(opencode-project) Singular command")
|
||||||
@@ -66,7 +89,7 @@ describe("claude-code command loader", () => {
|
|||||||
writeCommand(projectDir, "duplicate", "Nearest command")
|
writeCommand(projectDir, "duplicate", "Nearest command")
|
||||||
|
|
||||||
// when
|
// when
|
||||||
const commands = await loadOpencodeProjectCommands(childDir)
|
const commands = await loader.loadOpencodeProjectCommands(childDir)
|
||||||
|
|
||||||
// then
|
// then
|
||||||
expect(commands.duplicate?.description).toBe("(opencode-project) Nearest command")
|
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")
|
writeCommand(join(opencodeConfigDir, "commands"), "global-plural", "Global plural command")
|
||||||
|
|
||||||
// when
|
// when
|
||||||
const commands = await loadOpencodeGlobalCommands()
|
const commands = await loader.loadOpencodeGlobalCommands()
|
||||||
|
|
||||||
// then
|
// then
|
||||||
expect(commands["global-plural"]?.description).toBe("(opencode) Global plural command")
|
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")
|
writeCommand(join(profileConfigDir, "commands"), "duplicate-global", "Profile global command")
|
||||||
|
|
||||||
// when
|
// when
|
||||||
const commands = await loadOpencodeGlobalCommands()
|
const commands = await loader.loadOpencodeGlobalCommands()
|
||||||
|
|
||||||
// then
|
// then
|
||||||
expect(commands["duplicate-global"]?.description).toBe("(opencode) Profile global command")
|
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")
|
writeCommand(join(TEST_DIR, ".opencode", "commands"), "outside", "Outside command")
|
||||||
|
|
||||||
// when
|
// when
|
||||||
const commands = await loadOpencodeProjectCommands(nestedDirectory)
|
const commands = await loader.loadOpencodeProjectCommands(nestedDirectory)
|
||||||
|
|
||||||
// then
|
// then
|
||||||
expect(commands["deploy/staging"]?.description).toBe("(opencode-project) Deploy staging")
|
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.outside).toBeUndefined()
|
||||||
expect(commands["deploy:staging"]).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 { sanitizeModelField } from "../../shared/model-sanitizer"
|
||||||
import { isMarkdownFile } from "../../shared/file-utils"
|
import { isMarkdownFile } from "../../shared/file-utils"
|
||||||
import {
|
import {
|
||||||
|
EXCLUDED_DIRS,
|
||||||
findProjectOpencodeCommandDirs,
|
findProjectOpencodeCommandDirs,
|
||||||
getClaudeConfigDir,
|
getClaudeConfigDir,
|
||||||
getOpenCodeCommandDirs,
|
getOpenCodeCommandDirs,
|
||||||
} from "../../shared"
|
} from "../../shared"
|
||||||
import { log } from "../../shared/logger"
|
import { log } from "../../shared/logger"
|
||||||
|
import {
|
||||||
|
clearCommandLoaderCache,
|
||||||
|
deleteCachedCommands,
|
||||||
|
getCachedCommands,
|
||||||
|
getCommandLoaderCacheKey,
|
||||||
|
setCachedCommands,
|
||||||
|
} from "./loader-cache"
|
||||||
import type { CommandScope, CommandDefinition, CommandFrontmatter, LoadedCommand } from "./types"
|
import type { CommandScope, CommandDefinition, CommandFrontmatter, LoadedCommand } from "./types"
|
||||||
|
|
||||||
|
export { clearCommandLoaderCache }
|
||||||
|
|
||||||
async function loadCommandsFromDir(
|
async function loadCommandsFromDir(
|
||||||
commandsDir: string,
|
commandsDir: string,
|
||||||
scope: CommandScope,
|
scope: CommandScope,
|
||||||
@@ -48,6 +58,7 @@ async function loadCommandsFromDir(
|
|||||||
|
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
if (entry.isDirectory()) {
|
if (entry.isDirectory()) {
|
||||||
|
if (EXCLUDED_DIRS.has(entry.name)) continue
|
||||||
if (entry.name.startsWith(".")) continue
|
if (entry.name.startsWith(".")) continue
|
||||||
const subDirPath = join(commandsDir, entry.name)
|
const subDirPath = join(commandsDir, entry.name)
|
||||||
const subPrefix = prefix ? `${prefix}/${entry.name}` : 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>> {
|
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(),
|
loadUserCommands(),
|
||||||
loadProjectCommands(directory),
|
loadProjectCommands(directory),
|
||||||
loadOpencodeGlobalCommands(),
|
loadOpencodeGlobalCommands(),
|
||||||
loadOpencodeProjectCommands(directory),
|
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 { describe, it, expect, beforeEach, afterEach } from "bun:test"
|
||||||
import { join } from "node:path"
|
import { join } from "node:path"
|
||||||
import { tmpdir } from "node:os"
|
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 originalEnv: Record<string, string | undefined>
|
||||||
let testConfigDir: string
|
let testConfigDir: string
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
clearSkillCache()
|
||||||
originalEnv = {
|
originalEnv = {
|
||||||
CLAUDE_CONFIG_DIR: process.env.CLAUDE_CONFIG_DIR,
|
CLAUDE_CONFIG_DIR: process.env.CLAUDE_CONFIG_DIR,
|
||||||
OPENCODE_CONFIG_DIR: process.env.OPENCODE_CONFIG_DIR,
|
OPENCODE_CONFIG_DIR: process.env.OPENCODE_CONFIG_DIR,
|
||||||
@@ -20,6 +27,7 @@ beforeEach(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
|
clearSkillCache()
|
||||||
for (const [key, value] of Object.entries(originalEnv)) {
|
for (const [key, value] of Object.entries(originalEnv)) {
|
||||||
if (value !== undefined) {
|
if (value !== undefined) {
|
||||||
process.env[key] = value
|
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 { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"
|
||||||
import { tmpdir } from "node:os"
|
import { tmpdir } from "node:os"
|
||||||
import { join } from "node:path"
|
import { join } from "node:path"
|
||||||
|
import { clearCommandLoaderCache } from "../../features/claude-code-command-loader"
|
||||||
import { executeSlashCommand } from "./executor"
|
import { executeSlashCommand } from "./executor"
|
||||||
|
|
||||||
const ENV_KEYS = [
|
const ENV_KEYS = [
|
||||||
@@ -95,6 +96,7 @@ describe("auto-slash command executor plugin dispatch", () => {
|
|||||||
let envSnapshot: EnvSnapshot
|
let envSnapshot: EnvSnapshot
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
clearCommandLoaderCache()
|
||||||
tempDir = mkdtempSync(join(tmpdir(), "omo-executor-plugin-test-"))
|
tempDir = mkdtempSync(join(tmpdir(), "omo-executor-plugin-test-"))
|
||||||
envSnapshot = {
|
envSnapshot = {
|
||||||
CLAUDE_CONFIG_DIR: process.env.CLAUDE_CONFIG_DIR,
|
CLAUDE_CONFIG_DIR: process.env.CLAUDE_CONFIG_DIR,
|
||||||
@@ -106,6 +108,7 @@ describe("auto-slash command executor plugin dispatch", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
|
clearCommandLoaderCache()
|
||||||
for (const key of ENV_KEYS) {
|
for (const key of ENV_KEYS) {
|
||||||
const previousValue = envSnapshot[key]
|
const previousValue = envSnapshot[key]
|
||||||
if (previousValue === undefined) {
|
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 { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"
|
||||||
import { tmpdir } from "node:os"
|
import { tmpdir } from "node:os"
|
||||||
import { join } from "node:path"
|
import { join } from "node:path"
|
||||||
|
import { clearCommandLoaderCache } from "../../features/claude-code-command-loader"
|
||||||
import type { LoadedSkill } from "../../features/opencode-skill-loader/types"
|
import type { LoadedSkill } from "../../features/opencode-skill-loader/types"
|
||||||
import type {
|
import type {
|
||||||
AutoSlashCommandHookInput,
|
AutoSlashCommandHookInput,
|
||||||
@@ -43,6 +44,7 @@ describe("createAutoSlashCommandHook", () => {
|
|||||||
let createAutoSlashCommandHook: AutoSlashCommandModule["createAutoSlashCommandHook"]
|
let createAutoSlashCommandHook: AutoSlashCommandModule["createAutoSlashCommandHook"]
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
|
clearCommandLoaderCache()
|
||||||
mock.restore()
|
mock.restore()
|
||||||
logCalls = []
|
logCalls = []
|
||||||
spyOn(shared, "log").mockImplementation((message: string, data?: unknown) => {
|
spyOn(shared, "log").mockImplementation((message: string, data?: unknown) => {
|
||||||
@@ -56,6 +58,7 @@ describe("createAutoSlashCommandHook", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
|
clearCommandLoaderCache()
|
||||||
process.chdir(originalWorkingDirectory)
|
process.chdir(originalWorkingDirectory)
|
||||||
rmSync(tempDir, { recursive: true, force: true })
|
rmSync(tempDir, { recursive: true, force: true })
|
||||||
mock.restore()
|
mock.restore()
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -3,6 +3,7 @@ import { log } from "../../shared/logger"
|
|||||||
import type { AutoUpdateCheckerOptions } from "./types"
|
import type { AutoUpdateCheckerOptions } from "./types"
|
||||||
import { getCachedVersion, getLocalDevVersion } from "./checker"
|
import { getCachedVersion, getLocalDevVersion } from "./checker"
|
||||||
import { runBackgroundUpdateCheck } from "./hook/background-update-check"
|
import { runBackgroundUpdateCheck } from "./hook/background-update-check"
|
||||||
|
import { scheduleDeferredStartupCheck } from "./hook/deferred-startup-check"
|
||||||
import { showConfigErrorsIfAny } from "./hook/config-errors-toast"
|
import { showConfigErrorsIfAny } from "./hook/config-errors-toast"
|
||||||
import { updateAndShowConnectedProvidersCacheStatus } from "./hook/connected-providers-status"
|
import { updateAndShowConnectedProvidersCacheStatus } from "./hook/connected-providers-status"
|
||||||
import { refreshModelCapabilitiesOnStartup } from "./hook/model-capabilities-status"
|
import { refreshModelCapabilitiesOnStartup } from "./hook/model-capabilities-status"
|
||||||
@@ -35,6 +36,20 @@ const defaultDeps: AutoUpdateCheckerDeps = {
|
|||||||
log,
|
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(
|
export function createAutoUpdateCheckerHook(
|
||||||
ctx: PluginInput,
|
ctx: PluginInput,
|
||||||
options: AutoUpdateCheckerOptions = {},
|
options: AutoUpdateCheckerOptions = {},
|
||||||
@@ -60,44 +75,46 @@ export function createAutoUpdateCheckerHook(
|
|||||||
}
|
}
|
||||||
|
|
||||||
let hasChecked = false
|
let hasChecked = false
|
||||||
|
let hasScheduled = false
|
||||||
|
|
||||||
return {
|
return {
|
||||||
event: ({ event }: { event: { type: string; properties?: unknown } }) => {
|
event: ({ event }: { event: { type: string; properties?: unknown } }) => {
|
||||||
if (event.type !== "session.created") return
|
if (event.type !== "session.created") return
|
||||||
if (isCliRunMode) 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
|
hasScheduled = true
|
||||||
if (props?.info?.parentID) return
|
|
||||||
|
|
||||||
|
scheduleDeferredStartupCheck(() => {
|
||||||
hasChecked = true
|
hasChecked = true
|
||||||
|
void (async () => {
|
||||||
|
const cachedVersion = deps.getCachedVersion()
|
||||||
|
const localDevVersion = deps.getLocalDevVersion(ctx.directory)
|
||||||
|
const displayVersion = localDevVersion ?? cachedVersion
|
||||||
|
|
||||||
setTimeout(async () => {
|
await deps.showConfigErrorsIfAny(ctx)
|
||||||
const cachedVersion = deps.getCachedVersion()
|
await deps.updateAndShowConnectedProvidersCacheStatus(ctx)
|
||||||
const localDevVersion = deps.getLocalDevVersion(ctx.directory)
|
await deps.refreshModelCapabilitiesOnStartup(modelCapabilities)
|
||||||
const displayVersion = localDevVersion ?? cachedVersion
|
await deps.showModelCacheWarningIfNeeded(ctx)
|
||||||
|
|
||||||
await deps.showConfigErrorsIfAny(ctx)
|
if (localDevVersion) {
|
||||||
await deps.updateAndShowConnectedProvidersCacheStatus(ctx)
|
if (showStartupToast) {
|
||||||
await deps.refreshModelCapabilitiesOnStartup(modelCapabilities)
|
deps.showLocalDevToast(ctx, displayVersion, isSisyphusEnabled).catch(() => {})
|
||||||
await deps.showModelCacheWarningIfNeeded(ctx)
|
}
|
||||||
|
deps.log("[auto-update-checker] Local development mode")
|
||||||
if (localDevVersion) {
|
return
|
||||||
if (showStartupToast) {
|
|
||||||
deps.showLocalDevToast(ctx, displayVersion, isSisyphusEnabled).catch(() => {})
|
|
||||||
}
|
}
|
||||||
deps.log("[auto-update-checker] Local development mode")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (showStartupToast) {
|
if (showStartupToast) {
|
||||||
deps.showVersionToast(ctx, displayVersion, getToastMessage(false)).catch(() => {})
|
deps.showVersionToast(ctx, displayVersion, getToastMessage(false)).catch(() => {})
|
||||||
}
|
}
|
||||||
|
|
||||||
deps.runBackgroundUpdateCheck(ctx, autoUpdate, getToastMessage).catch((err) => {
|
deps.runBackgroundUpdateCheck(ctx, autoUpdate, getToastMessage).catch((err) => {
|
||||||
deps.log("[auto-update-checker] Background update check failed:", err)
|
deps.log("[auto-update-checker] Background update check failed:", err)
|
||||||
})
|
})
|
||||||
}, 0)
|
})()
|
||||||
|
})
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -28,6 +28,7 @@ import {
|
|||||||
stopPendingCallCleanup,
|
stopPendingCallCleanup,
|
||||||
takePendingCall,
|
takePendingCall,
|
||||||
} from "./pending-calls"
|
} from "./pending-calls"
|
||||||
|
import { ensureCommentCheckerInitialization } from "./initialization-gate"
|
||||||
|
|
||||||
import * as fs from "fs"
|
import * as fs from "fs"
|
||||||
import { tmpdir } from "os"
|
import { tmpdir } from "os"
|
||||||
@@ -48,14 +49,16 @@ function debugLog(...args: unknown[]) {
|
|||||||
export function createCommentCheckerHooks(config?: CommentCheckerConfig) {
|
export function createCommentCheckerHooks(config?: CommentCheckerConfig) {
|
||||||
debugLog("createCommentCheckerHooks called", { config })
|
debugLog("createCommentCheckerHooks called", { config })
|
||||||
|
|
||||||
startPendingCallCleanup()
|
|
||||||
initializeCommentCheckerCli(debugLog)
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"tool.execute.before": async (
|
"tool.execute.before": async (
|
||||||
input: { tool: string; sessionID: string; callID: string },
|
input: { tool: string; sessionID: string; callID: string },
|
||||||
output: { args: Record<string, unknown> },
|
output: { args: Record<string, unknown> },
|
||||||
): Promise<void> => {
|
): Promise<void> => {
|
||||||
|
ensureCommentCheckerInitialization(() => {
|
||||||
|
startPendingCallCleanup()
|
||||||
|
initializeCommentCheckerCli(debugLog)
|
||||||
|
})
|
||||||
|
|
||||||
debugLog("tool.execute.before:", {
|
debugLog("tool.execute.before:", {
|
||||||
tool: input.tool,
|
tool: input.tool,
|
||||||
callID: input.callID,
|
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 { dirname, isAbsolute, join, resolve } from "node:path";
|
||||||
|
|
||||||
import { AGENTS_FILENAME } from "./constants";
|
import { AGENTS_FILENAME } from "./constants";
|
||||||
@@ -9,10 +9,10 @@ export function resolveFilePath(rootDirectory: string, path: string): string | n
|
|||||||
return resolve(rootDirectory, path);
|
return resolve(rootDirectory, path);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function findAgentsMdUp(input: {
|
export async function findAgentsMdUp(input: {
|
||||||
startDir: string;
|
startDir: string;
|
||||||
rootDir: string;
|
rootDir: string;
|
||||||
}): string[] {
|
}): Promise<string[]> {
|
||||||
const found: string[] = [];
|
const found: string[] = [];
|
||||||
let current = input.startDir;
|
let current = input.startDir;
|
||||||
|
|
||||||
@@ -22,7 +22,11 @@ export function findAgentsMdUp(input: {
|
|||||||
const isRootDir = current === input.rootDir;
|
const isRootDir = current === input.rootDir;
|
||||||
if (!isRootDir) {
|
if (!isRootDir) {
|
||||||
const agentsPath = join(current, AGENTS_FILENAME);
|
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);
|
found.push(agentsPath);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -84,6 +84,23 @@ describe("processFilePathForAgentsInjection", () => {
|
|||||||
expect(output.output).toContain(srcAgentsContent)
|
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 () => {
|
it("skips root-level AGENTS.md", async () => {
|
||||||
// given
|
// given
|
||||||
rmSync(join(srcDirectory, "AGENTS.md"), { force: true })
|
rmSync(join(srcDirectory, "AGENTS.md"), { force: true })
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { PluginInput } from "@opencode-ai/plugin";
|
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 { dirname } from "node:path";
|
||||||
|
|
||||||
import type { createDynamicTruncator } from "../../shared/dynamic-truncator";
|
import type { createDynamicTruncator } from "../../shared/dynamic-truncator";
|
||||||
@@ -31,7 +31,7 @@ export async function processFilePathForAgentsInjection(input: {
|
|||||||
|
|
||||||
const dir = dirname(resolved);
|
const dir = dirname(resolved);
|
||||||
const cache = getSessionCache(input.sessionCaches, input.sessionID);
|
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;
|
let dirty = false;
|
||||||
for (const agentsPath of agentsPaths) {
|
for (const agentsPath of agentsPaths) {
|
||||||
@@ -39,7 +39,8 @@ export async function processFilePathForAgentsInjection(input: {
|
|||||||
if (cache.has(agentsDir)) continue;
|
if (cache.has(agentsDir)) continue;
|
||||||
|
|
||||||
try {
|
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(
|
const { result, truncated } = await input.truncator.truncate(
|
||||||
input.sessionID,
|
input.sessionID,
|
||||||
content,
|
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}]`
|
? `\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}`;
|
input.output.output += `\n\n[Directory Context: ${agentsPath}]\n${result}${truncationNotice}`;
|
||||||
cache.add(agentsDir);
|
|
||||||
dirty = true;
|
dirty = true;
|
||||||
} catch {}
|
} 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 { dirname, isAbsolute, join, resolve } from "node:path";
|
||||||
|
|
||||||
import { README_FILENAME } from "./constants";
|
import { README_FILENAME } from "./constants";
|
||||||
@@ -9,17 +9,19 @@ export function resolveFilePath(rootDirectory: string, path: string): string | n
|
|||||||
return resolve(rootDirectory, path);
|
return resolve(rootDirectory, path);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function findReadmeMdUp(input: {
|
export async function findReadmeMdUp(input: {
|
||||||
startDir: string;
|
startDir: string;
|
||||||
rootDir: string;
|
rootDir: string;
|
||||||
}): string[] {
|
}): Promise<string[]> {
|
||||||
const found: string[] = [];
|
const found: string[] = [];
|
||||||
let current = input.startDir;
|
let current = input.startDir;
|
||||||
|
|
||||||
while (true) {
|
while (true) {
|
||||||
const readmePath = join(current, README_FILENAME);
|
const readmePath = join(current, README_FILENAME);
|
||||||
if (existsSync(readmePath)) {
|
try {
|
||||||
|
await access(readmePath);
|
||||||
found.push(readmePath);
|
found.push(readmePath);
|
||||||
|
} catch {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (current === input.rootDir) break;
|
if (current === input.rootDir) break;
|
||||||
|
|||||||
@@ -133,6 +133,32 @@ describe("processFilePathForReadmeInjection", () => {
|
|||||||
expect(output.output).toContain("# Components README")
|
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 () => {
|
it("does not re-inject already cached directories", async () => {
|
||||||
// given
|
// given
|
||||||
const sourceDirectory = join(testRoot, "src")
|
const sourceDirectory = join(testRoot, "src")
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { PluginInput } from "@opencode-ai/plugin";
|
import type { PluginInput } from "@opencode-ai/plugin";
|
||||||
import { readFileSync } from "node:fs";
|
import { readFile } from "node:fs/promises";
|
||||||
import { dirname } from "node:path";
|
import { dirname } from "node:path";
|
||||||
|
|
||||||
import type { createDynamicTruncator } from "../../shared/dynamic-truncator";
|
import type { createDynamicTruncator } from "../../shared/dynamic-truncator";
|
||||||
@@ -31,7 +31,7 @@ export async function processFilePathForReadmeInjection(input: {
|
|||||||
|
|
||||||
const dir = dirname(resolved);
|
const dir = dirname(resolved);
|
||||||
const cache = getSessionCache(input.sessionCaches, input.sessionID);
|
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;
|
let dirty = false;
|
||||||
for (const readmePath of readmePaths) {
|
for (const readmePath of readmePaths) {
|
||||||
@@ -39,7 +39,7 @@ export async function processFilePathForReadmeInjection(input: {
|
|||||||
if (cache.has(readmeDir)) continue;
|
if (cache.has(readmeDir)) continue;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const content = readFileSync(readmePath, "utf-8");
|
const content = await readFile(readmePath, "utf-8");
|
||||||
const { result, truncated } = await input.truncator.truncate(
|
const { result, truncated } = await input.truncator.truncate(
|
||||||
input.sessionID,
|
input.sessionID,
|
||||||
content,
|
content,
|
||||||
|
|||||||
@@ -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"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
import { clearInjectedRules, loadInjectedRules } from "./storage";
|
import { clearInjectedRules, loadInjectedRules } from "./storage";
|
||||||
|
import { createRuleScanCache } from "./rule-scan-cache";
|
||||||
|
import type { RuleScanCache } from "./rule-scan-cache";
|
||||||
|
|
||||||
export type SessionInjectedRulesCache = {
|
export type SessionInjectedRulesCache = {
|
||||||
contentHashes: Set<string>;
|
contentHashes: Set<string>;
|
||||||
@@ -25,3 +27,29 @@ export function createSessionCacheStore(): {
|
|||||||
|
|
||||||
return { getSessionCache, clearSessionCache };
|
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 };
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import type { PluginInput } from "@opencode-ai/plugin";
|
import type { PluginInput } from "@opencode-ai/plugin";
|
||||||
import { createDynamicTruncator } from "../../shared/dynamic-truncator";
|
import { createDynamicTruncator } from "../../shared/dynamic-truncator";
|
||||||
import { getRuleInjectionFilePath } from "./output-path";
|
import { getRuleInjectionFilePath } from "./output-path";
|
||||||
import { createSessionCacheStore } from "./cache";
|
import { createSessionCacheStore, createSessionRuleScanCacheStore } from "./cache";
|
||||||
import { createRuleInjectionProcessor } from "./injector";
|
import { createRuleInjectionProcessor } from "./injector";
|
||||||
|
import { clearProjectRootCache } from "./project-root-finder";
|
||||||
|
|
||||||
interface ToolExecuteInput {
|
interface ToolExecuteInput {
|
||||||
tool: string;
|
tool: string;
|
||||||
@@ -36,15 +37,23 @@ export function createRulesInjectorHook(
|
|||||||
) {
|
) {
|
||||||
const truncator = createDynamicTruncator(ctx, modelCacheState);
|
const truncator = createDynamicTruncator(ctx, modelCacheState);
|
||||||
const { getSessionCache, clearSessionCache } = createSessionCacheStore();
|
const { getSessionCache, clearSessionCache } = createSessionCacheStore();
|
||||||
|
const { getSessionRuleScanCache, clearSessionRuleScanCache } =
|
||||||
|
createSessionRuleScanCacheStore();
|
||||||
const { processFilePathForInjection } = createRuleInjectionProcessor({
|
const { processFilePathForInjection } = createRuleInjectionProcessor({
|
||||||
workspaceDirectory: ctx.directory,
|
workspaceDirectory: ctx.directory,
|
||||||
truncator,
|
truncator,
|
||||||
getSessionCache,
|
getSessionCache,
|
||||||
|
getSessionRuleScanCache,
|
||||||
ruleFinderOptions: options?.skipClaudeUserRules
|
ruleFinderOptions: options?.skipClaudeUserRules
|
||||||
? { skipClaudeUserRules: true }
|
? { skipClaudeUserRules: true }
|
||||||
: undefined,
|
: undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function clearSessionState(sessionID: string): void {
|
||||||
|
clearSessionCache(sessionID);
|
||||||
|
clearSessionRuleScanCache(sessionID);
|
||||||
|
}
|
||||||
|
|
||||||
const toolExecuteAfter = async (
|
const toolExecuteAfter = async (
|
||||||
input: ToolExecuteInput,
|
input: ToolExecuteInput,
|
||||||
output: ToolExecuteOutput
|
output: ToolExecuteOutput
|
||||||
@@ -73,16 +82,18 @@ export function createRulesInjectorHook(
|
|||||||
if (event.type === "session.deleted") {
|
if (event.type === "session.deleted") {
|
||||||
const sessionInfo = props?.info as { id?: string } | undefined;
|
const sessionInfo = props?.info as { id?: string } | undefined;
|
||||||
if (sessionInfo?.id) {
|
if (sessionInfo?.id) {
|
||||||
clearSessionCache(sessionInfo.id);
|
clearSessionState(sessionInfo.id);
|
||||||
}
|
}
|
||||||
|
clearProjectRootCache();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (event.type === "session.compacted") {
|
if (event.type === "session.compacted") {
|
||||||
const sessionID = (props?.sessionID ??
|
const sessionID = (props?.sessionID ??
|
||||||
(props?.info as { id?: string } | undefined)?.id) as string | undefined;
|
(props?.info as { id?: string } | undefined)?.id) as string | undefined;
|
||||||
if (sessionID) {
|
if (sessionID) {
|
||||||
clearSessionCache(sessionID);
|
clearSessionState(sessionID);
|
||||||
}
|
}
|
||||||
|
clearProjectRootCache();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
import { parseRuleFrontmatter } from "./parser";
|
import { parseRuleFrontmatter } from "./parser";
|
||||||
import { saveInjectedRules } from "./storage";
|
import { saveInjectedRules } from "./storage";
|
||||||
import type { SessionInjectedRulesCache } from "./cache";
|
import type { SessionInjectedRulesCache } from "./cache";
|
||||||
|
import type { RuleScanCache } from "./rule-scan-cache";
|
||||||
import type { RuleMetadata } from "./types";
|
import type { RuleMetadata } from "./types";
|
||||||
|
|
||||||
type ToolExecuteOutput = {
|
type ToolExecuteOutput = {
|
||||||
@@ -56,6 +57,7 @@ export function createRuleInjectionProcessor(deps: {
|
|||||||
workspaceDirectory: string;
|
workspaceDirectory: string;
|
||||||
truncator: DynamicTruncator;
|
truncator: DynamicTruncator;
|
||||||
getSessionCache: (sessionID: string) => SessionInjectedRulesCache;
|
getSessionCache: (sessionID: string) => SessionInjectedRulesCache;
|
||||||
|
getSessionRuleScanCache?: (sessionID: string) => RuleScanCache;
|
||||||
ruleFinderOptions?: FindRuleFilesOptions;
|
ruleFinderOptions?: FindRuleFilesOptions;
|
||||||
readFileSync?: typeof readFileSync;
|
readFileSync?: typeof readFileSync;
|
||||||
statSync?: typeof statSync;
|
statSync?: typeof statSync;
|
||||||
@@ -76,6 +78,7 @@ export function createRuleInjectionProcessor(deps: {
|
|||||||
workspaceDirectory,
|
workspaceDirectory,
|
||||||
truncator,
|
truncator,
|
||||||
getSessionCache,
|
getSessionCache,
|
||||||
|
getSessionRuleScanCache,
|
||||||
ruleFinderOptions,
|
ruleFinderOptions,
|
||||||
readFileSync: readRuleFileSync = readFileSync,
|
readFileSync: readRuleFileSync = readFileSync,
|
||||||
statSync: statRuleSync = statSync,
|
statSync: statRuleSync = statSync,
|
||||||
@@ -121,9 +124,16 @@ export function createRuleInjectionProcessor(deps: {
|
|||||||
|
|
||||||
const projectRoot = findProjectRoot(resolved);
|
const projectRoot = findProjectRoot(resolved);
|
||||||
const cache = getSessionCache(sessionID);
|
const cache = getSessionCache(sessionID);
|
||||||
|
const ruleScanCache = getSessionRuleScanCache?.(sessionID);
|
||||||
const home = getHomeDir();
|
const home = getHomeDir();
|
||||||
|
|
||||||
const ruleFileCandidates = findRuleFiles(projectRoot, home, resolved, ruleFinderOptions);
|
const ruleFileCandidates = findRuleFiles(
|
||||||
|
projectRoot,
|
||||||
|
home,
|
||||||
|
resolved,
|
||||||
|
ruleFinderOptions,
|
||||||
|
ruleScanCache,
|
||||||
|
);
|
||||||
const toInject: RuleToInject[] = [];
|
const toInject: RuleToInject[] = [];
|
||||||
let dirty = false;
|
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 { dirname, join } from "node:path";
|
||||||
import { PROJECT_MARKERS } from "./constants";
|
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.
|
* Find project root by walking up from startPath.
|
||||||
* Checks for PROJECT_MARKERS (.git, pyproject.toml, package.json, etc.)
|
* 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
|
* @returns Project root path or null if not found
|
||||||
*/
|
*/
|
||||||
export function findProjectRoot(startPath: string): string | null {
|
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;
|
let current: string;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,51 +1,108 @@
|
|||||||
import { existsSync, statSync } from "node:fs";
|
import { existsSync, statSync } from "node:fs";
|
||||||
import { dirname, join } from "node:path";
|
import { dirname, join, sep } from "node:path";
|
||||||
import {
|
import {
|
||||||
|
OPENCODE_USER_RULE_DIRS,
|
||||||
PROJECT_RULE_FILES,
|
PROJECT_RULE_FILES,
|
||||||
PROJECT_RULE_SUBDIRS,
|
PROJECT_RULE_SUBDIRS,
|
||||||
USER_RULE_DIR,
|
USER_RULE_DIR,
|
||||||
OPENCODE_USER_RULE_DIRS,
|
|
||||||
} from "./constants";
|
} from "./constants";
|
||||||
import type { RuleFileCandidate } from "./types";
|
import type { RuleScanCache } from "./rule-scan-cache";
|
||||||
import { findRuleFilesRecursive, safeRealpathSync } from "./rule-file-scanner";
|
import { findRuleFilesRecursive, safeRealpathSync } from "./rule-file-scanner";
|
||||||
|
import type { RuleFileCandidate } from "./types";
|
||||||
|
|
||||||
export interface FindRuleFilesOptions {
|
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;
|
skipClaudeUserRules?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
function getUserRuleDirs(homeDir: string, skipClaudeUserRules: boolean): string[] {
|
||||||
* Find all rule files for a given context.
|
const userRuleDirs = OPENCODE_USER_RULE_DIRS.map((dir) => join(homeDir, dir));
|
||||||
* Searches from currentFile upward to projectRoot for rule directories,
|
if (!skipClaudeUserRules) {
|
||||||
* then user-level directory (~/.claude/rules).
|
userRuleDirs.push(join(homeDir, USER_RULE_DIR));
|
||||||
*
|
}
|
||||||
* IMPORTANT: This searches EVERY directory from file to project root.
|
return userRuleDirs;
|
||||||
* Not just the project root itself.
|
}
|
||||||
*
|
|
||||||
* @param projectRoot - Project root path (or null if outside any project)
|
function createCacheKey(
|
||||||
* @param homeDir - User home directory
|
projectRoot: string | null,
|
||||||
* @param currentFile - Current file being edited (for distance calculation)
|
startDir: string,
|
||||||
* @returns Array of rule file candidates sorted by distance
|
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(
|
export function findRuleFiles(
|
||||||
projectRoot: string | null,
|
projectRoot: string | null,
|
||||||
homeDir: string,
|
homeDir: string,
|
||||||
currentFile: string,
|
currentFile: string,
|
||||||
options?: FindRuleFilesOptions,
|
options?: FindRuleFilesOptions,
|
||||||
|
cache?: RuleScanCache,
|
||||||
): RuleFileCandidate[] {
|
): 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 candidates: RuleFileCandidate[] = [];
|
||||||
const seenRealPaths = new Set<string>();
|
const seenRealPaths = new Set<string>();
|
||||||
|
let currentDir = startDir;
|
||||||
// Search from current file's directory up to project root
|
|
||||||
let currentDir = dirname(currentFile);
|
|
||||||
let distance = 0;
|
let distance = 0;
|
||||||
|
|
||||||
while (true) {
|
while (true) {
|
||||||
// Search rule directories in current directory
|
|
||||||
for (const [parent, subdir] of PROJECT_RULE_SUBDIRS) {
|
for (const [parent, subdir] of PROJECT_RULE_SUBDIRS) {
|
||||||
const ruleDir = join(currentDir, parent, subdir);
|
const ruleDir = join(currentDir, parent, subdir);
|
||||||
const files: string[] = [];
|
const files: string[] = [];
|
||||||
@@ -55,60 +112,41 @@ export function findRuleFiles(
|
|||||||
const realPath = safeRealpathSync(filePath);
|
const realPath = safeRealpathSync(filePath);
|
||||||
if (seenRealPaths.has(realPath)) continue;
|
if (seenRealPaths.has(realPath)) continue;
|
||||||
seenRealPaths.add(realPath);
|
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;
|
if (projectRoot && currentDir === projectRoot) break;
|
||||||
const parentDir = dirname(currentDir);
|
const parentDir = dirname(currentDir);
|
||||||
if (parentDir === currentDir) break;
|
if (parentDir === currentDir) break;
|
||||||
currentDir = parentDir;
|
currentDir = parentDir;
|
||||||
distance++;
|
distance += 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for single-file rules at project root (e.g., .github/copilot-instructions.md)
|
|
||||||
if (projectRoot) {
|
if (projectRoot) {
|
||||||
for (const ruleFile of PROJECT_RULE_FILES) {
|
for (const ruleFile of PROJECT_RULE_FILES) {
|
||||||
const filePath = join(projectRoot, ruleFile);
|
const filePath = join(projectRoot, ruleFile);
|
||||||
if (existsSync(filePath)) {
|
if (!existsSync(filePath)) continue;
|
||||||
try {
|
|
||||||
const stat = statSync(filePath);
|
try {
|
||||||
if (stat.isFile()) {
|
const stat = statSync(filePath);
|
||||||
const realPath = safeRealpathSync(filePath);
|
if (!stat.isFile()) continue;
|
||||||
if (!seenRealPaths.has(realPath)) {
|
const realPath = safeRealpathSync(filePath);
|
||||||
seenRealPaths.add(realPath);
|
if (seenRealPaths.has(realPath)) continue;
|
||||||
candidates.push({
|
seenRealPaths.add(realPath);
|
||||||
path: filePath,
|
candidates.push({
|
||||||
realPath,
|
path: filePath,
|
||||||
isGlobal: false,
|
realPath,
|
||||||
distance: 0,
|
isGlobal: false,
|
||||||
isSingleFile: true,
|
distance: 0,
|
||||||
});
|
isSingleFile: true,
|
||||||
}
|
});
|
||||||
}
|
} catch {
|
||||||
} catch {
|
continue;
|
||||||
// Skip if file can't be read
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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) {
|
for (const userRuleDir of userRuleDirs) {
|
||||||
const userFiles: string[] = [];
|
const userFiles: string[] = [];
|
||||||
findRuleFilesRecursive(userRuleDir, userFiles);
|
findRuleFilesRecursive(userRuleDir, userFiles);
|
||||||
@@ -117,23 +155,21 @@ export function findRuleFiles(
|
|||||||
const realPath = safeRealpathSync(filePath);
|
const realPath = safeRealpathSync(filePath);
|
||||||
if (seenRealPaths.has(realPath)) continue;
|
if (seenRealPaths.has(realPath)) continue;
|
||||||
seenRealPaths.add(realPath);
|
seenRealPaths.add(realPath);
|
||||||
|
candidates.push({ path: filePath, realPath, isGlobal: true, distance: 9999 });
|
||||||
candidates.push({
|
|
||||||
path: filePath,
|
|
||||||
realPath,
|
|
||||||
isGlobal: true,
|
|
||||||
distance: 9999, // Global rules always have max distance
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sort by distance (closest first, then global rules last)
|
candidates.sort((left, right) => {
|
||||||
candidates.sort((a, b) => {
|
if (left.isGlobal !== right.isGlobal) {
|
||||||
if (a.isGlobal !== b.isGlobal) {
|
return left.isGlobal ? 1 : -1;
|
||||||
return a.isGlobal ? 1 : -1;
|
|
||||||
}
|
}
|
||||||
return a.distance - b.distance;
|
return left.distance - right.distance;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
cache?.set(
|
||||||
|
cacheKey,
|
||||||
|
candidates.map((candidate) => candidate.path),
|
||||||
|
);
|
||||||
|
|
||||||
return candidates;
|
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 { existsSync, readdirSync, realpathSync } from "node:fs";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
|
import { EXCLUDED_DIRS } from "../../shared";
|
||||||
import { GITHUB_INSTRUCTIONS_PATTERN, RULE_EXTENSIONS } from "./constants";
|
import { GITHUB_INSTRUCTIONS_PATTERN, RULE_EXTENSIONS } from "./constants";
|
||||||
|
|
||||||
function isGitHubInstructionsDir(dir: string): boolean {
|
function isGitHubInstructionsDir(dir: string): boolean {
|
||||||
@@ -28,6 +29,7 @@ export function findRuleFilesRecursive(dir: string, results: string[]): void {
|
|||||||
const fullPath = join(dir, entry.name);
|
const fullPath = join(dir, entry.name);
|
||||||
|
|
||||||
if (entry.isDirectory()) {
|
if (entry.isDirectory()) {
|
||||||
|
if (EXCLUDED_DIRS.has(entry.name)) continue;
|
||||||
findRuleFilesRecursive(fullPath, results);
|
findRuleFilesRecursive(fullPath, results);
|
||||||
} else if (entry.isFile()) {
|
} else if (entry.isFile()) {
|
||||||
if (isValidRuleFile(entry.name, dir)) {
|
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();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -107,9 +107,10 @@ describe("createRuntimeFallbackHook dispose", () => {
|
|||||||
globalThis.clearTimeout = originalClearTimeout
|
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
|
// given
|
||||||
const hook = createRuntimeFallbackHook(createMockContext(), { pluginConfig: {} })
|
const hook = createRuntimeFallbackHook(createMockContext(), { pluginConfig: {} })
|
||||||
|
await hook.event({ event: { type: "session.created", properties: {} } })
|
||||||
|
|
||||||
// when
|
// when
|
||||||
hook.dispose?.()
|
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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,7 +1,5 @@
|
|||||||
import type { HookDeps, RuntimeFallbackHook, RuntimeFallbackInterval, RuntimeFallbackOptions, RuntimeFallbackPluginInput, RuntimeFallbackTimeout } from "./types"
|
import type { HookDeps, RuntimeFallbackHook, RuntimeFallbackInterval, RuntimeFallbackOptions, RuntimeFallbackPluginInput, RuntimeFallbackTimeout } from "./types"
|
||||||
import { DEFAULT_CONFIG, HOOK_NAME } from "./constants"
|
import { DEFAULT_CONFIG } from "./constants"
|
||||||
import { log } from "../../shared/logger"
|
|
||||||
import { loadPluginConfig } from "../../plugin-config"
|
|
||||||
import { createAutoRetryHelpers } from "./auto-retry"
|
import { createAutoRetryHelpers } from "./auto-retry"
|
||||||
import { createEventHandler } from "./event-handler"
|
import { createEventHandler } from "./event-handler"
|
||||||
import { createMessageUpdateHandler } from "./message-update-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,
|
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 = {
|
const deps: HookDeps = {
|
||||||
ctx,
|
ctx,
|
||||||
config,
|
config,
|
||||||
options,
|
options,
|
||||||
pluginConfig,
|
pluginConfig: options?.pluginConfig,
|
||||||
sessionStates: new Map(),
|
sessionStates: new Map(),
|
||||||
sessionLastAccess: new Map(),
|
sessionLastAccess: new Map(),
|
||||||
sessionRetryInFlight: new Set(),
|
sessionRetryInFlight: new Set(),
|
||||||
@@ -51,10 +40,23 @@ export function createRuntimeFallbackHook(
|
|||||||
const messageUpdateHandler = createMessageUpdateHandler(deps, helpers)
|
const messageUpdateHandler = createMessageUpdateHandler(deps, helpers)
|
||||||
const chatMessageHandler = createChatMessageHandler(deps)
|
const chatMessageHandler = createChatMessageHandler(deps)
|
||||||
|
|
||||||
const cleanupInterval = setInterval(helpers.cleanupStaleSessions, 5 * 60 * 1000)
|
let cleanupInterval: RuntimeFallbackInterval | null = null
|
||||||
cleanupInterval.unref()
|
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 } }) => {
|
const eventHandler = async ({ event }: { event: { type: string; properties?: unknown } }) => {
|
||||||
|
ensureInterval()
|
||||||
|
|
||||||
if (event.type === "message.updated") {
|
if (event.type === "message.updated") {
|
||||||
if (!config.enabled) return
|
if (!config.enabled) return
|
||||||
const props = event.properties as Record<string, unknown> | undefined
|
const props = event.properties as Record<string, unknown> | undefined
|
||||||
@@ -65,7 +67,9 @@ export function createRuntimeFallbackHook(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const dispose = () => {
|
const dispose = () => {
|
||||||
clearInterval(cleanupInterval)
|
if (cleanupInterval) {
|
||||||
|
clearInterval(cleanupInterval)
|
||||||
|
}
|
||||||
|
|
||||||
for (const fallbackTimeout of deps.sessionFallbackTimeouts.values()) {
|
for (const fallbackTimeout of deps.sessionFallbackTimeouts.values()) {
|
||||||
clearTimeout(fallbackTimeout)
|
clearTimeout(fallbackTimeout)
|
||||||
|
|||||||
@@ -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).toHaveLength(1)
|
||||||
expect(notificationCalls[0]).toContain("Agent needs permission to continue")
|
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 {}
|
export {}
|
||||||
|
|||||||
@@ -1,20 +1,12 @@
|
|||||||
import type { PluginInput } from "@opencode-ai/plugin"
|
import type { PluginInput } from "@opencode-ai/plugin"
|
||||||
import { subagentSessions, getMainSessionID } from "../features/claude-code-session-state"
|
import { subagentSessions, getMainSessionID } from "../features/claude-code-session-state"
|
||||||
import {
|
|
||||||
startBackgroundCheck,
|
|
||||||
} from "./session-notification-utils"
|
|
||||||
import { buildReadyNotificationContent } from "./session-notification-content"
|
import { buildReadyNotificationContent } from "./session-notification-content"
|
||||||
import {
|
import { type Platform } from "./session-notification-sender"
|
||||||
type Platform,
|
|
||||||
} from "./session-notification-sender"
|
|
||||||
import * as sessionNotificationSender from "./session-notification-sender"
|
import * as sessionNotificationSender from "./session-notification-sender"
|
||||||
import {
|
import { getEventToolName, getQuestionText, getSessionID } from "./session-notification-event-properties"
|
||||||
getEventToolName,
|
|
||||||
getQuestionText,
|
|
||||||
getSessionID,
|
|
||||||
} from "./session-notification-event-properties"
|
|
||||||
import { hasIncompleteTodos } from "./session-todo-status"
|
import { hasIncompleteTodos } from "./session-todo-status"
|
||||||
import { createIdleNotificationScheduler } from "./session-notification-scheduler"
|
import { createIdleNotificationScheduler } from "./session-notification-scheduler"
|
||||||
|
import { createSessionNotificationInit } from "./session-notification-init"
|
||||||
|
|
||||||
interface SessionNotificationConfig {
|
interface SessionNotificationConfig {
|
||||||
title?: string
|
title?: string
|
||||||
@@ -33,22 +25,15 @@ interface SessionNotificationConfig {
|
|||||||
/** Grace period in ms to ignore late-arriving activity events after scheduling (default: 100) */
|
/** Grace period in ms to ignore late-arriving activity events after scheduling (default: 100) */
|
||||||
activityGracePeriodMs?: number
|
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 = {
|
const mergedConfig = {
|
||||||
title: "OpenCode",
|
title: "OpenCode",
|
||||||
message: "Agent is ready for input",
|
message: "Agent is ready for input",
|
||||||
questionMessage: "Agent is asking a question",
|
questionMessage: "Agent is asking a question",
|
||||||
permissionMessage: "Agent needs permission to continue",
|
permissionMessage: "Agent needs permission to continue",
|
||||||
playSound: false,
|
playSound: false,
|
||||||
soundPath: defaultSoundPath,
|
soundPath: "",
|
||||||
idleConfirmationDelay: 1500,
|
idleConfirmationDelay: 1500,
|
||||||
skipIfIncompleteTodos: true,
|
skipIfIncompleteTodos: true,
|
||||||
maxTrackedSessions: 100,
|
maxTrackedSessions: 100,
|
||||||
@@ -56,22 +41,18 @@ export function createSessionNotification(
|
|||||||
...config,
|
...config,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const sessionNotificationInit = createSessionNotificationInit()
|
||||||
|
let currentPlatform: Platform | null = null
|
||||||
|
let defaultSoundPath = mergedConfig.soundPath
|
||||||
|
|
||||||
const scheduler = createIdleNotificationScheduler({
|
const scheduler = createIdleNotificationScheduler({
|
||||||
ctx,
|
ctx,
|
||||||
platform: currentPlatform,
|
platform: "unsupported",
|
||||||
config: mergedConfig,
|
config: mergedConfig,
|
||||||
hasIncompleteTodos,
|
hasIncompleteTodos,
|
||||||
send: async (hookCtx, platform, sessionID) => {
|
send: async (hookCtx, platform, sessionID) => {
|
||||||
if (
|
if (typeof hookCtx.client.session.get !== "function" && typeof hookCtx.client.session.messages !== "function") {
|
||||||
typeof hookCtx.client.session.get !== "function"
|
await sessionNotificationSender.sendSessionNotification(hookCtx, platform, mergedConfig.title, mergedConfig.message)
|
||||||
&& typeof hookCtx.client.session.messages !== "function"
|
|
||||||
) {
|
|
||||||
await sessionNotificationSender.sendSessionNotification(
|
|
||||||
hookCtx,
|
|
||||||
platform,
|
|
||||||
mergedConfig.title,
|
|
||||||
mergedConfig.message,
|
|
||||||
)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,6 +71,15 @@ export function createSessionNotification(
|
|||||||
const PERMISSION_EVENTS = new Set(["permission.ask", "permission.asked", "permission.updated", "permission.requested"])
|
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 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 => {
|
const shouldNotifyForSession = (sessionID: string): boolean => {
|
||||||
if (subagentSessions.has(sessionID)) return false
|
if (subagentSessions.has(sessionID)) return false
|
||||||
|
|
||||||
@@ -102,16 +92,12 @@ export function createSessionNotification(
|
|||||||
}
|
}
|
||||||
|
|
||||||
return async ({ event }: { event: { type: string; properties?: unknown } }) => {
|
return async ({ event }: { event: { type: string; properties?: unknown } }) => {
|
||||||
if (currentPlatform === "unsupported") return
|
|
||||||
|
|
||||||
const props = event.properties as Record<string, unknown> | undefined
|
const props = event.properties as Record<string, unknown> | undefined
|
||||||
|
|
||||||
if (event.type === "session.created") {
|
if (event.type === "session.created") {
|
||||||
const info = props?.info as Record<string, unknown> | undefined
|
const info = props?.info as Record<string, unknown> | undefined
|
||||||
const sessionID = info?.id as string | undefined
|
const sessionID = info?.id as string | undefined
|
||||||
if (sessionID) {
|
if (sessionID) scheduler.markSessionActivity(sessionID)
|
||||||
scheduler.markSessionActivity(sessionID)
|
|
||||||
}
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -119,6 +105,8 @@ export function createSessionNotification(
|
|||||||
const sessionID = getSessionID(props)
|
const sessionID = getSessionID(props)
|
||||||
if (!sessionID) return
|
if (!sessionID) return
|
||||||
|
|
||||||
|
const platform = ensureNotificationPlatform()
|
||||||
|
if (platform === "unsupported") return
|
||||||
if (!shouldNotifyForSession(sessionID)) return
|
if (!shouldNotifyForSession(sessionID)) return
|
||||||
|
|
||||||
scheduler.scheduleIdleNotification(sessionID)
|
scheduler.scheduleIdleNotification(sessionID)
|
||||||
@@ -128,26 +116,22 @@ export function createSessionNotification(
|
|||||||
if (event.type === "message.updated") {
|
if (event.type === "message.updated") {
|
||||||
const info = props?.info as Record<string, unknown> | undefined
|
const info = props?.info as Record<string, unknown> | undefined
|
||||||
const sessionID = getSessionID({ ...props, info })
|
const sessionID = getSessionID({ ...props, info })
|
||||||
if (sessionID) {
|
if (sessionID) scheduler.markSessionActivity(sessionID)
|
||||||
scheduler.markSessionActivity(sessionID)
|
|
||||||
}
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (PERMISSION_EVENTS.has(event.type)) {
|
if (PERMISSION_EVENTS.has(event.type)) {
|
||||||
const sessionID = getSessionID(props)
|
const sessionID = getSessionID(props)
|
||||||
if (!sessionID) return
|
if (!sessionID) return
|
||||||
|
|
||||||
|
const platform = ensureNotificationPlatform()
|
||||||
|
if (platform === "unsupported") return
|
||||||
if (!shouldNotifyForSession(sessionID)) return
|
if (!shouldNotifyForSession(sessionID)) return
|
||||||
|
|
||||||
scheduler.markSessionActivity(sessionID)
|
scheduler.markSessionActivity(sessionID)
|
||||||
await sessionNotificationSender.sendSessionNotification(
|
await sessionNotificationSender.sendSessionNotification(ctx, platform, mergedConfig.title, mergedConfig.permissionMessage)
|
||||||
ctx,
|
if (mergedConfig.playSound && defaultSoundPath) {
|
||||||
currentPlatform,
|
await sessionNotificationSender.playSessionNotificationSound(ctx, platform, defaultSoundPath)
|
||||||
mergedConfig.title,
|
|
||||||
mergedConfig.permissionMessage,
|
|
||||||
)
|
|
||||||
if (mergedConfig.playSound && mergedConfig.soundPath) {
|
|
||||||
await sessionNotificationSender.playSessionNotificationSound(ctx, currentPlatform, mergedConfig.soundPath)
|
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -160,16 +144,16 @@ export function createSessionNotification(
|
|||||||
if (event.type === "tool.execute.before") {
|
if (event.type === "tool.execute.before") {
|
||||||
const toolName = getEventToolName(props)?.toLowerCase()
|
const toolName = getEventToolName(props)?.toLowerCase()
|
||||||
if (toolName && QUESTION_TOOLS.has(toolName)) {
|
if (toolName && QUESTION_TOOLS.has(toolName)) {
|
||||||
|
const platform = ensureNotificationPlatform()
|
||||||
|
if (platform === "unsupported") return
|
||||||
if (!shouldNotifyForSession(sessionID)) return
|
if (!shouldNotifyForSession(sessionID)) return
|
||||||
|
|
||||||
const questionText = getQuestionText(props)
|
const questionText = getQuestionText(props)
|
||||||
const message = PERMISSION_HINT_PATTERN.test(questionText)
|
const message = PERMISSION_HINT_PATTERN.test(questionText) ? mergedConfig.permissionMessage : mergedConfig.questionMessage
|
||||||
? mergedConfig.permissionMessage
|
|
||||||
: mergedConfig.questionMessage
|
|
||||||
|
|
||||||
await sessionNotificationSender.sendSessionNotification(ctx, currentPlatform, mergedConfig.title, message)
|
await sessionNotificationSender.sendSessionNotification(ctx, platform, mergedConfig.title, message)
|
||||||
if (mergedConfig.playSound && mergedConfig.soundPath) {
|
if (mergedConfig.playSound && defaultSoundPath) {
|
||||||
await sessionNotificationSender.playSessionNotificationSound(ctx, currentPlatform, mergedConfig.soundPath)
|
await sessionNotificationSender.playSessionNotificationSound(ctx, platform, defaultSoundPath)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -179,9 +163,7 @@ export function createSessionNotification(
|
|||||||
|
|
||||||
if (event.type === "session.deleted") {
|
if (event.type === "session.deleted") {
|
||||||
const sessionInfo = props?.info as { id?: string } | undefined
|
const sessionInfo = props?.info as { id?: string } | undefined
|
||||||
if (sessionInfo?.id) {
|
if (sessionInfo?.id) scheduler.deleteSession(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 { afterAll, afterEach, describe, expect, it, mock } from "bun:test"
|
||||||
|
|
||||||
|
import type { BackgroundManager } from "../../features/background-agent"
|
||||||
import * as actualSessionStateModule from "./session-state"
|
import * as actualSessionStateModule from "./session-state"
|
||||||
import type { SessionStateStore } from "./session-state"
|
import type { SessionStateStore } from "./session-state"
|
||||||
|
|
||||||
@@ -37,6 +38,12 @@ function createMockPluginInput(): PluginInput {
|
|||||||
} as PluginInput
|
} as PluginInput
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createMockBackgroundManager(): BackgroundManager {
|
||||||
|
return {
|
||||||
|
getTasksByParentSession: () => [{ status: "running" }],
|
||||||
|
} as BackgroundManager
|
||||||
|
}
|
||||||
|
|
||||||
function getCreatedSessionStateStore(): SessionStateStore {
|
function getCreatedSessionStateStore(): SessionStateStore {
|
||||||
if (!createdSessionStateStore) {
|
if (!createdSessionStateStore) {
|
||||||
throw new Error("expected session state store to be created")
|
throw new Error("expected session state store to be created")
|
||||||
@@ -68,7 +75,7 @@ describe("todo-continuation-enforcer dispose", () => {
|
|||||||
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
|
// given
|
||||||
const originalClearInterval = globalThis.clearInterval
|
const originalClearInterval = globalThis.clearInterval
|
||||||
const clearIntervalCalls: Array<Parameters<typeof clearInterval>[0]> = []
|
const clearIntervalCalls: Array<Parameters<typeof clearInterval>[0]> = []
|
||||||
@@ -78,9 +85,13 @@ describe("todo-continuation-enforcer dispose", () => {
|
|||||||
}) as typeof clearInterval
|
}) as typeof clearInterval
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const enforcer = createTodoContinuationEnforcer(createMockPluginInput())
|
const enforcer = createTodoContinuationEnforcer(createMockPluginInput(), {
|
||||||
|
backgroundManager: createMockBackgroundManager(),
|
||||||
|
})
|
||||||
const sessionStateStore = getCreatedSessionStateStore()
|
const sessionStateStore = getCreatedSessionStateStore()
|
||||||
|
|
||||||
|
await enforcer.handler({ event: { type: "session.idle", properties: { sessionID: "session-1" } } })
|
||||||
|
|
||||||
enforcer.markRecovering("session-1")
|
enforcer.markRecovering("session-1")
|
||||||
enforcer.markRecovering("session-2")
|
enforcer.markRecovering("session-2")
|
||||||
|
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ export function createTodoContinuationHandler(args: {
|
|||||||
const sessionID = props?.sessionID as string | undefined
|
const sessionID = props?.sessionID as string | undefined
|
||||||
if (!sessionID) return
|
if (!sessionID) return
|
||||||
|
|
||||||
|
sessionStateStore.startPruneInterval()
|
||||||
await handleSessionIdle({
|
await handleSessionIdle({
|
||||||
ctx,
|
ctx,
|
||||||
sessionID,
|
sessionID,
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ export interface ContinuationProgressUpdate {
|
|||||||
export interface SessionStateStore {
|
export interface SessionStateStore {
|
||||||
getState: (sessionID: string) => SessionState
|
getState: (sessionID: string) => SessionState
|
||||||
getExistingState: (sessionID: string) => SessionState | undefined
|
getExistingState: (sessionID: string) => SessionState | undefined
|
||||||
|
startPruneInterval: () => void
|
||||||
recordActivity: (sessionID: string) => void
|
recordActivity: (sessionID: string) => void
|
||||||
trackContinuationProgress: (
|
trackContinuationProgress: (
|
||||||
sessionID: string,
|
sessionID: string,
|
||||||
@@ -76,18 +77,26 @@ export function createSessionStateStore(): SessionStateStore {
|
|||||||
|
|
||||||
// Periodic pruning of stale session states to prevent unbounded Map growth
|
// Periodic pruning of stale session states to prevent unbounded Map growth
|
||||||
let pruneInterval: TimerHandle | undefined
|
let pruneInterval: TimerHandle | undefined
|
||||||
pruneInterval = setInterval(() => {
|
let pruneIntervalStarted = false
|
||||||
const now = Date.now()
|
|
||||||
for (const [sessionID, tracked] of sessions.entries()) {
|
function startPruneInterval(): void {
|
||||||
if (now - tracked.lastAccessedAt > SESSION_STATE_TTL_MS) {
|
if (pruneIntervalStarted) {
|
||||||
cancelCountdown(sessionID)
|
return
|
||||||
sessions.delete(sessionID)
|
}
|
||||||
}
|
|
||||||
|
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 {
|
function getTrackedSession(sessionID: string): TrackedSessionState {
|
||||||
@@ -272,6 +281,7 @@ export function createSessionStateStore(): SessionStateStore {
|
|||||||
return {
|
return {
|
||||||
getState,
|
getState,
|
||||||
getExistingState,
|
getExistingState,
|
||||||
|
startPruneInterval,
|
||||||
recordActivity,
|
recordActivity,
|
||||||
trackContinuationProgress,
|
trackContinuationProgress,
|
||||||
resetContinuationProgress,
|
resetContinuationProgress,
|
||||||
|
|||||||
@@ -249,6 +249,33 @@ describe("todo-continuation-enforcer", () => {
|
|||||||
_resetForTesting()
|
_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 () => {
|
test("should inject continuation when idle with incomplete todos", async () => {
|
||||||
fakeTimers.restore()
|
fakeTimers.restore()
|
||||||
// given - main session with incomplete todos
|
// given - main session with incomplete todos
|
||||||
|
|||||||
@@ -76,7 +76,15 @@ export function isOverwriteEnabled(value: boolean | string | undefined): boolean
|
|||||||
export function createWriteExistingFileGuardHook(ctx: PluginInput): Hooks {
|
export function createWriteExistingFileGuardHook(ctx: PluginInput): Hooks {
|
||||||
const readPermissionsBySession = new Map<string, Set<string>>()
|
const readPermissionsBySession = new Map<string, Set<string>>()
|
||||||
const sessionLastAccess = new Map<string, number>()
|
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 {
|
return {
|
||||||
"tool.execute.before": async (input, output) => {
|
"tool.execute.before": async (input, output) => {
|
||||||
@@ -86,7 +94,7 @@ export function createWriteExistingFileGuardHook(ctx: PluginInput): Hooks {
|
|||||||
output,
|
output,
|
||||||
readPermissionsBySession,
|
readPermissionsBySession,
|
||||||
sessionLastAccess,
|
sessionLastAccess,
|
||||||
canonicalSessionRoot,
|
getCanonicalSessionRoot,
|
||||||
maxTrackedSessions: MAX_TRACKED_SESSIONS,
|
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 }
|
output: { args?: unknown }
|
||||||
readPermissionsBySession: Map<string, Set<string>>
|
readPermissionsBySession: Map<string, Set<string>>
|
||||||
sessionLastAccess: Map<string, number>
|
sessionLastAccess: Map<string, number>
|
||||||
canonicalSessionRoot: string
|
getCanonicalSessionRoot: () => string
|
||||||
maxTrackedSessions: number
|
maxTrackedSessions: number
|
||||||
}): Promise<void> {
|
}): 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()
|
const toolName = input.tool?.toLowerCase()
|
||||||
if (toolName !== "write" && toolName !== "read") {
|
if (toolName !== "write" && toolName !== "read") {
|
||||||
return
|
return
|
||||||
@@ -107,6 +107,7 @@ export async function handleWriteExistingFileGuardToolExecuteBefore(params: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const resolvedPath = resolveInputPath(ctx, filePath)
|
const resolvedPath = resolveInputPath(ctx, filePath)
|
||||||
|
const canonicalSessionRoot = getCanonicalSessionRoot()
|
||||||
const canonicalPath = toCanonicalPath(resolvedPath)
|
const canonicalPath = toCanonicalPath(resolvedPath)
|
||||||
if (!isPathInsideDirectory(canonicalPath, canonicalSessionRoot)) {
|
if (!isPathInsideDirectory(canonicalPath, canonicalSessionRoot)) {
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -1,5 +1,13 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test"
|
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 mockShowConfigErrorsIfAny = mock(async () => {})
|
||||||
const mockShowModelCacheWarningIfNeeded = mock(async () => {})
|
const mockShowModelCacheWarningIfNeeded = mock(async () => {})
|
||||||
@@ -38,6 +46,12 @@ function runSessionCreatedEvent(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function drainDeferredCheck(): void {
|
||||||
|
const run = scheduledDeferredCheck
|
||||||
|
scheduledDeferredCheck = null
|
||||||
|
run?.()
|
||||||
|
}
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
mockShowConfigErrorsIfAny.mockClear()
|
mockShowConfigErrorsIfAny.mockClear()
|
||||||
mockShowModelCacheWarningIfNeeded.mockClear()
|
mockShowModelCacheWarningIfNeeded.mockClear()
|
||||||
@@ -51,6 +65,8 @@ beforeEach(() => {
|
|||||||
|
|
||||||
mockGetCachedVersion.mockReturnValue("3.6.0")
|
mockGetCachedVersion.mockReturnValue("3.6.0")
|
||||||
mockGetLocalDevVersion.mockReturnValue(null)
|
mockGetLocalDevVersion.mockReturnValue(null)
|
||||||
|
|
||||||
|
scheduledDeferredCheck = null
|
||||||
})
|
})
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -108,8 +124,9 @@ describe("createAutoUpdateCheckerHook", () => {
|
|||||||
log: () => {},
|
log: () => {},
|
||||||
})
|
})
|
||||||
|
|
||||||
//#when - session.created event arrives on primary session
|
//#when - session.created schedules work and deferred check drains it
|
||||||
runSessionCreatedEvent(hook)
|
runSessionCreatedEvent(hook)
|
||||||
|
drainDeferredCheck()
|
||||||
await flushScheduledWork()
|
await flushScheduledWork()
|
||||||
|
|
||||||
//#then - startup checks, toast, and background check run
|
//#then - startup checks, toast, and background check run
|
||||||
@@ -165,9 +182,10 @@ describe("createAutoUpdateCheckerHook", () => {
|
|||||||
log: () => {},
|
log: () => {},
|
||||||
})
|
})
|
||||||
|
|
||||||
//#when - session.created event is fired twice
|
//#when - session.created fires twice and deferred check drains once
|
||||||
runSessionCreatedEvent(hook)
|
runSessionCreatedEvent(hook)
|
||||||
runSessionCreatedEvent(hook)
|
runSessionCreatedEvent(hook)
|
||||||
|
drainDeferredCheck()
|
||||||
await flushScheduledWork()
|
await flushScheduledWork()
|
||||||
|
|
||||||
//#then - side effects execute only once
|
//#then - side effects execute only once
|
||||||
@@ -195,8 +213,9 @@ describe("createAutoUpdateCheckerHook", () => {
|
|||||||
log: () => {},
|
log: () => {},
|
||||||
})
|
})
|
||||||
|
|
||||||
//#when - session.created event arrives
|
//#when - session.created schedules and deferred check drains
|
||||||
runSessionCreatedEvent(hook)
|
runSessionCreatedEvent(hook)
|
||||||
|
drainDeferredCheck()
|
||||||
await flushScheduledWork()
|
await flushScheduledWork()
|
||||||
|
|
||||||
//#then - local dev toast is shown and background check is skipped
|
//#then - local dev toast is shown and background check is skipped
|
||||||
@@ -259,8 +278,9 @@ describe("createAutoUpdateCheckerHook", () => {
|
|||||||
log: () => {},
|
log: () => {},
|
||||||
})
|
})
|
||||||
|
|
||||||
//#when - session.created event arrives
|
//#when - session.created schedules and deferred check drains
|
||||||
runSessionCreatedEvent(hook)
|
runSessionCreatedEvent(hook)
|
||||||
|
drainDeferredCheck()
|
||||||
await flushScheduledWork()
|
await flushScheduledWork()
|
||||||
|
|
||||||
//#then - startup toast includes sisyphus wording
|
//#then - startup toast includes sisyphus wording
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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))
|
||||||
@@ -79,3 +79,4 @@ export * from "./log-legacy-plugin-startup-warning"
|
|||||||
export * from "./task-system-enabled"
|
export * from "./task-system-enabled"
|
||||||
export * from "./parse-tools-config"
|
export * from "./parse-tools-config"
|
||||||
export { parseModelString } from "./model-string-parser"
|
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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, test } from "bun:test"
|
import { afterEach, beforeEach, describe, expect, test } from "bun:test"
|
||||||
import { detectConfigFile, detectPluginConfigFile, parseJsonc, parseJsoncSafe, readJsoncFile } from "./jsonc-parser"
|
import { clearPluginConfigFileDetectionCache, detectConfigFile, detectPluginConfigFile, parseJsonc, parseJsoncSafe, readJsoncFile } from "./jsonc-parser"
|
||||||
import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"
|
import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"
|
||||||
import { join } from "node:path"
|
import { join } from "node:path"
|
||||||
|
|
||||||
@@ -330,6 +330,14 @@ describe("detectConfigFile", () => {
|
|||||||
describe("detectPluginConfigFile", () => {
|
describe("detectPluginConfigFile", () => {
|
||||||
const testDir = join(__dirname, ".test-detect-plugin")
|
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", () => {
|
test("prefers oh-my-openagent over oh-my-opencode when both jsonc files exist", () => {
|
||||||
// given
|
// given
|
||||||
if (!existsSync(testDir)) mkdirSync(testDir, { recursive: true })
|
if (!existsSync(testDir)) mkdirSync(testDir, { recursive: true })
|
||||||
|
|||||||
+28
-10
@@ -9,6 +9,14 @@ export interface JsoncParseResult<T> {
|
|||||||
errors: Array<{ message: string; offset: number; length: number }>
|
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 {
|
function stripBom(content: string): string {
|
||||||
return content.charCodeAt(0) === 0xfeff ? content.slice(1) : content
|
return content.charCodeAt(0) === 0xfeff ? content.slice(1) : content
|
||||||
}
|
}
|
||||||
@@ -75,24 +83,34 @@ export function detectConfigFile(basePath: string): {
|
|||||||
return { format: "none", path: jsonPath }
|
return { format: "none", path: jsonPath }
|
||||||
}
|
}
|
||||||
|
|
||||||
export function detectPluginConfigFile(dir: string): {
|
export function clearPluginConfigFileDetectionCache(): void {
|
||||||
format: "json" | "jsonc" | "none"
|
pluginConfigFileDetectionCache.clear()
|
||||||
path: string
|
}
|
||||||
legacyPath?: string
|
|
||||||
} {
|
export function detectPluginConfigFile(dir: string): DetectPluginConfigResult {
|
||||||
|
const cachedResult = pluginConfigFileDetectionCache.get(dir)
|
||||||
|
|
||||||
|
if (cachedResult !== undefined) {
|
||||||
|
return cachedResult
|
||||||
|
}
|
||||||
|
|
||||||
const canonicalResult = detectConfigFile(join(dir, CONFIG_BASENAME))
|
const canonicalResult = detectConfigFile(join(dir, CONFIG_BASENAME))
|
||||||
const legacyResult = detectConfigFile(join(dir, LEGACY_CONFIG_BASENAME))
|
const legacyResult = detectConfigFile(join(dir, LEGACY_CONFIG_BASENAME))
|
||||||
|
|
||||||
|
let detectionResult: DetectPluginConfigResult
|
||||||
|
|
||||||
if (canonicalResult.format !== "none") {
|
if (canonicalResult.format !== "none") {
|
||||||
return {
|
detectionResult = {
|
||||||
...canonicalResult,
|
...canonicalResult,
|
||||||
legacyPath: legacyResult.format !== "none" ? legacyResult.path : undefined,
|
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") {
|
pluginConfigFileDetectionCache.set(dir, detectionResult)
|
||||||
return legacyResult
|
|
||||||
}
|
|
||||||
|
|
||||||
return { format: "none", path: join(dir, `${CONFIG_BASENAME}.json`) }
|
return detectionResult
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -8,6 +8,8 @@ interface OpencodeConfig {
|
|||||||
plugin?: (string | [string, ...unknown[]])[]
|
plugin?: (string | [string, ...unknown[]])[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const opencodePluginsCache = new Map<string, string[]>()
|
||||||
|
|
||||||
function getWindowsAppdataDir(): string | null {
|
function getWindowsAppdataDir(): string | null {
|
||||||
return process.env.APPDATA || null
|
return process.env.APPDATA || null
|
||||||
}
|
}
|
||||||
@@ -33,6 +35,11 @@ function getConfigPaths(directory: string): string[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function loadOpencodePlugins(directory: string): string[] {
|
export function loadOpencodePlugins(directory: string): string[] {
|
||||||
|
const cachedPluginEntries = opencodePluginsCache.get(directory)
|
||||||
|
if (cachedPluginEntries) {
|
||||||
|
return cachedPluginEntries
|
||||||
|
}
|
||||||
|
|
||||||
const pluginEntries: string[] = []
|
const pluginEntries: string[] = []
|
||||||
const seenPluginEntries = new Set<string>()
|
const seenPluginEntries = new Set<string>()
|
||||||
|
|
||||||
@@ -56,5 +63,10 @@ export function loadOpencodePlugins(directory: string): string[] {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
opencodePluginsCache.set(directory, pluginEntries)
|
||||||
return pluginEntries
|
return pluginEntries
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function clearOpencodePluginsCache(): void {
|
||||||
|
opencodePluginsCache.clear()
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 { mkdirSync, realpathSync, rmSync } from "node:fs"
|
||||||
import { tmpdir } from "node:os"
|
import { tmpdir } from "node:os"
|
||||||
import { join } from "node:path"
|
import { join } from "node:path"
|
||||||
import {
|
|
||||||
findProjectAgentsSkillDirs,
|
|
||||||
findProjectClaudeSkillDirs,
|
|
||||||
findProjectOpencodeCommandDirs,
|
|
||||||
findProjectOpencodeSkillDirs,
|
|
||||||
} from "./project-discovery-dirs"
|
|
||||||
|
|
||||||
const TEST_DIR = join(tmpdir(), `project-discovery-dirs-${Date.now()}`)
|
const TEST_DIR = join(tmpdir(), `project-discovery-dirs-${Date.now()}`)
|
||||||
|
let worktreeSpawnCount = 0
|
||||||
|
|
||||||
function canonicalPath(path: string): string {
|
function canonicalPath(path: string): string {
|
||||||
return realpathSync(path)
|
return realpathSync(path)
|
||||||
@@ -24,7 +19,35 @@ describe("project-discovery-dirs", () => {
|
|||||||
rmSync(TEST_DIR, { recursive: true, force: true })
|
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
|
// given
|
||||||
const projectDir = join(TEST_DIR, "project")
|
const projectDir = join(TEST_DIR, "project")
|
||||||
const childDir = join(projectDir, "apps", "cli")
|
const childDir = join(projectDir, "apps", "cli")
|
||||||
@@ -32,6 +55,8 @@ describe("project-discovery-dirs", () => {
|
|||||||
mkdirSync(join(projectDir, ".opencode", "skills"), { recursive: true })
|
mkdirSync(join(projectDir, ".opencode", "skills"), { recursive: true })
|
||||||
mkdirSync(join(TEST_DIR, ".opencode", "skills"), { recursive: true })
|
mkdirSync(join(TEST_DIR, ".opencode", "skills"), { recursive: true })
|
||||||
|
|
||||||
|
const { findProjectOpencodeSkillDirs } = await import("./project-discovery-dirs")
|
||||||
|
|
||||||
// when
|
// when
|
||||||
const directories = findProjectOpencodeSkillDirs(childDir)
|
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
|
// given
|
||||||
const projectDir = join(TEST_DIR, "project")
|
const projectDir = join(TEST_DIR, "project")
|
||||||
const childDir = join(projectDir, "packages", "tool")
|
const childDir = join(projectDir, "packages", "tool")
|
||||||
mkdirSync(join(projectDir, ".opencode", "commands"), { recursive: true })
|
mkdirSync(join(projectDir, ".opencode", "commands"), { recursive: true })
|
||||||
mkdirSync(join(TEST_DIR, ".opencode", "command"), { recursive: true })
|
mkdirSync(join(TEST_DIR, ".opencode", "command"), { recursive: true })
|
||||||
|
|
||||||
|
const { findProjectOpencodeCommandDirs } = await import("./project-discovery-dirs")
|
||||||
|
|
||||||
// when
|
// when
|
||||||
const directories = findProjectOpencodeCommandDirs(childDir)
|
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
|
// given
|
||||||
const projectDir = join(TEST_DIR, "project")
|
const projectDir = join(TEST_DIR, "project")
|
||||||
const childDir = join(projectDir, "src", "nested")
|
const childDir = join(projectDir, "src", "nested")
|
||||||
mkdirSync(join(projectDir, ".claude", "skills"), { recursive: true })
|
mkdirSync(join(projectDir, ".claude", "skills"), { recursive: true })
|
||||||
mkdirSync(join(TEST_DIR, ".agents", "skills"), { recursive: true })
|
mkdirSync(join(TEST_DIR, ".agents", "skills"), { recursive: true })
|
||||||
|
|
||||||
|
const { findProjectAgentsSkillDirs, findProjectClaudeSkillDirs } = await import("./project-discovery-dirs")
|
||||||
|
|
||||||
// when
|
// when
|
||||||
const claudeDirectories = findProjectClaudeSkillDirs(childDir)
|
const claudeDirectories = findProjectClaudeSkillDirs(childDir)
|
||||||
const agentsDirectories = findProjectAgentsSkillDirs(childDir)
|
const agentsDirectories = findProjectAgentsSkillDirs(childDir)
|
||||||
@@ -76,17 +105,20 @@ describe("project-discovery-dirs", () => {
|
|||||||
expect(agentsDirectories).toEqual([canonicalPath(join(TEST_DIR, ".agents", "skills"))])
|
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
|
// given
|
||||||
const projectDir = join(TEST_DIR, "project")
|
const projectDir = join(TEST_DIR, "project")
|
||||||
const childDir = join(projectDir, "apps", "cli")
|
const childDir = join(projectDir, "apps", "cli")
|
||||||
mkdirSync(join(projectDir, ".opencode", "skills"), { recursive: true })
|
mkdirSync(join(projectDir, ".opencode", "skills"), { recursive: true })
|
||||||
mkdirSync(join(TEST_DIR, ".opencode", "skills"), { recursive: true })
|
mkdirSync(join(TEST_DIR, ".opencode", "skills"), { recursive: true })
|
||||||
|
|
||||||
|
const { findProjectOpencodeSkillDirs } = await import("./project-discovery-dirs")
|
||||||
|
|
||||||
// when
|
// when
|
||||||
const directories = findProjectOpencodeSkillDirs(childDir, projectDir)
|
const directories = findProjectOpencodeSkillDirs(childDir, projectDir)
|
||||||
|
|
||||||
// then
|
// then
|
||||||
expect(directories).toEqual([canonicalPath(join(projectDir, ".opencode", "skills"))])
|
expect(directories).toEqual([canonicalPath(join(projectDir, ".opencode", "skills"))])
|
||||||
})
|
})
|
||||||
|
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import { execFileSync } from "node:child_process"
|
|||||||
import { existsSync, realpathSync } from "node:fs"
|
import { existsSync, realpathSync } from "node:fs"
|
||||||
import { dirname, join, resolve } from "node:path"
|
import { dirname, join, resolve } from "node:path"
|
||||||
|
|
||||||
|
const worktreePathCache = new Map<string, string | undefined>()
|
||||||
|
|
||||||
function normalizePath(path: string): string {
|
function normalizePath(path: string): string {
|
||||||
const resolvedPath = resolve(path)
|
const resolvedPath = resolve(path)
|
||||||
if (!existsSync(resolvedPath)) {
|
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 {
|
try {
|
||||||
return execFileSync("git", ["rev-parse", "--show-toplevel"], {
|
const worktreePath = execFileSync("git", ["rev-parse", "--show-toplevel"], {
|
||||||
cwd: directory,
|
cwd: resolvedDirectory,
|
||||||
encoding: "utf-8",
|
encoding: "utf-8",
|
||||||
timeout: 5000,
|
timeout: 5000,
|
||||||
stdio: ["pipe", "pipe", "pipe"],
|
stdio: ["pipe", "pipe", "pipe"],
|
||||||
}).trim()
|
}).trim()
|
||||||
|
|
||||||
|
worktreePathCache.set(resolvedDirectory, worktreePath)
|
||||||
|
return worktreePath
|
||||||
} catch {
|
} catch {
|
||||||
|
worktreePathCache.set(resolvedDirectory, undefined)
|
||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,14 +38,17 @@ function formatSlashCommand(command: CommandInfo): string {
|
|||||||
return lines.join("\n")
|
return lines.join("\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatCombinedDescription(skills: SkillInfo[], commands: CommandInfo[]): string {
|
export function formatCombinedDescription(skills?: SkillInfo[], commands?: CommandInfo[]): string {
|
||||||
if (skills.length === 0 && commands.length === 0) {
|
const availableSkills = skills ?? []
|
||||||
|
const availableCommands = commands ?? []
|
||||||
|
|
||||||
|
if (availableSkills.length === 0 && availableCommands.length === 0) {
|
||||||
return TOOL_DESCRIPTION_NO_SKILLS
|
return TOOL_DESCRIPTION_NO_SKILLS
|
||||||
}
|
}
|
||||||
|
|
||||||
const availableItems = [
|
const availableItems = [
|
||||||
...sortByScopePriority(skills).map(formatSkillCommand),
|
...sortByScopePriority(availableSkills).map(formatSkillCommand),
|
||||||
...sortByScopePriority(commands).map(formatSlashCommand),
|
...sortByScopePriority(availableCommands).map(formatSlashCommand),
|
||||||
]
|
]
|
||||||
|
|
||||||
if (availableItems.length === 0) {
|
if (availableItems.length === 0) {
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -2,9 +2,10 @@ import { dirname } from "node:path"
|
|||||||
import { tool, type ToolDefinition } from "@opencode-ai/plugin"
|
import { tool, type ToolDefinition } from "@opencode-ai/plugin"
|
||||||
import type { ToolContext } from "@opencode-ai/plugin/tool"
|
import type { ToolContext } from "@opencode-ai/plugin/tool"
|
||||||
import { TOOL_DESCRIPTION_PREFIX } from "./constants"
|
import { TOOL_DESCRIPTION_PREFIX } from "./constants"
|
||||||
|
import { shouldInvalidateSkillCacheForSession } from "./session-skill-cache"
|
||||||
import type { SkillArgs, SkillLoadOptions } from "./types"
|
import type { SkillArgs, SkillLoadOptions } from "./types"
|
||||||
import type { LoadedSkill } from "../../features/opencode-skill-loader"
|
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 { injectGitMasterConfig } from "../../features/opencode-skill-loader/skill-content"
|
||||||
import { discoverCommandsSync } from "../slashcommand/command-discovery"
|
import { discoverCommandsSync } from "../slashcommand/command-discovery"
|
||||||
import type { CommandInfo } from "../slashcommand/types"
|
import type { CommandInfo } from "../slashcommand/types"
|
||||||
@@ -27,12 +28,15 @@ import {
|
|||||||
export function createSkillTool(options: SkillLoadOptions = {}): ToolDefinition {
|
export function createSkillTool(options: SkillLoadOptions = {}): ToolDefinition {
|
||||||
let cachedDescription: string | null = null
|
let cachedDescription: string | null = null
|
||||||
|
|
||||||
const getSkills = async (): Promise<LoadedSkill[]> => {
|
const getSkills = async (context?: ToolContext): Promise<LoadedSkill[]> => {
|
||||||
clearSkillCache()
|
if (shouldInvalidateSkillCacheForSession(context?.sessionID)) {
|
||||||
const discovered = await getAllSkills({
|
clearSkillCache()
|
||||||
|
}
|
||||||
|
|
||||||
|
const discovered = (await getAllSkills({
|
||||||
disabledSkills: options?.disabledSkills,
|
disabledSkills: options?.disabledSkills,
|
||||||
browserProvider: options?.browserProvider,
|
browserProvider: options?.browserProvider,
|
||||||
})
|
})) ?? []
|
||||||
const allSkills = !options.skills
|
const allSkills = !options.skills
|
||||||
? discovered
|
? discovered
|
||||||
: [
|
: [
|
||||||
@@ -57,7 +61,7 @@ export function createSkillTool(options: SkillLoadOptions = {}): ToolDefinition
|
|||||||
return discoverCommandsSync(undefined, {
|
return discoverCommandsSync(undefined, {
|
||||||
pluginsEnabled: options.pluginsEnabled,
|
pluginsEnabled: options.pluginsEnabled,
|
||||||
enabledPluginsOverride: options.enabledPluginsOverride,
|
enabledPluginsOverride: options.enabledPluginsOverride,
|
||||||
})
|
}) ?? []
|
||||||
}
|
}
|
||||||
|
|
||||||
const buildDescription = async (force = false): Promise<string> => {
|
const buildDescription = async (force = false): Promise<string> => {
|
||||||
@@ -92,8 +96,6 @@ export function createSkillTool(options: SkillLoadOptions = {}): ToolDefinition
|
|||||||
}
|
}
|
||||||
} else if (options.commands !== undefined) {
|
} else if (options.commands !== undefined) {
|
||||||
cachedDescription = formatCombinedDescription([], options.commands)
|
cachedDescription = formatCombinedDescription([], options.commands)
|
||||||
} else {
|
|
||||||
void buildDescription()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return tool({
|
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'"),
|
.describe("Optional arguments or context for command invocation. Example: name='publish', user_message='patch'"),
|
||||||
},
|
},
|
||||||
async execute(args: SkillArgs, ctx?: ToolContext) {
|
async execute(args: SkillArgs, ctx?: ToolContext) {
|
||||||
const skills = await getSkills()
|
const skills = await getSkills(ctx)
|
||||||
const commands = getCommands()
|
const commands = getCommands()
|
||||||
cachedDescription = formatCombinedDescription(skills.map(loadedSkillToInfo), commands)
|
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 { afterAll, beforeEach, describe, expect, it, mock, spyOn } from "bun:test"
|
||||||
import type { ToolContext } from "@opencode-ai/plugin/tool"
|
import type { ToolContext } from "@opencode-ai/plugin/tool"
|
||||||
import * as fs from "node:fs"
|
import * as fs from "node:fs"
|
||||||
|
import { tmpdir } from "node:os"
|
||||||
|
import { join } from "node:path"
|
||||||
import { SkillMcpManager } from "../../../features/skill-mcp-manager"
|
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 { LoadedSkill } from "../../../features/opencode-skill-loader/types"
|
||||||
import type { CommandInfo } from "../../slashcommand/types"
|
import type { CommandInfo } from "../../slashcommand/types"
|
||||||
import type { Tool as McpTool } from "@modelcontextprotocol/sdk/types.js"
|
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
|
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", () => ({
|
mock.module("node:fs", () => ({
|
||||||
...fs,
|
...fs,
|
||||||
readFileSync: (path: string, encoding?: string) => {
|
readFileSync: (path: string, encoding?: string) => {
|
||||||
@@ -23,9 +47,8 @@ Test skill body content`
|
|||||||
return originalReadFileSync(path, encoding as BufferEncoding)
|
return originalReadFileSync(path, encoding as BufferEncoding)
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
|
|
||||||
const module = await import("../tools")
|
createSkillTool = requireFresh<typeof import("../tools")>("../tools").createSkillTool
|
||||||
createSkillTool = module.createSkillTool
|
|
||||||
})
|
})
|
||||||
|
|
||||||
afterAll(() => {
|
afterAll(() => {
|
||||||
@@ -548,16 +571,43 @@ describe("skill tool - ordering and priority", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe("skill tool - dynamic discovery", () => {
|
describe("skill tool - dynamic discovery", () => {
|
||||||
it("discovers skills from disk on every invocation instead of caching", async () => {
|
it("caches discovered skills across tool instances until the shared cache resets", async () => {
|
||||||
// given: tool created with initial skills
|
// given
|
||||||
const initialSkills = [createMockSkill("initial-skill")]
|
clearSkillCache()
|
||||||
const tool = createSkillTool({ skills: initialSkills })
|
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
|
fs.mkdirSync(initialSkillDirectory, { recursive: true })
|
||||||
const result = await tool.execute({ name: "initial-skill" }, mockContext)
|
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)
|
try {
|
||||||
expect(result).toContain("Skill: initial-skill")
|
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 () => {
|
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", () => {
|
describe("skill tool - dynamic description cache invalidation", () => {
|
||||||
it("rebuilds description after execute() discovers new skills", async () => {
|
it("keeps description available after execute misses a skill", async () => {
|
||||||
// given: tool created with initial skills (no pre-provided skills)
|
// given
|
||||||
// This triggers lazy description building
|
|
||||||
const tool = createSkillTool({})
|
const tool = createSkillTool({})
|
||||||
|
|
||||||
// Get initial description - it will build from empty or disk skills
|
// when
|
||||||
const initialDescription = tool.description
|
const initialDescription = tool.description
|
||||||
expect(initialDescription).toBeString()
|
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 {
|
try {
|
||||||
await tool.execute({ name: "nonexistent-skill-12345" }, mockContext)
|
await tool.execute({ name: "nonexistent-skill-12345" }, mockContext)
|
||||||
} catch (e) {
|
} catch {
|
||||||
// Expected to fail - skill doesn't exist
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// then: cachedDescription should be invalidated, so next description access should rebuild
|
// then
|
||||||
// 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
|
|
||||||
expect(tool.description).toBeDefined()
|
expect(tool.description).toBeDefined()
|
||||||
expect(typeof tool.description).toBe("string")
|
expect(typeof tool.description).toBe("string")
|
||||||
})
|
})
|
||||||
|
|
||||||
it("description reflects fresh skills after execute() clears cache", async () => {
|
it("picks up new disk skills only after the shared skill cache resets", async () => {
|
||||||
// given: tool created without pre-provided skills (will use disk discovery)
|
// given
|
||||||
const tool = createSkillTool({})
|
clearSkillCache()
|
||||||
|
const originalDirectory = process.cwd()
|
||||||
// when: execute() is called with a skill that exists on disk (via mock)
|
const temporaryDirectory = fs.mkdtempSync(join(tmpdir(), "skill-tool-refresh-"))
|
||||||
// This simulates the real scenario: execute() discovers skills, cache should be invalidated
|
const initialSkillDirectory = join(temporaryDirectory, ".opencode", "skills", "initial-skill")
|
||||||
|
const secondSkillDirectory = join(temporaryDirectory, ".opencode", "skills", "second-skill")
|
||||||
// Execute to trigger the cache invalidation path
|
|
||||||
|
fs.mkdirSync(initialSkillDirectory, { recursive: true })
|
||||||
|
fs.writeFileSync(join(initialSkillDirectory, "SKILL.md"), "---\ndescription: Initial skill\n---\nInitial skill body")
|
||||||
|
process.chdir(temporaryDirectory)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// This will call getSkills() which clears cache
|
const initialTool = createSkillTool({})
|
||||||
await tool.execute({ name: "nonexistent" }, mockContext)
|
await initialTool.execute({ name: "initial-skill" }, mockContext)
|
||||||
} catch (e) {
|
|
||||||
// Expected
|
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).toBeDefined()
|
||||||
expect(testCmd?.content).toContain("Test command content.")
|
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,
|
findProjectOpencodeCommandDirs,
|
||||||
getOpenCodeCommandDirs,
|
getOpenCodeCommandDirs,
|
||||||
discoverPluginCommandDefinitions,
|
discoverPluginCommandDefinitions,
|
||||||
|
EXCLUDED_DIRS,
|
||||||
} from "../../shared"
|
} from "../../shared"
|
||||||
import type { CommandFrontmatter } from "../../features/claude-code-command-loader/types"
|
import type { CommandFrontmatter } from "../../features/claude-code-command-loader/types"
|
||||||
import { isMarkdownFile } from "../../shared/file-utils"
|
import { isMarkdownFile } from "../../shared/file-utils"
|
||||||
@@ -36,6 +37,7 @@ function discoverCommandsFromDir(
|
|||||||
|
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
if (entry.isDirectory()) {
|
if (entry.isDirectory()) {
|
||||||
|
if (EXCLUDED_DIRS.has(entry.name)) continue
|
||||||
if (entry.name.startsWith(".")) continue
|
if (entry.name.startsWith(".")) continue
|
||||||
const nestedPrefix = prefix
|
const nestedPrefix = prefix
|
||||||
? `${prefix}${NESTED_COMMAND_SEPARATOR}${entry.name}`
|
? `${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 { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"
|
||||||
import { tmpdir } from "node:os"
|
import { tmpdir } from "node:os"
|
||||||
import { join } from "node:path"
|
import { join } from "node:path"
|
||||||
|
import { clearCommandLoaderCache } from "../../features/claude-code-command-loader"
|
||||||
|
|
||||||
function requireFresh<T>(modulePath: string): T {
|
function requireFresh<T>(modulePath: string): T {
|
||||||
const resolvedPath = require.resolve(modulePath)
|
const resolvedPath = require.resolve(modulePath)
|
||||||
@@ -25,12 +26,14 @@ describe("slashcommand discovery and execution compatibility", () => {
|
|||||||
let originalOpencodeConfigDir: string | undefined
|
let originalOpencodeConfigDir: string | undefined
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
clearCommandLoaderCache()
|
||||||
tempDir = mkdtempSync(join(tmpdir(), "omo-slashcommand-compat-test-"))
|
tempDir = mkdtempSync(join(tmpdir(), "omo-slashcommand-compat-test-"))
|
||||||
originalWorkingDirectory = process.cwd()
|
originalWorkingDirectory = process.cwd()
|
||||||
originalOpencodeConfigDir = process.env.OPENCODE_CONFIG_DIR
|
originalOpencodeConfigDir = process.env.OPENCODE_CONFIG_DIR
|
||||||
})
|
})
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
|
clearCommandLoaderCache()
|
||||||
process.chdir(originalWorkingDirectory)
|
process.chdir(originalWorkingDirectory)
|
||||||
|
|
||||||
if (originalOpencodeConfigDir === undefined) {
|
if (originalOpencodeConfigDir === undefined) {
|
||||||
|
|||||||
Reference in New Issue
Block a user