Skip to content

Commit 4430a35

Browse files
dokterbobaryadhruv
andauthored
Request cancellation with E2E tests and fixups (#229)
Co-authored-by: Dhruv <dhruv166arya@gmail.com>
1 parent c21f4bd commit 4430a35

File tree

11 files changed

+327
-230
lines changed

11 files changed

+327
-230
lines changed

.github/workflows/ci.yml

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,21 @@ on:
77
branches: ["**"]
88
workflow_dispatch:
99

10+
permissions: {}
11+
1012
env:
1113
DOCKER_IMAGE_NAME: ghcr.io/synergyai-nl/svelte-langgraph-frontend
1214

1315
jobs:
1416
ci-job:
1517
name: "CI Job"
1618
runs-on: "ubuntu-latest"
19+
permissions:
20+
contents: read # checkout
21+
checks: write # run-report-action
22+
pull-requests: write # run-report-action, moon-ci-retrospect
23+
statuses: write # moon-ci-retrospect
24+
actions: write # setup-toolchain cache
1725
steps:
1826
- uses: actions/checkout@v5
1927
with:
@@ -22,7 +30,11 @@ jobs:
2230
with:
2331
cache-base: main
2432
auto-install: true
25-
- run: "moon ci --color"
33+
- uses: nick-fields/retry@v3
34+
with:
35+
timeout_minutes: 5
36+
max_attempts: 3
37+
command: "moon ci --color"
2638
- uses: moonrepo/run-report-action@v1
2739
if: success() || failure()
2840
with:
@@ -61,6 +73,9 @@ jobs:
6173
build:
6274
name: "Docker Build"
6375
runs-on: ubuntu-latest
76+
permissions:
77+
contents: read # checkout
78+
packages: write # push to ghcr.io
6479
outputs:
6580
image_version: ${{ steps.meta.outputs.version }}
6681
steps:

apps/frontend/src/lib/components/Chat.svelte

Lines changed: 29 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
<script lang="ts">
2-
import { Client, type Thread } from '@langchain/langgraph-sdk';
2+
import { Client, type HumanMessage, type Thread } from '@langchain/langgraph-sdk';
33
import { streamAnswer } from '$lib/langgraph/streamAnswer.js';
44
import { convertThreadMessages } from '$lib/langgraph/utils.js';
55
import ChatInput from './ChatInput.svelte';
@@ -39,6 +39,8 @@
3939
let generationError = $state<Error | null>(null);
4040
let last_user_message = $state<string>('');
4141
42+
let generateController: AbortController | null;
43+
4244
// Load existing messages from thread on component initialization
4345
onMount(() => {
4446
if (thread?.values?.messages && thread.values.messages.length > 0) {
@@ -118,17 +120,25 @@
118120
final_answer_started = false;
119121
generationError = null; // Clear previous errors
120122
123+
generateController = new AbortController();
124+
const signal = generateController.signal;
125+
126+
const inputMessage: HumanMessage = { type: 'human', content: messageText, id: messageId };
127+
121128
try {
122129
for await (const chunk of streamAnswer(
123130
langGraphClient,
124131
thread.thread_id,
125132
assistantId,
126-
messageText,
127-
messageId
133+
inputMessage,
134+
signal
128135
))
129136
updateMessages(chunk);
130-
} catch (err) {
131-
if (err instanceof Error) generationError = err;
137+
} catch (e) {
138+
// Aborted by user, ignore.
139+
if (e instanceof DOMException && e.name === 'AbortError') return;
140+
141+
if (e instanceof Error) generationError = e;
132142
error(500, {
133143
message: 'Error during generation'
134144
});
@@ -144,6 +154,14 @@
144154
submitInputOrRetry(true);
145155
}
146156
}
157+
158+
async function stopGeneration() {
159+
if (!generateController)
160+
throw Error(
161+
'Unable to cancel null generateController. This is a bug! Was a generation running? Was an abort controller passed?'
162+
);
163+
generateController.abort();
164+
}
147165
</script>
148166

149167
<div class="flex h-[calc(100vh-4rem)] flex-col">
@@ -167,5 +185,10 @@
167185
/>
168186
{/if}
169187
</div>
170-
<ChatInput bind:value={current_input} isStreaming={is_streaming} onSubmit={submitInputOrRetry} />
188+
<ChatInput
189+
bind:value={current_input}
190+
isStreaming={is_streaming}
191+
onSubmit={submitInputOrRetry}
192+
onStop={() => stopGeneration()}
193+
/>
171194
</div>

apps/frontend/src/lib/components/Chat.svelte.test.ts

Lines changed: 89 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { describe, test, expect, vi } from 'vitest';
2-
import { screen, waitFor } from '@testing-library/svelte';
2+
import { screen, waitFor, within } from '@testing-library/svelte';
33
import { userEvent } from '@testing-library/user-event';
44
import { renderWithProviders } from './__tests__/render';
55
import { anAIMessage } from './__tests__/fixtures';
@@ -106,8 +106,12 @@ describe('Chat', () => {
106106
mockClient,
107107
'test-123',
108108
'assistant-1',
109-
'Tell me about AI',
110-
expect.any(String)
109+
expect.objectContaining({
110+
type: 'human',
111+
content: 'Tell me about AI',
112+
id: expect.any(String)
113+
}),
114+
expect.any(AbortSignal)
111115
);
112116
});
113117
});
@@ -157,6 +161,88 @@ describe('Chat', () => {
157161
});
158162
});
159163

164+
describe('when stop is clicked', () => {
165+
test('stop button aborts the stream signal', async () => {
166+
const user = userEvent.setup();
167+
let capturedSignal: AbortSignal | undefined;
168+
169+
vi.mocked(streamAnswer).mockImplementation(async function* (_c, _t, _a, _im, signal) {
170+
capturedSignal = signal;
171+
yield anAIMessage({ text: 'Partial...' });
172+
await new Promise<void>((r) => signal.addEventListener('abort', () => r()));
173+
});
174+
175+
renderChat();
176+
await user.type(screen.getByPlaceholderText('Message...'), 'Hello');
177+
await user.keyboard('{Enter}');
178+
179+
const form = document.getElementById('input_form')!;
180+
const stopButton = await within(form).findByRole('button');
181+
await user.click(stopButton);
182+
183+
expect(capturedSignal?.aborted).toBe(true);
184+
});
185+
186+
test('aborting stream shows no error', async () => {
187+
const user = userEvent.setup();
188+
vi.mocked(streamAnswer).mockImplementation(async function* () {
189+
const empty: Message[] = [];
190+
yield* empty;
191+
throw new DOMException('Aborted', 'AbortError');
192+
});
193+
194+
renderChat();
195+
await user.type(screen.getByPlaceholderText('Message...'), 'Hello');
196+
await user.keyboard('{Enter}');
197+
198+
await waitFor(() => {
199+
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
200+
expect(screen.getByRole('textbox')).not.toBeDisabled();
201+
});
202+
});
203+
204+
test('partial messages are preserved after stopping', async () => {
205+
const user = userEvent.setup();
206+
207+
vi.mocked(streamAnswer).mockImplementation(async function* (_c, _t, _a, _im, signal) {
208+
yield anAIMessage({ text: 'Partial response' });
209+
await new Promise<void>((r) => signal.addEventListener('abort', () => r()));
210+
});
211+
212+
renderChat();
213+
await user.type(screen.getByPlaceholderText('Message...'), 'Hello');
214+
await user.keyboard('{Enter}');
215+
216+
await screen.findByText('Partial response');
217+
218+
const stopButton = within(document.getElementById('input_form')!).getByRole('button');
219+
await user.click(stopButton);
220+
221+
await waitFor(() => {
222+
expect(screen.getByText('Partial response')).toBeInTheDocument();
223+
});
224+
});
225+
226+
test('input is re-enabled after stopping', async () => {
227+
const user = userEvent.setup();
228+
229+
vi.mocked(streamAnswer).mockImplementation(async function* (_c, _t, _a, _im, signal) {
230+
yield anAIMessage({ text: 'Partial...' });
231+
await new Promise<void>((r) => signal.addEventListener('abort', () => r()));
232+
});
233+
234+
renderChat();
235+
await user.type(screen.getByPlaceholderText('Message...'), 'Hello');
236+
await user.keyboard('{Enter}');
237+
238+
await waitFor(() => expect(screen.getByRole('textbox')).toBeDisabled());
239+
240+
await user.click(within(document.getElementById('input_form')!).getByRole('button'));
241+
242+
await waitFor(() => expect(screen.getByRole('textbox')).not.toBeDisabled());
243+
});
244+
});
245+
160246
describe('when rendered with existing thread messages', () => {
161247
test('displays messages view immediately', async () => {
162248
const existingMessages = [

apps/frontend/src/lib/components/ChatInput.svelte

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,15 @@
77
value: string;
88
isStreaming?: boolean;
99
onSubmit: () => void;
10+
onStop?: () => void;
1011
placeholder?: string;
1112
}
1213
1314
let {
1415
value = $bindable(''),
1516
isStreaming = false,
1617
onSubmit,
18+
onStop,
1719
placeholder = m.chat_input_placeholder()
1820
}: Props = $props();
1921
@@ -52,7 +54,7 @@
5254

5355
<!-- Submit button -->
5456
<div class="flex shrink-0 items-end pb-1">
55-
<SubmitButton {isStreaming} disabled={isStreaming || isEmpty} />
57+
<SubmitButton {isStreaming} disabled={isStreaming || isEmpty} {onStop} />
5658
</div>
5759
</div>
5860
</form>

apps/frontend/src/lib/components/ChatInput.svelte.test.ts

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -58,16 +58,25 @@ describe('ChatInput', () => {
5858
});
5959

6060
describe('when isStreaming is true', () => {
61-
beforeEach(() => {
61+
test('disables the textarea', () => {
6262
renderChatInput({ value: 'Hello', isStreaming: true });
63+
expect(screen.getByRole('textbox')).toBeDisabled();
6364
});
6465

65-
test('disables the textarea', () => {
66-
expect(screen.getByRole('textbox')).toBeDisabled();
66+
test('shows enabled stop button', () => {
67+
renderChatInput({ value: 'Hello', isStreaming: true });
68+
expect(screen.getByRole('button')).not.toBeDisabled();
6769
});
6870

69-
test('disables the submit button', () => {
70-
expect(screen.getByRole('button')).toBeDisabled();
71+
test('calls onStop when stop button is clicked', async () => {
72+
const user = userEvent.setup();
73+
const onStop = vi.fn();
74+
renderChatInput({ value: 'Hello', isStreaming: true, onStop });
75+
76+
const button = screen.getByRole('button');
77+
await user.click(button);
78+
79+
expect(onStop).toHaveBeenCalled();
7180
});
7281
});
7382

Lines changed: 23 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,34 @@
11
<script lang="ts">
2-
import { Spinner } from '$lib/components/ui/spinner';
3-
import { SendHorizontal } from '@lucide/svelte';
2+
import { SendHorizontal, Square } from '@lucide/svelte';
43
import { Button } from '$lib/components/ui/button';
54
65
interface Props {
76
isStreaming: boolean;
87
disabled: boolean;
8+
onStop?: () => void;
99
}
1010
11-
let { isStreaming, disabled }: Props = $props();
11+
let { isStreaming, disabled, onStop }: Props = $props();
1212
</script>
1313

14-
<Button
15-
type="submit"
16-
{disabled}
17-
size="icon-sm"
18-
variant="default"
19-
class="bg-primary-600 hover:bg-primary-700 flex h-8 w-8 shrink-0 items-center justify-center rounded-full p-0 text-white shadow-sm disabled:cursor-not-allowed disabled:bg-gray-300"
20-
>
21-
{#if isStreaming}
22-
<Spinner />
23-
{:else}
14+
{#if isStreaming}
15+
<Button
16+
type="button"
17+
size="icon-sm"
18+
variant="destructive"
19+
class="flex h-8 w-8 shrink-0 items-center justify-center rounded-full p-0 text-white shadow-sm"
20+
onclick={onStop}
21+
>
22+
<Square class="h-4 w-4" />
23+
</Button>
24+
{:else}
25+
<Button
26+
type="submit"
27+
{disabled}
28+
size="icon-sm"
29+
variant="default"
30+
class="bg-primary-600 hover:bg-primary-700 flex h-8 w-8 shrink-0 items-center justify-center rounded-full p-0 text-white shadow-sm disabled:cursor-not-allowed disabled:bg-gray-300"
31+
>
2432
<SendHorizontal />
25-
{/if}
26-
</Button>
33+
</Button>
34+
{/if}

apps/frontend/src/lib/components/SubmitButton.svelte.test.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,10 @@ describe('SubmitButton', () => {
2727
renderComponent({ isStreaming: true, disabled: false });
2828
});
2929

30-
test('shows the spinner instead of send icon', () => {
31-
expect(screen.getByRole('status', { name: 'Loading' })).toBeInTheDocument();
30+
test('shows stop button instead of submit button', () => {
31+
const button = screen.getByRole('button');
32+
expect(button).toBeInTheDocument();
33+
expect(button).toHaveAttribute('type', 'button');
3234
});
3335
});
3436

0 commit comments

Comments
 (0)