diff --git a/src/agent-runner-environment.test.ts b/src/agent-runner-environment.test.ts index e70b5db..ae54aa1 100644 --- a/src/agent-runner-environment.test.ts +++ b/src/agent-runner-environment.test.ts @@ -287,6 +287,54 @@ describe('prepareGroupEnvironment codex auth handling', () => { ); expect(fs.existsSync(authPath)).toBe(false); }); +}); + +describe('prepareGroupEnvironment prompt stacks', () => { + let tempRoot: string; + let previousCwd: string; + let previousOpenAiKey: string | undefined; + let previousCodexOpenAiKey: string | undefined; + + beforeEach(() => { + tempRoot = fs.mkdtempSync(path.join('/tmp', 'ejclaw-agent-env-')); + previousCwd = process.cwd(); + process.chdir(tempRoot); + + process.env.EJ_TEST_ROOT = tempRoot; + process.env.EJ_TEST_HOME = path.join(tempRoot, 'home'); + previousOpenAiKey = process.env.OPENAI_API_KEY; + previousCodexOpenAiKey = process.env.CODEX_OPENAI_API_KEY; + delete process.env.OPENAI_API_KEY; + delete process.env.CODEX_OPENAI_API_KEY; + + fs.mkdirSync(process.env.EJ_TEST_HOME, { recursive: true }); + fs.mkdirSync(path.join(process.env.EJ_TEST_HOME, '.codex'), { + recursive: true, + }); + + mockReadEnvFile.mockReset(); + mockGetActiveCodexAuthPath.mockReset(); + mockGetCodexAccountCount.mockReset(); + mockGetCodexAccountCount.mockReturnValue(0); + mockClaimCodexAuthLease.mockReset(); + mockClaimCodexAuthLease.mockReturnValue(null); + mockFindCodexAccountIndexByAuthPath.mockReset(); + mockFindCodexAccountIndexByAuthPath.mockReturnValue(null); + }); + + afterEach(() => { + process.chdir(previousCwd); + delete process.env.EJ_TEST_ROOT; + delete process.env.EJ_TEST_HOME; + if (previousOpenAiKey) process.env.OPENAI_API_KEY = previousOpenAiKey; + else delete process.env.OPENAI_API_KEY; + if (previousCodexOpenAiKey) { + process.env.CODEX_OPENAI_API_KEY = previousCodexOpenAiKey; + } else { + delete process.env.CODEX_OPENAI_API_KEY; + } + fs.rmSync(tempRoot, { recursive: true, force: true }); + }); it('uses the failover owner prompt pack for codex-review when it owns an explicit failover lease', () => { vi.mocked(config.isReviewService).mockReturnValue(true); diff --git a/src/codex-token-rotation.test.ts b/src/codex-token-rotation.test.ts index 1647112..018af22 100644 --- a/src/codex-token-rotation.test.ts +++ b/src/codex-token-rotation.test.ts @@ -222,6 +222,22 @@ describe('codex-token-rotation d7 ≥ 100% auto-skip', () => { expect(accounts[0].isRateLimited).toBe(false); expect(accounts[1].isActive).toBe(true); }); +}); + +describe('codex-token-rotation auth synchronization', () => { + let tempHome: string; + + beforeEach(() => { + vi.resetModules(); + tempHome = fs.mkdtempSync(path.join('/tmp', 'ejclaw-codex-rot-')); + process.env.CODEX_ROT_TEST_HOME = tempHome; + createFakeAccounts(tempHome, 4); + }); + + afterEach(() => { + delete process.env.CODEX_ROT_TEST_HOME; + fs.rmSync(tempHome, { recursive: true, force: true }); + }); it('recovers a dead_auth account when canonical auth.json is refreshed before lease claim', async () => { vi.useFakeTimers(); diff --git a/src/message-agent-executor.test.ts b/src/message-agent-executor.test.ts index 7dcdd6f..d85a65d 100644 --- a/src/message-agent-executor.test.ts +++ b/src/message-agent-executor.test.ts @@ -1,6 +1,7 @@ import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'; import * as config from './config.js'; +const NO_VISIBLE_VERDICT_SUMMARY = `검토 중입니다.\nExecution completed without a visible terminal verdict.`; vi.mock('./agent-runner.js', () => ({ runAgentProcess: vi.fn(), writeGroupsSnapshot: vi.fn(), @@ -1190,7 +1191,7 @@ describe('runAgentForGroup room memory', () => { taskId: 'paired-task-review-no-verdict', role: 'reviewer', status: 'failed', - summary: 'Execution completed without a visible terminal verdict.', + summary: NO_VISIBLE_VERDICT_SUMMARY, }), ); expect(db.failPairedTurn).toHaveBeenCalledWith({ @@ -1202,7 +1203,7 @@ describe('runAgentForGroup room memory', () => { intentKind: 'reviewer-turn', role: 'reviewer', }, - error: 'Execution completed without a visible terminal verdict.', + error: NO_VISIBLE_VERDICT_SUMMARY, }); expect(db.completePairedTurn).not.toHaveBeenCalled(); expect(db.insertPairedTurnOutput).not.toHaveBeenCalled(); diff --git a/src/outbound-attachments.test.ts b/src/outbound-attachments.test.ts index ae6d02d..5428013 100644 --- a/src/outbound-attachments.test.ts +++ b/src/outbound-attachments.test.ts @@ -38,6 +38,7 @@ function writeFile( afterEach(() => { vi.unstubAllEnvs(); + vi.restoreAllMocks(); for (const dir of cleanupDirs.splice(0)) { fs.rmSync(dir, { force: true, recursive: true }); } @@ -176,7 +177,13 @@ describe('validateOutboundAttachments', () => { }); describe('validateOutboundAttachments policy checks', () => { + function useIsolatedDefaultTempDir(): void { + const tempDir = makeTempDir(os.tmpdir(), 'ejclaw-policy-temp-'); + vi.spyOn(os, 'tmpdir').mockReturnValue(tempDir); + } + it('requires workspace paths to be explicitly allowlisted', () => { + useIsolatedDefaultTempDir(); const dir = makeTempDir(process.cwd(), '.ejclaw-attachment-'); const imagePath = writeFile(dir, 'workspace-shot.png', ONE_PIXEL_PNG); @@ -223,6 +230,7 @@ describe('validateOutboundAttachments policy checks', () => { }); it('rejects symlink attempts that escape the allowed directory', () => { + useIsolatedDefaultTempDir(); const workspaceDir = makeTempDir(process.cwd(), '.ejclaw-attachment-'); const targetPath = writeFile( workspaceDir, @@ -242,6 +250,7 @@ describe('validateOutboundAttachments policy checks', () => { }); it('rejects symlink attempts that escape a user-configured directory', () => { + useIsolatedDefaultTempDir(); const allowedDir = makeTempDir(process.cwd(), '.ejclaw-user-images-'); const secretDir = makeTempDir(process.cwd(), '.ejclaw-secret-images-'); const targetPath = writeFile(secretDir, 'secret-shot.png', ONE_PIXEL_PNG); diff --git a/src/streamed-output-evaluator.test.ts b/src/streamed-output-evaluator.test.ts index 56f1e41..75154df 100644 --- a/src/streamed-output-evaluator.test.ts +++ b/src/streamed-output-evaluator.test.ts @@ -438,7 +438,9 @@ describe('evaluateStreamedOutput', () => { expect(result.state.sawSuccessNullResultWithoutOutput).toBe(false); }); }); +}); +describe('evaluateStreamedOutput error handling', () => { describe('error → Claude rotation trigger', () => { it('returns rotation trigger with retryAfterMs', () => { vi.mocked(classifyRotationTrigger).mockReturnValue({ diff --git a/src/streamed-output-evaluator.ts b/src/streamed-output-evaluator.ts index a84f557..f60f7a7 100644 --- a/src/streamed-output-evaluator.ts +++ b/src/streamed-output-evaluator.ts @@ -52,58 +52,26 @@ export function evaluateStreamedOutput( options: EvaluateStreamedOutputOptions, ): EvaluateStreamedOutputResult { const nextState: StreamedOutputState = { ...state }; - const isPrimaryClaude = - options.agentType === 'claude-code' && options.provider === 'claude'; - const isPrimaryCodex = - options.agentType === 'codex' && options.provider === 'codex'; + const primaryAgent = primaryAgentFor(options); const countsAsFinalOutput = output.phase === undefined || output.phase === 'final'; const outputText = getAgentOutputText(output); - if ( - isPrimaryClaude && - !state.sawOutput && - shouldRetryFreshSessionOnAgentFailure(output) - ) { - nextState.retryableSessionFailureDetected = true; - return { - state: nextState, - shouldForwardOutput: false, - suppressedRetryableSessionFailure: true, - }; - } + const retryableSessionFailure = suppressRetryableSessionFailure( + output, + state, + nextState, + primaryAgent, + ); + if (retryableSessionFailure) return retryableSessionFailure; if ( - isPrimaryCodex && - !state.sawOutput && - shouldRetryFreshCodexSessionOnAgentFailure(output) - ) { - nextState.retryableSessionFailureDetected = true; - return { - state: nextState, - shouldForwardOutput: false, - suppressedRetryableSessionFailure: true, - }; - } - - if ( - isPrimaryClaude && + primaryAgent === 'claude' && output.status === 'success' && !state.sawOutput && typeof outputText === 'string' ) { - const authClassification = classifyClaudeAuthError(outputText); - const triggerReason: AgentTriggerReason | undefined = - isClaudeUsageExhaustedMessage(outputText) - ? 'usage-exhausted' - : isClaudeOrgAccessDeniedMessage(outputText) || - authClassification.category === 'org-access-denied' - ? 'org-access-denied' - : isClaudeAuthExpiredMessage(outputText) || - authClassification.category === 'auth-expired' - ? 'auth-expired' - : detectClaudeProviderFailureMessage(outputText) || undefined; - + const triggerReason = classifyClaudeSuccessTrigger(outputText); if (triggerReason) { const newTrigger = nextState.streamedTriggerReason ? undefined @@ -133,7 +101,7 @@ export function evaluateStreamedOutput( nextState.sawOutput = true; } else if ( options.trackSuccessNullResult && - isPrimaryClaude && + primaryAgent === 'claude' && output.status === 'success' && !state.sawOutput ) { @@ -141,7 +109,7 @@ export function evaluateStreamedOutput( } if ( - isPrimaryCodex && + primaryAgent === 'codex' && typeof output.error === 'string' && output.error.length > 0 && !nextState.sawOutput && @@ -169,7 +137,7 @@ export function evaluateStreamedOutput( ) { let newTrigger: StreamedTriggerReason | undefined; - if (isPrimaryClaude) { + if (primaryAgent === 'claude') { const trigger = classifyRotationTrigger(output.error); if (trigger.shouldRetry) { newTrigger = { @@ -177,7 +145,7 @@ export function evaluateStreamedOutput( retryAfterMs: trigger.retryAfterMs, }; } - } else if (isPrimaryCodex) { + } else if (primaryAgent === 'codex') { const trigger = detectCodexRotationTrigger(output.error); if (trigger.shouldRotate) { newTrigger = { reason: trigger.reason, message: output.error }; @@ -199,3 +167,60 @@ export function evaluateStreamedOutput( shouldForwardOutput: true, }; } + +type PrimaryAgent = 'claude' | 'codex' | undefined; + +function primaryAgentFor(options: EvaluateStreamedOutputOptions): PrimaryAgent { + if (options.agentType === 'claude-code' && options.provider === 'claude') { + return 'claude'; + } + if (options.agentType === 'codex' && options.provider === 'codex') { + return 'codex'; + } + return undefined; +} + +function suppressRetryableSessionFailure( + output: AgentOutput, + state: StreamedOutputState, + nextState: StreamedOutputState, + primaryAgent: PrimaryAgent, +): EvaluateStreamedOutputResult | undefined { + if (state.sawOutput) return undefined; + + const shouldSuppress = + primaryAgent === 'claude' + ? shouldRetryFreshSessionOnAgentFailure(output) + : primaryAgent === 'codex' + ? shouldRetryFreshCodexSessionOnAgentFailure(output) + : false; + if (!shouldSuppress) return undefined; + + nextState.retryableSessionFailureDetected = true; + return { + state: nextState, + shouldForwardOutput: false, + suppressedRetryableSessionFailure: true, + }; +} + +function classifyClaudeSuccessTrigger( + outputText: string, +): AgentTriggerReason | undefined { + if (isClaudeUsageExhaustedMessage(outputText)) return 'usage-exhausted'; + + const authClassification = classifyClaudeAuthError(outputText); + if ( + isClaudeOrgAccessDeniedMessage(outputText) || + authClassification.category === 'org-access-denied' + ) { + return 'org-access-denied'; + } + if ( + isClaudeAuthExpiredMessage(outputText) || + authClassification.category === 'auth-expired' + ) { + return 'auth-expired'; + } + return detectClaudeProviderFailureMessage(outputText) || undefined; +}