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:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 <next>\\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",
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user