mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-05 02:43:32 +00:00
fix(web): address PR review feedback for Nexus AI stop
Guard stream cleanup against Stop-then-Send races, remove dead cancelled handler, tighten abort error detection, add stopped tool-call status, and extend abort unit tests. Fixes #1615.
This commit is contained in:
parent
6918eb662b
commit
9cd4ab895c
7 changed files with 67 additions and 57 deletions
|
|
@ -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" />,
|
||||
|
|
|
|||
|
|
@ -367,11 +367,11 @@ export interface AgentRuntimeOptions {
|
|||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
const isAbortError = (error: unknown): boolean => {
|
||||
const isAbortError = (error: unknown, signal?: AbortSignal): boolean => {
|
||||
if (signal?.aborted) return true;
|
||||
if (error instanceof DOMException && error.name === 'AbortError') return true;
|
||||
if (error instanceof Error && error.name === 'AbortError') return true;
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return /aborted|abort/i.test(message);
|
||||
return false;
|
||||
};
|
||||
|
||||
export const buildLangChainMessages = (messages: AgentMessage[]): BaseMessage[] =>
|
||||
|
|
@ -662,7 +662,7 @@ export async function* streamAgentResponse(
|
|||
: undefined,
|
||||
};
|
||||
} catch (error) {
|
||||
if (options.signal?.aborted || isAbortError(error)) {
|
||||
if (isAbortError(error, options.signal)) {
|
||||
yield { type: 'cancelled' };
|
||||
return;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -1017,39 +1017,6 @@ const AppStateProviderInner = ({ children }: { children: ReactNode }) => {
|
|||
// Finalize the assistant message - just call updateMessage one more time
|
||||
scheduleMessageUpdate();
|
||||
break;
|
||||
|
||||
case 'cancelled': {
|
||||
const stoppedLabel = i18n.t('chat:stopped');
|
||||
for (let i = 0; i < toolCallsForMessage.length; i++) {
|
||||
const tc = toolCallsForMessage[i];
|
||||
if (tc.status === 'running' || tc.status === 'pending') {
|
||||
toolCallsForMessage[i] = {
|
||||
...tc,
|
||||
status: 'error',
|
||||
result: stoppedLabel,
|
||||
};
|
||||
}
|
||||
}
|
||||
for (let i = 0; i < stepsForMessage.length; i++) {
|
||||
const step = stepsForMessage[i];
|
||||
if (
|
||||
step.type === 'tool_call' &&
|
||||
step.toolCall &&
|
||||
(step.toolCall.status === 'running' || step.toolCall.status === 'pending')
|
||||
) {
|
||||
stepsForMessage[i] = {
|
||||
...step,
|
||||
toolCall: {
|
||||
...step.toolCall,
|
||||
status: 'error',
|
||||
result: stoppedLabel,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
scheduleMessageUpdate();
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -1062,9 +1029,6 @@ const AppStateProviderInner = ({ children }: { children: ReactNode }) => {
|
|||
captureHistory: providerCapabilities.preserveAssistantTranscript,
|
||||
signal: chatAbortController.signal,
|
||||
})) {
|
||||
if (chatAbortController.signal.aborted) {
|
||||
break;
|
||||
}
|
||||
onChunk(chunk);
|
||||
if (chunk.type === 'cancelled') {
|
||||
break;
|
||||
|
|
@ -1078,9 +1042,13 @@ const AppStateProviderInner = ({ children }: { children: ReactNode }) => {
|
|||
} finally {
|
||||
if (chatAbortRef.current === chatAbortController) {
|
||||
chatAbortRef.current = null;
|
||||
setIsChatLoading(false);
|
||||
setCurrentToolCalls([]);
|
||||
} else if (!chatAbortRef.current) {
|
||||
// Stopped with no new turn started — safe to clear loading UI
|
||||
setIsChatLoading(false);
|
||||
setCurrentToolCalls([]);
|
||||
}
|
||||
setIsChatLoading(false);
|
||||
setCurrentToolCalls([]);
|
||||
}
|
||||
},
|
||||
[
|
||||
|
|
@ -1105,13 +1073,12 @@ const AppStateProviderInner = ({ children }: { children: ReactNode }) => {
|
|||
chatAbortRef.current = null;
|
||||
|
||||
const stoppedLabel = i18n.t('chat:stopped');
|
||||
setCurrentToolCalls((prev) =>
|
||||
prev.map((tc) =>
|
||||
tc.status === 'running' || tc.status === 'pending'
|
||||
? { ...tc, status: 'error', result: stoppedLabel }
|
||||
: tc,
|
||||
),
|
||||
);
|
||||
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]
|
||||
|
|
@ -1121,10 +1088,7 @@ const AppStateProviderInner = ({ children }: { children: ReactNode }) => {
|
|||
if (lastAssistantIdx === undefined) return prev;
|
||||
|
||||
const message = prev[lastAssistantIdx];
|
||||
const markStopped = (tc: ToolCallInfo): ToolCallInfo =>
|
||||
tc.status === 'running' || tc.status === 'pending'
|
||||
? { ...tc, status: 'error', result: stoppedLabel }
|
||||
: tc;
|
||||
const markStopped = markStoppedToolCall;
|
||||
|
||||
const updated: ChatMessage = {
|
||||
...message,
|
||||
|
|
|
|||
|
|
@ -10,7 +10,8 @@
|
|||
"status": {
|
||||
"running": "running",
|
||||
"completed": "completed",
|
||||
"error": "error"
|
||||
"error": "error",
|
||||
"stopped": "stopped"
|
||||
},
|
||||
"tools": {
|
||||
"search": "🔍 Search Code",
|
||||
|
|
|
|||
|
|
@ -10,7 +10,8 @@
|
|||
"status": {
|
||||
"running": "运行中",
|
||||
"completed": "已完成",
|
||||
"error": "错误"
|
||||
"error": "错误",
|
||||
"stopped": "已停止"
|
||||
},
|
||||
"tools": {
|
||||
"search": "🔍 搜索代码",
|
||||
|
|
|
|||
|
|
@ -27,7 +27,6 @@ describe('streamAgentResponse abort', () => {
|
|||
stream: async function* () {
|
||||
yield ['values', { messages: [] }];
|
||||
controller.abort();
|
||||
// Simulate a long-running graph step after abort
|
||||
for (let i = 0; i < 100; i++) {
|
||||
yield ['messages', [{ _getType: () => 'ai', content: 'still going' }]];
|
||||
}
|
||||
|
|
@ -45,4 +44,41 @@ describe('streamAgentResponse abort', () => {
|
|||
expect(chunks.some((c) => c.type === 'cancelled')).toBe(true);
|
||||
expect(chunks.some((c) => c.type === 'error')).toBe(false);
|
||||
});
|
||||
|
||||
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('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' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue