import { describe, it, expect, beforeEach, afterEach, vi, type Mocked, type MockInstance } from 'vitest'; import { AgentRunnerService, AgentRunTimeoutError, ToolCallLoopDetector } from './agent-runner.service.js'; import { ModelManager } from './model-manager.js'; import { CrewlyApiClient } from './api-client.js'; import { CREWLY_AGENT_DEFAULTS, type CrewlyAgentConfig, type SecurityPolicy, type AuditEntry } from './types.js'; describe('AgentRunnerService', () => { let runner: AgentRunnerService; let mockModelManager: Mocked; let mockApiClient: Mocked; let mockGenerateText: vi.Mock; const mockModel = { provider: 'mock', modelId: 'test-model' }; const baseConfig: CrewlyAgentConfig = { model: { provider: 'anthropic', modelId: 'claude-sonnet-4-20250514', temperature: 0.3, maxTokens: 8192 }, maxSteps: 10, sessionName: 'test-session', apiBaseUrl: 'http://localhost:8787', systemPrompt: 'You are a test agent.', maxHistoryMessages: 20, compactionThreshold: 0.8, }; beforeEach(() => { vi.clearAllMocks(); mockGenerateText = vi.fn(); mockModelManager = { getModel: vi.fn().mockResolvedValue(mockModel), getAvailableProviders: vi.fn(), clearCache: vi.fn(), // I2 — DeepSeek reasoning_content extraction. Defaults to no reasoning // captured (returns null) so non-DeepSeek tests are unaffected. consumeDeepseekReasoning: vi.fn().mockResolvedValue(null), } as any; mockApiClient = { get: vi.fn(), post: vi.fn(), delete: vi.fn(), } as any; runner = new AgentRunnerService(baseConfig, mockModelManager, mockApiClient); runner._generateTextFn = mockGenerateText; }); describe('constructor', () => { it('should create with default ModelManager and ApiClient when not provided', () => { const r = new AgentRunnerService(baseConfig); expect(r).toBeDefined(); expect(r.isInitialized()).toBe(false); }); it('should initialize conversation state with empty messages', () => { const state = runner.getState(); expect(state.messages).toEqual([]); expect(state.systemPrompt).toContain('You are a test agent.'); expect(state.totalTokens).toEqual({ input: 0, output: 0 }); }); it('appends the harness rules to every role prompt, so a weak model is told how to work', () => { const prompt = runner.getState().systemPrompt; expect(prompt.indexOf('You are a test agent.')).toBeLessThan(prompt.indexOf('## How to work')); expect(prompt).toMatch(/never write a tool invocation as text/i); expect(prompt).toMatch(/do the work in this turn/i); // Naming the markup is what teaches a model to emit it. expect(prompt).not.toMatch(/<\s*\/?\s*(invoke|parameter|function_calls)/i); }); }); describe('initialize', () => { it('should load the model via ModelManager', async () => { await runner.initialize(); expect(runner.isInitialized()).toBe(true); expect(mockModelManager.getModel).toHaveBeenCalledWith(baseConfig.model); }); it('should propagate ModelManager errors', async () => { mockModelManager.getModel.mockRejectedValueOnce(new Error('Invalid API key')); await expect(runner.initialize()).rejects.toThrow('Invalid API key'); expect(runner.isInitialized()).toBe(false); }); }); describe('run', () => { beforeEach(async () => { await runner.initialize(); }); it('should call generateText and return structured result', async () => { mockGenerateText.mockResolvedValueOnce({ text: 'Hello from agent', steps: [{ toolCalls: [], toolResults: [] }], usage: { inputTokens: 100, outputTokens: 50 }, finishReason: 'stop', }); const result = await runner.run('Hi there'); expect(result.text).toBe('Hello from agent'); expect(result.steps).toBe(1); expect(result.usage).toEqual({ input: 100, output: 50, cachedInput: 0 }); expect(result.toolCalls).toEqual([]); expect(result.finishReason).toBe('stop'); }); it('should add user message and assistant response to history', async () => { mockGenerateText.mockResolvedValueOnce({ text: 'Response', steps: [], usage: { inputTokens: 10, outputTokens: 5 }, finishReason: 'stop', }); await runner.run('Question'); expect(runner.getHistoryLength()).toBe(2); // user + assistant const state = runner.getState(); expect(state.messages[0]).toEqual({ role: 'user', content: 'Question' }); expect(state.messages[1]).toEqual({ role: 'assistant', content: 'Response' }); }); it('should not add assistant message when text is empty', async () => { mockGenerateText.mockResolvedValueOnce({ text: '', steps: [{ toolCalls: [], toolResults: [] }], usage: { inputTokens: 10, outputTokens: 0 }, finishReason: 'tool-calls', }); await runner.run('Do something'); expect(runner.getHistoryLength()).toBe(1); // only user message }); it('asks for a short user-facing reply (not a status report) when tools ran and no text came back', async () => { mockGenerateText .mockResolvedValueOnce({ text: '', steps: [{ toolCalls: [{ toolCallId: 'tc-1', toolName: 'get_team_status', input: {} }], toolResults: [{ toolCallId: 'tc-1', output: {} }] }], usage: { inputTokens: 10, outputTokens: 0 }, finishReason: 'stop', }) .mockResolvedValueOnce({ text: '两个团队都在跑。', usage: { inputTokens: 5, outputTokens: 5 } }); const result = await runner.run('团队怎么样'); expect(result.text).toBe('两个团队都在跑。'); expect(mockGenerateText).toHaveBeenCalledTimes(2); const fallbackCall = mockGenerateText.mock.calls[1][0] as { messages: Array<{ role: string; content: string }>; tools?: unknown }; expect(fallbackCall.tools).toBeUndefined(); // The history array is shared, so find the injected prompt rather than taking the last entry. const prompt = fallbackCall.messages.find((m) => m.role === 'user' && /No status report/.test(String(m.content))); expect(prompt).toBeDefined(); expect(fallbackCall.messages.some((m) => /summarize what you just did/i.test(String(m.content)))).toBe(false); }); it('asks for nothing more when the turn already replied with reply-chat (2026-09-26 status noise)', async () => { mockGenerateText.mockResolvedValueOnce({ text: '', steps: [{ toolCalls: [{ toolCallId: 'tc-1', toolName: 'bash_exec', input: { command: "bash config/skills/orchestrator/reply-chat/execute.sh '{\"content\":\"好了\"}'" } }], toolResults: [{ toolCallId: 'tc-1', output: { success: true } }], }], usage: { inputTokens: 10, outputTokens: 0 }, finishReason: 'stop', }); const result = await runner.run('帮我重新登陆claude code'); expect(result.text).toBe(''); expect(mockGenerateText).toHaveBeenCalledTimes(1); }); it('should track tool calls across steps', async () => { mockGenerateText.mockResolvedValueOnce({ text: 'Done', steps: [ { toolCalls: [ { toolCallId: 'tc-1', toolName: 'get_team_status', input: {} }, ], toolResults: [ { toolCallId: 'tc-1', output: { teams: [] } }, ], }, { toolCalls: [ { toolCallId: 'tc-2', toolName: 'send_message', input: { to: 'sam' } }, ], toolResults: [ { toolCallId: 'tc-2', output: { success: true } }, ], }, ], usage: { inputTokens: 200, outputTokens: 100 }, finishReason: 'stop', }); const result = await runner.run('Check status and notify'); expect(result.toolCalls).toHaveLength(2); expect(result.toolCalls[0].toolName).toBe('get_team_status'); expect(result.toolCalls[0].result).toEqual({ teams: [] }); expect(result.toolCalls[1].toolName).toBe('send_message'); expect(result.steps).toBe(2); }); it('should accumulate token usage across multiple runs', async () => { mockGenerateText .mockResolvedValueOnce({ text: 'First', steps: [], usage: { inputTokens: 100, outputTokens: 50 }, finishReason: 'stop', }) .mockResolvedValueOnce({ text: 'Second', steps: [], usage: { inputTokens: 200, outputTokens: 75 }, finishReason: 'stop', }); await runner.run('Message 1'); await runner.run('Message 2'); const state = runner.getState(); expect(state.totalTokens.input).toBe(300); expect(state.totalTokens.output).toBe(125); }); it('should handle missing usage gracefully', async () => { mockGenerateText.mockResolvedValueOnce({ text: 'Done', steps: [], usage: undefined, finishReason: 'stop', }); const result = await runner.run('Test'); expect(result.usage).toEqual({ input: 0, output: 0, cachedInput: 0 }); }); it('should throw if not initialized', async () => { const uninitRunner = new AgentRunnerService(baseConfig, mockModelManager, mockApiClient); uninitRunner._generateTextFn = mockGenerateText; await expect(uninitRunner.run('Hello')).rejects.toThrow('not initialized'); }); }); describe('serial queue', () => { beforeEach(async () => { await runner.initialize(); }); it('should process multiple messages serially', async () => { const callOrder: number[] = []; mockGenerateText .mockImplementationOnce(async () => { callOrder.push(1); return { text: 'First', steps: [], usage: { inputTokens: 10, outputTokens: 5 }, finishReason: 'stop', }; }) .mockImplementationOnce(async () => { callOrder.push(2); return { text: 'Second', steps: [], usage: { inputTokens: 10, outputTokens: 5 }, finishReason: 'stop', }; }); const [r1, r2] = await Promise.all([ runner.run('First'), runner.run('Second'), ]); expect(r1.text).toBe('First'); expect(r2.text).toBe('Second'); expect(callOrder).toEqual([1, 2]); }); it('should reject queued item if generateText throws', async () => { mockGenerateText.mockRejectedValueOnce(new Error('API error')); await expect(runner.run('Fail')).rejects.toThrow('API error'); }); it('should preserve lastKnownConversationId as fallback for messages without explicit conversationId', async () => { // Track which conversationId is passed to generateText via tools argument const capturedConvIds: (string | undefined)[] = []; mockGenerateText .mockImplementation(async (opts: Record) => { // The tools object is created with the current conversationId. // We verify by checking if report_status tool exists — its closure // captures the conversationId. We call it to extract the value. const tools = opts.tools as Record) => Promise }>; if (tools?.report_status) { // Call report_status to see if conversationId is included in the POST body mockApiClient.post.mockResolvedValueOnce({ success: true, data: {} } as any); await tools.report_status.execute({ status: 'in_progress', summary: 'test' }); const postCall = mockApiClient.post.mock.calls[mockApiClient.post.mock.calls.length - 1]; const body = postCall[1] as Record; capturedConvIds.push(body.conversationId as string | undefined); } return { text: 'Response', steps: [{ toolCalls: [], toolResults: [] }], usage: { inputTokens: 10, outputTokens: 5 }, finishReason: 'stop', }; }); // First message with conversationId await runner.run('With conv', 'conv-123'); // Second message without conversationId (e.g., scheduled check) await runner.run('No conv'); // First call should have conversationId = 'conv-123' expect(capturedConvIds[0]).toBe('conv-123'); // Second call should inherit the last known conversationId as fallback expect(capturedConvIds[1]).toBe('conv-123'); }); it('should update lastKnownConversationId when a new explicit conversationId arrives', async () => { const capturedConvIds: (string | undefined)[] = []; mockGenerateText .mockImplementation(async (opts: Record) => { const tools = opts.tools as Record) => Promise }>; if (tools?.report_status) { mockApiClient.post.mockResolvedValueOnce({ success: true, data: {} } as any); await tools.report_status.execute({ status: 'in_progress', summary: 'test' }); const postCall = mockApiClient.post.mock.calls[mockApiClient.post.mock.calls.length - 1]; const body = postCall[1] as Record; capturedConvIds.push(body.conversationId as string | undefined); } return { text: 'Response', steps: [{ toolCalls: [], toolResults: [] }], usage: { inputTokens: 10, outputTokens: 5 }, finishReason: 'stop', }; }); await runner.run('First', 'conv-A'); await runner.run('Second', 'conv-B'); await runner.run('Third (no conv)'); expect(capturedConvIds[0]).toBe('conv-A'); expect(capturedConvIds[1]).toBe('conv-B'); // Third should use conv-B (last known) expect(capturedConvIds[2]).toBe('conv-B'); }); it('should have no conversationId when no message ever provided one', async () => { const capturedConvIds: (string | undefined)[] = []; mockGenerateText .mockImplementation(async (opts: Record) => { const tools = opts.tools as Record) => Promise }>; if (tools?.report_status) { mockApiClient.post.mockResolvedValueOnce({ success: true, data: {} } as any); await tools.report_status.execute({ status: 'in_progress', summary: 'test' }); const postCall = mockApiClient.post.mock.calls[mockApiClient.post.mock.calls.length - 1]; const body = postCall[1] as Record; capturedConvIds.push(body.conversationId as string | undefined); } return { text: 'Response', steps: [{ toolCalls: [], toolResults: [] }], usage: { inputTokens: 10, outputTokens: 5 }, finishReason: 'stop', }; }); await runner.run('No conv ever'); expect(capturedConvIds[0]).toBeUndefined(); }); }); describe('context compaction', () => { it('should compact history when messages exceed keepRecent threshold', async () => { const config: CrewlyAgentConfig = { ...baseConfig, maxHistoryMessages: 12, }; const r = new AgentRunnerService(config, mockModelManager, mockApiClient); r._generateTextFn = mockGenerateText; await r.initialize(); // 6 runs × 2 messages = 12 messages for (let i = 0; i < 6; i++) { mockGenerateText.mockResolvedValueOnce({ text: `Response ${i}`, steps: [], usage: { inputTokens: 10, outputTokens: 5 }, finishReason: 'stop', }); await r.run(`Message ${i}`); } expect(r.getHistoryLength()).toBe(12); // AI summarization call for compaction + the actual run mockGenerateText.mockResolvedValueOnce({ text: '[Compacted State] Summary of active tasks and decisions', steps: [], usage: { inputTokens: 50, outputTokens: 30 }, finishReason: 'stop', }); mockGenerateText.mockResolvedValueOnce({ text: 'Compacted result', steps: [], usage: { inputTokens: 10, outputTokens: 5 }, finishReason: 'stop', }); await r.run('After compact'); const state = r.getState(); // Compaction: 2 old messages → 1 AI summary, keep 10 recent, +1 user +1 assistant = 13 expect(state.messages.length).toBeLessThan(15); expect(state.messages[0].role).toBe('assistant'); expect(String(state.messages[0].content)).toContain('Compacted State'); }); it('should fall back to truncation summary when AI summarization fails', async () => { const config: CrewlyAgentConfig = { ...baseConfig, maxHistoryMessages: 12, }; const r = new AgentRunnerService(config, mockModelManager, mockApiClient); r._generateTextFn = mockGenerateText; await r.initialize(); // 6 runs × 2 messages = 12 messages for (let i = 0; i < 6; i++) { mockGenerateText.mockResolvedValueOnce({ text: `Response ${i}`, steps: [], usage: { inputTokens: 10, outputTokens: 5 }, finishReason: 'stop', }); await r.run(`Message ${i}`); } // AI summarization fails, then actual run succeeds mockGenerateText.mockRejectedValueOnce(new Error('Model error')); mockGenerateText.mockResolvedValueOnce({ text: 'After fallback', steps: [], usage: { inputTokens: 10, outputTokens: 5 }, finishReason: 'stop', }); await r.run('After compact'); const state = r.getState(); expect(state.messages.length).toBeLessThan(15); expect(state.messages[0].role).toBe('assistant'); expect(String(state.messages[0].content)).toContain('summary'); }); // SKIPPED in standalone: ContextFlushService is currently a no-op stub // (OSS injects a concrete implementation). Re-enable once the service is // ported or the test rewires the stub via dependency injection. it.skip('should include ContextFlushService extracted items in AI summary prompt', async () => { const config: CrewlyAgentConfig = { ...baseConfig, maxHistoryMessages: 12, }; const r = new AgentRunnerService(config, mockModelManager, mockApiClient); r._generateTextFn = mockGenerateText; await r.initialize(); // 6 runs with messages containing extractable context patterns const contextMessages = [ 'Currently working on fixing the login endpoint', 'Decided to use JWT instead of session cookies', 'Port is 8787 for the API server', 'User wants concise error messages', 'Blocked on database migration failing', 'Completed the auth middleware refactor', ]; for (let i = 0; i < 6; i++) { mockGenerateText.mockResolvedValueOnce({ text: `Response ${i}`, steps: [], usage: { inputTokens: 10, outputTokens: 5 }, finishReason: 'stop', }); await r.run(contextMessages[i]); } expect(r.getHistoryLength()).toBe(12); // Capture the AI summarization prompt to verify extracted items are included let capturedSummaryPrompt = ''; mockGenerateText .mockImplementationOnce(async (opts: Record) => { const msgs = opts.messages as Array<{ content: string }>; capturedSummaryPrompt = msgs[0]?.content || ''; return { text: 'Compacted state with critical items', steps: [], usage: { inputTokens: 50, outputTokens: 30 }, finishReason: 'stop', }; }) .mockResolvedValueOnce({ text: 'After compact', steps: [], usage: { inputTokens: 10, outputTokens: 5 }, finishReason: 'stop', }); await r.run('Trigger compaction'); // ContextFlushService should have extracted items from old messages // and they should appear in the AI summary prompt expect(capturedSummaryPrompt).toContain('critical items were auto-extracted'); // At minimum, the task_progress and decision patterns should match expect(capturedSummaryPrompt).toContain('task_progress'); }); // SKIPPED in standalone: see preceding ContextFlushService note. it.skip('should include extracted items in fallback summary when AI fails', async () => { const config: CrewlyAgentConfig = { ...baseConfig, maxHistoryMessages: 12, }; const r = new AgentRunnerService(config, mockModelManager, mockApiClient); r._generateTextFn = mockGenerateText; await r.initialize(); // 6 runs — early messages contain extractable patterns so they end up // in oldMessages (not keepRecent=10). With 12 messages total, the first // 2 messages are old and the rest (10) are recent. So the extractable // content must be in messages 0-1 (runs 0's user+assistant). const userMessages = [ 'Currently working on fixing the login endpoint', 'Message 1', 'Message 2', 'Message 3', 'Message 4', 'Message 5', ]; for (let i = 0; i < 6; i++) { mockGenerateText.mockResolvedValueOnce({ text: i === 0 ? 'Decided to use Redis for caching instead of Memcached' : `Response ${i}`, steps: [], usage: { inputTokens: 10, outputTokens: 5 }, finishReason: 'stop', }); await r.run(userMessages[i]); } // AI fails, fallback used mockGenerateText.mockRejectedValueOnce(new Error('Model error')); mockGenerateText.mockResolvedValueOnce({ text: 'After fallback', steps: [], usage: { inputTokens: 10, outputTokens: 5 }, finishReason: 'stop', }); await r.run('After compact'); const state = r.getState(); const summaryContent = String(state.messages[0].content); // Fallback summary should contain extracted critical context section // Old messages include "Currently working on fixing the login endpoint" (task_progress) // and "Decided to use Redis..." (decision) expect(summaryContent).toContain('Extracted critical context'); expect(summaryContent).toContain('task_progress'); }); it('should skip compaction when history is small', async () => { const config: CrewlyAgentConfig = { ...baseConfig, maxHistoryMessages: 20, }; const r = new AgentRunnerService(config, mockModelManager, mockApiClient); r._generateTextFn = mockGenerateText; await r.initialize(); mockGenerateText.mockResolvedValueOnce({ text: 'Response', steps: [], usage: { inputTokens: 10, outputTokens: 5 }, finishReason: 'stop', }); await r.run('Message'); expect(r.getHistoryLength()).toBe(2); }); }); describe('requestCompaction', () => { it('should return skipped when history is too small', async () => { await runner.initialize(); const result = await runner.requestCompaction(); expect(result.compacted).toBe(false); expect(result.reason).toContain('Too few'); expect(result.messagesBefore).toBe(0); expect(result.messagesAfter).toBe(0); }); it('should perform AI-powered compaction when history is large enough', async () => { const config: CrewlyAgentConfig = { ...baseConfig, maxHistoryMessages: 100, }; const r = new AgentRunnerService(config, mockModelManager, mockApiClient); r._generateTextFn = mockGenerateText; await r.initialize(); // Build up 12 messages (6 runs × 2) for (let i = 0; i < 6; i++) { mockGenerateText.mockResolvedValueOnce({ text: `Response ${i}`, steps: [], usage: { inputTokens: 10, outputTokens: 5 }, finishReason: 'stop', }); await r.run(`Message ${i}`); } expect(r.getHistoryLength()).toBe(12); // AI summarization for compaction mockGenerateText.mockResolvedValueOnce({ text: 'Structured summary of conversation state with active tasks and decisions', steps: [], usage: { inputTokens: 50, outputTokens: 30 }, finishReason: 'stop', }); const result = await r.requestCompaction(); expect(result.compacted).toBe(true); expect(result.messagesBefore).toBe(12); expect(result.messagesAfter).toBe(11); // 1 summary + 10 recent }); it('should return skipped when not initialized', async () => { const r = new AgentRunnerService(baseConfig, mockModelManager, mockApiClient); const result = await r.requestCompaction(); expect(result.compacted).toBe(false); expect(result.reason).toBeDefined(); }); }); describe('getContextBudget', () => { it('should return normal level before any request has been billed', () => { const budget = runner.getContextBudget(); expect(budget.lifetimeTokensUsed).toBe(0); // No provider measurement yet, so occupancy is estimated from the system // prompt — small but non-zero, and it must not read as an empty context. expect(budget.contextTokensUsed).toBeGreaterThan(0); expect(budget.usagePercent).toBeLessThan(0.01); expect(budget.level).toBe('normal'); expect(budget.messageCount).toBe(0); expect(budget.compactionPending).toBe(false); expect(budget.contextWindowSize).toBeGreaterThan(0); }); it('separates context occupancy from lifetime spend', async () => { // The two must diverge: history is re-sent every turn, so lifetime spend // accumulates while occupancy only reflects the latest prompt. Conflating // them drove `usagePercent` past 100% within a few turns. await runner.initialize(); mockGenerateText.mockResolvedValueOnce({ text: 'Response', steps: [], usage: { inputTokens: 500, outputTokens: 200 }, finishReason: 'stop', }); await runner.run('Hello'); expect(runner.getContextBudget().lifetimeTokensUsed).toBe(700); expect(runner.getContextBudget().contextTokensUsed).toBe(700); mockGenerateText.mockResolvedValueOnce({ text: 'Response 2', steps: [], usage: { inputTokens: 800, outputTokens: 100 }, finishReason: 'stop', }); await runner.run('Hello again'); const budget = runner.getContextBudget(); expect(budget.lifetimeTokensUsed).toBe(1600); // 700 + 900, still climbing expect(budget.contextTokensUsed).toBe(900); // just the latest prompt expect(budget.messageCount).toBe(4); }); it('counts cache hits as occupying the window even though they bill cheaper', async () => { // Cached tokens are served at a discount but are still part of the // prompt. Dropping them would make a well-cached session look almost // empty and defeat compaction entirely. await runner.initialize(); mockGenerateText.mockResolvedValueOnce({ text: 'Response', steps: [], usage: { inputTokens: 300, outputTokens: 100, cachedInputTokens: 20_000 }, finishReason: 'stop', }); await runner.run('Hello'); const budget = runner.getContextBudget(); expect(budget.contextTokensUsed).toBe(20_400); // Billing is unaffected — lifetime counts only what the provider charged // as fresh input plus output. expect(budget.lifetimeTokensUsed).toBe(400); }); it('should return warning level when approaching threshold', async () => { await runner.initialize(); // Use a small context window model to easily trigger warning // Default compactionThreshold is 0.8, warningThreshold is 0.8*0.85 = 0.68 // With default context window 128000, need ~87,000 tokens to hit warning // Simulate high token usage for (let i = 0; i < 10; i++) { mockGenerateText.mockResolvedValueOnce({ text: `Response ${i}`, steps: [], usage: { inputTokens: 5000, outputTokens: 4000 }, finishReason: 'stop', }); await runner.run(`Message ${i}`); } const budget = runner.getContextBudget(); // 10 turns × 9,000 billed = 90,000 lifetime tokens, but each individual // prompt was only 9,000 — well under the 200,000 window. The budget must // read the second number: measuring lifetime spend against the window is // what pinned long sessions at 'critical' forever. expect(budget.lifetimeTokensUsed).toBe(90000); expect(budget.contextTokensUsed).toBe(9000); expect(budget.level).toBe('normal'); }); it('should return critical level when at or above compaction threshold', async () => { // Use a config with a low compaction threshold to trigger critical easily const config = { ...baseConfig, compactionThreshold: 0.1, // 10% threshold for testing }; const r = new AgentRunnerService(config, mockModelManager, mockApiClient); r._generateTextFn = mockGenerateText; await r.initialize(); mockGenerateText.mockResolvedValueOnce({ text: 'Response', steps: [], usage: { inputTokens: 15000, outputTokens: 10000 }, finishReason: 'stop', }); await r.run('Hello'); const budget = r.getContextBudget(); // 25,000 / 200,000 = 0.125 which is >= 0.1 threshold expect(budget.level).toBe('critical'); expect(budget.compactionPending).toBe(true); expect(budget.summary).toContain('CRITICAL'); }); // Regression: a long session used to settle into a state where EVERY // message ran a compaction before its own request, doubling the model calls // for the rest of the session and never recovering — compaction shrank the // history but nothing reset the counter it was judged against. Reproduced // at an orchestrator's real scale: a ~20k-token prompt on DeepSeek's 64k // window crossed the 0.8 threshold by the third message. it('does not fall into a permanent compact-before-every-message loop', async () => { const config = { ...baseConfig, model: { provider: 'deepseek' as const, modelId: 'deepseek-chat', temperature: 0.3, maxTokens: 8192 }, maxHistoryMessages: 100, }; const r = new AgentRunnerService(config, mockModelManager, mockApiClient); r._generateTextFn = mockGenerateText; await r.initialize(); mockGenerateText.mockResolvedValue({ text: 'ok', steps: [], usage: { inputTokens: 20_000, outputTokens: 300 }, finishReason: 'stop', }); const TURNS = 12; for (let i = 0; i < TURNS; i++) { await r.run(`message ${i}`); } // One call per turn. A compaction would add a summarization call on top, // so a count above TURNS means the loop is back. expect(mockGenerateText).toHaveBeenCalledTimes(TURNS); const budget = r.getContextBudget(); expect(budget.level).toBe('normal'); // The old formula reported ~380% here. expect(budget.usagePercent).toBeLessThan(1); }); it('should set compactionPending when message count exceeds max', async () => { const config = { ...baseConfig, maxHistoryMessages: 4, // Very low for testing }; const r = new AgentRunnerService(config, mockModelManager, mockApiClient); r._generateTextFn = mockGenerateText; await r.initialize(); // 2 runs × 2 messages = 4 messages = maxHistoryMessages for (let i = 0; i < 2; i++) { mockGenerateText.mockResolvedValueOnce({ text: `R${i}`, steps: [], usage: { inputTokens: 10, outputTokens: 5 }, finishReason: 'stop', }); await r.run(`M${i}`); } const budget = r.getContextBudget(); expect(budget.messageCount).toBe(4); expect(budget.compactionPending).toBe(true); }); it('should use default context window for unknown models', () => { const config = { ...baseConfig, model: { provider: 'anthropic' as const, modelId: 'unknown-model-xyz', temperature: 0.3, maxTokens: 8192 }, }; const r = new AgentRunnerService(config, mockModelManager, mockApiClient); const budget = r.getContextBudget(); expect(budget.contextWindowSize).toBe(128_000); // default fallback }); it('should not split tool_call/tool_result pairs during compaction', async () => { // Create a runner with low maxHistoryMessages so compaction triggers const config: CrewlyAgentConfig = { ...baseConfig, maxHistoryMessages: 14, }; const r = new AgentRunnerService(config, mockModelManager, mockApiClient); r._generateTextFn = mockGenerateText; await r.initialize(); // Manually set messages to simulate tool call pairs at the split boundary // Position 0-3: older messages, 4: assistant (tool_call), 5: tool (result), 6-13: recent const messages: Array<{ role: string; content: string | object }> = []; for (let i = 0; i < 4; i++) { messages.push({ role: 'user', content: `Old message ${i}` }); } // Tool call pair that could get split messages.push({ role: 'assistant', content: [{ type: 'tool_use', id: 'tc1', name: 'bash', input: {} }] }); messages.push({ role: 'tool', content: 'tool result for tc1' }); // More recent messages for (let i = 0; i < 8; i++) { messages.push({ role: i % 2 === 0 ? 'user' : 'assistant', content: `Recent ${i}` }); } (r as any).state.messages = messages; // Trigger compaction via requestCompaction mockGenerateText.mockResolvedValueOnce({ text: '[Summary] Compacted state', steps: [], usage: { inputTokens: 50, outputTokens: 30 }, finishReason: 'stop', }); const result = await r.requestCompaction(); expect(result.compacted).toBe(true); // Verify: no tool-role message should be the first in the kept recent set // The compaction should have included the assistant tool_call message when it // found the tool result at the split boundary const state = r.getState(); if (state.messages.length > 1) { // First message after summary should not be a bare 'tool' role // (it's OK if it's 'user' or 'assistant') const secondMsg = state.messages[1]; if (secondMsg.role === 'tool') { // If a tool message is kept, the preceding assistant must also be kept expect(state.messages[0].role).toBe('assistant'); } } }); }); describe('budgetWarning in run result', () => { it('should include budgetWarning when token usage is high', async () => { const config = { ...baseConfig, compactionThreshold: 0.001, // extremely low threshold for testing }; const r = new AgentRunnerService(config, mockModelManager, mockApiClient); r._generateTextFn = mockGenerateText; await r.initialize(); mockGenerateText.mockResolvedValueOnce({ text: 'Done', steps: [], usage: { inputTokens: 500, outputTokens: 200 }, finishReason: 'stop', }); // The first run triggers compaction (critical threshold), but since there are // fewer than 10 messages, compaction skips. Budget warning should still appear. const result = await r.run('Hello'); expect(result.budgetWarning).toBeDefined(); expect(result.budgetWarning).toContain('CRITICAL'); }); it('should not include budgetWarning when usage is normal', async () => { await runner.initialize(); mockGenerateText.mockResolvedValueOnce({ text: 'Done', steps: [], usage: { inputTokens: 100, outputTokens: 50 }, finishReason: 'stop', }); const result = await runner.run('Hello'); expect(result.budgetWarning).toBeUndefined(); }); }); describe('token-budget-based compaction trigger', () => { it('should trigger compaction when token budget is critical even if message count is low', async () => { // Use a threshold that won't trigger during initial message buildup // but will trigger on the final run after enough tokens accumulate. // Each run uses 150 tokens. After 6 runs: 900 tokens. // Context window for claude-sonnet-4-20250514 = 200,000 // Set threshold to 0.004 (0.4%) = 800 tokens // Compaction must stay reachable on token pressure alone, with a message // count nowhere near `maxHistoryMessages`. Pressure now comes from one // large prompt rather than from many small ones adding up: occupancy is // measured per request, so a long-but-light session no longer reads as // full. Threshold 0.004 of a 200,000 window = 800 tokens. const config = { ...baseConfig, maxHistoryMessages: 1000, // high message limit, won't trigger by count compactionThreshold: 0.004, }; const r = new AgentRunnerService(config, mockModelManager, mockApiClient); r._generateTextFn = mockGenerateText; await r.initialize(); // Build up 12 messages (6 runs × 2) and 900 tokens for (let i = 0; i < 6; i++) { mockGenerateText.mockResolvedValueOnce({ text: `Response ${i}`, steps: [], usage: { inputTokens: 100, outputTokens: 50 }, finishReason: 'stop', }); await r.run(`Message ${i}`); } expect(r.getHistoryLength()).toBe(12); expect(r.getContextBudget().level).toBe('normal'); // A turn that actually fills the context — 950 tokens against an 800 // token threshold. mockGenerateText.mockResolvedValueOnce({ text: 'Big context turn', steps: [], usage: { inputTokens: 900, outputTokens: 50 }, finishReason: 'stop', }); await r.run('Message with a large prompt'); expect(r.getContextBudget().level).toBe('critical'); // The next run sees a critical budget and compacts first: // one summarization call, then the run itself. mockGenerateText.mockResolvedValueOnce({ text: 'Compaction summary of state', steps: [], usage: { inputTokens: 50, outputTokens: 30 }, finishReason: 'stop', }); mockGenerateText.mockResolvedValueOnce({ text: 'After compact', steps: [], usage: { inputTokens: 10, outputTokens: 5 }, finishReason: 'stop', }); await r.run('Trigger compaction by budget'); // Should have compacted: 1 summary + 10 recent + 1 user + 1 assistant expect(r.getHistoryLength()).toBeLessThan(16); const state = r.getState(); expect(String(state.messages[0].content)).toContain('Compacted State'); }); }); // Prompt caching — the fixed prefix (system prompt + tool schemas) is // byte-stable for the life of a session and was billed at full rate on every // turn because no provider options were ever sent. describe('prompt caching', () => { const lastCallArgs = () => mockGenerateText.mock.calls[mockGenerateText.mock.calls.length - 1][0]; it('asks Anthropic to cache the request prefix', async () => { await runner.initialize(); mockGenerateText.mockResolvedValueOnce({ text: 'ok', steps: [], usage: { inputTokens: 10, outputTokens: 5 }, finishReason: 'stop', }); await runner.run('Hello'); expect(lastCallArgs().providerOptions).toEqual({ anthropic: { cacheControl: { type: 'ephemeral' } }, }); }); it('sends no provider options for a non-Anthropic model', async () => { // DeepSeek caches server-side with no opt-in and the others have no // equivalent knob; an Anthropic-namespaced option there is just noise. const config = { ...baseConfig, model: { provider: 'deepseek' as const, modelId: 'deepseek-chat', temperature: 0.3, maxTokens: 8192 }, }; const r = new AgentRunnerService(config, mockModelManager, mockApiClient); r._generateTextFn = mockGenerateText; await r.initialize(); mockGenerateText.mockResolvedValueOnce({ text: 'ok', steps: [], usage: { inputTokens: 10, outputTokens: 5 }, finishReason: 'stop', }); await r.run('Hello'); expect(lastCallArgs().providerOptions).toBeUndefined(); }); it('reports cache hits on the run result so the hit rate is observable', async () => { await runner.initialize(); mockGenerateText.mockResolvedValueOnce({ text: 'ok', steps: [], usage: { inputTokens: 400, outputTokens: 60, cachedInputTokens: 18_000 }, finishReason: 'stop', }); const result = await runner.run('Hello'); expect(result.usage).toEqual({ input: 400, output: 60, cachedInput: 18_000 }); }); it('reports zero cache hits when the provider omits the field', async () => { await runner.initialize(); mockGenerateText.mockResolvedValueOnce({ text: 'ok', steps: [], usage: { inputTokens: 400, outputTokens: 60 }, finishReason: 'stop', }); const result = await runner.run('Hello'); expect(result.usage.cachedInput).toBe(0); }); }); describe('audit trail', () => { it('should return empty audit log initially', () => { const log = runner.getAuditLog(); expect(log).toEqual([]); }); it('should return default security policy', () => { const policy = runner.getSecurityPolicy(); expect(policy.auditEnabled).toBe(true); expect(policy.requireApproval).toEqual([]); expect(policy.blockedTools).toEqual([]); expect(policy.maxAuditEntries).toBe(500); }); it('should update security policy', () => { runner.updateSecurityPolicy({ requireApproval: ['destructive', 'sensitive'] }); const policy = runner.getSecurityPolicy(); expect(policy.requireApproval).toEqual(['destructive', 'sensitive']); expect(policy.auditEnabled).toBe(true); // unchanged }); it('should return a copy of the security policy', () => { const p1 = runner.getSecurityPolicy(); const p2 = runner.getSecurityPolicy(); expect(p1).not.toBe(p2); expect(p1).toEqual(p2); }); }); describe('approval mode enforcement', () => { beforeEach(async () => { await runner.initialize(); }); it('should block tool execution when sensitivity requires approval', async () => { runner.updateSecurityPolicy({ requireApproval: ['destructive'] }); // When a tool with 'destructive' sensitivity is called, it should be denied // We verify this by running a message that would trigger tool use // and checking the security policy state const policy = runner.getSecurityPolicy(); expect(policy.requireApproval).toContain('destructive'); }); it('should allow tool execution when sensitivity is not in requireApproval', async () => { runner.updateSecurityPolicy({ requireApproval: ['destructive'] }); const policy = runner.getSecurityPolicy(); expect(policy.requireApproval).not.toContain('safe'); expect(policy.requireApproval).not.toContain('sensitive'); }); it('should block explicitly blocked tools', async () => { runner.updateSecurityPolicy({ blockedTools: ['stop_agent', 'write_file'] }); const policy = runner.getSecurityPolicy(); expect(policy.blockedTools).toContain('stop_agent'); expect(policy.blockedTools).toContain('write_file'); }); it('should combine approval and blocked tools', async () => { runner.updateSecurityPolicy({ requireApproval: ['destructive', 'sensitive'], blockedTools: ['handle_agent_failure'], }); const policy = runner.getSecurityPolicy(); expect(policy.requireApproval).toEqual(['destructive', 'sensitive']); expect(policy.blockedTools).toEqual(['handle_agent_failure']); expect(policy.auditEnabled).toBe(true); // unchanged }); }); describe('read-only audit mode', () => { beforeEach(async () => { await runner.initialize(); }); it('should default readOnlyMode to false', () => { const policy = runner.getSecurityPolicy(); expect(policy.readOnlyMode).toBe(false); }); it('should enable read-only mode via updateSecurityPolicy', () => { runner.updateSecurityPolicy({ readOnlyMode: true }); const policy = runner.getSecurityPolicy(); expect(policy.readOnlyMode).toBe(true); }); it('should block write tools when readOnlyMode is active', async () => { runner.updateSecurityPolicy({ readOnlyMode: true }); // Run a message that triggers a write tool — we check via tool execution // The tool should be blocked by checkApproval before reaching the API let toolResult: unknown; mockGenerateText.mockImplementation(async (opts: Record) => { const tools = opts.tools as Record) => Promise }>; // Try to call write_file — should be blocked toolResult = await tools.write_file.execute({ file_path: '/test/file.ts', content: 'blocked content', }); return { text: 'Done', steps: [{ toolCalls: [], toolResults: [] }], usage: { inputTokens: 10, outputTokens: 5 }, finishReason: 'stop', }; }); await runner.run('Try to write'); expect(toolResult).toBeDefined(); expect((toolResult as Record).success).toBe(false); expect((toolResult as Record).blocked).toBe(true); expect((toolResult as Record).error).toContain('read-only'); // Should NOT have called the API expect(mockApiClient.post).not.toHaveBeenCalled(); }); it('should allow safe/read-only tools when readOnlyMode is active', async () => { runner.updateSecurityPolicy({ readOnlyMode: true }); let toolResult: unknown; mockGenerateText.mockImplementation(async (opts: Record) => { const tools = opts.tools as Record) => Promise }>; mockApiClient.get.mockResolvedValueOnce({ success: true, data: [{ name: 'team-a' }], status: 200 } as any); toolResult = await tools.get_team_status.execute({}); return { text: 'Done', steps: [{ toolCalls: [], toolResults: [] }], usage: { inputTokens: 10, outputTokens: 5 }, finishReason: 'stop', }; }); await runner.run('Check teams'); // Safe tool should work expect(mockApiClient.get).toHaveBeenCalled(); expect(toolResult).toEqual([{ name: 'team-a' }]); }); it('should log blocked write attempts in audit trail during readOnlyMode', async () => { runner.updateSecurityPolicy({ readOnlyMode: true }); mockGenerateText.mockImplementation(async (opts: Record) => { const tools = opts.tools as Record) => Promise }>; await tools.edit_file.execute({ file_path: '/test/file.ts', old_string: 'foo', new_string: 'bar', replace_all: false, }); return { text: 'Done', steps: [{ toolCalls: [], toolResults: [] }], usage: { inputTokens: 10, outputTokens: 5 }, finishReason: 'stop', }; }); await runner.run('Try to edit'); const auditLog = runner.getAuditLog(); expect(auditLog.length).toBeGreaterThanOrEqual(1); const editEntry = auditLog.find(e => e.toolName === 'edit_file'); expect(editEntry).toBeDefined(); expect(editEntry!.success).toBe(false); expect(editEntry!.error).toContain('read-only'); }); }); describe('audit trail with sessionName', () => { beforeEach(async () => { await runner.initialize(); }); it('should include sessionName in audit entries', async () => { mockGenerateText.mockImplementation(async (opts: Record) => { const tools = opts.tools as Record) => Promise }>; mockApiClient.get.mockResolvedValueOnce({ success: true, data: [], status: 200 } as any); await tools.get_team_status.execute({}); return { text: 'Done', steps: [{ toolCalls: [], toolResults: [] }], usage: { inputTokens: 10, outputTokens: 5 }, finishReason: 'stop', }; }); await runner.run('Check status'); const auditLog = runner.getAuditLog(); expect(auditLog.length).toBeGreaterThanOrEqual(1); expect(auditLog[0].sessionName).toBe('test-session'); }); }); describe('getFilteredAuditLog via get_audit_log tool', () => { beforeEach(async () => { await runner.initialize(); }); it('should return actual audit entries via get_audit_log tool', async () => { // First generate some audit data mockGenerateText.mockImplementationOnce(async (opts: Record) => { const tools = opts.tools as Record) => Promise }>; mockApiClient.get.mockResolvedValueOnce({ success: true, data: [], status: 200 } as any); await tools.get_team_status.execute({}); return { text: 'Done', steps: [{ toolCalls: [], toolResults: [] }], usage: { inputTokens: 10, outputTokens: 5 }, finishReason: 'stop', }; }); await runner.run('Generate audit data'); // Now query the audit log through the tool let auditResult: Record | undefined; mockGenerateText.mockImplementationOnce(async (opts: Record) => { const tools = opts.tools as Record) => Promise }>; auditResult = await tools.get_audit_log.execute({ limit: 10 }) as Record; return { text: 'Audit retrieved', steps: [{ toolCalls: [], toolResults: [] }], usage: { inputTokens: 10, outputTokens: 5 }, finishReason: 'stop', }; }); await runner.run('Get audit log'); expect(auditResult).toBeDefined(); expect(auditResult!.success).toBe(true); expect(auditResult!.totalEntries).toBeGreaterThanOrEqual(1); const entries = auditResult!.entries as AuditEntry[]; expect(entries[0].toolName).toBe('get_team_status'); }); }); describe('conversation history integration', () => { beforeEach(async () => { await runner.initialize(); }); it('should accumulate messages across consecutive run() calls', async () => { mockGenerateText .mockResolvedValueOnce({ text: 'Answer 1', steps: [], usage: { inputTokens: 10, outputTokens: 5 }, finishReason: 'stop', }) .mockResolvedValueOnce({ text: 'Answer 2', steps: [], usage: { inputTokens: 20, outputTokens: 10 }, finishReason: 'stop', }) .mockResolvedValueOnce({ text: 'Answer 3', steps: [], usage: { inputTokens: 30, outputTokens: 15 }, finishReason: 'stop', }); await runner.run('Question 1'); await runner.run('Question 2'); await runner.run('Question 3'); const state = runner.getState(); expect(state.messages).toHaveLength(6); // 3 user + 3 assistant expect(state.messages[0]).toEqual({ role: 'user', content: 'Question 1' }); expect(state.messages[1]).toEqual({ role: 'assistant', content: 'Answer 1' }); expect(state.messages[2]).toEqual({ role: 'user', content: 'Question 2' }); expect(state.messages[3]).toEqual({ role: 'assistant', content: 'Answer 2' }); expect(state.messages[4]).toEqual({ role: 'user', content: 'Question 3' }); expect(state.messages[5]).toEqual({ role: 'assistant', content: 'Answer 3' }); }); it('should pass prior conversation context to generateText on subsequent calls', async () => { // Capture messages snapshot at each generateText call (array is passed by reference) const capturedMessages: Array> = []; mockGenerateText .mockImplementationOnce(async (opts: Record) => { const msgs = opts.messages as Array<{ role: string; content: string }>; capturedMessages.push([...msgs]); return { text: 'First response', steps: [], usage: { inputTokens: 10, outputTokens: 5 }, finishReason: 'stop', }; }) .mockImplementationOnce(async (opts: Record) => { const msgs = opts.messages as Array<{ role: string; content: string }>; capturedMessages.push([...msgs]); return { text: 'Second response', steps: [], usage: { inputTokens: 20, outputTokens: 10 }, finishReason: 'stop', }; }); await runner.run('Hello'); await runner.run('Follow up'); // First call should have only the new user message expect(capturedMessages[0]).toHaveLength(1); expect(capturedMessages[0][0]).toEqual({ role: 'user', content: 'Hello' }); // Second call should include prior context: user + assistant + new user expect(capturedMessages[1]).toHaveLength(3); expect(capturedMessages[1][0]).toEqual({ role: 'user', content: 'Hello' }); expect(capturedMessages[1][1]).toEqual({ role: 'assistant', content: 'First response' }); expect(capturedMessages[1][2]).toEqual({ role: 'user', content: 'Follow up' }); }); it('should trigger compaction when history reaches maxHistoryMessages during run', async () => { // Use a runner with low maxHistoryMessages to trigger compaction const smallHistoryConfig: CrewlyAgentConfig = { ...baseConfig, maxHistoryMessages: 10, // will trigger at 10 messages }; const r = new AgentRunnerService(smallHistoryConfig, mockModelManager, mockApiClient); r._generateTextFn = mockGenerateText; await r.initialize(); // Fill up to 10 messages (5 runs × 2 messages each) for (let i = 0; i < 5; i++) { mockGenerateText.mockResolvedValueOnce({ text: `Response ${i}`, steps: [], usage: { inputTokens: 10, outputTokens: 5 }, finishReason: 'stop', }); await r.run(`Message ${i}`); } expect(r.getHistoryLength()).toBe(10); // Next run should trigger compaction (messages >= maxHistoryMessages) // Compaction needs AI summary call + the actual run call mockGenerateText .mockResolvedValueOnce({ // AI summary during compaction text: '[Summary] Previous conversation covered messages 0-4', steps: [], usage: { inputTokens: 50, outputTokens: 20 }, finishReason: 'stop', }) .mockResolvedValueOnce({ // Actual run response text: 'Post-compaction response', steps: [], usage: { inputTokens: 15, outputTokens: 8 }, finishReason: 'stop', }); const result = await r.run('After compaction'); expect(result.text).toBe('Post-compaction response'); // After compaction: keepRecent=10 messages retained + summary message + new user + new assistant // But since we had exactly 10, compaction keeps 10 recent, and old=0 so it won't compact // Actually compaction requires >= 10 messages to proceed (line 595 check) // The history had 10 messages when the 6th run started, so compaction triggered // keepRecent=10 means all messages are "recent", oldMessages is empty // With < 10 old messages, the compactHistory still proceeds since total >= 10 // Let's just verify history didn't grow unbounded expect(r.getHistoryLength()).toBeLessThanOrEqual(14); // bounded }); it('should return correct getHistoryLength after multiple runs', async () => { expect(runner.getHistoryLength()).toBe(0); mockGenerateText.mockResolvedValueOnce({ text: 'R1', steps: [], usage: { inputTokens: 10, outputTokens: 5 }, finishReason: 'stop', }); await runner.run('M1'); expect(runner.getHistoryLength()).toBe(2); mockGenerateText.mockResolvedValueOnce({ text: 'R2', steps: [], usage: { inputTokens: 10, outputTokens: 5 }, finishReason: 'stop', }); await runner.run('M2'); expect(runner.getHistoryLength()).toBe(4); mockGenerateText.mockResolvedValueOnce({ text: '', steps: [], usage: { inputTokens: 10, outputTokens: 0 }, finishReason: 'tool-calls', }); await runner.run('M3'); // Empty response doesn't add assistant message expect(runner.getHistoryLength()).toBe(5); }); it('should return current messages array via getState after multiple runs', async () => { mockGenerateText .mockResolvedValueOnce({ text: 'Alpha', steps: [], usage: { inputTokens: 10, outputTokens: 5 }, finishReason: 'stop', }) .mockResolvedValueOnce({ text: 'Beta', steps: [], usage: { inputTokens: 10, outputTokens: 5 }, finishReason: 'stop', }); await runner.run('First'); await runner.run('Second'); const state = runner.getState(); expect(state.messages).toHaveLength(4); expect(state.messages.map(m => m.content)).toEqual(['First', 'Alpha', 'Second', 'Beta']); expect(state.totalTokens).toEqual({ input: 20, output: 10 }); }); }); describe('getState', () => { it('should return a copy of state, not the original', () => { const state1 = runner.getState(); const state2 = runner.getState(); expect(state1).not.toBe(state2); expect(state1).toEqual(state2); }); }); describe('getHistoryLength', () => { it('should return 0 for fresh runner', () => { expect(runner.getHistoryLength()).toBe(0); }); }); describe('isInitialized', () => { it('should return false before initialize', () => { expect(runner.isInitialized()).toBe(false); }); it('should return true after initialize', async () => { await runner.initialize(); expect(runner.isInitialized()).toBe(true); }); }); describe('long message handling (Bug 1)', () => { beforeEach(async () => { await runner.initialize(); }); it('should process messages longer than 500 chars without dropping', async () => { const longMessage = 'A'.repeat(2000); mockGenerateText.mockResolvedValueOnce({ text: 'Processed long message', steps: [], usage: { inputTokens: 500, outputTokens: 50 }, finishReason: 'stop', }); const result = await runner.run(longMessage); expect(result.text).toBe('Processed long message'); // Verify the full message was passed to generateText const callArgs = mockGenerateText.mock.calls[0][0] as Record; const messages = callArgs.messages as Array<{ role: string; content: string }>; // After run(), assistant response is pushed to the same array reference, // so the user message is second-to-last const userMsg = messages.find(m => m.role === 'user' && m.content.length === 2000); expect(userMsg).toBeDefined(); expect(userMsg!.content).toBe(longMessage); expect(userMsg!.content.length).toBe(2000); }); it('should process messages over 5000 chars without truncation', async () => { const veryLongMessage = 'Task: '.repeat(1000); mockGenerateText.mockResolvedValueOnce({ text: 'Done', steps: [], usage: { inputTokens: 1000, outputTokens: 20 }, finishReason: 'stop', }); const result = await runner.run(veryLongMessage); expect(result.text).toBe('Done'); const callArgs = mockGenerateText.mock.calls[0][0] as Record; const messages = callArgs.messages as Array<{ role: string; content: string }>; // Find the user message (assistant response is also in array due to shared reference) const userMsg = messages.find(m => m.role === 'user' && m.content === veryLongMessage); expect(userMsg).toBeDefined(); }); it('should not strand messages in the queue (race condition guard)', async () => { // Simulate the scenario where a message arrives right as processQueue exits const messages: string[] = []; mockGenerateText.mockImplementation(async () => { return { text: 'Response', steps: [], usage: { inputTokens: 10, outputTokens: 5 }, finishReason: 'stop', }; }); // Send multiple messages concurrently const results = await Promise.all([ runner.run('Message 1'), runner.run('Message 2'), runner.run('Message 3'), ]); expect(results).toHaveLength(3); expect(results[0].text).toBe('Response'); expect(results[1].text).toBe('Response'); expect(results[2].text).toBe('Response'); expect(mockGenerateText).toHaveBeenCalledTimes(3); }); }); describe('Slack context (Bug 5)', () => { beforeEach(async () => { await runner.initialize(); }); it('should store Slack context from metadata', async () => { mockGenerateText.mockResolvedValueOnce({ text: 'Response', steps: [{ toolCalls: [], toolResults: [] }], usage: { inputTokens: 10, outputTokens: 5 }, finishReason: 'stop', }); await runner.run('Hello', 'conv-123', { channelId: 'D0AC7NF5N7L', threadTs: '123.456' }); const slackCtx = runner.getSlackContext(); expect(slackCtx).toBeDefined(); expect(slackCtx!.channelId).toBe('D0AC7NF5N7L'); expect(slackCtx!.threadTs).toBe('123.456'); }); it('should pass Slack context to tools via createTools', async () => { // Verify the reply_slack tool gets the Slack context let replySlackResult: unknown; mockGenerateText.mockImplementation(async (opts: Record) => { const tools = opts.tools as Record) => Promise }>; mockApiClient.post.mockResolvedValueOnce({ success: true, data: {} } as any); replySlackResult = await tools.reply_slack.execute({ text: 'Hello from agent', }); return { text: 'Done', steps: [{ toolCalls: [], toolResults: [] }], usage: { inputTokens: 10, outputTokens: 5 }, finishReason: 'stop', }; }); await runner.run('Reply to Slack', 'conv-1', { channelId: 'C123', threadTs: '456.789' }); // The reply_slack should have auto-filled channelId from context expect(mockApiClient.post).toHaveBeenCalledWith('/slack/send', expect.objectContaining({ channelId: 'C123', threadTs: '456.789', })); }); it('should return error when no channelId available', async () => { let replySlackResult: unknown; mockGenerateText.mockImplementation(async (opts: Record) => { const tools = opts.tools as Record) => Promise }>; replySlackResult = await tools.reply_slack.execute({ text: 'No channel', }); return { text: 'Done', steps: [{ toolCalls: [], toolResults: [] }], usage: { inputTokens: 10, outputTokens: 5 }, finishReason: 'stop', }; }); await runner.run('Reply without context'); expect(replySlackResult).toBeDefined(); expect((replySlackResult as Record).success).toBe(false); expect((replySlackResult as Record).error).toContain('No channelId'); }); }); describe('compaction guard (concurrent compaction prevention)', () => { it('should skip compaction when already compacting', async () => { const config: CrewlyAgentConfig = { ...baseConfig, maxHistoryMessages: 12, }; const r = new AgentRunnerService(config, mockModelManager, mockApiClient); r._generateTextFn = mockGenerateText; await r.initialize(); // Fill history to 12 messages (6 runs × 2 messages) for (let i = 0; i < 6; i++) { mockGenerateText.mockResolvedValueOnce({ text: `Response ${i}`, steps: [], usage: { inputTokens: 10, outputTokens: 5 }, finishReason: 'stop', }); await r.run(`Message ${i}`); } expect(r.getHistoryLength()).toBe(12); // requestCompaction should succeed // AI summarization + the compaction mockGenerateText.mockResolvedValueOnce({ text: '[Summary] Compacted state', steps: [], usage: { inputTokens: 30, outputTokens: 20 }, finishReason: 'stop', }); const result = await r.requestCompaction(); expect(result.compacted).toBe(true); expect(result.messagesBefore).toBe(12); expect(result.messagesAfter).toBeLessThan(12); }); it('should skip compaction when history is too small', async () => { await runner.initialize(); mockGenerateText.mockResolvedValueOnce({ text: 'Response', steps: [], usage: { inputTokens: 10, outputTokens: 5 }, finishReason: 'stop', }); await runner.run('Short history'); const result = await runner.requestCompaction(); expect(result.compacted).toBe(false); expect(result.reason).toContain('Too few messages'); }); }); describe('abort', () => { beforeEach(async () => { await runner.initialize(); }); it('should return false when no run is in progress', () => { expect(runner.abortCurrentRun()).toBe(false); }); it('should report processing state via isProcessing()', async () => { expect(runner.isProcessing()).toBe(false); mockGenerateText.mockResolvedValueOnce({ text: 'ok', steps: [], usage: { inputTokens: 10, outputTokens: 5 }, finishReason: 'stop', }); await runner.run('test'); // After completion, processing should be false again expect(runner.isProcessing()).toBe(false); }); it('should pass abort signal to generateText when provided', async () => { mockGenerateText.mockResolvedValueOnce({ text: 'ok', steps: [], usage: { inputTokens: 10, outputTokens: 5 }, finishReason: 'stop', }); const abortController = new AbortController(); await runner.run('test', undefined, undefined, { abortSignal: abortController.signal }); // Verify generateText received the abort signal expect(mockGenerateText).toHaveBeenCalledWith( expect.objectContaining({ abortSignal: expect.anything(), }), ); }); it('should pass streaming callbacks through options', async () => { mockGenerateText.mockResolvedValueOnce({ text: 'ok', steps: [], usage: { inputTokens: 10, outputTokens: 5 }, finishReason: 'stop', }); const onTextChunk = vi.fn(); await runner.run('test', undefined, undefined, { streaming: { onTextChunk }, }); // Callbacks are set on the instance — they won't fire with the mock // but verify no error expect(runner.isProcessing()).toBe(false); }); }); describe('retry with backoff', () => { beforeEach(async () => { await runner.initialize(); }); it('should retry on 429 rate limit error and succeed on retry', async () => { mockGenerateText .mockRejectedValueOnce(new Error('429 Too Many Requests')) .mockResolvedValueOnce({ text: 'Success after retry', steps: [], usage: { inputTokens: 10, outputTokens: 5 }, finishReason: 'stop', }); const result = await runner.run('test'); expect(result.text).toBe('Success after retry'); expect(mockGenerateText).toHaveBeenCalledTimes(2); }); it('should retry on 500 server error with exponential backoff', async () => { mockGenerateText .mockRejectedValueOnce(new Error('500 Internal Server Error')) .mockRejectedValueOnce(new Error('502 Bad Gateway')) .mockResolvedValueOnce({ text: 'Recovered', steps: [], usage: { inputTokens: 10, outputTokens: 5 }, finishReason: 'stop', }); const result = await runner.run('test'); expect(result.text).toBe('Recovered'); expect(mockGenerateText).toHaveBeenCalledTimes(3); }); it('should retry on network errors', async () => { mockGenerateText .mockRejectedValueOnce(new Error('fetch failed: ECONNRESET')) .mockResolvedValueOnce({ text: 'OK', steps: [], usage: { inputTokens: 10, outputTokens: 5 }, finishReason: 'stop', }); const result = await runner.run('test'); expect(result.text).toBe('OK'); expect(mockGenerateText).toHaveBeenCalledTimes(2); }); it('should NOT retry on 401 auth error (non-recoverable)', async () => { mockGenerateText.mockRejectedValue(new Error('401 Unauthorized')); await expect(runner.run('test')).rejects.toThrow('401 Unauthorized'); expect(mockGenerateText).toHaveBeenCalledTimes(1); }); it('should NOT retry on 400 bad request (non-recoverable)', async () => { mockGenerateText.mockRejectedValue(new Error('400 Bad Request: invalid model')); await expect(runner.run('test')).rejects.toThrow('400 Bad Request'); expect(mockGenerateText).toHaveBeenCalledTimes(1); }); it('should give up after max retries on persistent 429', async () => { mockGenerateText.mockRejectedValue(new Error('429 rate limit exceeded')); await expect(runner.run('test')).rejects.toThrow('429 rate limit exceeded'); // 1 initial + 3 retries = 4 calls total expect(mockGenerateText).toHaveBeenCalledTimes(4); }, 15000); // 3 retries with exponential backoff take ~7s of wall time it('should attempt context compaction on context length error', async () => { // First call: context length error. After compaction, second call succeeds. mockGenerateText .mockRejectedValueOnce(new Error('context length exceeded: too many tokens')) .mockResolvedValueOnce({ // AI summary call during compaction — need 10+ messages text: '[Summary]', steps: [], usage: { inputTokens: 10, outputTokens: 5 }, finishReason: 'stop', }) .mockResolvedValueOnce({ text: 'Success after trim', steps: [], usage: { inputTokens: 10, outputTokens: 5 }, finishReason: 'stop', }); // Fill history to make compaction possible (need >= 10 messages) for (let i = 0; i < 5; i++) { runner.getState().messages.push( { role: 'user', content: `msg ${i}` }, { role: 'assistant', content: `resp ${i}` }, ); } const result = await runner.run('test after context error'); // The first generateText throws context error, then compaction + retry expect(result.text).toBe('Success after trim'); }); it('should trim oldest messages if compaction does not help', async () => { // Fill history to make compaction possible for (let i = 0; i < 6; i++) { runner.getState().messages.push( { role: 'user', content: `msg ${i}` }, { role: 'assistant', content: `resp ${i}` }, ); } mockGenerateText .mockRejectedValueOnce(new Error('context length exceeded')) .mockResolvedValueOnce({ // AI summary during compaction text: '[Summary]', steps: [], usage: { inputTokens: 10, outputTokens: 5 }, finishReason: 'stop', }) .mockRejectedValueOnce(new Error('context length exceeded')) .mockResolvedValueOnce({ text: 'Finally worked', steps: [], usage: { inputTokens: 10, outputTokens: 5 }, finishReason: 'stop', }); const result = await runner.run('test'); expect(result.text).toBe('Finally worked'); // Messages should have been trimmed expect(runner.getHistoryLength()).toBeLessThan(13); }); }); describe('loop detection in generateText path', () => { it('should detect consecutive identical tool calls and return loop-detected', async () => { await runner.initialize(); // Simulate 3 identical tool calls (threshold = 3) const identicalToolCall = { toolName: 'bash', toolCallId: 'tc-1', input: { command: 'curl http://example.com/missing' } }; mockGenerateText.mockResolvedValueOnce({ text: '', steps: [ { toolCalls: [identicalToolCall], toolResults: [{ toolCallId: 'tc-1', output: '404 Not Found' }], }, { toolCalls: [{ ...identicalToolCall, toolCallId: 'tc-2' }], toolResults: [{ toolCallId: 'tc-2', output: '404 Not Found' }], }, { toolCalls: [{ ...identicalToolCall, toolCallId: 'tc-3' }], toolResults: [{ toolCallId: 'tc-3', output: '404 Not Found' }], }, ], usage: { inputTokens: 100, outputTokens: 50 }, finishReason: 'stop', }); const result = await runner.run('fetch the page'); expect(result.finishReason).toBe('loop-detected'); expect(result.text).toContain('Loop detected'); expect(result.toolCalls).toHaveLength(3); }); it('should not trigger loop for different tool calls', async () => { await runner.initialize(); mockGenerateText.mockResolvedValueOnce({ text: 'All done', steps: [ { toolCalls: [{ toolName: 'bash', toolCallId: 'tc-1', input: { command: 'ls' } }], toolResults: [{ toolCallId: 'tc-1', output: 'file1.ts' }], }, { toolCalls: [{ toolName: 'bash', toolCallId: 'tc-2', input: { command: 'cat file1.ts' } }], toolResults: [{ toolCallId: 'tc-2', output: 'content' }], }, { toolCalls: [{ toolName: 'bash', toolCallId: 'tc-3', input: { command: 'echo done' } }], toolResults: [{ toolCallId: 'tc-3', output: 'done' }], }, ], usage: { inputTokens: 50, outputTokens: 20 }, finishReason: 'stop', }); const result = await runner.run('do things'); expect(result.finishReason).toBe('stop'); expect(result.text).toBe('All done'); }); it('should detect error loop from same tool returning errors', async () => { await runner.initialize(); mockGenerateText.mockResolvedValueOnce({ text: '', steps: [ { toolCalls: [{ toolName: 'read_file', toolCallId: 'tc-1', input: { path: '/a.ts' } }], toolResults: [{ toolCallId: 'tc-1', output: 'error: not found' }], }, { toolCalls: [{ toolName: 'read_file', toolCallId: 'tc-2', input: { path: '/b.ts' } }], toolResults: [{ toolCallId: 'tc-2', output: 'error: not found' }], }, { toolCalls: [{ toolName: 'read_file', toolCallId: 'tc-3', input: { path: '/c.ts' } }], toolResults: [{ toolCallId: 'tc-3', output: 'error: failed to read' }], }, ], usage: { inputTokens: 50, outputTokens: 20 }, finishReason: 'stop', }); const result = await runner.run('find the file'); expect(result.finishReason).toBe('loop-detected'); expect(result.text).toContain('Loop detected'); }); it('should inject corrective messages into conversation history on loop', async () => { await runner.initialize(); mockGenerateText.mockResolvedValueOnce({ text: '', steps: [ { toolCalls: [{ toolName: 'bash', toolCallId: 'tc-1', input: { command: 'curl x' } }], toolResults: [{ toolCallId: 'tc-1', output: 'ok' }] }, { toolCalls: [{ toolName: 'bash', toolCallId: 'tc-2', input: { command: 'curl x' } }], toolResults: [{ toolCallId: 'tc-2', output: 'ok' }] }, { toolCalls: [{ toolName: 'bash', toolCallId: 'tc-3', input: { command: 'curl x' } }], toolResults: [{ toolCallId: 'tc-3', output: 'ok' }] }, ], usage: { inputTokens: 50, outputTokens: 20 }, finishReason: 'stop', }); await runner.run('test'); // Should have injected corrective messages const state = runner.getState(); const lastUserMsg = state.messages[state.messages.length - 1]; expect(lastUserMsg.role).toBe('user'); expect(String(lastUserMsg.content)).toContain('LOOP DETECTED'); expect(String(lastUserMsg.content)).toContain('different approach'); }); }); }); describe('ToolCallLoopDetector', () => { it('should detect consecutive identical tool calls at threshold', () => { const detector = new ToolCallLoopDetector(3, 3); expect(detector.recordToolCall('bash', { command: 'ls' }, 'output')).toBe(false); expect(detector.recordToolCall('bash', { command: 'ls' }, 'output')).toBe(false); expect(detector.recordToolCall('bash', { command: 'ls' }, 'output')).toBe(true); expect(detector.loopDetected).toBe(true); expect(detector.loopReason).toContain('Identical tool call repeated 3 times'); expect(detector.loopReason).toContain('bash'); }); it('should not trigger for varied tool calls', () => { const detector = new ToolCallLoopDetector(3, 3); detector.recordToolCall('bash', { command: 'ls' }, 'output1'); detector.recordToolCall('bash', { command: 'pwd' }, 'output2'); detector.recordToolCall('bash', { command: 'ls' }, 'output3'); expect(detector.loopDetected).toBe(false); }); it('should reset identical counter when a different call appears', () => { const detector = new ToolCallLoopDetector(3, 3); detector.recordToolCall('bash', { command: 'ls' }, 'ok'); detector.recordToolCall('bash', { command: 'ls' }, 'ok'); // Different call breaks the streak detector.recordToolCall('bash', { command: 'pwd' }, 'ok'); detector.recordToolCall('bash', { command: 'ls' }, 'ok'); detector.recordToolCall('bash', { command: 'ls' }, 'ok'); expect(detector.loopDetected).toBe(false); }); it('should detect consecutive error responses from the same tool', () => { const detector = new ToolCallLoopDetector(5, 3); detector.recordToolCall('web_fetch', { url: '/a' }, '404 not found'); detector.recordToolCall('web_fetch', { url: '/b' }, '404 not found'); expect(detector.recordToolCall('web_fetch', { url: '/c' }, '404 not found')).toBe(true); expect(detector.loopDetected).toBe(true); expect(detector.loopReason).toContain('returned errors 3 consecutive times'); }); it('should reset error counter when a different tool errors', () => { const detector = new ToolCallLoopDetector(5, 3); detector.recordToolCall('web_fetch', { url: '/a' }, '404 not found'); detector.recordToolCall('web_fetch', { url: '/b' }, '404 not found'); // Different tool resets the error counter detector.recordToolCall('bash', { command: 'x' }, 'error: command not found'); detector.recordToolCall('web_fetch', { url: '/c' }, '404 not found'); expect(detector.loopDetected).toBe(false); }); it('should reset error counter on successful result', () => { const detector = new ToolCallLoopDetector(5, 3); detector.recordToolCall('web_fetch', { url: '/a' }, '404 not found'); detector.recordToolCall('web_fetch', { url: '/b' }, '404 not found'); // Successful result resets error counter detector.recordToolCall('web_fetch', { url: '/c' }, 'OK'); detector.recordToolCall('web_fetch', { url: '/d' }, '404 not found'); expect(detector.loopDetected).toBe(false); }); it('should detect various error patterns in results', () => { const detector = new ToolCallLoopDetector(10, 2); detector.recordToolCall('bash', { command: 'x' }, 'error: connection refused'); expect(detector.recordToolCall('bash', { command: 'y' }, 'failed to connect: timeout')).toBe(true); expect(detector.loopReason).toContain('returned errors'); }); it('should stay detected once triggered', () => { const detector = new ToolCallLoopDetector(2, 5); detector.recordToolCall('bash', { command: 'ls' }, 'ok'); detector.recordToolCall('bash', { command: 'ls' }, 'ok'); expect(detector.loopDetected).toBe(true); // Further calls should still report detected expect(detector.recordToolCall('bash', { command: 'pwd' }, 'ok')).toBe(true); }); it('should handle null/undefined results without error detection', () => { const detector = new ToolCallLoopDetector(5, 2); detector.recordToolCall('tool', {}, null); detector.recordToolCall('tool', {}, undefined); expect(detector.loopDetected).toBe(false); }); it('should use custom thresholds', () => { const detector = new ToolCallLoopDetector(5, 5); // 4 identical calls should NOT trigger with threshold 5 for (let i = 0; i < 4; i++) { detector.recordToolCall('bash', { command: 'ls' }, 'ok'); } expect(detector.loopDetected).toBe(false); // 5th should trigger detector.recordToolCall('bash', { command: 'ls' }, 'ok'); expect(detector.loopDetected).toBe(true); }); }); // --------------------------------------------------------------------------- // P1: Eval Mode — stripDelegationInstructions // --------------------------------------------------------------------------- describe('AgentRunnerService.stripDelegationInstructions (P1)', () => { it('should remove "delegate 80% of execution tasks" instruction', () => { const prompt = 'You are a TL. delegate 80% of execution tasks to workers. Always verify.'; const result = AgentRunnerService.stripDelegationInstructions(prompt); expect(result).not.toContain('delegate 80% of execution tasks'); expect(result).toContain('Always verify'); }); it('should remove DELEGATION-FIRST PROTOCOL sections', () => { const prompt = [ '## Some section', 'DELEGATION-FIRST PROTOCOL: Your core loop:', '1. Analyze', '2. Decompose', '3. Delegate', '', '## Next section', 'Important stuff', ].join('\n'); const result = AgentRunnerService.stripDelegationInstructions(prompt); expect(result).not.toContain('DELEGATION-FIRST PROTOCOL'); expect(result).toContain('Important stuff'); }); it('should remove "Target: delegate 70–80% of execution tasks"', () => { const prompt = 'Be efficient. Target: delegate 70–80% of execution tasks. Also code.'; const result = AgentRunnerService.stripDelegationInstructions(prompt); expect(result).not.toContain('Target: delegate 70–80%'); expect(result).toContain('Also code'); }); it('should inject eval mode override instructions', () => { const prompt = 'You are a developer.'; const result = AgentRunnerService.stripDelegationInstructions(prompt); expect(result).toContain('## Eval Mode Active'); expect(result).toContain('Implement directly'); expect(result).toContain('Create all output files'); expect(result).toContain('Self-check before stopping'); }); it('should handle empty prompt', () => { const result = AgentRunnerService.stripDelegationInstructions(''); expect(result).toContain('## Eval Mode Active'); }); it('should clean up consecutive blank lines', () => { const prompt = 'Line 1\n\n\n\n\n\nLine 2'; const result = AgentRunnerService.stripDelegationInstructions(prompt); // Should not have more than 3 consecutive newlines expect(result).not.toMatch(/\n{4,}/); }); it('should apply delegation stripping when evalMode=true in constructor', () => { const config: CrewlyAgentConfig = { model: { provider: 'anthropic', modelId: 'claude-sonnet-4-20250514' }, maxSteps: 10, sessionName: 'eval-test', apiBaseUrl: 'http://localhost:8787', systemPrompt: 'You are a TL. delegate 80% of execution tasks. Be thorough.', maxHistoryMessages: 20, compactionThreshold: 0.8, evalMode: true, }; const evalRunner = new AgentRunnerService(config); const state = evalRunner.getState(); expect(state.systemPrompt).not.toContain('delegate 80% of execution tasks'); expect(state.systemPrompt).toContain('## Eval Mode Active'); }); it('should NOT strip delegation when evalMode is false/undefined', () => { const config: CrewlyAgentConfig = { model: { provider: 'anthropic', modelId: 'claude-sonnet-4-20250514' }, maxSteps: 10, sessionName: 'normal-test', apiBaseUrl: 'http://localhost:8787', systemPrompt: 'delegate 80% of execution tasks', maxHistoryMessages: 20, compactionThreshold: 0.8, }; const normalRunner = new AgentRunnerService(config); const state = normalRunner.getState(); expect(state.systemPrompt).toContain('delegate 80% of execution tasks'); expect(state.systemPrompt).not.toContain('## Eval Mode Active'); }); }); // --------------------------------------------------------------------------- // P0: Stop Hook — extractExpectedOutputFiles & checkMissingDeliverables // --------------------------------------------------------------------------- describe('AgentRunnerService.extractExpectedOutputFiles (P0)', () => { it('should extract file names from "create X.ts" pattern', () => { const prompt = 'Create health.controller.ts with a GET /health endpoint.'; const files = AgentRunnerService.extractExpectedOutputFiles(prompt); expect(files).toContain('health.controller.ts'); }); it('should extract file names from "write team-health.json" pattern', () => { const prompt = 'Analyze team status and write team-health.json with the results.'; const files = AgentRunnerService.extractExpectedOutputFiles(prompt); expect(files).toContain('team-health.json'); }); it('should extract file names from "produce a file called X" pattern', () => { const prompt = 'Produce a file called report.md with your findings.'; const files = AgentRunnerService.extractExpectedOutputFiles(prompt); expect(files).toContain('report.md'); }); it('should extract file names from backtick-quoted paths', () => { const prompt = 'Implement `user.service.ts` and `user.controller.ts` with the API.'; const files = AgentRunnerService.extractExpectedOutputFiles(prompt); expect(files).toContain('user.service.ts'); expect(files).toContain('user.controller.ts'); }); it('should extract file from "output to X" pattern', () => { const prompt = 'Save output to results.json after processing.'; const files = AgentRunnerService.extractExpectedOutputFiles(prompt); expect(files).toContain('results.json'); }); it('should not extract glob patterns', () => { const prompt = 'Create *.ts files in the directory.'; const files = AgentRunnerService.extractExpectedOutputFiles(prompt); expect(files).toEqual([]); }); it('should return empty array for prompts with no file mentions', () => { const prompt = 'Check the team status and report back.'; const files = AgentRunnerService.extractExpectedOutputFiles(prompt); expect(files).toEqual([]); }); it('should deduplicate file names', () => { const prompt = 'Create health.controller.ts. Implement health.controller.ts with exports.'; const files = AgentRunnerService.extractExpectedOutputFiles(prompt); const healthFiles = files.filter(f => f === 'health.controller.ts'); expect(healthFiles.length).toBe(1); }); }); describe('AgentRunnerService.checkMissingDeliverables (P0)', () => { it('should return empty when all files are written', () => { const expected = ['health.controller.ts', 'health.controller.test.ts']; const toolCalls = [ { toolName: 'write_file', args: { file_path: '/tmp/health.controller.ts' }, result: 'ok' }, { toolName: 'write_file', args: { file_path: '/tmp/health.controller.test.ts' }, result: 'ok' }, ]; const missing = AgentRunnerService.checkMissingDeliverables(expected, toolCalls); expect(missing).toEqual([]); }); it('should return missing files when not written', () => { const expected = ['health.controller.ts', 'health.controller.test.ts']; const toolCalls = [ { toolName: 'write_file', args: { file_path: '/tmp/health.controller.ts' }, result: 'ok' }, ]; const missing = AgentRunnerService.checkMissingDeliverables(expected, toolCalls); expect(missing).toContain('health.controller.test.ts'); expect(missing).not.toContain('health.controller.ts'); }); it('should match by basename when full path differs', () => { const expected = ['report.json']; const toolCalls = [ { toolName: 'write_file', args: { file_path: '/workspace/output/report.json' }, result: 'ok' }, ]; const missing = AgentRunnerService.checkMissingDeliverables(expected, toolCalls); expect(missing).toEqual([]); }); it('should also check edit_file tool calls', () => { const expected = ['config.ts']; const toolCalls = [ { toolName: 'edit_file', args: { file_path: '/src/config.ts' }, result: 'ok' }, ]; const missing = AgentRunnerService.checkMissingDeliverables(expected, toolCalls); expect(missing).toEqual([]); }); it('should return all files when no write tools were used', () => { const expected = ['a.ts', 'b.ts']; const toolCalls = [ { toolName: 'read_file', args: { file_path: '/src/a.ts' }, result: 'content' }, ]; const missing = AgentRunnerService.checkMissingDeliverables(expected, toolCalls); expect(missing).toEqual(['a.ts', 'b.ts']); }); it('should return empty when expectedFiles is empty', () => { const missing = AgentRunnerService.checkMissingDeliverables([], []); expect(missing).toEqual([]); }); it('should handle write_file with path arg (alternative naming)', () => { const expected = ['data.json']; const toolCalls = [ { toolName: 'write_file', args: { path: '/tmp/data.json' }, result: 'ok' }, ]; const missing = AgentRunnerService.checkMissingDeliverables(expected, toolCalls); expect(missing).toEqual([]); }); }); /** * B4 — DeepSeek tool_choice passthrough regression test. * * Spec: /Users/yellowsunhy/Desktop/projects/crewly-projects/crewly/.crewly/specs/2026-05-03-crewly-agent-deepseek-gap-list.md * * Background — B4 was originally listed as a 🔴 BLOCKER ("tool_choice 不确定") * estimated at 2h. Live smoke testing on 2026-05-03 INVALIDATED it: * * ✅ Live test 3/3 runs, toolChoice: { type: 'tool', toolName: 'reply_slack' } * on deepseek-chat V3 = completely deterministic. Each run returned the * correct tool_call within 1.4-1.5s, 0 fallback to text, 0 multi-tool drift. * * runs: [ * { run: 1, ms: 1528, toolName: 'reply_slack', stepCount: 1, err: null }, * { run: 2, ms: 1397, toolName: 'reply_slack', stepCount: 1, err: null }, * { run: 3, ms: 1411, toolName: 'reply_slack', stepCount: 1, err: null }, * ] * all_picked_reply_slack: true * * Decision (TL): no product-code change for B4 — DO NOT add a retry-with-tool-choice * fallback layer. This regression test pins the current "passthrough" behavior: * agent-runner.service.ts must NOT inject a hardcoded `toolChoice` into the * generateText / streamText call. The default ('auto') lets the model decide, * and the live smoke confirms DeepSeek V3 is reliable under that default. * * If a future change adds `toolChoice: 'required'` or similar to either * the streamText or generateText path, these tests fail — forcing a re-evaluation * against the smoke evidence above. */ describe('B4 — DeepSeek tool_choice passthrough regression', () => { let runner: AgentRunnerService; let mockGenerateText: vi.Mock; const baseConfig: CrewlyAgentConfig = { model: { provider: 'deepseek', modelId: 'deepseek-chat', temperature: 0.3, maxTokens: 8192 }, maxSteps: 10, sessionName: 'b4-regression-session', apiBaseUrl: 'http://localhost:8787', systemPrompt: 'You are a deepseek agent.', maxHistoryMessages: 20, compactionThreshold: 0.8, }; beforeEach(async () => { vi.clearAllMocks(); mockGenerateText = vi.fn().mockResolvedValue({ text: 'noop', steps: [{ toolCalls: [], toolResults: [] }], usage: { inputTokens: 10, outputTokens: 5 }, finishReason: 'stop', }); const mockModelManager = { getModel: vi.fn().mockResolvedValue({ provider: 'deepseek', modelId: 'deepseek-chat' }), getAvailableProviders: vi.fn(), clearCache: vi.fn(), // I2 — DeepSeek reasoning_content extraction. Mocked as null so the // result.reasoning is undefined for these passthrough regression tests // (they assert tool_choice wiring, not reasoning capture). consumeDeepseekReasoning: vi.fn().mockResolvedValue(null), } as any; const mockApiClient = { get: vi.fn(), post: vi.fn(), delete: vi.fn(), } as any; runner = new AgentRunnerService(baseConfig, mockModelManager, mockApiClient); runner._generateTextFn = mockGenerateText; await runner.initialize(); }); it('should NOT pass a hardcoded toolChoice to generateText (let SDK default apply)', async () => { await runner.run('say hi'); expect(mockGenerateText).toHaveBeenCalled(); const callArgs = mockGenerateText.mock.calls[0]?.[0] as Record; expect(callArgs).toBeDefined(); // The contract: agent-runner relies on the AI SDK's default toolChoice='auto'. // DeepSeek V3 was validated as deterministic under this default (3/3 smoke runs). // If we ever start passing toolChoice explicitly, this test catches it // and forces a deliberate re-test against deepseek-chat / deepseek-reasoner. expect(callArgs).not.toHaveProperty('toolChoice'); }); it('should NOT pass toolChoice on follow-up generateText calls either', async () => { // Multi-turn conversation — the same passthrough invariant must hold for // every call to the SDK, not just the first one. await runner.run('first turn'); await runner.run('second turn'); expect(mockGenerateText.mock.calls.length).toBeGreaterThanOrEqual(2); for (const [callArgs] of mockGenerateText.mock.calls) { expect(callArgs).not.toHaveProperty('toolChoice'); } }); it('should pass tools dict but leave toolChoice unset (B4 invariant on tools wiring)', async () => { await runner.run('use a tool'); const callArgs = mockGenerateText.mock.calls[0]?.[0] as Record; // tools is wired (the agent has a tool registry), but toolChoice is intentionally absent. expect(callArgs).toHaveProperty('tools'); expect(callArgs).not.toHaveProperty('toolChoice'); }); // ────────────────────────────────────────────────────────────────── // 2026-05-15 — thread isolation per Slack thread / chat thread // Goal: "一个 Slack thread 代表一个 chat thread, 不同 Slack // thread 之间不会串联在一起." // // The runner now keeps a Map // so the LLM context the model sees for thread A never contains // turns from thread B. The conversationKey is the chat-v2 channel // id (e.g. `slack-D0AC7-1777760999-956969`). // ────────────────────────────────────────────────────────────────── describe('thread isolation (per-conversation state)', () => { it('keeps message histories separate across conversation keys', async () => { // Conversation A — two turns. `run(message, conversationId)` // is positional; conversationId is the second arg. await runner.run('hello from A', 'slack-D0AC7-thread-A'); await runner.run('still in A', 'slack-D0AC7-thread-A'); // Conversation B — one turn await runner.run('hello from B', 'slack-D0AC7-thread-B'); // Inspect the messages the LLM saw for the third call (B's // run). It must contain ONLY B's user message, never A's. const lastCallArgs = mockGenerateText.mock.calls[ mockGenerateText.mock.calls.length - 1 ]?.[0] as { messages: Array<{ role: string; content: string }> }; const userMessagesSeenByB = lastCallArgs.messages.filter( (m) => m.role === 'user', ); expect(userMessagesSeenByB.some((m) => m.content === 'hello from B')).toBe( true, ); expect(userMessagesSeenByB.some((m) => m.content === 'hello from A')).toBe( false, ); expect(userMessagesSeenByB.some((m) => m.content === 'still in A')).toBe( false, ); // Two conversation keys are now live. expect(runner.getConversationCount()).toBe(2); }); it('routes runtime-internal messages (no conversationId) to __default__', async () => { await runner.run('a scheduled-check ping with no thread identity'); // First-time creation under the default key — count is 1. expect(runner.getConversationCount()).toBe(1); await runner.run('another no-id ping'); // Same default bucket — count stays 1. expect(runner.getConversationCount()).toBe(1); }); it('reuses an existing state when the same conversationId comes back', async () => { await runner.run('first', 'web-conv-x'); await runner.run('second', 'web-conv-x'); // The second run's messages array (seen by the LLM) carries // the first turn — proves we're not creating fresh state per // run for a returning conversation. const secondCall = mockGenerateText.mock.calls[1]?.[0] as { messages: Array<{ role: string; content: string }>; }; const userMsgs = secondCall.messages.filter((m) => m.role === 'user'); expect(userMsgs.map((m) => m.content)).toEqual(['first', 'second']); expect(runner.getConversationCount()).toBe(1); }); }); }); /** * Wall-clock run budget — the 假死 (silent-catatonia) guard. * * Regression cover for the production incident where a stalled model * connection hung `await streamResult` forever. `MESSAGE_TIMEOUT_MS` and * `MODEL_TIMEOUT_MS` existed as documented constants but nothing ever read * them, so the orchestrator went silent for 12 days without a single log * line. These tests assert the budget is actually ENFORCED, not merely * declared. */ describe('AgentRunnerService run wall-clock budget', () => { let runner: AgentRunnerService; let mockGenerateText: vi.Mock; let mockModelManager: any; let mockApiClient: any; const baseConfig: CrewlyAgentConfig = { model: { provider: 'anthropic', modelId: 'claude-sonnet-4-20250514', temperature: 0.3, maxTokens: 8192 }, maxSteps: 10, sessionName: 'budget-session', apiBaseUrl: 'http://localhost:8787', systemPrompt: 'You are a test agent.', maxHistoryMessages: 20, compactionThreshold: 0.8, }; beforeEach(async () => { vi.clearAllMocks(); vi.useFakeTimers(); mockGenerateText = vi.fn(); mockModelManager = { getModel: vi.fn().mockResolvedValue({ provider: 'mock', modelId: 'test-model' }), getAvailableProviders: vi.fn(), clearCache: vi.fn(), consumeDeepseekReasoning: vi.fn().mockResolvedValue(null), }; mockApiClient = { get: vi.fn(), post: vi.fn(), delete: vi.fn() }; runner = new AgentRunnerService(baseConfig, mockModelManager, mockApiClient); runner._generateTextFn = mockGenerateText; await runner.initialize(); }); afterEach(() => { vi.useRealTimers(); }); it('rejects with AgentRunTimeoutError when the model never responds', async () => { // A model call that never settles — exactly the stalled-socket shape. mockGenerateText.mockImplementation(() => new Promise(() => {})); const runPromise = runner.run('will stall'); const assertion = expect(runPromise).rejects.toBeInstanceOf(AgentRunTimeoutError); await vi.advanceTimersByTimeAsync(CREWLY_AGENT_DEFAULTS.MESSAGE_TIMEOUT_MS + 1); await assertion; }); it('does not wedge the queue — later messages still process after a timeout', async () => { // THE regression: a hung run left `processing` true forever, so every // subsequent message queued behind it and the agent went catatonic. mockGenerateText.mockImplementationOnce(() => new Promise(() => {})); const stalled = runner.run('will stall'); const stalledAssertion = expect(stalled).rejects.toBeInstanceOf(AgentRunTimeoutError); await vi.advanceTimersByTimeAsync(CREWLY_AGENT_DEFAULTS.MESSAGE_TIMEOUT_MS + 1); await stalledAssertion; expect(runner.isProcessing()).toBe(false); mockGenerateText.mockResolvedValueOnce({ text: 'recovered', toolCalls: [], steps: [], usage: { inputTokens: 1, outputTokens: 1 }, finishReason: 'stop', }); await expect(runner.run('after the stall')).resolves.toMatchObject({ text: 'recovered' }); }); it('does not retry a budget expiry (it spans the retries already)', async () => { mockGenerateText.mockImplementation(() => new Promise(() => {})); const runPromise = runner.run('will stall'); const assertion = expect(runPromise).rejects.toBeInstanceOf(AgentRunTimeoutError); await vi.advanceTimersByTimeAsync(CREWLY_AGENT_DEFAULTS.MESSAGE_TIMEOUT_MS + 1); await assertion; // One attempt only — a retried timeout would multiply the stall. expect(mockGenerateText).toHaveBeenCalledTimes(1); }); it('honors the per-model budget override for reasoning models', async () => { const reasoningRunner = new AgentRunnerService( { ...baseConfig, model: { ...baseConfig.model, modelId: 'deepseek-reasoner' } }, mockModelManager, mockApiClient, ); reasoningRunner._generateTextFn = mockGenerateText; await reasoningRunner.initialize(); mockGenerateText.mockImplementation(() => new Promise(() => {})); const runPromise = reasoningRunner.run('slow chain-of-thought'); const settled = vi.fn(); runPromise.then(settled, settled); // Still alive at the default budget — the override must win. await vi.advanceTimersByTimeAsync(CREWLY_AGENT_DEFAULTS.MESSAGE_TIMEOUT_MS + 1); expect(settled).not.toHaveBeenCalled(); const assertion = expect(runPromise).rejects.toBeInstanceOf(AgentRunTimeoutError); await vi.advanceTimersByTimeAsync( CREWLY_AGENT_DEFAULTS.MODEL_TIMEOUT_MS['deepseek-reasoner'], ); await assertion; }); it('leaves a run that completes in time untouched', async () => { mockGenerateText.mockResolvedValueOnce({ text: 'fast enough', toolCalls: [], steps: [], usage: { inputTokens: 1, outputTokens: 1 }, finishReason: 'stop', }); await expect(runner.run('quick')).resolves.toMatchObject({ text: 'fast enough' }); }); });