mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-19 06:45:50 +00:00
Compare commits
8 Commits
v1.10.2
...
feature/ad
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0a90b218c3 | ||
|
|
de9494c0a3 | ||
|
|
e7e1f7a34d | ||
|
|
9a5998eaef | ||
|
|
dff678924e | ||
|
|
4591432a1d | ||
|
|
901628b4d9 | ||
|
|
cf33c06021 |
@@ -188,7 +188,7 @@ The TUI (REPL) mode requires a real terminal and cannot be launched directly via
|
|||||||
## Documentation & Links
|
## Documentation & Links
|
||||||
|
|
||||||
- **Online docs (Mintlify)**: [ccb.agent-aura.top](https://ccb.agent-aura.top/) — source in [`docs/`](docs/), PR contributions welcome
|
- **Online docs (Mintlify)**: [ccb.agent-aura.top](https://ccb.agent-aura.top/) — source in [`docs/`](docs/), PR contributions welcome
|
||||||
- **DeepWiki**: <https://deepwiki.com/claude-code-best/claude-code>
|
- **DeepWiki**: https://deepwiki.com/claude-code-best/claude-code
|
||||||
|
|
||||||
## Contributors
|
## Contributors
|
||||||
|
|
||||||
|
|||||||
@@ -145,8 +145,8 @@ M 键(或 ← / →)用于在两种路由模式之间切换,**无需展开
|
|||||||
|
|
||||||
```
|
```
|
||||||
/pipes — 显示所有实例 + 切换选择面板
|
/pipes — 显示所有实例 + 切换选择面板
|
||||||
/pipes select <name> — 选中某实例(消息会广播到它)
|
/pipes select <name> — 选中某实例(消息会广播到它)
|
||||||
/pipes deselect <name> — 取消选中
|
/pipes deselect <name> — 取消选中
|
||||||
/pipes all — 全选
|
/pipes all — 全选
|
||||||
/pipes none — 全部取消
|
/pipes none — 全部取消
|
||||||
```
|
```
|
||||||
@@ -169,7 +169,7 @@ LAN Peers:
|
|||||||
Selected: cli-da029538
|
Selected: cli-da029538
|
||||||
```
|
```
|
||||||
|
|
||||||
### /attach <name>
|
### /attach <name>
|
||||||
|
|
||||||
手动 attach 到一个实例,使其成为你的 slave。
|
手动 attach 到一个实例,使其成为你的 slave。
|
||||||
|
|
||||||
@@ -179,7 +179,7 @@ Selected: cli-da029538
|
|||||||
|
|
||||||
attach 后,对方变为 slave,你变为 master。可以向它发送 prompt。通常不需要手动 attach——heartbeat 会自动发现并连接。
|
attach 后,对方变为 slave,你变为 master。可以向它发送 prompt。通常不需要手动 attach——heartbeat 会自动发现并连接。
|
||||||
|
|
||||||
### /detach <name>
|
### /detach <name>
|
||||||
|
|
||||||
断开与某个 slave 的连接。
|
断开与某个 slave 的连接。
|
||||||
|
|
||||||
@@ -187,7 +187,7 @@ attach 后,对方变为 slave,你变为 master。可以向它发送 prompt
|
|||||||
/detach cli-04d67950
|
/detach cli-04d67950
|
||||||
```
|
```
|
||||||
|
|
||||||
### /send <name> <message>
|
### /send <name> <message>
|
||||||
|
|
||||||
向指定 pipe 发送消息(不依赖选择状态,直接指定目标)。
|
向指定 pipe 发送消息(不依赖选择状态,直接指定目标)。
|
||||||
|
|
||||||
|
|||||||
@@ -200,9 +200,9 @@ LSP 服务器通过插件提供。插件的 `manifest.json` 中可以声明 LSP
|
|||||||
|------|------|------|------|
|
|------|------|------|------|
|
||||||
| `command` | string | 是 | LSP 服务器可执行命令(不含空格) |
|
| `command` | string | 是 | LSP 服务器可执行命令(不含空格) |
|
||||||
| `args` | string[] | 否 | 命令行参数 |
|
| `args` | string[] | 否 | 命令行参数 |
|
||||||
| `extensionToLanguage` | Record<string, string> | 是 | 文件扩展名到语言 ID 的映射(至少一个) |
|
| `extensionToLanguage` | `Record<string, string>` | 是 | 文件扩展名到语言 ID 的映射(至少一个) |
|
||||||
| `transport` | `"stdio"` \| `"socket"` | 否 | 通信方式,默认 `stdio` |
|
| `transport` | `"stdio"` \| `"socket"` | 否 | 通信方式,默认 `stdio` |
|
||||||
| `env` | Record<string, string> | 否 | 启动服务器时设置的环境变量 |
|
| `env` | `Record<string, string>` | 否 | 启动服务器时设置的环境变量 |
|
||||||
| `initializationOptions` | unknown | 否 | 传给服务器的初始化选项 |
|
| `initializationOptions` | unknown | 否 | 传给服务器的初始化选项 |
|
||||||
| `settings` | unknown | 否 | 通过 `workspace/didChangeConfiguration` 传递的设置 |
|
| `settings` | unknown | 否 | 通过 `workspace/didChangeConfiguration` 传递的设置 |
|
||||||
| `workspaceFolder` | string | 否 | 工作区目录路径 |
|
| `workspaceFolder` | string | 否 | 工作区目录路径 |
|
||||||
|
|||||||
@@ -175,7 +175,7 @@ F. getCompletedResults() → 空
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
#### #8 stream_event (input_json_delta: '{"file_path":')
|
#### #8 stream_event (input_json_delta: `'{"file_path":'`)
|
||||||
|
|
||||||
```
|
```
|
||||||
D. yield message ✅ → REPL 追加工具输入 JSON 碎片
|
D. yield message ✅ → REPL 追加工具输入 JSON 碎片
|
||||||
|
|||||||
@@ -421,7 +421,7 @@ export const PowerShellTool = buildTool({
|
|||||||
isSearch: boolean
|
isSearch: boolean
|
||||||
isRead: boolean
|
isRead: boolean
|
||||||
} {
|
} {
|
||||||
if (!input.command) {
|
if (!input?.command) {
|
||||||
return { isSearch: false, isRead: false }
|
return { isSearch: false, isRead: false }
|
||||||
}
|
}
|
||||||
return isSearchOrReadPowerShellCommand(input.command)
|
return isSearchOrReadPowerShellCommand(input.command)
|
||||||
|
|||||||
@@ -169,7 +169,7 @@ export async function call(
|
|||||||
|
|
||||||
if (COMMON_HELP_ARGS.includes(args)) {
|
if (COMMON_HELP_ARGS.includes(args)) {
|
||||||
onDone(
|
onDone(
|
||||||
'Usage: /effort [low|medium|high|max|auto]\n\nEffort levels:\n- low: Quick, straightforward implementation\n- medium: Balanced approach with standard testing\n- high: Comprehensive implementation with extensive testing\n- max: Maximum capability with deepest reasoning (Opus 4.6 only)\n- auto: Use the default effort level for your model',
|
'Usage: /effort [low|medium|high|max|auto]\n\nEffort levels:\n- low: Quick, straightforward implementation\n- medium: Balanced approach with standard testing\n- high: Comprehensive implementation with extensive testing\n- max: Maximum capability with deepest reasoning (Opus 4.6/4.7, DeepSeek V4 Pro)\n- auto: Use the default effort level for your model',
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ import { usePrStatus } from '../../hooks/usePrStatus.js'
|
|||||||
import { Byline, KeyboardShortcutHint } from '@anthropic/ink'
|
import { Byline, KeyboardShortcutHint } from '@anthropic/ink'
|
||||||
import { useTerminalSize } from '../../hooks/useTerminalSize.js'
|
import { useTerminalSize } from '../../hooks/useTerminalSize.js'
|
||||||
import { useTasksV2 } from '../../hooks/useTasksV2.js'
|
import { useTasksV2 } from '../../hooks/useTasksV2.js'
|
||||||
import { formatDuration } from '../../utils/format.js'
|
import { formatDuration, formatFileSize } from '../../utils/format.js'
|
||||||
import { VoiceWarmupHint } from './VoiceIndicator.js'
|
import { VoiceWarmupHint } from './VoiceIndicator.js'
|
||||||
import { useVoiceEnabled } from '../../hooks/useVoiceEnabled.js'
|
import { useVoiceEnabled } from '../../hooks/useVoiceEnabled.js'
|
||||||
import { useVoiceState } from '../../context/voice.js'
|
import { useVoiceState } from '../../context/voice.js'
|
||||||
@@ -63,6 +63,26 @@ const NO_OP_SUBSCRIBE = (_cb: () => void) => () => {}
|
|||||||
const NULL = () => null
|
const NULL = () => null
|
||||||
const MAX_VOICE_HINT_SHOWS = 3
|
const MAX_VOICE_HINT_SHOWS = 3
|
||||||
|
|
||||||
|
const RSS_UPDATE_INTERVAL_MS = 5_000
|
||||||
|
|
||||||
|
type RssState = { text: string; level: 'normal' | 'warning' | 'error' }
|
||||||
|
|
||||||
|
function useRssDisplay(): RssState | null {
|
||||||
|
const [state, setState] = useState<RssState | null>(null)
|
||||||
|
useEffect(() => {
|
||||||
|
function update(): void {
|
||||||
|
const mb = process.memoryUsage().rss / (1024 * 1024)
|
||||||
|
const level = mb >= 1024 ? 'error' : mb >= 512 ? 'warning' : 'normal'
|
||||||
|
const text = formatFileSize(mb * 1024 * 1024)
|
||||||
|
setState(prev => (prev?.text === text ? prev : { text, level }))
|
||||||
|
}
|
||||||
|
update()
|
||||||
|
const timer = setInterval(update, RSS_UPDATE_INTERVAL_MS)
|
||||||
|
return () => clearInterval(timer)
|
||||||
|
}, [])
|
||||||
|
return state
|
||||||
|
}
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
exitMessage: {
|
exitMessage: {
|
||||||
show: boolean
|
show: boolean
|
||||||
@@ -315,6 +335,7 @@ function ModeIndicator({
|
|||||||
const isKillAgentsConfirmShowing = useAppState(
|
const isKillAgentsConfirmShowing = useAppState(
|
||||||
s => s.notifications.current?.key === 'kill-agents-confirm',
|
s => s.notifications.current?.key === 'kill-agents-confirm',
|
||||||
)
|
)
|
||||||
|
const rssState = useRssDisplay()
|
||||||
|
|
||||||
// Derive team info from teamContext (no filesystem I/O needed)
|
// Derive team info from teamContext (no filesystem I/O needed)
|
||||||
// Match the same logic as TeamStatus to avoid trailing separator
|
// Match the same logic as TeamStatus to avoid trailing separator
|
||||||
@@ -428,6 +449,18 @@ function ModeIndicator({
|
|||||||
/>,
|
/>,
|
||||||
]
|
]
|
||||||
: []),
|
: []),
|
||||||
|
// RSS memory indicator — always visible
|
||||||
|
...(rssState
|
||||||
|
? [
|
||||||
|
<Text
|
||||||
|
key="rss"
|
||||||
|
dimColor={rssState.level === 'normal'}
|
||||||
|
color={rssState.level === 'error' ? 'error' : rssState.level === 'warning' ? 'warning' : undefined}
|
||||||
|
>
|
||||||
|
{rssState.text}
|
||||||
|
</Text>,
|
||||||
|
]
|
||||||
|
: []),
|
||||||
]
|
]
|
||||||
|
|
||||||
// Check if any in-process teammates exist (for hint text cycling)
|
// Check if any in-process teammates exist (for hint text cycling)
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
import chalk from 'chalk';
|
import chalk from 'chalk';
|
||||||
import {
|
import {
|
||||||
permissionModeTitle,
|
permissionModeTitle,
|
||||||
|
permissionModeShortTitle,
|
||||||
permissionModeFromString,
|
permissionModeFromString,
|
||||||
toExternalPermissionMode,
|
toExternalPermissionMode,
|
||||||
isExternalPermissionMode,
|
isExternalPermissionMode,
|
||||||
@@ -153,7 +154,7 @@ export function Config({
|
|||||||
const initialLanguage = React.useRef(currentLanguage);
|
const initialLanguage = React.useRef(currentLanguage);
|
||||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||||
const [scrollOffset, setScrollOffset] = useState(0);
|
const [scrollOffset, setScrollOffset] = useState(0);
|
||||||
const [isSearchMode, setIsSearchMode] = useState(true);
|
const [isSearchMode, setIsSearchMode] = useState(false);
|
||||||
const isTerminalFocused = useTerminalFocus();
|
const isTerminalFocused = useTerminalFocus();
|
||||||
const { rows } = useTerminalSize();
|
const { rows } = useTerminalSize();
|
||||||
// contentHeight is set by Settings.tsx (same value passed to Tabs to fix
|
// contentHeight is set by Settings.tsx (same value passed to Tabs to fix
|
||||||
@@ -167,6 +168,9 @@ export function Config({
|
|||||||
const thinkingEnabled = useAppState(s => s.thinkingEnabled);
|
const thinkingEnabled = useAppState(s => s.thinkingEnabled);
|
||||||
const isFastMode = useAppState(s => (isFastModeEnabled() ? s.fastMode : false));
|
const isFastMode = useAppState(s => (isFastModeEnabled() ? s.fastMode : false));
|
||||||
const promptSuggestionEnabled = useAppState(s => s.promptSuggestionEnabled);
|
const promptSuggestionEnabled = useAppState(s => s.promptSuggestionEnabled);
|
||||||
|
const currentDefaultPermissionMode = permissionModeFromString(
|
||||||
|
settingsData?.permissions?.defaultMode ?? 'default',
|
||||||
|
);
|
||||||
// Show auto in the default-mode dropdown when the user has opted in OR the
|
// Show auto in the default-mode dropdown when the user has opted in OR the
|
||||||
// config is fully 'enabled' — even if currently circuit-broken ('disabled'),
|
// config is fully 'enabled' — even if currently circuit-broken ('disabled'),
|
||||||
// an opted-in user should still see it in settings (it's a temporary state).
|
// an opted-in user should still see it in settings (it's a temporary state).
|
||||||
@@ -558,27 +562,23 @@ export function Config({
|
|||||||
{
|
{
|
||||||
id: 'defaultPermissionMode',
|
id: 'defaultPermissionMode',
|
||||||
label: 'Default permission mode',
|
label: 'Default permission mode',
|
||||||
value: settingsData?.permissions?.defaultMode || 'default',
|
value: currentDefaultPermissionMode,
|
||||||
options: (() => {
|
options: (() => {
|
||||||
const priorityOrder: PermissionMode[] = ['default', 'plan'];
|
const priorityOrder: PermissionMode[] = ['default', 'plan'];
|
||||||
const allModes: readonly PermissionMode[] = feature('TRANSCRIPT_CLASSIFIER')
|
return [...priorityOrder, ...PERMISSION_MODES.filter(m => !priorityOrder.includes(m))];
|
||||||
? PERMISSION_MODES
|
|
||||||
: EXTERNAL_PERMISSION_MODES;
|
|
||||||
const excluded: PermissionMode[] = ['bypassPermissions'];
|
|
||||||
if (feature('TRANSCRIPT_CLASSIFIER') && !showAutoInDefaultModePicker) {
|
|
||||||
excluded.push('auto');
|
|
||||||
}
|
|
||||||
return [...priorityOrder, ...allModes.filter(m => !priorityOrder.includes(m) && !excluded.includes(m))];
|
|
||||||
})(),
|
})(),
|
||||||
type: 'enum' as const,
|
type: 'enum' as const,
|
||||||
onChange(mode: string) {
|
onChange(mode: string) {
|
||||||
const parsedMode = permissionModeFromString(mode);
|
const parsedMode = permissionModeFromString(mode);
|
||||||
// Internal modes (e.g. auto) are stored directly
|
// auto is an internal-only mode — store it directly, don't convert
|
||||||
const validatedMode = isExternalPermissionMode(parsedMode) ? toExternalPermissionMode(parsedMode) : parsedMode;
|
// to its external mapping ('default') which would make it invisible.
|
||||||
|
const validatedMode = parsedMode === 'auto'
|
||||||
|
? parsedMode
|
||||||
|
: (isExternalPermissionMode(parsedMode) ? toExternalPermissionMode(parsedMode) : parsedMode);
|
||||||
const result = updateSettingsForSource('userSettings', {
|
const result = updateSettingsForSource('userSettings', {
|
||||||
permissions: {
|
permissions: {
|
||||||
...settingsData?.permissions,
|
...settingsData?.permissions,
|
||||||
defaultMode: validatedMode as ExternalPermissionMode,
|
defaultMode: validatedMode as (typeof PERMISSION_MODES)[number],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1548,6 +1548,8 @@ export function Config({
|
|||||||
'scroll:lineUp': () => moveSelection(-1),
|
'scroll:lineUp': () => moveSelection(-1),
|
||||||
'scroll:lineDown': () => moveSelection(1),
|
'scroll:lineDown': () => moveSelection(1),
|
||||||
'select:accept': toggleSetting,
|
'select:accept': toggleSetting,
|
||||||
|
'select:previousValue': () => toggleSetting(),
|
||||||
|
'select:nextValue': () => toggleSetting(),
|
||||||
'settings:search': () => {
|
'settings:search': () => {
|
||||||
setIsSearchMode(true);
|
setIsSearchMode(true);
|
||||||
setSearchQuery('');
|
setSearchQuery('');
|
||||||
@@ -1936,13 +1938,13 @@ export function Config({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<React.Fragment key={setting.id}>
|
<React.Fragment key={setting.id}>
|
||||||
<Box>
|
<Box width="100%">
|
||||||
<Box width={44}>
|
<Box width={44}>
|
||||||
<Text color={isSelected ? 'suggestion' : undefined}>
|
<Text color={isSelected ? 'suggestion' : undefined}>
|
||||||
{isSelected ? figures.pointer : ' '} {setting.label}
|
{isSelected ? figures.pointer : ' '} {setting.label}
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
<Box key={isSelected ? 'selected' : 'unselected'}>
|
<Box flexGrow={1}>
|
||||||
{setting.type === 'boolean' ? (
|
{setting.type === 'boolean' ? (
|
||||||
<>
|
<>
|
||||||
<Text color={isSelected ? 'suggestion' : undefined}>{setting.value.toString()}</Text>
|
<Text color={isSelected ? 'suggestion' : undefined}>{setting.value.toString()}</Text>
|
||||||
@@ -1963,7 +1965,7 @@ export function Config({
|
|||||||
</Text>
|
</Text>
|
||||||
) : setting.id === 'defaultPermissionMode' ? (
|
) : setting.id === 'defaultPermissionMode' ? (
|
||||||
<Text color={isSelected ? 'suggestion' : undefined}>
|
<Text color={isSelected ? 'suggestion' : undefined}>
|
||||||
{permissionModeTitle(setting.value as PermissionMode)}
|
{permissionModeShortTitle(setting.value as PermissionMode)}
|
||||||
</Text>
|
</Text>
|
||||||
) : setting.id === 'autoUpdatesChannel' && autoUpdaterDisabledReason ? (
|
) : setting.id === 'autoUpdatesChannel' && autoUpdaterDisabledReason ? (
|
||||||
<Box flexDirection="column">
|
<Box flexDirection="column">
|
||||||
|
|||||||
@@ -117,6 +117,9 @@ export const DEFAULT_BINDINGS: KeybindingBlock[] = [
|
|||||||
j: 'select:next',
|
j: 'select:next',
|
||||||
'ctrl+p': 'select:previous',
|
'ctrl+p': 'select:previous',
|
||||||
'ctrl+n': 'select:next',
|
'ctrl+n': 'select:next',
|
||||||
|
// Cycle enum values left/right (same as left/right arrow in handleKeyDown)
|
||||||
|
left: 'select:previousValue',
|
||||||
|
right: 'select:nextValue',
|
||||||
// Toggle/activate the selected setting (space only — enter saves & closes)
|
// Toggle/activate the selected setting (space only — enter saves & closes)
|
||||||
space: 'select:accept',
|
space: 'select:accept',
|
||||||
// Save and close the config panel
|
// Save and close the config panel
|
||||||
|
|||||||
@@ -168,6 +168,8 @@ export const KEYBINDING_ACTIONS = [
|
|||||||
'settings:search',
|
'settings:search',
|
||||||
'settings:retry',
|
'settings:retry',
|
||||||
'settings:close',
|
'settings:close',
|
||||||
|
'select:previousValue',
|
||||||
|
'select:nextValue',
|
||||||
// Voice actions
|
// Voice actions
|
||||||
'voice:pushToTalk',
|
'voice:pushToTalk',
|
||||||
] as const
|
] as const
|
||||||
|
|||||||
@@ -1340,7 +1340,10 @@ async function* queryModel(
|
|||||||
// media stripping) but before Anthropic-specific logic (betas, thinking, caching).
|
// media stripping) but before Anthropic-specific logic (betas, thinking, caching).
|
||||||
if (getAPIProvider() === 'openai') {
|
if (getAPIProvider() === 'openai') {
|
||||||
const { queryModelOpenAI } = await import('./openai/index.js')
|
const { queryModelOpenAI } = await import('./openai/index.js')
|
||||||
yield* queryModelOpenAI(messagesForAPI, systemPrompt, filteredTools, signal, options)
|
// OpenAI emulates Anthropic's dynamic tool loading client-side. It needs
|
||||||
|
// the full tool pool so ToolSearchTool can search deferred MCP tools that
|
||||||
|
// were intentionally filtered out of the initial API tool list above.
|
||||||
|
yield* queryModelOpenAI(messagesForAPI, systemPrompt, tools, signal, options)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -196,10 +196,52 @@ async function runQueryModel(
|
|||||||
// We mock at module level. Bun's mock.module replaces the module for the
|
// We mock at module level. Bun's mock.module replaces the module for the
|
||||||
// entire file, so we configure the stream per-test via a shared variable.
|
// entire file, so we configure the stream per-test via a shared variable.
|
||||||
let _nextEvents: BetaRawMessageStreamEvent[] = []
|
let _nextEvents: BetaRawMessageStreamEvent[] = []
|
||||||
|
let _toolSearchEnabled = false
|
||||||
|
|
||||||
/** Captured arguments from the last chat.completions.create() call */
|
/** Captured arguments from the last chat.completions.create() call */
|
||||||
let _lastCreateArgs: Record<string, any> | null = null
|
let _lastCreateArgs: Record<string, any> | null = null
|
||||||
|
|
||||||
|
mock.module('@ant/model-provider', () => ({
|
||||||
|
resolveOpenAIModel: (m: string) => m,
|
||||||
|
adaptOpenAIStreamToAnthropic: (_stream: any, _model: string) =>
|
||||||
|
eventStream(_nextEvents),
|
||||||
|
anthropicMessagesToOpenAI: (messages: any[]) =>
|
||||||
|
messages.map(msg => ({
|
||||||
|
role: msg.message?.role ?? 'user',
|
||||||
|
content: msg.message?.content ?? '',
|
||||||
|
})),
|
||||||
|
anthropicToolsToOpenAI: (tools: any[]) =>
|
||||||
|
tools.map(tool => ({
|
||||||
|
type: 'function',
|
||||||
|
function: {
|
||||||
|
name: tool.name,
|
||||||
|
description: tool.description ?? '',
|
||||||
|
parameters: tool.input_schema ?? { type: 'object', properties: {} },
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
anthropicToolChoiceToOpenAI: () => undefined,
|
||||||
|
}))
|
||||||
|
|
||||||
|
mock.module('../../../../utils/envUtils.js', () => ({
|
||||||
|
isEnvTruthy: (value: string | undefined) =>
|
||||||
|
value === '1' || value === 'true' || value === 'yes' || value === 'on',
|
||||||
|
isEnvDefinedFalsy: (value: string | undefined) =>
|
||||||
|
value === '0' || value === 'false' || value === 'no' || value === 'off',
|
||||||
|
}))
|
||||||
|
|
||||||
|
mock.module('../../../../services/analytics/growthbook.js', () => ({
|
||||||
|
getFeatureValue_CACHED_MAY_BE_STALE: (_key: string, fallback: unknown) =>
|
||||||
|
fallback,
|
||||||
|
}))
|
||||||
|
|
||||||
|
mock.module('src/bootstrap/state.js', () => ({
|
||||||
|
isReplBridgeActive: () => false,
|
||||||
|
}))
|
||||||
|
|
||||||
|
mock.module('bun:bundle', () => ({
|
||||||
|
feature: () => false,
|
||||||
|
}))
|
||||||
|
|
||||||
mock.module('../client.js', () => ({
|
mock.module('../client.js', () => ({
|
||||||
getOpenAIClient: () => ({
|
getOpenAIClient: () => ({
|
||||||
chat: {
|
chat: {
|
||||||
@@ -252,6 +294,13 @@ mock.module('../../../../utils/context.js', () => ({
|
|||||||
mock.module('../../../../utils/messages.js', () => ({
|
mock.module('../../../../utils/messages.js', () => ({
|
||||||
normalizeMessagesForAPI: (msgs: any) => msgs,
|
normalizeMessagesForAPI: (msgs: any) => msgs,
|
||||||
normalizeContentFromAPI: (blocks: any[]) => blocks,
|
normalizeContentFromAPI: (blocks: any[]) => blocks,
|
||||||
|
createUserMessage: (opts: any) => ({
|
||||||
|
type: 'user',
|
||||||
|
message: { role: 'user', content: opts.content },
|
||||||
|
uuid: 'user-uuid',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
isMeta: opts.isMeta,
|
||||||
|
}),
|
||||||
createAssistantAPIErrorMessage: (opts: any) => ({
|
createAssistantAPIErrorMessage: (opts: any) => ({
|
||||||
type: 'assistant',
|
type: 'assistant',
|
||||||
message: {
|
message: {
|
||||||
@@ -268,8 +317,9 @@ mock.module('../../../../utils/api.js', () => ({
|
|||||||
}))
|
}))
|
||||||
|
|
||||||
mock.module('../../../../utils/toolSearch.js', () => ({
|
mock.module('../../../../utils/toolSearch.js', () => ({
|
||||||
isToolSearchEnabled: async () => false,
|
isToolSearchEnabled: async () => _toolSearchEnabled,
|
||||||
extractDiscoveredToolNames: () => new Set(),
|
extractDiscoveredToolNames: () => new Set(),
|
||||||
|
isDeferredToolsDeltaEnabled: () => false,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
mock.module('../../../../tools/ToolSearchTool/prompt.js', () => ({
|
mock.module('../../../../tools/ToolSearchTool/prompt.js', () => ({
|
||||||
@@ -297,6 +347,16 @@ mock.module('../../../../utils/modelCost.js', () => ({
|
|||||||
getModelPricingString: () => undefined,
|
getModelPricingString: () => undefined,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
mock.module('../../../../services/langfuse/tracing.js', () => ({
|
||||||
|
recordLLMObservation: () => {},
|
||||||
|
}))
|
||||||
|
|
||||||
|
mock.module('../../../../services/langfuse/convert.js', () => ({
|
||||||
|
convertMessagesToLangfuse: () => [],
|
||||||
|
convertOutputToLangfuse: () => ({}),
|
||||||
|
convertToolsToLangfuse: () => [],
|
||||||
|
}))
|
||||||
|
|
||||||
mock.module('../../../../utils/debug.js', () => ({
|
mock.module('../../../../utils/debug.js', () => ({
|
||||||
logForDebugging: () => {},
|
logForDebugging: () => {},
|
||||||
logAntError: () => {},
|
logAntError: () => {},
|
||||||
@@ -543,3 +603,59 @@ describe('queryModelOpenAI — max_tokens forwarded to request', () => {
|
|||||||
expect(_lastCreateArgs!.max_tokens).toBe(8192)
|
expect(_lastCreateArgs!.max_tokens).toBe(8192)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('queryModelOpenAI — deferred MCP tool visibility', () => {
|
||||||
|
test('prepends available deferred MCP tools to OpenAI messages', async () => {
|
||||||
|
_toolSearchEnabled = true
|
||||||
|
_nextEvents = [makeMessageStart(), makeMessageStop()]
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { queryModelOpenAI } = await import('../index.js')
|
||||||
|
const tools: any[] = [
|
||||||
|
{
|
||||||
|
name: 'ToolSearch',
|
||||||
|
isMcp: false,
|
||||||
|
input_schema: { type: 'object', properties: {} },
|
||||||
|
prompt: async () => 'Search deferred tools',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'mcp__wechat__send_message',
|
||||||
|
isMcp: true,
|
||||||
|
input_schema: { type: 'object', properties: {} },
|
||||||
|
prompt: async () => 'Send a WeChat message',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const options: any = {
|
||||||
|
model: 'test-model',
|
||||||
|
tools: [],
|
||||||
|
agents: [],
|
||||||
|
querySource: 'main_loop',
|
||||||
|
getToolPermissionContext: async () => ({
|
||||||
|
alwaysAllow: [],
|
||||||
|
alwaysDeny: [],
|
||||||
|
needsPermission: [],
|
||||||
|
mode: 'default',
|
||||||
|
isBypassingPermissions: false,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
|
||||||
|
for await (const _item of queryModelOpenAI(
|
||||||
|
[],
|
||||||
|
{ type: 'text', text: '' } as any,
|
||||||
|
tools as any,
|
||||||
|
new AbortController().signal,
|
||||||
|
options,
|
||||||
|
)) {
|
||||||
|
// Exhaust generator so request body is built.
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(_lastCreateArgs).not.toBeNull()
|
||||||
|
expect(JSON.stringify(_lastCreateArgs!.messages)).toContain(
|
||||||
|
'<available-deferred-tools>\\nmcp__wechat__send_message\\n</available-deferred-tools>',
|
||||||
|
)
|
||||||
|
} finally {
|
||||||
|
_toolSearchEnabled = false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import type {
|
|||||||
StreamEvent,
|
StreamEvent,
|
||||||
SystemAPIErrorMessage,
|
SystemAPIErrorMessage,
|
||||||
AssistantMessage,
|
AssistantMessage,
|
||||||
|
UserMessage,
|
||||||
} from '../../../types/message.js'
|
} from '../../../types/message.js'
|
||||||
import type { AgentId } from '../../../types/ids.js'
|
import type { AgentId } from '../../../types/ids.js'
|
||||||
import type { Tools } from '../../../Tool.js'
|
import type { Tools } from '../../../Tool.js'
|
||||||
@@ -32,18 +33,58 @@ import type { Options } from '../claude.js'
|
|||||||
import { randomUUID } from 'crypto'
|
import { randomUUID } from 'crypto'
|
||||||
import {
|
import {
|
||||||
createAssistantAPIErrorMessage,
|
createAssistantAPIErrorMessage,
|
||||||
|
createUserMessage,
|
||||||
normalizeContentFromAPI,
|
normalizeContentFromAPI,
|
||||||
} from '../../../utils/messages.js'
|
} from '../../../utils/messages.js'
|
||||||
import type { SDKAssistantMessageError } from '../../../entrypoints/agentSdkTypes.js'
|
import type { SDKAssistantMessageError } from '../../../entrypoints/agentSdkTypes.js'
|
||||||
import {
|
import {
|
||||||
isToolSearchEnabled,
|
isToolSearchEnabled,
|
||||||
extractDiscoveredToolNames,
|
extractDiscoveredToolNames,
|
||||||
|
isDeferredToolsDeltaEnabled,
|
||||||
} from '../../../utils/toolSearch.js'
|
} from '../../../utils/toolSearch.js'
|
||||||
import {
|
import {
|
||||||
|
formatDeferredToolLine,
|
||||||
isDeferredTool,
|
isDeferredTool,
|
||||||
TOOL_SEARCH_TOOL_NAME,
|
TOOL_SEARCH_TOOL_NAME,
|
||||||
} from '@claude-code-best/builtin-tools/tools/ToolSearchTool/prompt.js'
|
} from '@claude-code-best/builtin-tools/tools/ToolSearchTool/prompt.js'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mirrors the Anthropic request path's deferred-tool announcement for OpenAI.
|
||||||
|
*
|
||||||
|
* OpenAI-compatible endpoints cannot consume Anthropic's `defer_loading` or
|
||||||
|
* `tool_reference` beta payloads directly, so the model needs the same textual
|
||||||
|
* list of deferred MCP tool names that Anthropic receives before it can ask
|
||||||
|
* ToolSearchTool to load their full schemas.
|
||||||
|
*/
|
||||||
|
function prependDeferredToolListIfNeeded(
|
||||||
|
messages: (AssistantMessage | UserMessage)[],
|
||||||
|
tools: Tools,
|
||||||
|
deferredToolNames: Set<string>,
|
||||||
|
useToolSearch: boolean,
|
||||||
|
): (AssistantMessage | UserMessage)[] {
|
||||||
|
if (!useToolSearch || isDeferredToolsDeltaEnabled()) return messages
|
||||||
|
|
||||||
|
const deferredToolList = tools
|
||||||
|
.filter(tool => deferredToolNames.has(tool.name))
|
||||||
|
.map(formatDeferredToolLine)
|
||||||
|
.sort()
|
||||||
|
.join('\n')
|
||||||
|
|
||||||
|
if (!deferredToolList) return messages
|
||||||
|
|
||||||
|
return [
|
||||||
|
createUserMessage({
|
||||||
|
content: `<available-deferred-tools>\n${deferredToolList}\n</available-deferred-tools>`,
|
||||||
|
isMeta: true,
|
||||||
|
}),
|
||||||
|
...messages,
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
function isOpenAIConvertibleMessage(msg: Message): msg is AssistantMessage | UserMessage {
|
||||||
|
return msg.type === 'assistant' || msg.type === 'user'
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Assemble the final AssistantMessage (and optional max_tokens error) from
|
* Assemble the final AssistantMessage (and optional max_tokens error) from
|
||||||
* accumulated stream state. Extracted to avoid duplication between the
|
* accumulated stream state. Extracted to avoid duplication between the
|
||||||
@@ -176,9 +217,18 @@ export async function* queryModelOpenAI(
|
|||||||
|
|
||||||
// 8. Convert messages and tools to OpenAI format
|
// 8. Convert messages and tools to OpenAI format
|
||||||
const enableThinking = isOpenAIThinkingEnabled(openaiModel)
|
const enableThinking = isOpenAIThinkingEnabled(openaiModel)
|
||||||
const openaiMessages = anthropicMessagesToOpenAI(messagesForAPI, systemPrompt, {
|
const openAIConvertibleMessages = messagesForAPI.filter(isOpenAIConvertibleMessage)
|
||||||
enableThinking,
|
const messagesWithDeferredToolList = prependDeferredToolListIfNeeded(
|
||||||
})
|
openAIConvertibleMessages,
|
||||||
|
tools,
|
||||||
|
deferredToolNames,
|
||||||
|
useToolSearch,
|
||||||
|
)
|
||||||
|
const openaiMessages = anthropicMessagesToOpenAI(
|
||||||
|
messagesWithDeferredToolList,
|
||||||
|
systemPrompt,
|
||||||
|
{ enableThinking },
|
||||||
|
)
|
||||||
const openaiTools = anthropicToolsToOpenAI(standardTools)
|
const openaiTools = anthropicToolsToOpenAI(standardTools)
|
||||||
const openaiToolChoice = anthropicToolChoiceToOpenAI(options.toolChoice)
|
const openaiToolChoice = anthropicToolChoiceToOpenAI(options.toolChoice)
|
||||||
|
|
||||||
@@ -356,7 +406,7 @@ export async function* queryModelOpenAI(
|
|||||||
recordLLMObservation(options.langfuseTrace ?? null, {
|
recordLLMObservation(options.langfuseTrace ?? null, {
|
||||||
model: openaiModel,
|
model: openaiModel,
|
||||||
provider: 'openai',
|
provider: 'openai',
|
||||||
input: convertMessagesToLangfuse(messagesForAPI, systemPrompt),
|
input: convertMessagesToLangfuse(openaiMessages),
|
||||||
output: convertOutputToLangfuse(collectedMessages),
|
output: convertOutputToLangfuse(collectedMessages),
|
||||||
usage: {
|
usage: {
|
||||||
input_tokens: usage.input_tokens,
|
input_tokens: usage.input_tokens,
|
||||||
|
|||||||
@@ -184,6 +184,101 @@ describe('Langfuse integration', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('convertMessagesToLangfuse', () => {
|
||||||
|
test('preserves OpenAI-style messages including deferred tool announcements', async () => {
|
||||||
|
const { convertMessagesToLangfuse } = await import('../convert.js')
|
||||||
|
const result = convertMessagesToLangfuse([
|
||||||
|
{
|
||||||
|
role: 'system',
|
||||||
|
content: 'system prompt',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: 'user',
|
||||||
|
content:
|
||||||
|
'<available-deferred-tools>\nmcp__wechat__send_message\n</available-deferred-tools>',
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
|
expect(result).toEqual([
|
||||||
|
{ role: 'system', content: 'system prompt' },
|
||||||
|
{
|
||||||
|
role: 'user',
|
||||||
|
content:
|
||||||
|
'<available-deferred-tools>\nmcp__wechat__send_message\n</available-deferred-tools>',
|
||||||
|
},
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('preserves roles for OpenAI-style array content messages', async () => {
|
||||||
|
const { convertMessagesToLangfuse } = await import('../convert.js')
|
||||||
|
const result = convertMessagesToLangfuse([
|
||||||
|
{
|
||||||
|
role: 'system',
|
||||||
|
content: [{ type: 'text', text: 'system reminder' }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: 'tool',
|
||||||
|
tool_call_id: 'call_1',
|
||||||
|
content: [{ type: 'text', text: 'tool output' }],
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
|
expect(result).toEqual([
|
||||||
|
{ role: 'system', content: 'system reminder' },
|
||||||
|
{ role: 'tool', content: 'tool output', tool_call_id: 'call_1' },
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('merges assistant tool calls from OpenAI-style array content', async () => {
|
||||||
|
const { convertMessagesToLangfuse } = await import('../convert.js')
|
||||||
|
// Content part with embedded tool_calls is non-standard; cast for defensive test
|
||||||
|
const result = convertMessagesToLangfuse([
|
||||||
|
{
|
||||||
|
role: 'assistant',
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
text: 'calling a tool',
|
||||||
|
tool_calls: [
|
||||||
|
{
|
||||||
|
id: 'call_from_part',
|
||||||
|
type: 'function',
|
||||||
|
function: { name: 'part_tool', arguments: '{}' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
tool_calls: [
|
||||||
|
{
|
||||||
|
id: 'call_from_message',
|
||||||
|
type: 'function',
|
||||||
|
function: { name: 'message_tool', arguments: '{"ok":true}' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
] as any)
|
||||||
|
|
||||||
|
expect(result).toEqual([
|
||||||
|
{
|
||||||
|
role: 'assistant',
|
||||||
|
content: 'calling a tool',
|
||||||
|
tool_calls: [
|
||||||
|
{
|
||||||
|
id: 'call_from_message',
|
||||||
|
type: 'function',
|
||||||
|
function: { name: 'message_tool', arguments: '{"ok":true}' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'call_from_part',
|
||||||
|
type: 'function',
|
||||||
|
function: { name: 'part_tool', arguments: '{}' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
// ── client tests ────────────────────────────────────────────────────────────
|
// ── client tests ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
describe('isLangfuseEnabled', () => {
|
describe('isLangfuseEnabled', () => {
|
||||||
|
|||||||
@@ -10,7 +10,8 @@
|
|||||||
* - tool_result blocks → separate { role: 'tool' } messages
|
* - tool_result blocks → separate { role: 'tool' } messages
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { Message, AssistantMessage, UserMessage } from 'src/types/message.js'
|
import type { AssistantMessage, UserMessage } from 'src/types/message.js'
|
||||||
|
import type { ChatCompletionMessageParam } from 'openai/resources/chat/completions/completions.mjs'
|
||||||
|
|
||||||
type LangfuseContentPart =
|
type LangfuseContentPart =
|
||||||
| { type: 'text'; text: string }
|
| { type: 'text'; text: string }
|
||||||
@@ -30,6 +31,61 @@ type LangfuseChatMessage = {
|
|||||||
tool_call_id?: string
|
tool_call_id?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isLangfuseRole(value: unknown): value is LangfuseChatMessage['role'] {
|
||||||
|
switch (value) {
|
||||||
|
case 'user':
|
||||||
|
case 'assistant':
|
||||||
|
case 'system':
|
||||||
|
case 'tool':
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
function isLangfuseToolCall(value: unknown): value is LangfuseToolCall {
|
||||||
|
if (!isRecord(value)) return false
|
||||||
|
const fn = value.function
|
||||||
|
return (
|
||||||
|
typeof value.id === 'string' &&
|
||||||
|
value.type === 'function' &&
|
||||||
|
isRecord(fn) &&
|
||||||
|
typeof fn.name === 'string' &&
|
||||||
|
typeof fn.arguments === 'string'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getToolCalls(value: unknown): LangfuseToolCall[] {
|
||||||
|
return Array.isArray(value) ? value.filter(isLangfuseToolCall) : []
|
||||||
|
}
|
||||||
|
|
||||||
|
function getContentToolCalls(content: unknown[]): LangfuseToolCall[] {
|
||||||
|
return content.flatMap(block =>
|
||||||
|
isRecord(block) ? getToolCalls(block.tool_calls) : [],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeToolCalls(
|
||||||
|
...groups: readonly LangfuseToolCall[][]
|
||||||
|
): LangfuseToolCall[] {
|
||||||
|
const merged = new Map<string, LangfuseToolCall>()
|
||||||
|
for (const toolCall of groups.flat()) {
|
||||||
|
const key = toolCall.id || `${toolCall.function.name}:${toolCall.function.arguments}`
|
||||||
|
if (!merged.has(key)) merged.set(key, toolCall)
|
||||||
|
}
|
||||||
|
return [...merged.values()]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Union of all message formats accepted by Langfuse converters. */
|
||||||
|
type LangfuseInputMessage =
|
||||||
|
| UserMessage
|
||||||
|
| AssistantMessage
|
||||||
|
| ChatCompletionMessageParam
|
||||||
|
|
||||||
/** Normalize a content block into a LangfuseContentPart (non-tool_use, non-tool_result) */
|
/** Normalize a content block into a LangfuseContentPart (non-tool_use, non-tool_result) */
|
||||||
function toContentPart(block: Record<string, unknown>): LangfuseContentPart | null {
|
function toContentPart(block: Record<string, unknown>): LangfuseContentPart | null {
|
||||||
const type = block.type as string | undefined
|
const type = block.type as string | undefined
|
||||||
@@ -121,15 +177,15 @@ function collapseContent(parts: LangfuseContentPart[]): string | LangfuseContent
|
|||||||
return parts
|
return parts
|
||||||
}
|
}
|
||||||
|
|
||||||
function toRole(msg: Message): 'user' | 'assistant' | 'system' {
|
function toRoleFromWrappedMessage(msg: Record<string, unknown>): 'user' | 'assistant' | 'system' {
|
||||||
if (msg.type === 'assistant') return 'assistant'
|
if (msg.type === 'assistant') return 'assistant'
|
||||||
if (msg.type === 'system') return 'system'
|
if (msg.type === 'system') return 'system'
|
||||||
return 'user'
|
return 'user'
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Convert messagesForAPI (UserMessage | AssistantMessage)[] → Langfuse input format */
|
/** Convert internal or OpenAI-style messages → Langfuse input format */
|
||||||
export function convertMessagesToLangfuse(
|
export function convertMessagesToLangfuse(
|
||||||
messages: (UserMessage | AssistantMessage)[],
|
messages: readonly LangfuseInputMessage[],
|
||||||
systemPrompt?: readonly string[],
|
systemPrompt?: readonly string[],
|
||||||
): LangfuseChatMessage[] {
|
): LangfuseChatMessage[] {
|
||||||
const result: LangfuseChatMessage[] = []
|
const result: LangfuseChatMessage[] = []
|
||||||
@@ -139,18 +195,34 @@ export function convertMessagesToLangfuse(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
for (const msg of messages) {
|
for (const msg of messages) {
|
||||||
const inner = msg.message
|
if (!isRecord(msg)) continue
|
||||||
if (!inner) continue
|
const wrappedMessage = msg.message
|
||||||
const role = (inner.role as 'user' | 'assistant' | undefined) ?? toRole(msg)
|
const isWrappedMessage = isRecord(wrappedMessage)
|
||||||
|
const inner = isWrappedMessage ? wrappedMessage : msg
|
||||||
|
const role =
|
||||||
|
isLangfuseRole(inner.role) ? inner.role : isWrappedMessage ? toRoleFromWrappedMessage(msg) : 'user'
|
||||||
const rawContent = inner.content
|
const rawContent = inner.content
|
||||||
if (typeof rawContent === 'string' || !Array.isArray(rawContent)) {
|
if (typeof rawContent === 'string' || !Array.isArray(rawContent)) {
|
||||||
result.push({ role, content: String(rawContent ?? '') })
|
const toolCalls = getToolCalls(inner.tool_calls)
|
||||||
|
result.push({
|
||||||
|
role,
|
||||||
|
content: String(rawContent ?? ''),
|
||||||
|
...('tool_call_id' in inner && typeof inner.tool_call_id === 'string'
|
||||||
|
? { tool_call_id: inner.tool_call_id }
|
||||||
|
: {}),
|
||||||
|
...(toolCalls.length > 0 ? { tool_calls: toolCalls } : {}),
|
||||||
|
})
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if (role === 'assistant') {
|
if (role === 'assistant') {
|
||||||
// Extract tool_use → tool_calls at message level
|
// Extract tool_use → tool_calls at message level
|
||||||
const { tool_calls, rest } = extractToolCalls(rawContent)
|
const { tool_calls, rest } = extractToolCalls(rawContent)
|
||||||
|
const allToolCalls = mergeToolCalls(
|
||||||
|
tool_calls,
|
||||||
|
getToolCalls(inner.tool_calls),
|
||||||
|
getContentToolCalls(rest),
|
||||||
|
)
|
||||||
const parts = rest
|
const parts = rest
|
||||||
.filter((b): b is Record<string, unknown> => b != null && typeof b === 'object')
|
.filter((b): b is Record<string, unknown> => b != null && typeof b === 'object')
|
||||||
.map(b => toContentPart(b))
|
.map(b => toContentPart(b))
|
||||||
@@ -158,7 +230,7 @@ export function convertMessagesToLangfuse(
|
|||||||
result.push({
|
result.push({
|
||||||
role: 'assistant',
|
role: 'assistant',
|
||||||
content: collapseContent(parts),
|
content: collapseContent(parts),
|
||||||
...(tool_calls.length > 0 && { tool_calls }),
|
...(allToolCalls.length > 0 && { tool_calls: allToolCalls }),
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
// User messages: extract tool_result → separate tool messages
|
// User messages: extract tool_result → separate tool messages
|
||||||
@@ -168,7 +240,18 @@ export function convertMessagesToLangfuse(
|
|||||||
.map(b => toContentPart(b))
|
.map(b => toContentPart(b))
|
||||||
.filter((p): p is LangfuseContentPart => p !== null)
|
.filter((p): p is LangfuseContentPart => p !== null)
|
||||||
if (parts.length > 0 || toolMessages.length === 0) {
|
if (parts.length > 0 || toolMessages.length === 0) {
|
||||||
result.push({ role: 'user', content: collapseContent(parts) })
|
const toolCalls = mergeToolCalls(
|
||||||
|
getToolCalls(inner.tool_calls),
|
||||||
|
getContentToolCalls(rest),
|
||||||
|
)
|
||||||
|
result.push({
|
||||||
|
role,
|
||||||
|
content: collapseContent(parts),
|
||||||
|
...('tool_call_id' in inner && typeof inner.tool_call_id === 'string'
|
||||||
|
? { tool_call_id: inner.tool_call_id }
|
||||||
|
: {}),
|
||||||
|
...(toolCalls.length > 0 ? { tool_calls: toolCalls } : {}),
|
||||||
|
})
|
||||||
}
|
}
|
||||||
result.push(...toolMessages)
|
result.push(...toolMessages)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,9 +30,11 @@ export type PermissionMode = InternalPermissionMode
|
|||||||
|
|
||||||
// Runtime validation set: modes that are user-addressable (settings.json
|
// Runtime validation set: modes that are user-addressable (settings.json
|
||||||
// defaultMode, --permission-mode CLI flag, conversation recovery).
|
// defaultMode, --permission-mode CLI flag, conversation recovery).
|
||||||
|
// 'auto' is always available — when TRANSCRIPT_CLASSIFIER is off, the
|
||||||
|
// classifier is unavailable and auto mode falls back to prompting.
|
||||||
export const INTERNAL_PERMISSION_MODES = [
|
export const INTERNAL_PERMISSION_MODES = [
|
||||||
...EXTERNAL_PERMISSION_MODES,
|
...EXTERNAL_PERMISSION_MODES,
|
||||||
...(feature('TRANSCRIPT_CLASSIFIER') ? (['auto'] as const) : ([] as const)),
|
'auto' as const,
|
||||||
] as const satisfies readonly PermissionMode[]
|
] as const satisfies readonly PermissionMode[]
|
||||||
|
|
||||||
export const PERMISSION_MODES = INTERNAL_PERMISSION_MODES
|
export const PERMISSION_MODES = INTERNAL_PERMISSION_MODES
|
||||||
|
|||||||
@@ -36,7 +36,8 @@ export function modelSupportsEffort(model: string): boolean {
|
|||||||
if (
|
if (
|
||||||
m.includes('opus-4-7') ||
|
m.includes('opus-4-7') ||
|
||||||
m.includes('opus-4-6') ||
|
m.includes('opus-4-6') ||
|
||||||
m.includes('sonnet-4-6')
|
m.includes('sonnet-4-6') ||
|
||||||
|
m.includes('deepseek-v4-pro')
|
||||||
) {
|
) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@@ -57,11 +58,16 @@ export function modelSupportsEffort(model: string): boolean {
|
|||||||
|
|
||||||
// @[MODEL LAUNCH]: Add the new model to the allowlist if it supports 'max' effort.
|
// @[MODEL LAUNCH]: Add the new model to the allowlist if it supports 'max' effort.
|
||||||
// Per API docs, 'max' is Opus 4.6/4.7 only for public models — other models return an error.
|
// Per API docs, 'max' is Opus 4.6/4.7 only for public models — other models return an error.
|
||||||
|
// However, DeepSeek V4 Pro also supports max effort when using Anthropic-compatible API.
|
||||||
export function modelSupportsMaxEffort(model: string): boolean {
|
export function modelSupportsMaxEffort(model: string): boolean {
|
||||||
const supported3P = get3PModelCapabilityOverride(model, 'max_effort')
|
const supported3P = get3PModelCapabilityOverride(model, 'max_effort')
|
||||||
if (supported3P !== undefined) {
|
if (supported3P !== undefined) {
|
||||||
return supported3P
|
return supported3P
|
||||||
}
|
}
|
||||||
|
// Support DeepSeek V4 Pro specifically (Anthropic-compatible API)
|
||||||
|
if (model.toLowerCase().includes('deepseek-v4-pro')) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
if (
|
if (
|
||||||
model.toLowerCase().includes('opus-4-7') ||
|
model.toLowerCase().includes('opus-4-7') ||
|
||||||
model.toLowerCase().includes('opus-4-6')
|
model.toLowerCase().includes('opus-4-6')
|
||||||
@@ -267,7 +273,7 @@ export function getEffortLevelDescription(level: EffortLevel): string {
|
|||||||
case 'xhigh':
|
case 'xhigh':
|
||||||
return 'Extended reasoning beyond high, short of max (Opus 4.7 only)'
|
return 'Extended reasoning beyond high, short of max (Opus 4.7 only)'
|
||||||
case 'max':
|
case 'max':
|
||||||
return 'Maximum capability with deepest reasoning (Opus 4.6/4.7 only)'
|
return 'Maximum capability with deepest reasoning (Opus 4.6/4.7/DeepSeek V4 Pro)'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ const PERMISSION_MODE_CONFIG: Partial<
|
|||||||
external: 'acceptEdits',
|
external: 'acceptEdits',
|
||||||
},
|
},
|
||||||
bypassPermissions: {
|
bypassPermissions: {
|
||||||
title: 'Bypass Permissions',
|
title: 'Bypass',
|
||||||
shortTitle: 'Bypass',
|
shortTitle: 'Bypass',
|
||||||
symbol: '⏵⏵',
|
symbol: '⏵⏵',
|
||||||
color: 'error',
|
color: 'error',
|
||||||
@@ -77,17 +77,13 @@ const PERMISSION_MODE_CONFIG: Partial<
|
|||||||
color: 'error',
|
color: 'error',
|
||||||
external: 'dontAsk',
|
external: 'dontAsk',
|
||||||
},
|
},
|
||||||
...(feature('TRANSCRIPT_CLASSIFIER')
|
auto: {
|
||||||
? {
|
title: 'Auto',
|
||||||
auto: {
|
shortTitle: 'Auto',
|
||||||
title: 'Auto mode',
|
symbol: '⏵⏵',
|
||||||
shortTitle: 'Auto',
|
color: 'warning' as ModeColorKey,
|
||||||
symbol: '⏵⏵',
|
external: 'default' as ExternalPermissionMode,
|
||||||
color: 'warning' as ModeColorKey,
|
},
|
||||||
external: 'default' as ExternalPermissionMode,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
: {}),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ describe("permissionModeTitle", () => {
|
|||||||
expect(permissionModeTitle("default")).toBe("Default");
|
expect(permissionModeTitle("default")).toBe("Default");
|
||||||
expect(permissionModeTitle("plan")).toBe("Plan Mode");
|
expect(permissionModeTitle("plan")).toBe("Plan Mode");
|
||||||
expect(permissionModeTitle("acceptEdits")).toBe("Accept edits");
|
expect(permissionModeTitle("acceptEdits")).toBe("Accept edits");
|
||||||
expect(permissionModeTitle("bypassPermissions")).toBe("Bypass Permissions");
|
expect(permissionModeTitle("bypassPermissions")).toBe("Bypass");
|
||||||
expect(permissionModeTitle("dontAsk")).toBe("Don't Ask");
|
expect(permissionModeTitle("dontAsk")).toBe("Don't Ask");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -57,11 +57,7 @@ export const PermissionsSchema = lazySchema(() =>
|
|||||||
'List of permission rules that should always prompt for confirmation',
|
'List of permission rules that should always prompt for confirmation',
|
||||||
),
|
),
|
||||||
defaultMode: z
|
defaultMode: z
|
||||||
.enum(
|
.enum(PERMISSION_MODES)
|
||||||
feature('TRANSCRIPT_CLASSIFIER')
|
|
||||||
? PERMISSION_MODES
|
|
||||||
: EXTERNAL_PERMISSION_MODES,
|
|
||||||
)
|
|
||||||
.optional()
|
.optional()
|
||||||
.describe('Default permission mode when Claude Code needs access'),
|
.describe('Default permission mode when Claude Code needs access'),
|
||||||
disableBypassPermissionsMode: z
|
disableBypassPermissionsMode: z
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
Updated: 2026-04-24
|
Updated: 2026-04-24
|
||||||
|
|
||||||
## Style
|
## Style
|
||||||
- Learns best with: Analogies (Memory page <-> Capsule hotel), Concrete trade-offs (Latency vs Throughput)
|
- Learns best with: Analogies (Memory page <-> Capsule hotel), Concrete trade-offs (Latency vs Throughput)
|
||||||
- Strength: Strong logical intuition regarding memory constraints.
|
- Strength: Strong logical intuition regarding memory constraints.
|
||||||
- Pace: Fast. Grasped PagedAttention/TP concepts quickly from first principles.
|
- Pace: Fast. Grasped PagedAttention/TP concepts quickly from first principles.
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user