Skip to content

Commit 56b251c

Browse files
authored
🤖 Fix: web_search_call reasoning error - always strip item IDs (#84)
This PR fixes a critical bug in the OpenAI reasoning middleware where item IDs were only being stripped when a message contained reasoning parts. ## Problem The middleware had a condition `if (hasReasoning && ...)` that prevented stripping `providerOptions.openai` from parts in messages that didn't contain reasoning. This caused the error: ``` Item 'ws_*' of type 'web_search_call' was provided without its required 'reasoning' item: 'rs_*' ``` ## Root Cause Multi-step execution scenario: 1. **Message 1**: Has `reasoning + web_search_call` - Middleware strips reasoning → `hasReasoning = true` - Strips itemId from web_search_call ✅ 2. **Message 2**: Has ONLY `web_search_call` (no reasoning in this message) - `hasReasoning = false` - Does NOT strip itemId ❌ 3. OpenAI rejects message 2 because the itemId references a reasoning item that doesn't exist ## Solution Changed the condition from: ```typescript if (hasReasoning && typeof part === 'object' && part !== null) { ``` To: ```typescript if (typeof part === 'object' && part !== null) { ``` Now the middleware **always** strips `providerOptions.openai` from any part that has it, regardless of whether the message contains reasoning. This is correct because OpenAI manages all response context via the `previousResponseId` parameter, not via message content. ## Testing - ✅ Manually verified the fix logic - ✅ Type checking passes - ✅ Affects: web_search_call, tool-call, and any other OpenAI-specific part types ## Related - Fixes the web_search_call error reported after PR #77 was merged - Completes the reasoning error fix from PR #77 _Generated with `cmux`_
1 parent a0c670e commit 56b251c

File tree

2 files changed

+232
-4
lines changed

2 files changed

+232
-4
lines changed

src/debug/replay-history.ts

Lines changed: 228 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,228 @@
1+
#!/usr/bin/env bun
2+
3+
/**
4+
* Debug script to replay a chat history and send a new message.
5+
* Useful for reproducing errors with specific conversation contexts.
6+
*
7+
* Usage:
8+
* bun src/debug/replay-history.ts <history-file.json> <message> [--model <model>]
9+
*
10+
* Example:
11+
* bun src/debug/replay-history.ts /tmp/chat-broken.json "test message" --model openai:gpt-5-codex
12+
*/
13+
14+
import * as fs from "fs";
15+
import * as path from "path";
16+
import { parseArgs } from "util";
17+
import { defaultConfig } from "@/config";
18+
import type { CmuxMessage } from "@/types/message";
19+
import { createCmuxMessage } from "@/types/message";
20+
import { AIService } from "@/services/aiService";
21+
import { HistoryService } from "@/services/historyService";
22+
import { PartialService } from "@/services/partialService";
23+
24+
const { positionals, values } = parseArgs({
25+
args: process.argv.slice(2),
26+
options: {
27+
model: { type: "string", short: "m" },
28+
thinking: { type: "string", short: "t" },
29+
},
30+
allowPositionals: true,
31+
});
32+
33+
const historyFile = positionals[0];
34+
const messageText = positionals[1];
35+
36+
if (!historyFile || !messageText) {
37+
console.error(
38+
"Usage: bun src/debug/replay-history.ts <history-file.json> <message> [--model <model>]"
39+
);
40+
console.error(
41+
"Example: bun src/debug/replay-history.ts /tmp/chat-broken.json 'test' --model openai:gpt-5-codex"
42+
);
43+
process.exit(1);
44+
}
45+
46+
if (!fs.existsSync(historyFile)) {
47+
console.error(`❌ History file not found: ${historyFile}`);
48+
process.exit(1);
49+
}
50+
51+
async function main() {
52+
console.log(`\n=== Replay History Debug Tool ===\n`);
53+
console.log(`History file: ${historyFile}`);
54+
console.log(`Message: ${messageText}`);
55+
console.log(`Model: ${values.model ?? "default (openai:gpt-5-codex)"}\n`);
56+
57+
// Read history
58+
const historyContent = fs.readFileSync(historyFile, "utf-8");
59+
let messages: CmuxMessage[];
60+
61+
try {
62+
// Try parsing as JSON array first
63+
messages = JSON.parse(historyContent) as CmuxMessage[];
64+
if (!Array.isArray(messages)) {
65+
messages = [messages];
66+
}
67+
} catch {
68+
// Try parsing as JSONL
69+
messages = historyContent
70+
.split("\n")
71+
.filter((line) => line.trim())
72+
.map((line) => JSON.parse(line) as CmuxMessage);
73+
}
74+
75+
console.log(`📝 Loaded ${messages.length} messages from history\n`);
76+
77+
// Display summary
78+
for (const msg of messages) {
79+
const preview =
80+
msg.role === "user"
81+
? (msg.parts.find((p) => p.type === "text")?.text?.substring(0, 60) ?? "")
82+
: `[${msg.parts.length} parts: ${msg.parts.map((p) => p.type).join(", ")}]`;
83+
const model = msg.metadata?.model ?? "unknown";
84+
console.log(` ${msg.role.padEnd(9)} (${model}): ${preview}`);
85+
}
86+
87+
// Create a temporary workspace
88+
const workspaceId = `debug-replay-${Date.now()}`;
89+
const sessionDir = defaultConfig.getSessionDir(workspaceId);
90+
fs.mkdirSync(sessionDir, { recursive: true });
91+
92+
// Create workspace metadata
93+
const metadataPath = path.join(sessionDir, "metadata.json");
94+
fs.writeFileSync(
95+
metadataPath,
96+
JSON.stringify({
97+
id: workspaceId,
98+
projectName: "debug",
99+
workspacePath: `/tmp/${workspaceId}`,
100+
})
101+
);
102+
103+
const chatHistoryPath = path.join(sessionDir, "chat.jsonl");
104+
105+
// Write history to temp workspace
106+
const historyLines = messages.map((m) => JSON.stringify({ ...m, workspaceId })).join("\n");
107+
fs.writeFileSync(chatHistoryPath, historyLines + "\n");
108+
109+
console.log(`\n✓ Created temporary workspace: ${workspaceId}`);
110+
111+
// Add new user message to the history
112+
const userMessage = createCmuxMessage(
113+
`user-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
114+
"user",
115+
messageText,
116+
{ timestamp: Date.now(), historySequence: messages.length }
117+
);
118+
messages.push(userMessage);
119+
120+
console.log(`\n📤 Sending message: "${messageText}"\n`);
121+
122+
// Initialize services - AIService creates its own StreamManager
123+
const config = defaultConfig;
124+
const historyService = new HistoryService(config);
125+
const partialService = new PartialService(config, historyService);
126+
const aiService = new AIService(config, historyService, partialService);
127+
128+
const modelString = values.model ?? "openai:gpt-5-codex";
129+
const thinkingLevel = (values.thinking ?? "high") as "low" | "medium" | "high";
130+
131+
try {
132+
// Stream the message - pass all messages including the new one
133+
const result = await aiService.streamMessage(messages, workspaceId, modelString, thinkingLevel);
134+
135+
if (!result.success) {
136+
console.error(`\n❌ Error:`, JSON.stringify(result.error, null, 2));
137+
process.exit(1);
138+
}
139+
140+
console.log(`✓ Stream started`);
141+
142+
// Wait for stream to complete
143+
console.log(`\n⏳ Waiting for stream to complete...\n`);
144+
145+
// Subscribe to stream events
146+
let hasError = false;
147+
let errorMessage = "";
148+
149+
interface StreamEvent {
150+
workspaceId: string;
151+
type: string;
152+
toolName?: string;
153+
error?: string;
154+
}
155+
156+
aiService.on("stream-event", (event: StreamEvent) => {
157+
if (event.workspaceId !== workspaceId) return;
158+
159+
if (event.type === "stream-start") {
160+
console.log(`[${event.type}] Started`);
161+
} else if (event.type === "reasoning-delta" || event.type === "text-delta") {
162+
// Don't log every delta, too verbose
163+
} else if (event.type === "reasoning-end") {
164+
console.log(`[${event.type}] Reasoning complete`);
165+
} else if (event.type === "tool-call-start") {
166+
console.log(`[${event.type}] Tool: ${event.toolName ?? "unknown"}`);
167+
} else if (event.type === "tool-call-end") {
168+
console.log(`[${event.type}] Tool complete`);
169+
} else if (event.type === "stream-end") {
170+
console.log(`[${event.type}] Stream complete`);
171+
} else if (event.type === "stream-error") {
172+
console.error(`\n❌ [${event.type}] ${event.error ?? "unknown error"}`);
173+
errorMessage = event.error ?? "";
174+
hasError = true;
175+
} else {
176+
console.log(`[${event.type}]`);
177+
}
178+
});
179+
180+
// Wait for completion
181+
await new Promise<void>((resolve) => {
182+
const checkInterval = setInterval(() => {
183+
const streamManager = (
184+
aiService as unknown as {
185+
streamManager: { workspaceStreams: Map<string, { state: string }> };
186+
}
187+
).streamManager;
188+
const stream = streamManager.workspaceStreams.get(workspaceId);
189+
if (!stream || stream.state === "completed" || stream.state === "error") {
190+
clearInterval(checkInterval);
191+
resolve();
192+
}
193+
}, 100);
194+
195+
// Timeout after 2 minutes
196+
setTimeout(() => {
197+
clearInterval(checkInterval);
198+
resolve();
199+
}, 120000);
200+
});
201+
202+
if (hasError) {
203+
console.log(`\n❌ Stream encountered an error:`);
204+
console.log(errorMessage);
205+
206+
// Check if it's the web_search_call error
207+
if (errorMessage.includes("web_search_call") && errorMessage.includes("reasoning")) {
208+
console.log(`\n🎯 Reproduced the web_search_call + reasoning error!`);
209+
}
210+
211+
process.exit(1);
212+
}
213+
214+
console.log(`\n✅ Stream completed successfully!`);
215+
} catch (error) {
216+
console.error(`\n❌ Exception:`, error);
217+
process.exit(1);
218+
} finally {
219+
// Cleanup
220+
console.log(`\n🧹 Cleaning up temporary workspace...`);
221+
fs.rmSync(sessionDir, { recursive: true, force: true });
222+
}
223+
}
224+
225+
main().catch((error) => {
226+
console.error("Fatal error:", error);
227+
process.exit(1);
228+
});

src/utils/ai/openaiReasoningMiddleware.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ export const openaiReasoningFixMiddleware: LanguageModelV2Middleware = {
4848
// Filter out reasoning content from assistant messages
4949
if (Array.isArray(message.content)) {
5050
// Check if this message contains reasoning
51-
const hasReasoning = message.content.some(
51+
const _hasReasoning = message.content.some(
5252
(part) =>
5353
typeof part === "object" &&
5454
part !== null &&
@@ -65,9 +65,9 @@ export const openaiReasoningFixMiddleware: LanguageModelV2Middleware = {
6565
return true;
6666
})
6767
.map((part) => {
68-
// If we filtered out reasoning from this message, also strip OpenAI item IDs
69-
// from remaining parts to avoid dangling references
70-
if (hasReasoning && typeof part === "object" && part !== null) {
68+
// Always strip OpenAI item IDs from parts that have them
69+
// OpenAI manages these via previousResponseId, not via message content
70+
if (typeof part === "object" && part !== null) {
7171
// Check if part has providerOptions.openai.itemId
7272
const partObj = part as unknown as Record<string, unknown>;
7373
if (

0 commit comments

Comments
 (0)