fix(runtime): guard carry-forward prompts and support inbound IPC injection
This commit is contained in:
@@ -15,8 +15,24 @@ export async function forwardAuthorizedIpcMessage(
|
|||||||
senderRole?: string,
|
senderRole?: string,
|
||||||
runId?: string,
|
runId?: string,
|
||||||
) => Promise<void>,
|
) => Promise<void>,
|
||||||
|
injectInboundMessage?: (payload: {
|
||||||
|
chatJid: string;
|
||||||
|
text: string;
|
||||||
|
sender?: string;
|
||||||
|
senderName?: string;
|
||||||
|
messageId?: string;
|
||||||
|
timestamp?: string;
|
||||||
|
treatAsHuman: boolean;
|
||||||
|
sourceKind?: import('./types.js').MessageSourceKind;
|
||||||
|
}) => Promise<void>,
|
||||||
): Promise<IpcMessageForwardResult> {
|
): Promise<IpcMessageForwardResult> {
|
||||||
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 };
|
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);
|
await sendMessage(msg.chatJid, msg.text, msg.senderRole, msg.runId);
|
||||||
return {
|
return {
|
||||||
outcome: 'sent',
|
outcome: 'sent',
|
||||||
|
|||||||
@@ -114,6 +114,7 @@ export function startIpcWatcher(deps: IpcDeps): void {
|
|||||||
isMain,
|
isMain,
|
||||||
roomBindings,
|
roomBindings,
|
||||||
deps.sendMessage,
|
deps.sendMessage,
|
||||||
|
deps.injectInboundMessage,
|
||||||
);
|
);
|
||||||
if (forwardResult.outcome === 'sent') {
|
if (forwardResult.outcome === 'sent') {
|
||||||
logger.info(
|
logger.info(
|
||||||
|
|||||||
97
src/message-runtime-prompts.test.ts
Normal file
97
src/message-runtime-prompts.test.ts
Normal file
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,6 +2,12 @@ import { buildArbiterContextPrompt } from './arbiter-context.js';
|
|||||||
import { formatMessages } from './router.js';
|
import { formatMessages } from './router.js';
|
||||||
import type { NewMessage, PairedTask, PairedTurnOutput } from './types.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(
|
function turnOutputsToMessages(
|
||||||
outputs: PairedTurnOutput[],
|
outputs: PairedTurnOutput[],
|
||||||
chatJid: string,
|
chatJid: string,
|
||||||
@@ -29,6 +35,22 @@ function mergeHumanAndTurnOutputMessages(
|
|||||||
].sort((a, b) => a.timestamp.localeCompare(b.timestamp));
|
].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: {
|
export function buildPairedTurnPrompt(args: {
|
||||||
taskId: string;
|
taskId: string;
|
||||||
chatJid: string;
|
chatJid: string;
|
||||||
@@ -44,13 +66,16 @@ export function buildPairedTurnPrompt(args: {
|
|||||||
const humanMessages = args.missedMessages.filter(
|
const humanMessages = args.missedMessages.filter(
|
||||||
(message) => !message.is_bot_message,
|
(message) => !message.is_bot_message,
|
||||||
);
|
);
|
||||||
return formatMessages(
|
return prependCarriedForwardGuidance(
|
||||||
|
formatMessages(
|
||||||
mergeHumanAndTurnOutputMessages(
|
mergeHumanAndTurnOutputMessages(
|
||||||
args.chatJid,
|
args.chatJid,
|
||||||
humanMessages,
|
humanMessages,
|
||||||
args.turnOutputs,
|
args.turnOutputs,
|
||||||
),
|
),
|
||||||
args.timezone,
|
args.timezone,
|
||||||
|
),
|
||||||
|
args.turnOutputs,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,13 +87,16 @@ export function buildReviewerPendingPrompt(args: {
|
|||||||
lastHumanMessage: string | null | undefined;
|
lastHumanMessage: string | null | undefined;
|
||||||
}): string {
|
}): string {
|
||||||
if (args.turnOutputs.length > 0) {
|
if (args.turnOutputs.length > 0) {
|
||||||
return formatMessages(
|
return prependCarriedForwardGuidance(
|
||||||
|
formatMessages(
|
||||||
mergeHumanAndTurnOutputMessages(
|
mergeHumanAndTurnOutputMessages(
|
||||||
args.chatJid,
|
args.chatJid,
|
||||||
args.recentHumanMessages,
|
args.recentHumanMessages,
|
||||||
args.turnOutputs,
|
args.turnOutputs,
|
||||||
),
|
),
|
||||||
args.timezone,
|
args.timezone,
|
||||||
|
),
|
||||||
|
args.turnOutputs,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,13 +115,16 @@ export function buildOwnerPendingPrompt(args: {
|
|||||||
lastHumanMessage: string | null | undefined;
|
lastHumanMessage: string | null | undefined;
|
||||||
}): string {
|
}): string {
|
||||||
if (args.turnOutputs.length > 0) {
|
if (args.turnOutputs.length > 0) {
|
||||||
return formatMessages(
|
return prependCarriedForwardGuidance(
|
||||||
|
formatMessages(
|
||||||
mergeHumanAndTurnOutputMessages(
|
mergeHumanAndTurnOutputMessages(
|
||||||
args.chatJid,
|
args.chatJid,
|
||||||
args.recentHumanMessages,
|
args.recentHumanMessages,
|
||||||
args.turnOutputs,
|
args.turnOutputs,
|
||||||
),
|
),
|
||||||
args.timezone,
|
args.timezone,
|
||||||
|
),
|
||||||
|
args.turnOutputs,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user