fix(FN-4930): make promptSessionAndCheck transcript diagnostics recursion-safe

Fusion-Task-Id: FN-4930
Fusion-Task-Lineage: 5fb64145-a526-4aec-846e-5771b5844b18
This commit is contained in:
Fusion (runfusion.ai)
2026-05-17 16:09:19 -07:00
committed by gsxdsm
parent 0e4fda59b8
commit 24f5c238a3
3 changed files with 89 additions and 5 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": patch
---
Make `promptSessionAndCheck` transcript diagnostics circular-safe to prevent stack overflows on malformed message metadata.

View File

@@ -0,0 +1,50 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const warnMock = vi.fn();
vi.mock("../logger.js", () => ({
createLogger: () => ({
log: vi.fn(),
info: vi.fn(),
warn: warnMock,
error: vi.fn(),
}),
}));
describe("promptSessionAndCheck recursion guard (FN-4930)", () => {
beforeEach(() => {
warnMock.mockReset();
vi.resetModules();
});
it("does not overflow when transcript-inspection metadata has pathological toJSON", async () => {
const { promptSessionAndCheck } = await import("../pi.js");
const recursive = {
toJSON() {
return { recursive };
},
};
const state = {
errorMessage: "",
messages: [
{
role: "assistant",
content: "ok",
toolName: recursive,
stopReason: recursive,
},
],
};
const session = {
prompt: vi.fn(async () => {
state.errorMessage = "Cannot read properties of undefined (reading 'foo')";
}),
state,
} as any;
await expect(promptSessionAndCheck(session, "hello")).rejects.not.toThrow(/Maximum call stack/i);
expect(warnMock).not.toHaveBeenCalledWith(expect.stringContaining("failed to inspect transcript"));
});
});

View File

@@ -221,7 +221,36 @@ function isThinkingReasoningConflictError(message: string): boolean {
return /cannot specify both\s+['"]?thinking['"]?\s+and\s+['"]?reasoning_effort['"]?/i.test(message);
}
async function promptSessionAndCheck(session: AgentSession, prompt: string, options?: unknown): Promise<void> {
function coercePreviewValue(value: unknown): string {
if (value === null) return "null";
if (value === undefined) return "undefined";
if (typeof value === "string") return value;
if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") {
return String(value);
}
if (typeof value === "symbol") {
return value.description ? `symbol(${value.description})` : "symbol";
}
if (typeof value === "function") {
return `[function ${value.name || "anonymous"}]`;
}
return Object.prototype.toString.call(value);
}
function safePreviewJson(value: unknown): string {
const seen = new WeakSet<object>();
return JSON.stringify(value, (_key, candidate) => {
if (typeof candidate === "object" && candidate !== null) {
if (seen.has(candidate)) {
return "[Circular]";
}
seen.add(candidate);
}
return candidate;
});
}
export async function promptSessionAndCheck(session: AgentSession, prompt: string, options?: unknown): Promise<void> {
clearSessionStateError(session);
if (options === undefined) {
await session.prompt(prompt);
@@ -244,13 +273,13 @@ async function promptSessionAndCheck(session: AgentSession, prompt: string, opti
const content = m?.content;
return {
index: i < 0 ? idx : i,
role: m?.role,
role: coercePreviewValue(m?.role),
contentType: Array.isArray(content) ? `array(len=${content.length})` : typeof content,
toolName: (m as { toolName?: unknown }).toolName,
stopReason: (m as { stopReason?: unknown }).stopReason,
toolName: coercePreviewValue((m as { toolName?: unknown }).toolName),
stopReason: coercePreviewValue((m as { stopReason?: unknown }).stopReason),
};
});
piLog.error(`pi state error — transcript tail (${messages.length} msgs total): ${JSON.stringify(recent)}`);
piLog.error(`pi state error — transcript tail (${messages.length} msgs total): ${safePreviewJson(recent)}`);
} else {
piLog.error(`pi state error — state.messages is not an array: ${typeof messages}`);
}