refactor: remove dead output suppression path
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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 } =
|
||||
|
||||
@@ -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(
|
||||
{
|
||||
|
||||
@@ -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".',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user