mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-27 15:13:37 +08:00
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:
parent
360b565873
commit
34aa9d7102
@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@ -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 */}
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 && (
|
||||
|
||||
@ -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')
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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': 'ファイルの内容を更新',
|
||||
|
||||
@ -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': '파일 내용 업데이트',
|
||||
|
||||
@ -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': '更新檔案內容',
|
||||
|
||||
@ -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': '更新文件内容',
|
||||
|
||||
@ -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', {
|
||||
|
||||
@ -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) => {
|
||||
|
||||
@ -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 }
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@ -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,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user