Carry forward the arbiter verdict too, not just the owner final

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 <noreply@anthropic.com>
This commit is contained in:
Codex
2026-08-28 20:44:22 +09:00
parent 5376df5389
commit 2259471b66
4 changed files with 123 additions and 22 deletions

View File

@@ -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', () => { it('prepends a carry-forward warning to reviewer pending prompts', () => {
const prompt = buildReviewerPendingPrompt({ const prompt = buildReviewerPendingPrompt({
chatJid: 'group@test', chatJid: 'group@test',

View File

@@ -10,11 +10,15 @@ import type {
PairedTurnOutput, PairedTurnOutput,
} from './types.js'; } from './types.js';
const CARRIED_FORWARD_OWNER_FINAL_MARKER = // Common prefix for every carried-forward context block. Covers both the owner
'[Carried forward context from the previous task: latest owner final]'; // 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: 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; 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) => 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, prompt: string,
turnOutputs: PairedTurnOutput[], turnOutputs: PairedTurnOutput[],
): string { ): string {
if (!hasCarriedForwardOwnerFinal(turnOutputs)) { if (!hasCarriedForwardFinal(turnOutputs)) {
return prompt; return prompt;
} }
return `${CARRIED_FORWARD_OWNER_FINAL_GUIDANCE}\n\n${prompt}`; return `${CARRIED_FORWARD_OWNER_FINAL_GUIDANCE}\n\n${prompt}`;

View File

@@ -246,4 +246,66 @@ describe('paired execution carry-forward attachments', () => {
expect.objectContaining({ createdAt: '2026-03-28T00:01:00.000Z' }), 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(),
);
});
}); });

View File

@@ -251,20 +251,28 @@ function isIntermediateStepOutput(outputText: string): boolean {
return /^\s*STEP_DONE\b/.test(outputText); return /^\s*STEP_DONE\b/.test(outputText);
} }
function getLatestOwnerFinalOutput(taskId: string): PairedTurnOutput | null { function getLatestUserFacingFinalOutput(
const ownerOutputs = [...getPairedTurnOutputs(taskId)] 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() .reverse()
.filter((output) => output.role === 'owner'); .filter(
(output) => output.role === 'owner' || output.role === 'arbiter',
);
return ( return (
ownerOutputs.find( userFacingOutputs.find(
(output) => !isIntermediateStepOutput(output.output_text), (output) => !isIntermediateStepOutput(output.output_text),
) ?? ) ??
ownerOutputs[0] ?? userFacingOutputs[0] ??
null null
); );
} }
function carryForwardLatestOwnerFinal(args: { function carryForwardLatestFinal(args: {
sourceTask: PairedTask; sourceTask: PairedTask;
targetTask: PairedTask; targetTask: PairedTask;
}): void { }): void {
@@ -272,29 +280,34 @@ function carryForwardLatestOwnerFinal(args: {
return; return;
} }
const latestOwnerFinal = getLatestOwnerFinalOutput(args.sourceTask.id); const latestFinal = getLatestUserFacingFinalOutput(args.sourceTask.id);
if (!latestOwnerFinal) { if (!latestFinal) {
return; return;
} }
const label =
latestFinal.role === 'arbiter'
? 'latest arbiter verdict'
: 'latest owner final';
insertPairedTurnOutput( insertPairedTurnOutput(
args.targetTask.id, args.targetTask.id,
0, 0,
'owner', '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, createdAt: latestFinal.created_at,
attachments: latestOwnerFinal.attachments, attachments: latestFinal.attachments,
}, },
); );
logger.info( logger.info(
{ {
sourceTaskId: args.sourceTask.id, sourceTaskId: args.sourceTask.id,
targetTaskId: args.targetTask.id, targetTaskId: args.targetTask.id,
carriedChars: latestOwnerFinal.output_text.length, carriedRole: latestFinal.role,
attachmentCount: latestOwnerFinal.attachments?.length ?? 0, 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 // fresh task with the previous task's latest owner final so the owner keeps
// continuity across sessions instead of answering with "no context". // continuity across sessions instead of answering with "no context".
if (newTask && previousTask) { if (newTask && previousTask) {
carryForwardLatestOwnerFinal({ carryForwardLatestFinal({
sourceTask: previousTask, sourceTask: previousTask,
targetTask: newTask, targetTask: newTask,
}); });
@@ -373,7 +386,7 @@ export function resolveOwnerTaskForHumanMessage(args: {
canonicalWorkDir, canonicalWorkDir,
roomRoleContext: args.roomRoleContext, roomRoleContext: args.roomRoleContext,
}); });
carryForwardLatestOwnerFinal({ carryForwardLatestFinal({
sourceTask: existing, sourceTask: existing,
targetTask: newTask, targetTask: newTask,
}); });