Merge branch 'main' into feature/pokemon/battle

This commit is contained in:
claude-code-best
2026-04-24 21:33:23 +08:00
35 changed files with 2528 additions and 221 deletions

View File

@@ -286,6 +286,15 @@ export default class App extends PureComponent<Props, State> {
// ignore calling setRawMode on an handle stdin it cannot be called
if (this.isRawModeSupported()) {
this.handleSetRawMode(false)
} else {
// Even when raw mode was never enabled (e.g. non-TTY stdin on
// Windows Node.js), ensure stdin is unref'd so the process can
// exit. earlyInput may have called ref() before Ink mounted.
try {
this.props.stdin.unref()
} catch {
// stdin may already be destroyed
}
}
}

View File

@@ -121,7 +121,7 @@ describe('anthropicMessagesToOpenAI', () => {
])
})
test('strips thinking blocks', () => {
test('preserves thinking blocks as reasoning_content', () => {
const result = anthropicMessagesToOpenAI(
[
makeAssistantMsg([
@@ -131,7 +131,7 @@ describe('anthropicMessagesToOpenAI', () => {
],
[] as any,
)
expect(result).toEqual([{ role: 'assistant', content: 'visible response' }])
expect(result).toEqual([{ role: 'assistant', content: 'visible response', reasoning_content: 'internal thoughts...' }] as any)
})
test('handles full conversation with tools', () => {
@@ -299,7 +299,7 @@ describe('DeepSeek thinking mode (enableThinking)', () => {
expect(assistant.reasoning_content).toBe('Let me reason about this...')
})
test('drops thinking block when enableThinking is false (default)', () => {
test('preserves thinking block as reasoning_content even without enableThinking', () => {
const result = anthropicMessagesToOpenAI(
[
makeAssistantMsg([
@@ -311,7 +311,7 @@ describe('DeepSeek thinking mode (enableThinking)', () => {
)
const assistant = result[0] as any
expect(assistant.content).toBe('visible response')
expect(assistant.reasoning_content).toBeUndefined()
expect(assistant.reasoning_content).toBe('internal thoughts...')
})
test('preserves reasoning_content with tool_calls in same turn', () => {
@@ -352,7 +352,7 @@ describe('DeepSeek thinking mode (enableThinking)', () => {
expect(assistant.tool_calls[0].function.name).toBe('get_weather')
})
test('strips reasoning_content from previous turns', () => {
test('always preserves reasoning_content from all turns', () => {
const result = anthropicMessagesToOpenAI(
[
// Turn 1: user → assistant (with thinking)
@@ -361,7 +361,8 @@ describe('DeepSeek thinking mode (enableThinking)', () => {
{ type: 'thinking' as const, thinking: 'Turn 1 reasoning...' },
{ type: 'text', text: 'Turn 1 answer' },
]),
// Turn 2: new user message → previous reasoning should be stripped
// Turn 2: new user message → reasoning should still be preserved
// (DeepSeek requires reasoning_content to be passed back when tool calls are involved)
makeUserMsg('question 2'),
makeAssistantMsg([
{ type: 'thinking' as const, thinking: 'Turn 2 reasoning...' },
@@ -373,10 +374,9 @@ describe('DeepSeek thinking mode (enableThinking)', () => {
)
const assistants = result.filter(m => m.role === 'assistant')
// Turn 1 assistant: reasoning should be stripped (previous turn)
expect((assistants[0] as any).reasoning_content).toBeUndefined()
// Both turns preserve reasoning_content (DeepSeek API requires it for tool calls)
expect((assistants[0] as any).reasoning_content).toBe('Turn 1 reasoning...')
expect((assistants[0] as any).content).toBe('Turn 1 answer')
// Turn 2 assistant: reasoning should be preserved (current turn)
expect((assistants[1] as any).reasoning_content).toBe('Turn 2 reasoning...')
expect((assistants[1] as any).content).toBe('Turn 2 answer')
})

View File

@@ -26,16 +26,16 @@ export interface ConvertMessagesOptions {
* - system prompt → role: "system" message prepended
* - tool_use blocks → tool_calls[] on assistant message
* - tool_result blocks → role: "tool" messages
* - thinking blocks → silently dropped (or preserved as reasoning_content when enableThinking=true)
* - thinking blocks → preserved as reasoning_content (DeepSeek requires passing it back)
* - cache_control → stripped
*/
export function anthropicMessagesToOpenAI(
messages: (UserMessage | AssistantMessage)[],
systemPrompt: SystemPrompt,
options?: ConvertMessagesOptions,
// options retained for API compatibility; thinking blocks are now always preserved
_options?: ConvertMessagesOptions,
): ChatCompletionMessageParam[] {
const result: ChatCompletionMessageParam[] = []
const enableThinking = options?.enableThinking ?? false
// Prepend system prompt as system message
const systemText = systemPromptToText(systemPrompt)
@@ -46,53 +46,13 @@ export function anthropicMessagesToOpenAI(
} satisfies ChatCompletionSystemMessageParam)
}
// When thinking mode is on, detect turn boundaries so that reasoning_content
// from *previous* user turns is stripped (saves bandwidth; DeepSeek ignores it).
// A "new turn" starts when a user text message appears after at least one assistant response.
const turnBoundaries = new Set<number>()
if (enableThinking) {
let hasSeenAssistant = false
for (let i = 0; i < messages.length; i++) {
const msg = messages[i]
if (msg.type === 'assistant') {
hasSeenAssistant = true
}
if (msg.type === 'user' && hasSeenAssistant) {
const content = msg.message.content
// A user message starts a new turn if it contains any non-tool_result content
// (text, image, or other media). Tool results alone do NOT start a new turn
// because they are continuations of the previous assistant tool call.
const startsNewUserTurn =
typeof content === 'string'
? content.length > 0
: Array.isArray(content) &&
content.some(
(b: any) =>
typeof b === 'string' ||
(b &&
typeof b === 'object' &&
'type' in b &&
b.type !== 'tool_result'),
)
if (startsNewUserTurn) {
turnBoundaries.add(i)
}
}
}
}
for (let i = 0; i < messages.length; i++) {
const msg = messages[i]
for (const msg of messages) {
switch (msg.type) {
case 'user':
result.push(...convertInternalUserMessage(msg))
break
case 'assistant':
// Preserve reasoning_content unless we're before a turn boundary
// (i.e., from a previous user Q&A round)
const preserveReasoning =
enableThinking && !isBeforeAnyTurnBoundary(i, turnBoundaries)
result.push(...convertInternalAssistantMessage(msg, preserveReasoning))
result.push(...convertInternalAssistantMessage(msg))
break
default:
break
@@ -107,17 +67,6 @@ function systemPromptToText(systemPrompt: SystemPrompt): string {
return systemPrompt.filter(Boolean).join('\n\n')
}
/**
* Check if index `i` falls before any turn boundary (i.e. it belongs to a previous turn).
* A message at index i is "before" a boundary if there exists a boundary j where i < j.
*/
function isBeforeAnyTurnBoundary(i: number, boundaries: Set<number>): boolean {
for (const b of boundaries) {
if (i < b) return true
}
return false
}
function convertInternalUserMessage(
msg: UserMessage,
): ChatCompletionMessageParam[] {
@@ -213,7 +162,6 @@ function convertToolResult(
function convertInternalAssistantMessage(
msg: AssistantMessage,
preserveReasoning = false,
): ChatCompletionMessageParam[] {
const content = msg.message.content
@@ -257,8 +205,10 @@ function convertInternalAssistantMessage(
typeof tu.input === 'string' ? tu.input : JSON.stringify(tu.input),
},
})
} else if (block.type === 'thinking' && preserveReasoning) {
// DeepSeek thinking mode: preserve reasoning_content for tool call iterations
} else if (block.type === 'thinking') {
// DeepSeek thinking mode: always preserve reasoning_content.
// DeepSeek requires reasoning_content to be passed back in subsequent requests,
// especially when tool calls are involved (returns 400 if missing).
const thinkingText = (block as unknown as Record<string, unknown>)
.thinking
if (typeof thinkingText === 'string' && thinkingText) {

View File

@@ -394,6 +394,7 @@ export const getAgentDefinitionsWithOverrides = memoize(
export function clearAgentDefinitionsCache(): void {
getAgentDefinitionsWithOverrides.cache.clear?.()
loadMarkdownFilesForSubdir.cache?.clear?.()
clearPluginAgentCache()
}

View File

@@ -273,18 +273,6 @@ export const FileEditTool = buildTool({
}
const readTimestamp = toolUseContext.readFileState.get(fullFilePath)
if (!readTimestamp || readTimestamp.isPartialView) {
return {
result: false,
behavior: 'ask',
message:
'File has not been read yet. Read it first before writing to it.',
meta: {
isFilePathAbsolute: String(isAbsolute(file_path)),
},
errorCode: 6,
}
}
// Check if file exists and get its last modified time
if (readTimestamp) {

View File

@@ -186,14 +186,6 @@ export function renderToolUseErrorMessage(
extractTag(result, 'tool_use_error')
) {
const errorMessage = extractTag(result, 'tool_use_error')
// Show a less scary message for intended behavior
if (errorMessage?.includes('File has not been read yet')) {
return (
<MessageResponse>
<Text dimColor>File must be read first</Text>
</MessageResponse>
)
}
if (errorMessage?.includes(FILE_NOT_FOUND_CWD_NOTE)) {
return (
<MessageResponse>

View File

@@ -196,25 +196,18 @@ export const FileWriteTool = buildTool({
}
const readTimestamp = toolUseContext.readFileState.get(fullFilePath)
if (!readTimestamp || readTimestamp.isPartialView) {
return {
result: false,
message:
'File has not been read yet. Read it first before writing to it.',
errorCode: 2,
}
}
// Reuse mtime from the stat above — avoids a redundant statSync via
// getFileModificationTime. The readTimestamp guard above ensures this
// block is always reached when the file exists.
const lastWriteTime = Math.floor(fileMtimeMs)
if (lastWriteTime > readTimestamp.timestamp) {
return {
result: false,
message:
'File has been modified since read, either by the user or by a linter. Read it again before attempting to write it.',
errorCode: 3,
// getFileModificationTime.
if (readTimestamp) {
const lastWriteTime = Math.floor(fileMtimeMs)
if (lastWriteTime > readTimestamp.timestamp) {
return {
result: false,
message:
'File has been modified since read, either by the user or by a linter. Read it again before attempting to write it.',
errorCode: 3,
}
}
}