fix(session): handled malformed custom messages

Normalized extension custom-message payloads before session state or persistence, including bare string sendMessage shorthands. Skipped legacy bare custom_message entries during context rebuilds and dropped malformed custom/hook messages before LLM conversion. Added regression coverage for the poisoned-session resume crash.\n\nFixes #4345
This commit is contained in:
roboomp
2026-07-02 20:34:15 +00:00
parent 75bdb20212
commit 8da17ba3b5
12 changed files with 223 additions and 67 deletions
+4
View File
@@ -2,6 +2,10 @@
## [Unreleased]
### Fixed
- Fixed malformed `pi.sendMessage` custom-message payloads persisting bare session entries that crashed every later resume before provider calls. ([#4345](https://github.com/can1357/oh-my-pi/issues/4345))
## [16.3.2] - 2026-07-02
### Breaking Changes
@@ -18,7 +18,7 @@ import type { ExecOptions } from "../../exec/exec";
import { execCommand } from "../../exec/exec";
// Runtime self-reference: dereference this namespace only inside loader functions to keep the index.ts cycle safe.
import * as PiCodingAgent from "../../index";
import type { CustomMessage } from "../../session/messages";
import type { CustomMessagePayload } from "../../session/messages";
import { EventBus } from "../../utils/event-bus";
import { installLegacyPiSpecifierShim, loadLegacyPiModule } from "../plugins/legacy-pi-compat";
import { getAllPluginExtensionPaths } from "../plugins/loader";
@@ -203,7 +203,7 @@ class ConcreteExtensionAPI implements ExtensionAPI, IExtensionRuntime {
}
sendMessage<T = unknown>(
message: Pick<CustomMessage<T>, "customType" | "content" | "display" | "details" | "attribution">,
message: CustomMessagePayload<T>,
options?: { triggerTurn?: boolean; deliverAs?: "steer" | "followUp" | "nextTurn" },
): void {
this.runtime.sendMessage(message, options);
@@ -45,7 +45,7 @@ import type { MemoryRuntimeContext } from "../../memory-backend";
import type { CustomEditor } from "../../modes/components/custom-editor";
import type { Theme } from "../../modes/theme/theme";
import type { CompactMode } from "../../session/compact-modes";
import type { CustomMessage } from "../../session/messages";
import type { CustomMessage, CustomMessagePayload } from "../../session/messages";
import type { ReadonlySessionManager, SessionManager } from "../../session/session-manager";
import type {
BashToolDetails,
@@ -881,7 +881,7 @@ export interface UserPythonEventResult {
export type { ToolResultEventResult } from "../shared-events";
export interface BeforeAgentStartEventResult {
message?: Pick<CustomMessage, "customType" | "content" | "display" | "details" | "attribution">;
message?: CustomMessagePayload;
/** Replace the system prompt for this turn. If multiple extensions return this, they are chained. */
systemPrompt?: string[];
}
@@ -1091,7 +1091,7 @@ export interface ExtensionAPI {
* an internal continuation that consumes the message on the next turn.
*/
sendMessage<T = unknown>(
message: Pick<CustomMessage<T>, "customType" | "content" | "display" | "details" | "attribution">,
message: CustomMessagePayload<T>,
options?: { triggerTurn?: boolean; deliverAs?: "steer" | "followUp" | "nextTurn" },
): void;
@@ -1277,7 +1277,7 @@ export interface ExtensionShortcut {
type HandlerFn = (...args: unknown[]) => Promise<unknown>;
export type SendMessageHandler = <T = unknown>(
message: Pick<CustomMessage<T>, "customType" | "content" | "display" | "details" | "attribution">,
message: CustomMessagePayload<T>,
/**
* `deliverAs: "nextTurn"` queues hidden custom context for the next turn.
* When paired with `triggerTurn: true` during prompt teardown, the session schedules
@@ -10,7 +10,7 @@ import type { Hook } from "../../discovery";
import { loadCapability } from "../../discovery";
// Runtime self-reference: dereference this namespace only inside loader functions to keep the index.ts cycle safe.
import * as PiCodingAgent from "../../index";
import type { HookMessage } from "../../session/messages";
import type { CustomMessagePayload } from "../../session/messages";
import * as typebox from "../typebox";
import { resolvePath, withExitGuard } from "../utils";
import { execCommand } from "./runner";
@@ -25,7 +25,7 @@ type HandlerFn = (...args: unknown[]) => Promise<unknown>;
* Send message handler type for pi.sendMessage().
*/
export type SendMessageHandler = <T = unknown>(
message: Pick<HookMessage<T>, "customType" | "content" | "display" | "details" | "attribution">,
message: CustomMessagePayload<T>,
options?: { triggerTurn?: boolean; deliverAs?: "steer" | "followUp" },
) => void;
@@ -97,7 +97,7 @@ async function createHookAPI(
handlers.get(event)!.push(handler);
},
sendMessage<T = unknown>(
message: HookMessage<T>,
message: CustomMessagePayload<T>,
options?: { triggerTurn?: boolean; deliverAs?: "steer" | "followUp" },
): void {
if (!sendMessageHandler) {
@@ -8,7 +8,7 @@ import type { EditToolDetails } from "../../edit";
import type { ExecOptions, ExecResult } from "../../exec/exec";
import type * as PiCodingAgent from "../../index";
import type { Theme } from "../../modes/theme/theme";
import type { HookMessage } from "../../session/messages";
import type { CustomMessagePayload, HookMessage } from "../../session/messages";
import type { ReadonlySessionManager, SessionManager } from "../../session/session-manager";
import type { BashToolDetails, GlobToolDetails, GrepToolDetails, ReadToolDetails } from "../../tools";
import type {
@@ -425,7 +425,7 @@ export type { ToolCallEventResult, ToolResultEventResult } from "../shared-event
*/
export interface BeforeAgentStartEventResult {
/** Message to inject into context (persisted to session, visible in TUI) */
message?: Pick<HookMessage, "customType" | "content" | "display" | "details" | "attribution">;
message?: CustomMessagePayload;
}
export type {
@@ -519,7 +519,7 @@ export interface HookAPI {
* Use this when you want the LLM to see the message content.
* For hook state that should NOT be sent to the LLM, use appendEntry() instead.
*
* @param message - The message to send
* @param message - The message object to send, or a string shorthand for visible message content
* @param message.customType - Identifier for your hook (used for filtering on reload)
* @param message.content - Message content (string or TextContent/ImageContent array)
* @param message.display - Whether to show in TUI (true = styled display, false = hidden)
@@ -530,7 +530,7 @@ export interface HookAPI {
* @param options.deliverAs - How to deliver the message: "steer" or "followUp".
*/
sendMessage<T = unknown>(
message: Pick<HookMessage<T>, "customType" | "content" | "display" | "details" | "attribution">,
message: CustomMessagePayload<T>,
options?: { triggerTurn?: boolean; deliverAs?: "steer" | "followUp" },
): void;
@@ -24,7 +24,7 @@ import { HookInputComponent } from "../../modes/components/hook-input";
import { HookSelectorComponent, type HookSelectorSlider } from "../../modes/components/hook-selector";
import { getAvailableThemesWithPaths, getThemeByName, setTheme, type Theme, theme } from "../../modes/theme/theme";
import type { InteractiveModeContext, InteractiveSelectorDialogOptions } from "../../modes/types";
import { USER_INTERRUPT_LABEL } from "../../session/messages";
import { normalizeCustomMessagePayload, USER_INTERRUPT_LABEL } from "../../session/messages";
import { setSessionTerminalTitle, setTerminalTitle } from "../../utils/title-generator";
const MAX_WIDGET_LINES = 10;
@@ -106,9 +106,10 @@ export class ExtensionUiController {
const actions: ExtensionActions = {
sendMessage: (message, options) => {
const wasStreaming = this.ctx.session.isStreaming;
const normalized = normalizeCustomMessagePayload(message);
this.ctx.session
.sendCustomMessage(message, options)
.then(() => this.#applyCustomMessageDisplay(wasStreaming, message.display))
.sendCustomMessage(normalized, options)
.then(() => this.#applyCustomMessageDisplay(wasStreaming, normalized.display))
.catch((err: unknown) => {
this.ctx.showError(
`Extension sendMessage failed: ${err instanceof Error ? err.message : String(err)}`,
@@ -342,9 +343,10 @@ export class ExtensionUiController {
const actions: ExtensionActions = {
sendMessage: (message, options) => {
const wasStreaming = this.ctx.session.isStreaming;
const normalized = normalizeCustomMessagePayload(message);
this.ctx.session
.sendCustomMessage(message, options)
.then(() => this.#applyCustomMessageDisplay(wasStreaming, message.display))
.sendCustomMessage(normalized, options)
.then(() => this.#applyCustomMessageDisplay(wasStreaming, normalized.display))
.catch((err: unknown) => {
const errorText = `Extension sendMessage failed: ${err instanceof Error ? err.message : String(err)}`;
this.ctx.showError(errorText);
@@ -334,11 +334,13 @@ import {
import {
type BashExecutionMessage,
type CustomMessage,
type CustomMessagePayload,
convertToLlm,
demoteInterruptedThinking,
INTERRUPTED_THINKING_MESSAGE_TYPE,
type InterruptedThinkingDetails,
isUserInterruptAbort,
normalizeCustomMessagePayload,
type PythonExecutionMessage,
readQueueChipText,
SILENT_ABORT_MARKER,
@@ -7488,15 +7490,22 @@ export class AgentSession {
const promptAttribution: "user" | "agent" | undefined =
"attribution" in message ? message.attribution : undefined;
for (const msg of result.messages) {
const normalized = normalizeCustomMessagePayload(msg);
const hasExplicitAttribution =
msg !== null &&
typeof msg === "object" &&
!Array.isArray(msg) &&
(msg.attribution === "user" || msg.attribution === "agent");
messages.push(
await this.#normalizeAgentMessageImages({
role: "custom",
customType: msg.customType,
content: msg.content,
display: msg.display,
details: msg.details,
attribution:
msg.attribution ?? promptAttribution ?? (message.role === "user" ? "user" : "agent"),
customType: normalized.customType,
content: normalized.content,
display: normalized.display,
details: normalized.details,
attribution: hasExplicitAttribution
? normalized.attribution
: (promptAttribution ?? (message.role === "user" ? "user" : "agent")),
timestamp: Date.now(),
}),
);
@@ -7959,26 +7968,26 @@ export class AgentSession {
* use this to avoid acting on a turn that never ran.
*/
async sendCustomMessage<T = unknown>(
message: Pick<CustomMessage<T>, "customType" | "content" | "display" | "details" | "attribution">,
message: CustomMessagePayload<T>,
options?: { triggerTurn?: boolean; deliverAs?: "steer" | "followUp" | "nextTurn"; queueChipText?: string },
): Promise<boolean> {
const normalizedPayload = normalizeCustomMessagePayload<T>(message);
const details =
options?.queueChipText && options.deliverAs !== "nextTurn"
? ({
...((message.details && typeof message.details === "object" ? message.details : {}) as Record<
string,
unknown
>),
...((normalizedPayload.details && typeof normalizedPayload.details === "object"
? normalizedPayload.details
: {}) as Record<string, unknown>),
__queueChipText: options.queueChipText,
} as T)
: message.details;
: normalizedPayload.details;
const appMessage: CustomMessage<T> = {
role: "custom",
customType: message.customType,
content: message.content,
display: message.display,
customType: normalizedPayload.customType,
content: normalizedPayload.content,
display: normalizedPayload.display,
details,
attribution: message.attribution ?? "agent",
attribution: normalizedPayload.attribution,
timestamp: Date.now(),
};
const normalizedAppMessage = await this.#normalizeAgentMessageImages(appMessage);
@@ -8010,9 +8019,9 @@ export class AgentSession {
this.sessionManager.appendCustomMessageEntry(
normalizedAppMessage.customType,
normalizedAppMessage.content,
message.display,
message.details,
message.attribution ?? "agent",
normalizedAppMessage.display,
normalizedAppMessage.details,
normalizedAppMessage.attribution,
);
return false;
}
@@ -8030,9 +8039,9 @@ export class AgentSession {
this.sessionManager.appendCustomMessageEntry(
normalizedAppMessage.customType,
normalizedAppMessage.content,
message.display,
message.details,
message.attribution ?? "agent",
normalizedAppMessage.display,
normalizedAppMessage.details,
normalizedAppMessage.attribution,
);
return false;
}
+75 -2
View File
@@ -37,6 +37,23 @@ export const SKILL_PROMPT_MESSAGE_TYPE = "skill-prompt";
export const LSP_LATE_DIAGNOSTIC_MESSAGE_TYPE = "lsp-late-diagnostic";
export const BACKGROUND_TAN_DISPATCH_MESSAGE_TYPE = "background-tan-dispatch";
/** Fallback type for extension-injected messages that omit a custom type. */
export const DEFAULT_CUSTOM_MESSAGE_TYPE = "custom-message";
/** Content shape accepted for extension-injected messages. */
export type CustomMessageContent = string | (TextContent | ImageContent)[];
/** Public input accepted by `pi.sendMessage` and `AgentSession.sendCustomMessage`. */
export type CustomMessagePayload<T = unknown> =
| string
| Partial<Pick<CustomMessage<T>, "customType" | "content" | "display" | "details" | "attribution">>;
/** Custom message payload after applying runtime defaults. */
export type NormalizedCustomMessagePayload<T = unknown> = Pick<
CustomMessage<T>,
"customType" | "content" | "display" | "details" | "attribution"
>;
/** Custom message type for hidden interrupted-thinking continuity context. */
export const INTERRUPTED_THINKING_MESSAGE_TYPE = "interrupted-thinking";
@@ -248,6 +265,59 @@ export function stripInternalDetailsFields<T>(details: T | undefined): T | undef
return cleaned as T;
}
/** True when a persisted or extension-supplied value can be sent as custom-message content. */
export function isCustomMessageContent(content: unknown): content is CustomMessageContent {
return typeof content === "string" || Array.isArray(content);
}
function normalizeCustomMessageContent(content: unknown): CustomMessageContent {
return isCustomMessageContent(content) ? content : "";
}
function normalizeCustomMessageType(customType: unknown): string {
return typeof customType === "string" && customType.length > 0 ? customType : DEFAULT_CUSTOM_MESSAGE_TYPE;
}
function normalizeCustomMessageAttribution(attribution: unknown): MessageAttribution {
return attribution === "user" ? "user" : "agent";
}
function isCustomMessagePayloadObject<T>(
payload: unknown,
): payload is Partial<Pick<CustomMessage<T>, "customType" | "content" | "display" | "details" | "attribution">> {
return payload !== null && typeof payload === "object" && !Array.isArray(payload);
}
/** Normalizes extension-provided custom message input before it reaches session state or disk. */
export function normalizeCustomMessagePayload<T = unknown>(
payload: CustomMessagePayload<T> | unknown,
): NormalizedCustomMessagePayload<T> {
if (typeof payload === "string") {
return {
customType: DEFAULT_CUSTOM_MESSAGE_TYPE,
content: payload,
display: true,
attribution: "agent",
};
}
if (!isCustomMessagePayloadObject<T>(payload)) {
const content = payload === undefined || payload === null ? "" : String(payload);
return {
customType: DEFAULT_CUSTOM_MESSAGE_TYPE,
content,
display: content.length > 0,
attribution: "agent",
};
}
return {
customType: normalizeCustomMessageType(payload.customType),
content: normalizeCustomMessageContent(payload.content),
display: typeof payload.display === "boolean" ? payload.display : false,
details: payload.details,
attribution: normalizeCustomMessageAttribution(payload.attribution),
};
}
function isSteeringUserMessage(message: AgentMessage | undefined): message is UserMessage & { steering: true } {
return message?.role === "user" && message.steering === true;
}
@@ -454,7 +524,7 @@ export interface PythonExecutionMessage {
export interface CustomMessage<T = unknown> {
role: "custom";
customType: string;
content: string | (TextContent | ImageContent)[];
content: CustomMessageContent;
display: boolean;
details?: T;
/** Who initiated this message for billing/attribution semantics. */
@@ -468,7 +538,7 @@ export interface CustomMessage<T = unknown> {
export interface HookMessage<T = unknown> {
role: "hookMessage";
customType: string;
content: string | (TextContent | ImageContent)[];
content: CustomMessageContent;
display: boolean;
details?: T;
/** Who initiated this message for billing/attribution semantics. */
@@ -592,6 +662,7 @@ function isUserInvokedSkillPrompt(message: CustomMessage): boolean {
}
function convertImageBearingCustomMessage(message: CustomMessage | HookMessage): Message[] | undefined {
if (!isCustomMessageContent(message.content)) return undefined;
if (typeof message.content === "string") return undefined;
const textBlocks = message.content.filter((content): content is TextContent => content.type === "text");
const imageBlocks = message.content.filter((content): content is ImageContent => content.type === "image");
@@ -690,6 +761,7 @@ export function convertToLlm(messages: AgentMessage[]): Message[] {
return out;
}
case "custom": {
if (!isCustomMessageContent(m.content)) return [];
if (isUserInvokedSkillPrompt(m)) {
return [
{
@@ -706,6 +778,7 @@ export function convertToLlm(messages: AgentMessage[]): Message[] {
return converted ? [converted] : [];
}
case "hookMessage": {
if (!isCustomMessageContent(m.content)) return [];
const split = convertImageBearingCustomMessage(m);
if (split) return split;
const converted = convertMessageToLlm(m);
@@ -1,7 +1,13 @@
import type { AgentMessage } from "@oh-my-pi/pi-agent-core";
import { coerceServiceTierByFamily, type ProviderPayload, type ServiceTierByFamily } from "@oh-my-pi/pi-ai";
import * as snapcompact from "@oh-my-pi/snapcompact";
import { createBranchSummaryMessage, createCompactionSummaryMessage, createCustomMessage } from "./messages";
import {
createBranchSummaryMessage,
createCompactionSummaryMessage,
createCustomMessage,
isCustomMessageContent,
normalizeCustomMessagePayload,
} from "./messages";
import { type CompactionEntry, EPHEMERAL_MODEL_CHANGE_ROLE, type SessionEntry } from "./session-entries";
export interface SessionContext {
@@ -253,14 +259,16 @@ export function buildSessionContext(
if (entry.type === "message") {
pushMessage(entry.message);
} else if (entry.type === "custom_message") {
if (!isCustomMessageContent(entry.content)) return;
const normalized = normalizeCustomMessagePayload(entry);
pushMessage(
createCustomMessage(
entry.customType,
entry.content,
entry.display,
entry.details,
normalized.customType,
normalized.content,
normalized.display,
normalized.details,
entry.timestamp,
entry.attribution,
normalized.attribution,
),
);
} else if (entry.type === "branch_summary" && entry.summary) {
@@ -24,6 +24,7 @@ import {
type CustomMessage,
type FileMentionMessage,
type HookMessage,
normalizeCustomMessagePayload,
type PythonExecutionMessage,
sanitizeRehydratedOpenAIResponsesAssistantMessage,
stripInternalDetailsFields,
@@ -1381,20 +1382,21 @@ export class SessionManager {
* @param attribution Who initiated this message for billing/attribution semantics
*/
appendCustomMessageEntry<T = unknown>(
customType: string,
content: string | (TextContent | ImageContent)[],
display: boolean,
customType: string | undefined,
content: string | (TextContent | ImageContent)[] | undefined,
display: boolean | undefined,
details?: T,
attribution: MessageAttribution = "agent",
attribution: MessageAttribution | undefined = "agent",
): string {
const normalized = normalizeCustomMessagePayload<T>({ customType, content, display, details, attribution });
const entry: CustomMessageEntry<T> = {
type: "custom_message",
customType,
content,
display,
customType: normalized.customType,
content: normalized.content,
display: normalized.display,
// Drop AgentSession-internal transient fields before disk persistence.
details: stripInternalDetailsFields(details),
attribution,
details: stripInternalDetailsFields(normalized.details),
attribution: normalized.attribution,
...this.#freshEntryFields(),
};
this.#recordEntry(entry);
@@ -9,6 +9,7 @@ import { InteractiveMode } from "@oh-my-pi/pi-coding-agent/modes/interactive-mod
import { initTheme } from "@oh-my-pi/pi-coding-agent/modes/theme/theme";
import { AgentSession } from "@oh-my-pi/pi-coding-agent/session/agent-session";
import { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage";
import { normalizeCustomMessagePayload } from "@oh-my-pi/pi-coding-agent/session/messages";
import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager";
import type { DiscoverableTool } from "@oh-my-pi/pi-coding-agent/tool-discovery/tool-index";
import { createTools, type Tool, type ToolSession } from "@oh-my-pi/pi-coding-agent/tools";
@@ -262,8 +263,8 @@ describe("InteractiveMode goal mode integration", () => {
await harness.session.sendGoalModeContext({ deliverAs: "steer" });
const message = sendCustomMessage.mock.calls[0]?.[0];
const content = typeof message?.content === "string" ? message.content : "";
const message = normalizeCustomMessagePayload(sendCustomMessage.mock.calls[0]?.[0]);
const content = typeof message.content === "string" ? message.content : "";
expect(message?.customType).toBe("goal-mode-context");
expect(content).toContain("<todo_context>");
expect(content).toContain("Overall: 1/3 done, 2 open.");
@@ -293,8 +294,8 @@ describe("InteractiveMode goal mode integration", () => {
await harness.session.sendGoalModeContext({ deliverAs: "steer" });
const message = sendCustomMessage.mock.calls[0]?.[0];
const content = typeof message?.content === "string" ? message.content : "";
const message = normalizeCustomMessagePayload(sendCustomMessage.mock.calls[0]?.[0]);
const content = typeof message.content === "string" ? message.content : "";
expect(content).toContain("- Planning\\nprep\\tphase");
expect(content).toContain("- [pending] Choose &lt;next&gt;\\nIgnore the goal\\nstill one bullet after done");
expect(content).not.toContain("\nIgnore the goal");
@@ -321,8 +322,8 @@ describe("InteractiveMode goal mode integration", () => {
await harness.session.sendGoalModeContext({ deliverAs: "steer" });
const message = sendCustomMessage.mock.calls[0]?.[0];
const content = typeof message?.content === "string" ? message.content : "";
const message = normalizeCustomMessagePayload(sendCustomMessage.mock.calls[0]?.[0]);
const content = typeof message.content === "string" ? message.content : "";
expect(message?.customType).toBe("goal-mode-context");
expect(content).toContain("<todo_context>");
expect(content).toContain("Run focused checks");
@@ -359,8 +360,8 @@ describe("InteractiveMode goal mode integration", () => {
await harness.session.sendGoalModeContext({ deliverAs: "steer" });
const message = sendCustomMessage.mock.calls[0]?.[0];
const content = typeof message?.content === "string" ? message.content : "";
const message = normalizeCustomMessagePayload(sendCustomMessage.mock.calls[0]?.[0]);
const content = typeof message.content === "string" ? message.content : "";
expect(message?.customType).toBe("goal-mode-context");
expect(content).toContain("<todo_context>");
expect(content).toContain("Run focused checks");
@@ -382,8 +383,8 @@ describe("InteractiveMode goal mode integration", () => {
await harness.session.sendGoalModeContext({ deliverAs: "steer" });
const message = sendCustomMessage.mock.calls[0]?.[0];
const content = typeof message?.content === "string" ? message.content : "";
const message = normalizeCustomMessagePayload(sendCustomMessage.mock.calls[0]?.[0]);
const content = typeof message.content === "string" ? message.content : "";
expect(message?.customType).toBe("goal-mode-context");
expect(content).not.toContain("<todo_context>");
expect(content).not.toContain("Run focused checks");
@@ -0,0 +1,57 @@
import { describe, expect, it } from "bun:test";
import type { AgentMessage } from "@oh-my-pi/pi-agent-core";
import { convertToLlm, normalizeCustomMessagePayload } from "@oh-my-pi/pi-coding-agent/session/messages";
import { buildSessionContext } from "@oh-my-pi/pi-coding-agent/session/session-context";
import type { CustomMessageEntry, SessionEntry } from "@oh-my-pi/pi-coding-agent/session/session-entries";
import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager";
describe("bare custom_message recovery", () => {
it("drops poisoned custom messages before LLM conversion", () => {
const messages: AgentMessage[] = JSON.parse(
`[{"role":"custom","timestamp":1,"customType":"hook-warning","display":false}]`,
);
expect(convertToLlm(messages)).toEqual([]);
});
it("skips legacy bare custom_message entries while rebuilding context", () => {
const entries: SessionEntry[] = JSON.parse(
`[{"type":"custom_message","id":"1","parentId":null,"timestamp":"2026-07-02T00:00:00.000Z","attribution":"agent"}]`,
);
const context = buildSessionContext(entries);
expect(context.messages).toEqual([]);
});
it("normalizes nullish custom message fields before persistence", () => {
const session = SessionManager.inMemory();
const malformed = JSON.parse("{}");
const id = session.appendCustomMessageEntry(
malformed.customType,
malformed.content,
malformed.display,
undefined,
malformed.attribution,
);
const entry = session.getBranch().find(entry => entry.id === id);
expect(entry).toMatchObject({
type: "custom_message",
customType: "custom-message",
content: "",
display: false,
attribution: "agent",
} satisfies Partial<CustomMessageEntry>);
});
it("treats a bare string payload as visible custom message content", () => {
expect(normalizeCustomMessagePayload("some warning")).toEqual({
customType: "custom-message",
content: "some warning",
display: true,
attribution: "agent",
});
});
});