refactor: normalize inbound context

This commit is contained in:
Peter Steinberger
2026-01-17 04:04:05 +00:00
parent 9f4b7a1683
commit a2b5b1f0cb
31 changed files with 155 additions and 35 deletions

View File

@@ -120,12 +120,18 @@ export async function resolveReplyDirectives(params: {
// Prefer CommandBody/RawBody (clean message without structural context) for directive parsing.
// Keep `Body`/`BodyStripped` as the best-available prompt text (may include context).
const commandSource =
sessionCtx.BodyForCommands ??
sessionCtx.CommandBody ??
sessionCtx.RawBody ??
sessionCtx.Transcript ??
sessionCtx.BodyStripped ??
sessionCtx.Body ??
ctx.BodyForCommands ??
ctx.CommandBody ??
ctx.RawBody ??
"";
const promptSource = sessionCtx.BodyForAgent ?? sessionCtx.BodyStripped ?? sessionCtx.Body ?? "";
const commandText = commandSource || promptSource;
const command = buildCommandContext({
ctx,
cfg,
@@ -162,7 +168,7 @@ export async function resolveReplyDirectives(params: {
.filter((alias): alias is string => Boolean(alias))
.filter((alias) => !reservedCommands.has(alias.toLowerCase()));
const allowStatusDirective = allowTextCommands && command.isAuthorizedSender;
let parsedDirectives = parseInlineDirectives(commandSource, {
let parsedDirectives = parseInlineDirectives(commandText, {
modelAliases: configuredAliases,
allowStatusDirective,
});
@@ -253,6 +259,7 @@ export async function resolveReplyDirectives(params: {
cleanedBody = stripInlineStatus(cleanedBody).cleaned;
}
sessionCtx.BodyForAgent = cleanedBody;
sessionCtx.Body = cleanedBody;
sessionCtx.BodyStripped = cleanedBody;
@@ -402,7 +409,7 @@ export async function resolveReplyDirectives(params: {
return {
kind: "continue",
result: {
commandSource,
commandSource: commandText,
command,
allowTextCommands,
skillCommands,

View File

@@ -135,7 +135,9 @@ export async function handleInlineActions(params: {
].filter((entry): entry is string => Boolean(entry));
const rewrittenBody = promptParts.join("\n\n");
ctx.Body = rewrittenBody;
ctx.BodyForAgent = rewrittenBody;
sessionCtx.Body = rewrittenBody;
sessionCtx.BodyForAgent = rewrittenBody;
sessionCtx.BodyStripped = rewrittenBody;
cleanedBody = rewrittenBody;
}
@@ -153,6 +155,7 @@ export async function handleInlineActions(params: {
if (inlineCommand) {
cleanedBody = inlineCommand.cleaned;
sessionCtx.Body = cleanedBody;
sessionCtx.BodyForAgent = cleanedBody;
sessionCtx.BodyStripped = cleanedBody;
}

View File

@@ -295,7 +295,10 @@ export async function runPreparedReply(
abortKey: command.abortKey,
messageId: sessionCtx.MessageSid,
});
const isGroupSession = sessionEntry?.chatType === "group" || sessionEntry?.chatType === "room";
const isGroupSession =
sessionEntry?.chatType === "group" ||
sessionEntry?.chatType === "channel" ||
sessionEntry?.chatType === "room";
const isMainSession = !isGroupSession && sessionKey === normalizeMainKey(sessionCfg?.mainKey);
prefixedBodyBase = await prependSystemEvents({
cfg,

View File

@@ -28,10 +28,10 @@ describe("formatInboundBodyWithSenderMeta", () => {
);
});
it("preserves escaped newline style when body uses literal \\\\n", () => {
it("appends with a real newline even if the body contains literal \\\\n", () => {
const ctx: MsgContext = { ChatType: "group", SenderName: "Bob", SenderId: "+222" };
expect(formatInboundBodyWithSenderMeta({ ctx, body: "[X] one\\n[X] two" })).toBe(
"[X] one\\n[X] two\\n[from: Bob (+222)]",
"[X] one\\n[X] two\n[from: Bob (+222)]",
);
});

View File

@@ -1,28 +1,21 @@
import type { MsgContext } from "../templating.js";
import { normalizeChatType } from "../../channels/chat-type.js";
export function formatInboundBodyWithSenderMeta(params: { body: string; ctx: MsgContext }): string {
const body = params.body;
if (!body.trim()) return body;
const chatType = params.ctx.ChatType?.trim().toLowerCase();
const chatType = normalizeChatType(params.ctx.ChatType);
if (!chatType || chatType === "direct") return body;
if (hasSenderMetaLine(body)) return body;
const senderLabel = formatSenderLabel(params.ctx);
if (!senderLabel) return body;
const lineBreak = resolveBodyLineBreak(body);
return `${body}${lineBreak}[from: ${senderLabel}]`;
}
function resolveBodyLineBreak(body: string): string {
const hasEscaped = body.includes("\\n");
const hasNewline = body.includes("\n");
if (hasEscaped && !hasNewline) return "\\n";
return "\n";
return `${body}\n[from: ${senderLabel}]`;
}
function hasSenderMetaLine(body: string): boolean {
return /(^|\n|\\n)\[from:/i.test(body);
return /(^|\n)\[from:/i.test(body);
}
function formatSenderLabel(ctx: MsgContext): string | null {

View File

@@ -25,8 +25,10 @@ import {
import { normalizeMainKey } from "../../routing/session-key.js";
import { resolveCommandAuthorization } from "../command-auth.js";
import type { MsgContext, TemplateContext } from "../templating.js";
import { normalizeChatType } from "../../channels/chat-type.js";
import { stripMentions, stripStructuralPrefixes } from "./mentions.js";
import { formatInboundBodyWithSenderMeta } from "./inbound-sender-meta.js";
import { normalizeInboundTextNewlines } from "./inbound-text.js";
export type SessionInitResult = {
sessionCtx: TemplateContext;
@@ -126,10 +128,11 @@ export async function initSessionState(params: {
let persistedProviderOverride: string | undefined;
const groupResolution = resolveGroupSessionKey(sessionCtxForState) ?? undefined;
const isGroup = ctx.ChatType?.trim().toLowerCase() === "group" || Boolean(groupResolution);
const normalizedChatType = normalizeChatType(ctx.ChatType);
const isGroup = normalizedChatType != null && normalizedChatType !== "direct" ? true : Boolean(groupResolution);
// Prefer CommandBody/RawBody (clean message) for command detection; fall back
// to Body which may contain structural context (history, sender labels).
const commandSource = ctx.CommandBody ?? ctx.RawBody ?? ctx.Body ?? "";
const commandSource = ctx.BodyForCommands ?? ctx.CommandBody ?? ctx.RawBody ?? ctx.Body ?? "";
const triggerBodyNormalized = stripStructuralPrefixes(commandSource).trim().toLowerCase();
// Use CommandBody/RawBody for reset trigger matching (clean message without structural context).
@@ -308,7 +311,15 @@ export async function initSessionState(params: {
// RawBody is reserved for command/directive parsing and may omit context.
BodyStripped: formatInboundBodyWithSenderMeta({
ctx,
body: bodyStripped ?? ctx.Body ?? ctx.CommandBody ?? ctx.RawBody ?? "",
body: normalizeInboundTextNewlines(
bodyStripped ??
ctx.BodyForAgent ??
ctx.Body ??
ctx.CommandBody ??
ctx.RawBody ??
ctx.BodyForCommands ??
"",
),
}),
SessionId: sessionId,
IsNewSession: isNewSession ? "true" : "false",

View File

@@ -253,6 +253,7 @@ export function buildStatusMessage(args: StatusArgs): string {
const isGroupSession =
entry?.chatType === "group" ||
entry?.chatType === "channel" ||
entry?.chatType === "room" ||
Boolean(args.sessionKey?.includes(":group:")) ||
Boolean(args.sessionKey?.includes(":channel:")) ||

View File

@@ -8,6 +8,11 @@ export type OriginatingChannelType = ChannelId | InternalMessageChannel;
export type MsgContext = {
Body?: string;
/**
* Agent prompt body (may include envelope/history/context). Prefer this for prompt shaping.
* Should use real newlines (`\n`), not escaped `\\n`.
*/
BodyForAgent?: string;
/**
* Raw message body without structural context (history, sender labels).
* Legacy alias for CommandBody. Falls back to Body if not set.
@@ -17,6 +22,11 @@ export type MsgContext = {
* Prefer for command detection; RawBody is treated as legacy alias.
*/
CommandBody?: string;
/**
* Command parsing body. Prefer this over CommandBody/RawBody when set.
* Should be the "clean" text (no history/sender context).
*/
BodyForCommands?: string;
CommandArgs?: CommandArgs;
From?: string;
To?: string;
@@ -46,6 +56,8 @@ export type MsgContext = {
Prompt?: string;
MaxChars?: number;
ChatType?: string;
/** Human label for envelope headers (conversation label, not sender). */
ConversationLabel?: string;
GroupSubject?: string;
GroupRoom?: string;
GroupSpace?: string;