FN-5832: surface unsupported message-role provider errors

Make unsupported provider message-role failures fail fast with actionable operator guidance.

- detect provider errors that reject `messages.[n].role` values via a dedicated transient-error detector helper
- classify unsupported message-role failures as operator-actionable/permanent to prevent retry loops
- annotate `promptSessionAndCheck` failures with model/provider compatibility guidance for imported company-role agents
- add regression tests for role-error detection, operator-actionable classification, and prompt-boundary error hinting
- add a patch changeset for `@runfusion/fusion`

Files changed:
 .changeset/fn-5832-unsupported-message-role.md     |  5 +++
 .../pi-prompt-session-and-check-recursion.test.ts  | 24 +++++++++++++++
 .../src/__tests__/transient-error-detector.test.ts | 36 ++++++++++++++++++++++
 packages/engine/src/pi.ts                          |  8 +++++
 packages/engine/src/transient-error-detector.ts    | 14 ++++++++-
 5 files changed, 86 insertions(+), 1 deletion(-)

Fusion-Task-Id: FN-5832

Fusion-Task-Lineage: b3f78f0e-d097-4cb7-a652-dc547f46fe1d
This commit is contained in:
gsxdsm
2026-06-01 07:53:56 -07:00
parent 1d3978e20a
commit e16893a3c7
5 changed files with 86 additions and 1 deletions

View File

@@ -56,4 +56,28 @@ describe("promptSessionAndCheck recursion guard (FN-4930)", () => {
}
expect(warnMock).not.toHaveBeenCalledWith(expect.stringContaining("failed to inspect transcript"));
});
it("annotates unsupported message-role provider errors with actionable hint", async () => {
const { promptSessionAndCheck } = await import("../pi.js");
const state = {
errorMessage: "",
messages: [],
};
const session = {
prompt: vi.fn(async () => {
state.errorMessage =
"developer is not one of ['system', 'assistant', 'user', 'tool', 'function'] - 'messages.[0].role'";
}),
state,
} as any;
await expect(promptSessionAndCheck(session, "hello")).rejects.toThrow(
/developer is not one of \['system', 'assistant', 'user', 'tool', 'function'\] - 'messages\.\[0\]\.role'/,
);
await expect(promptSessionAndCheck(session, "hello")).rejects.toThrow(
/Operator action required: this agent's configured model\/provider rejected a message role\./,
);
});
});

View File

@@ -6,6 +6,7 @@ import {
extractMissingModulePath,
isOperatorActionableAgentError,
isStaleWorktreeModuleResolutionError,
isUnsupportedMessageRoleError,
TRANSIENT_ERROR_PATTERNS,
} from "../transient-error-detector.js";
import { isUsageLimitError } from "../usage-limit-detector.js";
@@ -269,6 +270,34 @@ describe("Transient Error Detector", () => {
});
});
describe("isUnsupportedMessageRoleError", () => {
it("returns true for the reported provider error", () => {
expect(
isUnsupportedMessageRoleError(
"developer is not one of ['system', 'assistant', 'user', 'tool', 'function'] - 'messages.[0].role'",
),
).toBe(true);
});
it("returns true for role/index/case variants", () => {
expect(
isUnsupportedMessageRoleError(
"assistant_role is not one of ['system','assistant','user','tool','function'] - 'messages.[3].role'",
),
).toBe(true);
expect(
isUnsupportedMessageRoleError(
"'MESSAGES.[9].ROLE' IS NOT ONE OF ['system','assistant']",
),
).toBe(true);
});
it("returns false for unrelated errors", () => {
expect(isUnsupportedMessageRoleError("socket hang up")).toBe(false);
expect(isUnsupportedMessageRoleError("invalid api key")).toBe(false);
});
});
describe("isOperatorActionableAgentError", () => {
it("returns true for credential/model/billing errors", () => {
expect(isOperatorActionableAgentError("invalid api key")).toBe(true);
@@ -278,6 +307,13 @@ describe("Transient Error Detector", () => {
expect(isOperatorActionableAgentError("billing issue: quota exceeded")).toBe(true);
});
it("returns true for unsupported message-role errors", () => {
const message =
"developer is not one of ['system', 'assistant', 'user', 'tool', 'function'] - 'messages.[0].role'";
expect(isOperatorActionableAgentError(message)).toBe(true);
expect(classifyError(message)).toBe("permanent");
});
it("returns false for transient network errors", () => {
expect(isOperatorActionableAgentError("socket hang up")).toBe(false);
expect(isOperatorActionableAgentError("upstream connect error")).toBe(false);

View File

@@ -59,6 +59,7 @@ import { resolvePermanentAgentToolDecision } from "./permanent-agent-gating.js";
import type { SystemPromptLayers } from "./prompt-layers.js";
import { READONLY_ALLOWLIST, filterCustomToolsForReadonly, isReadonlyAllowed } from "./workflow-step-tool-policy.js";
import { createStreamingDeltaNormalizer } from "./streaming-delta.js";
import { isUnsupportedMessageRoleError } from "./transient-error-detector.js";
const RTK_ACCEPTED_REWRITE_EXIT_CODES = new Set([0, 3]);
const RTK_EXPECTED_PASSTHROUGH_EXIT_CODES = new Set([1, 2]);
@@ -388,6 +389,13 @@ export async function promptSessionAndCheck(session: AgentSession, prompt: strin
piLog.warn(`pi state error — Codex WebSocket transport drop (model=${modelDesc}): ${stateError}`);
throw new Error(`${stateError} (model=${modelDesc})`);
}
if (isUnsupportedMessageRoleError(stateError)) {
const modelDesc = describeModel(session);
const hint =
"Operator action required: this agent's configured model/provider rejected a message role. Check the agent model selection and provider compatibility (imported non-default 'company' agents may default to an incompatible model+provider combination).";
piLog.error(`pi state error — unsupported message role (model=${modelDesc}): ${stateError}`);
throw new Error(`${stateError} (model=${modelDesc}). ${hint}`);
}
throw new Error(stateError);
}
}

View File

@@ -191,6 +191,15 @@ export function extractMissingModulePath(errorMessage: string): string | null {
return match[1];
}
const UNSUPPORTED_MESSAGE_ROLE_PATTERN = /\bmessages\.\[\d+\]\.role\b[\s\S]*\bis not one of\b|\bis not one of\b[\s\S]*\bmessages\.\[\d+\]\.role\b/i;
export function isUnsupportedMessageRoleError(errorMessage: string): boolean {
if (!errorMessage || typeof errorMessage !== "string") {
return false;
}
return UNSUPPORTED_MESSAGE_ROLE_PATTERN.test(errorMessage);
}
const OPERATOR_ACTIONABLE_AGENT_ERROR_PATTERNS: RegExp[] = [
/invalid api key/i,
/authentication failed/i,
@@ -210,5 +219,8 @@ export function isOperatorActionableAgentError(errorMessage: string): boolean {
if (!errorMessage || typeof errorMessage !== "string") {
return false;
}
return OPERATOR_ACTIONABLE_AGENT_ERROR_PATTERNS.some((pattern) => pattern.test(errorMessage));
return (
isUnsupportedMessageRoleError(errorMessage) ||
OPERATOR_ACTIONABLE_AGENT_ERROR_PATTERNS.some((pattern) => pattern.test(errorMessage))
);
}