revert: remove upstream #823 (ToolSearch forced enable) and #703/#707/#712/#751 (pending tool UI)

These upstream features cause tool_use responses to degrade into XML
text blocks on third-party providers:

- #823 forced ENABLE_TOOL_SEARCH=true for all anthropic-format
  providers, but third-party proxies don't support the ToolSearch
  beta header, causing format degradation.
- #712/#707/#751/#703 changed pending tool JSON rendering and
  spinner lifecycle in ways that interact badly with the degraded
  responses.

Reverted changes:
- Remove normalizeToolSearchEnabled, ENABLE_TOOL_SEARCH env injection,
  and toolSearchEnabled from normalizeSavedProvider
- Revert ToolCallBlock pending JSON wrap and write/edit progress
- Revert ToolCallGroup interrupt spinner fix
- Revert chatStore pendingTaskToolUseIds tracking

The CLI's built-in host detection (toolSearch.ts:300) still correctly
disables ToolSearch for non-first-party hosts when ENABLE_TOOL_SEARCH
is unset, so Anthropic-direct users are unaffected.
This commit is contained in:
你的姓名 2026-06-19 17:21:23 +08:00
parent 360b565873
commit 34aa9d7102
17 changed files with 22 additions and 581 deletions

View File

@ -22,15 +22,4 @@ describe('CodeViewer', () => {
expect(container.querySelector('[data-line-number="1"]')).toBeTruthy()
expect(container.querySelector('[data-line-number="2"]')).toBeTruthy()
})
it('can wrap long highlighted code content when requested', () => {
const { container } = render(
<CodeViewer code={'{"command":"cat << EOF > /tmp/index.html"}'} language="json" wrapLongLines />,
)
const contentWrapper = container.querySelector('[data-code-viewer-content]') as HTMLElement | null
expect(contentWrapper).toBeTruthy()
expect(contentWrapper?.style.whiteSpace).toBe('pre-wrap')
expect(contentWrapper?.style.wordBreak).toBe('break-word')
})
})

View File

@ -7,7 +7,6 @@ type Props = {
language?: string
maxLines?: number
showLineNumbers?: boolean
wrapLongLines?: boolean
}
const warmPrismTheme: PrismTheme = {
@ -125,17 +124,7 @@ function loadShikiRuntime(): Promise<ShikiRuntime | null> {
return shikiRuntimePromise
}
function PrismCodeContent({
code,
language,
showLineNumbers,
wrapLongLines,
}: {
code: string
language?: string
showLineNumbers: boolean
wrapLongLines: boolean
}) {
function PrismCodeContent({ code, language, showLineNumbers }: { code: string; language?: string; showLineNumbers: boolean }) {
return (
<Highlight
theme={warmPrismTheme}
@ -152,8 +141,8 @@ function PrismCodeContent({
fontFamily: 'var(--font-mono)',
fontSize: '12px',
lineHeight: String(CODE_LINE_HEIGHT),
whiteSpace: wrapLongLines ? 'pre-wrap' : 'pre',
wordBreak: wrapLongLines ? 'break-word' : 'normal',
whiteSpace: 'pre',
wordBreak: 'normal',
color: 'var(--color-code-fg)',
}}
>
@ -179,17 +168,7 @@ function PrismCodeContent({
)
}
function CodeArea({
code,
language,
showLineNumbers,
wrapLongLines,
}: {
code: string
language?: string
showLineNumbers: boolean
wrapLongLines: boolean
}) {
function CodeArea({ code, language, showLineNumbers }: { code: string; language?: string; showLineNumbers: boolean }) {
const containerRef = useRef<HTMLDivElement>(null)
const [runtime, setRuntime] = useState<ShikiRuntime | null>(null)
const [loaded, setLoaded] = useState(false)
@ -238,7 +217,6 @@ function CodeArea({
code={code}
language={language}
showLineNumbers={showLineNumbers}
wrapLongLines={wrapLongLines}
/>
)}
{ShikiHighlighter && (
@ -270,8 +248,7 @@ function CodeArea({
fontFamily: 'var(--font-mono)',
fontSize: '12px',
lineHeight: String(CODE_LINE_HEIGHT),
whiteSpace: wrapLongLines ? 'pre-wrap' : 'pre',
wordBreak: wrapLongLines ? 'break-word' : 'normal',
whiteSpace: 'pre',
}}
>
{code}
@ -282,7 +259,7 @@ function CodeArea({
)
}
export function CodeViewer({ code, language, maxLines = 20, showLineNumbers = false, wrapLongLines = false }: Props) {
export function CodeViewer({ code, language, maxLines = 20, showLineNumbers = false }: Props) {
const [expanded, setExpanded] = useState(false)
const allLines = code.split('\n')
@ -313,7 +290,6 @@ export function CodeViewer({ code, language, maxLines = 20, showLineNumbers = fa
code={visibleCode}
language={language}
showLineNumbers={effectiveShowLineNumbers}
wrapLongLines={wrapLongLines}
/>
{/* Expand/collapse toggle */}

View File

@ -1044,33 +1044,6 @@ describe('MessageList nested tool calls', () => {
expect(container.querySelectorAll('[data-message-shell="assistant"]')).toHaveLength(0)
})
it('renders stopped tool calls as terminal instead of still generating content', () => {
useChatStore.setState({
sessions: {
[ACTIVE_TAB]: makeSessionState({
chatState: 'idle',
messages: [
{
id: 'tool-write',
type: 'tool_use',
toolName: 'Write',
toolUseId: 'write-1',
input: { file_path: '/tmp/story.md' },
timestamp: 1,
isPending: false,
status: 'stopped',
} as UIMessage,
],
}),
},
})
render(<MessageList />)
expect(screen.getByText('Stopped')).toBeTruthy()
expect(screen.queryByText('Generating content')).toBeNull()
})
it('renders saved memory events with an entrypoint to memory settings', () => {
useChatStore.setState({
sessions: {

View File

@ -1151,7 +1151,7 @@ function getMessageMetricSignature(message: UIMessage): string {
case 'system':
return `${message.type}:${message.content.length}`
case 'tool_use':
return `${message.type}:${message.toolName}:${message.toolUseId}:${message.partialInput?.length ?? 0}:${message.isPending ? 1 : 0}:${message.status ?? ''}`
return `${message.type}:${message.toolName}:${message.toolUseId}:${message.partialInput?.length ?? 0}:${message.isPending ? 1 : 0}`
case 'tool_result':
return `${message.type}:${message.toolUseId}:${message.isError ? 1 : 0}`
case 'compact_summary':
@ -2152,7 +2152,6 @@ export const MessageBlock = memo(function MessageBlock({
input={message.input}
result={toolResult}
isPending={message.isPending}
status={message.status}
partialInput={message.partialInput}
agentTaskNotification={
message.toolName === 'Agent'

View File

@ -1,5 +1,5 @@
import { memo, useMemo, useState } from 'react'
import { CircleStop, LoaderCircle } from 'lucide-react'
import { LoaderCircle } from 'lucide-react'
import { CodeViewer } from './CodeViewer'
import { DiffViewer } from './DiffViewer'
import { TerminalChrome } from './TerminalChrome'
@ -18,7 +18,6 @@ type Props = {
agentTaskNotification?: AgentTaskNotification
compact?: boolean
isPending?: boolean
status?: 'stopped'
partialInput?: string
}
@ -39,14 +38,7 @@ const TOOL_ICONS: Record<string, string> = {
const WRITER_PREVIEW_MAX_LINES = 120
const WRITER_PREVIEW_MAX_CHARS = 30000
type ContentStats = {
lines: number
chars: number
visibleLines?: number
windowed?: boolean
}
export const ToolCallBlock = memo(function ToolCallBlock({ toolName, input, result, compact = false, isPending = false, status, partialInput }: Props) {
export const ToolCallBlock = memo(function ToolCallBlock({ toolName, input, result, compact = false, isPending = false, partialInput }: Props) {
const isPlanTool = isExitPlanModeTool(toolName)
const [expanded, setExpanded] = useState(isPlanTool)
const t = useTranslation()
@ -63,14 +55,6 @@ export const ToolCallBlock = memo(function ToolCallBlock({ toolName, input, resu
const pendingSummary = isPending && !result
? getPendingSummary(toolName, t)
: ''
const stoppedSummary = status === 'stopped' && !result
? t('tool.stopped')
: ''
const liveStats = useMemo(
() => getToolContentStats(toolName, obj, isPending ? partialInput : undefined),
[isPending, obj, partialInput, toolName],
)
const liveStatsSummary = liveStats ? formatContentStats(liveStats, t) : ''
const preview = useMemo(() => renderPreview(toolName, obj, result, t), [obj, result, toolName, t])
const details = useMemo(() => renderDetails(toolName, obj, t, isPending ? partialInput : undefined), [isPending, obj, partialInput, toolName, t])
@ -121,25 +105,9 @@ export const ToolCallBlock = memo(function ToolCallBlock({ toolName, input, resu
<span className="flex-1" />
)}
{pendingSummary ? (
<span
className="inline-flex min-w-0 max-w-[58%] shrink-0 items-center gap-1 text-[10px] text-[var(--color-outline)]"
title={liveStatsSummary ? `${pendingSummary} · ${liveStatsSummary}` : pendingSummary}
>
<LoaderCircle size={12} strokeWidth={2.4} className="animate-spin" aria-hidden="true" />
<span className="truncate">{pendingSummary}</span>
{liveStatsSummary ? (
<>
<span className="shrink-0 text-[var(--color-text-tertiary)]">·</span>
<span className="shrink-0 font-[var(--font-mono)] tabular-nums text-[var(--color-text-tertiary)]">
{liveStatsSummary}
</span>
</>
) : null}
</span>
) : stoppedSummary ? (
<span className="inline-flex shrink-0 items-center gap-1 text-[10px] text-[var(--color-outline)]">
<CircleStop size={12} strokeWidth={2.25} aria-hidden="true" />
{stoppedSummary}
<LoaderCircle size={12} strokeWidth={2.4} className="animate-spin" aria-hidden="true" />
{pendingSummary}
</span>
) : result && outputSummary ? (
<span
@ -151,10 +119,6 @@ export const ToolCallBlock = memo(function ToolCallBlock({ toolName, input, resu
>
{outputSummary}
</span>
) : liveStatsSummary ? (
<span className="shrink-0 font-[var(--font-mono)] text-[10px] tabular-nums text-[var(--color-outline)]">
{liveStatsSummary}
</span>
) : null}
{result?.isError && (
<span className="material-symbols-outlined shrink-0 text-[14px] text-[var(--color-error)]">error</span>
@ -407,7 +371,7 @@ function renderDetails(
if (partialInput) {
if (toolName === 'Write') {
const writerContent = extractPartialJsonStringField(partialInput, 'content')
if (writerContent !== null) {
if (writerContent) {
return renderWriterPreview(writerContent, t)
}
}
@ -503,99 +467,12 @@ function extractPartialJsonStringField(source: string, field: string): string |
return value
}
function getToolContentStats(
toolName: string,
obj: Record<string, unknown>,
partialInput?: string,
): ContentStats | null {
const content = getToolContentForStats(toolName, obj, partialInput)
return content === null ? null : countContentStats(content)
}
function getToolContentForStats(
toolName: string,
obj: Record<string, unknown>,
partialInput?: string,
): string | null {
if (toolName === 'Write') {
if (typeof obj.content === 'string') return obj.content
return partialInput ? extractPartialJsonStringField(partialInput, 'content') : null
}
if (toolName === 'Edit') {
if (typeof obj.new_string === 'string') return obj.new_string
return partialInput ? extractPartialJsonStringField(partialInput, 'new_string') : null
}
if (toolName === 'MultiEdit' && Array.isArray(obj.edits)) {
const replacements = obj.edits
.map((edit) => (
edit && typeof edit === 'object' && typeof (edit as Record<string, unknown>).new_string === 'string'
? (edit as Record<string, string>).new_string
: ''
))
.filter(Boolean)
return replacements.length > 0 ? replacements.join('\n') : null
}
return null
}
function countContentStats(content: string): ContentStats {
return {
lines: content.length === 0 ? 0 : content.split('\n').length,
chars: content.length,
}
}
function formatContentStats(
stats: ContentStats,
t?: (key: TranslationKey, params?: Record<string, string | number>) => string,
): string {
const chars = formatCharCount(stats.chars, t)
if (stats.windowed && typeof stats.visibleLines === 'number' && stats.visibleLines < stats.lines) {
return t?.('tool.contentStatsLatest', {
visible: formatCount(stats.visibleLines),
total: formatCount(stats.lines),
chars,
}) ?? `Latest ${formatCount(stats.visibleLines)} / ${formatCount(stats.lines)} lines · ${chars}`
}
return t?.('tool.contentStats', {
lines: formatLineCount(stats.lines, t),
chars,
}) ?? `${formatLineCount(stats.lines, t)} · ${chars}`
}
function formatLineCount(
count: number,
t?: (key: TranslationKey, params?: Record<string, string | number>) => string,
): string {
return count === 1
? (t?.('tool.lineCountSingular', { count: formatCount(count) }) ?? `${formatCount(count)} line`)
: (t?.('tool.lineCountPlural', { count: formatCount(count) }) ?? `${formatCount(count)} lines`)
}
function formatCharCount(
count: number,
t?: (key: TranslationKey, params?: Record<string, string | number>) => string,
): string {
return count === 1
? (t?.('tool.charCountSingular', { count: formatCount(count) }) ?? `${formatCount(count)} char`)
: (t?.('tool.charCountPlural', { count: formatCount(count) }) ?? `${formatCount(count)} chars`)
}
function formatCount(count: number): string {
return new Intl.NumberFormat().format(count)
}
function renderWriterPreview(
content: string,
t?: (key: TranslationKey, params?: Record<string, string | number>) => string,
) {
const contentStats = countContentStats(content)
const lines = content.length === 0 ? [] : content.split('\n')
const totalLines = contentStats.lines
const lines = content.split('\n')
const totalLines = lines.length
const visibleLines = lines.length > WRITER_PREVIEW_MAX_LINES
? lines.slice(-WRITER_PREVIEW_MAX_LINES)
: lines
@ -606,21 +483,16 @@ function renderWriterPreview(
}
const lineWindowed = totalLines > visibleLines.length
const isWindowed = lineWindowed || charTruncated
const visibleLineCount = visibleContent.length === 0 ? 0 : visibleContent.split('\n').length
const statsSummary = formatContentStats({
lines: totalLines,
chars: contentStats.chars,
visibleLines: visibleLineCount,
windowed: isWindowed,
}, t)
return (
<div className="overflow-hidden rounded-lg border border-[var(--color-border)] bg-[var(--color-surface)]">
<div className="flex items-center justify-between border-b border-[var(--color-border)] px-3 py-2 text-[10px] uppercase tracking-[0.18em] text-[var(--color-outline)]">
<span>{t?.('tool.writerPreview') ?? 'Writer'}</span>
<span className="font-[var(--font-mono)] normal-case tracking-normal tabular-nums">
{statsSummary}
</span>
{isWindowed ? (
<span className="normal-case tracking-normal">
{t?.('tool.writerPreviewLatest', { visible: visibleLines.length, total: totalLines }) ?? `Showing latest ${visibleLines.length} of ${totalLines} lines`}
</span>
) : null}
</div>
<pre className="max-h-[420px] overflow-auto whitespace-pre-wrap break-words bg-[var(--color-code-bg)] px-3 py-2 font-[var(--font-mono)] text-[12px] leading-[1.45] text-[var(--color-code-fg)]">
{visibleContent}
@ -633,96 +505,16 @@ function renderPartialInput(
partialInput: string,
t?: (key: TranslationKey, params?: Record<string, string | number>) => string,
) {
const formattedInput = formatPartialJsonInput(partialInput)
return (
<div className="overflow-hidden rounded-lg border border-[var(--color-border)] bg-[var(--color-surface)]">
<div className="border-b border-[var(--color-border)] px-3 py-2 text-[10px] uppercase tracking-[0.18em] text-[var(--color-outline)]">
{t?.('tool.partialInput') ?? 'Partial input'}
</div>
<CodeViewer code={formattedInput} language="json" maxLines={8} wrapLongLines />
<CodeViewer code={partialInput} language="json" maxLines={8} />
</div>
)
}
function formatPartialJsonInput(source: string): string {
const trimmed = source.trim()
if (!trimmed) return source
try {
return JSON.stringify(JSON.parse(trimmed), null, 2)
} catch {
return formatJsonLikeInput(trimmed)
}
}
function formatJsonLikeInput(source: string): string {
let output = ''
let indent = 0
let inString = false
let escaping = false
let skipWhitespace = false
const newline = () => {
output = output.trimEnd()
output += `\n${' '.repeat(indent)}`
skipWhitespace = true
}
for (const char of source) {
if (inString) {
output += char
if (escaping) {
escaping = false
} else if (char === '\\') {
escaping = true
} else if (char === '"') {
inString = false
}
continue
}
if (skipWhitespace && /\s/.test(char)) continue
skipWhitespace = false
if (char === '"') {
inString = true
output += char
continue
}
if (char === '{' || char === '[') {
output += char
indent += 1
newline()
continue
}
if (char === '}' || char === ']') {
indent = Math.max(0, indent - 1)
if (!output.endsWith('\n')) newline()
output += char
continue
}
if (char === ',') {
output += char
newline()
continue
}
if (char === ':') {
output += ': '
skipWhitespace = true
continue
}
output += char
}
return output.trimEnd()
}
function getPendingSummary(
toolName: string,
t?: (key: TranslationKey, params?: Record<string, string | number>) => string,

View File

@ -91,7 +91,6 @@ function isToolCallResolved(
resultMap: Map<string, ToolResult>,
childToolCallsByParent: Map<string, ToolCall[]>,
): boolean {
if (toolCall.status === 'stopped') return true
if (!resultMap.has(toolCall.toolUseId)) return false
return (childToolCallsByParent.get(toolCall.toolUseId) ?? []).every((childToolCall) =>
@ -662,7 +661,6 @@ function ToolCallTree({
result={result ? { content: result.content, isError: result.isError } : null}
compact={compact}
isPending={toolCall.isPending}
status={toolCall.status}
partialInput={toolCall.partialInput}
/>
{childToolCalls.length > 0 && (

View File

@ -121,22 +121,6 @@ describe('chat blocks', () => {
expect(container.textContent).toContain('Generating content')
})
it('shows pending Write line and character progress in the collapsed header', () => {
const { container } = render(
<ToolCallBlock
toolName="Write"
input={{ file_path: '/private/tmp/ai-code-novel.md' }}
isPending
partialInput={'{"file_path":"/private/tmp/ai-code-novel.md","content":"alpha\\nbeta'}
/>,
)
expect(container.textContent).toContain('Generating content')
expect(container.textContent).toContain('2 lines')
expect(container.textContent).toContain('10 chars')
expect(container.textContent).not.toContain('latest')
})
it('expands pending Write tool calls into a live writer preview instead of raw JSON', () => {
const { container } = render(
<ToolCallBlock
@ -155,65 +139,6 @@ describe('chat blocks', () => {
expect(container.textContent).not.toContain('"content"')
})
it('formats and wraps pending Bash partial JSON input when expanded', () => {
const partialInput = [
'{"command":"cat << \'HTMLEOF\' > /tmp/index.html\\n<!DOCTYPE html>\\n<html lang=\\"zh-CN\\">",',
'"description":"Create HTML shell command"}',
].join('')
const { container } = render(
<ToolCallBlock
toolName="Bash"
input={{ command: 'cat << \'HTMLEOF\' > /tmp/index.html', description: 'Create HTML shell command' }}
isPending
partialInput={partialInput}
/>,
)
fireEvent.click(screen.getByRole('button'))
expect(container.textContent).toContain('Partial input')
expect(container.textContent).toContain('json')
expect(container.textContent).toContain('4 lines')
expect(container.textContent).not.toContain('1 line')
const contentWrapper = container.querySelector('[data-code-viewer-content]') as HTMLElement | null
expect(contentWrapper?.style.whiteSpace).toBe('pre-wrap')
expect(contentWrapper?.style.wordBreak).toBe('break-word')
})
it('shows non-windowed Writer preview stats before the 120-line limit', () => {
const { container } = render(
<ToolCallBlock
toolName="Write"
input={{ file_path: '/private/tmp/generated.ts' }}
isPending
partialInput={'{"file_path":"/private/tmp/generated.ts","content":"alpha\\nbeta\\ngamma'}
/>,
)
fireEvent.click(screen.getByRole('button'))
expect(container.textContent).toContain('Writer')
expect(container.textContent).toContain('3 lines')
expect(container.textContent).toContain('16 chars')
expect(container.textContent).not.toContain('latest')
})
it('shows pending Edit replacement character progress in the collapsed header', () => {
const { container } = render(
<ToolCallBlock
toolName="Edit"
input={{ file_path: '/tmp/example.ts' }}
isPending
partialInput={'{"file_path":"/tmp/example.ts","old_string":"const ready = false","new_string":"const ready = true'}
/>,
)
expect(container.textContent).toContain('Preparing edit')
expect(container.textContent).toContain('1 line')
expect(container.textContent).toContain('18 chars')
})
it('windows long pending Write previews to the latest content', () => {
const lines = Array.from({ length: 180 }, (_, index) => `line-${index + 1}`)
const escapedContent = lines.join('\\n')

View File

@ -1822,16 +1822,9 @@ export const en = {
'tool.partialInput': 'Partial input',
'tool.writerPreview': 'Writer',
'tool.writerPreviewLatest': 'Showing latest {visible} of {total} lines',
'tool.contentStats': '{lines} · {chars}',
'tool.contentStatsLatest': 'Showing latest {visible} / {total} lines · {chars}',
'tool.lineCountSingular': '{count} line',
'tool.lineCountPlural': '{count} lines',
'tool.charCountSingular': '{count} char',
'tool.charCountPlural': '{count} chars',
'tool.generatingContent': 'Generating content',
'tool.preparingEdit': 'Preparing edit',
'tool.preparingTool': 'Preparing tool',
'tool.stopped': 'Stopped',
'tool.readFileContents': 'Read file contents',
'tool.createFile': 'Create file',
'tool.updateFileContents': 'Update file contents',

View File

@ -1791,16 +1791,9 @@ export const jp: Record<TranslationKey, string> = {
'tool.partialInput': '部分的な入力',
'tool.writerPreview': 'Writer',
'tool.writerPreviewLatest': '{total} 行のうち最新の {visible} 行を表示中',
'tool.contentStats': '{lines} · {chars}',
'tool.contentStatsLatest': '最新 {visible} / {total} 行 · {chars}',
'tool.lineCountSingular': '{count} 行',
'tool.lineCountPlural': '{count} 行',
'tool.charCountSingular': '{count} 文字',
'tool.charCountPlural': '{count} 文字',
'tool.generatingContent': 'コンテンツを生成中',
'tool.preparingEdit': '編集を準備中',
'tool.preparingTool': 'ツールを準備中',
'tool.stopped': '停止済み',
'tool.readFileContents': 'ファイルの内容を読み取り',
'tool.createFile': 'ファイルを作成',
'tool.updateFileContents': 'ファイルの内容を更新',

View File

@ -1791,16 +1791,9 @@ export const kr: Record<TranslationKey, string> = {
'tool.partialInput': '부분 입력',
'tool.writerPreview': 'Writer',
'tool.writerPreviewLatest': '{total}줄 중 최신 {visible}줄 표시 중',
'tool.contentStats': '{lines} · {chars}',
'tool.contentStatsLatest': '최신 {visible} / {total}줄 · {chars}',
'tool.lineCountSingular': '{count}줄',
'tool.lineCountPlural': '{count}줄',
'tool.charCountSingular': '{count}자',
'tool.charCountPlural': '{count}자',
'tool.generatingContent': '콘텐츠 생성 중',
'tool.preparingEdit': '편집 준비 중',
'tool.preparingTool': '도구 준비 중',
'tool.stopped': '중지됨',
'tool.readFileContents': '파일 내용 읽기',
'tool.createFile': '파일 만들기',
'tool.updateFileContents': '파일 내용 업데이트',

View File

@ -1791,16 +1791,9 @@ export const zh: Record<TranslationKey, string> = {
'tool.partialInput': '部分輸入',
'tool.writerPreview': 'Writer',
'tool.writerPreviewLatest': '正在顯示最新 {visible} / {total} 行',
'tool.contentStats': '{lines} · {chars}',
'tool.contentStatsLatest': '最新 {visible} / {total} 行 · {chars}',
'tool.lineCountSingular': '{count} 行',
'tool.lineCountPlural': '{count} 行',
'tool.charCountSingular': '{count} 字元',
'tool.charCountPlural': '{count} 字元',
'tool.generatingContent': '正在生成內容',
'tool.preparingEdit': '正在準備編輯',
'tool.preparingTool': '正在準備工具',
'tool.stopped': '已停止',
'tool.readFileContents': '讀取檔案內容',
'tool.createFile': '建立檔案',
'tool.updateFileContents': '更新檔案內容',

View File

@ -1793,16 +1793,9 @@ export const zh: Record<TranslationKey, string> = {
'tool.partialInput': '部分输入',
'tool.writerPreview': 'Writer',
'tool.writerPreviewLatest': '正在显示最新 {visible} / {total} 行',
'tool.contentStats': '{lines} · {chars}',
'tool.contentStatsLatest': '最新 {visible} / {total} 行 · {chars}',
'tool.lineCountSingular': '{count} 行',
'tool.lineCountPlural': '{count} 行',
'tool.charCountSingular': '{count} 字符',
'tool.charCountPlural': '{count} 字符',
'tool.generatingContent': '正在生成内容',
'tool.preparingEdit': '正在准备编辑',
'tool.preparingTool': '正在准备工具',
'tool.stopped': '已停止',
'tool.readFileContents': '读取文件内容',
'tool.createFile': '创建文件',
'tool.updateFileContents': '更新文件内容',

View File

@ -2351,45 +2351,6 @@ describe('chatStore history mapping', () => {
vi.useRealTimers()
})
it('marks pending tool input as stopped when generation is stopped', () => {
vi.useFakeTimers()
useChatStore.setState({
sessions: {
[TEST_SESSION_ID]: makeSession({ chatState: 'tool_executing' }),
},
})
useChatStore.getState().handleServerMessage(TEST_SESSION_ID, {
type: 'content_start',
blockType: 'tool_use',
toolName: 'Write',
toolUseId: 'write-1',
})
useChatStore.getState().handleServerMessage(TEST_SESSION_ID, {
type: 'content_delta',
toolInput: '{"file_path":"/private/tmp/story.md","content":"第一章',
})
vi.advanceTimersByTime(60)
useChatStore.getState().stopGeneration(TEST_SESSION_ID)
const session = useChatStore.getState().sessions[TEST_SESSION_ID]
expect(session?.chatState).toBe('idle')
expect(session?.activeToolUseId).toBeNull()
expect(session?.activeToolName).toBeNull()
expect(session?.streamingToolInput).toBe('')
expect(session?.messages[0]).toMatchObject({
type: 'tool_use',
toolUseId: 'write-1',
isPending: false,
status: 'stopped',
})
vi.runOnlyPendingTimers()
vi.useRealTimers()
})
it('refreshes merged slash commands when a live CLI update omits project commands', async () => {
const cliCommand = { name: 'builtin-help', description: 'Built-in command' }
const projectCommand = { name: 'project-probe', description: 'Project custom command' }
@ -4433,10 +4394,7 @@ describe('chatStore history mapping', () => {
useChatStore.getState().stopGeneration('session-a')
expect(useChatStore.getState().sessions['session-a']?.streamingText).toBe('')
expect(useChatStore.getState().sessions['session-a']?.messages).toMatchObject([
{ type: 'assistant_text', content: 'A-only response' },
])
expect(useChatStore.getState().sessions['session-a']?.streamingText).toBe('A-only response')
expect(useChatStore.getState().sessions['session-b']?.streamingText).toBe('')
useChatStore.getState().handleServerMessage('session-b', {

View File

@ -511,20 +511,6 @@ function upsertToolUseMessage(
return next
}
function markPendingToolUseMessagesStopped(messages: UIMessage[]): UIMessage[] {
let changed = false
const stoppedMessages = messages.map((message) => {
if (message.type !== 'tool_use' || !message.isPending) return message
changed = true
return {
...message,
isPending: false,
status: 'stopped' as const,
}
})
return changed ? stoppedMessages : messages
}
// Streaming throttle for content_delta. Buffers must be per-session because
// multiple desktop tabs can stream at the same time.
const pendingDeltaBySession = new Map<string, string>()
@ -1489,31 +1475,18 @@ export const useChatStore = create<ChatStore>((set, get) => ({
if ((get().sessions[sessionId]?.messageQueue?.length ?? 0) > 0) {
queueDrainPaused.add(sessionId)
}
const bufferedText = consumePendingDelta(sessionId)
consumePendingDelta(sessionId)
clearPendingToolInputDelta(sessionId)
clearPendingTaskToolUseIds(sessionId)
clearPendingToolParentUseIds(sessionId)
set((s) => {
const session = s.sessions[sessionId]
if (!session) return s
if (session.elapsedTimer) clearInterval(session.elapsedTimer)
const pendingAssistantText = `${session.streamingText}${bufferedText}`
const messagesWithFlushedText = pendingAssistantText.trim()
? appendAssistantTextMessage(session.messages, pendingAssistantText, Date.now())
: session.messages
return {
sessions: {
...s.sessions,
[sessionId]: {
...session,
messages: markPendingToolUseMessagesStopped(messagesWithFlushedText),
chatState: 'idle',
activeToolUseId: null,
activeToolName: null,
activeThinkingId: null,
streamingText: '',
streamingToolInput: '',
statusVerb: '',
pendingPermission: null,
pendingComputerUsePermission: null,
apiRetry: null,
@ -1523,7 +1496,6 @@ export const useChatStore = create<ChatStore>((set, get) => ({
},
}
})
useTabStore.getState().updateTabStatus(sessionId, 'idle')
},
loadHistory: async (sessionId) => {

View File

@ -301,7 +301,6 @@ export type UIMessage =
timestamp: number
parentToolUseId?: string
isPending?: boolean
status?: 'stopped'
partialInput?: string
}
| { id: string; type: 'tool_result'; toolUseId: string; content: unknown; isError: boolean; timestamp: number; parentToolUseId?: string }

View File

@ -52,7 +52,6 @@ describe('providerRuntimeEnv', () => {
ANTHROPIC_BASE_URL: 'https://api.example.com/anthropic',
ANTHROPIC_API_KEY: '',
ANTHROPIC_AUTH_TOKEN: 'sk-active',
ENABLE_TOOL_SEARCH: 'true',
ANTHROPIC_MODEL: 'active-main',
ANTHROPIC_DEFAULT_HAIKU_MODEL: 'active-main',
ANTHROPIC_DEFAULT_SONNET_MODEL: 'active-sonnet',
@ -96,7 +95,6 @@ describe('providerRuntimeEnv', () => {
ANTHROPIC_BASE_URL: 'https://sub2api.example.com',
ANTHROPIC_API_KEY: '',
ANTHROPIC_AUTH_TOKEN: 'sk-sub2api',
ENABLE_TOOL_SEARCH: 'true',
ANTHROPIC_MODEL: 'gpt-5.5',
ANTHROPIC_DEFAULT_HAIKU_MODEL: 'gpt-5.5',
ANTHROPIC_DEFAULT_SONNET_MODEL: 'gpt-5.5',
@ -160,89 +158,4 @@ describe('providerRuntimeEnv', () => {
const env = readActiveProviderManagedEnv(tmpDir)
expect(env?.CLAUDE_CODE_DISABLE_THINKING).toBeUndefined()
})
test('honors disabled tool search for native Anthropic providers', async () => {
await writeJson(path.join(tmpDir, 'cc-haha', 'providers.json'), {
activeId: 'provider-1',
providers: [
{
id: 'provider-1',
presetId: 'custom',
name: 'Tool Search Off',
apiKey: 'sk-active',
authStrategy: 'auth_token',
baseUrl: 'https://api.example.com/anthropic',
apiFormat: 'anthropic',
toolSearchEnabled: false,
models: {
main: 'active-main',
haiku: 'active-main',
sonnet: 'active-main',
opus: 'active-main',
},
},
],
})
const env = readActiveProviderManagedEnv(tmpDir)
expect(env.ENABLE_TOOL_SEARCH).toBe('false')
})
test('keeps providers readable when stored tool search values are stringly typed', async () => {
await writeJson(path.join(tmpDir, 'cc-haha', 'providers.json'), {
activeId: 'provider-1',
providers: [
{
id: 'provider-1',
presetId: 'custom',
name: 'String Tool Search',
apiKey: 'sk-active',
authStrategy: 'auth_token',
baseUrl: 'https://api.example.com/anthropic',
apiFormat: 'anthropic',
toolSearchEnabled: 'false',
models: {
main: 'active-main',
haiku: 'active-main',
sonnet: 'active-main',
opus: 'active-main',
},
},
],
})
const env = readActiveProviderManagedEnv(tmpDir)
expect(env.ANTHROPIC_BASE_URL).toBe('https://api.example.com/anthropic')
expect(env.ENABLE_TOOL_SEARCH).toBe('false')
})
test('does not write tool search env for OpenAI proxy providers', async () => {
await writeJson(path.join(tmpDir, 'cc-haha', 'providers.json'), {
activeId: 'provider-1',
providers: [
{
id: 'provider-1',
presetId: 'custom',
name: 'OpenAI Proxy Provider',
apiKey: 'sk-active',
authStrategy: 'auth_token',
baseUrl: 'https://api.example.com/openai',
apiFormat: 'openai_chat',
toolSearchEnabled: true,
models: {
main: 'active-main',
haiku: 'active-main',
sonnet: 'active-main',
opus: 'active-main',
},
},
],
})
const env = readActiveProviderManagedEnv(tmpDir)
expect(env.ENABLE_TOOL_SEARCH).toBeUndefined()
})
})

View File

@ -27,7 +27,6 @@ export const MANAGED_PROVIDER_ENV_KEYS = [
'ANTHROPIC_BASE_URL',
'ANTHROPIC_API_KEY',
'ANTHROPIC_AUTH_TOKEN',
'ENABLE_TOOL_SEARCH',
'ANTHROPIC_MODEL',
'ANTHROPIC_DEFAULT_HAIKU_MODEL',
'ANTHROPIC_DEFAULT_HAIKU_MODEL_SUPPORTED_CAPABILITIES',
@ -87,19 +86,6 @@ function isSavedProvider(value: unknown): value is SavedProvider {
)
}
function normalizeToolSearchEnabled(value: unknown): boolean {
if (typeof value === 'boolean') return value
if (typeof value === 'number') return value !== 0
if (typeof value === 'string') {
const normalized = value.trim().toLowerCase()
if (['0', 'false', 'off', 'no'].includes(normalized)) return false
if (['1', 'true', 'on', 'yes', 'auto'].includes(normalized) || normalized.startsWith('auto:')) {
return true
}
}
return true
}
export function normalizeModelMapping(models: SavedProvider['models']): SavedProvider['models'] {
const main = models.main.trim()
return {
@ -150,7 +136,6 @@ export function normalizeSavedProvider(provider: SavedProvider): SavedProvider {
apiFormat: provider.apiFormat ?? 'anthropic',
runtimeKind: provider.runtimeKind ?? 'anthropic_compatible',
models: normalizeModelMapping(provider.models),
toolSearchEnabled: normalizeToolSearchEnabled(rawProvider.toolSearchEnabled),
...(model1mSupport !== undefined ? { model1mSupport } : {}),
}
}
@ -352,9 +337,6 @@ export function buildProviderManagedEnv(
...(provider.thinkingIncompatible === true && {
CLAUDE_CODE_DISABLE_THINKING: '1',
}),
...(apiFormat === 'anthropic' && {
ENABLE_TOOL_SEARCH: provider.toolSearchEnabled === false ? 'false' : 'true',
}),
ANTHROPIC_BASE_URL: baseUrl,
...buildProviderAuthEnv(provider, presetDefaultEnv, needsProxy),
ANTHROPIC_MODEL: runtimeModels.main,