feat: add codex review failover and suppress output hardening

This commit is contained in:
Eyejoker
2026-03-28 05:40:38 +09:00
parent d1c693fb17
commit ba9f6871b6
49 changed files with 3884 additions and 1763 deletions

View File

@@ -1,5 +1,6 @@
import { type AgentOutput } from './agent-runner.js';
import { logger } from './logger.js';
import { classifySuppressTokenOutput } from './output-suppression.js';
import { formatOutbound } from './router.js';
import { shouldResetSessionOnAgentFailure } from './session-recovery.js';
import { TASK_STATUS_MESSAGE_PREFIX } from './task-watch-status.js';
@@ -28,6 +29,8 @@ interface MessageTurnControllerOptions {
idleTimeout: number;
failureFinalText: string;
isClaudeCodeAgent: boolean;
deferTypingUntilVisible?: boolean;
suppressToken?: string;
clearSession: () => void;
requestClose: (reason: string) => void;
deliverFinalText: (text: string) => Promise<boolean>;
@@ -53,6 +56,7 @@ export class MessageTurnController {
private lastIntermediateText: string | null = null;
private poisonedSessionDetected = false;
private closeRequested = false;
private typingActive = false;
constructor(private readonly options: MessageTurnControllerOptions) {}
@@ -78,7 +82,10 @@ export class MessageTurnController {
async start(): Promise<void> {
this.resetIdleTimer();
await this.setTyping(true, 'turn:start');
if (this.options.deferTypingUntilVisible) {
return;
}
await this.activateTyping('turn:start');
}
async handleOutput(result: AgentOutput): Promise<void> {
@@ -123,7 +130,11 @@ export class MessageTurnController {
: typeof result.result === 'string'
? result.result
: JSON.stringify(result.result);
const text = raw ? formatOutbound(raw) : null;
const suppressState =
raw && this.options.suppressToken
? classifySuppressTokenOutput(raw, this.options.suppressToken)
: 'none';
const text = raw && suppressState === 'none' ? formatOutbound(raw) : null;
if (raw) {
logger.info(
@@ -138,6 +149,31 @@ export class MessageTurnController {
},
`Agent output: ${raw.slice(0, 200)}`,
);
if (suppressState === 'exact') {
logger.info(
{
chatJid: this.options.chatJid,
group: this.options.group.name,
groupFolder: this.options.group.folder,
runId: this.options.runId,
resultStatus: result.status,
resultPhase: result.phase,
},
'Suppressed exact-match silent output token',
);
} else if (suppressState === 'mixed') {
logger.warn(
{
chatJid: this.options.chatJid,
group: this.options.group.name,
groupFolder: this.options.group.folder,
runId: this.options.runId,
resultStatus: result.status,
resultPhase: result.phase,
},
'Blocked malformed output that mixed the silent output token with visible text',
);
}
}
const phase: AgentOutputPhase = normalizeAgentOutputPhase(result.phase);
@@ -275,6 +311,9 @@ export class MessageTurnController {
await this.finalizeProgressMessage();
await this.deliverFinalText(text);
}
} else if (suppressState !== 'none') {
await this.finalizeProgressMessage();
this.latestProgressTextForFinal = null;
} else if (raw) {
logger.info(
{
@@ -294,7 +333,7 @@ export class MessageTurnController {
await this.finalizeProgressMessage();
}
await this.setTyping(false, 'turn:handle-output', {
await this.deactivateTyping('turn:handle-output', {
outputStatus: result.status,
phase,
});
@@ -311,7 +350,7 @@ export class MessageTurnController {
deliverySucceeded: boolean;
visiblePhase: VisiblePhase;
}> {
await this.setTyping(false, 'turn:finish', { outputStatus });
await this.deactivateTyping('turn:finish', { outputStatus });
if (outputStatus === 'error') {
this.hadError = true;
@@ -566,6 +605,7 @@ export class MessageTurnController {
}
private async deliverFinalText(text: string): Promise<void> {
await this.activateTyping('turn:deliver-final');
this.visiblePhase = toVisiblePhase('final');
const delivered = await this.options.deliverFinalText(text);
if (!delivered) {
@@ -593,6 +633,8 @@ export class MessageTurnController {
return;
}
await this.activateTyping('turn:send-progress');
if (this.progressStartedAt === null) {
this.progressStartedAt = Date.now();
}
@@ -670,6 +712,28 @@ export class MessageTurnController {
this.visiblePhase = toVisiblePhase('progress');
}
private async activateTyping(
source: string,
extra?: Record<string, unknown>,
): Promise<void> {
if (this.typingActive) return;
await this.setTyping(true, source, extra);
this.typingActive = true;
}
private async deactivateTyping(
source: string,
extra?: Record<string, unknown>,
): Promise<void> {
if (!this.typingActive) return;
await this.setTyping(false, source, extra);
this.typingActive = false;
}
cancelPendingTypingDelay(): void {
// No-op: typing delay removed. Kept for call-site compatibility.
}
private resetIdleTimer(): void {
if (this.idleTimer) clearTimeout(this.idleTimer);
if (this.hasVisibleOutput()) {