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