diff --git a/src/message-runtime-prompts.test.ts b/src/message-runtime-prompts.test.ts index a2bd283..900b8ca 100644 --- a/src/message-runtime-prompts.test.ts +++ b/src/message-runtime-prompts.test.ts @@ -96,6 +96,28 @@ describe('message-runtime-prompts carry-forward guidance', () => { ); }); + it('prepends the carry-forward warning for a carried arbiter verdict too', () => { + const prompt = buildPairedTurnPrompt({ + taskId: 'task-1', + chatJid: 'group@test', + timezone: 'UTC', + missedMessages: [makeHumanMessage('1번 선택할게')], + labeledFallbackMessages: [makeHumanMessage('1번 선택할게')], + turnOutputs: [ + makeTurnOutput( + '[Carried forward context from the previous task: latest arbiter verdict]\nESCALATE\n1) A 2) B', + ), + ], + }); + + expect( + prompt.startsWith('System note:\nIf you see a message beginning with'), + ).toBe(true); + expect(prompt).toContain( + 'Respond only to the latest human request and the current task.', + ); + }); + it('prepends a carry-forward warning to reviewer pending prompts', () => { const prompt = buildReviewerPendingPrompt({ chatJid: 'group@test', diff --git a/src/message-runtime-prompts.ts b/src/message-runtime-prompts.ts index d456fe4..d84edaa 100644 --- a/src/message-runtime-prompts.ts +++ b/src/message-runtime-prompts.ts @@ -10,11 +10,15 @@ import type { PairedTurnOutput, } from './types.js'; -const CARRIED_FORWARD_OWNER_FINAL_MARKER = - '[Carried forward context from the previous task: latest owner final]'; +// Common prefix for every carried-forward context block. Covers both the owner +// final ("… latest owner final]") and the arbiter verdict ("… latest arbiter +// verdict]"), so the guidance triggers whichever the previous task ended on — +// e.g. an arbiter ESCALATE the user is now replying to. +const CARRIED_FORWARD_MARKER_PREFIX = + '[Carried forward context from the previous task:'; 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.`; +If you see a message beginning with "${CARRIED_FORWARD_MARKER_PREFIX}", 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; @@ -159,9 +163,9 @@ function currentTaskHumanMessages( }); } -function hasCarriedForwardOwnerFinal(outputs: PairedTurnOutput[]): boolean { +function hasCarriedForwardFinal(outputs: PairedTurnOutput[]): boolean { return outputs.some((output) => - output.output_text.startsWith(CARRIED_FORWARD_OWNER_FINAL_MARKER), + output.output_text.startsWith(CARRIED_FORWARD_MARKER_PREFIX), ); } @@ -169,7 +173,7 @@ function prependCarriedForwardGuidance( prompt: string, turnOutputs: PairedTurnOutput[], ): string { - if (!hasCarriedForwardOwnerFinal(turnOutputs)) { + if (!hasCarriedForwardFinal(turnOutputs)) { return prompt; } return `${CARRIED_FORWARD_OWNER_FINAL_GUIDANCE}\n\n${prompt}`; diff --git a/src/paired-execution-context-carry-forward.test.ts b/src/paired-execution-context-carry-forward.test.ts index a5dc62f..d9831ba 100644 --- a/src/paired-execution-context-carry-forward.test.ts +++ b/src/paired-execution-context-carry-forward.test.ts @@ -246,4 +246,66 @@ describe('paired execution carry-forward attachments', () => { expect.objectContaining({ createdAt: '2026-03-28T00:01:00.000Z' }), ); }); + + it('carries the arbiter verdict forward when the previous task ended on an arbiter escalation', () => { + vi.clearAllMocks(); + const previousTask = buildTask({ + id: 'task-escalated', + status: 'completed', + completion_reason: 'arbiter_escalated', + }); + vi.mocked(db.getLatestOpenPairedTaskForChat).mockReturnValue(undefined); + vi.mocked(db.getLatestPairedTaskForChat).mockReturnValue(previousTask); + // The arbiter's ESCALATE is the last thing the user saw — they are now + // replying to it, so it (not the earlier owner turn) must carry forward. + vi.mocked(db.getPairedTurnOutputs).mockReturnValue([ + { + id: 1, + task_id: previousTask.id, + turn_number: 1, + role: 'owner', + output_text: 'STEP_DONE\n방향 A로 진행했습니다.', + created_at: '2026-03-28T00:01:00.000Z', + }, + { + id: 2, + task_id: previousTask.id, + turn_number: 2, + role: 'reviewer', + output_text: 'REVISE\n방향 B가 맞습니다.', + created_at: '2026-03-28T00:02:00.000Z', + }, + { + id: 3, + task_id: previousTask.id, + turn_number: 3, + role: 'arbiter', + output_text: + 'ESCALATE\n두 방향(A/B) 중 사용자가 선택해야 합니다. 1) A 유지 2) B 전환', + created_at: '2026-03-28T00:03:00.000Z', + }, + ]); + + resolveOwnerTaskForHumanMessage({ + group, + chatJid: 'dc:test', + roomRoleContext: ownerContext, + }); + + expect(db.insertPairedTurnOutput).toHaveBeenCalledWith( + expect.any(String), + 0, + 'owner', + expect.stringContaining('latest arbiter verdict'), + expect.objectContaining({ createdAt: '2026-03-28T00:03:00.000Z' }), + ); + // And it must carry the arbiter's escalation text, not the earlier owner turn. + expect(db.insertPairedTurnOutput).toHaveBeenCalledWith( + expect.any(String), + 0, + 'owner', + expect.stringContaining('두 방향(A/B) 중 사용자가 선택'), + expect.anything(), + ); + }); }); diff --git a/src/paired-execution-context.ts b/src/paired-execution-context.ts index 6f641a6..7f35212 100644 --- a/src/paired-execution-context.ts +++ b/src/paired-execution-context.ts @@ -251,20 +251,28 @@ function isIntermediateStepOutput(outputText: string): boolean { return /^\s*STEP_DONE\b/.test(outputText); } -function getLatestOwnerFinalOutput(taskId: string): PairedTurnOutput | null { - const ownerOutputs = [...getPairedTurnOutputs(taskId)] +function getLatestUserFacingFinalOutput( + taskId: string, +): PairedTurnOutput | null { + // The user only ever sees owner finals and arbiter verdicts (reviewer turns + // are internal). Whichever of those the previous task ended on is what the + // user is replying to next — e.g. an arbiter ESCALATE asking them to choose. + // So carry the chronologically-latest owner/arbiter final forward. + const userFacingOutputs = [...getPairedTurnOutputs(taskId)] .reverse() - .filter((output) => output.role === 'owner'); + .filter( + (output) => output.role === 'owner' || output.role === 'arbiter', + ); return ( - ownerOutputs.find( + userFacingOutputs.find( (output) => !isIntermediateStepOutput(output.output_text), ) ?? - ownerOutputs[0] ?? + userFacingOutputs[0] ?? null ); } -function carryForwardLatestOwnerFinal(args: { +function carryForwardLatestFinal(args: { sourceTask: PairedTask; targetTask: PairedTask; }): void { @@ -272,29 +280,34 @@ function carryForwardLatestOwnerFinal(args: { return; } - const latestOwnerFinal = getLatestOwnerFinalOutput(args.sourceTask.id); - if (!latestOwnerFinal) { + const latestFinal = getLatestUserFacingFinalOutput(args.sourceTask.id); + if (!latestFinal) { return; } + const label = + latestFinal.role === 'arbiter' + ? 'latest arbiter verdict' + : 'latest owner final'; insertPairedTurnOutput( args.targetTask.id, 0, 'owner', - `[Carried forward context from the previous task: latest owner final]\n${latestOwnerFinal.output_text}`, + `[Carried forward context from the previous task: ${label}]\n${latestFinal.output_text}`, { - createdAt: latestOwnerFinal.created_at, - attachments: latestOwnerFinal.attachments, + createdAt: latestFinal.created_at, + attachments: latestFinal.attachments, }, ); logger.info( { sourceTaskId: args.sourceTask.id, targetTaskId: args.targetTask.id, - carriedChars: latestOwnerFinal.output_text.length, - attachmentCount: latestOwnerFinal.attachments?.length ?? 0, + carriedRole: latestFinal.role, + carriedChars: latestFinal.output_text.length, + attachmentCount: latestFinal.attachments?.length ?? 0, }, - 'Carried forward latest owner final into superseding paired task', + 'Carried forward latest user-facing final into superseding paired task', ); } @@ -328,7 +341,7 @@ export function resolveOwnerTaskForHumanMessage(args: { // fresh task with the previous task's latest owner final so the owner keeps // continuity across sessions instead of answering with "no context". if (newTask && previousTask) { - carryForwardLatestOwnerFinal({ + carryForwardLatestFinal({ sourceTask: previousTask, targetTask: newTask, }); @@ -373,7 +386,7 @@ export function resolveOwnerTaskForHumanMessage(args: { canonicalWorkDir, roomRoleContext: args.roomRoleContext, }); - carryForwardLatestOwnerFinal({ + carryForwardLatestFinal({ sourceTask: existing, targetTask: newTask, });