Files
claude-code/src/utils/sideQuestion.ts
claude-code-best e897385a7e Feature/docker/run (#1268)
* feat: 删除垃圾更改

* fix: 消除生产代码中的 as any 类型不安全模式

- API 兼容层(openai/grok/gemini): 利用 BetaRawMessageStreamEvent 的
  discriminated union 在 switch/case 中直接属性访问,消除 ~29 个 as any
- ConsoleOAuthFlow: 用 as unknown as Parameters<typeof> 替代 as any
- performanceShim: 用 Record<string, unknown> 和显式类型断言替代 as any
- companionReact/auth: 直接访问已有类型属性消除 as any
- sliceAnsi/textHighlighting: 用 as Char 替代 as any(Token 联合类型收窄)
- ccrClient: 利用 RequestResult 类型收窄直接访问 retryAfterMs
- outputsScanner: 用 TurnStartTime.turnStartTime 属性访问替代双重断言
- plans: 用显式数组类型替代 as any[]
- FeedbackSurvey: 用 in 操作符和 Parameters<typeof> 替代 as any
- messageQueueManager: 用 Record<string, unknown> 替代 as any
- mcp.ts: 用 in 操作符类型守卫替代 as any

precheck 通过: typecheck 零错误 + 5420 测试全部通过 + lint 通过

* fix: 将 pipeIpc 添加到 AppState 类型声明,消除 4 个 as any

- AppStateStore: 添加 pipeIpc?: PipeIpcState 可选字段
- PromptInputFooter: 直接访问 s.pipeIpc
- useBackgroundTaskNavigation: 直接访问 s.pipeIpc
- usePipeRouter: 直接访问 store.getState().pipeIpc
- REPL.tsx: 移除 getPipeIpc(s as any) 中的 as any

precheck 通过

* fix: 消除 UltraplanChoiceDialog 中的 wheelDown/wheelUp as any

Ink Key 类型已包含 wheelDown/wheelUp 属性,直接访问即可。

* fix: 消除 sideQuestion.ts 中的 2 个 as any

- toolUse.name: 使用 as unknown as { name: string } 双重断言
- apiErr.error: 使用 as Parameters<typeof formatAPIError>[0] 类型参数

* fix: 为 auto dream 添加 maxTurns: 20 限制,防止单次执行消耗过多 token

* fix: 补充 SAFE_ENV_VARS 中缺失的 OpenAI/Gemini/Grok provider 环境变量

项目级 settings.local.json 的 env 字段在 trust dialog 之前只有
SAFE_ENV_VARS 白名单中的变量会被应用到 process.env。
OPENAI_API_KEY、OPENAI_BASE_URL 等关键变量不在白名单中,
导致容器中通过 settings.local.json 配置 OpenAI 协议时认证失败。

* fix: 修复 goalState.js 模块不存在的类型错误

* fix: 增强 providers 测试的环境变量隔离,防止 mock 污染

* fix: 内联 providers 测试逻辑,彻底隔离 mock 污染

测试不再 import providers.ts(其默认参数触发 getInitialSettings 全链),
改为内联纯函数逻辑,从根源消除 CI 上其他测试 mock.module 污染。

* fix: 添加 goalState 模块存根,修复 CI 构建打包解析失败

CI 中的 autonomy-lifecycle-user-flow 集成测试会执行 build.ts 打包 CLI。
此前 PromptInputFooterLeftSide.tsx 中 require('../../services/goal/goalState.js')
的路径在源码中不存在,打包器报 Could not resolve,导致 (unnamed) 测试失败。

新增 src/services/goal/goalState.ts 存根模块(getGoal 返回 null,组件不渲染),
让打包器在构建期可以解析该 require 路径。同时把 PromptInputFooterLeftSide.tsx
里两处 as unknown as 内联类型签名换成 as typeof import(...),让类型直接来自
存根模块,避免类型定义重复。
2026-06-11 17:59:08 +08:00

164 lines
6.2 KiB
TypeScript

/**
* Side Question ("/btw") feature - allows asking quick questions without
* interrupting the main agent context.
*
* Uses runForkedAgent to leverage prompt caching from the parent context
* while keeping the side question response separate from main conversation.
*/
import { formatAPIError } from '@ant/model-provider'
import type { NonNullableUsage } from '@ant/model-provider'
import type { Message, SystemAPIErrorMessage } from '../types/message.js'
import { type CacheSafeParams, runForkedAgent } from './forkedAgent.js'
import { createUserMessage, extractTextContent } from './messages.js'
// Pattern to detect "/btw" at start of input (case-insensitive, word boundary)
const BTW_PATTERN = /^\/btw\b/gi
/**
* Find positions of "/btw" keyword at the start of text for highlighting.
* Similar to findThinkingTriggerPositions in thinking.ts.
*/
export function findBtwTriggerPositions(text: string): Array<{
word: string
start: number
end: number
}> {
const positions: Array<{ word: string; start: number; end: number }> = []
const matches = text.matchAll(BTW_PATTERN)
for (const match of matches) {
if (match.index !== undefined) {
positions.push({
word: match[0],
start: match.index,
end: match.index + match[0].length,
})
}
}
return positions
}
export type SideQuestionResult = {
response: string | null
usage: NonNullableUsage
}
/**
* Run a side question using a forked agent.
* Shares the parent's prompt cache — no thinking override, no cache write.
* All tools are blocked and we cap at 1 turn.
*/
export async function runSideQuestion({
question,
cacheSafeParams,
}: {
question: string
cacheSafeParams: CacheSafeParams
}): Promise<SideQuestionResult> {
// Wrap the question with instructions to answer without tools
const wrappedQuestion = `<system-reminder>This is a side question from the user. You must answer this question directly in a single response.
IMPORTANT CONTEXT:
- You are a separate, lightweight agent spawned to answer this one question
- The main agent is NOT interrupted - it continues working independently in the background
- You share the conversation context but are a completely separate instance
- Do NOT reference being interrupted or what you were "previously doing" - that framing is incorrect
CRITICAL CONSTRAINTS:
- You have NO tools available - you cannot read files, run commands, search, or take any actions
- This is a one-off response - there will be no follow-up turns
- You can ONLY provide information based on what you already know from the conversation context
- NEVER say things like "Let me try...", "I'll now...", "Let me check...", or promise to take any action
- If you don't know the answer, say so - do not offer to look it up or investigate
Simply answer the question with the information you have.</system-reminder>
${question}`
const agentResult = await runForkedAgent({
promptMessages: [createUserMessage({ content: wrappedQuestion })],
// Do NOT override thinkingConfig — thinking is part of the API cache key,
// and diverging from the main thread's config busts the prompt cache.
// Adaptive thinking on a quick Q&A has negligible overhead.
cacheSafeParams,
canUseTool: async () => ({
behavior: 'deny' as const,
message: 'Side questions cannot use tools',
decisionReason: { type: 'other' as const, reason: 'side_question' },
}),
querySource: 'side_question',
forkLabel: 'side_question',
maxTurns: 1, // Single turn only - no tool use loops
// No future request shares this suffix; skip writing cache entries.
skipCacheWrite: true,
})
return {
response: extractSideQuestionResponse(agentResult.messages),
usage: agentResult.totalUsage,
}
}
/**
* Extract a display string from forked agent messages.
*
* IMPORTANT: claude.ts yields one AssistantMessage PER CONTENT BLOCK, not one
* per API response. With adaptive thinking enabled (inherited from the main
* thread to preserve the cache key), a thinking response arrives as:
* messages[0] = assistant { content: [thinking_block] }
* messages[1] = assistant { content: [text_block] }
*
* The old code used `.find(m => m.type === 'assistant')` which grabbed the
* first (thinking-only) message, found no text block, and returned null →
* "No response received". Repos with large context (many skills, big CLAUDE.md)
* trigger thinking more often, which is why this reproduced in the monorepo
* but not here.
*
* Secondary failure modes also surfaced as "No response received":
* - Model attempts tool_use → content = [thinking, tool_use], no text.
* Rare — the system-reminder usually prevents this, but handled here.
* - API error exhausts retries → query yields system api_error + user
* interruption, no assistant message at all.
*/
function extractSideQuestionResponse(messages: Message[]): string | null {
// Flatten all assistant content blocks across the per-block messages.
const assistantBlocks = messages.flatMap(m =>
m.type === 'assistant'
? (m.message!.content as unknown as Array<{
type: string
[key: string]: unknown
}>)
: [],
)
if (assistantBlocks.length > 0) {
// Concatenate all text blocks (there's normally at most one, but be safe).
const text = extractTextContent(assistantBlocks, '\n\n').trim()
if (text) return text
// No text — check if the model tried to call a tool despite instructions.
const toolUse = assistantBlocks.find(b => b.type === 'tool_use')
if (toolUse) {
const toolName =
'name' in toolUse
? (toolUse as unknown as { name: string }).name
: 'a tool'
return `(The model tried to call ${toolName} instead of answering directly. Try rephrasing or ask in the main conversation.)`
}
}
// No assistant content — likely API error exhausted retries. Surface the
// first system api_error message so the user sees what happened.
const apiErr = messages.find(
(m): m is SystemAPIErrorMessage =>
m.type === 'system' && 'subtype' in m && m.subtype === 'api_error',
)
if (apiErr) {
return `(API error: ${formatAPIError(apiErr.error as Parameters<typeof formatAPIError>[0])})`
}
return null
}