fix: restore main quality checks
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user