mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-17 13:55:50 +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.
|
> 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 可能会有非常多的冲突, 所以大的功能请尽量在这之前提交哈
|
[Peri Code](https://github.com/KonghaYao/peri):Claude Code 兼容的 Rust Agent,多年大模型经验匠心制作,国内大模型(DeepSeek/GLM)精调,CPU/内存极致优化,在开发版/树莓派上也能跑 CC 一样的体验。
|
||||||
|
|
||||||
[文档在这里, 支持投稿 PR](https://ccb.agent-aura.top/) | [留影文档在这里](./Friends.md) | [Discord 群组](https://discord.gg/uApuzJWGKX)
|
|
||||||
|
|
||||||
|
[文档在这里](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` |
|
| 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",
|
"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",
|
"description": "Reverse-engineered Anthropic Claude Code CLI — interactive AI coding assistant in the terminal",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"author": "claude-code-best <claude-code-best@proton.me>",
|
"author": "claude-code-best <claude-code-best@proton.me>",
|
||||||
|
|||||||
@@ -70,7 +70,6 @@ import {
|
|||||||
areFileEditsInputsEquivalent,
|
areFileEditsInputsEquivalent,
|
||||||
findActualString,
|
findActualString,
|
||||||
getPatchForEdit,
|
getPatchForEdit,
|
||||||
preserveQuoteStyle,
|
|
||||||
} from './utils.js'
|
} from './utils.js'
|
||||||
|
|
||||||
// V8/Bun string length limit is ~2^30 characters (~1 billion). For typical
|
// V8/Bun string length limit is ~2^30 characters (~1 billion). For typical
|
||||||
@@ -297,7 +296,7 @@ export const FileEditTool = buildTool({
|
|||||||
|
|
||||||
const file = fileContent
|
const file = fileContent
|
||||||
|
|
||||||
// Use findActualString to handle quote normalization
|
// Use findActualString to find exact match
|
||||||
const actualOldString = findActualString(file, old_string)
|
const actualOldString = findActualString(file, old_string)
|
||||||
if (!actualOldString) {
|
if (!actualOldString) {
|
||||||
return {
|
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 =
|
const actualOldString =
|
||||||
findActualString(originalFileContents, old_string) || old_string
|
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
|
// 4. Generate patch
|
||||||
const { patch, updatedFile } = getPatchForEdit({
|
const { patch, updatedFile } = getPatchForEdit({
|
||||||
filePath: absoluteFilePath,
|
filePath: absoluteFilePath,
|
||||||
fileContents: originalFileContents,
|
fileContents: originalFileContents,
|
||||||
oldString: actualOldString,
|
oldString: actualOldString,
|
||||||
newString: actualNewString,
|
newString: new_string,
|
||||||
replaceAll: replace_all,
|
replaceAll: replace_all,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import { readEditContext } from 'src/utils/readEditContext.js';
|
|||||||
import { firstLineOf } from 'src/utils/stringUtils.js';
|
import { firstLineOf } from 'src/utils/stringUtils.js';
|
||||||
import type { ThemeName } from 'src/utils/theme.js';
|
import type { ThemeName } from 'src/utils/theme.js';
|
||||||
import type { FileEditOutput } from './types.js';
|
import type { FileEditOutput } from './types.js';
|
||||||
import { findActualString, getPatchForEdit, preserveQuoteStyle } from './utils.js';
|
import { findActualString, getPatchForEdit } from './utils.js';
|
||||||
|
|
||||||
export function userFacingName(
|
export function userFacingName(
|
||||||
input:
|
input:
|
||||||
@@ -265,12 +265,11 @@ async function loadRejectionDiff(
|
|||||||
return { patch, firstLine: null, fileContent: undefined };
|
return { patch, firstLine: null, fileContent: undefined };
|
||||||
}
|
}
|
||||||
const actualOld = findActualString(ctx.content, oldString) || oldString;
|
const actualOld = findActualString(ctx.content, oldString) || oldString;
|
||||||
const actualNew = preserveQuoteStyle(oldString, actualOld, newString);
|
|
||||||
const { patch } = getPatchForEdit({
|
const { patch } = getPatchForEdit({
|
||||||
filePath,
|
filePath,
|
||||||
fileContents: ctx.content,
|
fileContents: ctx.content,
|
||||||
oldString: actualOld,
|
oldString: actualOld,
|
||||||
newString: actualNew,
|
newString: newString,
|
||||||
replaceAll,
|
replaceAll,
|
||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -4,45 +4,8 @@ import { logMock } from '../../../../../../tests/mocks/log'
|
|||||||
// Mock log.ts to cut the heavy dependency chain
|
// Mock log.ts to cut the heavy dependency chain
|
||||||
mock.module('src/utils/log.ts', logMock)
|
mock.module('src/utils/log.ts', logMock)
|
||||||
|
|
||||||
const {
|
const { stripTrailingWhitespace, findActualString, applyEditToFile } =
|
||||||
normalizeQuotes,
|
await import('../utils')
|
||||||
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('')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
// ─── stripTrailingWhitespace ────────────────────────────────────────────
|
// ─── stripTrailingWhitespace ────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -91,12 +54,6 @@ describe('findActualString', () => {
|
|||||||
expect(findActualString('hello world', 'hello')).toBe('hello')
|
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', () => {
|
test('returns null when not found', () => {
|
||||||
expect(findActualString('hello world', 'xyz')).toBeNull()
|
expect(findActualString('hello world', 'xyz')).toBeNull()
|
||||||
})
|
})
|
||||||
@@ -107,124 +64,13 @@ describe('findActualString', () => {
|
|||||||
expect(result).toBe('')
|
expect(result).toBe('')
|
||||||
})
|
})
|
||||||
|
|
||||||
// ── Tab/space normalization (Bug #2 reproduction) ──
|
// ── CJK / UTF-8 characters ──
|
||||||
|
|
||||||
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) ──
|
|
||||||
|
|
||||||
test('finds match with CJK characters in content', () => {
|
test('finds match with CJK characters in content', () => {
|
||||||
const fileContent = 'input int x = 620; // 止盈点数(点) — 32个pip=320点'
|
const fileContent = 'input int x = 620; // 止盈点数(点) — 32个pip=320点'
|
||||||
const result = findActualString(fileContent, fileContent)
|
const result = findActualString(fileContent, fileContent)
|
||||||
expect(result).toBe(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 ────────────────────────────────────────────────────
|
// ─── applyEditToFile ────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -15,27 +15,6 @@ import {
|
|||||||
} from 'src/utils/file.js'
|
} from 'src/utils/file.js'
|
||||||
import type { EditInput, FileEdit } from './types.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
|
* Strips trailing whitespace from each line in a string while preserving line endings
|
||||||
* @param str The string to process
|
* @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
|
* Finds the exact string in the file content.
|
||||||
* 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
|
|
||||||
*
|
*
|
||||||
* @param fileContent The file content to search in
|
* @param fileContent The file content to search in
|
||||||
* @param searchString The string to search for
|
* @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(
|
export function findActualString(
|
||||||
fileContent: string,
|
fileContent: string,
|
||||||
searchString: string,
|
searchString: string,
|
||||||
): string | null {
|
): string | null {
|
||||||
// First try exact match
|
|
||||||
if (fileContent.includes(searchString)) {
|
if (fileContent.includes(searchString)) {
|
||||||
return 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
|
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
|
* Transform edits to ensure replace_all always has a boolean value
|
||||||
* @param edits Array of edits with optional replace_all
|
* @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
|
// channel messages queue at priority 'next' and are seen by the model on
|
||||||
// the turn after they arrive.
|
// the turn after they arrive.
|
||||||
connection.client.setNotificationHandler(
|
connection.client.setNotificationHandler(
|
||||||
ChannelMessageNotificationSchema(),
|
ChannelMessageNotificationSchema() as any,
|
||||||
async notification => {
|
async notification => {
|
||||||
const { content, meta } = notification.params
|
const { content, meta } = notification.params
|
||||||
logMCPDebug(
|
logMCPDebug(
|
||||||
@@ -5042,7 +5042,7 @@ function reregisterChannelHandlerAfterReconnect(
|
|||||||
'Channel notifications re-registered after reconnect',
|
'Channel notifications re-registered after reconnect',
|
||||||
)
|
)
|
||||||
connection.client.setNotificationHandler(
|
connection.client.setNotificationHandler(
|
||||||
ChannelMessageNotificationSchema(),
|
ChannelMessageNotificationSchema() as any,
|
||||||
async notification => {
|
async notification => {
|
||||||
const { content, meta } = notification.params
|
const { content, meta } = notification.params
|
||||||
logMCPDebug(
|
logMCPDebug(
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { Suspense, use, useState } from 'react';
|
|||||||
import { useTerminalSize } from '../hooks/useTerminalSize.js';
|
import { useTerminalSize } from '../hooks/useTerminalSize.js';
|
||||||
import { Box, Text } from '@anthropic/ink';
|
import { Box, Text } from '@anthropic/ink';
|
||||||
import type { FileEdit } from '@claude-code-best/builtin-tools/tools/FileEditTool/types.js';
|
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 { adjustHunkLineNumbers, CONTEXT_LINES, getPatchForDisplay } from '../utils/diff.js';
|
||||||
import { logError } from '../utils/log.js';
|
import { logError } from '../utils/log.js';
|
||||||
import { CHUNK_SIZE, openForScan, readCapped, scanForContext } from '../utils/readEditContext.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 {
|
function normalizeEdit(fileContent: string, edit: FileEdit): FileEdit {
|
||||||
const actualOld = findActualString(fileContent, edit.old_string) || edit.old_string;
|
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 };
|
||||||
return { ...edit, old_string: actualOld, new_string: actualNew };
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -146,7 +146,7 @@ async function main(): Promise<void> {
|
|||||||
shutdown1PEventLogging,
|
shutdown1PEventLogging,
|
||||||
logForDebugging,
|
logForDebugging,
|
||||||
registerPermissionHandler(server, handler) {
|
registerPermissionHandler(server, handler) {
|
||||||
server.setNotificationHandler(ChannelPermissionRequestNotificationSchema(), async notification =>
|
server.setNotificationHandler(ChannelPermissionRequestNotificationSchema() as any, async notification =>
|
||||||
handler(notification.params),
|
handler(notification.params),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ export function useIdeAtMentioned(
|
|||||||
// If we found a connected IDE client, register our handler
|
// If we found a connected IDE client, register our handler
|
||||||
if (ideClient) {
|
if (ideClient) {
|
||||||
ideClient.client.setNotificationHandler(
|
ideClient.client.setNotificationHandler(
|
||||||
AtMentionedSchema(),
|
AtMentionedSchema() as any,
|
||||||
notification => {
|
notification => {
|
||||||
if (ideClientRef.current !== ideClient) {
|
if (ideClientRef.current !== ideClient) {
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ export function useIdeLogging(mcpClients: MCPServerConnection[]): void {
|
|||||||
if (ideClient) {
|
if (ideClient) {
|
||||||
// Register the log event handler
|
// Register the log event handler
|
||||||
ideClient.client.setNotificationHandler(
|
ideClient.client.setNotificationHandler(
|
||||||
LogEventSchema(),
|
LogEventSchema() as any,
|
||||||
notification => {
|
notification => {
|
||||||
const { eventName, eventData } = notification.params
|
const { eventName, eventData } = notification.params
|
||||||
logEvent(
|
logEvent(
|
||||||
|
|||||||
@@ -110,7 +110,7 @@ export function useIdeSelection(
|
|||||||
|
|
||||||
// Register notification handler for selection_changed events
|
// Register notification handler for selection_changed events
|
||||||
ideClient.client.setNotificationHandler(
|
ideClient.client.setNotificationHandler(
|
||||||
SelectionChangedSchema(),
|
SelectionChangedSchema() as any,
|
||||||
notification => {
|
notification => {
|
||||||
if (currentIDERef.current !== ideClient) {
|
if (currentIDERef.current !== ideClient) {
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ export function usePromptsFromClaudeInChrome(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (mcpClient) {
|
if (mcpClient) {
|
||||||
mcpClient.client.setNotificationHandler(ClaudeInChromePromptNotificationSchema(), notification => {
|
mcpClient.client.setNotificationHandler(ClaudeInChromePromptNotificationSchema() as any, notification => {
|
||||||
if (mcpClientRef.current !== mcpClient) {
|
if (mcpClientRef.current !== mcpClient) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -504,7 +504,7 @@ export function useManageMCPConnections(
|
|||||||
case 'register':
|
case 'register':
|
||||||
logMCPDebug(client.name, 'Channel notifications registered')
|
logMCPDebug(client.name, 'Channel notifications registered')
|
||||||
client.client.setNotificationHandler(
|
client.client.setNotificationHandler(
|
||||||
ChannelMessageNotificationSchema(),
|
ChannelMessageNotificationSchema() as any,
|
||||||
async notification => {
|
async notification => {
|
||||||
const { content, meta } = notification.params
|
const { content, meta } = notification.params
|
||||||
logMCPDebug(
|
logMCPDebug(
|
||||||
@@ -539,7 +539,7 @@ export function useManageMCPConnections(
|
|||||||
client.capabilities?.experimental?.['claude/channel/permission']
|
client.capabilities?.experimental?.['claude/channel/permission']
|
||||||
) {
|
) {
|
||||||
client.client.setNotificationHandler(
|
client.client.setNotificationHandler(
|
||||||
ChannelPermissionNotificationSchema(),
|
ChannelPermissionNotificationSchema() as any,
|
||||||
async notification => {
|
async notification => {
|
||||||
const { request_id, behavior } = notification.params
|
const { request_id, behavior } = notification.params
|
||||||
const resolved =
|
const resolved =
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ export function setupVscodeSdkMcp(sdkClients: MCPServerConnection[]): void {
|
|||||||
vscodeMcpClient = client
|
vscodeMcpClient = client
|
||||||
|
|
||||||
client.client.setNotificationHandler(
|
client.client.setNotificationHandler(
|
||||||
LogEventNotificationSchema(),
|
LogEventNotificationSchema() as any,
|
||||||
async notification => {
|
async notification => {
|
||||||
const { eventName, eventData } = notification.params
|
const { eventName, eventData } = notification.params
|
||||||
logEvent(
|
logEvent(
|
||||||
|
|||||||
@@ -385,7 +385,7 @@ export function searchSkills(
|
|||||||
index: SkillIndexEntry[],
|
index: SkillIndexEntry[],
|
||||||
limit = 5,
|
limit = 5,
|
||||||
): SearchResult[] {
|
): SearchResult[] {
|
||||||
if (index.length === 0 || !query.trim()) return []
|
if (index.length === 0 || !query?.trim()) return []
|
||||||
|
|
||||||
const queryTokens = tokenizeAndStem(query)
|
const queryTokens = tokenizeAndStem(query)
|
||||||
if (queryTokens.length === 0) return []
|
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 v of freq.values()) if (v > max) max = v
|
||||||
for (const [term, count] of freq) queryTf.set(term, count / max)
|
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>()
|
const queryTfIdf = new Map<string, number>()
|
||||||
for (const [term, tf] of queryTf) {
|
for (const [term, tf] of queryTf) {
|
||||||
queryTfIdf.set(term, tf * (idf.get(term) ?? 0))
|
queryTfIdf.set(term, tf * (idf.get(term) ?? 0))
|
||||||
|
|||||||
@@ -610,3 +610,179 @@ describe('ensureToolResultPairing', () => {
|
|||||||
expect(lastMsg.type).toBe('user')
|
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 { getOauthConfig } from '../constants/oauth.js'
|
||||||
import { isEnvTruthy } from './envUtils.js'
|
import { isEnvTruthy } from './envUtils.js'
|
||||||
|
import { isEssentialTrafficOnly } from './privacyLevel.js'
|
||||||
|
|
||||||
let fired = false
|
let fired = false
|
||||||
|
|
||||||
@@ -32,6 +33,10 @@ export function preconnectAnthropicApi(): void {
|
|||||||
if (fired) return
|
if (fired) return
|
||||||
fired = true
|
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
|
// Skip if using a cloud provider — different endpoint + auth
|
||||||
if (
|
if (
|
||||||
isEnvTruthy(process.env.CLAUDE_CODE_USE_BEDROCK) ||
|
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.
|
// Find a previous assistant message with the same message ID and merge.
|
||||||
// Walk backwards, skipping tool results and different-ID assistants,
|
// Walk backwards, skipping different-ID assistants, since concurrent
|
||||||
// since concurrent agents (teammates) can interleave streaming content
|
// agents (teammates) can interleave streaming content blocks from
|
||||||
// blocks from multiple API responses with different message IDs.
|
// 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--) {
|
for (let i = result.length - 1; i >= 0; i--) {
|
||||||
const msg = result[i]!
|
const msg = result[i]!
|
||||||
|
|
||||||
if (msg.type !== 'assistant' && !isToolResultMessage(msg)) {
|
if (msg.type !== 'assistant') {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
if (msg.type === 'assistant') {
|
if (msg.message.id === normalizedMessage.message.id) {
|
||||||
if (msg.message.id === normalizedMessage.message.id) {
|
result[i] = mergeAssistantMessages(msg, normalizedMessage)
|
||||||
result[i] = mergeAssistantMessages(msg, normalizedMessage)
|
return
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -120,6 +120,19 @@ export function getBestModel(): ModelName {
|
|||||||
return getDefaultOpusModel()
|
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).
|
// @[MODEL LAUNCH]: Update the default Opus model (3P providers may lag so keep defaults unchanged).
|
||||||
export function getDefaultOpusModel(): ModelName {
|
export function getDefaultOpusModel(): ModelName {
|
||||||
const provider = getAPIProvider()
|
const provider = getAPIProvider()
|
||||||
@@ -138,10 +151,12 @@ export function getDefaultOpusModel(): ModelName {
|
|||||||
if (process.env.ANTHROPIC_DEFAULT_OPUS_MODEL) {
|
if (process.env.ANTHROPIC_DEFAULT_OPUS_MODEL) {
|
||||||
return 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
|
// 3P providers: if user set a primary model (e.g. OPENAI_MODEL=glm-5.1),
|
||||||
// with firstParty as of 2026-04-17 (AWS Bedrock, Google Vertex AI, and
|
// fall back to it instead of a hardcoded Anthropic model. This prevents
|
||||||
// Microsoft Foundry announcements and model catalogs all confirm). The
|
// sideQuery / background tasks from sending requests to Anthropic's API
|
||||||
// branch is kept as a structural hook in case a future launch lags on 3P.
|
// when the user configured a third-party provider.
|
||||||
|
const primaryModel = getProviderPrimaryModel()
|
||||||
|
if (primaryModel) return primaryModel
|
||||||
if (provider !== 'firstParty') {
|
if (provider !== 'firstParty') {
|
||||||
return getModelStrings().opus47
|
return getModelStrings().opus47
|
||||||
}
|
}
|
||||||
@@ -166,7 +181,11 @@ export function getDefaultSonnetModel(): ModelName {
|
|||||||
if (process.env.ANTHROPIC_DEFAULT_SONNET_MODEL) {
|
if (process.env.ANTHROPIC_DEFAULT_SONNET_MODEL) {
|
||||||
return 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') {
|
if (provider !== 'firstParty') {
|
||||||
return getModelStrings().sonnet45
|
return getModelStrings().sonnet45
|
||||||
}
|
}
|
||||||
@@ -191,6 +210,10 @@ export function getDefaultHaikuModel(): ModelName {
|
|||||||
if (process.env.ANTHROPIC_DEFAULT_HAIKU_MODEL) {
|
if (process.env.ANTHROPIC_DEFAULT_HAIKU_MODEL) {
|
||||||
return 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)
|
// Haiku 4.5 is available on all platforms (first-party, Foundry, Bedrock, Vertex)
|
||||||
return getModelStrings().haiku45
|
return getModelStrings().haiku45
|
||||||
|
|||||||
@@ -135,6 +135,9 @@ const shim = {
|
|||||||
clearResourceTimings: (() => {}) as typeof performance.clearResourceTimings,
|
clearResourceTimings: (() => {}) as typeof performance.clearResourceTimings,
|
||||||
setResourceTimingBufferSize:
|
setResourceTimingBufferSize:
|
||||||
(() => {}) as typeof performance.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
|
// Delegate read-only properties to the original
|
||||||
get timeOrigin() {
|
get timeOrigin() {
|
||||||
return original.timeOrigin
|
return original.timeOrigin
|
||||||
@@ -148,7 +151,7 @@ const shim = {
|
|||||||
toJSON() {
|
toJSON() {
|
||||||
return original.toJSON()
|
return original.toJSON()
|
||||||
},
|
},
|
||||||
} as typeof performance
|
} as unknown as typeof performance
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Install the shim onto globalThis.performance. Safe to call multiple times.
|
* 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 { computeFingerprint } from './fingerprint.js'
|
||||||
import { getAPIProvider } from './model/providers.js'
|
import { getAPIProvider } from './model/providers.js'
|
||||||
import { normalizeModelStringForAPI } from './model/model.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 MessageParam = Anthropic.MessageParam
|
||||||
type TextBlockParam = Anthropic.TextBlockParam
|
type TextBlockParam = Anthropic.TextBlockParam
|
||||||
@@ -99,6 +112,46 @@ function extractFirstUserMessageText(messages: MessageParam[]): string {
|
|||||||
return textBlock?.type === 'text' ? textBlock.text : ''
|
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.
|
* 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
|
* - Proper betas for the model
|
||||||
* - API metadata
|
* - API metadata
|
||||||
* - Model string normalization (strips [1m] suffix for API)
|
* - Model string normalization (strips [1m] suffix for API)
|
||||||
|
* - Third-party provider routing (OpenAI, Grok, Gemini)
|
||||||
*
|
*
|
||||||
* @example
|
* @example
|
||||||
* // Permission explainer
|
* // Permission explainer
|
||||||
@@ -142,6 +196,14 @@ export async function sideQuery(opts: SideQueryOptions): Promise<BetaMessage> {
|
|||||||
stop_sequences,
|
stop_sequences,
|
||||||
} = opts
|
} = opts
|
||||||
|
|
||||||
|
const provider = getAPIProvider()
|
||||||
|
if (provider === 'openai' || provider === 'grok') {
|
||||||
|
return sideQueryViaOpenAICompatible(opts)
|
||||||
|
}
|
||||||
|
if (provider === 'gemini') {
|
||||||
|
return sideQueryViaGemini(opts)
|
||||||
|
}
|
||||||
|
|
||||||
const client = await getAnthropicClient({
|
const client = await getAnthropicClient({
|
||||||
maxRetries,
|
maxRetries,
|
||||||
model,
|
model,
|
||||||
@@ -198,7 +260,6 @@ export async function sideQuery(opts: SideQueryOptions): Promise<BetaMessage> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const normalizedModel = normalizeModelStringForAPI(model)
|
const normalizedModel = normalizeModelStringForAPI(model)
|
||||||
const provider = getAPIProvider()
|
|
||||||
const start = Date.now()
|
const start = Date.now()
|
||||||
const traceName = `side-query:${opts.querySource}`
|
const traceName = `side-query:${opts.querySource}`
|
||||||
|
|
||||||
@@ -328,3 +389,352 @@ export async function sideQuery(opts: SideQueryOptions): Promise<BetaMessage> {
|
|||||||
|
|
||||||
return response
|
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/**/*.ts",
|
||||||
"packages/**/*.tsx"
|
"packages/**/*.tsx"
|
||||||
],
|
],
|
||||||
"exclude": ["node_modules"]
|
"exclude": ["node_modules", "packages/remote-control-server/web"]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user