fix: restore main quality checks

This commit is contained in:
Eyejoker
2026-06-02 18:37:37 +08:00
committed by GitHub
parent 5648b61075
commit 7fc558acb6
6 changed files with 149 additions and 48 deletions

View File

@@ -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);

View File

@@ -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();

View File

@@ -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();

View File

@@ -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);

View File

@@ -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({

View File

@@ -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;
}