diff --git a/src/db.ts b/src/db.ts index 28a3e81..0732e78 100644 --- a/src/db.ts +++ b/src/db.ts @@ -92,6 +92,7 @@ export { getLastBotFinalMessage, getLatestOpenPairedTaskForChat, getLatestPairedTaskForChat, + getLatestPreviousPairedTaskForChat, getLatestTurnNumber, getPairedProject, getPairedTaskById, diff --git a/src/db/paired-state.ts b/src/db/paired-state.ts index 44eaa90..cb589bd 100644 --- a/src/db/paired-state.ts +++ b/src/db/paired-state.ts @@ -254,6 +254,26 @@ export function getLatestOpenPairedTaskForChatFromDatabase( return row ? hydratePairedTaskRow(database, row) : undefined; } +export function getLatestPreviousPairedTaskForChatFromDatabase( + database: Database, + chatJid: string, + currentTaskId: string, +): PairedTask | undefined { + const row = database + .prepare( + ` + SELECT * + FROM paired_tasks + WHERE chat_jid = ? + AND id != ? + ORDER BY updated_at DESC, created_at DESC + LIMIT 1 + `, + ) + .get(chatJid, currentTaskId) as StoredPairedTaskRow | undefined; + return row ? hydratePairedTaskRow(database, row) : undefined; +} + export function updatePairedTaskInDatabase( database: Database, id: string, diff --git a/src/db/runtime-paired.ts b/src/db/runtime-paired.ts index 807df33..4d0541b 100644 --- a/src/db/runtime-paired.ts +++ b/src/db/runtime-paired.ts @@ -26,6 +26,7 @@ import { createPairedTaskInDatabase, getLastBotFinalMessageFromDatabase, getLatestOpenPairedTaskForChatFromDatabase, + getLatestPreviousPairedTaskForChatFromDatabase, getLatestPairedTaskForChatFromDatabase, getPairedProjectFromDatabase, getPairedTaskByIdFromDatabase, @@ -101,6 +102,17 @@ export function getLatestOpenPairedTaskForChat( return getLatestOpenPairedTaskForChatFromDatabase(requireDatabase(), chatJid); } +export function getLatestPreviousPairedTaskForChat( + chatJid: string, + currentTaskId: string, +): PairedTask | undefined { + return getLatestPreviousPairedTaskForChatFromDatabase( + requireDatabase(), + chatJid, + currentTaskId, + ); +} + export function updatePairedTask(id: string, updates: PairedTaskUpdates): void { updatePairedTaskInDatabase(requireDatabase(), id, updates); } diff --git a/src/message-runtime-flow.ts b/src/message-runtime-flow.ts index 9b52a95..078fe7d 100644 --- a/src/message-runtime-flow.ts +++ b/src/message-runtime-flow.ts @@ -1,5 +1,6 @@ import { getLastHumanMessageContent, + getLatestPreviousPairedTaskForChat, getPairedTurnOutputs, getRecentChatMessages, } from './db.js'; @@ -10,6 +11,7 @@ import { buildOwnerPendingPrompt, buildPairedTurnPrompt, buildReviewerPendingPrompt, + type PriorTaskPromptContext, } from './message-runtime-prompts.js'; import { requeuePendingPairedTurn, @@ -32,6 +34,7 @@ import type { NewMessage, PairedTask, PairedRoomRole, + PairedTurnOutput, PairedTurnReservationIntentKind, RegisteredGroup, } from './types.js'; @@ -115,6 +118,7 @@ export function buildPendingPairedTurn(args: { const cursor = lastRaw?.seq ?? lastRaw?.timestamp ?? null; const taskStatus = task.status; const turnOutputs = getPairedTurnOutputs(task.id); + const priorTaskContext = resolvePriorTaskPromptContext(chatJid, task, turnOutputs); const lastTurnOutput = turnOutputs[turnOutputs.length - 1]; const nextTurnAction = resolveNextTurnAction({ taskStatus, @@ -131,6 +135,7 @@ export function buildPendingPairedTurn(args: { turnOutputs, recentHumanMessages, lastHumanMessage, + priorTaskContext, }), channel: resolveChannel(taskStatus), cursor, @@ -182,6 +187,7 @@ export function buildPendingPairedTurn(args: { turnOutputs, recentHumanMessages, lastHumanMessage, + priorTaskContext, }), channel: resolveChannel(taskStatus), cursor, @@ -195,6 +201,38 @@ export function buildPendingPairedTurn(args: { return null; } +function resolvePriorTaskPromptContext( + chatJid: string, + task: PairedTask, + turnOutputs: PairedTurnOutput[], +): PriorTaskPromptContext | null { + if (turnOutputs.length > 0) { + return null; + } + + const previousTask = getLatestPreviousPairedTaskForChat(chatJid, task.id); + if (!previousTask || previousTask.status !== 'completed') { + return null; + } + + const previousOutputs = getPairedTurnOutputs(previousTask.id); + const ownerFinal = [...previousOutputs] + .reverse() + .find((output) => output.role === 'owner')?.output_text; + const reviewerFinal = [...previousOutputs] + .reverse() + .find((output) => output.role === 'reviewer')?.output_text; + + if (!ownerFinal && !reviewerFinal) { + return null; + } + + return { + ownerFinal: ownerFinal ?? null, + reviewerFinal: reviewerFinal ?? null, + }; +} + export async function executePendingPairedTurn(args: { pendingTurn: Exclude; chatJid: string; diff --git a/src/message-runtime-prompts.test.ts b/src/message-runtime-prompts.test.ts index b2f7f5e..10d26d1 100644 --- a/src/message-runtime-prompts.test.ts +++ b/src/message-runtime-prompts.test.ts @@ -65,6 +65,7 @@ describe('message-runtime-prompts carry-forward guidance', () => { ], recentHumanMessages: [makeHumanMessage('이제 새 질문')], lastHumanMessage: '이제 새 질문', + priorTaskContext: null, }); expect( @@ -84,6 +85,7 @@ describe('message-runtime-prompts carry-forward guidance', () => { ], recentHumanMessages: [makeHumanMessage('새 owner 질문')], lastHumanMessage: '새 owner 질문', + priorTaskContext: null, }); expect( @@ -108,4 +110,43 @@ describe('message-runtime-prompts carry-forward guidance', () => { prompt.startsWith('System note:\nIf you see a message beginning with'), ).toBe(false); }); + + it('includes previous owner and reviewer finals in reviewer pending prompts when the current task has no outputs', () => { + const prompt = buildReviewerPendingPrompt({ + chatJid: 'group@test', + timezone: 'UTC', + turnOutputs: [], + recentHumanMessages: [makeHumanMessage('추가 질문')], + lastHumanMessage: '추가 질문', + priorTaskContext: { + ownerFinal: 'DONE\n이전 owner 결론', + reviewerFinal: 'DONE_WITH_CONCERNS\n이전 reviewer 결론', + }, + }); + + expect(prompt).toContain('Background from the previous completed paired task:'); + expect(prompt).toContain('Previous task owner final:'); + expect(prompt).toContain('이전 owner 결론'); + expect(prompt).toContain('Previous task reviewer final:'); + expect(prompt).toContain('이전 reviewer 결론'); + }); + + it('includes previous owner and reviewer finals in owner pending prompts when the current task has no outputs', () => { + const prompt = buildOwnerPendingPrompt({ + chatJid: 'group@test', + timezone: 'UTC', + turnOutputs: [], + recentHumanMessages: [makeHumanMessage('추가 owner 질문')], + lastHumanMessage: '추가 owner 질문', + priorTaskContext: { + ownerFinal: 'DONE\n이전 owner 결론', + reviewerFinal: 'DONE_WITH_CONCERNS\n이전 reviewer 결론', + }, + }); + + expect(prompt).toContain('Background from the previous completed paired task:'); + expect(prompt).toContain('Previous task owner final:'); + expect(prompt).toContain('Previous task reviewer final:'); + expect(prompt).toContain('추가 owner 질문'); + }); }); diff --git a/src/message-runtime-prompts.ts b/src/message-runtime-prompts.ts index b6ccf0c..99a6380 100644 --- a/src/message-runtime-prompts.ts +++ b/src/message-runtime-prompts.ts @@ -8,6 +8,11 @@ const CARRIED_FORWARD_OWNER_FINAL_MARKER = const CARRIED_FORWARD_OWNER_FINAL_GUIDANCE = `System note: If you see a message beginning with "${CARRIED_FORWARD_OWNER_FINAL_MARKER}", treat it as background only. Do not repeat, continue, or answer that carried-forward final directly. Respond only to the latest human request and the current task.`; +export interface PriorTaskPromptContext { + ownerFinal?: string | null; + reviewerFinal?: string | null; +} + function turnOutputsToMessages( outputs: PairedTurnOutput[], chatJid: string, @@ -51,6 +56,40 @@ function prependCarriedForwardGuidance( return `${CARRIED_FORWARD_OWNER_FINAL_GUIDANCE}\n\n${prompt}`; } +function truncatePriorTaskFinal(text: string, maxChars = 1200): string { + const normalized = text.trim(); + if (normalized.length <= maxChars) { + return normalized; + } + return `${normalized.slice(0, maxChars)}...`; +} + +function formatPriorTaskPromptContext( + priorTaskContext?: PriorTaskPromptContext | null, +): string { + if (!priorTaskContext) { + return ''; + } + + const sections: string[] = []; + if (priorTaskContext.ownerFinal?.trim()) { + sections.push( + `Previous task owner final:\n---\n${truncatePriorTaskFinal(priorTaskContext.ownerFinal)}\n---`, + ); + } + if (priorTaskContext.reviewerFinal?.trim()) { + sections.push( + `Previous task reviewer final:\n---\n${truncatePriorTaskFinal(priorTaskContext.reviewerFinal)}\n---`, + ); + } + + if (sections.length === 0) { + return ''; + } + + return `Background from the previous completed paired task:\n${sections.join('\n\n')}\n\n`; +} + export function buildPairedTurnPrompt(args: { taskId: string; chatJid: string; @@ -85,6 +124,7 @@ export function buildReviewerPendingPrompt(args: { turnOutputs: PairedTurnOutput[]; recentHumanMessages: NewMessage[]; lastHumanMessage: string | null | undefined; + priorTaskContext?: PriorTaskPromptContext | null; }): string { if (args.turnOutputs.length > 0) { return prependCarriedForwardGuidance( @@ -101,10 +141,10 @@ export function buildReviewerPendingPrompt(args: { } if (!args.lastHumanMessage) { - return 'Review the latest owner changes in the workspace.'; + return `${formatPriorTaskPromptContext(args.priorTaskContext)}Review the latest owner changes in the workspace.`; } - return `User request:\n---\n${args.lastHumanMessage}\n---\n\nReview the latest owner changes in the workspace.`; + return `${formatPriorTaskPromptContext(args.priorTaskContext)}User request:\n---\n${args.lastHumanMessage}\n---\n\nReview the latest owner changes in the workspace.`; } export function buildOwnerPendingPrompt(args: { @@ -113,6 +153,7 @@ export function buildOwnerPendingPrompt(args: { turnOutputs: PairedTurnOutput[]; recentHumanMessages: NewMessage[]; lastHumanMessage: string | null | undefined; + priorTaskContext?: PriorTaskPromptContext | null; }): string { if (args.turnOutputs.length > 0) { return prependCarriedForwardGuidance( @@ -129,10 +170,10 @@ export function buildOwnerPendingPrompt(args: { } if (!args.lastHumanMessage) { - return 'Continue the owner turn using the latest reviewer or arbiter feedback.'; + return `${formatPriorTaskPromptContext(args.priorTaskContext)}Continue the owner turn using the latest reviewer or arbiter feedback.`; } - return `User request:\n---\n${args.lastHumanMessage}\n---\n\nContinue the owner turn using the latest reviewer or arbiter feedback.`; + return `${formatPriorTaskPromptContext(args.priorTaskContext)}User request:\n---\n${args.lastHumanMessage}\n---\n\nContinue the owner turn using the latest reviewer or arbiter feedback.`; } export function buildArbiterPromptForTask(args: { diff --git a/src/message-runtime.test.ts b/src/message-runtime.test.ts index 3a12ebf..92d6f17 100644 --- a/src/message-runtime.test.ts +++ b/src/message-runtime.test.ts @@ -161,6 +161,7 @@ vi.mock('./db.js', () => { ), hasActiveCiWatcherForChat: vi.fn(() => false), getLatestOpenPairedTaskForChat: vi.fn(() => undefined), + getLatestPreviousPairedTaskForChat: vi.fn(() => undefined), getPairedTaskById: vi.fn(() => undefined), getPairedTurnOutputs: vi.fn(() => []), getRecentChatMessages: vi.fn(() => []), @@ -1921,6 +1922,83 @@ If your first line is DONE_WITH_CONCERNS, the system will reopen review instead }); }); + it('includes previous task owner and reviewer finals in a new reviewer prompt when the current task has no outputs', () => { + const chatJid = 'group@test'; + const group = makeGroup('claude-code'); + const currentTask = { + id: 'task-current-reviewer', + chat_jid: chatJid, + group_folder: group.folder, + owner_service_id: 'claude', + reviewer_service_id: 'codex-main', + title: null, + source_ref: 'HEAD', + plan_notes: null, + review_requested_at: '2026-03-30T00:00:10.000Z', + round_trip_count: 1, + status: 'review_ready', + arbiter_verdict: null, + arbiter_requested_at: null, + completion_reason: null, + created_at: '2026-03-30T00:00:10.000Z', + updated_at: '2026-03-30T00:00:10.000Z', + } as any; + const previousTask = { + ...currentTask, + id: 'task-previous-reviewer', + status: 'completed', + completion_reason: 'superseded', + created_at: '2026-03-30T00:00:00.000Z', + updated_at: '2026-03-30T00:00:09.000Z', + } as any; + + vi.mocked(db.getLatestPreviousPairedTaskForChat).mockReturnValue(previousTask); + vi.mocked(db.getPairedTurnOutputs).mockImplementation((taskId: string) => { + if (taskId === currentTask.id) { + return []; + } + if (taskId === previousTask.id) { + return [ + { + id: 1, + task_id: previousTask.id, + turn_number: 1, + role: 'owner', + output_text: 'DONE\n이전 owner 답변', + created_at: '2026-03-30T00:00:05.000Z', + }, + { + id: 2, + task_id: previousTask.id, + turn_number: 2, + role: 'reviewer', + output_text: 'DONE_WITH_CONCERNS\n이전 reviewer 피드백', + created_at: '2026-03-30T00:00:06.000Z', + }, + ] as any; + } + return []; + }); + vi.mocked(db.getLastHumanMessageContent).mockReturnValue('추가 질문'); + + const pending = buildPendingPairedTurn({ + chatJid, + timezone: 'UTC', + task: currentTask, + rawMissedMessages: [{ seq: 42, timestamp: '2026-03-30T00:00:11.000Z' }], + recentHumanMessages: [], + labeledRecentMessages: [], + resolveChannel: () => makeChannel(chatJid, 'discord-review', false), + }); + + expect(pending).not.toBeNull(); + expect(pending?.prompt).toContain('Background from the previous completed paired task:'); + expect(pending?.prompt).toContain('Previous task owner final:'); + expect(pending?.prompt).toContain('이전 owner 답변'); + expect(pending?.prompt).toContain('Previous task reviewer final:'); + expect(pending?.prompt).toContain('이전 reviewer 피드백'); + }); + it('re-enqueues reviewer after a successful owner delivery moves the task to review_ready', async () => { const chatJid = 'group@test'; const group = makeGroup('codex');