diff --git a/src/agents/registry.ts b/src/agents/registry.ts index 3e72dc75..60187375 100644 --- a/src/agents/registry.ts +++ b/src/agents/registry.ts @@ -22,6 +22,10 @@ registerBackend(new ClaudeCodeBackend()); * 2. Project-level default backend * 3. Cascade-level default backend * 4. Fallback: 'llmist' + * + * All backends — including llmist — go through the shared adapter + * (executeWithBackend), which handles repo setup, lifecycle, progress + * monitoring, run tracking, and log finalization in one place. */ export async function runAgent( agentType: string, @@ -48,34 +52,10 @@ export async function runAgent( logger.info('Running agent via backend', { agentType, backend: backendName }); - // For the llmist backend, delegate directly (it wraps existing executors) - // For other backends, use the shared adapter which handles lifecycle - if (backendName === 'llmist') { - // The llmist backend needs the full AgentBackendInput, but since it - // delegates to the existing executors which handle their own lifecycle, - // we pass a minimal input and let it reconstruct what it needs. - return backend.execute({ - agentType, - project: input.project, - config: input.config, - repoDir: '', - systemPrompt: '', - taskPrompt: '', - cliToolsDir: '', - availableTools: [], - contextInjections: [], - maxIterations: 0, - model: '', - progressReporter: { - onIteration: async () => {}, - onToolCall: () => {}, - onText: () => {}, - }, - logWriter: () => {}, - agentInput: input, - }); - } - + // All backends (including llmist) use the shared adapter which handles: + // - Repo setup, CWD change/restore, env var loading + // - Run record creation, log finalization + // - Progress monitor, watchdog return executeWithBackend(backend, agentType, input); } diff --git a/src/backends/adapter.ts b/src/backends/adapter.ts index d7e13435..b35d50c5 100644 --- a/src/backends/adapter.ts +++ b/src/backends/adapter.ts @@ -238,6 +238,7 @@ export async function executeWithBackend( onText: () => {}, }, runId, + llmistLogPath: fileLogger.llmistLogPath, }; monitor?.start(); diff --git a/src/backends/llmist/index.ts b/src/backends/llmist/index.ts index 8d3035e0..2bfa5cdb 100644 --- a/src/backends/llmist/index.ts +++ b/src/backends/llmist/index.ts @@ -1,37 +1,36 @@ -import { executeAgent } from '../../agents/base.js'; -import { executeRespondToCIAgent } from '../../agents/respond-to-ci.js'; -import { executeRespondToPRCommentAgent } from '../../agents/respond-to-pr-comment.js'; -import { executeRespondToReviewAgent } from '../../agents/respond-to-review.js'; -import { executeReviewAgent } from '../../agents/review.js'; -import type { AgentInput, AgentResult, CascadeConfig, ProjectConfig } from '../../types/index.js'; -import type { AgentBackend, AgentBackendInput, AgentBackendResult } from '../types.js'; +import os from 'node:os'; -/** - * Mapping from agent type to its specialized executor function. - * Agents not listed here fall through to the base `executeAgent()`. - */ -const specializedExecutors: Record< - string, - (input: AgentInput & { project: ProjectConfig; config: CascadeConfig }) => Promise -> = { - 'respond-to-review': (input) => - executeRespondToReviewAgent(input as Parameters[0]), - 'respond-to-ci': (input) => - executeRespondToCIAgent(input as Parameters[0]), - 'respond-to-pr-comment': (input) => - executeRespondToPRCommentAgent(input as Parameters[0]), - review: (input) => executeReviewAgent(input as Parameters[0]), -}; +import { LLMist, type ModelSpec, createLogger } from 'llmist'; + +import { type BuilderType, createConfiguredBuilder } from '../../agents/shared/builderFactory.js'; +import { injectSyntheticCall } from '../../agents/shared/syntheticCalls.js'; +import { runAgentLoop } from '../../agents/utils/agentLoop.js'; +import type { AccumulatedLlmCall } from '../../agents/utils/hooks.js'; +import { getLogLevel } from '../../agents/utils/index.js'; +import { createAgentLogger } from '../../agents/utils/logging.js'; +import { createTrackingContext } from '../../agents/utils/tracking.js'; +import { CUSTOM_MODELS } from '../../config/customModels.js'; +import { createLLMCallLogger } from '../../utils/llmLogging.js'; +import { extractPRUrl } from '../../utils/prUrl.js'; +import { getAgentProfile } from '../agent-profiles.js'; +import type { AgentBackend, AgentBackendInput, AgentBackendResult } from '../types.js'; /** - * llmist backend - wraps the existing llmist-based agent execution. + * llmist backend — executes agents using the llmist SDK. * - * This is the "Option A" approach: the llmist backend delegates to the existing - * executeAgent()/executeGitHubAgent() functions as-is. The shared adapter from - * adapter.ts handles lifecycle only for non-llmist backends. + * Receives a fully pre-resolved AgentBackendInput from the shared adapter + * (adapter.ts → executeWithBackend → buildBackendInput), which provides: + * - systemPrompt, taskPrompt, model, maxIterations + * - contextInjections (pre-fetched PR/work-item/directory data) + * - repoDir (already set up by the outer executeAgentPipeline) + * - logWriter (shared file logger from the outer pipeline) * - * In a follow-up, the llmist code can be refactored to also use the shared adapter, - * but that's not needed for this PR. + * Llmist-specific features preserved: + * - AccumulatedLlmCall metrics (via createObserverHooks inside createConfiguredBuilder) + * - Loop detection and hard-stop (via createObserverHooks + runAgentLoop) + * - Iteration hints / trailing messages (via createConfiguredBuilder) + * - Context compaction (via createConfiguredBuilder) + * - Synthetic gadget call injection from ContextInjection[] */ export class LlmistBackend implements AgentBackend { readonly name = 'llmist'; @@ -41,25 +40,129 @@ export class LlmistBackend implements AgentBackend { } async execute(input: AgentBackendInput): Promise { - const fullInput: AgentInput & { project: ProjectConfig; config: CascadeConfig } = { - ...input.agentInput, - project: input.project, - config: input.config, - }; + const { + agentType, + systemPrompt, + taskPrompt, + model, + maxIterations, + contextInjections, + budgetUsd, + repoDir, + logWriter, + runId, + agentInput, + llmistLogPath, + progressReporter, + } = input; + + const profile = getAgentProfile(agentType); + + // Create LLMist client with custom model definitions + const client = new LLMist({ customModels: CUSTOM_MODELS as ModelSpec[] }); + + // Create per-execution llmist logger and tracking state + const llmistLogger = createLogger({ minLevel: getLogLevel() }); + const trackingContext = createTrackingContext(); + const llmCallAccumulator: AccumulatedLlmCall[] = []; - const executor = specializedExecutors[input.agentType]; - const result = executor - ? await executor(fullInput) - : await executeAgent(input.agentType, fullInput); + // Create a LLM call logger for raw request/response file logging. + // Lives in the system tmp dir, independent from the outer fileLogger + // (which handles cascade.log / llmist.log). + const llmCallLogger = createLLMCallLogger(os.tmpdir(), `llmist-${agentType}-${Date.now()}`); + + // Point llmist SDK at the workspace directory llmist log path (provided by the outer + // pipeline's fileLogger). This ensures the structured llmist log is included in run + // records and log bundles (read from fileLogger.llmistLogPath during finalization). + if (llmistLogPath) { + process.env.LLMIST_LOG_FILE = llmistLogPath; + process.env.LLMIST_LOG_TEE = 'true'; + } + + // Get gadget instances from the agent profile (single source of truth for tool sets) + const gadgets = profile.getLlmistGadgets(agentType); + + // Build the configured agent builder with all llmist-specific features: + // rate limiting, retry, compaction, iteration hints, observer hooks + let builder: BuilderType = createConfiguredBuilder({ + client, + agentType, + model, + systemPrompt, + maxIterations, + llmistLogger, + trackingContext, + logWriter, + llmCallLogger, + repoDir, + gadgets: gadgets as Parameters[0]['gadgets'], + remainingBudgetUsd: budgetUsd, + llmCallAccumulator, + runId, + baseBranch: input.project.baseBranch, + projectId: input.project.id, + cardId: agentInput.cardId, + // Pass the progress monitor from the adapter so createObserverHooks can call + // onIteration/onToolCall/onText — enables progress updates to Trello/GitHub + progressMonitor: progressReporter as Parameters< + typeof createConfiguredBuilder + >[0]['progressMonitor'], + // Implementation agent uses sequential execution to ensure file operations + // are properly ordered (e.g., FileSearchAndReplace then ReadFile on same file) + postConfigure: + agentType === 'implementation' ? (b) => b.withGadgetExecutionMode('sequential') : undefined, + }); + + // Convert ContextInjection[] from the unified adapter into synthetic gadget calls. + // This is the llmist-native way to inject pre-fetched context: each injection + // appears in the conversation as if the agent called the gadget itself. + for (let idx = 0; idx < contextInjections.length; idx++) { + const injection = contextInjections[idx]; + const invocationId = `gc_${injection.toolName.toLowerCase()}_${idx}`; + builder = injectSyntheticCall( + builder, + trackingContext, + injection.toolName, + injection.params, + injection.result, + invocationId, + ); + } + + // Create agent logger that writes to the shared logWriter from the outer pipeline + const log = createAgentLogger({ write: logWriter } as Parameters[0]); + + log.info('Starting llmist agent', { + model, + maxIterations, + promptLength: taskPrompt.length, + contextInjections: contextInjections.length, + runId, + }); + + // Run the agent event loop (includes loop detection, session notices, etc.) + const agent = builder.ask(taskPrompt); + const result = await runAgentLoop( + agent, + log, + trackingContext, + agentInput.interactive === true, + agentInput.autoAccept === true, + ); + + log.info('Agent completed', { + iterations: result.iterations, + gadgetCalls: result.gadgetCalls, + cost: result.cost, + loopTerminated: result.loopTerminated ?? false, + }); return { - success: result.success, + success: !result.loopTerminated, output: result.output, - prUrl: result.prUrl, - error: result.error, + prUrl: extractPRUrl(result.output) ?? undefined, + error: result.loopTerminated ? 'Agent terminated due to persistent loop' : undefined, cost: result.cost, - logBuffer: result.logBuffer, - runId: result.runId, }; } } diff --git a/src/backends/types.ts b/src/backends/types.ts index c2e1f240..cd5f2934 100644 --- a/src/backends/types.ts +++ b/src/backends/types.ts @@ -69,6 +69,8 @@ export interface AgentBackendInput { enableStopHooks?: boolean; /** Whether to block git push in hooks (defaults to true) */ blockGitPush?: boolean; + /** Path where the llmist SDK should write its structured log (workspace dir, not temp) */ + llmistLogPath?: string; } export type LogWriter = (level: string, message: string, context?: Record) => void; diff --git a/tests/unit/agents/registry.test.ts b/tests/unit/agents/registry.test.ts index 69414e41..bf34f19f 100644 --- a/tests/unit/agents/registry.test.ts +++ b/tests/unit/agents/registry.test.ts @@ -87,6 +87,7 @@ describe('runAgent', () => { const backend = makeMockBackend('llmist'); mockResolveBackendName.mockReturnValue('llmist'); mockGetBackend.mockReturnValue(backend); + mockExecuteWithBackend.mockResolvedValue({ success: true, output: 'Done' }); await runAgent('implementation', makeInput()); @@ -120,26 +121,25 @@ describe('runAgent', () => { expect(result.error).toContain('does not support agent type "implementation"'); }); - it('for llmist: calls backend.execute with minimal input + agentInput', async () => { + it('for llmist: calls executeWithBackend (unified adapter path)', async () => { const backend = makeMockBackend('llmist'); mockResolveBackendName.mockReturnValue('llmist'); mockGetBackend.mockReturnValue(backend); + mockExecuteWithBackend.mockResolvedValue({ + success: true, + output: 'Done via adapter', + }); - await runAgent('implementation', makeInput()); + const input = makeInput(); + const result = await runAgent('implementation', input); - expect(backend.execute).toHaveBeenCalledWith( - expect.objectContaining({ - agentType: 'implementation', - repoDir: '', - systemPrompt: '', - availableTools: [], - contextInjections: [], - }), - ); - expect(mockExecuteWithBackend).not.toHaveBeenCalled(); + // llmist now goes through executeWithBackend like all other backends + expect(mockExecuteWithBackend).toHaveBeenCalledWith(backend, 'implementation', input); + expect(backend.execute).not.toHaveBeenCalled(); + expect(result.output).toBe('Done via adapter'); }); - it('for non-llmist: calls executeWithBackend with full lifecycle', async () => { + it('for claude-code: calls executeWithBackend with full lifecycle', async () => { const backend = makeMockBackend('claude-code'); mockResolveBackendName.mockReturnValue('claude-code'); mockGetBackend.mockReturnValue(backend); diff --git a/tests/unit/backends/llmist.test.ts b/tests/unit/backends/llmist.test.ts index dc0fede7..d5d35c6f 100644 --- a/tests/unit/backends/llmist.test.ts +++ b/tests/unit/backends/llmist.test.ts @@ -1,67 +1,115 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; -vi.mock('../../../src/agents/base.js', () => ({ - executeAgent: vi.fn(), +// Mock all llmist SDK and internal dependencies +vi.mock('llmist', () => ({ + LLMist: vi.fn().mockImplementation(() => ({})), + createLogger: vi.fn(() => ({})), + type: undefined, })); -vi.mock('../../../src/agents/respond-to-review.js', () => ({ - executeRespondToReviewAgent: vi.fn(), +vi.mock('../../../src/backends/agent-profiles.js', () => ({ + getAgentProfile: vi.fn(() => ({ + getLlmistGadgets: vi.fn(() => []), + })), })); -vi.mock('../../../src/agents/respond-to-ci.js', () => ({ - executeRespondToCIAgent: vi.fn(), +vi.mock('../../../src/agents/shared/builderFactory.js', () => ({ + createConfiguredBuilder: vi.fn(() => ({ + ask: vi.fn(() => ({ + run: vi.fn(async function* () {}), + getTree: vi.fn(() => null), + injectUserMessage: vi.fn(), + })), + })), })); -vi.mock('../../../src/agents/respond-to-pr-comment.js', () => ({ - executeRespondToPRCommentAgent: vi.fn(), +vi.mock('../../../src/agents/shared/syntheticCalls.js', () => ({ + injectSyntheticCall: vi.fn((builder) => builder), })); -vi.mock('../../../src/agents/review.js', () => ({ - executeReviewAgent: vi.fn(), +vi.mock('../../../src/agents/utils/agentLoop.js', () => ({ + runAgentLoop: vi.fn().mockResolvedValue({ + output: 'Agent completed', + iterations: 3, + gadgetCalls: 5, + cost: 0.05, + loopTerminated: false, + }), })); -import { executeAgent } from '../../../src/agents/base.js'; -import { executeRespondToCIAgent } from '../../../src/agents/respond-to-ci.js'; -import { executeRespondToPRCommentAgent } from '../../../src/agents/respond-to-pr-comment.js'; -import { executeRespondToReviewAgent } from '../../../src/agents/respond-to-review.js'; -import { executeReviewAgent } from '../../../src/agents/review.js'; +vi.mock('../../../src/agents/utils/index.js', () => ({ + getLogLevel: vi.fn(() => 'info'), +})); + +vi.mock('../../../src/agents/utils/logging.js', () => ({ + createAgentLogger: vi.fn(() => ({ + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + })), +})); + +vi.mock('../../../src/agents/utils/tracking.js', () => ({ + createTrackingContext: vi.fn(() => ({ + metrics: { llmIterations: 0, gadgetCalls: 0 }, + loopDetection: { repeatCount: 0, nameOnlyRepeatCount: 0 }, + syntheticInvocationIds: new Set(), + })), +})); + +vi.mock('../../../src/config/customModels.js', () => ({ + CUSTOM_MODELS: [], +})); + +vi.mock('../../../src/utils/llmLogging.js', () => ({ + createLLMCallLogger: vi.fn(() => ({ + logDir: '/tmp', + logRequest: vi.fn(), + logResponse: vi.fn(), + getLogFiles: vi.fn(() => []), + })), +})); + +vi.mock('../../../src/utils/prUrl.js', () => ({ + extractPRUrl: vi.fn((output: string) => { + const m = output.match(/https:\/\/github\.com\/[^/]+\/[^/]+\/pull\/\d+/); + return m ? m[0] : undefined; + }), +})); + +import { runAgentLoop } from '../../../src/agents/utils/agentLoop.js'; import { LlmistBackend } from '../../../src/backends/llmist/index.js'; import type { AgentBackendInput } from '../../../src/backends/types.js'; -const mockExecuteAgent = vi.mocked(executeAgent); -const mockRespondToReview = vi.mocked(executeRespondToReviewAgent); -const mockRespondToCI = vi.mocked(executeRespondToCIAgent); -const mockRespondToPRComment = vi.mocked(executeRespondToPRCommentAgent); -const mockReviewAgent = vi.mocked(executeReviewAgent); +const mockRunAgentLoop = vi.mocked(runAgentLoop); -function makeInput(agentType: string): AgentBackendInput { +function makeInput(agentType = 'implementation'): AgentBackendInput { return { agentType, - project: { id: 'test', name: 'Test', repo: 'o/r' } as AgentBackendInput['project'], + project: { + id: 'p1', + name: 'P', + repo: 'o/r', + baseBranch: 'main', + } as AgentBackendInput['project'], config: { defaults: {} } as AgentBackendInput['config'], - repoDir: '', - systemPrompt: '', - taskPrompt: '', - cliToolsDir: '', + repoDir: '/repo', + systemPrompt: 'You are an agent.', + taskPrompt: 'Implement feature X.', + cliToolsDir: '/cli', availableTools: [], contextInjections: [], - maxIterations: 0, - model: '', + maxIterations: 10, + model: 'claude-sonnet-4', progressReporter: { onIteration: async () => {}, onToolCall: () => {}, onText: () => {} }, logWriter: () => {}, agentInput: { cardId: 'c1' } as AgentBackendInput['agentInput'], + runId: 'run-123', + llmistLogPath: '/workspace/llmist-implementation-12345.log', }; } -const agentResult = { - success: true, - output: 'Done', - prUrl: 'https://github.com/o/r/pull/1', - error: undefined, - cost: 0.05, - logBuffer: Buffer.from('log'), -}; - beforeEach(() => { vi.clearAllMocks(); }); @@ -80,77 +128,209 @@ describe('LlmistBackend', () => { }); }); -describe('execute', () => { - it('delegates to executeAgent for generic types', async () => { - mockExecuteAgent.mockResolvedValue(agentResult); +describe('LlmistBackend.execute', () => { + it('returns success when runAgentLoop completes normally', async () => { + mockRunAgentLoop.mockResolvedValue({ + output: 'Done', + iterations: 5, + gadgetCalls: 8, + cost: 0.1, + loopTerminated: false, + }); const backend = new LlmistBackend(); - const result = await backend.execute(makeInput('implementation')); + const result = await backend.execute(makeInput()); - expect(mockExecuteAgent).toHaveBeenCalledWith('implementation', expect.any(Object)); expect(result.success).toBe(true); expect(result.output).toBe('Done'); + expect(result.cost).toBe(0.1); + expect(result.error).toBeUndefined(); }); - it('delegates to executeAgent for briefing', async () => { - mockExecuteAgent.mockResolvedValue(agentResult); + it('returns failure when loop is terminated due to persistent loop', async () => { + mockRunAgentLoop.mockResolvedValue({ + output: 'partial output', + iterations: 10, + gadgetCalls: 20, + cost: 0.3, + loopTerminated: true, + }); const backend = new LlmistBackend(); - await backend.execute(makeInput('briefing')); + const result = await backend.execute(makeInput()); - expect(mockExecuteAgent).toHaveBeenCalledWith('briefing', expect.any(Object)); + expect(result.success).toBe(false); + expect(result.error).toContain('loop'); }); - it('delegates to specialized executor for respond-to-review', async () => { - mockRespondToReview.mockResolvedValue(agentResult); + it('extracts PR URL from output when present', async () => { + mockRunAgentLoop.mockResolvedValue({ + output: 'Created PR: https://github.com/owner/repo/pull/42', + iterations: 5, + gadgetCalls: 8, + cost: 0.1, + loopTerminated: false, + }); const backend = new LlmistBackend(); - await backend.execute(makeInput('respond-to-review')); + const result = await backend.execute(makeInput()); - expect(mockRespondToReview).toHaveBeenCalled(); - expect(mockExecuteAgent).not.toHaveBeenCalled(); + expect(result.prUrl).toBe('https://github.com/owner/repo/pull/42'); }); - it('delegates to specialized executor for respond-to-ci', async () => { - mockRespondToCI.mockResolvedValue(agentResult); + it('injects context injections as synthetic calls', async () => { + mockRunAgentLoop.mockResolvedValue({ + output: 'Done', + iterations: 5, + gadgetCalls: 8, + cost: 0.1, + loopTerminated: false, + }); - const backend = new LlmistBackend(); - await backend.execute(makeInput('respond-to-ci')); + const { injectSyntheticCall } = await import('../../../src/agents/shared/syntheticCalls.js'); + const mockInjectSyntheticCall = vi.mocked(injectSyntheticCall); + + const input = makeInput(); + input.contextInjections = [ + { + toolName: 'ReadWorkItem', + params: { workItemId: 'c1' }, + result: 'card content', + description: 'Work item', + }, + { + toolName: 'ListDirectory', + params: { directoryPath: '.' }, + result: 'dir listing', + description: 'Dir', + }, + ]; - expect(mockRespondToCI).toHaveBeenCalled(); + const backend = new LlmistBackend(); + await backend.execute(input); + + expect(mockInjectSyntheticCall).toHaveBeenCalledTimes(2); + expect(mockInjectSyntheticCall).toHaveBeenNthCalledWith( + 1, + expect.anything(), + expect.anything(), + 'ReadWorkItem', + { workItemId: 'c1' }, + 'card content', + 'gc_readworkitem_0', + ); + expect(mockInjectSyntheticCall).toHaveBeenNthCalledWith( + 2, + expect.anything(), + expect.anything(), + 'ListDirectory', + { directoryPath: '.' }, + 'dir listing', + 'gc_listdirectory_1', + ); }); - it('delegates to specialized executor for respond-to-pr-comment', async () => { - mockRespondToPRComment.mockResolvedValue(agentResult); + it('passes model and maxIterations from input to createConfiguredBuilder', async () => { + mockRunAgentLoop.mockResolvedValue({ + output: 'Done', + iterations: 5, + gadgetCalls: 8, + cost: 0.1, + loopTerminated: false, + }); - const backend = new LlmistBackend(); - await backend.execute(makeInput('respond-to-pr-comment')); + const { createConfiguredBuilder } = await import( + '../../../src/agents/shared/builderFactory.js' + ); + const mockCreateConfiguredBuilder = vi.mocked(createConfiguredBuilder); + + const input = makeInput(); + input.model = 'claude-3-5-sonnet-20241022'; + input.maxIterations = 25; - expect(mockRespondToPRComment).toHaveBeenCalled(); + const backend = new LlmistBackend(); + await backend.execute(input); + + expect(mockCreateConfiguredBuilder).toHaveBeenCalledWith( + expect.objectContaining({ + model: 'claude-3-5-sonnet-20241022', + maxIterations: 25, + }), + ); }); - it('delegates to specialized executor for review', async () => { - mockReviewAgent.mockResolvedValue(agentResult); + it('gets gadgets from the agent profile', async () => { + mockRunAgentLoop.mockResolvedValue({ + output: 'Done', + iterations: 1, + gadgetCalls: 0, + cost: 0.01, + loopTerminated: false, + }); + + const { getAgentProfile } = await import('../../../src/backends/agent-profiles.js'); + const mockGetAgentProfile = vi.mocked(getAgentProfile); + const mockGetLlmistGadgets = vi.fn().mockReturnValue([]); + mockGetAgentProfile.mockReturnValue({ + getLlmistGadgets: mockGetLlmistGadgets, + } as ReturnType); const backend = new LlmistBackend(); await backend.execute(makeInput('review')); - expect(mockReviewAgent).toHaveBeenCalled(); + expect(mockGetAgentProfile).toHaveBeenCalledWith('review'); + expect(mockGetLlmistGadgets).toHaveBeenCalledWith('review'); }); - it('maps AgentResult fields to AgentBackendResult', async () => { - mockExecuteAgent.mockResolvedValue(agentResult); + it('sets LLMIST_LOG_FILE to the provided llmistLogPath', async () => { + mockRunAgentLoop.mockResolvedValue({ + output: 'Done', + iterations: 1, + gadgetCalls: 0, + cost: 0.01, + loopTerminated: false, + }); + + const input = makeInput(); + input.llmistLogPath = '/workspace/test-llmist.log'; const backend = new LlmistBackend(); - const result = await backend.execute(makeInput('planning')); + await backend.execute(input); - expect(result).toEqual({ - success: true, + expect(process.env.LLMIST_LOG_FILE).toBe('/workspace/test-llmist.log'); + expect(process.env.LLMIST_LOG_TEE).toBe('true'); + }); + + it('passes progressReporter to createConfiguredBuilder as progressMonitor', async () => { + mockRunAgentLoop.mockResolvedValue({ output: 'Done', - prUrl: 'https://github.com/o/r/pull/1', - error: undefined, - cost: 0.05, - logBuffer: Buffer.from('log'), + iterations: 5, + gadgetCalls: 8, + cost: 0.1, + loopTerminated: false, }); + + const { createConfiguredBuilder } = await import( + '../../../src/agents/shared/builderFactory.js' + ); + const mockCreateConfiguredBuilder = vi.mocked(createConfiguredBuilder); + + const mockProgressReporter = { + onIteration: vi.fn().mockResolvedValue(undefined), + onToolCall: vi.fn(), + onText: vi.fn(), + }; + + const input = makeInput(); + input.progressReporter = mockProgressReporter; + + const backend = new LlmistBackend(); + await backend.execute(input); + + expect(mockCreateConfiguredBuilder).toHaveBeenCalledWith( + expect.objectContaining({ + progressMonitor: mockProgressReporter, + }), + ); }); });