diff --git a/package-lock.json b/package-lock.json index 43e36df..a5dd1bd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "codepilot", - "version": "0.27.1", + "version": "0.28.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "codepilot", - "version": "0.27.1", + "version": "0.28.0", "dependencies": { "@ai-sdk/anthropic": "^3.0.47", "@ai-sdk/google": "^3.0.31", diff --git a/package.json b/package.json index 123f8ef..6f50bfb 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "codepilot", - "version": "0.27.1", + "version": "0.28.0", "private": true, "author": { "name": "op7418", diff --git a/src/__tests__/unit/structured-output.test.ts b/src/__tests__/unit/structured-output.test.ts new file mode 100644 index 0000000..c9df97e --- /dev/null +++ b/src/__tests__/unit/structured-output.test.ts @@ -0,0 +1,138 @@ +/** + * Unit tests for structured output route logic. + * + * Tests verify that: + * 1. structured_output from SDKResultSuccess is preferred over text fallback + * 2. Text fallback is used when structured_output is absent + * 3. Raw text is returned when JSON.parse fails on fallback + */ + +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; + +// Simulate the extraction logic from the structured route +// (We can't import Next.js route handlers directly in node:test, +// so we test the core logic inline.) + +function extractStructuredResult(messages: Array<{ type: string; subtype?: string; structured_output?: unknown; result?: string; message?: unknown }>) { + let structuredOutput: unknown = undefined; + let resultText = ''; + + for (const message of messages) { + if (message.type === 'result' && message.subtype === 'success') { + if (message.structured_output !== undefined) { + structuredOutput = message.structured_output; + } + if (message.result) { + resultText = message.result; + } + } else if (message.type === 'assistant') { + const msg = message.message as { content?: Array<{ type: string; text?: string }> } | string; + if (typeof msg === 'string') { + resultText += msg; + } else if (msg && Array.isArray(msg.content)) { + for (const block of msg.content) { + if (block.type === 'text' && block.text) { + resultText += block.text; + } + } + } + } + } + + // Prefer structured_output + if (structuredOutput !== undefined) { + return { result: structuredOutput, source: 'structured_output' }; + } + + // Fallback: try JSON parse + if (resultText) { + try { + return { result: JSON.parse(resultText), source: 'text_parsed' }; + } catch { + return { result: resultText, source: 'text_raw' }; + } + } + + return { result: null, source: 'empty' }; +} + +describe('structured output extraction', () => { + it('prefers structured_output from SDK result', () => { + const messages = [ + { + type: 'assistant', + message: { content: [{ type: 'text', text: '{"name":"from text"}' }] }, + }, + { + type: 'result', + subtype: 'success', + structured_output: { name: 'from structured' }, + result: '{"name":"from text"}', + }, + ]; + + const out = extractStructuredResult(messages); + assert.equal(out.source, 'structured_output'); + assert.deepEqual(out.result, { name: 'from structured' }); + }); + + it('falls back to text parsing when structured_output is absent', () => { + const messages = [ + { + type: 'assistant', + message: { content: [{ type: 'text', text: '{"fallback":true}' }] }, + }, + { + type: 'result', + subtype: 'success', + result: '', + }, + ]; + + const out = extractStructuredResult(messages); + assert.equal(out.source, 'text_parsed'); + assert.deepEqual(out.result, { fallback: true }); + }); + + it('returns raw text when JSON parse fails', () => { + const messages = [ + { + type: 'assistant', + message: { content: [{ type: 'text', text: 'not valid json' }] }, + }, + { + type: 'result', + subtype: 'success', + }, + ]; + + const out = extractStructuredResult(messages); + assert.equal(out.source, 'text_raw'); + assert.equal(out.result, 'not valid json'); + }); + + it('returns null when no content at all', () => { + const messages = [ + { type: 'result', subtype: 'success' }, + ]; + + const out = extractStructuredResult(messages); + assert.equal(out.source, 'empty'); + assert.equal(out.result, null); + }); + + it('handles structured_output that is a primitive (e.g. number)', () => { + const messages = [ + { + type: 'result', + subtype: 'success', + structured_output: 42, + }, + ]; + + const out = extractStructuredResult(messages); + assert.equal(out.source, 'structured_output'); + assert.equal(out.result, 42); + }); +}); diff --git a/src/app/api/chat/interrupt/route.ts b/src/app/api/chat/interrupt/route.ts new file mode 100644 index 0000000..1ae748c --- /dev/null +++ b/src/app/api/chat/interrupt/route.ts @@ -0,0 +1,27 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getConversation } from '@/lib/conversation-registry'; + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; + +export async function POST(request: NextRequest) { + try { + const { sessionId } = await request.json(); + + if (!sessionId) { + return NextResponse.json({ error: 'sessionId is required' }, { status: 400 }); + } + + const conversation = getConversation(sessionId); + if (!conversation) { + return NextResponse.json({ interrupted: false }); + } + + await conversation.interrupt(); + + return NextResponse.json({ interrupted: true }); + } catch (error) { + console.error('[interrupt] Failed to interrupt:', error); + return NextResponse.json({ interrupted: false, error: String(error) }); + } +} diff --git a/src/app/api/chat/model/route.ts b/src/app/api/chat/model/route.ts new file mode 100644 index 0000000..cc27a1f --- /dev/null +++ b/src/app/api/chat/model/route.ts @@ -0,0 +1,27 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getConversation } from '@/lib/conversation-registry'; + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; + +export async function POST(request: NextRequest) { + try { + const { sessionId, model } = await request.json(); + + if (!sessionId || !model) { + return NextResponse.json({ error: 'sessionId and model are required' }, { status: 400 }); + } + + const conversation = getConversation(sessionId); + if (!conversation) { + return NextResponse.json({ applied: false }); + } + + await conversation.setModel(model); + + return NextResponse.json({ applied: true }); + } catch (error) { + console.error('[model] Failed to set model:', error); + return NextResponse.json({ applied: false, error: String(error) }); + } +} diff --git a/src/app/api/chat/rewind/route.ts b/src/app/api/chat/rewind/route.ts new file mode 100644 index 0000000..31ba0e5 --- /dev/null +++ b/src/app/api/chat/rewind/route.ts @@ -0,0 +1,27 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getConversation } from '@/lib/conversation-registry'; + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; + +export async function POST(request: NextRequest) { + try { + const { sessionId, userMessageId, dryRun } = await request.json(); + + if (!sessionId || !userMessageId) { + return NextResponse.json({ error: 'sessionId and userMessageId are required' }, { status: 400 }); + } + + const conversation = getConversation(sessionId); + if (!conversation) { + return NextResponse.json({ canRewind: false, error: 'No active conversation' }); + } + + const result = await conversation.rewindFiles(userMessageId, { dryRun: !!dryRun }); + + return NextResponse.json(result); + } catch (error) { + console.error('[rewind] Failed to rewind:', error); + return NextResponse.json({ canRewind: false, error: String(error) }); + } +} diff --git a/src/app/api/chat/route.ts b/src/app/api/chat/route.ts index 4e66bd3..74c56a6 100644 --- a/src/app/api/chat/route.ts +++ b/src/app/api/chat/route.ts @@ -2,21 +2,42 @@ import { NextRequest } from 'next/server'; import { streamClaude } from '@/lib/claude-client'; import { addMessage, getMessages, getSession, updateSessionTitle, updateSdkSessionId, updateSessionModel, updateSessionProvider, updateSessionProviderId, getSetting, getProvider, getDefaultProviderId, acquireSessionLock, renewSessionLock, releaseSessionLock, setSessionRuntimeStatus, syncSdkTasks } from '@/lib/db'; import { notifySessionStart, notifySessionComplete, notifySessionError } from '@/lib/telegram-bot'; -import type { SendMessageRequest, SSEEvent, TokenUsage, MessageContentBlock, FileAttachment } from '@/types'; +import type { SendMessageRequest, SSEEvent, TokenUsage, MessageContentBlock, FileAttachment, ClaudeStreamOptions } from '@/types'; import crypto from 'crypto'; import fs from 'fs'; import path from 'path'; +import os from 'os'; +import type { MCPServerConfig } from '@/types'; export const runtime = 'nodejs'; export const dynamic = 'force-dynamic'; +/** Read MCP server configs from ~/.claude.json and ~/.claude/settings.json */ +function loadMcpServers(): Record | undefined { + try { + const readJson = (p: string): Record => { + if (!fs.existsSync(p)) return {}; + try { return JSON.parse(fs.readFileSync(p, 'utf-8')); } catch { return {}; } + }; + const userConfig = readJson(path.join(os.homedir(), '.claude.json')); + const settings = readJson(path.join(os.homedir(), '.claude', 'settings.json')); + const merged = { + ...((userConfig.mcpServers || {}) as Record), + ...((settings.mcpServers || {}) as Record), + }; + return Object.keys(merged).length > 0 ? merged : undefined; + } catch { + return undefined; + } +} + export async function POST(request: NextRequest) { let activeSessionId: string | undefined; let activeLockId: string | undefined; try { - const body: SendMessageRequest & { files?: FileAttachment[]; toolTimeout?: number; provider_id?: string; systemPromptAppend?: string; autoTrigger?: boolean } = await request.json(); - const { session_id, content, model, mode, files, toolTimeout, provider_id, systemPromptAppend, autoTrigger } = body; + const body: SendMessageRequest & { files?: FileAttachment[]; toolTimeout?: number; provider_id?: string; systemPromptAppend?: string; autoTrigger?: boolean; thinking?: unknown; effort?: string; enableFileCheckpointing?: boolean } = await request.json(); + const { session_id, content, model, mode, files, toolTimeout, provider_id, systemPromptAppend, autoTrigger, thinking, effort, enableFileCheckpointing } = body; console.log('[chat API] content length:', content.length, 'first 200 chars:', content.slice(0, 200)); console.log('[chat API] systemPromptAppend:', systemPromptAppend ? `${systemPromptAppend.length} chars` : 'none'); @@ -292,6 +313,10 @@ Start by greeting the user and asking the first question. content: m.content, })); + // Load MCP servers from Claude config files so the SDK knows about them + // even when settingSources skips 'user' (custom provider scenario). + const mcpServers = loadMcpServers(); + // Stream Claude response, using SDK session ID for resume if available console.log('[chat API] streamClaude params:', { promptLength: content.length, @@ -313,8 +338,13 @@ Start by greeting the user and asking the first question. imageAgentMode: !!systemPromptAppend, toolTimeoutSeconds: toolTimeout || 300, provider: resolvedProvider, + mcpServers, conversationHistory: historyMsgs, bypassPermissions: session.permission_profile === 'full_access', + thinking: thinking as ClaudeStreamOptions['thinking'], + effort: effort as ClaudeStreamOptions['effort'], + enableFileCheckpointing: enableFileCheckpointing ?? (effectiveMode === 'code'), + autoTrigger: !!autoTrigger, onRuntimeStatusChange: (status: string) => { try { setSessionRuntimeStatus(session_id, status); } catch { /* best effort */ } }, diff --git a/src/app/api/chat/structured/route.ts b/src/app/api/chat/structured/route.ts new file mode 100644 index 0000000..b92894a --- /dev/null +++ b/src/app/api/chat/structured/route.ts @@ -0,0 +1,90 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { query } from '@anthropic-ai/claude-agent-sdk'; +import type { Options, SDKResultSuccess } from '@anthropic-ai/claude-agent-sdk'; + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; + +export async function POST(request: NextRequest) { + try { + const body = await request.json(); + const { prompt, outputFormat, options } = body; + + if (!prompt) { + return NextResponse.json({ error: 'prompt is required' }, { status: 400 }); + } + + // Validate outputFormat: must be { type: 'json_schema', schema: { ... } } + if ( + !outputFormat || + typeof outputFormat !== 'object' || + outputFormat.type !== 'json_schema' || + !outputFormat.schema || + typeof outputFormat.schema !== 'object' + ) { + return NextResponse.json( + { error: 'outputFormat must be { type: "json_schema", schema: { ... } }' }, + { status: 400 }, + ); + } + + const queryOptions: Partial = { + cwd: options?.cwd || process.cwd(), + model: options?.model, + outputFormat, + }; + + // Collect the result message which contains structured_output + let structuredOutput: unknown = undefined; + let resultText = ''; + + for await (const message of query({ + prompt, + options: queryOptions as Options, + })) { + if (message.type === 'result' && message.subtype === 'success') { + const successResult = message as SDKResultSuccess; + // Primary path: read structured_output directly from the SDK result + if (successResult.structured_output !== undefined) { + structuredOutput = successResult.structured_output; + } + // Also capture result text as fallback + if (successResult.result) { + resultText = successResult.result; + } + } else if (message.type === 'assistant') { + // Fallback: accumulate assistant text in case structured_output is absent + const msg = message.message as { content?: Array<{ type: string; text?: string }> } | string; + if (typeof msg === 'string') { + resultText += msg; + } else if (msg && Array.isArray(msg.content)) { + for (const block of msg.content) { + if (block.type === 'text' && block.text) { + resultText += block.text; + } + } + } + } + } + + // Prefer structured_output from the SDK result message + if (structuredOutput !== undefined) { + return NextResponse.json({ result: structuredOutput }); + } + + // Fallback: try to parse accumulated text as JSON + if (resultText) { + try { + const parsed = JSON.parse(resultText); + return NextResponse.json({ result: parsed }); + } catch { + return NextResponse.json({ result: resultText }); + } + } + + return NextResponse.json({ result: null }); + } catch (error) { + console.error('[structured] Structured query failed:', error); + return NextResponse.json({ error: String(error) }, { status: 500 }); + } +} diff --git a/src/app/api/plugins/mcp/reconnect/route.ts b/src/app/api/plugins/mcp/reconnect/route.ts new file mode 100644 index 0000000..44fbf13 --- /dev/null +++ b/src/app/api/plugins/mcp/reconnect/route.ts @@ -0,0 +1,27 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getConversation } from '@/lib/conversation-registry'; + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; + +export async function POST(request: NextRequest) { + try { + const { sessionId, serverName } = await request.json(); + + if (!sessionId || !serverName) { + return NextResponse.json({ error: 'sessionId and serverName are required' }, { status: 400 }); + } + + const conversation = getConversation(sessionId); + if (!conversation) { + return NextResponse.json({ success: false, error: 'No active conversation' }, { status: 404 }); + } + + await conversation.reconnectMcpServer(serverName); + + return NextResponse.json({ success: true }); + } catch (error) { + console.error('[mcp/reconnect] Failed to reconnect MCP server:', error); + return NextResponse.json({ success: false, error: String(error) }, { status: 500 }); + } +} diff --git a/src/app/api/plugins/mcp/status/route.ts b/src/app/api/plugins/mcp/status/route.ts new file mode 100644 index 0000000..a8d8792 --- /dev/null +++ b/src/app/api/plugins/mcp/status/route.ts @@ -0,0 +1,36 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { refreshMcpStatus, getCachedMcpStatus, getCapabilityCacheAge } from '@/lib/agent-sdk-capabilities'; +import { getSession } from '@/lib/db'; + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; + +export async function GET(request: NextRequest) { + try { + const sessionId = request.nextUrl.searchParams.get('sessionId'); + // Accept explicit providerId, or resolve from session's stored provider_id + let providerId = request.nextUrl.searchParams.get('providerId'); + + if (!providerId && sessionId) { + const session = getSession(sessionId); + providerId = session?.provider_id || 'env'; + } + + providerId = providerId || 'env'; + + let servers; + if (sessionId) { + servers = await refreshMcpStatus(sessionId, providerId); + } else { + servers = getCachedMcpStatus(providerId); + } + + const cacheAge = getCapabilityCacheAge(providerId); + const cachedAt = cacheAge === Infinity ? null : Date.now() - cacheAge; + + return NextResponse.json({ servers, cached_at: cachedAt }); + } catch (error) { + console.error('[mcp/status] Failed to get MCP status:', error); + return NextResponse.json({ servers: [], cached_at: null, error: String(error) }); + } +} diff --git a/src/app/api/plugins/mcp/toggle/route.ts b/src/app/api/plugins/mcp/toggle/route.ts new file mode 100644 index 0000000..3c7e035 --- /dev/null +++ b/src/app/api/plugins/mcp/toggle/route.ts @@ -0,0 +1,27 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getConversation } from '@/lib/conversation-registry'; + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; + +export async function POST(request: NextRequest) { + try { + const { sessionId, serverName, enabled } = await request.json(); + + if (!sessionId || !serverName || typeof enabled !== 'boolean') { + return NextResponse.json({ error: 'sessionId, serverName, and enabled (boolean) are required' }, { status: 400 }); + } + + const conversation = getConversation(sessionId); + if (!conversation) { + return NextResponse.json({ success: false, error: 'No active conversation' }, { status: 404 }); + } + + await conversation.toggleMcpServer(serverName, enabled); + + return NextResponse.json({ success: true }); + } catch (error) { + console.error('[mcp/toggle] Failed to toggle MCP server:', error); + return NextResponse.json({ success: false, error: String(error) }, { status: 500 }); + } +} diff --git a/src/app/api/providers/models/route.ts b/src/app/api/providers/models/route.ts index b411918..914dc54 100644 --- a/src/app/api/providers/models/route.ts +++ b/src/app/api/providers/models/route.ts @@ -96,6 +96,28 @@ export async function GET() { }), }); + // If SDK has discovered models, use them for the env group + try { + const { getCachedModels } = await import('@/lib/agent-sdk-capabilities'); + const sdkModels = getCachedModels('env'); + if (sdkModels.length > 0) { + groups[0].models = sdkModels.map(m => { + const cw = getContextWindow(m.value); + return { + value: m.value, + label: m.displayName, + description: m.description, + supportsEffort: m.supportsEffort, + supportedEffortLevels: m.supportedEffortLevels, + supportsAdaptiveThinking: m.supportsAdaptiveThinking, + ...(cw != null ? { contextWindow: cw } : {}), + }; + }); + } + } catch { + // SDK capabilities not available, keep defaults + } + // Provider types that are not LLMs (e.g. image generation) — skip in chat model selector const MEDIA_PROVIDER_TYPES = new Set(['gemini-image']); diff --git a/src/app/api/sdk/account/route.ts b/src/app/api/sdk/account/route.ts new file mode 100644 index 0000000..d4dc672 --- /dev/null +++ b/src/app/api/sdk/account/route.ts @@ -0,0 +1,19 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getCachedAccountInfo, getCapabilityCacheAge } from '@/lib/agent-sdk-capabilities'; + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; + +export async function GET(request: NextRequest) { + try { + const providerId = request.nextUrl.searchParams.get('providerId') || 'env'; + const account = getCachedAccountInfo(providerId); + const cacheAge = getCapabilityCacheAge(providerId); + const cachedAt = cacheAge === Infinity ? 0 : Date.now() - cacheAge; + + return NextResponse.json({ account, cached_at: cachedAt }); + } catch (error) { + console.error('[account] Failed to get account info:', error); + return NextResponse.json({ account: null, cached_at: 0, error: String(error) }); + } +} diff --git a/src/app/api/settings/app/route.ts b/src/app/api/settings/app/route.ts index 65aca03..363954c 100644 --- a/src/app/api/settings/app/route.ts +++ b/src/app/api/settings/app/route.ts @@ -11,6 +11,7 @@ const ALLOWED_KEYS = [ 'anthropic_base_url', 'dangerously_skip_permissions', 'locale', + 'thinking_mode', ]; export async function GET() { diff --git a/src/app/api/skills/route.ts b/src/app/api/skills/route.ts index a1e4011..73e1832 100644 --- a/src/app/api/skills/route.ts +++ b/src/app/api/skills/route.ts @@ -320,6 +320,29 @@ export async function GET(request: NextRequest) { const all = [...globalSkills, ...projectSkills, ...dedupedProjectSkills, ...installedSkills, ...pluginSkills]; console.log(`[skills] Found: global=${globalSkills.length}, project=${projectSkills.length}, projectSkills=${dedupedProjectSkills.length}, installed=${installedSkills.length}, plugin=${pluginSkills.length}`); + // Merge SDK slash commands if available + try { + const { getCachedCommands } = await import('@/lib/agent-sdk-capabilities'); + const sdkCommands = getCachedCommands('env'); + if (sdkCommands.length > 0) { + const existingNames = new Set(all.map(s => s.name)); + for (const cmd of sdkCommands) { + if (!existingNames.has(cmd.name)) { + all.push({ + name: cmd.name, + description: cmd.description || `SDK command: /${cmd.name}`, + content: '', // SDK commands don't have local content + source: 'sdk' as typeof all[number]['source'], + filePath: '', + }); + } + } + console.log(`[skills] Added ${sdkCommands.length} SDK commands (${sdkCommands.filter(c => !existingNames.has(c.name)).length} unique)`); + } + } catch { + // SDK capabilities not available, skip + } + return NextResponse.json({ skills: all }); } catch (error) { console.error('[skills] Error:', error); diff --git a/src/app/chat/page.tsx b/src/app/chat/page.tsx index 2982a7a..e2d6feb 100644 --- a/src/app/chat/page.tsx +++ b/src/app/chat/page.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useState, useCallback, useRef } from 'react'; +import { useState, useCallback, useRef, useEffect } from 'react'; import { useRouter } from 'next/navigation'; import type { Message, SSEEvent, SessionResponse, TokenUsage, PermissionRequestEvent } from '@/types'; import { MessageList } from '@/components/chat/MessageList'; @@ -40,6 +40,22 @@ export default function NewChatPage() { const [streamingToolOutput, setStreamingToolOutput] = useState(''); const [permissionProfile, setPermissionProfile] = useState<'default' | 'full_access'>('default'); const abortControllerRef = useRef(null); + // Effort level — lifted here so the first message includes it + const [selectedEffort, setSelectedEffort] = useState(undefined); + // Thinking mode from app settings + const [thinkingMode, setThinkingMode] = useState('adaptive'); + + // Fetch thinking mode from app settings + useEffect(() => { + fetch('/api/settings/app') + .then(r => r.ok ? r.json() : null) + .then(data => { + if (data?.settings?.thinking_mode) { + setThinkingMode(data.settings.thinking_mode); + } + }) + .catch(() => {}); + }, []); const stopStreaming = useCallback(() => { abortControllerRef.current?.abort(); @@ -147,11 +163,25 @@ export default function NewChatPage() { }; setMessages([userMessage]); + // Build thinking config from settings + const thinkingConfig = thinkingMode && thinkingMode !== 'adaptive' + ? { type: thinkingMode } + : thinkingMode === 'adaptive' ? { type: 'adaptive' } : undefined; + // Send the message via streaming API const response = await fetch('/api/chat', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ session_id: session.id, content, mode, model: currentModel, provider_id: currentProviderId, ...(systemPromptAppend ? { systemPromptAppend } : {}) }), + body: JSON.stringify({ + session_id: session.id, + content, + mode, + model: currentModel, + provider_id: currentProviderId, + ...(systemPromptAppend ? { systemPromptAppend } : {}), + ...(selectedEffort ? { effort: selectedEffort } : {}), + ...(thinkingConfig ? { thinking: thinkingConfig } : {}), + }), signal: controller.signal, }); @@ -318,7 +348,7 @@ export default function NewChatPage() { abortControllerRef.current = null; } }, - [isStreaming, router, workingDir, mode, currentModel, currentProviderId, permissionProfile, setPendingApprovalSessionId] + [isStreaming, router, workingDir, mode, currentModel, currentProviderId, permissionProfile, selectedEffort, thinkingMode, setPendingApprovalSessionId] ); const handleCommand = useCallback((command: string) => { @@ -388,6 +418,8 @@ export default function NewChatPage() { workingDirectory={workingDir} mode={mode} onModeChange={setMode} + effort={selectedEffort} + onEffortChange={setSelectedEffort} /> } diff --git a/src/components/chat/ChatView.tsx b/src/components/chat/ChatView.tsx index 652ad8c..d13a2ee 100644 --- a/src/components/chat/ChatView.tsx +++ b/src/components/chat/ChatView.tsx @@ -19,6 +19,7 @@ import { stopStream, subscribe, getSnapshot, + getRewindPoints, respondToPermission, clearSnapshot, } from '@/lib/stream-session-manager'; @@ -48,6 +49,10 @@ export function ChatView({ sessionId, initialMessages = [], initialHasMore = fal const [mode, setMode] = useState(initialMode || 'code'); const [currentModel, setCurrentModel] = useState(modelName || (typeof window !== 'undefined' ? localStorage.getItem('codepilot:last-model') : null) || 'sonnet'); const [currentProviderId, setCurrentProviderId] = useState(providerId || (typeof window !== 'undefined' ? localStorage.getItem('codepilot:last-provider-id') : null) || ''); + // Effort level selected in MessageInput — lifted here so it enters the stream chain + const [selectedEffort, setSelectedEffort] = useState(undefined); + // Thinking mode from app settings + const [thinkingMode, setThinkingMode] = useState('adaptive'); // Sync model/provider when session data loads (props update after async fetch) // Unconditional: when modelName is empty (old session with no saved model), @@ -58,6 +63,18 @@ export function ChatView({ sessionId, initialMessages = [], initialHasMore = fal useEffect(() => { setCurrentProviderId(providerId || (typeof window !== 'undefined' ? localStorage.getItem('codepilot:last-provider-id') : null) || ''); }, [providerId]); + + // Fetch thinking mode from app settings + useEffect(() => { + fetch('/api/settings/app') + .then(r => r.ok ? r.json() : null) + .then(data => { + if (data?.settings?.thinking_mode) { + setThinkingMode(data.settings.thinking_mode); + } + }) + .catch(() => {}); + }, []); useEffect(() => { if (initialPermissionProfile) { setPermissionProfile(initialPermissionProfile); @@ -78,6 +95,7 @@ export function ChatView({ sessionId, initialMessages = [], initialHasMore = fal const statusText = streamSnapshot?.statusText; const pendingPermission = streamSnapshot?.pendingPermission ?? null; const permissionResolved = streamSnapshot?.permissionResolved ?? null; + const rewindPoints = getRewindPoints(sessionId); // Pending image generation notices — flushed into the next user message so the LLM knows about generated images const pendingImageNoticesRef = useRef([]); @@ -290,6 +308,7 @@ export function ChatView({ sessionId, initialMessages = [], initialHasMore = fal model: currentModel, providerId: currentProviderId, autoTrigger: true, + thinking: buildThinkingConfig(), onModeChanged: (sdkMode) => { const uiMode = sdkMode === 'plan' ? 'plan' : 'code'; handleModeChange(uiMode); @@ -468,6 +487,14 @@ export function ChatView({ sessionId, initialMessages = [], initialHasMore = fal } }, [sessionId, workingDirectory]); + // Build SDK thinking config from settings + const buildThinkingConfig = useCallback((): { type: string } | undefined => { + if (!thinkingMode || thinkingMode === 'adaptive') return { type: 'adaptive' }; + if (thinkingMode === 'enabled') return { type: 'enabled' }; + if (thinkingMode === 'disabled') return { type: 'disabled' }; + return undefined; + }, [thinkingMode]); + // Send message — delegates stream management to the manager const sendMessage = useCallback( async (content: string, files?: FileAttachment[], systemPromptAppend?: string, displayOverride?: string) => { @@ -512,6 +539,8 @@ export function ChatView({ sessionId, initialMessages = [], initialHasMore = fal files, systemPromptAppend, pendingImageNotices: notices, + effort: selectedEffort, + thinking: buildThinkingConfig(), onModeChanged: (sdkMode) => { const uiMode = sdkMode === 'plan' ? 'plan' : 'code'; handleModeChange(uiMode); @@ -521,7 +550,7 @@ export function ChatView({ sessionId, initialMessages = [], initialHasMore = fal }, }); }, - [sessionId, isStreaming, mode, currentModel, currentProviderId, handleModeChange] + [sessionId, isStreaming, mode, currentModel, currentProviderId, selectedEffort, buildThinkingConfig, handleModeChange] ); // Keep sendMessageRef in sync so timeout auto-retry can call it @@ -663,6 +692,8 @@ export function ChatView({ sessionId, initialMessages = [], initialHasMore = fal hasMore={hasMore} loadingMore={loadingMore} onLoadMore={loadEarlierMessages} + rewindPoints={rewindPoints} + sessionId={sessionId} /> {/* Permission prompt — rendered outside MessageList so it's always visible at bottom */} } diff --git a/src/components/chat/MessageInput.tsx b/src/components/chat/MessageInput.tsx index 377f03c..2497738 100644 --- a/src/components/chat/MessageInput.tsx +++ b/src/components/chat/MessageInput.tsx @@ -98,6 +98,9 @@ interface MessageInputProps { mode?: string; onModeChange?: (mode: string) => void; onAssistantTrigger?: () => void; + /** Effort selection lifted to parent for inclusion in the stream chain */ + effort?: string; + onEffortChange?: (effort: string | undefined) => void; } interface PopoverItem { @@ -375,6 +378,8 @@ export function MessageInput({ mode = 'code', onModeChange, onAssistantTrigger, + effort: effortProp, + onEffortChange, }: MessageInputProps) { const { t } = useTranslation(); const imageGen = useImageGen(); @@ -932,6 +937,31 @@ export function MessageInput({ const currentModelValue = modelName || 'sonnet'; const currentModelOption = MODEL_OPTIONS.find((m) => m.value === currentModelValue) || MODEL_OPTIONS[0]; + // Effort selector state — only shown when the current model supports effort + const currentModelMeta = currentModelOption as typeof currentModelOption & { supportsEffort?: boolean; supportedEffortLevels?: string[] }; + const showEffortSelector = currentModelMeta.supportsEffort === true; + // Use prop if provided (lifted state), otherwise local state + const [localEffort, setLocalEffort] = useState('high'); + const selectedEffort = effortProp ?? localEffort; + const setSelectedEffort = useCallback((v: string) => { + setLocalEffort(v); + onEffortChange?.(v); + }, [onEffortChange]); + const [effortMenuOpen, setEffortMenuOpen] = useState(false); + const effortMenuRef = useRef(null); + + // Close effort menu on outside click + useEffect(() => { + if (!effortMenuOpen) return; + const handler = (e: MouseEvent) => { + if (effortMenuRef.current && !effortMenuRef.current.contains(e.target as Node)) { + setEffortMenuOpen(false); + } + }; + document.addEventListener('mousedown', handler); + return () => document.removeEventListener('mousedown', handler); + }, [effortMenuOpen]); + // Map isStreaming to ChatStatus for PromptInputSubmit const chatStatus: ChatStatus = isStreaming ? 'streaming' : 'ready'; @@ -1191,6 +1221,44 @@ export function MessageInput({ )} + {/* Effort selector — only visible when model supports effort */} + {showEffortSelector && ( +
+ setEffortMenuOpen((prev) => !prev)} + > + {t(`messageInput.effort.${selectedEffort}` as TranslationKey)} + + + + {effortMenuOpen && ( +
+
+ {t('messageInput.effort.label' as TranslationKey)} +
+
+ {(currentModelMeta.supportedEffortLevels || ['low', 'medium', 'high', 'max']).map((level) => ( + + ))} +
+
+ )} +
+ )} + ('idle'); + const [preview, setPreview] = useState<{ filesChanged?: string[]; insertions?: number; deletions?: number } | null>(null); + + const handleDryRun = useCallback(async () => { + setState('loading'); + try { + const res = await fetch('/api/chat/rewind', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ sessionId, userMessageId, dryRun: true }), + }); + const data = await res.json(); + if (data.canRewind) { + setPreview(data); + setState('preview'); + } else { + setState('idle'); + } + } catch { + setState('idle'); + } + }, [sessionId, userMessageId]); + + const handleRewind = useCallback(async () => { + setState('loading'); + try { + const res = await fetch('/api/chat/rewind', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ sessionId, userMessageId }), + }); + const data = await res.json(); + if (data.canRewind !== false) { + setState('done'); + setTimeout(() => setState('idle'), 3000); + } else { + setState('idle'); + } + } catch { + setState('idle'); + } + }, [sessionId, userMessageId]); + + if (state === 'done') { + return ( + + {t('messageList.rewindDone' as TranslationKey)} + + ); + } + + if (state === 'preview' && preview) { + return ( + + + {preview.filesChanged?.length || 0} files, +{preview.insertions || 0}/-{preview.deletions || 0} + + + + + ); + } + + return ( + + ); +} + interface ToolUseInfo { id: string; name: string; @@ -53,6 +143,11 @@ interface ToolResultInfo { is_error?: boolean; } +/** Rewind points contain SDK UUIDs (not local message IDs) */ +interface RewindPoint { + userMessageId: string; // SDK UUID +} + interface MessageListProps { messages: Message[]; streamingContent: string; @@ -65,6 +160,9 @@ interface MessageListProps { hasMore?: boolean; loadingMore?: boolean; onLoadMore?: () => void; + /** SDK rewind points — only emitted for visible prompt-level user messages (not tool results or auto-triggers), mapped by position */ + rewindPoints?: RewindPoint[]; + sessionId?: string; } export function MessageList({ @@ -79,6 +177,8 @@ export function MessageList({ hasMore, loadingMore, onLoadMore, + rewindPoints = [], + sessionId, }: MessageListProps) { const { t } = useTranslation(); // Scroll anchor: preserve position when older messages are prepended @@ -132,11 +232,28 @@ export function MessageList({ )} - {messages.map((message) => ( -
- -
- ))} + {messages.map((message) => { + // Map rewind points to visible user messages by position: + // Backend only emits rewind_point for prompt-level user messages + // (not tool results, not auto-trigger), so they're 1:1 with visible user messages. + let rewindSdkUuid: string | undefined; + if (message.role === 'user' && sessionId && rewindPoints.length > 0) { + const userMsgsBefore = messages.filter(m => m.role === 'user'); + const userIndex = userMsgsBefore.indexOf(message); + if (userIndex >= 0 && userIndex < rewindPoints.length) { + rewindSdkUuid = rewindPoints[userIndex].userMessageId; + } + } + + return ( +
+ + {rewindSdkUuid && sessionId && !isStreaming && ( + + )} +
+ ); + })} {isStreaming && ( >({}); @@ -20,6 +28,9 @@ export function McpManager() { const [editingServer, setEditingServer] = useState(); const [tab, setTab] = useState<"list" | "json">("list"); const [error, setError] = useState(null); + const [runtimeStatus, setRuntimeStatus] = useState([]); + const [runtimeLoading, setRuntimeLoading] = useState(false); + const [activeSessionId, setActiveSessionId] = useState(null); const fetchServers = useCallback(async () => { try { @@ -39,9 +50,37 @@ export function McpManager() { } }, []); + const fetchRuntimeStatus = useCallback(async () => { + setRuntimeLoading(true); + try { + // Try to get active session from stream manager + const sessionsRes = await fetch('/api/chat/sessions?status=active&limit=1'); + const sessionsData = await sessionsRes.json(); + const sessionId = sessionsData?.sessions?.[0]?.id; + + if (!sessionId) { + setActiveSessionId(null); + setRuntimeStatus([]); + return; + } + + setActiveSessionId(sessionId); + const res = await fetch(`/api/plugins/mcp/status?sessionId=${encodeURIComponent(sessionId)}`); + const data = await res.json(); + if (data.servers) { + setRuntimeStatus(data.servers); + } + } catch { + // Runtime status unavailable + } finally { + setRuntimeLoading(false); + } + }, []); + useEffect(() => { fetchServers(); - }, [fetchServers]); + fetchRuntimeStatus(); + }, [fetchServers, fetchRuntimeStatus]); function handleEdit(name: string, server: MCPServer) { setEditingName(name); @@ -188,6 +227,8 @@ export function McpManager() { servers={servers} onEdit={handleEdit} onDelete={handleDelete} + runtimeStatus={runtimeStatus} + activeSessionId={activeSessionId || undefined} /> )} @@ -201,6 +242,56 @@ export function McpManager() { + {/* Runtime Status Section */} +
+
+
+ +

{t('mcp.runtimeStatus' as TranslationKey)}

+
+ +
+ + {!activeSessionId ? ( +

+ {t('mcp.noActiveSession' as TranslationKey)} +

+ ) : runtimeStatus.length === 0 ? ( +

+ {t('mcp.noRuntimeStatus' as TranslationKey)} +

+ ) : ( +
+ {runtimeStatus.map((s) => ( +
+
+ + {s.name} +
+ + {s.status} + +
+ ))} +
+ )} +
+ ; onEdit: (name: string, server: MCPServer) => void; onDelete: (name: string) => void; + runtimeStatus?: McpRuntimeStatus[]; + activeSessionId?: string; } function getServerTypeInfo(server: MCPServer) { @@ -27,9 +36,68 @@ function getServerTypeInfo(server: MCPServer) { } } -export function McpServerList({ servers, onEdit, onDelete }: McpServerListProps) { +function getStatusBadge(status: McpRuntimeStatus['status']) { + switch (status) { + case 'connected': + return { label: 'Connected', className: 'bg-green-500/10 text-green-600 border-green-500/20' }; + case 'failed': + return { label: 'Failed', className: 'bg-red-500/10 text-red-600 border-red-500/20' }; + case 'needs-auth': + return { label: 'Auth Required', className: 'bg-yellow-500/10 text-yellow-600 border-yellow-500/20' }; + case 'pending': + return { label: 'Pending', className: 'bg-blue-500/10 text-blue-600 border-blue-500/20' }; + case 'disabled': + return { label: 'Disabled', className: 'bg-gray-500/10 text-gray-500 border-gray-500/20' }; + default: + return { label: status, className: '' }; + } +} + +export function McpServerList({ servers, onEdit, onDelete, runtimeStatus, activeSessionId }: McpServerListProps) { const { t } = useTranslation(); const entries = Object.entries(servers); + const [reconnecting, setReconnecting] = useState>(new Set()); + const [toggling, setToggling] = useState>(new Set()); + + const handleReconnect = useCallback(async (serverName: string) => { + if (!activeSessionId) return; + setReconnecting(prev => new Set(prev).add(serverName)); + try { + await fetch('/api/plugins/mcp/reconnect', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ sessionId: activeSessionId, serverName }), + }); + } catch { + // Best effort + } finally { + setReconnecting(prev => { + const next = new Set(prev); + next.delete(serverName); + return next; + }); + } + }, [activeSessionId]); + + const handleToggle = useCallback(async (serverName: string, enabled: boolean) => { + if (!activeSessionId) return; + setToggling(prev => new Set(prev).add(serverName)); + try { + await fetch('/api/plugins/mcp/toggle', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ sessionId: activeSessionId, serverName, enabled }), + }); + } catch { + // Best effort + } finally { + setToggling(prev => { + const next = new Set(prev); + next.delete(serverName); + return next; + }); + } + }, [activeSessionId]); if (entries.length === 0) { return ( @@ -43,10 +111,23 @@ export function McpServerList({ servers, onEdit, onDelete }: McpServerListProps) ); } + // Build a lookup for runtime status by server name + const statusByName = new Map(); + if (runtimeStatus) { + for (const s of runtimeStatus) { + statusByName.set(s.name, s); + } + } + return (
{entries.map(([name, server]) => { const typeInfo = getServerTypeInfo(server); + const runtime = statusByName.get(name); + const statusBadge = runtime ? getStatusBadge(runtime.status) : null; + const isReconnecting = reconnecting.has(name); + const isToggling = toggling.has(name); + return ( @@ -57,17 +138,60 @@ export function McpServerList({ servers, onEdit, onDelete }: McpServerListProps) {typeInfo.label} - - {t('provider.configured')} - + {statusBadge ? ( + + {statusBadge.label} + + ) : ( + + {t('provider.configured')} + + )}
{server.url ? server.url : `${server.command} ${server.args?.join(' ') || ''}`} + {runtime?.serverInfo && ( +

+ {runtime.serverInfo.name} v{runtime.serverInfo.version} +

+ )}
+ {/* Reconnect button — only for failed servers */} + {runtime?.status === 'failed' && activeSessionId && ( + + )} + {/* Enable button — only for disabled servers */} + {runtime?.status === 'disabled' && activeSessionId && ( + + )}
+ {/* Thinking mode */} +
+
+
+

{t('settings.thinkingMode' as TranslationKey)}

+

{t('settings.thinkingModeDesc' as TranslationKey)}

+
+ +
+
+ + {/* Account info */} + {accountInfo && ( +
+

{t('settings.accountInfo' as TranslationKey)}

+
+ {accountInfo.email && ( +

+ {t('settings.email' as TranslationKey)}: {accountInfo.email} +

+ )} + {accountInfo.organization && ( +

+ {t('settings.organization' as TranslationKey)}: {accountInfo.organization} +

+ )} + {accountInfo.subscriptionType && ( +

+ {t('settings.subscription' as TranslationKey)}: {accountInfo.subscriptionType} +

+ )} +
+
+ )} + {/* Skip-permissions warning dialog */} diff --git a/src/hooks/useSSEStream.ts b/src/hooks/useSSEStream.ts index d313073..7c42af8 100644 --- a/src/hooks/useSSEStream.ts +++ b/src/hooks/useSSEStream.ts @@ -24,6 +24,7 @@ export interface SSECallbacks { onToolTimeout: (toolName: string, elapsedSeconds: number) => void; onModeChanged: (mode: string) => void; onTaskUpdate: (sessionId: string) => void; + onRewindPoint: (sdkUserMessageId: string) => void; onKeepAlive: () => void; onError: (accumulated: string) => void; } @@ -89,7 +90,7 @@ function handleSSEEvent( try { const statusData = JSON.parse(event.data); if (statusData.session_id) { - callbacks.onStatus(`Connected (${statusData.model || 'claude'})`); + callbacks.onStatus(`Connected (${statusData.requested_model || statusData.model || 'claude'})`); } else if (statusData.notification) { callbacks.onStatus(statusData.message || statusData.title || undefined); } else { @@ -147,6 +148,18 @@ function handleSSEEvent( return accumulated; } + case 'rewind_point': { + try { + const rpData = JSON.parse(event.data); + if (rpData.userMessageId) { + callbacks.onRewindPoint(rpData.userMessageId); + } + } catch { + // skip malformed rewind_point data + } + return accumulated; + } + case 'keep_alive': { callbacks.onKeepAlive(); return accumulated; @@ -238,6 +251,7 @@ export function useSSEStream() { onToolTimeout: (n, s) => callbacksRef.current?.onToolTimeout(n, s), onModeChanged: (m) => callbacksRef.current?.onModeChanged(m), onTaskUpdate: (s) => callbacksRef.current?.onTaskUpdate(s), + onRewindPoint: (id) => callbacksRef.current?.onRewindPoint(id), onKeepAlive: () => callbacksRef.current?.onKeepAlive(), onError: (a) => callbacksRef.current?.onError(a), }; diff --git a/src/i18n/en.ts b/src/i18n/en.ts index e3f856f..78399c2 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -766,6 +766,40 @@ const en = { 'context.estimate': 'Estimated from the most recent response', 'context.noData': 'No context usage data yet', 'context.unknown': 'Unknown', + + // ── SDK Capabilities: Effort ────────────────────────────── + 'messageInput.effort.label': 'Effort Level', + 'messageInput.effort.low': 'Low', + 'messageInput.effort.medium': 'Medium', + 'messageInput.effort.high': 'High', + 'messageInput.effort.max': 'Max', + + // ── SDK Capabilities: Rewind ────────────────────────────── + 'messageList.rewindToHere': 'Rewind to here', + 'messageList.rewindConfirm': 'Confirm rewind', + 'messageList.rewindCancel': 'Cancel', + 'messageList.rewindDone': 'Files rewound successfully', + + // ── SDK Capabilities: MCP Runtime ───────────────────────── + 'mcp.runtimeStatus': 'Runtime Status', + 'mcp.refresh': 'Refresh', + 'mcp.noActiveSession': 'Start a conversation to see live status', + 'mcp.noRuntimeStatus': 'No runtime status available', + 'mcp.reconnect': 'Reconnect', + 'mcp.enable': 'Enable', + + // ── SDK Capabilities: Thinking ──────────────────────────── + 'settings.thinkingMode': 'Thinking Mode', + 'settings.thinkingModeDesc': 'Control Claude\'s extended thinking behavior', + 'settings.thinkingAdaptive': 'Adaptive', + 'settings.thinkingEnabled': 'Enabled', + 'settings.thinkingDisabled': 'Disabled', + + // ── SDK Capabilities: Account ───────────────────────────── + 'settings.accountInfo': 'Account Information', + 'settings.email': 'Email', + 'settings.organization': 'Organization', + 'settings.subscription': 'Subscription', } as const; export type TranslationKey = keyof typeof en; diff --git a/src/i18n/zh.ts b/src/i18n/zh.ts index c2d17d0..0a51824 100644 --- a/src/i18n/zh.ts +++ b/src/i18n/zh.ts @@ -763,6 +763,40 @@ const zh: Record = { 'context.estimate': '基于最近一轮响应估算', 'context.noData': '暂无上下文统计', 'context.unknown': '未知上下文窗口', + + // ── SDK Capabilities: Effort ────────────────────────────── + 'messageInput.effort.label': '推理力度', + 'messageInput.effort.low': '低', + 'messageInput.effort.medium': '中', + 'messageInput.effort.high': '高', + 'messageInput.effort.max': '最大', + + // ── SDK Capabilities: Rewind ────────────────────────────── + 'messageList.rewindToHere': '回滚到此处', + 'messageList.rewindConfirm': '确认回滚', + 'messageList.rewindCancel': '取消', + 'messageList.rewindDone': '文件回滚成功', + + // ── SDK Capabilities: MCP Runtime ───────────────────────── + 'mcp.runtimeStatus': '运行状态', + 'mcp.refresh': '刷新', + 'mcp.noActiveSession': '开始对话后可查看实时状态', + 'mcp.noRuntimeStatus': '暂无运行状态信息', + 'mcp.reconnect': '重连', + 'mcp.enable': '启用', + + // ── SDK Capabilities: Thinking ──────────────────────────── + 'settings.thinkingMode': '思考模式', + 'settings.thinkingModeDesc': '控制 Claude 的扩展思考行为', + 'settings.thinkingAdaptive': '自适应', + 'settings.thinkingEnabled': '启用', + 'settings.thinkingDisabled': '禁用', + + // ── SDK Capabilities: Account ───────────────────────────── + 'settings.accountInfo': '账户信息', + 'settings.email': '邮箱', + 'settings.organization': '组织', + 'settings.subscription': '订阅类型', }; export default zh; diff --git a/src/lib/agent-sdk-agents.ts b/src/lib/agent-sdk-agents.ts new file mode 100644 index 0000000..b00b1bf --- /dev/null +++ b/src/lib/agent-sdk-agents.ts @@ -0,0 +1,58 @@ +/** + * Agent SDK Agents Registry — manages built-in agent definitions + * that can be injected into SDK query options. + * + * Existing "Image Agent" and other special agents in CodePilot are + * handled via system prompt injection, not SDK agents. This registry + * is for future SDK-native agent integration. + */ + +import type { AgentDefinition } from '@anthropic-ai/claude-agent-sdk'; + +const GLOBAL_KEY = '__agentSdkAgents__' as const; + +function getRegistry(): Map { + if (!(globalThis as Record)[GLOBAL_KEY]) { + (globalThis as Record)[GLOBAL_KEY] = new Map(); + } + return (globalThis as Record)[GLOBAL_KEY] as Map; +} + +/** + * Register a built-in agent definition. + */ +export function registerAgent(name: string, definition: AgentDefinition): void { + getRegistry().set(name, definition); +} + +/** + * Unregister a built-in agent. + */ +export function unregisterAgent(name: string): void { + getRegistry().delete(name); +} + +/** + * Get all registered agent definitions as a record suitable for SDK Options.agents. + */ +export function getRegisteredAgents(): Record { + const result: Record = {}; + for (const [name, def] of getRegistry()) { + result[name] = def; + } + return result; +} + +/** + * Get a specific registered agent. + */ +export function getAgent(name: string): AgentDefinition | undefined { + return getRegistry().get(name); +} + +/** + * Check if any agents are registered. + */ +export function hasRegisteredAgents(): boolean { + return getRegistry().size > 0; +} diff --git a/src/lib/agent-sdk-capabilities.ts b/src/lib/agent-sdk-capabilities.ts new file mode 100644 index 0000000..f8ab781 --- /dev/null +++ b/src/lib/agent-sdk-capabilities.ts @@ -0,0 +1,176 @@ +/** + * 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 { + if (!(globalThis as Record)[GLOBAL_KEY]) { + (globalThis as Record)[GLOBAL_KEY] = new Map(); + } + return (globalThis as Record)[GLOBAL_KEY] as Map; +} + +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 { + 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 { + 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; + } +} diff --git a/src/lib/claude-client.ts b/src/lib/claude-client.ts index c81ca69..87e922a 100644 --- a/src/lib/claude-client.ts +++ b/src/lib/claude-client.ts @@ -19,6 +19,7 @@ import type { ClaudeStreamOptions, SSEEvent, TokenUsage, MCPServerConfig, Permis import { isImageFile } from '@/types'; import { registerPendingPermission } from './permission-registry'; import { registerConversation, unregisterConversation } from './conversation-registry'; +import { captureCapabilities } from './agent-sdk-capabilities'; import { getSetting, getActiveProvider, updateSdkSessionId, createPermissionRequest } from './db'; import { findClaudeBinary, findGitBash, getExpandedPath } from './platform'; import { notifyPermissionRequest, notifyGeneric } from './telegram-bot'; @@ -281,6 +282,13 @@ export function streamClaude(options: ClaudeStreamOptions): ReadableStream({ @@ -430,6 +438,26 @@ export function streamClaude(options: ClaudeStreamOptions): ReadableStream { + console.warn('[claude-client] Capability capture failed:', err); + }); + let lastAssistantText = ''; let tokenUsage: TokenUsage | null = null; @@ -792,6 +827,20 @@ export function streamClaude(options: ClaudeStreamOptions): ReadableStream void) | null; + rewindPoints: Array<{ userMessageId: string }>; } export interface StartStreamParams { @@ -59,6 +60,10 @@ export interface StartStreamParams { onModeChanged?: (mode: string) => void; /** Reference to the outer sendMessage so tool-timeout auto-retry works */ sendMessageFn?: (content: string, files?: FileAttachment[]) => void; + /** SDK effort level (low/medium/high/max) — only sent when model supports it */ + effort?: string; + /** SDK thinking config */ + thinking?: { type: string; budgetTokens?: number }; } // ========================================== @@ -187,6 +192,7 @@ export function startStream(params: StartStreamParams): void { toolTimeoutInfo: null, isIdleTimeout: false, sendMessageFn: params.sendMessageFn ?? null, + rewindPoints: [], }; map.set(params.sessionId, stream); @@ -228,6 +234,8 @@ async function runStream(stream: ActiveStream, params: StartStreamParams): Promi ...(params.files && params.files.length > 0 ? { files: params.files } : {}), ...(params.systemPromptAppend ? { systemPromptAppend: params.systemPromptAppend } : {}), ...(params.autoTrigger ? { autoTrigger: true } : {}), + ...(params.effort ? { effort: params.effort } : {}), + ...(params.thinking ? { thinking: params.thinking } : {}), }), signal: stream.abortController.signal, }); @@ -324,6 +332,10 @@ async function runStream(stream: ActiveStream, params: StartStreamParams): Promi markActive(); window.dispatchEvent(new CustomEvent('tasks-updated')); }, + onRewindPoint: (sdkUserMessageId) => { + markActive(); + stream.rewindPoints = [...stream.rewindPoints, { userMessageId: sdkUserMessageId }]; + }, onKeepAlive: () => { markActive(); }, @@ -497,7 +509,21 @@ async function runStream(stream: ActiveStream, params: StartStreamParams): Promi export function stopStream(sessionId: string): void { const stream = getStreamsMap().get(sessionId); if (stream && stream.snapshot.phase === 'active') { - stream.abortController.abort(); + // Try graceful interrupt first, fallback to abort + fetch('/api/chat/interrupt', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ sessionId }), + }).catch(() => { + // Interrupt failed, force abort + }).finally(() => { + // Always abort after a short delay to ensure cleanup + setTimeout(() => { + if (stream.snapshot.phase === 'active') { + stream.abortController.abort(); + } + }, 2000); + }); } } @@ -539,6 +565,11 @@ export function isStreamActive(sessionId: string): boolean { return stream?.snapshot.phase === 'active' || false; } +export function getRewindPoints(sessionId: string): Array<{ userMessageId: string }> { + const stream = getStreamsMap().get(sessionId); + return stream?.rewindPoints ?? []; +} + export function getActiveSessionIds(): string[] { const ids: string[] = []; for (const [id, stream] of getStreamsMap()) { diff --git a/src/types/index.ts b/src/types/index.ts index 3dd4990..511b81c 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -123,7 +123,15 @@ export interface ProviderModelGroup { provider_id: string; // provider DB id, or 'env' for environment variables provider_name: string; provider_type: string; - models: Array<{ value: string; label: string; contextWindow?: number }>; + models: Array<{ + value: string; + label: string; + contextWindow?: number; + description?: string; + supportsEffort?: boolean; + supportedEffortLevels?: string[]; + supportsAdaptiveThinking?: boolean; + }>; } export interface CreateProviderRequest { @@ -359,6 +367,7 @@ export type SSEEventType = | 'mode_changed' // SDK permission mode changed (e.g. plan → code) | 'task_update' // SDK TodoWrite task sync | 'keep_alive' // SDK keep-alive heartbeat (resets idle timer) + | 'rewind_point' // SDK user message with rewind checkpoint | 'done'; // stream complete export interface SSEEvent { @@ -817,4 +826,18 @@ export interface ClaudeStreamOptions { onRuntimeStatusChange?: (status: string) => void; /** Per-session bypass: when true, skip all permission checks for this session */ bypassPermissions?: boolean; + /** Thinking configuration for the query */ + thinking?: { type: 'adaptive' } | { type: 'enabled'; budgetTokens?: number } | { type: 'disabled' }; + /** Effort level for the query */ + effort?: 'low' | 'medium' | 'high' | 'max'; + /** Output format for structured responses */ + outputFormat?: { type: 'json_schema'; schema: Record }; + /** Custom agent definitions */ + agents?: Record; + /** Agent name for the main thread */ + agent?: string; + /** Enable file checkpointing for rewind support */ + enableFileCheckpointing?: boolean; + /** When true, this is an auto-trigger turn (invisible to user) — skip rewind point emission */ + autoTrigger?: boolean; }