From 2259471b6625a8e085423d28e8cef9721d7ce3db Mon Sep 17 00:00:00 2001 From: Codex Date: Fri, 28 Aug 2026 20:44:22 +0900 Subject: [PATCH] Carry forward the arbiter verdict too, not just the owner final MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a paired task ends on an arbiter ESCALATE (asking the user to choose), the user's next reply was carried into the fresh task with the owner's earlier final as context — not the arbiter message they were actually responding to. Generalize the carry-forward to the latest user-facing final (owner final OR arbiter verdict, whichever the task ended on), so the user can choose based on the last arbiter message without the new turn re-reading the whole prior conversation. The carried-forward guidance marker is now matched by a shared prefix so it triggers for both. Adds tests for the arbiter-escalation carry and the prefix guidance. Co-Authored-By: Claude Opus 4.7 --- src/message-runtime-prompts.test.ts | 22 +++++++ src/message-runtime-prompts.ts | 16 +++-- ...ed-execution-context-carry-forward.test.ts | 62 +++++++++++++++++++ src/paired-execution-context.ts | 45 +++++++++----- 4 files changed, 123 insertions(+), 22 deletions(-) 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, });