Skip to content

Commit 58fd6ae

Browse files
committed
feat: v4.6.0 - Context overflow handling and missing tool result injection
- Context Overflow Handler: Gracefully handles 'prompt too long' errors by returning synthetic SSE response with /compact, /clear, /undo suggestions - Missing Tool Result Injection: Detects orphaned function_call items and injects 'Operation cancelled by user' to prevent API errors - 34 new unit tests (379 total) Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)
1 parent bd01201 commit 58fd6ae

8 files changed

Lines changed: 532 additions & 11 deletions

File tree

CHANGELOG.md

Lines changed: 28 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,15 +8,27 @@ All notable changes to this project are documented here. Dates use the ISO forma
88

99
### Changed
1010

11-
## [4.3.1] - 2026-01-23
11+
## [4.6.0] - 2026-01-25
12+
13+
**Feature release**: Context overflow handling and missing tool result injection.
1214

1315
### Added
14-
- `openai-accounts-status --json` for scriptable status output with email/ID labels.
16+
- **Context Overflow Handler**: Gracefully handles "prompt too long" / context length exceeded errors:
17+
- Returns synthetic SSE response with helpful instructions instead of raw 400 error
18+
- Suggests `/compact`, `/clear`, or `/undo` commands to reduce context size
19+
- Prevents OpenCode session from getting locked on context overflow
20+
- New module: `lib/context-overflow.ts`
21+
- **Missing Tool Result Injection**: Automatically handles cancelled tool calls (ESC mid-execution):
22+
- Detects orphaned `function_call` items (calls without matching outputs)
23+
- Injects synthetic output: `"Operation cancelled by user"`
24+
- Prevents "missing tool_result" API errors when user cancels mid-tool
25+
- New function: `injectMissingToolOutputs()` in `lib/request/helpers/input-utils.ts`
26+
- **34 new unit tests** for context overflow and tool injection (now 379 total tests)
1527

16-
### Changed
17-
- Account labels now prefer email and show ID suffix when available; list/status outputs are columnized for readability.
18-
- Stored account emails are trimmed/lowercased when present.
19-
- Dependency refresh: @opencode-ai plugin/sdk 1.1.34, hono 4.11.5, vitest 4.0.18, @types/node 25.0.10, @typescript-eslint 8.53.1.
28+
### Technical Details
29+
- Context overflow detection matches patterns: `prompt_too_long`, `context_length_exceeded`, `maximum context length`, `token limit exceeded`, `too many tokens`
30+
- Synthetic SSE response includes proper message_start/content_block_delta/message_stop events
31+
- Tool injection preserves message order: outputs are placed immediately after their calls
2032

2133
## [4.5.0] - 2026-01-25
2234

@@ -48,6 +60,16 @@ All notable changes to this project are documented here. Dates use the ISO forma
4860
- Stale entries (>30s) are automatically cleaned up to prevent memory leaks
4961
- Auto-update check is non-blocking and fails silently to avoid disrupting plugin operation
5062

63+
## [4.3.1] - 2026-01-23
64+
65+
### Added
66+
- `openai-accounts-status --json` for scriptable status output with email/ID labels.
67+
68+
### Changed
69+
- Account labels now prefer email and show ID suffix when available; list/status outputs are columnized for readability.
70+
- Stored account emails are trimmed/lowercased when present.
71+
- Dependency refresh: @opencode-ai plugin/sdk 1.1.34, hono 4.11.5, vitest 4.0.18, @types/node 25.0.10, @typescript-eslint 8.53.1.
72+
5173
## [4.4.0] - 2026-01-25
5274

5375
**Feature release**: Intelligent rate-limit rotation with health-based account selection.

index.ts

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ import {
5555
} from "./lib/constants.js";
5656
import { logRequest, logDebug } from "./lib/logger.js";
5757
import { checkAndNotify } from "./lib/auto-update-checker.js";
58+
import { handleContextOverflow } from "./lib/context-overflow.js";
5859
import {
5960
AccountManager,
6061
extractAccountEmail,
@@ -660,10 +661,16 @@ export const OpenAIAuthPlugin: Plugin = async ({ client }: PluginInput) => {
660661
headers: Object.fromEntries(response.headers.entries()),
661662
});
662663

663-
if (!response.ok) {
664-
const { response: errorResponse, rateLimit } =
665-
await handleErrorResponse(response);
666-
if (rateLimit) {
664+
if (!response.ok) {
665+
// Check for context overflow (400 "prompt too long") before other error handling
666+
const contextOverflowResult = await handleContextOverflow(response, model);
667+
if (contextOverflowResult.handled) {
668+
return contextOverflowResult.response;
669+
}
670+
671+
const { response: errorResponse, rateLimit } =
672+
await handleErrorResponse(response);
673+
if (rateLimit) {
667674
const { attempt, delayMs } = getRateLimitBackoff(
668675
account.index,
669676
quotaKey,

lib/context-overflow.ts

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
/**
2+
* Context Overflow Handler
3+
*
4+
* Handles "Prompt too long" / context length exceeded errors by returning
5+
* a synthetic SSE response that advises the user to use /compact or /clear.
6+
* This prevents the OpenCode session from getting locked on 400 errors.
7+
*/
8+
9+
import { PLUGIN_NAME } from "./constants.js";
10+
11+
/**
12+
* Error patterns that indicate context overflow
13+
*/
14+
const CONTEXT_OVERFLOW_PATTERNS = [
15+
"prompt is too long",
16+
"prompt_too_long",
17+
"context length exceeded",
18+
"context_length_exceeded",
19+
"maximum context length",
20+
"token limit exceeded",
21+
"too many tokens",
22+
];
23+
24+
/**
25+
* Check if an error body indicates context overflow
26+
*/
27+
export function isContextOverflowError(status: number, bodyText: string): boolean {
28+
if (status !== 400) return false;
29+
if (!bodyText) return false;
30+
31+
const lowerBody = bodyText.toLowerCase();
32+
return CONTEXT_OVERFLOW_PATTERNS.some(pattern => lowerBody.includes(pattern));
33+
}
34+
35+
/**
36+
* The message shown to users when context overflow occurs
37+
*/
38+
const CONTEXT_OVERFLOW_MESSAGE = `[Plugin Notice] Context is too long for this model.
39+
40+
Please use one of these commands to reduce context size:
41+
42+
• **/compact** - Compress conversation history (recommended)
43+
• **/clear** - Start fresh with empty context
44+
• **/undo** - Remove recent messages
45+
46+
Then retry your request.
47+
48+
Alternatively, you can switch to a model with a larger context window.`;
49+
50+
/**
51+
* Creates a synthetic SSE response for context overflow errors.
52+
* This returns a 200 OK with the error message as assistant text,
53+
* preventing the session from getting locked.
54+
*/
55+
export function createContextOverflowResponse(model: string = "unknown"): Response {
56+
const messageId = `msg_synthetic_overflow_${Date.now()}`;
57+
const events: string[] = [];
58+
59+
// message_start
60+
events.push(`event: message_start\ndata: ${JSON.stringify({
61+
type: "message_start",
62+
message: {
63+
id: messageId,
64+
type: "message",
65+
role: "assistant",
66+
content: [],
67+
model,
68+
usage: { input_tokens: 0, output_tokens: 0 },
69+
},
70+
})}\n\n`);
71+
72+
// content_block_start
73+
events.push(`event: content_block_start\ndata: ${JSON.stringify({
74+
type: "content_block_start",
75+
index: 0,
76+
content_block: { type: "text", text: "" },
77+
})}\n\n`);
78+
79+
// content_block_delta (the actual message)
80+
events.push(`event: content_block_delta\ndata: ${JSON.stringify({
81+
type: "content_block_delta",
82+
index: 0,
83+
delta: { type: "text_delta", text: CONTEXT_OVERFLOW_MESSAGE },
84+
})}\n\n`);
85+
86+
// content_block_stop
87+
events.push(`event: content_block_stop\ndata: ${JSON.stringify({
88+
type: "content_block_stop",
89+
index: 0,
90+
})}\n\n`);
91+
92+
// message_delta (end_turn)
93+
events.push(`event: message_delta\ndata: ${JSON.stringify({
94+
type: "message_delta",
95+
delta: { stop_reason: "end_turn" },
96+
usage: { output_tokens: 0 },
97+
})}\n\n`);
98+
99+
// message_stop
100+
events.push(`event: message_stop\ndata: ${JSON.stringify({
101+
type: "message_stop",
102+
})}\n\n`);
103+
104+
return new Response(events.join(""), {
105+
status: 200,
106+
headers: {
107+
"Content-Type": "text/event-stream",
108+
"X-Codex-Plugin-Synthetic": "true",
109+
"X-Codex-Plugin-Error-Type": "context_overflow",
110+
},
111+
});
112+
}
113+
114+
/**
115+
* Check response for context overflow and return synthetic response if needed
116+
*/
117+
export async function handleContextOverflow(
118+
response: Response,
119+
model?: string,
120+
): Promise<{ handled: true; response: Response } | { handled: false }> {
121+
if (response.status !== 400) {
122+
return { handled: false };
123+
}
124+
125+
try {
126+
const bodyText = await response.clone().text();
127+
if (isContextOverflowError(response.status, bodyText)) {
128+
console.log(`[${PLUGIN_NAME}] Context overflow detected, returning synthetic response`);
129+
return {
130+
handled: true,
131+
response: createContextOverflowResponse(model),
132+
};
133+
}
134+
} catch {
135+
// Ignore read errors
136+
}
137+
138+
return { handled: false };
139+
}

lib/request/helpers/input-utils.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -208,3 +208,53 @@ export const normalizeOrphanedToolOutputs = (
208208
return item;
209209
});
210210
};
211+
212+
const CANCELLED_TOOL_OUTPUT = "Operation cancelled by user";
213+
214+
const collectOutputCallIds = (input: InputItem[]): Set<string> => {
215+
const outputCallIds = new Set<string>();
216+
for (const item of input) {
217+
if (
218+
item.type === "function_call_output" ||
219+
item.type === "local_shell_call_output" ||
220+
item.type === "custom_tool_call_output"
221+
) {
222+
const callId = getCallId(item);
223+
if (callId) outputCallIds.add(callId);
224+
}
225+
}
226+
return outputCallIds;
227+
};
228+
229+
export const injectMissingToolOutputs = (input: InputItem[]): InputItem[] => {
230+
const outputCallIds = collectOutputCallIds(input);
231+
const result: InputItem[] = [];
232+
233+
for (const item of input) {
234+
result.push(item);
235+
236+
if (
237+
item.type === "function_call" ||
238+
item.type === "local_shell_call" ||
239+
item.type === "custom_tool_call"
240+
) {
241+
const callId = getCallId(item);
242+
if (callId && !outputCallIds.has(callId)) {
243+
const outputType =
244+
item.type === "function_call"
245+
? "function_call_output"
246+
: item.type === "local_shell_call"
247+
? "local_shell_call_output"
248+
: "custom_tool_call_output";
249+
250+
result.push({
251+
type: outputType,
252+
call_id: callId,
253+
output: CANCELLED_TOOL_OUTPUT,
254+
} as unknown as InputItem);
255+
}
256+
}
257+
}
258+
259+
return result;
260+
};

lib/request/request-transformer.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { getNormalizedModel } from "./helpers/model-map.js";
66
import {
77
filterOpenCodeSystemPromptsWithCachedPrompt,
88
normalizeOrphanedToolOutputs,
9+
injectMissingToolOutputs,
910
} from "./helpers/input-utils.js";
1011
import type {
1112
ConfigOptions,
@@ -498,6 +499,7 @@ export async function transformRequestBody(
498499
// convert them to messages to preserve context while avoiding API errors
499500
if (body.input) {
500501
body.input = normalizeOrphanedToolOutputs(body.input);
502+
body.input = injectMissingToolOutputs(body.input);
501503
}
502504
}
503505

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "opencode-openai-codex-auth-multi",
3-
"version": "4.5.0",
3+
"version": "4.6.0",
44
"description": "Fork of opencode-openai-codex-auth with multi-account rotation (ChatGPT OAuth / Codex backend)",
55
"main": "./dist/index.js",
66
"types": "./dist/index.d.ts",

0 commit comments

Comments
 (0)