Skip to content

Commit c18776f

Browse files
7418claude
andcommitted
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>
1 parent 6c02c13 commit c18776f

File tree

30 files changed

+1487
-28
lines changed

30 files changed

+1487
-28
lines changed

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "codepilot",
3-
"version": "0.27.1",
3+
"version": "0.28.0",
44
"private": true,
55
"author": {
66
"name": "op7418",
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
/**
2+
* Unit tests for structured output route logic.
3+
*
4+
* Tests verify that:
5+
* 1. structured_output from SDKResultSuccess is preferred over text fallback
6+
* 2. Text fallback is used when structured_output is absent
7+
* 3. Raw text is returned when JSON.parse fails on fallback
8+
*/
9+
10+
import { describe, it } from 'node:test';
11+
import assert from 'node:assert/strict';
12+
13+
// Simulate the extraction logic from the structured route
14+
// (We can't import Next.js route handlers directly in node:test,
15+
// so we test the core logic inline.)
16+
17+
function extractStructuredResult(messages: Array<{ type: string; subtype?: string; structured_output?: unknown; result?: string; message?: unknown }>) {
18+
let structuredOutput: unknown = undefined;
19+
let resultText = '';
20+
21+
for (const message of messages) {
22+
if (message.type === 'result' && message.subtype === 'success') {
23+
if (message.structured_output !== undefined) {
24+
structuredOutput = message.structured_output;
25+
}
26+
if (message.result) {
27+
resultText = message.result;
28+
}
29+
} else if (message.type === 'assistant') {
30+
const msg = message.message as { content?: Array<{ type: string; text?: string }> } | string;
31+
if (typeof msg === 'string') {
32+
resultText += msg;
33+
} else if (msg && Array.isArray(msg.content)) {
34+
for (const block of msg.content) {
35+
if (block.type === 'text' && block.text) {
36+
resultText += block.text;
37+
}
38+
}
39+
}
40+
}
41+
}
42+
43+
// Prefer structured_output
44+
if (structuredOutput !== undefined) {
45+
return { result: structuredOutput, source: 'structured_output' };
46+
}
47+
48+
// Fallback: try JSON parse
49+
if (resultText) {
50+
try {
51+
return { result: JSON.parse(resultText), source: 'text_parsed' };
52+
} catch {
53+
return { result: resultText, source: 'text_raw' };
54+
}
55+
}
56+
57+
return { result: null, source: 'empty' };
58+
}
59+
60+
describe('structured output extraction', () => {
61+
it('prefers structured_output from SDK result', () => {
62+
const messages = [
63+
{
64+
type: 'assistant',
65+
message: { content: [{ type: 'text', text: '{"name":"from text"}' }] },
66+
},
67+
{
68+
type: 'result',
69+
subtype: 'success',
70+
structured_output: { name: 'from structured' },
71+
result: '{"name":"from text"}',
72+
},
73+
];
74+
75+
const out = extractStructuredResult(messages);
76+
assert.equal(out.source, 'structured_output');
77+
assert.deepEqual(out.result, { name: 'from structured' });
78+
});
79+
80+
it('falls back to text parsing when structured_output is absent', () => {
81+
const messages = [
82+
{
83+
type: 'assistant',
84+
message: { content: [{ type: 'text', text: '{"fallback":true}' }] },
85+
},
86+
{
87+
type: 'result',
88+
subtype: 'success',
89+
result: '',
90+
},
91+
];
92+
93+
const out = extractStructuredResult(messages);
94+
assert.equal(out.source, 'text_parsed');
95+
assert.deepEqual(out.result, { fallback: true });
96+
});
97+
98+
it('returns raw text when JSON parse fails', () => {
99+
const messages = [
100+
{
101+
type: 'assistant',
102+
message: { content: [{ type: 'text', text: 'not valid json' }] },
103+
},
104+
{
105+
type: 'result',
106+
subtype: 'success',
107+
},
108+
];
109+
110+
const out = extractStructuredResult(messages);
111+
assert.equal(out.source, 'text_raw');
112+
assert.equal(out.result, 'not valid json');
113+
});
114+
115+
it('returns null when no content at all', () => {
116+
const messages = [
117+
{ type: 'result', subtype: 'success' },
118+
];
119+
120+
const out = extractStructuredResult(messages);
121+
assert.equal(out.source, 'empty');
122+
assert.equal(out.result, null);
123+
});
124+
125+
it('handles structured_output that is a primitive (e.g. number)', () => {
126+
const messages = [
127+
{
128+
type: 'result',
129+
subtype: 'success',
130+
structured_output: 42,
131+
},
132+
];
133+
134+
const out = extractStructuredResult(messages);
135+
assert.equal(out.source, 'structured_output');
136+
assert.equal(out.result, 42);
137+
});
138+
});
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { NextRequest, NextResponse } from 'next/server';
2+
import { getConversation } from '@/lib/conversation-registry';
3+
4+
export const runtime = 'nodejs';
5+
export const dynamic = 'force-dynamic';
6+
7+
export async function POST(request: NextRequest) {
8+
try {
9+
const { sessionId } = await request.json();
10+
11+
if (!sessionId) {
12+
return NextResponse.json({ error: 'sessionId is required' }, { status: 400 });
13+
}
14+
15+
const conversation = getConversation(sessionId);
16+
if (!conversation) {
17+
return NextResponse.json({ interrupted: false });
18+
}
19+
20+
await conversation.interrupt();
21+
22+
return NextResponse.json({ interrupted: true });
23+
} catch (error) {
24+
console.error('[interrupt] Failed to interrupt:', error);
25+
return NextResponse.json({ interrupted: false, error: String(error) });
26+
}
27+
}

src/app/api/chat/model/route.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { NextRequest, NextResponse } from 'next/server';
2+
import { getConversation } from '@/lib/conversation-registry';
3+
4+
export const runtime = 'nodejs';
5+
export const dynamic = 'force-dynamic';
6+
7+
export async function POST(request: NextRequest) {
8+
try {
9+
const { sessionId, model } = await request.json();
10+
11+
if (!sessionId || !model) {
12+
return NextResponse.json({ error: 'sessionId and model are required' }, { status: 400 });
13+
}
14+
15+
const conversation = getConversation(sessionId);
16+
if (!conversation) {
17+
return NextResponse.json({ applied: false });
18+
}
19+
20+
await conversation.setModel(model);
21+
22+
return NextResponse.json({ applied: true });
23+
} catch (error) {
24+
console.error('[model] Failed to set model:', error);
25+
return NextResponse.json({ applied: false, error: String(error) });
26+
}
27+
}

src/app/api/chat/rewind/route.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { NextRequest, NextResponse } from 'next/server';
2+
import { getConversation } from '@/lib/conversation-registry';
3+
4+
export const runtime = 'nodejs';
5+
export const dynamic = 'force-dynamic';
6+
7+
export async function POST(request: NextRequest) {
8+
try {
9+
const { sessionId, userMessageId, dryRun } = await request.json();
10+
11+
if (!sessionId || !userMessageId) {
12+
return NextResponse.json({ error: 'sessionId and userMessageId are required' }, { status: 400 });
13+
}
14+
15+
const conversation = getConversation(sessionId);
16+
if (!conversation) {
17+
return NextResponse.json({ canRewind: false, error: 'No active conversation' });
18+
}
19+
20+
const result = await conversation.rewindFiles(userMessageId, { dryRun: !!dryRun });
21+
22+
return NextResponse.json(result);
23+
} catch (error) {
24+
console.error('[rewind] Failed to rewind:', error);
25+
return NextResponse.json({ canRewind: false, error: String(error) });
26+
}
27+
}

src/app/api/chat/route.ts

Lines changed: 33 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,21 +2,42 @@ import { NextRequest } from 'next/server';
22
import { streamClaude } from '@/lib/claude-client';
33
import { addMessage, getMessages, getSession, updateSessionTitle, updateSdkSessionId, updateSessionModel, updateSessionProvider, updateSessionProviderId, getSetting, getProvider, getDefaultProviderId, acquireSessionLock, renewSessionLock, releaseSessionLock, setSessionRuntimeStatus, syncSdkTasks } from '@/lib/db';
44
import { notifySessionStart, notifySessionComplete, notifySessionError } from '@/lib/telegram-bot';
5-
import type { SendMessageRequest, SSEEvent, TokenUsage, MessageContentBlock, FileAttachment } from '@/types';
5+
import type { SendMessageRequest, SSEEvent, TokenUsage, MessageContentBlock, FileAttachment, ClaudeStreamOptions } from '@/types';
66
import crypto from 'crypto';
77
import fs from 'fs';
88
import path from 'path';
9+
import os from 'os';
10+
import type { MCPServerConfig } from '@/types';
911

1012
export const runtime = 'nodejs';
1113
export const dynamic = 'force-dynamic';
1214

15+
/** Read MCP server configs from ~/.claude.json and ~/.claude/settings.json */
16+
function loadMcpServers(): Record<string, MCPServerConfig> | undefined {
17+
try {
18+
const readJson = (p: string): Record<string, unknown> => {
19+
if (!fs.existsSync(p)) return {};
20+
try { return JSON.parse(fs.readFileSync(p, 'utf-8')); } catch { return {}; }
21+
};
22+
const userConfig = readJson(path.join(os.homedir(), '.claude.json'));
23+
const settings = readJson(path.join(os.homedir(), '.claude', 'settings.json'));
24+
const merged = {
25+
...((userConfig.mcpServers || {}) as Record<string, MCPServerConfig>),
26+
...((settings.mcpServers || {}) as Record<string, MCPServerConfig>),
27+
};
28+
return Object.keys(merged).length > 0 ? merged : undefined;
29+
} catch {
30+
return undefined;
31+
}
32+
}
33+
1334
export async function POST(request: NextRequest) {
1435
let activeSessionId: string | undefined;
1536
let activeLockId: string | undefined;
1637

1738
try {
18-
const body: SendMessageRequest & { files?: FileAttachment[]; toolTimeout?: number; provider_id?: string; systemPromptAppend?: string; autoTrigger?: boolean } = await request.json();
19-
const { session_id, content, model, mode, files, toolTimeout, provider_id, systemPromptAppend, autoTrigger } = body;
39+
const body: SendMessageRequest & { files?: FileAttachment[]; toolTimeout?: number; provider_id?: string; systemPromptAppend?: string; autoTrigger?: boolean; thinking?: unknown; effort?: string; enableFileCheckpointing?: boolean } = await request.json();
40+
const { session_id, content, model, mode, files, toolTimeout, provider_id, systemPromptAppend, autoTrigger, thinking, effort, enableFileCheckpointing } = body;
2041

2142
console.log('[chat API] content length:', content.length, 'first 200 chars:', content.slice(0, 200));
2243
console.log('[chat API] systemPromptAppend:', systemPromptAppend ? `${systemPromptAppend.length} chars` : 'none');
@@ -292,6 +313,10 @@ Start by greeting the user and asking the first question.
292313
content: m.content,
293314
}));
294315

316+
// Load MCP servers from Claude config files so the SDK knows about them
317+
// even when settingSources skips 'user' (custom provider scenario).
318+
const mcpServers = loadMcpServers();
319+
295320
// Stream Claude response, using SDK session ID for resume if available
296321
console.log('[chat API] streamClaude params:', {
297322
promptLength: content.length,
@@ -313,8 +338,13 @@ Start by greeting the user and asking the first question.
313338
imageAgentMode: !!systemPromptAppend,
314339
toolTimeoutSeconds: toolTimeout || 300,
315340
provider: resolvedProvider,
341+
mcpServers,
316342
conversationHistory: historyMsgs,
317343
bypassPermissions: session.permission_profile === 'full_access',
344+
thinking: thinking as ClaudeStreamOptions['thinking'],
345+
effort: effort as ClaudeStreamOptions['effort'],
346+
enableFileCheckpointing: enableFileCheckpointing ?? (effectiveMode === 'code'),
347+
autoTrigger: !!autoTrigger,
318348
onRuntimeStatusChange: (status: string) => {
319349
try { setSessionRuntimeStatus(session_id, status); } catch { /* best effort */ }
320350
},

0 commit comments

Comments
 (0)