diff --git a/src/core/bridge-docs.ts b/src/core/bridge-docs.ts index 2a80cd8..59b8e13 100644 --- a/src/core/bridge-docs.ts +++ b/src/core/bridge-docs.ts @@ -142,6 +142,7 @@ Commands are intercepted by the bridge before reaching the Copilot session. The | Command | Description | |---------|-------------| | \`/status\` | Show session info, model, mode, context usage | +| \`/config\` | Show effective channel config with source attribution | | \`/context\` | Show context window usage | | \`/verbose\` | Toggle verbose tool output | | \`/mcp\` | Show loaded MCP servers and their source | diff --git a/src/core/command-handler.test.ts b/src/core/command-handler.test.ts index 75679b4..3bd6b7c 100644 --- a/src/core/command-handler.test.ts +++ b/src/core/command-handler.test.ts @@ -1,8 +1,23 @@ -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import { handleCommand, parseCommand, type ModelInfo } from './command-handler.js'; +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { handleCommand, parseCommand, resolveEffectiveConfig, formatConfigTable, type ModelInfo, type ConfigField } from './command-handler.js'; import * as fs from 'node:fs'; import * as path from 'node:path'; import * as os from 'node:os'; +import * as config from '../config.js'; +import * as store from '../state/store.js'; + +const mockConfig = { + platforms: {}, + channels: [], + defaults: { + model: 'claude-sonnet-4.5', + agent: null, + triggerMode: 'mention' as const, + threadedReplies: true, + verbose: false, + permissionMode: 'interactive' as const, + }, +}; // --- parseCommand --- @@ -573,3 +588,219 @@ describe('/skills command', () => { expect(result.payload).toEqual({ action: 'disable', targets: ['humanizer'] }); }); }); + +// --- /config --- + +describe('/config', () => { + beforeEach(() => { + vi.spyOn(config, 'getConfig').mockReturnValue(mockConfig as any); + vi.spyOn(config, 'getChannelBotConfig').mockResolvedValue(null); + vi.spyOn(config, 'getChannelBotName').mockResolvedValue('default'); + vi.spyOn(store, 'getDynamicChannel').mockResolvedValue(null); + vi.spyOn(store, 'getChannelPrefs').mockResolvedValue(null); + }); + afterEach(() => { vi.restoreAllMocks(); }); + + it('returns handled with response', async () => { + const result = await handleCommand('ch1', '/config'); + expect(result.handled).toBe(true); + expect(result.response).toBeDefined(); + expect(result.response).toContain('Channel Config'); + }); + + it('shows source attribution in table', async () => { + const result = await handleCommand('ch1', '/config'); + expect(result.response).toContain('Source'); + expect(result.response).toContain('model'); + expect(result.response).toContain('defaults'); + }); + + it('reflects session info when provided', async () => { + const sessionInfo = { sessionId: 'sess-123', model: 'claude-opus-4.6', agent: 'researcher' }; + const result = await handleCommand('ch1', '/config', sessionInfo); + expect(result.response).toContain('claude-opus-4.6'); + expect(result.response).toContain('researcher'); + expect(result.response).toContain('session (active)'); + }); + + it('shows channel meta when provided', async () => { + const meta = { workingDirectory: '/home/test/project', bot: 'mybot' }; + const result = await handleCommand('ch1', '/config', undefined, undefined, meta); + expect(result.response).toContain('/home/test/project'); + expect(result.response).toContain('mybot'); + }); +}); + +// --- resolveEffectiveConfig / formatConfigTable --- + +describe('resolveEffectiveConfig', () => { + beforeEach(() => { + vi.spyOn(config, 'getConfig').mockReturnValue(mockConfig as any); + vi.spyOn(config, 'getChannelBotConfig').mockResolvedValue(null); + vi.spyOn(config, 'getChannelBotName').mockResolvedValue('default'); + vi.spyOn(store, 'getDynamicChannel').mockResolvedValue(null); + vi.spyOn(store, 'getChannelPrefs').mockResolvedValue(null); + }); + afterEach(() => { vi.restoreAllMocks(); }); + + it('returns fields array with settings', async () => { + const result = await resolveEffectiveConfig('ch1'); + expect(result.fields.length).toBeGreaterThan(0); + const settingNames = result.fields.map(f => f.setting); + expect(settingNames).toContain('model'); + expect(settingNames).toContain('agent'); + expect(settingNames).toContain('triggerMode'); + expect(settingNames).toContain('verbose'); + expect(settingNames).toContain('permissionMode'); + }); + + it('attributes defaults source when no overrides', async () => { + const result = await resolveEffectiveConfig('ch1'); + const model = result.fields.find(f => f.setting === 'model'); + expect(model?.source).toBe('defaults'); + expect(model?.value).toBe('claude-sonnet-4.5'); + }); + + it('session info overrides defaults for model and agent', async () => { + const result = await resolveEffectiveConfig('ch1', + { sessionId: 's1', model: 'test-model', agent: 'test-agent' }); + const model = result.fields.find(f => f.setting === 'model'); + const agent = result.fields.find(f => f.setting === 'agent'); + expect(model?.value).toBe('test-model'); + expect(model?.source).toBe('session (active)'); + expect(agent?.value).toBe('test-agent'); + expect(agent?.source).toBe('session (active)'); + }); + + it('channel prefs override defaults', async () => { + vi.spyOn(store, 'getChannelPrefs').mockResolvedValue({ model: 'gpt-5.1', verbose: true }); + const result = await resolveEffectiveConfig('ch1'); + const model = result.fields.find(f => f.setting === 'model'); + const verbose = result.fields.find(f => f.setting === 'verbose'); + expect(model?.value).toBe('gpt-5.1'); + expect(model?.source).toBe('channel prefs'); + expect(verbose?.value).toBe('On'); + expect(verbose?.source).toBe('channel prefs'); + }); + + it('identifies static channel source', async () => { + vi.spyOn(config, 'getConfig').mockReturnValue({ + ...mockConfig, + channels: [{ id: 'ch-static', platform: 'mm', name: 'test-channel', workingDirectory: '/tmp', triggerMode: 'all', threadedReplies: false, verbose: false, model: 'opus' }], + } as any); + const result = await resolveEffectiveConfig('ch-static'); + expect(result.channelSource).toBe('config.json'); + expect(result.channelName).toBe('test-channel'); + }); + + it('identifies dynamic channel source', async () => { + vi.spyOn(store, 'getDynamicChannel').mockResolvedValue({ + channelId: 'ch-dyn', platform: 'mm', name: 'dyn-channel', + workingDirectory: '/tmp', isDM: false, createdAt: '', updatedAt: '', + }); + const result = await resolveEffectiveConfig('ch-dyn'); + expect(result.channelSource).toBe('dynamic (SQLite)'); + }); + + it('identifies DM auto-discovered source', async () => { + vi.spyOn(store, 'getDynamicChannel').mockResolvedValue({ + channelId: 'ch-dm', platform: 'mm', name: 'dm-channel', + workingDirectory: '/tmp', isDM: true, createdAt: '', updatedAt: '', + }); + const result = await resolveEffectiveConfig('ch-dm'); + expect(result.channelSource).toBe('DM (auto-discovered)'); + }); + + it('bot default fills agent when channel has none', async () => { + vi.spyOn(config, 'getChannelBotConfig').mockResolvedValue({ token: 'x', agent: 'bot-agent' }); + const result = await resolveEffectiveConfig('ch1'); + const agent = result.fields.find(f => f.setting === 'agent'); + expect(agent?.value).toBe('bot-agent'); + expect(agent?.source).toBe('bot default'); + }); + + it('includes provider prefix on model when set in prefs', async () => { + vi.spyOn(store, 'getChannelPrefs').mockResolvedValue({ model: 'qwen3:8b', provider: 'ollama' }); + const result = await resolveEffectiveConfig('ch1'); + const model = result.fields.find(f => f.setting === 'model'); + expect(model?.value).toBe('ollama:qwen3:8b'); + }); + + it('does NOT prepend provider when model comes from session', async () => { + vi.spyOn(store, 'getChannelPrefs').mockResolvedValue({ provider: 'ollama' }); + const result = await resolveEffectiveConfig('ch1', + { sessionId: 's1', model: 'claude-opus-4.6', agent: null }); + const model = result.fields.find(f => f.setting === 'model'); + expect(model?.value).toBe('claude-opus-4.6'); + expect(model?.source).toBe('session (active)'); + }); + + it('workspace source is channelSource when from channel config', async () => { + vi.spyOn(config, 'getConfig').mockReturnValue({ + ...mockConfig, + channels: [{ id: 'ch-ws', platform: 'mm', name: 'ws-test', workingDirectory: '/project', triggerMode: 'all', threadedReplies: false, verbose: false }], + } as any); + const result = await resolveEffectiveConfig('ch-ws'); + const ws = result.fields.find(f => f.setting === 'workspace'); + expect(ws?.value).toBe('/project'); + expect(ws?.source).toBe('config.json'); + }); + + it('workspace source is runtime when from channelMeta', async () => { + const result = await resolveEffectiveConfig('ch1', undefined, + { workingDirectory: '/runtime/path', bot: 'mybot' }); + const ws = result.fields.find(f => f.setting === 'workspace'); + const bot = result.fields.find(f => f.setting === 'bot'); + expect(ws?.source).toBe('runtime'); + expect(bot?.source).toBe('runtime'); + }); + + it('agent: null in prefs is an explicit deselect', async () => { + vi.spyOn(store, 'getChannelPrefs').mockResolvedValue({ agent: null }); + vi.spyOn(config, 'getChannelBotConfig').mockResolvedValue({ token: 'x', agent: 'bot-agent' }); + const result = await resolveEffectiveConfig('ch1'); + const agent = result.fields.find(f => f.setting === 'agent'); + expect(agent?.value).toBe('\u2014'); + expect(agent?.source).toBe('channel prefs'); + }); + + it('resolves bot name via platform default', async () => { + vi.spyOn(config, 'getChannelBotName').mockResolvedValue('copilot'); + const result = await resolveEffectiveConfig('ch1'); + const bot = result.fields.find(f => f.setting === 'bot'); + expect(bot?.value).toBe('copilot'); + expect(bot?.source).toBe('platform default'); + }); +}); + +describe('formatConfigTable', () => { + it('produces markdown table', () => { + const fields: ConfigField[] = [ + { setting: 'model', value: 'claude-opus-4.6', source: 'defaults' }, + { setting: 'verbose', value: 'Off', source: 'config.json' }, + ]; + const output = formatConfigTable(fields, '#test', 'config.json'); + expect(output).toContain('Channel Config'); + expect(output).toContain('#test'); + expect(output).toContain('| model |'); + expect(output).toContain('defaults'); + }); + + it('shows em dash for unset values without backticks', () => { + const fields: ConfigField[] = [ + { setting: 'reasoningEffort', value: '\u2014', source: '(not set)' }, + ]; + const output = formatConfigTable(fields, '#test', 'config.json'); + expect(output).toContain('| \u2014 |'); + expect(output).not.toContain('`\u2014`'); + }); + + it('escapes pipe characters and newlines in values', () => { + const fields: ConfigField[] = [ + { setting: 'test', value: 'a|b\nc', source: 'test' }, + ]; + const output = formatConfigTable(fields, '#test', 'config.json'); + expect(output).not.toContain('| a|b'); + expect(output).toContain('a\\|b c'); + }); +}); diff --git a/src/core/command-handler.ts b/src/core/command-handler.ts index 6727a1b..ab780ff 100644 --- a/src/core/command-handler.ts +++ b/src/core/command-handler.ts @@ -1,6 +1,6 @@ -import { setChannelPrefs, getChannelPrefs, getGlobalSetting, setGlobalSetting } from '../state/store.js'; +import { setChannelPrefs, getChannelPrefs, getGlobalSetting, setGlobalSetting, getDynamicChannel } from '../state/store.js'; import { discoverAgentDefinitions, discoverAgentNames } from './inter-agent.js'; -import { isBotAdminAny } from '../config.js'; +import { isBotAdminAny, getConfig, getChannelBotConfig, getChannelBotName } from '../config.js'; import type { BridgeProviderConfig } from '../types.js'; const VALID_REASONING_EFFORTS = new Set(['low', 'medium', 'high', 'xhigh']); @@ -300,6 +300,196 @@ function extractAgentDescription(content: string): string { return ''; } +// --------------------------------------------------------------------------- +// /config — effective channel configuration with source attribution +// --------------------------------------------------------------------------- + +export interface ConfigField { + setting: string; + value: string; + source: string; +} + +/** + * Resolve the effective configuration for a channel, with source attribution. + * Each field includes the resolved value and which layer set it. + */ +export async function resolveEffectiveConfig( + channelId: string, + sessionInfo?: { sessionId: string; model: string; agent: string | null }, + channelMeta?: { workingDirectory?: string; bot?: string }, +): Promise<{ fields: ConfigField[]; channelSource: string; channelName: string }> { + const config = getConfig(); + const defaults = config.defaults; + const prefs = await getChannelPrefs(channelId); + + // Determine channel source + const staticChannel = config.channels.find(c => c.id === channelId); + const dynChannel = staticChannel ? null : await getDynamicChannel(channelId); + const channelSource = staticChannel ? 'config.json' : dynChannel?.isDM ? 'DM (auto-discovered)' : dynChannel ? 'dynamic (SQLite)' : 'unknown'; + const channelName = staticChannel?.name ?? dynChannel?.name ?? channelId; + + // Bot-level defaults + const botConfig = await getChannelBotConfig(channelId); + const channelObj = staticChannel ?? (dynChannel ? { + model: dynChannel.model, + agent: dynChannel.agent, + triggerMode: dynChannel.triggerMode, + threadedReplies: dynChannel.threadedReplies, + verbose: dynChannel.verbose, + bot: dynChannel.bot, + workingDirectory: dynChannel.workingDirectory, + } : null); + + // Helper to resolve a field through the layer stack + function resolve( + field: string, + channelVal: unknown, + botVal: unknown, + defaultVal: unknown, + prefsVal: unknown, + sessionVal: unknown, + ): { value: string; source: string } { + // Session overrides (active model/agent) take precedence + if (sessionVal !== undefined && sessionVal !== null) { + return { value: String(sessionVal), source: 'session (active)' }; + } + // Channel prefs (persisted runtime overrides) + if (prefsVal !== undefined && prefsVal !== null) { + return { value: String(prefsVal), source: 'channel prefs' }; + } + // Channel config (static or dynamic) + if (channelVal !== undefined && channelVal !== null) { + return { value: String(channelVal), source: channelSource }; + } + // Bot-level default + if (botVal !== undefined && botVal !== null) { + return { value: String(botVal), source: 'bot default' }; + } + // Global defaults + if (defaultVal !== undefined && defaultVal !== null) { + return { value: String(defaultVal), source: 'defaults' }; + } + return { value: '\u2014', source: '(not set)' }; + } + + const fields: ConfigField[] = []; + + // Model: session active > prefs > channel > defaults + const modelResolved = resolve('model', + channelObj?.model, null, defaults.model, + prefs?.model, sessionInfo?.model); + if (prefs?.provider && modelResolved.source === 'channel prefs') { + modelResolved.value = `${prefs.provider}:${modelResolved.value}`; + } + fields.push({ setting: 'model', ...modelResolved }); + + // Agent: needs special handling because null is an explicit "deselect" value + // Matches session-manager.ts getEffectivePrefs() logic + let agentField: ConfigField; + if (sessionInfo?.agent !== undefined && sessionInfo.agent !== null) { + agentField = { setting: 'agent', value: sessionInfo.agent, source: 'session (active)' }; + } else if (prefs?.agent !== undefined) { + // agent: null in prefs is an explicit deselect + agentField = { setting: 'agent', value: prefs.agent ?? '\u2014', source: 'channel prefs' }; + } else if (channelObj?.agent !== undefined) { + agentField = { setting: 'agent', value: channelObj.agent ?? '\u2014', source: channelSource }; + } else if (botConfig?.agent !== undefined) { + agentField = { setting: 'agent', value: botConfig.agent ?? '\u2014', source: 'bot default' }; + } else if (defaults.agent !== undefined && defaults.agent !== null) { + agentField = { setting: 'agent', value: defaults.agent, source: 'defaults' }; + } else { + agentField = { setting: 'agent', value: '\u2014', source: '(not set)' }; + } + fields.push(agentField); + + // Trigger mode + fields.push({ setting: 'triggerMode', ...resolve('triggerMode', + channelObj?.triggerMode, null, defaults.triggerMode, + null, null) }); + + // Threaded replies + const threadedResolved = resolve('threadedReplies', + channelObj?.threadedReplies, null, defaults.threadedReplies, + prefs?.threadedReplies, null); + threadedResolved.value = threadedResolved.value === 'true' ? 'On' : threadedResolved.value === 'false' ? 'Off' : threadedResolved.value; + fields.push({ setting: 'threadedReplies', ...threadedResolved }); + + // Verbose + const verboseResolved = resolve('verbose', + channelObj?.verbose, null, defaults.verbose, + prefs?.verbose, null); + verboseResolved.value = verboseResolved.value === 'true' ? 'On' : verboseResolved.value === 'false' ? 'Off' : verboseResolved.value; + fields.push({ setting: 'verbose', ...verboseResolved }); + + // Permission mode + fields.push({ setting: 'permissionMode', ...resolve('permissionMode', + null, null, defaults.permissionMode, + prefs?.permissionMode, null) }); + + // Reasoning effort + fields.push({ setting: 'reasoningEffort', ...resolve('reasoningEffort', + null, null, null, + prefs?.reasoningEffort, null) }); + + // Session mode + fields.push({ setting: 'sessionMode', ...resolve('sessionMode', + null, null, 'interactive', + prefs?.sessionMode, null) }); + + // Disabled skills + const disabledSkills = prefs?.disabledSkills; + fields.push({ + setting: 'disabledSkills', + value: disabledSkills?.length ? disabledSkills.join(', ') : '\u2014', + source: disabledSkills?.length ? 'channel prefs' : '(none)', + }); + + // Workspace & bot (admin-visible) + fields.push({ + setting: 'workspace', + value: channelMeta?.workingDirectory ?? channelObj?.workingDirectory ?? '\u2014', + source: channelMeta?.workingDirectory ? 'runtime' + : channelObj?.workingDirectory ? channelSource + : '(not set)', + }); + + const resolvedBotName = channelMeta?.bot ?? await getChannelBotName(channelId); + fields.push({ + setting: 'bot', + value: resolvedBotName, + source: channelMeta?.bot ? 'runtime' + : channelObj?.bot ? channelSource + : 'platform default', + }); + + return { fields, channelSource, channelName }; +} + +/** Escape a string for safe inclusion in a markdown table cell. */ +function escapeTableCell(s: string): string { + return s.replace(/\\/g, '\\\\').replace(/\|/g, '\\|').replace(/\n/g, ' ').replace(/`/g, "'"); +} + +/** + * Format the effective config as a markdown table for chat display. + */ +export function formatConfigTable(fields: ConfigField[], channelName: string, channelSource: string): string { + const lines = [ + `\u2699\uFE0F **Channel Config** \u2014 ${escapeTableCell(channelName)}`, + `Source: ${channelSource}`, + '', + '| Setting | Value | Source |', + '|:--|:--|:--|', + ]; + for (const f of fields) { + const escaped = escapeTableCell(f.value); + const val = f.value === '\u2014' ? '\u2014' : `\`${escaped}\``; + lines.push(`| ${f.setting} | ${val} | ${f.source} |`); + } + return lines.join('\n'); +} + export function parseCommand(text: string): { command: string; args: string } | null { const trimmed = text.trim(); if (!trimmed.startsWith('/')) return null; @@ -597,6 +787,11 @@ export async function handleCommand(channelId: string, text: string, sessionInfo return { handled: true, response: lines.join('\n') }; } + case 'config': { + const { fields, channelSource, channelName } = await resolveEffectiveConfig(channelId, sessionInfo, channelMeta); + return { handled: true, response: formatConfigTable(fields, channelName, channelSource) }; + } + case 'context': { if (!contextUsage) { return { handled: true, response: '📊 Context usage not available yet. Send a message first.' }; @@ -701,6 +896,7 @@ export async function handleCommand(channelId: string, text: string, sessionInfo '`/stop` — Stop the current task', '`/model [name]` — List or switch models', '`/status` — Show session info', + '`/config` — Show effective channel configuration', '`/context` — Show context window usage', '`/verbose` — Toggle tool call visibility', '`/autopilot` — Toggle autopilot mode', @@ -730,6 +926,7 @@ export async function handleCommand(channelId: string, text: string, sessionInfo '`/agents` — List available agent definitions', '`/reasoning ` — Set reasoning effort (low/medium/high/xhigh)', '`/context` — Show context window usage', + '`/config` — Show effective channel config with source attribution', '`/verbose` — Toggle tool call visibility', '`/status` — Show session info', '',