mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-15 12:55:51 +00:00
Compare commits
10 Commits
fixture/re
...
v2.6.7
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b62b384e36 | ||
|
|
d7001b870f | ||
|
|
18437c20d2 | ||
|
|
02298cb199 | ||
|
|
b2b1981da3 | ||
|
|
33c52578a6 | ||
|
|
e33b17bde7 | ||
|
|
797424115d | ||
|
|
efc218d8a9 | ||
|
|
a91653a0dd |
@@ -10,12 +10,11 @@
|
||||
|
||||
> Which Claude do you like? The open source one is the best.
|
||||
|
||||
牢 A (Anthropic) 官方 [Claude Code](https://docs.anthropic.com/en/docs/claude-code) CLI 工具的源码反编译/逆向还原项目。目标是将 Claude Code 大部分功能及工程化能力复现 (问就是老佛爷已经付过钱了)。虽然很难绷, 但是它叫做 CCB(踩踩背)... 而且, 我们实现了企业版或者需要登陆 Claude 账号才能使用的特性, 实现技术普惠
|
||||
牢 A (Anthropic) 官方 [Claude Code](https://docs.anthropic.com/en/docs/claude-code) 完整复原的工程化项目。虽然很难绷, 但是它叫做 CCB(踩踩背)... 而且, 我们实现了企业版或者需要登陆 Claude 账号才能使用的特性, 并在此基础上扩展了更多好玩的特性。
|
||||
|
||||
> 我们将会在五一期间进行整个代码仓库的 lint 规范化, 这个期间提交的 PR 可能会有非常多的冲突, 所以大的功能请尽量在这之前提交哈
|
||||
|
||||
[文档在这里, 支持投稿 PR](https://ccb.agent-aura.top/) | [留影文档在这里](./Friends.md) | [Discord 群组](https://discord.gg/uApuzJWGKX)
|
||||
[Peri Code](https://github.com/KonghaYao/peri):Claude Code 兼容的 Rust Agent,多年大模型经验匠心制作,国内大模型(DeepSeek/GLM)精调,CPU/内存极致优化,在开发版/树莓派上也能跑 CC 一样的体验。
|
||||
|
||||
[文档在这里](https://ccb.agent-aura.top/) | [留影文档在这里](./Friends.md) | [Discord 群组,群主在线答疑](https://discord.gg/uApuzJWGKX)
|
||||
|
||||
| 特性 | 说明 | 文档 |
|
||||
| --------------------------- | ---------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
@@ -150,7 +149,6 @@ bun run build
|
||||
|
||||
需要填写的字段:
|
||||
|
||||
|
||||
| 📌 字段 | 📝 说明 | 💡 示例 |
|
||||
| ------------ | ------------- | ---------------------------- |
|
||||
| Base URL | API 服务地址 | `https://api.example.com/v1` |
|
||||
|
||||
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 2.3 MiB After Width: | Height: | Size: 2.5 MiB |
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "claude-code-best",
|
||||
"version": "2.6.5",
|
||||
"version": "2.6.6",
|
||||
"description": "Reverse-engineered Anthropic Claude Code CLI — interactive AI coding assistant in the terminal",
|
||||
"type": "module",
|
||||
"author": "claude-code-best <claude-code-best@proton.me>",
|
||||
|
||||
@@ -70,7 +70,6 @@ import {
|
||||
areFileEditsInputsEquivalent,
|
||||
findActualString,
|
||||
getPatchForEdit,
|
||||
preserveQuoteStyle,
|
||||
} from './utils.js'
|
||||
|
||||
// V8/Bun string length limit is ~2^30 characters (~1 billion). For typical
|
||||
@@ -297,7 +296,7 @@ export const FileEditTool = buildTool({
|
||||
|
||||
const file = fileContent
|
||||
|
||||
// Use findActualString to handle quote normalization
|
||||
// Use findActualString to find exact match
|
||||
const actualOldString = findActualString(file, old_string)
|
||||
if (!actualOldString) {
|
||||
return {
|
||||
@@ -452,23 +451,16 @@ export const FileEditTool = buildTool({
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Use findActualString to handle quote normalization
|
||||
// 3. Find the exact string in file content
|
||||
const actualOldString =
|
||||
findActualString(originalFileContents, old_string) || old_string
|
||||
|
||||
// Preserve curly quotes in new_string when the file uses them
|
||||
const actualNewString = preserveQuoteStyle(
|
||||
old_string,
|
||||
actualOldString,
|
||||
new_string,
|
||||
)
|
||||
|
||||
// 4. Generate patch
|
||||
const { patch, updatedFile } = getPatchForEdit({
|
||||
filePath: absoluteFilePath,
|
||||
fileContents: originalFileContents,
|
||||
oldString: actualOldString,
|
||||
newString: actualNewString,
|
||||
newString: new_string,
|
||||
replaceAll: replace_all,
|
||||
})
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ import { readEditContext } from 'src/utils/readEditContext.js';
|
||||
import { firstLineOf } from 'src/utils/stringUtils.js';
|
||||
import type { ThemeName } from 'src/utils/theme.js';
|
||||
import type { FileEditOutput } from './types.js';
|
||||
import { findActualString, getPatchForEdit, preserveQuoteStyle } from './utils.js';
|
||||
import { findActualString, getPatchForEdit } from './utils.js';
|
||||
|
||||
export function userFacingName(
|
||||
input:
|
||||
@@ -265,12 +265,11 @@ async function loadRejectionDiff(
|
||||
return { patch, firstLine: null, fileContent: undefined };
|
||||
}
|
||||
const actualOld = findActualString(ctx.content, oldString) || oldString;
|
||||
const actualNew = preserveQuoteStyle(oldString, actualOld, newString);
|
||||
const { patch } = getPatchForEdit({
|
||||
filePath,
|
||||
fileContents: ctx.content,
|
||||
oldString: actualOld,
|
||||
newString: actualNew,
|
||||
newString: newString,
|
||||
replaceAll,
|
||||
});
|
||||
return {
|
||||
|
||||
@@ -4,45 +4,8 @@ import { logMock } from '../../../../../../tests/mocks/log'
|
||||
// Mock log.ts to cut the heavy dependency chain
|
||||
mock.module('src/utils/log.ts', logMock)
|
||||
|
||||
const {
|
||||
normalizeQuotes,
|
||||
stripTrailingWhitespace,
|
||||
findActualString,
|
||||
preserveQuoteStyle,
|
||||
applyEditToFile,
|
||||
LEFT_SINGLE_CURLY_QUOTE,
|
||||
RIGHT_SINGLE_CURLY_QUOTE,
|
||||
LEFT_DOUBLE_CURLY_QUOTE,
|
||||
RIGHT_DOUBLE_CURLY_QUOTE,
|
||||
} = await import('../utils')
|
||||
|
||||
// ─── normalizeQuotes ────────────────────────────────────────────────────
|
||||
|
||||
describe('normalizeQuotes', () => {
|
||||
test('converts left single curly to straight', () => {
|
||||
expect(normalizeQuotes(`${LEFT_SINGLE_CURLY_QUOTE}hello`)).toBe("'hello")
|
||||
})
|
||||
|
||||
test('converts right single curly to straight', () => {
|
||||
expect(normalizeQuotes(`hello${RIGHT_SINGLE_CURLY_QUOTE}`)).toBe("hello'")
|
||||
})
|
||||
|
||||
test('converts left double curly to straight', () => {
|
||||
expect(normalizeQuotes(`${LEFT_DOUBLE_CURLY_QUOTE}hello`)).toBe('"hello')
|
||||
})
|
||||
|
||||
test('converts right double curly to straight', () => {
|
||||
expect(normalizeQuotes(`hello${RIGHT_DOUBLE_CURLY_QUOTE}`)).toBe('hello"')
|
||||
})
|
||||
|
||||
test('leaves straight quotes unchanged', () => {
|
||||
expect(normalizeQuotes('\'hello\' "world"')).toBe('\'hello\' "world"')
|
||||
})
|
||||
|
||||
test('handles empty string', () => {
|
||||
expect(normalizeQuotes('')).toBe('')
|
||||
})
|
||||
})
|
||||
const { stripTrailingWhitespace, findActualString, applyEditToFile } =
|
||||
await import('../utils')
|
||||
|
||||
// ─── stripTrailingWhitespace ────────────────────────────────────────────
|
||||
|
||||
@@ -91,12 +54,6 @@ describe('findActualString', () => {
|
||||
expect(findActualString('hello world', 'hello')).toBe('hello')
|
||||
})
|
||||
|
||||
test('finds match with curly quotes normalized', () => {
|
||||
const fileContent = `${LEFT_DOUBLE_CURLY_QUOTE}hello${RIGHT_DOUBLE_CURLY_QUOTE}`
|
||||
const result = findActualString(fileContent, '"hello"')
|
||||
expect(result).not.toBeNull()
|
||||
})
|
||||
|
||||
test('returns null when not found', () => {
|
||||
expect(findActualString('hello world', 'xyz')).toBeNull()
|
||||
})
|
||||
@@ -107,124 +64,13 @@ describe('findActualString', () => {
|
||||
expect(result).toBe('')
|
||||
})
|
||||
|
||||
// ── Tab/space normalization (Bug #2 reproduction) ──
|
||||
|
||||
test('finds match when search uses spaces but file uses tabs', () => {
|
||||
// File content uses Tab indentation
|
||||
const fileContent = '\tif (x) {\n\t\treturn 1;\n\t}'
|
||||
// User copies from Read output which renders tabs as spaces
|
||||
const searchWithSpaces = ' if (x) {\n return 1;\n }'
|
||||
const result = findActualString(fileContent, searchWithSpaces)
|
||||
expect(result).not.toBeNull()
|
||||
expect(result).toBe(fileContent)
|
||||
})
|
||||
|
||||
test('finds match when search mixes tabs and spaces inconsistently', () => {
|
||||
const fileContent = '\tconst x = 1; // comment'
|
||||
const searchMixed = ' const x = 1; // comment'
|
||||
const result = findActualString(fileContent, searchMixed)
|
||||
expect(result).not.toBeNull()
|
||||
})
|
||||
|
||||
test('finds match for single-line tab-to-space mismatch', () => {
|
||||
const fileContent = '\t\torder_price = NormalizeDouble(ask, digits);'
|
||||
const searchSpaces = ' order_price = NormalizeDouble(ask, digits);'
|
||||
const result = findActualString(fileContent, searchSpaces)
|
||||
expect(result).not.toBeNull()
|
||||
})
|
||||
|
||||
// ── CJK / UTF-8 characters (Bug #1 reproduction) ──
|
||||
// ── CJK / UTF-8 characters ──
|
||||
|
||||
test('finds match with CJK characters in content', () => {
|
||||
const fileContent = 'input int x = 620; // 止盈点数(点) — 32个pip=320点'
|
||||
const result = findActualString(fileContent, fileContent)
|
||||
expect(result).toBe(fileContent)
|
||||
})
|
||||
|
||||
test('finds match with CJK characters when tab/space differs', () => {
|
||||
const fileContent = '\t// 向上突破 → Sell Limit (逆方向做空)'
|
||||
const searchSpaces = ' // 向上突破 → Sell Limit (逆方向做空)'
|
||||
const result = findActualString(fileContent, searchSpaces)
|
||||
expect(result).not.toBeNull()
|
||||
expect(result).toBe(fileContent)
|
||||
})
|
||||
|
||||
// ── Multiline with tabs + CJK (combined Bug #1 + #2) ──
|
||||
|
||||
test('finds multiline match with tabs and CJK characters', () => {
|
||||
const fileContent =
|
||||
'\tif(effective_dir == BREAKOUT_UP)\n\t\t{\n\t\t\t// 向上突破\n\t\t}'
|
||||
const searchSpaces =
|
||||
' if(effective_dir == BREAKOUT_UP)\n {\n // 向上突破\n }'
|
||||
const result = findActualString(fileContent, searchSpaces)
|
||||
expect(result).not.toBeNull()
|
||||
expect(result).toBe(fileContent)
|
||||
})
|
||||
|
||||
// ── Returned string must be a valid substring of fileContent ──
|
||||
|
||||
test('returned string from tab match is a real substring of fileContent', () => {
|
||||
const fileContent = 'prefix\n\t\tindented code\nsuffix'
|
||||
const searchSpaces = 'prefix\n indented code\nsuffix'
|
||||
const result = findActualString(fileContent, searchSpaces)
|
||||
expect(result).not.toBeNull()
|
||||
expect(fileContent.includes(result!)).toBe(true)
|
||||
})
|
||||
|
||||
test('returned string from partial tab match is a real substring', () => {
|
||||
const fileContent = 'line1\n\tif (x) {\n\t\tdoStuff();\n\t}\nline5'
|
||||
const searchSpaces = ' if (x) {\n doStuff();\n }'
|
||||
const result = findActualString(fileContent, searchSpaces)
|
||||
expect(result).not.toBeNull()
|
||||
expect(fileContent.includes(result!)).toBe(true)
|
||||
})
|
||||
|
||||
test('tab match with mixed indentation levels', () => {
|
||||
const fileContent =
|
||||
'class Foo {\n\t\tmethod1() {\n\t\t\treturn 42;\n\t\t}\n}'
|
||||
const searchSpaces =
|
||||
'class Foo {\n method1() {\n return 42;\n }\n}'
|
||||
const result = findActualString(fileContent, searchSpaces)
|
||||
expect(result).not.toBeNull()
|
||||
expect(fileContent.includes(result!)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
// ─── preserveQuoteStyle ─────────────────────────────────────────────────
|
||||
|
||||
describe('preserveQuoteStyle', () => {
|
||||
test('returns newString unchanged when no normalization happened', () => {
|
||||
expect(preserveQuoteStyle('hello', 'hello', 'world')).toBe('world')
|
||||
})
|
||||
|
||||
test('converts straight double quotes to curly in replacement', () => {
|
||||
const oldString = '"hello"'
|
||||
const actualOldString = `${LEFT_DOUBLE_CURLY_QUOTE}hello${RIGHT_DOUBLE_CURLY_QUOTE}`
|
||||
const newString = '"world"'
|
||||
const result = preserveQuoteStyle(oldString, actualOldString, newString)
|
||||
expect(result).toContain(LEFT_DOUBLE_CURLY_QUOTE)
|
||||
expect(result).toContain(RIGHT_DOUBLE_CURLY_QUOTE)
|
||||
})
|
||||
|
||||
test('converts straight single quotes to curly in replacement', () => {
|
||||
const oldString = "'hello'"
|
||||
const actualOldString = `${LEFT_SINGLE_CURLY_QUOTE}hello${RIGHT_SINGLE_CURLY_QUOTE}`
|
||||
const newString = "'world'"
|
||||
const result = preserveQuoteStyle(oldString, actualOldString, newString)
|
||||
expect(result).toContain(LEFT_SINGLE_CURLY_QUOTE)
|
||||
expect(result).toContain(RIGHT_SINGLE_CURLY_QUOTE)
|
||||
})
|
||||
|
||||
test('treats apostrophe in contraction as right curly quote', () => {
|
||||
const oldString = "'it's a test'"
|
||||
const actualOldString = `${LEFT_SINGLE_CURLY_QUOTE}it${RIGHT_SINGLE_CURLY_QUOTE}s a test${RIGHT_SINGLE_CURLY_QUOTE}`
|
||||
const newString = "'don't worry'"
|
||||
const result = preserveQuoteStyle(oldString, actualOldString, newString)
|
||||
// The leading ' at position 0 should be LEFT_SINGLE_CURLY_QUOTE
|
||||
expect(result[0]).toBe(LEFT_SINGLE_CURLY_QUOTE)
|
||||
// The apostrophe in "don't" (between n and t) should be RIGHT_SINGLE_CURLY_QUOTE
|
||||
expect(result).toContain(RIGHT_SINGLE_CURLY_QUOTE)
|
||||
})
|
||||
})
|
||||
|
||||
// ─── applyEditToFile ────────────────────────────────────────────────────
|
||||
|
||||
@@ -15,27 +15,6 @@ import {
|
||||
} from 'src/utils/file.js'
|
||||
import type { EditInput, FileEdit } from './types.js'
|
||||
|
||||
// Claude can't output curly quotes, so we define them as constants here for Claude to use
|
||||
// in the code. We do this because we normalize curly quotes to straight quotes
|
||||
// when applying edits.
|
||||
export const LEFT_SINGLE_CURLY_QUOTE = '‘'
|
||||
export const RIGHT_SINGLE_CURLY_QUOTE = '’'
|
||||
export const LEFT_DOUBLE_CURLY_QUOTE = '“'
|
||||
export const RIGHT_DOUBLE_CURLY_QUOTE = '”'
|
||||
|
||||
/**
|
||||
* Normalizes quotes in a string by converting curly quotes to straight quotes
|
||||
* @param str The string to normalize
|
||||
* @returns The string with all curly quotes replaced by straight quotes
|
||||
*/
|
||||
export function normalizeQuotes(str: string): string {
|
||||
return str
|
||||
.replaceAll(LEFT_SINGLE_CURLY_QUOTE, "'")
|
||||
.replaceAll(RIGHT_SINGLE_CURLY_QUOTE, "'")
|
||||
.replaceAll(LEFT_DOUBLE_CURLY_QUOTE, '"')
|
||||
.replaceAll(RIGHT_DOUBLE_CURLY_QUOTE, '"')
|
||||
}
|
||||
|
||||
/**
|
||||
* Strips trailing whitespace from each line in a string while preserving line endings
|
||||
* @param str The string to process
|
||||
@@ -64,261 +43,22 @@ export function stripTrailingWhitespace(str: string): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes whitespace for fuzzy matching by converting tabs to spaces
|
||||
* and collapsing leading whitespace on each line to a canonical form.
|
||||
* This handles the case where Read tool output renders tabs as spaces,
|
||||
* so users copy spaces from the output but the file actually has tabs.
|
||||
*/
|
||||
function normalizeWhitespace(str: string): string {
|
||||
return str.replace(/\t/g, ' ')
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the actual string in the file content that matches the search string,
|
||||
* accounting for quote normalization and tab/space differences.
|
||||
*
|
||||
* Matching cascade:
|
||||
* 1. Exact match
|
||||
* 2. Quote normalization (curly → straight quotes)
|
||||
* 3. Tab/space normalization (tabs ↔ spaces in leading whitespace)
|
||||
* 4. Quote + tab/space normalization combined
|
||||
* Finds the exact string in the file content.
|
||||
*
|
||||
* @param fileContent The file content to search in
|
||||
* @param searchString The string to search for
|
||||
* @returns The actual string found in the file, or null if not found
|
||||
* @returns The search string if found, or null if not found
|
||||
*/
|
||||
export function findActualString(
|
||||
fileContent: string,
|
||||
searchString: string,
|
||||
): string | null {
|
||||
// First try exact match
|
||||
if (fileContent.includes(searchString)) {
|
||||
return searchString
|
||||
}
|
||||
|
||||
// Try with normalized quotes
|
||||
const normalizedSearch = normalizeQuotes(searchString)
|
||||
const normalizedFile = normalizeQuotes(fileContent)
|
||||
|
||||
const searchIndex = normalizedFile.indexOf(normalizedSearch)
|
||||
if (searchIndex !== -1) {
|
||||
// Find the actual string in the file that matches
|
||||
return fileContent.substring(searchIndex, searchIndex + searchString.length)
|
||||
}
|
||||
|
||||
// Try with tab/space normalization — handles the case where Read output
|
||||
// renders tabs as spaces and the user copies the rendered version
|
||||
const wsNormalizedFile = normalizeWhitespace(fileContent)
|
||||
const wsNormalizedSearch = normalizeWhitespace(searchString)
|
||||
|
||||
const wsSearchIndex = wsNormalizedFile.indexOf(wsNormalizedSearch)
|
||||
if (wsSearchIndex !== -1) {
|
||||
// Map the match position back to the original file content.
|
||||
// We need to find the corresponding range in the original string.
|
||||
return mapNormalizedMatchBackToFile(
|
||||
fileContent,
|
||||
wsNormalizedFile,
|
||||
wsSearchIndex,
|
||||
wsNormalizedSearch.length,
|
||||
)
|
||||
}
|
||||
|
||||
// Try combined: quote normalization + tab/space normalization
|
||||
const combinedFile = normalizeWhitespace(normalizedFile)
|
||||
const combinedSearch = normalizeWhitespace(normalizedSearch)
|
||||
|
||||
const combinedIndex = combinedFile.indexOf(combinedSearch)
|
||||
if (combinedIndex !== -1) {
|
||||
return mapNormalizedMatchBackToFile(
|
||||
fileContent,
|
||||
combinedFile,
|
||||
combinedIndex,
|
||||
combinedSearch.length,
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Given a match found in a normalized version of fileContent, map the match
|
||||
* position back to the original fileContent and extract the corresponding
|
||||
* substring.
|
||||
*
|
||||
* Strategy: walk through both strings character by character, building a
|
||||
* mapping from normalized offset to original offset. When a tab is expanded
|
||||
* to 4 spaces in the normalized version, the normalized offset advances by 4
|
||||
* while the original offset advances by 1.
|
||||
*/
|
||||
function mapNormalizedMatchBackToFile(
|
||||
fileContent: string,
|
||||
normalizedFile: string,
|
||||
normalizedStart: number,
|
||||
normalizedLength: number,
|
||||
): string {
|
||||
// Build a sparse mapping from normalized position → original position.
|
||||
// We only need to map the range [normalizedStart, normalizedStart + normalizedLength].
|
||||
let normPos = 0
|
||||
let origPos = 0
|
||||
let origStart = -1
|
||||
let origEnd = -1
|
||||
|
||||
while (
|
||||
origPos < fileContent.length &&
|
||||
normPos <= normalizedStart + normalizedLength
|
||||
) {
|
||||
if (normPos === normalizedStart) {
|
||||
origStart = origPos
|
||||
}
|
||||
if (normPos === normalizedStart + normalizedLength) {
|
||||
origEnd = origPos
|
||||
break
|
||||
}
|
||||
|
||||
const origChar = fileContent[origPos]!
|
||||
if (origChar === '\t') {
|
||||
// Tab expands to 4 spaces in normalized version
|
||||
const nextNormPos = normPos + 4
|
||||
// If normalizedStart falls within this expanded tab, snap to origPos
|
||||
if (
|
||||
normPos < normalizedStart &&
|
||||
nextNormPos > normalizedStart &&
|
||||
origStart === -1
|
||||
) {
|
||||
origStart = origPos
|
||||
}
|
||||
if (
|
||||
normPos < normalizedStart + normalizedLength &&
|
||||
nextNormPos > normalizedStart + normalizedLength &&
|
||||
origEnd === -1
|
||||
) {
|
||||
origEnd = origPos + 1
|
||||
}
|
||||
normPos = nextNormPos
|
||||
origPos++
|
||||
} else {
|
||||
normPos++
|
||||
origPos++
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: if we couldn't map precisely, use character-count heuristic
|
||||
if (origStart === -1) origStart = 0
|
||||
if (origEnd === -1) {
|
||||
// Approximate: use the ratio of original to normalized length
|
||||
const ratio = fileContent.length / normalizedFile.length
|
||||
origEnd = Math.round(origStart + normalizedLength * ratio)
|
||||
}
|
||||
|
||||
return fileContent.substring(origStart, origEnd)
|
||||
}
|
||||
|
||||
/**
|
||||
* When old_string matched via quote normalization (curly quotes in file,
|
||||
* straight quotes from model), apply the same curly quote style to new_string
|
||||
* so the edit preserves the file's typography.
|
||||
*
|
||||
* Uses a simple open/close heuristic: a quote character preceded by whitespace,
|
||||
* start of string, or opening punctuation is treated as an opening quote;
|
||||
* otherwise it's a closing quote.
|
||||
*/
|
||||
export function preserveQuoteStyle(
|
||||
oldString: string,
|
||||
actualOldString: string,
|
||||
newString: string,
|
||||
): string {
|
||||
// If they're the same, no normalization happened
|
||||
if (oldString === actualOldString) {
|
||||
return newString
|
||||
}
|
||||
|
||||
// Detect which curly quote types were in the file
|
||||
const hasDoubleQuotes =
|
||||
actualOldString.includes(LEFT_DOUBLE_CURLY_QUOTE) ||
|
||||
actualOldString.includes(RIGHT_DOUBLE_CURLY_QUOTE)
|
||||
const hasSingleQuotes =
|
||||
actualOldString.includes(LEFT_SINGLE_CURLY_QUOTE) ||
|
||||
actualOldString.includes(RIGHT_SINGLE_CURLY_QUOTE)
|
||||
|
||||
if (!hasDoubleQuotes && !hasSingleQuotes) {
|
||||
return newString
|
||||
}
|
||||
|
||||
let result = newString
|
||||
|
||||
if (hasDoubleQuotes) {
|
||||
result = applyCurlyDoubleQuotes(result)
|
||||
}
|
||||
if (hasSingleQuotes) {
|
||||
result = applyCurlySingleQuotes(result)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
function isOpeningContext(chars: string[], index: number): boolean {
|
||||
if (index === 0) {
|
||||
return true
|
||||
}
|
||||
const prev = chars[index - 1]
|
||||
return (
|
||||
prev === ' ' ||
|
||||
prev === '\t' ||
|
||||
prev === '\n' ||
|
||||
prev === '\r' ||
|
||||
prev === '(' ||
|
||||
prev === '[' ||
|
||||
prev === '{' ||
|
||||
prev === '\u2014' || // em dash
|
||||
prev === '\u2013' // en dash
|
||||
)
|
||||
}
|
||||
|
||||
function applyCurlyDoubleQuotes(str: string): string {
|
||||
const chars = [...str]
|
||||
const result: string[] = []
|
||||
for (let i = 0; i < chars.length; i++) {
|
||||
if (chars[i] === '"') {
|
||||
result.push(
|
||||
isOpeningContext(chars, i)
|
||||
? LEFT_DOUBLE_CURLY_QUOTE
|
||||
: RIGHT_DOUBLE_CURLY_QUOTE,
|
||||
)
|
||||
} else {
|
||||
result.push(chars[i]!)
|
||||
}
|
||||
}
|
||||
return result.join('')
|
||||
}
|
||||
|
||||
function applyCurlySingleQuotes(str: string): string {
|
||||
const chars = [...str]
|
||||
const result: string[] = []
|
||||
for (let i = 0; i < chars.length; i++) {
|
||||
if (chars[i] === "'") {
|
||||
// Don't convert apostrophes in contractions (e.g., "don't", "it's")
|
||||
// An apostrophe between two letters is a contraction, not a quote
|
||||
const prev = i > 0 ? chars[i - 1] : undefined
|
||||
const next = i < chars.length - 1 ? chars[i + 1] : undefined
|
||||
const prevIsLetter = prev !== undefined && /\p{L}/u.test(prev)
|
||||
const nextIsLetter = next !== undefined && /\p{L}/u.test(next)
|
||||
if (prevIsLetter && nextIsLetter) {
|
||||
// Apostrophe in a contraction — use right single curly quote
|
||||
result.push(RIGHT_SINGLE_CURLY_QUOTE)
|
||||
} else {
|
||||
result.push(
|
||||
isOpeningContext(chars, i)
|
||||
? LEFT_SINGLE_CURLY_QUOTE
|
||||
: RIGHT_SINGLE_CURLY_QUOTE,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
result.push(chars[i]!)
|
||||
}
|
||||
}
|
||||
return result.join('')
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform edits to ensure replace_all always has a boolean value
|
||||
* @param edits Array of edits with optional replace_all
|
||||
|
||||
@@ -4966,7 +4966,7 @@ function handleChannelEnable(
|
||||
// channel messages queue at priority 'next' and are seen by the model on
|
||||
// the turn after they arrive.
|
||||
connection.client.setNotificationHandler(
|
||||
ChannelMessageNotificationSchema(),
|
||||
ChannelMessageNotificationSchema() as any,
|
||||
async notification => {
|
||||
const { content, meta } = notification.params
|
||||
logMCPDebug(
|
||||
@@ -5042,7 +5042,7 @@ function reregisterChannelHandlerAfterReconnect(
|
||||
'Channel notifications re-registered after reconnect',
|
||||
)
|
||||
connection.client.setNotificationHandler(
|
||||
ChannelMessageNotificationSchema(),
|
||||
ChannelMessageNotificationSchema() as any,
|
||||
async notification => {
|
||||
const { content, meta } = notification.params
|
||||
logMCPDebug(
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Suspense, use, useState } from 'react';
|
||||
import { useTerminalSize } from '../hooks/useTerminalSize.js';
|
||||
import { Box, Text } from '@anthropic/ink';
|
||||
import type { FileEdit } from '@claude-code-best/builtin-tools/tools/FileEditTool/types.js';
|
||||
import { findActualString, preserveQuoteStyle } from '@claude-code-best/builtin-tools/tools/FileEditTool/utils.js';
|
||||
import { findActualString } from '@claude-code-best/builtin-tools/tools/FileEditTool/utils.js';
|
||||
import { adjustHunkLineNumbers, CONTEXT_LINES, getPatchForDisplay } from '../utils/diff.js';
|
||||
import { logError } from '../utils/log.js';
|
||||
import { CHUNK_SIZE, openForScan, readCapped, scanForContext } from '../utils/readEditContext.js';
|
||||
@@ -135,6 +135,5 @@ function diffToolInputsOnly(filePath: string, edits: FileEdit[]): DiffData {
|
||||
|
||||
function normalizeEdit(fileContent: string, edit: FileEdit): FileEdit {
|
||||
const actualOld = findActualString(fileContent, edit.old_string) || edit.old_string;
|
||||
const actualNew = preserveQuoteStyle(edit.old_string, actualOld, edit.new_string);
|
||||
return { ...edit, old_string: actualOld, new_string: actualNew };
|
||||
return { ...edit, old_string: actualOld };
|
||||
}
|
||||
|
||||
@@ -146,7 +146,7 @@ async function main(): Promise<void> {
|
||||
shutdown1PEventLogging,
|
||||
logForDebugging,
|
||||
registerPermissionHandler(server, handler) {
|
||||
server.setNotificationHandler(ChannelPermissionRequestNotificationSchema(), async notification =>
|
||||
server.setNotificationHandler(ChannelPermissionRequestNotificationSchema() as any, async notification =>
|
||||
handler(notification.params),
|
||||
);
|
||||
},
|
||||
|
||||
@@ -47,7 +47,7 @@ export function useIdeAtMentioned(
|
||||
// If we found a connected IDE client, register our handler
|
||||
if (ideClient) {
|
||||
ideClient.client.setNotificationHandler(
|
||||
AtMentionedSchema(),
|
||||
AtMentionedSchema() as any,
|
||||
notification => {
|
||||
if (ideClientRef.current !== ideClient) {
|
||||
return
|
||||
|
||||
@@ -27,7 +27,7 @@ export function useIdeLogging(mcpClients: MCPServerConnection[]): void {
|
||||
if (ideClient) {
|
||||
// Register the log event handler
|
||||
ideClient.client.setNotificationHandler(
|
||||
LogEventSchema(),
|
||||
LogEventSchema() as any,
|
||||
notification => {
|
||||
const { eventName, eventData } = notification.params
|
||||
logEvent(
|
||||
|
||||
@@ -110,7 +110,7 @@ export function useIdeSelection(
|
||||
|
||||
// Register notification handler for selection_changed events
|
||||
ideClient.client.setNotificationHandler(
|
||||
SelectionChangedSchema(),
|
||||
SelectionChangedSchema() as any,
|
||||
notification => {
|
||||
if (currentIDERef.current !== ideClient) {
|
||||
return
|
||||
|
||||
@@ -48,7 +48,7 @@ export function usePromptsFromClaudeInChrome(
|
||||
}
|
||||
|
||||
if (mcpClient) {
|
||||
mcpClient.client.setNotificationHandler(ClaudeInChromePromptNotificationSchema(), notification => {
|
||||
mcpClient.client.setNotificationHandler(ClaudeInChromePromptNotificationSchema() as any, notification => {
|
||||
if (mcpClientRef.current !== mcpClient) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -504,7 +504,7 @@ export function useManageMCPConnections(
|
||||
case 'register':
|
||||
logMCPDebug(client.name, 'Channel notifications registered')
|
||||
client.client.setNotificationHandler(
|
||||
ChannelMessageNotificationSchema(),
|
||||
ChannelMessageNotificationSchema() as any,
|
||||
async notification => {
|
||||
const { content, meta } = notification.params
|
||||
logMCPDebug(
|
||||
@@ -539,7 +539,7 @@ export function useManageMCPConnections(
|
||||
client.capabilities?.experimental?.['claude/channel/permission']
|
||||
) {
|
||||
client.client.setNotificationHandler(
|
||||
ChannelPermissionNotificationSchema(),
|
||||
ChannelPermissionNotificationSchema() as any,
|
||||
async notification => {
|
||||
const { request_id, behavior } = notification.params
|
||||
const resolved =
|
||||
|
||||
@@ -69,7 +69,7 @@ export function setupVscodeSdkMcp(sdkClients: MCPServerConnection[]): void {
|
||||
vscodeMcpClient = client
|
||||
|
||||
client.client.setNotificationHandler(
|
||||
LogEventNotificationSchema(),
|
||||
LogEventNotificationSchema() as any,
|
||||
async notification => {
|
||||
const { eventName, eventData } = notification.params
|
||||
logEvent(
|
||||
|
||||
@@ -385,7 +385,7 @@ export function searchSkills(
|
||||
index: SkillIndexEntry[],
|
||||
limit = 5,
|
||||
): SearchResult[] {
|
||||
if (index.length === 0 || !query.trim()) return []
|
||||
if (index.length === 0 || !query?.trim()) return []
|
||||
|
||||
const queryTokens = tokenizeAndStem(query)
|
||||
if (queryTokens.length === 0) return []
|
||||
@@ -397,7 +397,7 @@ export function searchSkills(
|
||||
for (const v of freq.values()) if (v > max) max = v
|
||||
for (const [term, count] of freq) queryTf.set(term, count / max)
|
||||
|
||||
const idf = cachedIdf ?? computeIdf(index)
|
||||
const idf = cachedIndex === index && cachedIdf ? cachedIdf : computeIdf(index)
|
||||
const queryTfIdf = new Map<string, number>()
|
||||
for (const [term, tf] of queryTf) {
|
||||
queryTfIdf.set(term, tf * (idf.get(term) ?? 0))
|
||||
|
||||
@@ -610,3 +610,179 @@ describe('ensureToolResultPairing', () => {
|
||||
expect(lastMsg.type).toBe('user')
|
||||
})
|
||||
})
|
||||
|
||||
// ─── CC-1215: normalizeMessagesForAPI must not merge assistants across tool_results ──
|
||||
|
||||
describe('normalizeMessagesForAPI – thinking + tool_use same turn (CC-1215)', () => {
|
||||
test('does not merge same-id assistants across a tool_result boundary', () => {
|
||||
// Simulate the streaming sequence when extended thinking + tool_use appear
|
||||
// in the same turn, and StreamingToolExecutor inserts a tool_result
|
||||
// between the two assistant content-block messages.
|
||||
const sharedMessageId = 'msg_shared_001'
|
||||
const toolUseId = 'toolu_cc1215'
|
||||
|
||||
// assistant[thinking] — first content_block_stop yield
|
||||
const thinkingMsg = createAssistantMessage({
|
||||
content: [
|
||||
{ type: 'thinking', thinking: 'Let me think...', signature: 'sig1' },
|
||||
],
|
||||
})
|
||||
thinkingMsg.message.id = sharedMessageId
|
||||
|
||||
// user[tool_result] — from StreamingToolExecutor completing fast
|
||||
const toolResultMsg = createUserMessage({
|
||||
content: [
|
||||
{
|
||||
type: 'tool_result',
|
||||
tool_use_id: toolUseId,
|
||||
content: '/home/user',
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
// assistant[tool_use] — second content_block_stop yield
|
||||
const toolUseMsg = createAssistantMessage({
|
||||
content: [
|
||||
{
|
||||
type: 'tool_use',
|
||||
id: toolUseId,
|
||||
name: 'Bash',
|
||||
input: { command: 'pwd' },
|
||||
},
|
||||
],
|
||||
})
|
||||
toolUseMsg.message.id = sharedMessageId
|
||||
|
||||
const messages: Message[] = [
|
||||
makeUserMsg('Run pwd'),
|
||||
thinkingMsg,
|
||||
toolResultMsg,
|
||||
toolUseMsg,
|
||||
]
|
||||
|
||||
const result = normalizeMessagesForAPI(messages)
|
||||
|
||||
// Before the fix, the backward walk would skip the tool_result and merge
|
||||
// thinking + tool_use into one assistant. This produced duplicate tool_use
|
||||
// IDs after ensureToolResultPairing ran, leading to orphaned tool_results
|
||||
// and consecutive user messages → API 400.
|
||||
//
|
||||
// After the fix, the backward walk stops at the tool_result, so the two
|
||||
// assistants remain separate. The result should have 4 messages:
|
||||
// user, assistant[thinking], user[tool_result], assistant[tool_use]
|
||||
expect(result).toHaveLength(4)
|
||||
expect(result[0]!.type).toBe('user')
|
||||
expect(result[1]!.type).toBe('assistant')
|
||||
expect(result[2]!.type).toBe('user')
|
||||
expect(result[3]!.type).toBe('assistant')
|
||||
|
||||
// The thinking assistant should NOT have been merged with the tool_use one
|
||||
const thinkingAssistant = result[1] as AssistantMessage
|
||||
const thinkingContent = thinkingAssistant.message.content as Array<{
|
||||
type: string
|
||||
}>
|
||||
expect(thinkingContent.some(b => b.type === 'tool_use')).toBe(false)
|
||||
|
||||
const toolUseAssistant = result[3] as AssistantMessage
|
||||
const toolUseContent = toolUseAssistant.message.content as Array<{
|
||||
type: string
|
||||
}>
|
||||
expect(toolUseContent.some(b => b.type === 'tool_use')).toBe(true)
|
||||
})
|
||||
|
||||
test('still merges consecutive same-id assistants without intervening tool_result', () => {
|
||||
const sharedMessageId = 'msg_shared_002'
|
||||
|
||||
const thinkingMsg = createAssistantMessage({
|
||||
content: [{ type: 'thinking', thinking: 'Hmm', signature: 'sig2' }],
|
||||
})
|
||||
thinkingMsg.message.id = sharedMessageId
|
||||
|
||||
const toolUseMsg = createAssistantMessage({
|
||||
content: [
|
||||
{
|
||||
type: 'tool_use',
|
||||
id: 'toolu_merge',
|
||||
name: 'Bash',
|
||||
input: { command: 'ls' },
|
||||
},
|
||||
],
|
||||
})
|
||||
toolUseMsg.message.id = sharedMessageId
|
||||
|
||||
// No tool_result between them — they should still be merged
|
||||
const messages: Message[] = [
|
||||
makeUserMsg('List files'),
|
||||
thinkingMsg,
|
||||
toolUseMsg,
|
||||
]
|
||||
|
||||
const result = normalizeMessagesForAPI(messages)
|
||||
|
||||
// Should be: user, assistant[thinking + tool_use]
|
||||
expect(result).toHaveLength(2)
|
||||
expect(result[0]!.type).toBe('user')
|
||||
|
||||
const merged = result[1] as AssistantMessage
|
||||
const content = merged.message.content as Array<{ type: string }>
|
||||
expect(content.some(b => b.type === 'thinking')).toBe(true)
|
||||
expect(content.some(b => b.type === 'tool_use')).toBe(true)
|
||||
})
|
||||
|
||||
test('full pipeline: normalize + ensureToolResultPairing produces valid role alternation', () => {
|
||||
const sharedMessageId = 'msg_shared_003'
|
||||
const toolUseId = 'toolu_pipeline'
|
||||
|
||||
const thinkingMsg = createAssistantMessage({
|
||||
content: [
|
||||
{ type: 'thinking', thinking: 'Planning...', signature: 'sig3' },
|
||||
],
|
||||
})
|
||||
thinkingMsg.message.id = sharedMessageId
|
||||
|
||||
const toolResultMsg = createUserMessage({
|
||||
content: [
|
||||
{
|
||||
type: 'tool_result',
|
||||
tool_use_id: toolUseId,
|
||||
content: 'file.txt',
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const toolUseMsg = createAssistantMessage({
|
||||
content: [
|
||||
{
|
||||
type: 'tool_use',
|
||||
id: toolUseId,
|
||||
name: 'Bash',
|
||||
input: { command: 'ls' },
|
||||
},
|
||||
],
|
||||
})
|
||||
toolUseMsg.message.id = sharedMessageId
|
||||
|
||||
// Full pipeline: normalize → ensureToolResultPairing
|
||||
const normalized = normalizeMessagesForAPI([
|
||||
makeUserMsg('Run ls'),
|
||||
thinkingMsg,
|
||||
toolResultMsg,
|
||||
toolUseMsg,
|
||||
])
|
||||
const result = ensureToolResultPairing(normalized)
|
||||
|
||||
// Verify strict role alternation: user → assistant → user → assistant → ...
|
||||
for (let i = 1; i < result.length; i++) {
|
||||
const prev = result[i - 1]!
|
||||
const curr = result[i]!
|
||||
if (prev.type === 'user' && curr.type === 'user') {
|
||||
expect.unreachable(`Consecutive user messages at index ${i - 1}-${i}`)
|
||||
}
|
||||
if (prev.type === 'assistant' && curr.type === 'assistant') {
|
||||
expect.unreachable(
|
||||
`Consecutive assistant messages at index ${i - 1}-${i}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
|
||||
import { getOauthConfig } from '../constants/oauth.js'
|
||||
import { isEnvTruthy } from './envUtils.js'
|
||||
import { isEssentialTrafficOnly } from './privacyLevel.js'
|
||||
|
||||
let fired = false
|
||||
|
||||
@@ -32,6 +33,10 @@ export function preconnectAnthropicApi(): void {
|
||||
if (fired) return
|
||||
fired = true
|
||||
|
||||
// Also skip when non-essential traffic is disabled via
|
||||
// CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC / DISABLE_TELEMETRY / proxy env.
|
||||
if (isEssentialTrafficOnly()) return
|
||||
|
||||
// Skip if using a cloud provider — different endpoint + auth
|
||||
if (
|
||||
isEnvTruthy(process.env.CLAUDE_CODE_USE_BEDROCK) ||
|
||||
|
||||
@@ -2541,21 +2541,26 @@ export function normalizeMessagesForAPI(
|
||||
}
|
||||
|
||||
// Find a previous assistant message with the same message ID and merge.
|
||||
// Walk backwards, skipping tool results and different-ID assistants,
|
||||
// since concurrent agents (teammates) can interleave streaming content
|
||||
// blocks from multiple API responses with different message IDs.
|
||||
// Walk backwards, skipping different-ID assistants, since concurrent
|
||||
// agents (teammates) can interleave streaming content blocks from
|
||||
// multiple API responses with different message IDs.
|
||||
//
|
||||
// Do NOT skip tool_result messages — when claude.ts yields separate
|
||||
// AssistantMessages for thinking and tool_use blocks (same message.id),
|
||||
// a StreamingToolExecutor tool_result can land between them. Merging
|
||||
// across that boundary produces duplicate tool_use IDs that downstream
|
||||
// ensureToolResultPairing strips, leaving orphaned tool_results and
|
||||
// ultimately consecutive user messages → API 400 (CC-1215).
|
||||
for (let i = result.length - 1; i >= 0; i--) {
|
||||
const msg = result[i]!
|
||||
|
||||
if (msg.type !== 'assistant' && !isToolResultMessage(msg)) {
|
||||
if (msg.type !== 'assistant') {
|
||||
break
|
||||
}
|
||||
|
||||
if (msg.type === 'assistant') {
|
||||
if (msg.message.id === normalizedMessage.message.id) {
|
||||
result[i] = mergeAssistantMessages(msg, normalizedMessage)
|
||||
return
|
||||
}
|
||||
if (msg.message.id === normalizedMessage.message.id) {
|
||||
result[i] = mergeAssistantMessages(msg, normalizedMessage)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -120,6 +120,19 @@ export function getBestModel(): ModelName {
|
||||
return getDefaultOpusModel()
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the provider's primary model from its env var (e.g. OPENAI_MODEL).
|
||||
* Returns undefined for providers that don't have a primary-model env var
|
||||
* (Bedrock, Vertex, Foundry, firstParty).
|
||||
*/
|
||||
function getProviderPrimaryModel(): ModelName | undefined {
|
||||
const provider = getAPIProvider()
|
||||
if (provider === 'openai') return process.env.OPENAI_MODEL
|
||||
if (provider === 'gemini') return process.env.GEMINI_MODEL
|
||||
if (provider === 'grok') return process.env.GROK_MODEL
|
||||
return undefined
|
||||
}
|
||||
|
||||
// @[MODEL LAUNCH]: Update the default Opus model (3P providers may lag so keep defaults unchanged).
|
||||
export function getDefaultOpusModel(): ModelName {
|
||||
const provider = getAPIProvider()
|
||||
@@ -138,10 +151,12 @@ export function getDefaultOpusModel(): ModelName {
|
||||
if (process.env.ANTHROPIC_DEFAULT_OPUS_MODEL) {
|
||||
return process.env.ANTHROPIC_DEFAULT_OPUS_MODEL
|
||||
}
|
||||
// 3P providers (Bedrock, Vertex, Foundry) all publish Opus 4.7 in sync
|
||||
// with firstParty as of 2026-04-17 (AWS Bedrock, Google Vertex AI, and
|
||||
// Microsoft Foundry announcements and model catalogs all confirm). The
|
||||
// branch is kept as a structural hook in case a future launch lags on 3P.
|
||||
// 3P providers: if user set a primary model (e.g. OPENAI_MODEL=glm-5.1),
|
||||
// fall back to it instead of a hardcoded Anthropic model. This prevents
|
||||
// sideQuery / background tasks from sending requests to Anthropic's API
|
||||
// when the user configured a third-party provider.
|
||||
const primaryModel = getProviderPrimaryModel()
|
||||
if (primaryModel) return primaryModel
|
||||
if (provider !== 'firstParty') {
|
||||
return getModelStrings().opus47
|
||||
}
|
||||
@@ -166,7 +181,11 @@ export function getDefaultSonnetModel(): ModelName {
|
||||
if (process.env.ANTHROPIC_DEFAULT_SONNET_MODEL) {
|
||||
return process.env.ANTHROPIC_DEFAULT_SONNET_MODEL
|
||||
}
|
||||
// Default to Sonnet 4.5 for 3P since they may not have 4.6 yet
|
||||
// 3P providers: fall back to user's primary model instead of a hardcoded
|
||||
// Anthropic model name. Prevents background API calls from being routed to
|
||||
// Anthropic when the user configured a third-party endpoint.
|
||||
const primaryModel = getProviderPrimaryModel()
|
||||
if (primaryModel) return primaryModel
|
||||
if (provider !== 'firstParty') {
|
||||
return getModelStrings().sonnet45
|
||||
}
|
||||
@@ -191,6 +210,10 @@ export function getDefaultHaikuModel(): ModelName {
|
||||
if (process.env.ANTHROPIC_DEFAULT_HAIKU_MODEL) {
|
||||
return process.env.ANTHROPIC_DEFAULT_HAIKU_MODEL
|
||||
}
|
||||
// 3P providers: fall back to user's primary model instead of a hardcoded
|
||||
// Anthropic model name.
|
||||
const primaryModel = getProviderPrimaryModel()
|
||||
if (primaryModel) return primaryModel
|
||||
|
||||
// Haiku 4.5 is available on all platforms (first-party, Foundry, Bedrock, Vertex)
|
||||
return getModelStrings().haiku45
|
||||
|
||||
@@ -135,6 +135,9 @@ const shim = {
|
||||
clearResourceTimings: (() => {}) as typeof performance.clearResourceTimings,
|
||||
setResourceTimingBufferSize:
|
||||
(() => {}) as typeof performance.setResourceTimingBufferSize,
|
||||
// Node.js v22 undici internal calls this after every fetch — must exist to
|
||||
// avoid TypeError: markResourceTiming is not a function
|
||||
markResourceTiming: (() => {}) as any,
|
||||
// Delegate read-only properties to the original
|
||||
get timeOrigin() {
|
||||
return original.timeOrigin
|
||||
@@ -148,7 +151,7 @@ const shim = {
|
||||
toJSON() {
|
||||
return original.toJSON()
|
||||
},
|
||||
} as typeof performance
|
||||
} as unknown as typeof performance
|
||||
|
||||
/**
|
||||
* Install the shim onto globalThis.performance. Safe to call multiple times.
|
||||
|
||||
@@ -33,6 +33,19 @@ import { errorMessage } from './errors.js'
|
||||
import { computeFingerprint } from './fingerprint.js'
|
||||
import { getAPIProvider } from './model/providers.js'
|
||||
import { normalizeModelStringForAPI } from './model/model.js'
|
||||
import { getOpenAIClient } from '../services/api/openai/client.js'
|
||||
import { getGrokClient } from '../services/api/grok/client.js'
|
||||
import {
|
||||
anthropicMessagesToOpenAI,
|
||||
resolveOpenAIModel,
|
||||
anthropicToolsToOpenAI,
|
||||
anthropicToolChoiceToOpenAI,
|
||||
resolveGrokModel,
|
||||
resolveGeminiModel,
|
||||
anthropicToolsToGemini,
|
||||
anthropicToolChoiceToGemini,
|
||||
} from '@ant/model-provider'
|
||||
import type { SystemPrompt } from './systemPromptType.js'
|
||||
|
||||
type MessageParam = Anthropic.MessageParam
|
||||
type TextBlockParam = Anthropic.TextBlockParam
|
||||
@@ -99,6 +112,46 @@ function extractFirstUserMessageText(messages: MessageParam[]): string {
|
||||
return textBlock?.type === 'text' ? textBlock.text : ''
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract system prompt text from the `system` option.
|
||||
*/
|
||||
function extractSystemText(system?: string | TextBlockParam[]): string {
|
||||
if (!system) return ''
|
||||
if (typeof system === 'string') return system
|
||||
return system
|
||||
.filter((b): b is { type: 'text'; text: string } => 'text' in b && !!b.text)
|
||||
.map(b => b.text)
|
||||
.join('\n\n')
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert Anthropic MessageParam[] to a list of {role, content} objects
|
||||
* suitable for OpenAI-compatible chat.completions APIs.
|
||||
*/
|
||||
function messageParamsToOpenAIRoleContent(
|
||||
messages: MessageParam[],
|
||||
): Array<{ role: 'user' | 'assistant'; content: string }> {
|
||||
const result: Array<{ role: 'user' | 'assistant'; content: string }> = []
|
||||
for (const m of messages) {
|
||||
if (m.role !== 'user' && m.role !== 'assistant') continue
|
||||
const text =
|
||||
typeof m.content === 'string'
|
||||
? m.content
|
||||
: Array.isArray(m.content)
|
||||
? m.content
|
||||
.filter(
|
||||
(b): b is { type: 'text'; text: string } => b.type === 'text',
|
||||
)
|
||||
.map(b => b.text)
|
||||
.join('\n')
|
||||
: ''
|
||||
if (text) {
|
||||
result.push({ role: m.role as 'user' | 'assistant', content: text })
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Lightweight API wrapper for "side queries" outside the main conversation loop.
|
||||
*
|
||||
@@ -112,6 +165,7 @@ function extractFirstUserMessageText(messages: MessageParam[]): string {
|
||||
* - Proper betas for the model
|
||||
* - API metadata
|
||||
* - Model string normalization (strips [1m] suffix for API)
|
||||
* - Third-party provider routing (OpenAI, Grok, Gemini)
|
||||
*
|
||||
* @example
|
||||
* // Permission explainer
|
||||
@@ -142,6 +196,14 @@ export async function sideQuery(opts: SideQueryOptions): Promise<BetaMessage> {
|
||||
stop_sequences,
|
||||
} = opts
|
||||
|
||||
const provider = getAPIProvider()
|
||||
if (provider === 'openai' || provider === 'grok') {
|
||||
return sideQueryViaOpenAICompatible(opts)
|
||||
}
|
||||
if (provider === 'gemini') {
|
||||
return sideQueryViaGemini(opts)
|
||||
}
|
||||
|
||||
const client = await getAnthropicClient({
|
||||
maxRetries,
|
||||
model,
|
||||
@@ -198,7 +260,6 @@ export async function sideQuery(opts: SideQueryOptions): Promise<BetaMessage> {
|
||||
}
|
||||
|
||||
const normalizedModel = normalizeModelStringForAPI(model)
|
||||
const provider = getAPIProvider()
|
||||
const start = Date.now()
|
||||
const traceName = `side-query:${opts.querySource}`
|
||||
|
||||
@@ -328,3 +389,352 @@ export async function sideQuery(opts: SideQueryOptions): Promise<BetaMessage> {
|
||||
|
||||
return response
|
||||
}
|
||||
|
||||
/**
|
||||
* OpenAI-compatible side query for OpenAI and Grok providers.
|
||||
* Both use the OpenAI SDK with different base URLs.
|
||||
*
|
||||
* Converts Anthropic-format params to OpenAI Chat Completions, sends a
|
||||
* non-streaming request, and wraps the response back into a BetaMessage
|
||||
* shape so callers remain provider-agnostic.
|
||||
*
|
||||
* Supports tools and tool_choice for structured output (e.g. yoloClassifier,
|
||||
* permissionExplainer).
|
||||
*/
|
||||
async function sideQueryViaOpenAICompatible(
|
||||
opts: SideQueryOptions,
|
||||
): Promise<BetaMessage> {
|
||||
const {
|
||||
model,
|
||||
system,
|
||||
messages,
|
||||
tools,
|
||||
tool_choice,
|
||||
max_tokens = 1024,
|
||||
temperature,
|
||||
signal,
|
||||
} = opts
|
||||
|
||||
const provider = getAPIProvider()
|
||||
const normalizedModel = normalizeModelStringForAPI(model)
|
||||
|
||||
// Resolve model name and client per provider
|
||||
let openaiModel: string
|
||||
// eslint-disable-next-line @typescript-eslint/no-redundant-type-constituents
|
||||
let client: import('openai').default
|
||||
if (provider === 'grok') {
|
||||
openaiModel = resolveGrokModel(normalizedModel)
|
||||
client = getGrokClient({ maxRetries: opts.maxRetries ?? 2 })
|
||||
} else {
|
||||
openaiModel = resolveOpenAIModel(normalizedModel)
|
||||
client = getOpenAIClient({ maxRetries: opts.maxRetries ?? 2 })
|
||||
}
|
||||
|
||||
// Build system prompt text
|
||||
const systemText = extractSystemText(system)
|
||||
|
||||
// Build OpenAI messages: system first, then user/assistant
|
||||
const openaiMessages: Array<{
|
||||
role: 'system' | 'user' | 'assistant'
|
||||
content: string
|
||||
}> = []
|
||||
if (systemText) {
|
||||
openaiMessages.push({ role: 'system', content: systemText })
|
||||
}
|
||||
openaiMessages.push(...messageParamsToOpenAIRoleContent(messages))
|
||||
|
||||
// Convert tools and tool_choice if provided
|
||||
const openaiTools =
|
||||
tools && tools.length > 0
|
||||
? anthropicToolsToOpenAI(tools as BetaToolUnion[])
|
||||
: undefined
|
||||
const openaiToolChoice = tool_choice
|
||||
? anthropicToolChoiceToOpenAI(tool_choice)
|
||||
: undefined
|
||||
|
||||
const start = Date.now()
|
||||
|
||||
const requestParams: Record<string, unknown> = {
|
||||
model: openaiModel,
|
||||
messages: openaiMessages,
|
||||
max_tokens,
|
||||
}
|
||||
if (temperature !== undefined) requestParams.temperature = temperature
|
||||
if (openaiTools && openaiTools.length > 0) {
|
||||
requestParams.tools = openaiTools
|
||||
if (openaiToolChoice) requestParams.tool_choice = openaiToolChoice
|
||||
}
|
||||
|
||||
const response = await client.chat.completions.create(
|
||||
requestParams as unknown as import('openai/resources/chat/completions/completions.mjs').ChatCompletionCreateParamsNonStreaming,
|
||||
{ signal },
|
||||
)
|
||||
|
||||
const choice = response.choices[0]
|
||||
const message = choice?.message
|
||||
|
||||
// Build content blocks for BetaMessage
|
||||
const contentBlocks: Array<
|
||||
| { type: 'text'; text: string }
|
||||
| { type: 'tool_use'; id: string; name: string; input: unknown }
|
||||
> = []
|
||||
|
||||
if (message?.content) {
|
||||
contentBlocks.push({ type: 'text', text: message.content })
|
||||
}
|
||||
|
||||
if (message?.tool_calls) {
|
||||
for (const tc of message.tool_calls) {
|
||||
// ChatCompletionMessageToolCall is a union — only function-type has .function
|
||||
if (tc.type === 'function' && 'function' in tc) {
|
||||
const fn = (tc as { function: { name: string; arguments: string } })
|
||||
.function
|
||||
contentBlocks.push({
|
||||
type: 'tool_use',
|
||||
id: tc.id ?? `toolu_${Date.now()}`,
|
||||
name: fn.name,
|
||||
input: JSON.parse(fn.arguments || '{}'),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const now = Date.now()
|
||||
const requestId = response.id
|
||||
const lastCompletion = getLastApiCompletionTimestamp()
|
||||
logEvent('tengu_api_success', {
|
||||
requestId:
|
||||
requestId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
querySource:
|
||||
opts.querySource as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
model:
|
||||
openaiModel as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
inputTokens: response.usage?.prompt_tokens ?? 0,
|
||||
outputTokens: response.usage?.completion_tokens ?? 0,
|
||||
cachedInputTokens: 0,
|
||||
uncachedInputTokens: response.usage?.prompt_tokens ?? 0,
|
||||
durationMsIncludingRetries: now - start,
|
||||
timeSinceLastApiCallMs:
|
||||
lastCompletion !== null ? now - lastCompletion : undefined,
|
||||
})
|
||||
setLastApiCompletionTimestamp(now)
|
||||
|
||||
const stopReason =
|
||||
choice?.finish_reason === 'tool_calls'
|
||||
? 'tool_use'
|
||||
: choice?.finish_reason === 'length'
|
||||
? 'max_tokens'
|
||||
: 'end_turn'
|
||||
|
||||
return {
|
||||
id: response.id,
|
||||
type: 'message',
|
||||
role: 'assistant',
|
||||
content: contentBlocks as BetaMessage['content'],
|
||||
model: openaiModel,
|
||||
stop_reason: stopReason as BetaMessage['stop_reason'],
|
||||
stop_sequence: null,
|
||||
usage: {
|
||||
input_tokens: response.usage?.prompt_tokens ?? 0,
|
||||
output_tokens: response.usage?.completion_tokens ?? 0,
|
||||
},
|
||||
} as BetaMessage
|
||||
}
|
||||
|
||||
/**
|
||||
* Gemini side query. Converts Anthropic-format params to Gemini
|
||||
* generateContent format, sends a non-streaming request via fetch,
|
||||
* and wraps the response back into a BetaMessage shape.
|
||||
*/
|
||||
async function sideQueryViaGemini(
|
||||
opts: SideQueryOptions,
|
||||
): Promise<BetaMessage> {
|
||||
const {
|
||||
model,
|
||||
system,
|
||||
messages,
|
||||
tools,
|
||||
tool_choice,
|
||||
max_tokens = 1024,
|
||||
temperature,
|
||||
signal,
|
||||
} = opts
|
||||
|
||||
const normalizedModel = normalizeModelStringForAPI(model)
|
||||
const geminiModel = resolveGeminiModel(normalizedModel)
|
||||
|
||||
// Build Gemini contents from Anthropic MessageParam[]
|
||||
const contents: Array<{
|
||||
role: 'user' | 'model'
|
||||
parts: Array<{ text: string }>
|
||||
}> = []
|
||||
for (const m of messages) {
|
||||
if (m.role !== 'user' && m.role !== 'assistant') continue
|
||||
const text =
|
||||
typeof m.content === 'string'
|
||||
? m.content
|
||||
: Array.isArray(m.content)
|
||||
? m.content
|
||||
.filter(
|
||||
(b): b is { type: 'text'; text: string } => b.type === 'text',
|
||||
)
|
||||
.map(b => b.text)
|
||||
.join('\n')
|
||||
: ''
|
||||
if (text) {
|
||||
contents.push({
|
||||
role: m.role === 'assistant' ? 'model' : 'user',
|
||||
parts: [{ text }],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Build system instruction
|
||||
const systemText = extractSystemText(system)
|
||||
const systemInstruction = systemText
|
||||
? { parts: [{ text: systemText }] }
|
||||
: undefined
|
||||
|
||||
// Convert tools and tool_choice
|
||||
const geminiTools =
|
||||
tools && tools.length > 0
|
||||
? anthropicToolsToGemini(tools as BetaToolUnion[])
|
||||
: undefined
|
||||
const geminiToolConfig = tool_choice
|
||||
? anthropicToolChoiceToGemini(tool_choice)
|
||||
: undefined
|
||||
|
||||
const baseUrl = (
|
||||
process.env.GEMINI_BASE_URL ||
|
||||
'https://generativelanguage.googleapis.com/v1beta'
|
||||
).replace(/\/+$/, '')
|
||||
const modelPath = geminiModel.startsWith('models/')
|
||||
? geminiModel
|
||||
: `models/${geminiModel}`
|
||||
const url = `${baseUrl}/${modelPath}:generateContent`
|
||||
|
||||
const body: Record<string, unknown> = {
|
||||
contents,
|
||||
...(systemInstruction && { systemInstruction }),
|
||||
...(geminiTools && geminiTools.length > 0 && { tools: geminiTools }),
|
||||
...(geminiToolConfig && {
|
||||
toolConfig: { functionCallingConfig: geminiToolConfig },
|
||||
}),
|
||||
...(temperature !== undefined && {
|
||||
generationConfig: { temperature },
|
||||
}),
|
||||
...(max_tokens !== undefined && {
|
||||
generationConfig: {
|
||||
...(temperature !== undefined && { temperature }),
|
||||
maxOutputTokens: max_tokens,
|
||||
},
|
||||
}),
|
||||
}
|
||||
|
||||
// Merge generationConfig if both temperature and max_tokens are set
|
||||
if (temperature !== undefined && max_tokens !== undefined) {
|
||||
body.generationConfig = { temperature, maxOutputTokens: max_tokens }
|
||||
}
|
||||
|
||||
const start = Date.now()
|
||||
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-goog-api-key': process.env.GEMINI_API_KEY || '',
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
signal,
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
const errorBody = await res.text()
|
||||
throw new Error(
|
||||
`Gemini API request failed (${res.status} ${res.statusText}): ${errorBody || 'empty response body'}`,
|
||||
)
|
||||
}
|
||||
|
||||
const geminiResponse = (await res.json()) as {
|
||||
candidates?: Array<{
|
||||
content?: {
|
||||
role?: string
|
||||
parts?: Array<{
|
||||
text?: string
|
||||
functionCall?: { name?: string; args?: Record<string, unknown> }
|
||||
}>
|
||||
}
|
||||
finishReason?: string
|
||||
}>
|
||||
usageMetadata?: {
|
||||
promptTokenCount?: number
|
||||
candidatesTokenCount?: number
|
||||
totalTokenCount?: number
|
||||
}
|
||||
id?: string
|
||||
}
|
||||
|
||||
// Build content blocks from Gemini response
|
||||
const contentBlocks: Array<
|
||||
| { type: 'text'; text: string }
|
||||
| { type: 'tool_use'; id: string; name: string; input: unknown }
|
||||
> = []
|
||||
|
||||
const candidate = geminiResponse.candidates?.[0]
|
||||
const parts = candidate?.content?.parts
|
||||
if (parts) {
|
||||
for (const part of parts) {
|
||||
if (part.text) {
|
||||
contentBlocks.push({ type: 'text', text: part.text })
|
||||
}
|
||||
if (part.functionCall) {
|
||||
contentBlocks.push({
|
||||
type: 'tool_use',
|
||||
id: `toolu_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
|
||||
name: part.functionCall.name ?? '',
|
||||
input: part.functionCall.args ?? {},
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const now = Date.now()
|
||||
const lastCompletion = getLastApiCompletionTimestamp()
|
||||
logEvent('tengu_api_success', {
|
||||
requestId: (geminiResponse.id ??
|
||||
'') as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
querySource:
|
||||
opts.querySource as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
model:
|
||||
geminiModel as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
inputTokens: geminiResponse.usageMetadata?.promptTokenCount ?? 0,
|
||||
outputTokens: geminiResponse.usageMetadata?.candidatesTokenCount ?? 0,
|
||||
cachedInputTokens: 0,
|
||||
uncachedInputTokens: geminiResponse.usageMetadata?.promptTokenCount ?? 0,
|
||||
durationMsIncludingRetries: now - start,
|
||||
timeSinceLastApiCallMs:
|
||||
lastCompletion !== null ? now - lastCompletion : undefined,
|
||||
})
|
||||
setLastApiCompletionTimestamp(now)
|
||||
|
||||
const stopReason =
|
||||
candidate?.finishReason === 'STOP'
|
||||
? 'end_turn'
|
||||
: candidate?.finishReason === 'MAX_TOKENS'
|
||||
? 'max_tokens'
|
||||
: 'end_turn'
|
||||
|
||||
return {
|
||||
id: geminiResponse.id ?? `gemini_${Date.now()}`,
|
||||
type: 'message',
|
||||
role: 'assistant',
|
||||
content: contentBlocks as BetaMessage['content'],
|
||||
model: geminiModel,
|
||||
stop_reason: stopReason as BetaMessage['stop_reason'],
|
||||
stop_sequence: null,
|
||||
usage: {
|
||||
input_tokens: geminiResponse.usageMetadata?.promptTokenCount ?? 0,
|
||||
output_tokens: geminiResponse.usageMetadata?.candidatesTokenCount ?? 0,
|
||||
},
|
||||
} as BetaMessage
|
||||
}
|
||||
|
||||
@@ -32,5 +32,5 @@
|
||||
"packages/**/*.ts",
|
||||
"packages/**/*.tsx"
|
||||
],
|
||||
"exclude": ["node_modules"]
|
||||
"exclude": ["node_modules", "packages/remote-control-server/web"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user