Files
CodePilot/src/lib/agent-sdk-capabilities.ts
T
7418andClaude Opus 4.6 c18776f4ca feat: integrate Claude Agent SDK capabilities — model/command discovery, effort/thinking, MCP runtime, file rewind, structured output
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>
2026-03-07 18:22:05 +08:00

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;
}
}