feat: add high-risk paired plan gate
This commit is contained in:
@@ -38,6 +38,10 @@ vi.mock('./paired-execution-context.js', () => ({
|
|||||||
completePairedExecutionContext: vi.fn(),
|
completePairedExecutionContext: vi.fn(),
|
||||||
markRoomReviewReady: vi.fn(() => null),
|
markRoomReviewReady: vi.fn(() => null),
|
||||||
formatRoomReviewReadyMessage: vi.fn(() => null),
|
formatRoomReviewReadyMessage: vi.fn(() => null),
|
||||||
|
setRoomTaskRiskLevel: vi.fn(() => null),
|
||||||
|
recordRoomPlan: vi.fn(() => null),
|
||||||
|
approveRoomPlan: vi.fn(() => null),
|
||||||
|
requestRoomPlanChanges: vi.fn(() => null),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('./db.js', () => {
|
vi.mock('./db.js', () => {
|
||||||
@@ -312,6 +316,249 @@ describe('createMessageRuntime', () => {
|
|||||||
expect(channel.sendMessage).toHaveBeenCalledWith(chatJid, pendingMessage);
|
expect(channel.sendMessage).toHaveBeenCalledWith(chatJid, pendingMessage);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('surfaces the high-risk plan gate message through the message-runtime /review path', async () => {
|
||||||
|
const chatJid = 'group@test';
|
||||||
|
const group = {
|
||||||
|
...makeGroup('codex'),
|
||||||
|
workDir: '/repo/canonical',
|
||||||
|
};
|
||||||
|
const channel = makeChannel(chatJid);
|
||||||
|
const saveState = vi.fn();
|
||||||
|
const lastAgentTimestamps: Record<string, string> = {};
|
||||||
|
const blockedMessage = [
|
||||||
|
'Plan review is required before formal review for this high-risk task.',
|
||||||
|
'- Task: paired-task-1',
|
||||||
|
'- Plan status: pending',
|
||||||
|
'Ask the owner to record a plan and have the reviewer approve it before /review.',
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
vi.mocked(db.isPairedRoomJid).mockReturnValue(true);
|
||||||
|
vi.mocked(db.getMessagesSince).mockReturnValue([
|
||||||
|
{
|
||||||
|
id: 'msg-review',
|
||||||
|
chat_jid: chatJid,
|
||||||
|
sender: 'me@test',
|
||||||
|
sender_name: 'Me',
|
||||||
|
content: '/review',
|
||||||
|
timestamp: '2026-03-29T00:00:00.000Z',
|
||||||
|
is_from_me: true,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
const actualSessionCommands =
|
||||||
|
await vi.importActual<typeof import('./session-commands.js')>(
|
||||||
|
'./session-commands.js',
|
||||||
|
);
|
||||||
|
vi.mocked(sessionCommands.handleSessionCommand).mockImplementation((opts) =>
|
||||||
|
actualSessionCommands.handleSessionCommand(opts),
|
||||||
|
);
|
||||||
|
vi.mocked(pairedExecutionContext.markRoomReviewReady).mockReturnValue({
|
||||||
|
status: 'blocked',
|
||||||
|
task: {
|
||||||
|
id: 'paired-task-1',
|
||||||
|
chat_jid: chatJid,
|
||||||
|
group_folder: group.folder,
|
||||||
|
owner_service_id: 'claude',
|
||||||
|
reviewer_service_id: 'codex-main',
|
||||||
|
title: null,
|
||||||
|
source_ref: 'HEAD',
|
||||||
|
task_policy: 'autonomous',
|
||||||
|
risk_level: 'high',
|
||||||
|
plan_status: 'pending',
|
||||||
|
review_requested_at: null,
|
||||||
|
status: 'plan_review_pending',
|
||||||
|
created_at: '2026-03-29T00:00:00.000Z',
|
||||||
|
updated_at: '2026-03-29T00:00:00.000Z',
|
||||||
|
},
|
||||||
|
blockedReason: 'plan-review-required',
|
||||||
|
});
|
||||||
|
vi.mocked(
|
||||||
|
pairedExecutionContext.formatRoomReviewReadyMessage,
|
||||||
|
).mockReturnValue(blockedMessage);
|
||||||
|
|
||||||
|
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-review-blocked',
|
||||||
|
reason: 'messages',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toBe(true);
|
||||||
|
expect(channel.sendMessage).toHaveBeenCalledWith(chatJid, blockedMessage);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('routes /approve-plan through reviewer context in the message-runtime path', async () => {
|
||||||
|
const chatJid = 'group@test';
|
||||||
|
const group = {
|
||||||
|
...makeGroup('codex'),
|
||||||
|
workDir: '/repo/canonical',
|
||||||
|
};
|
||||||
|
const channel = makeChannel(chatJid);
|
||||||
|
const saveState = vi.fn();
|
||||||
|
const lastAgentTimestamps: Record<string, string> = {};
|
||||||
|
|
||||||
|
vi.mocked(db.isPairedRoomJid).mockReturnValue(true);
|
||||||
|
vi.mocked(db.getMessagesSince).mockReturnValue([
|
||||||
|
{
|
||||||
|
id: 'msg-approve-plan',
|
||||||
|
chat_jid: chatJid,
|
||||||
|
sender: 'me@test',
|
||||||
|
sender_name: 'Me',
|
||||||
|
content: '/approve-plan',
|
||||||
|
timestamp: '2026-03-29T00:00:00.000Z',
|
||||||
|
is_from_me: true,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
const actualSessionCommands =
|
||||||
|
await vi.importActual<typeof import('./session-commands.js')>(
|
||||||
|
'./session-commands.js',
|
||||||
|
);
|
||||||
|
vi.mocked(sessionCommands.handleSessionCommand).mockImplementation((opts) =>
|
||||||
|
actualSessionCommands.handleSessionCommand(opts),
|
||||||
|
);
|
||||||
|
vi.mocked(pairedExecutionContext.approveRoomPlan).mockReturnValue(
|
||||||
|
'Plan approved.\n- Task: paired-task-1',
|
||||||
|
);
|
||||||
|
|
||||||
|
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-approve-plan',
|
||||||
|
reason: 'messages',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toBe(true);
|
||||||
|
expect(pairedExecutionContext.approveRoomPlan).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
chatJid,
|
||||||
|
roomRoleContext: expect.objectContaining({
|
||||||
|
role: 'reviewer',
|
||||||
|
serviceId: 'codex-main',
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(channel.sendMessage).toHaveBeenCalledWith(
|
||||||
|
chatJid,
|
||||||
|
'Plan approved.\n- Task: paired-task-1',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('routes /request-plan-changes through reviewer context in the message-runtime path', async () => {
|
||||||
|
const chatJid = 'group@test';
|
||||||
|
const group = {
|
||||||
|
...makeGroup('codex'),
|
||||||
|
workDir: '/repo/canonical',
|
||||||
|
};
|
||||||
|
const channel = makeChannel(chatJid);
|
||||||
|
const saveState = vi.fn();
|
||||||
|
const lastAgentTimestamps: Record<string, string> = {};
|
||||||
|
|
||||||
|
vi.mocked(db.isPairedRoomJid).mockReturnValue(true);
|
||||||
|
vi.mocked(db.getMessagesSince).mockReturnValue([
|
||||||
|
{
|
||||||
|
id: 'msg-request-plan-changes',
|
||||||
|
chat_jid: chatJid,
|
||||||
|
sender: 'me@test',
|
||||||
|
sender_name: 'Me',
|
||||||
|
content: '/request-plan-changes tighten scope',
|
||||||
|
timestamp: '2026-03-29T00:00:00.000Z',
|
||||||
|
is_from_me: true,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
const actualSessionCommands =
|
||||||
|
await vi.importActual<typeof import('./session-commands.js')>(
|
||||||
|
'./session-commands.js',
|
||||||
|
);
|
||||||
|
vi.mocked(sessionCommands.handleSessionCommand).mockImplementation((opts) =>
|
||||||
|
actualSessionCommands.handleSessionCommand(opts),
|
||||||
|
);
|
||||||
|
vi.mocked(pairedExecutionContext.requestRoomPlanChanges).mockReturnValue(
|
||||||
|
'Plan changes requested.\n- Task: paired-task-1',
|
||||||
|
);
|
||||||
|
|
||||||
|
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-request-plan-changes',
|
||||||
|
reason: 'messages',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toBe(true);
|
||||||
|
expect(pairedExecutionContext.requestRoomPlanChanges).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
chatJid,
|
||||||
|
roomRoleContext: expect.objectContaining({
|
||||||
|
role: 'reviewer',
|
||||||
|
serviceId: 'codex-main',
|
||||||
|
}),
|
||||||
|
note: 'tighten scope',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(channel.sendMessage).toHaveBeenCalledWith(
|
||||||
|
chatJid,
|
||||||
|
'Plan changes requested.\n- Task: paired-task-1',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it('ignores generic failure bot messages in paired rooms', async () => {
|
it('ignores generic failure bot messages in paired rooms', async () => {
|
||||||
const chatJid = 'group@test';
|
const chatJid = 'group@test';
|
||||||
const group = makeGroup('codex');
|
const group = makeGroup('codex');
|
||||||
|
|||||||
@@ -42,8 +42,12 @@ 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 { createSuppressToken } from './output-suppression.js';
|
||||||
import {
|
import {
|
||||||
|
approveRoomPlan,
|
||||||
formatRoomReviewReadyMessage,
|
formatRoomReviewReadyMessage,
|
||||||
markRoomReviewReady,
|
markRoomReviewReady,
|
||||||
|
recordRoomPlan,
|
||||||
|
requestRoomPlanChanges,
|
||||||
|
setRoomTaskRiskLevel,
|
||||||
} from './paired-execution-context.js';
|
} from './paired-execution-context.js';
|
||||||
import { buildRoomRoleContext } from './room-role-context.js';
|
import { buildRoomRoleContext } from './room-role-context.js';
|
||||||
import {
|
import {
|
||||||
@@ -565,6 +569,9 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
|
|||||||
isTriggerAllowed(chatJid, msg.sender, loadSenderAllowlist())))
|
isTriggerAllowed(chatJid, msg.sender, loadSenderAllowlist())))
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
// Session commands are explicit control events, so owner-only and
|
||||||
|
// reviewer-only commands resolve against the target role rather than
|
||||||
|
// whichever service happened to pick up the slash command first.
|
||||||
markReviewReady: async () => {
|
markReviewReady: async () => {
|
||||||
const lease = getEffectiveChannelLease(chatJid);
|
const lease = getEffectiveChannelLease(chatJid);
|
||||||
const roomRoleContext = buildRoomRoleContext(
|
const roomRoleContext = buildRoomRoleContext(
|
||||||
@@ -578,6 +585,57 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
|
|||||||
});
|
});
|
||||||
return formatRoomReviewReadyMessage(result);
|
return formatRoomReviewReadyMessage(result);
|
||||||
},
|
},
|
||||||
|
setTaskRiskLevel: async (riskLevel) => {
|
||||||
|
const lease = getEffectiveChannelLease(chatJid);
|
||||||
|
const roomRoleContext = buildRoomRoleContext(
|
||||||
|
lease,
|
||||||
|
lease.owner_service_id,
|
||||||
|
);
|
||||||
|
return setRoomTaskRiskLevel({
|
||||||
|
group,
|
||||||
|
chatJid,
|
||||||
|
roomRoleContext,
|
||||||
|
riskLevel,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
recordPlan: async (plan) => {
|
||||||
|
const lease = getEffectiveChannelLease(chatJid);
|
||||||
|
const roomRoleContext = buildRoomRoleContext(
|
||||||
|
lease,
|
||||||
|
lease.owner_service_id,
|
||||||
|
);
|
||||||
|
return recordRoomPlan({
|
||||||
|
group,
|
||||||
|
chatJid,
|
||||||
|
roomRoleContext,
|
||||||
|
...plan,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
approvePlan: async () => {
|
||||||
|
const lease = getEffectiveChannelLease(chatJid);
|
||||||
|
const roomRoleContext = buildRoomRoleContext(
|
||||||
|
lease,
|
||||||
|
lease.reviewer_service_id ?? lease.owner_service_id,
|
||||||
|
);
|
||||||
|
return approveRoomPlan({
|
||||||
|
group,
|
||||||
|
chatJid,
|
||||||
|
roomRoleContext,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
requestPlanChanges: async (note) => {
|
||||||
|
const lease = getEffectiveChannelLease(chatJid);
|
||||||
|
const roomRoleContext = buildRoomRoleContext(
|
||||||
|
lease,
|
||||||
|
lease.reviewer_service_id ?? lease.owner_service_id,
|
||||||
|
);
|
||||||
|
return requestRoomPlanChanges({
|
||||||
|
group,
|
||||||
|
chatJid,
|
||||||
|
roomRoleContext,
|
||||||
|
note,
|
||||||
|
});
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (cmdResult.handled) return cmdResult.success;
|
if (cmdResult.handled) return cmdResult.success;
|
||||||
|
|||||||
@@ -1,18 +1,22 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
vi.mock('./db.js', () => ({
|
vi.mock('./db.js', () => ({
|
||||||
|
createPairedArtifact: vi.fn(),
|
||||||
createPairedExecution: vi.fn(),
|
createPairedExecution: vi.fn(),
|
||||||
createPairedTask: vi.fn(),
|
createPairedTask: vi.fn(),
|
||||||
getLatestOpenPairedTaskForChat: vi.fn(),
|
getLatestOpenPairedTaskForChat: vi.fn(),
|
||||||
getPairedExecutionById: vi.fn(),
|
getPairedExecutionById: vi.fn(),
|
||||||
getPairedTaskById: vi.fn(),
|
getPairedTaskById: vi.fn(),
|
||||||
getPairedWorkspace: vi.fn(),
|
getPairedWorkspace: vi.fn(),
|
||||||
|
listPairedArtifactsForTask: vi.fn(),
|
||||||
updatePairedExecution: vi.fn(),
|
updatePairedExecution: vi.fn(),
|
||||||
updatePairedTask: vi.fn(),
|
updatePairedTask: vi.fn(),
|
||||||
upsertPairedProject: vi.fn(),
|
upsertPairedProject: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('./paired-workspace-manager.js', () => ({
|
vi.mock('./paired-workspace-manager.js', () => ({
|
||||||
|
PLAN_REVIEW_REQUIRED_BLOCK_MESSAGE:
|
||||||
|
'Plan review is required before formal review for this high-risk task.',
|
||||||
markPairedTaskReviewReady: vi.fn(),
|
markPairedTaskReviewReady: vi.fn(),
|
||||||
prepareReviewerWorkspaceForExecution: vi.fn(),
|
prepareReviewerWorkspaceForExecution: vi.fn(),
|
||||||
provisionOwnerWorkspaceForPairedTask: vi.fn(),
|
provisionOwnerWorkspaceForPairedTask: vi.fn(),
|
||||||
@@ -29,9 +33,13 @@ vi.mock('./logger.js', () => ({
|
|||||||
|
|
||||||
import * as db from './db.js';
|
import * as db from './db.js';
|
||||||
import {
|
import {
|
||||||
|
approveRoomPlan,
|
||||||
formatRoomReviewReadyMessage,
|
formatRoomReviewReadyMessage,
|
||||||
markRoomReviewReady,
|
markRoomReviewReady,
|
||||||
preparePairedExecutionContext,
|
preparePairedExecutionContext,
|
||||||
|
recordRoomPlan,
|
||||||
|
requestRoomPlanChanges,
|
||||||
|
setRoomTaskRiskLevel,
|
||||||
} from './paired-execution-context.js';
|
} from './paired-execution-context.js';
|
||||||
import * as pairedWorkspaceManager from './paired-workspace-manager.js';
|
import * as pairedWorkspaceManager from './paired-workspace-manager.js';
|
||||||
import type { RegisteredGroup, RoomRoleContext } from './types.js';
|
import type { RegisteredGroup, RoomRoleContext } from './types.js';
|
||||||
@@ -68,6 +76,7 @@ describe('paired execution context', () => {
|
|||||||
vi.mocked(db.getPairedExecutionById).mockReturnValue(undefined);
|
vi.mocked(db.getPairedExecutionById).mockReturnValue(undefined);
|
||||||
vi.mocked(db.getPairedTaskById).mockReturnValue(undefined);
|
vi.mocked(db.getPairedTaskById).mockReturnValue(undefined);
|
||||||
vi.mocked(db.getPairedWorkspace).mockReturnValue(undefined);
|
vi.mocked(db.getPairedWorkspace).mockReturnValue(undefined);
|
||||||
|
vi.mocked(db.listPairedArtifactsForTask).mockReturnValue([]);
|
||||||
vi.mocked(
|
vi.mocked(
|
||||||
pairedWorkspaceManager.provisionOwnerWorkspaceForPairedTask,
|
pairedWorkspaceManager.provisionOwnerWorkspaceForPairedTask,
|
||||||
).mockReturnValue({
|
).mockReturnValue({
|
||||||
@@ -408,4 +417,321 @@ describe('paired execution context', () => {
|
|||||||
].join('\n'),
|
].join('\n'),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('blocks /review for high-risk tasks until the plan is approved', () => {
|
||||||
|
vi.mocked(db.getLatestOpenPairedTaskForChat).mockReturnValue({
|
||||||
|
id: 'task-1',
|
||||||
|
chat_jid: 'dc:test',
|
||||||
|
group_folder: group.folder,
|
||||||
|
owner_service_id: 'codex-main',
|
||||||
|
reviewer_service_id: 'codex-review',
|
||||||
|
title: null,
|
||||||
|
source_ref: 'HEAD',
|
||||||
|
task_policy: 'autonomous',
|
||||||
|
risk_level: 'high',
|
||||||
|
plan_status: 'pending',
|
||||||
|
review_requested_at: null,
|
||||||
|
status: 'plan_review_pending',
|
||||||
|
created_at: '2026-03-28T00:00:00.000Z',
|
||||||
|
updated_at: '2026-03-28T00:00:00.000Z',
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = markRoomReviewReady({
|
||||||
|
group,
|
||||||
|
chatJid: 'dc:test',
|
||||||
|
roomRoleContext: ownerContext,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(pairedWorkspaceManager.markPairedTaskReviewReady).not.toHaveBeenCalled();
|
||||||
|
expect(result).toEqual({
|
||||||
|
status: 'blocked',
|
||||||
|
task: expect.objectContaining({
|
||||||
|
id: 'task-1',
|
||||||
|
risk_level: 'high',
|
||||||
|
plan_status: 'pending',
|
||||||
|
}),
|
||||||
|
blockedReason: 'plan-review-required',
|
||||||
|
});
|
||||||
|
expect(formatRoomReviewReadyMessage(result)).toBe(
|
||||||
|
[
|
||||||
|
'Plan review is required before formal review for this high-risk task.',
|
||||||
|
'- Task: task-1',
|
||||||
|
'- Plan status: pending',
|
||||||
|
'Ask the owner to record a plan and have the reviewer approve it before /review.',
|
||||||
|
].join('\n'),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('raises a task to high risk and moves it into plan_review_pending', () => {
|
||||||
|
vi.mocked(db.getLatestOpenPairedTaskForChat).mockReturnValue({
|
||||||
|
id: 'task-1',
|
||||||
|
chat_jid: 'dc:test',
|
||||||
|
group_folder: group.folder,
|
||||||
|
owner_service_id: 'codex-main',
|
||||||
|
reviewer_service_id: 'codex-review',
|
||||||
|
title: null,
|
||||||
|
source_ref: 'HEAD',
|
||||||
|
task_policy: 'autonomous',
|
||||||
|
risk_level: 'low',
|
||||||
|
plan_status: 'not_requested',
|
||||||
|
review_requested_at: null,
|
||||||
|
status: 'active',
|
||||||
|
created_at: '2026-03-28T00:00:00.000Z',
|
||||||
|
updated_at: '2026-03-28T00:00:00.000Z',
|
||||||
|
});
|
||||||
|
vi.mocked(db.getPairedTaskById).mockReturnValue({
|
||||||
|
id: 'task-1',
|
||||||
|
chat_jid: 'dc:test',
|
||||||
|
group_folder: group.folder,
|
||||||
|
owner_service_id: 'codex-main',
|
||||||
|
reviewer_service_id: 'codex-review',
|
||||||
|
title: null,
|
||||||
|
source_ref: 'HEAD',
|
||||||
|
task_policy: 'autonomous',
|
||||||
|
risk_level: 'high',
|
||||||
|
plan_status: 'not_requested',
|
||||||
|
review_requested_at: null,
|
||||||
|
status: 'plan_review_pending',
|
||||||
|
created_at: '2026-03-28T00:00:00.000Z',
|
||||||
|
updated_at: '2026-03-29T00:00:00.000Z',
|
||||||
|
});
|
||||||
|
|
||||||
|
const message = setRoomTaskRiskLevel({
|
||||||
|
group,
|
||||||
|
chatJid: 'dc:test',
|
||||||
|
roomRoleContext: ownerContext,
|
||||||
|
riskLevel: 'high',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(db.updatePairedTask).toHaveBeenCalledWith(
|
||||||
|
'task-1',
|
||||||
|
expect.objectContaining({
|
||||||
|
risk_level: 'high',
|
||||||
|
plan_status: 'not_requested',
|
||||||
|
status: 'plan_review_pending',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(message).toBe(
|
||||||
|
[
|
||||||
|
'Task risk updated.',
|
||||||
|
'- Task: task-1',
|
||||||
|
'- Risk: high',
|
||||||
|
'- Status: plan_review_pending',
|
||||||
|
].join('\n'),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('records plan artifacts for a high-risk task and keeps it pending review', () => {
|
||||||
|
vi.mocked(db.getLatestOpenPairedTaskForChat).mockReturnValue({
|
||||||
|
id: 'task-1',
|
||||||
|
chat_jid: 'dc:test',
|
||||||
|
group_folder: group.folder,
|
||||||
|
owner_service_id: 'codex-main',
|
||||||
|
reviewer_service_id: 'codex-review',
|
||||||
|
title: null,
|
||||||
|
source_ref: 'HEAD',
|
||||||
|
task_policy: 'autonomous',
|
||||||
|
risk_level: 'high',
|
||||||
|
plan_status: 'not_requested',
|
||||||
|
review_requested_at: null,
|
||||||
|
status: 'plan_review_pending',
|
||||||
|
created_at: '2026-03-28T00:00:00.000Z',
|
||||||
|
updated_at: '2026-03-28T00:00:00.000Z',
|
||||||
|
});
|
||||||
|
vi.mocked(db.getPairedTaskById).mockReturnValue({
|
||||||
|
id: 'task-1',
|
||||||
|
chat_jid: 'dc:test',
|
||||||
|
group_folder: group.folder,
|
||||||
|
owner_service_id: 'codex-main',
|
||||||
|
reviewer_service_id: 'codex-review',
|
||||||
|
title: null,
|
||||||
|
source_ref: 'HEAD',
|
||||||
|
task_policy: 'autonomous',
|
||||||
|
risk_level: 'high',
|
||||||
|
plan_status: 'pending',
|
||||||
|
review_requested_at: null,
|
||||||
|
status: 'plan_review_pending',
|
||||||
|
created_at: '2026-03-28T00:00:00.000Z',
|
||||||
|
updated_at: '2026-03-29T00:00:00.000Z',
|
||||||
|
});
|
||||||
|
|
||||||
|
const message = recordRoomPlan({
|
||||||
|
group,
|
||||||
|
chatJid: 'dc:test',
|
||||||
|
roomRoleContext: ownerContext,
|
||||||
|
planBrief: 'ship governance gate',
|
||||||
|
acceptanceCriteria: 'high risk blocks /review',
|
||||||
|
riskSummary: 'runtime state drift',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(db.createPairedArtifact).toHaveBeenCalledTimes(3);
|
||||||
|
expect(db.updatePairedTask).toHaveBeenCalledWith(
|
||||||
|
'task-1',
|
||||||
|
expect.objectContaining({
|
||||||
|
plan_status: 'pending',
|
||||||
|
status: 'plan_review_pending',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(message).toBe(
|
||||||
|
[
|
||||||
|
'Plan recorded.',
|
||||||
|
'- Task: task-1',
|
||||||
|
'- Plan status: pending',
|
||||||
|
'- Status: plan_review_pending',
|
||||||
|
].join('\n'),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('approves a high-risk plan from the reviewer side once plan artifacts exist', () => {
|
||||||
|
vi.mocked(db.getLatestOpenPairedTaskForChat).mockReturnValue({
|
||||||
|
id: 'task-1',
|
||||||
|
chat_jid: 'dc:test',
|
||||||
|
group_folder: group.folder,
|
||||||
|
owner_service_id: 'codex-main',
|
||||||
|
reviewer_service_id: 'codex-review',
|
||||||
|
title: null,
|
||||||
|
source_ref: 'HEAD',
|
||||||
|
task_policy: 'autonomous',
|
||||||
|
risk_level: 'high',
|
||||||
|
plan_status: 'pending',
|
||||||
|
review_requested_at: null,
|
||||||
|
status: 'plan_review_pending',
|
||||||
|
created_at: '2026-03-28T00:00:00.000Z',
|
||||||
|
updated_at: '2026-03-28T00:00:00.000Z',
|
||||||
|
});
|
||||||
|
vi.mocked(db.listPairedArtifactsForTask).mockReturnValue([
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
task_id: 'task-1',
|
||||||
|
execution_id: null,
|
||||||
|
service_id: 'codex-main',
|
||||||
|
artifact_type: 'plan_brief',
|
||||||
|
title: null,
|
||||||
|
content: 'brief',
|
||||||
|
file_path: null,
|
||||||
|
created_at: '2026-03-29T00:00:00.000Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
task_id: 'task-1',
|
||||||
|
execution_id: null,
|
||||||
|
service_id: 'codex-main',
|
||||||
|
artifact_type: 'acceptance_criteria',
|
||||||
|
title: null,
|
||||||
|
content: 'criteria',
|
||||||
|
file_path: null,
|
||||||
|
created_at: '2026-03-29T00:00:01.000Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
task_id: 'task-1',
|
||||||
|
execution_id: null,
|
||||||
|
service_id: 'codex-main',
|
||||||
|
artifact_type: 'risk_summary',
|
||||||
|
title: null,
|
||||||
|
content: 'risk',
|
||||||
|
file_path: null,
|
||||||
|
created_at: '2026-03-29T00:00:02.000Z',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
vi.mocked(db.getPairedTaskById).mockReturnValue({
|
||||||
|
id: 'task-1',
|
||||||
|
chat_jid: 'dc:test',
|
||||||
|
group_folder: group.folder,
|
||||||
|
owner_service_id: 'codex-main',
|
||||||
|
reviewer_service_id: 'codex-review',
|
||||||
|
title: null,
|
||||||
|
source_ref: 'HEAD',
|
||||||
|
task_policy: 'autonomous',
|
||||||
|
risk_level: 'high',
|
||||||
|
plan_status: 'approved',
|
||||||
|
review_requested_at: null,
|
||||||
|
status: 'active',
|
||||||
|
created_at: '2026-03-28T00:00:00.000Z',
|
||||||
|
updated_at: '2026-03-29T00:00:00.000Z',
|
||||||
|
});
|
||||||
|
|
||||||
|
const message = approveRoomPlan({
|
||||||
|
group,
|
||||||
|
chatJid: 'dc:test',
|
||||||
|
roomRoleContext: reviewerContext,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(db.updatePairedTask).toHaveBeenCalledWith(
|
||||||
|
'task-1',
|
||||||
|
expect.objectContaining({
|
||||||
|
plan_status: 'approved',
|
||||||
|
status: 'active',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(message).toBe(
|
||||||
|
['Plan approved.', '- Task: task-1', '- Status: active'].join('\n'),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('requests plan changes from the reviewer side and keeps the task pending', () => {
|
||||||
|
vi.mocked(db.getLatestOpenPairedTaskForChat).mockReturnValue({
|
||||||
|
id: 'task-1',
|
||||||
|
chat_jid: 'dc:test',
|
||||||
|
group_folder: group.folder,
|
||||||
|
owner_service_id: 'codex-main',
|
||||||
|
reviewer_service_id: 'codex-review',
|
||||||
|
title: null,
|
||||||
|
source_ref: 'HEAD',
|
||||||
|
task_policy: 'autonomous',
|
||||||
|
risk_level: 'high',
|
||||||
|
plan_status: 'pending',
|
||||||
|
review_requested_at: null,
|
||||||
|
status: 'plan_review_pending',
|
||||||
|
created_at: '2026-03-28T00:00:00.000Z',
|
||||||
|
updated_at: '2026-03-28T00:00:00.000Z',
|
||||||
|
});
|
||||||
|
vi.mocked(db.getPairedTaskById).mockReturnValue({
|
||||||
|
id: 'task-1',
|
||||||
|
chat_jid: 'dc:test',
|
||||||
|
group_folder: group.folder,
|
||||||
|
owner_service_id: 'codex-main',
|
||||||
|
reviewer_service_id: 'codex-review',
|
||||||
|
title: null,
|
||||||
|
source_ref: 'HEAD',
|
||||||
|
task_policy: 'autonomous',
|
||||||
|
risk_level: 'high',
|
||||||
|
plan_status: 'changes_requested',
|
||||||
|
review_requested_at: null,
|
||||||
|
status: 'plan_review_pending',
|
||||||
|
created_at: '2026-03-28T00:00:00.000Z',
|
||||||
|
updated_at: '2026-03-29T00:00:00.000Z',
|
||||||
|
});
|
||||||
|
|
||||||
|
const message = requestRoomPlanChanges({
|
||||||
|
group,
|
||||||
|
chatJid: 'dc:test',
|
||||||
|
roomRoleContext: reviewerContext,
|
||||||
|
note: 'tighten acceptance criteria',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(db.createPairedArtifact).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
task_id: 'task-1',
|
||||||
|
service_id: 'codex-review',
|
||||||
|
artifact_type: 'comment',
|
||||||
|
content: 'tighten acceptance criteria',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(db.updatePairedTask).toHaveBeenCalledWith(
|
||||||
|
'task-1',
|
||||||
|
expect.objectContaining({
|
||||||
|
plan_status: 'changes_requested',
|
||||||
|
status: 'plan_review_pending',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(message).toBe(
|
||||||
|
[
|
||||||
|
'Plan changes requested.',
|
||||||
|
'- Task: task-1',
|
||||||
|
'- Plan status: changes_requested',
|
||||||
|
'- Status: plan_review_pending',
|
||||||
|
].join('\n'),
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,22 +3,26 @@ import crypto from 'crypto';
|
|||||||
|
|
||||||
import { SERVICE_ID, normalizeServiceId } from './config.js';
|
import { SERVICE_ID, normalizeServiceId } from './config.js';
|
||||||
import {
|
import {
|
||||||
|
createPairedArtifact,
|
||||||
createPairedExecution,
|
createPairedExecution,
|
||||||
createPairedTask,
|
createPairedTask,
|
||||||
getLatestOpenPairedTaskForChat,
|
getLatestOpenPairedTaskForChat,
|
||||||
getPairedExecutionById,
|
getPairedExecutionById,
|
||||||
getPairedTaskById,
|
getPairedTaskById,
|
||||||
|
listPairedArtifactsForTask,
|
||||||
updatePairedExecution,
|
updatePairedExecution,
|
||||||
updatePairedTask,
|
updatePairedTask,
|
||||||
upsertPairedProject,
|
upsertPairedProject,
|
||||||
} from './db.js';
|
} from './db.js';
|
||||||
import { logger } from './logger.js';
|
import { logger } from './logger.js';
|
||||||
import {
|
import {
|
||||||
|
PLAN_REVIEW_REQUIRED_BLOCK_MESSAGE,
|
||||||
markPairedTaskReviewReady,
|
markPairedTaskReviewReady,
|
||||||
prepareReviewerWorkspaceForExecution,
|
prepareReviewerWorkspaceForExecution,
|
||||||
provisionOwnerWorkspaceForPairedTask,
|
provisionOwnerWorkspaceForPairedTask,
|
||||||
} from './paired-workspace-manager.js';
|
} from './paired-workspace-manager.js';
|
||||||
import type {
|
import type {
|
||||||
|
PairedArtifactType,
|
||||||
PairedExecution,
|
PairedExecution,
|
||||||
PairedTask,
|
PairedTask,
|
||||||
PairedWorkspace,
|
PairedWorkspace,
|
||||||
@@ -152,19 +156,6 @@ export interface PreparedPairedExecutionContext {
|
|||||||
blockMessage?: string;
|
blockMessage?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type MarkRoomReviewReadyResult =
|
|
||||||
| {
|
|
||||||
status: 'ready';
|
|
||||||
task: PairedTask;
|
|
||||||
ownerWorkspace: PairedWorkspace;
|
|
||||||
reviewerWorkspace: PairedWorkspace;
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
status: 'pending';
|
|
||||||
task: PairedTask;
|
|
||||||
pendingReason: 'owner-workspace-not-ready';
|
|
||||||
};
|
|
||||||
|
|
||||||
export function preparePairedExecutionContext(args: {
|
export function preparePairedExecutionContext(args: {
|
||||||
group: RegisteredGroup;
|
group: RegisteredGroup;
|
||||||
chatJid: string;
|
chatJid: string;
|
||||||
@@ -228,6 +219,85 @@ export function preparePairedExecutionContext(args: {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type MarkRoomReviewReadyResult =
|
||||||
|
| {
|
||||||
|
status: 'ready';
|
||||||
|
task: PairedTask;
|
||||||
|
ownerWorkspace: PairedWorkspace;
|
||||||
|
reviewerWorkspace: PairedWorkspace;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
status: 'pending';
|
||||||
|
task: PairedTask;
|
||||||
|
pendingReason: 'owner-workspace-not-ready';
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
status: 'blocked';
|
||||||
|
task: PairedTask;
|
||||||
|
blockedReason: 'plan-review-required';
|
||||||
|
}
|
||||||
|
| null;
|
||||||
|
|
||||||
|
const PLAN_RECORD_OWNER_ONLY_MESSAGE =
|
||||||
|
'Plan recording must be handled by the owner service.';
|
||||||
|
const RISK_UPDATE_OWNER_ONLY_MESSAGE =
|
||||||
|
'Risk updates must be handled by the owner service.';
|
||||||
|
const PLAN_APPROVAL_REVIEWER_ONLY_MESSAGE =
|
||||||
|
'Plan approval must be handled by the reviewer service.';
|
||||||
|
const PLAN_REQUIRES_HIGH_RISK_MESSAGE =
|
||||||
|
'Plan review commands are only required for high-risk tasks.';
|
||||||
|
const PLAN_INCOMPLETE_MESSAGE =
|
||||||
|
'Plan artifacts are incomplete. Record /plan <plan brief> || <acceptance criteria> || <risk summary> before approval.';
|
||||||
|
|
||||||
|
function isPlanReviewRequired(task: PairedTask): boolean {
|
||||||
|
return task.risk_level === 'high' && task.plan_status !== 'approved';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLatestArtifactContent(
|
||||||
|
taskId: string,
|
||||||
|
artifactType: PairedArtifactType,
|
||||||
|
): string | null {
|
||||||
|
const artifacts = listPairedArtifactsForTask(taskId).filter(
|
||||||
|
(artifact) => artifact.artifact_type === artifactType,
|
||||||
|
);
|
||||||
|
return artifacts.at(-1)?.content?.trim() || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasCompletePlanArtifacts(taskId: string): boolean {
|
||||||
|
return (
|
||||||
|
!!getLatestArtifactContent(taskId, 'plan_brief') &&
|
||||||
|
!!getLatestArtifactContent(taskId, 'acceptance_criteria') &&
|
||||||
|
!!getLatestArtifactContent(taskId, 'risk_summary')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createTaskArtifact(args: {
|
||||||
|
taskId: string;
|
||||||
|
serviceId: string;
|
||||||
|
artifactType: PairedArtifactType;
|
||||||
|
content: string;
|
||||||
|
}): void {
|
||||||
|
createPairedArtifact({
|
||||||
|
task_id: args.taskId,
|
||||||
|
execution_id: null,
|
||||||
|
service_id: args.serviceId,
|
||||||
|
artifact_type: args.artifactType,
|
||||||
|
title: null,
|
||||||
|
content: args.content,
|
||||||
|
file_path: null,
|
||||||
|
created_at: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatPlanReviewRequiredMessage(task: PairedTask): string {
|
||||||
|
return [
|
||||||
|
PLAN_REVIEW_REQUIRED_BLOCK_MESSAGE,
|
||||||
|
`- Task: ${task.id}`,
|
||||||
|
`- Plan status: ${task.plan_status}`,
|
||||||
|
'Ask the owner to record a plan and have the reviewer approve it before /review.',
|
||||||
|
].join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
export function completePairedExecutionContext(args: {
|
export function completePairedExecutionContext(args: {
|
||||||
executionId: string;
|
executionId: string;
|
||||||
status: 'succeeded' | 'failed';
|
status: 'succeeded' | 'failed';
|
||||||
@@ -244,7 +314,7 @@ export function markRoomReviewReady(args: {
|
|||||||
group: RegisteredGroup;
|
group: RegisteredGroup;
|
||||||
chatJid: string;
|
chatJid: string;
|
||||||
roomRoleContext?: RoomRoleContext;
|
roomRoleContext?: RoomRoleContext;
|
||||||
}): MarkRoomReviewReadyResult | null {
|
}): MarkRoomReviewReadyResult {
|
||||||
const { group, chatJid, roomRoleContext } = args;
|
const { group, chatJid, roomRoleContext } = args;
|
||||||
if (!roomRoleContext || !group.workDir) {
|
if (!roomRoleContext || !group.workDir) {
|
||||||
return null;
|
return null;
|
||||||
@@ -255,6 +325,14 @@ export function markRoomReviewReady(args: {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isPlanReviewRequired(task)) {
|
||||||
|
return {
|
||||||
|
status: 'blocked',
|
||||||
|
task,
|
||||||
|
blockedReason: 'plan-review-required',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const isOwnerService =
|
const isOwnerService =
|
||||||
normalizeServiceId(SERVICE_ID) === task.owner_service_id;
|
normalizeServiceId(SERVICE_ID) === task.owner_service_id;
|
||||||
if (isOwnerService) {
|
if (isOwnerService) {
|
||||||
@@ -262,11 +340,10 @@ export function markRoomReviewReady(args: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const reviewReady = markPairedTaskReviewReady(task.id);
|
const reviewReady = markPairedTaskReviewReady(task.id);
|
||||||
const latestTask = getPairedTaskById(task.id) ?? task;
|
|
||||||
if (!reviewReady) {
|
if (!reviewReady) {
|
||||||
return {
|
return {
|
||||||
status: 'pending',
|
status: 'pending',
|
||||||
task: latestTask,
|
task: getPairedTaskById(task.id) ?? task,
|
||||||
pendingReason: 'owner-workspace-not-ready',
|
pendingReason: 'owner-workspace-not-ready',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -274,19 +351,23 @@ export function markRoomReviewReady(args: {
|
|||||||
const { ownerWorkspace, reviewerWorkspace } = reviewReady;
|
const { ownerWorkspace, reviewerWorkspace } = reviewReady;
|
||||||
return {
|
return {
|
||||||
status: 'ready',
|
status: 'ready',
|
||||||
task: latestTask,
|
task: getPairedTaskById(task.id) ?? task,
|
||||||
ownerWorkspace,
|
ownerWorkspace,
|
||||||
reviewerWorkspace,
|
reviewerWorkspace,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatRoomReviewReadyMessage(
|
export function formatRoomReviewReadyMessage(
|
||||||
result: MarkRoomReviewReadyResult | null,
|
result: MarkRoomReviewReadyResult,
|
||||||
): string | null {
|
): string | null {
|
||||||
if (!result) {
|
if (!result) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (result.status === 'blocked') {
|
||||||
|
return formatPlanReviewRequiredMessage(result.task);
|
||||||
|
}
|
||||||
|
|
||||||
if (result.status === 'pending') {
|
if (result.status === 'pending') {
|
||||||
return [
|
return [
|
||||||
'Review request recorded, but the owner workspace is not ready yet.',
|
'Review request recorded, but the owner workspace is not ready yet.',
|
||||||
@@ -302,3 +383,196 @@ export function formatRoomReviewReadyMessage(
|
|||||||
`- Reviewer snapshot: ${result.reviewerWorkspace.workspace_dir}`,
|
`- Reviewer snapshot: ${result.reviewerWorkspace.workspace_dir}`,
|
||||||
].join('\n');
|
].join('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function setRoomTaskRiskLevel(args: {
|
||||||
|
group: RegisteredGroup;
|
||||||
|
chatJid: string;
|
||||||
|
roomRoleContext?: RoomRoleContext;
|
||||||
|
riskLevel: 'low' | 'high';
|
||||||
|
}): string | null {
|
||||||
|
const { group, chatJid, roomRoleContext, riskLevel } = args;
|
||||||
|
if (!roomRoleContext || !group.workDir) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (roomRoleContext.role !== 'owner') {
|
||||||
|
return RISK_UPDATE_OWNER_ONLY_MESSAGE;
|
||||||
|
}
|
||||||
|
|
||||||
|
const task = ensureActiveTask(group, chatJid, roomRoleContext);
|
||||||
|
if (!task) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
if (riskLevel === 'high') {
|
||||||
|
updatePairedTask(task.id, {
|
||||||
|
risk_level: 'high',
|
||||||
|
plan_status: hasCompletePlanArtifacts(task.id) ? 'pending' : 'not_requested',
|
||||||
|
status: 'plan_review_pending',
|
||||||
|
updated_at: now,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
updatePairedTask(task.id, {
|
||||||
|
risk_level: 'low',
|
||||||
|
status: task.status === 'plan_review_pending' ? 'active' : task.status,
|
||||||
|
updated_at: now,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const latestTask = getPairedTaskById(task.id) ?? task;
|
||||||
|
return [
|
||||||
|
'Task risk updated.',
|
||||||
|
`- Task: ${latestTask.id}`,
|
||||||
|
`- Risk: ${latestTask.risk_level}`,
|
||||||
|
`- Status: ${latestTask.status}`,
|
||||||
|
].join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function recordRoomPlan(args: {
|
||||||
|
group: RegisteredGroup;
|
||||||
|
chatJid: string;
|
||||||
|
roomRoleContext?: RoomRoleContext;
|
||||||
|
planBrief: string;
|
||||||
|
acceptanceCriteria: string;
|
||||||
|
riskSummary: string;
|
||||||
|
}): string | null {
|
||||||
|
const {
|
||||||
|
group,
|
||||||
|
chatJid,
|
||||||
|
roomRoleContext,
|
||||||
|
planBrief,
|
||||||
|
acceptanceCriteria,
|
||||||
|
riskSummary,
|
||||||
|
} = args;
|
||||||
|
if (!roomRoleContext || !group.workDir) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (roomRoleContext.role !== 'owner') {
|
||||||
|
return PLAN_RECORD_OWNER_ONLY_MESSAGE;
|
||||||
|
}
|
||||||
|
|
||||||
|
const task = ensureActiveTask(group, chatJid, roomRoleContext);
|
||||||
|
if (!task) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (task.risk_level !== 'high') {
|
||||||
|
return PLAN_REQUIRES_HIGH_RISK_MESSAGE;
|
||||||
|
}
|
||||||
|
|
||||||
|
createTaskArtifact({
|
||||||
|
taskId: task.id,
|
||||||
|
serviceId: roomRoleContext.serviceId,
|
||||||
|
artifactType: 'plan_brief',
|
||||||
|
content: planBrief,
|
||||||
|
});
|
||||||
|
createTaskArtifact({
|
||||||
|
taskId: task.id,
|
||||||
|
serviceId: roomRoleContext.serviceId,
|
||||||
|
artifactType: 'acceptance_criteria',
|
||||||
|
content: acceptanceCriteria,
|
||||||
|
});
|
||||||
|
createTaskArtifact({
|
||||||
|
taskId: task.id,
|
||||||
|
serviceId: roomRoleContext.serviceId,
|
||||||
|
artifactType: 'risk_summary',
|
||||||
|
content: riskSummary,
|
||||||
|
});
|
||||||
|
|
||||||
|
updatePairedTask(task.id, {
|
||||||
|
plan_status: 'pending',
|
||||||
|
status: 'plan_review_pending',
|
||||||
|
updated_at: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const latestTask = getPairedTaskById(task.id) ?? task;
|
||||||
|
return [
|
||||||
|
'Plan recorded.',
|
||||||
|
`- Task: ${latestTask.id}`,
|
||||||
|
`- Plan status: ${latestTask.plan_status}`,
|
||||||
|
`- Status: ${latestTask.status}`,
|
||||||
|
].join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function approveRoomPlan(args: {
|
||||||
|
group: RegisteredGroup;
|
||||||
|
chatJid: string;
|
||||||
|
roomRoleContext?: RoomRoleContext;
|
||||||
|
}): string | null {
|
||||||
|
const { group, chatJid, roomRoleContext } = args;
|
||||||
|
if (!roomRoleContext || !group.workDir) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (roomRoleContext.role !== 'reviewer') {
|
||||||
|
return PLAN_APPROVAL_REVIEWER_ONLY_MESSAGE;
|
||||||
|
}
|
||||||
|
|
||||||
|
const task = ensureActiveTask(group, chatJid, roomRoleContext);
|
||||||
|
if (!task) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (task.risk_level !== 'high') {
|
||||||
|
return PLAN_REQUIRES_HIGH_RISK_MESSAGE;
|
||||||
|
}
|
||||||
|
if (!hasCompletePlanArtifacts(task.id)) {
|
||||||
|
return PLAN_INCOMPLETE_MESSAGE;
|
||||||
|
}
|
||||||
|
|
||||||
|
updatePairedTask(task.id, {
|
||||||
|
plan_status: 'approved',
|
||||||
|
status: 'active',
|
||||||
|
updated_at: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const latestTask = getPairedTaskById(task.id) ?? task;
|
||||||
|
return [
|
||||||
|
'Plan approved.',
|
||||||
|
`- Task: ${latestTask.id}`,
|
||||||
|
`- Status: ${latestTask.status}`,
|
||||||
|
].join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function requestRoomPlanChanges(args: {
|
||||||
|
group: RegisteredGroup;
|
||||||
|
chatJid: string;
|
||||||
|
roomRoleContext?: RoomRoleContext;
|
||||||
|
note?: string;
|
||||||
|
}): string | null {
|
||||||
|
const { group, chatJid, roomRoleContext, note } = args;
|
||||||
|
if (!roomRoleContext || !group.workDir) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (roomRoleContext.role !== 'reviewer') {
|
||||||
|
return PLAN_APPROVAL_REVIEWER_ONLY_MESSAGE;
|
||||||
|
}
|
||||||
|
|
||||||
|
const task = ensureActiveTask(group, chatJid, roomRoleContext);
|
||||||
|
if (!task) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (task.risk_level !== 'high') {
|
||||||
|
return PLAN_REQUIRES_HIGH_RISK_MESSAGE;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (note?.trim()) {
|
||||||
|
createTaskArtifact({
|
||||||
|
taskId: task.id,
|
||||||
|
serviceId: roomRoleContext.serviceId,
|
||||||
|
artifactType: 'comment',
|
||||||
|
content: note.trim(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
updatePairedTask(task.id, {
|
||||||
|
plan_status: 'changes_requested',
|
||||||
|
status: 'plan_review_pending',
|
||||||
|
updated_at: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const latestTask = getPairedTaskById(task.id) ?? task;
|
||||||
|
return [
|
||||||
|
'Plan changes requested.',
|
||||||
|
`- Task: ${latestTask.id}`,
|
||||||
|
`- Plan status: ${latestTask.plan_status}`,
|
||||||
|
`- Status: ${latestTask.status}`,
|
||||||
|
].join('\n');
|
||||||
|
}
|
||||||
|
|||||||
@@ -199,4 +199,62 @@ describe('paired /review command path', () => {
|
|||||||
expect(task?.review_requested_at).toBeTruthy();
|
expect(task?.review_requested_at).toBeTruthy();
|
||||||
expect(fs.existsSync(reviewerLocalOwnerDir)).toBe(false);
|
expect(fs.existsSync(reviewerLocalOwnerDir)).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('blocks /review when a high-risk task plan is not approved yet', async () => {
|
||||||
|
const canonicalDir = path.join(tempRoot, 'canonical');
|
||||||
|
fs.mkdirSync(canonicalDir, { recursive: true });
|
||||||
|
runGit(['init'], canonicalDir);
|
||||||
|
runGit(['config', 'user.email', 'test@example.com'], canonicalDir);
|
||||||
|
runGit(['config', 'user.name', 'EJClaw Test'], canonicalDir);
|
||||||
|
fs.writeFileSync(path.join(canonicalDir, 'README.md'), 'original\n');
|
||||||
|
runGit(['add', 'README.md'], canonicalDir);
|
||||||
|
runGit(['commit', '-m', 'initial'], canonicalDir);
|
||||||
|
|
||||||
|
const group: RegisteredGroup = {
|
||||||
|
name: 'Paired Room',
|
||||||
|
folder: 'paired-room',
|
||||||
|
trigger: '@codex',
|
||||||
|
added_at: '2026-03-28T00:00:00.000Z',
|
||||||
|
agentType: 'codex',
|
||||||
|
workDir: canonicalDir,
|
||||||
|
};
|
||||||
|
|
||||||
|
process.env.EJCLAW_DATA_DIR = path.join(tempRoot, 'data-owner');
|
||||||
|
process.env.SERVICE_ID = 'codex-main';
|
||||||
|
vi.resetModules();
|
||||||
|
const { db, executionContext } = await loadModules();
|
||||||
|
db.initDatabase();
|
||||||
|
|
||||||
|
const task = executionContext.preparePairedExecutionContext({
|
||||||
|
group,
|
||||||
|
chatJid: 'dc:test',
|
||||||
|
runId: 'run-owner',
|
||||||
|
roomRoleContext: ownerContext,
|
||||||
|
})?.task;
|
||||||
|
|
||||||
|
expect(task).toBeTruthy();
|
||||||
|
db.updatePairedTask(task!.id, {
|
||||||
|
risk_level: 'high',
|
||||||
|
plan_status: 'pending',
|
||||||
|
status: 'plan_review_pending',
|
||||||
|
updated_at: '2026-03-29T00:00:00.000Z',
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = executionContext.markRoomReviewReady({
|
||||||
|
group,
|
||||||
|
chatJid: 'dc:test',
|
||||||
|
roomRoleContext: ownerContext,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
status: 'blocked',
|
||||||
|
task: expect.objectContaining({
|
||||||
|
id: task!.id,
|
||||||
|
risk_level: 'high',
|
||||||
|
plan_status: 'pending',
|
||||||
|
status: 'plan_review_pending',
|
||||||
|
}),
|
||||||
|
blockedReason: 'plan-review-required',
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -833,4 +833,54 @@ describe('paired workspace manager', () => {
|
|||||||
db.getPairedTaskById('paired-task-6')?.review_requested_at,
|
db.getPairedTaskById('paired-task-6')?.review_requested_at,
|
||||||
).toBeTruthy();
|
).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
it('blocks reviewer execution for a high-risk task until the plan is approved', async () => {
|
||||||
|
const { db, manager } = await loadModules();
|
||||||
|
db._initTestDatabase();
|
||||||
|
|
||||||
|
const canonicalDir = path.join(tempRoot, 'canonical');
|
||||||
|
fs.mkdirSync(canonicalDir, { recursive: true });
|
||||||
|
runGit(['init'], canonicalDir);
|
||||||
|
runGit(['config', 'user.email', 'test@example.com'], canonicalDir);
|
||||||
|
runGit(['config', 'user.name', 'EJClaw Test'], canonicalDir);
|
||||||
|
fs.writeFileSync(path.join(canonicalDir, 'README.md'), 'base\n');
|
||||||
|
runGit(['add', 'README.md'], canonicalDir);
|
||||||
|
runGit(['commit', '-m', 'initial'], canonicalDir);
|
||||||
|
|
||||||
|
const now = '2026-03-28T00:00:00.000Z';
|
||||||
|
db.upsertPairedProject({
|
||||||
|
chat_jid: 'dc:test',
|
||||||
|
group_folder: 'paired-room',
|
||||||
|
canonical_work_dir: canonicalDir,
|
||||||
|
workspace_topology: 'shadow-snapshot',
|
||||||
|
created_at: now,
|
||||||
|
updated_at: now,
|
||||||
|
});
|
||||||
|
db.createPairedTask({
|
||||||
|
id: 'paired-task-plan-gate',
|
||||||
|
chat_jid: 'dc:test',
|
||||||
|
group_folder: 'paired-room',
|
||||||
|
owner_service_id: 'codex-main',
|
||||||
|
reviewer_service_id: 'codex-review',
|
||||||
|
title: null,
|
||||||
|
source_ref: 'HEAD',
|
||||||
|
task_policy: 'autonomous',
|
||||||
|
risk_level: 'high',
|
||||||
|
plan_status: 'pending',
|
||||||
|
review_requested_at: null,
|
||||||
|
status: 'plan_review_pending',
|
||||||
|
created_at: now,
|
||||||
|
updated_at: now,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = manager.prepareReviewerWorkspaceForExecution(
|
||||||
|
db.getPairedTaskById('paired-task-plan-gate')!,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
workspace: null,
|
||||||
|
autoRefreshed: false,
|
||||||
|
blockMessage:
|
||||||
|
'Plan review is required before formal review for this high-risk task.',
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ const REVIEWER_SNAPSHOT_STALE_BLOCK_MESSAGE =
|
|||||||
'Review snapshot is stale after owner changes. Retry the review once to refresh against the latest owner workspace.';
|
'Review snapshot is stale after owner changes. Retry the review once to refresh against the latest owner workspace.';
|
||||||
const REVIEWER_SNAPSHOT_NOT_READY_BLOCK_MESSAGE =
|
const REVIEWER_SNAPSHOT_NOT_READY_BLOCK_MESSAGE =
|
||||||
'Review snapshot is not ready yet. Ask the owner to run /review (or /review-ready) after preparing changes.';
|
'Review snapshot is not ready yet. Ask the owner to run /review (or /review-ready) after preparing changes.';
|
||||||
|
export const PLAN_REVIEW_REQUIRED_BLOCK_MESSAGE =
|
||||||
|
'Plan review is required before formal review for this high-risk task.';
|
||||||
|
|
||||||
const REVIEWER_SNAPSHOT_DENY_SEGMENTS = new Set([
|
const REVIEWER_SNAPSHOT_DENY_SEGMENTS = new Set([
|
||||||
'.git',
|
'.git',
|
||||||
@@ -471,6 +473,14 @@ export interface PreparedReviewerWorkspace {
|
|||||||
export function prepareReviewerWorkspaceForExecution(
|
export function prepareReviewerWorkspaceForExecution(
|
||||||
task: PairedTask,
|
task: PairedTask,
|
||||||
): PreparedReviewerWorkspace {
|
): PreparedReviewerWorkspace {
|
||||||
|
if (task.risk_level === 'high' && task.plan_status !== 'approved') {
|
||||||
|
return {
|
||||||
|
workspace: null,
|
||||||
|
autoRefreshed: false,
|
||||||
|
blockMessage: PLAN_REVIEW_REQUIRED_BLOCK_MESSAGE,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const ownerWorkspace = getPairedWorkspace(task.id, 'owner') ?? null;
|
const ownerWorkspace = getPairedWorkspace(task.id, 'owner') ?? null;
|
||||||
if (!ownerWorkspace) {
|
if (!ownerWorkspace) {
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -31,6 +31,28 @@ describe('extractSessionCommand', () => {
|
|||||||
expect(extractSessionCommand('/review-ready', trigger)).toBe('/review');
|
expect(extractSessionCommand('/review-ready', trigger)).toBe('/review');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('detects /risk with an argument payload', () => {
|
||||||
|
expect(extractSessionCommand('/risk high', trigger)).toBe('/risk');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('detects /plan with structured payload', () => {
|
||||||
|
expect(
|
||||||
|
extractSessionCommand('/plan brief || criteria || risk', trigger),
|
||||||
|
).toBe('/plan');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('detects /approve-plan', () => {
|
||||||
|
expect(extractSessionCommand('/approve-plan', trigger)).toBe(
|
||||||
|
'/approve-plan',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('detects /request-plan-changes with an optional note', () => {
|
||||||
|
expect(
|
||||||
|
extractSessionCommand('/request-plan-changes tighten scope', trigger),
|
||||||
|
).toBe('/request-plan-changes');
|
||||||
|
});
|
||||||
|
|
||||||
it('rejects /compact with extra text', () => {
|
it('rejects /compact with extra text', () => {
|
||||||
expect(extractSessionCommand('/compact now please', trigger)).toBeNull();
|
expect(extractSessionCommand('/compact now please', trigger)).toBeNull();
|
||||||
});
|
});
|
||||||
@@ -110,6 +132,20 @@ describe('isSessionCommandControlMessage', () => {
|
|||||||
).toBe(true);
|
).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('matches plan-gate control output', () => {
|
||||||
|
expect(
|
||||||
|
isSessionCommandControlMessage(
|
||||||
|
'Plan review is required before formal review for this high-risk task.\n- Task: task-1',
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('matches plan artifact control output', () => {
|
||||||
|
expect(isSessionCommandControlMessage('Plan recorded.\n- Task: task-1')).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it('does not match regular bot conversation', () => {
|
it('does not match regular bot conversation', () => {
|
||||||
expect(
|
expect(
|
||||||
isSessionCommandControlMessage(
|
isSessionCommandControlMessage(
|
||||||
@@ -148,6 +184,10 @@ function makeDeps(
|
|||||||
isAdminSender: vi.fn().mockReturnValue(false),
|
isAdminSender: vi.fn().mockReturnValue(false),
|
||||||
canSenderInteract: vi.fn().mockReturnValue(true),
|
canSenderInteract: vi.fn().mockReturnValue(true),
|
||||||
markReviewReady: vi.fn().mockResolvedValue('Review snapshot updated.'),
|
markReviewReady: vi.fn().mockResolvedValue('Review snapshot updated.'),
|
||||||
|
setTaskRiskLevel: vi.fn().mockResolvedValue('Task risk updated.'),
|
||||||
|
recordPlan: vi.fn().mockResolvedValue('Plan recorded.'),
|
||||||
|
approvePlan: vi.fn().mockResolvedValue('Plan approved.'),
|
||||||
|
requestPlanChanges: vi.fn().mockResolvedValue('Plan changes requested.'),
|
||||||
...overrides,
|
...overrides,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -289,6 +329,124 @@ describe('handleSessionCommand', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('handles authorized /risk without invoking the agent', async () => {
|
||||||
|
const deps = makeDeps({
|
||||||
|
setTaskRiskLevel: vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue('Task risk updated.\n- Task: paired-task-1'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await handleSessionCommand({
|
||||||
|
missedMessages: [makeMsg('/risk high')],
|
||||||
|
isMainGroup: true,
|
||||||
|
groupName: 'test',
|
||||||
|
triggerPattern: trigger,
|
||||||
|
timezone: 'UTC',
|
||||||
|
deps,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toEqual({ handled: true, success: true });
|
||||||
|
expect(deps.setTaskRiskLevel).toHaveBeenCalledWith('high');
|
||||||
|
expect(deps.runAgent).not.toHaveBeenCalled();
|
||||||
|
expect(deps.sendMessage).toHaveBeenCalledWith(
|
||||||
|
'Task risk updated.\n- Task: paired-task-1',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles authorized /plan without invoking the agent', async () => {
|
||||||
|
const deps = makeDeps({
|
||||||
|
recordPlan: vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue('Plan recorded.\n- Task: paired-task-1'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await handleSessionCommand({
|
||||||
|
missedMessages: [
|
||||||
|
makeMsg('/plan brief || acceptance criteria || rollout risk'),
|
||||||
|
],
|
||||||
|
isMainGroup: true,
|
||||||
|
groupName: 'test',
|
||||||
|
triggerPattern: trigger,
|
||||||
|
timezone: 'UTC',
|
||||||
|
deps,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toEqual({ handled: true, success: true });
|
||||||
|
expect(deps.recordPlan).toHaveBeenCalledWith({
|
||||||
|
planBrief: 'brief',
|
||||||
|
acceptanceCriteria: 'acceptance criteria',
|
||||||
|
riskSummary: 'rollout risk',
|
||||||
|
});
|
||||||
|
expect(deps.sendMessage).toHaveBeenCalledWith(
|
||||||
|
'Plan recorded.\n- Task: paired-task-1',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sends /plan usage when structured payload is incomplete', async () => {
|
||||||
|
const deps = makeDeps();
|
||||||
|
|
||||||
|
const result = await handleSessionCommand({
|
||||||
|
missedMessages: [makeMsg('/plan only brief')],
|
||||||
|
isMainGroup: true,
|
||||||
|
groupName: 'test',
|
||||||
|
triggerPattern: trigger,
|
||||||
|
timezone: 'UTC',
|
||||||
|
deps,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toEqual({ handled: true, success: true });
|
||||||
|
expect(deps.recordPlan).not.toHaveBeenCalled();
|
||||||
|
expect(deps.sendMessage).toHaveBeenCalledWith(
|
||||||
|
'Usage: /plan <plan brief> || <acceptance criteria> || <risk summary>',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles /approve-plan without invoking the agent', async () => {
|
||||||
|
const deps = makeDeps({
|
||||||
|
approvePlan: vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue('Plan approved.\n- Task: paired-task-1'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await handleSessionCommand({
|
||||||
|
missedMessages: [makeMsg('/approve-plan')],
|
||||||
|
isMainGroup: true,
|
||||||
|
groupName: 'test',
|
||||||
|
triggerPattern: trigger,
|
||||||
|
timezone: 'UTC',
|
||||||
|
deps,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toEqual({ handled: true, success: true });
|
||||||
|
expect(deps.approvePlan).toHaveBeenCalledTimes(1);
|
||||||
|
expect(deps.sendMessage).toHaveBeenCalledWith(
|
||||||
|
'Plan approved.\n- Task: paired-task-1',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles /request-plan-changes without invoking the agent', async () => {
|
||||||
|
const deps = makeDeps({
|
||||||
|
requestPlanChanges: vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue('Plan changes requested.\n- Task: paired-task-1'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await handleSessionCommand({
|
||||||
|
missedMessages: [makeMsg('/request-plan-changes tighten scope')],
|
||||||
|
isMainGroup: true,
|
||||||
|
groupName: 'test',
|
||||||
|
triggerPattern: trigger,
|
||||||
|
timezone: 'UTC',
|
||||||
|
deps,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toEqual({ handled: true, success: true });
|
||||||
|
expect(deps.requestPlanChanges).toHaveBeenCalledWith('tighten scope');
|
||||||
|
expect(deps.sendMessage).toHaveBeenCalledWith(
|
||||||
|
'Plan changes requested.\n- Task: paired-task-1',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it('sends denial to interactable sender in non-main group', async () => {
|
it('sends denial to interactable sender in non-main group', async () => {
|
||||||
const deps = makeDeps();
|
const deps = makeDeps();
|
||||||
const result = await handleSessionCommand({
|
const result = await handleSessionCommand({
|
||||||
|
|||||||
@@ -12,9 +12,26 @@ const SESSION_COMMAND_CONTROL_PATTERNS = [
|
|||||||
/^Conversation compacted\.$/,
|
/^Conversation compacted\.$/,
|
||||||
/^Review snapshot updated\.(?:\n|$)/,
|
/^Review snapshot updated\.(?:\n|$)/,
|
||||||
/^Review request recorded, but the owner workspace is not ready yet\.(?:\n|$)/,
|
/^Review request recorded, but the owner workspace is not ready yet\.(?:\n|$)/,
|
||||||
|
/^Plan review is required before formal review for this high-risk task\.(?:\n|$)/,
|
||||||
|
/^Task risk updated\.(?:\n|$)/,
|
||||||
|
/^Plan recorded\.(?:\n|$)/,
|
||||||
|
/^Plan approved\.(?:\n|$)/,
|
||||||
|
/^Plan changes requested\.(?:\n|$)/,
|
||||||
|
/^Risk updates must be handled by the owner service\.$/,
|
||||||
|
/^Plan recording must be handled by the owner service\.$/,
|
||||||
|
/^Plan approval must be handled by the reviewer service\.$/,
|
||||||
|
/^Plan review commands are only required for high-risk tasks\.$/,
|
||||||
|
/^Plan artifacts are incomplete\.(?:\n|$)/,
|
||||||
/^Review is unavailable for this room\./,
|
/^Review is unavailable for this room\./,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
function normalizeSessionCommandText(
|
||||||
|
content: string,
|
||||||
|
triggerPattern: RegExp,
|
||||||
|
): string {
|
||||||
|
return content.trim().replace(triggerPattern, '').trim();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extract a session slash command from a message, stripping the trigger prefix if present.
|
* Extract a session slash command from a message, stripping the trigger prefix if present.
|
||||||
* Returns the slash command (e.g., '/compact') or null if not a session command.
|
* Returns the slash command (e.g., '/compact') or null if not a session command.
|
||||||
@@ -23,11 +40,16 @@ export function extractSessionCommand(
|
|||||||
content: string,
|
content: string,
|
||||||
triggerPattern: RegExp,
|
triggerPattern: RegExp,
|
||||||
): string | null {
|
): string | null {
|
||||||
let text = content.trim();
|
const text = normalizeSessionCommandText(content, triggerPattern);
|
||||||
text = text.replace(triggerPattern, '').trim();
|
|
||||||
if (text === '/compact') return '/compact';
|
if (text === '/compact') return '/compact';
|
||||||
if (text === '/clear') return '/clear';
|
if (text === '/clear') return '/clear';
|
||||||
if (text === '/review' || text === '/review-ready') return '/review';
|
if (text === '/review' || text === '/review-ready') return '/review';
|
||||||
|
if (/^\/risk(?:\s|$)/.test(text)) return '/risk';
|
||||||
|
if (/^\/plan(?:\s|$)/.test(text)) return '/plan';
|
||||||
|
if (text === '/approve-plan') return '/approve-plan';
|
||||||
|
if (/^\/request-plan-changes(?:\s|$)/.test(text)) {
|
||||||
|
return '/request-plan-changes';
|
||||||
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,6 +95,14 @@ export interface SessionCommandDeps {
|
|||||||
/** Whether the denied sender would normally be allowed to interact (for denial messages). */
|
/** Whether the denied sender would normally be allowed to interact (for denial messages). */
|
||||||
canSenderInteract: (msg: NewMessage) => boolean;
|
canSenderInteract: (msg: NewMessage) => boolean;
|
||||||
markReviewReady: () => Promise<string | null>;
|
markReviewReady: () => Promise<string | null>;
|
||||||
|
setTaskRiskLevel: (riskLevel: 'low' | 'high') => Promise<string | null>;
|
||||||
|
recordPlan: (plan: {
|
||||||
|
planBrief: string;
|
||||||
|
acceptanceCriteria: string;
|
||||||
|
riskSummary: string;
|
||||||
|
}) => Promise<string | null>;
|
||||||
|
approvePlan: () => Promise<string | null>;
|
||||||
|
requestPlanChanges: (note?: string) => Promise<string | null>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function resultToText(result: string | object | null | undefined): string {
|
function resultToText(result: string | object | null | undefined): string {
|
||||||
@@ -120,6 +150,9 @@ export async function handleSessionCommand(opts: {
|
|||||||
const command = cmdMsg
|
const command = cmdMsg
|
||||||
? extractSessionCommand(cmdMsg.content, triggerPattern)
|
? extractSessionCommand(cmdMsg.content, triggerPattern)
|
||||||
: null;
|
: null;
|
||||||
|
const normalizedCommandText = cmdMsg
|
||||||
|
? normalizeSessionCommandText(cmdMsg.content, triggerPattern)
|
||||||
|
: '';
|
||||||
|
|
||||||
if (!command || !cmdMsg) return { handled: false };
|
if (!command || !cmdMsg) return { handled: false };
|
||||||
|
|
||||||
@@ -164,6 +197,60 @@ export async function handleSessionCommand(opts: {
|
|||||||
return { handled: true, success: true };
|
return { handled: true, success: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (command === '/risk') {
|
||||||
|
const riskArg = normalizedCommandText.slice('/risk'.length).trim().toLowerCase();
|
||||||
|
const riskLevel =
|
||||||
|
riskArg === 'high' ? 'high' : riskArg === 'low' ? 'low' : null;
|
||||||
|
deps.advanceCursor(cmdMsg.timestamp);
|
||||||
|
await deps.sendMessage(
|
||||||
|
riskLevel
|
||||||
|
? (await deps.setTaskRiskLevel(riskLevel)) ??
|
||||||
|
'Risk is unavailable for this room.'
|
||||||
|
: 'Usage: /risk <low|high>',
|
||||||
|
);
|
||||||
|
return { handled: true, success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (command === '/plan') {
|
||||||
|
const payload = normalizedCommandText.slice('/plan'.length).trim();
|
||||||
|
const parts = payload.split('||').map((part) => part.trim());
|
||||||
|
deps.advanceCursor(cmdMsg.timestamp);
|
||||||
|
if (parts.length !== 3 || parts.some((part) => !part)) {
|
||||||
|
await deps.sendMessage(
|
||||||
|
'Usage: /plan <plan brief> || <acceptance criteria> || <risk summary>',
|
||||||
|
);
|
||||||
|
return { handled: true, success: true };
|
||||||
|
}
|
||||||
|
await deps.sendMessage(
|
||||||
|
(await deps.recordPlan({
|
||||||
|
planBrief: parts[0],
|
||||||
|
acceptanceCriteria: parts[1],
|
||||||
|
riskSummary: parts[2],
|
||||||
|
})) ?? 'Plan recording is unavailable for this room.',
|
||||||
|
);
|
||||||
|
return { handled: true, success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (command === '/approve-plan') {
|
||||||
|
deps.advanceCursor(cmdMsg.timestamp);
|
||||||
|
await deps.sendMessage(
|
||||||
|
(await deps.approvePlan()) ?? 'Plan approval is unavailable for this room.',
|
||||||
|
);
|
||||||
|
return { handled: true, success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (command === '/request-plan-changes') {
|
||||||
|
const note = normalizedCommandText
|
||||||
|
.slice('/request-plan-changes'.length)
|
||||||
|
.trim();
|
||||||
|
deps.advanceCursor(cmdMsg.timestamp);
|
||||||
|
await deps.sendMessage(
|
||||||
|
(await deps.requestPlanChanges(note || undefined)) ??
|
||||||
|
'Plan change requests are unavailable for this room.',
|
||||||
|
);
|
||||||
|
return { handled: true, success: true };
|
||||||
|
}
|
||||||
|
|
||||||
const cmdIndex = missedMessages.indexOf(cmdMsg);
|
const cmdIndex = missedMessages.indexOf(cmdMsg);
|
||||||
const preCompactMsgs = missedMessages.slice(0, cmdIndex);
|
const preCompactMsgs = missedMessages.slice(0, cmdIndex);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user