Merge branch 'main' into codex/cpp-strict-fn-type-adl

This commit is contained in:
Gergő Magyar 2026-05-26 19:18:45 +01:00 • committed by GitHub
commit 552f1369f4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 223 additions and 35 deletions

View file

@ -13,6 +13,7 @@ import {
Check,
Loader2,
AlertCircle,
Square,
} from '@/lib/lucide-icons';
import type { ToolCallInfo } from '../core/llm/types';
import type { TFunction } from 'i18next';
@ -77,6 +78,13 @@ const getStatusDisplay = (status: ToolCallInfo['status']) => {
bgColor: 'bg-rose-500/10',
borderColor: 'border-rose-500/30',
};
case 'stopped':
return {
icon: <Square className="h-3.5 w-3.5 fill-current" />,
color: 'text-amber-300',
bgColor: 'bg-amber-500/10',
borderColor: 'border-amber-500/30',
};
default:
return {
icon: <Sparkles className="h-3.5 w-3.5" />,

View file

@ -363,8 +363,17 @@ export type AgentMessage = { role: 'user'; content: string } | AgentHistoryMessa
export interface AgentRuntimeOptions {
/** Capture assistant/tool messages for providers that require exact transcript replay. */
captureHistory?: boolean;
/** When aborted (e.g. user clicked Stop), the stream ends with a `cancelled` chunk. */
signal?: AbortSignal;
}
const isAbortError = (error: unknown, signal?: AbortSignal): boolean => {
if (error instanceof DOMException && error.name === 'AbortError') return true;
if (error instanceof Error && error.name === 'AbortError') return true;
if (signal?.aborted) return true;
return false;
};
export const buildLangChainMessages = (messages: AgentMessage[]): BaseMessage[] =>
messages.map((message) => {
if (message.role === 'user') {
@ -438,6 +447,7 @@ export async function* streamAgentResponse(
streamMode: ['values', 'messages'] as any,
// Allow longer tool/reasoning loops (more Cursor-like persistence)
recursionLimit: 50,
signal: options.signal,
} as any);
// Track what we've yielded to avoid duplicates
@ -455,6 +465,10 @@ export async function* streamAgentResponse(
let lastStepMessages: any[] | null = null;
for await (const event of stream) {
if (options.signal?.aborted) {
break;
}
// Events come as [streamMode, data] tuples when using multiple modes
// or just data when using single mode
let mode: string;
@ -511,10 +525,11 @@ export async function* streamAgentResponse(
// - After all tools are done: treat as final content
const isReasoning =
!hasSeenToolCallThisTurn || toolCalls.length > 0 || pendingToolCalls > 0;
yield {
type: isReasoning ? 'reasoning' : 'content',
[isReasoning ? 'reasoning' : 'content']: content,
};
if (isReasoning) {
yield { type: 'reasoning', reasoning: content };
} else {
yield { type: 'content', content };
}
}
// Track tool calls from message chunks
@ -627,6 +642,11 @@ export async function* streamAgentResponse(
}
}
if (options.signal?.aborted) {
yield { type: 'cancelled' };
return;
}
// DEBUG: Stream completed normally
if (import.meta.env.DEV) {
console.log('✅ Stream completed normally, yielding done');
@ -640,6 +660,10 @@ export async function* streamAgentResponse(
: undefined,
};
} catch (error) {
if (isAbortError(error, options.signal)) {
yield { type: 'cancelled' };
return;
}
const message = error instanceof Error ? error.message : String(error);
// DEBUG: Stream error
if (import.meta.env.DEV) {

View file

@ -254,7 +254,7 @@ export interface ToolCallInfo {
name: string;
args: Record<string, unknown>;
result?: string;
status: 'pending' | 'running' | 'completed' | 'error';
status: 'pending' | 'running' | 'completed' | 'error' | 'stopped';
}
/**
@ -286,22 +286,17 @@ export type AgentHistoryMessage =
};
/**
* Streaming chunk from agent
* Now supports step-based streaming where each step is a distinct message
* Streaming chunk from agent (discriminated union).
* Each variant carries only its relevant fields, enabling exhaustive switch handling.
*/
export interface AgentStreamChunk {
type: 'reasoning' | 'tool_call' | 'tool_result' | 'content' | 'error' | 'done';
/** LLM's reasoning/thinking text (shown as a step) */
reasoning?: string;
/** Final answer content (streamed token by token) */
content?: string;
/** Hidden raw transcript for reconstructing future agent turns */
historyMessages?: AgentHistoryMessage[];
/** Tool call information */
toolCall?: ToolCallInfo;
/** Error message */
error?: string;
}
export type AgentStreamChunk =
| { type: 'reasoning'; reasoning: string }
| { type: 'tool_call'; toolCall: ToolCallInfo }
| { type: 'tool_result'; toolCall: ToolCallInfo }
| { type: 'content'; content: string }
| { type: 'error'; error: string }
| { type: 'done'; historyMessages?: AgentHistoryMessage[] }
| { type: 'cancelled' };
/**
* A single step in the agent's execution

View file

@ -588,6 +588,8 @@ const AppStateProviderInner = ({ children }: { children: ReactNode }) => {
// Agent state — agent runs on main thread now (I/O-bound, not CPU-bound)
const agentRef = useRef<any>(null);
const chatAbortRef = useRef<AbortController | null>(null);
const chatStateRef = useRef<'idle' | 'streaming' | 'aborting'>('idle');
const initializeAgent = useCallback(
async (overrideProjectName?: string): Promise<void> => {
@ -646,7 +648,7 @@ const AppStateProviderInner = ({ children }: { children: ReactNode }) => {
const sendChatMessage = useCallback(
async (message: string): Promise<void> => {
if (isChatLoading) return;
if (chatStateRef.current !== 'idle') return;
// Refresh Code panel for the new question: keep user-pinned refs, clear old AI citations
clearAICodeReferences();
@ -685,8 +687,13 @@ const AppStateProviderInner = ({ children }: { children: ReactNode }) => {
}
setIsChatLoading(true);
chatStateRef.current = 'streaming';
setCurrentToolCalls([]);
chatAbortRef.current?.abort();
const chatAbortController = new AbortController();
chatAbortRef.current = chatAbortController;
const providerCapabilities = getProviderCapabilities(llmSettings.activeProvider);
// Prepare message history for agent (convert our format to AgentMessage format)
@ -742,11 +749,13 @@ const AppStateProviderInner = ({ children }: { children: ReactNode }) => {
});
};
let pendingUpdate = false;
let rafHandle: number | null = null;
const scheduleMessageUpdate = () => {
if (pendingUpdate) return;
pendingUpdate = true;
requestAnimationFrame(() => {
rafHandle = requestAnimationFrame(() => {
pendingUpdate = false;
rafHandle = null;
updateMessage();
});
};
@ -893,7 +902,7 @@ const AppStateProviderInner = ({ children }: { children: ReactNode }) => {
if (idx < 0) {
idx = toolCallsForMessage.findIndex((t) => t.name === tc.name && !t.result);
}
if (idx >= 0) {
if (idx >= 0 && toolCallsForMessage[idx].status !== 'stopped') {
toolCallsForMessage[idx] = {
...toolCallsForMessage[idx],
result: tc.result,
@ -909,7 +918,11 @@ const AppStateProviderInner = ({ children }: { children: ReactNode }) => {
(s.toolCall.id === tc.id ||
(s.toolCall.name === tc.name && s.toolCall.status === 'running')),
);
if (stepIdx >= 0 && stepsForMessage[stepIdx].toolCall) {
if (
stepIdx >= 0 &&
stepsForMessage[stepIdx].toolCall &&
stepsForMessage[stepIdx].toolCall!.status !== 'stopped'
) {
stepsForMessage[stepIdx] = {
...stepsForMessage[stepIdx],
toolCall: {
@ -930,6 +943,8 @@ const AppStateProviderInner = ({ children }: { children: ReactNode }) => {
targetIdx = prev.findIndex((t) => t.name === tc.name && !t.result);
}
if (targetIdx >= 0) {
const target = prev[targetIdx];
if (target.status === 'stopped') return prev;
return prev.map((t, i) =>
i === targetIdx ? { ...t, result: tc.result, status: 'completed' } : t,
);
@ -1028,13 +1043,24 @@ const AppStateProviderInner = ({ children }: { children: ReactNode }) => {
const { streamAgentResponse } = await import('../core/llm/agent');
for await (const chunk of streamAgentResponse(agent, history, {
captureHistory: providerCapabilities.preserveAssistantTranscript,
signal: chatAbortController.signal,
})) {
if (chunk.type === 'cancelled') {
break;
}
onChunk(chunk);
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
setAgentError(message);
if (!chatAbortController.signal.aborted) {
const message = error instanceof Error ? error.message : String(error);
setAgentError(message);
}
} finally {
if (rafHandle != null) {
cancelAnimationFrame(rafHandle);
rafHandle = null;
}
chatStateRef.current = 'idle';
setIsChatLoading(false);
setCurrentToolCalls([]);
}
@ -1050,22 +1076,56 @@ const AppStateProviderInner = ({ children }: { children: ReactNode }) => {
clearAIToolHighlights,
graph,
embeddingStatus,
isChatLoading,
],
);
const stopChatResponse = useCallback(() => {
if (isChatLoading) {
// Agent streaming will be interrupted by the AbortController in sendChatMessage
setIsChatLoading(false);
setCurrentToolCalls([]);
}
}, [isChatLoading]);
if (!chatAbortRef.current) return;
chatStateRef.current = 'aborting';
chatAbortRef.current.abort();
chatAbortRef.current = null;
const stoppedLabel = i18n.t('chat:stopped');
const markStoppedToolCall = (tc: ToolCallInfo): ToolCallInfo =>
tc.status === 'running' || tc.status === 'pending'
? { ...tc, status: 'stopped', result: stoppedLabel }
: tc;
setCurrentToolCalls((prev) => prev.map(markStoppedToolCall));
setChatMessages((prev) => {
const lastAssistantIdx = [...prev]
.map((m, i) => (m.role === 'assistant' ? i : -1))
.filter((i) => i >= 0)
.pop();
if (lastAssistantIdx === undefined) return prev;
const message = prev[lastAssistantIdx];
const updated: ChatMessage = {
...message,
toolCalls: message.toolCalls?.map(markStoppedToolCall),
steps: message.steps?.map((step) =>
step.type === 'tool_call' && step.toolCall
? { ...step, toolCall: markStoppedToolCall(step.toolCall) }
: step,
),
};
return prev.map((m, i) => (i === lastAssistantIdx ? updated : m));
});
setIsChatLoading(false);
}, []);
const clearChat = useCallback(() => {
chatAbortRef.current?.abort();
chatAbortRef.current = null;
chatStateRef.current = 'idle';
setChatMessages([]);
setCurrentToolCalls([]);
setAgentError(null);
setIsChatLoading(false);
}, []);
// Switch to a different repo on the connected server

View file

@ -24,6 +24,7 @@
"clearChat": "Clear chat",
"stopResponse": "Stop response"
},
"stopped": "Stopped by user",
"badges": {
"configureAI": "Configure AI",
"connecting": "Connecting"

View file

@ -10,7 +10,8 @@
"status": {
"running": "running",
"completed": "completed",
"error": "error"
"error": "error",
"stopped": "stopped"
},
"tools": {
"search": "🔍 Search Code",

View file

@ -24,6 +24,7 @@
"clearChat": "清空聊天",
"stopResponse": "停止响应"
},
"stopped": "用户已停止",
"badges": {
"configureAI": "配置 AI",
"connecting": "连接中"

View file

@ -10,7 +10,8 @@
"status": {
"running": "运行中",
"completed": "已完成",
"error": "错误"
"error": "错误",
"stopped": "已停止"
},
"tools": {
"search": "🔍 搜索代码",

View file

@ -0,0 +1,97 @@
import { describe, expect, it } from 'vitest';
import { streamAgentResponse, type AgentMessage } from '../../src/core/llm/agent';
describe('streamAgentResponse abort', () => {
const userMessage: AgentMessage[] = [{ role: 'user', content: 'hello' }];
it('yields cancelled when the LangGraph stream throws AbortError', async () => {
const agent = {
stream: async () => {
throw new DOMException('The operation was aborted', 'AbortError');
},
};
const chunks = [];
for await (const chunk of streamAgentResponse(agent as any, userMessage, {
signal: new AbortController().signal,
})) {
chunks.push(chunk);
}
expect(chunks).toEqual([{ type: 'cancelled' }]);
});
it('yields cancelled when the abort signal is set mid-stream', async () => {
const controller = new AbortController();
const agent = {
stream: async function* () {
yield ['values', { messages: [] }];
controller.abort();
for (let i = 0; i < 100; i++) {
yield ['messages', [{ _getType: () => 'ai', content: 'still going' }]];
}
},
};
const chunks = [];
for await (const chunk of streamAgentResponse(agent as any, userMessage, {
signal: controller.signal,
})) {
chunks.push(chunk);
if (chunk.type === 'cancelled') break;
}
expect(chunks[chunks.length - 1]).toEqual({ type: 'cancelled' });
expect(chunks.filter((c) => c.type === 'error')).toEqual([]);
});
it('passes AbortSignal to agent.stream config', async () => {
const controller = new AbortController();
let capturedConfig: Record<string, unknown> | undefined;
const agent = {
stream: async (_input: unknown, config: Record<string, unknown>) => {
capturedConfig = config;
throw new DOMException('aborted', 'AbortError');
},
};
for await (const _chunk of streamAgentResponse(agent as any, userMessage, {
signal: controller.signal,
})) {
// drain
}
expect(capturedConfig?.signal).toBe(controller.signal);
});
it('yields cancelled for a plain Error with name AbortError', async () => {
const agent = {
stream: async () => {
throw Object.assign(new Error('aborted'), { name: 'AbortError' });
},
};
const chunks = [];
for await (const chunk of streamAgentResponse(agent as any, userMessage)) {
chunks.push(chunk);
}
expect(chunks).toEqual([{ type: 'cancelled' }]);
});
it('does not treat unrelated errors mentioning abort as cancellation', async () => {
const agent = {
stream: async () => {
throw new Error('Cannot abort the current transaction');
},
};
const chunks = [];
for await (const chunk of streamAgentResponse(agent as any, userMessage)) {
chunks.push(chunk);
}
expect(chunks).toEqual([{ type: 'error', error: 'Cannot abort the current transaction' }]);
});
});