From fc1c377cf17700215813486e9a8618e2233b172c Mon Sep 17 00:00:00 2001 From: ejclaw Date: Mon, 20 Apr 2026 13:31:34 +0900 Subject: [PATCH] fix(runtime): guard carry-forward prompts and support inbound IPC injection --- src/ipc-message-forwarding.ts | 47 +++++++++++++- src/ipc.ts | 1 + src/message-runtime-prompts.test.ts | 97 +++++++++++++++++++++++++++++ src/message-runtime-prompts.ts | 67 ++++++++++++++------ 4 files changed, 193 insertions(+), 19 deletions(-) create mode 100644 src/message-runtime-prompts.test.ts diff --git a/src/ipc-message-forwarding.ts b/src/ipc-message-forwarding.ts index bd5f2e4..6a4f8fb 100644 --- a/src/ipc-message-forwarding.ts +++ b/src/ipc-message-forwarding.ts @@ -15,8 +15,24 @@ export async function forwardAuthorizedIpcMessage( senderRole?: string, runId?: string, ) => Promise, + injectInboundMessage?: (payload: { + chatJid: string; + text: string; + sender?: string; + senderName?: string; + messageId?: string; + timestamp?: string; + treatAsHuman: boolean; + sourceKind?: import('./types.js').MessageSourceKind; + }) => Promise, ): Promise { - if (!(msg.type === 'message' && msg.chatJid && msg.text)) { + if ( + !( + (msg.type === 'message' || msg.type === 'inject_inbound_message') && + msg.chatJid && + msg.text + ) + ) { return { outcome: 'ignored', senderRole: msg.senderRole ?? null }; } @@ -34,6 +50,35 @@ export async function forwardAuthorizedIpcMessage( }; } + if (msg.type === 'inject_inbound_message') { + if (!injectInboundMessage) { + return { + outcome: 'ignored', + chatJid: msg.chatJid, + targetGroup: targetGroup?.folder ?? null, + isMainOverride, + senderRole: msg.senderRole ?? null, + }; + } + await injectInboundMessage({ + chatJid: msg.chatJid, + text: msg.text, + sender: msg.sender, + senderName: msg.senderName, + messageId: msg.messageId, + timestamp: msg.timestamp, + treatAsHuman: msg.treatAsHuman === true, + sourceKind: msg.sourceKind, + }); + return { + outcome: 'sent', + chatJid: msg.chatJid, + targetGroup: targetGroup?.folder ?? null, + isMainOverride, + senderRole: msg.senderRole ?? null, + }; + } + await sendMessage(msg.chatJid, msg.text, msg.senderRole, msg.runId); return { outcome: 'sent', diff --git a/src/ipc.ts b/src/ipc.ts index 04f2017..5edb7c0 100644 --- a/src/ipc.ts +++ b/src/ipc.ts @@ -114,6 +114,7 @@ export function startIpcWatcher(deps: IpcDeps): void { isMain, roomBindings, deps.sendMessage, + deps.injectInboundMessage, ); if (forwardResult.outcome === 'sent') { logger.info( diff --git a/src/message-runtime-prompts.test.ts b/src/message-runtime-prompts.test.ts new file mode 100644 index 0000000..dc7e9c1 --- /dev/null +++ b/src/message-runtime-prompts.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, it } from 'vitest'; + +import { + buildOwnerPendingPrompt, + buildPairedTurnPrompt, + buildReviewerPendingPrompt, +} from './message-runtime-prompts.js'; +import type { NewMessage, PairedTurnOutput } from './types.js'; + +const CARRY_FORWARD_MARKER = + '[Carried forward context from the previous task: latest owner final]'; + +function makeHumanMessage(content: string): NewMessage { + return { + id: `msg-${content}`, + chat_jid: 'group@test', + sender: 'user@test', + sender_name: 'User', + content, + timestamp: '2026-04-20T01:00:00.000Z', + is_bot_message: false, + is_from_me: false, + }; +} + +function makeTurnOutput(outputText: string): PairedTurnOutput { + return { + id: 1, + task_id: 'task-1', + turn_number: 0, + role: 'owner', + output_text: outputText, + created_at: '2026-04-20T00:59:00.000Z', + }; +} + +describe('message-runtime-prompts carry-forward guidance', () => { + it('prepends a carry-forward warning to paired turn prompts', () => { + const prompt = buildPairedTurnPrompt({ + taskId: 'task-1', + chatJid: 'group@test', + timezone: 'UTC', + missedMessages: [makeHumanMessage('새 질문')], + labeledFallbackMessages: [makeHumanMessage('새 질문')], + turnOutputs: [ + makeTurnOutput(`${CARRY_FORWARD_MARKER}\nDONE\n이전 owner final`), + ], + }); + + expect(prompt.startsWith('System note:\nIf you see a message beginning with')).toBe(true); + expect(prompt).toContain(CARRY_FORWARD_MARKER); + 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', + timezone: 'UTC', + turnOutputs: [ + makeTurnOutput(`${CARRY_FORWARD_MARKER}\nDONE\n이전 owner final`), + ], + recentHumanMessages: [makeHumanMessage('이제 새 질문')], + lastHumanMessage: '이제 새 질문', + }); + + expect(prompt.startsWith('System note:\nIf you see a message beginning with')).toBe(true); + expect(prompt).toContain('Do not repeat, continue, or answer that carried-forward final directly.'); + }); + + it('prepends a carry-forward warning to owner pending prompts', () => { + const prompt = buildOwnerPendingPrompt({ + chatJid: 'group@test', + timezone: 'UTC', + turnOutputs: [ + makeTurnOutput(`${CARRY_FORWARD_MARKER}\nDONE\n이전 owner final`), + ], + recentHumanMessages: [makeHumanMessage('새 owner 질문')], + lastHumanMessage: '새 owner 질문', + }); + + 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('does not prepend the warning when there is no carried-forward turn output', () => { + const prompt = buildPairedTurnPrompt({ + taskId: 'task-1', + chatJid: 'group@test', + timezone: 'UTC', + missedMessages: [makeHumanMessage('그냥 새 질문')], + labeledFallbackMessages: [makeHumanMessage('그냥 새 질문')], + turnOutputs: [makeTurnOutput('DONE\n일반 owner final')], + }); + + expect(prompt.startsWith('System note:\nIf you see a message beginning with')).toBe(false); + }); +}); diff --git a/src/message-runtime-prompts.ts b/src/message-runtime-prompts.ts index bcd8978..b6ccf0c 100644 --- a/src/message-runtime-prompts.ts +++ b/src/message-runtime-prompts.ts @@ -2,6 +2,12 @@ import { buildArbiterContextPrompt } from './arbiter-context.js'; import { formatMessages } from './router.js'; import type { NewMessage, PairedTask, PairedTurnOutput } from './types.js'; +const CARRIED_FORWARD_OWNER_FINAL_MARKER = + '[Carried forward context from the previous task: latest owner final]'; + +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.`; + function turnOutputsToMessages( outputs: PairedTurnOutput[], chatJid: string, @@ -29,6 +35,22 @@ function mergeHumanAndTurnOutputMessages( ].sort((a, b) => a.timestamp.localeCompare(b.timestamp)); } +function hasCarriedForwardOwnerFinal(outputs: PairedTurnOutput[]): boolean { + return outputs.some((output) => + output.output_text.startsWith(CARRIED_FORWARD_OWNER_FINAL_MARKER), + ); +} + +function prependCarriedForwardGuidance( + prompt: string, + turnOutputs: PairedTurnOutput[], +): string { + if (!hasCarriedForwardOwnerFinal(turnOutputs)) { + return prompt; + } + return `${CARRIED_FORWARD_OWNER_FINAL_GUIDANCE}\n\n${prompt}`; +} + export function buildPairedTurnPrompt(args: { taskId: string; chatJid: string; @@ -44,13 +66,16 @@ export function buildPairedTurnPrompt(args: { const humanMessages = args.missedMessages.filter( (message) => !message.is_bot_message, ); - return formatMessages( - mergeHumanAndTurnOutputMessages( - args.chatJid, - humanMessages, - args.turnOutputs, + return prependCarriedForwardGuidance( + formatMessages( + mergeHumanAndTurnOutputMessages( + args.chatJid, + humanMessages, + args.turnOutputs, + ), + args.timezone, ), - args.timezone, + args.turnOutputs, ); } @@ -62,13 +87,16 @@ export function buildReviewerPendingPrompt(args: { lastHumanMessage: string | null | undefined; }): string { if (args.turnOutputs.length > 0) { - return formatMessages( - mergeHumanAndTurnOutputMessages( - args.chatJid, - args.recentHumanMessages, - args.turnOutputs, + return prependCarriedForwardGuidance( + formatMessages( + mergeHumanAndTurnOutputMessages( + args.chatJid, + args.recentHumanMessages, + args.turnOutputs, + ), + args.timezone, ), - args.timezone, + args.turnOutputs, ); } @@ -87,13 +115,16 @@ export function buildOwnerPendingPrompt(args: { lastHumanMessage: string | null | undefined; }): string { if (args.turnOutputs.length > 0) { - return formatMessages( - mergeHumanAndTurnOutputMessages( - args.chatJid, - args.recentHumanMessages, - args.turnOutputs, + return prependCarriedForwardGuidance( + formatMessages( + mergeHumanAndTurnOutputMessages( + args.chatJid, + args.recentHumanMessages, + args.turnOutputs, + ), + args.timezone, ), - args.timezone, + args.turnOutputs, ); }