Skip to content

Commit 2c633f6

Browse files
HaShiSharkclaude
andcommitted
Preserve Codex context on workbench open
Avoid replacing a live transcript with context-control or prefix-only data so the workbench reflects the active Codex input without dropping prior turns. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 7e1ed55 commit 2c633f6

4 files changed

Lines changed: 191 additions & 22 deletions

File tree

codex_context.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
from __future__ import annotations
2+
3+
from typing import Any
4+
5+
CONTEXT_CONTROL_COMMANDS = {"ctx", "/ctx", "context", "/context"}
6+
7+
8+
def text_value(value: Any) -> str:
9+
return str(value or "")
10+
11+
12+
def is_context_control_command_text(text: Any) -> bool:
13+
return text_value(text).strip().lower() in CONTEXT_CONTROL_COMMANDS
14+
15+
16+
def is_contextual_user_text(text: Any) -> bool:
17+
trimmed = text_value(text).lstrip()
18+
lowered = trimmed.lower()
19+
return (
20+
trimmed.startswith("# AGENTS.md instructions for ")
21+
or lowered.startswith("<environment_context>")
22+
or lowered.startswith("<skills>")
23+
or lowered.startswith("<user_shell_command>")
24+
or lowered.startswith("<turn_aborted>")
25+
or lowered.startswith("<subagent_notification>")
26+
)
27+
28+
29+
def is_conversation_record(record: dict[str, Any]) -> bool:
30+
role = text_value(record.get("role")).strip()
31+
if role == "assistant":
32+
return True
33+
if role != "user":
34+
return False
35+
text = text_value(record.get("text"))
36+
return (
37+
bool(text.strip())
38+
and not is_contextual_user_text(text)
39+
and not is_context_control_command_text(text)
40+
)
41+
42+
43+
def conversation_record_count(transcript: list[dict[str, Any]]) -> int:
44+
return sum(1 for record in transcript if is_conversation_record(record))

proxy_server.py

Lines changed: 19 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,12 @@
1818
from pathlib import Path
1919
from typing import Any
2020

21+
from codex_context import (
22+
conversation_record_count,
23+
is_context_control_command_text,
24+
is_contextual_user_text,
25+
)
26+
2127

2228
HOST = os.environ.get("HASH_CONTEXT_PROXY_HOST", "127.0.0.1")
2329
PORT = int(os.environ.get("HASH_CONTEXT_PROXY_PORT", "8787"))
@@ -36,7 +42,6 @@
3642
CODEX_PROXY_BASE_URL = f"http://{HOST}:{PORT}/v1"
3743
INTERNAL_CONTEXT_HEADER = "x-hash-context-internal"
3844
INTERNAL_CONTEXT_VALUE = "context-workbench"
39-
CONTEXT_CONTROL_COMMANDS = {"ctx", "/ctx", "context", "/context"}
4045
CONTEXT_CONTROL_NOTICE_TEXT = "Hash Context: opened workbench."
4146
CONTROL_PORT = int(os.environ.get("HASH_CONTEXT_CONTROL_PORT", "8790"))
4247
LOCAL_COMPACT_PROMPT_PREFIX = "You are performing a CONTEXT CHECKPOINT COMPACTION."
@@ -507,10 +512,6 @@ def flush_assistant() -> None:
507512
return records
508513

509514

510-
def is_context_control_command_text(text: str) -> bool:
511-
return str(text or "").strip().lower() in CONTEXT_CONTROL_COMMANDS
512-
513-
514515
def context_control_command_from_input(input_items: Any) -> str:
515516
transcript = input_items_to_transcript(input_items)
516517
for record in reversed(transcript):
@@ -1115,19 +1116,6 @@ def is_local_compact_summary_text(text: str) -> bool:
11151116
return str(text or "").startswith(f"{LOCAL_COMPACT_SUMMARY_PREFIX}\n\n")
11161117

11171118

1118-
def is_contextual_user_text(text: str) -> bool:
1119-
trimmed = str(text or "").lstrip()
1120-
lowered = trimmed.lower()
1121-
return (
1122-
trimmed.startswith("# AGENTS.md instructions for ")
1123-
or lowered.startswith("<environment_context>")
1124-
or lowered.startswith("<skills>")
1125-
or lowered.startswith("<user_shell_command>")
1126-
or lowered.startswith("<turn_aborted>")
1127-
or lowered.startswith("<subagent_notification>")
1128-
)
1129-
1130-
11311119
def is_initial_context_prefix_record(record: dict[str, Any]) -> bool:
11321120
role = str(record.get("role") or "").strip()
11331121
if role in {"system", "developer"}:
@@ -1161,6 +1149,13 @@ def with_fresh_initial_context_prefix(
11611149
return clean_transcript([*prefix, *body])
11621150

11631151

1152+
def should_replace_transcript_from_control_intercept(
1153+
existing_transcript: list[dict[str, Any]],
1154+
candidate_transcript: list[dict[str, Any]],
1155+
) -> bool:
1156+
return conversation_record_count(candidate_transcript) >= conversation_record_count(existing_transcript)
1157+
1158+
11641159
def local_compact_source_from_transcript(transcript: list[dict[str, Any]]) -> list[dict[str, Any]] | None:
11651160
records = clean_transcript(transcript)
11661161
if not records:
@@ -1410,8 +1405,12 @@ def record_control_intercept(self, session_id: str, body: dict[str, Any], header
14101405
if session is None:
14111406
session = ProxySession(id=session_id, title=f"Codex {session_id[:8]}")
14121407
self.sessions[session_id] = session
1413-
source_transcript = strip_context_edit_notice_records(input_items_to_transcript(body.get("input")))
1414-
session.transcript = clean_transcript(source_transcript)
1408+
source_transcript = clean_transcript(
1409+
strip_context_edit_notice_records(input_items_to_transcript(body.get("input")))
1410+
)
1411+
existing_transcript = clean_transcript(session.transcript)
1412+
if should_replace_transcript_from_control_intercept(existing_transcript, source_transcript):
1413+
session.transcript = source_transcript
14151414
session.pending_transcript = None
14161415
session.local_compact_source_transcript = None
14171416
session.status = "override" if session.edited_transcript is not None else "mirror"

scripts/test_compact_proxy.py

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,16 @@
2828
PRE_TOOL_TEXT = "I will inspect the root directory."
2929
FINAL_TEXT = "The root contains README.md and proxy_server.py."
3030
TOOL_CALL_ID = "call_root_ls"
31+
DEVELOPER_INSTRUCTIONS = "developer instructions"
32+
ENVIRONMENT_CONTEXT = "<environment_context><cwd>/repo</cwd></environment_context>"
33+
34+
35+
def record(role: str, text: str) -> dict[str, Any]:
36+
return proxy_server.transcript_record(role, text, [proxy_server.provider_message(role, text)])
37+
38+
39+
def provider_texts(records: list[dict[str, Any]]) -> list[str]:
40+
return [str(record.get("text") or "") for record in records]
3141

3242

3343
class MockCompactUpstream(BaseHTTPRequestHandler):
@@ -341,6 +351,114 @@ def test_request_without_override_preserves_codex_body() -> None:
341351
assert request_log[-1]["forwarded_body"] == original_body
342352

343353

354+
def test_clean_transcript_preserves_repeated_input_context_records() -> None:
355+
input_mapped_transcript = [
356+
record("developer", DEVELOPER_INSTRUCTIONS),
357+
record("user", ENVIRONMENT_CONTEXT),
358+
record("user", "first real question"),
359+
record("assistant", "first answer"),
360+
record("developer", DEVELOPER_INSTRUCTIONS),
361+
record("user", ENVIRONMENT_CONTEXT),
362+
record("user", "second real question"),
363+
]
364+
365+
cleaned = proxy_server.clean_transcript(input_mapped_transcript)
366+
367+
assert provider_texts(cleaned) == [
368+
DEVELOPER_INSTRUCTIONS,
369+
ENVIRONMENT_CONTEXT,
370+
"first real question",
371+
"first answer",
372+
DEVELOPER_INSTRUCTIONS,
373+
ENVIRONMENT_CONTEXT,
374+
"second real question",
375+
]
376+
377+
378+
def test_control_intercept_preserves_existing_transcript_when_input_is_prefix_only() -> None:
379+
with tempfile.TemporaryDirectory() as temp_dir:
380+
store = proxy_server.ProxyStore(Path(temp_dir) / "proxy_state.json")
381+
existing = proxy_server.clean_transcript(
382+
[
383+
record("developer", DEVELOPER_INSTRUCTIONS),
384+
record("user", ENVIRONMENT_CONTEXT),
385+
record("user", "first real question"),
386+
record("assistant", "first answer"),
387+
]
388+
)
389+
store.sessions[SESSION_ID] = proxy_server.ProxySession(
390+
id=SESSION_ID,
391+
title="Codex fake",
392+
transcript=existing,
393+
status="mirror",
394+
)
395+
body = {
396+
"input": [
397+
proxy_server.provider_message("developer", DEVELOPER_INSTRUCTIONS),
398+
proxy_server.provider_message("user", ENVIRONMENT_CONTEXT),
399+
proxy_server.provider_message("user", "ctx"),
400+
]
401+
}
402+
403+
store.record_control_intercept(SESSION_ID, body, {"x-codex-session-id": SESSION_ID}, "ctx")
404+
405+
session = store.get_session(SESSION_ID)
406+
assert session is not None
407+
assert provider_texts(session["transcript"]) == provider_texts(existing)
408+
assert store.sessions[SESSION_ID].request_log[-1]["kind"] == "context_control_intercept"
409+
410+
411+
def test_codex_local_session_sync_does_not_fallback_to_prefix_only_transcript() -> None:
412+
with tempfile.TemporaryDirectory() as temp_dir:
413+
previous_sessions_dir = web_server.CODEX_LOCAL_SESSIONS_DIR
414+
previous_proxy_state_file = web_server.PROXY_STATE_FILE
415+
try:
416+
temp_path = Path(temp_dir)
417+
web_server.CODEX_LOCAL_SESSIONS_DIR = temp_path / "missing-sessions"
418+
web_server.PROXY_STATE_FILE = temp_path / "proxy_state.json"
419+
web_server.PROXY_STATE_FILE.write_text(
420+
json.dumps(
421+
{
422+
"active_session_id": SESSION_ID,
423+
"sessions": [
424+
{
425+
"id": SESSION_ID,
426+
"updated_at": "2026-05-09T00:00:00Z",
427+
"request_log": [
428+
{
429+
"body": {
430+
"input": [
431+
proxy_server.provider_message("developer", DEVELOPER_INSTRUCTIONS),
432+
proxy_server.provider_message("user", ENVIRONMENT_CONTEXT),
433+
]
434+
}
435+
}
436+
],
437+
}
438+
],
439+
}
440+
),
441+
encoding="utf-8",
442+
)
443+
444+
assert web_server.latest_proxy_instruction_prefix_records()
445+
assert web_server.codex_local_session_transcript(SESSION_ID) == []
446+
finally:
447+
web_server.CODEX_LOCAL_SESSIONS_DIR = previous_sessions_dir
448+
web_server.PROXY_STATE_FILE = previous_proxy_state_file
449+
450+
451+
def test_prefix_only_codex_local_session_is_not_conversation() -> None:
452+
transcript = web_server.normalize_transcript(
453+
[
454+
record("developer", DEVELOPER_INSTRUCTIONS),
455+
record("user", ENVIRONMENT_CONTEXT),
456+
]
457+
)
458+
459+
assert not web_server.transcript_has_conversation_records(transcript)
460+
461+
344462
def test_local_compact_without_override_preserves_codex_body() -> None:
345463
with tempfile.TemporaryDirectory() as temp_dir:
346464
store = proxy_server.ProxyStore(Path(temp_dir) / "proxy_state.json")

web_server.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
from urllib import request as urllib_request
2020
from urllib.parse import quote, urlparse, urlunparse
2121

22+
from codex_context import is_conversation_record
2223
from dotenv import load_dotenv
2324

2425
try:
@@ -3441,6 +3442,10 @@ def transcript_has_instruction_prefix(records: list[dict[str, Any]]) -> bool:
34413442
)
34423443

34433444

3445+
def transcript_has_conversation_records(records: list[dict[str, Any]]) -> bool:
3446+
return any(is_conversation_record(record) for record in records)
3447+
3448+
34443449
def provider_message_record(item: dict[str, Any]) -> dict[str, Any] | None:
34453450
role = sanitize_text(item.get("role") or "").strip()
34463451
if role not in CONTEXT_INPUT_MESSAGE_ROLES:
@@ -3514,7 +3519,7 @@ def latest_proxy_instruction_prefix_records() -> list[dict[str, Any]]:
35143519
def codex_local_session_transcript(session_id: str) -> list[dict[str, Any]]:
35153520
session_file = find_codex_local_session_file(session_id)
35163521
if session_file is None:
3517-
return latest_proxy_instruction_prefix_records()
3522+
return []
35183523

35193524
records: list[dict[str, Any]] = []
35203525
try:
@@ -3551,6 +3556,9 @@ def codex_local_session_transcript(session_id: str) -> list[dict[str, Any]]:
35513556
}
35523557
)
35533558

3559+
if not records:
3560+
return []
3561+
35543562
if not transcript_has_instruction_prefix(records):
35553563
records = [*latest_proxy_instruction_prefix_records(), *records]
35563564

@@ -7302,7 +7310,7 @@ def do_POST(self) -> None:
73027310
if not session_id:
73037311
raise ValueError("session_id is required")
73047312
transcript = codex_local_session_transcript(session_id)
7305-
if not transcript:
7313+
if not transcript or not transcript_has_conversation_records(transcript):
73067314
self._send_json(
73077315
{
73087316
"error": "Codex local session was not found or has no transcript",

0 commit comments

Comments
 (0)