GitNexus/gitnexus-web/test/unit/agent-abort.test.ts
Bassey Riman 6c572749b0
fix(web): stop Nexus AI agent when user clicks Stop (#1820)
* fix(web): stop Nexus AI agent when user clicks Stop

Wire AbortController through chat streaming so Stop cancels the LangGraph
run instead of only hiding the loading UI. Fixes #1615.

* 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.

* chore(autofix): apply prettier + eslint fixes via /autofix command

* fix(web): address review findings for Nexus AI stop/cancel

- Fix race conditions in useAppState.tsx abort lifecycle:
  - Replace stale isChatLoading closure guard with chatStateRef
  - Track and cancel rAF handles in stopChatResponse/finally
  - Move cancelled chunk check before onChunk dispatch
  - Simplify finally block to unconditional cleanup via chatStateRef
  - Guard tool_result from overwriting stopped status
  - Have clearChat abort in-flight streams before clearing
- Reorder isAbortError to check error identity before signal.aborted
- Refactor AgentStreamChunk to discriminated union for exhaustive switch
- Fix test assertions to use exact .toEqual() per DoD §2.7
- Add test for plain Error with name AbortError
- Remove dead markStopped alias, simplify signal spread-conditional

---------

Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Test <test@example.com>
2026-05-26 19:18:36 +01:00

97 lines
2.9 KiB
TypeScript

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' }]);
});
});