diff --git a/src/server/chat/dynamic-context.test.ts b/src/server/chat/dynamic-context.test.ts index 17efbf5c..58569686 100644 --- a/src/server/chat/dynamic-context.test.ts +++ b/src/server/chat/dynamic-context.test.ts @@ -1,5 +1,10 @@ import { describe, it, expect } from 'vitest' -import { computeUnifiedDiff, computeDynamicContextHash } from './dynamic-context.js' +import { + computeUnifiedDiff, + computeDynamicContextHash, + computeToolDiff, + computePreviewToolDiff, +} from './dynamic-context.js' describe('computeUnifiedDiff', () => { it('returns unchanged lines when texts are identical', () => { @@ -179,6 +184,90 @@ Respond concisely and clearly. }) }) +describe('computeToolDiff', () => { + const tool = (name: string) => ({ + type: 'function' as const, + function: { name, description: `desc ${name}`, parameters: { type: 'object', properties: {} } }, + }) + + it('returns empty array when tool sets are identical', () => { + const tools = [tool('read_file'), tool('write_file')] + expect(computeToolDiff(tools, [...tools])).toEqual([]) + }) + + it('detects removed tools', () => { + const oldTools = [tool('read_file'), tool('write_file')] + const newTools = [tool('read_file')] + expect(computeToolDiff(oldTools, newTools)).toEqual([{ type: 'removed', content: 'write_file' }]) + }) + + it('detects added tools', () => { + const oldTools = [tool('read_file')] + const newTools = [tool('read_file'), tool('write_file')] + expect(computeToolDiff(oldTools, newTools)).toEqual([{ type: 'added', content: 'write_file' }]) + }) + + it('detects both additions and removals with removals first', () => { + const oldTools = [tool('a'), tool('b')] + const newTools = [tool('b'), tool('c')] + expect(computeToolDiff(oldTools, newTools)).toEqual([ + { type: 'removed', content: 'a' }, + { type: 'added', content: 'c' }, + ]) + }) +}) + +describe('computePreviewToolDiff', () => { + const tool = (name: string) => ({ + type: 'function' as const, + function: { name, description: `desc ${name}`, parameters: { type: 'object', properties: {} } }, + }) + + it('uses cached tools as baseline when a cached prompt exists', () => { + const cached = [tool('read_file'), tool('write_file')] + const unfiltered = [tool('read_file'), tool('write_file'), tool('chrome_click')] + const fresh = [tool('read_file'), tool('write_file')] + expect(computePreviewToolDiff(cached, unfiltered, fresh)).toEqual([]) + }) + + it('uses cached tools as baseline and detects removals when MCP is toggled off', () => { + const cached = [tool('read_file'), tool('chrome_click')] + const unfiltered = [tool('read_file'), tool('chrome_click')] + const fresh = [tool('read_file')] + expect(computePreviewToolDiff(cached, unfiltered, fresh)).toEqual([{ type: 'removed', content: 'chrome_click' }]) + }) + + it('falls back to unfiltered registry when no cached prompt exists', () => { + const unfiltered = [tool('read_file'), tool('chrome_click')] + const fresh = [tool('read_file')] + expect(computePreviewToolDiff(undefined, unfiltered, fresh)).toEqual([{ type: 'removed', content: 'chrome_click' }]) + }) + + it('falls back to unfiltered registry when cached tools are empty', () => { + const unfiltered = [tool('read_file'), tool('chrome_click')] + const fresh = [tool('read_file')] + expect(computePreviewToolDiff([], unfiltered, fresh)).toEqual([{ type: 'removed', content: 'chrome_click' }]) + }) + + it('reports no additions without a cached prompt since the baseline already includes all MCP tools', () => { + const unfiltered = [tool('read_file'), tool('chrome_click')] + const fresh = [tool('read_file'), tool('chrome_click')] + expect(computePreviewToolDiff(undefined, unfiltered, fresh)).toEqual([]) + }) + + it('detects additions when a cached prompt was built with MCP off and it is toggled on', () => { + const cached = [tool('read_file')] + const unfiltered = [tool('read_file'), tool('chrome_click')] + const fresh = [tool('read_file'), tool('chrome_click')] + expect(computePreviewToolDiff(cached, unfiltered, fresh)).toEqual([{ type: 'added', content: 'chrome_click' }]) + }) + + it('reports no change when both baselines match the fresh tool set', () => { + const unfiltered = [tool('read_file')] + expect(computePreviewToolDiff(undefined, unfiltered, [tool('read_file')])).toEqual([]) + }) +}) + describe('computeDynamicContextHash', () => { const skills = [{ id: 'playwright', name: 'Playwright', description: 'Browser automation', version: '1.0' }] diff --git a/src/server/chat/dynamic-context.ts b/src/server/chat/dynamic-context.ts index c412bedb..ffcf0303 100644 --- a/src/server/chat/dynamic-context.ts +++ b/src/server/chat/dynamic-context.ts @@ -112,6 +112,40 @@ export function getToolFingerprint(tools: LLMToolDefinition[]): string { .join('|') } +/** + * Compute a diff of tool names between two tool lists. + * Returns added/removed lines (removals first) for tools present in only one list. + */ +export function computeToolDiff(oldTools: LLMToolDefinition[], newTools: LLMToolDefinition[]): DiffLine[] { + const oldNames = oldTools.map((t) => t.function.name).sort() + const newNames = newTools.map((t) => t.function.name).sort() + const oldSet = new Set(oldNames) + const newSet = new Set(newNames) + const result: DiffLine[] = [] + for (const name of oldNames) { + if (!newSet.has(name)) result.push({ type: 'removed', content: name }) + } + for (const name of newNames) { + if (!oldSet.has(name)) result.push({ type: 'added', content: name }) + } + return result +} + +/** + * Compute the tool diff shown in the apply-dynamic-context preview. + * Baseline: cached prompt tools if present, otherwise the unfiltered registry + * (all MCP tools, no session overrides) so tool add/remove is visible even + * before a cached prompt exists. + */ +export function computePreviewToolDiff( + oldCachedTools: LLMToolDefinition[] | undefined, + unfilteredTools: LLMToolDefinition[], + newTools: LLMToolDefinition[], +): DiffLine[] { + const baseline = oldCachedTools && oldCachedTools.length > 0 ? oldCachedTools : unfilteredTools + return computeToolDiff(baseline, newTools) +} + async function loadSessionContext( sessionManager: SessionManager, sessionId: string, diff --git a/src/server/index.ts b/src/server/index.ts index a0621f07..150b3742 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -1262,7 +1262,14 @@ export async function createServerHandle(config: Config): Promise return res.status(400).json({ error: 'disabledServers must be an array of strings' }) } setSessionDisabledServers(sessionId, disabledServers) - sessionManager.setDynamicContextChanged(sessionId, true) + const messages = session.messages ?? [] + if (messages.length === 0) { + const { applyDynamicContext } = await import('./chat/dynamic-context.js') + const modelName = session.providerModel ?? providerManager.getCurrentModel() + await applyDynamicContext(sessionManager, sessionId, modelName) + } else { + sessionManager.setDynamicContextChanged(sessionId, true) + } const state = sessionManager.getContextState(sessionId) wssExports.broadcastForSession(sessionId, createContextStateMessage(state)) res.json({ disabledServers: getSessionDisabledServers(sessionId) }) diff --git a/src/server/tools/mcp-config.test.ts b/src/server/tools/mcp-config.test.ts index af79e198..deed273d 100644 --- a/src/server/tools/mcp-config.test.ts +++ b/src/server/tools/mcp-config.test.ts @@ -53,6 +53,10 @@ vi.mock('./index.js', () => ({ createToolRegistry: vi.fn(() => ({ definitions: [] })), })) +vi.mock('../mcp/session-overrides.js', () => ({ + getSessionDisabledServers: (sessionId: string) => (sessionId === 's2' ? ['filesystem'] : []), +})) + vi.mock('../utils/logger.js', () => ({ logger: { info: vi.fn(), error: vi.fn(), warn: vi.fn() }, })) @@ -177,6 +181,37 @@ describe('mcpConfigTool', () => { expect(result.output).toContain('0 tools') }) + it('should omit servers disabled for the session', async () => { + setMcpManagerForTools(mockManager) + + const { mcpConfigTool } = await import('./mcp-config.js') + + const result = await mcpConfigTool.execute( + { action: 'list' }, + { workdir: '/tmp', sessionId: 's2', sessionManager: mockSessionManager() }, + ) + + expect(result.success).toBe(true) + expect(result.output).toBe('No MCP servers configured.') + expect(result.output).not.toContain('filesystem') + }) + + it('should keep servers enabled for the session with their tool listing', async () => { + setMcpManagerForTools(mockManager) + + const { mcpConfigTool } = await import('./mcp-config.js') + + const result = await mcpConfigTool.execute( + { action: 'list' }, + { workdir: '/tmp', sessionId: 's1', sessionManager: mockSessionManager() }, + ) + + expect(result.success).toBe(true) + expect(result.output).not.toContain('disabled for this session') + expect(result.output).toContain('Enabled: read_file') + expect(result.output).toContain('Disabled: write_file') + }) + it('should report no servers when none configured', async () => { setMcpManagerForTools({ ...mockManager, diff --git a/src/server/tools/mcp-config.ts b/src/server/tools/mcp-config.ts index 0f8c2037..14eb5873 100644 --- a/src/server/tools/mcp-config.ts +++ b/src/server/tools/mcp-config.ts @@ -154,8 +154,16 @@ export const mcpConfigTool: Tool = createTool( return helpers.success('No MCP servers configured.') } + const { getSessionDisabledServers } = await import('../mcp/session-overrides.js') + const disabledForSession = new Set(context.sessionId ? getSessionDisabledServers(context.sessionId) : []) + + const visibleServers = servers.filter((server) => !disabledForSession.has(server.name)) + if (visibleServers.length === 0) { + return helpers.success('No MCP servers configured.') + } + const lines: string[] = [] - for (const server of servers) { + for (const server of visibleServers) { const connStr = server.status === 'connected' ? '●' : server.status === 'error' ? '✗' : '○' const cmdStr = server.config.command ? `${server.config.command} ${(server.config.args ?? []).join(' ')}` diff --git a/src/server/ws/server.ts b/src/server/ws/server.ts index ebc78640..f285c8ea 100644 --- a/src/server/ws/server.ts +++ b/src/server/ws/server.ts @@ -1150,18 +1150,17 @@ async function handleClientMessage( const session = sessionManager.requireSession(sessionId) try { - const { buildCachedPrompt } = await import('../chat/dynamic-context.js') + const { buildCachedPrompt, computePreviewToolDiff } = await import('../chat/dynamic-context.js') const allAgents = await import('../agents/registry.js') const agentDef = allAgents.findAgentById(session.mode, await allAgents.loadAllAgentsDefault()) ?? allAgents.findAgentById('planner', await allAgents.loadAllAgentsDefault())! const modelName = session.providerModel ?? _providerManager?.getCurrentModel() - const { systemPrompt: newPrompt, hash: newHash } = await buildCachedPrompt( - sessionManager, - sessionId, - agentDef, - modelName, - ) + const { + systemPrompt: newPrompt, + tools: newTools, + hash: newHash, + } = await buildCachedPrompt(sessionManager, sessionId, agentDef, modelName) const oldCached = sessionManager.getCachedPrompt(sessionId) const oldPrompt = oldCached?.systemPrompt @@ -1169,6 +1168,13 @@ async function handleClientMessage( // Compute unified diff const diff = oldPrompt ? computeUnifiedDiff(oldPrompt, newPrompt) : [] + // Baseline: cached tools if present, else the unfiltered registry + // (all MCP tools, no session overrides) so tool add/remove is visible + // even before a cached prompt exists. + const { getToolRegistryForAgent } = await import('../tools/index.js') + const unfilteredTools = getToolRegistryForAgent(agentDef).definitions + const toolDiff = computePreviewToolDiff(oldCached?.tools, unfilteredTools, newTools) + send({ type: 'context.preview', payload: { @@ -1177,6 +1183,7 @@ async function handleClientMessage( oldHash: oldCached?.hash, newHash, diff, + ...(toolDiff.length > 0 ? { toolDiff } : {}), }, id: message.id, }) diff --git a/src/shared/protocol.ts b/src/shared/protocol.ts index ad18869b..bb4bd7fb 100644 --- a/src/shared/protocol.ts +++ b/src/shared/protocol.ts @@ -435,6 +435,7 @@ export interface ContextPreviewPayload { oldHash?: string newHash: string diff: DiffLine[] + toolDiff?: DiffLine[] } // Provider payloads (server → client) diff --git a/web/src/components/plan/DynamicContextPreviewModal.tsx b/web/src/components/plan/DynamicContextPreviewModal.tsx index 5ab2192e..658fe2ab 100644 --- a/web/src/components/plan/DynamicContextPreviewModal.tsx +++ b/web/src/components/plan/DynamicContextPreviewModal.tsx @@ -16,6 +16,8 @@ interface DynamicContextPreviewModalProps { export function DynamicContextPreviewModal({ isOpen, onClose, isRunning, onApply }: DynamicContextPreviewModalProps) { const sessionId = useSessionScope() const [diffPreview, setDiffPreview] = useState(null) + const [toolDiffPreview, setToolDiffPreview] = useState([]) + const [hasBaseline, setHasBaseline] = useState(false) const [isLoadingPreview, setIsLoadingPreview] = useState(false) const pendingPreviewRequestId = useRef(null) @@ -26,8 +28,10 @@ export function DynamicContextPreviewModal({ isOpen, onClose, isRunning, onApply const unsubscribe = wsClient.subscribe((message) => { if (message.id === requestId && message.type === 'context.preview') { - const payload = message.payload as { diff: DiffLine[] } + const payload = message.payload as { diff: DiffLine[]; toolDiff?: DiffLine[]; oldPrompt?: string } setDiffPreview(payload.diff ?? []) + setToolDiffPreview(payload.toolDiff ?? []) + setHasBaseline(payload.oldPrompt !== undefined) setIsLoadingPreview(false) pendingPreviewRequestId.current = null unsubscribe() @@ -46,11 +50,16 @@ export function DynamicContextPreviewModal({ isOpen, onClose, isRunning, onApply useEffect(() => { if (isOpen) { setDiffPreview(null) + setToolDiffPreview([]) + setHasBaseline(false) setIsLoadingPreview(true) fetchPreview() } }, [isOpen, fetchPreview]) + const hasDiff = diffPreview !== null && diffPreview.length > 0 + const hasToolDiff = toolDiffPreview.length > 0 + return (

@@ -65,15 +74,29 @@ export function DynamicContextPreviewModal({ isOpen, onClose, isRunning, onApply

{isLoadingPreview ? (
Loading diff...
- ) : diffPreview && diffPreview.length > 0 ? ( + ) : hasDiff || hasToolDiff ? ( - + {hasDiff && } + {hasToolDiff && ( +
+ {hasDiff &&
} +
+ Tools ({toolDiffPreview.filter((l) => l.type === 'added').length} added,{' '} + {toolDiffPreview.filter((l) => l.type === 'removed').length} removed) +
+ +
+ )} - ) : ( + ) : hasBaseline ? (

The system prompt hash has changed (e.g., due to tool or skill changes), but the actual prompt text appears identical. Applying the update will still rebuild the cached prompt to ensure consistency.

+ ) : ( +

+ The cached system prompt will be built with the current tools and settings on apply. +

)}