Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 32 additions & 7 deletions src/ServiceErrorMessage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,20 @@ const SERVICE_ERROR_TYPES = new Set([
"overloaded_error",
]);

/** Reads the message of a known service error envelope. Other text stays unchanged. */
const INVALID_ENCRYPTED_CONTENT_CODE = "invalid_encrypted_content";

// Codex resends the encrypted reasoning of earlier turns, and only the provider that encrypted it can read it.
const INVALID_ENCRYPTED_CONTENT_MESSAGE = "The current provider can't read this thread's earlier reasoning, "
+ "which another provider or account encrypted. This happens after the provider changes in a thread. "
+ "Switch back to the previous provider or start a new thread.";

/**
* Reads the message of a known service error envelope, or adds a human-readable explanation.
*/
export function readableServiceErrorMessage(text: string): string {
let value: unknown;
try {
value = JSON.parse(text);
} catch {
return text;
}
const value = parseJson(text);
if (value === undefined) return text;
if (isInvalidEncryptedContentError(value)) return INVALID_ENCRYPTED_CONTENT_MESSAGE;
if (!isObject(value)
|| Object.keys(value).some(key => !["type", "status", "error"].includes(key))
|| value["type"] !== "error"
Expand All @@ -34,6 +40,25 @@ export function readableServiceErrorMessage(text: string): string {
return error["message"];
}

/** A provider error `{error: {code}}`, or a gateway error that carries it in `metadata`. */
function isInvalidEncryptedContentError(value: unknown): boolean {
if (!isObject(value) || !isObject(value["error"])) return false;
const error = value["error"];
if (error["code"] === INVALID_ENCRYPTED_CONTENT_CODE) return true;
const metadata = error["metadata"];
if (!isObject(metadata)) return false;
return metadata["provider_error_code"] === INVALID_ENCRYPTED_CONTENT_CODE
|| typeof metadata["raw"] === "string" && isInvalidEncryptedContentError(parseJson(metadata["raw"]));
}

function parseJson(text: string): unknown {
try {
return JSON.parse(text);
} catch {
return undefined;
}
}

function isObject(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
30 changes: 30 additions & 0 deletions src/__tests__/CodexACPAgent/auth-error-events.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,22 @@ const modelRefusalEnvelope = JSON.stringify({
type: "error", status: 400, error: {type: "invalid_request_error", message: modelRefusalMessage},
});

// The 400 that an LLM gateway returns when the provider can't decrypt reasoning from an earlier turn.
const invalidEncryptedContentGatewayError = JSON.stringify({
error: {
message: "Provider returned error",
code: 400,
metadata: {
raw: JSON.stringify({error: {
message: "The encrypted content for item rs_1 could not be verified. Reason: Encrypted content could not be decrypted or parsed.",
type: "invalid_request_error", param: null, code: "invalid_encrypted_content",
}}, null, 2),
provider_name: "Azure", is_byok: false, provider_error_code: "invalid_encrypted_content",
},
},
user_id: "org_1",
});

describe("CodexEventHandler - auth error events", () => {
it.each([
{name: "terminal", retry: false, turnId: "turn-id"},
Expand Down Expand Up @@ -139,6 +155,20 @@ describe("CodexEventHandler - auth error events", () => {
expect(result).toMatchObject({data: {message: modelRefusalEnvelope, codexErrorInfo: "usageLimitExceeded"}});
});

it.each([
{name: "legacy error text", capabilities: undefined},
{name: "typed failure", capabilities: typedFailureCapabilities},
])("explains encrypted content from another provider in the $name", async ({capabilities}) => {
const {result, updates} = await runPromptWithError(createTestSessionState({
sessionId: "encrypted-content-session", account: {type: "apiKey"},
}), {
message: invalidEncryptedContentGatewayError, codexErrorInfo: "badRequest", additionalDetails: null, misalignment: null,
}, false, capabilities);
await expect(JSON.stringify({result, updates}, null, 2)).toMatchFileSnapshot(
`data/invalid-encrypted-content-v1-${capabilities ? "typed-failure" : "legacy"}.json`,
);
});

it.each([
"Plain error", "", "{invalid JSON}",
JSON.stringify({type: "error", status: 400, error: {type: "custom_error", message: "custom text"}}),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
{
"result": {
"stopReason": "end_turn",
"usage": null,
"_meta": {
"quota": {
"token_count": null,
"model_usage": []
}
}
},
"updates": [
{
"sessionUpdate": "agent_message_chunk",
"content": {
"type": "text",
"text": "The current provider can't read this thread's earlier reasoning, which another provider or account encrypted. This happens after the provider changes in a thread. Switch back to the previous provider or start a new thread.\n\n"
}
}
]
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
{
"result": {
"stopReason": "end_turn",
"usage": null,
"_meta": {
"quota": {
"token_count": null,
"model_usage": []
},
"jetbrains": {
"air": {
"version": 1,
"sessionFailure": {
"id": "turn-id:error",
"revision": 1,
"category": "request",
"severity": "error",
"title": "The current provider can't read this thread's earlier reasoning, which another provider or account encrypted. This happens after the provider changes in a thread. Switch back to the previous provider or start a new thread.",
"actions": []
}
}
}
}
},
"updates": []
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
[
{
"codexRequest": "turn/start",
"params": {
"threadId": "thread-1",
"input": [
{
"type": "text",
"text": "Hello",
"text_elements": []
}
],
"approvalPolicy": "on-request",
"approvalsReviewer": "auto_review",
"sandboxPolicy": {
"type": "workspaceWrite",
"writableRoots": [],
"networkAccess": false,
"excludeTmpdirEnvVar": false,
"excludeSlashTmp": false
},
"summary": "auto",
"effort": "medium",
"model": "gpt-5",
"serviceTier": null,
"clientUserMessageId": "<messageId>"
}
},
{
"codexResponse": "turn/start"
},
{
"sessionUpdate": {
"state": "running",
"sessionUpdate": "state_update"
}
},
{
"codexNotification": "turn/started"
},
{
"codexNotification": "item/completed"
},
{
"sessionUpdate": {
"messageId": "<messageId>",
"content": {
"text": "Hello",
"type": "text"
},
"sessionUpdate": "user_message_chunk"
}
},
{
"promptResponse": {
"messageId": "<messageId>"
}
},
{
"codexNotification": "error"
},
{
"codexNotification": "turn/completed"
},
{
"sessionUpdate": {
"messageId": "<agentMessageId>",
"content": {
"text": "The current provider can't read this thread's earlier reasoning, which another provider or account encrypted. This happens after the provider changes in a thread. Switch back to the previous provider or start a new thread.\n\n",
"type": "text"
},
"sessionUpdate": "agent_message_chunk"
}
},
{
"sessionUpdate": {
"title": "Hello",
"sessionUpdate": "session_info_update"
}
},
{
"sessionUpdate": {
"error": {
"code": -32603,
"message": "The current provider can't read this thread's earlier reasoning, which another provider or account encrypted. This happens after the provider changes in a thread. Switch back to the previous provider or start a new thread."
},
"stopReason": "error",
"_meta": {
"quota": {
"token_count": null,
"model_usage": []
}
},
"state": "idle",
"sessionUpdate": "state_update"
}
}
]
25 changes: 25 additions & 0 deletions src/__tests__/CodexACPAgent/prompt-v2.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,31 @@ describe('session/prompt over ACP v2', () => {
await expect(dump(results)).toMatchFileSnapshot('data/prompt-v2-turn-error-after-insertion.json');
});

it('explains encrypted content from another provider instead of the raw gateway error', async () => {
const client = await connectSession();
closeClient = () => client.connection.close();

const start = client.transcript.length;
const messageId = await insertPrompt(client, 0);
client.emit(turnError("badRequest", JSON.stringify({
error: {
message: "Provider returned error",
code: 400,
metadata: {
raw: JSON.stringify({error: {message: "The encrypted content for item rs_1 could not be verified.", code: "invalid_encrypted_content"}}),
provider_name: "Azure",
provider_error_code: "invalid_encrypted_content",
},
},
})));
client.emit(turnFinished("failed"));
await client.promptRunFinished();
await settle();

await expect(dump(stableTranscript(client.transcript.slice(start), messageId)))
.toMatchFileSnapshot('data/prompt-v2-invalid-encrypted-content.json');
});

it('puts a typed terminal failure on the idle _meta, not in a session update', async () => {
const client = await connectSession(2, {clientCapabilities: typedFailureCapabilities});
closeClient = () => client.connection.close();
Expand Down
32 changes: 32 additions & 0 deletions src/__tests__/ServiceErrorMessage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,38 @@ describe("service error messages", () => {
},
);

describe("encrypted content from another provider", () => {
const providerError = {
message: "The encrypted content for item rs_1 could not be verified. Reason: Encrypted content could not be decrypted or parsed.",
type: "invalid_request_error",
param: null,
code: "invalid_encrypted_content",
};

it.each([
["the provider error", {error: providerError}],
["the Codex service envelope", {type: "error", status: 400, error: providerError}],
["a gateway error with the raw provider error", {
error: {message: "Provider returned error", code: 400, metadata: {raw: JSON.stringify({error: providerError}, null, 2)}},
user_id: "org_1",
}],
["a gateway error with the provider error code", {
error: {message: "Provider returned error", code: 400, metadata: {provider_name: "Azure", is_byok: false, provider_error_code: "invalid_encrypted_content"}},
}],
])("explains %s", async (_name, error) => {
await expect(readableServiceErrorMessage(JSON.stringify(error)))
.toMatchFileSnapshot("data/service-error-invalid-encrypted-content.txt");
});

it.each([
JSON.stringify({error: {...providerError, code: "other_code"}}),
JSON.stringify({error: {message: "Provider returned error", metadata: {raw: "{not json}"}}}),
JSON.stringify({error: {message: "Provider returned error", metadata: {raw: JSON.stringify({error: {code: "other_code"}})}}}),
])("keeps other provider errors verbatim: %s", text => {
expect(readableServiceErrorMessage(text)).toBe(text);
});
});

it.each([
"Plain error text", "", "{not json}", "null", "[]", '"json string"',
JSON.stringify({error: {message}}),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
The current provider can't read this thread's earlier reasoning, which another provider or account encrypted. This happens after the provider changes in a thread. Switch back to the previous provider or start a new thread.
Loading