From 77a79180638da997f711cf43b8b1507426b5cddf Mon Sep 17 00:00:00 2001 From: Eyejoker Date: Mon, 23 Mar 2026 05:46:37 +0900 Subject: [PATCH] fix: restore implicit follow-up continuation --- src/message-runtime.test.ts | 93 +++++++++++++++++++++++++++++++++++++ src/message-runtime.ts | 38 ++++++++++++--- 2 files changed, 125 insertions(+), 6 deletions(-) diff --git a/src/message-runtime.test.ts b/src/message-runtime.test.ts index 09b9651..ba94163 100644 --- a/src/message-runtime.test.ts +++ b/src/message-runtime.test.ts @@ -314,6 +314,99 @@ describe('createMessageRuntime', () => { expect(saveState).toHaveBeenCalled(); }); + it('allows follow-up messages without a trigger after a visible reply in non-main groups', async () => { + const chatJid = 'group@test'; + const group: RegisteredGroup = { + ...makeGroup('codex'), + requiresTrigger: true, + }; + const channel = makeChannel(chatJid); + const saveState = vi.fn(); + const lastAgentTimestamps: Record = {}; + + vi.mocked(db.getMessagesSince) + .mockReturnValueOnce([ + { + id: 'msg-1', + chat_jid: chatJid, + sender: 'user@test', + sender_name: 'User', + content: '@Andy 첫 요청', + timestamp: '2026-03-18T09:00:00.000Z', + }, + ]) + .mockReturnValueOnce([ + { + id: 'msg-2', + chat_jid: chatJid, + sender: 'user@test', + sender_name: 'User', + content: '두 번째 말은 멘션 없이 이어서', + timestamp: '2026-03-18T09:00:10.000Z', + }, + ]); + + vi.mocked(agentRunner.runAgentProcess).mockImplementation( + async (_group, _input, _onProcess, onOutput) => { + await onOutput?.({ + status: 'success', + result: '응답했습니다.', + phase: 'final', + }); + return { + status: 'success', + result: '응답했습니다.', + phase: 'final', + }; + }, + ); + + const runtime = createMessageRuntime({ + assistantName: 'Andy', + idleTimeout: 60_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 first = await runtime.processGroupMessages(chatJid, { + runId: 'run-triggered-first-turn', + reason: 'messages', + }); + const second = await runtime.processGroupMessages(chatJid, { + runId: 'run-triggerless-follow-up', + reason: 'messages', + }); + + expect(first).toBe(true); + expect(second).toBe(true); + expect(agentRunner.runAgentProcess).toHaveBeenCalledTimes(2); + expect(channel.sendMessage).toHaveBeenNthCalledWith( + 1, + chatJid, + '응답했습니다.', + ); + expect(channel.sendMessage).toHaveBeenNthCalledWith( + 2, + chatJid, + '응답했습니다.', + ); + }); + it('clears Claude sessions and closes stdin immediately on poisoned output', async () => { const chatJid = 'group@test'; const group = makeGroup('claude-code'); diff --git a/src/message-runtime.ts b/src/message-runtime.ts index be705d5..10d4aa5 100644 --- a/src/message-runtime.ts +++ b/src/message-runtime.ts @@ -86,6 +86,7 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): { startMessageLoop: () => Promise; } { let messageLoopRunning = false; + const implicitContinuationUntil = new Map(); const getCurrentAvailableGroups = (): AvailableGroup[] => getAvailableGroups(deps.getRegisteredGroups()); @@ -115,6 +116,24 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): { deps.saveState(); }; + const openImplicitContinuationWindow = (chatJid: string): void => { + if (deps.idleTimeout <= 0) return; + implicitContinuationUntil.set(chatJid, Date.now() + deps.idleTimeout); + }; + + const hasImplicitContinuationWindow = ( + chatJid: string, + messages: NewMessage[], + ): boolean => { + const until = implicitContinuationUntil.get(chatJid); + if (!until) return false; + if (Date.now() > until) { + implicitContinuationUntil.delete(chatJid); + return false; + } + return messages.some((message) => message.is_from_me !== true); + }; + const getProcessableMessages = ( chatJid: string, messages: Parameters[0], @@ -138,10 +157,7 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): { // into a reply loop. return messages.filter( (message) => - !( - message.is_bot_message && - message.content.trim() === failureText - ), + !(message.is_bot_message && message.content.trim() === failureText), ); }; @@ -161,6 +177,7 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): { item.result_payload, ); markWorkItemDelivered(item.id, replaceMessageId); + openImplicitContinuationWindow(item.chat_jid); logger.info( { chatJid: item.chat_jid, @@ -188,6 +205,7 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): { try { await channel.sendMessage(item.chat_jid, item.result_payload); markWorkItemDelivered(item.id); + openImplicitContinuationWindow(item.chat_jid); logger.info( { chatJid: item.chat_jid, @@ -655,7 +673,10 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): { (msg.is_from_me || isTriggerAllowed(chatJid, msg.sender, allowlistCfg)), ); - if (!hasTrigger) { + if ( + !hasTrigger && + !hasImplicitContinuationWindow(chatJid, missedMessages) + ) { logger.info( { chatJid, group: group.name, groupFolder: group.folder, runId }, 'Skipping queued run because no allowed trigger was found', @@ -1203,7 +1224,12 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): { (msg.is_from_me || isTriggerAllowed(chatJid, msg.sender, allowlistCfg)), ); - if (!hasTrigger) continue; + if ( + !hasTrigger && + !hasImplicitContinuationWindow(chatJid, processableGroupMessages) + ) { + continue; + } } deps.queue.enqueueMessageCheck(chatJid, group.folder);