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' };
|
const group = { ...makeGroup(), folder: 'test-group' };
|
||||||
|
|
||||||
await runAgentForGroup(makeDeps(), {
|
await runAgentForGroup(makeDeps(), {
|
||||||
@@ -232,7 +232,6 @@ describe('runAgentForGroup room memory', () => {
|
|||||||
prompt: 'hello',
|
prompt: 'hello',
|
||||||
chatJid: 'group@test',
|
chatJid: 'group@test',
|
||||||
runId: 'run-suppress',
|
runId: 'run-suppress',
|
||||||
suppressToken: '__TEST_SUPPRESS__',
|
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(agentRunner.runAgentProcess).toHaveBeenCalledWith(
|
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' };
|
const group = { ...makeGroup(), folder: 'test-group' };
|
||||||
vi.mocked(serviceRouting.getEffectiveChannelLease).mockReturnValue({
|
vi.mocked(serviceRouting.getEffectiveChannelLease).mockReturnValue({
|
||||||
chat_jid: 'group@test',
|
chat_jid: 'group@test',
|
||||||
@@ -289,7 +288,6 @@ describe('runAgentForGroup room memory', () => {
|
|||||||
prompt: 'hello',
|
prompt: 'hello',
|
||||||
chatJid: 'group@test',
|
chatJid: 'group@test',
|
||||||
runId: 'run-review-suppress',
|
runId: 'run-review-suppress',
|
||||||
suppressToken: '__TEST_SUPPRESS__',
|
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(agentRunner.runAgentProcess).toHaveBeenCalledWith(
|
expect(agentRunner.runAgentProcess).toHaveBeenCalledWith(
|
||||||
|
|||||||
@@ -40,10 +40,6 @@ import {
|
|||||||
REVIEWER_AGENT_TYPE,
|
REVIEWER_AGENT_TYPE,
|
||||||
isClaudeService,
|
isClaudeService,
|
||||||
} from './config.js';
|
} from './config.js';
|
||||||
import {
|
|
||||||
buildStructuredOutputPrompt,
|
|
||||||
classifySuppressTokenOutput,
|
|
||||||
} from './output-suppression.js';
|
|
||||||
import {
|
import {
|
||||||
activateCodexFailover,
|
activateCodexFailover,
|
||||||
getEffectiveChannelLease,
|
getEffectiveChannelLease,
|
||||||
@@ -80,7 +76,6 @@ export async function runAgentForGroup(
|
|||||||
prompt: string;
|
prompt: string;
|
||||||
chatJid: string;
|
chatJid: string;
|
||||||
runId: string;
|
runId: string;
|
||||||
suppressToken?: string;
|
|
||||||
startSeq?: number | null;
|
startSeq?: number | null;
|
||||||
endSeq?: number | null;
|
endSeq?: number | null;
|
||||||
onOutput?: (output: AgentOutput) => Promise<void>;
|
onOutput?: (output: AgentOutput) => Promise<void>;
|
||||||
@@ -91,7 +86,6 @@ export async function runAgentForGroup(
|
|||||||
prompt,
|
prompt,
|
||||||
chatJid,
|
chatJid,
|
||||||
runId,
|
runId,
|
||||||
suppressToken,
|
|
||||||
startSeq,
|
startSeq,
|
||||||
endSeq,
|
endSeq,
|
||||||
onOutput,
|
onOutput,
|
||||||
@@ -174,12 +168,7 @@ export async function runAgentForGroup(
|
|||||||
runId,
|
runId,
|
||||||
roomRoleContext,
|
roomRoleContext,
|
||||||
});
|
});
|
||||||
const effectivePrompt = buildStructuredOutputPrompt(prompt, {
|
const effectivePrompt = prompt;
|
||||||
reviewerMode,
|
|
||||||
pairedRoom: !!pairedExecutionContext,
|
|
||||||
gateTurnKind: pairedExecutionContext?.gateTurnKind,
|
|
||||||
requiresVisibleVerdict: pairedExecutionContext?.requiresVisibleVerdict,
|
|
||||||
});
|
|
||||||
let pairedExecutionStatus: 'succeeded' | 'failed' = 'failed';
|
let pairedExecutionStatus: 'succeeded' | 'failed' = 'failed';
|
||||||
let pairedExecutionSummary: string | null = null;
|
let pairedExecutionSummary: string | null = null;
|
||||||
let pairedExecutionCompleted = false;
|
let pairedExecutionCompleted = false;
|
||||||
@@ -399,14 +388,9 @@ export async function runAgentForGroup(
|
|||||||
if (!evaluation.shouldForwardOutput) {
|
if (!evaluation.shouldForwardOutput) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const suppressState =
|
|
||||||
typeof outputText === 'string'
|
|
||||||
? classifySuppressTokenOutput(outputText, suppressToken)
|
|
||||||
: 'none';
|
|
||||||
if (
|
if (
|
||||||
typeof outputText === 'string' &&
|
typeof outputText === 'string' &&
|
||||||
outputText.length > 0 &&
|
outputText.length > 0
|
||||||
suppressState === 'none'
|
|
||||||
) {
|
) {
|
||||||
streamedState = {
|
streamedState = {
|
||||||
...evaluation.state,
|
...evaluation.state,
|
||||||
|
|||||||
@@ -11,15 +11,6 @@ vi.mock('./agent-runner.js', () => ({
|
|||||||
writeTasksSnapshot: vi.fn(),
|
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', () => ({
|
vi.mock('./config.js', () => ({
|
||||||
DATA_DIR: '/tmp/ejclaw-test-data',
|
DATA_DIR: '/tmp/ejclaw-test-data',
|
||||||
SERVICE_ID: 'claude',
|
SERVICE_ID: 'claude',
|
||||||
@@ -179,7 +170,6 @@ import * as agentRunner from './agent-runner.js';
|
|||||||
import * as db from './db.js';
|
import * as db from './db.js';
|
||||||
import { resolveGroupIpcPath } from './group-folder.js';
|
import { resolveGroupIpcPath } from './group-folder.js';
|
||||||
import { createMessageRuntime } from './message-runtime.js';
|
import { createMessageRuntime } from './message-runtime.js';
|
||||||
import * as outputSuppression from './output-suppression.js';
|
|
||||||
import * as config from './config.js';
|
import * as config from './config.js';
|
||||||
import type { Channel, RegisteredGroup } from './types.js';
|
import type { Channel, RegisteredGroup } from './types.js';
|
||||||
|
|
||||||
@@ -345,7 +335,7 @@ describe('createMessageRuntime', () => {
|
|||||||
expect(saveState).toHaveBeenCalled();
|
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 chatJid = 'group@test';
|
||||||
const group = makeGroup('codex');
|
const group = makeGroup('codex');
|
||||||
const channel = makeChannel(chatJid);
|
const channel = makeChannel(chatJid);
|
||||||
@@ -1374,65 +1364,6 @@ describe('createMessageRuntime', () => {
|
|||||||
expect(channel.sendAndTrack).not.toHaveBeenCalled();
|
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 () => {
|
it('does not emit a visible message when the final output is structured silent output', async () => {
|
||||||
const chatJid = 'group@test';
|
const chatJid = 'group@test';
|
||||||
const group = makeGroup('claude-code');
|
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 chatJid = 'group@test';
|
||||||
const group = makeGroup('claude-code');
|
const group = makeGroup('claude-code');
|
||||||
const channel = makeChannel(chatJid);
|
const channel = makeChannel(chatJid);
|
||||||
@@ -1611,12 +1542,12 @@ describe('createMessageRuntime', () => {
|
|||||||
await onOutput?.({
|
await onOutput?.({
|
||||||
status: 'success',
|
status: 'success',
|
||||||
phase: 'final',
|
phase: 'final',
|
||||||
result: 'visible suppress-capable reply',
|
result: 'visible reply',
|
||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
status: 'success',
|
status: 'success',
|
||||||
result: null,
|
result: null,
|
||||||
newSessionId: 'session-visible-suppress-capable',
|
newSessionId: 'session-visible-reply',
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -1644,316 +1575,19 @@ describe('createMessageRuntime', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const result = await runtime.processGroupMessages(chatJid, {
|
const result = await runtime.processGroupMessages(chatJid, {
|
||||||
runId: 'run-visible-suppress-capable',
|
runId: 'run-visible-reply',
|
||||||
reason: 'messages',
|
reason: 'messages',
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result).toBe(true);
|
expect(result).toBe(true);
|
||||||
expect(channel.sendMessage).toHaveBeenCalledWith(
|
expect(channel.sendMessage).toHaveBeenCalledWith(
|
||||||
chatJid,
|
chatJid,
|
||||||
'visible suppress-capable reply',
|
'visible reply',
|
||||||
);
|
);
|
||||||
expect(channel.setTyping).toHaveBeenCalledWith(chatJid, true);
|
expect(channel.setTyping).toHaveBeenCalledWith(chatJid, true);
|
||||||
expect(channel.setTyping).toHaveBeenCalledWith(chatJid, false);
|
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 () => {
|
it('resets tracked progress after a final output that becomes empty after formatting', async () => {
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
const chatJid = 'group@test';
|
const chatJid = 'group@test';
|
||||||
|
|||||||
@@ -20,8 +20,6 @@ import {
|
|||||||
type WorkItem,
|
type WorkItem,
|
||||||
} from './db.js';
|
} from './db.js';
|
||||||
import {
|
import {
|
||||||
isClaudeService,
|
|
||||||
isReviewService,
|
|
||||||
isSessionCommandSenderAllowed,
|
isSessionCommandSenderAllowed,
|
||||||
REVIEWER_AGENT_TYPE,
|
REVIEWER_AGENT_TYPE,
|
||||||
SERVICE_AGENT_TYPE,
|
SERVICE_AGENT_TYPE,
|
||||||
@@ -45,7 +43,6 @@ import {
|
|||||||
} from './message-runtime-rules.js';
|
} from './message-runtime-rules.js';
|
||||||
import { runAgentForGroup } from './message-agent-executor.js';
|
import { runAgentForGroup } from './message-agent-executor.js';
|
||||||
import { MessageTurnController } from './message-turn-controller.js';
|
import { MessageTurnController } from './message-turn-controller.js';
|
||||||
import { createSuppressToken } from './output-suppression.js';
|
|
||||||
import {
|
import {
|
||||||
extractSessionCommand,
|
extractSessionCommand,
|
||||||
handleSessionCommand,
|
handleSessionCommand,
|
||||||
@@ -232,7 +229,6 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
|
|||||||
runId: string,
|
runId: string,
|
||||||
onOutput?: (output: AgentOutput) => Promise<void>,
|
onOutput?: (output: AgentOutput) => Promise<void>,
|
||||||
options?: {
|
options?: {
|
||||||
suppressToken?: string;
|
|
||||||
startSeq?: number | null;
|
startSeq?: number | null;
|
||||||
endSeq?: number | null;
|
endSeq?: number | null;
|
||||||
},
|
},
|
||||||
@@ -251,7 +247,6 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
|
|||||||
prompt,
|
prompt,
|
||||||
chatJid,
|
chatJid,
|
||||||
runId,
|
runId,
|
||||||
suppressToken: options?.suppressToken,
|
|
||||||
startSeq: options?.startSeq,
|
startSeq: options?.startSeq,
|
||||||
endSeq: options?.endSeq,
|
endSeq: options?.endSeq,
|
||||||
onOutput,
|
onOutput,
|
||||||
@@ -280,10 +275,6 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
|
|||||||
const { group, prompt, chatJid, runId, channel, startSeq, endSeq } = args;
|
const { group, prompt, chatJid, runId, channel, startSeq, endSeq } = args;
|
||||||
const isClaudeCodeAgent =
|
const isClaudeCodeAgent =
|
||||||
(group.agentType || 'claude-code') === 'claude-code';
|
(group.agentType || 'claude-code') === 'claude-code';
|
||||||
const suppressToken =
|
|
||||||
isClaudeService() || isReviewService()
|
|
||||||
? createSuppressToken()
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
const turnController = new MessageTurnController({
|
const turnController = new MessageTurnController({
|
||||||
chatJid,
|
chatJid,
|
||||||
@@ -293,7 +284,6 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
|
|||||||
idleTimeout: deps.idleTimeout,
|
idleTimeout: deps.idleTimeout,
|
||||||
failureFinalText: FAILURE_FINAL_TEXT,
|
failureFinalText: FAILURE_FINAL_TEXT,
|
||||||
isClaudeCodeAgent,
|
isClaudeCodeAgent,
|
||||||
suppressToken,
|
|
||||||
clearSession: () => deps.clearSession(group.folder),
|
clearSession: () => deps.clearSession(group.folder),
|
||||||
requestClose: (reason) =>
|
requestClose: (reason) =>
|
||||||
deps.queue.closeStdin(chatJid, { runId, reason }),
|
deps.queue.closeStdin(chatJid, { runId, reason }),
|
||||||
@@ -327,7 +317,7 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
|
|||||||
chatJid,
|
chatJid,
|
||||||
runId,
|
runId,
|
||||||
(result) => turnController.handleOutput(result),
|
(result) => turnController.handleOutput(result),
|
||||||
{ suppressToken, startSeq, endSeq },
|
{ startSeq, endSeq },
|
||||||
);
|
);
|
||||||
|
|
||||||
const { deliverySucceeded, visiblePhase } =
|
const { deliverySucceeded, visiblePhase } =
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { type AgentOutput } from './agent-runner.js';
|
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 { logger } from './logger.js';
|
||||||
import { classifySuppressTokenOutput } from './output-suppression.js';
|
|
||||||
import { formatOutbound } from './router.js';
|
import { formatOutbound } from './router.js';
|
||||||
import { shouldResetSessionOnAgentFailure } from './session-recovery.js';
|
import { shouldResetSessionOnAgentFailure } from './session-recovery.js';
|
||||||
import { TASK_STATUS_MESSAGE_PREFIX } from './task-watch-status.js';
|
import { TASK_STATUS_MESSAGE_PREFIX } from './task-watch-status.js';
|
||||||
@@ -30,7 +29,6 @@ interface MessageTurnControllerOptions {
|
|||||||
idleTimeout: number;
|
idleTimeout: number;
|
||||||
failureFinalText: string;
|
failureFinalText: string;
|
||||||
isClaudeCodeAgent: boolean;
|
isClaudeCodeAgent: boolean;
|
||||||
suppressToken?: string;
|
|
||||||
clearSession: () => void;
|
clearSession: () => void;
|
||||||
requestClose: (reason: string) => void;
|
requestClose: (reason: string) => void;
|
||||||
deliverFinalText: (text: string) => Promise<boolean>;
|
deliverFinalText: (text: string) => Promise<boolean>;
|
||||||
@@ -122,8 +120,6 @@ export class MessageTurnController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const raw = getAgentOutputText(result);
|
const raw = getAgentOutputText(result);
|
||||||
const silentOutput = false;
|
|
||||||
const suppressState = 'none' as const;
|
|
||||||
const text = raw ? formatOutbound(raw) : null;
|
const text = raw ? formatOutbound(raw) : null;
|
||||||
|
|
||||||
if (raw) {
|
if (raw) {
|
||||||
@@ -267,14 +263,6 @@ export class MessageTurnController {
|
|||||||
await this.flushPendingProgress(text);
|
await this.flushPendingProgress(text);
|
||||||
await this.finalizeProgressMessage();
|
await this.finalizeProgressMessage();
|
||||||
await this.deliverFinalText(text);
|
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) {
|
} else if (raw) {
|
||||||
logger.info(
|
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 });
|
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();
|
const { db, manager } = await loadModules();
|
||||||
db._initTestDatabase();
|
db._initTestDatabase();
|
||||||
|
|
||||||
@@ -113,15 +113,10 @@ describe('paired workspace manager', () => {
|
|||||||
'utf-8',
|
'utf-8',
|
||||||
),
|
),
|
||||||
).toBe('review me\n');
|
).toBe('review me\n');
|
||||||
expect(
|
expect(reviewerWorkspace.workspace_dir).toBe(ownerWorkspace.workspace_dir);
|
||||||
runGit(
|
expect(reviewerWorkspace.snapshot_source_dir).toBe(
|
||||||
['config', '--local', '--get', 'remote.origin.pushurl'],
|
ownerWorkspace.workspace_dir,
|
||||||
reviewerWorkspace.workspace_dir,
|
);
|
||||||
),
|
|
||||||
).toBe('DISABLED_BY_EJCLAW');
|
|
||||||
expect(
|
|
||||||
runGit(['status', '--short'], reviewerWorkspace.workspace_dir),
|
|
||||||
).toContain('M README.md');
|
|
||||||
expect(db.getPairedTaskById('paired-task-1')?.status).toBe('review_ready');
|
expect(db.getPairedTaskById('paired-task-1')?.status).toBe('review_ready');
|
||||||
expect(
|
expect(
|
||||||
db.getPairedTaskById('paired-task-1')?.review_requested_at,
|
db.getPairedTaskById('paired-task-1')?.review_requested_at,
|
||||||
@@ -129,9 +124,9 @@ describe('paired workspace manager', () => {
|
|||||||
expect(
|
expect(
|
||||||
db.getPairedWorkspace('paired-task-1', 'reviewer')?.snapshot_refreshed_at,
|
db.getPairedWorkspace('paired-task-1', 'reviewer')?.snapshot_refreshed_at,
|
||||||
).toBeTruthy();
|
).toBeTruthy();
|
||||||
expect(
|
expect(db.getPairedWorkspace('paired-task-1', 'reviewer')?.snapshot_ref).toBe(
|
||||||
db.getPairedWorkspace('paired-task-1', 'reviewer')?.snapshot_ref,
|
null,
|
||||||
).toBeTruthy();
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('uses the shared DB owner workspace across service-local data dirs', async () => {
|
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');
|
).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();
|
const { db, manager } = await loadModules();
|
||||||
db._initTestDatabase();
|
db._initTestDatabase();
|
||||||
|
|
||||||
@@ -541,8 +536,12 @@ describe('paired workspace manager', () => {
|
|||||||
db.getPairedTaskById('paired-task-5b')!,
|
db.getPairedTaskById('paired-task-5b')!,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(result.autoRefreshed).toBe(true);
|
expect(result.autoRefreshed).toBe(false);
|
||||||
expect(result.blockMessage).toBeUndefined();
|
expect(result.blockMessage).toBeUndefined();
|
||||||
|
expect(result.workspace?.workspace_dir).toBe(ownerWorkspace.workspace_dir);
|
||||||
|
expect(result.workspace?.snapshot_source_dir).toBe(
|
||||||
|
ownerWorkspace.workspace_dir,
|
||||||
|
);
|
||||||
expect(
|
expect(
|
||||||
fs.readFileSync(
|
fs.readFileSync(
|
||||||
path.join(result.workspace!.workspace_dir, 'README.md'),
|
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();
|
const { db, manager } = await loadModules();
|
||||||
db._initTestDatabase();
|
db._initTestDatabase();
|
||||||
|
|
||||||
@@ -609,37 +608,22 @@ describe('paired workspace manager', () => {
|
|||||||
'owner changed again\n',
|
'owner changed again\n',
|
||||||
);
|
);
|
||||||
|
|
||||||
const blocked = manager.prepareReviewerWorkspaceForExecution(
|
const result = manager.prepareReviewerWorkspaceForExecution(
|
||||||
db.getPairedTaskById('paired-task-6')!,
|
db.getPairedTaskById('paired-task-6')!,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(blocked.workspace).toBeNull();
|
expect(result.workspace?.workspace_dir).toBe(ownerWorkspace.workspace_dir);
|
||||||
expect(blocked.autoRefreshed).toBe(false);
|
expect(result.autoRefreshed).toBe(false);
|
||||||
expect(blocked.blockMessage).toBe(
|
expect(result.blockMessage).toBeUndefined();
|
||||||
'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('in_review');
|
||||||
);
|
|
||||||
expect(db.getPairedTaskById('paired-task-6')?.status).toBe('review_ready');
|
|
||||||
expect(
|
expect(
|
||||||
db.getPairedTaskById('paired-task-6')?.review_requested_at,
|
db.getPairedTaskById('paired-task-6')?.review_requested_at,
|
||||||
).toBeTruthy();
|
).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(
|
expect(
|
||||||
fs.readFileSync(
|
fs.readFileSync(
|
||||||
path.join(refreshed.workspace!.workspace_dir, 'README.md'),
|
path.join(result.workspace!.workspace_dir, 'README.md'),
|
||||||
'utf-8',
|
'utf-8',
|
||||||
),
|
),
|
||||||
).toBe('owner changed again\n');
|
).toBe('owner changed again\n');
|
||||||
expect(
|
|
||||||
db.getPairedTaskById('paired-task-6')?.review_requested_at,
|
|
||||||
).toBeTruthy();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user