Skip to content

Commit 6d14a32

Browse files
feat(utils): implement static and dynamic system prompt
| File | Description | | --- | --- | | src/constants/prompt.ts | Static base system prompt with coding assistant persona and tool instructions | | src/utils/agents.ts | Dynamic loader that combines base prompt with AGENTS.md content (if present) | | src/components/Chat.tsx | Initializes messages state with system message | | src/utils/agents.test.ts | Tests for system prompt building | Features: - Base prompt uses TUI-style imperative instructions ("You are a coding assistant... Always use tools...") - Dynamically loads `AGENTS.md` from current working directory at startup - Combines both into a single system message as the first message in every conversation - Gracefully handles missing `AGENTS.md` or file read errors
1 parent da86cb6 commit 6d14a32

7 files changed

Lines changed: 171 additions & 46 deletions

File tree

src/components/Chat.test.tsx

Lines changed: 44 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -14,24 +14,23 @@ const mockState = vi.hoisted(() => ({
1414
},
1515
}));
1616

17-
vi.mock('./Messages', () => ({
18-
Messages: ({
19-
messages,
20-
isLoading,
21-
}: {
22-
messages: { role: string; content: string }[];
23-
isLoading: boolean;
24-
}) => (
25-
<>
26-
{messages.map((m, i) => (
27-
<Text key={i}>{m.content}</Text>
28-
))}
29-
{isLoading && messages[messages.length - 1]?.content === '' && (
30-
<Text>⏳Thinking...</Text>
31-
)}
32-
</>
33-
),
34-
}));
17+
vi.mock('../utils', async () => {
18+
const actual = await vi.importActual<typeof import('../utils')>('../utils');
19+
return {
20+
...actual,
21+
ollama: {
22+
streamChat: vi.fn().mockImplementation(function* () {
23+
yield { type: 'content', content: 'Mocked' };
24+
yield { type: 'content', content: ' response' };
25+
}),
26+
},
27+
tools: {
28+
TOOLS: [],
29+
TOOLS_REQUIRING_APPROVAL: new Set(),
30+
executeTool: vi.fn(),
31+
},
32+
};
33+
});
3534

3635
vi.mock('./Autocomplete', () => ({
3736
Autocomplete: (props: {
@@ -61,20 +60,6 @@ vi.mock('./Autocomplete', () => ({
6160

6261
import { Chat } from './Chat';
6362

64-
vi.mock('../utils', () => ({
65-
ollama: {
66-
streamChat: vi.fn().mockImplementation(function* () {
67-
yield { type: 'content', content: 'Mocked' };
68-
yield { type: 'content', content: ' response' };
69-
}),
70-
},
71-
tools: {
72-
TOOLS: [],
73-
TOOLS_REQUIRING_APPROVAL: new Set(),
74-
executeTool: vi.fn(),
75-
},
76-
}));
77-
7863
async function typeText(
7964
rerender: (tree: React.ReactElement) => void,
8065
text: string,
@@ -102,61 +87,74 @@ describe('Chat', () => {
10287
mockState.clear();
10388
});
10489

105-
it('renders input prompt', () => {
90+
it('renders input prompt with system message', async () => {
10691
const { lastFrame } = render(
10792
<Chat model="gemma4" onCommand={vi.fn()} autoExecute={false} />,
10893
);
109-
expect(lastFrame()).toContain('>');
94+
await tick();
95+
const frame = lastFrame() ?? '';
96+
// System message should be present (dimmed, but visible)
97+
expect(frame).toContain('coding assistant');
98+
expect(frame).toContain('>');
11099
});
111100

112101
it('shows message after submit', async () => {
113102
const chat = (
114103
<Chat model="gemma4" onCommand={vi.fn()} autoExecute={false} />
115104
);
116105
const { lastFrame, rerender } = render(chat);
106+
await tick();
117107
await typeText(rerender, 'hello', chat);
118108
submitInput('hello');
119109
rerender(chat);
120110
await waitForStream();
121-
expect(lastFrame()).toContain('hello');
111+
const frame = lastFrame() ?? '';
112+
expect(frame).toContain('coding assistant');
113+
expect(frame).toContain('hello');
122114
});
123115

124116
it('clears input after submit', async () => {
125117
const chat = (
126118
<Chat model="gemma4" onCommand={vi.fn()} autoExecute={false} />
127119
);
128120
const { lastFrame, rerender } = render(chat);
121+
await tick();
129122
await typeText(rerender, 'hello', chat);
130123
submitInput('hello');
131124
rerender(chat);
132125
await waitForStream();
133126
// Verify the user message appears in the chat
134-
expect(lastFrame()).toContain('hello');
127+
const frame = lastFrame() ?? '';
128+
expect(frame).toContain('coding assistant');
129+
expect(frame).toContain('hello');
135130
});
136131

137132
it('does not add blank messages', async () => {
138133
const chat = (
139134
<Chat model="gemma4" onCommand={vi.fn()} autoExecute={false} />
140135
);
141136
const { lastFrame, rerender } = render(chat);
137+
await tick();
138+
const beforeFrame = lastFrame() ?? '';
139+
const systemLineCount = beforeFrame.split('\n').length;
142140
await typeText(rerender, ' ', chat);
143141
submitInput(' ');
144142
rerender(chat);
145143
await tick();
146-
const frame = lastFrame() ?? '';
147-
const lines = frame
148-
.split('\n')
149-
.filter(
150-
(line) => line.trim() && !line.includes('>') && !line.includes('Mode:'),
151-
);
152-
expect(lines).toHaveLength(0);
144+
const afterFrame = lastFrame() ?? '';
145+
const afterLineCount = afterFrame.split('\n').length;
146+
// After submitting blank input, line count should not increase
147+
// (no new user message added)
148+
expect(afterLineCount).toBe(systemLineCount);
149+
expect(afterFrame).toContain('coding assistant');
153150
});
154151

155152
it('shows multiple messages in order', async () => {
156153
const chat = (
157154
<Chat model="gemma4" onCommand={vi.fn()} autoExecute={false} />
158155
);
159156
const { lastFrame, rerender } = render(chat);
157+
await tick();
160158
await typeText(rerender, 'first', chat);
161159
submitInput('first');
162160
rerender(chat);
@@ -166,9 +164,11 @@ describe('Chat', () => {
166164
rerender(chat);
167165
await waitForStream();
168166
const frame = lastFrame() ?? '';
167+
const systemIdx = frame.indexOf('coding assistant');
169168
const firstIdx = frame.indexOf('first');
170169
const secondIdx = frame.indexOf('second');
171-
expect(firstIdx).toBeGreaterThanOrEqual(0);
170+
expect(systemIdx).toBeGreaterThanOrEqual(0);
171+
expect(firstIdx).toBeGreaterThan(systemIdx);
172172
expect(secondIdx).toBeGreaterThan(firstIdx);
173173
});
174174

src/components/Chat.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { Box } from 'ink';
22
import { useCallback, useState } from 'react';
33

44
import { ROLE, TOOL } from '../constants';
5-
import { ollama, tools } from '../utils';
5+
import { agents, ollama, tools } from '../utils';
66
import { Autocomplete } from './Autocomplete';
77
import { Messages } from './Messages';
88
import { ToolApproval } from './ToolApproval';
@@ -14,7 +14,9 @@ interface Props {
1414
}
1515

1616
export function Chat({ model, onCommand, autoExecute }: Props) {
17-
const [messages, setMessages] = useState<ollama.Message[]>([]);
17+
const [messages, setMessages] = useState<ollama.Message[]>([
18+
agents.createSystemMessage(),
19+
]);
1820
const [submitKey, setSubmitKey] = useState(0);
1921
const [isLoading, setIsLoading] = useState(false);
2022
const [pendingToolCall, setPendingToolCall] =

src/constants/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
export * from './commands';
22
export * as KEY from './key';
33
export * as PACKAGE from './package';
4+
export * as PROMPT from './prompt';
45
export * from './role';
56
export * as TOOL from './tool';
67
export * as UI from './ui';

src/constants/prompt.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
export const BASE_SYSTEM_PROMPT = `You are a coding assistant that helps users write, edit, and understand code. You have access to tools for reading files, writing files, running shell commands, and searching code
2+
3+
Follow these rules:
4+
1. Always use available tools rather than guessing file contents or code behavior
5+
2. Read files before editing them to understand context
6+
3. When writing files, provide complete, working code
7+
4. Explain your reasoning when making non-trivial changes
8+
5. Prefer minimal changes that achieve the goal
9+
6. Confirm with the user before destructive operations
10+
11+
When tools return results, incorporate them into your response naturally`;
12+
13+
export const TOOL_INSTRUCTIONS = `Available tools:
14+
- read_file: Read file contents at a path
15+
- write_file: Write content to a file (requires approval)
16+
- edit_file: Make precise edits to a file
17+
- list_dir: List files in a directory
18+
- grep_search: Search code with regex
19+
- run_shell: Execute shell commands (requires approval)
20+
21+
Always use tools when you need to:
22+
- Check file contents before referencing them
23+
- Make file changes
24+
- Explore project structure
25+
- Search the codebase`;

src/utils/agents.test.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import { existsSync, readFileSync } from 'node:fs';
2+
3+
import { ROLE } from '../constants';
4+
import { BASE_SYSTEM_PROMPT, TOOL_INSTRUCTIONS } from '../constants/prompt';
5+
6+
vi.mock('node:fs');
7+
8+
describe('agents', () => {
9+
it('creates system message with base prompt', async () => {
10+
const { createSystemMessage } = await import('./agents');
11+
const message = createSystemMessage();
12+
13+
expect(message.role).toBe(ROLE.SYSTEM);
14+
expect(message.content).toContain(BASE_SYSTEM_PROMPT);
15+
expect(message.content).toContain(TOOL_INSTRUCTIONS);
16+
});
17+
18+
it('includes AGENTS.md content when available', async () => {
19+
vi.mocked(existsSync).mockReturnValue(true);
20+
vi.mocked(readFileSync).mockReturnValue('## Test Project\nTest context');
21+
22+
const { buildSystemPrompt } = await import('./agents');
23+
const prompt = buildSystemPrompt();
24+
25+
expect(prompt).toContain('Project context from AGENTS.md:');
26+
expect(prompt).toContain('## Test Project');
27+
expect(prompt).toContain('Test context');
28+
});
29+
30+
it('works without AGENTS.md', async () => {
31+
vi.mocked(existsSync).mockReturnValue(false);
32+
33+
const { buildSystemPrompt } = await import('./agents');
34+
const prompt = buildSystemPrompt();
35+
36+
expect(prompt).not.toContain('Project context from AGENTS.md:');
37+
expect(prompt).toContain(BASE_SYSTEM_PROMPT);
38+
});
39+
40+
it('handles read file error gracefully', async () => {
41+
vi.mocked(existsSync).mockReturnValue(true);
42+
vi.mocked(readFileSync).mockImplementation(() => {
43+
throw new Error('Permission denied');
44+
});
45+
46+
const { buildSystemPrompt } = await import('./agents');
47+
const prompt = buildSystemPrompt();
48+
49+
// Should not include AGENTS.md content when read fails
50+
expect(prompt).not.toContain('Project context from AGENTS.md:');
51+
expect(prompt).toContain(BASE_SYSTEM_PROMPT);
52+
});
53+
});

src/utils/agents.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { existsSync, readFileSync } from 'node:fs';
2+
import { join } from 'node:path';
3+
4+
import { ROLE } from '../constants';
5+
import { BASE_SYSTEM_PROMPT, TOOL_INSTRUCTIONS } from '../constants/prompt';
6+
import type * as ollama from './ollama';
7+
8+
const AGENTS_FILE = 'AGENTS.md';
9+
10+
function loadAgentsContent(): string | null {
11+
const cwd = process.cwd();
12+
const agentsPath = join(cwd, AGENTS_FILE);
13+
14+
if (!existsSync(agentsPath)) {
15+
return null;
16+
}
17+
18+
try {
19+
return readFileSync(agentsPath, 'utf8');
20+
} catch {
21+
return null;
22+
}
23+
}
24+
25+
export function buildSystemPrompt(): string {
26+
const parts: string[] = [BASE_SYSTEM_PROMPT];
27+
28+
const agentsContent = loadAgentsContent();
29+
if (agentsContent) {
30+
parts.push('\n\nProject context from AGENTS.md:\n', agentsContent);
31+
}
32+
33+
parts.push('\n\n', TOOL_INSTRUCTIONS);
34+
35+
return parts.join('');
36+
}
37+
38+
export function createSystemMessage(): ollama.Message {
39+
return {
40+
role: ROLE.SYSTEM,
41+
content: buildSystemPrompt(),
42+
};
43+
}

src/utils/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
export * as agents from './agents';
12
export * as config from './config';
23
export * as ollama from './ollama';
34
export * as screen from './screen';

0 commit comments

Comments
 (0)