Skip to content

Commit cc76c2f

Browse files
MkDev11steipete
authored andcommitted
fix(openai-codex): avoid stale Responses replay state
1 parent ab03267 commit cc76c2f

6 files changed

Lines changed: 273 additions & 14 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -400,6 +400,7 @@ Docs: https://docs.openclaw.ai
400400
- Plugins/config: deduplicate identical manifest compatibility diagnostics when an explicitly configured plugin overrides another discovered candidate, so external channel plugins do not print the same missing `channelConfigs` warning repeatedly during install and enable. Thanks @vincentkoc.
401401
- Discord/status: honor explicit `messages.statusReactions.enabled: true` in tool-only guild channels so queued ack reactions can progress through thinking/done lifecycle reactions instead of stopping at the initial emoji. Thanks @Marvinthebored.
402402
- Discord/native commands: compare Discord-normalized slash-command descriptions and localized descriptions during reconcile so CJK or multiline command text no longer triggers redundant startup PATCH bursts and rate-limit 429s. Fixes #76587. Thanks @zhengsx.
403+
- Agents/OpenAI Codex: scope ChatGPT Codex Responses request identity to each turn, strip the unsupported native Codex `prompt_cache_key`, and avoid replaying prior Responses reasoning/message/function item IDs so tool-call turns do not feed stale state into later Telegram replies. Refs #76413.
403404
- Agents/OpenAI: omit Chat Completions `reasoning_effort` for `gpt-5.4-mini` only when function tools are present while preserving tool-free Chat and Responses reasoning support, preventing Telegram-routed fallback runs from hanging after OpenAI rejects tool payloads. Fixes #76176. Thanks @ThisIsAdilah and @chinar-amrutkar.
404405
- Telegram: reuse the successful startup `getMe` probe for grammY polling startup and continue into `getUpdates` after recoverable `deleteWebhook` cleanup failures, reducing high-latency Bot API control-plane calls before long polling starts. Refs #76388. Thanks @jackiedepp.
405406
- Gateway/diagnostics: merge session id/key aliases in diagnostic session state and activity tracking so completed runs no longer leave stale queued work behind that keeps liveness samples at warning level.

docs/reference/transcript-hygiene.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,7 @@ inter-session user turns that only have provenance metadata.
117117
- Image sanitization only.
118118
- Drop orphaned reasoning signatures (standalone reasoning items without a following content block) for OpenAI Responses/Codex transcripts, and drop replayable OpenAI reasoning after a model route switch.
119119
- Preserve replayable OpenAI Responses reasoning item payloads, including encrypted empty-summary items, so manual/WebSocket replay keeps required `rs_*` state paired with assistant output items.
120+
- Native ChatGPT Codex Responses is the exception: OpenClaw does not replay prior Responses reasoning/message/function item IDs or session `prompt_cache_key` to avoid stale backend replay across turns.
120121
- No tool call id sanitization.
121122
- Tool result pairing repair may move real matched outputs and synthesize Codex-style `aborted` outputs for missing tool calls.
122123
- No turn validation or reordering.

extensions/openai/transport-policy.test.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,32 @@ describe("openai transport policy", () => {
6767
).toBeUndefined();
6868
});
6969

70+
it("uses turn-scoped request identity for ChatGPT Codex stream turns", () => {
71+
expect(
72+
resolveOpenAITransportTurnState({
73+
provider: "openai-codex",
74+
modelId: "gpt-5.4",
75+
model: {
76+
...nativeModel,
77+
provider: "openai-codex",
78+
api: "openai-codex-responses",
79+
baseUrl: "https://chatgpt.com/backend-api",
80+
},
81+
sessionId: "session-123",
82+
turnId: "turn-123",
83+
attempt: 2,
84+
transport: "stream",
85+
}),
86+
).toMatchObject({
87+
headers: {
88+
"x-client-request-id": "turn-123",
89+
"x-openclaw-session-id": "session-123",
90+
"x-openclaw-turn-id": "turn-123",
91+
"x-openclaw-turn-attempt": "2",
92+
},
93+
});
94+
});
95+
7096
it("returns websocket session headers and cooldown for native routes", () => {
7197
expect(
7298
resolveOpenAIWebSocketSessionPolicy({

extensions/openai/transport-policy.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,13 @@ function usesKnownNativeOpenAIRoute(provider: string, baseUrl?: string): boolean
4646
return false;
4747
}
4848

49+
function usesNativeOpenAICodexRoute(provider: string, baseUrl?: string): boolean {
50+
const normalizedProvider = normalizeProviderId(provider);
51+
return (
52+
normalizedProvider === OPENAI_CODEX_PROVIDER_ID && (!baseUrl || isOpenAICodexBaseUrl(baseUrl))
53+
);
54+
}
55+
4956
function resolveSessionHeaders(params: {
5057
provider: string;
5158
baseUrl?: string;
@@ -78,10 +85,14 @@ export function resolveOpenAITransportTurnState(
7885

7986
const turnId = normalizeIdentityValue(ctx.turnId);
8087
const attempt = String(Math.max(1, ctx.attempt));
88+
const requestId = usesNativeOpenAICodexRoute(ctx.provider, ctx.model?.baseUrl)
89+
? turnId || `${sessionHeaders["x-openclaw-session-id"] ?? "session"}:${attempt}`
90+
: sessionHeaders["x-client-request-id"];
8191

8292
return {
8393
headers: {
8494
...sessionHeaders,
95+
"x-client-request-id": requestId,
8596
"x-openclaw-turn-id": turnId,
8697
"x-openclaw-turn-attempt": attempt,
8798
},

src/agents/openai-transport-stream.test.ts

Lines changed: 199 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1059,7 +1059,7 @@ describe("openai transport stream", () => {
10591059
expect(params.input?.some((item) => item.role === "system" || item.role === "developer")).toBe(
10601060
false,
10611061
);
1062-
expect(params.prompt_cache_key).toBe("session-123");
1062+
expect(params).not.toHaveProperty("prompt_cache_key");
10631063
expect(params.store).toBe(false);
10641064
expect(params).not.toHaveProperty("metadata");
10651065
expect(params).not.toHaveProperty("max_output_tokens");
@@ -1097,7 +1097,7 @@ describe("openai transport stream", () => {
10971097
payload,
10981098
);
10991099

1100-
expect(sanitized.prompt_cache_key).toBe("session-123");
1100+
expect(sanitized).not.toHaveProperty("prompt_cache_key");
11011101
expect(sanitized).not.toHaveProperty("metadata");
11021102
expect(sanitized).not.toHaveProperty("max_output_tokens");
11031103
expect(sanitized).not.toHaveProperty("prompt_cache_retention");
@@ -1178,6 +1178,197 @@ describe("openai transport stream", () => {
11781178
expect(sanitized).toEqual(payload);
11791179
});
11801180

1181+
it("omits prior Responses replay item ids for native Codex responses", () => {
1182+
const params = buildOpenAIResponsesParams(
1183+
{
1184+
id: "gpt-5.4",
1185+
name: "GPT-5.4",
1186+
api: "openai-codex-responses",
1187+
provider: "openai-codex",
1188+
baseUrl: "https://chatgpt.com/backend-api",
1189+
reasoning: true,
1190+
input: ["text"],
1191+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
1192+
contextWindow: 200000,
1193+
maxTokens: 8192,
1194+
} satisfies Model<"openai-codex-responses">,
1195+
{
1196+
systemPrompt: "system",
1197+
messages: [
1198+
{
1199+
role: "assistant",
1200+
api: "openai-codex-responses",
1201+
provider: "openai-codex",
1202+
model: "gpt-5.4",
1203+
usage: {
1204+
input: 0,
1205+
output: 0,
1206+
cacheRead: 0,
1207+
cacheWrite: 0,
1208+
totalTokens: 0,
1209+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
1210+
},
1211+
stopReason: "toolUse",
1212+
timestamp: 1,
1213+
content: [
1214+
{
1215+
type: "thinking",
1216+
thinking: "Need a tool.",
1217+
thinkingSignature: JSON.stringify({
1218+
type: "reasoning",
1219+
id: "rs_prior",
1220+
encrypted_content: "ciphertext",
1221+
}),
1222+
},
1223+
{
1224+
type: "text",
1225+
text: "Checking the price.",
1226+
textSignature: JSON.stringify({
1227+
v: 1,
1228+
id: "msg_prior",
1229+
phase: "commentary",
1230+
}),
1231+
},
1232+
{
1233+
type: "toolCall",
1234+
id: "call_abc|fc_prior",
1235+
name: "price_lookup",
1236+
arguments: { symbol: "SOL" },
1237+
},
1238+
],
1239+
},
1240+
{
1241+
role: "toolResult",
1242+
toolCallId: "call_abc|fc_prior",
1243+
toolName: "price_lookup",
1244+
content: [{ type: "text", text: "$83.95" }],
1245+
isError: false,
1246+
timestamp: 2,
1247+
},
1248+
{ role: "user", content: "what is the capital of the philippines", timestamp: 3 },
1249+
],
1250+
tools: [],
1251+
} as never,
1252+
{ sessionId: "session-123" },
1253+
) as {
1254+
input?: Array<{
1255+
type?: string;
1256+
role?: string;
1257+
id?: string;
1258+
call_id?: string;
1259+
phase?: string;
1260+
}>;
1261+
};
1262+
1263+
expect(params.input?.some((item) => item.type === "reasoning")).toBe(false);
1264+
const assistantMessage = params.input?.find(
1265+
(item) => item.type === "message" && item.role === "assistant",
1266+
);
1267+
expect(assistantMessage).toMatchObject({
1268+
type: "message",
1269+
role: "assistant",
1270+
phase: "commentary",
1271+
});
1272+
expect(assistantMessage?.id).toBeUndefined();
1273+
const functionCall = params.input?.find((item) => item.type === "function_call");
1274+
expect(functionCall).toMatchObject({
1275+
type: "function_call",
1276+
call_id: "call_abc",
1277+
});
1278+
expect(functionCall?.id).toBeUndefined();
1279+
});
1280+
1281+
it("preserves prior Responses replay item ids for custom Codex-compatible responses", () => {
1282+
const params = buildOpenAIResponsesParams(
1283+
{
1284+
id: "gpt-5.4",
1285+
name: "GPT-5.4",
1286+
api: "openai-codex-responses",
1287+
provider: "openai-codex",
1288+
baseUrl: "https://proxy.example.com/v1",
1289+
reasoning: true,
1290+
input: ["text"],
1291+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
1292+
contextWindow: 200000,
1293+
maxTokens: 8192,
1294+
} satisfies Model<"openai-codex-responses">,
1295+
{
1296+
systemPrompt: "system",
1297+
messages: [
1298+
{
1299+
role: "assistant",
1300+
api: "openai-codex-responses",
1301+
provider: "openai-codex",
1302+
model: "gpt-5.4",
1303+
usage: {
1304+
input: 0,
1305+
output: 0,
1306+
cacheRead: 0,
1307+
cacheWrite: 0,
1308+
totalTokens: 0,
1309+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
1310+
},
1311+
stopReason: "toolUse",
1312+
timestamp: 1,
1313+
content: [
1314+
{
1315+
type: "thinking",
1316+
thinking: "Need a tool.",
1317+
thinkingSignature: JSON.stringify({
1318+
type: "reasoning",
1319+
id: "rs_prior",
1320+
encrypted_content: "ciphertext",
1321+
}),
1322+
},
1323+
{
1324+
type: "text",
1325+
text: "Checking the price.",
1326+
textSignature: JSON.stringify({
1327+
v: 1,
1328+
id: "msg_prior",
1329+
phase: "commentary",
1330+
}),
1331+
},
1332+
{
1333+
type: "toolCall",
1334+
id: "call_abc|fc_prior",
1335+
name: "price_lookup",
1336+
arguments: { symbol: "SOL" },
1337+
},
1338+
],
1339+
},
1340+
],
1341+
tools: [],
1342+
} as never,
1343+
{ sessionId: "session-123" },
1344+
) as {
1345+
input?: Array<{
1346+
type?: string;
1347+
role?: string;
1348+
id?: string;
1349+
call_id?: string;
1350+
phase?: string;
1351+
}>;
1352+
};
1353+
1354+
expect(params.input?.some((item) => item.type === "reasoning")).toBe(true);
1355+
const assistantMessage = params.input?.find(
1356+
(item) => item.type === "message" && item.role === "assistant",
1357+
);
1358+
expect(assistantMessage).toMatchObject({
1359+
type: "message",
1360+
role: "assistant",
1361+
id: "msg_prior",
1362+
phase: "commentary",
1363+
});
1364+
const functionCall = params.input?.find((item) => item.type === "function_call");
1365+
expect(functionCall).toMatchObject({
1366+
type: "function_call",
1367+
id: "fc_prior",
1368+
call_id: "call_abc",
1369+
});
1370+
});
1371+
11811372
it("adds minimal user input for Codex responses when only the system prompt is present", () => {
11821373
const params = buildOpenAIResponsesParams(
11831374
{
@@ -1492,7 +1683,7 @@ describe("openai transport stream", () => {
14921683
baseUrl: "https://proxy.example.com/v1",
14931684
},
14941685
},
1495-
])("replays assistant phase metadata for $label responses payloads", ({ model }) => {
1686+
])("replays assistant phase metadata for $label responses payloads", ({ label, model }) => {
14961687
const params = buildOpenAIResponsesParams(
14971688
{
14981689
...model,
@@ -1548,9 +1739,13 @@ describe("openai transport stream", () => {
15481739
const assistantItem = params.input?.find((item) => item.role === "assistant");
15491740
expect(assistantItem).toMatchObject({
15501741
role: "assistant",
1551-
id: "msg_commentary",
15521742
phase: "commentary",
15531743
});
1744+
if (label === "openai-codex") {
1745+
expect(assistantItem?.id).toBeUndefined();
1746+
} else {
1747+
expect(assistantItem?.id).toBe("msg_commentary");
1748+
}
15541749
});
15551750

15561751
it("strips the internal cache boundary from OpenAI system prompts", () => {

0 commit comments

Comments
 (0)