diff --git a/gitnexus-web/src/components/ToolCallCard.tsx b/gitnexus-web/src/components/ToolCallCard.tsx index da27b789a..b94239781 100644 --- a/gitnexus-web/src/components/ToolCallCard.tsx +++ b/gitnexus-web/src/components/ToolCallCard.tsx @@ -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: , + color: 'text-amber-300', + bgColor: 'bg-amber-500/10', + borderColor: 'border-amber-500/30', + }; default: return { icon: , diff --git a/gitnexus-web/src/core/llm/agent.ts b/gitnexus-web/src/core/llm/agent.ts index 5153e35fb..4f79d1c95 100644 --- a/gitnexus-web/src/core/llm/agent.ts +++ b/gitnexus-web/src/core/llm/agent.ts @@ -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) { diff --git a/gitnexus-web/src/core/llm/types.ts b/gitnexus-web/src/core/llm/types.ts index c568c7d93..b7727da10 100644 --- a/gitnexus-web/src/core/llm/types.ts +++ b/gitnexus-web/src/core/llm/types.ts @@ -254,7 +254,7 @@ export interface ToolCallInfo { name: string; args: Record; 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 diff --git a/gitnexus-web/src/hooks/useAppState.tsx b/gitnexus-web/src/hooks/useAppState.tsx index 020d5ebcf..5c118450b 100644 --- a/gitnexus-web/src/hooks/useAppState.tsx +++ b/gitnexus-web/src/hooks/useAppState.tsx @@ -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(null); + const chatAbortRef = useRef(null); + const chatStateRef = useRef<'idle' | 'streaming' | 'aborting'>('idle'); const initializeAgent = useCallback( async (overrideProjectName?: string): Promise => { @@ -646,7 +648,7 @@ const AppStateProviderInner = ({ children }: { children: ReactNode }) => { const sendChatMessage = useCallback( async (message: string): Promise => { - 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 diff --git a/gitnexus-web/src/locales/en/chat.json b/gitnexus-web/src/locales/en/chat.json index 8fd9719bc..4d976c335 100644 --- a/gitnexus-web/src/locales/en/chat.json +++ b/gitnexus-web/src/locales/en/chat.json @@ -24,6 +24,7 @@ "clearChat": "Clear chat", "stopResponse": "Stop response" }, + "stopped": "Stopped by user", "badges": { "configureAI": "Configure AI", "connecting": "Connecting" diff --git a/gitnexus-web/src/locales/en/graph.json b/gitnexus-web/src/locales/en/graph.json index 5862253d9..072e47c2d 100644 --- a/gitnexus-web/src/locales/en/graph.json +++ b/gitnexus-web/src/locales/en/graph.json @@ -10,7 +10,8 @@ "status": { "running": "running", "completed": "completed", - "error": "error" + "error": "error", + "stopped": "stopped" }, "tools": { "search": "🔍 Search Code", diff --git a/gitnexus-web/src/locales/zh-CN/chat.json b/gitnexus-web/src/locales/zh-CN/chat.json index 366fb7ccb..f83c5ad75 100644 --- a/gitnexus-web/src/locales/zh-CN/chat.json +++ b/gitnexus-web/src/locales/zh-CN/chat.json @@ -24,6 +24,7 @@ "clearChat": "清空聊天", "stopResponse": "停止响应" }, + "stopped": "用户已停止", "badges": { "configureAI": "配置 AI", "connecting": "连接中" diff --git a/gitnexus-web/src/locales/zh-CN/graph.json b/gitnexus-web/src/locales/zh-CN/graph.json index 671c72ba0..7fc7c71e7 100644 --- a/gitnexus-web/src/locales/zh-CN/graph.json +++ b/gitnexus-web/src/locales/zh-CN/graph.json @@ -10,7 +10,8 @@ "status": { "running": "运行中", "completed": "已完成", - "error": "错误" + "error": "错误", + "stopped": "已停止" }, "tools": { "search": "🔍 搜索代码", diff --git a/gitnexus-web/test/unit/agent-abort.test.ts b/gitnexus-web/test/unit/agent-abort.test.ts new file mode 100644 index 000000000..2a8475e34 --- /dev/null +++ b/gitnexus-web/test/unit/agent-abort.test.ts @@ -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 | undefined; + + const agent = { + stream: async (_input: unknown, config: Record) => { + 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' }]); + }); +});