From 92382dd821101e5bdadf901bda1cab3accd7e0a2 Mon Sep 17 00:00:00 2001 From: TxHawks Date: Mon, 29 Jun 2026 18:50:42 +0300 Subject: [PATCH] feat(config): add project-level config --- README.md | 29 ++- src/config-controller.ts | 189 ++++++++++++++ src/config-modal.ts | 60 ++++- src/config-store.ts | 261 ++++++++++++++++++-- src/debug-logger.ts | 69 +++++- src/index.ts | 94 ++----- src/pending-diff-preview.ts | 16 +- src/presets.ts | 35 +-- src/tool-overrides.ts | 6 +- src/types.ts | 27 ++ tests/config-modal.test.ts | 75 +++++- tests/config-store.test.ts | 140 ++++++++++- tests/debug-logger.test.ts | 77 +++++- tests/index-integration.test.ts | 286 +++++++++++++++++++++- tests/presets-edge.test.ts | 5 + tests/reload-behavior.test.ts | 30 ++- tests/tool-overrides-registration.test.ts | 7 +- 17 files changed, 1225 insertions(+), 181 deletions(-) create mode 100644 src/config-controller.ts diff --git a/README.md b/README.md index 0dfac5c..dcc150e 100644 --- a/README.md +++ b/README.md @@ -83,10 +83,14 @@ Advanced options remain in `config.json`. ```text /tool-display show # Show the effective config summary -/tool-display reset # Reset to the default opencode preset -/tool-display preset opencode # Apply opencode preset -/tool-display preset balanced # Apply balanced preset -/tool-display preset verbose # Apply verbose preset +/tool-display reset # Reset the current effective scope to opencode +/tool-display reset --global # Reset global config +/tool-display reset --project # Reset project config (trusted projects only) +/tool-display preset opencode # Apply opencode preset to the current effective scope +/tool-display preset balanced # Apply balanced preset to the current effective scope +/tool-display preset verbose # Apply verbose preset to the current effective scope +/tool-display preset verbose --global # Apply preset to global config +/tool-display preset verbose --project # Apply preset to project config (trusted projects only) ``` ### Tool display adapter API @@ -124,11 +128,12 @@ import { decorateToolForDisplay, decorateMcpToolForDisplay } from "pi-tool-displ Runtime configuration is stored at: ```text -Default global path: ~/.pi/agent/extensions/pi-tool-display/config.json -Actual global path: $PI_CODING_AGENT_DIR/extensions/pi-tool-display/config.json when PI_CODING_AGENT_DIR is set +Global default: ~/.pi/agent/extensions/pi-tool-display/config.json +Global with PI_CODING_AGENT_DIR: $PI_CODING_AGENT_DIR/extensions/pi-tool-display/config.json +Project override: .pi/extensions/pi-tool-display/config.json ``` -A starter template is included at `config/config.example.json`. +Effective configuration is merged as defaults → global config → project config. Project-level config requires Pi 0.79.1 or newer so the extension can verify project trust status; when trust is unavailable or the project is not trusted, project config is ignored with a warning. Project saves store only values that differ from the global config so inherited global settings keep applying. A starter template is included at `config/config.example.json`. ### Configuration options @@ -268,7 +273,7 @@ Notes: ### Debug logging -Debug logging is disabled by default. Set `debug` to `true` in the extension root `config.json` only when collecting diagnostics; missing or non-`true` values are treated as `false`. When enabled, diagnostics are appended to `debug/debug.log` under a runtime-created `debug/` directory, and no debug output is written to the terminal. +Debug logging is disabled by default. Set `debug` to `true` in the global or active project `config.json` only when collecting diagnostics; missing or non-`true` values are treated as `false`. When enabled, diagnostics are appended to `debug/debug.log` next to the active config file, and no debug output is written to the terminal. ## Rendering notes @@ -321,9 +326,11 @@ Built-in tool overrides (including `bash`) are registered with deferred ownershi If your settings are not being applied: -1. Check that the global Pi tool-display config exists (default: `~/.pi/agent/extensions/pi-tool-display/config.json`, respects `PI_CODING_AGENT_DIR`) -2. Make sure the JSON is valid -3. Run `/tool-display show` to inspect the effective config summary +1. Check the global Pi tool-display config (default: `~/.pi/agent/extensions/pi-tool-display/config.json`, respects `PI_CODING_AGENT_DIR`) +2. Check the optional project override at `.pi/extensions/pi-tool-display/config.json` +3. Make sure the JSON is valid +4. Make sure you are running Pi 0.79.1 or newer and the project is trusted when expecting project overrides to apply +5. Run `/tool-display show` to inspect the effective config summary ### MCP or custom tool rendering not appearing diff --git a/src/config-controller.ts b/src/config-controller.ts new file mode 100644 index 0000000..1a2d123 --- /dev/null +++ b/src/config-controller.ts @@ -0,0 +1,189 @@ +import type { + ExtensionAPI, + ExtensionCommandContext, +} from "@earendil-works/pi-coding-agent"; +import { + getToolDisplayDebugPaths, + loadEffectiveToolDisplayConfig, + loadToolDisplayConfig, + normalizeToolDisplayConfig, + saveToolDisplayConfig, + saveToolDisplayConfigOverlay, + type EffectiveToolDisplayConfigLoadResult, + type ToolDisplayConfigScope, +} from "./config-store.js"; +import { + applyCapabilityConfigGuards, + detectToolDisplayCapabilities, + type ToolDisplayCapabilities, +} from "./capabilities.js"; +import type { ToolDisplayDebugRuntimeConfig } from "./debug-logger.js"; +import { + BUILT_IN_TOOL_OVERRIDE_NAMES, + type ToolDisplayConfig, +} from "./types.js"; + +export interface ToolDisplayRuntimeConfigController { + getConfig(): ToolDisplayConfig; + getConfigPath(): string; + getCapabilities(): ToolDisplayCapabilities; + getEffectiveConfig(): ToolDisplayConfig; + getDebugRuntimeConfig(): ToolDisplayDebugRuntimeConfig; + refreshFromContext(ctx: unknown): void; + refreshCapabilitiesFromContext(ctx: unknown): void; + consumePendingLoadWarnings(): string[]; + setConfig( + next: ToolDisplayConfig, + ctx: ExtensionCommandContext, + options?: { scope?: ToolDisplayConfigScope }, + ): boolean; +} + +function ownershipChanged( + previous: ToolDisplayConfig, + next: ToolDisplayConfig, +): boolean { + return BUILT_IN_TOOL_OVERRIDE_NAMES.some( + (toolName) => + previous.registerToolOverrides[toolName] !== + next.registerToolOverrides[toolName], + ); +} + +function getContextCwd(ctx: unknown): string { + const cwd = (ctx as { cwd?: unknown } | undefined)?.cwd; + return typeof cwd === "string" && cwd.length > 0 ? cwd : process.cwd(); +} + +function getContextProjectTrust(ctx: unknown): { trusted: boolean; trustApiAvailable: boolean } { + const isProjectTrusted = (ctx as { isProjectTrusted?: unknown } | undefined)?.isProjectTrusted; + if (typeof isProjectTrusted !== "function") { + return { trusted: false, trustApiAvailable: false }; + } + + try { + return { trusted: isProjectTrusted() === true, trustApiAvailable: true }; + } catch { + return { trusted: false, trustApiAvailable: true }; + } +} + +export function createToolDisplayConfigController(pi: ExtensionAPI): ToolDisplayRuntimeConfigController { + let currentCwd = process.cwd(); + let currentProjectTrusted = false; + let currentProjectTrustApiAvailable = false; + let configLoad: EffectiveToolDisplayConfigLoadResult = loadEffectiveToolDisplayConfig({ + cwd: currentCwd, + projectTrusted: currentProjectTrusted, + }); + let config: ToolDisplayConfig = configLoad.config; + let pendingLoadWarnings = [...configLoad.warnings]; + let capabilities: ToolDisplayCapabilities = { + hasMcpTooling: false, + hasRtkOptimizer: false, + }; + + const reloadConfig = (): void => { + configLoad = loadEffectiveToolDisplayConfig({ + cwd: currentCwd, + projectTrusted: currentProjectTrusted, + }); + config = configLoad.config; + pendingLoadWarnings = [...configLoad.warnings]; + }; + + const explainMissingTrustApi = (): void => { + if (currentProjectTrustApiAvailable || !configLoad.projectConfigIgnored) { + return; + } + + pendingLoadWarnings = pendingLoadWarnings.filter( + (warning) => !warning.startsWith("Ignored untrusted project tool-display config:"), + ); + pendingLoadWarnings.push( + `Project-level tool-display configs are only supported in Pi 0.79.1 or newer; ignored ${configLoad.projectConfigFile}. Upgrade Pi or use global config instead.`, + ); + }; + + const refreshCapabilities = (cwd = currentCwd): void => { + capabilities = detectToolDisplayCapabilities(pi, cwd); + }; + + return { + getConfig: () => config, + getConfigPath: () => configLoad.activeConfigFile, + getCapabilities: () => capabilities, + getEffectiveConfig: () => applyCapabilityConfigGuards(config, capabilities), + getDebugRuntimeConfig: () => ({ + debug: config.debug, + ...getToolDisplayDebugPaths(configLoad.activeConfigFile), + }), + refreshFromContext(ctx: unknown): void { + currentCwd = getContextCwd(ctx); + const projectTrust = getContextProjectTrust(ctx); + currentProjectTrusted = projectTrust.trusted; + currentProjectTrustApiAvailable = projectTrust.trustApiAvailable; + reloadConfig(); + explainMissingTrustApi(); + refreshCapabilities(currentCwd); + }, + refreshCapabilitiesFromContext(ctx: unknown): void { + refreshCapabilities(getContextCwd(ctx)); + }, + consumePendingLoadWarnings(): string[] { + const warnings = pendingLoadWarnings; + pendingLoadWarnings = []; + return warnings; + }, + setConfig( + next: ToolDisplayConfig, + ctx: ExtensionCommandContext, + options?: { scope?: ToolDisplayConfigScope }, + ): boolean { + const normalized = normalizeToolDisplayConfig(next); + const selectedScope = options?.scope ?? configLoad.activeScope; + const targetConfigFile = selectedScope === "project" + ? configLoad.projectConfigFile + : configLoad.globalConfigFile; + + if (selectedScope === "project" && !currentProjectTrusted) { + const message = currentProjectTrustApiAvailable + ? "Cannot save project tool-display config because this project is not trusted." + : "Project-level tool-display configs are only supported in Pi 0.79.1 or newer; cannot save project config. Upgrade Pi or use global config instead."; + ctx.ui.notify(message, "warning"); + return false; + } + + if (!targetConfigFile) { + ctx.ui.notify(`Cannot resolve ${selectedScope} tool-display config path.`, "error"); + return false; + } + + const previous = config; + const saved = selectedScope === "project" + ? saveToolDisplayConfigOverlay( + normalized, + loadToolDisplayConfig(configLoad.globalConfigFile).config, + targetConfigFile, + ) + : saveToolDisplayConfig(normalized, targetConfigFile); + if (!saved.success) { + if (saved.error) { + ctx.ui.notify(saved.error, "error"); + } + return false; + } + + reloadConfig(); + + if (ownershipChanged(previous, config)) { + ctx.ui.notify( + "Tool ownership updates apply after /reload.", + "warning", + ); + } + + return true; + }, + }; +} diff --git a/src/config-modal.ts b/src/config-modal.ts index f88ba55..5c1d759 100644 --- a/src/config-modal.ts +++ b/src/config-modal.ts @@ -1,6 +1,6 @@ import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent"; import type { ToolDisplayCapabilities } from "./capabilities.js"; -import { getToolDisplayConfigPath } from "./config-store.js"; +import { getToolDisplayConfigPath, type ToolDisplayConfigScope } from "./config-store.js"; import { detectToolDisplayPreset, getToolDisplayPresetConfig, @@ -14,8 +14,9 @@ import { type ToolDisplayConfig } from "./types.js"; interface ToolDisplayConfigController { getConfig(): ToolDisplayConfig; - setConfig(next: ToolDisplayConfig, ctx: ExtensionCommandContext): void; + setConfig(next: ToolDisplayConfig, ctx: ExtensionCommandContext, options?: { scope?: ToolDisplayConfigScope }): boolean | void; getCapabilities(): ToolDisplayCapabilities; + getConfigPath?(): string; } interface ModalOverlayOptions { @@ -75,6 +76,31 @@ function parseNumber(value: string, fallback: number): number { return Number.isNaN(parsed) ? fallback : parsed; } +function extractScopeFlag(raw: string): { command: string; scope?: ToolDisplayConfigScope } { + const tokens = raw.split(/\s+/).filter(Boolean); + let scope: ToolDisplayConfigScope | undefined; + const commandTokens: string[] = []; + + for (const token of tokens) { + const normalized = token.toLowerCase(); + if (normalized === "--project") { + scope = "project"; + continue; + } + if (normalized === "--global") { + scope = "global"; + continue; + } + if (normalized === "--effective") { + scope = undefined; + continue; + } + commandTokens.push(token); + } + + return { command: commandTokens.join(" "), scope }; +} + function buildAdvancedNotes( config: ToolDisplayConfig, capabilities: ToolDisplayCapabilities, @@ -92,8 +118,9 @@ function buildAdvancedNotes( function buildInspectorSettings( config: ToolDisplayConfig, capabilities: ToolDisplayCapabilities, + activeConfigPath = getToolDisplayConfigPath(), ): InspectorSettingItem[] { - const configPath = shortenPath(getToolDisplayConfigPath()); + const configPath = shortenPath(activeConfigPath); const items: InspectorSettingItem[] = [ { id: "preset", @@ -314,15 +341,18 @@ function buildInspectorSettings( return items; } -function applyPreset(preset: ToolDisplayPreset): ToolDisplayConfig { - return getToolDisplayPresetConfig(preset); +function applyPreset(preset: ToolDisplayPreset, currentConfig?: ToolDisplayConfig): ToolDisplayConfig { + const presetConfig = getToolDisplayPresetConfig(preset); + return currentConfig + ? { ...presetConfig, debug: currentConfig.debug } + : presetConfig; } function applySetting(config: ToolDisplayConfig, id: string, value: string): ToolDisplayConfig { switch (id) { case "preset": { const parsed = parseToolDisplayPreset(value); - return parsed ? applyPreset(parsed) : config; + return parsed ? applyPreset(parsed, config) : config; } case "enableNativeUserMessageBox": return { @@ -414,7 +444,7 @@ export async function openSettingsModal(ctx: ExtensionCommandContext, controller (tui, theme, _keybindings, done) => { const inspector = new SplitPaneInspectorModal( { - getSettings: () => buildInspectorSettings(controller.getConfig(), capabilities), + getSettings: () => buildInspectorSettings(controller.getConfig(), capabilities, controller.getConfigPath?.()), onChange: (id, newValue) => { const next = applySetting(controller.getConfig(), id, newValue); controller.setConfig(next, ctx); @@ -458,7 +488,9 @@ export function handleToolDisplayArgs(args: string, ctx: ExtensionCommandContext return false; } - const normalized = raw.toLowerCase(); + const parsedArgs = extractScopeFlag(raw); + const normalized = parsedArgs.command.toLowerCase(); + const setOptions = parsedArgs.scope ? { scope: parsedArgs.scope } : undefined; if (normalized === "show") { ctx.ui.notify( @@ -469,8 +501,10 @@ export function handleToolDisplayArgs(args: string, ctx: ExtensionCommandContext } if (normalized === "reset") { - controller.setConfig(getToolDisplayPresetConfig("opencode"), ctx); - ctx.ui.notify("Tool display preset reset to opencode.", "info"); + const saved = controller.setConfig(applyPreset("opencode", controller.getConfig()), ctx, setOptions); + if (saved !== false) { + ctx.ui.notify("Tool display preset reset to opencode.", "info"); + } return true; } @@ -482,8 +516,10 @@ export function handleToolDisplayArgs(args: string, ctx: ExtensionCommandContext return true; } - controller.setConfig(getToolDisplayPresetConfig(preset), ctx); - ctx.ui.notify(`Tool display preset set to ${preset}.`, "info"); + const saved = controller.setConfig(applyPreset(preset, controller.getConfig()), ctx, setOptions); + if (saved !== false) { + ctx.ui.notify(`Tool display preset set to ${preset}.`, "info"); + } return true; } diff --git a/src/config-store.ts b/src/config-store.ts index 23e6110..719dbd9 100644 --- a/src/config-store.ts +++ b/src/config-store.ts @@ -1,3 +1,4 @@ +import * as PiCodingAgent from "@earendil-works/pi-coding-agent"; import { resolvePiAgentDir } from "./agent-dir.js"; import { existsSync, mkdirSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs"; import { dirname, join } from "node:path"; @@ -16,12 +17,41 @@ import { READ_OUTPUT_MODES, SEARCH_OUTPUT_MODES, type ToolDisplayConfig, + TOOL_DISPLAY_SCALAR_CONFIG_KEYS, type ToolOverrideOwnership, } from "./types.js"; import { toRecord } from "./tool-metadata.js"; -const CONFIG_DIR = join(resolvePiAgentDir(), "extensions", "pi-tool-display"); -const CONFIG_FILE = join(CONFIG_DIR, "config.json"); +const piCodingAgentExports = PiCodingAgent as unknown as { CONFIG_DIR_NAME?: unknown }; +const PROJECT_CONFIG_DIR_NAME = typeof piCodingAgentExports.CONFIG_DIR_NAME === "string" + ? piCodingAgentExports.CONFIG_DIR_NAME + : ".pi"; +function getGlobalToolDisplayConfigDir(): string { + return join(resolvePiAgentDir(), "extensions", "pi-tool-display"); +} + +export function getGlobalToolDisplayConfigPath(): string { + return join(getGlobalToolDisplayConfigDir(), "config.json"); +} + +export type ToolDisplayConfigScope = "global" | "project"; + +export interface EffectiveToolDisplayConfigLoadOptions { + cwd?: string; + projectTrusted?: boolean; + globalConfigFile?: string; + projectConfigFile?: string; +} + +export interface EffectiveToolDisplayConfigLoadResult extends ConfigLoadResult { + activeScope: ToolDisplayConfigScope; + activeConfigFile: string; + globalConfigFile: string; + projectConfigFile?: string; + projectConfigLoaded: boolean; + projectConfigIgnored: boolean; + warnings: string[]; +} interface LegacyToolDisplayConfigSource extends Partial { registerReadToolOverride?: unknown; @@ -125,6 +155,124 @@ function getConfigFingerprint(configFile: string): string { } } +function isPlainRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function mergeConfigRecords( + base: Record, + override: Record, +): Record { + const merged: Record = { ...base }; + for (const [key, value] of Object.entries(override)) { + const existing = merged[key]; + merged[key] = isPlainRecord(existing) && isPlainRecord(value) + ? mergeConfigRecords(existing, value) + : value; + } + return merged; +} + +function mergeRawConfigSources(sources: unknown[]): Record { + return sources.reduce>((merged, source) => { + return isPlainRecord(source) ? mergeConfigRecords(merged, source) : merged; + }, {}); +} + +function readRawConfigFile(configFile: string): { exists: boolean; value?: unknown; error?: string } { + if (!existsSync(configFile)) { + return { exists: false }; + } + + try { + return { + exists: true, + value: JSON.parse(readFileSync(configFile, "utf-8")) as unknown, + }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return { + exists: true, + error: `Failed to parse ${configFile}: ${message}`, + }; + } +} + +function customToolOverrideConfigsEqual( + left: CustomToolOverrideConfig | undefined, + right: CustomToolOverrideConfig | undefined, +): boolean { + if (!left || !right) { + return left === right; + } + + return left.enabled === right.enabled && + left.kind === right.kind && + left.outputMode === right.outputMode; +} + +function createCustomToolOverridesOverlay( + next: Record, + base: Record, +): Record { + const overlay: Record = {}; + const toolNames = new Set([...Object.keys(next), ...Object.keys(base)]); + + for (const toolName of toolNames) { + const nextOverride = next[toolName]; + const baseOverride = base[toolName]; + if (customToolOverrideConfigsEqual(nextOverride, baseOverride)) { + continue; + } + + if (nextOverride) { + overlay[toolName] = { ...nextOverride }; + continue; + } + + if (baseOverride?.enabled) { + overlay[toolName] = { ...baseOverride, enabled: false }; + } + } + + return overlay; +} + +export function createToolDisplayConfigOverlay( + config: ToolDisplayConfig, + baseConfig: ToolDisplayConfig, +): Record { + const next = normalizeToolDisplayConfig(config); + const base = normalizeToolDisplayConfig(baseConfig); + const overlay: Record = {}; + + for (const key of TOOL_DISPLAY_SCALAR_CONFIG_KEYS) { + if (next[key] !== base[key]) { + overlay[key] = next[key]; + } + } + + const registerToolOverrides: Partial = {}; + for (const toolName of BUILT_IN_TOOL_OVERRIDE_NAMES) { + if (next.registerToolOverrides[toolName] !== base.registerToolOverrides[toolName]) { + registerToolOverrides[toolName] = next.registerToolOverrides[toolName]; + } + } + if (Object.keys(registerToolOverrides).length > 0) { + overlay.registerToolOverrides = registerToolOverrides; + } + + const customToolOverrides = createCustomToolOverridesOverlay( + next.customToolOverrides, + base.customToolOverrides, + ); + if (Object.keys(customToolOverrides).length > 0) { + overlay.customToolOverrides = customToolOverrides; + } + + return overlay; +} + function normalizeToolOverrideOwnership( rawOverrides: unknown, legacyRegisterReadToolOverride: unknown, @@ -205,6 +353,7 @@ export function normalizeToolDisplayConfig(raw: unknown): ToolDisplayConfig { typeof raw === "object" && raw !== null ? (raw as LegacyToolDisplayConfigSource) : ({} as LegacyToolDisplayConfigSource); return { + debug: toBoolean(source.debug, DEFAULT_TOOL_DISPLAY_CONFIG.debug), registerToolOverrides: normalizeToolOverrideOwnership( source.registerToolOverrides, source.registerReadToolOverride, @@ -239,27 +388,79 @@ export function normalizeToolDisplayConfig(raw: unknown): ToolDisplayConfig { }; } -export function loadToolDisplayConfig(configFile = CONFIG_FILE): ConfigLoadResult { +export function getProjectToolDisplayConfigPath( + cwd: string, + configDirName = PROJECT_CONFIG_DIR_NAME, +): string { + return join(cwd, configDirName, "extensions", "pi-tool-display", "config.json"); +} + +export function loadEffectiveToolDisplayConfig( + options: EffectiveToolDisplayConfigLoadOptions = {}, +): EffectiveToolDisplayConfigLoadResult { + const globalConfigFile = options.globalConfigFile ?? getGlobalToolDisplayConfigPath(); + const projectConfigFile = options.projectConfigFile ?? (options.cwd ? getProjectToolDisplayConfigPath(options.cwd) : undefined); + const warnings: string[] = []; + const rawSources: unknown[] = []; + + const globalRaw = readRawConfigFile(globalConfigFile); + if (globalRaw.error) { + warnings.push(globalRaw.error); + } else if (globalRaw.exists) { + rawSources.push(globalRaw.value); + } + + let projectConfigLoaded = false; + let projectConfigIgnored = false; + if (projectConfigFile && existsSync(projectConfigFile)) { + if (!options.projectTrusted) { + projectConfigIgnored = true; + warnings.push(`Ignored untrusted project tool-display config: ${projectConfigFile}`); + } else { + const projectRaw = readRawConfigFile(projectConfigFile); + if (projectRaw.error) { + warnings.push(projectRaw.error); + } else if (projectRaw.exists) { + rawSources.push(projectRaw.value); + projectConfigLoaded = true; + } + } + } + + const activeScope: ToolDisplayConfigScope = projectConfigLoaded ? "project" : "global"; + const activeConfigFile = activeScope === "project" && projectConfigFile ? projectConfigFile : globalConfigFile; + const mergedRaw = mergeRawConfigSources(rawSources); + + return { + config: normalizeToolDisplayConfig(mergedRaw), + activeScope, + activeConfigFile, + globalConfigFile, + projectConfigFile, + projectConfigLoaded, + projectConfigIgnored, + warnings, + error: warnings[0], + }; +} + +export function loadToolDisplayConfig(configFile = getGlobalToolDisplayConfigPath()): ConfigLoadResult { const fingerprint = getConfigFingerprint(configFile); if (cachedConfigResult && cachedConfigFile === configFile && cachedConfigFingerprint === fingerprint) { return cloneLoadResult(cachedConfigResult); } + const rawConfig = readRawConfigFile(configFile); let result: ConfigLoadResult; - if (!existsSync(configFile)) { + if (!rawConfig.exists) { result = { config: cloneDefaultConfig() }; + } else if (rawConfig.error) { + result = { + config: cloneDefaultConfig(), + error: rawConfig.error, + }; } else { - try { - const rawText = readFileSync(configFile, "utf-8"); - const rawConfig = JSON.parse(rawText) as unknown; - result = { config: normalizeToolDisplayConfig(rawConfig) }; - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - result = { - config: cloneDefaultConfig(), - error: `Failed to parse ${configFile}: ${message}`, - }; - } + result = { config: normalizeToolDisplayConfig(rawConfig.value) }; } cachedConfigFile = configFile; @@ -268,13 +469,12 @@ export function loadToolDisplayConfig(configFile = CONFIG_FILE): ConfigLoadResul return result; } -export function saveToolDisplayConfig(config: ToolDisplayConfig, configFile = CONFIG_FILE): ConfigSaveResult { - const normalized = normalizeToolDisplayConfig(config); +function writeToolDisplayConfigJson(configFile: string, value: unknown): ConfigSaveResult { const tmpFile = `${configFile}.tmp`; try { mkdirSync(dirname(configFile), { recursive: true }); - writeFileSync(tmpFile, `${JSON.stringify(normalized, null, 2)}\n`, "utf-8"); + writeFileSync(tmpFile, `${JSON.stringify(value, null, 2)}\n`, "utf-8"); renameSync(tmpFile, configFile); cachedConfigFile = undefined; cachedConfigFingerprint = undefined; @@ -296,6 +496,29 @@ export function saveToolDisplayConfig(config: ToolDisplayConfig, configFile = CO } } +export function saveToolDisplayConfig(config: ToolDisplayConfig, configFile = getGlobalToolDisplayConfigPath()): ConfigSaveResult { + return writeToolDisplayConfigJson(configFile, normalizeToolDisplayConfig(config)); +} + +export function saveToolDisplayConfigOverlay( + config: ToolDisplayConfig, + baseConfig: ToolDisplayConfig, + configFile = getGlobalToolDisplayConfigPath(), +): ConfigSaveResult { + return writeToolDisplayConfigJson( + configFile, + createToolDisplayConfigOverlay(config, baseConfig), + ); +} + +export function getToolDisplayDebugPaths(configFile: string): { debugDir: string; debugLogFile: string } { + const debugDir = join(dirname(configFile), "debug"); + return { + debugDir, + debugLogFile: join(debugDir, "debug.log"), + }; +} + export function getToolDisplayConfigPath(): string { - return CONFIG_FILE; + return getGlobalToolDisplayConfigPath(); } diff --git a/src/debug-logger.ts b/src/debug-logger.ts index 422e87c..28266a4 100644 --- a/src/debug-logger.ts +++ b/src/debug-logger.ts @@ -21,10 +21,17 @@ interface ToolDisplayDebugLoggerFileSystem { appendFile: typeof appendFile; } +export interface ToolDisplayDebugRuntimeConfig { + debug: boolean; + debugDir?: string; + debugLogFile?: string; +} + export interface ToolDisplayDebugLoggerOptions { configFile?: string; debugDir?: string; debugLogFile?: string; + runtimeConfig?: () => ToolDisplayDebugRuntimeConfig; cacheTtlMs?: number; now?: () => number; createDate?: () => Date; @@ -52,6 +59,7 @@ export function createToolDisplayDebugLogger(options: ToolDisplayDebugLoggerOpti const configFile = options.configFile ?? DEFAULT_DEBUG_CONFIG_FILE; const debugDir = options.debugDir ?? DEFAULT_DEBUG_DIR; const debugLogFile = options.debugLogFile ?? DEFAULT_DEBUG_LOG_FILE; + const runtimeConfig = options.runtimeConfig; const cacheTtlMs = options.cacheTtlMs ?? DEFAULT_DEBUG_CONFIG_CACHE_TTL_MS; const now = options.now ?? Date.now; const createDate = options.createDate ?? (() => new Date()); @@ -60,7 +68,7 @@ export function createToolDisplayDebugLogger(options: ToolDisplayDebugLoggerOpti let cachedDebugFingerprint: string | undefined; let cachedDebugEnabled = false; let cachedDebugCheckedAt = 0; - let debugDirectoryReady = false; + let debugDirectoryReadyFor: string | undefined; let writeQueue: Promise = Promise.resolve(); function getDebugConfigFingerprint(): string { @@ -72,7 +80,27 @@ export function createToolDisplayDebugLogger(options: ToolDisplayDebugLoggerOpti } } - function isDebugEnabled(): boolean { + function getRuntimeConfig(): ToolDisplayDebugRuntimeConfig | undefined { + try { + return runtimeConfig?.(); + } catch { + return undefined; + } + } + + function getCurrentDebugDir(runtime?: ToolDisplayDebugRuntimeConfig): string { + return runtime?.debugDir ?? debugDir; + } + + function getCurrentDebugLogFile(runtime?: ToolDisplayDebugRuntimeConfig): string { + return runtime?.debugLogFile ?? debugLogFile; + } + + function isDebugEnabled(runtime?: ToolDisplayDebugRuntimeConfig): boolean { + if (runtime) { + return runtime.debug === true; + } + const checkedAt = now(); if (cachedDebugFingerprint !== undefined && checkedAt - cachedDebugCheckedAt < cacheTtlMs) { return cachedDebugEnabled; @@ -99,27 +127,30 @@ export function createToolDisplayDebugLogger(options: ToolDisplayDebugLoggerOpti } } - function ensureDebugDirectory(): void { - if (debugDirectoryReady) { + function ensureDebugDirectory(currentDebugDir: string): void { + if (debugDirectoryReadyFor === currentDebugDir) { return; } - fileSystem.mkdirSync(debugDir, { recursive: true }); - debugDirectoryReady = true; + fileSystem.mkdirSync(currentDebugDir, { recursive: true }); + debugDirectoryReadyFor = currentDebugDir; } - function appendLine(line: string): Promise { - return fileSystem.appendFile(debugLogFile, line, "utf8"); + function appendLine(debugLogFileForLine: string, line: string): Promise { + return fileSystem.appendFile(debugLogFileForLine, line, "utf8"); } return { log(message: string, error?: unknown): void { - if (!isDebugEnabled()) { + const runtime = getRuntimeConfig(); + if (!isDebugEnabled(runtime)) { return; } try { - ensureDebugDirectory(); + const debugDirForLine = getCurrentDebugDir(runtime); + const debugLogFileForLine = getCurrentDebugLogFile(runtime); + ensureDebugDirectory(debugDirForLine); const errorText = error instanceof Error ? `${error.name}: ${error.message}` : error === undefined @@ -128,8 +159,8 @@ export function createToolDisplayDebugLogger(options: ToolDisplayDebugLoggerOpti const suffix = errorText ? ` ${redactMessage(errorText)}` : ""; const line = `${createDate().toISOString()} ${redactMessage(message)}${suffix}\n`; writeQueue = writeQueue.then( - () => appendLine(line), - () => appendLine(line), + () => appendLine(debugLogFileForLine, line), + () => appendLine(debugLogFileForLine, line), ); void writeQueue.catch(() => undefined); } catch { @@ -142,8 +173,20 @@ export function createToolDisplayDebugLogger(options: ToolDisplayDebugLoggerOpti }; } -const defaultDebugLogger = createToolDisplayDebugLogger(); +let defaultRuntimeConfigProvider: (() => ToolDisplayDebugRuntimeConfig) | undefined; + +const defaultDebugLogger = createToolDisplayDebugLogger({ + runtimeConfig: () => defaultRuntimeConfigProvider?.() ?? { debug: false }, +}); + +export function configureToolDisplayDebugLogger(provider: () => ToolDisplayDebugRuntimeConfig): void { + defaultRuntimeConfigProvider = provider; +} export function logToolDisplayDebug(message: string, error?: unknown): void { defaultDebugLogger.log(message, error); } + +export function flushToolDisplayDebugLogger(): Promise { + return defaultDebugLogger.flush(); +} diff --git a/src/index.ts b/src/index.ts index 9aa4f8a..a04c2a3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,36 +1,10 @@ -import type { - ExtensionAPI, - ExtensionCommandContext, -} from "@earendil-works/pi-coding-agent"; -import { - loadToolDisplayConfig, - normalizeToolDisplayConfig, - saveToolDisplayConfig, -} from "./config-store.js"; -import { - applyCapabilityConfigGuards, - detectToolDisplayCapabilities, - type ToolDisplayCapabilities, -} from "./capabilities.js"; +import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; +import { createToolDisplayConfigController } from "./config-controller.js"; import { registerToolDisplayOverrides } from "./tool-overrides.js"; import { disposeAll, resetDisposed } from "./disposable.js"; +import { configureToolDisplayDebugLogger } from "./debug-logger.js"; import { registerThinkingLabeling } from "./thinking-label.js"; import registerNativeUserMessageBox from "./user-message-box-native.js"; -import { - BUILT_IN_TOOL_OVERRIDE_NAMES, - type ToolDisplayConfig, -} from "./types.js"; - -function ownershipChanged( - previous: ToolDisplayConfig, - next: ToolDisplayConfig, -): boolean { - return BUILT_IN_TOOL_OVERRIDE_NAMES.some( - (toolName) => - previous.registerToolOverrides[toolName] !== - next.registerToolOverrides[toolName], - ); -} export default function toolDisplayExtension(pi: ExtensionAPI): void { resetDisposed(); @@ -41,72 +15,36 @@ export default function toolDisplayExtension(pi: ExtensionAPI): void { } }); - const initial = loadToolDisplayConfig(); - let config: ToolDisplayConfig = initial.config; - let pendingLoadError = initial.error; - let capabilities: ToolDisplayCapabilities = { - hasMcpTooling: false, - hasRtkOptimizer: false, - }; - - const refreshCapabilities = (): void => { - capabilities = detectToolDisplayCapabilities(pi, process.cwd()); - }; - - const getConfig = (): ToolDisplayConfig => config; - const getCapabilities = (): ToolDisplayCapabilities => capabilities; - const getEffectiveConfig = (): ToolDisplayConfig => - applyCapabilityConfigGuards(config, capabilities); - - const setConfig = ( - next: ToolDisplayConfig, - ctx: ExtensionCommandContext, - ): void => { - const normalized = normalizeToolDisplayConfig(next); - const requiresReload = ownershipChanged(config, normalized); - config = normalized; - - const saved = saveToolDisplayConfig(normalized); - if (!saved.success && saved.error) { - ctx.ui.notify(saved.error, "error"); - } + const configController = createToolDisplayConfigController(pi); + configureToolDisplayDebugLogger(configController.getDebugRuntimeConfig); - if (requiresReload) { - ctx.ui.notify( - "Tool ownership updates apply after /reload.", - "warning", - ); + pi.on("session_start", async (_event, ctx) => { + configController.refreshFromContext(ctx); + for (const warning of configController.consumePendingLoadWarnings()) { + ctx.ui?.notify?.(warning, "warning"); } - }; + }); - registerToolDisplayOverrides(pi, getEffectiveConfig); - registerNativeUserMessageBox(pi, getConfig); + registerToolDisplayOverrides(pi, configController.getEffectiveConfig); + registerNativeUserMessageBox(pi, configController.getConfig); registerThinkingLabeling(pi); pi.registerCommand("tool-display", { description: "Configure tool output rendering (OpenCode-style)", handler: async (args, ctx) => { const { handleToolDisplayArgs, openSettingsModal } = await import("./config-modal.js"); - if (handleToolDisplayArgs(args, ctx, { getConfig, setConfig, getCapabilities })) { + if (handleToolDisplayArgs(args, ctx, configController)) { return; } if (!ctx.hasUI) { ctx.ui.notify("/tool-display requires interactive TUI mode.", "warning"); return; } - await openSettingsModal(ctx, { getConfig, setConfig, getCapabilities }); + await openSettingsModal(ctx, configController); }, }); - pi.on("session_start", async (_event, ctx) => { - refreshCapabilities(); - if (pendingLoadError) { - ctx.ui.notify(pendingLoadError, "warning"); - pendingLoadError = undefined; - } - }); - - pi.on("before_agent_start", async () => { - refreshCapabilities(); + pi.on("before_agent_start", async (_event, ctx) => { + configController.refreshCapabilitiesFromContext(ctx); }); } diff --git a/src/pending-diff-preview.ts b/src/pending-diff-preview.ts index 5187b98..5ca4cfe 100644 --- a/src/pending-diff-preview.ts +++ b/src/pending-diff-preview.ts @@ -83,15 +83,15 @@ function resolveWorkspaceReadPath(cwd: string, rawPath: string): { resolvedPath: const workspacePath = safeRealpath(cwd); const resolvedPath = resolvePreviewPath(cwd, rawPath); - if (!isWithinWorkspace(workspacePath, resolvedPath)) { - return { - resolvedPath, - error: "Preview unavailable because the target path is outside the current workspace.", - }; - } - if (!existsSync(resolvedPath)) { - return { resolvedPath }; + const lexicalPath = resolvePreviewPath(workspacePath, rawPath); + if (!isWithinWorkspace(workspacePath, lexicalPath)) { + return { + resolvedPath, + error: "Preview unavailable because the target path is outside the current workspace.", + }; + } + return { resolvedPath: lexicalPath }; } try { diff --git a/src/presets.ts b/src/presets.ts index 8d127f6..9761ee0 100644 --- a/src/presets.ts +++ b/src/presets.ts @@ -1,4 +1,11 @@ -import { DEFAULT_TOOL_DISPLAY_CONFIG, type CustomToolOverrideConfig, type ToolDisplayConfig } from "./types.js"; +import { + DEFAULT_TOOL_DISPLAY_CONFIG, + TOOL_DISPLAY_PRESET_IGNORED_CONFIG_KEYS, + TOOL_DISPLAY_SCALAR_CONFIG_KEYS, + type CustomToolOverrideConfig, + type ToolDisplayConfig, + type ToolDisplayScalarConfigKey, +} from "./types.js"; export const TOOL_DISPLAY_PRESETS = ["opencode", "balanced", "verbose"] as const; export type ToolDisplayPreset = (typeof TOOL_DISPLAY_PRESETS)[number]; @@ -69,25 +76,21 @@ function customToolOverridesEqual(a: ToolDisplayConfig, b: ToolDisplayConfig): b }); } +function isPresetIgnoredConfigKey(key: ToolDisplayScalarConfigKey): boolean { + return (TOOL_DISPLAY_PRESET_IGNORED_CONFIG_KEYS as ReadonlyArray).includes(key); +} + +function scalarConfigEqual(a: ToolDisplayConfig, b: ToolDisplayConfig): boolean { + return TOOL_DISPLAY_SCALAR_CONFIG_KEYS.every((key) => + isPresetIgnoredConfigKey(key) || a[key] === b[key] + ); +} + function configsEqual(a: ToolDisplayConfig, b: ToolDisplayConfig): boolean { return ( toolOverrideOwnershipEqual(a, b) && customToolOverridesEqual(a, b) && - a.enableNativeUserMessageBox === b.enableNativeUserMessageBox && - a.readOutputMode === b.readOutputMode && - a.searchOutputMode === b.searchOutputMode && - a.mcpOutputMode === b.mcpOutputMode && - a.previewLines === b.previewLines && - a.expandedPreviewMaxLines === b.expandedPreviewMaxLines && - a.bashOutputMode === b.bashOutputMode && - a.bashCollapsedLines === b.bashCollapsedLines && - a.diffViewMode === b.diffViewMode && - a.diffIndicatorMode === b.diffIndicatorMode && - a.diffSplitMinWidth === b.diffSplitMinWidth && - a.diffCollapsedLines === b.diffCollapsedLines && - a.diffWordWrap === b.diffWordWrap && - a.showTruncationHints === b.showTruncationHints && - a.showRtkCompactionHints === b.showRtkCompactionHints + scalarConfigEqual(a, b) ); } diff --git a/src/tool-overrides.ts b/src/tool-overrides.ts index 88116a9..8e37f24 100644 --- a/src/tool-overrides.ts +++ b/src/tool-overrides.ts @@ -1778,7 +1778,7 @@ export function registerToolDisplayOverrides( ); }, }); - }); + }, { deferUntilBuiltinOwner: true }); registerIfOwned("ls", () => { registerRuntimeTool(pi, { @@ -1817,7 +1817,7 @@ export function registerToolDisplayOverrides( ); }, }); - }); + }, { deferUntilBuiltinOwner: true }); registerIfOwned("edit", () => { registerRuntimeTool(pi, { @@ -1965,7 +1965,7 @@ export function registerToolDisplayOverrides( ); }, }); - }); + }, { deferUntilBuiltinOwner: true }); registerIfOwned("bash", () => { registerRuntimeTool(pi, { diff --git a/src/types.ts b/src/types.ts index 79c4f14..2b0f34d 100644 --- a/src/types.ts +++ b/src/types.ts @@ -45,6 +45,7 @@ export interface CustomToolOverrideConfig { } export interface ToolDisplayConfig { + debug: boolean; registerToolOverrides: ToolOverrideOwnership; customToolOverrides: Record; enableNativeUserMessageBox: boolean; @@ -64,7 +65,33 @@ export interface ToolDisplayConfig { showRtkCompactionHints: boolean; } +export const TOOL_DISPLAY_SCALAR_CONFIG_KEYS = [ + "debug", + "enableNativeUserMessageBox", + "readOutputMode", + "searchOutputMode", + "mcpOutputMode", + "previewLines", + "expandedPreviewMaxLines", + "bashOutputMode", + "bashCollapsedLines", + "diffViewMode", + "diffIndicatorMode", + "diffSplitMinWidth", + "diffCollapsedLines", + "diffWordWrap", + "showTruncationHints", + "showRtkCompactionHints", +] as const satisfies ReadonlyArray>; + +export type ToolDisplayScalarConfigKey = (typeof TOOL_DISPLAY_SCALAR_CONFIG_KEYS)[number]; + +export const TOOL_DISPLAY_PRESET_IGNORED_CONFIG_KEYS = [ + "debug", +] as const satisfies ReadonlyArray; + export const DEFAULT_TOOL_DISPLAY_CONFIG: ToolDisplayConfig = { + debug: false, registerToolOverrides: { read: true, grep: true, diff --git a/tests/config-modal.test.ts b/tests/config-modal.test.ts index d5b1246..5574074 100644 --- a/tests/config-modal.test.ts +++ b/tests/config-modal.test.ts @@ -58,13 +58,14 @@ function createCtxStub( function createControllerStub( initialConfig?: Partial, capabilities?: ToolDisplayCapabilities, + setConfigResult = true, ): { controller: { getConfig: () => ToolDisplayConfig; - setConfig: (next: ToolDisplayConfig, ctx: ExtensionCommandContext) => void; + setConfig: (next: ToolDisplayConfig, ctx: ExtensionCommandContext, options?: { scope?: "global" | "project" }) => boolean; getCapabilities: () => ToolDisplayCapabilities; }; - getLastSet: () => { config: ToolDisplayConfig | null; ctx: ExtensionCommandContext | null }; + getLastSet: () => { config: ToolDisplayConfig | null; ctx: ExtensionCommandContext | null; options?: { scope?: "global" | "project" } }; } { let config: ToolDisplayConfig = { ...DEFAULT_TOOL_DISPLAY_CONFIG, @@ -73,7 +74,7 @@ function createControllerStub( ...(initialConfig?.registerToolOverrides ?? DEFAULT_TOOL_DISPLAY_CONFIG.registerToolOverrides), }, }; - const last = { config: null as ToolDisplayConfig | null, ctx: null as ExtensionCommandContext | null }; + const last = { config: null as ToolDisplayConfig | null, ctx: null as ExtensionCommandContext | null, options: undefined as { scope?: "global" | "project" } | undefined }; return { controller: { @@ -81,10 +82,12 @@ function createControllerStub( ...config, registerToolOverrides: { ...config.registerToolOverrides }, }), - setConfig: (next: ToolDisplayConfig, ctx: ExtensionCommandContext) => { + setConfig: (next: ToolDisplayConfig, ctx: ExtensionCommandContext, options?: { scope?: "global" | "project" }) => { config = { ...next, registerToolOverrides: { ...next.registerToolOverrides } }; last.config = config; last.ctx = ctx; + last.options = options; + return setConfigResult; }, getCapabilities: () => capabilities ?? { hasMcpTooling: false, hasRtkOptimizer: false }, @@ -171,6 +174,21 @@ test("'reset' argument sets config to opencode preset", async () => { assert.equal(notifications[0]?.level, "info"); }); +test("'reset' does not report success when save is refused", async () => { + const { api, getHandler } = createPiStub(); + const { controller, getLastSet } = createControllerStub(undefined, undefined, false); + const { ctx, notifications } = createCtxStub(true); + + registerToolDisplayCommand(api, controller); + const handler = getHandler(); + assert.ok(handler); + + await handler("reset", ctx); + + assert.ok(getLastSet().config, "expected setConfig to be attempted"); + assert.equal(notifications.length, 0); +}); + test("'preset balanced' sets correct config", async () => { const { api, getHandler } = createPiStub(); const { controller, getLastSet } = createControllerStub(); @@ -191,6 +209,23 @@ test("'preset balanced' sets correct config", async () => { assert.match(notifications[0]?.message ?? "", /set to balanced/i); }); +test("'preset balanced' preserves current debug setting", async () => { + const { api, getHandler } = createPiStub(); + const { controller, getLastSet } = createControllerStub({ debug: true }); + const { ctx } = createCtxStub(true); + + registerToolDisplayCommand(api, controller); + const handler = getHandler(); + assert.ok(handler); + + await handler("preset balanced", ctx); + + const last = getLastSet(); + assert.ok(last.config); + assert.equal(last.config!.readOutputMode, "summary"); + assert.equal(last.config!.debug, true); +}); + test("'preset verbose' sets correct config", async () => { const { api, getHandler } = createPiStub(); const { controller, getLastSet } = createControllerStub(); @@ -211,6 +246,38 @@ test("'preset verbose' sets correct config", async () => { assert.equal(last.config!.bashCollapsedLines, 20); }); +test("'preset verbose --project' applies preset and requests project save scope", async () => { + const { api, getHandler } = createPiStub(); + const { controller, getLastSet } = createControllerStub(); + const { ctx } = createCtxStub(true); + + registerToolDisplayCommand(api, controller); + const handler = getHandler(); + assert.ok(handler); + + await handler("preset verbose --project", ctx); + + const last = getLastSet(); + assert.ok(last.config); + assert.equal(last.config!.readOutputMode, "preview"); + assert.equal(last.options?.scope, "project"); +}); + +test("'preset verbose --project' does not report success when save is refused", async () => { + const { api, getHandler } = createPiStub(); + const { controller, getLastSet } = createControllerStub(undefined, undefined, false); + const { ctx, notifications } = createCtxStub(true); + + registerToolDisplayCommand(api, controller); + const handler = getHandler(); + assert.ok(handler); + + await handler("preset verbose --project", ctx); + + assert.ok(getLastSet().config, "expected setConfig to be attempted"); + assert.equal(notifications.length, 0); +}); + test("'preset ' warns about unknown preset", async () => { const { api, getHandler } = createPiStub(); const { controller, getLastSet } = createControllerStub(); diff --git a/tests/config-store.test.ts b/tests/config-store.test.ts index af606f1..3c21e03 100644 --- a/tests/config-store.test.ts +++ b/tests/config-store.test.ts @@ -1,12 +1,14 @@ import assert from "node:assert/strict"; -import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import test from "node:test"; import { + loadEffectiveToolDisplayConfig, loadToolDisplayConfig, normalizeToolDisplayConfig, saveToolDisplayConfig, + saveToolDisplayConfigOverlay, } from "../src/config-store.ts"; import { DEFAULT_TOOL_DISPLAY_CONFIG } from "../src/types.ts"; @@ -33,6 +35,7 @@ test("config normalization clamps invalid values and migrates legacy read overri diffSplitMinWidth: 1, diffCollapsedLines: 999, diffWordWrap: false, + debug: true, }); assert.equal(config.registerToolOverrides.read, false); @@ -48,6 +51,86 @@ test("config normalization clamps invalid values and migrates legacy read overri assert.equal(config.diffSplitMinWidth, 70); assert.equal(config.diffCollapsedLines, 240); assert.equal(config.diffWordWrap, false); + assert.equal(config.debug, true); +}); + +test("effective config merges trusted project config over global config", () => { + withTempDir("pi-tool-display-config-merge-", (dir) => { + const globalConfigFile = join(dir, "global", "config.json"); + const projectConfigFile = join(dir, "project", ".pi", "extensions", "pi-tool-display", "config.json"); + mkdirSync(join(dir, "global"), { recursive: true }); + mkdirSync(join(dir, "project", ".pi", "extensions", "pi-tool-display"), { recursive: true }); + writeFileSync(globalConfigFile, JSON.stringify({ + readOutputMode: "summary", + registerToolOverrides: { bash: false }, + }), "utf8"); + writeFileSync(projectConfigFile, JSON.stringify({ + previewLines: 20, + registerToolOverrides: { read: false }, + }), "utf8"); + + const result = loadEffectiveToolDisplayConfig({ + cwd: join(dir, "project"), + projectTrusted: true, + globalConfigFile, + }); + + assert.equal(result.config.readOutputMode, "summary"); + assert.equal(result.config.previewLines, 20); + assert.equal(result.config.registerToolOverrides.bash, false); + assert.equal(result.config.registerToolOverrides.read, false); + assert.equal(result.activeScope, "project"); + }); +}); + +test("effective config treats malformed trusted project config as not loaded", () => { + withTempDir("pi-tool-display-config-malformed-project-", (dir) => { + const globalConfigFile = join(dir, "global", "config.json"); + const projectConfigFile = join(dir, "project", ".pi", "extensions", "pi-tool-display", "config.json"); + mkdirSync(join(dir, "global"), { recursive: true }); + mkdirSync(join(dir, "project", ".pi", "extensions", "pi-tool-display"), { recursive: true }); + writeFileSync(globalConfigFile, JSON.stringify({ readOutputMode: "summary" }), "utf8"); + writeFileSync(projectConfigFile, "{not-json", "utf8"); + + const result = loadEffectiveToolDisplayConfig({ + cwd: join(dir, "project"), + projectTrusted: true, + globalConfigFile, + }); + + assert.equal(result.config.readOutputMode, "summary"); + assert.equal(result.projectConfigLoaded, false); + assert.equal(result.projectConfigIgnored, false); + assert.equal(result.activeScope, "global"); + assert.equal(result.activeConfigFile, globalConfigFile); + assert.equal(result.projectConfigFile, projectConfigFile); + assert.match(result.warnings.join("\n"), /Failed to parse/); + assert.match(result.warnings.join("\n"), /config\.json/); + }); +}); + +test("effective config ignores untrusted project config and warns", () => { + withTempDir("pi-tool-display-config-untrusted-", (dir) => { + const globalConfigFile = join(dir, "global", "config.json"); + const projectConfigFile = join(dir, "project", ".pi", "extensions", "pi-tool-display", "config.json"); + mkdirSync(join(dir, "global"), { recursive: true }); + mkdirSync(join(dir, "project", ".pi", "extensions", "pi-tool-display"), { recursive: true }); + writeFileSync(globalConfigFile, JSON.stringify({ readOutputMode: "summary" }), "utf8"); + writeFileSync(projectConfigFile, JSON.stringify({ readOutputMode: "preview" }), "utf8"); + + const result = loadEffectiveToolDisplayConfig({ + cwd: join(dir, "project"), + projectTrusted: false, + globalConfigFile, + }); + + assert.equal(result.config.readOutputMode, "summary"); + assert.equal(result.projectConfigLoaded, false); + assert.equal(result.projectConfigIgnored, true); + assert.equal(result.activeScope, "global"); + assert.match(result.warnings.join("\n"), /Ignored untrusted project tool-display config/); + assert.match(result.warnings.join("\n"), /config\.json/); + }); }); test("config load reports parse errors and falls back to defaults", () => { @@ -63,6 +146,61 @@ test("config load reports parse errors and falls back to defaults", () => { }); }); +test("project overlay save writes only values that differ from the base config", () => { + withTempDir("pi-tool-display-config-overlay-", (dir) => { + const configFile = join(dir, "config.json"); + const base = normalizeToolDisplayConfig({ + readOutputMode: "summary", + registerToolOverrides: { read: false, bash: false }, + }); + const next = normalizeToolDisplayConfig({ + ...base, + searchOutputMode: "count", + registerToolOverrides: { + ...base.registerToolOverrides, + bash: true, + }, + }); + + const saved = saveToolDisplayConfigOverlay(next, base, configFile); + + assert.equal(saved.success, true); + assert.deepEqual(JSON.parse(readFileSync(configFile, "utf8")), { + searchOutputMode: "count", + registerToolOverrides: { bash: true }, + }); + }); +}); + +test("project overlay save disables inherited custom overrides omitted by the next config", () => { + withTempDir("pi-tool-display-config-overlay-custom-", (dir) => { + const configFile = join(dir, "config.json"); + const base = normalizeToolDisplayConfig({ + customToolOverrides: { + inherited_tool: { enabled: true, kind: "mcp", outputMode: "preview" }, + unchanged_tool: { enabled: true, kind: "generic", outputMode: "summary" }, + }, + }); + const next = normalizeToolDisplayConfig({ + ...base, + customToolOverrides: { + unchanged_tool: { enabled: true, kind: "generic", outputMode: "summary" }, + project_tool: { enabled: true, kind: "generic", outputMode: "preview" }, + }, + }); + + const saved = saveToolDisplayConfigOverlay(next, base, configFile); + + assert.equal(saved.success, true); + assert.deepEqual(JSON.parse(readFileSync(configFile, "utf8")), { + customToolOverrides: { + inherited_tool: { enabled: false, kind: "mcp", outputMode: "preview" }, + project_tool: { enabled: true, kind: "generic", outputMode: "preview" }, + }, + }); + }); +}); + test("config save writes normalized JSON and cleans temporary file on failure", () => { withTempDir("pi-tool-display-config-save-", (dir) => { const configFile = join(dir, "config.json"); diff --git a/tests/debug-logger.test.ts b/tests/debug-logger.test.ts index c828bd7..3ed66e5 100644 --- a/tests/debug-logger.test.ts +++ b/tests/debug-logger.test.ts @@ -3,7 +3,12 @@ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync import { tmpdir } from "node:os"; import { join } from "node:path"; import test from "node:test"; -import { createToolDisplayDebugLogger } from "../src/debug-logger.ts"; +import { + configureToolDisplayDebugLogger, + createToolDisplayDebugLogger, + flushToolDisplayDebugLogger, + logToolDisplayDebug, +} from "../src/debug-logger.ts"; async function withTempRoot(run: (root: string) => Promise | void): Promise { const root = mkdtempSync(join(tmpdir(), "pi-tool-display-debug-")); @@ -49,6 +54,76 @@ test("enabled debug logger writes on flush and redacts secret values", async () }); }); +test("debug logger snapshots runtime log path for queued writes", async () => { + await withTempRoot(async (root) => { + type Scope = "global" | "project"; + let activeScope: Scope = "global"; + const getPaths = (scope: Scope) => ({ + debugDir: join(root, scope, "debug"), + debugLogFile: join(root, scope, "debug", "debug.log"), + }); + const logger = createToolDisplayDebugLogger({ + runtimeConfig: () => ({ + debug: true, + ...getPaths(activeScope), + }), + createDate: () => new Date("2026-01-01T00:00:00.000Z"), + }); + + logger.log("from global scope"); + activeScope = "project"; + logger.log("from project scope"); + await logger.flush(); + + const globalContent = readFileSync(getPaths("global").debugLogFile, "utf-8"); + const projectContent = readFileSync(getPaths("project").debugLogFile, "utf-8"); + assert.match(globalContent, /from global scope/); + assert.doesNotMatch(globalContent, /from project scope/); + assert.match(projectContent, /from project scope/); + assert.doesNotMatch(projectContent, /from global scope/); + }); +}); + +test("debug logger can use runtime project config and log path", async () => { + await withTempRoot(async (root) => { + const logger = createToolDisplayDebugLogger({ + runtimeConfig: () => ({ + debug: true, + debugDir: join(root, "project", ".pi", "extensions", "pi-tool-display", "debug"), + debugLogFile: join(root, "project", ".pi", "extensions", "pi-tool-display", "debug", "debug.log"), + }), + createDate: () => new Date("2026-01-01T00:00:00.000Z"), + }); + + logger.log("from project config"); + await logger.flush(); + + const logContent = readFileSync( + join(root, "project", ".pi", "extensions", "pi-tool-display", "debug", "debug.log"), + "utf-8", + ); + assert.match(logContent, /from project config/); + }); +}); + +test("default debug logger can be configured from effective config", async () => { + await withTempRoot(async (root) => { + configureToolDisplayDebugLogger(() => ({ + debug: true, + debugDir: join(root, "effective", "debug"), + debugLogFile: join(root, "effective", "debug", "debug.log"), + })); + + logToolDisplayDebug("from effective config"); + await flushToolDisplayDebugLogger(); + + const logContent = readFileSync(join(root, "effective", "debug", "debug.log"), "utf-8"); + assert.match(logContent, /from effective config/); + + configureToolDisplayDebugLogger(() => ({ debug: false })); + }); +}); + test("debug logger swallows append failures", async () => { await withTempRoot(async (root) => { writeFileSync(join(root, "config.json"), JSON.stringify({ debug: true }), "utf-8"); diff --git a/tests/index-integration.test.ts b/tests/index-integration.test.ts index b3917d7..55817a4 100644 --- a/tests/index-integration.test.ts +++ b/tests/index-integration.test.ts @@ -1,4 +1,7 @@ import assert from "node:assert/strict"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import test from "node:test"; import type { ExtensionAPI, @@ -63,6 +66,108 @@ function createApiStub( return { api, capturedTools, capturedCommands, capturedHandlers }; } +function withTempDir(name: string, run: (dir: string) => void | Promise): Promise | void { + const dir = mkdtempSync(join(tmpdir(), name)); + const cleanup = (): void => rmSync(dir, { recursive: true, force: true }); + try { + const result = run(dir); + if (result instanceof Promise) { + return result.finally(cleanup); + } + } catch (error) { + cleanup(); + throw error; + } + cleanup(); +} + +interface Notification { + message: string; + level: string; +} + +interface ProjectConfigFixtureOptions { + globalConfig?: Record; + projectConfig?: Record; + projectTrusted?: boolean; +} + +interface ProjectConfigFixture { + dir: string; + projectRoot: string; + globalConfigFile: string; + projectConfigFile: string; + notifications: Notification[]; + ctx: ExtensionCommandContext & { cwd: string }; + command: CapturedCommand; + capturedTools: Array<{ name: string } & Record>; +} + +async function withProjectConfigFixture( + name: string, + options: ProjectConfigFixtureOptions, + run: (fixture: ProjectConfigFixture) => void | Promise, +): Promise { + await withTempDir(name, async (dir) => { + const previousAgentDir = process.env.PI_CODING_AGENT_DIR; + process.env.PI_CODING_AGENT_DIR = join(dir, "agent"); + try { + const globalConfigDir = join(dir, "agent", "extensions", "pi-tool-display"); + const projectRoot = join(dir, "project"); + const projectConfigDir = join(projectRoot, ".pi", "extensions", "pi-tool-display"); + const globalConfigFile = join(globalConfigDir, "config.json"); + const projectConfigFile = join(projectConfigDir, "config.json"); + mkdirSync(globalConfigDir, { recursive: true }); + mkdirSync(projectConfigDir, { recursive: true }); + if (options.globalConfig !== undefined) { + writeFileSync(globalConfigFile, JSON.stringify(options.globalConfig), "utf8"); + } + if (options.projectConfig !== undefined) { + writeFileSync(projectConfigFile, JSON.stringify(options.projectConfig), "utf8"); + } + + const { api, capturedCommands, capturedHandlers, capturedTools } = createApiStub(); + toolDisplayExtension(api); + const sessionHandler = capturedHandlers.find((h) => h.event === "session_start")?.handler; + assert.ok(sessionHandler, "session_start handler captured"); + const notifications: Notification[] = []; + const ctx = { + cwd: projectRoot, + hasUI: true, + ui: { + theme: { fg: (_c: string, text: string) => text }, + notify: (message: string, level: string): void => { + notifications.push({ message, level }); + }, + }, + } as unknown as ExtensionCommandContext & { cwd: string; isProjectTrusted?: () => boolean }; + if (options.projectTrusted !== undefined) { + ctx.isProjectTrusted = () => options.projectTrusted === true; + } + await sessionHandler({}, ctx); + + const command = capturedCommands.find((c) => c.name === "tool-display"); + assert.ok(command?.handler, "tool-display command captured"); + await run({ + dir, + projectRoot, + globalConfigFile, + projectConfigFile, + notifications, + ctx, + command, + capturedTools, + }); + } finally { + if (previousAgentDir === undefined) { + delete process.env.PI_CODING_AGENT_DIR; + } else { + process.env.PI_CODING_AGENT_DIR = previousAgentDir; + } + } + }); +} + // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- @@ -94,9 +199,170 @@ test("entry point registers tool-display command", () => { assert.ok(cmdNames.includes("tool-display"), "tool-display command registered"); }); -test("entry point registers built-in tool overrides", () => { - const { api, capturedTools } = createApiStub(); +test("session_start loads trusted project config over global config", async () => { + await withProjectConfigFixture( + "pi-tool-display-index-project-config-", + { + globalConfig: { readOutputMode: "summary" }, + projectConfig: { readOutputMode: "preview" }, + projectTrusted: true, + }, + async ({ command, ctx, notifications }) => { + await command.handler?.("show", ctx); + + assert.match(notifications.at(-1)?.message ?? "", /read=preview/); + }, + ); +}); + +test("session_start ignores project config and explains Pi 0.79.1 requirement when trust API is unavailable", async () => { + await withProjectConfigFixture( + "pi-tool-display-index-missing-trust-api-project-config-", + { + globalConfig: { readOutputMode: "summary" }, + projectConfig: { readOutputMode: "preview" }, + }, + async ({ command, ctx, notifications }) => { + await command.handler?.("show", ctx); + + assert.ok( + notifications.some((notification) => /Project-level tool-display configs are only supported in Pi 0\.79\.1 or newer/.test(notification.message)), + "missing trust API warning shown", + ); + assert.match(notifications.at(-1)?.message ?? "", /read=summary/); + }, + ); +}); + +test("tool-display command refuses project save and explains Pi 0.79.1 requirement when trust API is unavailable", async () => { + await withProjectConfigFixture( + "pi-tool-display-index-missing-trust-api-save-project-config-", + { globalConfig: { readOutputMode: "summary" } }, + async ({ command, ctx, notifications, projectConfigFile }) => { + await command.handler?.("preset balanced --project", ctx); + + assert.equal(existsSync(projectConfigFile), false); + assert.ok( + notifications.some((notification) => /Project-level tool-display configs are only supported in Pi 0\.79\.1 or newer/.test(notification.message)), + "missing trust API save warning shown", + ); + assert.equal( + notifications.some((notification) => /Tool display preset set to balanced\./.test(notification.message)), + false, + "refused project save should not show success notification", + ); + }, + ); +}); + +test("tool-display command saves to active project config by default", async () => { + await withProjectConfigFixture( + "pi-tool-display-index-save-project-config-", + { + globalConfig: { readOutputMode: "summary" }, + projectConfig: { readOutputMode: "preview" }, + projectTrusted: true, + }, + async ({ command, ctx, globalConfigFile, projectConfigFile }) => { + await command.handler?.("preset balanced", ctx); + + const globalSaved = JSON.parse(readFileSync(globalConfigFile, "utf8")) as { searchOutputMode?: string }; + const projectSaved = JSON.parse(readFileSync(projectConfigFile, "utf8")) as { readOutputMode?: string; searchOutputMode?: string }; + assert.equal(globalSaved.searchOutputMode, undefined); + assert.equal(projectSaved.readOutputMode, undefined); + assert.equal(projectSaved.searchOutputMode, "count"); + }, + ); +}); + +test("tool-display command can explicitly save to global config while project config is active", async () => { + await withProjectConfigFixture( + "pi-tool-display-index-save-global-config-", + { + globalConfig: { readOutputMode: "summary" }, + projectConfig: { readOutputMode: "preview" }, + projectTrusted: true, + }, + async ({ command, ctx, globalConfigFile, projectConfigFile }) => { + await command.handler?.("preset balanced --global", ctx); + + const globalSaved = JSON.parse(readFileSync(globalConfigFile, "utf8")) as { searchOutputMode?: string }; + const projectSaved = JSON.parse(readFileSync(projectConfigFile, "utf8")) as { searchOutputMode?: string }; + assert.equal(globalSaved.searchOutputMode, "count"); + assert.equal(projectSaved.searchOutputMode, undefined); + }, + ); +}); + +test("tool-display command refuses explicit project save when project is untrusted", async () => { + await withProjectConfigFixture( + "pi-tool-display-index-refuse-untrusted-save-", + { + globalConfig: { readOutputMode: "summary" }, + projectTrusted: false, + }, + async ({ command, ctx, notifications, projectConfigFile }) => { + await command.handler?.("preset verbose --project", ctx); + + assert.equal(existsSync(projectConfigFile), false); + assert.ok( + notifications.some((notification) => /not trusted/i.test(notification.message)), + "untrusted project save warning shown", + ); + assert.equal( + notifications.some((notification) => /Tool display preset set to verbose\./.test(notification.message)), + false, + "refused project save should not show success notification", + ); + }, + ); +}); + +test("session_start warns and ignores untrusted project config", async () => { + await withProjectConfigFixture( + "pi-tool-display-index-untrusted-project-config-", + { + globalConfig: { readOutputMode: "summary" }, + projectConfig: { readOutputMode: "preview" }, + projectTrusted: false, + }, + async ({ command, ctx, notifications }) => { + assert.ok( + notifications.some((notification) => /Ignored untrusted project tool-display config/.test(notification.message)), + "untrusted project config warning shown", + ); + + await command.handler?.("show", ctx); + + assert.match(notifications.at(-1)?.message ?? "", /read=summary/); + }, + ); +}); + +test("trusted project ownership config prevents built-in override registration", async () => { + await withProjectConfigFixture( + "pi-tool-display-index-project-ownership-", + { + projectConfig: { registerToolOverrides: { find: false, ls: false, write: false } }, + projectTrusted: true, + }, + ({ capturedTools }) => { + const toolNames = capturedTools.map((tool) => tool.name); + assert.equal(toolNames.includes("find"), false); + assert.equal(toolNames.includes("ls"), false); + assert.equal(toolNames.includes("write"), false); + }, + ); +}); + +test("entry point registers built-in tool overrides", async () => { + const { api, capturedTools, capturedHandlers } = createApiStub(); toolDisplayExtension(api); + for (const { event, handler } of capturedHandlers) { + if (event === "session_start") { + await handler({}, { cwd: process.cwd(), isProjectTrusted: () => false, ui: { notify: () => {}, theme: {} } }); + } + } const toolNames = capturedTools.map((t) => t.name); // find, ls, write are registered immediately; read/grep/edit/bash are deferred @@ -138,12 +404,17 @@ test("before_agent_start handler refreshes capabilities without crashing", async await assert.doesNotReject(async () => beforeHandler()); }); -test("multiple calls to toolDisplayExtension are idempotent", () => { +test("multiple calls to toolDisplayExtension are idempotent", async () => { const { api, capturedTools, capturedCommands, capturedHandlers } = createApiStub(); // Call twice toolDisplayExtension(api); toolDisplayExtension(api); + for (const { event, handler } of capturedHandlers) { + if (event === "session_start") { + await handler({}, { cwd: process.cwd(), isProjectTrusted: () => false, ui: { notify: () => {}, theme: {} } }); + } + } // Second call should not throw. Tools may be registered again (that's up // to the extension loader to deduplicate), but the extension itself must @@ -334,9 +605,14 @@ test("overridden tools include renderCall and renderResult functions", () => { } }); -test("overridden tools preserve promptSnippet and promptGuidelines from built-ins", () => { - const { api, capturedTools } = createApiStub(); +test("overridden tools preserve promptSnippet and promptGuidelines from built-ins", async () => { + const { api, capturedTools, capturedHandlers } = createApiStub(); toolDisplayExtension(api); + for (const { event, handler } of capturedHandlers) { + if (event === "session_start") { + await handler({}, { cwd: process.cwd(), isProjectTrusted: () => false, ui: { notify: () => {}, theme: {} } }); + } + } const byName = new Map(capturedTools.map((t) => [t.name, t])); diff --git a/tests/presets-edge.test.ts b/tests/presets-edge.test.ts index aa102fc..8adaf15 100644 --- a/tests/presets-edge.test.ts +++ b/tests/presets-edge.test.ts @@ -90,6 +90,11 @@ test("detectToolDisplayPreset returns 'custom' when config differs by one field" assert.equal(detectToolDisplayPreset(modified), "custom"); }); +test("detectToolDisplayPreset ignores debug because it is diagnostic state", () => { + const balanced = getToolDisplayPresetConfig("balanced"); + assert.equal(detectToolDisplayPreset({ ...balanced, debug: true }), "balanced"); +}); + test("detectToolDisplayPreset returns 'custom' when bashCollapsedLines differs", () => { const verbose = getToolDisplayPresetConfig("verbose"); const modified = { ...verbose, bashCollapsedLines: verbose.bashCollapsedLines + 5 }; diff --git a/tests/reload-behavior.test.ts b/tests/reload-behavior.test.ts index 7699724..a6a17bf 100644 --- a/tests/reload-behavior.test.ts +++ b/tests/reload-behavior.test.ts @@ -143,21 +143,31 @@ test("1: after reload, new lifecycle handlers are registered", () => { // 2. Tool override restoration // --------------------------------------------------------------------------- -test("2: built-in tool overrides are re-registered on reload", () => { - const { api, capturedTools } = createApiStub(); +test("2: built-in tool overrides are re-registered on reload", async () => { + const { api, capturedTools, capturedHandlers } = createApiStub(); // First call toolDisplayExtension(api); + for (const { event, handler } of capturedHandlers) { + if (event === "session_start") { + await handler({}, { cwd: process.cwd(), isProjectTrusted: () => false, ui: { notify: () => {}, theme: {} } }); + } + } const firstTools = capturedTools.map((t) => t.name); assert.ok(firstTools.includes("find"), "find registered on first call"); // Simulate reload const countBeforeReload = capturedTools.length; toolDisplayExtension(api); + for (const { event, handler } of capturedHandlers) { + if (event === "session_start") { + await handler({}, { cwd: process.cwd(), isProjectTrusted: () => false, ui: { notify: () => {}, theme: {} } }); + } + } const countAfterReload = capturedTools.length; // Each call to registerToolDisplayOverrides registers the same built-in - // tools again (find, ls, write immediately; read/grep/edit/bash deferred). + // tools again after session_start. assert.ok( countAfterReload >= countBeforeReload + 3, "at least 3 tools re-registered on reload", @@ -714,19 +724,29 @@ test("8: session_start handler can be invoked after reload without errors", asyn // 9. Double reload safety // --------------------------------------------------------------------------- -test("9: calling toolDisplayExtension three times (double reload) is safe", () => { - const { api, capturedTools, capturedCommands } = createApiStub(); +test("9: calling toolDisplayExtension three times (double reload) is safe", async () => { + const { api, capturedTools, capturedCommands, capturedHandlers } = createApiStub(); + const fireSessionStart = async (): Promise => { + for (const { event, handler } of capturedHandlers) { + if (event === "session_start") { + await handler({}, { cwd: process.cwd(), isProjectTrusted: () => false, ui: { notify: () => {}, theme: {} } }); + } + } + }; // First call toolDisplayExtension(api); + await fireSessionStart(); const afterFirst = { tools: capturedTools.length, cmds: capturedCommands.length }; // First reload toolDisplayExtension(api); + await fireSessionStart(); const afterSecond = { tools: capturedTools.length, cmds: capturedCommands.length }; // Second reload (double reload) assert.doesNotThrow(() => toolDisplayExtension(api)); + await fireSessionStart(); const afterThird = { tools: capturedTools.length, cmds: capturedCommands.length }; // Each call adds more registrations (no deduplication in the stub) diff --git a/tests/tool-overrides-registration.test.ts b/tests/tool-overrides-registration.test.ts index c80539e..0af88b8 100644 --- a/tests/tool-overrides-registration.test.ts +++ b/tests/tool-overrides-registration.test.ts @@ -92,11 +92,8 @@ test("registerToolDisplayOverrides copies built-in prompt metadata onto overridd const { api, registeredTools, eventHandlers } = createExtensionApiStub(); registerToolDisplayOverrides(api, () => DEFAULT_TOOL_DISPLAY_CONFIG); - assert.deepEqual( - registeredTools.map((tool) => tool.name).sort(), - ["find", "ls", "write"], - ); - await eventHandlers.before_agent_start?.(); + assert.deepEqual(registeredTools.map((tool) => tool.name).sort(), []); + await eventHandlers.session_start?.(); assert.equal(registeredTools.length, 7);