Merge remote-tracking branch 'origin/main' into feat/background-subagent

Resolve conflicts in client.ts (keep both notificationDisplayText and
modelOverride fields), useGeminiStream.ts (combine both options), and
agent.ts (integrate background subagent feature with fork subagent
refactor and resolvedMode improvements).
This commit is contained in:
tanzhenxin 2026-04-16 21:34:53 +08:00
commit aba07b890e
378 changed files with 32772 additions and 5085 deletions

View file

@ -17,6 +17,7 @@ import type {
ThoughtSummary,
ToolCallRequestInfo,
GeminiErrorEventValue,
StopFailureErrorType,
} from '@qwen-code/qwen-code-core';
import {
GeminiEventType as ServerGeminiEventType,
@ -78,6 +79,43 @@ import { t } from '../../i18n/index.js';
const debugLogger = createDebugLogger('GEMINI_STREAM');
/**
* Classify API error to StopFailureErrorType
* @internal Exported for testing purposes
*/
export function classifyApiError(error: {
message: string;
status?: number;
}): StopFailureErrorType {
const status = error.status;
const message = error.message?.toLowerCase() ?? '';
if (status === 429 || message.includes('rate limit')) {
return 'rate_limit';
}
if (status === 401 || message.includes('unauthorized')) {
return 'authentication_failed';
}
if (
status === 402 ||
status === 403 ||
message.includes('billing') ||
message.includes('quota')
) {
return 'billing_error';
}
if (status === 400 || message.includes('invalid')) {
return 'invalid_request';
}
if (status !== undefined && status >= 500) {
return 'server_error';
}
if (message.includes('max_tokens') || message.includes('token limit')) {
return 'max_output_tokens';
}
return 'unknown';
}
/**
* Checks if image parts have supported formats and returns unsupported ones
*/
@ -199,6 +237,8 @@ export const useGeminiStream = (
null,
);
const processedMemoryToolsRef = useRef<Set<string>>(new Set());
const submitPromptOnCompleteRef = useRef<(() => Promise<void>) | null>(null);
const modelOverrideRef = useRef<string | undefined>(undefined);
const {
startNewPrompt,
getPromptCount,
@ -218,13 +258,13 @@ export const useGeminiStream = (
async (completedToolCallsFromScheduler) => {
// This onComplete is called when ALL scheduled tools for a given batch are done.
if (completedToolCallsFromScheduler.length > 0) {
const projectRoot = config.getProjectRoot();
// Add the final state of these tools to the history for display.
addItem(
mapTrackedToolCallsToDisplay(
completedToolCallsFromScheduler as TrackedToolCall[],
),
Date.now(),
const toolGroupDisplay = mapTrackedToolCallsToDisplay(
completedToolCallsFromScheduler as TrackedToolCall[],
projectRoot,
);
addItem(toolGroupDisplay, Date.now());
// Handle tool response submission immediately when tools complete
await handleCompletedTools(
@ -239,8 +279,10 @@ export const useGeminiStream = (
const pendingToolCallGroupDisplay = useMemo(
() =>
toolCalls.length ? mapTrackedToolCallsToDisplay(toolCalls) : undefined,
[toolCalls],
toolCalls.length
? mapTrackedToolCallsToDisplay(toolCalls, config.getProjectRoot())
: undefined,
[toolCalls, config],
);
const activeToolPtyId = useMemo(() => {
@ -537,6 +579,8 @@ export const useGeminiStream = (
}
case 'submit_prompt': {
localQueryToSendToGemini = slashCommandResult.content;
submitPromptOnCompleteRef.current =
slashCommandResult.onComplete ?? null;
return {
queryToSend: localQueryToSendToGemini,
@ -808,6 +852,12 @@ export const useGeminiStream = (
// (auto-retry countdown is shown when retryCountdownTimerRef is active)
const isShowingAutoRetry = retryCountdownTimerRef.current !== null;
clearRetryCountdown();
const formattedErrorText = parseAndFormatApiError(
eventValue.error,
config.getContentGeneratorConfig()?.authType,
);
if (!isShowingAutoRetry) {
const retryHint = t('Press Ctrl+Y to retry');
// Store error with hint as a pending item (not in history).
@ -815,14 +865,24 @@ export const useGeminiStream = (
// since pending items are in the dynamic rendering area (not <Static>).
setPendingRetryErrorItem({
type: 'error' as const,
text: parseAndFormatApiError(
eventValue.error,
config.getContentGeneratorConfig()?.authType,
),
text: formattedErrorText,
hint: retryHint,
});
}
setThought(null); // Reset thought when there's an error
// Fire StopFailure hook (fire-and-forget, replaces Stop event for API errors)
const errorType = classifyApiError(eventValue.error);
config
.getHookSystem()
?.fireStopFailureEvent(
errorType,
eventValue.error.message,
formattedErrorText,
)
.catch((err) => {
debugLogger.warn(`StopFailure hook failed: ${err}`);
});
},
[
addItem,
@ -1215,6 +1275,11 @@ export const useGeminiStream = (
!allowConcurrentBtwDuringResponse
) {
setModelSwitchedFromQuotaError(false);
// Clear model override for new user turns, but preserve it on retry
// so the same skill-selected model is used again.
if (submitType !== SendMessageType.Retry) {
modelOverrideRef.current = undefined;
}
// Commit any pending retry error to history (without hint) since the
// user is starting a new conversation turn.
// Clear both countdown-based errors AND static errors (those without
@ -1318,6 +1383,7 @@ export const useGeminiStream = (
{
type: submitType,
notificationDisplayText: metadata?.notificationDisplayText,
modelOverride: modelOverrideRef.current,
},
);
@ -1347,6 +1413,35 @@ export const useGeminiStream = (
loopDetectedRef.current = false;
handleLoopDetectedEvent();
}
// If the turn was initiated by a submit_prompt with an onComplete
// callback (e.g. /dream recording lastDreamAt), fire it now.
const onComplete = submitPromptOnCompleteRef.current;
if (onComplete) {
submitPromptOnCompleteRef.current = null;
void onComplete();
}
// After the turn completes, wire up notifications for any background
// dream / extraction tasks that were kicked off by the client.
if (geminiClient) {
const memoryTaskPromises =
geminiClient.consumePendingMemoryTaskPromises();
for (const p of memoryTaskPromises) {
void p.then((count) => {
if (count > 0) {
addItem(
{
type: 'memory_saved',
writtenCount: count,
verb: 'Updated',
} as HistoryItemWithoutId,
Date.now(),
);
}
});
}
}
} catch (error: unknown) {
if (error instanceof UnauthorizedError) {
onAuthError('Session expired or is unauthorized.');
@ -1584,6 +1679,15 @@ export const useGeminiStream = (
(toolCall) => toolCall.request.prompt_id,
);
// Persist model override from skill tool results (last one wins).
// Uses `in` so that undefined (from inherit/no-model skills) clears a
// prior override, while non-skill tools (field absent) leave it intact.
for (const toolCall of geminiTools) {
if ('modelOverride' in toolCall.response) {
modelOverrideRef.current = toolCall.response.modelOverride;
}
}
markToolsAsSubmitted(callIdsToMarkAsSubmitted);
// Don't continue if model was switched due to quota error