Skip to content

Conversation becomes permanently unusable after hitting 50 image limit #31820

Open

Description

@ysemanag

What version of the Codex App are you using (From “About Codex” dialog)?

26.623.141536

What subscription do you have?

Interprise

What platform is your computer?

Darwin 25.5.0 arm64 arm

What issue are you seeing?

After a conversation reaches the 50-image request limit, the conversation becomes permanently unusable.

Once the error

Exceeded maximum number of images (50) allowed in the request.

is returned, every subsequent prompt fails with the same error, even when the prompt contains only text (for example, hey).

I manually compacted the conversation after hitting the limit, and the conversation displayed "Context compacted", but subsequent text-only prompts still failed with the same error.

Restarting the Codex App did not resolve the issue. The conversation could not be recovered and had to be abandoned.

I've attached screenshots showing:

  • the initial 50-image limit error,
  • a text-only prompt failing with the same error,
  • "Context compacted" followed by the same error again.

What steps can reproduce the bug?

  1. Start a long-running conversation in the Codex App.

  2. Upload enough screenshots/images throughout the conversation to reach the image request limit.

  3. Receive the error:

    Exceeded maximum number of images (50) allowed in the request.

  4. Send a text-only prompt (for example, hey).

  5. Observe that the same error is returned immediately.

  6. Manually compact the conversation.

  7. Send another text-only prompt.

  8. Observe that the same error continues to occur, even after context compaction.

  9. Quit and restart the Codex App.

  10. Open the same conversation and send another text-only prompt.

  11. Observe that the conversation still cannot be used and returns the same error.

What is the expected behavior?

Once the image limit has been reached, the conversation should remain usable.

Either:

  • text-only prompts should continue to work without including previous image references, or
  • context compaction should remove or summarize old image references so the conversation can continue.

The conversation should be recoverable without creating a new chat.

Additional information

Additional observations:

  • The conversation displayed "Context compacted" after I manually compacted it, but the issue persisted.
  • Every subsequent prompt, including simple text-only prompts such as hey, immediately failed with the same "Exceeded maximum number of images (50) allowed in the request." error.
  • Restarting the Codex App did not resolve the issue.
  • The issue appears to affect only the existing conversation. Creating a new conversation is the only workaround, but it requires abandoning all accumulated context from the original session.
  • I have attached screenshots showing the initial error, the failed text-only prompt, and the conversation after context compaction.

Activity

  1. added
    appIssues related to the Codex desktop app
    on Jul 9, 2026
  2. zdayang commented on Aug 13, 2026

    @zdayang

    If text-only prompts still hit the 50-image limit after compaction, those historical images are likely still active. I built a free local Skill that detects them and prepares a clean-session handoff without changing or uploading the blocked thread: Context Offload.

    A pass/fail result is enough; please don't share the transcript. (Disclosure: I maintain it.)

  3. chipfighter commented on Oct 29, 2026

    @chipfighter

    @ysemanag Having to abandon a conversation because old screenshots still count toward the image limit is a painful way to lose your working context. I built Context Attachment Manager after repeated character-image revisions caused a similar pile-up in my Codex work.

    I'm the author. It's free and open source, currently v0.9.1 with 560 GitHub Stars. People facing similar image-history problems are using it and sharing feedback. After installation and restarting Codex, automatic selection is on by default: the assistant retrieves originals when needed, including after compaction, instead of carrying every old picture into each turn.

    You can also browse thumbnails, search by remembered visual details, zoom previews, and choose references to keep selected. Original quality and chat records are preserved. The plugin manages them locally without separately collecting or uploading your pictures or chats.

    https://github.com/chipfighter/context-attachment-manager
    The page includes setup steps. Once you're comfortable with it, you can ask Codex or Claude Code to install it for you. It supports Codex Desktop and the local Code tab in Claude Desktop.

    If you're still working with lots of screenshots, I'd welcome a try and feedback on whether it keeps the conversation usable for you.

  4. mklikushin commented on Nov 12, 2026

    @mklikushin

    I reproduced the native app-server image accumulation and terminal-error behavior on Codex CLI 0.167.2 and 0.170.0-alpha.2, using the official macOS arm64 release binaries. We also encountered this exact provider error in a real session on 0.164.0.

    The standalone Python reproduction below uses codex app-server --stdio, an isolated temporary CODEX_HOME, a generated 1×1 PNG, and a local fake Responses API. It requires no API credentials or live inference. The client initializes a thread and starts one turn; Codex executes its built-in view_image tool and maintains the request history throughout one uninterrupted app-server process.

    The fixture returns native view_image calls in batches of 6, 6, 8, 4, 12, 12, 5. It counts outgoing input_image parts and returns HTTP 400 when the count exceeds 50:

    {"error":{"message":"Exceeded maximum number of images (50) allowed in the request.","type":"invalid_request_error","param":"input","code":null}}
    

    Observed on both binaries:

    Outgoing input_image counts: 0, 6, 12, 20, 24, 36, 48, 53
    turn/completed: status = failed
    turn.error.message: contains the original provider error JSON
    turn.error.codexErrorInfo: other
    error notifications: 1, with willRetry = false
    Compaction observed: false
    

    The fixture deliberately reports low token usage (input_tokens: 10, output_tokens: 5 per response), isolating the image-count limit from token-based automatic compaction. Codex sends all 53 accumulated image parts, then terminates the turn when the provider rejects that request.

    Expected: Codex should keep the conversation usable when a provider rejects accumulated image history, for example by removing or summarizing older image payloads and retrying with a valid request, or by enforcing a provider-aware image budget before sending.

    Scope: This reproduction verifies native image accumulation and the absence of automatic recovery for this HTTP 400. It does not test the original report's manual-compaction, app-restart, or subsequent text-only-prompt cases. The newer-version tests use a synthetic provider rejection, not a live provider call.

    Run with Python 3 and the Codex executable to test:

    python3 codex-image-limit-repro.py /absolute/path/to/codex
    

    An exit status of 0 means the bug was reproduced; the script asserts the observed failure behavior.

    Standalone reproduction: codex-image-limit-repro.py (Python standard library only)
    #!/usr/bin/env python3
    """Characterize Codex's native image-count failure using a local fake provider.
    
    Usage: python3 codex-image-limit-repro.py /absolute/path/to/codex
    Python standard library only; no credentials or live model inference required.
    Exit 0 means the reported bug was reproduced, NOT that it was fixed.
    """
    
    import base64
    import json
    import os
    import platform
    import queue
    import shutil
    import subprocess
    import sys
    import tempfile
    import threading
    import time
    from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
    from pathlib import Path
    
    BATCHES = [6, 6, 8, 4, 12, 12, 5]
    EXPECTED_COUNTS = [0, 6, 12, 20, 24, 36, 48, 53]
    FAILURE = "Exceeded maximum number of images (50) allowed in the request."
    PNG = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg=="
    
    
    def image_count(value):
        if isinstance(value, list):
            return sum(map(image_count, value))
        if isinstance(value, dict):
            return int(value.get("type") == "input_image") + sum(map(image_count, value.values()))
        return 0
    
    
    def completed_response(response_id, items):
        yield {"type": "response.created", "response": {
            "id": response_id, "status": "in_progress", "output": []}}
        for index, item in enumerate(items):
            yield {"type": "response.output_item.added", "output_index": index,
                   "item": {**item, "status": "in_progress"}}
            yield {"type": "response.output_item.done", "output_index": index, "item": item}
        yield {"type": "response.completed", "response": {
            "id": response_id, "status": "completed", "output": items,
            "usage": {"input_tokens": 10, "output_tokens": 5, "total_tokens": 15}}}
    
    
    def run(binary):
        print(subprocess.check_output([binary, "--version"], text=True).strip())
        print(platform.system(), platform.release(), platform.machine())
        with tempfile.TemporaryDirectory(prefix="codex-image-limit-") as directory:
            root = Path(directory)
            codex_home = root / "codex-home"
            codex_home.mkdir()
            image_path = root / "probe.png"
            image_path.write_bytes(base64.b64decode(PNG))
            counts, notifications, unexpected_paths = [], [], []
    
            class Provider(BaseHTTPRequestHandler):
                def log_message(self, *_args):
                    pass
    
                def do_POST(self):
                    body = json.loads(self.rfile.read(int(self.headers["Content-Length"])))
                    if self.path != "/v1/responses":
                        unexpected_paths.append(self.path)
                        self.send_error(404)
                        return
                    count = image_count(body.get("input", []))
                    counts.append(count)
                    index = len(counts)
                    print(f"request {index}: input_image count = {count}", flush=True)
                    if count > 50:
                        payload = json.dumps({"error": {"message": FAILURE,
                            "type": "invalid_request_error", "param": "input", "code": None}}).encode()
                        self.send_response(400)
                        content_type = "application/json"
                    else:
                        items = [{"type": "function_call", "id": f"image-{index}-{i}",
                            "call_id": f"image-{index}-{i}", "name": "view_image",
                            "arguments": json.dumps({"path": str(image_path)})}
                            for i in range(BATCHES[index - 1])] if index <= len(BATCHES) else []
                        payload = "".join(f"event: {event['type']}\ndata: {json.dumps(event)}\n\n"
                            for event in completed_response(f"response-{index}", items)).encode()
                        self.send_response(200)
                        content_type = "text/event-stream"
                    self.send_header("Content-Type", content_type)
                    self.send_header("Content-Length", str(len(payload)))
                    self.end_headers()
                    self.wfile.write(payload)
    
            server = ThreadingHTTPServer(("127.0.0.1", 0), Provider)
            threading.Thread(target=server.serve_forever, daemon=True).start()
            (codex_home / "config.toml").write_text(f'''model = "gpt-5.4"
    model_provider = "image-limit-fixture"
    [model_providers.image-limit-fixture]
    name = "Local image limit fixture"
    base_url = "http://127.0.0.1:{server.server_port}/v1"
    wire_api = "responses"
    requires_openai_auth = false
    supports_websockets = false
    stream_max_retries = 2
    request_max_retries = 0
    ''')
            # Do not inherit API keys, proxy settings, or the user's Codex config.
            environment = {key: os.environ[key] for key in ("PATH", "HOME", "TMPDIR") if key in os.environ}
            environment.update(CODEX_HOME=str(codex_home), OTEL_SDK_DISABLED="true")
            proc = None
            with tempfile.TemporaryFile(mode="w+t") as stderr:
                try:
                    proc = subprocess.Popen([binary, "app-server", "--stdio"], cwd=root,
                        env=environment, stdin=subprocess.PIPE, stdout=subprocess.PIPE,
                        stderr=stderr, text=True, bufsize=1)
                    inbox = queue.Queue()
    
                    def read_stdout():
                        for line in proc.stdout:
                            inbox.put(line)
                        inbox.put(None)
    
                    threading.Thread(target=read_stdout, daemon=True).start()
                    deadline = time.monotonic() + 45
    
                    def send(message):
                        proc.stdin.write(json.dumps(message) + "\n")
                        proc.stdin.flush()
    
                    def receive():
                        try:
                            line = inbox.get(timeout=max(0, deadline - time.monotonic()))
                        except queue.Empty:
                            raise TimeoutError("App-server did not complete within 45 seconds") from None
                        if line is None:
                            raise RuntimeError("App-server closed stdout unexpectedly")
                        message = json.loads(line)
                        if "method" in message:
                            if "id" in message:
                                raise RuntimeError(f"Unexpected server request: {message['method']}")
                            notifications.append(message)
                        return message
    
                    def request(request_id, method, params):
                        send({"id": request_id, "method": method, "params": params})
                        while True:
                            message = receive()
                            if message.get("id") == request_id:
                                if "error" in message:
                                    raise RuntimeError(message["error"])
                                return message["result"]
    
                    request(1, "initialize", {"clientInfo": {
                        "name": "image_limit_repro", "version": "0.1.0"},
                        "capabilities": {"experimentalApi": True}})
                    send({"method": "initialized", "params": {}})
                    thread = request(2, "thread/start", {
                        "cwd": str(root), "approvalPolicy": "never", "sandbox": "read-only"})["thread"]
                    request(3, "turn/start", {"threadId": thread["id"], "input": [{
                        "type": "text", "text": "Synthetic image-count reproduction.", "text_elements": []}]})
                    while not any(n.get("method") == "turn/completed" for n in notifications):
                        receive()
                    turn = next(n["params"]["turn"] for n in notifications if n.get("method") == "turn/completed")
                    errors = [n["params"] for n in notifications if n.get("method") == "error"]
                    compacted = any(n.get("method") == "thread/compacted" or
                        n.get("params", {}).get("item", {}).get("type") == "contextCompaction"
                        for n in notifications)
                    print("turn status:", turn["status"])
                    print("turn error:", json.dumps(turn.get("error")))
                    print("error willRetry flags:", [e.get("willRetry") for e in errors])
                    print("compaction observed:", compacted)
                    assert counts == EXPECTED_COUNTS, counts
                    assert turn["status"] == "failed", turn
                    assert FAILURE in turn["error"]["message"], turn["error"]
                    assert len(errors) == 1 and errors[0]["willRetry"] is False, errors
                    assert not compacted and not unexpected_paths, unexpected_paths
                    assert sum(n.get("method") == "turn/started" for n in notifications) == 1
                    print("REPRODUCED: native history crossed 50 images; HTTP 400 ended the turn.")
                except Exception:
                    stderr.seek(0)
                    print(stderr.read()[-4000:], file=sys.stderr)
                    raise
                finally:
                    if proc is not None:
                        proc.terminate()
                        try:
                            proc.wait(timeout=5)
                        except subprocess.TimeoutExpired:
                            proc.kill()
                            proc.wait()
                    server.shutdown()
                    server.server_close()
    
    
    if __name__ == "__main__":
        if len(sys.argv) != 2:
            sys.exit(__doc__)
        resolved_binary = shutil.which(sys.argv[1])
        if not resolved_binary:
            sys.exit(f"Codex executable not found: {sys.argv[1]}")
        run(str(Path(resolved_binary).resolve()))
    
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    appIssues related to the Codex desktop app

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions