Skip to content
Closed
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
186 changes: 186 additions & 0 deletions scripts/test-pty-overflow.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
#!/usr/bin/env python3
"""End-to-end real-PTY test for anthropic/openai context-overflow handling (#201-#203).

Points graff at a local OpenAI-compatible backend (the built-in `lmstudio` provider,
http://127.0.0.1:1234) whose only reply is an injected error, and drives a real turn
through the terminal. Two scenarios prove the overflow detection is BOTH correct and
precise:

A. error.code = context_length_exceeded with a message that matches none of the
English substrings (Dutch). graff must still detect the overflow via the
STRUCTURED code (#203/G2), pin the meter to the window, and stay responsive
rather than wedge (#201/#202). Observable: the prompt's ctx meter reads
"<W>k/<W>k ctx (100% ...)".
B. error.code = rate_limit_exceeded with a non-overflow message. graff must NOT
mistake it for an overflow: the meter must not pin. Proves detection is precise.

Requires 127.0.0.1:1234 to be free (the lmstudio provider URL is fixed); skips if a
real LM Studio (or anything) already holds it.
"""

import http.server
import json
import os
import re
import socket
import sys
import tempfile
import threading

from pty_harness import PtySession, terminal_text


_arg = sys.argv[1] if len(sys.argv) > 1 else "graff"
GRAFF = os.path.abspath(_arg) if os.sep in _arg else _arg

# "<used>k/<window>k ctx (<pct>% · compact@<X>k)" — the "·" is U+00B7.
METER_RE = re.compile(r"(\d+)k/(\d+)k ctx \((\d+)% · compact@(\d+)k\)")
PINNED_RE = re.compile(r"(\d+)k/(\d+)k ctx \(100% · compact@\d+k\)")


class OpenAiErrorMock:
"""Serves one fixed OpenAI-style error envelope for every /v1/chat/completions."""

def __init__(self, error_obj: dict) -> None:
self.body = json.dumps({"error": error_obj}).encode()
self.hits = 0
parent = self

class Handler(http.server.BaseHTTPRequestHandler):
def do_POST(self) -> None: # noqa: N802
parent.hits += 1
length = int(self.headers.get("content-length", 0))
if length:
self.rfile.read(length)
self.send_response(200)
self.send_header("content-type", "application/json")
self.send_header("content-length", str(len(parent.body)))
self.end_headers()
self.wfile.write(parent.body)

def do_GET(self) -> None: # noqa: N802 (e.g. a /v1/models probe)
self.send_response(404)
self.end_headers()

def log_message(self, *_a) -> None: # silence the default stderr logging
pass

self.httpd = http.server.ThreadingHTTPServer(("127.0.0.1", 1234), Handler)

def start(self) -> None:
threading.Thread(target=self.httpd.serve_forever, daemon=True).start()

def stop(self) -> None:
self.httpd.shutdown()
self.httpd.server_close()


def _run(error_obj: dict, tmp: str):
"""Run one turn against a mock returning error_obj; return (rendered_text, hits)."""
mock = OpenAiErrorMock(error_obj)
mock.start()
try:
env = {
"HOME": tmp,
"LMSTUDIO_API_KEY": "local-pty-test",
"GRAFF_FLEET": "off",
"GRAFF_NO_TELEMETRY": "1",
}
ambient = tuple(
k for k in os.environ
if (k.startswith("GRAFF_") or k.startswith("CODEX_") or k == "NO_COLOR")
and k not in env
)
with PtySession(
GRAFF,
["--model", "lmstudio", "--no-telemetry"],
cwd=tmp,
env=env,
unset_env=ambient,
timeout=20.0,
) as session:
session.wait_for_literal("] ›")
cursor = len(session.raw)
session.send_line("hello")
# The turn ends with an api error either way; wait for it, then settle.
session.wait_for_literal("api error:", start=cursor)
session.pump_for(1.5)
rendered = terminal_text(bytes(session.raw[cursor:]))

# Session must remain usable after the failed turn (no wedge): a local
# command still works and the REPL exits cleanly.
c2 = len(session.raw)
session.send_line("/help")
session.wait_for_literal("/models [health]", start=c2)
session.send_key("ctrl-d")
result = session.read_until_exit(5.0)
if result.timed_out or result.exit_code != 0:
raise SystemExit(
f"REPL did not exit cleanly: exit={result.exit_code} "
f"timed_out={result.timed_out}"
)
return rendered, mock.hits
finally:
mock.stop()


def main() -> None:
# The lmstudio provider URL is hardcoded to :1234; bail cleanly if it's taken.
probe = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
# SO_REUSEADDR binds over a TIME_WAIT port left by a prior run, but still fails
# against a real LISTENing server — so back-to-back runs work, a live LM Studio skips.
probe.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
try:
probe.bind(("127.0.0.1", 1234))
except OSError:
print("skip 127.0.0.1:1234 is in use (real LM Studio?) — overflow PTY test skipped")
return
finally:
probe.close()

with tempfile.TemporaryDirectory(prefix="graff-pty-overflow-") as tmp:
# Disable the AI tab-titler so it doesn't fire an extra quiet turn.
harness = os.path.join(tmp, ".harness")
os.makedirs(harness, exist_ok=True)
with open(os.path.join(harness, "settings.json"), "w", encoding="utf-8") as fh:
json.dump({"ai_title": False}, fh)

# Scenario A: structured overflow code, message matches NO English substring.
dutch = "de aanvraag overschrijdt het maximale vensterformaat van dit model"
rendered, hits = _run(
{"message": dutch, "type": "invalid_request_error", "code": "context_length_exceeded"},
tmp,
)
if hits < 1:
raise AssertionError("A: graff never reached the backend")
if "api error" not in rendered or dutch not in rendered:
raise AssertionError(f"A: overflow error was not surfaced:\n{rendered}")
pinned = PINNED_RE.search(rendered)
if not pinned:
raise AssertionError(
"A: meter did not pin to the window — the structured error.code "
f"(context_length_exceeded) was not detected as overflow (#203/G2):\n{rendered}"
)
m = METER_RE.search(rendered)
if m.group(1) != m.group(2):
raise AssertionError(f"A: meter used != window despite pin: {m.group(0)!r}")
print(f"ok overflow-by-code detected end to end; meter pinned to {m.group(2)}k (100%)")

# Scenario B: a non-overflow code + non-overflow message must NOT pin.
rendered, _ = _run(
{"message": "too many requests", "type": "rate_limit_error", "code": "rate_limit_exceeded"},
tmp,
)
if "too many requests" not in rendered:
raise AssertionError(f"B: rate-limit error was not surfaced:\n{rendered}")
stray = PINNED_RE.search(rendered)
if stray:
raise AssertionError(
"B: meter pinned on a NON-overflow error — detection is not precise "
f"({stray.group(0)!r}):\n{rendered}"
)
print("ok non-overflow error did not pin the meter (detection is precise)")


if __name__ == "__main__":
main()
1 change: 1 addition & 0 deletions src/agent.zig
Original file line number Diff line number Diff line change
Expand Up @@ -346,6 +346,7 @@ pub const Agent = struct {
// unchanged.
pub const request = @import("agent_request.zig").request;
pub const inputOverCompactThreshold = @import("agent_request.zig").inputOverCompactThreshold;
pub const fullInputEstimateTokens = @import("agent_request.zig").fullInputEstimateTokens;
pub const recordUsage = @import("agent_request.zig").recordUsage;
pub const usageInt = @import("agent_request.zig").usageInt;
pub const recordCost = @import("agent_request.zig").recordCost;
Expand Down
15 changes: 11 additions & 4 deletions src/agent_compact.zig
Original file line number Diff line number Diff line change
Expand Up @@ -455,7 +455,8 @@ pub fn trimOldestToolOutputs(self: *Agent) usize {
if (seen > total - keep_recent) break; // keep the most recent verbatim
reclaimed += truncateToolOutput(self.arena, m, stub_cap, "[old tool output truncated to recover context (#163)]");
}
if (reclaimed > 0) self.last_context_tokens = 0; // force a re-measure next turn
// #202: reflect the trimmed size instead of blinding the meter to 0.
if (reclaimed > 0) self.last_context_tokens = self.fullInputEstimateTokens();
return reclaimed;
}

Expand All @@ -476,7 +477,9 @@ pub fn capOversizedToolOutputs(self: *Agent, cap: usize) usize {
if (isToolOutputMsg(m.*))
reclaimed += truncateToolOutput(self.arena, m, cap, "[tool output truncated: over this model's per-result cap — read/fetch a smaller range (#193)]");
}
if (reclaimed > 0) self.last_context_tokens = 0; // force a re-measure next turn
// #202: reflect the trimmed size instead of blinding the meter to 0, so the
// between-turns gate keeps working and an overflow recover-pin isn't clobbered.
if (reclaimed > 0) self.last_context_tokens = self.fullInputEstimateTokens();
return reclaimed;
}

Expand Down Expand Up @@ -554,7 +557,9 @@ test "trimOldestToolOutputs recovers a runaway tool-loop history (#163)" {
try std.testing.expect(emergencyCutIndex(agent.messages.items) == null);
const reclaimed = trimOldestToolOutputs(&agent);
try std.testing.expect(reclaimed > 0); // recovered instead of wedging
try std.testing.expectEqual(@as(usize, 0), agent.last_context_tokens); // forces a re-measure
// #202: re-measured to the trimmed size instead of blinding the meter to 0
try std.testing.expect(agent.last_context_tokens > 0);
try std.testing.expectEqual(agent.fullInputEstimateTokens(), agent.last_context_tokens);
var truncated: usize = 0;
var full: usize = 0;
for (agent.messages.items) |m| {
Expand Down Expand Up @@ -614,7 +619,9 @@ test "capOversizedToolOutputs (#193): bounds an oversized output in every wire f

const reclaimed = capOversizedToolOutputs(&agent, cap);
try std.testing.expect(reclaimed > 0);
try std.testing.expectEqual(@as(usize, 0), agent.last_context_tokens); // forces a re-measure
// #202: re-measured to the trimmed size instead of blinding the meter to 0
try std.testing.expect(agent.last_context_tokens > 0);
try std.testing.expectEqual(agent.fullInputEstimateTokens(), agent.last_context_tokens);

// every oversized tool output is now within the cap, with a marker
const out0 = agent.messages.items[0].object.get("output").?.string;
Expand Down
Loading
Loading