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', () => {
const prompt = buildReviewerPendingPrompt({
chatJid: 'group@test',

View File

@@ -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}`;

View File

@@ -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(),
);
});
});

View File

@@ -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,
});