Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 90 additions & 1 deletion src/server/chat/dynamic-context.test.ts
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand Down Expand Up @@ -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' }]

Expand Down
34 changes: 34 additions & 0 deletions src/server/chat/dynamic-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
9 changes: 8 additions & 1 deletion src/server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1262,7 +1262,14 @@ export async function createServerHandle(config: Config): Promise<ServerHandle>
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) })
Expand Down
35 changes: 35 additions & 0 deletions src/server/tools/mcp-config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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() },
}))
Expand Down Expand Up @@ -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,
Expand Down
10 changes: 9 additions & 1 deletion src/server/tools/mcp-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,8 +154,16 @@ export const mcpConfigTool: Tool = createTool<McpConfigArgs>(
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(' ')}`
Expand Down
21 changes: 14 additions & 7 deletions src/server/ws/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1150,25 +1150,31 @@ 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

// 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: {
Expand All @@ -1177,6 +1183,7 @@ async function handleClientMessage(
oldHash: oldCached?.hash,
newHash,
diff,
...(toolDiff.length > 0 ? { toolDiff } : {}),
},
id: message.id,
})
Expand Down
1 change: 1 addition & 0 deletions src/shared/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -435,6 +435,7 @@ export interface ContextPreviewPayload {
oldHash?: string
newHash: string
diff: DiffLine[]
toolDiff?: DiffLine[]
}

// Provider payloads (server → client)
Expand Down
31 changes: 27 additions & 4 deletions web/src/components/plan/DynamicContextPreviewModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ interface DynamicContextPreviewModalProps {
export function DynamicContextPreviewModal({ isOpen, onClose, isRunning, onApply }: DynamicContextPreviewModalProps) {
const sessionId = useSessionScope()
const [diffPreview, setDiffPreview] = useState<DiffLine[] | null>(null)
const [toolDiffPreview, setToolDiffPreview] = useState<DiffLine[]>([])
const [hasBaseline, setHasBaseline] = useState(false)
const [isLoadingPreview, setIsLoadingPreview] = useState(false)
const pendingPreviewRequestId = useRef<string | null>(null)

Expand All @@ -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()
Expand All @@ -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 (
<Modal isOpen={isOpen} onClose={onClose} title="Update system prompt" size="lg">
<p className="text-sm text-text-secondary mb-4">
Expand All @@ -65,15 +74,29 @@ export function DynamicContextPreviewModal({ isOpen, onClose, isRunning, onApply
</p>
{isLoadingPreview ? (
<div className="py-8 text-center text-text-muted">Loading diff...</div>
) : diffPreview && diffPreview.length > 0 ? (
) : hasDiff || hasToolDiff ? (
<ScrollArea className="max-h-[60vh] border border-border rounded-lg">
<UnifiedDiffViewer diff={diffPreview} />
{hasDiff && <UnifiedDiffViewer diff={diffPreview} />}
{hasToolDiff && (
<div>
{hasDiff && <div className="border-t border-border" />}
<div className="px-2 py-1 text-xs font-semibold text-text-muted uppercase tracking-wide">
Tools ({toolDiffPreview.filter((l) => l.type === 'added').length} added,{' '}
{toolDiffPreview.filter((l) => l.type === 'removed').length} removed)
</div>
<UnifiedDiffViewer diff={toolDiffPreview} hideHeader />
</div>
)}
</ScrollArea>
) : (
) : hasBaseline ? (
<p className="text-sm text-text-tertiary mb-4">
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.
</p>
) : (
<p className="text-sm text-text-tertiary mb-4">
The cached system prompt will be built with the current tools and settings on apply.
</p>
)}
<div className="flex justify-end gap-2 mt-4">
<button
Expand Down
7 changes: 5 additions & 2 deletions web/src/components/shared/DiffView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -289,14 +289,15 @@ function SimpleDiffLine({ type, content }: SimpleDiffLineProps) {

interface UnifiedDiffViewerProps {
diff: ProtocolDiffLine[]
hideHeader?: boolean
}

/**
* Unified diff viewer that shows changes line-by-line with +/- markers.
* Groups removed lines before their corresponding added lines at each change location.
* Used for system prompt diff preview and other text-based diffs.
*/
export function UnifiedDiffViewer({ diff }: UnifiedDiffViewerProps) {
export function UnifiedDiffViewer({ diff, hideHeader = false }: UnifiedDiffViewerProps) {
const changes: Array<{ type: 'removed' | 'added'; content: string }> = []

let i = 0
Expand Down Expand Up @@ -336,7 +337,9 @@ export function UnifiedDiffViewer({ diff }: UnifiedDiffViewerProps) {

return (
<div>
<div className="px-2 py-1 text-xs font-semibold text-text-muted uppercase tracking-wide">Changes:</div>
{!hideHeader && (
<div className="px-2 py-1 text-xs font-semibold text-text-muted uppercase tracking-wide">Changes:</div>
)}
<div className="font-mono text-xs leading-5">
{changes.map((change, idx) => (
<SimpleDiffLine key={idx} type={change.type} content={change.content} />
Expand Down
Loading