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 .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -46,3 +46,4 @@ test/.artifacts

# LLM
CLAUDE.md
.kiro/
Original file line number Diff line number Diff line change
@@ -0,0 +1,247 @@
import { describe, it, expect } from 'vitest'
import { SummarizingConversationManager } from '../summarizing-conversation-manager.js'
import { ContextWindowOverflowError, Message, TextBlock } from '../../index.js'
import { AfterModelCallEvent } from '../../hooks/events.js'
import { createMockAgent, invokeTrackedHook, type MockAgent } from '../../__fixtures__/agent-helpers.js'
import { MockMessageModel } from '../../__fixtures__/mock-message-model.js'
import type { Agent } from '../../agent/agent.js'

async function triggerContextOverflow(
manager: SummarizingConversationManager,
agent: MockAgent,
error: Error
): Promise<AfterModelCallEvent> {
manager.initAgent(agent as any)
const event = new AfterModelCallEvent({ agent: agent as any, error })
await invokeTrackedHook(agent, event)
return event
}

describe('SummarizingConversationManager', () => {
describe('constructor', () => {
it('sets default summaryRatio to 0.3', () => {
const manager = new SummarizingConversationManager()
expect((manager as any)._summaryRatio).toBe(0.3)
})

it('sets default preserveRecentMessages to 10', () => {
const manager = new SummarizingConversationManager()
expect((manager as any)._preserveRecentMessages).toBe(10)
})

it('accepts custom summaryRatio', () => {
const manager = new SummarizingConversationManager({ summaryRatio: 0.5 })
expect((manager as any)._summaryRatio).toBe(0.5)
})

it('clamps summaryRatio to 0.1 minimum', () => {
const manager = new SummarizingConversationManager({ summaryRatio: 0.05 })
expect((manager as any)._summaryRatio).toBe(0.1)
})

it('clamps summaryRatio to 0.8 maximum', () => {
const manager = new SummarizingConversationManager({ summaryRatio: 0.9 })
expect((manager as any)._summaryRatio).toBe(0.8)
})

it('accepts custom preserveRecentMessages', () => {
const manager = new SummarizingConversationManager({ preserveRecentMessages: 5 })
expect((manager as any)._preserveRecentMessages).toBe(5)
})

it('throws error when both summarizationAgent and summarizationSystemPrompt are provided', () => {
const mockAgent = createMockAgent()
expect(
() =>
new SummarizingConversationManager({
summarizationAgent: mockAgent,
summarizationSystemPrompt: 'Custom prompt',
})
).toThrow('Cannot provide both summarizationAgent and summarizationSystemPrompt')
})
})

describe('calculateSummarizeCount', () => {
it('calculates correct count based on summary ratio', () => {
const manager = new SummarizingConversationManager({ summaryRatio: 0.3 })
const count = (manager as any).calculateSummarizeCount(20)
expect(count).toBe(6) // 20 * 0.3 = 6
})

it('respects preserveRecentMessages limit', () => {
const manager = new SummarizingConversationManager({ summaryRatio: 0.5, preserveRecentMessages: 5 })
const count = (manager as any).calculateSummarizeCount(20)
expect(count).toBe(10) // min(20 * 0.5, 20 - 5) = min(10, 15) = 10
})

it('returns 0 when not enough messages to preserve recent', () => {
const manager = new SummarizingConversationManager({ preserveRecentMessages: 15 })
const count = (manager as any).calculateSummarizeCount(10)
expect(count).toBe(0) // 10 - 15 = -5, clamped to 0
})

it('returns 0 when preserveRecentMessages exceeds available messages', () => {
const manager = new SummarizingConversationManager({ summaryRatio: 0.1, preserveRecentMessages: 10 })
const count = (manager as any).calculateSummarizeCount(5)
expect(count).toBe(0) // 5 - 10 = -5, clamped to 0
})
})

describe('reduceContext', () => {
it('throws when insufficient messages for summarization', async () => {
const manager = new SummarizingConversationManager({ preserveRecentMessages: 10 })
const messages = [
new Message({ role: 'user', content: [new TextBlock('Message 1')] }),
new Message({ role: 'assistant', content: [new TextBlock('Response 1')] }),
]
const mockAgent = createMockAgent({ messages })

await expect(triggerContextOverflow(manager, mockAgent, new ContextWindowOverflowError('Test'))).rejects.toThrow(
'Cannot summarize: insufficient messages for summarization'
)
})

it('summarizes messages and replaces with summary', async () => {
const model = new MockMessageModel().addTurn(new TextBlock('Summary of conversation'))
const messages = Array.from({ length: 20 }, (_, i) =>
i % 2 === 0
? new Message({ role: 'user', content: [new TextBlock(`Message ${i}`)] })
: new Message({ role: 'assistant', content: [new TextBlock(`Response ${i}`)] })
)
const mockAgent = createMockAgent({ messages })
;(mockAgent as any).model = model

const manager = new SummarizingConversationManager({ summaryRatio: 0.3, preserveRecentMessages: 5 })

const result = await triggerContextOverflow(manager, mockAgent, new ContextWindowOverflowError('Test'))

expect(result.retry).toBe(true)
expect(mockAgent.messages.length).toBeLessThan(20)
expect(mockAgent.messages[0]?.role).toBe('user')
expect(mockAgent.messages[0]?.content[0]?.type).toBe('textBlock')
})

it('preserves recent messages', async () => {
const model = new MockMessageModel().addTurn(new TextBlock('Summary'))
const messages = Array.from(
{ length: 20 },
(_, i) => new Message({ role: 'user', content: [new TextBlock(`Message ${i}`)] })
)
const mockAgent = createMockAgent({ messages })
;(mockAgent as any).model = model

const manager = new SummarizingConversationManager({ summaryRatio: 0.5, preserveRecentMessages: 10 })

await triggerContextOverflow(manager, mockAgent, new ContextWindowOverflowError('Test'))

// Should have summary + 10 recent messages
expect(mockAgent.messages.length).toBe(11)
expect(mockAgent.messages[mockAgent.messages.length - 1]?.content[0]).toMatchObject({ text: 'Message 19' })
})
})

describe('generateSummaryWithModel', () => {
it('calls model with summarization prompt', async () => {
const model = new MockMessageModel().addTurn(new TextBlock('Generated summary'))
const messages = [
new Message({ role: 'user', content: [new TextBlock('Message 1')] }),
new Message({ role: 'assistant', content: [new TextBlock('Response 1')] }),
]
const mockAgent = createMockAgent({ messages: [] })
;(mockAgent as any).model = model

const manager = new SummarizingConversationManager()
const summary = await (manager as any).generateSummaryWithModel(messages, mockAgent)

expect(summary.role).toBe('user')
expect(summary.content[0]).toMatchObject({ type: 'textBlock', text: 'Generated summary' })
})

it('uses custom summarization prompt when provided', async () => {
const model = new MockMessageModel().addTurn(new TextBlock('Custom summary'))
const messages = [new Message({ role: 'user', content: [new TextBlock('Message')] })]
const mockAgent = createMockAgent({ messages: [] })
;(mockAgent as any).model = model

const manager = new SummarizingConversationManager({
summarizationSystemPrompt: 'Custom summarization instructions',
})
const summary = await (manager as any).generateSummaryWithModel(messages, mockAgent)

expect(summary.content[0]).toMatchObject({ type: 'textBlock', text: 'Custom summary' })
})
})

describe('generateSummaryWithAgent', () => {
it('uses dedicated summarization agent', async () => {
const summaryModel = new MockMessageModel().addTurn(new TextBlock('Agent summary'))
const summaryAgent = createMockAgent({ messages: [] })
;(summaryAgent as any).model = summaryModel
;(summaryAgent as any).invoke = async () => ({
lastMessage: new Message({ role: 'assistant', content: [new TextBlock('Agent summary')] }),
})

const messages = [new Message({ role: 'user', content: [new TextBlock('Message')] })]

const manager = new SummarizingConversationManager({ summarizationAgent: summaryAgent })
const summary = await (manager as any).generateSummaryWithAgent(messages)

expect(summary.role).toBe('user')
expect(summary.content[0]).toMatchObject({ type: 'textBlock', text: 'Agent summary' })
})

it('restores original messages after summarization', async () => {
const summaryModel = new MockMessageModel().addTurn(new TextBlock('Summary'))
const originalMessages = [new Message({ role: 'user', content: [new TextBlock('Original')] })]
const summaryAgent = createMockAgent({ messages: [...originalMessages] })
;(summaryAgent as any).model = summaryModel
;(summaryAgent as any).invoke = async () => ({
lastMessage: new Message({ role: 'assistant', content: [new TextBlock('Summary')] }),
})

const messages = [new Message({ role: 'user', content: [new TextBlock('To summarize')] })]

const manager = new SummarizingConversationManager({ summarizationAgent: summaryAgent })
await (manager as any).generateSummaryWithAgent(messages)

expect(summaryAgent.messages).toHaveLength(1)
expect(summaryAgent.messages[0]?.content[0]).toMatchObject({ text: 'Original' })
})
})

describe('hook integration', () => {
it('registers AfterModelCallEvent callback via initAgent', () => {
const manager = new SummarizingConversationManager()
const agent = createMockAgent()

manager.initAgent(agent as any)

expect(agent.trackedHooks.some((h) => h.eventType === AfterModelCallEvent)).toBe(true)
})

it('sets retry flag on context overflow', async () => {
const model = new MockMessageModel().addTurn(new TextBlock('Summary'))
const messages = Array.from(
{ length: 20 },
(_, i) => new Message({ role: 'user', content: [new TextBlock(`Message ${i}`)] })
)
const mockAgent = createMockAgent({ messages })
;(mockAgent as any).model = model

const manager = new SummarizingConversationManager()
const result = await triggerContextOverflow(manager, mockAgent, new ContextWindowOverflowError('Test'))

expect(result.retry).toBe(true)
})

it('does not set retry flag for non-overflow errors', async () => {
const messages = [new Message({ role: 'user', content: [new TextBlock('Message')] })]
const mockAgent = createMockAgent({ messages })

const manager = new SummarizingConversationManager()
const result = await triggerContextOverflow(manager, mockAgent, new Error('Other error'))

expect(result.retry).toBeUndefined()
})
})
})
53 changes: 53 additions & 0 deletions src/conversation-manager/__tests__/utils.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { describe, it, expect } from 'vitest'
import { findValidSplitPoint } from '../utils.js'
import { Message, TextBlock, ToolUseBlock, ToolResultBlock } from '../../index.js'

function textMsg(role: 'user' | 'assistant', text: string): Message {
return new Message({ role, content: [new TextBlock(text)] })
}

function toolUseMsg(): Message {
return new Message({
role: 'assistant',
content: [new ToolUseBlock({ toolUseId: 'tool-1', name: 'test', input: {} })],
})
}

function toolResultMsg(): Message {
return new Message({
role: 'user',
content: [new ToolResultBlock({ toolUseId: 'tool-1', status: 'success', content: [new TextBlock('result')] })],
})
}

describe('findValidSplitPoint', () => {
it('returns the initial split point when it is already valid', () => {
const messages = [textMsg('user', 'a'), textMsg('assistant', 'b'), textMsg('user', 'c')]
expect(findValidSplitPoint(messages, 1)).toBe(1)
})

it('skips past a toolResultBlock at the split point', () => {
const messages = [toolUseMsg(), toolResultMsg(), textMsg('user', 'next')]
expect(findValidSplitPoint(messages, 1)).toBe(2)
})

it('skips a toolUseBlock when next message is NOT a toolResult', () => {
const messages = [textMsg('user', 'a'), toolUseMsg(), textMsg('user', 'next')]
expect(findValidSplitPoint(messages, 1)).toBe(2)
})

it('keeps toolUseBlock when next message IS a toolResult', () => {
const messages = [textMsg('user', 'a'), toolUseMsg(), toolResultMsg(), textMsg('user', 'next')]
expect(findValidSplitPoint(messages, 1)).toBe(1)
})

it('returns -1 when no valid split point exists', () => {
const messages = [toolUseMsg(), toolResultMsg()]
expect(findValidSplitPoint(messages, 1)).toBe(-1)
})

it('returns the split point at the boundary', () => {
const messages = [textMsg('user', 'a')]
expect(findValidSplitPoint(messages, 0)).toBe(0)
})
})
4 changes: 4 additions & 0 deletions src/conversation-manager/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,7 @@ export {
SlidingWindowConversationManager,
type SlidingWindowConversationManagerConfig,
} from './sliding-window-conversation-manager.js'
export {
SummarizingConversationManager,
type SummarizingConversationManagerConfig,
} from './summarizing-conversation-manager.js'
36 changes: 4 additions & 32 deletions src/conversation-manager/sliding-window-conversation-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import type { LocalAgent } from '../types/agent.js'
import { AfterInvocationEvent } from '../hooks/events.js'
import { ConversationManager, type ConversationManagerReduceOptions } from './conversation-manager.js'
import { logger } from '../logging/logger.js'
import { findValidSplitPoint } from './utils.js'

/**
* Configuration for the sliding window conversation manager.
Expand Down Expand Up @@ -132,42 +133,13 @@ export class SlidingWindowConversationManager extends ConversationManager {

// Try to trim messages when tool result cannot be truncated anymore
// If the number of messages is less than the window_size, then we default to 2, otherwise, trim to window size
let trimIndex = messages.length <= this._windowSize ? 2 : messages.length - this._windowSize
const initialTrimIndex = messages.length <= this._windowSize ? 2 : messages.length - this._windowSize

// Find the next valid trim_index
while (trimIndex < messages.length) {
const oldestMessage = messages[trimIndex]
if (!oldestMessage) {
break
}

// Check if oldest message would be a toolResult (invalid - needs preceding toolUse)
const hasToolResult = oldestMessage.content.some((block) => block.type === 'toolResultBlock')
if (hasToolResult) {
trimIndex++
continue
}

// Check if oldest message would be a toolUse without immediately following toolResult
const hasToolUse = oldestMessage.content.some((block) => block.type === 'toolUseBlock')
if (hasToolUse) {
// Check if next message has toolResult
const nextMessage = messages[trimIndex + 1]
const nextHasToolResult = nextMessage && nextMessage.content.some((block) => block.type === 'toolResultBlock')

if (!nextHasToolResult) {
// toolUse without following toolResult - invalid trim point
trimIndex++
continue
}
}

// Valid trim point found
break
}
const trimIndex = findValidSplitPoint(messages, initialTrimIndex)

// If no valid trim point was found, return false and let the caller handle it
if (trimIndex >= messages.length) {
if (trimIndex < 0 || trimIndex >= messages.length) {
logger.warn(
`window_size=<${this._windowSize}>, messages=<${messages.length}> | unable to trim conversation context, no valid trim point found`
)
Expand Down
Loading