Skip to content
Open
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
1 change: 1 addition & 0 deletions src/agent/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -290,6 +290,7 @@ export class Agent {
return buildHistoryContext({
entries: recentTurns,
currentMessage: query,
historyLimit: 0,
});
}
}
113 changes: 113 additions & 0 deletions src/utils/history-context.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import { describe, test, expect } from 'bun:test';
import {
HISTORY_CONTEXT_MARKER,
CURRENT_MESSAGE_MARKER,
DEFAULT_HISTORY_LIMIT,
type HistoryEntry,
buildHistoryContext,
} from './history-context.js';

describe('buildHistoryContext', () => {
test('returns current message when there are no history entries', () => {
const context = buildHistoryContext({
entries: [],
currentMessage: 'What is free cash flow?',
});

expect(context).toBe('What is free cash flow?');
});

test('formats history entries with role labels and markers', () => {
const entries: HistoryEntry[] = [
{ role: 'user', content: 'Analyze AAPL earnings.' },
{ role: 'assistant', content: 'Sure, what horizon?' },
{ role: 'user', content: '5 years.' },
];

const context = buildHistoryContext({
entries,
currentMessage: 'Also include NVDA and MSFT.',
});

expect(context).toContain(HISTORY_CONTEXT_MARKER);
expect(context).toContain(CURRENT_MESSAGE_MARKER);
expect(context).toContain('User: Analyze AAPL earnings.');
expect(context).toContain('Assistant: Sure, what horizon?');
expect(context).toContain('User: 5 years.');
expect(context).toContain('Also include NVDA and MSFT.');
});

test('supports custom line breaks', () => {
const entries: HistoryEntry[] = [{ role: 'user', content: 'Hello' }];
const context = buildHistoryContext({
entries,
currentMessage: 'World',
lineBreak: '\r\n',
});

expect(context.includes('\r\n')).toBe(true);
});

test('applies DEFAULT_HISTORY_LIMIT when historyLimit is omitted', () => {
const entries: HistoryEntry[] = [];
const total = DEFAULT_HISTORY_LIMIT + 5;

for (let i = 0; i < total; i++) {
entries.push({ role: 'user', content: `Msg-${String(i).padStart(2, '0')}` });
}

const context = buildHistoryContext({
entries,
currentMessage: 'Current message',
});

// Oldest messages beyond the default limit should not appear (zero-pad to avoid substring collisions)
expect(context).not.toContain('Msg-00');
expect(context).not.toContain('Msg-01');

// Most recent messages should still be present
expect(context).toContain(`Msg-${String(total - 1).padStart(2, '0')}`);
expect(context).toContain(`Msg-${String(total - 2).padStart(2, '0')}`);
});

test('respects an explicit historyLimit override', () => {
const entries: HistoryEntry[] = [];

for (let i = 0; i < 6; i++) {
entries.push({ role: 'user', content: `Turn ${i}` });
}

const context = buildHistoryContext({
entries,
currentMessage: 'Now',
historyLimit: 2,
});

// Only the last two turns should be included
expect(context).toContain('Turn 4');
expect(context).toContain('Turn 5');
expect(context).not.toContain('Turn 0');
expect(context).not.toContain('Turn 1');
expect(context).not.toContain('Turn 2');
expect(context).not.toContain('Turn 3');
});

test('disables trimming when historyLimit is <= 0', () => {
const entries: HistoryEntry[] = [];

for (let i = 0; i < 5; i++) {
entries.push({ role: 'user', content: `Msg ${i}` });
}

const context = buildHistoryContext({
entries,
currentMessage: 'Now',
historyLimit: 0,
});

// All messages should appear when limit is non-positive
for (let i = 0; i < 5; i++) {
expect(context).toContain(`Msg ${i}`);
}
});
});
28 changes: 24 additions & 4 deletions src/utils/history-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,17 +8,37 @@ export interface HistoryEntry {
content: string;
}

export function buildHistoryContext(params: {
export interface BuildHistoryContextParams {
entries: HistoryEntry[];
currentMessage: string;
/**
* Line break separator to use between lines.
* Defaults to '\n'.
*/
lineBreak?: string;
}): string {
/**
* Maximum number of history entries to include.
* If omitted, DEFAULT_HISTORY_LIMIT is used.
* If <= 0, all entries are included.
*/
historyLimit?: number;
}

export function buildHistoryContext(params: BuildHistoryContextParams): string {
const lineBreak = params.lineBreak ?? '\n';
if (params.entries.length === 0) {
const effectiveLimit =
typeof params.historyLimit === 'number' ? params.historyLimit : DEFAULT_HISTORY_LIMIT;

const entriesToUse =
effectiveLimit > 0 && params.entries.length > effectiveLimit
? params.entries.slice(-effectiveLimit)
: params.entries;

if (entriesToUse.length === 0) {
return params.currentMessage;
}

const historyText = params.entries
const historyText = entriesToUse
.map(entry => `${entry.role === 'user' ? 'User' : 'Assistant'}: ${entry.content}`)
.join(`${lineBreak}${lineBreak}`);

Expand Down
126 changes: 126 additions & 0 deletions src/utils/long-term-chat-history.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
import { existsSync, readFileSync, rmSync } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
import { LongTermChatHistory } from './long-term-chat-history.js';

const TEST_BASE_DIR = join(tmpdir(), 'dexter-long-term-history-tests');

function getMessagesFilePath(): string {
return join(TEST_BASE_DIR, '.dexter', 'messages', 'chat_history.json');
}

beforeEach(() => {
if (existsSync(TEST_BASE_DIR)) {
rmSync(TEST_BASE_DIR, { recursive: true });
}
});

afterEach(() => {
if (existsSync(TEST_BASE_DIR)) {
rmSync(TEST_BASE_DIR, { recursive: true });
}
});

describe('LongTermChatHistory', () => {
test('load creates an empty history file when none exists', async () => {
const history = new LongTermChatHistory(TEST_BASE_DIR);
await history.load();

const filePath = getMessagesFilePath();
expect(existsSync(filePath)).toBe(true);

const parsed = JSON.parse(readFileSync(filePath, 'utf-8'));
expect(Array.isArray(parsed.messages)).toBe(true);
expect(parsed.messages.length).toBe(0);
});

test('addUserMessage prepends messages in stack order (newest first)', async () => {
const history = new LongTermChatHistory(TEST_BASE_DIR);

await history.addUserMessage('First');
await history.addUserMessage('Second');
await history.addUserMessage('Third');

const messages = history.getMessages();
expect(messages.map((m) => m.userMessage)).toEqual(['Third', 'Second', 'First']);
expect(messages[0].agentResponse).toBeNull();
});

test('updateAgentResponse updates the most recent entry', async () => {
const history = new LongTermChatHistory(TEST_BASE_DIR);

await history.addUserMessage('Question');
await history.updateAgentResponse('Answer');

const messages = history.getMessages();
expect(messages.length).toBe(1);
expect(messages[0].userMessage).toBe('Question');
expect(messages[0].agentResponse).toBe('Answer');
});

test('getMessageStrings deduplicates consecutive duplicate user messages only', async () => {
const history = new LongTermChatHistory(TEST_BASE_DIR);

await history.addUserMessage('AAPL');
await history.addUserMessage('AAPL');
await history.addUserMessage('NVDA');
await history.addUserMessage('AAPL');

const strings = history.getMessageStrings();
expect(strings).toEqual(['AAPL', 'NVDA', 'AAPL']);
});

test('enforces maxEntries when adding messages', async () => {
const history = new LongTermChatHistory(TEST_BASE_DIR, { maxEntries: 3 });

for (let i = 0; i < 5; i++) {
await history.addUserMessage(`Msg ${i}`);
}

const messages = history.getMessages();
expect(messages.length).toBe(3);
// Newest first
expect(messages[0].userMessage).toBe('Msg 4');
expect(messages[1].userMessage).toBe('Msg 3');
expect(messages[2].userMessage).toBe('Msg 2');
});

test('load trims existing on-disk history to maxEntries', async () => {
// First, create a history with more entries using a high maxEntries
const history = new LongTermChatHistory(TEST_BASE_DIR, { maxEntries: 50 });

for (let i = 0; i < 10; i++) {
await history.addUserMessage(`Seed ${i}`);
}

// Sanity check
expect(history.getMessages().length).toBe(10);

// Now load with a tighter maxEntries
const trimmedHistory = new LongTermChatHistory(TEST_BASE_DIR, { maxEntries: 3 });
await trimmedHistory.load();

const messages = trimmedHistory.getMessages();
expect(messages.length).toBe(3);
expect(messages[0].userMessage).toBe('Seed 9');
expect(messages[2].userMessage).toBe('Seed 7');

// On-disk file should also be trimmed
const filePath = getMessagesFilePath();
const parsed = JSON.parse(readFileSync(filePath, 'utf-8'));
expect(Array.isArray(parsed.messages)).toBe(true);
expect(parsed.messages.length).toBe(3);
});

test('does not trim when maxEntries is <= 0', async () => {
const history = new LongTermChatHistory(TEST_BASE_DIR, { maxEntries: 0 });

for (let i = 0; i < 10; i++) {
await history.addUserMessage(`Msg ${i}`);
}

const messages = history.getMessages();
expect(messages.length).toBe(10);
});
});
45 changes: 42 additions & 3 deletions src/utils/long-term-chat-history.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,12 @@ interface MessagesFile {
const MESSAGES_DIR = 'messages';
const MESSAGES_FILE = 'chat_history.json';

/**
* Default maximum number of conversation entries to keep.
* Older entries beyond this limit are dropped from the front of the stack.
*/
const DEFAULT_MAX_ENTRIES = 200;

/**
* Manages persistent storage of conversation history for input history navigation.
* Uses stack ordering (most recent first) for O(1) access to latest entries.
Expand All @@ -30,9 +36,29 @@ export class LongTermChatHistory {
private filePath: string;
private messages: ConversationEntry[] = [];
private loaded = false;
private maxEntries: number;

constructor(baseDir: string = process.cwd()) {
/**
* @param baseDir Base directory for the .dexter folder (defaults to process.cwd()).
* @param options Optional configuration; currently supports maxEntries.
*/
constructor(
baseDir: string = process.cwd(),
options?: {
/**
* Maximum number of entries to retain in memory and on disk.
* Values <= 0 are treated as "no limit" (no trimming).
*/
maxEntries?: number;
}
) {
this.filePath = join(baseDir, getDexterDir(), MESSAGES_DIR, MESSAGES_FILE);
const requestedMax = options?.maxEntries;
if (typeof requestedMax === 'number') {
this.maxEntries = requestedMax > 0 ? Math.floor(requestedMax) : 0;
} else {
this.maxEntries = DEFAULT_MAX_ENTRIES;
}
}

/**
Expand All @@ -46,17 +72,24 @@ export class LongTermChatHistory {
if (existsSync(this.filePath)) {
const content = await readFile(this.filePath, 'utf-8');
const data: MessagesFile = JSON.parse(content);
this.messages = data.messages || [];
this.messages = Array.isArray(data.messages) ? data.messages : [];
} else {
// File doesn't exist, initialize with empty messages
this.messages = [];
await this.save();
}
} catch {
// If there's any error reading/parsing, start fresh
// If there's any error reading/parsing, start fresh in memory
// but leave the on-disk file untouched so users can recover it manually.
this.messages = [];
}

// Enforce max entries on load as well, in case file grew too large.
if (this.maxEntries > 0 && this.messages.length > this.maxEntries) {
this.messages = this.messages.slice(0, this.maxEntries);
await this.save();
}

this.loaded = true;
}

Expand Down Expand Up @@ -93,6 +126,12 @@ export class LongTermChatHistory {

// Prepend to stack (most recent first)
this.messages.unshift(entry);

// Trim to max entries if enabled
if (this.maxEntries > 0 && this.messages.length > this.maxEntries) {
this.messages = this.messages.slice(0, this.maxEntries);
}

await this.save();
}

Expand Down
Loading