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:
@@ -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',
|
||||
|
||||
@@ -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}`;
|
||||
|
||||
@@ -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(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user