fix: suppress paired completion status reentry
This commit is contained in:
@@ -138,4 +138,41 @@ describe('filterProcessableMessages', () => {
|
|||||||
expect(result[0].id).toBe('human-1');
|
expect(result[0].id).toBe('human-1');
|
||||||
expect(result[1].id).toBe('other-1');
|
expect(result[1].id).toBe('other-1');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('filters paired execution status control bot messages in paired rooms', () => {
|
||||||
|
const isOwn = (m: NewMessage) =>
|
||||||
|
m.is_bot_message === true && m.sender === OWN_BOT_ID;
|
||||||
|
const result = filterProcessableMessages(
|
||||||
|
[
|
||||||
|
makeMsg({ id: 'human-1', content: 'human' }),
|
||||||
|
makeMsg({
|
||||||
|
id: 'control-1',
|
||||||
|
sender: 'other-bot-456',
|
||||||
|
sender_name: 'OtherBot',
|
||||||
|
content: '<@216851709744513024> ✅ 작업 완료.',
|
||||||
|
is_bot_message: true,
|
||||||
|
}),
|
||||||
|
makeMsg({
|
||||||
|
id: 'control-2',
|
||||||
|
sender: 'other-bot-456',
|
||||||
|
sender_name: 'OtherBot',
|
||||||
|
content: '⚠️ 자동 해결 불가 — 확인이 필요합니다.',
|
||||||
|
is_bot_message: true,
|
||||||
|
}),
|
||||||
|
makeMsg({
|
||||||
|
id: 'other-1',
|
||||||
|
sender: 'other-bot-456',
|
||||||
|
sender_name: 'OtherBot',
|
||||||
|
content: 'partner response',
|
||||||
|
is_bot_message: true,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
true,
|
||||||
|
isOwn,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toHaveLength(2);
|
||||||
|
expect(result[0].id).toBe('human-1');
|
||||||
|
expect(result[1].id).toBe('other-1');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,13 @@
|
|||||||
import { isSessionCommandControlMessage } from './session-commands.js';
|
import { isSessionCommandControlMessage } from './session-commands.js';
|
||||||
import { NewMessage } from './types.js';
|
import { NewMessage } from './types.js';
|
||||||
|
|
||||||
|
const PAIRED_EXECUTION_CONTROL_MESSAGE_PATTERN =
|
||||||
|
/^(?:<@\d+>\s+)?(?:✅ 작업 완료\.|⚠️ 자동 해결 불가 — 확인이 필요합니다\.|⚠️ 중재자 판단: 사람 개입이 필요합니다\.)$/;
|
||||||
|
|
||||||
|
function isPairedExecutionControlMessage(content: string): boolean {
|
||||||
|
return PAIRED_EXECUTION_CONTROL_MESSAGE_PATTERN.test(content.trim());
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Filter messages before processing.
|
* Filter messages before processing.
|
||||||
* - Normal rooms: drop all bot messages.
|
* - Normal rooms: drop all bot messages.
|
||||||
@@ -16,7 +23,8 @@ export function filterProcessableMessages(
|
|||||||
(message) =>
|
(message) =>
|
||||||
!(
|
!(
|
||||||
message.is_bot_message &&
|
message.is_bot_message &&
|
||||||
isSessionCommandControlMessage(message.content)
|
(isSessionCommandControlMessage(message.content) ||
|
||||||
|
isPairedExecutionControlMessage(message.content))
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -244,7 +244,6 @@ export function createPairedExecutionLifecycle(args: {
|
|||||||
const sender = getLastHumanMessageSender(chatJid);
|
const sender = getLastHumanMessageSender(chatJid);
|
||||||
const mention = sender ? `<@${sender}>` : '';
|
const mention = sender ? `<@${sender}>` : '';
|
||||||
const notifications: Record<string, string> = {
|
const notifications: Record<string, string> = {
|
||||||
done: `${mention} ✅ 작업 완료.`,
|
|
||||||
escalated: `${mention} ⚠️ 자동 해결 불가 — 확인이 필요합니다.`,
|
escalated: `${mention} ⚠️ 자동 해결 불가 — 확인이 필요합니다.`,
|
||||||
arbiter_escalated: `${mention} ⚠️ 중재자 판단: 사람 개입이 필요합니다.`,
|
arbiter_escalated: `${mention} ⚠️ 중재자 판단: 사람 개입이 필요합니다.`,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -837,6 +837,176 @@ describe('runAgentForGroup room memory', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('does not emit an extra public done notification after owner finalization completes the paired task', async () => {
|
||||||
|
const group = {
|
||||||
|
...makeGroup(),
|
||||||
|
folder: 'test-group',
|
||||||
|
workDir: '/repo/canonical',
|
||||||
|
};
|
||||||
|
const outputs: string[] = [];
|
||||||
|
|
||||||
|
vi.mocked(db.getLastHumanMessageSender).mockReturnValue('216851709744513024');
|
||||||
|
vi.mocked(
|
||||||
|
pairedExecutionContext.preparePairedExecutionContext,
|
||||||
|
).mockReturnValue({
|
||||||
|
task: {
|
||||||
|
id: 'paired-task-final-done',
|
||||||
|
chat_jid: 'group@test',
|
||||||
|
group_folder: 'test-group',
|
||||||
|
owner_service_id: 'claude',
|
||||||
|
reviewer_service_id: 'codex-main',
|
||||||
|
title: null,
|
||||||
|
source_ref: 'HEAD',
|
||||||
|
plan_notes: null,
|
||||||
|
round_trip_count: 1,
|
||||||
|
review_requested_at: '2026-04-09T00:00:00.000Z',
|
||||||
|
status: 'merge_ready',
|
||||||
|
arbiter_verdict: null,
|
||||||
|
arbiter_requested_at: null,
|
||||||
|
completion_reason: null,
|
||||||
|
created_at: '2026-04-09T00:00:00.000Z',
|
||||||
|
updated_at: '2026-04-09T00:00:00.000Z',
|
||||||
|
},
|
||||||
|
workspace: null,
|
||||||
|
envOverrides: {},
|
||||||
|
});
|
||||||
|
vi.mocked(db.getPairedTaskById).mockReturnValue({
|
||||||
|
id: 'paired-task-final-done',
|
||||||
|
chat_jid: 'group@test',
|
||||||
|
group_folder: 'test-group',
|
||||||
|
owner_service_id: 'claude',
|
||||||
|
reviewer_service_id: 'codex-main',
|
||||||
|
title: null,
|
||||||
|
source_ref: 'HEAD',
|
||||||
|
plan_notes: null,
|
||||||
|
round_trip_count: 1,
|
||||||
|
review_requested_at: '2026-04-09T00:00:00.000Z',
|
||||||
|
status: 'completed',
|
||||||
|
arbiter_verdict: null,
|
||||||
|
arbiter_requested_at: null,
|
||||||
|
completion_reason: 'done',
|
||||||
|
created_at: '2026-04-09T00:00:00.000Z',
|
||||||
|
updated_at: '2026-04-09T00:00:01.000Z',
|
||||||
|
});
|
||||||
|
vi.mocked(agentRunner.runAgentProcess).mockImplementation(
|
||||||
|
async (_group, _input, _onProcess, onOutput) => {
|
||||||
|
await onOutput?.({
|
||||||
|
status: 'success',
|
||||||
|
result: 'DONE\nfinalized',
|
||||||
|
output: { visibility: 'public', text: 'DONE\nfinalized' },
|
||||||
|
phase: 'final',
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
status: 'success',
|
||||||
|
result: 'DONE\nfinalized',
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await runAgentForGroup(makeDeps(), {
|
||||||
|
group,
|
||||||
|
prompt: 'finalize',
|
||||||
|
chatJid: 'group@test',
|
||||||
|
runId: 'run-final-done',
|
||||||
|
onOutput: async (output) => {
|
||||||
|
if (output.output?.visibility === 'public' && output.output.text) {
|
||||||
|
outputs.push(output.output.text);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toBe('success');
|
||||||
|
expect(outputs).toEqual(['DONE\nfinalized']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still emits escalation notifications when the paired task completes with human intervention required', async () => {
|
||||||
|
const group = {
|
||||||
|
...makeGroup(),
|
||||||
|
folder: 'test-group',
|
||||||
|
workDir: '/repo/canonical',
|
||||||
|
};
|
||||||
|
const outputs: string[] = [];
|
||||||
|
|
||||||
|
vi.mocked(db.getLastHumanMessageSender).mockReturnValue('216851709744513024');
|
||||||
|
vi.mocked(
|
||||||
|
pairedExecutionContext.preparePairedExecutionContext,
|
||||||
|
).mockReturnValue({
|
||||||
|
task: {
|
||||||
|
id: 'paired-task-final-escalated',
|
||||||
|
chat_jid: 'group@test',
|
||||||
|
group_folder: 'test-group',
|
||||||
|
owner_service_id: 'claude',
|
||||||
|
reviewer_service_id: 'codex-main',
|
||||||
|
title: null,
|
||||||
|
source_ref: 'HEAD',
|
||||||
|
plan_notes: null,
|
||||||
|
round_trip_count: 1,
|
||||||
|
review_requested_at: '2026-04-09T00:00:00.000Z',
|
||||||
|
status: 'merge_ready',
|
||||||
|
arbiter_verdict: null,
|
||||||
|
arbiter_requested_at: null,
|
||||||
|
completion_reason: null,
|
||||||
|
created_at: '2026-04-09T00:00:00.000Z',
|
||||||
|
updated_at: '2026-04-09T00:00:00.000Z',
|
||||||
|
},
|
||||||
|
workspace: null,
|
||||||
|
envOverrides: {},
|
||||||
|
});
|
||||||
|
vi.mocked(db.getPairedTaskById).mockReturnValue({
|
||||||
|
id: 'paired-task-final-escalated',
|
||||||
|
chat_jid: 'group@test',
|
||||||
|
group_folder: 'test-group',
|
||||||
|
owner_service_id: 'claude',
|
||||||
|
reviewer_service_id: 'codex-main',
|
||||||
|
title: null,
|
||||||
|
source_ref: 'HEAD',
|
||||||
|
plan_notes: null,
|
||||||
|
round_trip_count: 1,
|
||||||
|
review_requested_at: '2026-04-09T00:00:00.000Z',
|
||||||
|
status: 'completed',
|
||||||
|
arbiter_verdict: null,
|
||||||
|
arbiter_requested_at: null,
|
||||||
|
completion_reason: 'escalated',
|
||||||
|
created_at: '2026-04-09T00:00:00.000Z',
|
||||||
|
updated_at: '2026-04-09T00:00:01.000Z',
|
||||||
|
});
|
||||||
|
vi.mocked(agentRunner.runAgentProcess).mockImplementation(
|
||||||
|
async (_group, _input, _onProcess, onOutput) => {
|
||||||
|
await onOutput?.({
|
||||||
|
status: 'success',
|
||||||
|
result: 'BLOCKED\nhuman intervention required',
|
||||||
|
output: {
|
||||||
|
visibility: 'public',
|
||||||
|
text: 'BLOCKED\nhuman intervention required',
|
||||||
|
},
|
||||||
|
phase: 'final',
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
status: 'success',
|
||||||
|
result: 'BLOCKED\nhuman intervention required',
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await runAgentForGroup(makeDeps(), {
|
||||||
|
group,
|
||||||
|
prompt: 'finalize',
|
||||||
|
chatJid: 'group@test',
|
||||||
|
runId: 'run-final-escalated',
|
||||||
|
onOutput: async (output) => {
|
||||||
|
if (output.output?.visibility === 'public' && output.output.text) {
|
||||||
|
outputs.push(output.output.text);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toBe('success');
|
||||||
|
expect(outputs).toEqual([
|
||||||
|
'BLOCKED\nhuman intervention required',
|
||||||
|
'<@216851709744513024> ⚠️ 자동 해결 불가 — 확인이 필요합니다.',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
it('logs streamed activity with resolved execution attribution', async () => {
|
it('logs streamed activity with resolved execution attribution', async () => {
|
||||||
const group = {
|
const group = {
|
||||||
...makeGroup(),
|
...makeGroup(),
|
||||||
|
|||||||
Reference in New Issue
Block a user