refactor: remove dead output suppression path

This commit is contained in:
Eyejoker
2026-03-30 03:22:08 +09:00
parent 6edf5aebbd
commit e863db61a8
8 changed files with 33 additions and 628 deletions

View File

@@ -224,7 +224,7 @@ describe('runAgentForGroup room memory', () => {
);
});
it('injects structured silent-output instructions into the agent prompt', async () => {
it('passes the prompt through unchanged', async () => {
const group = { ...makeGroup(), folder: 'test-group' };
await runAgentForGroup(makeDeps(), {
@@ -232,7 +232,6 @@ describe('runAgentForGroup room memory', () => {
prompt: 'hello',
chatJid: 'group@test',
runId: 'run-suppress',
suppressToken: '__TEST_SUPPRESS__',
});
expect(agentRunner.runAgentProcess).toHaveBeenCalledWith(
@@ -273,7 +272,7 @@ describe('runAgentForGroup room memory', () => {
);
});
it('adds reviewer silence guidance when the current service is the reviewer for the chat', async () => {
it('keeps the reviewer prompt unchanged when the current service is the reviewer for the chat', async () => {
const group = { ...makeGroup(), folder: 'test-group' };
vi.mocked(serviceRouting.getEffectiveChannelLease).mockReturnValue({
chat_jid: 'group@test',
@@ -289,7 +288,6 @@ describe('runAgentForGroup room memory', () => {
prompt: 'hello',
chatJid: 'group@test',
runId: 'run-review-suppress',
suppressToken: '__TEST_SUPPRESS__',
});
expect(agentRunner.runAgentProcess).toHaveBeenCalledWith(

View File

@@ -40,10 +40,6 @@ import {
REVIEWER_AGENT_TYPE,
isClaudeService,
} from './config.js';
import {
buildStructuredOutputPrompt,
classifySuppressTokenOutput,
} from './output-suppression.js';
import {
activateCodexFailover,
getEffectiveChannelLease,
@@ -80,7 +76,6 @@ export async function runAgentForGroup(
prompt: string;
chatJid: string;
runId: string;
suppressToken?: string;
startSeq?: number | null;
endSeq?: number | null;
onOutput?: (output: AgentOutput) => Promise<void>;
@@ -91,7 +86,6 @@ export async function runAgentForGroup(
prompt,
chatJid,
runId,
suppressToken,
startSeq,
endSeq,
onOutput,
@@ -174,12 +168,7 @@ export async function runAgentForGroup(
runId,
roomRoleContext,
});
const effectivePrompt = buildStructuredOutputPrompt(prompt, {
reviewerMode,
pairedRoom: !!pairedExecutionContext,
gateTurnKind: pairedExecutionContext?.gateTurnKind,
requiresVisibleVerdict: pairedExecutionContext?.requiresVisibleVerdict,
});
const effectivePrompt = prompt;
let pairedExecutionStatus: 'succeeded' | 'failed' = 'failed';
let pairedExecutionSummary: string | null = null;
let pairedExecutionCompleted = false;
@@ -399,14 +388,9 @@ export async function runAgentForGroup(
if (!evaluation.shouldForwardOutput) {
return;
}
const suppressState =
typeof outputText === 'string'
? classifySuppressTokenOutput(outputText, suppressToken)
: 'none';
if (
typeof outputText === 'string' &&
outputText.length > 0 &&
suppressState === 'none'
outputText.length > 0
) {
streamedState = {
...evaluation.state,

View File

@@ -11,15 +11,6 @@ vi.mock('./agent-runner.js', () => ({
writeTasksSnapshot: vi.fn(),
}));
vi.mock('./output-suppression.js', async (importOriginal) => {
const actual =
await importOriginal<typeof import('./output-suppression.js')>();
return {
...actual,
createSuppressToken: vi.fn(() => '__TEST_SUPPRESS__'),
};
});
vi.mock('./config.js', () => ({
DATA_DIR: '/tmp/ejclaw-test-data',
SERVICE_ID: 'claude',
@@ -179,7 +170,6 @@ import * as agentRunner from './agent-runner.js';
import * as db from './db.js';
import { resolveGroupIpcPath } from './group-folder.js';
import { createMessageRuntime } from './message-runtime.js';
import * as outputSuppression from './output-suppression.js';
import * as config from './config.js';
import type { Channel, RegisteredGroup } from './types.js';
@@ -345,7 +335,7 @@ describe('createMessageRuntime', () => {
expect(saveState).toHaveBeenCalled();
});
it('does not defer typing-on for suppress-capable review turns', async () => {
it('does not defer typing-on for review turns', async () => {
const chatJid = 'group@test';
const group = makeGroup('codex');
const channel = makeChannel(chatJid);
@@ -1374,65 +1364,6 @@ describe('createMessageRuntime', () => {
expect(channel.sendAndTrack).not.toHaveBeenCalled();
});
it('does not emit a visible message when the final output is the suppress token only', async () => {
const chatJid = 'group@test';
const group = makeGroup('codex');
const channel = makeChannel(chatJid);
const lastAgentTimestamps: Record<string, string> = {};
const saveState = vi.fn();
vi.mocked(db.getMessagesSince).mockReturnValue([
{
id: 'msg-1',
chat_jid: chatJid,
sender: 'user@test',
sender_name: 'User',
content: 'hello',
timestamp: '2026-03-19T00:00:00.000Z',
seq: 1,
},
]);
vi.mocked(agentRunner.runAgentProcess).mockResolvedValue({
status: 'success',
result: '__TEST_SUPPRESS__',
newSessionId: 'session-suppress-run',
});
const runtime = createMessageRuntime({
assistantName: 'Andy',
idleTimeout: 1_000,
pollInterval: 1_000,
timezone: 'UTC',
triggerPattern: /^@Andy\b/i,
channels: [channel],
queue: {
registerProcess: vi.fn(),
closeStdin: vi.fn(),
notifyIdle: vi.fn(),
} as any,
getRegisteredGroups: () => ({ [chatJid]: group }),
getSessions: () => ({}),
getLastTimestamp: () => '',
setLastTimestamp: vi.fn(),
getLastAgentTimestamps: () => lastAgentTimestamps,
saveState,
persistSession: vi.fn(),
clearSession: vi.fn(),
});
const result = await runtime.processGroupMessages(chatJid, {
runId: 'run-suppress-only',
reason: 'messages',
});
expect(result).toBe(true);
expect(saveState).toHaveBeenCalled();
expect(lastAgentTimestamps[chatJid]).toBe('1');
expect(channel.sendMessage).not.toHaveBeenCalled();
expect(channel.sendAndTrack).not.toHaveBeenCalled();
});
it('does not emit a visible message when the final output is structured silent output', async () => {
const chatJid = 'group@test';
const group = makeGroup('claude-code');
@@ -1587,7 +1518,7 @@ describe('createMessageRuntime', () => {
}
});
it('starts typing immediately for suppress-capable turns with visible output', async () => {
it('starts typing immediately for turns with visible output', async () => {
const chatJid = 'group@test';
const group = makeGroup('claude-code');
const channel = makeChannel(chatJid);
@@ -1611,12 +1542,12 @@ describe('createMessageRuntime', () => {
await onOutput?.({
status: 'success',
phase: 'final',
result: 'visible suppress-capable reply',
result: 'visible reply',
});
return {
status: 'success',
result: null,
newSessionId: 'session-visible-suppress-capable',
newSessionId: 'session-visible-reply',
};
},
);
@@ -1644,316 +1575,19 @@ describe('createMessageRuntime', () => {
});
const result = await runtime.processGroupMessages(chatJid, {
runId: 'run-visible-suppress-capable',
runId: 'run-visible-reply',
reason: 'messages',
});
expect(result).toBe(true);
expect(channel.sendMessage).toHaveBeenCalledWith(
chatJid,
'visible suppress-capable reply',
'visible reply',
);
expect(channel.setTyping).toHaveBeenCalledWith(chatJid, true);
expect(channel.setTyping).toHaveBeenCalledWith(chatJid, false);
});
it('does not grant suppress-token silence authority to codex-main', async () => {
const chatJid = 'group@test';
const group = makeGroup('codex');
const channel = makeChannel(chatJid);
const lastAgentTimestamps: Record<string, string> = {};
const saveState = vi.fn();
vi.mocked(config.isClaudeService).mockReturnValue(false);
vi.mocked(config.isReviewService).mockReturnValue(false);
vi.mocked(db.getMessagesSince).mockReturnValue([
{
id: 'msg-1',
chat_jid: chatJid,
sender: 'user@test',
sender_name: 'User',
content: 'hello',
timestamp: '2026-03-19T00:00:00.000Z',
seq: 1,
},
]);
vi.mocked(agentRunner.runAgentProcess).mockResolvedValue({
status: 'success',
result: 'visible codex reply',
newSessionId: 'session-codex-main',
});
const runtime = createMessageRuntime({
assistantName: 'Andy',
idleTimeout: 1_000,
pollInterval: 1_000,
timezone: 'UTC',
triggerPattern: /^@Andy\b/i,
channels: [channel],
queue: {
registerProcess: vi.fn(),
closeStdin: vi.fn(),
notifyIdle: vi.fn(),
} as any,
getRegisteredGroups: () => ({ [chatJid]: group }),
getSessions: () => ({}),
getLastTimestamp: () => '',
setLastTimestamp: vi.fn(),
getLastAgentTimestamps: () => lastAgentTimestamps,
saveState,
persistSession: vi.fn(),
clearSession: vi.fn(),
});
const result = await runtime.processGroupMessages(chatJid, {
runId: 'run-codex-main-no-suppress',
reason: 'messages',
});
expect(result).toBe(true);
expect(outputSuppression.createSuppressToken).not.toHaveBeenCalled();
});
it('blocks malformed output when the suppress token is mixed with visible text', async () => {
const chatJid = 'group@test';
const group = makeGroup('codex');
const channel = makeChannel(chatJid);
const lastAgentTimestamps: Record<string, string> = {};
const saveState = vi.fn();
vi.mocked(db.getMessagesSince).mockReturnValue([
{
id: 'msg-1',
chat_jid: chatJid,
sender: 'user@test',
sender_name: 'User',
content: 'hello',
timestamp: '2026-03-19T00:00:00.000Z',
seq: 1,
},
]);
vi.mocked(agentRunner.runAgentProcess).mockResolvedValue({
status: 'success',
result: '동의합니다 __TEST_SUPPRESS__',
newSessionId: 'session-suppress-mixed',
});
const runtime = createMessageRuntime({
assistantName: 'Andy',
idleTimeout: 1_000,
pollInterval: 1_000,
timezone: 'UTC',
triggerPattern: /^@Andy\b/i,
channels: [channel],
queue: {
registerProcess: vi.fn(),
closeStdin: vi.fn(),
notifyIdle: vi.fn(),
} as any,
getRegisteredGroups: () => ({ [chatJid]: group }),
getSessions: () => ({}),
getLastTimestamp: () => '',
setLastTimestamp: vi.fn(),
getLastAgentTimestamps: () => lastAgentTimestamps,
saveState,
persistSession: vi.fn(),
clearSession: vi.fn(),
});
const result = await runtime.processGroupMessages(chatJid, {
runId: 'run-suppress-mixed',
reason: 'messages',
});
expect(result).toBe(true);
expect(saveState).toHaveBeenCalled();
expect(lastAgentTimestamps[chatJid]).toBe('1');
expect(channel.sendMessage).not.toHaveBeenCalled();
expect(channel.sendAndTrack).not.toHaveBeenCalled();
});
it('suppresses a leaked foreign suppress token even when it differs from the current turn token', async () => {
const chatJid = 'group@test';
const group = makeGroup('codex');
const channel = makeChannel(chatJid);
const lastAgentTimestamps: Record<string, string> = {};
const saveState = vi.fn();
vi.mocked(config.isClaudeService).mockReturnValue(false);
vi.mocked(config.isReviewService).mockReturnValue(true);
vi.mocked(db.getMessagesSince).mockReturnValue([
{
id: 'msg-1',
chat_jid: chatJid,
sender: 'user@test',
sender_name: 'User',
content: 'hello',
timestamp: '2026-03-19T00:00:00.000Z',
seq: 1,
},
]);
vi.mocked(agentRunner.runAgentProcess).mockResolvedValue({
status: 'success',
result: '__EJ_SUPPRESS_deadbeefdeadbeefdeadbeef__',
newSessionId: 'session-foreign-suppress-token',
});
const runtime = createMessageRuntime({
assistantName: 'Andy',
idleTimeout: 1_000,
pollInterval: 1_000,
timezone: 'UTC',
triggerPattern: /^@Andy\b/i,
channels: [channel],
queue: {
registerProcess: vi.fn(),
closeStdin: vi.fn(),
notifyIdle: vi.fn(),
} as any,
getRegisteredGroups: () => ({ [chatJid]: group }),
getSessions: () => ({}),
getLastTimestamp: () => '',
setLastTimestamp: vi.fn(),
getLastAgentTimestamps: () => lastAgentTimestamps,
saveState,
persistSession: vi.fn(),
clearSession: vi.fn(),
});
const result = await runtime.processGroupMessages(chatJid, {
runId: 'run-foreign-suppress-token',
reason: 'messages',
});
expect(result).toBe(true);
expect(saveState).toHaveBeenCalled();
expect(lastAgentTimestamps[chatJid]).toBe('1');
expect(channel.sendMessage).not.toHaveBeenCalled();
expect(channel.sendAndTrack).not.toHaveBeenCalled();
});
it('suppresses a malformed leaked foreign suppress token without the closing suffix', async () => {
const chatJid = 'group@test';
const group = makeGroup('codex');
const channel = makeChannel(chatJid);
const lastAgentTimestamps: Record<string, string> = {};
const saveState = vi.fn();
vi.mocked(config.isClaudeService).mockReturnValue(false);
vi.mocked(config.isReviewService).mockReturnValue(true);
vi.mocked(db.getMessagesSince).mockReturnValue([
{
id: 'msg-1',
chat_jid: chatJid,
sender: 'user@test',
sender_name: 'User',
content: 'hello',
timestamp: '2026-03-19T00:00:00.000Z',
seq: 1,
},
]);
vi.mocked(agentRunner.runAgentProcess).mockResolvedValue({
status: 'success',
result: '__EJ_SUPPRESS_deadbeefdeadbeefdeadbeef',
newSessionId: 'session-malformed-foreign-suppress-token',
});
const runtime = createMessageRuntime({
assistantName: 'Andy',
idleTimeout: 1_000,
pollInterval: 1_000,
timezone: 'UTC',
triggerPattern: /^@Andy\b/i,
channels: [channel],
queue: {
registerProcess: vi.fn(),
closeStdin: vi.fn(),
notifyIdle: vi.fn(),
} as any,
getRegisteredGroups: () => ({ [chatJid]: group }),
getSessions: () => ({}),
getLastTimestamp: () => '',
setLastTimestamp: vi.fn(),
getLastAgentTimestamps: () => lastAgentTimestamps,
saveState,
persistSession: vi.fn(),
clearSession: vi.fn(),
});
const result = await runtime.processGroupMessages(chatJid, {
runId: 'run-malformed-foreign-suppress-token',
reason: 'messages',
});
expect(result).toBe(true);
expect(saveState).toHaveBeenCalled();
expect(lastAgentTimestamps[chatJid]).toBe('1');
expect(channel.sendMessage).not.toHaveBeenCalled();
expect(channel.sendAndTrack).not.toHaveBeenCalled();
});
it('suppresses a malformed structured silent envelope without sending a visible message', async () => {
const chatJid = 'group@test';
const group = makeGroup('codex');
const channel = makeChannel(chatJid);
const lastAgentTimestamps: Record<string, string> = {};
const saveState = vi.fn();
vi.mocked(config.isClaudeService).mockReturnValue(false);
vi.mocked(config.isReviewService).mockReturnValue(true);
vi.mocked(db.getMessagesSince).mockReturnValue([
{
id: 'msg-1',
chat_jid: chatJid,
sender: 'user@test',
sender_name: 'User',
content: 'hello',
timestamp: '2026-03-19T00:00:00.000Z',
seq: 1,
},
]);
vi.mocked(agentRunner.runAgentProcess).mockResolvedValue({
status: 'success',
result: '{"ejclaw":{"visibility":"silent"}} extra',
newSessionId: 'session-malformed-structured-silent',
});
const runtime = createMessageRuntime({
assistantName: 'Andy',
idleTimeout: 1_000,
pollInterval: 1_000,
timezone: 'UTC',
triggerPattern: /^@Andy\b/i,
channels: [channel],
queue: {
registerProcess: vi.fn(),
closeStdin: vi.fn(),
notifyIdle: vi.fn(),
} as any,
getRegisteredGroups: () => ({ [chatJid]: group }),
getSessions: () => ({}),
getLastTimestamp: () => '',
setLastTimestamp: vi.fn(),
getLastAgentTimestamps: () => lastAgentTimestamps,
saveState,
persistSession: vi.fn(),
clearSession: vi.fn(),
});
const result = await runtime.processGroupMessages(chatJid, {
runId: 'run-malformed-structured-silent',
reason: 'messages',
});
expect(result).toBe(true);
expect(channel.sendMessage).not.toHaveBeenCalled();
expect(channel.sendAndTrack).not.toHaveBeenCalled();
});
it('resets tracked progress after a final output that becomes empty after formatting', async () => {
vi.useFakeTimers();
const chatJid = 'group@test';

View File

@@ -20,8 +20,6 @@ import {
type WorkItem,
} from './db.js';
import {
isClaudeService,
isReviewService,
isSessionCommandSenderAllowed,
REVIEWER_AGENT_TYPE,
SERVICE_AGENT_TYPE,
@@ -45,7 +43,6 @@ import {
} from './message-runtime-rules.js';
import { runAgentForGroup } from './message-agent-executor.js';
import { MessageTurnController } from './message-turn-controller.js';
import { createSuppressToken } from './output-suppression.js';
import {
extractSessionCommand,
handleSessionCommand,
@@ -232,7 +229,6 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
runId: string,
onOutput?: (output: AgentOutput) => Promise<void>,
options?: {
suppressToken?: string;
startSeq?: number | null;
endSeq?: number | null;
},
@@ -251,7 +247,6 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
prompt,
chatJid,
runId,
suppressToken: options?.suppressToken,
startSeq: options?.startSeq,
endSeq: options?.endSeq,
onOutput,
@@ -280,10 +275,6 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
const { group, prompt, chatJid, runId, channel, startSeq, endSeq } = args;
const isClaudeCodeAgent =
(group.agentType || 'claude-code') === 'claude-code';
const suppressToken =
isClaudeService() || isReviewService()
? createSuppressToken()
: undefined;
const turnController = new MessageTurnController({
chatJid,
@@ -293,7 +284,6 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
idleTimeout: deps.idleTimeout,
failureFinalText: FAILURE_FINAL_TEXT,
isClaudeCodeAgent,
suppressToken,
clearSession: () => deps.clearSession(group.folder),
requestClose: (reason) =>
deps.queue.closeStdin(chatJid, { runId, reason }),
@@ -327,7 +317,7 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
chatJid,
runId,
(result) => turnController.handleOutput(result),
{ suppressToken, startSeq, endSeq },
{ startSeq, endSeq },
);
const { deliverySucceeded, visiblePhase } =

View File

@@ -1,7 +1,6 @@
import { type AgentOutput } from './agent-runner.js';
import { getAgentOutputText, isSilentAgentOutput } from './agent-output.js';
import { getAgentOutputText } from './agent-output.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';
@@ -30,7 +29,6 @@ interface MessageTurnControllerOptions {
idleTimeout: number;
failureFinalText: string;
isClaudeCodeAgent: boolean;
suppressToken?: string;
clearSession: () => void;
requestClose: (reason: string) => void;
deliverFinalText: (text: string) => Promise<boolean>;
@@ -122,8 +120,6 @@ export class MessageTurnController {
}
const raw = getAgentOutputText(result);
const silentOutput = false;
const suppressState = 'none' as const;
const text = raw ? formatOutbound(raw) : null;
if (raw) {
@@ -267,14 +263,6 @@ export class MessageTurnController {
await this.flushPendingProgress(text);
await this.finalizeProgressMessage();
await this.deliverFinalText(text);
} else if (silentOutput || suppressState !== 'none') {
const shouldPromoteVisibleProgressToFinal =
this.visiblePhase === 'progress' &&
typeof this.latestProgressTextForFinal === 'string';
await this.finalizeProgressMessage();
if (!shouldPromoteVisibleProgressToFinal) {
this.latestProgressTextForFinal = null;
}
} else if (raw) {
logger.info(
{

View File

@@ -1,140 +0,0 @@
import { describe, expect, it } from 'vitest';
import {
buildStructuredOutputPrompt,
classifySuppressTokenOutput,
parseStructuredOutputEnvelope,
} from './output-suppression.js';
describe('classifySuppressTokenOutput', () => {
it('treats the current turn suppress token as exact', () => {
expect(
classifySuppressTokenOutput(
'__EJ_SUPPRESS_deadbeefdeadbeefdeadbeef__',
'__EJ_SUPPRESS_deadbeefdeadbeefdeadbeef__',
),
).toBe('exact');
});
it('treats a leaked foreign suppress token as exact silent output', () => {
expect(
classifySuppressTokenOutput(
'__EJ_SUPPRESS_feedfacefeedfacefeedface__',
'__EJ_SUPPRESS_deadbeefdeadbeefdeadbeef__',
),
).toBe('exact');
});
it('treats a malformed foreign suppress token without the closing suffix as exact silent output', () => {
expect(
classifySuppressTokenOutput(
'__EJ_SUPPRESS_feedfacefeedfacefeedface',
'__EJ_SUPPRESS_deadbeefdeadbeefdeadbeef__',
),
).toBe('exact');
});
it('treats a suppress token embedded in visible text as mixed', () => {
expect(
classifySuppressTokenOutput(
'prefix __EJ_SUPPRESS_feedfacefeedfacefeedface__ suffix',
'__EJ_SUPPRESS_deadbeefdeadbeefdeadbeef__',
),
).toBe('mixed');
});
it('treats the exact structured silent envelope as exact silent output', () => {
expect(
classifySuppressTokenOutput(
'{"ejclaw":{"visibility":"silent"}}',
undefined,
),
).toBe('exact');
});
it('treats a truncated structured silent envelope as mixed', () => {
expect(
classifySuppressTokenOutput(
'{"ejclaw":{"visibility":"silent"',
undefined,
),
).toBe('mixed');
});
it('treats a structured silent envelope mixed with extra text as mixed', () => {
expect(
classifySuppressTokenOutput(
'{"ejclaw":{"visibility":"silent"}} extra',
undefined,
),
).toBe('mixed');
});
});
describe('parseStructuredOutputEnvelope', () => {
it('parses the exact silent envelope', () => {
expect(
parseStructuredOutputEnvelope('{"ejclaw":{"visibility":"silent"}}'),
).toEqual({ visibility: 'silent' });
});
it('parses a public envelope', () => {
expect(
parseStructuredOutputEnvelope(
'{"ejclaw":{"visibility":"public","text":"hello"}}',
),
).toEqual({ visibility: 'public', text: 'hello' });
});
it('parses a public envelope with a reviewer verdict', () => {
expect(
parseStructuredOutputEnvelope(
'{"ejclaw":{"visibility":"public","verdict":"done_with_concerns","text":"**DONE_WITH_CONCERNS**"}}',
),
).toEqual({
visibility: 'public',
verdict: 'done_with_concerns',
text: '**DONE_WITH_CONCERNS**',
});
});
});
describe('buildStructuredOutputPrompt', () => {
it('prepends the structured output control block', () => {
expect(buildStructuredOutputPrompt('hello')).toContain(
'If you have no user-visible content to send for this turn, output exactly this JSON and nothing else: {"ejclaw":{"visibility":"silent"}}',
);
expect(buildStructuredOutputPrompt('hello')).toContain(
'If you have already emitted any visible progress, status update, or partial answer earlier in this turn, do not end with the JSON object. Finish with a short visible final conclusion for the user instead.',
);
});
it('tightens the reviewer silent rule when reviewer mode is enabled', () => {
expect(
buildStructuredOutputPrompt('hello', { reviewerMode: true }),
).toContain(
'If you have not already emitted any visible progress, status update, or partial answer in this turn and you are only agreeing, mirroring, or restating without adding a concrete correction, risk, missing prerequisite, test gap, or code change, output only the JSON object.',
);
});
it('requires a visible structured verdict on reviewer gate turns', () => {
expect(
buildStructuredOutputPrompt('hello', {
reviewerMode: true,
gateTurnKind: 'implementation_start',
requiresVisibleVerdict: true,
}),
).toContain(
'This turn is a paired-room gate turn for implementation_start. Silent output is forbidden.',
);
expect(
buildStructuredOutputPrompt('hello', {
reviewerMode: true,
gateTurnKind: 'implementation_start',
requiresVisibleVerdict: true,
}),
).toContain(
'Allowed verdict values are: "done", "done_with_concerns", "blocked".',
);
});
});

View File

@@ -1,33 +0,0 @@
/**
* Output suppression was removed — harness-level protections (isOwnMessage filter,
* shouldSkipBotOnlyCollaboration, PAIRED_MAX_ROUND_TRIPS) now prevent infinite loops.
*
* Retained exports are stubs so callers compile without changes.
*/
export function createSuppressToken(): string | undefined {
return undefined;
}
export function classifySuppressTokenOutput(
_rawText: string,
_suppressToken: string | undefined,
): 'none' {
return 'none';
}
export function buildStructuredOutputPrompt(
prompt: string,
_options?: {
reviewerMode?: boolean;
pairedRoom?: boolean;
gateTurnKind?: string | null;
requiresVisibleVerdict?: boolean;
},
): string {
return prompt;
}
export function parseStructuredOutputEnvelope(_rawText: string): null {
return null;
}

View File

@@ -45,7 +45,7 @@ describe('paired workspace manager', () => {
fs.rmSync(tempRoot, { recursive: true, force: true });
});
it('provisions an owner worktree and refreshes a reviewer shadow snapshot', async () => {
it('registers the owner workspace for reviewer execution when review is requested', async () => {
const { db, manager } = await loadModules();
db._initTestDatabase();
@@ -113,15 +113,10 @@ describe('paired workspace manager', () => {
'utf-8',
),
).toBe('review me\n');
expect(
runGit(
['config', '--local', '--get', 'remote.origin.pushurl'],
reviewerWorkspace.workspace_dir,
),
).toBe('DISABLED_BY_EJCLAW');
expect(
runGit(['status', '--short'], reviewerWorkspace.workspace_dir),
).toContain('M README.md');
expect(reviewerWorkspace.workspace_dir).toBe(ownerWorkspace.workspace_dir);
expect(reviewerWorkspace.snapshot_source_dir).toBe(
ownerWorkspace.workspace_dir,
);
expect(db.getPairedTaskById('paired-task-1')?.status).toBe('review_ready');
expect(
db.getPairedTaskById('paired-task-1')?.review_requested_at,
@@ -129,9 +124,9 @@ describe('paired workspace manager', () => {
expect(
db.getPairedWorkspace('paired-task-1', 'reviewer')?.snapshot_refreshed_at,
).toBeTruthy();
expect(
db.getPairedWorkspace('paired-task-1', 'reviewer')?.snapshot_ref,
).toBeTruthy();
expect(db.getPairedWorkspace('paired-task-1', 'reviewer')?.snapshot_ref).toBe(
null,
);
});
it('uses the shared DB owner workspace across service-local data dirs', async () => {
@@ -493,7 +488,7 @@ describe('paired workspace manager', () => {
).toBe('EXAMPLE=1\n');
});
it('auto-refreshes a missing reviewer snapshot after an explicit review request', async () => {
it('registers a reviewer workspace when an explicit review request already exists', async () => {
const { db, manager } = await loadModules();
db._initTestDatabase();
@@ -541,8 +536,12 @@ describe('paired workspace manager', () => {
db.getPairedTaskById('paired-task-5b')!,
);
expect(result.autoRefreshed).toBe(true);
expect(result.autoRefreshed).toBe(false);
expect(result.blockMessage).toBeUndefined();
expect(result.workspace?.workspace_dir).toBe(ownerWorkspace.workspace_dir);
expect(result.workspace?.snapshot_source_dir).toBe(
ownerWorkspace.workspace_dir,
);
expect(
fs.readFileSync(
path.join(result.workspace!.workspace_dir, 'README.md'),
@@ -555,7 +554,7 @@ describe('paired workspace manager', () => {
);
});
it('blocks once when an in-review snapshot becomes stale, then refreshes on retry', async () => {
it('reuses the live owner workspace during in-review turns', async () => {
const { db, manager } = await loadModules();
db._initTestDatabase();
@@ -609,37 +608,22 @@ describe('paired workspace manager', () => {
'owner changed again\n',
);
const blocked = manager.prepareReviewerWorkspaceForExecution(
const result = manager.prepareReviewerWorkspaceForExecution(
db.getPairedTaskById('paired-task-6')!,
);
expect(blocked.workspace).toBeNull();
expect(blocked.autoRefreshed).toBe(false);
expect(blocked.blockMessage).toBe(
'Review snapshot is stale after owner changes. Retry the review once to refresh against the latest owner workspace.',
);
expect(db.getPairedTaskById('paired-task-6')?.status).toBe('review_ready');
expect(result.workspace?.workspace_dir).toBe(ownerWorkspace.workspace_dir);
expect(result.autoRefreshed).toBe(false);
expect(result.blockMessage).toBeUndefined();
expect(db.getPairedTaskById('paired-task-6')?.status).toBe('in_review');
expect(
db.getPairedTaskById('paired-task-6')?.review_requested_at,
).toBeTruthy();
expect(db.getPairedWorkspace('paired-task-6', 'reviewer')?.status).toBe(
'stale',
);
const refreshed = manager.prepareReviewerWorkspaceForExecution(
db.getPairedTaskById('paired-task-6')!,
);
expect(refreshed.autoRefreshed).toBe(true);
expect(refreshed.blockMessage).toBeUndefined();
expect(
fs.readFileSync(
path.join(refreshed.workspace!.workspace_dir, 'README.md'),
path.join(result.workspace!.workspace_dir, 'README.md'),
'utf-8',
),
).toBe('owner changed again\n');
expect(
db.getPairedTaskById('paired-task-6')?.review_requested_at,
).toBeTruthy();
});
});