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
239 changes: 238 additions & 1 deletion src/components/Chat/Chat.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { Text } from 'ink';
import { prewarmCodeBlocks } from '@/components/CodeBlock';
import { DECISION, MODE, PROMPT, THEME } from '@/constants';
import type { Decision, ToolResult } from '@/types';
import { ollama, time, tools } from '@/utils';
import { agents, memory, ollama, time, tools } from '@/utils';
import { renderWithTheme } from '@/utils/testing';

const mockState = vi.hoisted(() => ({
Expand Down Expand Up @@ -58,6 +58,15 @@ const toolMocks = vi.hoisted(() => ({
executeTool: vi.fn(),
runShell: vi.fn(),
}));
const memoryMocks = vi.hoisted(() => ({
appendMemory: vi.fn(),
getMemoryPathSummary: vi.fn(),
showMemory: vi.fn(),
}));
const agentMocks = vi.hoisted(() => ({
resetSystemMessage: vi.fn(),
withSystemMessage: vi.fn((messages: unknown[]) => messages),
}));
const clearScreen = vi.hoisted(() => vi.fn());

vi.mock('@inkjs/ui', async () => {
Expand Down Expand Up @@ -100,6 +109,15 @@ vi.mock('@/components/CodeBlock', async () => ({

vi.mock('@/utils', async () => ({
...(await vi.importActual('@/utils')),
agents: {
resetSystemMessage: agentMocks.resetSystemMessage,
withSystemMessage: agentMocks.withSystemMessage,
},
memory: {
appendMemory: memoryMocks.appendMemory,
getMemoryPathSummary: memoryMocks.getMemoryPathSummary,
showMemory: memoryMocks.showMemory,
},
ollama: {
streamChat: vi.fn().mockImplementation(function* () {
yield { type: 'content', content: 'Mocked' };
Expand Down Expand Up @@ -237,6 +255,20 @@ function resetChatMocks() {
vi.mocked(ollama.hasUncalledToolIntent).mockReturnValue(false);
vi.mocked(tools.executeTool).mockReset();
vi.mocked(tools.runShell).mockReset();
memoryMocks.appendMemory.mockReset();
memoryMocks.getMemoryPathSummary.mockReset();
memoryMocks.showMemory.mockReset();
agentMocks.resetSystemMessage.mockReset();
agentMocks.withSystemMessage.mockImplementation(
(messages: unknown[]) => messages,
);
memoryMocks.appendMemory.mockReturnValue(
'/home/user/.code-ollama/memories/projects/repo-abc/MEMORY.md',
);
memoryMocks.getMemoryPathSummary.mockReturnValue(
'Project memory: /home/user/.code-ollama/memories/projects/repo-abc/MEMORY.md',
);
memoryMocks.showMemory.mockReturnValue('Loaded memory:\n- Use Vitest.');
vi.mocked(tools.executeToolCall).mockImplementation((toolCall) =>
tools.executeTool(toolCall.function.name, toolCall.function.arguments),
);
Expand Down Expand Up @@ -447,6 +479,211 @@ describe('Chat', () => {
expect(onCommand).toHaveBeenCalledWith('/models');
});

it('shows memory without calling the LLM', async () => {
const chat = (
<Chat
model="gemma4"
onCommand={vi.fn()}
mode={MODE.SAFE}
onModeChange={onModeChange}
sessionId="0"
/>
);
const { lastFrame, rerender } = renderWithTheme(chat);
submitInput('/memory show');
rerender(chat);
await time.tick();

expect(memory.showMemory).toHaveBeenCalled();
expect(ollama.streamChat).not.toHaveBeenCalled();
expect(lastFrame()).toContain('Use Vitest.');
});

it('resolves a unique memory subcommand prefix', async () => {
const chat = (
<Chat
model="gemma4"
onCommand={vi.fn()}
mode={MODE.SAFE}
onModeChange={onModeChange}
sessionId="0"
/>
);
const { lastFrame, rerender } = renderWithTheme(chat);
submitInput('/memory s');
rerender(chat);
await time.tick();

expect(memory.showMemory).toHaveBeenCalled();
expect(lastFrame()).toContain('Use Vitest.');
expect(ollama.streamChat).not.toHaveBeenCalled();
});

it('shows memory paths for /memory path', async () => {
const chat = (
<Chat
model="gemma4"
onCommand={vi.fn()}
mode={MODE.SAFE}
onModeChange={onModeChange}
sessionId="0"
/>
);
const { lastFrame, rerender } = renderWithTheme(chat);
submitInput('/memory path');
rerender(chat);
await time.tick();

expect(memory.getMemoryPathSummary).toHaveBeenCalled();
expect(lastFrame()).toContain('Project memory:');
});

it('resolves a unique memory path prefix', async () => {
const chat = (
<Chat
model="gemma4"
onCommand={vi.fn()}
mode={MODE.SAFE}
onModeChange={onModeChange}
sessionId="0"
/>
);
const { lastFrame, rerender } = renderWithTheme(chat);
submitInput('/memory p');
rerender(chat);
await time.tick();

expect(memory.getMemoryPathSummary).toHaveBeenCalled();
expect(lastFrame()).toContain('Project memory:');
});

it('adds project memory and resets the cached system message', async () => {
const chat = (
<Chat
model="gemma4"
onCommand={vi.fn()}
mode={MODE.SAFE}
onModeChange={onModeChange}
sessionId="0"
/>
);
const { lastFrame, rerender } = renderWithTheme(chat);
submitInput('/memory add Use Vitest.');
rerender(chat);
await time.tick();

expect(memory.appendMemory).toHaveBeenCalledWith('Use Vitest.', {
scope: 'project',
});
expect(agents.resetSystemMessage).toHaveBeenCalled();
expect(lastFrame()).toContain('Memory saved to');
});

it('adds global memory with the --global flag', async () => {
const chat = (
<Chat
model="gemma4"
onCommand={vi.fn()}
mode={MODE.SAFE}
onModeChange={onModeChange}
sessionId="0"
/>
);
const { lastFrame, rerender } = renderWithTheme(chat);
submitInput('/memory add --global Use Vitest globally.');
rerender(chat);
await time.tick();

expect(memory.appendMemory).toHaveBeenCalledWith('Use Vitest globally.', {
scope: 'global',
});
expect(lastFrame()).toContain('Memory saved to');
expect(ollama.streamChat).not.toHaveBeenCalled();
});

it('shows usage for an unknown memory subcommand', async () => {
const chat = (
<Chat
model="gemma4"
onCommand={vi.fn()}
mode={MODE.SAFE}
onModeChange={onModeChange}
sessionId="0"
/>
);
const { lastFrame, rerender } = renderWithTheme(chat);
submitInput('/memory unknown-cmd');
rerender(chat);
await time.tick();

expect(lastFrame()).toContain('Unknown memory command.');
expect(ollama.streamChat).not.toHaveBeenCalled();
});

it('does not resolve an ambiguous memory add prefix', async () => {
const chat = (
<Chat
model="gemma4"
onCommand={vi.fn()}
mode={MODE.SAFE}
onModeChange={onModeChange}
sessionId="0"
/>
);
const { lastFrame, rerender } = renderWithTheme(chat);
submitInput('/memory a');
rerender(chat);
await time.tick();

expect(memory.appendMemory).not.toHaveBeenCalled();
expect(lastFrame()).toContain('Unknown memory command.');
});

it('shows an error message when the memory command throws an Error', async () => {
memoryMocks.appendMemory.mockImplementation(() => {
throw new Error('Disk full');
});
const chat = (
<Chat
model="gemma4"
onCommand={vi.fn()}
mode={MODE.SAFE}
onModeChange={onModeChange}
sessionId="0"
/>
);
const { lastFrame, rerender } = renderWithTheme(chat);
submitInput('/memory add some note');
rerender(chat);
await time.tick();

expect(lastFrame()).toContain('Memory command failed: Disk full');
expect(ollama.streamChat).not.toHaveBeenCalled();
});

it('shows a stringified error when the memory command throws a non-Error', async () => {
memoryMocks.appendMemory.mockImplementation(() => {
// eslint-disable-next-line @typescript-eslint/only-throw-error
throw 'quota exceeded';
});
const chat = (
<Chat
model="gemma4"
onCommand={vi.fn()}
mode={MODE.SAFE}
onModeChange={onModeChange}
sessionId="0"
/>
);
const { lastFrame, rerender } = renderWithTheme(chat);
submitInput('/memory add some note');
rerender(chat);
await time.tick();

expect(lastFrame()).toContain('Memory command failed: quota exceeded');
expect(ollama.streamChat).not.toHaveBeenCalled();
});

it('runs a shell command locally without calling the LLM', async () => {
toolMocks.runShell.mockResolvedValue({ content: 'file1.txt\nfile2.txt' });
const onCommand = vi.fn();
Expand Down
71 changes: 70 additions & 1 deletion src/components/Chat/Chat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,10 @@ import { PlanReview } from '@/components/PlanReview';
import { ToolApproval } from '@/components/ToolApproval';
import { DECISION, MODE, ROLE, THEME, UI } from '@/constants';
import type { Decision, Mode, ThemeDefinition } from '@/types';
import { ollama, tools } from '@/utils';
import { agents, memory, ollama, tools } from '@/utils';

import { ChatInput, type SubmittedInput } from './ChatInput';
import { MEMORY_COMMANDS } from './CommandMenu';
import { ChatActionType, InterruptReason } from './constants';
import { useCompact, useRunTurn } from './hooks';
import { chatReducer, createInitialChatState } from './reducer';
Expand All @@ -33,6 +34,48 @@ interface Props {
theme?: ThemeDefinition;
}

function getMemoryCommandResult(command: string): string {
const normalizedCommand = command.trim().toLowerCase();
const matchingSubmitCommands = MEMORY_COMMANDS.filter(
({ value }) =>
value.shouldSubmit &&
value.text.toLowerCase().startsWith(normalizedCommand),
);
const resolvedCommand =
matchingSubmitCommands.length === 1
? matchingSubmitCommands[0].value.text
: command;
const [, subcommand = 'show', ...args] = resolvedCommand.split(/\s+/);

switch (subcommand) {
case 'show':
return memory.showMemory();

case 'path':
return memory.getMemoryPathSummary();

case 'add': {
const isGlobal = args[0] === '--global';
const text = (isGlobal ? args.slice(1) : args).join(' ').trim();
const path = memory.appendMemory(text, {
scope: isGlobal ? 'global' : 'project',
});
agents.resetSystemMessage();
return `Memory saved to ${path}`;
}

default:
return [
'Unknown memory command.',
'Usage:',
'/memory show',
'/memory path',
'/memory add <text>',
'/memory add --global <text>',
].join('\n');
}
}

export function Chat({
initialMessages,
model,
Expand Down Expand Up @@ -250,6 +293,32 @@ export function Chat({
return;
}

if (
(userContent === '/memory' || userContent.startsWith('/memory ')) &&
!images?.length
) {
try {
dispatch({
type: ChatActionType.AppendMessage,
message: {
role: ROLE.SYSTEM,
content: getMemoryCommandResult(userContent),
},
});
} catch (error) {
dispatch({
type: ChatActionType.AppendMessage,
message: {
role: ROLE.SYSTEM,
content: `Memory command failed: ${
error instanceof Error ? error.message : String(error)
}`,
},
});
}
return;
}

if (userContent.startsWith('/')) {
onCommand(userContent);
return;
Expand Down
Loading