From 0bbb3fe14f9f3502aae79729906b8a7064227c1e Mon Sep 17 00:00:00 2001 From: Eyejoker Date: Fri, 29 May 2026 19:15:14 +0900 Subject: [PATCH] fix: treat CI watcher completions as system input (#191) --- src/message-runtime-dispatch.test.ts | 29 ++++ src/message-runtime-dispatch.ts | 5 +- src/message-runtime-flow.ts | 6 +- ...message-runtime-preflight-messages.test.ts | 8 + src/message-runtime-preflight-messages.ts | 5 +- src/message-runtime-queue.ts | 5 +- src/message-runtime-rules.ts | 37 ++++- src/message-runtime-trusted-system.test.ts | 142 ++++++++++++++++++ 8 files changed, 218 insertions(+), 19 deletions(-) create mode 100644 src/message-runtime-trusted-system.test.ts diff --git a/src/message-runtime-dispatch.test.ts b/src/message-runtime-dispatch.test.ts index 8c5571f..cbafe0b 100644 --- a/src/message-runtime-dispatch.test.ts +++ b/src/message-runtime-dispatch.test.ts @@ -144,4 +144,33 @@ describe('processLoopGroupMessages', () => { expect(args.enqueueMessageCheck).not.toHaveBeenCalled(); expect(args.lastAgentTimestamps).toEqual({ [chatJid]: '12' }); }); + + it('pipes trusted external bot events instead of treating them as human interrupts', async () => { + hasReviewerLeaseMock.mockReturnValue(true); + const args = defaultArgs({ + groupMessages: [ + message({ + id: 'watch-ci-completed:task-1', + sender: 'ci-watcher', + sender_name: 'CI watcher', + content: '[CI watcher completed]\nCI succeeded', + is_from_me: false, + is_bot_message: false, + message_source_kind: 'trusted_external_bot', + seq: 13, + }), + ], + isRunningMessageTurn: vi.fn(() => true), + }); + + await processLoopGroupMessages(args); + + expect(args.sendQueuedMessage).toHaveBeenCalledWith( + chatJid, + '[CI watcher completed]\nCI succeeded', + ); + expect(args.closeStdin).not.toHaveBeenCalled(); + expect(args.enqueueMessageCheck).not.toHaveBeenCalled(); + expect(args.lastAgentTimestamps).toEqual({ [chatJid]: '13' }); + }); }); diff --git a/src/message-runtime-dispatch.ts b/src/message-runtime-dispatch.ts index 6543784..1d370ae 100644 --- a/src/message-runtime-dispatch.ts +++ b/src/message-runtime-dispatch.ts @@ -9,6 +9,7 @@ import { advanceLastAgentCursor, filterLoopingPairedBotMessages, getProcessableMessages, + isExternalHumanMessage, resolveCursorKey, shouldSkipBotOnlyCollaboration, } from './message-runtime-rules.js'; @@ -32,9 +33,7 @@ import type { type RuntimeLog = Pick; function hasExternalHumanMessage(messages: NewMessage[]): boolean { - return messages.some( - (message) => message.is_from_me !== true && !message.is_bot_message, - ); + return messages.some(isExternalHumanMessage); } export function enqueueGenericFollowUpAfterDeliveryRetry(args: { diff --git a/src/message-runtime-flow.ts b/src/message-runtime-flow.ts index c794a46..e7d937c 100644 --- a/src/message-runtime-flow.ts +++ b/src/message-runtime-flow.ts @@ -17,6 +17,7 @@ import { } from './message-runtime-follow-up.js'; import { advanceLastAgentCursor, + isBotOrTrustedSystemMessage, resolveCursorKey, resolveNextTurnAction, } from './message-runtime-rules.js'; @@ -83,10 +84,7 @@ export function isBotOnlyPairedRoomTurn( messages: NewMessage[], ): boolean { return ( - hasReviewerLease(chatJid) && - messages.every( - (message) => message.is_from_me === true || !!message.is_bot_message, - ) + hasReviewerLease(chatJid) && messages.every(isBotOrTrustedSystemMessage) ); } diff --git a/src/message-runtime-preflight-messages.test.ts b/src/message-runtime-preflight-messages.test.ts index da6b586..873391f 100644 --- a/src/message-runtime-preflight-messages.test.ts +++ b/src/message-runtime-preflight-messages.test.ts @@ -83,6 +83,14 @@ describe('message-runtime-preflight-messages', () => { seq: 13, is_bot_message: true, }), + makeMessage({ + id: 'trusted-system', + content: '[CI watcher completed]', + seq: 14, + is_from_me: false, + is_bot_message: false, + message_source_kind: 'trusted_external_bot', + }), ]); const messages = getFreshHumanPreflightMessages({ diff --git a/src/message-runtime-preflight-messages.ts b/src/message-runtime-preflight-messages.ts index 88f1a46..50292c2 100644 --- a/src/message-runtime-preflight-messages.ts +++ b/src/message-runtime-preflight-messages.ts @@ -2,6 +2,7 @@ import { getMessagesSinceSeq } from './db.js'; import { filterLoopingPairedBotMessages, getProcessableMessages, + isExternalHumanMessage, } from './message-runtime-rules.js'; import type { WorkItem } from './db/work-items.js'; import type { Channel, NewMessage } from './types.js'; @@ -24,9 +25,7 @@ export function getFreshHumanPreflightMessages(args: { getProcessableMessages(args.chatJid, preflightRawMessages, args.channel), args.failureFinalText, ); - return preflightMessages.filter( - (message) => message.is_from_me !== true && !message.is_bot_message, - ); + return preflightMessages.filter(isExternalHumanMessage); } export function hasHumanMessageAfterWorkItem( diff --git a/src/message-runtime-queue.ts b/src/message-runtime-queue.ts index 5fc2f23..9cda40d 100644 --- a/src/message-runtime-queue.ts +++ b/src/message-runtime-queue.ts @@ -15,6 +15,7 @@ import { resolveStoredVisibleVerdict } from './paired-verdict.js'; import { advanceLastAgentCursor, finalizeQueuedRunCursor, + isExternalHumanMessage, resolveActiveRole, resolveCursorKeyForRole, resolveQueuedPairedTurnRole, @@ -243,9 +244,7 @@ export async function runQueuedGroupTurn(args: { args; let currentTask = task; const hasHumanMsg = task - ? !missedMessages.every( - (message) => message.is_from_me === true || !!message.is_bot_message, - ) + ? missedMessages.some(isExternalHumanMessage) : !isBotOnlyPairedRoomTurn(chatJid, missedMessages); let fallbackMessages = missedMessages; if (currentTask !== undefined && hasHumanMsg) { diff --git a/src/message-runtime-rules.ts b/src/message-runtime-rules.ts index 4943013..ed30815 100644 --- a/src/message-runtime-rules.ts +++ b/src/message-runtime-rules.ts @@ -26,6 +26,35 @@ import { const BOT_COLLABORATION_WINDOW_MS = 12 * 60 * 60 * 1000; +export type MessageOriginFlags = Pick< + NewMessage, + 'is_from_me' | 'is_bot_message' | 'message_source_kind' +>; + +export function isTrustedExternalBotMessage( + message: Pick, +): boolean { + return message.message_source_kind === 'trusted_external_bot'; +} + +export function isExternalHumanMessage(message: MessageOriginFlags): boolean { + return ( + message.is_from_me !== true && + !message.is_bot_message && + !isTrustedExternalBotMessage(message) + ); +} + +export function isBotOrTrustedSystemMessage( + message: MessageOriginFlags, +): boolean { + return ( + message.is_from_me === true || + !!message.is_bot_message || + isTrustedExternalBotMessage(message) + ); +} + export function advanceLastAgentCursor( lastAgentTimestamps: Record, saveState: () => void, @@ -439,9 +468,7 @@ export function createImplicitContinuationTracker(idleTimeout: number) { implicitContinuationUntil.delete(chatJid); return false; } - return messages.some( - (message) => message.is_from_me !== true && !message.is_bot_message, - ); + return messages.some(isExternalHumanMessage); }, }; } @@ -451,9 +478,7 @@ export function shouldSkipBotOnlyCollaboration( messages: NewMessage[], ): boolean { if (hasReviewerLease(chatJid)) return false; - const allFromBots = messages.every( - (message) => message.is_from_me || !!message.is_bot_message, - ); + const allFromBots = messages.every(isBotOrTrustedSystemMessage); if (!allFromBots) return false; const lastHuman = getLastHumanMessageTimestamp(chatJid); if (!lastHuman) return true; diff --git a/src/message-runtime-trusted-system.test.ts b/src/message-runtime-trusted-system.test.ts new file mode 100644 index 0000000..32c214b --- /dev/null +++ b/src/message-runtime-trusted-system.test.ts @@ -0,0 +1,142 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { _initTestDatabase, createPairedTask } from './db.js'; +import { runQueuedGroupTurn } from './message-runtime-queue.js'; +import { + isBotOrTrustedSystemMessage, + isExternalHumanMessage, +} from './message-runtime-rules.js'; +import { resetPairedFollowUpScheduleState } from './paired-follow-up-scheduler.js'; +import type { Channel, PairedTask, RegisteredGroup } from './types.js'; + +function makeGroup(): RegisteredGroup { + return { + name: 'Test Group', + folder: 'test-group', + trigger: '@Andy', + added_at: '2026-03-30T00:00:00.000Z', + requiresTrigger: false, + agentType: 'codex', + workDir: '/repo', + }; +} + +function makeTask(): PairedTask { + return { + id: 'task-trusted-system', + chat_jid: 'group@test', + group_folder: 'test-group', + owner_service_id: 'claude', + reviewer_service_id: 'codex-main', + owner_agent_type: 'claude-code', + reviewer_agent_type: 'codex', + arbiter_agent_type: null, + title: null, + source_ref: 'HEAD', + plan_notes: null, + review_requested_at: '2026-03-30T00:00:00.000Z', + round_trip_count: 1, + status: 'review_ready', + arbiter_verdict: null, + arbiter_requested_at: null, + completion_reason: null, + created_at: '2026-03-30T00:00:00.000Z', + updated_at: '2026-03-30T00:00:00.000Z', + }; +} + +function makeChannel(): Channel { + return { + name: 'discord-review', + connect: vi.fn(), + sendMessage: vi.fn(), + isConnected: vi.fn(() => true), + ownsJid: vi.fn(() => true), + disconnect: vi.fn(), + } as unknown as Channel; +} + +describe('trusted system message routing', () => { + beforeEach(() => { + _initTestDatabase(); + resetPairedFollowUpScheduleState(); + }); + + it('does not classify trusted external bot events as human interruptions', () => { + const trustedEvent = { + is_from_me: false, + is_bot_message: false, + message_source_kind: 'trusted_external_bot' as const, + }; + + expect(isExternalHumanMessage(trustedEvent)).toBe(false); + expect(isBotOrTrustedSystemMessage(trustedEvent)).toBe(true); + expect( + isExternalHumanMessage({ + is_from_me: false, + is_bot_message: false, + message_source_kind: 'human', + }), + ).toBe(true); + }); + + it('keeps trusted CI completions on the pending reviewer path', async () => { + const task = makeTask(); + createPairedTask(task); + const executeTurn = vi.fn(async () => ({ + outputStatus: 'success' as const, + deliverySucceeded: true, + visiblePhase: 'final', + })); + + const outcome = await runQueuedGroupTurn({ + chatJid: task.chat_jid, + group: makeGroup(), + runId: 'run-ci-completion-reviewer', + log: { info: vi.fn(), warn: vi.fn(), error: vi.fn() } as any, + timezone: 'UTC', + missedMessages: [ + { + id: 'watch-ci-completed:task-1', + chat_jid: task.chat_jid, + sender: 'ci-watcher', + sender_name: 'CI watcher', + content: '[CI watcher completed]\nCI succeeded', + timestamp: '2026-03-30T00:00:02.000Z', + seq: 46, + is_from_me: false, + is_bot_message: false, + message_source_kind: 'trusted_external_bot', + }, + ], + task, + roleToChannel: { + owner: null, + reviewer: makeChannel(), + arbiter: null, + }, + ownerChannel: makeChannel(), + lastAgentTimestamps: {}, + saveState: vi.fn(), + executeTurn, + getFixedRoleChannelName: () => 'discord-review', + labelPairedSenders: (_chatJid, messages) => messages, + formatMessages: () => 'formatted prompt', + }); + + expect(outcome).toBe(true); + expect(executeTurn).toHaveBeenCalledWith( + expect.objectContaining({ + deliveryRole: 'reviewer', + forcedRole: 'reviewer', + pairedTurnIdentity: { + turnId: 'task-trusted-system:2026-03-30T00:00:00.000Z:reviewer-turn', + taskId: 'task-trusted-system', + taskUpdatedAt: '2026-03-30T00:00:00.000Z', + intentKind: 'reviewer-turn', + role: 'reviewer', + }, + }), + ); + }); +});