Systematically integrate high-value SDK capabilities while preserving the multi-provider architecture: SDK capability discovery (per-provider cache): - agent-sdk-capabilities.ts: cache models, commands, account info, MCP status per provider via Map<providerId, cache> to prevent cross-provider pollution - claude-client.ts: fire-and-forget captureCapabilities() after query() init - providers/models: merge SDK models with static defaults - skills: merge SDK slash commands with filesystem skills Query options passthrough: - thinking (adaptive/enabled/disabled), effort (low/medium/high/max), outputFormat, agents, enableFileCheckpointing - /chat page.tsx: first-message path now includes effort/thinking (was missing) - stream-session-manager: passes effort/thinking in POST body Graceful interrupt: - /api/chat/interrupt: conversation.interrupt() before abort fallback - stream-session-manager: tries interrupt first, aborts after 2s timeout MCP runtime management: - /api/plugins/mcp/status: live status from active Query, auto-resolves providerId from session DB record (prevents wrong-provider cache writes) - /api/plugins/mcp/reconnect + toggle: reconnect/enable/disable servers - McpManager + McpServerList: runtime status indicators with action buttons File checkpointing & rewind: - enableFileCheckpointing=true for code mode by default - rewind_point SSE: only emitted for prompt-level user messages (parent_tool_use_id===null) and skips autoTrigger turns - /api/chat/rewind: dry-run preview + actual rewind via rewindFiles() - MessageList: RewindButton with dry-run preview Structured output: - /api/chat/structured: reads SDKResultSuccess.structured_output as primary path, text fallback only when absent Account info & thinking config: - /api/sdk/account: cached account info display - Settings: thinking_mode in ALLOWED_KEYS, thinking mode selector in UI - Effort selector in MessageInput with lifted state pattern New files: 11 route handlers + 2 lib modules + 1 test file Modified files: 18 (types, client, manager, UI components, i18n) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
177 lines
5.8 KiB
TypeScript
177 lines
5.8 KiB
TypeScript
/**
|
|
* Agent SDK Capabilities Cache — per-provider capability cache that captures
|
|
* SDK data (models, commands, account info, MCP status) from active Query instances.
|
|
*
|
|
* Uses cache-on-first-query pattern: after each query() initialization,
|
|
* capabilities are captured and cached, keyed by providerId, so different
|
|
* providers never pollute each other's data.
|
|
*
|
|
* Uses globalThis pattern (same as conversation-registry.ts) to survive
|
|
* Next.js HMR without losing state.
|
|
*/
|
|
|
|
import type { Query } from '@anthropic-ai/claude-agent-sdk';
|
|
import type {
|
|
ModelInfo,
|
|
SlashCommand,
|
|
AccountInfo,
|
|
McpServerStatus,
|
|
} from '@anthropic-ai/claude-agent-sdk';
|
|
import { getConversation } from './conversation-registry';
|
|
|
|
// ==========================================
|
|
// Cache structure
|
|
// ==========================================
|
|
|
|
interface ProviderCapabilityCache {
|
|
models: ModelInfo[];
|
|
commands: SlashCommand[];
|
|
account: AccountInfo | null;
|
|
mcpStatus: McpServerStatus[];
|
|
capturedAt: number;
|
|
sessionId: string;
|
|
}
|
|
|
|
const GLOBAL_KEY = '__agentSdkCapabilities__' as const;
|
|
|
|
/** Returns the per-provider cache Map. */
|
|
function getCacheMap(): Map<string, ProviderCapabilityCache> {
|
|
if (!(globalThis as Record<string, unknown>)[GLOBAL_KEY]) {
|
|
(globalThis as Record<string, unknown>)[GLOBAL_KEY] = new Map<string, ProviderCapabilityCache>();
|
|
}
|
|
return (globalThis as Record<string, unknown>)[GLOBAL_KEY] as Map<string, ProviderCapabilityCache>;
|
|
}
|
|
|
|
function getOrCreateCache(providerId: string): ProviderCapabilityCache {
|
|
const map = getCacheMap();
|
|
let cache = map.get(providerId);
|
|
if (!cache) {
|
|
cache = {
|
|
models: [],
|
|
commands: [],
|
|
account: null,
|
|
mcpStatus: [],
|
|
capturedAt: 0,
|
|
sessionId: '',
|
|
};
|
|
map.set(providerId, cache);
|
|
}
|
|
return cache;
|
|
}
|
|
|
|
// ==========================================
|
|
// Capture
|
|
// ==========================================
|
|
|
|
/**
|
|
* Check if a conversation object is a real Query instance (not a resume-fallback
|
|
* async generator wrapper). The resume fallback in claude-client.ts wraps the
|
|
* iterator in a plain async generator that lacks Query control methods.
|
|
*/
|
|
function isRealQuery(conversation: unknown): conversation is Query {
|
|
return (
|
|
conversation != null &&
|
|
typeof (conversation as Query).supportedModels === 'function' &&
|
|
typeof (conversation as Query).supportedCommands === 'function' &&
|
|
typeof (conversation as Query).accountInfo === 'function' &&
|
|
typeof (conversation as Query).mcpServerStatus === 'function'
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Capture all capabilities from an active Query instance.
|
|
* Should be called fire-and-forget after registerConversation().
|
|
* Safe to call with non-Query objects (resume fallback) — will silently skip.
|
|
*
|
|
* @param providerId - The provider ID that owns this session (e.g. 'env', a DB provider ID)
|
|
*/
|
|
export async function captureCapabilities(
|
|
sessionId: string,
|
|
conversation: unknown,
|
|
providerId: string = 'env',
|
|
): Promise<void> {
|
|
if (!isRealQuery(conversation)) {
|
|
console.log('[capabilities] Skipping capture — not a real Query instance');
|
|
return;
|
|
}
|
|
|
|
const cache = getOrCreateCache(providerId);
|
|
|
|
try {
|
|
const [models, commands, account, mcpStatus] = await Promise.allSettled([
|
|
conversation.supportedModels(),
|
|
conversation.supportedCommands(),
|
|
conversation.accountInfo(),
|
|
conversation.mcpServerStatus(),
|
|
]);
|
|
|
|
cache.models = models.status === 'fulfilled' ? models.value : cache.models;
|
|
cache.commands = commands.status === 'fulfilled' ? commands.value : cache.commands;
|
|
cache.account = account.status === 'fulfilled' ? account.value : cache.account;
|
|
cache.mcpStatus = mcpStatus.status === 'fulfilled' ? mcpStatus.value : cache.mcpStatus;
|
|
cache.capturedAt = Date.now();
|
|
cache.sessionId = sessionId;
|
|
|
|
console.log(
|
|
`[capabilities] Captured for provider="${providerId}":`,
|
|
`models=${cache.models.length}`,
|
|
`commands=${cache.commands.length}`,
|
|
`account=${cache.account ? 'yes' : 'no'}`,
|
|
`mcpServers=${cache.mcpStatus.length}`,
|
|
);
|
|
} catch (error) {
|
|
console.warn('[capabilities] Capture failed:', error);
|
|
}
|
|
}
|
|
|
|
// ==========================================
|
|
// Read cached data (scoped by provider)
|
|
// ==========================================
|
|
|
|
export function getCachedModels(providerId: string = 'env'): ModelInfo[] {
|
|
return getOrCreateCache(providerId).models;
|
|
}
|
|
|
|
export function getCachedCommands(providerId: string = 'env'): SlashCommand[] {
|
|
return getOrCreateCache(providerId).commands;
|
|
}
|
|
|
|
export function getCachedAccountInfo(providerId: string = 'env'): AccountInfo | null {
|
|
return getOrCreateCache(providerId).account;
|
|
}
|
|
|
|
export function getCachedMcpStatus(providerId: string = 'env'): McpServerStatus[] {
|
|
return getOrCreateCache(providerId).mcpStatus;
|
|
}
|
|
|
|
export function getCapabilityCacheAge(providerId: string = 'env'): number {
|
|
const { capturedAt } = getOrCreateCache(providerId);
|
|
return capturedAt === 0 ? Infinity : Date.now() - capturedAt;
|
|
}
|
|
|
|
// ==========================================
|
|
// Refresh (from active Query)
|
|
// ==========================================
|
|
|
|
/**
|
|
* Refresh MCP server status from an active Query instance.
|
|
* Falls back to cached data if the session has no active conversation.
|
|
*/
|
|
export async function refreshMcpStatus(sessionId: string, providerId: string = 'env'): Promise<McpServerStatus[]> {
|
|
const conversation = getConversation(sessionId);
|
|
if (!isRealQuery(conversation)) {
|
|
return getOrCreateCache(providerId).mcpStatus;
|
|
}
|
|
|
|
try {
|
|
const status = await conversation.mcpServerStatus();
|
|
const cache = getOrCreateCache(providerId);
|
|
cache.mcpStatus = status;
|
|
cache.capturedAt = Date.now();
|
|
return status;
|
|
} catch (error) {
|
|
console.warn('[capabilities] MCP status refresh failed:', error);
|
|
return getOrCreateCache(providerId).mcpStatus;
|
|
}
|
|
}
|