diff --git a/src/message-runtime-flow.ts b/src/message-runtime-flow.ts index 35480af..c794a46 100644 --- a/src/message-runtime-flow.ts +++ b/src/message-runtime-flow.ts @@ -137,6 +137,7 @@ export function buildPendingPairedTurn(args: { turnOutputs, recentHumanMessages, lastHumanMessage, + taskCreatedAt: task.created_at, }), channel: resolveChannel(taskStatus), cursor, @@ -188,6 +189,7 @@ export function buildPendingPairedTurn(args: { turnOutputs, recentHumanMessages, lastHumanMessage, + taskCreatedAt: task.created_at, }), channel: resolveChannel(taskStatus), cursor, diff --git a/src/message-runtime-prompts.test.ts b/src/message-runtime-prompts.test.ts index 1d04561..5a2335e 100644 --- a/src/message-runtime-prompts.test.ts +++ b/src/message-runtime-prompts.test.ts @@ -12,14 +12,17 @@ import type { NewMessage, PairedTask, PairedTurnOutput } from './types.js'; const CARRY_FORWARD_MARKER = '[Carried forward context from the previous task: latest owner final]'; -function makeHumanMessage(content: string): NewMessage { +function makeHumanMessage( + content: string, + timestamp: string = '2026-04-20T01:00:00.000Z', +): NewMessage { return { id: `msg-${content}`, chat_jid: 'group@test', sender: 'user@test', sender_name: 'User', content, - timestamp: '2026-04-20T01:00:00.000Z', + timestamp, is_bot_message: false, is_from_me: false, }; @@ -134,6 +137,32 @@ describe('message-runtime-prompts output-only context', () => { expect(prompt).not.toContain('과거 요청을 다시 기준으로 보면 안 됨'); }); + it('includes current task user scope in reviewer pending prompts without pulling older human messages', () => { + const prompt = buildReviewerPendingPrompt({ + chatJid: 'group@test', + timezone: 'UTC', + turnOutputs: [ + makeTurnOutput('TASK_DONE\n현재 owner 결과', 'owner', { + turn_number: 1, + created_at: '2026-04-20T02:10:00.000Z', + }), + ], + recentHumanMessages: [ + makeHumanMessage('이전 작업의 gap 요청', '2026-04-20T01:50:00.000Z'), + makeHumanMessage( + '뒤쪽 트레일 주변 디졸브 넣어줘', + '2026-04-20T02:00:01.000Z', + ), + ], + lastHumanMessage: '뒤쪽 트레일 주변 디졸브 넣어줘', + taskCreatedAt: '2026-04-20T02:00:00.000Z', + }); + + expect(prompt).toContain('뒤쪽 트레일 주변 디졸브 넣어줘'); + expect(prompt).toContain('현재 owner 결과'); + expect(prompt).not.toContain('이전 작업의 gap 요청'); + }); + it('prepends a carry-forward warning to owner pending prompts', () => { const prompt = buildOwnerPendingPrompt({ chatJid: 'group@test', @@ -175,6 +204,38 @@ describe('message-runtime-prompts output-only context', () => { expect(prompt).not.toContain('이전 작업의 사용자 메시지'); }); + it('includes current task user scope in owner pending prompts without pulling older human messages', () => { + const prompt = buildOwnerPendingPrompt({ + chatJid: 'group@test', + timezone: 'UTC', + turnOutputs: [ + makeTurnOutput('DONE_WITH_CONCERNS\n현재 reviewer 피드백', 'reviewer', { + id: 2, + turn_number: 2, + created_at: '2026-04-20T02:11:00.000Z', + }), + ], + recentHumanMessages: [ + makeHumanMessage( + '이전 작업의 사용자 메시지', + '2026-04-20T01:50:00.000Z', + ), + makeHumanMessage( + '현재 task에서 추가한 조건', + '2026-04-20T02:00:01.000Z', + ), + ], + lastHumanMessage: '현재 task에서 추가한 조건', + taskCreatedAt: '2026-04-20T02:00:00.000Z', + }); + + expect(prompt).toContain('현재 task에서 추가한 조건'); + expect(prompt).toContain('현재 reviewer 피드백'); + expect(prompt).not.toContain('이전 작업의 사용자 메시지'); + }); +}); + +describe('message-runtime-prompts arbiter output context', () => { it('keeps arbiter prompts output-only when turn outputs exist', () => { const prompt = buildArbiterPromptForTask({ task: makeTask({ status: 'arbiter_requested' }), @@ -189,8 +250,12 @@ describe('message-runtime-prompts output-only context', () => { turn_number: 2, }), ], - recentMessages: [makeHumanMessage('예전 유저 지시')], - labeledRecentMessages: [makeHumanMessage('예전 유저 지시')], + recentMessages: [ + makeHumanMessage('예전 유저 지시', '2026-04-20T00:50:00.000Z'), + ], + labeledRecentMessages: [ + makeHumanMessage('예전 유저 지시', '2026-04-20T00:50:00.000Z'), + ], }); expect(prompt).toContain('owner 주장'); @@ -215,8 +280,12 @@ describe('message-runtime-prompts output-only context', () => { }, ); }), - recentMessages: [makeHumanMessage('예전 유저 지시')], - labeledRecentMessages: [makeHumanMessage('예전 유저 지시')], + recentMessages: [ + makeHumanMessage('예전 유저 지시', '2026-04-20T00:50:00.000Z'), + ], + labeledRecentMessages: [ + makeHumanMessage('예전 유저 지시', '2026-04-20T00:50:00.000Z'), + ], }); expect(prompt).not.toContain('arbiter-context-output-01'); @@ -226,6 +295,46 @@ describe('message-runtime-prompts output-only context', () => { expect(prompt).not.toContain('예전 유저 지시'); }); + it('includes current task user scope in arbiter prompts while keeping the output cap', () => { + const prompt = buildArbiterPromptForTask({ + task: makeTask({ + status: 'arbiter_requested', + created_at: '2026-04-20T02:00:00.000Z', + }), + chatJid: 'group@test', + timezone: 'UTC', + turnOutputs: Array.from({ length: 8 }, (_, index) => { + const turnNumber = index + 1; + return makeTurnOutput( + `arbiter-context-output-${String(turnNumber).padStart(2, '0')}`, + turnNumber % 2 === 0 ? 'reviewer' : 'owner', + { + id: turnNumber, + turn_number: turnNumber, + created_at: `2026-04-20T02:${String(turnNumber).padStart(2, '0')}:00.000Z`, + }, + ); + }), + recentMessages: [ + makeHumanMessage('이전 작업의 유저 지시', '2026-04-20T01:50:00.000Z'), + makeHumanMessage( + '현재 task의 트레일 디졸브 요청', + '2026-04-20T02:00:01.000Z', + ), + ], + labeledRecentMessages: [], + }); + + expect(prompt).toContain('현재 task의 트레일 디졸브 요청'); + expect(prompt).not.toContain('이전 작업의 유저 지시'); + expect(prompt).not.toContain('arbiter-context-output-01'); + expect(prompt).not.toContain('arbiter-context-output-02'); + expect(prompt).toContain('arbiter-context-output-03'); + expect(prompt).toContain('arbiter-context-output-08'); + }); +}); + +describe('message-runtime-prompts prompt hygiene', () => { it('preserves turn output order instead of timestamp interleaving', () => { const prompt = buildReviewerPendingPrompt({ chatJid: 'group@test', diff --git a/src/message-runtime-prompts.ts b/src/message-runtime-prompts.ts index 58f0d5a..abe836e 100644 --- a/src/message-runtime-prompts.ts +++ b/src/message-runtime-prompts.ts @@ -9,6 +9,7 @@ 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.`; const ARBITER_TURN_OUTPUT_CONTEXT_LIMIT = 6; +const TASK_USER_CONTEXT_START_SKEW_MS = 5_000; function turnOutputsToMessages( outputs: PairedTurnOutput[], @@ -47,6 +48,23 @@ function latestTurnOutputs( return outputs.slice(-limit); } +function currentTaskHumanMessages( + messages: NewMessage[], + taskCreatedAt: string | null | undefined, +): NewMessage[] { + if (!taskCreatedAt) return []; + const taskStartMs = Date.parse(taskCreatedAt); + if (!Number.isFinite(taskStartMs)) return []; + return messages.filter((message) => { + if (message.is_bot_message) return false; + const messageMs = Date.parse(message.timestamp); + return ( + Number.isFinite(messageMs) && + messageMs >= taskStartMs - TASK_USER_CONTEXT_START_SKEW_MS + ); + }); +} + function hasCarriedForwardOwnerFinal(outputs: PairedTurnOutput[]): boolean { return outputs.some((output) => output.output_text.startsWith(CARRIED_FORWARD_OWNER_FINAL_MARKER), @@ -95,9 +113,13 @@ function turnOutputsOnlyPrompt( chatJid: string, timezone: string, turnOutputs: PairedTurnOutput[], + taskHumanMessages: NewMessage[] = [], ): string { return prependCarriedForwardGuidance( - formatMessages(turnOutputsToMessages(turnOutputs, chatJid), timezone), + formatMessages( + [...taskHumanMessages, ...turnOutputsToMessages(turnOutputs, chatJid)], + timezone, + ), turnOutputs, ); } @@ -108,9 +130,15 @@ export function buildReviewerPendingPrompt(args: { turnOutputs: PairedTurnOutput[]; recentHumanMessages: NewMessage[]; lastHumanMessage: string | null | undefined; + taskCreatedAt?: string | null; }): string { if (args.turnOutputs.length > 0) { - return turnOutputsOnlyPrompt(args.chatJid, args.timezone, args.turnOutputs); + return turnOutputsOnlyPrompt( + args.chatJid, + args.timezone, + args.turnOutputs, + currentTaskHumanMessages(args.recentHumanMessages, args.taskCreatedAt), + ); } if (!args.lastHumanMessage) { @@ -126,9 +154,15 @@ export function buildOwnerPendingPrompt(args: { turnOutputs: PairedTurnOutput[]; recentHumanMessages: NewMessage[]; lastHumanMessage: string | null | undefined; + taskCreatedAt?: string | null; }): string { if (args.turnOutputs.length > 0) { - return turnOutputsOnlyPrompt(args.chatJid, args.timezone, args.turnOutputs); + return turnOutputsOnlyPrompt( + args.chatJid, + args.timezone, + args.turnOutputs, + currentTaskHumanMessages(args.recentHumanMessages, args.taskCreatedAt), + ); } if (!args.lastHumanMessage) { @@ -146,15 +180,22 @@ export function buildArbiterPromptForTask(args: { recentMessages: NewMessage[]; labeledRecentMessages: NewMessage[]; }): string { + const taskHumanMessages = currentTaskHumanMessages( + args.recentMessages, + args.task.created_at, + ); const messages = args.turnOutputs.length > 0 - ? turnOutputsToMessages( - latestTurnOutputs( - args.turnOutputs, - ARBITER_TURN_OUTPUT_CONTEXT_LIMIT, + ? [ + ...taskHumanMessages, + ...turnOutputsToMessages( + latestTurnOutputs( + args.turnOutputs, + ARBITER_TURN_OUTPUT_CONTEXT_LIMIT, + ), + args.chatJid, ), - args.chatJid, - ) + ] : args.labeledRecentMessages; return buildArbiterContextPrompt({