diff --git a/src/message-runtime-progress-entry.test.ts b/src/message-runtime-progress-entry.test.ts new file mode 100644 index 0000000..73c77eb --- /dev/null +++ b/src/message-runtime-progress-entry.test.ts @@ -0,0 +1,144 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const runtimeMocks = vi.hoisted(() => ({ + executeTurn: vi.fn(async () => ({ + outputStatus: 'success' as const, + deliverySucceeded: true, + visiblePhase: 'progress' as const, + })), +})); + +vi.mock('./message-runtime-turns.js', () => ({ + createRunAgent: vi.fn(() => vi.fn()), + createExecuteTurn: vi.fn(() => runtimeMocks.executeTurn), + isDuplicateOfLastBotFinal: vi.fn(() => false), + labelPairedSenders: vi.fn((_channels, _chatJid, messages) => messages), +})); + +import { + _initTestDatabase, + _setRegisteredGroupForTests, + getLatestOpenPairedTaskForChat, + storeChatMetadata, + storeMessage, +} from './db.js'; +import { createMessageRuntime } from './message-runtime.js'; +import { + clearGlobalFailover, + refreshChannelOwnerCache, +} from './service-routing.js'; +import type { Channel, RegisteredGroup } from './types.js'; + +const chatJid = 'group@test'; +const timestamp = '2026-04-30T00:30:00.000Z'; + +function makeGroup(): RegisteredGroup { + return { + name: 'Test Group', + folder: 'test-group', + trigger: '@Andy', + added_at: timestamp, + requiresTrigger: false, + agentType: 'codex', + workDir: process.cwd(), + }; +} + +function makeChannel(name: string, ownsJid: boolean): Channel { + return { + name, + connect: vi.fn(), + sendMessage: vi.fn(), + isConnected: vi.fn(() => true), + ownsJid: vi.fn((jid: string) => ownsJid && jid === chatJid), + disconnect: vi.fn(), + setTyping: vi.fn(), + } as unknown as Channel; +} + +describe('message-runtime progress entry path', () => { + beforeEach(() => { + _initTestDatabase(); + refreshChannelOwnerCache(true); + clearGlobalFailover(); + runtimeMocks.executeTurn.mockClear(); + runtimeMocks.executeTurn.mockResolvedValue({ + outputStatus: 'success', + deliverySucceeded: true, + visiblePhase: 'progress', + }); + }); + + it('passes pairedTurnIdentity through the production owner-turn entry path when no task is open', async () => { + const group = makeGroup(); + const ownerChannel = makeChannel('discord', true); + const reviewerChannel = makeChannel('discord-review', false); + const lastAgentTimestamps: Record = {}; + const saveState = vi.fn(); + + _setRegisteredGroupForTests(chatJid, { + ...group, + agentType: 'claude-code', + }); + _setRegisteredGroupForTests(chatJid, group); + refreshChannelOwnerCache(true); + storeChatMetadata(chatJid, timestamp, 'Test Group'); + storeMessage({ + id: 'human-first-owner-turn', + chat_jid: chatJid, + sender: 'user@test', + sender_name: 'User', + content: '디스코드에는 progress가 보이는데 사이트에는 안 보여요', + timestamp, + is_bot_message: false, + is_from_me: false, + }); + + const runtime = createMessageRuntime({ + assistantName: 'Andy', + idleTimeout: 1_000, + pollInterval: 1_000, + timezone: 'UTC', + triggerPattern: /^@Andy\b/i, + channels: [ownerChannel, reviewerChannel], + queue: { + enqueueMessageCheck: vi.fn(), + registerProcess: vi.fn(), + closeStdin: vi.fn(), + notifyIdle: vi.fn(), + } as any, + getRoomBindings: () => ({ [chatJid]: group }), + getSessions: () => ({}), + getLastTimestamp: () => '', + setLastTimestamp: vi.fn(), + getLastAgentTimestamps: () => lastAgentTimestamps, + saveState, + persistSession: vi.fn(), + clearSession: vi.fn(), + }); + + const result = await runtime.processGroupMessages(chatJid, { + runId: 'run-first-owner-entry', + reason: 'messages', + }); + + const freshTask = getLatestOpenPairedTaskForChat(chatJid); + expect(result).toBe(true); + expect(freshTask).toBeDefined(); + expect(runtimeMocks.executeTurn).toHaveBeenCalledWith( + expect.objectContaining({ + deliveryRole: 'owner', + forcedRole: 'owner', + pairedTurnIdentity: { + turnId: `${freshTask!.id}:${freshTask!.updated_at}:owner-turn`, + taskId: freshTask!.id, + taskUpdatedAt: freshTask!.updated_at, + intentKind: 'owner-turn', + role: 'owner', + }, + }), + ); + expect(lastAgentTimestamps).toEqual({ [chatJid]: '1' }); + expect(saveState).toHaveBeenCalled(); + }); +}); diff --git a/src/message-runtime-queue-progress.test.ts b/src/message-runtime-queue-progress.test.ts new file mode 100644 index 0000000..200d3a6 --- /dev/null +++ b/src/message-runtime-queue-progress.test.ts @@ -0,0 +1,98 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { _initTestDatabase, getLatestOpenPairedTaskForChat } from './db.js'; +import { runQueuedGroupTurn } from './message-runtime-queue.js'; +import { resetPairedFollowUpScheduleState } from './paired-follow-up-scheduler.js'; +import type { Channel, 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 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('message-runtime queue progress persistence', () => { + beforeEach(() => { + _initTestDatabase(); + resetPairedFollowUpScheduleState(); + }); + + it('creates a paired owner task before the first queued turn so progress has a turn identity', async () => { + const executeTurn = vi.fn(async () => ({ + outputStatus: 'success' as const, + deliverySucceeded: true, + visiblePhase: 'progress', + })); + const lastAgentTimestamps: Record = {}; + const saveState = vi.fn(); + + const outcome = await runQueuedGroupTurn({ + chatJid: 'group@test', + group: makeGroup(), + runId: 'run-new-paired-owner', + log: { info: vi.fn(), warn: vi.fn(), error: vi.fn() } as any, + timezone: 'UTC', + missedMessages: [ + { + id: 'human-new-task-1', + chat_jid: 'group@test', + sender: 'user@test', + sender_name: 'User', + content: '새 작업입니다', + timestamp: '2026-03-30T00:00:03.000Z', + seq: 48, + is_bot_message: false, + }, + ], + task: null, + roleToChannel: { + owner: null, + reviewer: makeChannel(), + arbiter: null, + }, + ownerChannel: makeChannel(), + lastAgentTimestamps, + saveState, + executeTurn, + getFixedRoleChannelName: () => 'discord-review', + labelPairedSenders: (_chatJid, messages) => messages, + formatMessages: () => 'formatted prompt', + }); + + const freshTask = getLatestOpenPairedTaskForChat('group@test'); + expect(outcome).toBe(true); + expect(freshTask).toBeDefined(); + expect(executeTurn).toHaveBeenCalledWith( + expect.objectContaining({ + deliveryRole: 'owner', + forcedRole: 'owner', + pairedTurnIdentity: { + turnId: `${freshTask!.id}:${freshTask!.updated_at}:owner-turn`, + taskId: freshTask!.id, + taskUpdatedAt: freshTask!.updated_at, + intentKind: 'owner-turn', + role: 'owner', + }, + }), + ); + expect(lastAgentTimestamps).toEqual({ 'group@test': '48' }); + expect(saveState).toHaveBeenCalled(); + }); +}); diff --git a/src/message-runtime-queue.ts b/src/message-runtime-queue.ts index 4cacf38..79a8a00 100644 --- a/src/message-runtime-queue.ts +++ b/src/message-runtime-queue.ts @@ -166,11 +166,11 @@ export async function runQueuedGroupTurn(args: { ) : !isBotOnlyPairedRoomTurn(chatJid, missedMessages); let fallbackMessages = missedMessages; - if (currentTask && hasHumanMsg) { + if (currentTask !== undefined && hasHumanMsg) { const resolvedTask = resolveOwnerTaskForHumanMessage({ group, chatJid, - existingTask: currentTask, + existingTask: currentTask ?? null, }); currentTask = resolvedTask.task; if (resolvedTask.supersededTask) { diff --git a/src/message-runtime.ts b/src/message-runtime.ts index d074b8f..edcb830 100644 --- a/src/message-runtime.ts +++ b/src/message-runtime.ts @@ -490,8 +490,8 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): { timezone: deps.timezone, missedMessages, task: hasReviewerLease(chatJid) - ? getLatestOpenPairedTaskForChat(chatJid) - : null, + ? (getLatestOpenPairedTaskForChat(chatJid) ?? null) + : undefined, roleToChannel, ownerChannel: channel, lastAgentTimestamps: deps.getLastAgentTimestamps(),