From 9c8ece351201a10e11f13ef35d09bef4e70993e5 Mon Sep 17 00:00:00 2001 From: Cascade Bot Date: Sat, 14 Mar 2026 19:44:23 +0000 Subject: [PATCH] feat(backends): add beforeExecute/afterExecute lifecycle hooks to AgentEngine interface --- src/backends/adapter.ts | 17 ++- src/backends/claude-code/index.ts | 157 ++++++++++++------------ src/backends/codex/index.ts | 71 +++++++++-- src/backends/opencode/index.ts | 7 ++ src/backends/types.ts | 12 ++ tests/unit/backends/adapter.test.ts | 104 ++++++++++++++++ tests/unit/backends/claude-code.test.ts | 60 ++++++++- tests/unit/backends/codex.test.ts | 81 ++++++++++++ tests/unit/backends/llmist.test.ts | 10 ++ tests/unit/backends/opencode.test.ts | 13 ++ 10 files changed, 439 insertions(+), 93 deletions(-) diff --git a/src/backends/adapter.ts b/src/backends/adapter.ts index 3f329003..02c4d412 100644 --- a/src/backends/adapter.ts +++ b/src/backends/adapter.ts @@ -558,10 +558,21 @@ export async function executeWithEngine( }; monitor?.start(); - let result: Awaited>; + let result: Awaited> | undefined; try { - result = await engine.execute(executionPlan); - await hydrateNativeToolSidecars(result, prSidecarPath, reviewSidecarPath); + if (engine.beforeExecute) { + await engine.beforeExecute(executionPlan); + } + try { + result = await engine.execute(executionPlan); + } finally { + if (engine.afterExecute) { + // afterExecute always runs; pass result if available (execute() may have thrown). + await engine.afterExecute(executionPlan, result ?? { success: false, output: '' }); + } + } + // biome-ignore lint/style/noNonNullAssertion: result is always defined when execute() did not throw + await hydrateNativeToolSidecars(result!, prSidecarPath, reviewSidecarPath); const completionEvidence = readCompletionEvidence(executionPlan.completionRequirements); postProcessResult(result, agentType, engine, input, identifier, { diff --git a/src/backends/claude-code/index.ts b/src/backends/claude-code/index.ts index 2ff9e22f..0706c031 100644 --- a/src/backends/claude-code/index.ts +++ b/src/backends/claude-code/index.ts @@ -460,6 +460,20 @@ export class ClaudeCodeEngine implements AgentEngine { return resolveClaudeModel(cascadeModel); } + async beforeExecute(plan: AgentExecutionPlan): Promise { + // Ensure onboarding flag exists (required for both API key and subscription auth) + ensureOnboardingFlag(); + // Log repo directory state for debugging + debugRepoDirectory(plan.repoDir); + } + + async afterExecute(plan: AgentExecutionPlan, _result: AgentEngineResult): Promise { + // Clean up offloaded context files after execution + await cleanupContextFiles(plan.repoDir); + // Clean up persisted session directory — workers are ephemeral + await cleanupPersistedSession(plan.repoDir); + } + async execute(input: AgentExecutionPlan): Promise { const startTime = Date.now(); const systemPrompt = buildSystemPrompt(input.systemPrompt, input.availableTools); @@ -488,16 +502,12 @@ export class ClaudeCodeEngine implements AgentEngine { input.cliToolsDir, input.nativeToolShimDir, ); - // Always ensure onboarding flag exists (required for both API key and subscription auth) - ensureOnboardingFlag(); const hooks = buildHooks(input.logWriter, input.repoDir, input.enableStopHooks ?? true, { blockGitPush: input.blockGitPush, }); const sdkTools = resolveNativeTools(input.nativeToolCapabilities); - debugRepoDirectory(input.repoDir); - const maxContinuationTurns = input.completionRequirements?.maxContinuationTurns ?? 0; let continuationTurns = 0; let promptText = taskPrompt; @@ -505,83 +515,74 @@ export class ClaudeCodeEngine implements AgentEngine { let turnCount = 0; let totalCost: number | undefined; - try { - for (;;) { - const stderrChunks: string[] = []; - const stream = query({ - prompt: promptText, - options: { - model, - systemPrompt, - cwd: input.repoDir, - additionalDirectories: [getWorkspaceDir()], - maxBudgetUsd: input.budgetUsd, - permissionMode: 'bypassPermissions', - allowDangerouslySkipPermissions: true, - tools: sdkTools, - allowedTools: sdkTools, - persistSession: true, - hooks, - env, - debug: true, - stderr: (data: string) => { - stderrChunks.push(data); - input.logWriter('INFO', 'Claude Code stderr', { data: data.trim() }); - }, - ...(isContinuation ? { continue: true } : {}), + for (;;) { + const stderrChunks: string[] = []; + const stream = query({ + prompt: promptText, + options: { + model, + systemPrompt, + cwd: input.repoDir, + additionalDirectories: [getWorkspaceDir()], + maxBudgetUsd: input.budgetUsd, + permissionMode: 'bypassPermissions', + allowDangerouslySkipPermissions: true, + tools: sdkTools, + allowedTools: sdkTools, + persistSession: true, + hooks, + env, + debug: true, + stderr: (data: string) => { + stderrChunks.push(data); + input.logWriter('INFO', 'Claude Code stderr', { data: data.trim() }); }, - }); - - const { - assistantMessages, - resultMessage, - turnCount: newTurnCount, - toolCallCount, - } = await consumeStream(stream, input, model, turnCount); - turnCount = newTurnCount; - - const turnResult = buildResult( - assistantMessages, - resultMessage, - stderrChunks, - input, - startTime, - ); - - // Accumulate cost across continuation turns - if (turnResult.cost !== undefined) { - totalCost = (totalCost ?? 0) + turnResult.cost; - } - - const result = applyCompletionEvidence(turnResult, input.completionRequirements); - - // Don't continue on non-success results - if (!result.success) { - return { ...result, cost: totalCost }; - } - - const decision = decideContinuation( - result, - input.completionRequirements, - continuationTurns, - maxContinuationTurns, - totalCost, - input.logWriter, - toolCallCount, - ); - if (decision.done) return decision.result; - - continuationTurns++; - promptText = decision.promptText; - isContinuation = true; + ...(isContinuation ? { continue: true } : {}), + }, + }); + + const { + assistantMessages, + resultMessage, + turnCount: newTurnCount, + toolCallCount, + } = await consumeStream(stream, input, model, turnCount); + turnCount = newTurnCount; + + const turnResult = buildResult( + assistantMessages, + resultMessage, + stderrChunks, + input, + startTime, + ); + + // Accumulate cost across continuation turns + if (turnResult.cost !== undefined) { + totalCost = (totalCost ?? 0) + turnResult.cost; } - } finally { - // Clean up offloaded context files after execution - if (hasOffloadedContext) { - await cleanupContextFiles(input.repoDir); + + const result = applyCompletionEvidence(turnResult, input.completionRequirements); + + // Don't continue on non-success results + if (!result.success) { + return { ...result, cost: totalCost }; } - // Clean up persisted session directory — workers are ephemeral - await cleanupPersistedSession(input.repoDir); + + const decision = decideContinuation( + result, + input.completionRequirements, + continuationTurns, + maxContinuationTurns, + totalCost, + input.logWriter, + toolCallCount, + ); + if (decision.done) return decision.result; + + continuationTurns++; + promptText = decision.promptText; + isContinuation = true; } } } diff --git a/src/backends/codex/index.ts b/src/backends/codex/index.ts index 67123619..d1768995 100644 --- a/src/backends/codex/index.ts +++ b/src/backends/codex/index.ts @@ -476,6 +476,11 @@ async function captureRefreshedToken( export class CodexEngine implements AgentEngine { readonly definition = CODEX_ENGINE_DEFINITION; + /** Stores the original auth JSON so afterExecute can detect token refreshes. */ + private _originalAuthJson: string | undefined; + /** True when beforeExecute has been called (adapter lifecycle is active). */ + private _adapterLifecycleActive = false; + supportsAgentType(_agentType: string): boolean { return true; } @@ -484,6 +489,45 @@ export class CodexEngine implements AgentEngine { return resolveCodexModel(cascadeModel); } + async beforeExecute(plan: AgentExecutionPlan): Promise { + this._adapterLifecycleActive = true; + this._originalAuthJson = await writeCodexAuthFile(plan.projectSecrets, plan.logWriter); + } + + async afterExecute(plan: AgentExecutionPlan, _result: AgentEngineResult): Promise { + await captureRefreshedToken(plan.project.orgId, this._originalAuthJson, plan.logWriter); + await cleanupContextFiles(plan.repoDir); + this._originalAuthJson = undefined; + this._adapterLifecycleActive = false; + } + + /** Remove temp file created by execute() — best-effort, ignores errors. */ + private static _cleanupLastMessagePath(path: string): void { + if (existsSync(path)) { + try { + unlinkSync(path); + } catch { + // Best-effort cleanup + } + } + } + + /** Cleanup called from execute() finally block when adapter lifecycle is not active. */ + private async _directCallCleanup( + repoDir: string, + orgId: string | undefined, + originalAuthJson: string | undefined, + logWriter: AgentExecutionPlan['logWriter'], + hasOffloadedContext: boolean, + ): Promise { + if (hasOffloadedContext) { + await cleanupContextFiles(repoDir); + } + if (orgId) { + await captureRefreshedToken(orgId, originalAuthJson, logWriter); + } + } + async execute(input: AgentExecutionPlan): Promise { const startTime = Date.now(); const systemPrompt = buildSystemPrompt(input.systemPrompt, input.availableTools); @@ -501,7 +545,11 @@ export class CodexEngine implements AgentEngine { const settings = resolveCodexSettings(input.project, input.nativeToolCapabilities); assertHeadlessCodexSettings(settings); - const originalAuthJson = await writeCodexAuthFile(input.projectSecrets, input.logWriter); + // When called via adapter, beforeExecute already wrote the auth file. + // When called directly (e.g. tests), write it here for backward compatibility. + const originalAuthJson = this._adapterLifecycleActive + ? this._originalAuthJson + : await writeCodexAuthFile(input.projectSecrets, input.logWriter); // Strip CODEX_AUTH_JSON from env — it's written to disk, not passed to the subprocess const strippedSecrets: Record | undefined = input.projectSecrets @@ -656,17 +704,18 @@ export class CodexEngine implements AgentEngine { prEvidence, }; } finally { - if (existsSync(lastMessagePath)) { - try { - unlinkSync(lastMessagePath); - } catch { - // Best-effort cleanup - } - } - if (hasOffloadedContext) { - await cleanupContextFiles(input.repoDir); + CodexEngine._cleanupLastMessagePath(lastMessagePath); + // When called directly (not via adapter), afterExecute won't be invoked. + // Perform cleanup here so direct callers (e.g. tests) still behave correctly. + if (!this._adapterLifecycleActive) { + await this._directCallCleanup( + input.repoDir, + input.project.orgId, + originalAuthJson, + input.logWriter, + hasOffloadedContext, + ); } - await captureRefreshedToken(input.project.orgId, originalAuthJson, input.logWriter); } } } diff --git a/src/backends/opencode/index.ts b/src/backends/opencode/index.ts index 971c74b1..85b1e445 100644 --- a/src/backends/opencode/index.ts +++ b/src/backends/opencode/index.ts @@ -805,6 +805,13 @@ export class OpenCodeEngine implements AgentEngine { return resolveOpenCodeModel(cascadeModel); } + async afterExecute(plan: AgentExecutionPlan, _result: AgentEngineResult): Promise { + // Clean up offloaded context files — idempotent, safe to call from adapter hook. + // Server process and session cleanup happen inside execute()'s finally block + // since those resources are local to the execution. + await cleanupContextFiles(plan.repoDir); + } + async execute(input: AgentExecutionPlan): Promise { const settings = resolveOpenCodeSettings(input.project); const agent = 'build' as const; diff --git a/src/backends/types.ts b/src/backends/types.ts index b8bef31e..54c46c1a 100644 --- a/src/backends/types.ts +++ b/src/backends/types.ts @@ -153,4 +153,16 @@ export interface AgentEngine { * Engines that pass the model through unchanged (e.g., LLMist) do not need to implement it. */ resolveModel?(cascadeModel: string): string; + /** + * Optional hook called by the adapter before engine.execute(). + * Use for engine-specific environment setup (e.g., writing auth files, checking directories). + * LLMist does not implement this hook. + */ + beforeExecute?(plan: AgentExecutionPlan): Promise; + /** + * Optional hook called by the adapter after engine.execute(), in a finally block. + * Use for engine-specific cleanup (e.g., removing temp files, killing subprocesses). + * LLMist does not implement this hook. + */ + afterExecute?(plan: AgentExecutionPlan, result: AgentEngineResult): Promise; } diff --git a/tests/unit/backends/adapter.test.ts b/tests/unit/backends/adapter.test.ts index 2b432f49..fcb3a686 100644 --- a/tests/unit/backends/adapter.test.ts +++ b/tests/unit/backends/adapter.test.ts @@ -1101,4 +1101,108 @@ describe('executeWithEngine', () => { ); }); }); + + describe('lifecycle hooks (beforeExecute / afterExecute)', () => { + it('calls beforeExecute before engine.execute when hook is defined', async () => { + setupMocks(); + const callOrder: string[] = []; + const engine = makeMockBackend(); + (engine as AgentEngine).beforeExecute = vi.fn().mockImplementation(async () => { + callOrder.push('before'); + }); + vi.mocked(engine.execute).mockImplementation(async () => { + callOrder.push('execute'); + return { success: true, output: 'Done' }; + }); + const input = makeInput(); + + await executeWithEngine(engine, 'implementation', input); + + expect(callOrder[0]).toBe('before'); + expect(callOrder[1]).toBe('execute'); + }); + + it('calls afterExecute after engine.execute when hook is defined', async () => { + setupMocks(); + const callOrder: string[] = []; + const engine = makeMockBackend(); + (engine as AgentEngine).afterExecute = vi.fn().mockImplementation(async () => { + callOrder.push('after'); + }); + vi.mocked(engine.execute).mockImplementation(async () => { + callOrder.push('execute'); + return { success: true, output: 'Done' }; + }); + const input = makeInput(); + + await executeWithEngine(engine, 'implementation', input); + + expect(callOrder[0]).toBe('execute'); + expect(callOrder[1]).toBe('after'); + }); + + it('calls afterExecute even when engine.execute throws', async () => { + setupMocks(); + const engine = makeMockBackend(); + const mockAfterExecute = vi.fn().mockResolvedValue(undefined); + (engine as AgentEngine).afterExecute = mockAfterExecute; + vi.mocked(engine.execute).mockRejectedValue(new Error('Execute crashed')); + const input = makeInput(); + + const result = await executeWithEngine(engine, 'implementation', input); + + expect(result.success).toBe(false); + expect(mockAfterExecute).toHaveBeenCalledTimes(1); + }); + + it('passes executionPlan and result to afterExecute', async () => { + setupMocks(); + const engine = makeMockBackend(); + const mockAfterExecute = vi.fn().mockResolvedValue(undefined); + (engine as AgentEngine).afterExecute = mockAfterExecute; + vi.mocked(engine.execute).mockResolvedValue({ + success: true, + output: 'Done', + cost: 1.5, + }); + const input = makeInput(); + + await executeWithEngine(engine, 'implementation', input); + + expect(mockAfterExecute).toHaveBeenCalledWith( + expect.objectContaining({ agentType: 'implementation' }), + expect.objectContaining({ success: true, output: 'Done', cost: 1.5 }), + ); + }); + + it('passes fallback result to afterExecute when execute() threw', async () => { + setupMocks(); + const engine = makeMockBackend(); + const mockAfterExecute = vi.fn().mockResolvedValue(undefined); + (engine as AgentEngine).afterExecute = mockAfterExecute; + vi.mocked(engine.execute).mockRejectedValue(new Error('Crashed')); + const input = makeInput(); + + await executeWithEngine(engine, 'implementation', input); + + expect(mockAfterExecute).toHaveBeenCalledWith( + expect.any(Object), + expect.objectContaining({ success: false, output: '' }), + ); + }); + + it('does not call beforeExecute or afterExecute when hooks are absent', async () => { + setupMocks(); + const engine = makeMockBackend(); + // Engine has no beforeExecute or afterExecute + expect((engine as AgentEngine).beforeExecute).toBeUndefined(); + expect((engine as AgentEngine).afterExecute).toBeUndefined(); + const input = makeInput(); + + const result = await executeWithEngine(engine, 'implementation', input); + + expect(result.success).toBe(true); + expect(engine.execute).toHaveBeenCalledTimes(1); + }); + }); }); diff --git a/tests/unit/backends/claude-code.test.ts b/tests/unit/backends/claude-code.test.ts index 43bad6c7..a17452cc 100644 --- a/tests/unit/backends/claude-code.test.ts +++ b/tests/unit/backends/claude-code.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; // Mock the SDK before importing the engine vi.mock('@anthropic-ai/claude-agent-sdk', () => ({ @@ -1332,3 +1332,61 @@ describe('buildEnv', () => { expect(env.CUSTOM_VAR).toBe('custom-val'); }); }); + +describe('ClaudeCodeEngine lifecycle hooks', () => { + let fakeHome: string; + let fakeRepoDir: string; + let originalHome: string | undefined; + + beforeEach(() => { + fakeHome = mkdtempSync(join(tmpdir(), 'cascade-test-home-')); + fakeRepoDir = mkdtempSync(join(tmpdir(), 'cascade-test-repo-')); + originalHome = process.env.HOME; + process.env.HOME = fakeHome; + }); + + afterEach(async () => { + process.env.HOME = originalHome; + await rm(fakeHome, { recursive: true, force: true }); + await rm(fakeRepoDir, { recursive: true, force: true }); + }); + + it('beforeExecute creates .claude.json onboarding flag', async () => { + const engine = new ClaudeCodeEngine(); + const plan = makeInput({ repoDir: fakeRepoDir }); + await engine.beforeExecute(plan); + + const claudeJsonPath = join(fakeHome, '.claude.json'); + expect(existsSync(claudeJsonPath)).toBe(true); + const content = JSON.parse(readFileSync(claudeJsonPath, 'utf8')); + expect(content).toEqual({ hasCompletedOnboarding: true }); + }); + + it('afterExecute cleans up context directory', async () => { + const contextDir = join(fakeRepoDir, '.cascade', 'context'); + await import('node:fs/promises').then((fs) => fs.mkdir(contextDir, { recursive: true })); + await import('node:fs/promises').then((fs) => + fs.writeFile(join(contextDir, 'test.txt'), 'test content'), + ); + + const engine = new ClaudeCodeEngine(); + const plan = makeInput({ repoDir: fakeRepoDir }); + await engine.afterExecute(plan, { success: true, output: '' }); + + expect(existsSync(contextDir)).toBe(false); + }); + + it('afterExecute cleans up persisted Claude session directory', async () => { + const { homedir } = await import('node:os'); + const path = await import('node:path'); + const encodedDir = fakeRepoDir.replaceAll(path.default.sep, '-'); + const sessionDir = path.default.join(homedir(), '.claude', 'projects', encodedDir); + await import('node:fs/promises').then((fs) => fs.mkdir(sessionDir, { recursive: true })); + + const engine = new ClaudeCodeEngine(); + const plan = makeInput({ repoDir: fakeRepoDir }); + await engine.afterExecute(plan, { success: true, output: '' }); + + expect(existsSync(sessionDir)).toBe(false); + }); +}); diff --git a/tests/unit/backends/codex.test.ts b/tests/unit/backends/codex.test.ts index a0ca2a80..1c73b3bb 100644 --- a/tests/unit/backends/codex.test.ts +++ b/tests/unit/backends/codex.test.ts @@ -1015,3 +1015,84 @@ describe('Codex subscription auth', () => { expect(mockUpdateCredential).not.toHaveBeenCalled(); }); }); + +describe('CodexEngine lifecycle hooks', () => { + const AUTH_JSON = JSON.stringify({ accessToken: 'tok_abc', refreshToken: 'ref_xyz' }); + + let workspaceDir: string; + + beforeEach(() => { + workspaceDir = mkdtempSync(join(tmpdir(), 'cascade-codex-lifecycle-test-')); + vi.clearAllMocks(); + mockMkdir.mockResolvedValue(undefined); + mockWriteFile.mockResolvedValue(undefined); + mockReadFile.mockRejectedValue(Object.assign(new Error('ENOENT'), { code: 'ENOENT' })); + mockFindCredentialIdByEnvVarKey.mockResolvedValue(null); + mockUpdateCredential.mockResolvedValue(undefined); + mockSpawn.mockImplementation(() => createMockChild({ exitCode: 0 })); + }); + + afterEach(() => { + rmSync(workspaceDir, { recursive: true, force: true }); + }); + + it('beforeExecute writes auth.json when CODEX_AUTH_JSON is in projectSecrets', async () => { + const engine = new CodexEngine(); + const input = makeInput({ + repoDir: workspaceDir, + projectSecrets: { CODEX_AUTH_JSON: AUTH_JSON }, + }); + + await engine.beforeExecute(input); + + expect(mockWriteFile).toHaveBeenCalledWith(expect.stringContaining('auth.json'), AUTH_JSON, { + mode: 0o600, + }); + }); + + it('afterExecute calls captureRefreshedToken', async () => { + const refreshedJson = JSON.stringify({ accessToken: 'tok_NEW', refreshToken: 'ref_xyz' }); + mockReadFile.mockResolvedValue(refreshedJson); + mockFindCredentialIdByEnvVarKey.mockResolvedValue(42); + + const engine = new CodexEngine(); + const input = makeInput({ + repoDir: workspaceDir, + projectSecrets: { CODEX_AUTH_JSON: AUTH_JSON }, + }); + + // Simulate adapter lifecycle: beforeExecute stores originalAuthJson, afterExecute compares + await engine.beforeExecute(input); + await engine.afterExecute(input, { success: true, output: '' }); + + expect(mockFindCredentialIdByEnvVarKey).toHaveBeenCalledWith('org-1', 'CODEX_AUTH_JSON'); + expect(mockUpdateCredential).toHaveBeenCalledWith(42, { value: refreshedJson }); + }); + + it('afterExecute completes without throwing', async () => { + const engine = new CodexEngine(); + const plan = makeInput({ repoDir: workspaceDir }); + + await expect(engine.afterExecute(plan, { success: true, output: '' })).resolves.not.toThrow(); + }); + + it('adapter lifecycle: execute does not double-capture token when adapter calls afterExecute', async () => { + const refreshedJson = JSON.stringify({ accessToken: 'tok_NEW', refreshToken: 'ref_xyz' }); + mockReadFile.mockResolvedValue(refreshedJson); + mockFindCredentialIdByEnvVarKey.mockResolvedValue(42); + + const engine = new CodexEngine(); + const input = makeInput({ + repoDir: workspaceDir, + projectSecrets: { CODEX_AUTH_JSON: AUTH_JSON }, + }); + + // Simulate adapter: beforeExecute → execute → afterExecute + await engine.beforeExecute(input); + await engine.execute(input); + await engine.afterExecute(input, { success: true, output: '' }); + + // captureRefreshedToken should be called exactly once (from afterExecute, not from execute's finally) + expect(mockFindCredentialIdByEnvVarKey).toHaveBeenCalledTimes(1); + }); +}); diff --git a/tests/unit/backends/llmist.test.ts b/tests/unit/backends/llmist.test.ts index 567021d2..124003f7 100644 --- a/tests/unit/backends/llmist.test.ts +++ b/tests/unit/backends/llmist.test.ts @@ -139,6 +139,16 @@ describe('LlmistEngine', () => { expect(engine.supportsAgentType('review')).toBe(true); expect(engine.supportsAgentType('anything')).toBe(true); }); + + it('does not implement beforeExecute lifecycle hook', () => { + const engine = new LlmistEngine(); + expect(engine.beforeExecute).toBeUndefined(); + }); + + it('does not implement afterExecute lifecycle hook', () => { + const engine = new LlmistEngine(); + expect(engine.afterExecute).toBeUndefined(); + }); }); describe('LlmistEngine.execute', () => { diff --git a/tests/unit/backends/opencode.test.ts b/tests/unit/backends/opencode.test.ts index a24b977f..48be90d6 100644 --- a/tests/unit/backends/opencode.test.ts +++ b/tests/unit/backends/opencode.test.ts @@ -925,3 +925,16 @@ describe('OpenCodeEngine', () => { expect(result.error).toContain('OpenCode transport failed after retries'); }); }); + +describe('OpenCodeEngine lifecycle hooks', () => { + it('afterExecute is defined on OpenCodeEngine', () => { + const engine = new OpenCodeEngine(); + expect(typeof engine.afterExecute).toBe('function'); + }); + + it('afterExecute does not throw when called with a valid plan', async () => { + const engine = new OpenCodeEngine(); + const plan = { repoDir: '/tmp/nonexistent-repo' } as AgentExecutionPlan; + await expect(engine.afterExecute(plan, { success: true, output: '' })).resolves.not.toThrow(); + }); +});