Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 94 additions & 2 deletions src/components/Chat/Chat.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -78,15 +78,27 @@ vi.mock('../../utils', async () => ({
},
}));

const interruptState = vi.hoisted(() => ({
handler: undefined as (() => void) | undefined,
clear() {
this.handler = undefined;
},
}));

vi.mock('./Input', () => ({
Input: (props: {
onSubmit?: (value: string) => void;
onInterrupt?: () => void;
isDisabled?: boolean;
}) => {
if (props.onSubmit) {
mockState.handler = props.onSubmit;
}

if (props.onInterrupt) {
interruptState.handler = props.onInterrupt;
}

if (props.isDisabled) {
return null;
}
Expand Down Expand Up @@ -134,12 +146,17 @@ async function waitForStream() {
await time.tick(10);
}

function fireInterrupt() {
interruptState.handler?.();
}

function resetChatMocks() {
vi.restoreAllMocks();
vi.clearAllMocks();
mockState.clear();
planApprovalState.clear();
toolApprovalState.clear();
interruptState.clear();
tools.TOOLS.splice(0, tools.TOOLS.length);
vi.mocked(ollama.streamChat).mockImplementation(async function* () {
await Promise.resolve();
Expand Down Expand Up @@ -312,6 +329,7 @@ describe('Chat', () => {
expect.any(Array),
'llama3',
expect.any(Array),
expect.any(AbortSignal),
);
});

Expand Down Expand Up @@ -1021,8 +1039,10 @@ describe('Chat with tool calls', () => {
await waitForStream();
rerender(chat);

// Should show rejection message
expect(lastFrame()).toContain('declined');
expect(lastFrame()).not.toContain('Tool requires approval');
expect(lastFrame()).toContain('❗ Tool call rejected.');
expect(lastFrame()).toContain('>');
expect(vi.mocked(ollama.streamChat)).toHaveBeenCalledOnce();
});

it('handles tool approval acceptance', async () => {
Expand Down Expand Up @@ -1235,3 +1255,75 @@ describe('Chat with error', () => {
expect(lastFrame()).toContain('Error: Plan generation crashed');
});
});

describe('Chat interrupt', () => {
beforeEach(() => {
resetChatMocks();
});

it('shows interrupt notice and turn_aborted message when interrupted during streaming', async () => {
vi.mocked(ollama.streamChat).mockImplementation(async function* () {
yield { type: 'content', content: 'Partial' };
await new Promise<never>(() => undefined);
});

const chat = (
<Chat
model="gemma4"
onCommand={vi.fn()}
mode={MODE.NAME.SAFE}
onModeChange={vi.fn()}
sessionId={0}
/>
);
const { lastFrame, rerender } = render(chat);
submitInput('hello');
rerender(chat);
await time.tick();

fireInterrupt();
rerender(chat);
await time.tick();

const frame = lastFrame() ?? '';
expect(frame).toContain('❗ Execution interrupted');
expect(frame).not.toContain('turn_aborted');
expect(frame).toContain('>');
});

it('clears interrupt notice on next submit', async () => {
vi.mocked(ollama.streamChat).mockImplementation(async function* () {
yield { type: 'content', content: 'Partial' };
await new Promise<never>(() => undefined);
});

const chat = (
<Chat
model="gemma4"
onCommand={vi.fn()}
mode={MODE.NAME.SAFE}
onModeChange={vi.fn()}
sessionId={0}
/>
);
const { lastFrame, rerender } = render(chat);
submitInput('hello');
rerender(chat);
await time.tick();

fireInterrupt();
rerender(chat);
await time.tick();
expect(lastFrame()).toContain('❗ Execution interrupted');

vi.mocked(ollama.streamChat).mockImplementation(async function* () {
await Promise.resolve();
yield { type: 'content', content: 'New response' };
});
submitInput('continue');
rerender(chat);
await waitForStream();

expect(lastFrame()).not.toContain('❗ Execution interrupted');
});
});
96 changes: 79 additions & 17 deletions src/components/Chat/Chat.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
import { Box } from 'ink';
import { useCallback, useEffect, useState } from 'react';
import { Box, Text } from 'ink';
import { useCallback, useEffect, useRef, useState } from 'react';

import { DECISION, MODE, PROMPT, ROLE } from '../../constants';
import { agents, ollama, tools } from '../../utils';
import { Messages } from '../Messages';
import { TURN_ABORTED_MESSAGE } from '../Messages/constants';
import { PlanApproval } from '../PlanApproval';
import { ToolApproval } from '../ToolApproval';
import {
ACTION_NOT_PERFORMED,
INTERRUPT_REASON,
PLAN_CHECKLIST_REMINDER,
PLAN_EXECUTION_REMINDER,
} from './constants';
Expand All @@ -32,20 +34,27 @@ export function Chat({
const [messages, setMessages] = useState<ollama.Message[]>([]);
const [streamingMessage, setStreamingMessage] =
useState<ollama.Message | null>(null);

const [isLoading, setIsLoading] = useState(false);

const [pendingToolCall, setPendingToolCall] =
useState<ollama.ToolCall | null>(null);
const [pendingPlan, setPendingPlan] = useState<{
planContent: string;
messages: ollama.Message[];
} | null>(null);

const [interruptReason, setInterruptReason] =
useState<INTERRUPT_REASON | null>(null);
const abortControllerRef = useRef<AbortController | null>(null);

useEffect(() => {
setMessages([]);
setStreamingMessage(null);
setIsLoading(false);
setPendingToolCall(null);
setPendingPlan(null);
setInterruptReason(null);
}, [sessionId]);

const buildToolResultMessage = useCallback(
Expand Down Expand Up @@ -84,11 +93,25 @@ export function Chat({
[],
);

const handleInterrupt = useCallback(() => {
abortControllerRef.current?.abort();
abortControllerRef.current = null;
setIsLoading(false);
setStreamingMessage(null);
setInterruptReason(INTERRUPT_REASON.INTERRUPTED);
setMessages((prev) => [
...prev,
{ role: ROLE.USER, content: TURN_ABORTED_MESSAGE },
]);
}, []);

const processStream = useCallback(
async (
currentMessages: ollama.Message[],
executionMode: MODE.Name = mode,
) => {
const controller = new AbortController();
abortControllerRef.current = controller;
const assistantMessage: ollama.Message = {
role: ROLE.ASSISTANT,
content: '',
Expand Down Expand Up @@ -129,7 +152,12 @@ export function Chat({
agents.withSystemMessage(currentMessages),
model,
tools.TOOLS,
controller.signal,
)) {
// v8 ignore next 3
if (controller.signal.aborted) {
return;
}
if (chunk.type === 'content') {
assistantMessage.content += chunk.content;
setStreamingMessage({ ...assistantMessage });
Expand Down Expand Up @@ -182,9 +210,14 @@ export function Chat({
commitAssistantMessage();
} catch (error) {
// v8 ignore next
assistantMessage.content = `Error: ${error instanceof Error ? error.message : String(error)}`;
commitAssistantMessage();
if (!controller.signal.aborted) {
assistantMessage.content = `Error: ${error instanceof Error ? error.message : String(error)}`;
commitAssistantMessage();
}
} finally {
if (abortControllerRef.current === controller) {
abortControllerRef.current = null;
}
setIsLoading(false);
}
},
Expand All @@ -194,10 +227,14 @@ export function Chat({
// Process stream with only read-only tools (for plan mode research phase)
const processStreamReadOnly = useCallback(
async (currentMessages: ollama.Message[]) => {
const controller = new AbortController();
abortControllerRef.current = controller;

const assistantMessage: ollama.Message = {
role: ROLE.ASSISTANT,
content: '',
};

let committedMessages = currentMessages;
let assistantCommitted = false;

Expand Down Expand Up @@ -239,7 +276,12 @@ export function Chat({
agents.withSystemMessage(currentMessages),
model,
readOnlyTools,
controller.signal,
)) {
// v8 ignore next 3
if (controller.signal.aborted) {
return;
}
if (chunk.type === 'content') {
assistantMessage.content += chunk.content;
setStreamingMessage({ ...assistantMessage });
Expand Down Expand Up @@ -310,7 +352,12 @@ export function Chat({
agents.withSystemMessage(planMessages),
model,
[], // No tools during plan generation output
controller.signal,
)) {
// v8 ignore next 3
if (controller.signal.aborted) {
return;
}
if (chunk.type === 'content') {
planAssistantMessage.content += chunk.content;
setStreamingMessage({ ...planAssistantMessage });
Expand Down Expand Up @@ -346,9 +393,14 @@ export function Chat({
setIsLoading(false);
} catch (error) {
// v8 ignore next
assistantMessage.content = `Error: ${error instanceof Error ? error.message : String(error)}`;
commitAssistantMessage();
if (!controller.signal.aborted) {
assistantMessage.content = `Error: ${error instanceof Error ? error.message : String(error)}`;
commitAssistantMessage();
}
} finally {
if (abortControllerRef.current === controller) {
abortControllerRef.current = null;
}
setIsLoading(false);
}
},
Expand Down Expand Up @@ -430,18 +482,12 @@ export function Chat({
}

case DECISION.REJECT: {
const rejectionMessage: ollama.Message = {
role: ROLE.SYSTEM,
content: `User declined to execute tool ${toolCall.function.name}`,
};

const newMessages = [...messages, rejectionMessage];
setMessages((previousMessages) => [
...previousMessages,
rejectionMessage,
{ role: ROLE.USER, content: TURN_ABORTED_MESSAGE },
]);

await processStream(newMessages);
setIsLoading(false);
setInterruptReason(INTERRUPT_REASON.REJECTED);
break;
}
}
Expand All @@ -451,7 +497,9 @@ export function Chat({

const handleSubmit = useCallback(
async (value: string) => {
setInterruptReason(null);
const userContent = value.trim();

if (!userContent) {
return;
}
Expand Down Expand Up @@ -504,9 +552,23 @@ export function Chat({
/>
)}

{interruptReason && !isLoading && (
<Box marginBottom={1}>
<Text color="red">
{interruptReason === INTERRUPT_REASON.REJECTED
? '❗ Tool call rejected.'
: '❗ Execution interrupted.'}
</Text>
</Box>
)}

{!pendingPlan && !pendingToolCall && (
// eslint-disable-next-line @typescript-eslint/no-misused-promises
<Input isDisabled={isLoading} onSubmit={handleSubmit} />
<Input
isDisabled={isLoading}
onInterrupt={handleInterrupt}
// eslint-disable-next-line @typescript-eslint/no-misused-promises
onSubmit={handleSubmit}
/>
)}
</Box>
);
Expand Down
30 changes: 30 additions & 0 deletions src/components/Chat/Input.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -480,6 +480,36 @@ describe('Input', () => {
expect(onSubmit).not.toHaveBeenCalled();
});

it('calls onInterrupt on Ctrl+C when disabled', async () => {
const onInterrupt = vi.fn();
const { stdin } = render(
<Input isDisabled onInterrupt={onInterrupt} onSubmit={vi.fn()} />,
);
stdin.write(KEY.CTRL_C);
await time.tick();
expect(onInterrupt).toHaveBeenCalledOnce();
});

it('calls onInterrupt on Esc when disabled', async () => {
const onInterrupt = vi.fn();
const { stdin } = render(
<Input isDisabled onInterrupt={onInterrupt} onSubmit={vi.fn()} />,
);
stdin.write(KEY.ESCAPE);
await time.tick(20);
expect(onInterrupt).toHaveBeenCalledOnce();
});

it('does not call onInterrupt when not disabled', async () => {
const onInterrupt = vi.fn();
const { stdin } = render(
<Input onInterrupt={onInterrupt} onSubmit={vi.fn()} />,
);
stdin.write(KEY.ESCAPE);
await time.tick();
expect(onInterrupt).not.toHaveBeenCalled();
});

it('ignores file suggestion interactions when disabled', async () => {
const onSubmit = vi.fn();
const { lastFrame, stdin } = render(
Expand Down
Loading