mirror of
https://github.com/openai/codex.git
synced 2026-02-02 06:57:03 +00:00
Compare commits
32 Commits
main
...
daniel/pr2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5060f6900a | ||
|
|
491f8e5e6f | ||
|
|
91bb558007 | ||
|
|
cc542eb8df | ||
|
|
d3047b27a4 | ||
|
|
7f4c0de7ef | ||
|
|
46b9997249 | ||
|
|
0ee506bd54 | ||
|
|
0cc7b8c0be | ||
|
|
a2bfce5105 | ||
|
|
f774fc3d1e | ||
|
|
90bd67cfa4 | ||
|
|
a409c34c85 | ||
|
|
a95b23f9b7 | ||
|
|
34707976a3 | ||
|
|
31070c28aa | ||
|
|
a377542817 | ||
|
|
d2af202db7 | ||
|
|
64751867ac | ||
|
|
7adacf7aae | ||
|
|
af3ccfd50a | ||
|
|
1cf16f35f5 | ||
|
|
0413279032 | ||
|
|
636cb23ef8 | ||
|
|
89b8877f6a | ||
|
|
f564f0518a | ||
|
|
66ab8fb43d | ||
|
|
01f4b41851 | ||
|
|
a0306495c7 | ||
|
|
0bc3b4bcbf | ||
|
|
9b74053cb7 | ||
|
|
2984f90dc4 |
318
codex-rs/lastprs
Executable file
318
codex-rs/lastprs
Executable file
@@ -0,0 +1,318 @@
|
||||
#!/usr/bin/env python3
|
||||
import argparse
|
||||
import concurrent.futures
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Iterable, List, Optional, Set, Tuple, Dict, Any
|
||||
|
||||
|
||||
def _run(cmd: List[str]) -> Tuple[int, str, str]:
|
||||
proc = subprocess.run(
|
||||
cmd,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
return proc.returncode, proc.stdout, proc.stderr
|
||||
|
||||
|
||||
def require_gh():
|
||||
if shutil.which("gh") is None:
|
||||
print("Error: GitHub CLI 'gh' not found. Please install and authenticate.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def require_pr2md(script_dir: str) -> str:
|
||||
# Prefer pr2md next to this script; fallback to PATH
|
||||
local = os.path.join(script_dir, "pr2md")
|
||||
if os.path.isfile(local) and os.access(local, os.X_OK):
|
||||
return local
|
||||
if shutil.which("pr2md"):
|
||||
return "pr2md"
|
||||
print("Error: 'pr2md' not found next to this script or in PATH.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def parse_repo_from_url(url: str) -> Optional[str]:
|
||||
u = url.strip()
|
||||
if not u:
|
||||
return None
|
||||
if "github.com:" in u:
|
||||
path = u.split("github.com:", 1)[1]
|
||||
elif "github.com/" in u:
|
||||
path = u.split("github.com/", 1)[1]
|
||||
elif u.startswith("github.com/"):
|
||||
path = u.split("github.com/", 1)[1]
|
||||
else:
|
||||
return None
|
||||
if path.endswith(".git"):
|
||||
path = path[:-4]
|
||||
parts = path.strip("/").split("/")
|
||||
if len(parts) >= 2:
|
||||
return f"{parts[0]}/{parts[1]}"
|
||||
return None
|
||||
|
||||
|
||||
def detect_repo_from_git() -> Optional[str]:
|
||||
code, out, _ = _run(["git", "rev-parse", "--is-inside-work-tree"])
|
||||
if code != 0 or out.strip() != "true":
|
||||
return None
|
||||
code, origin_url, _ = _run(["git", "config", "--get", "remote.origin.url"])
|
||||
if code != 0:
|
||||
return None
|
||||
return parse_repo_from_url(origin_url)
|
||||
|
||||
|
||||
def detect_repo_root() -> Optional[str]:
|
||||
code, out, _ = _run(["git", "rev-parse", "--show-toplevel"])
|
||||
if code != 0:
|
||||
return None
|
||||
return out.strip()
|
||||
|
||||
|
||||
def iso8601(dt: datetime) -> str:
|
||||
return dt.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
|
||||
|
||||
def list_review_comment_prs(repo: str, reviewer: str, since_iso: str) -> Set[int]:
|
||||
prs: Set[int] = set()
|
||||
page = 1
|
||||
reviewer_lc = reviewer.lower()
|
||||
while True:
|
||||
path = f"/repos/{repo}/pulls/comments?per_page=100&page={page}&since={since_iso}"
|
||||
code, out, err = _run(["gh", "api", path])
|
||||
if code != 0:
|
||||
print(f"Error: failed to fetch review comments: {err.strip()}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
try:
|
||||
batch = json.loads(out)
|
||||
except json.JSONDecodeError as e:
|
||||
print(f"Error: could not parse review comments JSON: {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
if not batch:
|
||||
break
|
||||
for c in batch:
|
||||
user = (c.get("user") or {}).get("login", "").lower()
|
||||
if user != reviewer_lc:
|
||||
continue
|
||||
pr_url = c.get("pull_request_url") or ""
|
||||
# Expect .../pulls/<number>
|
||||
try:
|
||||
pr_number = int(pr_url.rstrip("/").split("/")[-1])
|
||||
prs.add(pr_number)
|
||||
except Exception:
|
||||
continue
|
||||
# Progress line for discovery
|
||||
print(f"discover: page={page} batch={len(batch)} unique_prs={len(prs)}", file=sys.stderr, flush=True)
|
||||
if len(batch) < 100:
|
||||
break
|
||||
page += 1
|
||||
if page > 50:
|
||||
break
|
||||
return prs
|
||||
|
||||
|
||||
def list_recent_prs(repo: str, days: int) -> List[int]:
|
||||
# As a fallback: list PRs updated in the window via gh and parse numbers.
|
||||
# Uses GitHub search qualifiers supported by `gh pr list --search`.
|
||||
since_date = (datetime.now(timezone.utc) - timedelta(days=days)).strftime("%Y-%m-%d")
|
||||
code, out, err = _run([
|
||||
"gh",
|
||||
"pr",
|
||||
"list",
|
||||
"-R",
|
||||
repo,
|
||||
"--state",
|
||||
"all",
|
||||
"--search",
|
||||
f"updated:>={since_date}",
|
||||
"--json",
|
||||
"number",
|
||||
])
|
||||
if code != 0:
|
||||
print(f"Error: failed to list recent PRs: {err.strip()}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
try:
|
||||
data = json.loads(out)
|
||||
except json.JSONDecodeError:
|
||||
return []
|
||||
return [int(x.get("number")) for x in data if isinstance(x.get("number"), int)]
|
||||
|
||||
|
||||
def ensure_dir(path: str):
|
||||
os.makedirs(path, exist_ok=True)
|
||||
|
||||
|
||||
def run_pr2md(pr2md_path: str, repo: str, pr_number: int, reviewer: str) -> Tuple[int, str, Optional[str]]:
|
||||
"""Return (pr_number, status, markdown)."""
|
||||
try:
|
||||
cmd = [pr2md_path, str(pr_number), repo, "--reviewer", reviewer]
|
||||
code, out, err = _run(cmd)
|
||||
if code != 0:
|
||||
return pr_number, f"error: {err.strip() or 'pr2md failed'}", None
|
||||
return pr_number, "ok", out
|
||||
except Exception as e:
|
||||
return pr_number, f"error: {e}", None
|
||||
|
||||
|
||||
def dedupe(seq: Iterable[int]) -> List[int]:
|
||||
seen = set()
|
||||
out: List[int] = []
|
||||
for n in seq:
|
||||
if n not in seen:
|
||||
seen.add(n)
|
||||
out.append(n)
|
||||
return out
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="lastprs",
|
||||
description=(
|
||||
"Fetch PRs a reviewer commented on in the last N days and render each via pr2md.\n"
|
||||
"Writes a consolidated reviewers/<reviewer>.json with all raw PR markdowns."
|
||||
),
|
||||
)
|
||||
parser.add_argument("days", type=int, help="Number of days to look back (N)")
|
||||
parser.add_argument("reviewer", help="GitHub login of the reviewer")
|
||||
parser.add_argument(
|
||||
"repo",
|
||||
nargs="?",
|
||||
help="Repository in 'owner/repo' form; inferred from git origin if omitted",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--jobs",
|
||||
"-j",
|
||||
type=int,
|
||||
default=min(8, (os.cpu_count() or 4)),
|
||||
help="Parallel jobs when invoking pr2md (default: min(8, CPUs))",
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.days <= 0:
|
||||
print("Error: days must be a positive integer.", file=sys.stderr)
|
||||
sys.exit(2)
|
||||
|
||||
require_gh()
|
||||
script_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
pr2md_path = require_pr2md(script_dir)
|
||||
|
||||
repo = args.repo or detect_repo_from_git()
|
||||
if not repo:
|
||||
print(
|
||||
"Error: Could not determine repository from git origin. Pass repo as 'owner/repo'.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(2)
|
||||
|
||||
# Compute window
|
||||
since = datetime.now(timezone.utc) - timedelta(days=args.days)
|
||||
since_iso = iso8601(since)
|
||||
since_date = since.strftime("%Y-%m-%d")
|
||||
print(f"Discovering PRs for reviewer={args.reviewer} since={since_date} in repo={repo}…", file=sys.stderr)
|
||||
|
||||
# Identify PRs with review comments by reviewer since the cutoff
|
||||
pr_set = list_review_comment_prs(repo, args.reviewer, since_iso)
|
||||
|
||||
if not pr_set:
|
||||
# Fallback: scan recently updated PRs and check comments per-PR
|
||||
recent = list_recent_prs(repo, args.days)
|
||||
pr_set = set()
|
||||
reviewer_lc = args.reviewer.lower()
|
||||
total_recent = len(recent)
|
||||
print(f"Fallback: scanning {total_recent} recent PRs for comments by {args.reviewer}…", file=sys.stderr)
|
||||
for idx, pr_num in enumerate(recent, start=1):
|
||||
# Query review comments for this PR and filter by user + since
|
||||
page = 1
|
||||
found = False
|
||||
while True:
|
||||
path = f"/repos/{repo}/pulls/{pr_num}/comments?per_page=100&page={page}"
|
||||
code, out, err = _run(["gh", "api", path])
|
||||
if code != 0:
|
||||
break
|
||||
try:
|
||||
batch = json.loads(out)
|
||||
except json.JSONDecodeError:
|
||||
break
|
||||
if not batch:
|
||||
break
|
||||
for c in batch:
|
||||
user = (c.get("user") or {}).get("login", "").lower()
|
||||
created_at = c.get("created_at") or c.get("updated_at") or ""
|
||||
if user == reviewer_lc and created_at >= since_iso:
|
||||
found = True
|
||||
break
|
||||
if found or len(batch) < 100:
|
||||
break
|
||||
page += 1
|
||||
if page > 20:
|
||||
break
|
||||
if found:
|
||||
pr_set.add(pr_num)
|
||||
if idx % 10 == 0 or idx == total_recent:
|
||||
print(f"scan: {idx}/{total_recent} matched={len(pr_set)}", file=sys.stderr, flush=True)
|
||||
|
||||
prs = sorted(dedupe(pr_set))
|
||||
|
||||
if not prs:
|
||||
print(
|
||||
f"No PRs in {repo} with review comments from {args.reviewer} in the last {args.days} days.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return
|
||||
|
||||
# Determine reviewers JSON path under the repo root
|
||||
repo_root = detect_repo_root() or os.getcwd()
|
||||
reviewers_dir = os.path.join(repo_root, "reviewers")
|
||||
ensure_dir(reviewers_dir)
|
||||
out_json = os.path.join(reviewers_dir, f"{args.reviewer}.json")
|
||||
|
||||
# Run pr2md in parallel and collect
|
||||
print(f"Found {len(prs)} PR(s). Rendering to reviewers/{args.reviewer}.json", file=sys.stderr)
|
||||
results: List[Tuple[int, str, Optional[str]]] = []
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=max(1, args.jobs)) as ex:
|
||||
futs = [
|
||||
ex.submit(run_pr2md, pr2md_path, repo, pr_num, args.reviewer)
|
||||
for pr_num in prs
|
||||
]
|
||||
completed = 0
|
||||
total = len(futs)
|
||||
for fut in concurrent.futures.as_completed(futs):
|
||||
results.append(fut.result())
|
||||
completed += 1
|
||||
if completed % 5 == 0 or completed == total:
|
||||
print(f"render: {completed}/{total}", file=sys.stderr, flush=True)
|
||||
|
||||
ok = sum(1 for _, s, _ in results if s == "ok")
|
||||
failures = [(n, s) for n, s, _ in results if s != "ok"]
|
||||
for n, s in failures:
|
||||
print(f"PR {n}: {s}", file=sys.stderr)
|
||||
|
||||
# Build JSON
|
||||
now = iso8601(datetime.now(timezone.utc))
|
||||
prs_json: List[Dict[str, Any]] = []
|
||||
for pr_number, status, md in sorted(results, key=lambda t: t[0]):
|
||||
if status == "ok" and md is not None:
|
||||
prs_json.append({"number": pr_number, "markdown": md})
|
||||
|
||||
data: Dict[str, Any] = {
|
||||
"repo": repo,
|
||||
"reviewer": args.reviewer,
|
||||
"generated_at": now,
|
||||
"days": args.days,
|
||||
"prs": prs_json,
|
||||
}
|
||||
with open(out_json, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, indent=2)
|
||||
f.write("\n")
|
||||
print(f"Done. {ok}/{len(prs)} succeeded. Wrote {out_json}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
360
codex-rs/pr2md
Executable file
360
codex-rs/pr2md
Executable file
@@ -0,0 +1,360 @@
|
||||
#!/usr/bin/env python3
|
||||
import argparse
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from collections import defaultdict
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
from urllib.parse import quote
|
||||
|
||||
|
||||
def _run(cmd: List[str]) -> Tuple[int, str, str]:
|
||||
proc = subprocess.run(
|
||||
cmd,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
return proc.returncode, proc.stdout, proc.stderr
|
||||
|
||||
|
||||
def require_gh():
|
||||
if shutil.which("gh") is None:
|
||||
print("Error: GitHub CLI 'gh' not found. Please install and authenticate.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def iso_to_utc_str(iso: Optional[str]) -> str:
|
||||
if not iso:
|
||||
return ""
|
||||
try:
|
||||
# Handle both Z and offset formats
|
||||
if iso.endswith("Z"):
|
||||
dt = datetime.fromisoformat(iso.replace("Z", "+00:00"))
|
||||
else:
|
||||
dt = datetime.fromisoformat(iso)
|
||||
dt_utc = dt.astimezone(timezone.utc)
|
||||
return dt_utc.strftime("%Y-%m-%d %H:%M:%S UTC")
|
||||
except Exception:
|
||||
return iso
|
||||
|
||||
|
||||
def pr_view(repo: str, pr_number: int) -> Dict[str, Any]:
|
||||
fields = [
|
||||
"number",
|
||||
"title",
|
||||
"body",
|
||||
"url",
|
||||
"author",
|
||||
"createdAt",
|
||||
"updatedAt",
|
||||
"additions",
|
||||
"deletions",
|
||||
"changedFiles",
|
||||
"commits",
|
||||
"baseRefName",
|
||||
"headRefName",
|
||||
"headRepositoryOwner",
|
||||
]
|
||||
code, out, err = _run(["gh", "pr", "view", str(pr_number), "-R", repo, "--json", ",".join(fields)])
|
||||
if code != 0:
|
||||
print(f"Error: failed to fetch PR via gh: {err.strip()}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
try:
|
||||
data = json.loads(out)
|
||||
except json.JSONDecodeError as e:
|
||||
print(f"Error: failed to parse gh JSON output: {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
return data
|
||||
|
||||
|
||||
def pr_combined_diff(repo: str, pr: Dict[str, Any]) -> str:
|
||||
# Prefer a single combined diff between base and head.
|
||||
base = pr.get("baseRefName")
|
||||
head_branch = pr.get("headRefName")
|
||||
head_owner = (pr.get("headRepositoryOwner") or {}).get("login")
|
||||
if not base or not head_branch:
|
||||
# Fallback to gh pr diff if fields unavailable
|
||||
code, out, err = _run(["gh", "pr", "diff", str(pr.get("number")), "-R", repo, "--color=never"])
|
||||
if code != 0:
|
||||
print(f"Error: failed to fetch PR diff: {err.strip()}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
return out.rstrip()
|
||||
|
||||
base_owner = repo.split("/", 1)[0]
|
||||
if head_owner and head_owner != base_owner:
|
||||
head = f"{head_owner}:{head_branch}"
|
||||
else:
|
||||
head = head_branch
|
||||
|
||||
path = f"/repos/{repo}/compare/{quote(base, safe='')}...{quote(head, safe='')}"
|
||||
code, out, err = _run(["gh", "api", "-H", "Accept: application/vnd.github.v3.diff", path])
|
||||
if code == 0 and out.strip():
|
||||
return out.rstrip()
|
||||
# Fallback
|
||||
code, out, err = _run(["gh", "pr", "diff", str(pr.get("number")), "-R", repo, "--color=never"])
|
||||
if code != 0:
|
||||
print(f"Error: failed to fetch PR diff: {err.strip()}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
return out.rstrip()
|
||||
|
||||
|
||||
def pr_review_comments(repo: str, pr_number: int) -> List[Dict[str, Any]]:
|
||||
# Pull Request Review Comments (code comments). Fetch up to 1000 via pages of 100.
|
||||
all_comments: List[Dict[str, Any]] = []
|
||||
page = 1
|
||||
while True:
|
||||
path = f"/repos/{repo}/pulls/{pr_number}/comments?per_page=100&page={page}"
|
||||
code, out, err = _run(["gh", "api", path])
|
||||
if code != 0:
|
||||
print(f"Error: failed to fetch review comments: {err.strip()}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
try:
|
||||
batch = json.loads(out)
|
||||
except json.JSONDecodeError:
|
||||
print("Error: could not parse review comments JSON.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
if not batch:
|
||||
break
|
||||
all_comments.extend(batch)
|
||||
if len(batch) < 100:
|
||||
break
|
||||
page += 1
|
||||
if page > 10: # safety cap
|
||||
break
|
||||
return all_comments
|
||||
|
||||
|
||||
def parse_repo_from_url(url: str) -> Optional[str]:
|
||||
u = url.strip()
|
||||
if not u:
|
||||
return None
|
||||
|
||||
# Common forms:
|
||||
# - SSH scp-like: <user>@github.com:owner/repo.git
|
||||
# - SSH URL: ssh://<user>@github.com/owner/repo.git
|
||||
# - HTTPS: https://github.com/owner/repo(.git)
|
||||
# - Bare: github.com/owner/repo(.git)
|
||||
if "github.com:" in u:
|
||||
# scp-like syntax
|
||||
path = u.split("github.com:", 1)[1]
|
||||
elif "github.com/" in u:
|
||||
path = u.split("github.com/", 1)[1]
|
||||
elif u.startswith("github.com/"):
|
||||
path = u.split("github.com/", 1)[1]
|
||||
else:
|
||||
return None
|
||||
|
||||
# Remove trailing .git if present
|
||||
if path.endswith(".git"):
|
||||
path = path[:-4]
|
||||
|
||||
# Keep only owner/repo
|
||||
parts = path.strip("/").split("/")
|
||||
if len(parts) >= 2:
|
||||
return f"{parts[0]}/{parts[1]}"
|
||||
return None
|
||||
|
||||
|
||||
def detect_repo_from_git() -> Optional[str]:
|
||||
# Ensure we're inside a git repo
|
||||
code, out, _ = _run(["git", "rev-parse", "--is-inside-work-tree"])
|
||||
if code != 0 or out.strip() != "true":
|
||||
return None
|
||||
code, origin_url, _ = _run(["git", "config", "--get", "remote.origin.url"])
|
||||
if code != 0:
|
||||
return None
|
||||
return parse_repo_from_url(origin_url)
|
||||
|
||||
|
||||
def blockquote(text: str) -> str:
|
||||
lines = text.splitlines() or [""]
|
||||
return "\n".join("> " + ln for ln in lines)
|
||||
|
||||
|
||||
def format_header(pr: Dict[str, Any]) -> str:
|
||||
number = pr.get("number")
|
||||
title = pr.get("title", "")
|
||||
url = pr.get("url", "")
|
||||
author_login = (pr.get("author") or {}).get("login", "")
|
||||
created = iso_to_utc_str(pr.get("createdAt"))
|
||||
updated = iso_to_utc_str(pr.get("updatedAt"))
|
||||
additions = pr.get("additions", 0)
|
||||
deletions = pr.get("deletions", 0)
|
||||
changed_files = pr.get("changedFiles", 0)
|
||||
commits_obj = pr.get("commits")
|
||||
if isinstance(commits_obj, dict):
|
||||
if "totalCount" in commits_obj and isinstance(commits_obj["totalCount"], (int, float)):
|
||||
commits_count = int(commits_obj["totalCount"]) # GraphQL connection
|
||||
elif "nodes" in commits_obj and isinstance(commits_obj["nodes"], list):
|
||||
commits_count = len(commits_obj["nodes"]) # fallback
|
||||
else:
|
||||
commits_count = None
|
||||
elif isinstance(commits_obj, list):
|
||||
commits_count = len(commits_obj)
|
||||
elif isinstance(commits_obj, (int, float)):
|
||||
commits_count = int(commits_obj)
|
||||
else:
|
||||
commits_count = None
|
||||
commits_str = str(commits_count) if commits_count is not None else "?"
|
||||
|
||||
lines = []
|
||||
lines.append(f"# PR #{number}: {title}")
|
||||
lines.append("")
|
||||
lines.append(f"- URL: {url}")
|
||||
lines.append(f"- Author: {author_login}")
|
||||
lines.append(f"- Created: {created}")
|
||||
lines.append(f"- Updated: {updated}")
|
||||
lines.append(f"- Changes: +{additions}/-{deletions}, Files changed: {changed_files}, Commits: {commits_str}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def format_description(body: Optional[str]) -> str:
|
||||
desc = body or ""
|
||||
desc = desc.strip()
|
||||
if not desc:
|
||||
desc = "(No description.)"
|
||||
return f"\n## Description\n\n{desc}\n"
|
||||
|
||||
|
||||
def format_diff(diff_text: str) -> str:
|
||||
return f"\n## Full Diff\n\n```diff\n{diff_text}\n```\n"
|
||||
|
||||
|
||||
def format_review_comments(comments: List[Dict[str, Any]], reviewer: Optional[str]) -> str:
|
||||
if reviewer:
|
||||
reviewer_lc = reviewer.lower()
|
||||
comments = [c for c in comments if ((c.get("user") or {}).get("login", "").lower() == reviewer_lc)]
|
||||
|
||||
if not comments:
|
||||
return "\n## Review Comments\n\n(No review comments.)\n"
|
||||
|
||||
# Group by file path, preserve PR order but sort paths for stable output
|
||||
by_path: Dict[str, List[Dict[str, Any]]] = defaultdict(list)
|
||||
for c in comments:
|
||||
by_path[c.get("path", "(unknown)")].append(c)
|
||||
|
||||
out_lines: List[str] = []
|
||||
out_lines.append("\n## Review Comments\n")
|
||||
for path in sorted(by_path.keys()):
|
||||
out_lines.append(f"### {path}\n")
|
||||
for c in by_path[path]:
|
||||
created = iso_to_utc_str(c.get("created_at"))
|
||||
url = c.get("html_url", "")
|
||||
diff_hunk = c.get("diff_hunk", "").rstrip()
|
||||
body = c.get("body", "")
|
||||
out_lines.append(f"- Created: {created} | Link: {url}")
|
||||
out_lines.append("")
|
||||
if diff_hunk:
|
||||
out_lines.append("```diff")
|
||||
out_lines.append(diff_hunk)
|
||||
out_lines.append("```")
|
||||
out_lines.append("")
|
||||
if body:
|
||||
out_lines.append(blockquote(body))
|
||||
out_lines.append("")
|
||||
return "\n".join(out_lines).rstrip() + "\n"
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="pr2md",
|
||||
description=(
|
||||
"Render a GitHub PR into Markdown including description, full diff, and review comments.\n"
|
||||
"Requires GitHub CLI (gh) to be installed and authenticated."
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"pr_number",
|
||||
nargs="?",
|
||||
help="Pull request number (optional; auto-detect from current branch if omitted)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"repo",
|
||||
nargs="?",
|
||||
help="Repository in 'owner/repo' form; inferred from git origin if omitted",
|
||||
)
|
||||
parser.add_argument("--reviewer", help="Only include comments from this reviewer (login)")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
require_gh()
|
||||
|
||||
# Disambiguate single positional arg: if only one is provided and it looks like owner/repo,
|
||||
# treat it as repo, not PR number.
|
||||
if args.pr_number and not args.repo and "/" in args.pr_number and not args.pr_number.isdigit():
|
||||
args.repo, args.pr_number = args.pr_number, None
|
||||
|
||||
repo = args.repo or detect_repo_from_git()
|
||||
if not repo:
|
||||
print(
|
||||
"Error: Could not determine repository from git origin. Pass repo as 'owner/repo'.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(2)
|
||||
|
||||
# Determine PR number: use provided, else try to find open/draft PR for current branch
|
||||
pr_number: Optional[int]
|
||||
if args.pr_number:
|
||||
try:
|
||||
pr_number = int(args.pr_number)
|
||||
except ValueError:
|
||||
print("Error: PR number must be an integer.", file=sys.stderr)
|
||||
sys.exit(2)
|
||||
else:
|
||||
# Detect from current branch
|
||||
code, branch_out, _ = _run(["git", "rev-parse", "--abbrev-ref", "HEAD"])
|
||||
branch = branch_out.strip() if code == 0 else ""
|
||||
if not branch or branch == "HEAD":
|
||||
print("Error: Not on a branch. Provide a PR number explicitly.", file=sys.stderr)
|
||||
sys.exit(2)
|
||||
|
||||
# Query open PRs and find one with matching head branch and owner
|
||||
owner = repo.split("/", 1)[0]
|
||||
code, out, err = _run([
|
||||
"gh", "pr", "list", "-R", repo, "--state", "open",
|
||||
"--json", "number,headRefName,isDraft,headRepositoryOwner",
|
||||
])
|
||||
if code != 0:
|
||||
print(f"Error: failed to list PRs: {err.strip()}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
try:
|
||||
pr_list = json.loads(out)
|
||||
except json.JSONDecodeError:
|
||||
print("Error: failed to parse PR list JSON.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
candidates = [
|
||||
pr for pr in pr_list
|
||||
if pr.get("headRefName") == branch and ((pr.get("headRepositoryOwner") or {}).get("login") == owner)
|
||||
]
|
||||
if not candidates:
|
||||
# Relax owner constraint if none found
|
||||
candidates = [pr for pr in pr_list if pr.get("headRefName") == branch]
|
||||
if not candidates:
|
||||
print(
|
||||
f"Error: No open PR found for branch '{branch}'. Provide a PR number.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(2)
|
||||
# If multiple, pick the first
|
||||
pr_number = int(candidates[0]["number"])
|
||||
|
||||
pr = pr_view(repo, pr_number)
|
||||
diff_text = pr_combined_diff(repo, pr)
|
||||
comments = pr_review_comments(repo, pr_number)
|
||||
|
||||
parts = [
|
||||
format_header(pr),
|
||||
format_description(pr.get("body")),
|
||||
format_diff(diff_text),
|
||||
format_review_comments(comments, args.reviewer),
|
||||
]
|
||||
sys.stdout.write("\n".join(p.rstrip() for p in parts if p))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
1034
codex-rs/review
Executable file
1034
codex-rs/review
Executable file
File diff suppressed because it is too large
Load Diff
206
codex-rs/study
Executable file
206
codex-rs/study
Executable file
@@ -0,0 +1,206 @@
|
||||
#!/usr/bin/env python3
|
||||
import argparse
|
||||
import concurrent.futures
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from typing import List, Optional, Tuple
|
||||
|
||||
|
||||
def _run(cmd: List[str], input_text: Optional[str] = None) -> Tuple[int, str, str]:
|
||||
proc = subprocess.run(
|
||||
cmd,
|
||||
input=input_text,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
return proc.returncode, proc.stdout, proc.stderr
|
||||
|
||||
|
||||
def require(cmd: str, hint: str):
|
||||
if shutil.which(cmd) is None:
|
||||
print(f"Error: required command '{cmd}' not found. {hint}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def detect_repo_root() -> Optional[str]:
|
||||
code, out, _ = _run(["git", "rev-parse", "--show-toplevel"])
|
||||
if code != 0:
|
||||
return None
|
||||
return out.strip()
|
||||
|
||||
|
||||
def ensure_dir(path: str):
|
||||
os.makedirs(path, exist_ok=True)
|
||||
|
||||
|
||||
def pr_file_paths(out_dir: str) -> List[str]:
|
||||
if not os.path.isdir(out_dir):
|
||||
return []
|
||||
paths = []
|
||||
for name in os.listdir(out_dir):
|
||||
if re.match(r"PR-\d+\.md$", name):
|
||||
paths.append(os.path.join(out_dir, name))
|
||||
# Sort by PR number ascending
|
||||
def prnum(p: str) -> int:
|
||||
m = re.search(r"(\d+)", os.path.basename(p))
|
||||
return int(m.group(1)) if m else 0
|
||||
return sorted(paths, key=prnum)
|
||||
|
||||
|
||||
def extract_pr_number(path: str) -> int:
|
||||
m = re.search(r"(\d+)", os.path.basename(path))
|
||||
return int(m.group(1)) if m else 0
|
||||
|
||||
|
||||
def build_prompt(contents: str, reviewer: str, out_path: str) -> str:
|
||||
# We rely on `codex exec --output-last-message {out_path}` to write the
|
||||
# final message to disk. Instruct the agent to ONLY produce the final
|
||||
# document as its last message (no meta commentary), to avoid clutter.
|
||||
return (
|
||||
f"{contents}\n---\n"
|
||||
f"Summarize the takeaways from this PR review by {reviewer} into a concise, generalizable, and practical guide with two checklists: DOs and DON'Ts. "
|
||||
f"Add short, accurate code examples in fenced code blocks to illustrate each key point. "
|
||||
f"Output ONLY the final document as your final message — no preamble, no status notes, no explanations about saving files. "
|
||||
f"The CLI will save your final message to {out_path}."
|
||||
)
|
||||
|
||||
|
||||
def run_codex_exec(prompt: str, last_message_file: Optional[str] = None) -> Tuple[int, str, str]:
|
||||
# Prefer a globally installed `codex`; fall back to cargo if needed.
|
||||
if shutil.which("codex") is not None:
|
||||
cmd = ["codex", "-c", "model_reasoning_effort=high", "exec"]
|
||||
if last_message_file:
|
||||
cmd.extend(["--output-last-message", last_message_file])
|
||||
return _run(cmd, input_text=prompt)
|
||||
# Fallback: use cargo run (may build; slower but reliable in dev)
|
||||
cmd = [
|
||||
"cargo",
|
||||
"run",
|
||||
"--quiet",
|
||||
"--bin",
|
||||
"codex",
|
||||
"--",
|
||||
"-c",
|
||||
"model_reasoning_effort=high",
|
||||
"exec",
|
||||
]
|
||||
if last_message_file:
|
||||
cmd.extend(["--output-last-message", last_message_file])
|
||||
return _run(cmd, input_text=prompt)
|
||||
|
||||
|
||||
def study_one(pr_md_path: str, reviewer: str, out_dir: str) -> Tuple[str, str]:
|
||||
pr_num = extract_pr_number(pr_md_path)
|
||||
try:
|
||||
with open(pr_md_path, "r", encoding="utf-8") as f:
|
||||
contents = f.read()
|
||||
ensure_dir(out_dir)
|
||||
out_path = os.path.join(out_dir, f"PR-{pr_num}-study.md")
|
||||
prompt = build_prompt(contents, reviewer, out_path)
|
||||
code, out, err = run_codex_exec(prompt, last_message_file=out_path)
|
||||
if code != 0:
|
||||
return pr_md_path, f"error: codex exec failed (exit {code}): {err.strip()}"
|
||||
# If Codex did not write the file for some reason, fall back to captured stdout.
|
||||
# Note: we only fallback when the output file is missing/empty to avoid
|
||||
# overwriting a valid summary produced by Codex.
|
||||
if (not os.path.isfile(out_path)) or os.path.getsize(out_path) == 0:
|
||||
try:
|
||||
with open(out_path, "w", encoding="utf-8") as f:
|
||||
f.write(out)
|
||||
except Exception as e:
|
||||
return pr_md_path, f"error: failed to write fallback output: {e}"
|
||||
return pr_md_path, "ok"
|
||||
except Exception as e:
|
||||
return pr_md_path, f"error: {e}"
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="study",
|
||||
description=(
|
||||
"Generate PR markdowns via lastprs, then summarize each via `codex exec`.\n"
|
||||
"Writes summaries to prs/<reviewer>/study/PR-<num>-study.md."
|
||||
),
|
||||
)
|
||||
parser.add_argument("days", type=int, help="Number of days to look back (N)")
|
||||
parser.add_argument("reviewer", help="GitHub login of the reviewer")
|
||||
parser.add_argument(
|
||||
"repo",
|
||||
nargs="?",
|
||||
help="Repository in 'owner/repo' form; inferred from git origin if omitted (passed through to lastprs)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--jobs",
|
||||
"-j",
|
||||
type=int,
|
||||
default=10,
|
||||
help="Parallel jobs for summaries (default: 10)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--skip-generate",
|
||||
action="store_true",
|
||||
help="Skip running lastprs and reuse existing prs/<reviewer>/ files",
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.days <= 0:
|
||||
print("Error: days must be a positive integer.", file=sys.stderr)
|
||||
sys.exit(2)
|
||||
|
||||
# Check dependencies
|
||||
require("gh", "Install GitHub CLI: https://cli.github.com")
|
||||
# lastprs is shipped with this repo; prefer local copy, then PATH
|
||||
script_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
lastprs_path = os.path.join(script_dir, "lastprs")
|
||||
if not (os.path.isfile(lastprs_path) and os.access(lastprs_path, os.X_OK)):
|
||||
require("lastprs", "Ensure the lastprs helper script is on PATH or present in this folder.")
|
||||
lastprs_path = "lastprs"
|
||||
|
||||
# Determine paths
|
||||
repo_root = detect_repo_root() or os.getcwd()
|
||||
prs_dir = os.path.join(repo_root, "prs", args.reviewer)
|
||||
summaries_dir = os.path.join(prs_dir, "study")
|
||||
|
||||
# 1) Generate PR markdowns if not skipping
|
||||
if not args.skip_generate:
|
||||
cmd = [lastprs_path, str(args.days), args.reviewer]
|
||||
if args.repo:
|
||||
cmd.append(args.repo)
|
||||
print("Generating PR markdowns via lastprs…", file=sys.stderr)
|
||||
code, out, err = _run(cmd)
|
||||
if code != 0:
|
||||
print(f"Error: lastprs failed (exit {code}): {err.strip()}", file=sys.stderr)
|
||||
sys.exit(code)
|
||||
# Echo a short summary
|
||||
sys.stderr.write(out.strip() + "\n")
|
||||
|
||||
# 2) Discover PR files
|
||||
files = pr_file_paths(prs_dir)
|
||||
if not files:
|
||||
print(f"No PR markdowns found in {prs_dir}.", file=sys.stderr)
|
||||
sys.exit(0)
|
||||
|
||||
print(f"Summarizing {len(files)} PR(s) to {summaries_dir}")
|
||||
|
||||
# 3) Summarize via codex exec
|
||||
results: List[Tuple[str, str]] = []
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=max(1, args.jobs)) as ex:
|
||||
futs = [ex.submit(study_one, p, args.reviewer, summaries_dir) for p in files]
|
||||
for fut in concurrent.futures.as_completed(futs):
|
||||
results.append(fut.result())
|
||||
|
||||
ok = sum(1 for _, s in results if s == "ok")
|
||||
failures = [(p, s) for p, s in results if s != "ok"]
|
||||
for p, s in failures:
|
||||
print(f"{os.path.basename(p)}: {s}", file=sys.stderr)
|
||||
print(f"Done. {ok}/{len(files)} summaries succeeded.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -51,6 +51,9 @@ pub(crate) struct App {
|
||||
|
||||
// Esc-backtracking state grouped
|
||||
pub(crate) backtrack: crate::app_backtrack::BacktrackState,
|
||||
|
||||
// Whether the terminal has focus (tracked via TuiEvent::FocusChanged)
|
||||
pub(crate) app_focused: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
impl App {
|
||||
@@ -93,6 +96,7 @@ impl App {
|
||||
deferred_history_lines: Vec::new(),
|
||||
commit_anim_running: Arc::new(AtomicBool::new(false)),
|
||||
backtrack: BacktrackState::default(),
|
||||
app_focused: Arc::new(AtomicBool::new(true)),
|
||||
};
|
||||
|
||||
let tui_events = tui.event_stream();
|
||||
@@ -124,6 +128,10 @@ impl App {
|
||||
TuiEvent::Key(key_event) => {
|
||||
self.handle_key_event(tui, key_event).await;
|
||||
}
|
||||
TuiEvent::FocusChanged(focused) => {
|
||||
self.chat_widget.set_input_focus(focused);
|
||||
self.app_focused.store(focused, Ordering::Relaxed);
|
||||
}
|
||||
TuiEvent::Paste(pasted) => {
|
||||
// Many terminals convert newlines to \r when pasting (e.g., iTerm2),
|
||||
// but tui-textarea expects \n. Normalize CR to LF.
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::app_event_sender::AppEventSender;
|
||||
use crate::notifications;
|
||||
use crate::tui::FrameRequester;
|
||||
use crate::user_approval_widget::ApprovalRequest;
|
||||
use bottom_pane_view::BottomPaneView;
|
||||
@@ -99,6 +100,16 @@ impl BottomPane {
|
||||
}
|
||||
}
|
||||
|
||||
/// Update whether the bottom pane's composer has input focus.
|
||||
pub(crate) fn set_has_input_focus(&mut self, has_focus: bool) {
|
||||
self.has_input_focus = has_focus;
|
||||
// Use existing API to propagate focus to the composer without changing the
|
||||
// current Ctrl-C hint visibility.
|
||||
self.composer
|
||||
.set_ctrl_c_quit_hint(self.ctrl_c_quit_hint, self.has_input_focus);
|
||||
self.request_redraw();
|
||||
}
|
||||
|
||||
pub fn desired_height(&self, width: u16) -> u16 {
|
||||
let top_margin = if self.active_view.is_some() { 0 } else { 1 };
|
||||
|
||||
@@ -373,6 +384,29 @@ impl BottomPane {
|
||||
|
||||
/// Called when the agent requests user approval.
|
||||
pub fn push_approval_request(&mut self, request: ApprovalRequest) {
|
||||
if !self.has_input_focus {
|
||||
// Send a system notification whenever an approval dialog is about to be shown.
|
||||
match &request {
|
||||
ApprovalRequest::Exec { command, .. } => {
|
||||
let preview = command.join(" ");
|
||||
let msg = format!("Approve \"{preview}\"?");
|
||||
notifications::send_os_notification(&msg);
|
||||
}
|
||||
ApprovalRequest::ApplyPatch {
|
||||
reason, grant_root, ..
|
||||
} => {
|
||||
let msg = if let Some(root) = grant_root {
|
||||
format!("Approve patch changes? Grant write to {}", root.display())
|
||||
} else if let Some(r) = reason {
|
||||
format!("Approve patch changes? {r}")
|
||||
} else {
|
||||
"Approve patch changes?".to_string()
|
||||
};
|
||||
notifications::send_os_notification(&msg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let request = if let Some(view) = self.active_view.as_mut() {
|
||||
match view.try_consume_approval_request(request) {
|
||||
Some(request) => request,
|
||||
|
||||
@@ -693,6 +693,12 @@ impl ChatWidget {
|
||||
.map_or(0, |c| c.desired_height(width))
|
||||
}
|
||||
|
||||
/// Update input focus state for the bottom pane/composer.
|
||||
pub(crate) fn set_input_focus(&mut self, has_focus: bool) {
|
||||
self.bottom_pane.set_has_input_focus(has_focus);
|
||||
self.request_redraw();
|
||||
}
|
||||
|
||||
pub(crate) fn handle_key_event(&mut self, key_event: KeyEvent) {
|
||||
match key_event {
|
||||
KeyEvent {
|
||||
|
||||
@@ -45,6 +45,7 @@ pub mod insert_history;
|
||||
pub mod live_wrap;
|
||||
mod markdown;
|
||||
mod markdown_stream;
|
||||
mod notifications;
|
||||
pub mod onboarding;
|
||||
mod pager_overlay;
|
||||
mod render;
|
||||
|
||||
62
codex-rs/tui/src/notifications.rs
Normal file
62
codex-rs/tui/src/notifications.rs
Normal file
@@ -0,0 +1,62 @@
|
||||
use std::process::Command;
|
||||
|
||||
/// Send a simple OS notification with a fixed app title.
|
||||
/// Best-effort and silently ignores errors if the platform/tooling is unavailable.
|
||||
pub fn send_os_notification(message: &str) {
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
fn detect_bundle_id() -> Option<&'static str> {
|
||||
use std::env;
|
||||
// Common terminal mappings.
|
||||
let term_program = env::var("TERM_PROGRAM").unwrap_or_default();
|
||||
match term_program.as_str() {
|
||||
"Apple_Terminal" => Some("com.apple.Terminal"),
|
||||
"iTerm.app" | "iTerm2" | "iTerm2.app" => Some("com.googlecode.iterm2"),
|
||||
"WezTerm" => Some("com.github.wez.wezterm"),
|
||||
"Alacritty" => Some("io.alacritty"),
|
||||
other => {
|
||||
// Fallback heuristics.
|
||||
let term = env::var("TERM").unwrap_or_default();
|
||||
if other.to_lowercase().contains("kitty") || term.contains("xterm-kitty") {
|
||||
Some("net.kovidgoyal.kitty")
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Prefer terminal-notifier on macOS and attempt to activate the current terminal on click.
|
||||
let mut cmd = Command::new("terminal-notifier");
|
||||
cmd.arg("-title").arg("Codex").arg("-message").arg(message);
|
||||
if let Some(bundle) = detect_bundle_id() {
|
||||
cmd.arg("-activate").arg(bundle);
|
||||
}
|
||||
let _ = cmd.spawn();
|
||||
}
|
||||
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
{
|
||||
// Use notify-send if available (Linux/BSD). Title first, then body.
|
||||
let _ = Command::new("notify-send")
|
||||
.arg("Codex")
|
||||
.arg(message)
|
||||
.spawn();
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
// Best-effort: try a lightweight Toast via PowerShell if available.
|
||||
// Fall back to no-op if this fails.
|
||||
let ps = r#"
|
||||
Add-Type -AssemblyName System.Windows.Forms | Out-Null
|
||||
[System.Windows.Forms.MessageBox]::Show($args[0], 'Codex') | Out-Null
|
||||
"#;
|
||||
let _ = Command::new("powershell")
|
||||
.arg("-NoProfile")
|
||||
.arg("-Command")
|
||||
.arg(ps)
|
||||
.arg(message)
|
||||
.spawn();
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,9 @@ use crossterm::SynchronizedUpdate;
|
||||
use crossterm::cursor;
|
||||
use crossterm::cursor::MoveTo;
|
||||
use crossterm::event::DisableBracketedPaste;
|
||||
use crossterm::event::DisableFocusChange;
|
||||
use crossterm::event::EnableBracketedPaste;
|
||||
use crossterm::event::EnableFocusChange;
|
||||
use crossterm::event::Event;
|
||||
use crossterm::event::KeyCode;
|
||||
use crossterm::event::KeyEvent;
|
||||
@@ -49,6 +51,8 @@ pub type Terminal = CustomTerminal<CrosstermBackend<Stdout>>;
|
||||
|
||||
pub fn set_modes() -> Result<()> {
|
||||
execute!(stdout(), EnableBracketedPaste)?;
|
||||
// Enable focus change reporting where supported; ignore errors on unsupported terminals.
|
||||
let _ = execute!(stdout(), EnableFocusChange);
|
||||
|
||||
enable_raw_mode()?;
|
||||
// Enable keyboard enhancement flags so modifiers for keys like Enter are disambiguated.
|
||||
@@ -116,6 +120,8 @@ pub fn restore() -> Result<()> {
|
||||
// Pop may fail on platforms that didn't support the push; ignore errors.
|
||||
let _ = execute!(stdout(), PopKeyboardEnhancementFlags);
|
||||
execute!(stdout(), DisableBracketedPaste)?;
|
||||
// Disable focus change reporting if it was enabled; ignore errors.
|
||||
let _ = execute!(stdout(), DisableFocusChange);
|
||||
disable_raw_mode()?;
|
||||
let _ = execute!(stdout(), crossterm::cursor::Show);
|
||||
Ok(())
|
||||
@@ -154,6 +160,8 @@ pub enum TuiEvent {
|
||||
Key(KeyEvent),
|
||||
Paste(String),
|
||||
Draw,
|
||||
/// Terminal focus changed: true when focused, false when unfocused.
|
||||
FocusChanged(bool),
|
||||
AttachImage {
|
||||
path: PathBuf,
|
||||
width: u32,
|
||||
@@ -323,12 +331,12 @@ impl Tui {
|
||||
}
|
||||
Err(_) => {
|
||||
// Fall back to normal key handling if no image is available.
|
||||
yield TuiEvent::Key(key_event);
|
||||
}
|
||||
}
|
||||
}
|
||||
yield TuiEvent::Key(key_event);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
crossterm::event::Event::Key(key_event) => {
|
||||
crossterm::event::Event::Key(key_event) => {
|
||||
#[cfg(unix)]
|
||||
if matches!(
|
||||
key_event,
|
||||
@@ -363,6 +371,12 @@ impl Tui {
|
||||
Event::Resize(_, _) => {
|
||||
yield TuiEvent::Draw;
|
||||
}
|
||||
Event::FocusGained => {
|
||||
yield TuiEvent::FocusChanged(true);
|
||||
}
|
||||
Event::FocusLost => {
|
||||
yield TuiEvent::FocusChanged(false);
|
||||
}
|
||||
Event::Paste(pasted) => {
|
||||
yield TuiEvent::Paste(pasted);
|
||||
}
|
||||
|
||||
140
prs/bolinfest/PR-1164.md
Normal file
140
prs/bolinfest/PR-1164.md
Normal file
@@ -0,0 +1,140 @@
|
||||
# PR #1164: fix(codex-rs): use codex-mini-latest as default
|
||||
|
||||
- URL: https://github.com/openai/codex/pull/1164
|
||||
- Author: fouad-openai
|
||||
- Created: 2025-05-29 22:38:22 UTC
|
||||
- Updated: 2025-05-29 23:55:29 UTC
|
||||
- Changes: +7/-7, Files changed: 4, Commits: 3
|
||||
|
||||
## Description
|
||||
|
||||
(No description.)
|
||||
|
||||
## Full Diff
|
||||
|
||||
```diff
|
||||
diff --git a/codex-rs/README.md b/codex-rs/README.md
|
||||
index a0e3f5846e..a26f5b6d1d 100644
|
||||
--- a/codex-rs/README.md
|
||||
+++ b/codex-rs/README.md
|
||||
@@ -32,7 +32,7 @@ The `config.toml` file supports the following options:
|
||||
The model that Codex should use.
|
||||
|
||||
```toml
|
||||
-model = "o3" # overrides the default of "o4-mini"
|
||||
+model = "o3" # overrides the default of "codex-mini-latest"
|
||||
```
|
||||
|
||||
### model_provider
|
||||
@@ -155,7 +155,7 @@ Users can specify config values at multiple levels. Order of precedence is as fo
|
||||
1. custom command-line argument, e.g., `--model o3`
|
||||
2. as part of a profile, where the `--profile` is specified via a CLI (or in the config file itself)
|
||||
3. as an entry in `config.toml`, e.g., `model = "o3"`
|
||||
-4. the default value that comes with Codex CLI (i.e., Codex CLI defaults to `o4-mini`)
|
||||
+4. the default value that comes with Codex CLI (i.e., Codex CLI defaults to `codex-mini-latest`)
|
||||
|
||||
### sandbox_permissions
|
||||
|
||||
diff --git a/codex-rs/common/src/config_override.rs b/codex-rs/common/src/config_override.rs
|
||||
index bd2c036940..610195d6d1 100644
|
||||
--- a/codex-rs/common/src/config_override.rs
|
||||
+++ b/codex-rs/common/src/config_override.rs
|
||||
@@ -23,7 +23,7 @@ pub struct CliConfigOverrides {
|
||||
/// parse as JSON, the raw string is used as a literal.
|
||||
///
|
||||
/// Examples:
|
||||
- /// - `-c model="o4-mini"`
|
||||
+ /// - `-c model="o3"`
|
||||
/// - `-c 'sandbox_permissions=["disk-full-read-access"]'`
|
||||
/// - `-c shell_environment_policy.inherit=all`
|
||||
#[arg(
|
||||
@@ -61,7 +61,7 @@ impl CliConfigOverrides {
|
||||
|
||||
// Attempt to parse as JSON. If that fails, treat it as a raw
|
||||
// string. This allows convenient usage such as
|
||||
- // `-c model=o4-mini` without the quotes.
|
||||
+ // `-c model=o3` without the quotes.
|
||||
let value: Value = match parse_toml_value(value_str) {
|
||||
Ok(v) => v,
|
||||
Err(_) => Value::String(value_str.to_string()),
|
||||
diff --git a/codex-rs/core/src/flags.rs b/codex-rs/core/src/flags.rs
|
||||
index e8cc973c99..c21ef67026 100644
|
||||
--- a/codex-rs/core/src/flags.rs
|
||||
+++ b/codex-rs/core/src/flags.rs
|
||||
@@ -3,7 +3,7 @@ use std::time::Duration;
|
||||
use env_flags::env_flags;
|
||||
|
||||
env_flags! {
|
||||
- pub OPENAI_DEFAULT_MODEL: &str = "o4-mini";
|
||||
+ pub OPENAI_DEFAULT_MODEL: &str = "codex-mini-latest";
|
||||
pub OPENAI_API_BASE: &str = "https://api.openai.com/v1";
|
||||
|
||||
/// Fallback when the provider-specific key is not set.
|
||||
diff --git a/codex-rs/core/src/protocol.rs b/codex-rs/core/src/protocol.rs
|
||||
index 1b9871edd8..fc18f1d821 100644
|
||||
--- a/codex-rs/core/src/protocol.rs
|
||||
+++ b/codex-rs/core/src/protocol.rs
|
||||
@@ -561,7 +561,7 @@ mod tests {
|
||||
id: "1234".to_string(),
|
||||
msg: EventMsg::SessionConfigured(SessionConfiguredEvent {
|
||||
session_id,
|
||||
- model: "o4-mini".to_string(),
|
||||
+ model: "codex-mini-latest".to_string(),
|
||||
history_log_id: 0,
|
||||
history_entry_count: 0,
|
||||
}),
|
||||
@@ -569,7 +569,7 @@ mod tests {
|
||||
let serialized = serde_json::to_string(&event).unwrap();
|
||||
assert_eq!(
|
||||
serialized,
|
||||
- r#"{"id":"1234","msg":{"type":"session_configured","session_id":"67e55044-10b1-426f-9247-bb680e5fe0c8","model":"o4-mini","history_log_id":0,"history_entry_count":0}}"#
|
||||
+ r#"{"id":"1234","msg":{"type":"session_configured","session_id":"67e55044-10b1-426f-9247-bb680e5fe0c8","model":"codex-mini-latest","history_log_id":0,"history_entry_count":0}}"#
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Review Comments
|
||||
|
||||
### codex-rs/common/src/config_override.rs
|
||||
|
||||
- Created: 2025-05-29 22:47:43 UTC | Link: https://github.com/openai/codex/pull/1164#discussion_r2114856819
|
||||
|
||||
```diff
|
||||
@@ -23,7 +23,7 @@ pub struct CliConfigOverrides {
|
||||
/// parse as JSON, the raw string is used as a literal.
|
||||
///
|
||||
/// Examples:
|
||||
- /// - `-c model="o4-mini"`
|
||||
+ /// - `-c model="codex-mini-latest"`
|
||||
```
|
||||
|
||||
> This stay as-is because this is meant to demonstrate an override.
|
||||
|
||||
- Created: 2025-05-29 23:02:11 UTC | Link: https://github.com/openai/codex/pull/1164#discussion_r2114868563
|
||||
|
||||
```diff
|
||||
@@ -23,7 +23,7 @@ pub struct CliConfigOverrides {
|
||||
/// parse as JSON, the raw string is used as a literal.
|
||||
///
|
||||
/// Examples:
|
||||
- /// - `-c model="o4-mini"`
|
||||
+ /// - `-c model="codex-mini-latest"`
|
||||
```
|
||||
|
||||
> I think it can be anything other than the default, which will now be `codex-mini-latest`, right?
|
||||
|
||||
### codex-rs/mcp-server/src/codex_tool_config.rs
|
||||
|
||||
- Created: 2025-05-29 22:48:37 UTC | Link: https://github.com/openai/codex/pull/1164#discussion_r2114857544
|
||||
|
||||
```diff
|
||||
@@ -19,7 +19,7 @@ pub(crate) struct CodexToolCallParam {
|
||||
/// The *initial user prompt* to start the Codex conversation.
|
||||
pub prompt: String,
|
||||
|
||||
- /// Optional override for the model name (e.g. "o3", "o4-mini")
|
||||
+ /// Optional override for the model name (e.g. "o3", "codex-mini-latest")
|
||||
```
|
||||
|
||||
> Again, I would use `o4-mini` as the example of an override.
|
||||
759
prs/bolinfest/PR-1211.md
Normal file
759
prs/bolinfest/PR-1211.md
Normal file
@@ -0,0 +1,759 @@
|
||||
# PR #1211: codex-rs: make tool calls prettier
|
||||
|
||||
- URL: https://github.com/openai/codex/pull/1211
|
||||
- Author: rgwood-dd
|
||||
- Created: 2025-06-03 18:48:39 UTC
|
||||
- Updated: 2025-06-03 21:29:36 UTC
|
||||
- Changes: +352/-50, Files changed: 7, Commits: 4
|
||||
|
||||
## Description
|
||||
|
||||
This PR overhauls how active tool calls and completed tool calls are displayed:
|
||||
|
||||
1. More use of colour to indicate success/failure and distinguish between components like tool name+arguments
|
||||
2. Previously, the entire `CallToolResult` was serialized to JSON and pretty-printed. Now, we extract each individual `CallToolResultContent` and print those
|
||||
1. The previous solution was wasting space by unnecessarily showing details of the `CallToolResult` struct to users, without formatting the actual tool call results nicely
|
||||
2. We're now able to show users more information from tool results in less space, with nicer formatting when tools return JSON results
|
||||
|
||||
### Before:
|
||||
|
||||
<img width="1251" alt="Screenshot 2025-06-03 at 11 24 26" src="https://github.com/user-attachments/assets/5a58f222-219c-4c53-ace7-d887194e30cf" />
|
||||
|
||||
### After:
|
||||
|
||||
<img width="1265" alt="image" src="https://github.com/user-attachments/assets/99fe54d0-9ebe-406a-855b-7aa529b91274" />
|
||||
|
||||
## Future Work
|
||||
|
||||
1. Integrate image tool result handling better. We should be able to display images even if they're not the first `CallToolResultContent`
|
||||
2. Users should have some way to view the full version of truncated tool results
|
||||
3. It would be nice to add some left padding for tool results, make it more clear that they are results. This is doable, just a little fiddly due to the way `first_visible_line` scrolling works
|
||||
4. There's almost certainly a better way to format JSON than "all on 1 line with spaces to make Ratatui wrapping work". But I think that works OK for now.
|
||||
|
||||
## Full Diff
|
||||
|
||||
```diff
|
||||
diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock
|
||||
index 694e11383f..ae574ba303 100644
|
||||
--- a/codex-rs/Cargo.lock
|
||||
+++ b/codex-rs/Cargo.lock
|
||||
@@ -768,6 +768,7 @@ dependencies = [
|
||||
"tui-input",
|
||||
"tui-markdown",
|
||||
"tui-textarea",
|
||||
+ "unicode-segmentation",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
diff --git a/codex-rs/mcp-types/src/lib.rs b/codex-rs/mcp-types/src/lib.rs
|
||||
index afd6f4ad63..0ed518535f 100644
|
||||
--- a/codex-rs/mcp-types/src/lib.rs
|
||||
+++ b/codex-rs/mcp-types/src/lib.rs
|
||||
@@ -1144,6 +1144,7 @@ pub enum ServerRequest {
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
|
||||
#[serde(untagged)]
|
||||
+#[allow(clippy::large_enum_variant)]
|
||||
pub enum ServerResult {
|
||||
Result(Result),
|
||||
InitializeResult(InitializeResult),
|
||||
diff --git a/codex-rs/tui/Cargo.toml b/codex-rs/tui/Cargo.toml
|
||||
index 235f5f0c7a..ffc107e831 100644
|
||||
--- a/codex-rs/tui/Cargo.toml
|
||||
+++ b/codex-rs/tui/Cargo.toml
|
||||
@@ -34,7 +34,7 @@ ratatui = { version = "0.29.0", features = [
|
||||
] }
|
||||
ratatui-image = "8.0.0"
|
||||
regex-lite = "0.1"
|
||||
-serde_json = "1"
|
||||
+serde_json = { version = "1", features = ["preserve_order"] }
|
||||
shlex = "1.3.0"
|
||||
strum = "0.27.1"
|
||||
strum_macros = "0.27.1"
|
||||
@@ -51,6 +51,7 @@ tracing-subscriber = { version = "0.3.19", features = ["env-filter"] }
|
||||
tui-input = "0.11.1"
|
||||
tui-markdown = "0.3.3"
|
||||
tui-textarea = "0.7.0"
|
||||
+unicode-segmentation = "1.12.0"
|
||||
uuid = "1"
|
||||
|
||||
[dev-dependencies]
|
||||
diff --git a/codex-rs/tui/src/conversation_history_widget.rs b/codex-rs/tui/src/conversation_history_widget.rs
|
||||
index 9242e00389..a23e00d776 100644
|
||||
--- a/codex-rs/tui/src/conversation_history_widget.rs
|
||||
+++ b/codex-rs/tui/src/conversation_history_widget.rs
|
||||
@@ -299,7 +299,6 @@ impl ConversationHistoryWidget {
|
||||
for entry in self.entries.iter_mut() {
|
||||
if let HistoryCell::ActiveMcpToolCall {
|
||||
call_id: history_id,
|
||||
- fq_tool_name,
|
||||
invocation,
|
||||
start,
|
||||
..
|
||||
@@ -307,7 +306,7 @@ impl ConversationHistoryWidget {
|
||||
{
|
||||
if &call_id == history_id {
|
||||
let completed = HistoryCell::new_completed_mcp_tool_call(
|
||||
- fq_tool_name.clone(),
|
||||
+ width,
|
||||
invocation.clone(),
|
||||
*start,
|
||||
success,
|
||||
diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs
|
||||
index a1fc672c6b..481576b5b3 100644
|
||||
--- a/codex-rs/tui/src/history_cell.rs
|
||||
+++ b/codex-rs/tui/src/history_cell.rs
|
||||
@@ -2,6 +2,7 @@ use crate::cell_widget::CellWidget;
|
||||
use crate::exec_command::escape_command;
|
||||
use crate::markdown::append_markdown;
|
||||
use crate::text_block::TextBlock;
|
||||
+use crate::text_formatting::format_and_truncate_tool_result;
|
||||
use base64::Engine;
|
||||
use codex_ansi_escape::ansi_escape_line;
|
||||
use codex_common::elapsed::format_duration;
|
||||
@@ -14,6 +15,7 @@ use image::DynamicImage;
|
||||
use image::GenericImageView;
|
||||
use image::ImageReader;
|
||||
use lazy_static::lazy_static;
|
||||
+use mcp_types::EmbeddedResourceResource;
|
||||
use ratatui::prelude::*;
|
||||
use ratatui::style::Color;
|
||||
use ratatui::style::Modifier;
|
||||
@@ -73,18 +75,14 @@ pub(crate) enum HistoryCell {
|
||||
/// An MCP tool call that has not finished yet.
|
||||
ActiveMcpToolCall {
|
||||
call_id: String,
|
||||
- /// `server.tool` fully-qualified name so we can show a concise label
|
||||
- fq_tool_name: String,
|
||||
- /// Formatted invocation that mirrors the `$ cmd ...` style of exec
|
||||
- /// commands. We keep this around so the completed state can reuse the
|
||||
- /// exact same text without re-formatting.
|
||||
- invocation: String,
|
||||
+ /// Formatted line that shows the command name and arguments
|
||||
+ invocation: Line<'static>,
|
||||
start: Instant,
|
||||
view: TextBlock,
|
||||
},
|
||||
|
||||
/// Completed MCP tool call where we show the result serialized as JSON.
|
||||
- CompletedMcpToolCallWithTextOutput { view: TextBlock },
|
||||
+ CompletedMcpToolCall { view: TextBlock },
|
||||
|
||||
/// Completed MCP tool call where the result is an image.
|
||||
/// Admittedly, [mcp_types::CallToolResult] can have multiple content types,
|
||||
@@ -289,8 +287,6 @@ impl HistoryCell {
|
||||
tool: String,
|
||||
arguments: Option<serde_json::Value>,
|
||||
) -> Self {
|
||||
- let fq_tool_name = format!("{server}.{tool}");
|
||||
-
|
||||
// Format the arguments as compact JSON so they roughly fit on one
|
||||
// line. If there are no arguments we keep it empty so the invocation
|
||||
// mirrors a function-style call.
|
||||
@@ -302,29 +298,30 @@ impl HistoryCell {
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
- let invocation = if args_str.is_empty() {
|
||||
- format!("{fq_tool_name}()")
|
||||
- } else {
|
||||
- format!("{fq_tool_name}({args_str})")
|
||||
- };
|
||||
+ let invocation_spans = vec![
|
||||
+ Span::styled(server, Style::default().fg(Color::Blue)),
|
||||
+ Span::raw("."),
|
||||
+ Span::styled(tool, Style::default().fg(Color::Blue)),
|
||||
+ Span::raw("("),
|
||||
+ Span::styled(args_str, Style::default().fg(Color::Gray)),
|
||||
+ Span::raw(")"),
|
||||
+ ];
|
||||
+ let invocation = Line::from(invocation_spans);
|
||||
|
||||
let start = Instant::now();
|
||||
let title_line = Line::from(vec!["tool".magenta(), " running...".dim()]);
|
||||
- let lines: Vec<Line<'static>> = vec![
|
||||
- title_line,
|
||||
- Line::from(format!("$ {invocation}")),
|
||||
- Line::from(""),
|
||||
- ];
|
||||
+ let lines: Vec<Line<'static>> = vec![title_line, invocation.clone(), Line::from("")];
|
||||
|
||||
HistoryCell::ActiveMcpToolCall {
|
||||
call_id,
|
||||
- fq_tool_name,
|
||||
invocation,
|
||||
start,
|
||||
view: TextBlock::new(lines),
|
||||
}
|
||||
}
|
||||
|
||||
+ /// If the first content is an image, return a new cell with the image.
|
||||
+ /// TODO(rgwood-dd): Handle images properly even if they're not the first result.
|
||||
fn try_new_completed_mcp_tool_call_with_image_output(
|
||||
result: &Result<mcp_types::CallToolResult, String>,
|
||||
) -> Option<Self> {
|
||||
@@ -370,8 +367,8 @@ impl HistoryCell {
|
||||
}
|
||||
|
||||
pub(crate) fn new_completed_mcp_tool_call(
|
||||
- fq_tool_name: String,
|
||||
- invocation: String,
|
||||
+ num_cols: u16,
|
||||
+ invocation: Line<'static>,
|
||||
start: Instant,
|
||||
success: bool,
|
||||
result: Result<mcp_types::CallToolResult, String>,
|
||||
@@ -384,36 +381,70 @@ impl HistoryCell {
|
||||
let status_str = if success { "success" } else { "failed" };
|
||||
let title_line = Line::from(vec![
|
||||
"tool".magenta(),
|
||||
- format!(" {fq_tool_name} ({status_str}, duration: {})", duration).dim(),
|
||||
+ " ".into(),
|
||||
+ if success {
|
||||
+ status_str.green()
|
||||
+ } else {
|
||||
+ status_str.red()
|
||||
+ },
|
||||
+ format!(", duration: {duration}").gray(),
|
||||
]);
|
||||
|
||||
let mut lines: Vec<Line<'static>> = Vec::new();
|
||||
lines.push(title_line);
|
||||
- lines.push(Line::from(format!("$ {invocation}")));
|
||||
-
|
||||
- // Convert result into serde_json::Value early so we don't have to
|
||||
- // worry about lifetimes inside the match arm.
|
||||
- let result_val = result.map(|r| {
|
||||
- serde_json::to_value(r)
|
||||
- .unwrap_or_else(|_| serde_json::Value::String("<serialization error>".into()))
|
||||
- });
|
||||
-
|
||||
- if let Ok(res_val) = result_val {
|
||||
- let json_pretty =
|
||||
- serde_json::to_string_pretty(&res_val).unwrap_or_else(|_| res_val.to_string());
|
||||
- let mut iter = json_pretty.lines();
|
||||
- for raw in iter.by_ref().take(TOOL_CALL_MAX_LINES) {
|
||||
- lines.push(Line::from(raw.to_string()).dim());
|
||||
+ lines.push(invocation);
|
||||
+
|
||||
+ match result {
|
||||
+ Ok(mcp_types::CallToolResult { content, .. }) => {
|
||||
+ if !content.is_empty() {
|
||||
+ lines.push(Line::from(""));
|
||||
+
|
||||
+ for tool_call_result in content {
|
||||
+ let line_text = match tool_call_result {
|
||||
+ mcp_types::CallToolResultContent::TextContent(text) => {
|
||||
+ format_and_truncate_tool_result(
|
||||
+ &text.text,
|
||||
+ TOOL_CALL_MAX_LINES,
|
||||
+ num_cols as usize,
|
||||
+ )
|
||||
+ }
|
||||
+ mcp_types::CallToolResultContent::ImageContent(_) => {
|
||||
+ // TODO show images even if they're not the first result, will require a refactor of `CompletedMcpToolCall`
|
||||
+ "<image content>".to_string()
|
||||
+ }
|
||||
+ mcp_types::CallToolResultContent::AudioContent(_) => {
|
||||
+ "<audio content>".to_string()
|
||||
+ }
|
||||
+ mcp_types::CallToolResultContent::EmbeddedResource(resource) => {
|
||||
+ let uri = match resource.resource {
|
||||
+ EmbeddedResourceResource::TextResourceContents(text) => {
|
||||
+ text.uri
|
||||
+ }
|
||||
+ EmbeddedResourceResource::BlobResourceContents(blob) => {
|
||||
+ blob.uri
|
||||
+ }
|
||||
+ };
|
||||
+ format!("embedded resource: {uri}")
|
||||
+ }
|
||||
+ };
|
||||
+ lines.push(Line::styled(line_text, Style::default().fg(Color::Gray)));
|
||||
+ }
|
||||
+ }
|
||||
+
|
||||
+ lines.push(Line::from(""));
|
||||
}
|
||||
- let remaining = iter.count();
|
||||
- if remaining > 0 {
|
||||
- lines.push(Line::from(format!("... {} additional lines", remaining)).dim());
|
||||
+ Err(e) => {
|
||||
+ lines.push(Line::from(vec![
|
||||
+ Span::styled(
|
||||
+ "Error: ",
|
||||
+ Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
|
||||
+ ),
|
||||
+ Span::raw(e),
|
||||
+ ]));
|
||||
}
|
||||
- }
|
||||
-
|
||||
- lines.push(Line::from(""));
|
||||
+ };
|
||||
|
||||
- HistoryCell::CompletedMcpToolCallWithTextOutput {
|
||||
+ HistoryCell::CompletedMcpToolCall {
|
||||
view: TextBlock::new(lines),
|
||||
}
|
||||
}
|
||||
@@ -520,7 +551,7 @@ impl CellWidget for HistoryCell {
|
||||
| HistoryCell::ErrorEvent { view }
|
||||
| HistoryCell::SessionInfo { view }
|
||||
| HistoryCell::CompletedExecCommand { view }
|
||||
- | HistoryCell::CompletedMcpToolCallWithTextOutput { view }
|
||||
+ | HistoryCell::CompletedMcpToolCall { view }
|
||||
| HistoryCell::PendingPatch { view }
|
||||
| HistoryCell::ActiveExecCommand { view, .. }
|
||||
| HistoryCell::ActiveMcpToolCall { view, .. } => view.height(width),
|
||||
@@ -541,7 +572,7 @@ impl CellWidget for HistoryCell {
|
||||
| HistoryCell::ErrorEvent { view }
|
||||
| HistoryCell::SessionInfo { view }
|
||||
| HistoryCell::CompletedExecCommand { view }
|
||||
- | HistoryCell::CompletedMcpToolCallWithTextOutput { view }
|
||||
+ | HistoryCell::CompletedMcpToolCall { view }
|
||||
| HistoryCell::PendingPatch { view }
|
||||
| HistoryCell::ActiveExecCommand { view, .. }
|
||||
| HistoryCell::ActiveMcpToolCall { view, .. } => {
|
||||
diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs
|
||||
index df85673ef1..535168e324 100644
|
||||
--- a/codex-rs/tui/src/lib.rs
|
||||
+++ b/codex-rs/tui/src/lib.rs
|
||||
@@ -34,6 +34,7 @@ mod scroll_event_helper;
|
||||
mod slash_command;
|
||||
mod status_indicator_widget;
|
||||
mod text_block;
|
||||
+mod text_formatting;
|
||||
mod tui;
|
||||
mod user_approval_widget;
|
||||
|
||||
diff --git a/codex-rs/tui/src/text_formatting.rs b/codex-rs/tui/src/text_formatting.rs
|
||||
new file mode 100644
|
||||
index 0000000000..e79fbf7a08
|
||||
--- /dev/null
|
||||
+++ b/codex-rs/tui/src/text_formatting.rs
|
||||
@@ -0,0 +1,268 @@
|
||||
+use unicode_segmentation::UnicodeSegmentation;
|
||||
+
|
||||
+/// Truncate a tool result to fit within the given height and width. If the text is valid JSON, we format it in a compact way before truncating.
|
||||
+/// This is a best-effort approach that may not work perfectly for text where 1 grapheme is rendered as multiple terminal cells.
|
||||
+pub(crate) fn format_and_truncate_tool_result(
|
||||
+ text: &str,
|
||||
+ max_lines: usize,
|
||||
+ line_width: usize,
|
||||
+) -> String {
|
||||
+ // Work out the maximum number of graphemes we can display for a result.
|
||||
+ // It's not guaranteed that 1 grapheme = 1 cell, so we subtract 1 per line as a fudge factor.
|
||||
+ // It also won't handle future terminal resizes properly, but it's an OK approximation for now.
|
||||
+ let max_graphemes = (max_lines * line_width).saturating_sub(max_lines);
|
||||
+
|
||||
+ if let Some(formatted_json) = format_json_compact(text) {
|
||||
+ truncate_text(&formatted_json, max_graphemes)
|
||||
+ } else {
|
||||
+ truncate_text(text, max_graphemes)
|
||||
+ }
|
||||
+}
|
||||
+
|
||||
+/// Format JSON text in a compact single-line format with spaces for better Ratatui wrapping.
|
||||
+/// Ex: `{"a":"b",c:["d","e"]}` -> `{"a": "b", "c": ["d", "e"]}`
|
||||
+/// Returns the formatted JSON string if the input is valid JSON, otherwise returns None.
|
||||
+/// This is a little complicated, but it's necessary because Ratatui's wrapping is *very* limited
|
||||
+/// and can only do line breaks at whitespace. If we use the default serde_json format, we get lines
|
||||
+/// without spaces that Ratatui can't wrap nicely. If we use the serde_json pretty format as-is,
|
||||
+/// it's much too sparse and uses too many terminal rows.
|
||||
+/// Relevant issue: https://github.com/ratatui/ratatui/issues/293
|
||||
+pub(crate) fn format_json_compact(text: &str) -> Option<String> {
|
||||
+ let json = serde_json::from_str::<serde_json::Value>(text).ok()?;
|
||||
+ let json_pretty = serde_json::to_string_pretty(&json).unwrap_or_else(|_| json.to_string());
|
||||
+
|
||||
+ // Convert multi-line pretty JSON to compact single-line format by removing newlines and excess whitespace
|
||||
+ let mut result = String::new();
|
||||
+ let mut chars = json_pretty.chars().peekable();
|
||||
+ let mut in_string = false;
|
||||
+ let mut escape_next = false;
|
||||
+
|
||||
+ // Iterate over the characters in the JSON string, adding spaces after : and , but only when not in a string
|
||||
+ while let Some(ch) = chars.next() {
|
||||
+ match ch {
|
||||
+ '"' if !escape_next => {
|
||||
+ in_string = !in_string;
|
||||
+ result.push(ch);
|
||||
+ }
|
||||
+ '\\' if in_string => {
|
||||
+ escape_next = !escape_next;
|
||||
+ result.push(ch);
|
||||
+ }
|
||||
+ '\n' | '\r' if !in_string => {
|
||||
+ // Skip newlines when not in a string
|
||||
+ }
|
||||
+ ' ' | '\t' if !in_string => {
|
||||
+ // Add a space after : and , but only when not in a string
|
||||
+ if let Some(&next_ch) = chars.peek() {
|
||||
+ if let Some(last_ch) = result.chars().last() {
|
||||
+ if (last_ch == ':' || last_ch == ',') && !matches!(next_ch, '}' | ']') {
|
||||
+ result.push(' ');
|
||||
+ }
|
||||
+ }
|
||||
+ }
|
||||
+ }
|
||||
+ _ => {
|
||||
+ if escape_next && in_string {
|
||||
+ escape_next = false;
|
||||
+ }
|
||||
+ result.push(ch);
|
||||
+ }
|
||||
+ }
|
||||
+ }
|
||||
+
|
||||
+ Some(result)
|
||||
+}
|
||||
+
|
||||
+/// Truncate `text` to `max_graphemes` graphemes. Using graphemes to avoid accidentally truncating in the middle of a multi-codepoint character.
|
||||
+pub(crate) fn truncate_text(text: &str, max_graphemes: usize) -> String {
|
||||
+ let mut graphemes = text.grapheme_indices(true);
|
||||
+
|
||||
+ // Check if there's a grapheme at position max_graphemes (meaning there are more than max_graphemes total)
|
||||
+ if let Some((byte_index, _)) = graphemes.nth(max_graphemes) {
|
||||
+ // There are more than max_graphemes, so we need to truncate
|
||||
+ if max_graphemes >= 3 {
|
||||
+ // Truncate to max_graphemes - 3 and add "..." to stay within limit
|
||||
+ let mut truncate_graphemes = text.grapheme_indices(true);
|
||||
+ if let Some((truncate_byte_index, _)) = truncate_graphemes.nth(max_graphemes - 3) {
|
||||
+ let truncated = &text[..truncate_byte_index];
|
||||
+ format!("{}...", truncated)
|
||||
+ } else {
|
||||
+ text.to_string()
|
||||
+ }
|
||||
+ } else {
|
||||
+ // max_graphemes < 3, so just return first max_graphemes without "..."
|
||||
+ let truncated = &text[..byte_index];
|
||||
+ truncated.to_string()
|
||||
+ }
|
||||
+ } else {
|
||||
+ // There are max_graphemes or fewer graphemes, return original text
|
||||
+ text.to_string()
|
||||
+ }
|
||||
+}
|
||||
+
|
||||
+#[cfg(test)]
|
||||
+mod tests {
|
||||
+ #![allow(clippy::unwrap_used)]
|
||||
+ use super::*;
|
||||
+ use pretty_assertions::assert_eq;
|
||||
+
|
||||
+ #[test]
|
||||
+ fn test_truncate_text() {
|
||||
+ let text = "Hello, world!";
|
||||
+ let truncated = truncate_text(text, 8);
|
||||
+ assert_eq!(truncated, "Hello...");
|
||||
+ }
|
||||
+
|
||||
+ #[test]
|
||||
+ fn test_truncate_empty_string() {
|
||||
+ let text = "";
|
||||
+ let truncated = truncate_text(text, 5);
|
||||
+ assert_eq!(truncated, "");
|
||||
+ }
|
||||
+
|
||||
+ #[test]
|
||||
+ fn test_truncate_max_graphemes_zero() {
|
||||
+ let text = "Hello";
|
||||
+ let truncated = truncate_text(text, 0);
|
||||
+ assert_eq!(truncated, "");
|
||||
+ }
|
||||
+
|
||||
+ #[test]
|
||||
+ fn test_truncate_max_graphemes_one() {
|
||||
+ let text = "Hello";
|
||||
+ let truncated = truncate_text(text, 1);
|
||||
+ assert_eq!(truncated, "H");
|
||||
+ }
|
||||
+
|
||||
+ #[test]
|
||||
+ fn test_truncate_max_graphemes_two() {
|
||||
+ let text = "Hello";
|
||||
+ let truncated = truncate_text(text, 2);
|
||||
+ assert_eq!(truncated, "He");
|
||||
+ }
|
||||
+
|
||||
+ #[test]
|
||||
+ fn test_truncate_max_graphemes_three_boundary() {
|
||||
+ let text = "Hello";
|
||||
+ let truncated = truncate_text(text, 3);
|
||||
+ assert_eq!(truncated, "...");
|
||||
+ }
|
||||
+
|
||||
+ #[test]
|
||||
+ fn test_truncate_text_shorter_than_limit() {
|
||||
+ let text = "Hi";
|
||||
+ let truncated = truncate_text(text, 10);
|
||||
+ assert_eq!(truncated, "Hi");
|
||||
+ }
|
||||
+
|
||||
+ #[test]
|
||||
+ fn test_truncate_text_exact_length() {
|
||||
+ let text = "Hello";
|
||||
+ let truncated = truncate_text(text, 5);
|
||||
+ assert_eq!(truncated, "Hello");
|
||||
+ }
|
||||
+
|
||||
+ #[test]
|
||||
+ fn test_truncate_emoji() {
|
||||
+ let text = "👋🌍🚀✨💫";
|
||||
+ let truncated = truncate_text(text, 3);
|
||||
+ assert_eq!(truncated, "...");
|
||||
+
|
||||
+ let truncated_longer = truncate_text(text, 4);
|
||||
+ assert_eq!(truncated_longer, "👋...");
|
||||
+ }
|
||||
+
|
||||
+ #[test]
|
||||
+ fn test_truncate_unicode_combining_characters() {
|
||||
+ let text = "é́ñ̃"; // Characters with combining marks
|
||||
+ let truncated = truncate_text(text, 2);
|
||||
+ assert_eq!(truncated, "é́ñ̃");
|
||||
+ }
|
||||
+
|
||||
+ #[test]
|
||||
+ fn test_truncate_very_long_text() {
|
||||
+ let text = "a".repeat(1000);
|
||||
+ let truncated = truncate_text(&text, 10);
|
||||
+ assert_eq!(truncated, "aaaaaaa...");
|
||||
+ assert_eq!(truncated.len(), 10); // 7 'a's + 3 dots
|
||||
+ }
|
||||
+
|
||||
+ #[test]
|
||||
+ fn test_format_json_compact_simple_object() {
|
||||
+ let json = r#"{ "name": "John", "age": 30 }"#;
|
||||
+ let result = format_json_compact(json).unwrap();
|
||||
+ assert_eq!(result, r#"{"name": "John", "age": 30}"#);
|
||||
+ }
|
||||
+
|
||||
+ #[test]
|
||||
+ fn test_format_json_compact_nested_object() {
|
||||
+ let json = r#"{ "user": { "name": "John", "details": { "age": 30, "city": "NYC" } } }"#;
|
||||
+ let result = format_json_compact(json).unwrap();
|
||||
+ assert_eq!(
|
||||
+ result,
|
||||
+ r#"{"user": {"name": "John", "details": {"age": 30, "city": "NYC"}}}"#
|
||||
+ );
|
||||
+ }
|
||||
+
|
||||
+ #[test]
|
||||
+ fn test_format_json_compact_array() {
|
||||
+ let json = r#"[ 1, 2, { "key": "value" }, "string" ]"#;
|
||||
+ let result = format_json_compact(json).unwrap();
|
||||
+ assert_eq!(result, r#"[1, 2, {"key": "value"}, "string"]"#);
|
||||
+ }
|
||||
+
|
||||
+ #[test]
|
||||
+ fn test_format_json_compact_already_compact() {
|
||||
+ let json = r#"{"compact":true}"#;
|
||||
+ let result = format_json_compact(json).unwrap();
|
||||
+ assert_eq!(result, r#"{"compact": true}"#);
|
||||
+ }
|
||||
+
|
||||
+ #[test]
|
||||
+ fn test_format_json_compact_with_whitespace() {
|
||||
+ let json = r#"
|
||||
+ {
|
||||
+ "name": "John",
|
||||
+ "hobbies": [
|
||||
+ "reading",
|
||||
+ "coding"
|
||||
+ ]
|
||||
+ }
|
||||
+ "#;
|
||||
+ let result = format_json_compact(json).unwrap();
|
||||
+ assert_eq!(
|
||||
+ result,
|
||||
+ r#"{"name": "John", "hobbies": ["reading", "coding"]}"#
|
||||
+ );
|
||||
+ }
|
||||
+
|
||||
+ #[test]
|
||||
+ fn test_format_json_compact_invalid_json() {
|
||||
+ let invalid_json = r#"{"invalid": json syntax}"#;
|
||||
+ let result = format_json_compact(invalid_json);
|
||||
+ assert!(result.is_none());
|
||||
+ }
|
||||
+
|
||||
+ #[test]
|
||||
+ fn test_format_json_compact_empty_object() {
|
||||
+ let json = r#"{}"#;
|
||||
+ let result = format_json_compact(json).unwrap();
|
||||
+ assert_eq!(result, "{}");
|
||||
+ }
|
||||
+
|
||||
+ #[test]
|
||||
+ fn test_format_json_compact_empty_array() {
|
||||
+ let json = r#"[]"#;
|
||||
+ let result = format_json_compact(json).unwrap();
|
||||
+ assert_eq!(result, "[]");
|
||||
+ }
|
||||
+
|
||||
+ #[test]
|
||||
+ fn test_format_json_compact_primitive_values() {
|
||||
+ assert_eq!(format_json_compact("42").unwrap(), "42");
|
||||
+ assert_eq!(format_json_compact("true").unwrap(), "true");
|
||||
+ assert_eq!(format_json_compact("false").unwrap(), "false");
|
||||
+ assert_eq!(format_json_compact("null").unwrap(), "null");
|
||||
+ assert_eq!(format_json_compact(r#""string""#).unwrap(), r#""string""#);
|
||||
+ }
|
||||
+}
|
||||
```
|
||||
|
||||
## Review Comments
|
||||
|
||||
### codex-rs/tui/Cargo.toml
|
||||
|
||||
- Created: 2025-06-03 19:00:18 UTC | Link: https://github.com/openai/codex/pull/1211#discussion_r2124676784
|
||||
|
||||
```diff
|
||||
@@ -52,6 +52,7 @@ tui-input = "0.11.1"
|
||||
tui-markdown = "0.3.3"
|
||||
tui-textarea = "0.7.0"
|
||||
uuid = "1"
|
||||
+unicode-segmentation = "1.12.0"
|
||||
```
|
||||
|
||||
> nit: alpha sort?
|
||||
|
||||
### codex-rs/tui/src/history_cell.rs
|
||||
|
||||
- Created: 2025-06-03 19:01:06 UTC | Link: https://github.com/openai/codex/pull/1211#discussion_r2124678022
|
||||
|
||||
```diff
|
||||
@@ -149,25 +145,13 @@ impl HistoryCell {
|
||||
]),
|
||||
];
|
||||
|
||||
- let mut entries = vec![
|
||||
+ let entries = vec![
|
||||
("workdir", config.cwd.display().to_string()),
|
||||
("model", config.model.clone()),
|
||||
("provider", config.model_provider_id.clone()),
|
||||
("approval", format!("{:?}", config.approval_policy)),
|
||||
("sandbox", format!("{:?}", config.sandbox_policy)),
|
||||
];
|
||||
- if config.model_provider.wire_api == WireApi::Responses
|
||||
```
|
||||
|
||||
> bad rebase maybe? did you mean to remove this?
|
||||
|
||||
- Created: 2025-06-03 19:08:28 UTC | Link: https://github.com/openai/codex/pull/1211#discussion_r2124692532
|
||||
|
||||
```diff
|
||||
@@ -728,3 +741,53 @@ fn ensure_image_cache(
|
||||
|
||||
height_rows
|
||||
}
|
||||
+
|
||||
+/// Truncate a tool result to fit within the given width. If the text is valid JSON, we format it in a compact way before truncating
|
||||
+fn format_and_truncate_tool_result(text: &str, width: u16) -> String {
|
||||
+ // Work out the maximum number of graphemes we can display for a result given the current terminal width.
|
||||
+ // It's not guaranteed that 1 grapheme = 1 cell, so we subtract 1 per line as a fudge factor.
|
||||
+ // It also won't handle future terminal resizes properly, but it's an OK approximation for now.
|
||||
+ let max_graphemes = (TOOL_CALL_MAX_LINES * width as usize).saturating_sub(TOOL_CALL_MAX_LINES);
|
||||
+
|
||||
+ // try to parse the text as json
|
||||
+ let json = serde_json::from_str::<serde_json::Value>(text);
|
||||
+ if let Ok(json) = json {
|
||||
+ let json_pretty = serde_json::to_string_pretty(&json).unwrap_or_else(|_| json.to_string());
|
||||
```
|
||||
|
||||
> Hmm, I guess `PrettyFormatter` isn't configurable to support what you're trying to do here?
|
||||
>
|
||||
> https://docs.rs/serde_json/1.0.140/serde_json/ser/struct.PrettyFormatter.html
|
||||
|
||||
- Created: 2025-06-03 19:21:27 UTC | Link: https://github.com/openai/codex/pull/1211#discussion_r2124724203
|
||||
|
||||
```diff
|
||||
@@ -370,8 +353,8 @@ impl HistoryCell {
|
||||
}
|
||||
|
||||
pub(crate) fn new_completed_mcp_tool_call(
|
||||
- fq_tool_name: String,
|
||||
- invocation: String,
|
||||
+ terminal_width: u16,
|
||||
```
|
||||
|
||||
> Maybe `num_cols` is a more appropriate name? Because the terminal is technically a bit wider because of borders and whatnot, right?
|
||||
|
||||
- Created: 2025-06-03 19:23:42 UTC | Link: https://github.com/openai/codex/pull/1211#discussion_r2124727415
|
||||
|
||||
```diff
|
||||
@@ -728,3 +741,53 @@ fn ensure_image_cache(
|
||||
|
||||
height_rows
|
||||
}
|
||||
+
|
||||
+/// Truncate a tool result to fit within the given width. If the text is valid JSON, we format it in a compact way before truncating
|
||||
+fn format_and_truncate_tool_result(text: &str, width: u16) -> String {
|
||||
```
|
||||
|
||||
> Could you please add a couple of unit tests for these functions? Or maybe move them into a helper file (`truncate.rs`?) since this file is getting a bit long...
|
||||
|
||||
### codex-rs/tui/src/text_formatting.rs
|
||||
|
||||
- Created: 2025-06-03 21:20:13 UTC | Link: https://github.com/openai/codex/pull/1211#discussion_r2124948153
|
||||
|
||||
```diff
|
||||
@@ -0,0 +1,267 @@
|
||||
+use unicode_segmentation::UnicodeSegmentation;
|
||||
+
|
||||
+/// Truncate a tool result to fit within the given height and width. If the text is valid JSON, we format it in a compact way before truncating.
|
||||
+/// This is a best-effort approach that may not work perfectly for text where 1 grapheme is rendered as multiple terminal cells.
|
||||
+pub(crate) fn format_and_truncate_tool_result(
|
||||
+ text: &str,
|
||||
+ max_lines: usize,
|
||||
+ line_width: usize,
|
||||
+) -> String {
|
||||
+ // Work out the maximum number of graphemes we can display for a result.
|
||||
+ // It's not guaranteed that 1 grapheme = 1 cell, so we subtract 1 per line as a fudge factor.
|
||||
+ // It also won't handle future terminal resizes properly, but it's an OK approximation for now.
|
||||
+ let max_graphemes = (max_lines * line_width).saturating_sub(max_lines);
|
||||
+
|
||||
+ if let Some(formatted_json) = format_json_compact(text) {
|
||||
+ truncate_text(&formatted_json, max_graphemes)
|
||||
+ } else {
|
||||
+ truncate_text(text, max_graphemes)
|
||||
+ }
|
||||
+}
|
||||
+
|
||||
+/// Format JSON text in a compact single-line format with spaces for better Ratatui wrapping.
|
||||
+/// Ex: `{"a":"b",c:["d","e"]}` -> `{"a": "b", "c": ["d", "e"]}`
|
||||
+/// Returns the formatted JSON string if the input is valid JSON, otherwise returns None.
|
||||
+/// This is a little complicated, but it's necessary because Ratatui's wrapping is *very* limited
|
||||
+/// and can only do line breaks at whitespace. If we use the default serde_json format, we get lines
|
||||
+/// without spaces that Ratatui can't wrap nicely. If we use the serde_json pretty format as-is,
|
||||
+/// it's much too sparse and uses too many terminal rows.
|
||||
+/// Relevant issue: https://github.com/ratatui/ratatui/issues/293
|
||||
+pub(crate) fn format_json_compact(text: &str) -> Option<String> {
|
||||
+ let json = serde_json::from_str::<serde_json::Value>(text).ok()?;
|
||||
+ let json_pretty = serde_json::to_string_pretty(&json).unwrap_or_else(|_| json.to_string());
|
||||
+
|
||||
+ // Convert multi-line pretty JSON to compact single-line format by removing newlines and excess whitespace
|
||||
+ let mut result = String::new();
|
||||
+ let mut chars = json_pretty.chars().peekable();
|
||||
+ let mut in_string = false;
|
||||
+ let mut escape_next = false;
|
||||
+
|
||||
+ // Iterate over the characters in the JSON string, adding spaces after : and , but only when not in a string
|
||||
+ while let Some(ch) = chars.next() {
|
||||
+ match ch {
|
||||
+ '"' if !escape_next => {
|
||||
+ in_string = !in_string;
|
||||
+ result.push(ch);
|
||||
+ }
|
||||
+ '\\' if in_string => {
|
||||
+ escape_next = !escape_next;
|
||||
+ result.push(ch);
|
||||
+ }
|
||||
+ '\n' | '\r' if !in_string => {
|
||||
+ // Skip newlines when not in a string
|
||||
+ }
|
||||
+ ' ' | '\t' if !in_string => {
|
||||
```
|
||||
|
||||
> `to_string_pretty()` should never include a literal `\r` or `\n`, should it?
|
||||
156
prs/bolinfest/PR-1371.md
Normal file
156
prs/bolinfest/PR-1371.md
Normal file
@@ -0,0 +1,156 @@
|
||||
# PR #1371: fix: reduce MCP tool name delimiter to prevent OpenAI 64-char limit errors
|
||||
|
||||
- URL: https://github.com/openai/codex/pull/1371
|
||||
- Author: MinesJA
|
||||
- Created: 2025-06-24 10:20:30 UTC
|
||||
- Updated: 2025-07-10 15:22:38 UTC
|
||||
- Changes: +88/-3, Files changed: 1, Commits: 2
|
||||
|
||||
## Description
|
||||
|
||||
Fixes issue where MCP tool names exceed OpenAI's 64-character limit by using a shorter delimiter.
|
||||
|
||||
Fixes #1289
|
||||
|
||||
## Full Diff
|
||||
|
||||
```diff
|
||||
diff --git a/codex-rs/core/src/mcp_connection_manager.rs b/codex-rs/core/src/mcp_connection_manager.rs
|
||||
index 6ae1865f16..6fd8e2c4de 100644
|
||||
--- a/codex-rs/core/src/mcp_connection_manager.rs
|
||||
+++ b/codex-rs/core/src/mcp_connection_manager.rs
|
||||
@@ -25,12 +25,17 @@ use crate::config_types::McpServerConfig;
|
||||
/// qualified tool name.
|
||||
///
|
||||
/// OpenAI requires tool names to conform to `^[a-zA-Z0-9_-]+$`, so we must
|
||||
-/// choose a delimiter from this character set.
|
||||
-const MCP_TOOL_NAME_DELIMITER: &str = "__OAI_CODEX_MCP__";
|
||||
+/// choose a delimiter from this character set. We use a short delimiter to
|
||||
+/// maximize the remaining characters available for server and tool names
|
||||
+/// within OpenAI's 64-character limit.
|
||||
+const MCP_TOOL_NAME_DELIMITER: &str = "__";
|
||||
|
||||
/// Timeout for the `tools/list` request.
|
||||
const LIST_TOOLS_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
|
||||
+/// Maximum length for OpenAI tool names.
|
||||
+const MAX_TOOL_NAME_LENGTH: usize = 64;
|
||||
+
|
||||
/// Map that holds a startup error for every MCP server that could **not** be
|
||||
/// spawned successfully.
|
||||
pub type ClientStartErrors = HashMap<String, anyhow::Error>;
|
||||
@@ -193,7 +198,46 @@ pub async fn list_all_tools(
|
||||
|
||||
for tool in list_result.tools {
|
||||
// TODO(mbolin): escape tool names that contain invalid characters.
|
||||
- let fq_name = fully_qualified_tool_name(&server_name, &tool.name);
|
||||
+ let mut fq_name = fully_qualified_tool_name(&server_name, &tool.name);
|
||||
+
|
||||
+ // Ensure the fully qualified name doesn't exceed OpenAI's limit
|
||||
+ if fq_name.len() > MAX_TOOL_NAME_LENGTH {
|
||||
+ // Truncate the tool name part to fit within the limit
|
||||
+ let prefix_len = server_name.len() + MCP_TOOL_NAME_DELIMITER.len();
|
||||
+ let max_tool_len = MAX_TOOL_NAME_LENGTH.saturating_sub(prefix_len);
|
||||
+
|
||||
+ if max_tool_len < 3 {
|
||||
+ // Server name alone is too long
|
||||
+ tracing::warn!(
|
||||
+ "Skipping tool '{}' from server '{}': server name too long for OpenAI limit",
|
||||
+ tool.name,
|
||||
+ server_name
|
||||
+ );
|
||||
+ continue;
|
||||
+ }
|
||||
+
|
||||
+ // Truncate tool name and add a hash suffix for uniqueness
|
||||
+ let truncated_tool = if tool.name.len() > max_tool_len {
|
||||
+ // Simple hash based on string bytes
|
||||
+ let hash: u32 = tool
|
||||
+ .name
|
||||
+ .bytes()
|
||||
+ .fold(0u32, |acc, b| acc.wrapping_mul(31).wrapping_add(b as u32));
|
||||
+ let hash_suffix = format!("{:04x}", hash & 0xFFFF); // Use lower 16 bits as 4-char hex
|
||||
+ let available_len = max_tool_len.saturating_sub(5); // 4 for hash + 1 for underscore
|
||||
+ format!("{}_{}", &tool.name[..available_len], hash_suffix)
|
||||
+ } else {
|
||||
+ tool.name.clone()
|
||||
+ };
|
||||
+
|
||||
+ fq_name = fully_qualified_tool_name(&server_name, &truncated_tool);
|
||||
+ tracing::info!(
|
||||
+ "Truncated tool name from '{}' to '{}' to fit OpenAI limit",
|
||||
+ tool.name,
|
||||
+ truncated_tool
|
||||
+ );
|
||||
+ }
|
||||
+
|
||||
if aggregated.insert(fq_name.clone(), tool).is_some() {
|
||||
panic!("tool name collision for '{fq_name}': suspicious");
|
||||
}
|
||||
@@ -208,3 +252,44 @@ pub async fn list_all_tools(
|
||||
|
||||
Ok(aggregated)
|
||||
}
|
||||
+
|
||||
+#[cfg(test)]
|
||||
+mod tests {
|
||||
+ use super::*;
|
||||
+
|
||||
+ #[test]
|
||||
+ fn test_fully_qualified_tool_name_length() {
|
||||
+ // Test that delimiter is short
|
||||
+ assert_eq!(MCP_TOOL_NAME_DELIMITER.len(), 2);
|
||||
+
|
||||
+ // Test normal case
|
||||
+ let fq_name = fully_qualified_tool_name("myserver", "mytool");
|
||||
+ assert_eq!(fq_name, "myserver__mytool");
|
||||
+ assert!(fq_name.len() <= MAX_TOOL_NAME_LENGTH);
|
||||
+
|
||||
+ // Test parsing
|
||||
+ let parsed = try_parse_fully_qualified_tool_name("myserver__mytool");
|
||||
+ assert_eq!(parsed, Some(("myserver".to_string(), "mytool".to_string())));
|
||||
+
|
||||
+ // Test invalid parsing
|
||||
+ assert_eq!(try_parse_fully_qualified_tool_name("no_delimiter"), None);
|
||||
+ assert_eq!(try_parse_fully_qualified_tool_name("__only_tool"), None);
|
||||
+ assert_eq!(try_parse_fully_qualified_tool_name("only_server__"), None);
|
||||
+ }
|
||||
+
|
||||
+ #[test]
|
||||
+ fn test_long_tool_names() {
|
||||
+ // Test that very long server names would be handled
|
||||
+ let long_server = "a".repeat(50);
|
||||
+ let long_tool = "b".repeat(50);
|
||||
+ let fq_name = fully_qualified_tool_name(&long_server, &long_tool);
|
||||
+
|
||||
+ // With delimiter of 2 chars, 50 + 2 + 50 = 102 chars, which exceeds 64
|
||||
+ assert!(fq_name.len() > MAX_TOOL_NAME_LENGTH);
|
||||
+
|
||||
+ // The actual truncation logic is in list_all_tools, but we can verify
|
||||
+ // that our delimiter change helps maximize available space
|
||||
+ let available_for_names = MAX_TOOL_NAME_LENGTH - MCP_TOOL_NAME_DELIMITER.len();
|
||||
+ assert_eq!(available_for_names, 62); // Much better than 47 with old delimiter
|
||||
+ }
|
||||
+}
|
||||
```
|
||||
|
||||
## Review Comments
|
||||
|
||||
### codex-rs/core/src/mcp_connection_manager.rs
|
||||
|
||||
- Created: 2025-06-25 16:32:02 UTC | Link: https://github.com/openai/codex/pull/1371#discussion_r2167146295
|
||||
|
||||
```diff
|
||||
@@ -193,7 +198,46 @@ pub async fn list_all_tools(
|
||||
|
||||
for tool in list_result.tools {
|
||||
// TODO(mbolin): escape tool names that contain invalid characters.
|
||||
- let fq_name = fully_qualified_tool_name(&server_name, &tool.name);
|
||||
+ let mut fq_name = fully_qualified_tool_name(&server_name, &tool.name);
|
||||
```
|
||||
|
||||
> `fully_qualified_tool_name()` and `try_parse_fully_qualified_tool_name()` must be symmetric. It is not clear that this is the case given this implementation.
|
||||
>
|
||||
> Someone told me that, empirically, the model doesn't care about the names of the functions all that much and therefore, we could SHA1 the long name or something and things would still work.
|
||||
>
|
||||
> Another solution that is somewhat stateful, but more readable for users, would be to get the full list of tool names and only attempt to "fully qualify them" when there is a naming collision.
|
||||
570
prs/bolinfest/PR-1387.md
Normal file
570
prs/bolinfest/PR-1387.md
Normal file
@@ -0,0 +1,570 @@
|
||||
# PR #1387: [Rust] Allow resuming a session that was killed with ctrl + c
|
||||
|
||||
- URL: https://github.com/openai/codex/pull/1387
|
||||
- Author: gpeal
|
||||
- Created: 2025-06-25 21:06:06 UTC
|
||||
- Updated: 2025-06-26 18:40:53 UTC
|
||||
- Changes: +108/-19, Files changed: 4, Commits: 5
|
||||
|
||||
## Description
|
||||
|
||||
Previously, if you ctrl+c'd a conversation, all subsequent turns would 400 because the Responses API never got a response for one of its call ids. This ensures that if we aren't sending a call id by hand, we generate a synthetic aborted call.
|
||||
|
||||
Fixes #1244
|
||||
|
||||
https://github.com/user-attachments/assets/5126354f-b970-45f5-8c65-f811bca8294a
|
||||
|
||||
## Full Diff
|
||||
|
||||
```diff
|
||||
diff --git a/codex-rs/core/src/chat_completions.rs b/codex-rs/core/src/chat_completions.rs
|
||||
index 12c5b7afca..dfe06d1fec 100644
|
||||
--- a/codex-rs/core/src/chat_completions.rs
|
||||
+++ b/codex-rs/core/src/chat_completions.rs
|
||||
@@ -425,7 +425,12 @@ where
|
||||
response_id,
|
||||
token_usage,
|
||||
})));
|
||||
- } // No other `Ok` variants exist at the moment, continue polling.
|
||||
+ }
|
||||
+ Poll::Ready(Some(Ok(ResponseEvent::Created))) => {
|
||||
+ // These events are exclusive to the Responses API and
|
||||
+ // will never appear in a Chat Completions stream.
|
||||
+ continue;
|
||||
+ }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -439,7 +444,7 @@ pub(crate) trait AggregateStreamExt: Stream<Item = Result<ResponseEvent>> + Size
|
||||
///
|
||||
/// ```ignore
|
||||
/// OutputItemDone(<full message>)
|
||||
- /// Completed { .. }
|
||||
+ /// Completed
|
||||
/// ```
|
||||
///
|
||||
/// No other `OutputItemDone` events will be seen by the caller.
|
||||
diff --git a/codex-rs/core/src/client.rs b/codex-rs/core/src/client.rs
|
||||
index 4770796dbb..6daa3a8969 100644
|
||||
--- a/codex-rs/core/src/client.rs
|
||||
+++ b/codex-rs/core/src/client.rs
|
||||
@@ -168,7 +168,7 @@ impl ModelClient {
|
||||
// negligible.
|
||||
if !(status == StatusCode::TOO_MANY_REQUESTS || status.is_server_error()) {
|
||||
// Surface the error body to callers. Use `unwrap_or_default` per Clippy.
|
||||
- let body = (res.text().await).unwrap_or_default();
|
||||
+ let body = res.text().await.unwrap_or_default();
|
||||
return Err(CodexErr::UnexpectedStatus(status, body));
|
||||
}
|
||||
|
||||
@@ -208,6 +208,9 @@ struct SseEvent {
|
||||
item: Option<Value>,
|
||||
}
|
||||
|
||||
+#[derive(Debug, Deserialize)]
|
||||
+struct ResponseCreated {}
|
||||
+
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ResponseCompleted {
|
||||
id: String,
|
||||
@@ -335,6 +338,11 @@ where
|
||||
return;
|
||||
}
|
||||
}
|
||||
+ "response.created" => {
|
||||
+ if event.response.is_some() {
|
||||
+ let _ = tx_event.send(Ok(ResponseEvent::Created {})).await;
|
||||
+ }
|
||||
+ }
|
||||
// Final response completed – includes array of output items & id
|
||||
"response.completed" => {
|
||||
if let Some(resp_val) = event.response {
|
||||
@@ -350,7 +358,6 @@ where
|
||||
};
|
||||
}
|
||||
"response.content_part.done"
|
||||
- | "response.created"
|
||||
| "response.function_call_arguments.delta"
|
||||
| "response.in_progress"
|
||||
| "response.output_item.added"
|
||||
diff --git a/codex-rs/core/src/client_common.rs b/codex-rs/core/src/client_common.rs
|
||||
index e17cf22c59..b08880a0df 100644
|
||||
--- a/codex-rs/core/src/client_common.rs
|
||||
+++ b/codex-rs/core/src/client_common.rs
|
||||
@@ -51,6 +51,7 @@ impl Prompt {
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum ResponseEvent {
|
||||
+ Created,
|
||||
OutputItemDone(ResponseItem),
|
||||
Completed {
|
||||
response_id: String,
|
||||
diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs
|
||||
index a43f75a731..ec6e0bd185 100644
|
||||
--- a/codex-rs/core/src/codex.rs
|
||||
+++ b/codex-rs/core/src/codex.rs
|
||||
@@ -1,6 +1,7 @@
|
||||
// Poisoned mutex should fail the program
|
||||
#![allow(clippy::unwrap_used)]
|
||||
|
||||
+use std::borrow::Cow;
|
||||
use std::collections::HashMap;
|
||||
use std::collections::HashSet;
|
||||
use std::path::Path;
|
||||
@@ -188,7 +189,7 @@ pub(crate) struct Session {
|
||||
|
||||
/// Optional rollout recorder for persisting the conversation transcript so
|
||||
/// sessions can be replayed or inspected later.
|
||||
- rollout: Mutex<Option<crate::rollout::RolloutRecorder>>,
|
||||
+ rollout: Mutex<Option<RolloutRecorder>>,
|
||||
state: Mutex<State>,
|
||||
codex_linux_sandbox_exe: Option<PathBuf>,
|
||||
}
|
||||
@@ -206,6 +207,9 @@ impl Session {
|
||||
struct State {
|
||||
approved_commands: HashSet<Vec<String>>,
|
||||
current_task: Option<AgentTask>,
|
||||
+ /// Call IDs that have been sent from the Responses API but have not been sent back yet.
|
||||
+ /// You CANNOT send a Responses API follow-up message unless you have sent back the output for all pending calls or else it will 400.
|
||||
+ pending_call_ids: HashSet<String>,
|
||||
previous_response_id: Option<String>,
|
||||
pending_approvals: HashMap<String, oneshot::Sender<ReviewDecision>>,
|
||||
pending_input: Vec<ResponseInputItem>,
|
||||
@@ -312,7 +316,7 @@ impl Session {
|
||||
/// Append the given items to the session's rollout transcript (if enabled)
|
||||
/// and persist them to disk.
|
||||
async fn record_rollout_items(&self, items: &[ResponseItem]) {
|
||||
- // Clone the recorder outside of the mutex so we don’t hold the lock
|
||||
+ // Clone the recorder outside of the mutex so we don't hold the lock
|
||||
// across an await point (MutexGuard is not Send).
|
||||
let recorder = {
|
||||
let guard = self.rollout.lock().unwrap();
|
||||
@@ -411,6 +415,8 @@ impl Session {
|
||||
pub fn abort(&self) {
|
||||
info!("Aborting existing session");
|
||||
let mut state = self.state.lock().unwrap();
|
||||
+ // Don't clear pending_call_ids because we need to keep track of them to ensure we don't 400 on the next turn.
|
||||
+ // We will generate a synthetic aborted response for each pending call id.
|
||||
state.pending_approvals.clear();
|
||||
state.pending_input.clear();
|
||||
if let Some(task) = state.current_task.take() {
|
||||
@@ -431,7 +437,7 @@ impl Session {
|
||||
}
|
||||
|
||||
let Ok(json) = serde_json::to_string(¬ification) else {
|
||||
- tracing::error!("failed to serialise notification payload");
|
||||
+ error!("failed to serialise notification payload");
|
||||
return;
|
||||
};
|
||||
|
||||
@@ -443,7 +449,7 @@ impl Session {
|
||||
|
||||
// Fire-and-forget – we do not wait for completion.
|
||||
if let Err(e) = command.spawn() {
|
||||
- tracing::warn!("failed to spawn notifier '{}': {e}", notify_command[0]);
|
||||
+ warn!("failed to spawn notifier '{}': {e}", notify_command[0]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -647,7 +653,7 @@ async fn submission_loop(
|
||||
match RolloutRecorder::new(&config, session_id, instructions.clone()).await {
|
||||
Ok(r) => Some(r),
|
||||
Err(e) => {
|
||||
- tracing::warn!("failed to initialise rollout recorder: {e}");
|
||||
+ warn!("failed to initialise rollout recorder: {e}");
|
||||
None
|
||||
}
|
||||
};
|
||||
@@ -742,7 +748,7 @@ async fn submission_loop(
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = crate::message_history::append_entry(&text, &id, &config).await
|
||||
{
|
||||
- tracing::warn!("failed to append to message history: {e}");
|
||||
+ warn!("failed to append to message history: {e}");
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -772,7 +778,7 @@ async fn submission_loop(
|
||||
};
|
||||
|
||||
if let Err(e) = tx_event.send(event).await {
|
||||
- tracing::warn!("failed to send GetHistoryEntryResponse event: {e}");
|
||||
+ warn!("failed to send GetHistoryEntryResponse event: {e}");
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1052,6 +1058,7 @@ async fn run_turn(
|
||||
/// events map to a `ResponseItem`. A `ResponseItem` may need to be
|
||||
/// "handled" such that it produces a `ResponseInputItem` that needs to be
|
||||
/// sent back to the model on the next turn.
|
||||
+#[derive(Debug)]
|
||||
struct ProcessedResponseItem {
|
||||
item: ResponseItem,
|
||||
response: Option<ResponseInputItem>,
|
||||
@@ -1062,7 +1069,57 @@ async fn try_run_turn(
|
||||
sub_id: &str,
|
||||
prompt: &Prompt,
|
||||
) -> CodexResult<Vec<ProcessedResponseItem>> {
|
||||
- let mut stream = sess.client.clone().stream(prompt).await?;
|
||||
+ // call_ids that are part of this response.
|
||||
+ let completed_call_ids = prompt
|
||||
+ .input
|
||||
+ .iter()
|
||||
+ .filter_map(|ri| match ri {
|
||||
+ ResponseItem::FunctionCallOutput { call_id, .. } => Some(call_id),
|
||||
+ ResponseItem::LocalShellCall {
|
||||
+ call_id: Some(call_id),
|
||||
+ ..
|
||||
+ } => Some(call_id),
|
||||
+ _ => None,
|
||||
+ })
|
||||
+ .collect::<Vec<_>>();
|
||||
+
|
||||
+ // call_ids that were pending but are not part of this response.
|
||||
+ // This usually happens because the user interrupted the model before we responded to one of its tool calls
|
||||
+ // and then the user sent a follow-up message.
|
||||
+ let missing_calls = {
|
||||
+ sess.state
|
||||
+ .lock()
|
||||
+ .unwrap()
|
||||
+ .pending_call_ids
|
||||
+ .iter()
|
||||
+ .filter_map(|call_id| {
|
||||
+ if completed_call_ids.contains(&call_id) {
|
||||
+ None
|
||||
+ } else {
|
||||
+ Some(call_id.clone())
|
||||
+ }
|
||||
+ })
|
||||
+ .map(|call_id| ResponseItem::FunctionCallOutput {
|
||||
+ call_id: call_id.clone(),
|
||||
+ output: FunctionCallOutputPayload {
|
||||
+ content: "aborted".to_string(),
|
||||
+ success: Some(false),
|
||||
+ },
|
||||
+ })
|
||||
+ .collect::<Vec<_>>()
|
||||
+ };
|
||||
+ let prompt: Cow<Prompt> = if missing_calls.is_empty() {
|
||||
+ Cow::Borrowed(prompt)
|
||||
+ } else {
|
||||
+ // Add the synthetic aborted missing calls to the beginning of the input to ensure all call ids have responses.
|
||||
+ let input = [missing_calls, prompt.input.clone()].concat();
|
||||
+ Cow::Owned(Prompt {
|
||||
+ input,
|
||||
+ ..prompt.clone()
|
||||
+ })
|
||||
+ };
|
||||
+
|
||||
+ let mut stream = sess.client.clone().stream(&prompt).await?;
|
||||
|
||||
// Buffer all the incoming messages from the stream first, then execute them.
|
||||
// If we execute a function call in the middle of handling the stream, it can time out.
|
||||
@@ -1074,8 +1131,27 @@ async fn try_run_turn(
|
||||
let mut output = Vec::new();
|
||||
for event in input {
|
||||
match event {
|
||||
+ ResponseEvent::Created => {
|
||||
+ let mut state = sess.state.lock().unwrap();
|
||||
+ // We successfully created a new response and ensured that all pending calls were included so we can clear the pending call ids.
|
||||
+ state.pending_call_ids.clear();
|
||||
+ }
|
||||
ResponseEvent::OutputItemDone(item) => {
|
||||
+ let call_id = match &item {
|
||||
+ ResponseItem::LocalShellCall {
|
||||
+ call_id: Some(call_id),
|
||||
+ ..
|
||||
+ } => Some(call_id),
|
||||
+ ResponseItem::FunctionCall { call_id, .. } => Some(call_id),
|
||||
+ _ => None,
|
||||
+ };
|
||||
+ if let Some(call_id) = call_id {
|
||||
+ // We just got a new call id so we need to make sure to respond to it in the next turn.
|
||||
+ let mut state = sess.state.lock().unwrap();
|
||||
+ state.pending_call_ids.insert(call_id.clone());
|
||||
+ }
|
||||
let response = handle_response_item(sess, sub_id, item.clone()).await?;
|
||||
+
|
||||
output.push(ProcessedResponseItem { item, response });
|
||||
}
|
||||
ResponseEvent::Completed {
|
||||
@@ -1138,7 +1214,7 @@ async fn handle_response_item(
|
||||
arguments,
|
||||
call_id,
|
||||
} => {
|
||||
- tracing::info!("FunctionCall: {arguments}");
|
||||
+ info!("FunctionCall: {arguments}");
|
||||
Some(handle_function_call(sess, sub_id.to_string(), name, arguments, call_id).await)
|
||||
}
|
||||
ResponseItem::LocalShellCall {
|
||||
@@ -1220,7 +1296,7 @@ async fn handle_function_call(
|
||||
// Unknown function: reply with structured failure so the model can adapt.
|
||||
ResponseInputItem::FunctionCallOutput {
|
||||
call_id,
|
||||
- output: crate::models::FunctionCallOutputPayload {
|
||||
+ output: FunctionCallOutputPayload {
|
||||
content: format!("unsupported call: {}", name),
|
||||
success: None,
|
||||
},
|
||||
@@ -1252,7 +1328,7 @@ fn parse_container_exec_arguments(
|
||||
// allow model to re-sample
|
||||
let output = ResponseInputItem::FunctionCallOutput {
|
||||
call_id: call_id.to_string(),
|
||||
- output: crate::models::FunctionCallOutputPayload {
|
||||
+ output: FunctionCallOutputPayload {
|
||||
content: format!("failed to parse function arguments: {e}"),
|
||||
success: None,
|
||||
},
|
||||
@@ -1320,7 +1396,7 @@ async fn handle_container_exec_with_params(
|
||||
ReviewDecision::Denied | ReviewDecision::Abort => {
|
||||
return ResponseInputItem::FunctionCallOutput {
|
||||
call_id,
|
||||
- output: crate::models::FunctionCallOutputPayload {
|
||||
+ output: FunctionCallOutputPayload {
|
||||
content: "exec command rejected by user".to_string(),
|
||||
success: None,
|
||||
},
|
||||
@@ -1336,7 +1412,7 @@ async fn handle_container_exec_with_params(
|
||||
SafetyCheck::Reject { reason } => {
|
||||
return ResponseInputItem::FunctionCallOutput {
|
||||
call_id,
|
||||
- output: crate::models::FunctionCallOutputPayload {
|
||||
+ output: FunctionCallOutputPayload {
|
||||
content: format!("exec command rejected: {reason}"),
|
||||
success: None,
|
||||
},
|
||||
@@ -1870,7 +1946,7 @@ fn apply_changes_from_apply_patch(action: &ApplyPatchAction) -> anyhow::Result<A
|
||||
})
|
||||
}
|
||||
|
||||
-fn get_writable_roots(cwd: &Path) -> Vec<std::path::PathBuf> {
|
||||
+fn get_writable_roots(cwd: &Path) -> Vec<PathBuf> {
|
||||
let mut writable_roots = Vec::new();
|
||||
if cfg!(target_os = "macos") {
|
||||
// On macOS, $TMPDIR is private to the user.
|
||||
@@ -1898,7 +1974,7 @@ fn get_writable_roots(cwd: &Path) -> Vec<std::path::PathBuf> {
|
||||
}
|
||||
|
||||
/// Exec output is a pre-serialized JSON payload
|
||||
-fn format_exec_output(output: &str, exit_code: i32, duration: std::time::Duration) -> String {
|
||||
+fn format_exec_output(output: &str, exit_code: i32, duration: Duration) -> String {
|
||||
#[derive(Serialize)]
|
||||
struct ExecMetadata {
|
||||
exit_code: i32,
|
||||
```
|
||||
|
||||
## Review Comments
|
||||
|
||||
### codex-rs/core/src/client.rs
|
||||
|
||||
- Created: 2025-06-25 21:28:45 UTC | Link: https://github.com/openai/codex/pull/1387#discussion_r2167668129
|
||||
|
||||
```diff
|
||||
@@ -296,6 +299,11 @@ where
|
||||
return;
|
||||
}
|
||||
}
|
||||
+ "response.created" => {
|
||||
+ if let Some(_) = event.response {
|
||||
```
|
||||
|
||||
> I predict Clippy wants `if event.response.is_some()`?
|
||||
>
|
||||
> I have this in my `settings.json` in VS Code:
|
||||
>
|
||||
> ```
|
||||
> "rust-analyzer.check.command": "clippy",
|
||||
> "rust-analyzer.check.extraArgs": ["--", "-D", "warnings"],
|
||||
> ```
|
||||
|
||||
### codex-rs/core/src/client_common.rs
|
||||
|
||||
- Created: 2025-06-25 21:29:48 UTC | Link: https://github.com/openai/codex/pull/1387#discussion_r2167669466
|
||||
|
||||
```diff
|
||||
@@ -50,6 +50,7 @@ impl Prompt {
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum ResponseEvent {
|
||||
+ Created {},
|
||||
```
|
||||
|
||||
> ```suggestion
|
||||
> Created,
|
||||
> ```
|
||||
|
||||
### codex-rs/core/src/codex.rs
|
||||
|
||||
- Created: 2025-06-25 21:31:53 UTC | Link: https://github.com/openai/codex/pull/1387#discussion_r2167671770
|
||||
|
||||
```diff
|
||||
@@ -1062,7 +1068,59 @@ async fn try_run_turn(
|
||||
sub_id: &str,
|
||||
prompt: &Prompt,
|
||||
) -> CodexResult<Vec<ProcessedResponseItem>> {
|
||||
- let mut stream = sess.client.clone().stream(prompt).await?;
|
||||
+ // call_ids that are part of this response.
|
||||
+ let completed_call_ids = prompt
|
||||
+ .input
|
||||
+ .iter()
|
||||
+ .filter_map(|ri| match ri {
|
||||
+ ResponseItem::FunctionCallOutput { call_id, .. } => Some(call_id),
|
||||
+ ResponseItem::LocalShellCall {
|
||||
+ call_id: Some(call_id),
|
||||
+ ..
|
||||
+ } => Some(call_id),
|
||||
+ _ => None,
|
||||
+ })
|
||||
+ .collect::<Vec<_>>();
|
||||
+
|
||||
+ // call_ids that were pending but are not part of this response.
|
||||
+ // This usually happens because the user interrupted the model before we responded to one of its tool calls
|
||||
+ // and then the user sent a follow-up message.
|
||||
+ let missing_calls = {
|
||||
+ sess.state
|
||||
+ .lock()
|
||||
+ .unwrap()
|
||||
+ .pending_call_ids
|
||||
+ .iter()
|
||||
+ .filter_map(|call_id| {
|
||||
+ if completed_call_ids.contains(&call_id) {
|
||||
+ None
|
||||
+ } else {
|
||||
+ Some(call_id.clone())
|
||||
+ }
|
||||
+ })
|
||||
+ .map(|call_id| ResponseItem::FunctionCallOutput {
|
||||
+ call_id: call_id.clone(),
|
||||
+ output: FunctionCallOutputPayload {
|
||||
+ content: "aborted".to_string(),
|
||||
+ success: Some(false),
|
||||
+ },
|
||||
+ })
|
||||
+ .collect::<Vec<_>>()
|
||||
+ };
|
||||
+ let prompt = if missing_calls.is_empty() {
|
||||
+ prompt.clone()
|
||||
```
|
||||
|
||||
> I think you want `let prompt: Cow<'a, Prompt>` if you can to avoid the `clone()`? So in the consequent, it's `Cow::Borrowed` and in the alternative, it's `Cow::Owned`?
|
||||
|
||||
- Created: 2025-06-25 21:32:38 UTC | Link: https://github.com/openai/codex/pull/1387#discussion_r2167672701
|
||||
|
||||
```diff
|
||||
@@ -1062,7 +1068,59 @@ async fn try_run_turn(
|
||||
sub_id: &str,
|
||||
prompt: &Prompt,
|
||||
) -> CodexResult<Vec<ProcessedResponseItem>> {
|
||||
- let mut stream = sess.client.clone().stream(prompt).await?;
|
||||
+ // call_ids that are part of this response.
|
||||
+ let completed_call_ids = prompt
|
||||
+ .input
|
||||
+ .iter()
|
||||
+ .filter_map(|ri| match ri {
|
||||
+ ResponseItem::FunctionCallOutput { call_id, .. } => Some(call_id),
|
||||
+ ResponseItem::LocalShellCall {
|
||||
+ call_id: Some(call_id),
|
||||
+ ..
|
||||
+ } => Some(call_id),
|
||||
+ _ => None,
|
||||
+ })
|
||||
+ .collect::<Vec<_>>();
|
||||
+
|
||||
+ // call_ids that were pending but are not part of this response.
|
||||
+ // This usually happens because the user interrupted the model before we responded to one of its tool calls
|
||||
+ // and then the user sent a follow-up message.
|
||||
+ let missing_calls = {
|
||||
+ sess.state
|
||||
+ .lock()
|
||||
+ .unwrap()
|
||||
+ .pending_call_ids
|
||||
+ .iter()
|
||||
+ .filter_map(|call_id| {
|
||||
+ if completed_call_ids.contains(&call_id) {
|
||||
+ None
|
||||
+ } else {
|
||||
+ Some(call_id.clone())
|
||||
+ }
|
||||
+ })
|
||||
+ .map(|call_id| ResponseItem::FunctionCallOutput {
|
||||
+ call_id: call_id.clone(),
|
||||
+ output: FunctionCallOutputPayload {
|
||||
+ content: "aborted".to_string(),
|
||||
+ success: Some(false),
|
||||
+ },
|
||||
+ })
|
||||
+ .collect::<Vec<_>>()
|
||||
+ };
|
||||
+ let prompt = if missing_calls.is_empty() {
|
||||
```
|
||||
|
||||
> Can you add a comment explaining why we redefine the prompt when `missing_calls` is non-empty?
|
||||
|
||||
- Created: 2025-06-25 21:40:01 UTC | Link: https://github.com/openai/codex/pull/1387#discussion_r2167684677
|
||||
|
||||
```diff
|
||||
@@ -1062,7 +1068,59 @@ async fn try_run_turn(
|
||||
sub_id: &str,
|
||||
prompt: &Prompt,
|
||||
) -> CodexResult<Vec<ProcessedResponseItem>> {
|
||||
- let mut stream = sess.client.clone().stream(prompt).await?;
|
||||
+ // call_ids that are part of this response.
|
||||
+ let completed_call_ids = prompt
|
||||
+ .input
|
||||
+ .iter()
|
||||
+ .filter_map(|ri| match ri {
|
||||
+ ResponseItem::FunctionCallOutput { call_id, .. } => Some(call_id),
|
||||
+ ResponseItem::LocalShellCall {
|
||||
+ call_id: Some(call_id),
|
||||
+ ..
|
||||
+ } => Some(call_id),
|
||||
+ _ => None,
|
||||
+ })
|
||||
+ .collect::<Vec<_>>();
|
||||
+
|
||||
+ // call_ids that were pending but are not part of this response.
|
||||
+ // This usually happens because the user interrupted the model before we responded to one of its tool calls
|
||||
+ // and then the user sent a follow-up message.
|
||||
+ let missing_calls = {
|
||||
+ sess.state
|
||||
+ .lock()
|
||||
+ .unwrap()
|
||||
+ .pending_call_ids
|
||||
+ .iter()
|
||||
+ .filter_map(|call_id| {
|
||||
+ if completed_call_ids.contains(&call_id) {
|
||||
+ None
|
||||
+ } else {
|
||||
+ Some(call_id.clone())
|
||||
+ }
|
||||
+ })
|
||||
+ .map(|call_id| ResponseItem::FunctionCallOutput {
|
||||
+ call_id: call_id.clone(),
|
||||
+ output: FunctionCallOutputPayload {
|
||||
+ content: "aborted".to_string(),
|
||||
+ success: Some(false),
|
||||
+ },
|
||||
+ })
|
||||
+ .collect::<Vec<_>>()
|
||||
+ };
|
||||
+ let prompt = if missing_calls.is_empty() {
|
||||
+ prompt.clone()
|
||||
+ } else {
|
||||
+ let input = [prompt.input.clone(), missing_calls].concat();
|
||||
+ Prompt {
|
||||
+ input,
|
||||
+ prev_id: prompt.prev_id.clone(),
|
||||
+ user_instructions: prompt.user_instructions.clone(),
|
||||
+ store: prompt.store,
|
||||
+ extra_tools: prompt.extra_tools.clone(),
|
||||
+ }
|
||||
```
|
||||
|
||||
> Does this work?
|
||||
>
|
||||
> ```suggestion
|
||||
> Prompt {
|
||||
> input: [prompt.input.clone(), missing_calls].concat(),
|
||||
> ..prompt.clone(),
|
||||
> }
|
||||
> ```
|
||||
225
prs/bolinfest/PR-1402.md
Normal file
225
prs/bolinfest/PR-1402.md
Normal file
@@ -0,0 +1,225 @@
|
||||
# PR #1402: Handle Ctrl+C quit when idle
|
||||
|
||||
- URL: https://github.com/openai/codex/pull/1402
|
||||
- Author: gpeal
|
||||
- Created: 2025-06-27 06:32:14 UTC
|
||||
- Updated: 2025-06-27 17:37:19 UTC
|
||||
- Changes: +65/-7, Files changed: 4, Commits: 3
|
||||
|
||||
## Description
|
||||
|
||||
## Summary
|
||||
- show `Ctrl+C to quit` hint when pressing Ctrl+C with no active task
|
||||
- exiting with Ctrl+C if the hint is already visible
|
||||
- clear the hint when tasks begin or other keys are pressed
|
||||
|
||||
https://github.com/user-attachments/assets/931e2d7c-1c80-4b45-9908-d119f74df23c
|
||||
|
||||
|
||||
|
||||
------
|
||||
https://chatgpt.com/s/cd_685ec8875a308191beaa95886dc1379e
|
||||
|
||||
Fixes #1245
|
||||
|
||||
## Full Diff
|
||||
|
||||
```diff
|
||||
diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs
|
||||
index 73d512bcf0..4c8f004ad5 100644
|
||||
--- a/codex-rs/tui/src/app.rs
|
||||
+++ b/codex-rs/tui/src/app.rs
|
||||
@@ -11,7 +11,6 @@ use crate::slash_command::SlashCommand;
|
||||
use crate::tui;
|
||||
use codex_core::config::Config;
|
||||
use codex_core::protocol::Event;
|
||||
-use codex_core::protocol::Op;
|
||||
use color_eyre::eyre::Result;
|
||||
use crossterm::event::KeyCode;
|
||||
use crossterm::event::KeyEvent;
|
||||
@@ -193,10 +192,11 @@ impl<'a> App<'a> {
|
||||
modifiers: crossterm::event::KeyModifiers::CONTROL,
|
||||
..
|
||||
} => {
|
||||
- // Forward interrupt to ChatWidget when active.
|
||||
match &mut self.app_state {
|
||||
AppState::Chat { widget } => {
|
||||
- widget.submit_op(Op::Interrupt);
|
||||
+ if widget.on_ctrl_c() {
|
||||
+ self.app_event_tx.send(AppEvent::ExitRequest);
|
||||
+ }
|
||||
}
|
||||
AppState::Login { .. } | AppState::GitWarning { .. } => {
|
||||
// No-op.
|
||||
diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs
|
||||
index 4ec8299081..5e5819fa04 100644
|
||||
--- a/codex-rs/tui/src/bottom_pane/chat_composer.rs
|
||||
+++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs
|
||||
@@ -38,6 +38,7 @@ pub(crate) struct ChatComposer<'a> {
|
||||
command_popup: Option<CommandPopup>,
|
||||
app_event_tx: AppEventSender,
|
||||
history: ChatComposerHistory,
|
||||
+ ctrl_c_quit_hint: bool,
|
||||
}
|
||||
|
||||
impl ChatComposer<'_> {
|
||||
@@ -51,6 +52,7 @@ impl ChatComposer<'_> {
|
||||
command_popup: None,
|
||||
app_event_tx,
|
||||
history: ChatComposerHistory::new(),
|
||||
+ ctrl_c_quit_hint: false,
|
||||
};
|
||||
this.update_border(has_input_focus);
|
||||
this
|
||||
@@ -114,6 +116,11 @@ impl ChatComposer<'_> {
|
||||
self.update_border(has_focus);
|
||||
}
|
||||
|
||||
+ pub fn set_ctrl_c_quit_hint(&mut self, show: bool, has_focus: bool) {
|
||||
+ self.ctrl_c_quit_hint = show;
|
||||
+ self.update_border(has_focus);
|
||||
+ }
|
||||
+
|
||||
/// Handle a key event coming from the main UI.
|
||||
pub fn handle_key_event(&mut self, key_event: KeyEvent) -> (InputResult, bool) {
|
||||
let result = match self.command_popup {
|
||||
@@ -304,10 +311,17 @@ impl ChatComposer<'_> {
|
||||
}
|
||||
|
||||
let bs = if has_focus {
|
||||
- BlockState {
|
||||
- right_title: Line::from("Enter to send | Ctrl+D to quit | Ctrl+J for newline")
|
||||
- .alignment(Alignment::Right),
|
||||
- border_style: Style::default(),
|
||||
+ if self.ctrl_c_quit_hint {
|
||||
+ BlockState {
|
||||
+ right_title: Line::from("Ctrl+C to quit").alignment(Alignment::Right),
|
||||
+ border_style: Style::default(),
|
||||
+ }
|
||||
+ } else {
|
||||
+ BlockState {
|
||||
+ right_title: Line::from("Enter to send | Ctrl+D to quit | Ctrl+J for newline")
|
||||
+ .alignment(Alignment::Right),
|
||||
+ border_style: Style::default(),
|
||||
+ }
|
||||
}
|
||||
} else {
|
||||
BlockState {
|
||||
diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs
|
||||
index e3234e99a6..d9b1fcc96c 100644
|
||||
--- a/codex-rs/tui/src/bottom_pane/mod.rs
|
||||
+++ b/codex-rs/tui/src/bottom_pane/mod.rs
|
||||
@@ -37,6 +37,7 @@ pub(crate) struct BottomPane<'a> {
|
||||
app_event_tx: AppEventSender,
|
||||
has_input_focus: bool,
|
||||
is_task_running: bool,
|
||||
+ ctrl_c_quit_hint: bool,
|
||||
}
|
||||
|
||||
pub(crate) struct BottomPaneParams {
|
||||
@@ -52,6 +53,7 @@ impl BottomPane<'_> {
|
||||
app_event_tx: params.app_event_tx,
|
||||
has_input_focus: params.has_input_focus,
|
||||
is_task_running: false,
|
||||
+ ctrl_c_quit_hint: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -100,6 +102,26 @@ impl BottomPane<'_> {
|
||||
self.composer.set_input_focus(has_focus);
|
||||
}
|
||||
|
||||
+ pub(crate) fn show_ctrl_c_quit_hint(&mut self) {
|
||||
+ self.ctrl_c_quit_hint = true;
|
||||
+ self.composer
|
||||
+ .set_ctrl_c_quit_hint(true, self.has_input_focus);
|
||||
+ self.request_redraw();
|
||||
+ }
|
||||
+
|
||||
+ pub(crate) fn clear_ctrl_c_quit_hint(&mut self) {
|
||||
+ if self.ctrl_c_quit_hint {
|
||||
+ self.ctrl_c_quit_hint = false;
|
||||
+ self.composer
|
||||
+ .set_ctrl_c_quit_hint(false, self.has_input_focus);
|
||||
+ self.request_redraw();
|
||||
+ }
|
||||
+ }
|
||||
+
|
||||
+ pub(crate) fn ctrl_c_quit_hint_visible(&self) -> bool {
|
||||
+ self.ctrl_c_quit_hint
|
||||
+ }
|
||||
+
|
||||
pub fn set_task_running(&mut self, running: bool) {
|
||||
self.is_task_running = running;
|
||||
|
||||
@@ -130,6 +152,10 @@ impl BottomPane<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
+ pub(crate) fn is_task_running(&self) -> bool {
|
||||
+ self.is_task_running
|
||||
+ }
|
||||
+
|
||||
/// Update the *context-window remaining* indicator in the composer. This
|
||||
/// is forwarded directly to the underlying `ChatComposer`.
|
||||
pub(crate) fn set_token_usage(
|
||||
diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs
|
||||
index 92c0122003..78e828f02b 100644
|
||||
--- a/codex-rs/tui/src/chatwidget.rs
|
||||
+++ b/codex-rs/tui/src/chatwidget.rs
|
||||
@@ -138,6 +138,7 @@ impl ChatWidget<'_> {
|
||||
}
|
||||
|
||||
pub(crate) fn handle_key_event(&mut self, key_event: KeyEvent) {
|
||||
+ self.bottom_pane.clear_ctrl_c_quit_hint();
|
||||
// Special-case <Tab>: normally toggles focus between history and bottom panes.
|
||||
// However, when the slash-command popup is visible we forward the key
|
||||
// to the bottom pane so it can handle auto-completion.
|
||||
@@ -244,6 +245,7 @@ impl ChatWidget<'_> {
|
||||
}
|
||||
}
|
||||
EventMsg::TaskStarted => {
|
||||
+ self.bottom_pane.clear_ctrl_c_quit_hint();
|
||||
self.bottom_pane.set_task_running(true);
|
||||
self.request_redraw();
|
||||
}
|
||||
@@ -402,6 +404,22 @@ impl ChatWidget<'_> {
|
||||
self.request_redraw();
|
||||
}
|
||||
|
||||
+ /// Handle Ctrl-C key press.
|
||||
+ /// Returns true if the key press was handled, false if it was not.
|
||||
+ /// If the key press was not handled, the caller should handle it (likely by exiting the process).
|
||||
+ pub(crate) fn on_ctrl_c(&mut self) -> bool {
|
||||
+ if self.bottom_pane.is_task_running() {
|
||||
+ self.bottom_pane.clear_ctrl_c_quit_hint();
|
||||
+ self.submit_op(Op::Interrupt);
|
||||
+ false
|
||||
+ } else if self.bottom_pane.ctrl_c_quit_hint_visible() {
|
||||
+ true
|
||||
+ } else {
|
||||
+ self.bottom_pane.show_ctrl_c_quit_hint();
|
||||
+ false
|
||||
+ }
|
||||
+ }
|
||||
+
|
||||
/// Forward an `Op` directly to codex.
|
||||
pub(crate) fn submit_op(&self, op: Op) {
|
||||
if let Err(e) = self.codex_op_tx.send(op) {
|
||||
```
|
||||
|
||||
## Review Comments
|
||||
|
||||
### codex-rs/tui/src/chatwidget.rs
|
||||
|
||||
- Created: 2025-06-27 16:56:12 UTC | Link: https://github.com/openai/codex/pull/1402#discussion_r2172460843
|
||||
|
||||
```diff
|
||||
@@ -402,6 +404,19 @@ impl ChatWidget<'_> {
|
||||
self.request_redraw();
|
||||
}
|
||||
|
||||
+ pub(crate) fn on_ctrl_c(&mut self) -> bool {
|
||||
```
|
||||
|
||||
> Maybe a docstring to explain what the return value represents?
|
||||
460
prs/bolinfest/PR-1467.md
Normal file
460
prs/bolinfest/PR-1467.md
Normal file
@@ -0,0 +1,460 @@
|
||||
# PR #1467: Fix Unicode handling in chat_composer "@" token detection
|
||||
|
||||
- URL: https://github.com/openai/codex/pull/1467
|
||||
- Author: ryozi-tn
|
||||
- Created: 2025-07-06 18:00:10 UTC
|
||||
- Updated: 2025-07-07 20:43:39 UTC
|
||||
- Changes: +175/-18, Files changed: 1, Commits: 4
|
||||
|
||||
## Description
|
||||
|
||||
## Issues Fixed
|
||||
|
||||
- **Primary Issue (#1450)**: Unicode cursor positioning was incorrect due to mixing character positions with byte positions
|
||||
- **Additional Issue**: Full-width spaces (CJK whitespace like " ") weren't properly handled as token boundaries
|
||||
- ref: https://doc.rust-lang.org/std/primitive.char.html#method.is_whitespace
|
||||
|
||||
## Full Diff
|
||||
|
||||
```diff
|
||||
diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs
|
||||
index 59d6e4579d..cd8e9fa17f 100644
|
||||
--- a/codex-rs/tui/src/bottom_pane/chat_composer.rs
|
||||
+++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs
|
||||
@@ -290,26 +290,28 @@ impl ChatComposer<'_> {
|
||||
// Guard against out-of-bounds rows.
|
||||
let line = textarea.lines().get(row)?.as_str();
|
||||
|
||||
- // Clamp the cursor column to the line length to avoid slicing panics
|
||||
- // when the cursor is at the end of the line.
|
||||
- let col = col.min(line.len());
|
||||
+ // Calculate byte offset for cursor position
|
||||
+ let cursor_byte_offset = line.chars().take(col).map(|c| c.len_utf8()).sum::<usize>();
|
||||
|
||||
// Split the line at the cursor position so we can search for word
|
||||
// boundaries on both sides.
|
||||
- let before_cursor = &line[..col];
|
||||
- let after_cursor = &line[col..];
|
||||
+ let before_cursor = &line[..cursor_byte_offset];
|
||||
+ let after_cursor = &line[cursor_byte_offset..];
|
||||
|
||||
- // Find start index (first character **after** the previous whitespace).
|
||||
+ // Find start index (first character **after** the previous multi-byte whitespace).
|
||||
let start_idx = before_cursor
|
||||
- .rfind(|c: char| c.is_whitespace())
|
||||
- .map(|idx| idx + 1)
|
||||
+ .char_indices()
|
||||
+ .rfind(|(_, c)| c.is_whitespace())
|
||||
+ .map(|(idx, c)| idx + c.len_utf8())
|
||||
.unwrap_or(0);
|
||||
|
||||
- // Find end index (first whitespace **after** the cursor position).
|
||||
+ // Find end index (first multi-byte whitespace **after** the cursor position).
|
||||
let end_rel_idx = after_cursor
|
||||
- .find(|c: char| c.is_whitespace())
|
||||
+ .char_indices()
|
||||
+ .find(|(_, c)| c.is_whitespace())
|
||||
+ .map(|(idx, _)| idx)
|
||||
.unwrap_or(after_cursor.len());
|
||||
- let end_idx = col + end_rel_idx;
|
||||
+ let end_idx = cursor_byte_offset + end_rel_idx;
|
||||
|
||||
if start_idx >= end_idx {
|
||||
return None;
|
||||
@@ -336,21 +338,25 @@ impl ChatComposer<'_> {
|
||||
let mut lines: Vec<String> = self.textarea.lines().to_vec();
|
||||
|
||||
if let Some(line) = lines.get_mut(row) {
|
||||
- let col = col.min(line.len());
|
||||
+ // Calculate byte offset for cursor position
|
||||
+ let cursor_byte_offset = line.chars().take(col).map(|c| c.len_utf8()).sum::<usize>();
|
||||
|
||||
- let before_cursor = &line[..col];
|
||||
- let after_cursor = &line[col..];
|
||||
+ let before_cursor = &line[..cursor_byte_offset];
|
||||
+ let after_cursor = &line[cursor_byte_offset..];
|
||||
|
||||
// Determine token boundaries.
|
||||
let start_idx = before_cursor
|
||||
- .rfind(|c: char| c.is_whitespace())
|
||||
- .map(|idx| idx + 1)
|
||||
+ .char_indices()
|
||||
+ .rfind(|(_, c)| c.is_whitespace())
|
||||
+ .map(|(idx, c)| idx + c.len_utf8())
|
||||
.unwrap_or(0);
|
||||
|
||||
let end_rel_idx = after_cursor
|
||||
- .find(|c: char| c.is_whitespace())
|
||||
+ .char_indices()
|
||||
+ .find(|(_, c)| c.is_whitespace())
|
||||
+ .map(|(idx, _)| idx)
|
||||
.unwrap_or(after_cursor.len());
|
||||
- let end_idx = col + end_rel_idx;
|
||||
+ let end_idx = cursor_byte_offset + end_rel_idx;
|
||||
|
||||
// Replace the slice `[start_idx, end_idx)` with the chosen path and a trailing space.
|
||||
let mut new_line =
|
||||
@@ -618,3 +624,154 @@ impl WidgetRef for &ChatComposer<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
+
|
||||
+#[cfg(test)]
|
||||
+mod tests {
|
||||
+ use crate::bottom_pane::ChatComposer;
|
||||
+ use tui_textarea::TextArea;
|
||||
+
|
||||
+ #[test]
|
||||
+ fn test_current_at_token_basic_cases() {
|
||||
+ let test_cases = vec![
|
||||
+ // Valid @ tokens
|
||||
+ ("@hello", 3, Some("hello".to_string()), "Basic ASCII token"),
|
||||
+ (
|
||||
+ "@file.txt",
|
||||
+ 4,
|
||||
+ Some("file.txt".to_string()),
|
||||
+ "ASCII with extension",
|
||||
+ ),
|
||||
+ (
|
||||
+ "hello @world test",
|
||||
+ 8,
|
||||
+ Some("world".to_string()),
|
||||
+ "ASCII token in middle",
|
||||
+ ),
|
||||
+ (
|
||||
+ "@test123",
|
||||
+ 5,
|
||||
+ Some("test123".to_string()),
|
||||
+ "ASCII with numbers",
|
||||
+ ),
|
||||
+ // Unicode examples
|
||||
+ ("@İstanbul", 3, Some("İstanbul".to_string()), "Turkish text"),
|
||||
+ (
|
||||
+ "@testЙЦУ.rs",
|
||||
+ 8,
|
||||
+ Some("testЙЦУ.rs".to_string()),
|
||||
+ "Mixed ASCII and Cyrillic",
|
||||
+ ),
|
||||
+ ("@诶", 2, Some("诶".to_string()), "Chinese character"),
|
||||
+ ("@👍", 2, Some("👍".to_string()), "Emoji token"),
|
||||
+ // Invalid cases (should return None)
|
||||
+ ("hello", 2, None, "No @ symbol"),
|
||||
+ ("@", 1, None, "Only @ symbol"),
|
||||
+ ("@ hello", 2, None, "@ followed by space"),
|
||||
+ ("test @ world", 6, None, "@ with spaces around"),
|
||||
+ ];
|
||||
+
|
||||
+ for (input, cursor_pos, expected, description) in test_cases {
|
||||
+ let mut textarea = TextArea::default();
|
||||
+ textarea.insert_str(input);
|
||||
+ textarea.move_cursor(tui_textarea::CursorMove::Jump(0, cursor_pos));
|
||||
+
|
||||
+ let result = ChatComposer::current_at_token(&textarea);
|
||||
+ assert_eq!(
|
||||
+ result, expected,
|
||||
+ "Failed for case: {} - input: '{}', cursor: {}",
|
||||
+ description, input, cursor_pos
|
||||
+ );
|
||||
+ }
|
||||
+ }
|
||||
+
|
||||
+ #[test]
|
||||
+ fn test_current_at_token_cursor_positions() {
|
||||
+ let test_cases = vec![
|
||||
+ // Different cursor positions within a token
|
||||
+ ("@test", 0, Some("test".to_string()), "Cursor at @"),
|
||||
+ ("@test", 1, Some("test".to_string()), "Cursor after @"),
|
||||
+ ("@test", 5, Some("test".to_string()), "Cursor at end"),
|
||||
+ // Multiple tokens - cursor determines which token
|
||||
+ ("@file1 @file2", 0, Some("file1".to_string()), "First token"),
|
||||
+ (
|
||||
+ "@file1 @file2",
|
||||
+ 8,
|
||||
+ Some("file2".to_string()),
|
||||
+ "Second token",
|
||||
+ ),
|
||||
+ // Edge cases
|
||||
+ ("@", 0, None, "Only @ symbol"),
|
||||
+ ("@a", 2, Some("a".to_string()), "Single character after @"),
|
||||
+ ("", 0, None, "Empty input"),
|
||||
+ ];
|
||||
+
|
||||
+ for (input, cursor_pos, expected, description) in test_cases {
|
||||
+ let mut textarea = TextArea::default();
|
||||
+ textarea.insert_str(input);
|
||||
+ textarea.move_cursor(tui_textarea::CursorMove::Jump(0, cursor_pos));
|
||||
+
|
||||
+ let result = ChatComposer::current_at_token(&textarea);
|
||||
+ assert_eq!(
|
||||
+ result, expected,
|
||||
+ "Failed for cursor position case: {description} - input: '{input}', cursor: {cursor_pos}",
|
||||
+ );
|
||||
+ }
|
||||
+ }
|
||||
+
|
||||
+ #[test]
|
||||
+ fn test_current_at_token_whitespace_boundaries() {
|
||||
+ let test_cases = vec![
|
||||
+ // Space boundaries
|
||||
+ (
|
||||
+ "aaa@aaa",
|
||||
+ 4,
|
||||
+ None,
|
||||
+ "Connected @ token - no completion by design",
|
||||
+ ),
|
||||
+ (
|
||||
+ "aaa @aaa",
|
||||
+ 5,
|
||||
+ Some("aaa".to_string()),
|
||||
+ "@ token after space",
|
||||
+ ),
|
||||
+ (
|
||||
+ "test @file.txt",
|
||||
+ 7,
|
||||
+ Some("file.txt".to_string()),
|
||||
+ "@ token after space",
|
||||
+ ),
|
||||
+ // Full-width space boundaries
|
||||
+ (
|
||||
+ "test @İstanbul",
|
||||
+ 6,
|
||||
+ Some("İstanbul".to_string()),
|
||||
+ "@ token after full-width space",
|
||||
+ ),
|
||||
+ (
|
||||
+ "@ЙЦУ @诶",
|
||||
+ 6,
|
||||
+ Some("诶".to_string()),
|
||||
+ "Full-width space between Unicode tokens",
|
||||
+ ),
|
||||
+ // Tab and newline boundaries
|
||||
+ (
|
||||
+ "test\t@file",
|
||||
+ 6,
|
||||
+ Some("file".to_string()),
|
||||
+ "@ token after tab",
|
||||
+ ),
|
||||
+ ];
|
||||
+
|
||||
+ for (input, cursor_pos, expected, description) in test_cases {
|
||||
+ let mut textarea = TextArea::default();
|
||||
+ textarea.insert_str(input);
|
||||
+ textarea.move_cursor(tui_textarea::CursorMove::Jump(0, cursor_pos));
|
||||
+
|
||||
+ let result = ChatComposer::current_at_token(&textarea);
|
||||
+ assert_eq!(
|
||||
+ result, expected,
|
||||
+ "Failed for whitespace boundary case: {description} - input: '{input}', cursor: {cursor_pos}",
|
||||
+ );
|
||||
+ }
|
||||
+ }
|
||||
+}
|
||||
```
|
||||
|
||||
## Review Comments
|
||||
|
||||
### codex-rs/tui/src/bottom_pane/chat_composer.rs
|
||||
|
||||
- Created: 2025-07-07 20:31:18 UTC | Link: https://github.com/openai/codex/pull/1467#discussion_r2190962611
|
||||
|
||||
```diff
|
||||
@@ -618,3 +624,107 @@ impl WidgetRef for &ChatComposer<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
+
|
||||
+#[cfg(test)]
|
||||
+mod tests {
|
||||
+ use crate::bottom_pane::ChatComposer;
|
||||
+ use tui_textarea::TextArea;
|
||||
+
|
||||
+ #[test]
|
||||
+ fn test_current_at_token_basic_cases() {
|
||||
+ let test_cases = vec![
|
||||
+ // Valid @ tokens
|
||||
+ ("@hello", 3, Some("hello".to_string()), "Basic ASCII token"),
|
||||
+ ("@file.txt", 4, Some("file.txt".to_string()), "ASCII with extension"),
|
||||
+ ("hello @world test", 8, Some("world".to_string()), "ASCII token in middle"),
|
||||
+ ("@test123", 5, Some("test123".to_string()), "ASCII with numbers"),
|
||||
+
|
||||
+ // Unicode examples
|
||||
+ ("@İstanbul", 3, Some("İstanbul".to_string()), "Turkish text"),
|
||||
+ ("@testЙЦУ.rs", 8, Some("testЙЦУ.rs".to_string()), "Mixed ASCII and Cyrillic"),
|
||||
+ ("@诶", 2, Some("诶".to_string()), "Chinese character"),
|
||||
+ ("@👍", 2, Some("👍".to_string()), "Emoji token"),
|
||||
+
|
||||
+ // Invalid cases (should return None)
|
||||
+ ("hello", 2, None, "No @ symbol"),
|
||||
+ ("@", 1, None, "Only @ symbol"),
|
||||
+ ("@ hello", 2, None, "@ followed by space"),
|
||||
+ ("test @ world", 6, None, "@ with spaces around"),
|
||||
+ ];
|
||||
+
|
||||
+ for (input, cursor_pos, expected, description) in test_cases {
|
||||
+ let mut textarea = TextArea::default();
|
||||
+ textarea.insert_str(input);
|
||||
+ textarea.move_cursor(tui_textarea::CursorMove::Jump(0, cursor_pos));
|
||||
+
|
||||
+ let result = ChatComposer::current_at_token(&textarea);
|
||||
+ assert_eq!(
|
||||
+ result, expected,
|
||||
+ "Failed for case: {} - input: '{}', cursor: {}",
|
||||
+ description, input, cursor_pos
|
||||
+ );
|
||||
+ }
|
||||
+ }
|
||||
+
|
||||
+ #[test]
|
||||
+ fn test_current_at_token_cursor_positions() {
|
||||
+ let test_cases = vec![
|
||||
+ // Different cursor positions within a token
|
||||
+ ("@test", 0, Some("test".to_string()), "Cursor at @"),
|
||||
+ ("@test", 1, Some("test".to_string()), "Cursor after @"),
|
||||
+ ("@test", 5, Some("test".to_string()), "Cursor at end"),
|
||||
+
|
||||
+ // Multiple tokens - cursor determines which token
|
||||
+ ("@file1 @file2", 0, Some("file1".to_string()), "First token"),
|
||||
+ ("@file1 @file2", 8, Some("file2".to_string()), "Second token"),
|
||||
+
|
||||
+ // Edge cases
|
||||
+ ("@", 0, None, "Only @ symbol"),
|
||||
+ ("@a", 2, Some("a".to_string()), "Single character after @"),
|
||||
+ ("", 0, None, "Empty input"),
|
||||
+ ];
|
||||
+
|
||||
+ for (input, cursor_pos, expected, description) in test_cases {
|
||||
+ let mut textarea = TextArea::default();
|
||||
+ textarea.insert_str(input);
|
||||
+ textarea.move_cursor(tui_textarea::CursorMove::Jump(0, cursor_pos));
|
||||
+
|
||||
+ let result = ChatComposer::current_at_token(&textarea);
|
||||
+ assert_eq!(
|
||||
+ result, expected,
|
||||
+ "Failed for cursor position case: {} - input: '{}', cursor: {}",
|
||||
+ description, input, cursor_pos
|
||||
+ );
|
||||
+ }
|
||||
+ }
|
||||
+
|
||||
+ #[test]
|
||||
+ fn test_current_at_token_whitespace_boundaries() {
|
||||
+ let test_cases = vec![
|
||||
+ // Space boundaries
|
||||
+ ("aaa@aaa", 4, None, "Connected @ token - no completion by design"),
|
||||
+ ("aaa @aaa", 5, Some("aaa".to_string()), "@ token after space"),
|
||||
+ ("test @file.txt", 7, Some("file.txt".to_string()), "@ token after space"),
|
||||
+
|
||||
+ // Full-width space boundaries
|
||||
+ ("test @İstanbul", 6, Some("İstanbul".to_string()), "@ token after full-width space"),
|
||||
+ ("@ЙЦУ @诶", 6, Some("诶".to_string()), "Full-width space between Unicode tokens"),
|
||||
+
|
||||
+ // Tab and newline boundaries
|
||||
+ ("test\t@file", 6, Some("file".to_string()), "@ token after tab"),
|
||||
+ ];
|
||||
+
|
||||
+ for (input, cursor_pos, expected, description) in test_cases {
|
||||
+ let mut textarea = TextArea::default();
|
||||
+ textarea.insert_str(input);
|
||||
+ textarea.move_cursor(tui_textarea::CursorMove::Jump(0, cursor_pos));
|
||||
+
|
||||
+ let result = ChatComposer::current_at_token(&textarea);
|
||||
+ assert_eq!(
|
||||
+ result, expected,
|
||||
+ "Failed for whitespace boundary case: {} - input: '{}', cursor: {}",
|
||||
+ description, input, cursor_pos
|
||||
+ );
|
||||
```
|
||||
|
||||
> ```suggestion
|
||||
> assert_eq!(
|
||||
> result, expected,
|
||||
> "Failed for whitespace boundary case: {description} - input: '{input}', cursor: {cursor_pos}",
|
||||
> );
|
||||
> ```
|
||||
|
||||
- Created: 2025-07-07 20:31:43 UTC | Link: https://github.com/openai/codex/pull/1467#discussion_r2190963121
|
||||
|
||||
```diff
|
||||
@@ -618,3 +624,107 @@ impl WidgetRef for &ChatComposer<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
+
|
||||
+#[cfg(test)]
|
||||
+mod tests {
|
||||
+ use crate::bottom_pane::ChatComposer;
|
||||
+ use tui_textarea::TextArea;
|
||||
+
|
||||
+ #[test]
|
||||
+ fn test_current_at_token_basic_cases() {
|
||||
+ let test_cases = vec![
|
||||
+ // Valid @ tokens
|
||||
+ ("@hello", 3, Some("hello".to_string()), "Basic ASCII token"),
|
||||
+ ("@file.txt", 4, Some("file.txt".to_string()), "ASCII with extension"),
|
||||
+ ("hello @world test", 8, Some("world".to_string()), "ASCII token in middle"),
|
||||
+ ("@test123", 5, Some("test123".to_string()), "ASCII with numbers"),
|
||||
+
|
||||
+ // Unicode examples
|
||||
+ ("@İstanbul", 3, Some("İstanbul".to_string()), "Turkish text"),
|
||||
+ ("@testЙЦУ.rs", 8, Some("testЙЦУ.rs".to_string()), "Mixed ASCII and Cyrillic"),
|
||||
+ ("@诶", 2, Some("诶".to_string()), "Chinese character"),
|
||||
+ ("@👍", 2, Some("👍".to_string()), "Emoji token"),
|
||||
+
|
||||
+ // Invalid cases (should return None)
|
||||
+ ("hello", 2, None, "No @ symbol"),
|
||||
+ ("@", 1, None, "Only @ symbol"),
|
||||
+ ("@ hello", 2, None, "@ followed by space"),
|
||||
+ ("test @ world", 6, None, "@ with spaces around"),
|
||||
+ ];
|
||||
+
|
||||
+ for (input, cursor_pos, expected, description) in test_cases {
|
||||
+ let mut textarea = TextArea::default();
|
||||
+ textarea.insert_str(input);
|
||||
+ textarea.move_cursor(tui_textarea::CursorMove::Jump(0, cursor_pos));
|
||||
+
|
||||
+ let result = ChatComposer::current_at_token(&textarea);
|
||||
+ assert_eq!(
|
||||
+ result, expected,
|
||||
+ "Failed for case: {} - input: '{}', cursor: {}",
|
||||
+ description, input, cursor_pos
|
||||
+ );
|
||||
+ }
|
||||
+ }
|
||||
+
|
||||
+ #[test]
|
||||
+ fn test_current_at_token_cursor_positions() {
|
||||
+ let test_cases = vec![
|
||||
+ // Different cursor positions within a token
|
||||
+ ("@test", 0, Some("test".to_string()), "Cursor at @"),
|
||||
+ ("@test", 1, Some("test".to_string()), "Cursor after @"),
|
||||
+ ("@test", 5, Some("test".to_string()), "Cursor at end"),
|
||||
+
|
||||
+ // Multiple tokens - cursor determines which token
|
||||
+ ("@file1 @file2", 0, Some("file1".to_string()), "First token"),
|
||||
+ ("@file1 @file2", 8, Some("file2".to_string()), "Second token"),
|
||||
+
|
||||
+ // Edge cases
|
||||
+ ("@", 0, None, "Only @ symbol"),
|
||||
+ ("@a", 2, Some("a".to_string()), "Single character after @"),
|
||||
+ ("", 0, None, "Empty input"),
|
||||
+ ];
|
||||
+
|
||||
+ for (input, cursor_pos, expected, description) in test_cases {
|
||||
+ let mut textarea = TextArea::default();
|
||||
+ textarea.insert_str(input);
|
||||
+ textarea.move_cursor(tui_textarea::CursorMove::Jump(0, cursor_pos));
|
||||
+
|
||||
+ let result = ChatComposer::current_at_token(&textarea);
|
||||
+ assert_eq!(
|
||||
+ result, expected,
|
||||
+ "Failed for cursor position case: {} - input: '{}', cursor: {}",
|
||||
+ description, input, cursor_pos
|
||||
+ );
|
||||
```
|
||||
|
||||
> ```suggestion
|
||||
> assert_eq!(
|
||||
> result, expected,
|
||||
> "Failed for cursor position case: {description} - input: '{input}', cursor: {cursor_pos}",
|
||||
> );
|
||||
> ```
|
||||
3529
prs/bolinfest/PR-1494.md
Normal file
3529
prs/bolinfest/PR-1494.md
Normal file
File diff suppressed because it is too large
Load Diff
716
prs/bolinfest/PR-1497.md
Normal file
716
prs/bolinfest/PR-1497.md
Normal file
@@ -0,0 +1,716 @@
|
||||
# PR #1497: (tui): introduce /compact command to trim conversation context
|
||||
|
||||
- URL: https://github.com/openai/codex/pull/1497
|
||||
- Author: aibrahim-oai
|
||||
- Created: 2025-07-10 00:50:19 UTC
|
||||
- Updated: 2025-07-11 22:26:31 UTC
|
||||
- Changes: +276/-2, Files changed: 12, Commits: 4
|
||||
|
||||
## Description
|
||||
|
||||
Description
|
||||
# Adds an asynchronous `/compact` slash-command to the Rust TUI that:
|
||||
- sends the current transcript to the model, receives a short summary, and
|
||||
- replaces the full history with that summary to free up context window space.
|
||||
# Core layer:
|
||||
- new `Op::EraseConversationHistory` and `Session::erase_conversation_history` to let the backend
|
||||
forget prior messages,
|
||||
- `ConversationHistory::clear()` plus unit test.
|
||||
# UI/TUI layer:
|
||||
- chatwidget tracks a lightweight TranscriptEntry list and drives the background summarization
|
||||
job,
|
||||
- status indicator while the summary is generated, token-usage reset after compaction,
|
||||
- new compact.rs helper with summarization logic.
|
||||
|
||||
https://github.com/user-attachments/assets/17e11d67-cc0f-4f57-acb1-4bc331610313
|
||||
|
||||
## Full Diff
|
||||
|
||||
```diff
|
||||
diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock
|
||||
index 653c3e4ef2..8a874de121 100644
|
||||
--- a/codex-rs/Cargo.lock
|
||||
+++ b/codex-rs/Cargo.lock
|
||||
@@ -856,6 +856,8 @@ dependencies = [
|
||||
"ratatui",
|
||||
"ratatui-image",
|
||||
"regex-lite",
|
||||
+ "reqwest",
|
||||
+ "serde",
|
||||
"serde_json",
|
||||
"shlex",
|
||||
"strum 0.27.2",
|
||||
diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs
|
||||
index 3ab3e8d780..422ec4480b 100644
|
||||
--- a/codex-rs/core/src/codex.rs
|
||||
+++ b/codex-rs/core/src/codex.rs
|
||||
@@ -219,6 +219,23 @@ impl Session {
|
||||
.map(PathBuf::from)
|
||||
.map_or_else(|| self.cwd.clone(), |p| self.cwd.join(p))
|
||||
}
|
||||
+ /// Erases all previous messages from the conversation history (zdr_transcript), if present.
|
||||
+ pub fn erase_conversation_history(&self) {
|
||||
+ let mut state = self.state.lock().unwrap();
|
||||
+ if let Some(transcript) = state.zdr_transcript.as_mut() {
|
||||
+ transcript.clear();
|
||||
+ }
|
||||
+
|
||||
+ // When using the experimental OpenAI Responses API with server-side
|
||||
+ // storage enabled, `previous_response_id` is used to let the model
|
||||
+ // access the earlier part of the conversation **without** having to
|
||||
+ // resend the full transcript. To truly wipe all historical context
|
||||
+ // we must drop this identifier as well, otherwise the backend will
|
||||
+ // still be able to retrieve the prior messages via the ID even
|
||||
+ // though our local transcript has been cleared. See
|
||||
+ // https://platform.openai.com/docs/guides/responses for details.
|
||||
+ state.previous_response_id = None;
|
||||
+ }
|
||||
}
|
||||
|
||||
/// Mutable state of the agent
|
||||
@@ -558,6 +575,11 @@ async fn submission_loop(
|
||||
|
||||
debug!(?sub, "Submission");
|
||||
match sub.op {
|
||||
+ Op::EraseConversationHistory => {
|
||||
+ if let Some(sess) = sess.as_ref() {
|
||||
+ sess.erase_conversation_history();
|
||||
+ }
|
||||
+ }
|
||||
Op::Interrupt => {
|
||||
let sess = match sess.as_ref() {
|
||||
Some(sess) => sess,
|
||||
diff --git a/codex-rs/core/src/conversation_history.rs b/codex-rs/core/src/conversation_history.rs
|
||||
index 4cd989cbd9..50f641ad1e 100644
|
||||
--- a/codex-rs/core/src/conversation_history.rs
|
||||
+++ b/codex-rs/core/src/conversation_history.rs
|
||||
@@ -30,6 +30,11 @@ impl ConversationHistory {
|
||||
}
|
||||
}
|
||||
}
|
||||
+
|
||||
+ /// Clears the conversation history.
|
||||
+ pub(crate) fn clear(&mut self) {
|
||||
+ self.items.clear();
|
||||
+ }
|
||||
}
|
||||
|
||||
/// Anything that is not a system message or "reasoning" message is considered
|
||||
@@ -44,3 +49,31 @@ fn is_api_message(message: &ResponseItem) -> bool {
|
||||
ResponseItem::Other => false,
|
||||
}
|
||||
}
|
||||
+
|
||||
+#[cfg(test)]
|
||||
+mod tests {
|
||||
+ use super::*;
|
||||
+ use crate::models::ResponseItem;
|
||||
+
|
||||
+ #[test]
|
||||
+ fn clear_removes_all_items() {
|
||||
+ let mut hist = ConversationHistory::new();
|
||||
+
|
||||
+ use crate::models::ContentItem;
|
||||
+
|
||||
+ let items = [ResponseItem::Message {
|
||||
+ role: "user".into(),
|
||||
+ content: vec![ContentItem::InputText {
|
||||
+ text: "hello".into(),
|
||||
+ }],
|
||||
+ }];
|
||||
+
|
||||
+ hist.record_items(items.iter());
|
||||
+
|
||||
+ assert_eq!(hist.contents().len(), 1, "sanity – item should be present");
|
||||
+
|
||||
+ hist.clear();
|
||||
+
|
||||
+ assert!(hist.contents().is_empty(), "all items should be removed");
|
||||
+ }
|
||||
+}
|
||||
diff --git a/codex-rs/core/src/protocol.rs b/codex-rs/core/src/protocol.rs
|
||||
index 22bc1809f4..ee8fffe90f 100644
|
||||
--- a/codex-rs/core/src/protocol.rs
|
||||
+++ b/codex-rs/core/src/protocol.rs
|
||||
@@ -35,6 +35,8 @@ pub struct Submission {
|
||||
#[allow(clippy::large_enum_variant)]
|
||||
#[non_exhaustive]
|
||||
pub enum Op {
|
||||
+ /// Erase all conversation history for the current session.
|
||||
+ EraseConversationHistory,
|
||||
/// Configure the model session.
|
||||
ConfigureSession {
|
||||
/// Provider identifier ("openai", "openrouter", ...).
|
||||
diff --git a/codex-rs/tui/Cargo.toml b/codex-rs/tui/Cargo.toml
|
||||
index 2f150921fb..79f59eb1f2 100644
|
||||
--- a/codex-rs/tui/Cargo.toml
|
||||
+++ b/codex-rs/tui/Cargo.toml
|
||||
@@ -61,6 +61,8 @@ tui-textarea = "0.7.0"
|
||||
unicode-segmentation = "1.12.0"
|
||||
unicode-width = "0.1"
|
||||
uuid = "1"
|
||||
+reqwest = { version = "0.12", features = ["json"] }
|
||||
+serde = { version = "1", features = ["derive"] }
|
||||
|
||||
[dev-dependencies]
|
||||
insta = "1.43.1"
|
||||
diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs
|
||||
index e7097e6af0..69ceefd4f8 100644
|
||||
--- a/codex-rs/tui/src/app.rs
|
||||
+++ b/codex-rs/tui/src/app.rs
|
||||
@@ -329,6 +329,11 @@ impl App<'_> {
|
||||
widget.add_diff_output(text);
|
||||
}
|
||||
}
|
||||
+ SlashCommand::Compact => {
|
||||
+ if let AppState::Chat { widget } = &mut self.app_state {
|
||||
+ widget.start_compact();
|
||||
+ }
|
||||
+ }
|
||||
},
|
||||
AppEvent::StartFileSearch(query) => {
|
||||
self.file_search.on_user_query(query);
|
||||
@@ -338,6 +343,11 @@ impl App<'_> {
|
||||
widget.apply_file_search_result(query, matches);
|
||||
}
|
||||
}
|
||||
+ AppEvent::CompactComplete(result) => {
|
||||
+ if let AppState::Chat { widget } = &mut self.app_state {
|
||||
+ widget.apply_compact_summary(result);
|
||||
+ }
|
||||
+ }
|
||||
}
|
||||
}
|
||||
terminal.clear()?;
|
||||
diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs
|
||||
index a1f304fe42..4547f3c112 100644
|
||||
--- a/codex-rs/tui/src/app_event.rs
|
||||
+++ b/codex-rs/tui/src/app_event.rs
|
||||
@@ -51,5 +51,10 @@ pub(crate) enum AppEvent {
|
||||
matches: Vec<FileMatch>,
|
||||
},
|
||||
|
||||
+ /// Result of the asynchronous `/compact` summarization.
|
||||
+ CompactComplete(Result<String, String>),
|
||||
+
|
||||
+ /// Insert the most recently appended history entry directly into the
|
||||
+ /// terminal scrollback. Carries already formatted lines.
|
||||
InsertHistory(Vec<Line<'static>>),
|
||||
}
|
||||
diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs
|
||||
index a896ae37bc..30548627af 100644
|
||||
--- a/codex-rs/tui/src/chatwidget.rs
|
||||
+++ b/codex-rs/tui/src/chatwidget.rs
|
||||
@@ -36,6 +36,9 @@ use crate::bottom_pane::BottomPane;
|
||||
use crate::bottom_pane::BottomPaneParams;
|
||||
use crate::bottom_pane::CancellationEvent;
|
||||
use crate::bottom_pane::InputResult;
|
||||
+use crate::compact::Role;
|
||||
+use crate::compact::TranscriptEntry;
|
||||
+use crate::compact::generate_compact_summary;
|
||||
use crate::conversation_history_widget::ConversationHistoryWidget;
|
||||
use crate::exec_command::strip_bash_lc_and_escape;
|
||||
use crate::history_cell::PatchEventType;
|
||||
@@ -50,11 +53,12 @@ pub(crate) struct ChatWidget<'a> {
|
||||
config: Config,
|
||||
initial_user_message: Option<UserMessage>,
|
||||
token_usage: TokenUsage,
|
||||
+ // Buffer for streaming assistant reasoning text; emitted on final event.
|
||||
reasoning_buffer: String,
|
||||
- // Buffer for streaming assistant answer text; we do not surface partial
|
||||
- // We wait for the final AgentMessage event and then emit the full text
|
||||
- // at once into scrollback so the history contains a single message.
|
||||
+ // Buffer for streaming assistant answer text; emitted on final event.
|
||||
answer_buffer: String,
|
||||
+ // Transcript of chat for `/compact` summarization.
|
||||
+ transcript: Vec<TranscriptEntry>,
|
||||
}
|
||||
|
||||
struct UserMessage {
|
||||
@@ -140,6 +144,7 @@ impl ChatWidget<'_> {
|
||||
token_usage: TokenUsage::default(),
|
||||
reasoning_buffer: String::new(),
|
||||
answer_buffer: String::new(),
|
||||
+ transcript: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -198,8 +203,14 @@ impl ChatWidget<'_> {
|
||||
|
||||
// Only show text portion in conversation history for now.
|
||||
if !text.is_empty() {
|
||||
+ // Forward a copy for history and emit into scrollback.
|
||||
self.conversation_history.add_user_message(text.clone());
|
||||
self.emit_last_history_entry();
|
||||
+ // Record in transcript for `/compact`.
|
||||
+ self.transcript.push(TranscriptEntry {
|
||||
+ role: Role::User,
|
||||
+ text,
|
||||
+ });
|
||||
}
|
||||
self.conversation_history.scroll_to_bottom();
|
||||
}
|
||||
@@ -230,10 +241,7 @@ impl ChatWidget<'_> {
|
||||
self.request_redraw();
|
||||
}
|
||||
EventMsg::AgentMessage(AgentMessageEvent { message }) => {
|
||||
- // Final assistant answer. Prefer the fully provided message
|
||||
- // from the event; if it is empty fall back to any accumulated
|
||||
- // delta buffer (some providers may only stream deltas and send
|
||||
- // an empty final message).
|
||||
+ // Final assistant answer. Prefer the fully provided message.
|
||||
let full = if message.is_empty() {
|
||||
std::mem::take(&mut self.answer_buffer)
|
||||
} else {
|
||||
@@ -242,8 +250,13 @@ impl ChatWidget<'_> {
|
||||
};
|
||||
if !full.is_empty() {
|
||||
self.conversation_history
|
||||
- .add_agent_message(&self.config, full);
|
||||
+ .add_agent_message(&self.config, full.clone());
|
||||
self.emit_last_history_entry();
|
||||
+ // Record final answer in transcript for `/compact`.
|
||||
+ self.transcript.push(TranscriptEntry {
|
||||
+ role: Role::Assistant,
|
||||
+ text: full,
|
||||
+ });
|
||||
}
|
||||
self.request_redraw();
|
||||
}
|
||||
@@ -469,6 +482,88 @@ impl ChatWidget<'_> {
|
||||
self.bottom_pane.on_file_search_result(query, matches);
|
||||
}
|
||||
|
||||
+ // (removed deprecated synchronous `compact` implementation)
|
||||
+
|
||||
+ /// Kick off an asynchronous summarization of the current transcript.
|
||||
+ /// Returns immediately so the UI stays responsive.
|
||||
+ pub(crate) fn start_compact(&mut self) {
|
||||
+ // Show status indicator immediately.
|
||||
+ self.bottom_pane.set_task_running(true);
|
||||
+ self.bottom_pane
|
||||
+ .update_status_text("Summarizing context…".to_string());
|
||||
+ self.request_redraw();
|
||||
+
|
||||
+ // Clone data required for the background task.
|
||||
+ let transcript = self.transcript.clone();
|
||||
+ let model = self.config.model.clone();
|
||||
+ let config_clone = self.config.clone();
|
||||
+ let app_event_tx = self.app_event_tx.clone();
|
||||
+
|
||||
+ // Spawn the summarization on a blocking thread to avoid CPU-bound work
|
||||
+ // stalling the async runtime (and thus the UI).
|
||||
+ tokio::task::spawn_blocking(move || {
|
||||
+ let rt = tokio::runtime::Handle::current();
|
||||
+ rt.block_on(async move {
|
||||
+ let result = generate_compact_summary(&transcript, &model, &config_clone).await;
|
||||
+ let evt = match result {
|
||||
+ Ok(summary) => AppEvent::CompactComplete(Ok(summary)),
|
||||
+ Err(e) => AppEvent::CompactComplete(Err(format!("{e}"))),
|
||||
+ };
|
||||
+ app_event_tx.send(evt);
|
||||
+ });
|
||||
+ });
|
||||
+ }
|
||||
+
|
||||
+ /// Apply the completed summary returned by the background task.
|
||||
+ pub(crate) fn apply_compact_summary(&mut self, result: Result<String, String>) {
|
||||
+ match result {
|
||||
+ Ok(summary) => {
|
||||
+ self.conversation_history.clear_agent_history();
|
||||
+ self.transcript.clear();
|
||||
+ // clear session history in backend
|
||||
+ self.submit_op(Op::EraseConversationHistory);
|
||||
+ self.conversation_history
|
||||
+ .add_agent_message(&self.config, summary.clone());
|
||||
+ self.transcript = vec![TranscriptEntry {
|
||||
+ role: Role::Assistant,
|
||||
+ text: summary,
|
||||
+ }];
|
||||
+
|
||||
+ // Re-configure the Codex session so that the backend agent starts with
|
||||
+ // a clean conversation context.
|
||||
+ let op = Op::ConfigureSession {
|
||||
+ provider: self.config.model_provider.clone(),
|
||||
+ model: self.config.model.clone(),
|
||||
+ model_reasoning_effort: self.config.model_reasoning_effort,
|
||||
+ model_reasoning_summary: self.config.model_reasoning_summary,
|
||||
+ user_instructions: self.config.user_instructions.clone(),
|
||||
+ base_instructions: self.config.base_instructions.clone(),
|
||||
+ approval_policy: self.config.approval_policy,
|
||||
+ sandbox_policy: self.config.sandbox_policy.clone(),
|
||||
+ disable_response_storage: self.config.disable_response_storage,
|
||||
+ notify: self.config.notify.clone(),
|
||||
+ cwd: self.config.cwd.clone(),
|
||||
+ resume_path: None,
|
||||
+ };
|
||||
+ self.submit_op(op);
|
||||
+
|
||||
+ // Reset the recorded token usage because we start a fresh
|
||||
+ // conversation context. This ensures the *context remaining*
|
||||
+ // indicator in the composer is updated immediately.
|
||||
+ self.token_usage = TokenUsage::default();
|
||||
+ self.bottom_pane
|
||||
+ .set_token_usage(self.token_usage.clone(), self.config.model_context_window);
|
||||
+ }
|
||||
+ Err(msg) => {
|
||||
+ self.conversation_history.add_error(msg);
|
||||
+ }
|
||||
+ }
|
||||
+
|
||||
+ // Hide status indicator and refresh UI.
|
||||
+ self.bottom_pane.set_task_running(false);
|
||||
+ self.request_redraw();
|
||||
+ }
|
||||
+
|
||||
/// Handle Ctrl-C key press.
|
||||
/// Returns CancellationEvent::Handled if the event was consumed by the UI, or
|
||||
/// CancellationEvent::Ignored if the caller should handle it (e.g. exit).
|
||||
diff --git a/codex-rs/tui/src/compact.rs b/codex-rs/tui/src/compact.rs
|
||||
new file mode 100644
|
||||
index 0000000000..31ee9a58e7
|
||||
--- /dev/null
|
||||
+++ b/codex-rs/tui/src/compact.rs
|
||||
@@ -0,0 +1,91 @@
|
||||
+use anyhow::Result;
|
||||
+use anyhow::anyhow;
|
||||
+use codex_core::config::Config;
|
||||
+use codex_core::openai_api_key::get_openai_api_key;
|
||||
+use serde::Serialize;
|
||||
+
|
||||
+#[derive(Clone)]
|
||||
+pub enum Role {
|
||||
+ User,
|
||||
+ Assistant,
|
||||
+}
|
||||
+
|
||||
+#[derive(Clone)]
|
||||
+pub struct TranscriptEntry {
|
||||
+ pub role: Role,
|
||||
+ pub text: String,
|
||||
+}
|
||||
+
|
||||
+impl TranscriptEntry {
|
||||
+ fn role_str(&self) -> &'static str {
|
||||
+ match self.role {
|
||||
+ Role::User => "user",
|
||||
+ Role::Assistant => "assistant",
|
||||
+ }
|
||||
+ }
|
||||
+}
|
||||
+
|
||||
+#[derive(Serialize)]
|
||||
+struct Message<'a> {
|
||||
+ role: &'a str,
|
||||
+ content: String,
|
||||
+}
|
||||
+
|
||||
+#[derive(Serialize)]
|
||||
+struct Payload<'a> {
|
||||
+ model: &'a str,
|
||||
+ messages: Vec<Message<'a>>,
|
||||
+}
|
||||
+
|
||||
+/// Generate a concise summary of the provided transcript using the OpenAI chat
|
||||
+/// completions API.
|
||||
+pub async fn generate_compact_summary(
|
||||
+ transcript: &[TranscriptEntry],
|
||||
+ model: &str,
|
||||
+ config: &Config,
|
||||
+) -> Result<String> {
|
||||
+ let conversation_text = transcript
|
||||
+ .iter()
|
||||
+ .map(|e| format!("{}: {}", e.role_str(), e.text))
|
||||
+ .collect::<Vec<_>>()
|
||||
+ .join("\n");
|
||||
+
|
||||
+ let messages = vec![
|
||||
+ Message {
|
||||
+ role: "assistant",
|
||||
+ content: "You are an expert coding assistant. Your goal is to generate a concise, structured summary of the conversation below that captures all essential information needed to continue development after context replacement. Include tasks performed, code areas modified or reviewed, key decisions or assumptions, test results or errors, and outstanding tasks or next steps.".to_string(),
|
||||
+ },
|
||||
+ Message {
|
||||
+ role: "user",
|
||||
+ content: format!(
|
||||
+ "Here is the conversation so far:\n{conversation_text}\n\nPlease summarize this conversation, covering:\n1. Tasks performed and outcomes\n2. Code files, modules, or functions modified or examined\n3. Important decisions or assumptions made\n4. Errors encountered and test or build results\n5. Remaining tasks, open questions, or next steps\nProvide the summary in a clear, concise format."
|
||||
+ ),
|
||||
+ },
|
||||
+ ];
|
||||
+
|
||||
+ let api_key = get_openai_api_key().ok_or_else(|| anyhow!("OpenAI API key not set"))?;
|
||||
+ let client = reqwest::Client::new();
|
||||
+ let base = config.model_provider.base_url.trim_end_matches('/');
|
||||
+ let url = format!("{}/chat/completions", base);
|
||||
+
|
||||
+ let payload = Payload { model, messages };
|
||||
+ let res = client
|
||||
+ .post(url)
|
||||
+ .bearer_auth(api_key)
|
||||
+ .json(&payload)
|
||||
+ .send()
|
||||
+ .await?;
|
||||
+
|
||||
+ let body: serde_json::Value = res.json().await?;
|
||||
+ if let Some(summary) = body
|
||||
+ .get("choices")
|
||||
+ .and_then(|c| c.get(0))
|
||||
+ .and_then(|c| c.get("message"))
|
||||
+ .and_then(|m| m.get("content"))
|
||||
+ .and_then(|v| v.as_str())
|
||||
+ {
|
||||
+ Ok(summary.to_string())
|
||||
+ } else {
|
||||
+ Ok("Unable to generate summary.".to_string())
|
||||
+ }
|
||||
+}
|
||||
diff --git a/codex-rs/tui/src/conversation_history_widget.rs b/codex-rs/tui/src/conversation_history_widget.rs
|
||||
index dede0caf5f..cb905cc2d4 100644
|
||||
--- a/codex-rs/tui/src/conversation_history_widget.rs
|
||||
+++ b/codex-rs/tui/src/conversation_history_widget.rs
|
||||
@@ -122,6 +122,10 @@ impl ConversationHistoryWidget {
|
||||
self.add_to_history(HistoryCell::new_agent_message(config, message));
|
||||
}
|
||||
|
||||
+ pub fn clear_agent_history(&mut self) {
|
||||
+ self.clear_all();
|
||||
+ }
|
||||
+
|
||||
pub fn add_agent_reasoning(&mut self, config: &Config, text: String) {
|
||||
self.add_to_history(HistoryCell::new_agent_reasoning(config, text));
|
||||
}
|
||||
@@ -173,6 +177,10 @@ impl ConversationHistoryWidget {
|
||||
});
|
||||
}
|
||||
|
||||
+ fn clear_all(&mut self) {
|
||||
+ self.entries.clear();
|
||||
+ }
|
||||
+
|
||||
/// Return the lines for the most recently appended entry (if any) so the
|
||||
/// parent widget can surface them via the new scrollback insertion path.
|
||||
pub(crate) fn last_entry_plain_lines(&self) -> Option<Vec<Line<'static>>> {
|
||||
diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs
|
||||
index 905f0aaf0b..725cdd8d30 100644
|
||||
--- a/codex-rs/tui/src/lib.rs
|
||||
+++ b/codex-rs/tui/src/lib.rs
|
||||
@@ -27,6 +27,7 @@ mod cell_widget;
|
||||
mod chatwidget;
|
||||
mod citation_regex;
|
||||
mod cli;
|
||||
+mod compact;
|
||||
mod conversation_history_widget;
|
||||
mod exec_command;
|
||||
mod file_search;
|
||||
diff --git a/codex-rs/tui/src/slash_command.rs b/codex-rs/tui/src/slash_command.rs
|
||||
index 603eb721cd..dc7ada4283 100644
|
||||
--- a/codex-rs/tui/src/slash_command.rs
|
||||
+++ b/codex-rs/tui/src/slash_command.rs
|
||||
@@ -14,6 +14,7 @@ pub enum SlashCommand {
|
||||
// more frequently used commands should be listed first.
|
||||
New,
|
||||
Diff,
|
||||
+ Compact,
|
||||
Quit,
|
||||
}
|
||||
|
||||
@@ -26,6 +27,7 @@ impl SlashCommand {
|
||||
SlashCommand::Diff => {
|
||||
"Show git diff of the working directory (including untracked files)"
|
||||
}
|
||||
+ SlashCommand::Compact => "Condense context into a summary.",
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Review Comments
|
||||
|
||||
### codex-rs/core/src/codex.rs
|
||||
|
||||
- Created: 2025-07-10 05:28:45 UTC | Link: https://github.com/openai/codex/pull/1497#discussion_r2196617906
|
||||
|
||||
```diff
|
||||
@@ -549,6 +566,11 @@ async fn submission_loop(
|
||||
|
||||
debug!(?sub, "Submission");
|
||||
match sub.op {
|
||||
+ Op::EraseConversationHistory => {
|
||||
```
|
||||
|
||||
> Wherever you move this variant in the `enum` definition is probably where it should appear in this `match` statement.
|
||||
|
||||
- Created: 2025-07-10 05:29:11 UTC | Link: https://github.com/openai/codex/pull/1497#discussion_r2196618611
|
||||
|
||||
```diff
|
||||
@@ -200,6 +200,23 @@ impl Session {
|
||||
.map(PathBuf::from)
|
||||
.map_or_else(|| self.cwd.clone(), |p| self.cwd.join(p))
|
||||
}
|
||||
+ /// Erases all previous messages from the conversation history (zdr_transcript), if present.
|
||||
+ pub fn erase_conversation_history(&self) {
|
||||
+ let mut state = self.state.lock().unwrap();
|
||||
+ if let Some(transcript) = state.zdr_transcript.as_mut() {
|
||||
+ transcript.clear();
|
||||
+ }
|
||||
+
|
||||
+ // When using the experimental OpenAI Responses API with server-side
|
||||
```
|
||||
|
||||
> This feels like a particularly lengthy comment.
|
||||
|
||||
### codex-rs/core/src/conversation_history.rs
|
||||
|
||||
- Created: 2025-07-10 05:29:35 UTC | Link: https://github.com/openai/codex/pull/1497#discussion_r2196619154
|
||||
|
||||
```diff
|
||||
@@ -48,3 +53,31 @@ fn is_api_message(message: &ResponseItem) -> bool {
|
||||
ResponseItem::Reasoning { .. } | ResponseItem::Other => false,
|
||||
}
|
||||
}
|
||||
+
|
||||
+#[cfg(test)]
|
||||
+mod tests {
|
||||
+ use super::*;
|
||||
+ use crate::models::ResponseItem;
|
||||
+
|
||||
+ #[test]
|
||||
+ fn clear_removes_all_items() {
|
||||
+ let mut hist = ConversationHistory::new();
|
||||
+
|
||||
+ use crate::models::ContentItem;
|
||||
```
|
||||
|
||||
> I would move this with the other imports under `mod tests`.
|
||||
|
||||
### codex-rs/core/src/protocol.rs
|
||||
|
||||
- Created: 2025-07-10 05:28:17 UTC | Link: https://github.com/openai/codex/pull/1497#discussion_r2196617029
|
||||
|
||||
```diff
|
||||
@@ -33,6 +33,8 @@ pub struct Submission {
|
||||
#[allow(clippy::large_enum_variant)]
|
||||
#[non_exhaustive]
|
||||
pub enum Op {
|
||||
+ /// Erase all conversation history for the current session.
|
||||
```
|
||||
|
||||
> For a large enum, I generally try to put the most commonly used variants, or the ones that are likely to be used first (like `ConfigureSession`) at the top of the list, so I would list `EraseConversationHistory` later in this declaration.
|
||||
|
||||
- Created: 2025-07-10 05:37:28 UTC | Link: https://github.com/openai/codex/pull/1497#discussion_r2196629399
|
||||
|
||||
```diff
|
||||
@@ -33,6 +33,8 @@ pub struct Submission {
|
||||
#[allow(clippy::large_enum_variant)]
|
||||
#[non_exhaustive]
|
||||
pub enum Op {
|
||||
+ /// Erase all conversation history for the current session.
|
||||
+ EraseConversationHistory,
|
||||
```
|
||||
|
||||
> I think it would be cleaner if we introduced `Op::CompactConversationHistory` and then the TUI could send `AppEvent::CodexOp(Op::CompactConversationHistory)`, which ultimately should make it possible to eliminate that `rt.block_on()` code.
|
||||
>
|
||||
> Again, this could be done in a separate PR and then this PR would wire up the `Op` in the TUI.
|
||||
|
||||
### codex-rs/tui/src/compact.rs
|
||||
|
||||
- Created: 2025-07-10 05:25:34 UTC | Link: https://github.com/openai/codex/pull/1497#discussion_r2196613542
|
||||
|
||||
```diff
|
||||
@@ -0,0 +1,91 @@
|
||||
+use anyhow::Result;
|
||||
+use anyhow::anyhow;
|
||||
+use codex_core::config::Config;
|
||||
+use codex_core::openai_api_key::get_openai_api_key;
|
||||
+use serde::Serialize;
|
||||
+
|
||||
+#[derive(Clone)]
|
||||
+pub enum Role {
|
||||
+ User,
|
||||
+ Assistant,
|
||||
+}
|
||||
+
|
||||
+#[derive(Clone)]
|
||||
+pub struct TranscriptEntry {
|
||||
+ pub role: Role,
|
||||
+ pub text: String,
|
||||
+}
|
||||
+
|
||||
+impl TranscriptEntry {
|
||||
+ fn role_str(&self) -> &'static str {
|
||||
+ match self.role {
|
||||
+ Role::User => "user",
|
||||
+ Role::Assistant => "assistant",
|
||||
+ }
|
||||
+ }
|
||||
+}
|
||||
+
|
||||
+#[derive(Serialize)]
|
||||
+struct Message<'a> {
|
||||
+ role: &'a str,
|
||||
+ content: String,
|
||||
+}
|
||||
+
|
||||
+#[derive(Serialize)]
|
||||
+struct Payload<'a> {
|
||||
+ model: &'a str,
|
||||
+ messages: Vec<Message<'a>>,
|
||||
+}
|
||||
+
|
||||
+/// Generate a concise summary of the provided transcript using the OpenAI chat
|
||||
+/// completions API.
|
||||
+pub async fn generate_compact_summary(
|
||||
+ transcript: &[TranscriptEntry],
|
||||
+ model: &str,
|
||||
+ config: &Config,
|
||||
+) -> Result<String> {
|
||||
+ let conversation_text = transcript
|
||||
+ .iter()
|
||||
+ .map(|e| format!("{}: {}", e.role_str(), e.text))
|
||||
+ .collect::<Vec<_>>()
|
||||
+ .join("\n");
|
||||
+
|
||||
+ let messages = vec![
|
||||
+ Message {
|
||||
+ role: "assistant",
|
||||
+ content: "You are an expert coding assistant. Your goal is to generate a concise, structured summary of the conversation below that captures all essential information needed to continue development after context replacement. Include tasks performed, code areas modified or reviewed, key decisions or assumptions, test results or errors, and outstanding tasks or next steps.".to_string(),
|
||||
+ },
|
||||
+ Message {
|
||||
+ role: "user",
|
||||
+ content: format!(
|
||||
+ "Here is the conversation so far:\n{conversation_text}\n\nPlease summarize this conversation, covering:\n1. Tasks performed and outcomes\n2. Code files, modules, or functions modified or examined\n3. Important decisions or assumptions made\n4. Errors encountered and test or build results\n5. Remaining tasks, open questions, or next steps\nProvide the summary in a clear, concise format."
|
||||
+ ),
|
||||
+ },
|
||||
+ ];
|
||||
+
|
||||
+ let api_key = get_openai_api_key().ok_or_else(|| anyhow!("OpenAI API key not set"))?;
|
||||
+ let client = reqwest::Client::new();
|
||||
+ let base = config.model_provider.base_url.trim_end_matches('/');
|
||||
+ let url = format!("{}/chat/completions", base);
|
||||
+
|
||||
+ let payload = Payload { model, messages };
|
||||
+ let res = client
|
||||
+ .post(url)
|
||||
+ .bearer_auth(api_key)
|
||||
+ .json(&payload)
|
||||
+ .send()
|
||||
+ .await?;
|
||||
+
|
||||
+ let body: serde_json::Value = res.json().await?;
|
||||
+ if let Some(summary) = body
|
||||
+ .get("choices")
|
||||
+ .and_then(|c| c.get(0))
|
||||
+ .and_then(|c| c.get("message"))
|
||||
+ .and_then(|m| m.get("content"))
|
||||
+ .and_then(|v| v.as_str())
|
||||
+ {
|
||||
+ Ok(summary.to_string())
|
||||
+ } else {
|
||||
+ Ok("Unable to generate summary.".to_string())
|
||||
+ }
|
||||
+}
|
||||
```
|
||||
|
||||
> Some of this code duplicates code in `core/`. Note that the model provider might only support one of Responses or the chat completions API, so it is not safe to assume the above code is an option.
|
||||
>
|
||||
> That said, I recently learned that there is a _non-stateful version of the Responses API_ where you pass input as the array of all messages the same way you do for chat completions. So perhaps we could expose a function in `core/` that takes a `Config` and a list of messages like you have here and uses the `config.model_provider` and a `Client` to make the request?
|
||||
>
|
||||
> Admittedly this is complex enough that it is probably appropriate to do it in a separate PR.
|
||||
1115
prs/bolinfest/PR-1527.md
Normal file
1115
prs/bolinfest/PR-1527.md
Normal file
File diff suppressed because it is too large
Load Diff
873
prs/bolinfest/PR-1529.md
Normal file
873
prs/bolinfest/PR-1529.md
Normal file
@@ -0,0 +1,873 @@
|
||||
# PR #1529: tui supporting /compact operation
|
||||
|
||||
- URL: https://github.com/openai/codex/pull/1529
|
||||
- Author: aibrahim-oai
|
||||
- Created: 2025-07-11 04:01:08 UTC
|
||||
- Updated: 2025-07-30 23:35:05 UTC
|
||||
- Changes: +228/-14, Files changed: 9, Commits: 12
|
||||
|
||||
## Description
|
||||
|
||||
- Supporting compact by sending `Op` for getting summary.
|
||||
- Using the summary to start new Chat with the summary as initial prompt.
|
||||
- Building on this [PR](https://github.com/openai/codex/pull/1527)
|
||||
|
||||
https://github.com/user-attachments/assets/d8e36e41-b0d5-453f-864b-551314669b22
|
||||
|
||||
## Full Diff
|
||||
|
||||
```diff
|
||||
diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs
|
||||
index 6823a83a50..4adbdc6388 100644
|
||||
--- a/codex-rs/tui/src/app.rs
|
||||
+++ b/codex-rs/tui/src/app.rs
|
||||
@@ -9,6 +9,8 @@ use crate::slash_command::SlashCommand;
|
||||
use crate::tui;
|
||||
use codex_core::config::Config;
|
||||
use codex_core::protocol::Event;
|
||||
+use codex_core::protocol::EventMsg;
|
||||
+use codex_core::protocol::Op;
|
||||
use color_eyre::eyre::Result;
|
||||
use crossterm::event::KeyCode;
|
||||
use crossterm::event::KeyEvent;
|
||||
@@ -53,15 +55,9 @@ pub(crate) struct App<'a> {
|
||||
/// Stored parameters needed to instantiate the ChatWidget later, e.g.,
|
||||
/// after dismissing the Git-repo warning.
|
||||
chat_args: Option<ChatWidgetArgs>,
|
||||
-}
|
||||
|
||||
-/// Aggregate parameters needed to create a `ChatWidget`, as creation may be
|
||||
-/// deferred until after the Git warning screen is dismissed.
|
||||
-#[derive(Clone)]
|
||||
-struct ChatWidgetArgs {
|
||||
- config: Config,
|
||||
- initial_prompt: Option<String>,
|
||||
- initial_images: Vec<PathBuf>,
|
||||
+ /// Tracks pending summarization requests for the compact feature.
|
||||
+ pending_summarization: Option<PendingSummarization>,
|
||||
}
|
||||
|
||||
impl App<'_> {
|
||||
@@ -153,6 +149,7 @@ impl App<'_> {
|
||||
file_search,
|
||||
pending_redraw,
|
||||
chat_args,
|
||||
+ pending_summarization: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -271,6 +268,18 @@ impl App<'_> {
|
||||
self.app_state = AppState::Chat { widget: new_widget };
|
||||
self.app_event_tx.send(AppEvent::RequestRedraw);
|
||||
}
|
||||
+ SlashCommand::Compact => {
|
||||
+ if let AppState::Chat { widget } = &mut self.app_state {
|
||||
+ // Submit the summarization request to the current widget
|
||||
+ widget.submit_op(Op::SummarizeContext);
|
||||
+
|
||||
+ // Set up tracking for the summary response
|
||||
+ self.pending_summarization = Some(PendingSummarization {
|
||||
+ summary_buffer: String::new(),
|
||||
+ started_receiving: false,
|
||||
+ });
|
||||
+ }
|
||||
+ }
|
||||
SlashCommand::Quit => {
|
||||
break;
|
||||
}
|
||||
@@ -374,9 +383,113 @@ impl App<'_> {
|
||||
}
|
||||
|
||||
fn dispatch_codex_event(&mut self, event: Event) {
|
||||
+ // First check if we're waiting for a summarization response
|
||||
+ if self.pending_summarization.is_some() {
|
||||
+ self.handle_summarization_response(event);
|
||||
+ return;
|
||||
+ }
|
||||
+
|
||||
+ // Otherwise dispatch to the current app state
|
||||
match &mut self.app_state {
|
||||
AppState::Chat { widget } => widget.handle_codex_event(event),
|
||||
AppState::GitWarning { .. } => {}
|
||||
}
|
||||
}
|
||||
+
|
||||
+ /// Handles responses during a summarization request.
|
||||
+ fn handle_summarization_response(&mut self, event: Event) {
|
||||
+ match &event.msg {
|
||||
+ EventMsg::AgentMessage(msg) => {
|
||||
+ // Only collect messages once we've started receiving the summarization
|
||||
+ if let Some(ref mut pending) = self.pending_summarization {
|
||||
+ // Start collecting once we see a message that looks like a summary
|
||||
+ if !pending.started_receiving && msg.message.contains("summarize") {
|
||||
+ pending.started_receiving = true;
|
||||
+ }
|
||||
+
|
||||
+ if pending.started_receiving {
|
||||
+ pending.summary_buffer.push_str(&msg.message);
|
||||
+ pending.summary_buffer.push('\n');
|
||||
+ }
|
||||
+ }
|
||||
+ }
|
||||
+ EventMsg::TaskComplete(_) => {
|
||||
+ // Task is complete, now create a new widget with the summary
|
||||
+ if let Some(pending) = self.pending_summarization.take() {
|
||||
+ let summary = create_compact_summary_prompt(&pending.summary_buffer);
|
||||
+
|
||||
+ // Create new widget with summary as initial prompt
|
||||
+ let new_widget = Box::new(ChatWidget::new(
|
||||
+ self.config.clone(),
|
||||
+ self.app_event_tx.clone(),
|
||||
+ Some(summary),
|
||||
+ Vec::new(),
|
||||
+ ));
|
||||
+ self.app_state = AppState::Chat { widget: new_widget };
|
||||
+ self.app_event_tx.send(AppEvent::Redraw);
|
||||
+ }
|
||||
+ }
|
||||
+ _ => {}
|
||||
+ }
|
||||
+ }
|
||||
+}
|
||||
+
|
||||
+/// State for tracking a pending summarization request.
|
||||
+struct PendingSummarization {
|
||||
+ /// Buffer to collect the summary response.
|
||||
+ summary_buffer: String,
|
||||
+ /// Whether we've received the first message of the summarization response.
|
||||
+ started_receiving: bool,
|
||||
+}
|
||||
+
|
||||
+/// Aggregate parameters needed to create a `ChatWidget`, as creation may be
|
||||
+/// deferred until after the Git warning screen is dismissed.
|
||||
+#[derive(Clone)]
|
||||
+struct ChatWidgetArgs {
|
||||
+ config: Config,
|
||||
+ initial_prompt: Option<String>,
|
||||
+ initial_images: Vec<PathBuf>,
|
||||
+}
|
||||
+
|
||||
+/// Creates the initial prompt for a compacted conversation.
|
||||
+fn create_compact_summary_prompt(summary_text: &str) -> String {
|
||||
+ if summary_text.trim().is_empty() {
|
||||
+ "Previous conversation has been summarized.".to_string()
|
||||
+ } else {
|
||||
+ format!(
|
||||
+ r#"This chat is a continuation of a previous conversation. After providing the summary, acknowledge that /compact command has been applied. Here is the summary of the previous conversation:
|
||||
+
|
||||
+{}"#,
|
||||
+ summary_text.trim()
|
||||
+ )
|
||||
+ }
|
||||
+}
|
||||
+
|
||||
+#[cfg(test)]
|
||||
+mod tests {
|
||||
+ #![allow(clippy::unwrap_used)]
|
||||
+ use super::*;
|
||||
+
|
||||
+ #[test]
|
||||
+ fn test_summary_buffer_accumulation() {
|
||||
+ let mut buffer = String::new();
|
||||
+
|
||||
+ // Simulate the way we accumulate messages in pending_summarization
|
||||
+ buffer.push_str("First message part");
|
||||
+ buffer.push('\n');
|
||||
+ buffer.push_str("Second message part");
|
||||
+ buffer.push('\n');
|
||||
+ buffer.push_str("Final message part");
|
||||
+
|
||||
+ let prompt = create_compact_summary_prompt(&buffer);
|
||||
+
|
||||
+ // Should contain all parts
|
||||
+ assert!(prompt.contains("First message part"));
|
||||
+ assert!(prompt.contains("Second message part"));
|
||||
+ assert!(prompt.contains("Final message part"));
|
||||
+
|
||||
+ // Should preserve newlines in the content
|
||||
+ let trimmed_buffer = buffer.trim();
|
||||
+ assert!(prompt.contains(trimmed_buffer));
|
||||
+ }
|
||||
}
|
||||
diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs
|
||||
index 77a600d304..15dd82584a 100644
|
||||
--- a/codex-rs/tui/src/app_event.rs
|
||||
+++ b/codex-rs/tui/src/app_event.rs
|
||||
@@ -6,7 +6,7 @@ use ratatui::text::Line;
|
||||
use crate::slash_command::SlashCommand;
|
||||
|
||||
#[allow(clippy::large_enum_variant)]
|
||||
-pub(crate) enum AppEvent {
|
||||
+pub enum AppEvent {
|
||||
CodexEvent(Event),
|
||||
|
||||
/// Request a redraw which will be debounced by the [`App`].
|
||||
diff --git a/codex-rs/tui/src/app_event_sender.rs b/codex-rs/tui/src/app_event_sender.rs
|
||||
index 9d838273ef..9752a3c415 100644
|
||||
--- a/codex-rs/tui/src/app_event_sender.rs
|
||||
+++ b/codex-rs/tui/src/app_event_sender.rs
|
||||
@@ -3,18 +3,18 @@ use std::sync::mpsc::Sender;
|
||||
use crate::app_event::AppEvent;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
-pub(crate) struct AppEventSender {
|
||||
+pub struct AppEventSender {
|
||||
app_event_tx: Sender<AppEvent>,
|
||||
}
|
||||
|
||||
impl AppEventSender {
|
||||
- pub(crate) fn new(app_event_tx: Sender<AppEvent>) -> Self {
|
||||
+ pub fn new(app_event_tx: Sender<AppEvent>) -> Self {
|
||||
Self { app_event_tx }
|
||||
}
|
||||
|
||||
/// Send an event to the app event channel. If it fails, we swallow the
|
||||
/// error and log it.
|
||||
- pub(crate) fn send(&self, event: AppEvent) {
|
||||
+ pub fn send(&self, event: AppEvent) {
|
||||
if let Err(e) = self.app_event_tx.send(event) {
|
||||
tracing::error!("failed to send event: {e}");
|
||||
}
|
||||
diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs
|
||||
index b15d81f8f5..03d71afe1d 100644
|
||||
--- a/codex-rs/tui/src/bottom_pane/chat_composer.rs
|
||||
+++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs
|
||||
@@ -33,7 +33,7 @@ pub enum InputResult {
|
||||
None,
|
||||
}
|
||||
|
||||
-pub(crate) struct ChatComposer<'a> {
|
||||
+pub struct ChatComposer<'a> {
|
||||
textarea: TextArea<'a>,
|
||||
active_popup: ActivePopup,
|
||||
app_event_tx: AppEventSender,
|
||||
diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs
|
||||
index 4ec1ba4b3e..fd1a4a2eb4 100644
|
||||
--- a/codex-rs/tui/src/bottom_pane/mod.rs
|
||||
+++ b/codex-rs/tui/src/bottom_pane/mod.rs
|
||||
@@ -14,7 +14,7 @@ use ratatui::widgets::WidgetRef;
|
||||
|
||||
mod approval_modal_view;
|
||||
mod bottom_pane_view;
|
||||
-mod chat_composer;
|
||||
+pub mod chat_composer;
|
||||
mod chat_composer_history;
|
||||
mod command_popup;
|
||||
mod file_search_popup;
|
||||
diff --git a/codex-rs/tui/src/slash_command.rs b/codex-rs/tui/src/slash_command.rs
|
||||
index 603eb721cd..ab224ae054 100644
|
||||
--- a/codex-rs/tui/src/slash_command.rs
|
||||
+++ b/codex-rs/tui/src/slash_command.rs
|
||||
@@ -13,6 +13,7 @@ pub enum SlashCommand {
|
||||
// DO NOT ALPHA-SORT! Enum order is presentation order in the popup, so
|
||||
// more frequently used commands should be listed first.
|
||||
New,
|
||||
+ Compact,
|
||||
Diff,
|
||||
Quit,
|
||||
}
|
||||
@@ -22,6 +23,9 @@ impl SlashCommand {
|
||||
pub fn description(self) -> &'static str {
|
||||
match self {
|
||||
SlashCommand::New => "Start a new chat.",
|
||||
+ SlashCommand::Compact => {
|
||||
+ "Summarize and compact the current conversation to free up context."
|
||||
+ }
|
||||
SlashCommand::Quit => "Exit the application.",
|
||||
SlashCommand::Diff => {
|
||||
"Show git diff of the working directory (including untracked files)"
|
||||
@@ -40,3 +44,58 @@ impl SlashCommand {
|
||||
pub fn built_in_slash_commands() -> Vec<(&'static str, SlashCommand)> {
|
||||
SlashCommand::iter().map(|c| (c.command(), c)).collect()
|
||||
}
|
||||
+
|
||||
+#[cfg(test)]
|
||||
+mod tests {
|
||||
+ use crate::app_event_sender::AppEventSender;
|
||||
+ use crate::bottom_pane::chat_composer::ChatComposer;
|
||||
+ use crossterm::event::KeyCode;
|
||||
+ use insta::assert_snapshot;
|
||||
+ use ratatui::Terminal;
|
||||
+ use ratatui::backend::TestBackend;
|
||||
+ use std::sync::mpsc;
|
||||
+
|
||||
+ #[test]
|
||||
+ fn test_slash_commands() {
|
||||
+ let (tx, _rx) = mpsc::channel();
|
||||
+ let sender = AppEventSender::new(tx);
|
||||
+ let mut composer = ChatComposer::new(true, sender);
|
||||
+
|
||||
+ let mut terminal = match Terminal::new(TestBackend::new(100, 10)) {
|
||||
+ Ok(t) => t,
|
||||
+ Err(e) => panic!("Failed to create terminal: {e}"),
|
||||
+ };
|
||||
+
|
||||
+ // Initial empty state
|
||||
+ if let Err(e) = terminal.draw(|f| f.render_widget_ref(&composer, f.area())) {
|
||||
+ panic!("Failed to draw empty composer: {e}");
|
||||
+ }
|
||||
+ assert_snapshot!("empty_slash", terminal.backend());
|
||||
+
|
||||
+ // Type slash to show commands
|
||||
+ let _ = composer.handle_key_event(crossterm::event::KeyEvent::new(
|
||||
+ KeyCode::Char('/'),
|
||||
+ crossterm::event::KeyModifiers::empty(),
|
||||
+ ));
|
||||
+ if let Err(e) = terminal.draw(|f| f.render_widget_ref(&composer, f.area())) {
|
||||
+ panic!("Failed to draw slash commands: {e}");
|
||||
+ }
|
||||
+ assert_snapshot!("slash_commands", terminal.backend());
|
||||
+
|
||||
+ // Type 'c' to filter to compact
|
||||
+ let _ = composer.handle_key_event(crossterm::event::KeyEvent::new(
|
||||
+ KeyCode::Char('c'),
|
||||
+ crossterm::event::KeyModifiers::empty(),
|
||||
+ ));
|
||||
+ if let Err(e) = terminal.draw(|f| f.render_widget_ref(&composer, f.area())) {
|
||||
+ panic!("Failed to draw filtered commands: {e}");
|
||||
+ }
|
||||
+ assert_snapshot!("compact_filtered", terminal.backend());
|
||||
+
|
||||
+ // Select compact command - we don't check the final state since it's handled by the app layer
|
||||
+ let _ = composer.handle_key_event(crossterm::event::KeyEvent::new(
|
||||
+ KeyCode::Enter,
|
||||
+ crossterm::event::KeyModifiers::empty(),
|
||||
+ ));
|
||||
+ }
|
||||
+}
|
||||
diff --git a/codex-rs/tui/src/snapshots/codex_tui__slash_command__tests__compact_filtered.snap b/codex-rs/tui/src/snapshots/codex_tui__slash_command__tests__compact_filtered.snap
|
||||
new file mode 100644
|
||||
index 0000000000..44207a832a
|
||||
--- /dev/null
|
||||
+++ b/codex-rs/tui/src/snapshots/codex_tui__slash_command__tests__compact_filtered.snap
|
||||
@@ -0,0 +1,14 @@
|
||||
+---
|
||||
+source: tui/src/slash_command.rs
|
||||
+expression: terminal.backend()
|
||||
+---
|
||||
+"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮"
|
||||
+"│/compact Summarize and compact the current conversation to free up context. │"
|
||||
+"╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
+"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮"
|
||||
+"│/c │"
|
||||
+"│ │"
|
||||
+"│ │"
|
||||
+"│ │"
|
||||
+"│ │"
|
||||
+"╰───────────────────────────────────────────────Enter to send | Ctrl+D to quit | Ctrl+J for newline╯"
|
||||
diff --git a/codex-rs/tui/src/snapshots/codex_tui__slash_command__tests__empty_slash.snap b/codex-rs/tui/src/snapshots/codex_tui__slash_command__tests__empty_slash.snap
|
||||
new file mode 100644
|
||||
index 0000000000..68af93743f
|
||||
--- /dev/null
|
||||
+++ b/codex-rs/tui/src/snapshots/codex_tui__slash_command__tests__empty_slash.snap
|
||||
@@ -0,0 +1,14 @@
|
||||
+---
|
||||
+source: tui/src/slash_command.rs
|
||||
+expression: terminal.backend()
|
||||
+---
|
||||
+"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮"
|
||||
+"│ send a message │"
|
||||
+"│ │"
|
||||
+"│ │"
|
||||
+"│ │"
|
||||
+"│ │"
|
||||
+"│ │"
|
||||
+"│ │"
|
||||
+"│ │"
|
||||
+"╰───────────────────────────────────────────────Enter to send | Ctrl+D to quit | Ctrl+J for newline╯"
|
||||
diff --git a/codex-rs/tui/src/snapshots/codex_tui__slash_command__tests__slash_commands.snap b/codex-rs/tui/src/snapshots/codex_tui__slash_command__tests__slash_commands.snap
|
||||
new file mode 100644
|
||||
index 0000000000..257ed9fb46
|
||||
--- /dev/null
|
||||
+++ b/codex-rs/tui/src/snapshots/codex_tui__slash_command__tests__slash_commands.snap
|
||||
@@ -0,0 +1,14 @@
|
||||
+---
|
||||
+source: tui/src/slash_command.rs
|
||||
+expression: terminal.backend()
|
||||
+---
|
||||
+"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮"
|
||||
+"│/new Start a new chat. │"
|
||||
+"│/compact Summarize and compact the current conversation to free up context. │"
|
||||
+"│/diff Show git diff of the working directory (including untracked files) │"
|
||||
+"│/quit Exit the application. │"
|
||||
+"│/toggle-mouse-mode Toggle mouse mode (enable for scrolling, disable for text selection) │"
|
||||
+"╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
+"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮"
|
||||
+"│/ │"
|
||||
+"╰───────────────────────────────────────────────Enter to send | Ctrl+D to quit | Ctrl+J for newline╯"
|
||||
```
|
||||
|
||||
## Review Comments
|
||||
|
||||
### codex-rs/tui/src/app.rs
|
||||
|
||||
- Created: 2025-07-12 17:09:16 UTC | Link: https://github.com/openai/codex/pull/1529#discussion_r2202825742
|
||||
|
||||
```diff
|
||||
@@ -21,6 +22,22 @@ use std::path::PathBuf;
|
||||
use std::sync::mpsc::Receiver;
|
||||
use std::sync::mpsc::channel;
|
||||
|
||||
+/// Template for compact summary continuation prompt
|
||||
+const COMPACT_SUMMARY_TEMPLATE: &str = concat!(
|
||||
+ "This chat is a continuation of a previous conversation. ",
|
||||
+ "After providing the summary, acknowledge that /compact command has been applied. ",
|
||||
+ "Here is the summary of the previous conversation:\n\n{}"
|
||||
```
|
||||
|
||||
> Using `{}` as a placeholder in this way seems very confusing to me as a Rust person because it's not being used natively by `format!()`. Please use something like `SUMMARY_TEXT` instead so it's more obvious that something is meant to be replaced.
|
||||
|
||||
- Created: 2025-07-12 17:11:01 UTC | Link: https://github.com/openai/codex/pull/1529#discussion_r2202826750
|
||||
|
||||
```diff
|
||||
@@ -21,6 +22,22 @@ use std::path::PathBuf;
|
||||
use std::sync::mpsc::Receiver;
|
||||
use std::sync::mpsc::channel;
|
||||
|
||||
+/// Template for compact summary continuation prompt
|
||||
+const COMPACT_SUMMARY_TEMPLATE: &str = concat!(
|
||||
+ "This chat is a continuation of a previous conversation. ",
|
||||
+ "After providing the summary, acknowledge that /compact command has been applied. ",
|
||||
+ "Here is the summary of the previous conversation:\n\n{}"
|
||||
+);
|
||||
+
|
||||
+/// Creates the initial prompt for a compacted conversation
|
||||
+fn create_compact_summary_prompt(summary_text: &str) -> String {
|
||||
```
|
||||
|
||||
> For small helper functions, particularly ones that are private to the file, please declare them _after_ the functions that use them. I strongly prefer declaring the "most important stuff" at the top of the file and "details" (which includes functions like this) at the top of the file.
|
||||
|
||||
- Created: 2025-07-12 17:12:21 UTC | Link: https://github.com/openai/codex/pull/1529#discussion_r2202827235
|
||||
|
||||
```diff
|
||||
@@ -21,6 +22,22 @@ use std::path::PathBuf;
|
||||
use std::sync::mpsc::Receiver;
|
||||
use std::sync::mpsc::channel;
|
||||
|
||||
+/// Template for compact summary continuation prompt
|
||||
+const COMPACT_SUMMARY_TEMPLATE: &str = concat!(
|
||||
+ "This chat is a continuation of a previous conversation. ",
|
||||
+ "After providing the summary, acknowledge that /compact command has been applied. ",
|
||||
+ "Here is the summary of the previous conversation:\n\n{}"
|
||||
+);
|
||||
+
|
||||
+/// Creates the initial prompt for a compacted conversation
|
||||
+fn create_compact_summary_prompt(summary_text: &str) -> String {
|
||||
+ if summary_text.trim().is_empty() {
|
||||
+ "Previous conversation has been summarized.".to_string()
|
||||
+ } else {
|
||||
+ COMPACT_SUMMARY_TEMPLATE.replace("{}", summary_text.trim())
|
||||
```
|
||||
|
||||
> Even better, why not just use the string directly here (again, I would recommend `r#`) with `format!()`. I see that the other places `COMPACT_SUMMARY_TEMPLATE` is used is in tests, but I'm not sure those tests should be written the way they are, so I don't think we'll need this as its own variable.
|
||||
|
||||
- Created: 2025-07-12 17:13:46 UTC | Link: https://github.com/openai/codex/pull/1529#discussion_r2202827674
|
||||
|
||||
```diff
|
||||
@@ -36,6 +53,21 @@ enum AppState<'a> {
|
||||
GitWarning { screen: GitWarningScreen },
|
||||
}
|
||||
|
||||
+/// State for tracking a pending summarization request
|
||||
```
|
||||
|
||||
> Again, the previous order was more appropriate: `App` is the most important thing and should be listed before these structs.
|
||||
|
||||
- Created: 2025-07-12 17:14:29 UTC | Link: https://github.com/openai/codex/pull/1529#discussion_r2202827940
|
||||
|
||||
```diff
|
||||
@@ -49,15 +81,9 @@ pub(crate) struct App<'a> {
|
||||
/// Stored parameters needed to instantiate the ChatWidget later, e.g.,
|
||||
/// after dismissing the Git-repo warning.
|
||||
chat_args: Option<ChatWidgetArgs>,
|
||||
-}
|
||||
|
||||
-/// Aggregate parameters needed to create a `ChatWidget`, as creation may be
|
||||
-/// deferred until after the Git warning screen is dismissed.
|
||||
-#[derive(Clone)]
|
||||
-struct ChatWidgetArgs {
|
||||
- config: Config,
|
||||
- initial_prompt: Option<String>,
|
||||
- initial_images: Vec<PathBuf>,
|
||||
+ /// Tracks pending summarization requests for the compact feature
|
||||
```
|
||||
|
||||
> Please match the existing style where comments end with periods.
|
||||
|
||||
- Created: 2025-07-12 17:16:52 UTC | Link: https://github.com/openai/codex/pull/1529#discussion_r2202828654
|
||||
|
||||
```diff
|
||||
@@ -224,6 +251,40 @@ impl<'a> App<'a> {
|
||||
self.dispatch_scroll_event(scroll_delta);
|
||||
}
|
||||
AppEvent::CodexEvent(event) => {
|
||||
+ // Check if we're waiting for a summarization response
|
||||
```
|
||||
|
||||
> Is there a reason this logic isn't added to `dispatch_codex_event()` instead? Much of the reason to have the `dispatch_codex_event()` helper is to keep the length of `run()` down. In a new top-level function, there will be less indenting and the code should be easier to read, as well.
|
||||
|
||||
- Created: 2025-07-12 17:17:31 UTC | Link: https://github.com/openai/codex/pull/1529#discussion_r2202828894
|
||||
|
||||
```diff
|
||||
@@ -224,6 +251,40 @@ impl<'a> App<'a> {
|
||||
self.dispatch_scroll_event(scroll_delta);
|
||||
}
|
||||
AppEvent::CodexEvent(event) => {
|
||||
+ // Check if we're waiting for a summarization response
|
||||
+ if let Some(ref mut pending) = self.pending_summarization {
|
||||
+ if let Event {
|
||||
+ msg: codex_core::protocol::EventMsg::AgentMessage(ref msg),
|
||||
```
|
||||
|
||||
> I would `use codex_core::protocol::EventMsg` so this can just be `EventMsg`.
|
||||
|
||||
- Created: 2025-07-12 17:19:34 UTC | Link: https://github.com/openai/codex/pull/1529#discussion_r2202833564
|
||||
|
||||
```diff
|
||||
@@ -357,3 +429,119 @@ impl<'a> App<'a> {
|
||||
}
|
||||
}
|
||||
}
|
||||
+
|
||||
+#[cfg(test)]
|
||||
+mod tests {
|
||||
+ #![allow(clippy::unwrap_used)]
|
||||
+ use super::*;
|
||||
+ use codex_core::protocol::AgentMessageEvent;
|
||||
+ use codex_core::protocol::EventMsg;
|
||||
+ use codex_core::protocol::TaskCompleteEvent;
|
||||
+ use pretty_assertions::assert_eq;
|
||||
+
|
||||
+ #[test]
|
||||
+ fn test_create_compact_summary_prompt_with_content() {
|
||||
+ let summary_text = "User asked about Rust. I explained ownership and borrowing.";
|
||||
+ let result = create_compact_summary_prompt(summary_text);
|
||||
+
|
||||
+ let expected = COMPACT_SUMMARY_TEMPLATE.replace(
|
||||
+ "{}",
|
||||
+ "User asked about Rust. I explained ownership and borrowing.",
|
||||
+ );
|
||||
+ assert_eq!(result, expected);
|
||||
+ }
|
||||
+
|
||||
+ #[test]
|
||||
+ fn test_create_compact_summary_prompt_empty_content() {
|
||||
+ let result = create_compact_summary_prompt("");
|
||||
+ assert_eq!(result, "Previous conversation has been summarized.");
|
||||
+
|
||||
+ let result_whitespace = create_compact_summary_prompt(" \n\t ");
|
||||
+ assert_eq!(
|
||||
+ result_whitespace,
|
||||
+ "Previous conversation has been summarized."
|
||||
+ );
|
||||
+ }
|
||||
+
|
||||
+ #[test]
|
||||
+ fn test_pending_summarization_state_management() {
|
||||
+ let mut pending = PendingSummarization {
|
||||
+ summary_buffer: String::new(),
|
||||
+ };
|
||||
+
|
||||
+ // Simulate collecting summary pieces
|
||||
+ pending.summary_buffer.push_str("First part of summary");
|
||||
+ pending.summary_buffer.push('\n');
|
||||
+ pending.summary_buffer.push_str("Second part of summary");
|
||||
+
|
||||
+ let expected = "First part of summary\nSecond part of summary";
|
||||
+ assert_eq!(pending.summary_buffer, expected);
|
||||
+
|
||||
+ // Test that create_compact_summary_prompt works with collected buffer
|
||||
+ let prompt = create_compact_summary_prompt(&pending.summary_buffer);
|
||||
+ assert!(prompt.contains("First part of summary"));
|
||||
+ assert!(prompt.contains("Second part of summary"));
|
||||
+ }
|
||||
+
|
||||
+ #[test]
|
||||
```
|
||||
|
||||
> I don't think this test provides any signal. Please remove.
|
||||
|
||||
- Created: 2025-07-12 17:20:10 UTC | Link: https://github.com/openai/codex/pull/1529#discussion_r2202834695
|
||||
|
||||
```diff
|
||||
@@ -357,3 +429,119 @@ impl<'a> App<'a> {
|
||||
}
|
||||
}
|
||||
}
|
||||
+
|
||||
+#[cfg(test)]
|
||||
+mod tests {
|
||||
+ #![allow(clippy::unwrap_used)]
|
||||
+ use super::*;
|
||||
+ use codex_core::protocol::AgentMessageEvent;
|
||||
+ use codex_core::protocol::EventMsg;
|
||||
+ use codex_core::protocol::TaskCompleteEvent;
|
||||
+ use pretty_assertions::assert_eq;
|
||||
+
|
||||
+ #[test]
|
||||
+ fn test_create_compact_summary_prompt_with_content() {
|
||||
+ let summary_text = "User asked about Rust. I explained ownership and borrowing.";
|
||||
+ let result = create_compact_summary_prompt(summary_text);
|
||||
+
|
||||
+ let expected = COMPACT_SUMMARY_TEMPLATE.replace(
|
||||
+ "{}",
|
||||
+ "User asked about Rust. I explained ownership and borrowing.",
|
||||
+ );
|
||||
+ assert_eq!(result, expected);
|
||||
+ }
|
||||
+
|
||||
+ #[test]
|
||||
+ fn test_create_compact_summary_prompt_empty_content() {
|
||||
+ let result = create_compact_summary_prompt("");
|
||||
+ assert_eq!(result, "Previous conversation has been summarized.");
|
||||
+
|
||||
+ let result_whitespace = create_compact_summary_prompt(" \n\t ");
|
||||
+ assert_eq!(
|
||||
+ result_whitespace,
|
||||
+ "Previous conversation has been summarized."
|
||||
+ );
|
||||
+ }
|
||||
+
|
||||
+ #[test]
|
||||
+ fn test_pending_summarization_state_management() {
|
||||
+ let mut pending = PendingSummarization {
|
||||
+ summary_buffer: String::new(),
|
||||
+ };
|
||||
+
|
||||
+ // Simulate collecting summary pieces
|
||||
+ pending.summary_buffer.push_str("First part of summary");
|
||||
+ pending.summary_buffer.push('\n');
|
||||
+ pending.summary_buffer.push_str("Second part of summary");
|
||||
+
|
||||
+ let expected = "First part of summary\nSecond part of summary";
|
||||
+ assert_eq!(pending.summary_buffer, expected);
|
||||
+
|
||||
+ // Test that create_compact_summary_prompt works with collected buffer
|
||||
+ let prompt = create_compact_summary_prompt(&pending.summary_buffer);
|
||||
+ assert!(prompt.contains("First part of summary"));
|
||||
+ assert!(prompt.contains("Second part of summary"));
|
||||
+ }
|
||||
+
|
||||
+ #[test]
|
||||
+ fn test_compact_summary_template_integrity() {
|
||||
+ // Ensure the template has expected structure and placeholder
|
||||
+ assert!(COMPACT_SUMMARY_TEMPLATE.contains("{}"));
|
||||
+ assert!(COMPACT_SUMMARY_TEMPLATE.contains("continuation of a previous conversation"));
|
||||
+ assert!(COMPACT_SUMMARY_TEMPLATE.contains("/compact command has been applied"));
|
||||
+ }
|
||||
+
|
||||
+ #[test]
|
||||
+ fn test_agent_message_event_creation() {
|
||||
+ // Test that we can create the events we expect to handle
|
||||
+ let msg_event = EventMsg::AgentMessage(AgentMessageEvent {
|
||||
+ message: "This is a test summary".to_string(),
|
||||
+ });
|
||||
+
|
||||
+ if let EventMsg::AgentMessage(agent_msg) = msg_event {
|
||||
+ assert_eq!(agent_msg.message, "This is a test summary");
|
||||
+ } else {
|
||||
+ panic!("Expected AgentMessage event");
|
||||
+ }
|
||||
+
|
||||
+ let task_complete_event = EventMsg::TaskComplete(TaskCompleteEvent {
|
||||
+ last_agent_message: Some("Final message".to_string()),
|
||||
+ });
|
||||
+
|
||||
+ matches!(task_complete_event, EventMsg::TaskComplete(_));
|
||||
+ }
|
||||
+
|
||||
+ #[test]
|
||||
+ fn test_multiline_summary_handling() {
|
||||
+ let multiline_summary = "Line 1: User question\nLine 2: My response\nLine 3: Follow-up";
|
||||
+ let result = create_compact_summary_prompt(multiline_summary);
|
||||
+
|
||||
+ assert!(result.contains("Line 1: User question"));
|
||||
+ assert!(result.contains("Line 2: My response"));
|
||||
+ assert!(result.contains("Line 3: Follow-up"));
|
||||
+ assert!(result.contains("continuation of a previous conversation"));
|
||||
+ }
|
||||
+
|
||||
+ #[test]
|
||||
+ fn test_summary_buffer_accumulation() {
|
||||
```
|
||||
|
||||
> What is this test telling us?
|
||||
|
||||
- Created: 2025-07-12 17:21:20 UTC | Link: https://github.com/openai/codex/pull/1529#discussion_r2202835602
|
||||
|
||||
```diff
|
||||
@@ -357,3 +429,119 @@ impl<'a> App<'a> {
|
||||
}
|
||||
}
|
||||
}
|
||||
+
|
||||
+#[cfg(test)]
|
||||
+mod tests {
|
||||
+ #![allow(clippy::unwrap_used)]
|
||||
+ use super::*;
|
||||
+ use codex_core::protocol::AgentMessageEvent;
|
||||
+ use codex_core::protocol::EventMsg;
|
||||
+ use codex_core::protocol::TaskCompleteEvent;
|
||||
+ use pretty_assertions::assert_eq;
|
||||
+
|
||||
+ #[test]
|
||||
+ fn test_create_compact_summary_prompt_with_content() {
|
||||
+ let summary_text = "User asked about Rust. I explained ownership and borrowing.";
|
||||
+ let result = create_compact_summary_prompt(summary_text);
|
||||
+
|
||||
+ let expected = COMPACT_SUMMARY_TEMPLATE.replace(
|
||||
+ "{}",
|
||||
+ "User asked about Rust. I explained ownership and borrowing.",
|
||||
+ );
|
||||
+ assert_eq!(result, expected);
|
||||
+ }
|
||||
+
|
||||
+ #[test]
|
||||
+ fn test_create_compact_summary_prompt_empty_content() {
|
||||
+ let result = create_compact_summary_prompt("");
|
||||
+ assert_eq!(result, "Previous conversation has been summarized.");
|
||||
+
|
||||
+ let result_whitespace = create_compact_summary_prompt(" \n\t ");
|
||||
+ assert_eq!(
|
||||
+ result_whitespace,
|
||||
+ "Previous conversation has been summarized."
|
||||
+ );
|
||||
+ }
|
||||
+
|
||||
+ #[test]
|
||||
+ fn test_pending_summarization_state_management() {
|
||||
+ let mut pending = PendingSummarization {
|
||||
+ summary_buffer: String::new(),
|
||||
+ };
|
||||
+
|
||||
+ // Simulate collecting summary pieces
|
||||
+ pending.summary_buffer.push_str("First part of summary");
|
||||
+ pending.summary_buffer.push('\n');
|
||||
+ pending.summary_buffer.push_str("Second part of summary");
|
||||
+
|
||||
+ let expected = "First part of summary\nSecond part of summary";
|
||||
+ assert_eq!(pending.summary_buffer, expected);
|
||||
+
|
||||
+ // Test that create_compact_summary_prompt works with collected buffer
|
||||
+ let prompt = create_compact_summary_prompt(&pending.summary_buffer);
|
||||
+ assert!(prompt.contains("First part of summary"));
|
||||
+ assert!(prompt.contains("Second part of summary"));
|
||||
+ }
|
||||
+
|
||||
+ #[test]
|
||||
+ fn test_compact_summary_template_integrity() {
|
||||
+ // Ensure the template has expected structure and placeholder
|
||||
+ assert!(COMPACT_SUMMARY_TEMPLATE.contains("{}"));
|
||||
+ assert!(COMPACT_SUMMARY_TEMPLATE.contains("continuation of a previous conversation"));
|
||||
+ assert!(COMPACT_SUMMARY_TEMPLATE.contains("/compact command has been applied"));
|
||||
+ }
|
||||
+
|
||||
+ #[test]
|
||||
+ fn test_agent_message_event_creation() {
|
||||
+ // Test that we can create the events we expect to handle
|
||||
+ let msg_event = EventMsg::AgentMessage(AgentMessageEvent {
|
||||
+ message: "This is a test summary".to_string(),
|
||||
+ });
|
||||
+
|
||||
+ if let EventMsg::AgentMessage(agent_msg) = msg_event {
|
||||
+ assert_eq!(agent_msg.message, "This is a test summary");
|
||||
+ } else {
|
||||
+ panic!("Expected AgentMessage event");
|
||||
+ }
|
||||
+
|
||||
+ let task_complete_event = EventMsg::TaskComplete(TaskCompleteEvent {
|
||||
+ last_agent_message: Some("Final message".to_string()),
|
||||
+ });
|
||||
+
|
||||
+ matches!(task_complete_event, EventMsg::TaskComplete(_));
|
||||
+ }
|
||||
+
|
||||
+ #[test]
|
||||
```
|
||||
|
||||
> What is this test telling us?
|
||||
>
|
||||
> A test should help reduce risk. If the test is implemented as `format!()` as I suggested, then the risk of it failing in any of the ways this verifies seems low.
|
||||
|
||||
- Created: 2025-07-12 17:22:08 UTC | Link: https://github.com/openai/codex/pull/1529#discussion_r2202835856
|
||||
|
||||
```diff
|
||||
@@ -357,3 +429,119 @@ impl<'a> App<'a> {
|
||||
}
|
||||
}
|
||||
}
|
||||
+
|
||||
+#[cfg(test)]
|
||||
+mod tests {
|
||||
+ #![allow(clippy::unwrap_used)]
|
||||
+ use super::*;
|
||||
+ use codex_core::protocol::AgentMessageEvent;
|
||||
+ use codex_core::protocol::EventMsg;
|
||||
+ use codex_core::protocol::TaskCompleteEvent;
|
||||
+ use pretty_assertions::assert_eq;
|
||||
+
|
||||
+ #[test]
|
||||
+ fn test_create_compact_summary_prompt_with_content() {
|
||||
+ let summary_text = "User asked about Rust. I explained ownership and borrowing.";
|
||||
+ let result = create_compact_summary_prompt(summary_text);
|
||||
+
|
||||
+ let expected = COMPACT_SUMMARY_TEMPLATE.replace(
|
||||
```
|
||||
|
||||
> I would just `assert_eq!()` using a string literal that, yes, is a copy of `COMPACT_SUMMARY_TEMPLATE`. Again, I would use `r#`.
|
||||
|
||||
- Created: 2025-07-12 17:27:22 UTC | Link: https://github.com/openai/codex/pull/1529#discussion_r2202837355
|
||||
|
||||
```diff
|
||||
@@ -224,6 +251,40 @@ impl<'a> App<'a> {
|
||||
self.dispatch_scroll_event(scroll_delta);
|
||||
}
|
||||
AppEvent::CodexEvent(event) => {
|
||||
+ // Check if we're waiting for a summarization response
|
||||
+ if let Some(ref mut pending) = self.pending_summarization {
|
||||
+ if let Event {
|
||||
+ msg: codex_core::protocol::EventMsg::AgentMessage(ref msg),
|
||||
```
|
||||
|
||||
> Why is every `AgentMessage` used as the `PendingSummarization` in this case?
|
||||
|
||||
### codex-rs/tui/src/slash_command.rs
|
||||
|
||||
- Created: 2025-07-12 17:23:01 UTC | Link: https://github.com/openai/codex/pull/1529#discussion_r2202836083
|
||||
|
||||
```diff
|
||||
@@ -16,6 +16,7 @@ pub enum SlashCommand {
|
||||
Diff,
|
||||
Quit,
|
||||
ToggleMouseMode,
|
||||
+ Compact,
|
||||
```
|
||||
|
||||
> As noted on line 13, enum order should be what we think frequency order is. I would list this second (after `New`).
|
||||
|
||||
- Created: 2025-07-12 17:23:21 UTC | Link: https://github.com/openai/codex/pull/1529#discussion_r2202836138
|
||||
|
||||
```diff
|
||||
@@ -30,6 +31,9 @@ impl SlashCommand {
|
||||
SlashCommand::Diff => {
|
||||
"Show git diff of the working directory (including untracked files)"
|
||||
}
|
||||
+ SlashCommand::Compact => {
|
||||
```
|
||||
|
||||
> Assuming you reorder the enum, please reorder this, as well.
|
||||
|
||||
- Created: 2025-07-12 17:25:09 UTC | Link: https://github.com/openai/codex/pull/1529#discussion_r2202836629
|
||||
|
||||
```diff
|
||||
@@ -44,3 +48,28 @@ impl SlashCommand {
|
||||
pub fn built_in_slash_commands() -> Vec<(&'static str, SlashCommand)> {
|
||||
SlashCommand::iter().map(|c| (c.command(), c)).collect()
|
||||
}
|
||||
+
|
||||
+#[cfg(test)]
|
||||
```
|
||||
|
||||
> Again, I don't think this test derisks very much. It feels like it would only ever fail if we restructured how commands work, so it is unlikely to catch anything.
|
||||
>
|
||||
> An integration test that verifies `/compact` would be a better way to verify this.
|
||||
998
prs/bolinfest/PR-1538.md
Normal file
998
prs/bolinfest/PR-1538.md
Normal file
@@ -0,0 +1,998 @@
|
||||
# PR #1538: Add tests for chat stream aggregation and tool events
|
||||
|
||||
- URL: https://github.com/openai/codex/pull/1538
|
||||
- Author: aibrahim-oai
|
||||
- Created: 2025-07-11 19:03:03 UTC
|
||||
- Updated: 2025-07-21 20:58:18 UTC
|
||||
- Changes: +323/-12, Files changed: 5, Commits: 12
|
||||
|
||||
## Description
|
||||
|
||||
## Summary
|
||||
- unit test AggregatedChatStream to ensure it merges assistant message deltas and forwards other items
|
||||
- verify parsing of function_call_output and local_shell_call SSE events
|
||||
- ensure chat request payload encodes tool calls correctly
|
||||
|
||||
## Testing
|
||||
- `cargo test -p codex-core --manifest-path codex-rs/Cargo.toml`
|
||||
- `cargo test --manifest-path codex-rs/Cargo.toml --all --tests` *(fails: Sandbox(LandlockRestrict))*
|
||||
|
||||
------
|
||||
https://chatgpt.com/codex/tasks/task_i_687158d61e748321ba5f1631199bd8a4
|
||||
|
||||
## Full Diff
|
||||
|
||||
```diff
|
||||
diff --git a/codex-rs/core/src/chat_completions.rs b/codex-rs/core/src/chat_completions.rs
|
||||
index ad7b55952a..8eabcaf342 100644
|
||||
--- a/codex-rs/core/src/chat_completions.rs
|
||||
+++ b/codex-rs/core/src/chat_completions.rs
|
||||
@@ -458,6 +458,9 @@ pub(crate) trait AggregateStreamExt: Stream<Item = Result<ResponseEvent>> + Size
|
||||
/// // event now contains cumulative text
|
||||
/// }
|
||||
/// ```
|
||||
+ ///
|
||||
+ /// See [`tests::aggregates_consecutive_message_chunks`] for an example.
|
||||
+ /// ```
|
||||
fn aggregate(self) -> AggregatedChatStream<Self> {
|
||||
AggregatedChatStream {
|
||||
inner: self,
|
||||
@@ -468,3 +471,237 @@ pub(crate) trait AggregateStreamExt: Stream<Item = Result<ResponseEvent>> + Size
|
||||
}
|
||||
|
||||
impl<T> AggregateStreamExt for T where T: Stream<Item = Result<ResponseEvent>> + Sized {}
|
||||
+
|
||||
+#[cfg(test)]
|
||||
+mod tests {
|
||||
+ #![allow(clippy::unwrap_used)]
|
||||
+
|
||||
+ use super::*;
|
||||
+ use crate::models::FunctionCallOutputPayload;
|
||||
+ use crate::models::LocalShellAction;
|
||||
+ use crate::models::LocalShellExecAction;
|
||||
+ use crate::models::LocalShellStatus;
|
||||
+ use crate::openai_tools::create_tools_json_for_chat_completions_api;
|
||||
+ use futures::StreamExt;
|
||||
+ use futures::stream;
|
||||
+ use serde_json::json;
|
||||
+
|
||||
+ /// Helper constructing a minimal assistant text chunk.
|
||||
+ fn text_chunk(txt: &str) -> ResponseEvent {
|
||||
+ ResponseEvent::OutputItemDone(ResponseItem::Message {
|
||||
+ role: "assistant".to_string(),
|
||||
+ content: vec![ContentItem::OutputText { text: txt.into() }],
|
||||
+ })
|
||||
+ }
|
||||
+
|
||||
+ #[tokio::test]
|
||||
+ async fn aggregates_consecutive_message_chunks() {
|
||||
+ let events = vec![
|
||||
+ Ok(text_chunk("Hello")),
|
||||
+ Ok(text_chunk(", world")),
|
||||
+ Ok(ResponseEvent::Completed {
|
||||
+ response_id: "r1".to_string(),
|
||||
+ token_usage: None,
|
||||
+ }),
|
||||
+ ];
|
||||
+
|
||||
+ let stream = stream::iter(events).aggregate();
|
||||
+ let collected: Vec<_> = stream.map(Result::unwrap).collect().await;
|
||||
+
|
||||
+ let expected = vec![
|
||||
+ ResponseEvent::OutputItemDone(ResponseItem::Message {
|
||||
+ role: "assistant".into(),
|
||||
+ content: vec![ContentItem::OutputText {
|
||||
+ text: "Hello, world".into(),
|
||||
+ }],
|
||||
+ }),
|
||||
+ ResponseEvent::Completed {
|
||||
+ response_id: "r1".into(),
|
||||
+ token_usage: None,
|
||||
+ },
|
||||
+ ];
|
||||
+
|
||||
+ assert_eq!(
|
||||
+ collected, expected,
|
||||
+ "aggregated assistant message + Completed"
|
||||
+ );
|
||||
+ }
|
||||
+
|
||||
+ #[tokio::test]
|
||||
+ async fn forwards_non_text_items_without_merging() {
|
||||
+ let func_call = ResponseItem::FunctionCall {
|
||||
+ name: "shell".to_string(),
|
||||
+ arguments: "{}".to_string(),
|
||||
+ call_id: "call1".to_string(),
|
||||
+ };
|
||||
+
|
||||
+ let events = vec![
|
||||
+ Ok(text_chunk("foo")),
|
||||
+ Ok(ResponseEvent::OutputItemDone(func_call.clone())),
|
||||
+ Ok(text_chunk("bar")),
|
||||
+ Ok(ResponseEvent::Completed {
|
||||
+ response_id: "r2".to_string(),
|
||||
+ token_usage: None,
|
||||
+ }),
|
||||
+ ];
|
||||
+
|
||||
+ let stream = stream::iter(events).aggregate();
|
||||
+ let collected: Vec<_> = stream.map(Result::unwrap).collect().await;
|
||||
+
|
||||
+ let expected = vec![
|
||||
+ ResponseEvent::OutputItemDone(func_call.clone()),
|
||||
+ ResponseEvent::OutputItemDone(ResponseItem::Message {
|
||||
+ role: "assistant".into(),
|
||||
+ content: vec![ContentItem::OutputText {
|
||||
+ text: "foobar".into(),
|
||||
+ }],
|
||||
+ }),
|
||||
+ ResponseEvent::Completed {
|
||||
+ response_id: "r2".into(),
|
||||
+ token_usage: None,
|
||||
+ },
|
||||
+ ];
|
||||
+
|
||||
+ assert_eq!(
|
||||
+ collected, expected,
|
||||
+ "non-text items forwarded intact; text merged"
|
||||
+ );
|
||||
+ }
|
||||
+
|
||||
+ #[tokio::test]
|
||||
+ async fn formats_tool_calls_in_chat_payload() {
|
||||
+ use std::sync::Arc;
|
||||
+ use std::sync::Mutex;
|
||||
+ use wiremock::Mock;
|
||||
+ use wiremock::MockServer;
|
||||
+ use wiremock::Request;
|
||||
+ use wiremock::Respond;
|
||||
+ use wiremock::ResponseTemplate;
|
||||
+ use wiremock::matchers::method;
|
||||
+ use wiremock::matchers::path;
|
||||
+
|
||||
+ struct CaptureResponder(Arc<Mutex<Option<serde_json::Value>>>);
|
||||
+ impl Respond for CaptureResponder {
|
||||
+ fn respond(&self, req: &Request) -> ResponseTemplate {
|
||||
+ let v: serde_json::Value = serde_json::from_slice(&req.body).unwrap();
|
||||
+ *self.0.lock().unwrap() = Some(v);
|
||||
+ ResponseTemplate::new(200)
|
||||
+ .insert_header("content-type", "text/event-stream")
|
||||
+ .set_body_raw(
|
||||
+ "event: response.completed\n\
|
||||
+data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp\",\"output\":[]}}\n\n",
|
||||
+ "text/event-stream",
|
||||
+ )
|
||||
+ }
|
||||
+ }
|
||||
+
|
||||
+ let server = MockServer::start().await;
|
||||
+ let captured = Arc::new(Mutex::new(None));
|
||||
+
|
||||
+ Mock::given(method("POST"))
|
||||
+ .and(path("/v1/chat/completions"))
|
||||
+ .respond_with(CaptureResponder(captured.clone()))
|
||||
+ .expect(1)
|
||||
+ .mount(&server)
|
||||
+ .await;
|
||||
+
|
||||
+ // Build provider pointing at mock server; no need to mutate global env vars.
|
||||
+ let provider = ModelProviderInfo {
|
||||
+ name: "openai".into(),
|
||||
+ base_url: format!("{}/v1", server.uri()),
|
||||
+ env_key: Some("PATH".into()),
|
||||
+ env_key_instructions: None,
|
||||
+ wire_api: crate::WireApi::Chat,
|
||||
+ query_params: None,
|
||||
+ http_headers: None,
|
||||
+ env_http_headers: None,
|
||||
+ };
|
||||
+
|
||||
+ let mut prompt = Prompt::default();
|
||||
+ prompt.input.push(ResponseItem::Message {
|
||||
+ role: "user".into(),
|
||||
+ content: vec![ContentItem::InputText { text: "hi".into() }],
|
||||
+ });
|
||||
+ prompt.input.push(ResponseItem::FunctionCall {
|
||||
+ name: "shell".into(),
|
||||
+ arguments: "[]".into(),
|
||||
+ call_id: "call123".into(),
|
||||
+ });
|
||||
+ prompt.input.push(ResponseItem::FunctionCallOutput {
|
||||
+ call_id: "call123".into(),
|
||||
+ output: FunctionCallOutputPayload {
|
||||
+ content: "ok".into(),
|
||||
+ success: Some(true),
|
||||
+ },
|
||||
+ });
|
||||
+ prompt.input.push(ResponseItem::LocalShellCall {
|
||||
+ id: Some("ls1".into()),
|
||||
+ call_id: Some("call456".into()),
|
||||
+ status: LocalShellStatus::Completed,
|
||||
+ action: LocalShellAction::Exec(LocalShellExecAction {
|
||||
+ command: vec!["echo".into(), "hi".into()],
|
||||
+ timeout_ms: Some(1),
|
||||
+ working_directory: None,
|
||||
+ env: None,
|
||||
+ user: None,
|
||||
+ }),
|
||||
+ });
|
||||
+
|
||||
+ let client = reqwest::Client::new();
|
||||
+ let _ = stream_chat_completions(&prompt, "model", &client, &provider)
|
||||
+ .await
|
||||
+ .unwrap();
|
||||
+
|
||||
+ let body = captured.lock().unwrap().take().unwrap();
|
||||
+
|
||||
+ // Build the expected payload exactly as stream_chat_completions() should.
|
||||
+ let full_instructions = prompt.get_full_instructions("model");
|
||||
+ let expected_messages = vec![
|
||||
+ json!({"role":"system","content":full_instructions}),
|
||||
+ json!({"role":"user","content":"hi"}),
|
||||
+ json!({
|
||||
+ "role":"assistant",
|
||||
+ "content":null,
|
||||
+ "tool_calls":[{
|
||||
+ "id":"call123",
|
||||
+ "type":"function",
|
||||
+ "function":{
|
||||
+ "name":"shell",
|
||||
+ "arguments":"[]"
|
||||
+ }
|
||||
+ }]
|
||||
+ }),
|
||||
+ json!({
|
||||
+ "role":"tool",
|
||||
+ "tool_call_id":"call123",
|
||||
+ "content":"ok"
|
||||
+ }),
|
||||
+ json!({
|
||||
+ "role":"assistant",
|
||||
+ "content":null,
|
||||
+ "tool_calls":[{
|
||||
+ "id":"ls1",
|
||||
+ "type":"local_shell_call",
|
||||
+ "status":"completed",
|
||||
+ "action":{
|
||||
+ "type":"exec",
|
||||
+ "command":["echo","hi"],
|
||||
+ "timeout_ms":1,
|
||||
+ "working_directory":null,
|
||||
+ "env":null,
|
||||
+ "user":null
|
||||
+ }
|
||||
+ }]
|
||||
+ }),
|
||||
+ ];
|
||||
+ let tools_json = create_tools_json_for_chat_completions_api(&prompt, "model").unwrap();
|
||||
+ let expected_body = json!({
|
||||
+ "model":"model",
|
||||
+ "messages": expected_messages,
|
||||
+ "stream": true,
|
||||
+ "tools": tools_json,
|
||||
+ });
|
||||
+
|
||||
+ assert_eq!(body, expected_body, "chat payload encoded incorrectly");
|
||||
+ }
|
||||
+}
|
||||
diff --git a/codex-rs/core/src/client.rs b/codex-rs/core/src/client.rs
|
||||
index 8ec68d02e8..b2ff284fd0 100644
|
||||
--- a/codex-rs/core/src/client.rs
|
||||
+++ b/codex-rs/core/src/client.rs
|
||||
@@ -317,7 +317,7 @@ where
|
||||
// duplicated `output` array embedded in the `response.completed`
|
||||
// payload. That produced two concrete issues:
|
||||
// 1. No real‑time streaming – the user only saw output after the
|
||||
- // entire turn had finished, which broke the “typing” UX and
|
||||
+ // entire turn had finished, which broke the "typing" UX and
|
||||
// made long‑running turns look stalled.
|
||||
// 2. Duplicate `function_call_output` items – both the
|
||||
// individual *and* the completed array were forwarded, which
|
||||
@@ -390,6 +390,7 @@ where
|
||||
}
|
||||
|
||||
/// used in tests to stream from a text SSE file
|
||||
+#[allow(dead_code)]
|
||||
async fn stream_from_fixture(path: impl AsRef<Path>) -> Result<ResponseStream> {
|
||||
let (tx_event, rx_event) = mpsc::channel::<Result<ResponseEvent>>(1600);
|
||||
let f = std::fs::File::open(path.as_ref())?;
|
||||
@@ -413,6 +414,8 @@ mod tests {
|
||||
#![allow(clippy::expect_used, clippy::unwrap_used)]
|
||||
|
||||
use super::*;
|
||||
+ use crate::models::LocalShellAction;
|
||||
+ use crate::models::LocalShellStatus;
|
||||
use serde_json::json;
|
||||
use tokio::sync::mpsc;
|
||||
use tokio_test::io::Builder as IoBuilder;
|
||||
@@ -422,6 +425,17 @@ mod tests {
|
||||
// Helpers
|
||||
// ────────────────────────────
|
||||
|
||||
+ /// Build a tiny SSE string with the provided *raw* event chunks (already formatted as
|
||||
+ /// `"event: ...\ndata: ..."` lines). Each chunk is separated by a blank line.
|
||||
+ fn build_sse(chunks: &[&str]) -> String {
|
||||
+ let mut out = String::new();
|
||||
+ for c in chunks {
|
||||
+ out.push_str(c);
|
||||
+ out.push_str("\n\n");
|
||||
+ }
|
||||
+ out
|
||||
+ }
|
||||
+
|
||||
/// Runs the SSE parser on pre-chunked byte slices and returns every event
|
||||
/// (including any final `Err` from a stream-closure check).
|
||||
async fn collect_events(chunks: &[&[u8]]) -> Vec<Result<ResponseEvent>> {
|
||||
@@ -469,6 +483,65 @@ mod tests {
|
||||
out
|
||||
}
|
||||
|
||||
+ // ────────────────────────────
|
||||
+ // Tests from `implement-unit-tests-for-event-aggregation-and-tool-calls`
|
||||
+ // ────────────────────────────
|
||||
+
|
||||
+ #[tokio::test]
|
||||
+ async fn parses_function_and_local_shell_items() {
|
||||
+ let func = "event: response.output_item.done\n\
|
||||
+data: {\"type\":\"response.output_item.done\",\"item\":{\"type\":\"function_call_output\",\"call_id\":\"call1\",\"output\":{\"content\":\"ok\",\"success\":true}}}";
|
||||
+ let shell = "event: response.output_item.done\n\
|
||||
+data: {\"type\":\"response.output_item.done\",\"item\":{\"type\":\"local_shell_call\",\"id\":\"ls1\",\"call_id\":\"call2\",\"status\":\"in_progress\",\"action\":{\"type\":\"exec\",\"command\":[\"echo\",\"hi\"],\"timeout_ms\":123,\"working_directory\":null,\"env\":null,\"user\":null}}}";
|
||||
+ let done = "event: response.completed\n\
|
||||
+data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp\",\"output\":[]}}";
|
||||
+
|
||||
+ let content = build_sse(&[func, shell, done]);
|
||||
+
|
||||
+ let (tx, mut rx) = tokio::sync::mpsc::channel::<Result<ResponseEvent>>(8);
|
||||
+ let stream = ReaderStream::new(std::io::Cursor::new(content)).map_err(CodexErr::Io);
|
||||
+ tokio::spawn(super::process_sse(stream, tx));
|
||||
+
|
||||
+ // function_call_output
|
||||
+ match rx.recv().await.unwrap().unwrap() {
|
||||
+ ResponseEvent::OutputItemDone(ResponseItem::FunctionCallOutput { call_id, output }) => {
|
||||
+ assert_eq!(call_id, "call1");
|
||||
+ assert_eq!(output.content, "ok");
|
||||
+ assert_eq!(output.success, Some(true));
|
||||
+ }
|
||||
+ other => panic!("unexpected first event: {other:?}"),
|
||||
+ }
|
||||
+
|
||||
+ // local_shell_call
|
||||
+ match rx.recv().await.unwrap().unwrap() {
|
||||
+ ResponseEvent::OutputItemDone(ResponseItem::LocalShellCall {
|
||||
+ id,
|
||||
+ call_id,
|
||||
+ status,
|
||||
+ action,
|
||||
+ }) => {
|
||||
+ assert_eq!(id.as_deref(), Some("ls1"));
|
||||
+ assert_eq!(call_id.as_deref(), Some("call2"));
|
||||
+ if !matches!(status, LocalShellStatus::InProgress) {
|
||||
+ panic!("unexpected status: {status:?}");
|
||||
+ }
|
||||
+ match action {
|
||||
+ LocalShellAction::Exec(act) => {
|
||||
+ assert_eq!(act.command, vec!["echo".to_string(), "hi".to_string()]);
|
||||
+ assert_eq!(act.timeout_ms, Some(123));
|
||||
+ }
|
||||
+ }
|
||||
+ }
|
||||
+ other => panic!("unexpected second event: {other:?}"),
|
||||
+ }
|
||||
+
|
||||
+ // completed
|
||||
+ assert!(matches!(
|
||||
+ rx.recv().await.unwrap().unwrap(),
|
||||
+ ResponseEvent::Completed { response_id, .. } if response_id == "resp"
|
||||
+ ));
|
||||
+ }
|
||||
+
|
||||
// ────────────────────────────
|
||||
// Tests from `implement-test-for-responses-api-sse-parser`
|
||||
// ────────────────────────────
|
||||
@@ -549,6 +622,7 @@ mod tests {
|
||||
|
||||
let events = collect_events(&[sse1.as_bytes()]).await;
|
||||
|
||||
+ // We expect the item + a final Err complaining about the missing completed event.
|
||||
assert_eq!(events.len(), 2);
|
||||
|
||||
matches!(events[0], Ok(ResponseEvent::OutputItemDone(_)));
|
||||
diff --git a/codex-rs/core/src/client_common.rs b/codex-rs/core/src/client_common.rs
|
||||
index 3e3c2e7efa..6b220d4fff 100644
|
||||
--- a/codex-rs/core/src/client_common.rs
|
||||
+++ b/codex-rs/core/src/client_common.rs
|
||||
@@ -49,7 +49,7 @@ impl Prompt {
|
||||
}
|
||||
}
|
||||
|
||||
-#[derive(Debug)]
|
||||
+#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum ResponseEvent {
|
||||
Created,
|
||||
OutputItemDone(ResponseItem),
|
||||
diff --git a/codex-rs/core/src/models.rs b/codex-rs/core/src/models.rs
|
||||
index 6b392fb19d..26babba715 100644
|
||||
--- a/codex-rs/core/src/models.rs
|
||||
+++ b/codex-rs/core/src/models.rs
|
||||
@@ -8,7 +8,7 @@ use serde::ser::Serializer;
|
||||
|
||||
use crate::protocol::InputItem;
|
||||
|
||||
-#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
+#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum ResponseInputItem {
|
||||
Message {
|
||||
@@ -25,7 +25,7 @@ pub enum ResponseInputItem {
|
||||
},
|
||||
}
|
||||
|
||||
-#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
+#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum ContentItem {
|
||||
InputText { text: String },
|
||||
@@ -33,7 +33,7 @@ pub enum ContentItem {
|
||||
OutputText { text: String },
|
||||
}
|
||||
|
||||
-#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
+#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum ResponseItem {
|
||||
Message {
|
||||
@@ -99,7 +99,7 @@ impl From<ResponseInputItem> for ResponseItem {
|
||||
}
|
||||
}
|
||||
|
||||
-#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
+#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum LocalShellStatus {
|
||||
Completed,
|
||||
@@ -107,13 +107,13 @@ pub enum LocalShellStatus {
|
||||
Incomplete,
|
||||
}
|
||||
|
||||
-#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
+#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum LocalShellAction {
|
||||
Exec(LocalShellExecAction),
|
||||
}
|
||||
|
||||
-#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
+#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct LocalShellExecAction {
|
||||
pub command: Vec<String>,
|
||||
pub timeout_ms: Option<u64>,
|
||||
@@ -122,7 +122,7 @@ pub struct LocalShellExecAction {
|
||||
pub user: Option<String>,
|
||||
}
|
||||
|
||||
-#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
+#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum ReasoningItemReasoningSummary {
|
||||
SummaryText { text: String },
|
||||
@@ -177,10 +177,10 @@ pub struct ShellToolCallParams {
|
||||
pub timeout_ms: Option<u64>,
|
||||
}
|
||||
|
||||
-#[derive(Deserialize, Debug, Clone)]
|
||||
+#[derive(Deserialize, Debug, Clone, PartialEq)]
|
||||
pub struct FunctionCallOutputPayload {
|
||||
pub content: String,
|
||||
- #[expect(dead_code)]
|
||||
+ #[allow(dead_code)]
|
||||
pub success: Option<bool>,
|
||||
}
|
||||
|
||||
diff --git a/codex-rs/core/src/protocol.rs b/codex-rs/core/src/protocol.rs
|
||||
index b233d4f27b..c14b2e190a 100644
|
||||
--- a/codex-rs/core/src/protocol.rs
|
||||
+++ b/codex-rs/core/src/protocol.rs
|
||||
@@ -332,7 +332,7 @@ pub struct TaskCompleteEvent {
|
||||
pub last_agent_message: Option<String>,
|
||||
}
|
||||
|
||||
-#[derive(Debug, Clone, Deserialize, Serialize, Default)]
|
||||
+#[derive(Debug, Clone, Deserialize, Serialize, Default, PartialEq)]
|
||||
pub struct TokenUsage {
|
||||
pub input_tokens: u64,
|
||||
pub cached_input_tokens: Option<u64>,
|
||||
```
|
||||
|
||||
## Review Comments
|
||||
|
||||
### codex-rs/core/src/chat_completions.rs
|
||||
|
||||
- Created: 2025-07-12 19:43:31 UTC | Link: https://github.com/openai/codex/pull/1538#discussion_r2202890339
|
||||
|
||||
```diff
|
||||
@@ -462,3 +465,228 @@ pub(crate) trait AggregateStreamExt: Stream<Item = Result<ResponseEvent>> + Size
|
||||
}
|
||||
|
||||
impl<T> AggregateStreamExt for T where T: Stream<Item = Result<ResponseEvent>> + Sized {}
|
||||
+
|
||||
+#[cfg(test)]
|
||||
+mod tests {
|
||||
+ #![allow(clippy::unwrap_used)]
|
||||
+
|
||||
+ use super::*;
|
||||
+ use crate::models::FunctionCallOutputPayload;
|
||||
+ use crate::models::LocalShellAction;
|
||||
+ use crate::models::LocalShellExecAction;
|
||||
+ use crate::models::LocalShellStatus;
|
||||
+ use futures::StreamExt;
|
||||
+ use futures::stream;
|
||||
+
|
||||
+ /// Helper constructing a minimal assistant text chunk.
|
||||
+ fn text_chunk(txt: &str) -> ResponseEvent {
|
||||
+ ResponseEvent::OutputItemDone(ResponseItem::Message {
|
||||
+ role: "assistant".to_string(),
|
||||
+ content: vec![ContentItem::OutputText { text: txt.into() }],
|
||||
+ })
|
||||
+ }
|
||||
+
|
||||
+ #[tokio::test]
|
||||
+ async fn aggregates_consecutive_message_chunks() {
|
||||
+ let events = vec![
|
||||
+ Ok(text_chunk("Hello")),
|
||||
+ Ok(text_chunk(", world")),
|
||||
+ Ok(ResponseEvent::Completed {
|
||||
+ response_id: "r1".to_string(),
|
||||
+ token_usage: None,
|
||||
+ }),
|
||||
+ ];
|
||||
+
|
||||
+ let stream = stream::iter(events).aggregate();
|
||||
+ let collected: Vec<_> = stream.map(Result::unwrap).collect().await;
|
||||
+
|
||||
+ assert_eq!(collected.len(), 2, "only final message and Completed");
|
||||
```
|
||||
|
||||
> just `assert_eq!()` on all of `collected`?
|
||||
|
||||
- Created: 2025-07-12 19:44:08 UTC | Link: https://github.com/openai/codex/pull/1538#discussion_r2202890464
|
||||
|
||||
```diff
|
||||
@@ -462,3 +465,228 @@ pub(crate) trait AggregateStreamExt: Stream<Item = Result<ResponseEvent>> + Size
|
||||
}
|
||||
|
||||
impl<T> AggregateStreamExt for T where T: Stream<Item = Result<ResponseEvent>> + Sized {}
|
||||
+
|
||||
+#[cfg(test)]
|
||||
+mod tests {
|
||||
+ #![allow(clippy::unwrap_used)]
|
||||
+
|
||||
+ use super::*;
|
||||
+ use crate::models::FunctionCallOutputPayload;
|
||||
+ use crate::models::LocalShellAction;
|
||||
+ use crate::models::LocalShellExecAction;
|
||||
+ use crate::models::LocalShellStatus;
|
||||
+ use futures::StreamExt;
|
||||
+ use futures::stream;
|
||||
+
|
||||
+ /// Helper constructing a minimal assistant text chunk.
|
||||
+ fn text_chunk(txt: &str) -> ResponseEvent {
|
||||
+ ResponseEvent::OutputItemDone(ResponseItem::Message {
|
||||
+ role: "assistant".to_string(),
|
||||
+ content: vec![ContentItem::OutputText { text: txt.into() }],
|
||||
+ })
|
||||
+ }
|
||||
+
|
||||
+ #[tokio::test]
|
||||
+ async fn aggregates_consecutive_message_chunks() {
|
||||
+ let events = vec![
|
||||
+ Ok(text_chunk("Hello")),
|
||||
+ Ok(text_chunk(", world")),
|
||||
+ Ok(ResponseEvent::Completed {
|
||||
+ response_id: "r1".to_string(),
|
||||
+ token_usage: None,
|
||||
+ }),
|
||||
+ ];
|
||||
+
|
||||
+ let stream = stream::iter(events).aggregate();
|
||||
+ let collected: Vec<_> = stream.map(Result::unwrap).collect().await;
|
||||
+
|
||||
+ assert_eq!(collected.len(), 2, "only final message and Completed");
|
||||
+
|
||||
+ match &collected[0] {
|
||||
+ ResponseEvent::OutputItemDone(ResponseItem::Message { content, .. }) => {
|
||||
+ let text = match &content[0] {
|
||||
+ ContentItem::OutputText { text } => text,
|
||||
+ _ => panic!("unexpected content item"),
|
||||
+ };
|
||||
+ assert_eq!(text, "Hello, world");
|
||||
+ }
|
||||
+ other => panic!("unexpected first event: {other:?}"),
|
||||
+ }
|
||||
+
|
||||
+ assert!(matches!(
|
||||
+ collected[1],
|
||||
+ ResponseEvent::Completed { response_id: ref id, token_usage: None } if id == "r1"
|
||||
+ ));
|
||||
+ }
|
||||
+
|
||||
+ #[tokio::test]
|
||||
+ async fn forwards_non_text_items_without_merging() {
|
||||
+ let func_call = ResponseItem::FunctionCall {
|
||||
+ name: "shell".to_string(),
|
||||
+ arguments: "{}".to_string(),
|
||||
+ call_id: "call1".to_string(),
|
||||
+ };
|
||||
+
|
||||
+ let events = vec![
|
||||
+ Ok(text_chunk("foo")),
|
||||
+ Ok(ResponseEvent::OutputItemDone(func_call.clone())),
|
||||
+ Ok(text_chunk("bar")),
|
||||
+ Ok(ResponseEvent::Completed {
|
||||
+ response_id: "r2".to_string(),
|
||||
+ token_usage: None,
|
||||
+ }),
|
||||
+ ];
|
||||
+
|
||||
+ let stream = stream::iter(events).aggregate();
|
||||
+ let collected: Vec<_> = stream.map(Result::unwrap).collect().await;
|
||||
+
|
||||
+ assert_eq!(collected.len(), 3);
|
||||
```
|
||||
|
||||
> same here
|
||||
|
||||
- Created: 2025-07-12 19:45:40 UTC | Link: https://github.com/openai/codex/pull/1538#discussion_r2202891601
|
||||
|
||||
```diff
|
||||
@@ -462,3 +465,228 @@ pub(crate) trait AggregateStreamExt: Stream<Item = Result<ResponseEvent>> + Size
|
||||
}
|
||||
|
||||
impl<T> AggregateStreamExt for T where T: Stream<Item = Result<ResponseEvent>> + Sized {}
|
||||
+
|
||||
+#[cfg(test)]
|
||||
+mod tests {
|
||||
+ #![allow(clippy::unwrap_used)]
|
||||
+
|
||||
+ use super::*;
|
||||
+ use crate::models::FunctionCallOutputPayload;
|
||||
+ use crate::models::LocalShellAction;
|
||||
+ use crate::models::LocalShellExecAction;
|
||||
+ use crate::models::LocalShellStatus;
|
||||
+ use futures::StreamExt;
|
||||
+ use futures::stream;
|
||||
+
|
||||
+ /// Helper constructing a minimal assistant text chunk.
|
||||
+ fn text_chunk(txt: &str) -> ResponseEvent {
|
||||
+ ResponseEvent::OutputItemDone(ResponseItem::Message {
|
||||
+ role: "assistant".to_string(),
|
||||
+ content: vec![ContentItem::OutputText { text: txt.into() }],
|
||||
+ })
|
||||
+ }
|
||||
+
|
||||
+ #[tokio::test]
|
||||
+ async fn aggregates_consecutive_message_chunks() {
|
||||
+ let events = vec![
|
||||
+ Ok(text_chunk("Hello")),
|
||||
+ Ok(text_chunk(", world")),
|
||||
+ Ok(ResponseEvent::Completed {
|
||||
+ response_id: "r1".to_string(),
|
||||
+ token_usage: None,
|
||||
+ }),
|
||||
+ ];
|
||||
+
|
||||
+ let stream = stream::iter(events).aggregate();
|
||||
+ let collected: Vec<_> = stream.map(Result::unwrap).collect().await;
|
||||
+
|
||||
+ assert_eq!(collected.len(), 2, "only final message and Completed");
|
||||
+
|
||||
+ match &collected[0] {
|
||||
+ ResponseEvent::OutputItemDone(ResponseItem::Message { content, .. }) => {
|
||||
+ let text = match &content[0] {
|
||||
+ ContentItem::OutputText { text } => text,
|
||||
+ _ => panic!("unexpected content item"),
|
||||
+ };
|
||||
+ assert_eq!(text, "Hello, world");
|
||||
+ }
|
||||
+ other => panic!("unexpected first event: {other:?}"),
|
||||
+ }
|
||||
+
|
||||
+ assert!(matches!(
|
||||
+ collected[1],
|
||||
+ ResponseEvent::Completed { response_id: ref id, token_usage: None } if id == "r1"
|
||||
+ ));
|
||||
+ }
|
||||
+
|
||||
+ #[tokio::test]
|
||||
+ async fn forwards_non_text_items_without_merging() {
|
||||
+ let func_call = ResponseItem::FunctionCall {
|
||||
+ name: "shell".to_string(),
|
||||
+ arguments: "{}".to_string(),
|
||||
+ call_id: "call1".to_string(),
|
||||
+ };
|
||||
+
|
||||
+ let events = vec![
|
||||
+ Ok(text_chunk("foo")),
|
||||
+ Ok(ResponseEvent::OutputItemDone(func_call.clone())),
|
||||
+ Ok(text_chunk("bar")),
|
||||
+ Ok(ResponseEvent::Completed {
|
||||
+ response_id: "r2".to_string(),
|
||||
+ token_usage: None,
|
||||
+ }),
|
||||
+ ];
|
||||
+
|
||||
+ let stream = stream::iter(events).aggregate();
|
||||
+ let collected: Vec<_> = stream.map(Result::unwrap).collect().await;
|
||||
+
|
||||
+ assert_eq!(collected.len(), 3);
|
||||
+
|
||||
+ // First event should be the function call forwarded directly.
|
||||
+ assert!(matches!(
|
||||
+ collected[0],
|
||||
+ ResponseEvent::OutputItemDone(ResponseItem::FunctionCall { .. })
|
||||
+ ));
|
||||
+
|
||||
+ // Second is the combined assistant message.
|
||||
+ match &collected[1] {
|
||||
+ ResponseEvent::OutputItemDone(ResponseItem::Message { content, .. }) => {
|
||||
+ let text = match &content[0] {
|
||||
+ ContentItem::OutputText { text } => text,
|
||||
+ _ => panic!("unexpected content item"),
|
||||
+ };
|
||||
+ assert_eq!(text, "foobar");
|
||||
+ }
|
||||
+ other => panic!("unexpected second event: {other:?}"),
|
||||
+ }
|
||||
+
|
||||
+ // Final Completed event.
|
||||
+ assert!(matches!(
|
||||
+ collected[2],
|
||||
+ ResponseEvent::Completed { response_id: ref id, token_usage: None } if id == "r2"
|
||||
+ ));
|
||||
+ }
|
||||
+
|
||||
+ #[tokio::test]
|
||||
+ async fn formats_tool_calls_in_chat_payload() {
|
||||
+ use serde_json::Value;
|
||||
+ use std::sync::Arc;
|
||||
+ use std::sync::Mutex;
|
||||
+ use wiremock::Mock;
|
||||
+ use wiremock::MockServer;
|
||||
+ use wiremock::Request;
|
||||
+ use wiremock::Respond;
|
||||
+ use wiremock::ResponseTemplate;
|
||||
+ use wiremock::matchers::method;
|
||||
+ use wiremock::matchers::path;
|
||||
+
|
||||
+ struct CaptureResponder(Arc<Mutex<Option<Value>>>);
|
||||
+ impl Respond for CaptureResponder {
|
||||
+ fn respond(&self, req: &Request) -> ResponseTemplate {
|
||||
+ let v: Value = serde_json::from_slice(&req.body).unwrap();
|
||||
+ *self.0.lock().unwrap() = Some(v);
|
||||
+ ResponseTemplate::new(200)
|
||||
+ .insert_header("content-type", "text/event-stream")
|
||||
+ .set_body_raw(
|
||||
+ "event: response.completed\n\
|
||||
+data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp\",\"output\":[]}}\n\n",
|
||||
+ "text/event-stream",
|
||||
+ )
|
||||
+ }
|
||||
+ }
|
||||
+
|
||||
+ let server = MockServer::start().await;
|
||||
+ let captured = Arc::new(Mutex::new(None));
|
||||
+
|
||||
+ Mock::given(method("POST"))
|
||||
+ .and(path("/v1/chat/completions"))
|
||||
+ .respond_with(CaptureResponder(captured.clone()))
|
||||
+ .expect(1)
|
||||
+ .mount(&server)
|
||||
+ .await;
|
||||
+
|
||||
+ unsafe {
|
||||
+ std::env::set_var("OPENAI_REQUEST_MAX_RETRIES", "0");
|
||||
```
|
||||
|
||||
> At some point, we should really find another way to thread this through so we can eliminate all these `unsafe` blocks.
|
||||
|
||||
- Created: 2025-07-12 19:46:18 UTC | Link: https://github.com/openai/codex/pull/1538#discussion_r2202892586
|
||||
|
||||
```diff
|
||||
@@ -462,3 +465,228 @@ pub(crate) trait AggregateStreamExt: Stream<Item = Result<ResponseEvent>> + Size
|
||||
}
|
||||
|
||||
impl<T> AggregateStreamExt for T where T: Stream<Item = Result<ResponseEvent>> + Sized {}
|
||||
+
|
||||
+#[cfg(test)]
|
||||
+mod tests {
|
||||
+ #![allow(clippy::unwrap_used)]
|
||||
+
|
||||
+ use super::*;
|
||||
+ use crate::models::FunctionCallOutputPayload;
|
||||
+ use crate::models::LocalShellAction;
|
||||
+ use crate::models::LocalShellExecAction;
|
||||
+ use crate::models::LocalShellStatus;
|
||||
+ use futures::StreamExt;
|
||||
+ use futures::stream;
|
||||
+
|
||||
+ /// Helper constructing a minimal assistant text chunk.
|
||||
+ fn text_chunk(txt: &str) -> ResponseEvent {
|
||||
+ ResponseEvent::OutputItemDone(ResponseItem::Message {
|
||||
+ role: "assistant".to_string(),
|
||||
+ content: vec![ContentItem::OutputText { text: txt.into() }],
|
||||
+ })
|
||||
+ }
|
||||
+
|
||||
+ #[tokio::test]
|
||||
+ async fn aggregates_consecutive_message_chunks() {
|
||||
+ let events = vec![
|
||||
+ Ok(text_chunk("Hello")),
|
||||
+ Ok(text_chunk(", world")),
|
||||
+ Ok(ResponseEvent::Completed {
|
||||
+ response_id: "r1".to_string(),
|
||||
+ token_usage: None,
|
||||
+ }),
|
||||
+ ];
|
||||
+
|
||||
+ let stream = stream::iter(events).aggregate();
|
||||
+ let collected: Vec<_> = stream.map(Result::unwrap).collect().await;
|
||||
+
|
||||
+ assert_eq!(collected.len(), 2, "only final message and Completed");
|
||||
+
|
||||
+ match &collected[0] {
|
||||
+ ResponseEvent::OutputItemDone(ResponseItem::Message { content, .. }) => {
|
||||
+ let text = match &content[0] {
|
||||
+ ContentItem::OutputText { text } => text,
|
||||
+ _ => panic!("unexpected content item"),
|
||||
+ };
|
||||
+ assert_eq!(text, "Hello, world");
|
||||
+ }
|
||||
+ other => panic!("unexpected first event: {other:?}"),
|
||||
+ }
|
||||
+
|
||||
+ assert!(matches!(
|
||||
+ collected[1],
|
||||
+ ResponseEvent::Completed { response_id: ref id, token_usage: None } if id == "r1"
|
||||
+ ));
|
||||
+ }
|
||||
+
|
||||
+ #[tokio::test]
|
||||
+ async fn forwards_non_text_items_without_merging() {
|
||||
+ let func_call = ResponseItem::FunctionCall {
|
||||
+ name: "shell".to_string(),
|
||||
+ arguments: "{}".to_string(),
|
||||
+ call_id: "call1".to_string(),
|
||||
+ };
|
||||
+
|
||||
+ let events = vec![
|
||||
+ Ok(text_chunk("foo")),
|
||||
+ Ok(ResponseEvent::OutputItemDone(func_call.clone())),
|
||||
+ Ok(text_chunk("bar")),
|
||||
+ Ok(ResponseEvent::Completed {
|
||||
+ response_id: "r2".to_string(),
|
||||
+ token_usage: None,
|
||||
+ }),
|
||||
+ ];
|
||||
+
|
||||
+ let stream = stream::iter(events).aggregate();
|
||||
+ let collected: Vec<_> = stream.map(Result::unwrap).collect().await;
|
||||
+
|
||||
+ assert_eq!(collected.len(), 3);
|
||||
+
|
||||
+ // First event should be the function call forwarded directly.
|
||||
+ assert!(matches!(
|
||||
+ collected[0],
|
||||
+ ResponseEvent::OutputItemDone(ResponseItem::FunctionCall { .. })
|
||||
+ ));
|
||||
+
|
||||
+ // Second is the combined assistant message.
|
||||
+ match &collected[1] {
|
||||
+ ResponseEvent::OutputItemDone(ResponseItem::Message { content, .. }) => {
|
||||
+ let text = match &content[0] {
|
||||
+ ContentItem::OutputText { text } => text,
|
||||
+ _ => panic!("unexpected content item"),
|
||||
+ };
|
||||
+ assert_eq!(text, "foobar");
|
||||
+ }
|
||||
+ other => panic!("unexpected second event: {other:?}"),
|
||||
+ }
|
||||
+
|
||||
+ // Final Completed event.
|
||||
+ assert!(matches!(
|
||||
+ collected[2],
|
||||
+ ResponseEvent::Completed { response_id: ref id, token_usage: None } if id == "r2"
|
||||
+ ));
|
||||
+ }
|
||||
+
|
||||
+ #[tokio::test]
|
||||
+ async fn formats_tool_calls_in_chat_payload() {
|
||||
+ use serde_json::Value;
|
||||
+ use std::sync::Arc;
|
||||
+ use std::sync::Mutex;
|
||||
+ use wiremock::Mock;
|
||||
+ use wiremock::MockServer;
|
||||
+ use wiremock::Request;
|
||||
+ use wiremock::Respond;
|
||||
+ use wiremock::ResponseTemplate;
|
||||
+ use wiremock::matchers::method;
|
||||
+ use wiremock::matchers::path;
|
||||
+
|
||||
+ struct CaptureResponder(Arc<Mutex<Option<Value>>>);
|
||||
+ impl Respond for CaptureResponder {
|
||||
+ fn respond(&self, req: &Request) -> ResponseTemplate {
|
||||
+ let v: Value = serde_json::from_slice(&req.body).unwrap();
|
||||
+ *self.0.lock().unwrap() = Some(v);
|
||||
+ ResponseTemplate::new(200)
|
||||
+ .insert_header("content-type", "text/event-stream")
|
||||
+ .set_body_raw(
|
||||
+ "event: response.completed\n\
|
||||
+data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp\",\"output\":[]}}\n\n",
|
||||
+ "text/event-stream",
|
||||
+ )
|
||||
+ }
|
||||
+ }
|
||||
+
|
||||
+ let server = MockServer::start().await;
|
||||
+ let captured = Arc::new(Mutex::new(None));
|
||||
+
|
||||
+ Mock::given(method("POST"))
|
||||
+ .and(path("/v1/chat/completions"))
|
||||
+ .respond_with(CaptureResponder(captured.clone()))
|
||||
+ .expect(1)
|
||||
+ .mount(&server)
|
||||
+ .await;
|
||||
+
|
||||
+ unsafe {
|
||||
+ std::env::set_var("OPENAI_REQUEST_MAX_RETRIES", "0");
|
||||
+ }
|
||||
+
|
||||
+ let provider = ModelProviderInfo {
|
||||
+ name: "openai".into(),
|
||||
+ base_url: format!("{}/v1", server.uri()),
|
||||
+ env_key: Some("PATH".into()),
|
||||
+ env_key_instructions: None,
|
||||
+ wire_api: crate::WireApi::Chat,
|
||||
+ query_params: None,
|
||||
+ http_headers: None,
|
||||
+ env_http_headers: None,
|
||||
+ };
|
||||
+
|
||||
+ let mut prompt = Prompt::default();
|
||||
+ prompt.input.push(ResponseItem::Message {
|
||||
+ role: "user".into(),
|
||||
+ content: vec![ContentItem::InputText { text: "hi".into() }],
|
||||
+ });
|
||||
+ prompt.input.push(ResponseItem::FunctionCall {
|
||||
+ name: "shell".into(),
|
||||
+ arguments: "[]".into(),
|
||||
+ call_id: "call123".into(),
|
||||
+ });
|
||||
+ prompt.input.push(ResponseItem::FunctionCallOutput {
|
||||
+ call_id: "call123".into(),
|
||||
+ output: FunctionCallOutputPayload {
|
||||
+ content: "ok".into(),
|
||||
+ success: Some(true),
|
||||
+ },
|
||||
+ });
|
||||
+ prompt.input.push(ResponseItem::LocalShellCall {
|
||||
+ id: Some("ls1".into()),
|
||||
+ call_id: Some("call456".into()),
|
||||
+ status: LocalShellStatus::Completed,
|
||||
+ action: LocalShellAction::Exec(LocalShellExecAction {
|
||||
+ command: vec!["echo".into(), "hi".into()],
|
||||
+ timeout_ms: Some(1),
|
||||
+ working_directory: None,
|
||||
+ env: None,
|
||||
+ user: None,
|
||||
+ }),
|
||||
+ });
|
||||
+
|
||||
+ let client = reqwest::Client::new();
|
||||
+ let _ = stream_chat_completions(&prompt, "model", &client, &provider)
|
||||
+ .await
|
||||
+ .unwrap();
|
||||
+
|
||||
+ let body = captured.lock().unwrap().take().unwrap();
|
||||
+ let messages = body.get("messages").unwrap().as_array().unwrap();
|
||||
```
|
||||
|
||||
> `assert_eq!()` for `body`
|
||||
637
prs/bolinfest/PR-1541.md
Normal file
637
prs/bolinfest/PR-1541.md
Normal file
@@ -0,0 +1,637 @@
|
||||
# PR #1541: Add SSE Response parser tests
|
||||
|
||||
- URL: https://github.com/openai/codex/pull/1541
|
||||
- Author: aibrahim-oai
|
||||
- Created: 2025-07-11 20:15:10 UTC
|
||||
- Updated: 2025-07-14 21:51:39 UTC
|
||||
- Changes: +184/-11, Files changed: 3, Commits: 6
|
||||
|
||||
## Description
|
||||
|
||||
## Summary
|
||||
- add `tokio-test` dev dependency
|
||||
- implement response stream parsing unit tests
|
||||
|
||||
## Testing
|
||||
- `cargo clippy -p codex-core --tests -- -D warnings`
|
||||
- `cargo test -p codex-core -- --nocapture`
|
||||
|
||||
------
|
||||
https://chatgpt.com/codex/tasks/task_i_687163f3b2208321a6ce2adbef3fbc06
|
||||
|
||||
## Full Diff
|
||||
|
||||
```diff
|
||||
diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock
|
||||
index 3de3e78198..e59dbfa255 100644
|
||||
--- a/codex-rs/Cargo.lock
|
||||
+++ b/codex-rs/Cargo.lock
|
||||
@@ -250,6 +250,28 @@ dependencies = [
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
+[[package]]
|
||||
+name = "async-stream"
|
||||
+version = "0.3.6"
|
||||
+source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
+checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476"
|
||||
+dependencies = [
|
||||
+ "async-stream-impl",
|
||||
+ "futures-core",
|
||||
+ "pin-project-lite",
|
||||
+]
|
||||
+
|
||||
+[[package]]
|
||||
+name = "async-stream-impl"
|
||||
+version = "0.3.6"
|
||||
+source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
+checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d"
|
||||
+dependencies = [
|
||||
+ "proc-macro2",
|
||||
+ "quote",
|
||||
+ "syn 2.0.104",
|
||||
+]
|
||||
+
|
||||
[[package]]
|
||||
name = "async-trait"
|
||||
version = "0.1.88"
|
||||
@@ -654,6 +676,7 @@ dependencies = [
|
||||
"thiserror 2.0.12",
|
||||
"time",
|
||||
"tokio",
|
||||
+ "tokio-test",
|
||||
"tokio-util",
|
||||
"toml 0.9.1",
|
||||
"tracing",
|
||||
@@ -4516,6 +4539,30 @@ dependencies = [
|
||||
"tokio",
|
||||
]
|
||||
|
||||
+[[package]]
|
||||
+name = "tokio-stream"
|
||||
+version = "0.1.17"
|
||||
+source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
+checksum = "eca58d7bba4a75707817a2c44174253f9236b2d5fbd055602e9d5c07c139a047"
|
||||
+dependencies = [
|
||||
+ "futures-core",
|
||||
+ "pin-project-lite",
|
||||
+ "tokio",
|
||||
+]
|
||||
+
|
||||
+[[package]]
|
||||
+name = "tokio-test"
|
||||
+version = "0.4.4"
|
||||
+source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
+checksum = "2468baabc3311435b55dd935f702f42cd1b8abb7e754fb7dfb16bd36aa88f9f7"
|
||||
+dependencies = [
|
||||
+ "async-stream",
|
||||
+ "bytes",
|
||||
+ "futures-core",
|
||||
+ "tokio",
|
||||
+ "tokio-stream",
|
||||
+]
|
||||
+
|
||||
[[package]]
|
||||
name = "tokio-util"
|
||||
version = "0.7.15"
|
||||
diff --git a/codex-rs/core/Cargo.toml b/codex-rs/core/Cargo.toml
|
||||
index 22636102c9..c55d7d395d 100644
|
||||
--- a/codex-rs/core/Cargo.toml
|
||||
+++ b/codex-rs/core/Cargo.toml
|
||||
@@ -64,4 +64,5 @@ maplit = "1.0.2"
|
||||
predicates = "3"
|
||||
pretty_assertions = "1.4.1"
|
||||
tempfile = "3"
|
||||
+tokio-test = "0.4"
|
||||
wiremock = "0.6"
|
||||
diff --git a/codex-rs/core/src/client.rs b/codex-rs/core/src/client.rs
|
||||
index 1b8e4c959d..2fa182cf7f 100644
|
||||
--- a/codex-rs/core/src/client.rs
|
||||
+++ b/codex-rs/core/src/client.rs
|
||||
@@ -395,9 +395,39 @@ async fn stream_from_fixture(path: impl AsRef<Path>) -> Result<ResponseStream> {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#![allow(clippy::expect_used, clippy::unwrap_used)]
|
||||
+
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
+ use tokio::sync::mpsc;
|
||||
+ use tokio_test::io::Builder as IoBuilder;
|
||||
+ use tokio_util::io::ReaderStream;
|
||||
+
|
||||
+ // ────────────────────────────
|
||||
+ // Helpers
|
||||
+ // ────────────────────────────
|
||||
+
|
||||
+ /// Runs the SSE parser on pre-chunked byte slices and returns every event
|
||||
+ /// (including any final `Err` from a stream-closure check).
|
||||
+ async fn collect_events(chunks: &[&[u8]]) -> Vec<Result<ResponseEvent>> {
|
||||
+ let mut builder = IoBuilder::new();
|
||||
+ for chunk in chunks {
|
||||
+ builder.read(chunk);
|
||||
+ }
|
||||
+
|
||||
+ let reader = builder.build();
|
||||
+ let stream = ReaderStream::new(reader).map_err(CodexErr::Io);
|
||||
+ let (tx, mut rx) = mpsc::channel::<Result<ResponseEvent>>(16);
|
||||
+ tokio::spawn(process_sse(stream, tx));
|
||||
|
||||
+ let mut events = Vec::new();
|
||||
+ while let Some(ev) = rx.recv().await {
|
||||
+ events.push(ev);
|
||||
+ }
|
||||
+ events
|
||||
+ }
|
||||
+
|
||||
+ /// Builds an in-memory SSE stream from JSON fixtures and returns only the
|
||||
+ /// successfully parsed events (panics on internal channel errors).
|
||||
async fn run_sse(events: Vec<serde_json::Value>) -> Vec<ResponseEvent> {
|
||||
let mut body = String::new();
|
||||
for e in events {
|
||||
@@ -411,9 +441,11 @@ mod tests {
|
||||
body.push_str(&format!("event: {kind}\ndata: {e}\n\n"));
|
||||
}
|
||||
}
|
||||
+
|
||||
let (tx, mut rx) = mpsc::channel::<Result<ResponseEvent>>(8);
|
||||
let stream = ReaderStream::new(std::io::Cursor::new(body)).map_err(CodexErr::Io);
|
||||
tokio::spawn(process_sse(stream, tx));
|
||||
+
|
||||
let mut out = Vec::new();
|
||||
while let Some(ev) = rx.recv().await {
|
||||
out.push(ev.expect("channel closed"));
|
||||
@@ -421,14 +453,104 @@ mod tests {
|
||||
out
|
||||
}
|
||||
|
||||
- /// Verifies that the SSE adapter emits the expected [`ResponseEvent`] for
|
||||
- /// a variety of `type` values from the Responses API. The test is written
|
||||
- /// table-driven style to keep additions for new event kinds trivial.
|
||||
- ///
|
||||
- /// Each `Case` supplies an input event, a predicate that must match the
|
||||
- /// *first* `ResponseEvent` produced by the adapter, and the total number
|
||||
- /// of events expected after appending a synthetic `response.completed`
|
||||
- /// marker that terminates the stream.
|
||||
+ // ────────────────────────────
|
||||
+ // Tests from `implement-test-for-responses-api-sse-parser`
|
||||
+ // ────────────────────────────
|
||||
+
|
||||
+ #[tokio::test]
|
||||
+ async fn parses_items_and_completed() {
|
||||
+ let item1 = json!({
|
||||
+ "type": "response.output_item.done",
|
||||
+ "item": {
|
||||
+ "type": "message",
|
||||
+ "role": "assistant",
|
||||
+ "content": [{"type": "output_text", "text": "Hello"}]
|
||||
+ }
|
||||
+ })
|
||||
+ .to_string();
|
||||
+
|
||||
+ let item2 = json!({
|
||||
+ "type": "response.output_item.done",
|
||||
+ "item": {
|
||||
+ "type": "message",
|
||||
+ "role": "assistant",
|
||||
+ "content": [{"type": "output_text", "text": "World"}]
|
||||
+ }
|
||||
+ })
|
||||
+ .to_string();
|
||||
+
|
||||
+ let completed = json!({
|
||||
+ "type": "response.completed",
|
||||
+ "response": { "id": "resp1" }
|
||||
+ })
|
||||
+ .to_string();
|
||||
+
|
||||
+ let sse1 = format!("event: response.output_item.done\ndata: {item1}\n\n");
|
||||
+ let sse2 = format!("event: response.output_item.done\ndata: {item2}\n\n");
|
||||
+ let sse3 = format!("event: response.completed\ndata: {completed}\n\n");
|
||||
+
|
||||
+ let events = collect_events(&[sse1.as_bytes(), sse2.as_bytes(), sse3.as_bytes()]).await;
|
||||
+
|
||||
+ assert_eq!(events.len(), 3);
|
||||
+
|
||||
+ matches!(
|
||||
+ &events[0],
|
||||
+ Ok(ResponseEvent::OutputItemDone(ResponseItem::Message { role, .. }))
|
||||
+ if role == "assistant"
|
||||
+ );
|
||||
+
|
||||
+ matches!(
|
||||
+ &events[1],
|
||||
+ Ok(ResponseEvent::OutputItemDone(ResponseItem::Message { role, .. }))
|
||||
+ if role == "assistant"
|
||||
+ );
|
||||
+
|
||||
+ match &events[2] {
|
||||
+ Ok(ResponseEvent::Completed {
|
||||
+ response_id,
|
||||
+ token_usage,
|
||||
+ }) => {
|
||||
+ assert_eq!(response_id, "resp1");
|
||||
+ assert!(token_usage.is_none());
|
||||
+ }
|
||||
+ other => panic!("unexpected third event: {other:?}"),
|
||||
+ }
|
||||
+ }
|
||||
+
|
||||
+ #[tokio::test]
|
||||
+ async fn error_when_missing_completed() {
|
||||
+ let item1 = json!({
|
||||
+ "type": "response.output_item.done",
|
||||
+ "item": {
|
||||
+ "type": "message",
|
||||
+ "role": "assistant",
|
||||
+ "content": [{"type": "output_text", "text": "Hello"}]
|
||||
+ }
|
||||
+ })
|
||||
+ .to_string();
|
||||
+
|
||||
+ let sse1 = format!("event: response.output_item.done\ndata: {item1}\n\n");
|
||||
+
|
||||
+ let events = collect_events(&[sse1.as_bytes()]).await;
|
||||
+
|
||||
+ assert_eq!(events.len(), 2);
|
||||
+
|
||||
+ matches!(events[0], Ok(ResponseEvent::OutputItemDone(_)));
|
||||
+
|
||||
+ match &events[1] {
|
||||
+ Err(CodexErr::Stream(msg)) => {
|
||||
+ assert_eq!(msg, "stream closed before response.completed")
|
||||
+ }
|
||||
+ other => panic!("unexpected second event: {other:?}"),
|
||||
+ }
|
||||
+ }
|
||||
+
|
||||
+ // ────────────────────────────
|
||||
+ // Table-driven test from `main`
|
||||
+ // ────────────────────────────
|
||||
+
|
||||
+ /// Verifies that the adapter produces the right `ResponseEvent` for a
|
||||
+ /// variety of incoming `type` values.
|
||||
#[tokio::test]
|
||||
async fn table_driven_event_kinds() {
|
||||
struct TestCase {
|
||||
@@ -441,11 +563,9 @@ mod tests {
|
||||
fn is_created(ev: &ResponseEvent) -> bool {
|
||||
matches!(ev, ResponseEvent::Created)
|
||||
}
|
||||
-
|
||||
fn is_output(ev: &ResponseEvent) -> bool {
|
||||
matches!(ev, ResponseEvent::OutputItemDone(_))
|
||||
}
|
||||
-
|
||||
fn is_completed(ev: &ResponseEvent) -> bool {
|
||||
matches!(ev, ResponseEvent::Completed { .. })
|
||||
}
|
||||
@@ -498,9 +618,14 @@ mod tests {
|
||||
for case in cases {
|
||||
let mut evs = vec![case.event];
|
||||
evs.push(completed.clone());
|
||||
+
|
||||
let out = run_sse(evs).await;
|
||||
assert_eq!(out.len(), case.expected_len, "case {}", case.name);
|
||||
- assert!((case.expect_first)(&out[0]), "case {}", case.name);
|
||||
+ assert!(
|
||||
+ (case.expect_first)(&out[0]),
|
||||
+ "first event mismatch in case {}",
|
||||
+ case.name
|
||||
+ );
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Review Comments
|
||||
|
||||
### codex-rs/core/Cargo.toml
|
||||
|
||||
- Created: 2025-07-12 18:25:24 UTC | Link: https://github.com/openai/codex/pull/1541#discussion_r2202860562
|
||||
|
||||
```diff
|
||||
@@ -65,3 +65,4 @@ predicates = "3"
|
||||
pretty_assertions = "1.4.1"
|
||||
tempfile = "3"
|
||||
wiremock = "0.6"
|
||||
+tokio-test = "0.4"
|
||||
```
|
||||
|
||||
> nit: please keep alpha sorted
|
||||
|
||||
### codex-rs/core/src/client.rs
|
||||
|
||||
- Created: 2025-07-12 18:30:15 UTC | Link: https://github.com/openai/codex/pull/1541#discussion_r2202864002
|
||||
|
||||
```diff
|
||||
@@ -391,3 +391,122 @@ async fn stream_from_fixture(path: impl AsRef<Path>) -> Result<ResponseStream> {
|
||||
tokio::spawn(process_sse(stream, tx_event));
|
||||
Ok(ResponseStream { rx_event })
|
||||
}
|
||||
+
|
||||
+#[cfg(test)]
|
||||
+mod tests {
|
||||
+ use super::*;
|
||||
+ use tokio::sync::mpsc;
|
||||
+ use tokio_test::io::Builder as IoBuilder;
|
||||
+
|
||||
+ /// Helper that runs the SSE parser on the provided chunks and collects all events.
|
||||
+ async fn collect_events(chunks: &[&[u8]]) -> Vec<Result<ResponseEvent>> {
|
||||
+ let mut builder = IoBuilder::new();
|
||||
+ for chunk in chunks {
|
||||
+ builder.read(chunk);
|
||||
+ }
|
||||
+
|
||||
+ let reader = builder.build();
|
||||
+ let stream = ReaderStream::new(reader).map_err(CodexErr::Io);
|
||||
+ let (tx, mut rx) = mpsc::channel::<Result<ResponseEvent>>(16);
|
||||
+ tokio::spawn(process_sse(stream, tx));
|
||||
+
|
||||
+ let mut events = Vec::new();
|
||||
+ while let Some(ev) = rx.recv().await {
|
||||
+ events.push(ev);
|
||||
+ }
|
||||
+ events
|
||||
+ }
|
||||
+
|
||||
+ #[tokio::test]
|
||||
+ async fn parses_items_and_completed() {
|
||||
+ let item1 = serde_json::json!({
|
||||
+ "type": "response.output_item.done",
|
||||
+ "item": {
|
||||
+ "type": "message",
|
||||
+ "role": "assistant",
|
||||
+ "content": [{"type": "output_text", "text": "Hello"}]
|
||||
+ }
|
||||
+ })
|
||||
+ .to_string();
|
||||
+
|
||||
+ let item2 = serde_json::json!({
|
||||
+ "type": "response.output_item.done",
|
||||
+ "item": {
|
||||
+ "type": "message",
|
||||
+ "role": "assistant",
|
||||
+ "content": [{"type": "output_text", "text": "World"}]
|
||||
+ }
|
||||
+ })
|
||||
+ .to_string();
|
||||
+
|
||||
+ let completed = serde_json::json!({
|
||||
+ "type": "response.completed",
|
||||
+ "response": {"id": "resp1"}
|
||||
+ })
|
||||
+ .to_string();
|
||||
+
|
||||
+ let sse1 = format!("event: response.output_item.done\ndata: {item1}\n\n");
|
||||
+ let sse2 = format!("event: response.output_item.done\ndata: {item2}\n\n");
|
||||
+ let sse3 = format!("event: response.completed\ndata: {completed}\n\n");
|
||||
+
|
||||
+ let events = collect_events(&[sse1.as_bytes(), sse2.as_bytes(), sse3.as_bytes()]).await;
|
||||
+
|
||||
+ assert_eq!(events.len(), 3);
|
||||
+
|
||||
+ match &events[0] {
|
||||
+ Ok(ResponseEvent::OutputItemDone(ResponseItem::Message { role, .. })) => {
|
||||
+ assert_eq!(role, "assistant")
|
||||
+ }
|
||||
+ other => panic!("unexpected first event: {other:?}"),
|
||||
+ }
|
||||
+
|
||||
+ match &events[1] {
|
||||
+ Ok(ResponseEvent::OutputItemDone(ResponseItem::Message { role, .. })) => {
|
||||
+ assert_eq!(role, "assistant")
|
||||
+ }
|
||||
+ other => panic!("unexpected second event: {other:?}"),
|
||||
+ }
|
||||
+
|
||||
+ match &events[2] {
|
||||
+ Ok(ResponseEvent::Completed {
|
||||
+ response_id,
|
||||
+ token_usage,
|
||||
+ }) => {
|
||||
+ assert_eq!(response_id, "resp1");
|
||||
+ assert!(token_usage.is_none());
|
||||
+ }
|
||||
+ other => panic!("unexpected third event: {other:?}"),
|
||||
+ }
|
||||
+ }
|
||||
```
|
||||
|
||||
> This is a natural thing to do, but whenever possible, please just do one big `assert_eq!()` rather than doing a bunch of piecemeal `assert_eq!()` calls, so something like:
|
||||
>
|
||||
> ```suggestion
|
||||
> assert_eq!(events, vec![
|
||||
> Ok(ResponseEvent::OutputItemDone(ResponseItem::Message { role: "assistant" }),
|
||||
> Ok(ResponseEvent::OutputItemDone(ResponseItem::Message { role, "assistant" }),
|
||||
> Ok(ResponseEvent::Completed { response_id: "resp1", token_usage: None })
|
||||
> ]);
|
||||
> ```
|
||||
>
|
||||
> I think you can also potentially do an `iter` / `collect()` on `events` to convert `Vec<Result<T>>` into `Result<Vec<T>>`.
|
||||
>
|
||||
|
||||
- Created: 2025-07-12 18:31:17 UTC | Link: https://github.com/openai/codex/pull/1541#discussion_r2202864642
|
||||
|
||||
```diff
|
||||
@@ -391,3 +391,122 @@ async fn stream_from_fixture(path: impl AsRef<Path>) -> Result<ResponseStream> {
|
||||
tokio::spawn(process_sse(stream, tx_event));
|
||||
Ok(ResponseStream { rx_event })
|
||||
}
|
||||
+
|
||||
+#[cfg(test)]
|
||||
+mod tests {
|
||||
+ use super::*;
|
||||
+ use tokio::sync::mpsc;
|
||||
+ use tokio_test::io::Builder as IoBuilder;
|
||||
+
|
||||
+ /// Helper that runs the SSE parser on the provided chunks and collects all events.
|
||||
+ async fn collect_events(chunks: &[&[u8]]) -> Vec<Result<ResponseEvent>> {
|
||||
+ let mut builder = IoBuilder::new();
|
||||
+ for chunk in chunks {
|
||||
+ builder.read(chunk);
|
||||
+ }
|
||||
+
|
||||
+ let reader = builder.build();
|
||||
+ let stream = ReaderStream::new(reader).map_err(CodexErr::Io);
|
||||
+ let (tx, mut rx) = mpsc::channel::<Result<ResponseEvent>>(16);
|
||||
+ tokio::spawn(process_sse(stream, tx));
|
||||
+
|
||||
+ let mut events = Vec::new();
|
||||
+ while let Some(ev) = rx.recv().await {
|
||||
+ events.push(ev);
|
||||
+ }
|
||||
+ events
|
||||
+ }
|
||||
+
|
||||
+ #[tokio::test]
|
||||
+ async fn parses_items_and_completed() {
|
||||
+ let item1 = serde_json::json!({
|
||||
+ "type": "response.output_item.done",
|
||||
+ "item": {
|
||||
+ "type": "message",
|
||||
+ "role": "assistant",
|
||||
+ "content": [{"type": "output_text", "text": "Hello"}]
|
||||
+ }
|
||||
+ })
|
||||
+ .to_string();
|
||||
+
|
||||
+ let item2 = serde_json::json!({
|
||||
+ "type": "response.output_item.done",
|
||||
+ "item": {
|
||||
+ "type": "message",
|
||||
+ "role": "assistant",
|
||||
+ "content": [{"type": "output_text", "text": "World"}]
|
||||
+ }
|
||||
+ })
|
||||
+ .to_string();
|
||||
+
|
||||
+ let completed = serde_json::json!({
|
||||
+ "type": "response.completed",
|
||||
+ "response": {"id": "resp1"}
|
||||
+ })
|
||||
+ .to_string();
|
||||
+
|
||||
+ let sse1 = format!("event: response.output_item.done\ndata: {item1}\n\n");
|
||||
+ let sse2 = format!("event: response.output_item.done\ndata: {item2}\n\n");
|
||||
+ let sse3 = format!("event: response.completed\ndata: {completed}\n\n");
|
||||
+
|
||||
+ let events = collect_events(&[sse1.as_bytes(), sse2.as_bytes(), sse3.as_bytes()]).await;
|
||||
+
|
||||
+ assert_eq!(events.len(), 3);
|
||||
+
|
||||
+ match &events[0] {
|
||||
+ Ok(ResponseEvent::OutputItemDone(ResponseItem::Message { role, .. })) => {
|
||||
+ assert_eq!(role, "assistant")
|
||||
+ }
|
||||
+ other => panic!("unexpected first event: {other:?}"),
|
||||
+ }
|
||||
+
|
||||
+ match &events[1] {
|
||||
+ Ok(ResponseEvent::OutputItemDone(ResponseItem::Message { role, .. })) => {
|
||||
+ assert_eq!(role, "assistant")
|
||||
+ }
|
||||
+ other => panic!("unexpected second event: {other:?}"),
|
||||
+ }
|
||||
+
|
||||
+ match &events[2] {
|
||||
+ Ok(ResponseEvent::Completed {
|
||||
+ response_id,
|
||||
+ token_usage,
|
||||
+ }) => {
|
||||
+ assert_eq!(response_id, "resp1");
|
||||
+ assert!(token_usage.is_none());
|
||||
+ }
|
||||
+ other => panic!("unexpected third event: {other:?}"),
|
||||
+ }
|
||||
+ }
|
||||
+
|
||||
+ #[tokio::test]
|
||||
+ async fn error_when_missing_completed() {
|
||||
+ let item1 = serde_json::json!({
|
||||
+ "type": "response.output_item.done",
|
||||
+ "item": {
|
||||
+ "type": "message",
|
||||
+ "role": "assistant",
|
||||
+ "content": [{"type": "output_text", "text": "Hello"}]
|
||||
+ }
|
||||
+ })
|
||||
+ .to_string();
|
||||
+
|
||||
+ let sse1 = format!("event: response.output_item.done\ndata: {item1}\n\n");
|
||||
+
|
||||
+ let events = collect_events(&[sse1.as_bytes()]).await;
|
||||
+
|
||||
+ assert_eq!(events.len(), 2);
|
||||
```
|
||||
|
||||
> Same here.
|
||||
|
||||
- Created: 2025-07-12 18:40:12 UTC | Link: https://github.com/openai/codex/pull/1541#discussion_r2202866468
|
||||
|
||||
```diff
|
||||
@@ -391,3 +391,122 @@ async fn stream_from_fixture(path: impl AsRef<Path>) -> Result<ResponseStream> {
|
||||
tokio::spawn(process_sse(stream, tx_event));
|
||||
Ok(ResponseStream { rx_event })
|
||||
}
|
||||
+
|
||||
+#[cfg(test)]
|
||||
+mod tests {
|
||||
+ use super::*;
|
||||
+ use tokio::sync::mpsc;
|
||||
+ use tokio_test::io::Builder as IoBuilder;
|
||||
+
|
||||
+ /// Helper that runs the SSE parser on the provided chunks and collects all events.
|
||||
+ async fn collect_events(chunks: &[&[u8]]) -> Vec<Result<ResponseEvent>> {
|
||||
+ let mut builder = IoBuilder::new();
|
||||
+ for chunk in chunks {
|
||||
+ builder.read(chunk);
|
||||
+ }
|
||||
+
|
||||
+ let reader = builder.build();
|
||||
+ let stream = ReaderStream::new(reader).map_err(CodexErr::Io);
|
||||
+ let (tx, mut rx) = mpsc::channel::<Result<ResponseEvent>>(16);
|
||||
+ tokio::spawn(process_sse(stream, tx));
|
||||
+
|
||||
+ let mut events = Vec::new();
|
||||
+ while let Some(ev) = rx.recv().await {
|
||||
+ events.push(ev);
|
||||
+ }
|
||||
+ events
|
||||
+ }
|
||||
+
|
||||
+ #[tokio::test]
|
||||
+ async fn parses_items_and_completed() {
|
||||
+ let item1 = serde_json::json!({
|
||||
+ "type": "response.output_item.done",
|
||||
+ "item": {
|
||||
+ "type": "message",
|
||||
+ "role": "assistant",
|
||||
+ "content": [{"type": "output_text", "text": "Hello"}]
|
||||
+ }
|
||||
+ })
|
||||
+ .to_string();
|
||||
+
|
||||
+ let item2 = serde_json::json!({
|
||||
+ "type": "response.output_item.done",
|
||||
+ "item": {
|
||||
+ "type": "message",
|
||||
+ "role": "assistant",
|
||||
+ "content": [{"type": "output_text", "text": "World"}]
|
||||
+ }
|
||||
+ })
|
||||
+ .to_string();
|
||||
+
|
||||
+ let completed = serde_json::json!({
|
||||
+ "type": "response.completed",
|
||||
+ "response": {"id": "resp1"}
|
||||
+ })
|
||||
+ .to_string();
|
||||
+
|
||||
+ let sse1 = format!("event: response.output_item.done\ndata: {item1}\n\n");
|
||||
+ let sse2 = format!("event: response.output_item.done\ndata: {item2}\n\n");
|
||||
+ let sse3 = format!("event: response.completed\ndata: {completed}\n\n");
|
||||
```
|
||||
|
||||
> I wonder if instead of declaring these as:
|
||||
>
|
||||
> ```
|
||||
> item1
|
||||
> item2
|
||||
> item3
|
||||
> sse1
|
||||
> sse2
|
||||
> sse3
|
||||
> ```
|
||||
>
|
||||
> it would be easier to reason about if it were.
|
||||
>
|
||||
> ```
|
||||
> item1
|
||||
> sse1
|
||||
> item2
|
||||
> sse2
|
||||
> item3
|
||||
> sse3
|
||||
> ```
|
||||
>
|
||||
> Just a suggestion: one could argue it either way.
|
||||
467
prs/bolinfest/PR-1542.md
Normal file
467
prs/bolinfest/PR-1542.md
Normal file
@@ -0,0 +1,467 @@
|
||||
# PR #1542: Add CLI streaming integration tests
|
||||
|
||||
- URL: https://github.com/openai/codex/pull/1542
|
||||
- Author: aibrahim-oai
|
||||
- Created: 2025-07-11 20:26:16 UTC
|
||||
- Updated: 2025-07-13 01:06:04 UTC
|
||||
- Changes: +127/-0, Files changed: 2, Commits: 6
|
||||
|
||||
## Description
|
||||
|
||||
## Summary
|
||||
- add integration test for chat mode streaming via CLI using wiremock
|
||||
- add integration test for Responses API streaming via fixture
|
||||
- call `cargo run` to invoke the CLI during tests
|
||||
|
||||
## Testing
|
||||
- `cargo test -p codex-core --test cli_stream -- --nocapture`
|
||||
- `cargo clippy --all-targets --all-features -- -D warnings`
|
||||
|
||||
|
||||
------
|
||||
https://chatgpt.com/codex/tasks/task_i_68715980bbec8321999534fdd6a013c1
|
||||
|
||||
## Full Diff
|
||||
|
||||
```diff
|
||||
diff --git a/codex-rs/core/tests/cli_responses_fixture.sse b/codex-rs/core/tests/cli_responses_fixture.sse
|
||||
new file mode 100644
|
||||
index 0000000000..d297ebafb2
|
||||
--- /dev/null
|
||||
+++ b/codex-rs/core/tests/cli_responses_fixture.sse
|
||||
@@ -0,0 +1,8 @@
|
||||
+event: response.created
|
||||
+data: {"type":"response.created","response":{"id":"resp1"}}
|
||||
+
|
||||
+event: response.output_item.done
|
||||
+data: {"type":"response.output_item.done","item":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"fixture hello"}]}}
|
||||
+
|
||||
+event: response.completed
|
||||
+data: {"type":"response.completed","response":{"id":"resp1","output":[]}}
|
||||
diff --git a/codex-rs/core/tests/cli_stream.rs b/codex-rs/core/tests/cli_stream.rs
|
||||
new file mode 100644
|
||||
index 0000000000..df3fedfd48
|
||||
--- /dev/null
|
||||
+++ b/codex-rs/core/tests/cli_stream.rs
|
||||
@@ -0,0 +1,119 @@
|
||||
+#![expect(clippy::unwrap_used)]
|
||||
+
|
||||
+use assert_cmd::Command as AssertCommand;
|
||||
+use codex_core::exec::CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR;
|
||||
+use tempfile::TempDir;
|
||||
+use wiremock::Mock;
|
||||
+use wiremock::MockServer;
|
||||
+use wiremock::ResponseTemplate;
|
||||
+use wiremock::matchers::method;
|
||||
+use wiremock::matchers::path;
|
||||
+
|
||||
+/// Tests streaming chat completions through the CLI using a mock server.
|
||||
+/// This test:
|
||||
+/// 1. Sets up a mock server that simulates OpenAI's chat completions API
|
||||
+/// 2. Configures codex to use this mock server via a custom provider
|
||||
+/// 3. Sends a simple "hello?" prompt and verifies the streamed response
|
||||
+/// 4. Ensures the response is received exactly once and contains "hi"
|
||||
+#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
+async fn chat_mode_stream_cli() {
|
||||
+ if std::env::var(CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR).is_ok() {
|
||||
+ println!(
|
||||
+ "Skipping test because it cannot execute when network is disabled in a Codex sandbox."
|
||||
+ );
|
||||
+ return;
|
||||
+ }
|
||||
+
|
||||
+ let server = MockServer::start().await;
|
||||
+ let sse = concat!(
|
||||
+ "data: {\"choices\":[{\"delta\":{\"content\":\"hi\"}}]}\n\n",
|
||||
+ "data: {\"choices\":[{\"delta\":{}}]}\n\n",
|
||||
+ "data: [DONE]\n\n"
|
||||
+ );
|
||||
+ Mock::given(method("POST"))
|
||||
+ .and(path("/v1/chat/completions"))
|
||||
+ .respond_with(
|
||||
+ ResponseTemplate::new(200)
|
||||
+ .insert_header("content-type", "text/event-stream")
|
||||
+ .set_body_raw(sse, "text/event-stream"),
|
||||
+ )
|
||||
+ .expect(1)
|
||||
+ .mount(&server)
|
||||
+ .await;
|
||||
+
|
||||
+ let home = TempDir::new().unwrap();
|
||||
+ let provider_override = format!(
|
||||
+ "model_providers.mock={{ name = \"mock\", base_url = \"{}/v1\", env_key = \"PATH\", wire_api = \"chat\" }}",
|
||||
+ server.uri()
|
||||
+ );
|
||||
+ let mut cmd = AssertCommand::new("cargo");
|
||||
+ cmd.arg("run")
|
||||
+ .arg("-p")
|
||||
+ .arg("codex-cli")
|
||||
+ .arg("--quiet")
|
||||
+ .arg("--")
|
||||
+ .arg("exec")
|
||||
+ .arg("--skip-git-repo-check")
|
||||
+ .arg("-c")
|
||||
+ .arg(&provider_override)
|
||||
+ .arg("-c")
|
||||
+ .arg("model_provider=\"mock\"")
|
||||
+ .arg("-C")
|
||||
+ .arg(env!("CARGO_MANIFEST_DIR"))
|
||||
+ .arg("hello?");
|
||||
+ cmd.env("CODEX_HOME", home.path())
|
||||
+ .env("OPENAI_API_KEY", "dummy")
|
||||
+ .env("OPENAI_BASE_URL", format!("{}/v1", server.uri()));
|
||||
+
|
||||
+ let output = cmd.output().unwrap();
|
||||
+ println!("Status: {}", output.status);
|
||||
+ println!("Stdout:\n{}", String::from_utf8_lossy(&output.stdout));
|
||||
+ println!("Stderr:\n{}", String::from_utf8_lossy(&output.stderr));
|
||||
+ assert!(output.status.success());
|
||||
+ let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
+ assert!(stdout.contains("hi"));
|
||||
+ assert_eq!(stdout.matches("hi").count(), 1);
|
||||
+
|
||||
+ server.verify().await;
|
||||
+}
|
||||
+
|
||||
+/// Tests streaming responses through the CLI using a local SSE fixture file.
|
||||
+/// This test:
|
||||
+/// 1. Uses a pre-recorded SSE response fixture instead of a live server
|
||||
+/// 2. Configures codex to read from this fixture via CODEX_RS_SSE_FIXTURE env var
|
||||
+/// 3. Sends a "hello?" prompt and verifies the response
|
||||
+/// 4. Ensures the fixture content is correctly streamed through the CLI
|
||||
+#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
+async fn responses_api_stream_cli() {
|
||||
+ if std::env::var(CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR).is_ok() {
|
||||
+ println!(
|
||||
+ "Skipping test because it cannot execute when network is disabled in a Codex sandbox."
|
||||
+ );
|
||||
+ return;
|
||||
+ }
|
||||
+
|
||||
+ let fixture =
|
||||
+ std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/cli_responses_fixture.sse");
|
||||
+
|
||||
+ let home = TempDir::new().unwrap();
|
||||
+ let mut cmd = AssertCommand::new("cargo");
|
||||
+ cmd.arg("run")
|
||||
+ .arg("-p")
|
||||
+ .arg("codex-cli")
|
||||
+ .arg("--quiet")
|
||||
+ .arg("--")
|
||||
+ .arg("exec")
|
||||
+ .arg("--skip-git-repo-check")
|
||||
+ .arg("-C")
|
||||
+ .arg(env!("CARGO_MANIFEST_DIR"))
|
||||
+ .arg("hello?");
|
||||
+ cmd.env("CODEX_HOME", home.path())
|
||||
+ .env("OPENAI_API_KEY", "dummy")
|
||||
+ .env("CODEX_RS_SSE_FIXTURE", fixture)
|
||||
+ .env("OPENAI_BASE_URL", "http://unused.local");
|
||||
+
|
||||
+ let output = cmd.output().unwrap();
|
||||
+ assert!(output.status.success());
|
||||
+ let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
+ assert!(stdout.contains("fixture hello"));
|
||||
+}
|
||||
```
|
||||
|
||||
## Review Comments
|
||||
|
||||
### codex-rs/core/tests/cli_stream.rs
|
||||
|
||||
- Created: 2025-07-12 19:13:30 UTC | Link: https://github.com/openai/codex/pull/1542#discussion_r2202880322
|
||||
|
||||
```diff
|
||||
@@ -0,0 +1,99 @@
|
||||
+#![expect(clippy::unwrap_used)]
|
||||
+
|
||||
+use codex_core::exec::CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR;
|
||||
+use std::process::Command;
|
||||
+use tempfile::TempDir;
|
||||
+use wiremock::Mock;
|
||||
+use wiremock::MockServer;
|
||||
+use wiremock::ResponseTemplate;
|
||||
+use wiremock::matchers::method;
|
||||
+use wiremock::matchers::path;
|
||||
+
|
||||
+#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
+async fn chat_mode_stream_cli() {
|
||||
+ if std::env::var(CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR).is_ok() {
|
||||
+ println!(
|
||||
+ "Skipping test because it cannot execute when network is disabled in a Codex sandbox."
|
||||
+ );
|
||||
+ return;
|
||||
+ }
|
||||
+
|
||||
+ let server = MockServer::start().await;
|
||||
+ let sse = concat!(
|
||||
```
|
||||
|
||||
> I would use `r#` here. Since it isn't a format string, you won't have to escape `{`.
|
||||
|
||||
- Created: 2025-07-12 19:14:21 UTC | Link: https://github.com/openai/codex/pull/1542#discussion_r2202880453
|
||||
|
||||
```diff
|
||||
@@ -0,0 +1,99 @@
|
||||
+#![expect(clippy::unwrap_used)]
|
||||
+
|
||||
+use codex_core::exec::CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR;
|
||||
+use std::process::Command;
|
||||
+use tempfile::TempDir;
|
||||
+use wiremock::Mock;
|
||||
+use wiremock::MockServer;
|
||||
+use wiremock::ResponseTemplate;
|
||||
+use wiremock::matchers::method;
|
||||
+use wiremock::matchers::path;
|
||||
+
|
||||
+#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
+async fn chat_mode_stream_cli() {
|
||||
+ if std::env::var(CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR).is_ok() {
|
||||
+ println!(
|
||||
+ "Skipping test because it cannot execute when network is disabled in a Codex sandbox."
|
||||
+ );
|
||||
+ return;
|
||||
+ }
|
||||
+
|
||||
+ let server = MockServer::start().await;
|
||||
+ let sse = concat!(
|
||||
+ "data: {\"choices\":[{\"delta\":{\"content\":\"hi\"}}]}\n\n",
|
||||
+ "data: {\"choices\":[{\"delta\":{}}]}\n\n",
|
||||
+ "data: [DONE]\n\n",
|
||||
+ );
|
||||
+ Mock::given(method("POST"))
|
||||
+ .and(path("/v1/chat/completions"))
|
||||
+ .respond_with(
|
||||
+ ResponseTemplate::new(200)
|
||||
+ .insert_header("content-type", "text/event-stream")
|
||||
+ .set_body_raw(sse, "text/event-stream"),
|
||||
+ )
|
||||
+ .mount(&server)
|
||||
+ .await;
|
||||
+
|
||||
+ let home = TempDir::new().unwrap();
|
||||
+ let provider_override = format!(
|
||||
+ "model_providers.mock={{ name = \"mock\", base_url = \"{}/v1\", env_key = \"PATH\", wire_api = \"chat\" }}",
|
||||
+ server.uri()
|
||||
+ );
|
||||
+ let mut cmd = Command::new("cargo");
|
||||
```
|
||||
|
||||
> `Command::cargo_bin("codex-rs")` instead?
|
||||
|
||||
- Created: 2025-07-12 19:15:42 UTC | Link: https://github.com/openai/codex/pull/1542#discussion_r2202880670
|
||||
|
||||
```diff
|
||||
@@ -0,0 +1,99 @@
|
||||
+#![expect(clippy::unwrap_used)]
|
||||
+
|
||||
+use codex_core::exec::CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR;
|
||||
+use std::process::Command;
|
||||
+use tempfile::TempDir;
|
||||
+use wiremock::Mock;
|
||||
+use wiremock::MockServer;
|
||||
+use wiremock::ResponseTemplate;
|
||||
+use wiremock::matchers::method;
|
||||
+use wiremock::matchers::path;
|
||||
+
|
||||
+#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
+async fn chat_mode_stream_cli() {
|
||||
+ if std::env::var(CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR).is_ok() {
|
||||
+ println!(
|
||||
+ "Skipping test because it cannot execute when network is disabled in a Codex sandbox."
|
||||
+ );
|
||||
+ return;
|
||||
+ }
|
||||
+
|
||||
+ let server = MockServer::start().await;
|
||||
+ let sse = concat!(
|
||||
+ "data: {\"choices\":[{\"delta\":{\"content\":\"hi\"}}]}\n\n",
|
||||
+ "data: {\"choices\":[{\"delta\":{}}]}\n\n",
|
||||
```
|
||||
|
||||
> Is this empty `{}` the way it signals the end of the stream?
|
||||
|
||||
- Created: 2025-07-12 19:16:39 UTC | Link: https://github.com/openai/codex/pull/1542#discussion_r2202880874
|
||||
|
||||
```diff
|
||||
@@ -0,0 +1,99 @@
|
||||
+#![expect(clippy::unwrap_used)]
|
||||
+
|
||||
+use codex_core::exec::CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR;
|
||||
+use std::process::Command;
|
||||
+use tempfile::TempDir;
|
||||
+use wiremock::Mock;
|
||||
+use wiremock::MockServer;
|
||||
+use wiremock::ResponseTemplate;
|
||||
+use wiremock::matchers::method;
|
||||
+use wiremock::matchers::path;
|
||||
+
|
||||
+#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
+async fn chat_mode_stream_cli() {
|
||||
+ if std::env::var(CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR).is_ok() {
|
||||
+ println!(
|
||||
+ "Skipping test because it cannot execute when network is disabled in a Codex sandbox."
|
||||
+ );
|
||||
+ return;
|
||||
+ }
|
||||
+
|
||||
+ let server = MockServer::start().await;
|
||||
+ let sse = concat!(
|
||||
+ "data: {\"choices\":[{\"delta\":{\"content\":\"hi\"}}]}\n\n",
|
||||
+ "data: {\"choices\":[{\"delta\":{}}]}\n\n",
|
||||
+ "data: [DONE]\n\n",
|
||||
+ );
|
||||
+ Mock::given(method("POST"))
|
||||
+ .and(path("/v1/chat/completions"))
|
||||
+ .respond_with(
|
||||
+ ResponseTemplate::new(200)
|
||||
+ .insert_header("content-type", "text/event-stream")
|
||||
+ .set_body_raw(sse, "text/event-stream"),
|
||||
+ )
|
||||
+ .mount(&server)
|
||||
+ .await;
|
||||
+
|
||||
+ let home = TempDir::new().unwrap();
|
||||
+ let provider_override = format!(
|
||||
+ "model_providers.mock={{ name = \"mock\", base_url = \"{}/v1\", env_key = \"PATH\", wire_api = \"chat\" }}",
|
||||
+ server.uri()
|
||||
+ );
|
||||
+ let mut cmd = Command::new("cargo");
|
||||
+ cmd.arg("run")
|
||||
+ .arg("-p")
|
||||
+ .arg("codex-cli")
|
||||
+ .arg("--quiet")
|
||||
+ .arg("--")
|
||||
+ .arg("exec")
|
||||
+ .arg("--skip-git-repo-check")
|
||||
+ .arg("-c")
|
||||
+ .arg(&provider_override)
|
||||
+ .arg("-c")
|
||||
+ .arg("model_provider=\"mock\"")
|
||||
+ .arg("hello?");
|
||||
+ cmd.current_dir(env!("CARGO_MANIFEST_DIR"))
|
||||
+ .env("CODEX_HOME", home.path())
|
||||
+ .env("OPENAI_API_KEY", "dummy")
|
||||
+ .env("OPENAI_BASE_URL", format!("{}/v1", server.uri()));
|
||||
+
|
||||
+ let output = cmd.output().unwrap();
|
||||
+ assert!(output.status.success());
|
||||
+ let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
+ assert!(stdout.contains("hi"));
|
||||
+ assert_eq!(stdout.matches("hi").count(), 1);
|
||||
+}
|
||||
+
|
||||
+#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
+async fn responses_api_stream_cli() {
|
||||
+ if std::env::var(CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR).is_ok() {
|
||||
+ println!(
|
||||
+ "Skipping test because it cannot execute when network is disabled in a Codex sandbox."
|
||||
+ );
|
||||
+ return;
|
||||
+ }
|
||||
+
|
||||
+ let fixture =
|
||||
+ std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/cli_responses_fixture.sse");
|
||||
+
|
||||
+ let home = TempDir::new().unwrap();
|
||||
+ let mut cmd = Command::new("cargo");
|
||||
```
|
||||
|
||||
> `Command::cargo_bin` here, as well?
|
||||
|
||||
- Created: 2025-07-12 19:17:22 UTC | Link: https://github.com/openai/codex/pull/1542#discussion_r2202881035
|
||||
|
||||
```diff
|
||||
@@ -0,0 +1,99 @@
|
||||
+#![expect(clippy::unwrap_used)]
|
||||
+
|
||||
+use codex_core::exec::CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR;
|
||||
+use std::process::Command;
|
||||
+use tempfile::TempDir;
|
||||
+use wiremock::Mock;
|
||||
+use wiremock::MockServer;
|
||||
+use wiremock::ResponseTemplate;
|
||||
+use wiremock::matchers::method;
|
||||
+use wiremock::matchers::path;
|
||||
+
|
||||
+#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
+async fn chat_mode_stream_cli() {
|
||||
+ if std::env::var(CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR).is_ok() {
|
||||
+ println!(
|
||||
+ "Skipping test because it cannot execute when network is disabled in a Codex sandbox."
|
||||
+ );
|
||||
+ return;
|
||||
+ }
|
||||
+
|
||||
+ let server = MockServer::start().await;
|
||||
+ let sse = concat!(
|
||||
+ "data: {\"choices\":[{\"delta\":{\"content\":\"hi\"}}]}\n\n",
|
||||
+ "data: {\"choices\":[{\"delta\":{}}]}\n\n",
|
||||
+ "data: [DONE]\n\n",
|
||||
+ );
|
||||
+ Mock::given(method("POST"))
|
||||
+ .and(path("/v1/chat/completions"))
|
||||
+ .respond_with(
|
||||
+ ResponseTemplate::new(200)
|
||||
+ .insert_header("content-type", "text/event-stream")
|
||||
+ .set_body_raw(sse, "text/event-stream"),
|
||||
+ )
|
||||
+ .mount(&server)
|
||||
+ .await;
|
||||
+
|
||||
+ let home = TempDir::new().unwrap();
|
||||
+ let provider_override = format!(
|
||||
+ "model_providers.mock={{ name = \"mock\", base_url = \"{}/v1\", env_key = \"PATH\", wire_api = \"chat\" }}",
|
||||
+ server.uri()
|
||||
+ );
|
||||
+ let mut cmd = Command::new("cargo");
|
||||
+ cmd.arg("run")
|
||||
+ .arg("-p")
|
||||
+ .arg("codex-cli")
|
||||
+ .arg("--quiet")
|
||||
+ .arg("--")
|
||||
+ .arg("exec")
|
||||
+ .arg("--skip-git-repo-check")
|
||||
+ .arg("-c")
|
||||
+ .arg(&provider_override)
|
||||
+ .arg("-c")
|
||||
+ .arg("model_provider=\"mock\"")
|
||||
+ .arg("hello?");
|
||||
+ cmd.current_dir(env!("CARGO_MANIFEST_DIR"))
|
||||
+ .env("CODEX_HOME", home.path())
|
||||
+ .env("OPENAI_API_KEY", "dummy")
|
||||
+ .env("OPENAI_BASE_URL", format!("{}/v1", server.uri()));
|
||||
+
|
||||
+ let output = cmd.output().unwrap();
|
||||
+ assert!(output.status.success());
|
||||
+ let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
+ assert!(stdout.contains("hi"));
|
||||
+ assert_eq!(stdout.matches("hi").count(), 1);
|
||||
+}
|
||||
+
|
||||
+#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
+async fn responses_api_stream_cli() {
|
||||
+ if std::env::var(CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR).is_ok() {
|
||||
+ println!(
|
||||
+ "Skipping test because it cannot execute when network is disabled in a Codex sandbox."
|
||||
+ );
|
||||
+ return;
|
||||
+ }
|
||||
+
|
||||
+ let fixture =
|
||||
+ std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/cli_responses_fixture.sse");
|
||||
+
|
||||
+ let home = TempDir::new().unwrap();
|
||||
+ let mut cmd = Command::new("cargo");
|
||||
+ cmd.arg("run")
|
||||
+ .arg("-p")
|
||||
+ .arg("codex-cli")
|
||||
+ .arg("--quiet")
|
||||
+ .arg("--")
|
||||
+ .arg("exec")
|
||||
+ .arg("--skip-git-repo-check")
|
||||
+ .arg("hello?");
|
||||
+ cmd.current_dir(env!("CARGO_MANIFEST_DIR"))
|
||||
```
|
||||
|
||||
> note that if you need `codex` to use a specific `cwd`, it has a `--cd`/`-C` option.
|
||||
656
prs/bolinfest/PR-1545.md
Normal file
656
prs/bolinfest/PR-1545.md
Normal file
@@ -0,0 +1,656 @@
|
||||
# PR #1545: Add CLI integration tests
|
||||
|
||||
- URL: https://github.com/openai/codex/pull/1545
|
||||
- Author: aibrahim-oai
|
||||
- Created: 2025-07-11 21:03:16 UTC
|
||||
- Updated: 2025-07-17 16:38:35 UTC
|
||||
- Changes: +236/-0, Files changed: 3, Commits: 12
|
||||
|
||||
## Description
|
||||
|
||||
## Summary
|
||||
- add new integration tests for the Rust CLI
|
||||
- test a basic single-turn response
|
||||
- validate shell tool invocation flow
|
||||
- update Cargo.lock for test dependencies
|
||||
|
||||
## Testing
|
||||
- `cargo fmt --all`
|
||||
- `cargo clippy -p codex-cli --tests --all-features -- -D warnings`
|
||||
- `cargo test -p codex-cli --test integration -- --nocapture`
|
||||
|
||||
|
||||
------
|
||||
https://chatgpt.com/codex/tasks/task_i_68717125ff6083219bf892e0bdf14427
|
||||
|
||||
## Full Diff
|
||||
|
||||
```diff
|
||||
diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock
|
||||
index e59dbfa255..2b16b82e48 100644
|
||||
--- a/codex-rs/Cargo.lock
|
||||
+++ b/codex-rs/Cargo.lock
|
||||
@@ -617,6 +617,7 @@ name = "codex-cli"
|
||||
version = "0.0.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
+ "assert_cmd",
|
||||
"clap",
|
||||
"clap_complete",
|
||||
"codex-chatgpt",
|
||||
@@ -627,10 +628,14 @@ dependencies = [
|
||||
"codex-login",
|
||||
"codex-mcp-server",
|
||||
"codex-tui",
|
||||
+ "indoc",
|
||||
+ "predicates",
|
||||
"serde_json",
|
||||
+ "tempfile",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
+ "wiremock",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
diff --git a/codex-rs/cli/Cargo.toml b/codex-rs/cli/Cargo.toml
|
||||
index 943788157b..9932e89caa 100644
|
||||
--- a/codex-rs/cli/Cargo.toml
|
||||
+++ b/codex-rs/cli/Cargo.toml
|
||||
@@ -36,3 +36,11 @@ tokio = { version = "1", features = [
|
||||
] }
|
||||
tracing = "0.1.41"
|
||||
tracing-subscriber = "0.3.19"
|
||||
+
|
||||
+[dev-dependencies]
|
||||
+assert_cmd = "2"
|
||||
+predicates = "3"
|
||||
+tempfile = "3"
|
||||
+wiremock = "0.6"
|
||||
+tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
|
||||
+indoc = "2"
|
||||
diff --git a/codex-rs/cli/tests/integration.rs b/codex-rs/cli/tests/integration.rs
|
||||
new file mode 100644
|
||||
index 0000000000..6054dbe3d3
|
||||
--- /dev/null
|
||||
+++ b/codex-rs/cli/tests/integration.rs
|
||||
@@ -0,0 +1,223 @@
|
||||
+#![allow(clippy::unwrap_used)]
|
||||
+
|
||||
+//! End-to-end integration tests for the `codex` CLI.
|
||||
+//!
|
||||
+//! These spin up a local [`wiremock`][] server to stand in for the MCP server
|
||||
+//! and then run the real compiled `codex` binary against it. The goal is to
|
||||
+//! verify the high-level request/response flow rather than the details of the
|
||||
+//! individual async functions.
|
||||
+//!
|
||||
+//! [`wiremock`]: https://docs.rs/wiremock
|
||||
+
|
||||
+use codex_core::exec::CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR;
|
||||
+use predicates::prelude::*;
|
||||
+use std::fs;
|
||||
+use std::path::Path;
|
||||
+use tempfile::TempDir;
|
||||
+use wiremock::Mock;
|
||||
+use wiremock::MockServer;
|
||||
+use wiremock::ResponseTemplate;
|
||||
+use wiremock::matchers::method;
|
||||
+use wiremock::matchers::path;
|
||||
+
|
||||
+// ----- tests -----
|
||||
+
|
||||
+/// Sends a single simple prompt and verifies that the streamed response is
|
||||
+/// surfaced to the user. This exercises the most common "ask a question, get a
|
||||
+/// textual answer" flow.
|
||||
+#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
+async fn full_conversation_turn_integration() {
|
||||
+ if std::env::var(CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR).is_ok() {
|
||||
+ println!("Skipping test because network is disabled");
|
||||
+ return;
|
||||
+ }
|
||||
+
|
||||
+ let server = MockServer::start().await;
|
||||
+ Mock::given(method("POST"))
|
||||
+ .and(path("/v1/responses"))
|
||||
+ .respond_with(
|
||||
+ ResponseTemplate::new(200)
|
||||
+ .insert_header("content-type", "text/event-stream")
|
||||
+ .set_body_raw(sse_message("Hello, world."), "text/event-stream"),
|
||||
+ )
|
||||
+ .expect(1)
|
||||
+ .mount(&server)
|
||||
+ .await;
|
||||
+
|
||||
+ // Disable retries — the mock server will fail hard if we make an unexpected
|
||||
+ // request, so retries only slow the test down.
|
||||
+ unsafe {
|
||||
+ std::env::set_var("OPENAI_REQUEST_MAX_RETRIES", "0");
|
||||
+ std::env::set_var("OPENAI_STREAM_MAX_RETRIES", "0");
|
||||
+ }
|
||||
+
|
||||
+ let codex_home = TempDir::new().unwrap();
|
||||
+ let sandbox = TempDir::new().unwrap();
|
||||
+ write_config(codex_home.path(), &server);
|
||||
+
|
||||
+ // Capture the agent's final message in a file so we can assert on it precisely.
|
||||
+ let last_message_file = sandbox.path().join("last_message.txt");
|
||||
+
|
||||
+ let mut cmd = assert_cmd::Command::cargo_bin("codex").unwrap();
|
||||
+ cmd.env("CODEX_HOME", codex_home.path())
|
||||
+ .current_dir(sandbox.path())
|
||||
+ .arg("exec")
|
||||
+ .arg("--skip-git-repo-check")
|
||||
+ .arg("--output-last-message")
|
||||
+ .arg(&last_message_file)
|
||||
+ .arg("Hello");
|
||||
+
|
||||
+ cmd.assert()
|
||||
+ .success()
|
||||
+ .stdout(predicate::str::contains("Hello, world."));
|
||||
+
|
||||
+ // Assert on the captured last message file (more robust than stdout formatting).
|
||||
+ let last = fs::read_to_string(&last_message_file).unwrap();
|
||||
+ let expected = "Hello, world.";
|
||||
+ assert_eq!(last.trim(), expected);
|
||||
+}
|
||||
+
|
||||
+/// Simulates a tool invocation (`shell`) followed by a second assistant message
|
||||
+/// once the tool call completes.
|
||||
+#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
+async fn tool_invocation_flow() {
|
||||
+ if std::env::var(CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR).is_ok() {
|
||||
+ println!("Skipping test because network is disabled");
|
||||
+ return;
|
||||
+ }
|
||||
+
|
||||
+ let server = MockServer::start().await;
|
||||
+
|
||||
+ // The first request returns a function-call item; the second returns the
|
||||
+ // final assistant message. Use an atomic counter to serve them in order.
|
||||
+ struct SeqResponder {
|
||||
+ count: std::sync::atomic::AtomicUsize,
|
||||
+ }
|
||||
+ impl wiremock::Respond for SeqResponder {
|
||||
+ fn respond(&self, _: &wiremock::Request) -> ResponseTemplate {
|
||||
+ use std::sync::atomic::Ordering;
|
||||
+ match self.count.fetch_add(1, Ordering::SeqCst) {
|
||||
+ 0 => ResponseTemplate::new(200)
|
||||
+ .insert_header("content-type", "text/event-stream")
|
||||
+ .set_body_raw(sse_function_call(), "text/event-stream"),
|
||||
+ _ => ResponseTemplate::new(200)
|
||||
+ .insert_header("content-type", "text/event-stream")
|
||||
+ .set_body_raw(sse_final_after_call(), "text/event-stream"),
|
||||
+ }
|
||||
+ }
|
||||
+ }
|
||||
+
|
||||
+ Mock::given(method("POST"))
|
||||
+ .and(path("/v1/responses"))
|
||||
+ .respond_with(SeqResponder {
|
||||
+ count: std::sync::atomic::AtomicUsize::new(0),
|
||||
+ })
|
||||
+ .expect(2)
|
||||
+ .mount(&server)
|
||||
+ .await;
|
||||
+
|
||||
+ unsafe {
|
||||
+ std::env::set_var("OPENAI_REQUEST_MAX_RETRIES", "0");
|
||||
+ std::env::set_var("OPENAI_STREAM_MAX_RETRIES", "0");
|
||||
+ }
|
||||
+
|
||||
+ let codex_home = TempDir::new().unwrap();
|
||||
+ let sandbox = TempDir::new().unwrap();
|
||||
+ write_config(codex_home.path(), &server);
|
||||
+
|
||||
+ // Capture final assistant message after tool invocation.
|
||||
+ let last_message_file = sandbox.path().join("last_message.txt");
|
||||
+
|
||||
+ let mut cmd = assert_cmd::Command::cargo_bin("codex").unwrap();
|
||||
+ cmd.env("CODEX_HOME", codex_home.path())
|
||||
+ .current_dir(sandbox.path())
|
||||
+ .arg("exec")
|
||||
+ .arg("--skip-git-repo-check")
|
||||
+ .arg("--output-last-message")
|
||||
+ .arg(&last_message_file)
|
||||
+ .arg("Run shell");
|
||||
+
|
||||
+ cmd.assert()
|
||||
+ .success()
|
||||
+ .stdout(predicate::str::contains("exec echo hi"))
|
||||
+ .stdout(predicate::str::contains("hi"));
|
||||
+
|
||||
+ // Assert that the final assistant message (second response) was 'done'.
|
||||
+ let last = fs::read_to_string(&last_message_file).unwrap();
|
||||
+ let expected = "done";
|
||||
+ assert_eq!(last.trim(), expected);
|
||||
+}
|
||||
+
|
||||
+/// Write a minimal `config.toml` pointing the CLI at the mock server.
|
||||
+fn write_config(codex_home: &Path, server: &MockServer) {
|
||||
+ fs::write(
|
||||
+ codex_home.join("config.toml"),
|
||||
+ format!(
|
||||
+ r#"
|
||||
+model_provider = "mock"
|
||||
+model = "test-model"
|
||||
+
|
||||
+[model_providers.mock]
|
||||
+name = "mock"
|
||||
+base_url = "{}/v1"
|
||||
+env_key = "PATH"
|
||||
+wire_api = "responses"
|
||||
+"#,
|
||||
+ server.uri()
|
||||
+ ),
|
||||
+ )
|
||||
+ .unwrap();
|
||||
+}
|
||||
+
|
||||
+/// Small helper to generate an SSE stream with a single assistant message.
|
||||
+fn sse_message(text: &str) -> String {
|
||||
+ const TEMPLATE: &str = r#"event: response.output_item.done
|
||||
+data: {"type":"response.output_item.done","item":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"TEXT_PLACEHOLDER"}]}}
|
||||
+
|
||||
+event: response.completed
|
||||
+data: {"type":"response.completed","response":{"id":"resp1","output":[]}}
|
||||
+
|
||||
+
|
||||
+"#;
|
||||
+
|
||||
+ TEMPLATE.replace("TEXT_PLACEHOLDER", text)
|
||||
+}
|
||||
+
|
||||
+/// Helper to craft an SSE stream that returns a `function_call`.
|
||||
+fn sse_function_call() -> String {
|
||||
+ let call = serde_json::json!({
|
||||
+ "type": "response.output_item.done",
|
||||
+ "item": {
|
||||
+ "type": "function_call",
|
||||
+ "name": "shell",
|
||||
+ "arguments": "{\"command\":[\"echo\",\"hi\"]}",
|
||||
+ "call_id": "call1"
|
||||
+ }
|
||||
+ });
|
||||
+ let completed = serde_json::json!({
|
||||
+ "type": "response.completed",
|
||||
+ "response": {"id": "resp1", "output": []}
|
||||
+ });
|
||||
+
|
||||
+ format!(
|
||||
+ "event: response.output_item.done\ndata: {call}\n\n\
|
||||
+event: response.completed\ndata: {completed}\n\n\n"
|
||||
+ )
|
||||
+}
|
||||
+
|
||||
+/// SSE stream for the assistant's final message after the tool call returns.
|
||||
+fn sse_final_after_call() -> String {
|
||||
+ let msg = serde_json::json!({
|
||||
+ "type": "response.output_item.done",
|
||||
+ "item": {"type": "message", "role": "assistant", "content": [{"type": "output_text", "text": "done"}]}
|
||||
+ });
|
||||
+ let completed = serde_json::json!({
|
||||
+ "type": "response.completed",
|
||||
+ "response": {"id": "resp2", "output": []}
|
||||
+ });
|
||||
+
|
||||
+ format!(
|
||||
+ "event: response.output_item.done\ndata: {msg}\n\n\
|
||||
+event: response.completed\ndata: {completed}\n\n\n"
|
||||
+ )
|
||||
+}
|
||||
```
|
||||
|
||||
## Review Comments
|
||||
|
||||
### codex-rs/cli/tests/integration.rs
|
||||
|
||||
- Created: 2025-07-12 17:41:32 UTC | Link: https://github.com/openai/codex/pull/1545#discussion_r2202841506
|
||||
|
||||
```diff
|
||||
@@ -0,0 +1,171 @@
|
||||
+#![allow(clippy::unwrap_used)]
|
||||
+
|
||||
+use codex_core::exec::CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR;
|
||||
+use predicates::prelude::*;
|
||||
+use std::fs;
|
||||
+use std::path::Path;
|
||||
+use tempfile::TempDir;
|
||||
+use wiremock::Mock;
|
||||
+use wiremock::MockServer;
|
||||
+use wiremock::ResponseTemplate;
|
||||
+use wiremock::matchers::method;
|
||||
+use wiremock::matchers::path;
|
||||
+
|
||||
+fn write_config(dir: &Path, server: &MockServer) {
|
||||
```
|
||||
|
||||
> Similar to a comment I made on another PR, please list all of these helper functions below the tests. The tests are the most important thing in this file.
|
||||
|
||||
- Created: 2025-07-12 17:43:25 UTC | Link: https://github.com/openai/codex/pull/1545#discussion_r2202842748
|
||||
|
||||
```diff
|
||||
@@ -0,0 +1,171 @@
|
||||
+#![allow(clippy::unwrap_used)]
|
||||
+
|
||||
+use codex_core::exec::CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR;
|
||||
+use predicates::prelude::*;
|
||||
+use std::fs;
|
||||
+use std::path::Path;
|
||||
+use tempfile::TempDir;
|
||||
+use wiremock::Mock;
|
||||
+use wiremock::MockServer;
|
||||
+use wiremock::ResponseTemplate;
|
||||
+use wiremock::matchers::method;
|
||||
+use wiremock::matchers::path;
|
||||
+
|
||||
+fn write_config(dir: &Path, server: &MockServer) {
|
||||
+ fs::write(
|
||||
+ dir.join("config.toml"),
|
||||
+ format!(
|
||||
+ r#"model_provider = "mock"
|
||||
+model = "test-model"
|
||||
+[model_providers.mock]
|
||||
+name = "mock"
|
||||
+base_url = "{}/v1"
|
||||
+env_key = "PATH"
|
||||
+wire_api = "responses"
|
||||
+"#,
|
||||
```
|
||||
|
||||
> Since the leading newline at the start of the content doesn't hurt anything, I would do this for readability:
|
||||
>
|
||||
> ```suggestion
|
||||
> r#"
|
||||
> model_provider = "mock"
|
||||
> model = "test-model"
|
||||
> [model_providers.mock]
|
||||
> name = "mock"
|
||||
> base_url = "{}/v1"
|
||||
> env_key = "PATH"
|
||||
> wire_api = "responses"
|
||||
> "#,
|
||||
> ```
|
||||
>
|
||||
> You can also consider https://crates.io/crates/indoc if you feel strongly.
|
||||
|
||||
- Created: 2025-07-12 17:55:01 UTC | Link: https://github.com/openai/codex/pull/1545#discussion_r2202846863
|
||||
|
||||
```diff
|
||||
@@ -0,0 +1,171 @@
|
||||
+#![allow(clippy::unwrap_used)]
|
||||
+
|
||||
+use codex_core::exec::CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR;
|
||||
+use predicates::prelude::*;
|
||||
+use std::fs;
|
||||
+use std::path::Path;
|
||||
+use tempfile::TempDir;
|
||||
+use wiremock::Mock;
|
||||
+use wiremock::MockServer;
|
||||
+use wiremock::ResponseTemplate;
|
||||
+use wiremock::matchers::method;
|
||||
+use wiremock::matchers::path;
|
||||
+
|
||||
+fn write_config(dir: &Path, server: &MockServer) {
|
||||
+ fs::write(
|
||||
+ dir.join("config.toml"),
|
||||
+ format!(
|
||||
+ r#"model_provider = "mock"
|
||||
+model = "test-model"
|
||||
+[model_providers.mock]
|
||||
+name = "mock"
|
||||
+base_url = "{}/v1"
|
||||
+env_key = "PATH"
|
||||
+wire_api = "responses"
|
||||
+"#,
|
||||
+ server.uri()
|
||||
+ ),
|
||||
+ )
|
||||
+ .unwrap();
|
||||
+}
|
||||
+
|
||||
+fn sse_message(text: &str) -> String {
|
||||
+ format!(
|
||||
+ "event: response.output_item.done\n\
|
||||
+data: {{\"type\":\"response.output_item.done\",\"item\":{{\"type\":\"message\",\"role\":\"assistant\",\"content\":[{{\"type\":\"output_text\",\"text\":\"{text}\"}}]}}}}\n\n\
|
||||
+event: response.completed\n\
|
||||
+data: {{\"type\":\"response.completed\",\"response\":{{\"id\":\"resp1\",\"output\":[]}}}}\n\n\n"
|
||||
+ )
|
||||
```
|
||||
|
||||
> In this case, the escaping of `{` makes this so hard to read that I would consider using `replace()`:
|
||||
>
|
||||
> ```suggestion
|
||||
> let template = r#"event: response.output_item.done
|
||||
> data: {"type":"response.output_item.done","item":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"TEXT_PLACEHOLDER"}]}}
|
||||
>
|
||||
> event: response.completed
|
||||
> data: {"type":"response.completed","response":{"id":"resp1","output":[]}}
|
||||
>
|
||||
>
|
||||
> "#;
|
||||
> template.replace("TEXT_PLACEHOLDER", text);
|
||||
> ```
|
||||
|
||||
- Created: 2025-07-12 17:59:03 UTC | Link: https://github.com/openai/codex/pull/1545#discussion_r2202848480
|
||||
|
||||
```diff
|
||||
@@ -0,0 +1,171 @@
|
||||
+#![allow(clippy::unwrap_used)]
|
||||
+
|
||||
+use codex_core::exec::CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR;
|
||||
+use predicates::prelude::*;
|
||||
+use std::fs;
|
||||
+use std::path::Path;
|
||||
+use tempfile::TempDir;
|
||||
+use wiremock::Mock;
|
||||
+use wiremock::MockServer;
|
||||
+use wiremock::ResponseTemplate;
|
||||
+use wiremock::matchers::method;
|
||||
+use wiremock::matchers::path;
|
||||
+
|
||||
+fn write_config(dir: &Path, server: &MockServer) {
|
||||
```
|
||||
|
||||
> Also, I would name the variable `codex_home` rather than `dir`.
|
||||
|
||||
- Created: 2025-07-12 18:04:37 UTC | Link: https://github.com/openai/codex/pull/1545#discussion_r2202854166
|
||||
|
||||
```diff
|
||||
@@ -0,0 +1,171 @@
|
||||
+#![allow(clippy::unwrap_used)]
|
||||
+
|
||||
+use codex_core::exec::CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR;
|
||||
+use predicates::prelude::*;
|
||||
+use std::fs;
|
||||
+use std::path::Path;
|
||||
+use tempfile::TempDir;
|
||||
+use wiremock::Mock;
|
||||
+use wiremock::MockServer;
|
||||
+use wiremock::ResponseTemplate;
|
||||
+use wiremock::matchers::method;
|
||||
+use wiremock::matchers::path;
|
||||
+
|
||||
+fn write_config(dir: &Path, server: &MockServer) {
|
||||
+ fs::write(
|
||||
+ dir.join("config.toml"),
|
||||
+ format!(
|
||||
+ r#"model_provider = "mock"
|
||||
+model = "test-model"
|
||||
+[model_providers.mock]
|
||||
+name = "mock"
|
||||
+base_url = "{}/v1"
|
||||
+env_key = "PATH"
|
||||
+wire_api = "responses"
|
||||
+"#,
|
||||
+ server.uri()
|
||||
+ ),
|
||||
+ )
|
||||
+ .unwrap();
|
||||
+}
|
||||
+
|
||||
+fn sse_message(text: &str) -> String {
|
||||
+ format!(
|
||||
+ "event: response.output_item.done\n\
|
||||
+data: {{\"type\":\"response.output_item.done\",\"item\":{{\"type\":\"message\",\"role\":\"assistant\",\"content\":[{{\"type\":\"output_text\",\"text\":\"{text}\"}}]}}}}\n\n\
|
||||
+event: response.completed\n\
|
||||
+data: {{\"type\":\"response.completed\",\"response\":{{\"id\":\"resp1\",\"output\":[]}}}}\n\n\n"
|
||||
+ )
|
||||
+}
|
||||
+
|
||||
+fn sse_function_call() -> String {
|
||||
+ let call = serde_json::json!({
|
||||
+ "type": "response.output_item.done",
|
||||
+ "item": {
|
||||
+ "type": "function_call",
|
||||
+ "name": "shell",
|
||||
+ "arguments": "{\"command\":[\"echo\",\"hi\"]}",
|
||||
+ "call_id": "call1"
|
||||
+ }
|
||||
+ });
|
||||
+ let completed = serde_json::json!({
|
||||
+ "type": "response.completed",
|
||||
+ "response": {"id": "resp1", "output": []}
|
||||
+ });
|
||||
+ format!(
|
||||
+ "event: response.output_item.done\ndata: {call}\n\n\
|
||||
+event: response.completed\ndata: {completed}\n\n\n"
|
||||
+ )
|
||||
+}
|
||||
+
|
||||
+fn sse_final_after_call() -> String {
|
||||
+ let msg = serde_json::json!({
|
||||
+ "type": "response.output_item.done",
|
||||
+ "item": {"type": "message", "role": "assistant", "content": [{"type": "output_text", "text": "done"}]}
|
||||
+ });
|
||||
+ let completed = serde_json::json!({
|
||||
+ "type": "response.completed",
|
||||
+ "response": {"id": "resp2", "output": []}
|
||||
+ });
|
||||
+ format!(
|
||||
+ "event: response.output_item.done\ndata: {msg}\n\n\
|
||||
+event: response.completed\ndata: {completed}\n\n\n"
|
||||
+ )
|
||||
+}
|
||||
+
|
||||
+#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
+async fn full_conversation_turn_integration() {
|
||||
+ if std::env::var(CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR).is_ok() {
|
||||
+ println!("Skipping test because network is disabled");
|
||||
+ return;
|
||||
+ }
|
||||
+
|
||||
+ let server = MockServer::start().await;
|
||||
+ let resp = ResponseTemplate::new(200)
|
||||
+ .insert_header("content-type", "text/event-stream")
|
||||
+ .set_body_raw(sse_message("Hello, world."), "text/event-stream");
|
||||
+ Mock::given(method("POST"))
|
||||
+ .and(path("/v1/responses"))
|
||||
+ .respond_with(resp)
|
||||
+ .expect(1)
|
||||
+ .mount(&server)
|
||||
+ .await;
|
||||
+
|
||||
+ unsafe {
|
||||
+ std::env::set_var("OPENAI_REQUEST_MAX_RETRIES", "0");
|
||||
+ std::env::set_var("OPENAI_STREAM_MAX_RETRIES", "0");
|
||||
+ }
|
||||
+
|
||||
+ let home = TempDir::new().unwrap();
|
||||
+ let sandbox = TempDir::new().unwrap();
|
||||
+ write_config(home.path(), &server);
|
||||
+
|
||||
+ let mut cmd = assert_cmd::Command::cargo_bin("codex").unwrap();
|
||||
+ cmd.env("CODEX_HOME", home.path());
|
||||
+ cmd.current_dir(sandbox.path());
|
||||
+ cmd.arg("exec").arg("--skip-git-repo-check").arg("Hello");
|
||||
```
|
||||
|
||||
> I would also run this with `--output-last-message FILE` where `FILE` is some file in `sandbox`. Then you can do a more precise assertion on the final message since you can do it against `FILE` instead of stdout. (Same for the other test.)
|
||||
|
||||
- Created: 2025-07-12 18:06:39 UTC | Link: https://github.com/openai/codex/pull/1545#discussion_r2202854547
|
||||
|
||||
```diff
|
||||
@@ -0,0 +1,171 @@
|
||||
+#![allow(clippy::unwrap_used)]
|
||||
+
|
||||
+use codex_core::exec::CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR;
|
||||
+use predicates::prelude::*;
|
||||
+use std::fs;
|
||||
+use std::path::Path;
|
||||
+use tempfile::TempDir;
|
||||
+use wiremock::Mock;
|
||||
+use wiremock::MockServer;
|
||||
+use wiremock::ResponseTemplate;
|
||||
+use wiremock::matchers::method;
|
||||
+use wiremock::matchers::path;
|
||||
+
|
||||
+fn write_config(dir: &Path, server: &MockServer) {
|
||||
+ fs::write(
|
||||
+ dir.join("config.toml"),
|
||||
+ format!(
|
||||
+ r#"model_provider = "mock"
|
||||
+model = "test-model"
|
||||
+[model_providers.mock]
|
||||
+name = "mock"
|
||||
+base_url = "{}/v1"
|
||||
+env_key = "PATH"
|
||||
+wire_api = "responses"
|
||||
+"#,
|
||||
+ server.uri()
|
||||
+ ),
|
||||
+ )
|
||||
+ .unwrap();
|
||||
+}
|
||||
+
|
||||
+fn sse_message(text: &str) -> String {
|
||||
+ format!(
|
||||
+ "event: response.output_item.done\n\
|
||||
+data: {{\"type\":\"response.output_item.done\",\"item\":{{\"type\":\"message\",\"role\":\"assistant\",\"content\":[{{\"type\":\"output_text\",\"text\":\"{text}\"}}]}}}}\n\n\
|
||||
+event: response.completed\n\
|
||||
+data: {{\"type\":\"response.completed\",\"response\":{{\"id\":\"resp1\",\"output\":[]}}}}\n\n\n"
|
||||
+ )
|
||||
+}
|
||||
+
|
||||
+fn sse_function_call() -> String {
|
||||
+ let call = serde_json::json!({
|
||||
+ "type": "response.output_item.done",
|
||||
+ "item": {
|
||||
+ "type": "function_call",
|
||||
+ "name": "shell",
|
||||
+ "arguments": "{\"command\":[\"echo\",\"hi\"]}",
|
||||
+ "call_id": "call1"
|
||||
+ }
|
||||
+ });
|
||||
+ let completed = serde_json::json!({
|
||||
+ "type": "response.completed",
|
||||
+ "response": {"id": "resp1", "output": []}
|
||||
+ });
|
||||
+ format!(
|
||||
+ "event: response.output_item.done\ndata: {call}\n\n\
|
||||
+event: response.completed\ndata: {completed}\n\n\n"
|
||||
+ )
|
||||
+}
|
||||
+
|
||||
+fn sse_final_after_call() -> String {
|
||||
+ let msg = serde_json::json!({
|
||||
+ "type": "response.output_item.done",
|
||||
+ "item": {"type": "message", "role": "assistant", "content": [{"type": "output_text", "text": "done"}]}
|
||||
+ });
|
||||
+ let completed = serde_json::json!({
|
||||
+ "type": "response.completed",
|
||||
+ "response": {"id": "resp2", "output": []}
|
||||
+ });
|
||||
+ format!(
|
||||
+ "event: response.output_item.done\ndata: {msg}\n\n\
|
||||
+event: response.completed\ndata: {completed}\n\n\n"
|
||||
+ )
|
||||
+}
|
||||
+
|
||||
+#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
```
|
||||
|
||||
> Could you add docstrings for this test and the other test? Admittedly, there is a lot of code required just to setup these tests, so it's not 100% obvious what is being tested. That is, this line seems to be the key bit that is producing the behavior that we are verifying at the end of the test:
|
||||
>
|
||||
> ```rust
|
||||
> .set_body_raw(sse_message("Hello, world."), "text/event-stream")
|
||||
> ```
|
||||
635
prs/bolinfest/PR-1546.md
Normal file
635
prs/bolinfest/PR-1546.md
Normal file
@@ -0,0 +1,635 @@
|
||||
# PR #1546: Improve SSE tests
|
||||
|
||||
- URL: https://github.com/openai/codex/pull/1546
|
||||
- Author: aibrahim-oai
|
||||
- Created: 2025-07-11 21:19:52 UTC
|
||||
- Updated: 2025-07-12 23:54:13 UTC
|
||||
- Changes: +192/-11, Files changed: 6, Commits: 4
|
||||
|
||||
## Description
|
||||
|
||||
## Summary
|
||||
- support fixture-based SSE data in tests
|
||||
- add helpers to load SSE JSON fixtures
|
||||
- add table-driven SSE unit tests
|
||||
- let integration tests use fixture loading
|
||||
- fix clippy errors from format! calls
|
||||
|
||||
## Testing
|
||||
- `cargo clippy --tests`
|
||||
- `cargo test --workspace --exclude codex-linux-sandbox`
|
||||
|
||||
|
||||
------
|
||||
https://chatgpt.com/codex/tasks/task_i_68717468c3e48321b51c9ecac6ba0f09
|
||||
|
||||
## Full Diff
|
||||
|
||||
```diff
|
||||
diff --git a/codex-rs/core/src/client.rs b/codex-rs/core/src/client.rs
|
||||
index bd2eeb9457..1b8e4c959d 100644
|
||||
--- a/codex-rs/core/src/client.rs
|
||||
+++ b/codex-rs/core/src/client.rs
|
||||
@@ -391,3 +391,116 @@ async fn stream_from_fixture(path: impl AsRef<Path>) -> Result<ResponseStream> {
|
||||
tokio::spawn(process_sse(stream, tx_event));
|
||||
Ok(ResponseStream { rx_event })
|
||||
}
|
||||
+
|
||||
+#[cfg(test)]
|
||||
+mod tests {
|
||||
+ #![allow(clippy::expect_used, clippy::unwrap_used)]
|
||||
+ use super::*;
|
||||
+ use serde_json::json;
|
||||
+
|
||||
+ async fn run_sse(events: Vec<serde_json::Value>) -> Vec<ResponseEvent> {
|
||||
+ let mut body = String::new();
|
||||
+ for e in events {
|
||||
+ let kind = e
|
||||
+ .get("type")
|
||||
+ .and_then(|v| v.as_str())
|
||||
+ .expect("fixture event missing type");
|
||||
+ if e.as_object().map(|o| o.len() == 1).unwrap_or(false) {
|
||||
+ body.push_str(&format!("event: {kind}\n\n"));
|
||||
+ } else {
|
||||
+ body.push_str(&format!("event: {kind}\ndata: {e}\n\n"));
|
||||
+ }
|
||||
+ }
|
||||
+ let (tx, mut rx) = mpsc::channel::<Result<ResponseEvent>>(8);
|
||||
+ let stream = ReaderStream::new(std::io::Cursor::new(body)).map_err(CodexErr::Io);
|
||||
+ tokio::spawn(process_sse(stream, tx));
|
||||
+ let mut out = Vec::new();
|
||||
+ while let Some(ev) = rx.recv().await {
|
||||
+ out.push(ev.expect("channel closed"));
|
||||
+ }
|
||||
+ out
|
||||
+ }
|
||||
+
|
||||
+ /// Verifies that the SSE adapter emits the expected [`ResponseEvent`] for
|
||||
+ /// a variety of `type` values from the Responses API. The test is written
|
||||
+ /// table-driven style to keep additions for new event kinds trivial.
|
||||
+ ///
|
||||
+ /// Each `Case` supplies an input event, a predicate that must match the
|
||||
+ /// *first* `ResponseEvent` produced by the adapter, and the total number
|
||||
+ /// of events expected after appending a synthetic `response.completed`
|
||||
+ /// marker that terminates the stream.
|
||||
+ #[tokio::test]
|
||||
+ async fn table_driven_event_kinds() {
|
||||
+ struct TestCase {
|
||||
+ name: &'static str,
|
||||
+ event: serde_json::Value,
|
||||
+ expect_first: fn(&ResponseEvent) -> bool,
|
||||
+ expected_len: usize,
|
||||
+ }
|
||||
+
|
||||
+ fn is_created(ev: &ResponseEvent) -> bool {
|
||||
+ matches!(ev, ResponseEvent::Created)
|
||||
+ }
|
||||
+
|
||||
+ fn is_output(ev: &ResponseEvent) -> bool {
|
||||
+ matches!(ev, ResponseEvent::OutputItemDone(_))
|
||||
+ }
|
||||
+
|
||||
+ fn is_completed(ev: &ResponseEvent) -> bool {
|
||||
+ matches!(ev, ResponseEvent::Completed { .. })
|
||||
+ }
|
||||
+
|
||||
+ let completed = json!({
|
||||
+ "type": "response.completed",
|
||||
+ "response": {
|
||||
+ "id": "c",
|
||||
+ "usage": {
|
||||
+ "input_tokens": 0,
|
||||
+ "input_tokens_details": null,
|
||||
+ "output_tokens": 0,
|
||||
+ "output_tokens_details": null,
|
||||
+ "total_tokens": 0
|
||||
+ },
|
||||
+ "output": []
|
||||
+ }
|
||||
+ });
|
||||
+
|
||||
+ let cases = vec![
|
||||
+ TestCase {
|
||||
+ name: "created",
|
||||
+ event: json!({"type": "response.created", "response": {}}),
|
||||
+ expect_first: is_created,
|
||||
+ expected_len: 2,
|
||||
+ },
|
||||
+ TestCase {
|
||||
+ name: "output_item.done",
|
||||
+ event: json!({
|
||||
+ "type": "response.output_item.done",
|
||||
+ "item": {
|
||||
+ "type": "message",
|
||||
+ "role": "assistant",
|
||||
+ "content": [
|
||||
+ {"type": "output_text", "text": "hi"}
|
||||
+ ]
|
||||
+ }
|
||||
+ }),
|
||||
+ expect_first: is_output,
|
||||
+ expected_len: 2,
|
||||
+ },
|
||||
+ TestCase {
|
||||
+ name: "unknown",
|
||||
+ event: json!({"type": "response.new_tool_event"}),
|
||||
+ expect_first: is_completed,
|
||||
+ expected_len: 1,
|
||||
+ },
|
||||
+ ];
|
||||
+
|
||||
+ for case in cases {
|
||||
+ let mut evs = vec![case.event];
|
||||
+ evs.push(completed.clone());
|
||||
+ let out = run_sse(evs).await;
|
||||
+ assert_eq!(out.len(), case.expected_len, "case {}", case.name);
|
||||
+ assert!((case.expect_first)(&out[0]), "case {}", case.name);
|
||||
+ }
|
||||
+ }
|
||||
+}
|
||||
diff --git a/codex-rs/core/tests/fixtures/completed_template.json b/codex-rs/core/tests/fixtures/completed_template.json
|
||||
new file mode 100644
|
||||
index 0000000000..1774dc5e84
|
||||
--- /dev/null
|
||||
+++ b/codex-rs/core/tests/fixtures/completed_template.json
|
||||
@@ -0,0 +1,16 @@
|
||||
+[
|
||||
+ {
|
||||
+ "type": "response.completed",
|
||||
+ "response": {
|
||||
+ "id": "__ID__",
|
||||
+ "usage": {
|
||||
+ "input_tokens": 0,
|
||||
+ "input_tokens_details": null,
|
||||
+ "output_tokens": 0,
|
||||
+ "output_tokens_details": null,
|
||||
+ "total_tokens": 0
|
||||
+ },
|
||||
+ "output": []
|
||||
+ }
|
||||
+ }
|
||||
+]
|
||||
diff --git a/codex-rs/core/tests/fixtures/incomplete_sse.json b/codex-rs/core/tests/fixtures/incomplete_sse.json
|
||||
new file mode 100644
|
||||
index 0000000000..2876bbfd29
|
||||
--- /dev/null
|
||||
+++ b/codex-rs/core/tests/fixtures/incomplete_sse.json
|
||||
@@ -0,0 +1,3 @@
|
||||
+[
|
||||
+ {"type": "response.output_item.done"}
|
||||
+]
|
||||
diff --git a/codex-rs/core/tests/previous_response_id.rs b/codex-rs/core/tests/previous_response_id.rs
|
||||
index 10d6e8bf6a..e64271a0ff 100644
|
||||
--- a/codex-rs/core/tests/previous_response_id.rs
|
||||
+++ b/codex-rs/core/tests/previous_response_id.rs
|
||||
@@ -11,6 +11,7 @@ mod test_support;
|
||||
use serde_json::Value;
|
||||
use tempfile::TempDir;
|
||||
use test_support::load_default_config_for_test;
|
||||
+use test_support::load_sse_fixture_with_id;
|
||||
use tokio::time::timeout;
|
||||
use wiremock::Match;
|
||||
use wiremock::Mock;
|
||||
@@ -42,12 +43,9 @@ impl Match for HasPrevId {
|
||||
}
|
||||
}
|
||||
|
||||
-/// Build minimal SSE stream with completed marker.
|
||||
+/// Build minimal SSE stream with completed marker using the JSON fixture.
|
||||
fn sse_completed(id: &str) -> String {
|
||||
- format!(
|
||||
- "event: response.completed\n\
|
||||
-data: {{\"type\":\"response.completed\",\"response\":{{\"id\":\"{id}\",\"output\":[]}}}}\n\n\n"
|
||||
- )
|
||||
+ load_sse_fixture_with_id("tests/fixtures/completed_template.json", id)
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
diff --git a/codex-rs/core/tests/stream_no_completed.rs b/codex-rs/core/tests/stream_no_completed.rs
|
||||
index ece34ba299..da2736aa77 100644
|
||||
--- a/codex-rs/core/tests/stream_no_completed.rs
|
||||
+++ b/codex-rs/core/tests/stream_no_completed.rs
|
||||
@@ -12,6 +12,8 @@ use codex_core::protocol::Op;
|
||||
mod test_support;
|
||||
use tempfile::TempDir;
|
||||
use test_support::load_default_config_for_test;
|
||||
+use test_support::load_sse_fixture;
|
||||
+use test_support::load_sse_fixture_with_id;
|
||||
use tokio::time::timeout;
|
||||
use wiremock::Mock;
|
||||
use wiremock::MockServer;
|
||||
@@ -22,15 +24,11 @@ use wiremock::matchers::method;
|
||||
use wiremock::matchers::path;
|
||||
|
||||
fn sse_incomplete() -> String {
|
||||
- // Only a single line; missing the completed event.
|
||||
- "event: response.output_item.done\n\n".to_string()
|
||||
+ load_sse_fixture("tests/fixtures/incomplete_sse.json")
|
||||
}
|
||||
|
||||
fn sse_completed(id: &str) -> String {
|
||||
- format!(
|
||||
- "event: response.completed\n\
|
||||
-data: {{\"type\":\"response.completed\",\"response\":{{\"id\":\"{id}\",\"output\":[]}}}}\n\n\n"
|
||||
- )
|
||||
+ load_sse_fixture_with_id("tests/fixtures/completed_template.json", id)
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
diff --git a/codex-rs/core/tests/test_support.rs b/codex-rs/core/tests/test_support.rs
|
||||
index 532e3986d0..7d1e3a7fef 100644
|
||||
--- a/codex-rs/core/tests/test_support.rs
|
||||
+++ b/codex-rs/core/tests/test_support.rs
|
||||
@@ -21,3 +21,58 @@ pub fn load_default_config_for_test(codex_home: &TempDir) -> Config {
|
||||
)
|
||||
.expect("defaults for test should always succeed")
|
||||
}
|
||||
+
|
||||
+/// Builds an SSE stream body from a JSON fixture.
|
||||
+///
|
||||
+/// The fixture must contain an array of objects where each object represents a
|
||||
+/// single SSE event with at least a `type` field matching the `event:` value.
|
||||
+/// Additional fields become the JSON payload for the `data:` line. An object
|
||||
+/// with only a `type` field results in an event with no `data:` section. This
|
||||
+/// makes it trivial to extend the fixtures as OpenAI adds new event kinds or
|
||||
+/// fields.
|
||||
+#[allow(dead_code)]
|
||||
+pub fn load_sse_fixture(path: impl AsRef<std::path::Path>) -> String {
|
||||
+ let events: Vec<serde_json::Value> =
|
||||
+ serde_json::from_reader(std::fs::File::open(path).expect("read fixture"))
|
||||
+ .expect("parse JSON fixture");
|
||||
+ events
|
||||
+ .into_iter()
|
||||
+ .map(|e| {
|
||||
+ let kind = e
|
||||
+ .get("type")
|
||||
+ .and_then(|v| v.as_str())
|
||||
+ .expect("fixture event missing type");
|
||||
+ if e.as_object().map(|o| o.len() == 1).unwrap_or(false) {
|
||||
+ format!("event: {kind}\n\n")
|
||||
+ } else {
|
||||
+ format!("event: {kind}\ndata: {e}\n\n")
|
||||
+ }
|
||||
+ })
|
||||
+ .collect()
|
||||
+}
|
||||
+
|
||||
+/// Same as [`load_sse_fixture`], but replaces the placeholder `__ID__` in the
|
||||
+/// fixture template with the supplied identifier before parsing. This lets a
|
||||
+/// single JSON template be reused by multiple tests that each need a unique
|
||||
+/// `response_id`.
|
||||
+#[allow(dead_code)]
|
||||
+pub fn load_sse_fixture_with_id(path: impl AsRef<std::path::Path>, id: &str) -> String {
|
||||
+ let raw = std::fs::read_to_string(path).expect("read fixture template");
|
||||
+ let replaced = raw.replace("__ID__", id);
|
||||
+ let events: Vec<serde_json::Value> =
|
||||
+ serde_json::from_str(&replaced).expect("parse JSON fixture");
|
||||
+ events
|
||||
+ .into_iter()
|
||||
+ .map(|e| {
|
||||
+ let kind = e
|
||||
+ .get("type")
|
||||
+ .and_then(|v| v.as_str())
|
||||
+ .expect("fixture event missing type");
|
||||
+ if e.as_object().map(|o| o.len() == 1).unwrap_or(false) {
|
||||
+ format!("event: {kind}\n\n")
|
||||
+ } else {
|
||||
+ format!("event: {kind}\ndata: {e}\n\n")
|
||||
+ }
|
||||
+ })
|
||||
+ .collect()
|
||||
+}
|
||||
```
|
||||
|
||||
## Review Comments
|
||||
|
||||
### codex-rs/core/src/client.rs
|
||||
|
||||
- Created: 2025-07-12 19:26:29 UTC | Link: https://github.com/openai/codex/pull/1546#discussion_r2202883815
|
||||
|
||||
```diff
|
||||
@@ -391,3 +391,108 @@ async fn stream_from_fixture(path: impl AsRef<Path>) -> Result<ResponseStream> {
|
||||
tokio::spawn(process_sse(stream, tx_event));
|
||||
Ok(ResponseStream { rx_event })
|
||||
}
|
||||
+
|
||||
+#[cfg(test)]
|
||||
+mod tests {
|
||||
+ #![allow(clippy::expect_used, clippy::unwrap_used)]
|
||||
+ use super::*;
|
||||
+ use serde_json::json;
|
||||
+
|
||||
+ async fn run_sse(events: Vec<serde_json::Value>) -> Vec<ResponseEvent> {
|
||||
+ let mut body = String::new();
|
||||
+ for e in events {
|
||||
+ let kind = e
|
||||
+ .get("type")
|
||||
+ .and_then(|v| v.as_str())
|
||||
+ .expect("fixture event missing type");
|
||||
+ if e.as_object().map(|o| o.len() == 1).unwrap_or(false) {
|
||||
+ body.push_str(&format!("event: {kind}\n\n"));
|
||||
+ } else {
|
||||
+ body.push_str(&format!("event: {kind}\ndata: {e}\n\n"));
|
||||
+ }
|
||||
+ }
|
||||
+ let (tx, mut rx) = mpsc::channel::<Result<ResponseEvent>>(8);
|
||||
+ let stream = ReaderStream::new(std::io::Cursor::new(body)).map_err(CodexErr::Io);
|
||||
+ tokio::spawn(process_sse(stream, tx));
|
||||
+ let mut out = Vec::new();
|
||||
+ while let Some(ev) = rx.recv().await {
|
||||
+ out.push(ev.expect("channel closed"));
|
||||
+ }
|
||||
+ out
|
||||
+ }
|
||||
+
|
||||
+ #[tokio::test]
|
||||
+ async fn table_driven_event_kinds() {
|
||||
+ struct Case {
|
||||
```
|
||||
|
||||
> I don't know why this fields weird to me (maybe because `case` is a keyword in so many other languages), but I feel like `TestCase` is a better name.
|
||||
|
||||
- Created: 2025-07-12 19:30:52 UTC | Link: https://github.com/openai/codex/pull/1546#discussion_r2202885734
|
||||
|
||||
```diff
|
||||
@@ -391,3 +391,108 @@ async fn stream_from_fixture(path: impl AsRef<Path>) -> Result<ResponseStream> {
|
||||
tokio::spawn(process_sse(stream, tx_event));
|
||||
Ok(ResponseStream { rx_event })
|
||||
}
|
||||
+
|
||||
+#[cfg(test)]
|
||||
+mod tests {
|
||||
+ #![allow(clippy::expect_used, clippy::unwrap_used)]
|
||||
+ use super::*;
|
||||
+ use serde_json::json;
|
||||
+
|
||||
+ async fn run_sse(events: Vec<serde_json::Value>) -> Vec<ResponseEvent> {
|
||||
+ let mut body = String::new();
|
||||
+ for e in events {
|
||||
+ let kind = e
|
||||
+ .get("type")
|
||||
+ .and_then(|v| v.as_str())
|
||||
+ .expect("fixture event missing type");
|
||||
+ if e.as_object().map(|o| o.len() == 1).unwrap_or(false) {
|
||||
+ body.push_str(&format!("event: {kind}\n\n"));
|
||||
+ } else {
|
||||
+ body.push_str(&format!("event: {kind}\ndata: {e}\n\n"));
|
||||
+ }
|
||||
+ }
|
||||
+ let (tx, mut rx) = mpsc::channel::<Result<ResponseEvent>>(8);
|
||||
+ let stream = ReaderStream::new(std::io::Cursor::new(body)).map_err(CodexErr::Io);
|
||||
+ tokio::spawn(process_sse(stream, tx));
|
||||
+ let mut out = Vec::new();
|
||||
+ while let Some(ev) = rx.recv().await {
|
||||
+ out.push(ev.expect("channel closed"));
|
||||
+ }
|
||||
+ out
|
||||
+ }
|
||||
+
|
||||
+ #[tokio::test]
|
||||
+ async fn table_driven_event_kinds() {
|
||||
+ struct Case {
|
||||
+ name: &'static str,
|
||||
+ event: serde_json::Value,
|
||||
+ expect_first: fn(&ResponseEvent) -> bool,
|
||||
+ expected_len: usize,
|
||||
+ }
|
||||
+
|
||||
+ fn is_created(ev: &ResponseEvent) -> bool {
|
||||
+ matches!(ev, ResponseEvent::Created)
|
||||
+ }
|
||||
+
|
||||
+ fn is_output(ev: &ResponseEvent) -> bool {
|
||||
+ matches!(ev, ResponseEvent::OutputItemDone(_))
|
||||
+ }
|
||||
+
|
||||
+ fn is_completed(ev: &ResponseEvent) -> bool {
|
||||
+ matches!(ev, ResponseEvent::Completed { .. })
|
||||
+ }
|
||||
+
|
||||
+ let completed = json!({
|
||||
+ "type": "response.completed",
|
||||
+ "response": {
|
||||
+ "id": "c",
|
||||
+ "usage": {
|
||||
+ "input_tokens": 0,
|
||||
+ "input_tokens_details": null,
|
||||
+ "output_tokens": 0,
|
||||
+ "output_tokens_details": null,
|
||||
+ "total_tokens": 0
|
||||
+ },
|
||||
+ "output": []
|
||||
+ }
|
||||
+ });
|
||||
+
|
||||
+ let cases = vec![
|
||||
```
|
||||
|
||||
> Admittedly I have not used this crate before, but https://crates.io/crates/test_case seems worth considering.
|
||||
>
|
||||
> Alternatively, turn all of this into a helper function that takes a `TestCase` and then make one `#[test]` for each so that all of the test cases are always run independently so you can see if any permutation of them fail instead of the first one in the loop.
|
||||
|
||||
- Created: 2025-07-12 19:32:31 UTC | Link: https://github.com/openai/codex/pull/1546#discussion_r2202885979
|
||||
|
||||
```diff
|
||||
@@ -391,3 +391,108 @@ async fn stream_from_fixture(path: impl AsRef<Path>) -> Result<ResponseStream> {
|
||||
tokio::spawn(process_sse(stream, tx_event));
|
||||
Ok(ResponseStream { rx_event })
|
||||
}
|
||||
+
|
||||
+#[cfg(test)]
|
||||
+mod tests {
|
||||
+ #![allow(clippy::expect_used, clippy::unwrap_used)]
|
||||
+ use super::*;
|
||||
+ use serde_json::json;
|
||||
+
|
||||
+ async fn run_sse(events: Vec<serde_json::Value>) -> Vec<ResponseEvent> {
|
||||
+ let mut body = String::new();
|
||||
+ for e in events {
|
||||
+ let kind = e
|
||||
+ .get("type")
|
||||
+ .and_then(|v| v.as_str())
|
||||
+ .expect("fixture event missing type");
|
||||
+ if e.as_object().map(|o| o.len() == 1).unwrap_or(false) {
|
||||
+ body.push_str(&format!("event: {kind}\n\n"));
|
||||
+ } else {
|
||||
+ body.push_str(&format!("event: {kind}\ndata: {e}\n\n"));
|
||||
+ }
|
||||
+ }
|
||||
+ let (tx, mut rx) = mpsc::channel::<Result<ResponseEvent>>(8);
|
||||
+ let stream = ReaderStream::new(std::io::Cursor::new(body)).map_err(CodexErr::Io);
|
||||
+ tokio::spawn(process_sse(stream, tx));
|
||||
+ let mut out = Vec::new();
|
||||
+ while let Some(ev) = rx.recv().await {
|
||||
+ out.push(ev.expect("channel closed"));
|
||||
+ }
|
||||
+ out
|
||||
+ }
|
||||
+
|
||||
+ #[tokio::test]
|
||||
+ async fn table_driven_event_kinds() {
|
||||
```
|
||||
|
||||
> docstring?
|
||||
|
||||
- Created: 2025-07-12 19:34:42 UTC | Link: https://github.com/openai/codex/pull/1546#discussion_r2202886450
|
||||
|
||||
```diff
|
||||
@@ -391,3 +391,108 @@ async fn stream_from_fixture(path: impl AsRef<Path>) -> Result<ResponseStream> {
|
||||
tokio::spawn(process_sse(stream, tx_event));
|
||||
Ok(ResponseStream { rx_event })
|
||||
}
|
||||
+
|
||||
+#[cfg(test)]
|
||||
+mod tests {
|
||||
+ #![allow(clippy::expect_used, clippy::unwrap_used)]
|
||||
+ use super::*;
|
||||
+ use serde_json::json;
|
||||
+
|
||||
+ async fn run_sse(events: Vec<serde_json::Value>) -> Vec<ResponseEvent> {
|
||||
+ let mut body = String::new();
|
||||
+ for e in events {
|
||||
+ let kind = e
|
||||
+ .get("type")
|
||||
+ .and_then(|v| v.as_str())
|
||||
+ .expect("fixture event missing type");
|
||||
+ if e.as_object().map(|o| o.len() == 1).unwrap_or(false) {
|
||||
+ body.push_str(&format!("event: {kind}\n\n"));
|
||||
+ } else {
|
||||
+ body.push_str(&format!("event: {kind}\ndata: {e}\n\n"));
|
||||
+ }
|
||||
+ }
|
||||
+ let (tx, mut rx) = mpsc::channel::<Result<ResponseEvent>>(8);
|
||||
+ let stream = ReaderStream::new(std::io::Cursor::new(body)).map_err(CodexErr::Io);
|
||||
+ tokio::spawn(process_sse(stream, tx));
|
||||
+ let mut out = Vec::new();
|
||||
+ while let Some(ev) = rx.recv().await {
|
||||
+ out.push(ev.expect("channel closed"));
|
||||
+ }
|
||||
+ out
|
||||
+ }
|
||||
+
|
||||
+ #[tokio::test]
|
||||
+ async fn table_driven_event_kinds() {
|
||||
+ struct Case {
|
||||
+ name: &'static str,
|
||||
+ event: serde_json::Value,
|
||||
+ expect_first: fn(&ResponseEvent) -> bool,
|
||||
```
|
||||
|
||||
> If you do the helper function thing described below, perhaps you want two structs for the function: one for the args to drive the test (`name`, `event`) and the other with the expected values (`expect_first`, `expected_len`)?
|
||||
|
||||
- Created: 2025-07-12 19:37:18 UTC | Link: https://github.com/openai/codex/pull/1546#discussion_r2202888705
|
||||
|
||||
```diff
|
||||
@@ -391,3 +391,108 @@ async fn stream_from_fixture(path: impl AsRef<Path>) -> Result<ResponseStream> {
|
||||
tokio::spawn(process_sse(stream, tx_event));
|
||||
Ok(ResponseStream { rx_event })
|
||||
}
|
||||
+
|
||||
+#[cfg(test)]
|
||||
+mod tests {
|
||||
+ #![allow(clippy::expect_used, clippy::unwrap_used)]
|
||||
+ use super::*;
|
||||
+ use serde_json::json;
|
||||
+
|
||||
+ async fn run_sse(events: Vec<serde_json::Value>) -> Vec<ResponseEvent> {
|
||||
+ let mut body = String::new();
|
||||
+ for e in events {
|
||||
+ let kind = e
|
||||
+ .get("type")
|
||||
+ .and_then(|v| v.as_str())
|
||||
+ .expect("fixture event missing type");
|
||||
+ if e.as_object().map(|o| o.len() == 1).unwrap_or(false) {
|
||||
+ body.push_str(&format!("event: {kind}\n\n"));
|
||||
+ } else {
|
||||
+ body.push_str(&format!("event: {kind}\ndata: {e}\n\n"));
|
||||
+ }
|
||||
+ }
|
||||
+ let (tx, mut rx) = mpsc::channel::<Result<ResponseEvent>>(8);
|
||||
+ let stream = ReaderStream::new(std::io::Cursor::new(body)).map_err(CodexErr::Io);
|
||||
+ tokio::spawn(process_sse(stream, tx));
|
||||
+ let mut out = Vec::new();
|
||||
+ while let Some(ev) = rx.recv().await {
|
||||
+ out.push(ev.expect("channel closed"));
|
||||
+ }
|
||||
+ out
|
||||
+ }
|
||||
+
|
||||
+ #[tokio::test]
|
||||
+ async fn table_driven_event_kinds() {
|
||||
+ struct Case {
|
||||
+ name: &'static str,
|
||||
+ event: serde_json::Value,
|
||||
+ expect_first: fn(&ResponseEvent) -> bool,
|
||||
```
|
||||
|
||||
> Also, you could make `expect_first` an `enum` that has an instance method `fn(&ResponseEvent) -> bool` or something like that.
|
||||
|
||||
### codex-rs/core/tests/test_support.rs
|
||||
|
||||
- Created: 2025-07-12 19:38:24 UTC | Link: https://github.com/openai/codex/pull/1546#discussion_r2202889095
|
||||
|
||||
```diff
|
||||
@@ -21,3 +21,57 @@ pub fn load_default_config_for_test(codex_home: &TempDir) -> Config {
|
||||
)
|
||||
.expect("defaults for test should always succeed")
|
||||
}
|
||||
+
|
||||
+/// Builds an SSE stream body from a JSON fixture.
|
||||
+///
|
||||
+/// The fixture must contain an array of objects where each object represents a
|
||||
+/// single SSE event with at least a `type` field matching the `event:` value.
|
||||
+/// Additional fields become the JSON payload for the `data:` line. An object
|
||||
+/// with only a `type` field results in an event with no `data:` section. This
|
||||
+/// makes it trivial to extend the fixtures as OpenAI adds new event kinds or
|
||||
+/// fields.
|
||||
+#[allow(dead_code)]
|
||||
```
|
||||
|
||||
> This is no longer dead code, right?
|
||||
|
||||
- Created: 2025-07-12 19:39:06 UTC | Link: https://github.com/openai/codex/pull/1546#discussion_r2202889296
|
||||
|
||||
```diff
|
||||
@@ -21,3 +21,57 @@ pub fn load_default_config_for_test(codex_home: &TempDir) -> Config {
|
||||
)
|
||||
.expect("defaults for test should always succeed")
|
||||
}
|
||||
+
|
||||
+/// Builds an SSE stream body from a JSON fixture.
|
||||
+///
|
||||
+/// The fixture must contain an array of objects where each object represents a
|
||||
+/// single SSE event with at least a `type` field matching the `event:` value.
|
||||
+/// Additional fields become the JSON payload for the `data:` line. An object
|
||||
+/// with only a `type` field results in an event with no `data:` section. This
|
||||
+/// makes it trivial to extend the fixtures as OpenAI adds new event kinds or
|
||||
+/// fields.
|
||||
+#[allow(dead_code)]
|
||||
+pub fn load_sse_fixture(path: impl AsRef<std::path::Path>) -> String {
|
||||
+ let events: Vec<serde_json::Value> =
|
||||
+ serde_json::from_reader(std::fs::File::open(path).expect("read fixture"))
|
||||
+ .expect("parse JSON fixture");
|
||||
+ events
|
||||
+ .into_iter()
|
||||
+ .map(|e| {
|
||||
+ let kind = e
|
||||
+ .get("type")
|
||||
+ .and_then(|v| v.as_str())
|
||||
+ .expect("fixture event missing type");
|
||||
+ if e.as_object().map(|o| o.len() == 1).unwrap_or(false) {
|
||||
+ format!("event: {kind}\n\n")
|
||||
+ } else {
|
||||
+ format!("event: {kind}\ndata: {e}\n\n")
|
||||
+ }
|
||||
+ })
|
||||
+ .collect()
|
||||
+}
|
||||
+
|
||||
+/// Like [`load_sse_fixture`] but substitutes the placeholder `__ID__` with the
|
||||
+/// provided identifier before parsing. Useful when the test needs unique
|
||||
+/// `response_id` values.
|
||||
+#[allow(dead_code)]
|
||||
```
|
||||
|
||||
> And this?
|
||||
1546
prs/bolinfest/PR-1547.md
Normal file
1546
prs/bolinfest/PR-1547.md
Normal file
File diff suppressed because it is too large
Load Diff
820
prs/bolinfest/PR-1549.md
Normal file
820
prs/bolinfest/PR-1549.md
Normal file
@@ -0,0 +1,820 @@
|
||||
# PR #1549: Add paste summarization to Codex TUI
|
||||
|
||||
- URL: https://github.com/openai/codex/pull/1549
|
||||
- Author: aibrahim-oai
|
||||
- Created: 2025-07-12 05:00:17 UTC
|
||||
- Updated: 2025-07-12 22:32:07 UTC
|
||||
- Changes: +542/-16, Files changed: 12, Commits: 9
|
||||
|
||||
## Description
|
||||
|
||||
## Summary
|
||||
- introduce `Paste` event to avoid per-character paste handling
|
||||
- collapse large pasted blocks to `[Pasted Content X lines]`
|
||||
- store the real text so submission still includes it
|
||||
- wire paste handling through `App`, `ChatWidget`, `BottomPane`, and `ChatComposer`
|
||||
|
||||
## Testing
|
||||
- `cargo test -p codex-tui`
|
||||
|
||||
|
||||
------
|
||||
https://chatgpt.com/codex/tasks/task_i_6871e24abf80832184d1f3ca0c61a5ee
|
||||
|
||||
https://github.com/user-attachments/assets/eda7412f-da30-4474-9f7c-96b49d48fbf8
|
||||
|
||||
## Full Diff
|
||||
|
||||
```diff
|
||||
diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock
|
||||
index 2909c2c594..3de3e78198 100644
|
||||
--- a/codex-rs/Cargo.lock
|
||||
+++ b/codex-rs/Cargo.lock
|
||||
@@ -787,6 +787,7 @@ dependencies = [
|
||||
"color-eyre",
|
||||
"crossterm",
|
||||
"image",
|
||||
+ "insta",
|
||||
"lazy_static",
|
||||
"mcp-types",
|
||||
"path-clean",
|
||||
@@ -871,6 +872,18 @@ dependencies = [
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
+[[package]]
|
||||
+name = "console"
|
||||
+version = "0.15.11"
|
||||
+source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
+checksum = "054ccb5b10f9f2cbf51eb355ca1d05c2d279ce1804688d0db74b4733a5aeafd8"
|
||||
+dependencies = [
|
||||
+ "encode_unicode",
|
||||
+ "libc",
|
||||
+ "once_cell",
|
||||
+ "windows-sys 0.59.0",
|
||||
+]
|
||||
+
|
||||
[[package]]
|
||||
name = "convert_case"
|
||||
version = "0.6.0"
|
||||
@@ -1230,6 +1243,12 @@ dependencies = [
|
||||
"log",
|
||||
]
|
||||
|
||||
+[[package]]
|
||||
+name = "encode_unicode"
|
||||
+version = "1.0.0"
|
||||
+source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
+checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0"
|
||||
+
|
||||
[[package]]
|
||||
name = "encoding_rs"
|
||||
version = "0.8.35"
|
||||
@@ -2110,6 +2129,17 @@ version = "2.0.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f4c7245a08504955605670dbf141fceab975f15ca21570696aebe9d2e71576bd"
|
||||
|
||||
+[[package]]
|
||||
+name = "insta"
|
||||
+version = "1.43.1"
|
||||
+source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
+checksum = "154934ea70c58054b556dd430b99a98c2a7ff5309ac9891597e339b5c28f4371"
|
||||
+dependencies = [
|
||||
+ "console",
|
||||
+ "once_cell",
|
||||
+ "similar",
|
||||
+]
|
||||
+
|
||||
[[package]]
|
||||
name = "instability"
|
||||
version = "0.3.7"
|
||||
diff --git a/codex-rs/tui/Cargo.toml b/codex-rs/tui/Cargo.toml
|
||||
index 151222a1d3..74aedfa353 100644
|
||||
--- a/codex-rs/tui/Cargo.toml
|
||||
+++ b/codex-rs/tui/Cargo.toml
|
||||
@@ -61,4 +61,5 @@ unicode-segmentation = "1.12.0"
|
||||
uuid = "1"
|
||||
|
||||
[dev-dependencies]
|
||||
+insta = "1.43.1"
|
||||
pretty_assertions = "1"
|
||||
diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs
|
||||
index 4b8b9b7812..e1dde8332d 100644
|
||||
--- a/codex-rs/tui/src/app.rs
|
||||
+++ b/codex-rs/tui/src/app.rs
|
||||
@@ -98,21 +98,7 @@ impl<'a> App<'a> {
|
||||
scroll_event_helper.scroll_down();
|
||||
}
|
||||
crossterm::event::Event::Paste(pasted) => {
|
||||
- use crossterm::event::KeyModifiers;
|
||||
-
|
||||
- for ch in pasted.chars() {
|
||||
- let key_event = match ch {
|
||||
- '\n' | '\r' => {
|
||||
- // Represent newline as <Shift+Enter> so that the bottom
|
||||
- // pane treats it as a literal newline instead of a submit
|
||||
- // action (submission is only triggered on Enter *without*
|
||||
- // any modifiers).
|
||||
- KeyEvent::new(KeyCode::Enter, KeyModifiers::SHIFT)
|
||||
- }
|
||||
- _ => KeyEvent::new(KeyCode::Char(ch), KeyModifiers::empty()),
|
||||
- };
|
||||
- app_event_tx.send(AppEvent::KeyEvent(key_event));
|
||||
- }
|
||||
+ app_event_tx.send(AppEvent::Paste(pasted));
|
||||
}
|
||||
_ => {
|
||||
// Ignore any other events.
|
||||
@@ -223,6 +209,9 @@ impl<'a> App<'a> {
|
||||
AppEvent::Scroll(scroll_delta) => {
|
||||
self.dispatch_scroll_event(scroll_delta);
|
||||
}
|
||||
+ AppEvent::Paste(text) => {
|
||||
+ self.dispatch_paste_event(text);
|
||||
+ }
|
||||
AppEvent::CodexEvent(event) => {
|
||||
self.dispatch_codex_event(event);
|
||||
}
|
||||
@@ -343,6 +332,13 @@ impl<'a> App<'a> {
|
||||
}
|
||||
}
|
||||
|
||||
+ fn dispatch_paste_event(&mut self, pasted: String) {
|
||||
+ match &mut self.app_state {
|
||||
+ AppState::Chat { widget } => widget.handle_paste(pasted),
|
||||
+ AppState::Login { .. } | AppState::GitWarning { .. } => {}
|
||||
+ }
|
||||
+ }
|
||||
+
|
||||
fn dispatch_scroll_event(&mut self, scroll_delta: i32) {
|
||||
match &mut self.app_state {
|
||||
AppState::Chat { widget } => widget.handle_scroll_delta(scroll_delta),
|
||||
diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs
|
||||
index dd89b85331..fd6b2479ee 100644
|
||||
--- a/codex-rs/tui/src/app_event.rs
|
||||
+++ b/codex-rs/tui/src/app_event.rs
|
||||
@@ -12,6 +12,9 @@ pub(crate) enum AppEvent {
|
||||
|
||||
KeyEvent(KeyEvent),
|
||||
|
||||
+ /// Text pasted from the terminal clipboard.
|
||||
+ Paste(String),
|
||||
+
|
||||
/// Scroll event with a value representing the "scroll delta" as the net
|
||||
/// scroll up/down events within a short time window.
|
||||
Scroll(i32),
|
||||
diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs
|
||||
index 29bf74c810..e89187d165 100644
|
||||
--- a/codex-rs/tui/src/bottom_pane/chat_composer.rs
|
||||
+++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs
|
||||
@@ -28,6 +28,9 @@ const MIN_TEXTAREA_ROWS: usize = 1;
|
||||
const BORDER_LINES: u16 = 2;
|
||||
|
||||
const BASE_PLACEHOLDER_TEXT: &str = "send a message";
|
||||
+/// If the pasted content exceeds this number of characters, replace it with a
|
||||
+/// placeholder in the UI.
|
||||
+const LARGE_PASTE_CHAR_THRESHOLD: usize = 1000;
|
||||
|
||||
/// Result returned when the user interacts with the text area.
|
||||
pub enum InputResult {
|
||||
@@ -43,6 +46,7 @@ pub(crate) struct ChatComposer<'a> {
|
||||
ctrl_c_quit_hint: bool,
|
||||
dismissed_file_popup_token: Option<String>,
|
||||
current_file_query: Option<String>,
|
||||
+ pending_pastes: Vec<(String, String)>,
|
||||
}
|
||||
|
||||
/// Popup state – at most one can be visible at any time.
|
||||
@@ -66,6 +70,7 @@ impl ChatComposer<'_> {
|
||||
ctrl_c_quit_hint: false,
|
||||
dismissed_file_popup_token: None,
|
||||
current_file_query: None,
|
||||
+ pending_pastes: Vec::new(),
|
||||
};
|
||||
this.update_border(has_input_focus);
|
||||
this
|
||||
@@ -126,6 +131,20 @@ impl ChatComposer<'_> {
|
||||
self.update_border(has_focus);
|
||||
}
|
||||
|
||||
+ pub fn handle_paste(&mut self, pasted: String) -> bool {
|
||||
+ let char_count = pasted.chars().count();
|
||||
+ if char_count > LARGE_PASTE_CHAR_THRESHOLD {
|
||||
+ let placeholder = format!("[Pasted Content {char_count} chars]");
|
||||
+ self.textarea.insert_str(&placeholder);
|
||||
+ self.pending_pastes.push((placeholder, pasted));
|
||||
+ } else {
|
||||
+ self.textarea.insert_str(&pasted);
|
||||
+ }
|
||||
+ self.sync_command_popup();
|
||||
+ self.sync_file_search_popup();
|
||||
+ true
|
||||
+ }
|
||||
+
|
||||
/// Integrate results from an asynchronous file search.
|
||||
pub(crate) fn on_file_search_result(&mut self, query: String, matches: Vec<FileMatch>) {
|
||||
// Only apply if user is still editing a token starting with `query`.
|
||||
@@ -414,10 +433,18 @@ impl ChatComposer<'_> {
|
||||
alt: false,
|
||||
ctrl: false,
|
||||
} => {
|
||||
- let text = self.textarea.lines().join("\n");
|
||||
+ let mut text = self.textarea.lines().join("\n");
|
||||
self.textarea.select_all();
|
||||
self.textarea.cut();
|
||||
|
||||
+ // Replace all pending pastes in the text
|
||||
+ for (placeholder, actual) in &self.pending_pastes {
|
||||
+ if text.contains(placeholder) {
|
||||
+ text = text.replace(placeholder, actual);
|
||||
+ }
|
||||
+ }
|
||||
+ self.pending_pastes.clear();
|
||||
+
|
||||
if text.is_empty() {
|
||||
(InputResult::None, true)
|
||||
} else {
|
||||
@@ -443,10 +470,71 @@ impl ChatComposer<'_> {
|
||||
|
||||
/// Handle generic Input events that modify the textarea content.
|
||||
fn handle_input_basic(&mut self, input: Input) -> (InputResult, bool) {
|
||||
+ // Special handling for backspace on placeholders
|
||||
+ if let Input {
|
||||
+ key: Key::Backspace,
|
||||
+ ..
|
||||
+ } = input
|
||||
+ {
|
||||
+ if self.try_remove_placeholder_at_cursor() {
|
||||
+ return (InputResult::None, true);
|
||||
+ }
|
||||
+ }
|
||||
+
|
||||
+ // Normal input handling
|
||||
self.textarea.input(input);
|
||||
+ let text_after = self.textarea.lines().join("\n");
|
||||
+
|
||||
+ // Check if any placeholders were removed and remove their corresponding pending pastes
|
||||
+ self.pending_pastes
|
||||
+ .retain(|(placeholder, _)| text_after.contains(placeholder));
|
||||
+
|
||||
(InputResult::None, true)
|
||||
}
|
||||
|
||||
+ /// Attempts to remove a placeholder if the cursor is at the end of one.
|
||||
+ /// Returns true if a placeholder was removed.
|
||||
+ fn try_remove_placeholder_at_cursor(&mut self) -> bool {
|
||||
+ let (row, col) = self.textarea.cursor();
|
||||
+ let line = self
|
||||
+ .textarea
|
||||
+ .lines()
|
||||
+ .get(row)
|
||||
+ .map(|s| s.as_str())
|
||||
+ .unwrap_or("");
|
||||
+
|
||||
+ // Find any placeholder that ends at the cursor position
|
||||
+ let placeholder_to_remove = self.pending_pastes.iter().find_map(|(ph, _)| {
|
||||
+ if col < ph.len() {
|
||||
+ return None;
|
||||
+ }
|
||||
+ let potential_ph_start = col - ph.len();
|
||||
+ if line[potential_ph_start..col] == *ph {
|
||||
+ Some(ph.clone())
|
||||
+ } else {
|
||||
+ None
|
||||
+ }
|
||||
+ });
|
||||
+
|
||||
+ if let Some(placeholder) = placeholder_to_remove {
|
||||
+ // Remove the entire placeholder from the text
|
||||
+ let placeholder_len = placeholder.len();
|
||||
+ for _ in 0..placeholder_len {
|
||||
+ self.textarea.input(Input {
|
||||
+ key: Key::Backspace,
|
||||
+ ctrl: false,
|
||||
+ alt: false,
|
||||
+ shift: false,
|
||||
+ });
|
||||
+ }
|
||||
+ // Remove from pending pastes
|
||||
+ self.pending_pastes.retain(|(ph, _)| ph != &placeholder);
|
||||
+ true
|
||||
+ } else {
|
||||
+ false
|
||||
+ }
|
||||
+ }
|
||||
+
|
||||
/// Synchronize `self.command_popup` with the current text in the
|
||||
/// textarea. This must be called after every modification that can change
|
||||
/// the text so the popup is shown/updated/hidden as appropriate.
|
||||
@@ -624,7 +712,10 @@ impl WidgetRef for &ChatComposer<'_> {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
+ use crate::bottom_pane::AppEventSender;
|
||||
use crate::bottom_pane::ChatComposer;
|
||||
+ use crate::bottom_pane::InputResult;
|
||||
+ use crate::bottom_pane::chat_composer::LARGE_PASTE_CHAR_THRESHOLD;
|
||||
use tui_textarea::TextArea;
|
||||
|
||||
#[test]
|
||||
@@ -770,4 +861,324 @@ mod tests {
|
||||
);
|
||||
}
|
||||
}
|
||||
+
|
||||
+ #[test]
|
||||
+ fn handle_paste_small_inserts_text() {
|
||||
+ use crossterm::event::KeyCode;
|
||||
+ use crossterm::event::KeyEvent;
|
||||
+ use crossterm::event::KeyModifiers;
|
||||
+
|
||||
+ let (tx, _rx) = std::sync::mpsc::channel();
|
||||
+ let sender = AppEventSender::new(tx);
|
||||
+ let mut composer = ChatComposer::new(true, sender);
|
||||
+
|
||||
+ let needs_redraw = composer.handle_paste("hello".to_string());
|
||||
+ assert!(needs_redraw);
|
||||
+ assert_eq!(composer.textarea.lines(), ["hello"]);
|
||||
+ assert!(composer.pending_pastes.is_empty());
|
||||
+
|
||||
+ let (result, _) =
|
||||
+ composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
+ match result {
|
||||
+ InputResult::Submitted(text) => assert_eq!(text, "hello"),
|
||||
+ _ => panic!("expected Submitted"),
|
||||
+ }
|
||||
+ }
|
||||
+
|
||||
+ #[test]
|
||||
+ fn handle_paste_large_uses_placeholder_and_replaces_on_submit() {
|
||||
+ use crossterm::event::KeyCode;
|
||||
+ use crossterm::event::KeyEvent;
|
||||
+ use crossterm::event::KeyModifiers;
|
||||
+
|
||||
+ let (tx, _rx) = std::sync::mpsc::channel();
|
||||
+ let sender = AppEventSender::new(tx);
|
||||
+ let mut composer = ChatComposer::new(true, sender);
|
||||
+
|
||||
+ let large = "x".repeat(LARGE_PASTE_CHAR_THRESHOLD + 10);
|
||||
+ let needs_redraw = composer.handle_paste(large.clone());
|
||||
+ assert!(needs_redraw);
|
||||
+ let placeholder = format!("[Pasted Content {} chars]", large.chars().count());
|
||||
+ assert_eq!(composer.textarea.lines(), [placeholder.as_str()]);
|
||||
+ assert_eq!(composer.pending_pastes.len(), 1);
|
||||
+ assert_eq!(composer.pending_pastes[0].0, placeholder);
|
||||
+ assert_eq!(composer.pending_pastes[0].1, large);
|
||||
+
|
||||
+ let (result, _) =
|
||||
+ composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
+ match result {
|
||||
+ InputResult::Submitted(text) => assert_eq!(text, large),
|
||||
+ _ => panic!("expected Submitted"),
|
||||
+ }
|
||||
+ assert!(composer.pending_pastes.is_empty());
|
||||
+ }
|
||||
+
|
||||
+ #[test]
|
||||
+ fn edit_clears_pending_paste() {
|
||||
+ use crossterm::event::KeyCode;
|
||||
+ use crossterm::event::KeyEvent;
|
||||
+ use crossterm::event::KeyModifiers;
|
||||
+
|
||||
+ let large = "y".repeat(LARGE_PASTE_CHAR_THRESHOLD + 1);
|
||||
+ let (tx, _rx) = std::sync::mpsc::channel();
|
||||
+ let sender = AppEventSender::new(tx);
|
||||
+ let mut composer = ChatComposer::new(true, sender);
|
||||
+
|
||||
+ composer.handle_paste(large);
|
||||
+ assert_eq!(composer.pending_pastes.len(), 1);
|
||||
+
|
||||
+ // Any edit that removes the placeholder should clear pending_paste
|
||||
+ composer.handle_key_event(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE));
|
||||
+ assert!(composer.pending_pastes.is_empty());
|
||||
+ }
|
||||
+
|
||||
+ #[test]
|
||||
+ fn ui_snapshots() {
|
||||
+ use crossterm::event::KeyCode;
|
||||
+ use crossterm::event::KeyEvent;
|
||||
+ use crossterm::event::KeyModifiers;
|
||||
+ use insta::assert_snapshot;
|
||||
+ use ratatui::Terminal;
|
||||
+ use ratatui::backend::TestBackend;
|
||||
+
|
||||
+ let (tx, _rx) = std::sync::mpsc::channel();
|
||||
+ let sender = AppEventSender::new(tx);
|
||||
+ let mut terminal = match Terminal::new(TestBackend::new(100, 10)) {
|
||||
+ Ok(t) => t,
|
||||
+ Err(e) => panic!("Failed to create terminal: {e}"),
|
||||
+ };
|
||||
+
|
||||
+ let test_cases = vec![
|
||||
+ ("empty", None),
|
||||
+ ("small", Some("short".to_string())),
|
||||
+ ("large", Some("z".repeat(LARGE_PASTE_CHAR_THRESHOLD + 5))),
|
||||
+ ("multiple_pastes", None),
|
||||
+ ("backspace_after_pastes", None),
|
||||
+ ];
|
||||
+
|
||||
+ for (name, input) in test_cases {
|
||||
+ // Create a fresh composer for each test case
|
||||
+ let mut composer = ChatComposer::new(true, sender.clone());
|
||||
+
|
||||
+ if let Some(text) = input {
|
||||
+ composer.handle_paste(text);
|
||||
+ } else if name == "multiple_pastes" {
|
||||
+ // First large paste
|
||||
+ composer.handle_paste("x".repeat(LARGE_PASTE_CHAR_THRESHOLD + 3));
|
||||
+ // Second large paste
|
||||
+ composer.handle_paste("y".repeat(LARGE_PASTE_CHAR_THRESHOLD + 7));
|
||||
+ // Small paste
|
||||
+ composer.handle_paste(" another short paste".to_string());
|
||||
+ } else if name == "backspace_after_pastes" {
|
||||
+ // Three large pastes
|
||||
+ composer.handle_paste("a".repeat(LARGE_PASTE_CHAR_THRESHOLD + 2));
|
||||
+ composer.handle_paste("b".repeat(LARGE_PASTE_CHAR_THRESHOLD + 4));
|
||||
+ composer.handle_paste("c".repeat(LARGE_PASTE_CHAR_THRESHOLD + 6));
|
||||
+ // Move cursor to end and press backspace
|
||||
+ composer.textarea.move_cursor(tui_textarea::CursorMove::End);
|
||||
+ composer.handle_key_event(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE));
|
||||
+ }
|
||||
+
|
||||
+ terminal
|
||||
+ .draw(|f| f.render_widget_ref(&composer, f.area()))
|
||||
+ .unwrap_or_else(|e| panic!("Failed to draw {name} composer: {e}"));
|
||||
+
|
||||
+ assert_snapshot!(name, terminal.backend());
|
||||
+ }
|
||||
+ }
|
||||
+
|
||||
+ #[test]
|
||||
+ fn test_multiple_pastes_submission() {
|
||||
+ use crossterm::event::KeyCode;
|
||||
+ use crossterm::event::KeyEvent;
|
||||
+ use crossterm::event::KeyModifiers;
|
||||
+
|
||||
+ let (tx, _rx) = std::sync::mpsc::channel();
|
||||
+ let sender = AppEventSender::new(tx);
|
||||
+ let mut composer = ChatComposer::new(true, sender);
|
||||
+
|
||||
+ // Define test cases: (paste content, is_large)
|
||||
+ let test_cases = [
|
||||
+ ("x".repeat(LARGE_PASTE_CHAR_THRESHOLD + 3), true),
|
||||
+ (" and ".to_string(), false),
|
||||
+ ("y".repeat(LARGE_PASTE_CHAR_THRESHOLD + 7), true),
|
||||
+ ];
|
||||
+
|
||||
+ // Expected states after each paste
|
||||
+ let mut expected_text = String::new();
|
||||
+ let mut expected_pending_count = 0;
|
||||
+
|
||||
+ // Apply all pastes and build expected state
|
||||
+ let states: Vec<_> = test_cases
|
||||
+ .iter()
|
||||
+ .map(|(content, is_large)| {
|
||||
+ composer.handle_paste(content.clone());
|
||||
+ if *is_large {
|
||||
+ let placeholder = format!("[Pasted Content {} chars]", content.chars().count());
|
||||
+ expected_text.push_str(&placeholder);
|
||||
+ expected_pending_count += 1;
|
||||
+ } else {
|
||||
+ expected_text.push_str(content);
|
||||
+ }
|
||||
+ (expected_text.clone(), expected_pending_count)
|
||||
+ })
|
||||
+ .collect();
|
||||
+
|
||||
+ // Verify all intermediate states were correct
|
||||
+ assert_eq!(
|
||||
+ states,
|
||||
+ vec![
|
||||
+ (
|
||||
+ format!("[Pasted Content {} chars]", test_cases[0].0.chars().count()),
|
||||
+ 1
|
||||
+ ),
|
||||
+ (
|
||||
+ format!(
|
||||
+ "[Pasted Content {} chars] and ",
|
||||
+ test_cases[0].0.chars().count()
|
||||
+ ),
|
||||
+ 1
|
||||
+ ),
|
||||
+ (
|
||||
+ format!(
|
||||
+ "[Pasted Content {} chars] and [Pasted Content {} chars]",
|
||||
+ test_cases[0].0.chars().count(),
|
||||
+ test_cases[2].0.chars().count()
|
||||
+ ),
|
||||
+ 2
|
||||
+ ),
|
||||
+ ]
|
||||
+ );
|
||||
+
|
||||
+ // Submit and verify final expansion
|
||||
+ let (result, _) =
|
||||
+ composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
+ if let InputResult::Submitted(text) = result {
|
||||
+ assert_eq!(text, format!("{} and {}", test_cases[0].0, test_cases[2].0));
|
||||
+ } else {
|
||||
+ panic!("expected Submitted");
|
||||
+ }
|
||||
+ }
|
||||
+
|
||||
+ #[test]
|
||||
+ fn test_placeholder_deletion() {
|
||||
+ use crossterm::event::KeyCode;
|
||||
+ use crossterm::event::KeyEvent;
|
||||
+ use crossterm::event::KeyModifiers;
|
||||
+
|
||||
+ let (tx, _rx) = std::sync::mpsc::channel();
|
||||
+ let sender = AppEventSender::new(tx);
|
||||
+ let mut composer = ChatComposer::new(true, sender);
|
||||
+
|
||||
+ // Define test cases: (content, is_large)
|
||||
+ let test_cases = [
|
||||
+ ("a".repeat(LARGE_PASTE_CHAR_THRESHOLD + 5), true),
|
||||
+ (" and ".to_string(), false),
|
||||
+ ("b".repeat(LARGE_PASTE_CHAR_THRESHOLD + 6), true),
|
||||
+ ];
|
||||
+
|
||||
+ // Apply all pastes
|
||||
+ let mut current_pos = 0;
|
||||
+ let states: Vec<_> = test_cases
|
||||
+ .iter()
|
||||
+ .map(|(content, is_large)| {
|
||||
+ composer.handle_paste(content.clone());
|
||||
+ if *is_large {
|
||||
+ let placeholder = format!("[Pasted Content {} chars]", content.chars().count());
|
||||
+ current_pos += placeholder.len();
|
||||
+ } else {
|
||||
+ current_pos += content.len();
|
||||
+ }
|
||||
+ (
|
||||
+ composer.textarea.lines().join("\n"),
|
||||
+ composer.pending_pastes.len(),
|
||||
+ current_pos,
|
||||
+ )
|
||||
+ })
|
||||
+ .collect();
|
||||
+
|
||||
+ // Delete placeholders one by one and collect states
|
||||
+ let mut deletion_states = vec![];
|
||||
+
|
||||
+ // First deletion
|
||||
+ composer
|
||||
+ .textarea
|
||||
+ .move_cursor(tui_textarea::CursorMove::Jump(0, states[0].2 as u16));
|
||||
+ composer.handle_key_event(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE));
|
||||
+ deletion_states.push((
|
||||
+ composer.textarea.lines().join("\n"),
|
||||
+ composer.pending_pastes.len(),
|
||||
+ ));
|
||||
+
|
||||
+ // Second deletion
|
||||
+ composer
|
||||
+ .textarea
|
||||
+ .move_cursor(tui_textarea::CursorMove::Jump(
|
||||
+ 0,
|
||||
+ composer.textarea.lines().join("\n").len() as u16,
|
||||
+ ));
|
||||
+ composer.handle_key_event(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE));
|
||||
+ deletion_states.push((
|
||||
+ composer.textarea.lines().join("\n"),
|
||||
+ composer.pending_pastes.len(),
|
||||
+ ));
|
||||
+
|
||||
+ // Verify all states
|
||||
+ assert_eq!(
|
||||
+ deletion_states,
|
||||
+ vec![
|
||||
+ (" and [Pasted Content 1006 chars]".to_string(), 1),
|
||||
+ (" and ".to_string(), 0),
|
||||
+ ]
|
||||
+ );
|
||||
+ }
|
||||
+
|
||||
+ #[test]
|
||||
+ fn test_partial_placeholder_deletion() {
|
||||
+ use crossterm::event::KeyCode;
|
||||
+ use crossterm::event::KeyEvent;
|
||||
+ use crossterm::event::KeyModifiers;
|
||||
+
|
||||
+ let (tx, _rx) = std::sync::mpsc::channel();
|
||||
+ let sender = AppEventSender::new(tx);
|
||||
+ let mut composer = ChatComposer::new(true, sender);
|
||||
+
|
||||
+ // Define test cases: (cursor_position_from_end, expected_pending_count)
|
||||
+ let test_cases = [
|
||||
+ 5, // Delete from middle - should clear tracking
|
||||
+ 0, // Delete from end - should clear tracking
|
||||
+ ];
|
||||
+
|
||||
+ let paste = "x".repeat(LARGE_PASTE_CHAR_THRESHOLD + 4);
|
||||
+ let placeholder = format!("[Pasted Content {} chars]", paste.chars().count());
|
||||
+
|
||||
+ let states: Vec<_> = test_cases
|
||||
+ .into_iter()
|
||||
+ .map(|pos_from_end| {
|
||||
+ composer.handle_paste(paste.clone());
|
||||
+ composer
|
||||
+ .textarea
|
||||
+ .move_cursor(tui_textarea::CursorMove::Jump(
|
||||
+ 0,
|
||||
+ (placeholder.len() - pos_from_end) as u16,
|
||||
+ ));
|
||||
+ composer.handle_key_event(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE));
|
||||
+ let result = (
|
||||
+ composer.textarea.lines().join("\n").contains(&placeholder),
|
||||
+ composer.pending_pastes.len(),
|
||||
+ );
|
||||
+ composer.textarea.select_all();
|
||||
+ composer.textarea.cut();
|
||||
+ result
|
||||
+ })
|
||||
+ .collect();
|
||||
+
|
||||
+ assert_eq!(
|
||||
+ states,
|
||||
+ vec![
|
||||
+ (false, 0), // After deleting from middle
|
||||
+ (false, 0), // After deleting from end
|
||||
+ ]
|
||||
+ );
|
||||
+ }
|
||||
}
|
||||
diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs
|
||||
index 96f5c70285..350492b3e9 100644
|
||||
--- a/codex-rs/tui/src/bottom_pane/mod.rs
|
||||
+++ b/codex-rs/tui/src/bottom_pane/mod.rs
|
||||
@@ -82,6 +82,15 @@ impl BottomPane<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
+ pub fn handle_paste(&mut self, pasted: String) {
|
||||
+ if self.active_view.is_none() {
|
||||
+ let needs_redraw = self.composer.handle_paste(pasted);
|
||||
+ if needs_redraw {
|
||||
+ self.request_redraw();
|
||||
+ }
|
||||
+ }
|
||||
+ }
|
||||
+
|
||||
/// Update the status indicator text (only when the `StatusIndicatorView` is
|
||||
/// active).
|
||||
pub(crate) fn update_status_text(&mut self, text: String) {
|
||||
diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__backspace_after_pastes.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__backspace_after_pastes.snap
|
||||
new file mode 100644
|
||||
index 0000000000..fa604c862b
|
||||
--- /dev/null
|
||||
+++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__backspace_after_pastes.snap
|
||||
@@ -0,0 +1,14 @@
|
||||
+---
|
||||
+source: tui/src/bottom_pane/chat_composer.rs
|
||||
+expression: terminal.backend()
|
||||
+---
|
||||
+"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮"
|
||||
+"│[Pasted Content 1002 chars][Pasted Content 1004 chars] │"
|
||||
+"│ │"
|
||||
+"│ │"
|
||||
+"│ │"
|
||||
+"│ │"
|
||||
+"│ │"
|
||||
+"│ │"
|
||||
+"│ │"
|
||||
+"╰───────────────────────────────────────────────Enter to send | Ctrl+D to quit | Ctrl+J for newline╯"
|
||||
diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__empty.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__empty.snap
|
||||
new file mode 100644
|
||||
index 0000000000..a89076d8aa
|
||||
--- /dev/null
|
||||
+++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__empty.snap
|
||||
@@ -0,0 +1,14 @@
|
||||
+---
|
||||
+source: tui/src/bottom_pane/chat_composer.rs
|
||||
+expression: terminal.backend()
|
||||
+---
|
||||
+"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮"
|
||||
+"│ send a message │"
|
||||
+"│ │"
|
||||
+"│ │"
|
||||
+"│ │"
|
||||
+"│ │"
|
||||
+"│ │"
|
||||
+"│ │"
|
||||
+"│ │"
|
||||
+"╰───────────────────────────────────────────────Enter to send | Ctrl+D to quit | Ctrl+J for newline╯"
|
||||
diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__large.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__large.snap
|
||||
new file mode 100644
|
||||
index 0000000000..39a62da400
|
||||
--- /dev/null
|
||||
+++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__large.snap
|
||||
@@ -0,0 +1,14 @@
|
||||
+---
|
||||
+source: tui/src/bottom_pane/chat_composer.rs
|
||||
+expression: terminal.backend()
|
||||
+---
|
||||
+"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮"
|
||||
+"│[Pasted Content 1005 chars] │"
|
||||
+"│ │"
|
||||
+"│ │"
|
||||
+"│ │"
|
||||
+"│ │"
|
||||
+"│ │"
|
||||
+"│ │"
|
||||
+"│ │"
|
||||
+"╰───────────────────────────────────────────────Enter to send | Ctrl+D to quit | Ctrl+J for newline╯"
|
||||
diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__multiple_pastes.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__multiple_pastes.snap
|
||||
new file mode 100644
|
||||
index 0000000000..cd94095431
|
||||
--- /dev/null
|
||||
+++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__multiple_pastes.snap
|
||||
@@ -0,0 +1,14 @@
|
||||
+---
|
||||
+source: tui/src/bottom_pane/chat_composer.rs
|
||||
+expression: terminal.backend()
|
||||
+---
|
||||
+"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮"
|
||||
+"│[Pasted Content 1003 chars][Pasted Content 1007 chars] another short paste │"
|
||||
+"│ │"
|
||||
+"│ │"
|
||||
+"│ │"
|
||||
+"│ │"
|
||||
+"│ │"
|
||||
+"│ │"
|
||||
+"│ │"
|
||||
+"╰───────────────────────────────────────────────Enter to send | Ctrl+D to quit | Ctrl+J for newline╯"
|
||||
diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__small.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__small.snap
|
||||
new file mode 100644
|
||||
index 0000000000..e6b55e36d8
|
||||
--- /dev/null
|
||||
+++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__small.snap
|
||||
@@ -0,0 +1,14 @@
|
||||
+---
|
||||
+source: tui/src/bottom_pane/chat_composer.rs
|
||||
+expression: terminal.backend()
|
||||
+---
|
||||
+"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮"
|
||||
+"│short │"
|
||||
+"│ │"
|
||||
+"│ │"
|
||||
+"│ │"
|
||||
+"│ │"
|
||||
+"│ │"
|
||||
+"│ │"
|
||||
+"│ │"
|
||||
+"╰───────────────────────────────────────────────Enter to send | Ctrl+D to quit | Ctrl+J for newline╯"
|
||||
diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs
|
||||
index 0b623132b5..865e339763 100644
|
||||
--- a/codex-rs/tui/src/chatwidget.rs
|
||||
+++ b/codex-rs/tui/src/chatwidget.rs
|
||||
@@ -174,6 +174,12 @@ impl ChatWidget<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
+ pub(crate) fn handle_paste(&mut self, text: String) {
|
||||
+ if matches!(self.input_focus, InputFocus::BottomPane) {
|
||||
+ self.bottom_pane.handle_paste(text);
|
||||
+ }
|
||||
+ }
|
||||
+
|
||||
fn submit_user_message(&mut self, user_message: UserMessage) {
|
||||
let UserMessage { text, image_paths } = user_message;
|
||||
let mut items: Vec<InputItem> = Vec::new();
|
||||
```
|
||||
|
||||
## Review Comments
|
||||
|
||||
### codex-rs/tui/src/bottom_pane/chat_composer.rs
|
||||
|
||||
- Created: 2025-07-12 18:12:24 UTC | Link: https://github.com/openai/codex/pull/1549#discussion_r2202857188
|
||||
|
||||
```diff
|
||||
@@ -28,6 +28,9 @@ const MIN_TEXTAREA_ROWS: usize = 1;
|
||||
const BORDER_LINES: u16 = 2;
|
||||
|
||||
const BASE_PLACEHOLDER_TEXT: &str = "send a message";
|
||||
+/// If the pasted content exceeds this number of characters, replace it with a
|
||||
+/// placeholder in the UI.
|
||||
+const LARGE_PASTE_CHAR_THRESHOLD: usize = 100;
|
||||
```
|
||||
|
||||
> 100 feels a bit low to me. Maybe we start with 500 or even `1_000` and see how that goes?
|
||||
>
|
||||
> If the user is likely to edit the text, then it will be frustrating that it's replaced with the placeholder, which is why I'm worried about making the threshold too low.
|
||||
>
|
||||
> I don't really want to make a config option for this, though we could...
|
||||
|
||||
### codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__large.snap
|
||||
|
||||
- Created: 2025-07-12 18:18:13 UTC | Link: https://github.com/openai/codex/pull/1549#discussion_r2202858302
|
||||
|
||||
```diff
|
||||
@@ -0,0 +1,8 @@
|
||||
+---
|
||||
+source: tui/src/bottom_pane/chat_composer.rs
|
||||
+expression: terminal.backend()
|
||||
+---
|
||||
+"╭────────────────────────────╮"
|
||||
+"│t[Pasted Content 105 chars] │"
|
||||
```
|
||||
|
||||
> what's the `t` in front there?
|
||||
590
prs/bolinfest/PR-1571.md
Normal file
590
prs/bolinfest/PR-1571.md
Normal file
@@ -0,0 +1,590 @@
|
||||
# PR #1571: Deduplicate MCP tool names
|
||||
|
||||
- URL: https://github.com/openai/codex/pull/1571
|
||||
- Author: pakrym-oai
|
||||
- Created: 2025-07-14 22:21:40 UTC
|
||||
- Updated: 2025-07-17 18:35:48 UTC
|
||||
- Changes: +219/-22, Files changed: 4, Commits: 16
|
||||
|
||||
## Description
|
||||
|
||||
Store fully qualified names along with tool entries so we don't have to re-parse them.
|
||||
|
||||
Fixes: https://github.com/openai/codex/issues/1289
|
||||
|
||||
## Full Diff
|
||||
|
||||
```diff
|
||||
diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock
|
||||
index e59dbfa255..073faead4a 100644
|
||||
--- a/codex-rs/Cargo.lock
|
||||
+++ b/codex-rs/Cargo.lock
|
||||
@@ -399,6 +399,15 @@ version = "2.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6099cdc01846bc367c4e7dd630dc5966dccf36b652fae7a74e17b640411a91b2"
|
||||
|
||||
+[[package]]
|
||||
+name = "block-buffer"
|
||||
+version = "0.10.4"
|
||||
+source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
+checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
|
||||
+dependencies = [
|
||||
+ "generic-array",
|
||||
+]
|
||||
+
|
||||
[[package]]
|
||||
name = "bstr"
|
||||
version = "1.12.0"
|
||||
@@ -671,6 +680,7 @@ dependencies = [
|
||||
"seccompiler",
|
||||
"serde",
|
||||
"serde_json",
|
||||
+ "sha1",
|
||||
"strum_macros 0.27.1",
|
||||
"tempfile",
|
||||
"thiserror 2.0.12",
|
||||
@@ -932,6 +942,15 @@ version = "0.8.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
|
||||
|
||||
+[[package]]
|
||||
+name = "cpufeatures"
|
||||
+version = "0.2.17"
|
||||
+source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
+checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
|
||||
+dependencies = [
|
||||
+ "libc",
|
||||
+]
|
||||
+
|
||||
[[package]]
|
||||
name = "crc32fast"
|
||||
version = "1.4.2"
|
||||
@@ -1006,6 +1025,16 @@ version = "0.2.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
|
||||
|
||||
+[[package]]
|
||||
+name = "crypto-common"
|
||||
+version = "0.1.6"
|
||||
+source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
+checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3"
|
||||
+dependencies = [
|
||||
+ "generic-array",
|
||||
+ "typenum",
|
||||
+]
|
||||
+
|
||||
[[package]]
|
||||
name = "ctor"
|
||||
version = "0.1.26"
|
||||
@@ -1156,6 +1185,16 @@ version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6184e33543162437515c2e2b48714794e37845ec9851711914eec9d308f6ebe8"
|
||||
|
||||
+[[package]]
|
||||
+name = "digest"
|
||||
+version = "0.10.7"
|
||||
+source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
+checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
|
||||
+dependencies = [
|
||||
+ "block-buffer",
|
||||
+ "crypto-common",
|
||||
+]
|
||||
+
|
||||
[[package]]
|
||||
name = "dirs"
|
||||
version = "6.0.0"
|
||||
@@ -1645,6 +1684,16 @@ dependencies = [
|
||||
"byteorder",
|
||||
]
|
||||
|
||||
+[[package]]
|
||||
+name = "generic-array"
|
||||
+version = "0.14.7"
|
||||
+source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
+checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
|
||||
+dependencies = [
|
||||
+ "typenum",
|
||||
+ "version_check",
|
||||
+]
|
||||
+
|
||||
[[package]]
|
||||
name = "getopts"
|
||||
version = "0.2.23"
|
||||
@@ -3944,6 +3993,17 @@ dependencies = [
|
||||
"syn 2.0.104",
|
||||
]
|
||||
|
||||
+[[package]]
|
||||
+name = "sha1"
|
||||
+version = "0.10.6"
|
||||
+source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
+checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba"
|
||||
+dependencies = [
|
||||
+ "cfg-if",
|
||||
+ "cpufeatures",
|
||||
+ "digest",
|
||||
+]
|
||||
+
|
||||
[[package]]
|
||||
name = "sharded-slab"
|
||||
version = "0.1.7"
|
||||
@@ -4851,6 +4911,12 @@ dependencies = [
|
||||
"unicode-width 0.2.0",
|
||||
]
|
||||
|
||||
+[[package]]
|
||||
+name = "typenum"
|
||||
+version = "1.18.0"
|
||||
+source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
+checksum = "1dccffe3ce07af9386bfd29e80c0ab1a8205a2fc34e4bcd40364df902cfa8f3f"
|
||||
+
|
||||
[[package]]
|
||||
name = "unicase"
|
||||
version = "2.8.1"
|
||||
diff --git a/codex-rs/core/Cargo.toml b/codex-rs/core/Cargo.toml
|
||||
index c55d7d395d..9ce97a4c3a 100644
|
||||
--- a/codex-rs/core/Cargo.toml
|
||||
+++ b/codex-rs/core/Cargo.toml
|
||||
@@ -28,6 +28,7 @@ rand = "0.9"
|
||||
reqwest = { version = "0.12", features = ["json", "stream"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
+sha1 = "0.10.6"
|
||||
strum_macros = "0.27.1"
|
||||
thiserror = "2.0.12"
|
||||
time = { version = "0.3", features = ["formatting", "local-offset", "macros"] }
|
||||
diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs
|
||||
index 5227f93c8e..d4e73b2ebf 100644
|
||||
--- a/codex-rs/core/src/codex.rs
|
||||
+++ b/codex-rs/core/src/codex.rs
|
||||
@@ -51,7 +51,6 @@ use crate::exec::process_exec_tool_call;
|
||||
use crate::exec_env::create_env;
|
||||
use crate::flags::OPENAI_STREAM_MAX_RETRIES;
|
||||
use crate::mcp_connection_manager::McpConnectionManager;
|
||||
-use crate::mcp_connection_manager::try_parse_fully_qualified_tool_name;
|
||||
use crate::mcp_tool_call::handle_mcp_tool_call;
|
||||
use crate::models::ContentItem;
|
||||
use crate::models::FunctionCallOutputPayload;
|
||||
@@ -1292,7 +1291,7 @@ async fn handle_function_call(
|
||||
handle_container_exec_with_params(params, sess, sub_id, call_id).await
|
||||
}
|
||||
_ => {
|
||||
- match try_parse_fully_qualified_tool_name(&name) {
|
||||
+ match sess.mcp_connection_manager.parse_tool_name(&name) {
|
||||
Some((server, tool_name)) => {
|
||||
// TODO(mbolin): Determine appropriate timeout for tool call.
|
||||
let timeout = None;
|
||||
diff --git a/codex-rs/core/src/mcp_connection_manager.rs b/codex-rs/core/src/mcp_connection_manager.rs
|
||||
index 7cf6762752..c8161c9b90 100644
|
||||
--- a/codex-rs/core/src/mcp_connection_manager.rs
|
||||
+++ b/codex-rs/core/src/mcp_connection_manager.rs
|
||||
@@ -7,6 +7,7 @@
|
||||
//! `"<server><MCP_TOOL_NAME_DELIMITER><tool>"` as the key.
|
||||
|
||||
use std::collections::HashMap;
|
||||
+use std::collections::HashSet;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::Context;
|
||||
@@ -16,8 +17,12 @@ use codex_mcp_client::McpClient;
|
||||
use mcp_types::ClientCapabilities;
|
||||
use mcp_types::Implementation;
|
||||
use mcp_types::Tool;
|
||||
+
|
||||
+use sha1::Digest;
|
||||
+use sha1::Sha1;
|
||||
use tokio::task::JoinSet;
|
||||
use tracing::info;
|
||||
+use tracing::warn;
|
||||
|
||||
use crate::config_types::McpServerConfig;
|
||||
|
||||
@@ -26,7 +31,8 @@ use crate::config_types::McpServerConfig;
|
||||
///
|
||||
/// OpenAI requires tool names to conform to `^[a-zA-Z0-9_-]+$`, so we must
|
||||
/// choose a delimiter from this character set.
|
||||
-const MCP_TOOL_NAME_DELIMITER: &str = "__OAI_CODEX_MCP__";
|
||||
+const MCP_TOOL_NAME_DELIMITER: &str = "__";
|
||||
+const MAX_TOOL_NAME_LENGTH: usize = 64;
|
||||
|
||||
/// Timeout for the `tools/list` request.
|
||||
const LIST_TOOLS_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
@@ -35,16 +41,42 @@ const LIST_TOOLS_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
/// spawned successfully.
|
||||
pub type ClientStartErrors = HashMap<String, anyhow::Error>;
|
||||
|
||||
-fn fully_qualified_tool_name(server: &str, tool: &str) -> String {
|
||||
- format!("{server}{MCP_TOOL_NAME_DELIMITER}{tool}")
|
||||
-}
|
||||
+fn qualify_tools(tools: Vec<ToolInfo>) -> HashMap<String, ToolInfo> {
|
||||
+ let mut used_names = HashSet::new();
|
||||
+ let mut qualified_tools = HashMap::new();
|
||||
+ for tool in tools {
|
||||
+ let mut qualified_name = format!(
|
||||
+ "{}{}{}",
|
||||
+ tool.server_name, MCP_TOOL_NAME_DELIMITER, tool.tool_name
|
||||
+ );
|
||||
+ if qualified_name.len() > MAX_TOOL_NAME_LENGTH {
|
||||
+ let mut hasher = Sha1::new();
|
||||
+ hasher.update(qualified_name.as_bytes());
|
||||
+ let sha1 = hasher.finalize();
|
||||
+ let sha1_str = format!("{sha1:x}");
|
||||
+
|
||||
+ // Truncate to make room for the hash suffix
|
||||
+ let prefix_len = MAX_TOOL_NAME_LENGTH - sha1_str.len();
|
||||
+
|
||||
+ qualified_name = format!("{}{}", &qualified_name[..prefix_len], sha1_str);
|
||||
+ }
|
||||
|
||||
-pub(crate) fn try_parse_fully_qualified_tool_name(fq_name: &str) -> Option<(String, String)> {
|
||||
- let (server, tool) = fq_name.split_once(MCP_TOOL_NAME_DELIMITER)?;
|
||||
- if server.is_empty() || tool.is_empty() {
|
||||
- return None;
|
||||
+ if used_names.contains(&qualified_name) {
|
||||
+ warn!("skipping duplicated tool {}", qualified_name);
|
||||
+ continue;
|
||||
+ }
|
||||
+
|
||||
+ used_names.insert(qualified_name.clone());
|
||||
+ qualified_tools.insert(qualified_name, tool);
|
||||
}
|
||||
- Some((server.to_string(), tool.to_string()))
|
||||
+
|
||||
+ qualified_tools
|
||||
+}
|
||||
+
|
||||
+struct ToolInfo {
|
||||
+ server_name: String,
|
||||
+ tool_name: String,
|
||||
+ tool: Tool,
|
||||
}
|
||||
|
||||
/// A thin wrapper around a set of running [`McpClient`] instances.
|
||||
@@ -57,7 +89,7 @@ pub(crate) struct McpConnectionManager {
|
||||
clients: HashMap<String, std::sync::Arc<McpClient>>,
|
||||
|
||||
/// Fully qualified tool name -> tool instance.
|
||||
- tools: HashMap<String, Tool>,
|
||||
+ tools: HashMap<String, ToolInfo>,
|
||||
}
|
||||
|
||||
impl McpConnectionManager {
|
||||
@@ -141,7 +173,9 @@ impl McpConnectionManager {
|
||||
}
|
||||
}
|
||||
|
||||
- let tools = list_all_tools(&clients).await?;
|
||||
+ let all_tools = list_all_tools(&clients).await?;
|
||||
+
|
||||
+ let tools = qualify_tools(all_tools);
|
||||
|
||||
Ok((Self { clients, tools }, errors))
|
||||
}
|
||||
@@ -149,7 +183,10 @@ impl McpConnectionManager {
|
||||
/// Returns a single map that contains **all** tools. Each key is the
|
||||
/// fully-qualified name for the tool.
|
||||
pub fn list_all_tools(&self) -> HashMap<String, Tool> {
|
||||
- self.tools.clone()
|
||||
+ self.tools
|
||||
+ .iter()
|
||||
+ .map(|(name, tool)| (name.clone(), tool.tool.clone()))
|
||||
+ .collect()
|
||||
}
|
||||
|
||||
/// Invoke the tool indicated by the (server, tool) pair.
|
||||
@@ -171,13 +208,19 @@ impl McpConnectionManager {
|
||||
.await
|
||||
.with_context(|| format!("tool call failed for `{server}/{tool}`"))
|
||||
}
|
||||
+
|
||||
+ pub fn parse_tool_name(&self, tool_name: &str) -> Option<(String, String)> {
|
||||
+ self.tools
|
||||
+ .get(tool_name)
|
||||
+ .map(|tool| (tool.server_name.clone(), tool.tool_name.clone()))
|
||||
+ }
|
||||
}
|
||||
|
||||
/// Query every server for its available tools and return a single map that
|
||||
/// contains **all** tools. Each key is the fully-qualified name for the tool.
|
||||
-pub async fn list_all_tools(
|
||||
+async fn list_all_tools(
|
||||
clients: &HashMap<String, std::sync::Arc<McpClient>>,
|
||||
-) -> Result<HashMap<String, Tool>> {
|
||||
+) -> Result<Vec<ToolInfo>> {
|
||||
let mut join_set = JoinSet::new();
|
||||
|
||||
// Spawn one task per server so we can query them concurrently. This
|
||||
@@ -194,18 +237,19 @@ pub async fn list_all_tools(
|
||||
});
|
||||
}
|
||||
|
||||
- let mut aggregated: HashMap<String, Tool> = HashMap::with_capacity(join_set.len());
|
||||
+ let mut aggregated: Vec<ToolInfo> = Vec::with_capacity(join_set.len());
|
||||
|
||||
while let Some(join_res) = join_set.join_next().await {
|
||||
let (server_name, list_result) = join_res?;
|
||||
let list_result = list_result?;
|
||||
|
||||
for tool in list_result.tools {
|
||||
- // TODO(mbolin): escape tool names that contain invalid characters.
|
||||
- let fq_name = fully_qualified_tool_name(&server_name, &tool.name);
|
||||
- if aggregated.insert(fq_name.clone(), tool).is_some() {
|
||||
- panic!("tool name collision for '{fq_name}': suspicious");
|
||||
- }
|
||||
+ let tool_info = ToolInfo {
|
||||
+ server_name: server_name.clone(),
|
||||
+ tool_name: tool.name.clone(),
|
||||
+ tool,
|
||||
+ };
|
||||
+ aggregated.push(tool_info);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -224,3 +268,90 @@ fn is_valid_mcp_server_name(server_name: &str) -> bool {
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
|
||||
}
|
||||
+
|
||||
+#[cfg(test)]
|
||||
+#[allow(clippy::unwrap_used)]
|
||||
+mod tests {
|
||||
+ use super::*;
|
||||
+ use mcp_types::ToolInputSchema;
|
||||
+
|
||||
+ fn create_test_tool(server_name: &str, tool_name: &str) -> ToolInfo {
|
||||
+ ToolInfo {
|
||||
+ server_name: server_name.to_string(),
|
||||
+ tool_name: tool_name.to_string(),
|
||||
+ tool: Tool {
|
||||
+ annotations: None,
|
||||
+ description: Some(format!("Test tool: {tool_name}")),
|
||||
+ input_schema: ToolInputSchema {
|
||||
+ properties: None,
|
||||
+ required: None,
|
||||
+ r#type: "object".to_string(),
|
||||
+ },
|
||||
+ name: tool_name.to_string(),
|
||||
+ },
|
||||
+ }
|
||||
+ }
|
||||
+
|
||||
+ #[test]
|
||||
+ fn test_qualify_tools_short_non_duplicated_names() {
|
||||
+ let tools = vec![
|
||||
+ create_test_tool("server1", "tool1"),
|
||||
+ create_test_tool("server1", "tool2"),
|
||||
+ ];
|
||||
+
|
||||
+ let qualified_tools = qualify_tools(tools);
|
||||
+
|
||||
+ assert_eq!(qualified_tools.len(), 2);
|
||||
+ assert!(qualified_tools.contains_key("server1__tool1"));
|
||||
+ assert!(qualified_tools.contains_key("server1__tool2"));
|
||||
+ }
|
||||
+
|
||||
+ #[test]
|
||||
+ fn test_qualify_tools_duplicated_names_skipped() {
|
||||
+ let tools = vec![
|
||||
+ create_test_tool("server1", "duplicate_tool"),
|
||||
+ create_test_tool("server1", "duplicate_tool"),
|
||||
+ ];
|
||||
+
|
||||
+ let qualified_tools = qualify_tools(tools);
|
||||
+
|
||||
+ // Only the first tool should remain, the second is skipped
|
||||
+ assert_eq!(qualified_tools.len(), 1);
|
||||
+ assert!(qualified_tools.contains_key("server1__duplicate_tool"));
|
||||
+ }
|
||||
+
|
||||
+ #[test]
|
||||
+ fn test_qualify_tools_long_names_same_server() {
|
||||
+ let server_name = "my_server";
|
||||
+
|
||||
+ let tools = vec![
|
||||
+ create_test_tool(
|
||||
+ server_name,
|
||||
+ "extremely_lengthy_function_name_that_absolutely_surpasses_all_reasonable_limits",
|
||||
+ ),
|
||||
+ create_test_tool(
|
||||
+ server_name,
|
||||
+ "yet_another_extremely_lengthy_function_name_that_absolutely_surpasses_all_reasonable_limits",
|
||||
+ ),
|
||||
+ ];
|
||||
+
|
||||
+ let qualified_tools = qualify_tools(tools);
|
||||
+
|
||||
+ assert_eq!(qualified_tools.len(), 2);
|
||||
+
|
||||
+ let mut keys: Vec<_> = qualified_tools.keys().cloned().collect();
|
||||
+ keys.sort();
|
||||
+
|
||||
+ assert_eq!(keys[0].len(), 64);
|
||||
+ assert_eq!(
|
||||
+ keys[0],
|
||||
+ "my_server__extremely_lena02e507efc5a9de88637e436690364fd4219e4ef"
|
||||
+ );
|
||||
+
|
||||
+ assert_eq!(keys[1].len(), 64);
|
||||
+ assert_eq!(
|
||||
+ keys[1],
|
||||
+ "my_server__yet_another_e1c3987bd9c50b826cbe1687966f79f0c602d19ca"
|
||||
+ );
|
||||
+ }
|
||||
+}
|
||||
```
|
||||
|
||||
## Review Comments
|
||||
|
||||
### codex-cli/bin/codex.js
|
||||
|
||||
- Created: 2025-07-17 18:12:49 UTC | Link: https://github.com/openai/codex/pull/1571#discussion_r2213985657
|
||||
|
||||
```diff
|
||||
@@ -28,13 +28,14 @@ const __dirname = path.dirname(__filename);
|
||||
// For the @native release of the Node module, the `use-native` file is added,
|
||||
// indicating we should default to the native binary. For other releases,
|
||||
// setting CODEX_RUST=1 will opt-in to the native binary, if included.
|
||||
-const wantsNative = fs.existsSync(path.join(__dirname, "use-native")) ||
|
||||
+const wantsNative =
|
||||
```
|
||||
|
||||
> This is an unrelated change: would you mind removing this from the PR?
|
||||
|
||||
### codex-rs/core/src/mcp_connection_manager.rs
|
||||
|
||||
- Created: 2025-07-17 16:04:00 UTC | Link: https://github.com/openai/codex/pull/1571#discussion_r2213749303
|
||||
|
||||
```diff
|
||||
@@ -35,16 +40,41 @@ const LIST_TOOLS_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
/// spawned successfully.
|
||||
pub type ClientStartErrors = HashMap<String, anyhow::Error>;
|
||||
|
||||
-fn fully_qualified_tool_name(server: &str, tool: &str) -> String {
|
||||
- format!("{server}{MCP_TOOL_NAME_DELIMITER}{tool}")
|
||||
-}
|
||||
+fn qualify_tools(tools: Vec<ToolInfo>) -> HashMap<String, ToolInfo> {
|
||||
+ let mut used_names = HashSet::new();
|
||||
```
|
||||
|
||||
> I think it could be slightly problematic to add a random suffix at the end of the string because if you are trying to do analysis across rollouts, the tool name will not be consistent. What do you think about the following strategy (now that we are keeping track of used names):
|
||||
>
|
||||
> 1. If `tool.tool_name` exceeds `MAX_TOOL_NAME_LENGTH`, go to Step 4.
|
||||
> 2. If `tool.tool_name` is not in `used_names`, just use `tool.tool_name` as the qualified name.
|
||||
> 3. If `{server_name}__{tool_name}` is not in `used_names` and does not exceed `MAX_TOOL_NAME_LENGTH`, use that as the qualified name.
|
||||
> 4a. (Option 1) Take the hash (BLAKE3 or SHA256) of `{server_name}__{tool_name}` and use the 64-digit hex string as the qualified name.
|
||||
> 4b. (Option 2) Take the first 24 characters of `tool_name` and concatenate it with the SHA1 of `{server_name}__{tool_name}`, which is a maximum of 64 digits.
|
||||
>
|
||||
> Alternatively, we could just always hash, but I expect that makes things harder to debug.
|
||||
>
|
||||
> I feel like Option 2 provides a good balance between consistency and readability. What do you think?
|
||||
|
||||
- Created: 2025-07-17 18:17:02 UTC | Link: https://github.com/openai/codex/pull/1571#discussion_r2213994733
|
||||
|
||||
```diff
|
||||
@@ -35,16 +40,41 @@ const LIST_TOOLS_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
/// spawned successfully.
|
||||
pub type ClientStartErrors = HashMap<String, anyhow::Error>;
|
||||
|
||||
-fn fully_qualified_tool_name(server: &str, tool: &str) -> String {
|
||||
- format!("{server}{MCP_TOOL_NAME_DELIMITER}{tool}")
|
||||
-}
|
||||
+fn qualify_tools(tools: Vec<ToolInfo>) -> HashMap<String, ToolInfo> {
|
||||
+ let mut used_names = HashSet::new();
|
||||
```
|
||||
|
||||
> That's a great point about the tool ordering: I like your solution!
|
||||
|
||||
- Created: 2025-07-17 18:18:48 UTC | Link: https://github.com/openai/codex/pull/1571#discussion_r2213999444
|
||||
|
||||
```diff
|
||||
@@ -224,3 +268,90 @@ fn is_valid_mcp_server_name(server_name: &str) -> bool {
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
|
||||
}
|
||||
+
|
||||
+#[cfg(test)]
|
||||
+#[allow(clippy::unwrap_used)]
|
||||
+mod tests {
|
||||
+ use super::*;
|
||||
+ use mcp_types::ToolInputSchema;
|
||||
+
|
||||
+ fn create_test_tool(server_name: &str, tool_name: &str) -> ToolInfo {
|
||||
+ ToolInfo {
|
||||
+ server_name: server_name.to_string(),
|
||||
+ tool_name: tool_name.to_string(),
|
||||
+ tool: Tool {
|
||||
+ annotations: None,
|
||||
+ description: Some(format!("Test tool: {tool_name}")),
|
||||
+ input_schema: ToolInputSchema {
|
||||
+ properties: None,
|
||||
+ required: None,
|
||||
+ r#type: "object".to_string(),
|
||||
+ },
|
||||
+ name: tool_name.to_string(),
|
||||
+ },
|
||||
+ }
|
||||
+ }
|
||||
+
|
||||
+ #[test]
|
||||
+ fn test_qualify_tools_short_non_duplicated_names() {
|
||||
+ let tools = vec![
|
||||
+ create_test_tool("server1", "tool1"),
|
||||
+ create_test_tool("server1", "tool2"),
|
||||
+ ];
|
||||
+
|
||||
+ let qualified_tools = qualify_tools(tools);
|
||||
+
|
||||
+ assert_eq!(qualified_tools.len(), 2);
|
||||
+ assert!(qualified_tools.contains_key("server1__tool1"));
|
||||
+ assert!(qualified_tools.contains_key("server1__tool2"));
|
||||
+ }
|
||||
+
|
||||
+ #[test]
|
||||
+ fn test_qualify_tools_duplicated_names_skipped() {
|
||||
+ let tools = vec![
|
||||
+ create_test_tool("server1", "duplicate_tool"),
|
||||
+ create_test_tool("server1", "duplicate_tool"),
|
||||
+ ];
|
||||
+
|
||||
+ let qualified_tools = qualify_tools(tools);
|
||||
+
|
||||
+ // Only the first tool should remain, the second is skipped
|
||||
+ assert_eq!(qualified_tools.len(), 1);
|
||||
+ assert!(qualified_tools.contains_key("server1__duplicate_tool"));
|
||||
+ }
|
||||
+
|
||||
+ #[test]
|
||||
+ fn test_qualify_tools_long_names_same_server() {
|
||||
+ let server_name = "my_server";
|
||||
+
|
||||
+ let tools = vec![
|
||||
+ create_test_tool(
|
||||
+ server_name,
|
||||
+ "extremely_lengthy_function_name_that_absolutely_surpasses_all_reasonable_limits",
|
||||
+ ),
|
||||
+ create_test_tool(
|
||||
+ server_name,
|
||||
+ "yet_another_extremely_lengthy_function_name_that_absolutely_surpasses_all_reasonable_limits",
|
||||
+ ),
|
||||
+ ];
|
||||
+
|
||||
+ let qualified_tools = qualify_tools(tools);
|
||||
+
|
||||
+ assert_eq!(qualified_tools.len(), 2);
|
||||
+
|
||||
+ let mut keys: Vec<_> = qualified_tools.keys().cloned().collect();
|
||||
+ keys.sort();
|
||||
+
|
||||
+ assert_eq!(keys[0].len(), 64);
|
||||
+ assert_eq!(
|
||||
+ keys[0],
|
||||
+ "my_server__extremely_lena02e507efc5a9de88637e436690364fd4219e4ef"
|
||||
```
|
||||
|
||||
> Seeing this an imagining trying to read it in logs or whatever, I wonder whether we would rather use 15 characters of prefix, `_` as a delimiter, and then the hash, so this would be:
|
||||
>
|
||||
> ```
|
||||
> my_server__extremely_le_a02e507efc5a9de88637e436690364fd4219e4ef
|
||||
> ```
|
||||
>
|
||||
> Though I guess that's not materially more readable? Just a thought: fine either way.
|
||||
101
prs/bolinfest/PR-1578.md
Normal file
101
prs/bolinfest/PR-1578.md
Normal file
@@ -0,0 +1,101 @@
|
||||
# PR #1578: Added mcp-server name validation with regex
|
||||
|
||||
- URL: https://github.com/openai/codex/pull/1578
|
||||
- Author: preetDev004
|
||||
- Created: 2025-07-15 03:50:03 UTC
|
||||
- Updated: 2025-07-16 16:19:44 UTC
|
||||
- Changes: +28/-5, Files changed: 4, Commits: 2
|
||||
|
||||
## Description
|
||||
|
||||
This PR implements server name validation for MCP (Model Context Protocol) servers to ensure they conform to the required pattern `^[a-zA-Z0-9_-]+$`. This addresses the TODO comment in `mcp_connection_manager.rs:82`.
|
||||
|
||||
- Added validation before spawning MCP client tasks
|
||||
- Invalid server names are added to errors map with descriptive messages
|
||||
|
||||
I have read the CLA Document and I hereby sign the CLA
|
||||
|
||||
## Full Diff
|
||||
|
||||
```diff
|
||||
diff --git a/codex-rs/core/src/mcp_connection_manager.rs b/codex-rs/core/src/mcp_connection_manager.rs
|
||||
index 6ae1865f16..7cf6762752 100644
|
||||
--- a/codex-rs/core/src/mcp_connection_manager.rs
|
||||
+++ b/codex-rs/core/src/mcp_connection_manager.rs
|
||||
@@ -79,9 +79,19 @@ impl McpConnectionManager {
|
||||
|
||||
// Launch all configured servers concurrently.
|
||||
let mut join_set = JoinSet::new();
|
||||
+ let mut errors = ClientStartErrors::new();
|
||||
|
||||
for (server_name, cfg) in mcp_servers {
|
||||
- // TODO: Verify server name: require `^[a-zA-Z0-9_-]+$`?
|
||||
+ // Validate server name before spawning
|
||||
+ if !is_valid_mcp_server_name(&server_name) {
|
||||
+ let error = anyhow::anyhow!(
|
||||
+ "invalid server name '{}': must match pattern ^[a-zA-Z0-9_-]+$",
|
||||
+ server_name
|
||||
+ );
|
||||
+ errors.insert(server_name, error);
|
||||
+ continue;
|
||||
+ }
|
||||
+
|
||||
join_set.spawn(async move {
|
||||
let McpServerConfig { command, args, env } = cfg;
|
||||
let client_res = McpClient::new_stdio_client(command, args, env).await;
|
||||
@@ -117,7 +127,6 @@ impl McpConnectionManager {
|
||||
|
||||
let mut clients: HashMap<String, std::sync::Arc<McpClient>> =
|
||||
HashMap::with_capacity(join_set.len());
|
||||
- let mut errors = ClientStartErrors::new();
|
||||
|
||||
while let Some(res) = join_set.join_next().await {
|
||||
let (server_name, client_res) = res?; // JoinError propagation
|
||||
@@ -208,3 +217,10 @@ pub async fn list_all_tools(
|
||||
|
||||
Ok(aggregated)
|
||||
}
|
||||
+
|
||||
+fn is_valid_mcp_server_name(server_name: &str) -> bool {
|
||||
+ !server_name.is_empty()
|
||||
+ && server_name
|
||||
+ .chars()
|
||||
+ .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
|
||||
+}
|
||||
```
|
||||
|
||||
## Review Comments
|
||||
|
||||
### codex-rs/core/Cargo.toml
|
||||
|
||||
- Created: 2025-07-16 15:26:25 UTC | Link: https://github.com/openai/codex/pull/1578#discussion_r2210768032
|
||||
|
||||
```diff
|
||||
@@ -24,7 +24,9 @@ fs2 = "0.4.3"
|
||||
futures = "0.3"
|
||||
mcp-types = { path = "../mcp-types" }
|
||||
mime_guess = "2.0"
|
||||
+once_cell = "1"
|
||||
rand = "0.9"
|
||||
+regex-lite = "0.1"
|
||||
```
|
||||
|
||||
> I know that `regex-lite` is not as heavyweight as the `regex` crate, but it feels like it would be good to avoid adding this dependency for this one feature given that it seems it can be easily avoided. I asked chat for a solution and it gave me:
|
||||
>
|
||||
> ```rust
|
||||
> fn is_valid(s: &str) -> bool {
|
||||
> !s.is_empty() && s.chars().all(|c| {
|
||||
> c.is_ascii_alphanumeric() || c == '_' || c == '-'
|
||||
> })
|
||||
> }
|
||||
>
|
||||
> fn main() {
|
||||
> let test_cases = ["abc123", "abc_123", "abc-123", "abc$", ""];
|
||||
>
|
||||
> for &s in &test_cases {
|
||||
> println!("{s}: {}", is_valid(s));
|
||||
> }
|
||||
> }
|
||||
> ```
|
||||
>
|
||||
> do you want to update this PR or should I just do this in a separate one?
|
||||
174
prs/bolinfest/PR-1582.md
Normal file
174
prs/bolinfest/PR-1582.md
Normal file
@@ -0,0 +1,174 @@
|
||||
# PR #1582: Auto format code on save and add more details to AGENTS.md
|
||||
|
||||
- URL: https://github.com/openai/codex/pull/1582
|
||||
- Author: pakrym-oai
|
||||
- Created: 2025-07-15 14:37:35 UTC
|
||||
- Updated: 2025-07-17 18:40:09 UTC
|
||||
- Changes: +43/-2, Files changed: 7, Commits: 7
|
||||
|
||||
## Description
|
||||
|
||||
Adds a default vscode config with generally applicable settings.
|
||||
Adds makefile with some entrypoints for environment setup and to help agents better verify changes.
|
||||
|
||||
## Full Diff
|
||||
|
||||
```diff
|
||||
diff --git a/.vscode/launch.json b/.vscode/launch.json
|
||||
new file mode 100644
|
||||
index 0000000000..618207f301
|
||||
--- /dev/null
|
||||
+++ b/.vscode/launch.json
|
||||
@@ -0,0 +1,18 @@
|
||||
+{
|
||||
+ "version": "0.2.0",
|
||||
+ "configurations": [
|
||||
+ {
|
||||
+ "type": "lldb",
|
||||
+ "request": "launch",
|
||||
+ "name": "Cargo launch",
|
||||
+ "cargo": {
|
||||
+ "cwd": "${workspaceFolder}/codex-rs",
|
||||
+ "args": [
|
||||
+ "build",
|
||||
+ "--bin=codex-tui"
|
||||
+ ]
|
||||
+ },
|
||||
+ "args": []
|
||||
+ }
|
||||
+ ]
|
||||
+}
|
||||
diff --git a/.vscode/settings.json b/.vscode/settings.json
|
||||
new file mode 100644
|
||||
index 0000000000..f66a12583c
|
||||
--- /dev/null
|
||||
+++ b/.vscode/settings.json
|
||||
@@ -0,0 +1,10 @@
|
||||
+{
|
||||
+ "rust-analyzer.checkOnSave": true,
|
||||
+ "rust-analyzer.check.command": "clippy",
|
||||
+ "rust-analyzer.check.extraArgs": ["--all-features", "--tests"],
|
||||
+ "rust-analyzer.rustfmt.extraArgs": ["--config", "imports_granularity=Item"],
|
||||
+ "[rust]": {
|
||||
+ "editor.defaultFormatter": "rust-lang.rust-analyzer",
|
||||
+ "editor.formatOnSave": true,
|
||||
+ }
|
||||
+}
|
||||
diff --git a/AGENTS.md b/AGENTS.md
|
||||
index 1348e57824..4188cc9873 100644
|
||||
--- a/AGENTS.md
|
||||
+++ b/AGENTS.md
|
||||
@@ -3,3 +3,7 @@
|
||||
In the codex-rs folder where the rust code lives:
|
||||
|
||||
- Never add or modify any code related to `CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR`. You operate in a sandbox where `CODEX_SANDBOX_NETWORK_DISABLED=1` will be set whenever you use the `shell` tool. Any existing code that uses `CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR` was authored with this fact in mind. It is often used to early exit out of tests that the author knew you would not be able to run given your sandbox limitations.
|
||||
+
|
||||
+After making changes to the rust code run `just fmt` (in `codex-rs` directory) to format the code and `just fix` (in `codex-rs` directory) to fix any linter issues in the code.
|
||||
+
|
||||
+Ensure the test suite passes by running `cargo test --all-features` in the `codex-rs` directory.
|
||||
diff --git a/codex-rs/justfile b/codex-rs/justfile
|
||||
index 83a390ec56..6c8e9f9e4d 100644
|
||||
--- a/codex-rs/justfile
|
||||
+++ b/codex-rs/justfile
|
||||
@@ -23,3 +23,9 @@ file-search *args:
|
||||
# format code
|
||||
fmt:
|
||||
cargo fmt -- --config imports_granularity=Item
|
||||
+
|
||||
+fix:
|
||||
+ cargo clippy --fix --all-features --tests --allow-dirty
|
||||
+
|
||||
+install:
|
||||
+ cargo fetch
|
||||
diff --git a/codex-rs/toolchain.toml b/codex-rs/toolchain.toml
|
||||
new file mode 100644
|
||||
index 0000000000..72bafdf4b6
|
||||
--- /dev/null
|
||||
+++ b/codex-rs/toolchain.toml
|
||||
@@ -0,0 +1,3 @@
|
||||
+[toolchain]
|
||||
+channel = "1.88.0"
|
||||
+components = [ "clippy", "rustfmt", "rust-src"]
|
||||
diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs
|
||||
index e1dde8332d..0cf2b3538a 100644
|
||||
--- a/codex-rs/tui/src/app.rs
|
||||
+++ b/codex-rs/tui/src/app.rs
|
||||
@@ -60,7 +60,7 @@ struct ChatWidgetArgs {
|
||||
initial_images: Vec<PathBuf>,
|
||||
}
|
||||
|
||||
-impl<'a> App<'a> {
|
||||
+impl App<'_> {
|
||||
pub(crate) fn new(
|
||||
config: Config,
|
||||
initial_prompt: Option<String>,
|
||||
diff --git a/codex-rs/tui/src/bottom_pane/status_indicator_view.rs b/codex-rs/tui/src/bottom_pane/status_indicator_view.rs
|
||||
index d9ac57d7b9..de46ac2709 100644
|
||||
--- a/codex-rs/tui/src/bottom_pane/status_indicator_view.rs
|
||||
+++ b/codex-rs/tui/src/bottom_pane/status_indicator_view.rs
|
||||
@@ -24,7 +24,7 @@ impl StatusIndicatorView {
|
||||
}
|
||||
}
|
||||
|
||||
-impl<'a> BottomPaneView<'a> for StatusIndicatorView {
|
||||
+impl BottomPaneView<'_> for StatusIndicatorView {
|
||||
fn update_status_text(&mut self, text: String) -> ConditionalUpdate {
|
||||
self.update_text(text);
|
||||
ConditionalUpdate::NeedsRedraw
|
||||
```
|
||||
|
||||
## Review Comments
|
||||
|
||||
### .vscode/settings.json
|
||||
|
||||
- Created: 2025-07-17 18:22:23 UTC | Link: https://github.com/openai/codex/pull/1582#discussion_r2214005370
|
||||
|
||||
```diff
|
||||
@@ -0,0 +1,11 @@
|
||||
+{
|
||||
+ "rust-analyzer.checkOnSave": true,
|
||||
+ "rust-analyzer.check.command": "clippy",
|
||||
+ "rust-analyzer.check.extraArgs": ["--all-features", "--tests"],
|
||||
```
|
||||
|
||||
> Please also add:
|
||||
>
|
||||
> ```json
|
||||
> "rust-analyzer.rustfmt.extraArgs": ["--config", "imports_granularity=Item"],
|
||||
> ```
|
||||
|
||||
- Created: 2025-07-17 18:23:33 UTC | Link: https://github.com/openai/codex/pull/1582#discussion_r2214007185
|
||||
|
||||
```diff
|
||||
@@ -0,0 +1,11 @@
|
||||
+{
|
||||
+ "rust-analyzer.checkOnSave": true,
|
||||
+ "rust-analyzer.check.command": "clippy",
|
||||
+ "rust-analyzer.check.extraArgs": ["--all-features", "--tests"],
|
||||
+
|
||||
+ "[rust]": {
|
||||
+ "editor.defaultFormatter": "rust-lang.rust-analyzer",
|
||||
+ "editor.formatOnSave": true,
|
||||
+ }
|
||||
+}
|
||||
+
|
||||
```
|
||||
|
||||
> add newline at eof?
|
||||
|
||||
### AGENTS.md
|
||||
|
||||
- Created: 2025-07-17 18:25:08 UTC | Link: https://github.com/openai/codex/pull/1582#discussion_r2214011079
|
||||
|
||||
```diff
|
||||
@@ -3,3 +3,7 @@
|
||||
In the codex-rs folder where the rust code lives:
|
||||
|
||||
- Never add or modify any code related to `CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR`. You operate in a sandbox where `CODEX_SANDBOX_NETWORK_DISABLED=1` will be set whenever you use the `shell` tool. Any existing code that uses `CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR` was authored with this fact in mind. It is often used to early exit out of tests that the author knew you would not be able to run given your sandbox limitations.
|
||||
+
|
||||
+After making changes to the rust code run `make format` to format the code and `make fix` to fix the code.
|
||||
```
|
||||
|
||||
> I know everyone has their favorite tool, but can we stick with the `codex-rs/justfile`?
|
||||
>
|
||||
> I admit that `make` is more commonly available than `just`, but it feels a bit weird to have both?
|
||||
353
prs/bolinfest/PR-1587.md
Normal file
353
prs/bolinfest/PR-1587.md
Normal file
@@ -0,0 +1,353 @@
|
||||
# PR #1587: support deltas in core
|
||||
|
||||
- URL: https://github.com/openai/codex/pull/1587
|
||||
- Author: aibrahim-oai
|
||||
- Created: 2025-07-16 04:25:37 UTC
|
||||
- Updated: 2025-07-16 22:11:27 UTC
|
||||
- Changes: +89/-16, Files changed: 10, Commits: 10
|
||||
|
||||
## Description
|
||||
|
||||
- Added support for message and reasoning deltas
|
||||
- Skipped adding the support in the cli and tui for later
|
||||
- Commented a failing test (wrong merge) that needs fix in a separate PR.
|
||||
|
||||
Side note: I think we need to disable merge when the CI don't pass.
|
||||
|
||||
## Full Diff
|
||||
|
||||
```diff
|
||||
diff --git a/codex-rs/core/src/chat_completions.rs b/codex-rs/core/src/chat_completions.rs
|
||||
index 816fc80f9b..ad7b55952a 100644
|
||||
--- a/codex-rs/core/src/chat_completions.rs
|
||||
+++ b/codex-rs/core/src/chat_completions.rs
|
||||
@@ -134,7 +134,7 @@ pub(crate) async fn stream_chat_completions(
|
||||
|
||||
match res {
|
||||
Ok(resp) if resp.status().is_success() => {
|
||||
- let (tx_event, rx_event) = mpsc::channel::<Result<ResponseEvent>>(16);
|
||||
+ let (tx_event, rx_event) = mpsc::channel::<Result<ResponseEvent>>(1600);
|
||||
let stream = resp.bytes_stream().map_err(CodexErr::Reqwest);
|
||||
tokio::spawn(process_chat_sse(stream, tx_event));
|
||||
return Ok(ResponseStream { rx_event });
|
||||
@@ -426,6 +426,12 @@ where
|
||||
// will never appear in a Chat Completions stream.
|
||||
continue;
|
||||
}
|
||||
+ Poll::Ready(Some(Ok(ResponseEvent::OutputTextDelta(_))))
|
||||
+ | Poll::Ready(Some(Ok(ResponseEvent::ReasoningSummaryDelta(_)))) => {
|
||||
+ // Deltas are ignored here since aggregation waits for the
|
||||
+ // final OutputItemDone.
|
||||
+ continue;
|
||||
+ }
|
||||
}
|
||||
}
|
||||
}
|
||||
diff --git a/codex-rs/core/src/client.rs b/codex-rs/core/src/client.rs
|
||||
index 2fa182cf7f..8ec68d02e8 100644
|
||||
--- a/codex-rs/core/src/client.rs
|
||||
+++ b/codex-rs/core/src/client.rs
|
||||
@@ -125,6 +125,7 @@ impl ModelClient {
|
||||
reasoning,
|
||||
previous_response_id: prompt.prev_id.clone(),
|
||||
store: prompt.store,
|
||||
+ // TODO: make this configurable
|
||||
stream: true,
|
||||
};
|
||||
|
||||
@@ -148,7 +149,7 @@ impl ModelClient {
|
||||
let res = req_builder.send().await;
|
||||
match res {
|
||||
Ok(resp) if resp.status().is_success() => {
|
||||
- let (tx_event, rx_event) = mpsc::channel::<Result<ResponseEvent>>(16);
|
||||
+ let (tx_event, rx_event) = mpsc::channel::<Result<ResponseEvent>>(1600);
|
||||
|
||||
// spawn task to process SSE
|
||||
let stream = resp.bytes_stream().map_err(CodexErr::Reqwest);
|
||||
@@ -205,6 +206,7 @@ struct SseEvent {
|
||||
kind: String,
|
||||
response: Option<Value>,
|
||||
item: Option<Value>,
|
||||
+ delta: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -337,6 +339,22 @@ where
|
||||
return;
|
||||
}
|
||||
}
|
||||
+ "response.output_text.delta" => {
|
||||
+ if let Some(delta) = event.delta {
|
||||
+ let event = ResponseEvent::OutputTextDelta(delta);
|
||||
+ if tx_event.send(Ok(event)).await.is_err() {
|
||||
+ return;
|
||||
+ }
|
||||
+ }
|
||||
+ }
|
||||
+ "response.reasoning_summary_text.delta" => {
|
||||
+ if let Some(delta) = event.delta {
|
||||
+ let event = ResponseEvent::ReasoningSummaryDelta(delta);
|
||||
+ if tx_event.send(Ok(event)).await.is_err() {
|
||||
+ return;
|
||||
+ }
|
||||
+ }
|
||||
+ }
|
||||
"response.created" => {
|
||||
if event.response.is_some() {
|
||||
let _ = tx_event.send(Ok(ResponseEvent::Created {})).await;
|
||||
@@ -360,10 +378,8 @@ where
|
||||
| "response.function_call_arguments.delta"
|
||||
| "response.in_progress"
|
||||
| "response.output_item.added"
|
||||
- | "response.output_text.delta"
|
||||
| "response.output_text.done"
|
||||
| "response.reasoning_summary_part.added"
|
||||
- | "response.reasoning_summary_text.delta"
|
||||
| "response.reasoning_summary_text.done" => {
|
||||
// Currently, we ignore these events, but we handle them
|
||||
// separately to skip the logging message in the `other` case.
|
||||
@@ -375,7 +391,7 @@ where
|
||||
|
||||
/// used in tests to stream from a text SSE file
|
||||
async fn stream_from_fixture(path: impl AsRef<Path>) -> Result<ResponseStream> {
|
||||
- let (tx_event, rx_event) = mpsc::channel::<Result<ResponseEvent>>(16);
|
||||
+ let (tx_event, rx_event) = mpsc::channel::<Result<ResponseEvent>>(1600);
|
||||
let f = std::fs::File::open(path.as_ref())?;
|
||||
let lines = std::io::BufReader::new(f).lines();
|
||||
|
||||
diff --git a/codex-rs/core/src/client_common.rs b/codex-rs/core/src/client_common.rs
|
||||
index f9a816a7a9..3e3c2e7efa 100644
|
||||
--- a/codex-rs/core/src/client_common.rs
|
||||
+++ b/codex-rs/core/src/client_common.rs
|
||||
@@ -57,6 +57,8 @@ pub enum ResponseEvent {
|
||||
response_id: String,
|
||||
token_usage: Option<TokenUsage>,
|
||||
},
|
||||
+ OutputTextDelta(String),
|
||||
+ ReasoningSummaryDelta(String),
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs
|
||||
index 52c37c51ee..5227f93c8e 100644
|
||||
--- a/codex-rs/core/src/codex.rs
|
||||
+++ b/codex-rs/core/src/codex.rs
|
||||
@@ -61,7 +61,9 @@ use crate::models::ResponseInputItem;
|
||||
use crate::models::ResponseItem;
|
||||
use crate::models::ShellToolCallParams;
|
||||
use crate::project_doc::get_user_instructions;
|
||||
+use crate::protocol::AgentMessageDeltaEvent;
|
||||
use crate::protocol::AgentMessageEvent;
|
||||
+use crate::protocol::AgentReasoningDeltaEvent;
|
||||
use crate::protocol::AgentReasoningEvent;
|
||||
use crate::protocol::ApplyPatchApprovalRequestEvent;
|
||||
use crate::protocol::AskForApproval;
|
||||
@@ -103,7 +105,7 @@ impl Codex {
|
||||
/// submitted to start the session.
|
||||
pub async fn spawn(config: Config, ctrl_c: Arc<Notify>) -> CodexResult<(Codex, String)> {
|
||||
let (tx_sub, rx_sub) = async_channel::bounded(64);
|
||||
- let (tx_event, rx_event) = async_channel::bounded(64);
|
||||
+ let (tx_event, rx_event) = async_channel::bounded(1600);
|
||||
|
||||
let instructions = get_user_instructions(&config).await;
|
||||
let configure_session = Op::ConfigureSession {
|
||||
@@ -1121,15 +1123,8 @@ async fn try_run_turn(
|
||||
|
||||
let mut stream = sess.client.clone().stream(&prompt).await?;
|
||||
|
||||
- // Buffer all the incoming messages from the stream first, then execute them.
|
||||
- // If we execute a function call in the middle of handling the stream, it can time out.
|
||||
- let mut input = Vec::new();
|
||||
- while let Some(event) = stream.next().await {
|
||||
- input.push(event?);
|
||||
- }
|
||||
-
|
||||
let mut output = Vec::new();
|
||||
- for event in input {
|
||||
+ while let Some(Ok(event)) = stream.next().await {
|
||||
match event {
|
||||
ResponseEvent::Created => {
|
||||
let mut state = sess.state.lock().unwrap();
|
||||
@@ -1172,6 +1167,20 @@ async fn try_run_turn(
|
||||
state.previous_response_id = Some(response_id);
|
||||
break;
|
||||
}
|
||||
+ ResponseEvent::OutputTextDelta(delta) => {
|
||||
+ let event = Event {
|
||||
+ id: sub_id.to_string(),
|
||||
+ msg: EventMsg::AgentMessageDelta(AgentMessageDeltaEvent { delta }),
|
||||
+ };
|
||||
+ sess.tx_event.send(event).await.ok();
|
||||
+ }
|
||||
+ ResponseEvent::ReasoningSummaryDelta(delta) => {
|
||||
+ let event = Event {
|
||||
+ id: sub_id.to_string(),
|
||||
+ msg: EventMsg::AgentReasoningDelta(AgentReasoningDeltaEvent { delta }),
|
||||
+ };
|
||||
+ sess.tx_event.send(event).await.ok();
|
||||
+ }
|
||||
}
|
||||
}
|
||||
Ok(output)
|
||||
diff --git a/codex-rs/core/src/protocol.rs b/codex-rs/core/src/protocol.rs
|
||||
index fa25a2fe38..b233d4f27b 100644
|
||||
--- a/codex-rs/core/src/protocol.rs
|
||||
+++ b/codex-rs/core/src/protocol.rs
|
||||
@@ -282,9 +282,15 @@ pub enum EventMsg {
|
||||
/// Agent text output message
|
||||
AgentMessage(AgentMessageEvent),
|
||||
|
||||
+ /// Agent text output delta message
|
||||
+ AgentMessageDelta(AgentMessageDeltaEvent),
|
||||
+
|
||||
/// Reasoning event from agent.
|
||||
AgentReasoning(AgentReasoningEvent),
|
||||
|
||||
+ /// Agent reasoning delta event from agent.
|
||||
+ AgentReasoningDelta(AgentReasoningDeltaEvent),
|
||||
+
|
||||
/// Ack the client's configure message.
|
||||
SessionConfigured(SessionConfiguredEvent),
|
||||
|
||||
@@ -340,11 +346,21 @@ pub struct AgentMessageEvent {
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
+#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
+pub struct AgentMessageDeltaEvent {
|
||||
+ pub delta: String,
|
||||
+}
|
||||
+
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct AgentReasoningEvent {
|
||||
pub text: String,
|
||||
}
|
||||
|
||||
+#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
+pub struct AgentReasoningDeltaEvent {
|
||||
+ pub delta: String,
|
||||
+}
|
||||
+
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct McpToolCallBeginEvent {
|
||||
/// Identifier so this can be paired with the McpToolCallEnd event.
|
||||
diff --git a/codex-rs/core/tests/cli_stream.rs b/codex-rs/core/tests/cli_stream.rs
|
||||
index df3fedfd48..9ef042eb1a 100644
|
||||
--- a/codex-rs/core/tests/cli_stream.rs
|
||||
+++ b/codex-rs/core/tests/cli_stream.rs
|
||||
@@ -71,8 +71,8 @@ async fn chat_mode_stream_cli() {
|
||||
println!("Stderr:\n{}", String::from_utf8_lossy(&output.stderr));
|
||||
assert!(output.status.success());
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
- assert!(stdout.contains("hi"));
|
||||
- assert_eq!(stdout.matches("hi").count(), 1);
|
||||
+ let hi_lines = stdout.lines().filter(|line| line.trim() == "hi").count();
|
||||
+ assert_eq!(hi_lines, 1, "Expected exactly one line with 'hi'");
|
||||
|
||||
server.verify().await;
|
||||
}
|
||||
diff --git a/codex-rs/core/tests/stream_no_completed.rs b/codex-rs/core/tests/stream_no_completed.rs
|
||||
index da2736aa77..8883eff373 100644
|
||||
--- a/codex-rs/core/tests/stream_no_completed.rs
|
||||
+++ b/codex-rs/core/tests/stream_no_completed.rs
|
||||
@@ -32,6 +32,8 @@ fn sse_completed(id: &str) -> String {
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
+// this test is flaky (has race conditions), so we ignore it for now
|
||||
+#[ignore]
|
||||
async fn retries_on_early_close() {
|
||||
#![allow(clippy::unwrap_used)]
|
||||
|
||||
diff --git a/codex-rs/exec/src/event_processor.rs b/codex-rs/exec/src/event_processor.rs
|
||||
index 540e014298..2a7c4c621b 100644
|
||||
--- a/codex-rs/exec/src/event_processor.rs
|
||||
+++ b/codex-rs/exec/src/event_processor.rs
|
||||
@@ -3,7 +3,9 @@ use codex_common::summarize_sandbox_policy;
|
||||
use codex_core::WireApi;
|
||||
use codex_core::config::Config;
|
||||
use codex_core::model_supports_reasoning_summaries;
|
||||
+use codex_core::protocol::AgentMessageDeltaEvent;
|
||||
use codex_core::protocol::AgentMessageEvent;
|
||||
+use codex_core::protocol::AgentReasoningDeltaEvent;
|
||||
use codex_core::protocol::BackgroundEventEvent;
|
||||
use codex_core::protocol::ErrorEvent;
|
||||
use codex_core::protocol::Event;
|
||||
@@ -184,6 +186,12 @@ impl EventProcessor {
|
||||
EventMsg::TokenCount(TokenUsage { total_tokens, .. }) => {
|
||||
ts_println!(self, "tokens used: {total_tokens}");
|
||||
}
|
||||
+ EventMsg::AgentMessageDelta(AgentMessageDeltaEvent { delta: _ }) => {
|
||||
+ // TODO: think how we want to support this in the CLI
|
||||
+ }
|
||||
+ EventMsg::AgentReasoningDelta(AgentReasoningDeltaEvent { delta: _ }) => {
|
||||
+ // TODO: think how we want to support this in the CLI
|
||||
+ }
|
||||
EventMsg::AgentMessage(AgentMessageEvent { message }) => {
|
||||
ts_println!(
|
||||
self,
|
||||
diff --git a/codex-rs/mcp-server/src/codex_tool_runner.rs b/codex-rs/mcp-server/src/codex_tool_runner.rs
|
||||
index 7c3b02fe5e..88dcf649dc 100644
|
||||
--- a/codex-rs/mcp-server/src/codex_tool_runner.rs
|
||||
+++ b/codex-rs/mcp-server/src/codex_tool_runner.rs
|
||||
@@ -171,6 +171,12 @@ pub async fn run_codex_tool_session(
|
||||
EventMsg::SessionConfigured(_) => {
|
||||
tracing::error!("unexpected SessionConfigured event");
|
||||
}
|
||||
+ EventMsg::AgentMessageDelta(_) => {
|
||||
+ // TODO: think how we want to support this in the MCP
|
||||
+ }
|
||||
+ EventMsg::AgentReasoningDelta(_) => {
|
||||
+ // TODO: think how we want to support this in the MCP
|
||||
+ }
|
||||
EventMsg::Error(_)
|
||||
| EventMsg::TaskStarted
|
||||
| EventMsg::TokenCount(_)
|
||||
diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs
|
||||
index 51fdfc3e8a..28014c6e40 100644
|
||||
--- a/codex-rs/tui/src/chatwidget.rs
|
||||
+++ b/codex-rs/tui/src/chatwidget.rs
|
||||
@@ -3,7 +3,9 @@ use std::sync::Arc;
|
||||
|
||||
use codex_core::codex_wrapper::init_codex;
|
||||
use codex_core::config::Config;
|
||||
+use codex_core::protocol::AgentMessageDeltaEvent;
|
||||
use codex_core::protocol::AgentMessageEvent;
|
||||
+use codex_core::protocol::AgentReasoningDeltaEvent;
|
||||
use codex_core::protocol::AgentReasoningEvent;
|
||||
use codex_core::protocol::ApplyPatchApprovalRequestEvent;
|
||||
use codex_core::protocol::ErrorEvent;
|
||||
@@ -375,6 +377,12 @@ impl ChatWidget<'_> {
|
||||
self.bottom_pane
|
||||
.on_history_entry_response(log_id, offset, entry.map(|e| e.text));
|
||||
}
|
||||
+ EventMsg::AgentMessageDelta(AgentMessageDeltaEvent { delta: _ }) => {
|
||||
+ // TODO: think how we want to support this in the TUI
|
||||
+ }
|
||||
+ EventMsg::AgentReasoningDelta(AgentReasoningDeltaEvent { delta: _ }) => {
|
||||
+ // TODO: think how we want to support this in the TUI
|
||||
+ }
|
||||
event => {
|
||||
self.conversation_history
|
||||
.add_background_event(format!("{event:?}"));
|
||||
```
|
||||
|
||||
## Review Comments
|
||||
|
||||
### codex-rs/core/tests/stream_no_completed.rs
|
||||
|
||||
- Created: 2025-07-16 06:23:28 UTC | Link: https://github.com/openai/codex/pull/1587#discussion_r2209378616
|
||||
|
||||
```diff
|
||||
@@ -1,126 +1,127 @@
|
||||
-//! Verifies that the agent retries when the SSE stream terminates before
|
||||
-//! delivering a `response.completed` event.
|
||||
+/// This file was merged by mistake apparently. Fix will be merged in a separate PR.
|
||||
+// //! Verifies that the agent retries when the SSE stream terminates before
|
||||
+// //! delivering a `response.completed` event.
|
||||
|
||||
-use std::time::Duration;
|
||||
+// use std::time::Duration;
|
||||
```
|
||||
|
||||
> what's with all this commented out code?
|
||||
107
prs/bolinfest/PR-1591.md
Normal file
107
prs/bolinfest/PR-1591.md
Normal file
@@ -0,0 +1,107 @@
|
||||
# PR #1591: Added mcp-server name validation
|
||||
|
||||
- URL: https://github.com/openai/codex/pull/1591
|
||||
- Author: preetDev004
|
||||
- Created: 2025-07-16 16:38:38 UTC
|
||||
- Updated: 2025-07-16 23:00:51 UTC
|
||||
- Changes: +18/-2, Files changed: 1, Commits: 6
|
||||
|
||||
## Description
|
||||
|
||||
This PR implements server name validation for MCP (Model Context Protocol) servers to ensure they conform to the required pattern ^[a-zA-Z0-9_-]+$. This addresses the TODO comment in mcp_connection_manager.rs:82.
|
||||
|
||||
+ Added validation before spawning MCP client tasks
|
||||
+ Invalid server names are added to errors map with descriptive messages
|
||||
|
||||
I have read the CLA Document and I hereby sign the CLA
|
||||
|
||||
## Full Diff
|
||||
|
||||
```diff
|
||||
diff --git a/codex-rs/core/src/mcp_connection_manager.rs b/codex-rs/core/src/mcp_connection_manager.rs
|
||||
index 6ae1865f16..7cf6762752 100644
|
||||
--- a/codex-rs/core/src/mcp_connection_manager.rs
|
||||
+++ b/codex-rs/core/src/mcp_connection_manager.rs
|
||||
@@ -79,9 +79,19 @@ impl McpConnectionManager {
|
||||
|
||||
// Launch all configured servers concurrently.
|
||||
let mut join_set = JoinSet::new();
|
||||
+ let mut errors = ClientStartErrors::new();
|
||||
|
||||
for (server_name, cfg) in mcp_servers {
|
||||
- // TODO: Verify server name: require `^[a-zA-Z0-9_-]+$`?
|
||||
+ // Validate server name before spawning
|
||||
+ if !is_valid_mcp_server_name(&server_name) {
|
||||
+ let error = anyhow::anyhow!(
|
||||
+ "invalid server name '{}': must match pattern ^[a-zA-Z0-9_-]+$",
|
||||
+ server_name
|
||||
+ );
|
||||
+ errors.insert(server_name, error);
|
||||
+ continue;
|
||||
+ }
|
||||
+
|
||||
join_set.spawn(async move {
|
||||
let McpServerConfig { command, args, env } = cfg;
|
||||
let client_res = McpClient::new_stdio_client(command, args, env).await;
|
||||
@@ -117,7 +127,6 @@ impl McpConnectionManager {
|
||||
|
||||
let mut clients: HashMap<String, std::sync::Arc<McpClient>> =
|
||||
HashMap::with_capacity(join_set.len());
|
||||
- let mut errors = ClientStartErrors::new();
|
||||
|
||||
while let Some(res) = join_set.join_next().await {
|
||||
let (server_name, client_res) = res?; // JoinError propagation
|
||||
@@ -208,3 +217,10 @@ pub async fn list_all_tools(
|
||||
|
||||
Ok(aggregated)
|
||||
}
|
||||
+
|
||||
+fn is_valid_mcp_server_name(server_name: &str) -> bool {
|
||||
+ !server_name.is_empty()
|
||||
+ && server_name
|
||||
+ .chars()
|
||||
+ .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
|
||||
+}
|
||||
```
|
||||
|
||||
## Review Comments
|
||||
|
||||
### codex-rs/core/src/mcp_connection_manager.rs
|
||||
|
||||
- Created: 2025-07-16 16:40:53 UTC | Link: https://github.com/openai/codex/pull/1591#discussion_r2210941410
|
||||
|
||||
```diff
|
||||
@@ -79,9 +80,17 @@ impl McpConnectionManager {
|
||||
|
||||
// Launch all configured servers concurrently.
|
||||
let mut join_set = JoinSet::new();
|
||||
+ let mut errors = ClientStartErrors::new();
|
||||
|
||||
for (server_name, cfg) in mcp_servers {
|
||||
- // TODO: Verify server name: require `^[a-zA-Z0-9_-]+$`?
|
||||
+ // Validate server name before spawning
|
||||
+ if !is_valid_server_name(&server_name) {
|
||||
+ let error = anyhow::anyhow!("invalid server name '{}': must match pattern ^[a-zA-Z0-9_-]+$", server_name);
|
||||
+ errors.insert(server_name, error);
|
||||
+ continue;
|
||||
+ }
|
||||
+
|
||||
+ let server_name_cloned = server_name.clone();
|
||||
```
|
||||
|
||||
> Is this necessary? If we have reached this point, nothing else has taken ownership of `server_name`, correct?
|
||||
|
||||
### codex-rs/core/src/util.rs
|
||||
|
||||
- Created: 2025-07-16 16:42:26 UTC | Link: https://github.com/openai/codex/pull/1591#discussion_r2210945426
|
||||
|
||||
```diff
|
||||
@@ -64,3 +64,9 @@ pub fn is_inside_git_repo(config: &Config) -> bool {
|
||||
|
||||
false
|
||||
}
|
||||
+
|
||||
+pub fn is_valid_server_name(server_name: &str) -> bool {
|
||||
```
|
||||
|
||||
> I don't really like having generic "util" files because it's not clear what goes in there: please keep this in `mcp_connection_manager.rs` since that is the only place it is used.
|
||||
1013
prs/bolinfest/PR-1594.md
Normal file
1013
prs/bolinfest/PR-1594.md
Normal file
File diff suppressed because it is too large
Load Diff
289
prs/bolinfest/PR-1596.md
Normal file
289
prs/bolinfest/PR-1596.md
Normal file
@@ -0,0 +1,289 @@
|
||||
# PR #1596: Storing the sessions in a more organized way for easier look up.
|
||||
|
||||
- URL: https://github.com/openai/codex/pull/1596
|
||||
- Author: aibrahim-oai
|
||||
- Created: 2025-07-17 02:08:05 UTC
|
||||
- Updated: 2025-07-17 17:12:25 UTC
|
||||
- Changes: +164/-4, Files changed: 4, Commits: 8
|
||||
|
||||
## Description
|
||||
|
||||
now storing the sessions in `~/.codex/sessions/YYYY/MM/DD/<file>`
|
||||
|
||||
## Full Diff
|
||||
|
||||
```diff
|
||||
diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock
|
||||
index e59dbfa255..b1aa13a12c 100644
|
||||
--- a/codex-rs/Cargo.lock
|
||||
+++ b/codex-rs/Cargo.lock
|
||||
@@ -683,6 +683,7 @@ dependencies = [
|
||||
"tree-sitter",
|
||||
"tree-sitter-bash",
|
||||
"uuid",
|
||||
+ "walkdir",
|
||||
"wildmatch",
|
||||
"wiremock",
|
||||
]
|
||||
diff --git a/codex-rs/core/Cargo.toml b/codex-rs/core/Cargo.toml
|
||||
index c55d7d395d..ff066cc534 100644
|
||||
--- a/codex-rs/core/Cargo.toml
|
||||
+++ b/codex-rs/core/Cargo.toml
|
||||
@@ -65,4 +65,5 @@ predicates = "3"
|
||||
pretty_assertions = "1.4.1"
|
||||
tempfile = "3"
|
||||
tokio-test = "0.4"
|
||||
+walkdir = "2.5.0"
|
||||
wiremock = "0.6"
|
||||
diff --git a/codex-rs/core/src/rollout.rs b/codex-rs/core/src/rollout.rs
|
||||
index c18a58df06..0ff2e94a3a 100644
|
||||
--- a/codex-rs/core/src/rollout.rs
|
||||
+++ b/codex-rs/core/src/rollout.rs
|
||||
@@ -153,14 +153,16 @@ struct LogFileInfo {
|
||||
}
|
||||
|
||||
fn create_log_file(config: &Config, session_id: Uuid) -> std::io::Result<LogFileInfo> {
|
||||
- // Resolve ~/.codex/sessions and create it if missing.
|
||||
+ // Resolve ~/.codex/sessions/YYYY/MM/DD and create it if missing.
|
||||
+ let timestamp = OffsetDateTime::now_local()
|
||||
+ .map_err(|e| IoError::other(format!("failed to get local time: {e}")))?;
|
||||
let mut dir = config.codex_home.clone();
|
||||
dir.push(SESSIONS_SUBDIR);
|
||||
+ dir.push(timestamp.year().to_string());
|
||||
+ dir.push(format!("{:02}", u8::from(timestamp.month())));
|
||||
+ dir.push(format!("{:02}", timestamp.day()));
|
||||
fs::create_dir_all(&dir)?;
|
||||
|
||||
- let timestamp = OffsetDateTime::now_local()
|
||||
- .map_err(|e| IoError::other(format!("failed to get local time: {e}")))?;
|
||||
-
|
||||
// Custom format for YYYY-MM-DDThh-mm-ss. Use `-` instead of `:` for
|
||||
// compatibility with filesystems that do not allow colons in filenames.
|
||||
let format: &[FormatItem] =
|
||||
diff --git a/codex-rs/core/tests/cli_stream.rs b/codex-rs/core/tests/cli_stream.rs
|
||||
index 9ef042eb1a..3669b93f51 100644
|
||||
--- a/codex-rs/core/tests/cli_stream.rs
|
||||
+++ b/codex-rs/core/tests/cli_stream.rs
|
||||
@@ -2,7 +2,12 @@
|
||||
|
||||
use assert_cmd::Command as AssertCommand;
|
||||
use codex_core::exec::CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR;
|
||||
+use serde_json::Value;
|
||||
+use std::time::Duration;
|
||||
+use std::time::Instant;
|
||||
use tempfile::TempDir;
|
||||
+use uuid::Uuid;
|
||||
+use walkdir::WalkDir;
|
||||
use wiremock::Mock;
|
||||
use wiremock::MockServer;
|
||||
use wiremock::ResponseTemplate;
|
||||
@@ -117,3 +122,154 @@ async fn responses_api_stream_cli() {
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
assert!(stdout.contains("fixture hello"));
|
||||
}
|
||||
+
|
||||
+#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
+async fn integration_creates_and_checks_session_file() {
|
||||
+ // Honor sandbox network restrictions for CI parity with the other tests.
|
||||
+ if std::env::var(CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR).is_ok() {
|
||||
+ println!(
|
||||
+ "Skipping test because it cannot execute when network is disabled in a Codex sandbox."
|
||||
+ );
|
||||
+ return;
|
||||
+ }
|
||||
+
|
||||
+ // 1. Temp home so we read/write isolated session files.
|
||||
+ let home = TempDir::new().unwrap();
|
||||
+
|
||||
+ // 2. Unique marker we'll look for in the session log.
|
||||
+ let marker = format!("integration-test-{}", Uuid::new_v4());
|
||||
+ let prompt = format!("echo {marker}");
|
||||
+
|
||||
+ // 3. Use the same offline SSE fixture as responses_api_stream_cli so the test is hermetic.
|
||||
+ let fixture =
|
||||
+ std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/cli_responses_fixture.sse");
|
||||
+
|
||||
+ // 4. Run the codex CLI through cargo (ensures the right bin is built) and invoke `exec`,
|
||||
+ // which is what records a session.
|
||||
+ let mut cmd = AssertCommand::new("cargo");
|
||||
+ cmd.arg("run")
|
||||
+ .arg("-p")
|
||||
+ .arg("codex-cli")
|
||||
+ .arg("--quiet")
|
||||
+ .arg("--")
|
||||
+ .arg("exec")
|
||||
+ .arg("--skip-git-repo-check")
|
||||
+ .arg("-C")
|
||||
+ .arg(env!("CARGO_MANIFEST_DIR"))
|
||||
+ .arg(&prompt);
|
||||
+ cmd.env("CODEX_HOME", home.path())
|
||||
+ .env("OPENAI_API_KEY", "dummy")
|
||||
+ .env("CODEX_RS_SSE_FIXTURE", &fixture)
|
||||
+ // Required for CLI arg parsing even though fixture short-circuits network usage.
|
||||
+ .env("OPENAI_BASE_URL", "http://unused.local");
|
||||
+
|
||||
+ let output = cmd.output().unwrap();
|
||||
+ assert!(
|
||||
+ output.status.success(),
|
||||
+ "codex-cli exec failed: {}",
|
||||
+ String::from_utf8_lossy(&output.stderr)
|
||||
+ );
|
||||
+
|
||||
+ // 5. Sessions are written asynchronously; wait briefly for the directory to appear.
|
||||
+ let sessions_dir = home.path().join("sessions");
|
||||
+ let start = Instant::now();
|
||||
+ while !sessions_dir.exists() && start.elapsed() < Duration::from_secs(2) {
|
||||
+ std::thread::sleep(Duration::from_millis(50));
|
||||
+ }
|
||||
+
|
||||
+ // 6. Scan all session files and find the one that contains our marker.
|
||||
+ let mut matching_files = vec![];
|
||||
+ for entry in WalkDir::new(&sessions_dir) {
|
||||
+ let entry = entry.unwrap();
|
||||
+ if entry.file_type().is_file() && entry.file_name().to_string_lossy().ends_with(".jsonl") {
|
||||
+ let path = entry.path();
|
||||
+ let content = std::fs::read_to_string(path).unwrap();
|
||||
+ let mut lines = content.lines();
|
||||
+ // Skip SessionMeta (first line)
|
||||
+ let _ = lines.next();
|
||||
+ for line in lines {
|
||||
+ let item: Value = serde_json::from_str(line).unwrap();
|
||||
+ if let Some("message") = item.get("type").and_then(|t| t.as_str()) {
|
||||
+ if let Some(content) = item.get("content") {
|
||||
+ if content.to_string().contains(&marker) {
|
||||
+ matching_files.push(path.to_owned());
|
||||
+ break;
|
||||
+ }
|
||||
+ }
|
||||
+ }
|
||||
+ }
|
||||
+ }
|
||||
+ }
|
||||
+ assert_eq!(
|
||||
+ matching_files.len(),
|
||||
+ 1,
|
||||
+ "Expected exactly one session file containing the marker, found {}",
|
||||
+ matching_files.len()
|
||||
+ );
|
||||
+ let path = &matching_files[0];
|
||||
+
|
||||
+ // 7. Verify directory structure: sessions/YYYY/MM/DD/filename.jsonl
|
||||
+ let rel = match path.strip_prefix(&sessions_dir) {
|
||||
+ Ok(r) => r,
|
||||
+ Err(_) => panic!("session file should live under sessions/"),
|
||||
+ };
|
||||
+ let comps: Vec<String> = rel
|
||||
+ .components()
|
||||
+ .map(|c| c.as_os_str().to_string_lossy().into_owned())
|
||||
+ .collect();
|
||||
+ assert_eq!(
|
||||
+ comps.len(),
|
||||
+ 4,
|
||||
+ "Expected sessions/YYYY/MM/DD/<file>, got {rel:?}"
|
||||
+ );
|
||||
+ let year = &comps[0];
|
||||
+ let month = &comps[1];
|
||||
+ let day = &comps[2];
|
||||
+ assert!(
|
||||
+ year.len() == 4 && year.chars().all(|c| c.is_ascii_digit()),
|
||||
+ "Year dir not 4-digit numeric: {year}"
|
||||
+ );
|
||||
+ assert!(
|
||||
+ month.len() == 2 && month.chars().all(|c| c.is_ascii_digit()),
|
||||
+ "Month dir not zero-padded 2-digit numeric: {month}"
|
||||
+ );
|
||||
+ assert!(
|
||||
+ day.len() == 2 && day.chars().all(|c| c.is_ascii_digit()),
|
||||
+ "Day dir not zero-padded 2-digit numeric: {day}"
|
||||
+ );
|
||||
+ // Range checks (best-effort; won't fail on leading zeros)
|
||||
+ if let Ok(m) = month.parse::<u8>() {
|
||||
+ assert!((1..=12).contains(&m), "Month out of range: {m}");
|
||||
+ }
|
||||
+ if let Ok(d) = day.parse::<u8>() {
|
||||
+ assert!((1..=31).contains(&d), "Day out of range: {d}");
|
||||
+ }
|
||||
+
|
||||
+ // 8. Parse SessionMeta line and basic sanity checks.
|
||||
+ let content = std::fs::read_to_string(path).unwrap();
|
||||
+ let mut lines = content.lines();
|
||||
+ let meta: Value = serde_json::from_str(lines.next().unwrap()).unwrap();
|
||||
+ assert!(meta.get("id").is_some(), "SessionMeta missing id");
|
||||
+ assert!(
|
||||
+ meta.get("timestamp").is_some(),
|
||||
+ "SessionMeta missing timestamp"
|
||||
+ );
|
||||
+
|
||||
+ // 9. Confirm at least one message contains the marker.
|
||||
+ let mut found_message = false;
|
||||
+ for line in lines {
|
||||
+ let item: Value = serde_json::from_str(line).unwrap();
|
||||
+ if item.get("type").map(|t| t == "message").unwrap_or(false) {
|
||||
+ if let Some(content) = item.get("content") {
|
||||
+ if content.to_string().contains(&marker) {
|
||||
+ found_message = true;
|
||||
+ break;
|
||||
+ }
|
||||
+ }
|
||||
+ }
|
||||
+ }
|
||||
+ assert!(
|
||||
+ found_message,
|
||||
+ "No message found in session file containing the marker"
|
||||
+ );
|
||||
+}
|
||||
```
|
||||
|
||||
## Review Comments
|
||||
|
||||
### codex-rs/core/Cargo.toml
|
||||
|
||||
- Created: 2025-07-17 05:30:00 UTC | Link: https://github.com/openai/codex/pull/1596#discussion_r2212303676
|
||||
|
||||
```diff
|
||||
@@ -44,6 +44,7 @@ tracing = { version = "0.1.41", features = ["log"] }
|
||||
tree-sitter = "0.25.3"
|
||||
tree-sitter-bash = "0.25.0"
|
||||
uuid = { version = "1", features = ["serde", "v4"] }
|
||||
+walkdir = "2.5.0"
|
||||
```
|
||||
|
||||
> If this is only used for tests (I hope so?), then please put it under `dev-dependencies`.
|
||||
|
||||
### codex-rs/core/tests/cli_stream.rs
|
||||
|
||||
- Created: 2025-07-17 05:31:09 UTC | Link: https://github.com/openai/codex/pull/1596#discussion_r2212306707
|
||||
|
||||
```diff
|
||||
@@ -117,3 +122,154 @@ async fn responses_api_stream_cli() {
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
assert!(stdout.contains("fixture hello"));
|
||||
}
|
||||
+
|
||||
+#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
+async fn integration_creates_and_checks_session_file() {
|
||||
+ // Honor sandbox network restrictions for CI parity with the other tests.
|
||||
+ if std::env::var(CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR).is_ok() {
|
||||
+ println!(
|
||||
+ "Skipping test because it cannot execute when network is disabled in a Codex sandbox."
|
||||
+ );
|
||||
+ return;
|
||||
+ }
|
||||
+
|
||||
+ // 1. Temp home so we read/write isolated session files.
|
||||
+ let home = TempDir::new().unwrap();
|
||||
+
|
||||
+ // 2. Unique marker we'll look for in the session log.
|
||||
+ let marker = format!("integration-test-{}", Uuid::new_v4());
|
||||
+ let prompt = format!("echo {marker}");
|
||||
+
|
||||
+ // 3. Use the same offline SSE fixture as responses_api_stream_cli so the test is hermetic.
|
||||
+ let fixture =
|
||||
+ std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/cli_responses_fixture.sse");
|
||||
+
|
||||
+ // 4. Run the codex CLI through cargo (ensures the right bin is built) and invoke `exec`,
|
||||
+ // which is what records a session.
|
||||
+ let mut cmd = AssertCommand::new("cargo");
|
||||
```
|
||||
|
||||
> Why this instead of `Command::cargo_bin`?
|
||||
960
prs/bolinfest/PR-1598.md
Normal file
960
prs/bolinfest/PR-1598.md
Normal file
@@ -0,0 +1,960 @@
|
||||
# PR #1598: Record Git metadata to rollout
|
||||
|
||||
- URL: https://github.com/openai/codex/pull/1598
|
||||
- Author: vishnu-oai
|
||||
- Created: 2025-07-17 13:43:39 UTC
|
||||
- Updated: 2025-07-24 18:35:35 UTC
|
||||
- Changes: +475/-13, Files changed: 5, Commits: 18
|
||||
|
||||
## Description
|
||||
|
||||
# Summary
|
||||
|
||||
- Writing effective evals for codex sessions requires context of the overall repository state at the moment the session began
|
||||
- This change adds this metadata (git repository, branch, commit hash) to the top of the rollout of the session (if available - if not it doesn't add anything)
|
||||
- Currently, this is only effective on a clean working tree, as we can't track uncommitted/untracked changes with the current metadata set. Ideally in the future we may want to track unclean changes somehow, or perhaps prompt the user to stash or commit them.
|
||||
|
||||
# Testing
|
||||
- Added unit tests
|
||||
- `cargo test && cargo clippy --tests && cargo fmt -- --config imports_granularity=Item`
|
||||
|
||||
### Resulting Rollout
|
||||
<img width="1243" height="127" alt="Screenshot 2025-07-17 at 1 50 00 PM" src="https://github.com/user-attachments/assets/68108941-f015-45b2-985c-ea315ce05415" />
|
||||
|
||||
## Full Diff
|
||||
|
||||
```diff
|
||||
diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs
|
||||
index 4cc888b62e..f35348b779 100644
|
||||
--- a/codex-rs/core/src/codex.rs
|
||||
+++ b/codex-rs/core/src/codex.rs
|
||||
@@ -594,7 +594,7 @@ async fn submission_loop(
|
||||
let mut restored_items: Option<Vec<ResponseItem>> = None;
|
||||
let rollout_recorder: Option<RolloutRecorder> =
|
||||
if let Some(path) = resume_path.as_ref() {
|
||||
- match RolloutRecorder::resume(path).await {
|
||||
+ match RolloutRecorder::resume(path, cwd.clone()).await {
|
||||
Ok((rec, saved)) => {
|
||||
session_id = saved.session_id;
|
||||
if !saved.items.is_empty() {
|
||||
diff --git a/codex-rs/core/src/git_info.rs b/codex-rs/core/src/git_info.rs
|
||||
new file mode 100644
|
||||
index 0000000000..cf959d32d1
|
||||
--- /dev/null
|
||||
+++ b/codex-rs/core/src/git_info.rs
|
||||
@@ -0,0 +1,307 @@
|
||||
+use std::path::Path;
|
||||
+
|
||||
+use serde::Deserialize;
|
||||
+use serde::Serialize;
|
||||
+use tokio::process::Command;
|
||||
+use tokio::time::Duration as TokioDuration;
|
||||
+use tokio::time::timeout;
|
||||
+
|
||||
+/// Timeout for git commands to prevent freezing on large repositories
|
||||
+const GIT_COMMAND_TIMEOUT: TokioDuration = TokioDuration::from_secs(5);
|
||||
+
|
||||
+#[derive(Serialize, Deserialize, Clone)]
|
||||
+pub struct GitInfo {
|
||||
+ /// Current commit hash (SHA)
|
||||
+ #[serde(skip_serializing_if = "Option::is_none")]
|
||||
+ pub commit_hash: Option<String>,
|
||||
+ /// Current branch name
|
||||
+ #[serde(skip_serializing_if = "Option::is_none")]
|
||||
+ pub branch: Option<String>,
|
||||
+ /// Repository URL (if available from remote)
|
||||
+ #[serde(skip_serializing_if = "Option::is_none")]
|
||||
+ pub repository_url: Option<String>,
|
||||
+}
|
||||
+
|
||||
+/// Collect git repository information from the given working directory using command-line git.
|
||||
+/// Returns None if no git repository is found or if git operations fail.
|
||||
+/// Uses timeouts to prevent freezing on large repositories.
|
||||
+/// All git commands (except the initial repo check) run in parallel for better performance.
|
||||
+pub async fn collect_git_info(cwd: &Path) -> Option<GitInfo> {
|
||||
+ // Check if we're in a git repository first
|
||||
+ let is_git_repo = run_git_command_with_timeout(&["rev-parse", "--git-dir"], cwd)
|
||||
+ .await?
|
||||
+ .status
|
||||
+ .success();
|
||||
+
|
||||
+ if !is_git_repo {
|
||||
+ return None;
|
||||
+ }
|
||||
+
|
||||
+ // Run all git info collection commands in parallel
|
||||
+ let (commit_result, branch_result, url_result) = tokio::join!(
|
||||
+ run_git_command_with_timeout(&["rev-parse", "HEAD"], cwd),
|
||||
+ run_git_command_with_timeout(&["rev-parse", "--abbrev-ref", "HEAD"], cwd),
|
||||
+ run_git_command_with_timeout(&["remote", "get-url", "origin"], cwd)
|
||||
+ );
|
||||
+
|
||||
+ let mut git_info = GitInfo {
|
||||
+ commit_hash: None,
|
||||
+ branch: None,
|
||||
+ repository_url: None,
|
||||
+ };
|
||||
+
|
||||
+ // Process commit hash
|
||||
+ if let Some(output) = commit_result {
|
||||
+ if output.status.success() {
|
||||
+ if let Ok(hash) = String::from_utf8(output.stdout) {
|
||||
+ git_info.commit_hash = Some(hash.trim().to_string());
|
||||
+ }
|
||||
+ }
|
||||
+ }
|
||||
+
|
||||
+ // Process branch name
|
||||
+ if let Some(output) = branch_result {
|
||||
+ if output.status.success() {
|
||||
+ if let Ok(branch) = String::from_utf8(output.stdout) {
|
||||
+ let branch = branch.trim();
|
||||
+ if branch != "HEAD" {
|
||||
+ git_info.branch = Some(branch.to_string());
|
||||
+ }
|
||||
+ }
|
||||
+ }
|
||||
+ }
|
||||
+
|
||||
+ // Process repository URL
|
||||
+ if let Some(output) = url_result {
|
||||
+ if output.status.success() {
|
||||
+ if let Ok(url) = String::from_utf8(output.stdout) {
|
||||
+ git_info.repository_url = Some(url.trim().to_string());
|
||||
+ }
|
||||
+ }
|
||||
+ }
|
||||
+
|
||||
+ Some(git_info)
|
||||
+}
|
||||
+
|
||||
+/// Run a git command with a timeout to prevent blocking on large repositories
|
||||
+async fn run_git_command_with_timeout(args: &[&str], cwd: &Path) -> Option<std::process::Output> {
|
||||
+ let result = timeout(
|
||||
+ GIT_COMMAND_TIMEOUT,
|
||||
+ Command::new("git").args(args).current_dir(cwd).output(),
|
||||
+ )
|
||||
+ .await;
|
||||
+
|
||||
+ match result {
|
||||
+ Ok(Ok(output)) => Some(output),
|
||||
+ _ => None, // Timeout or error
|
||||
+ }
|
||||
+}
|
||||
+
|
||||
+#[cfg(test)]
|
||||
+mod tests {
|
||||
+ #![allow(clippy::expect_used)]
|
||||
+ #![allow(clippy::unwrap_used)]
|
||||
+
|
||||
+ use super::*;
|
||||
+
|
||||
+ use std::fs;
|
||||
+ use std::path::PathBuf;
|
||||
+ use tempfile::TempDir;
|
||||
+
|
||||
+ // Helper function to create a test git repository
|
||||
+ async fn create_test_git_repo(temp_dir: &TempDir) -> PathBuf {
|
||||
+ let repo_path = temp_dir.path().to_path_buf();
|
||||
+
|
||||
+ // Initialize git repo
|
||||
+ Command::new("git")
|
||||
+ .args(["init"])
|
||||
+ .current_dir(&repo_path)
|
||||
+ .output()
|
||||
+ .await
|
||||
+ .expect("Failed to init git repo");
|
||||
+
|
||||
+ // Configure git user (required for commits)
|
||||
+ Command::new("git")
|
||||
+ .args(["config", "user.name", "Test User"])
|
||||
+ .current_dir(&repo_path)
|
||||
+ .output()
|
||||
+ .await
|
||||
+ .expect("Failed to set git user name");
|
||||
+
|
||||
+ Command::new("git")
|
||||
+ .args(["config", "user.email", "test@example.com"])
|
||||
+ .current_dir(&repo_path)
|
||||
+ .output()
|
||||
+ .await
|
||||
+ .expect("Failed to set git user email");
|
||||
+
|
||||
+ // Create a test file and commit it
|
||||
+ let test_file = repo_path.join("test.txt");
|
||||
+ fs::write(&test_file, "test content").expect("Failed to write test file");
|
||||
+
|
||||
+ Command::new("git")
|
||||
+ .args(["add", "."])
|
||||
+ .current_dir(&repo_path)
|
||||
+ .output()
|
||||
+ .await
|
||||
+ .expect("Failed to add files");
|
||||
+
|
||||
+ Command::new("git")
|
||||
+ .args(["commit", "-m", "Initial commit"])
|
||||
+ .current_dir(&repo_path)
|
||||
+ .output()
|
||||
+ .await
|
||||
+ .expect("Failed to commit");
|
||||
+
|
||||
+ repo_path
|
||||
+ }
|
||||
+
|
||||
+ #[tokio::test]
|
||||
+ async fn test_collect_git_info_non_git_directory() {
|
||||
+ let temp_dir = TempDir::new().expect("Failed to create temp dir");
|
||||
+ let result = collect_git_info(temp_dir.path()).await;
|
||||
+ assert!(result.is_none());
|
||||
+ }
|
||||
+
|
||||
+ #[tokio::test]
|
||||
+ async fn test_collect_git_info_git_repository() {
|
||||
+ let temp_dir = TempDir::new().expect("Failed to create temp dir");
|
||||
+ let repo_path = create_test_git_repo(&temp_dir).await;
|
||||
+
|
||||
+ let git_info = collect_git_info(&repo_path)
|
||||
+ .await
|
||||
+ .expect("Should collect git info from repo");
|
||||
+
|
||||
+ // Should have commit hash
|
||||
+ assert!(git_info.commit_hash.is_some());
|
||||
+ let commit_hash = git_info.commit_hash.unwrap();
|
||||
+ assert_eq!(commit_hash.len(), 40); // SHA-1 hash should be 40 characters
|
||||
+ assert!(commit_hash.chars().all(|c| c.is_ascii_hexdigit()));
|
||||
+
|
||||
+ // Should have branch (likely "main" or "master")
|
||||
+ assert!(git_info.branch.is_some());
|
||||
+ let branch = git_info.branch.unwrap();
|
||||
+ assert!(branch == "main" || branch == "master");
|
||||
+
|
||||
+ // Repository URL might be None for local repos without remote
|
||||
+ // This is acceptable behavior
|
||||
+ }
|
||||
+
|
||||
+ #[tokio::test]
|
||||
+ async fn test_collect_git_info_with_remote() {
|
||||
+ let temp_dir = TempDir::new().expect("Failed to create temp dir");
|
||||
+ let repo_path = create_test_git_repo(&temp_dir).await;
|
||||
+
|
||||
+ // Add a remote origin
|
||||
+ Command::new("git")
|
||||
+ .args([
|
||||
+ "remote",
|
||||
+ "add",
|
||||
+ "origin",
|
||||
+ "https://github.com/example/repo.git",
|
||||
+ ])
|
||||
+ .current_dir(&repo_path)
|
||||
+ .output()
|
||||
+ .await
|
||||
+ .expect("Failed to add remote");
|
||||
+
|
||||
+ let git_info = collect_git_info(&repo_path)
|
||||
+ .await
|
||||
+ .expect("Should collect git info from repo");
|
||||
+
|
||||
+ // Should have repository URL
|
||||
+ assert_eq!(
|
||||
+ git_info.repository_url,
|
||||
+ Some("https://github.com/example/repo.git".to_string())
|
||||
+ );
|
||||
+ }
|
||||
+
|
||||
+ #[tokio::test]
|
||||
+ async fn test_collect_git_info_detached_head() {
|
||||
+ let temp_dir = TempDir::new().expect("Failed to create temp dir");
|
||||
+ let repo_path = create_test_git_repo(&temp_dir).await;
|
||||
+
|
||||
+ // Get the current commit hash
|
||||
+ let output = Command::new("git")
|
||||
+ .args(["rev-parse", "HEAD"])
|
||||
+ .current_dir(&repo_path)
|
||||
+ .output()
|
||||
+ .await
|
||||
+ .expect("Failed to get HEAD");
|
||||
+ let commit_hash = String::from_utf8(output.stdout).unwrap().trim().to_string();
|
||||
+
|
||||
+ // Checkout the commit directly (detached HEAD)
|
||||
+ Command::new("git")
|
||||
+ .args(["checkout", &commit_hash])
|
||||
+ .current_dir(&repo_path)
|
||||
+ .output()
|
||||
+ .await
|
||||
+ .expect("Failed to checkout commit");
|
||||
+
|
||||
+ let git_info = collect_git_info(&repo_path)
|
||||
+ .await
|
||||
+ .expect("Should collect git info from repo");
|
||||
+
|
||||
+ // Should have commit hash
|
||||
+ assert!(git_info.commit_hash.is_some());
|
||||
+ // Branch should be None for detached HEAD (since rev-parse --abbrev-ref HEAD returns "HEAD")
|
||||
+ assert!(git_info.branch.is_none());
|
||||
+ }
|
||||
+
|
||||
+ #[tokio::test]
|
||||
+ async fn test_collect_git_info_with_branch() {
|
||||
+ let temp_dir = TempDir::new().expect("Failed to create temp dir");
|
||||
+ let repo_path = create_test_git_repo(&temp_dir).await;
|
||||
+
|
||||
+ // Create and checkout a new branch
|
||||
+ Command::new("git")
|
||||
+ .args(["checkout", "-b", "feature-branch"])
|
||||
+ .current_dir(&repo_path)
|
||||
+ .output()
|
||||
+ .await
|
||||
+ .expect("Failed to create branch");
|
||||
+
|
||||
+ let git_info = collect_git_info(&repo_path)
|
||||
+ .await
|
||||
+ .expect("Should collect git info from repo");
|
||||
+
|
||||
+ // Should have the new branch name
|
||||
+ assert_eq!(git_info.branch, Some("feature-branch".to_string()));
|
||||
+ }
|
||||
+
|
||||
+ #[test]
|
||||
+ fn test_git_info_serialization() {
|
||||
+ let git_info = GitInfo {
|
||||
+ commit_hash: Some("abc123def456".to_string()),
|
||||
+ branch: Some("main".to_string()),
|
||||
+ repository_url: Some("https://github.com/example/repo.git".to_string()),
|
||||
+ };
|
||||
+
|
||||
+ let json = serde_json::to_string(&git_info).expect("Should serialize GitInfo");
|
||||
+ let parsed: serde_json::Value = serde_json::from_str(&json).expect("Should parse JSON");
|
||||
+
|
||||
+ assert_eq!(parsed["commit_hash"], "abc123def456");
|
||||
+ assert_eq!(parsed["branch"], "main");
|
||||
+ assert_eq!(
|
||||
+ parsed["repository_url"],
|
||||
+ "https://github.com/example/repo.git"
|
||||
+ );
|
||||
+ }
|
||||
+
|
||||
+ #[test]
|
||||
+ fn test_git_info_serialization_with_nones() {
|
||||
+ let git_info = GitInfo {
|
||||
+ commit_hash: None,
|
||||
+ branch: None,
|
||||
+ repository_url: None,
|
||||
+ };
|
||||
+
|
||||
+ let json = serde_json::to_string(&git_info).expect("Should serialize GitInfo");
|
||||
+ let parsed: serde_json::Value = serde_json::from_str(&json).expect("Should parse JSON");
|
||||
+
|
||||
+ // Fields with None values should be omitted due to skip_serializing_if
|
||||
+ assert!(!parsed.as_object().unwrap().contains_key("commit_hash"));
|
||||
+ assert!(!parsed.as_object().unwrap().contains_key("branch"));
|
||||
+ assert!(!parsed.as_object().unwrap().contains_key("repository_url"));
|
||||
+ }
|
||||
+}
|
||||
diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs
|
||||
index 6812260c97..4e69e94b55 100644
|
||||
--- a/codex-rs/core/src/lib.rs
|
||||
+++ b/codex-rs/core/src/lib.rs
|
||||
@@ -19,6 +19,7 @@ pub mod error;
|
||||
pub mod exec;
|
||||
pub mod exec_env;
|
||||
mod flags;
|
||||
+pub mod git_info;
|
||||
mod is_safe_command;
|
||||
mod mcp_connection_manager;
|
||||
mod mcp_tool_call;
|
||||
diff --git a/codex-rs/core/src/rollout.rs b/codex-rs/core/src/rollout.rs
|
||||
index 7f0f61b9eb..3e6de34d96 100644
|
||||
--- a/codex-rs/core/src/rollout.rs
|
||||
+++ b/codex-rs/core/src/rollout.rs
|
||||
@@ -20,6 +20,8 @@ use tracing::warn;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::config::Config;
|
||||
+use crate::git_info::GitInfo;
|
||||
+use crate::git_info::collect_git_info;
|
||||
use crate::models::ResponseItem;
|
||||
|
||||
const SESSIONS_SUBDIR: &str = "sessions";
|
||||
@@ -31,6 +33,14 @@ pub struct SessionMeta {
|
||||
pub instructions: Option<String>,
|
||||
}
|
||||
|
||||
+#[derive(Serialize)]
|
||||
+struct SessionMetaWithGit {
|
||||
+ #[serde(flatten)]
|
||||
+ meta: SessionMeta,
|
||||
+ #[serde(skip_serializing_if = "Option::is_none")]
|
||||
+ git: Option<GitInfo>,
|
||||
+}
|
||||
+
|
||||
#[derive(Serialize, Deserialize, Default, Clone)]
|
||||
pub struct SessionStateSnapshot {}
|
||||
|
||||
@@ -86,15 +96,12 @@ impl RolloutRecorder {
|
||||
.format(timestamp_format)
|
||||
.map_err(|e| IoError::other(format!("failed to format timestamp: {e}")))?;
|
||||
|
||||
- let meta = SessionMeta {
|
||||
- timestamp,
|
||||
- id: session_id,
|
||||
- instructions,
|
||||
- };
|
||||
+ // Clone the cwd for the spawned task to collect git info asynchronously
|
||||
+ let cwd = config.cwd.clone();
|
||||
|
||||
// A reasonably-sized bounded channel. If the buffer fills up the send
|
||||
// future will yield, which is fine – we only need to ensure we do not
|
||||
- // perform *blocking* I/O on the caller’s thread.
|
||||
+ // perform *blocking* I/O on the caller's thread.
|
||||
let (tx, rx) = mpsc::channel::<RolloutCmd>(256);
|
||||
|
||||
// Spawn a Tokio task that owns the file handle and performs async
|
||||
@@ -103,7 +110,12 @@ impl RolloutRecorder {
|
||||
tokio::task::spawn(rollout_writer(
|
||||
tokio::fs::File::from_std(file),
|
||||
rx,
|
||||
- Some(meta),
|
||||
+ Some(SessionMeta {
|
||||
+ timestamp,
|
||||
+ id: session_id,
|
||||
+ instructions,
|
||||
+ }),
|
||||
+ cwd,
|
||||
));
|
||||
|
||||
Ok(Self { tx })
|
||||
@@ -143,7 +155,10 @@ impl RolloutRecorder {
|
||||
.map_err(|e| IoError::other(format!("failed to queue rollout state: {e}")))
|
||||
}
|
||||
|
||||
- pub async fn resume(path: &Path) -> std::io::Result<(Self, SavedSession)> {
|
||||
+ pub async fn resume(
|
||||
+ path: &Path,
|
||||
+ cwd: std::path::PathBuf,
|
||||
+ ) -> std::io::Result<(Self, SavedSession)> {
|
||||
info!("Resuming rollout from {path:?}");
|
||||
let text = tokio::fs::read_to_string(path).await?;
|
||||
let mut lines = text.lines();
|
||||
@@ -201,7 +216,12 @@ impl RolloutRecorder {
|
||||
.open(path)?;
|
||||
|
||||
let (tx, rx) = mpsc::channel::<RolloutCmd>(256);
|
||||
- tokio::task::spawn(rollout_writer(tokio::fs::File::from_std(file), rx, None));
|
||||
+ tokio::task::spawn(rollout_writer(
|
||||
+ tokio::fs::File::from_std(file),
|
||||
+ rx,
|
||||
+ None,
|
||||
+ cwd,
|
||||
+ ));
|
||||
info!("Resumed rollout successfully from {path:?}");
|
||||
Ok((Self { tx }, saved))
|
||||
}
|
||||
@@ -270,15 +290,26 @@ fn create_log_file(config: &Config, session_id: Uuid) -> std::io::Result<LogFile
|
||||
async fn rollout_writer(
|
||||
mut file: tokio::fs::File,
|
||||
mut rx: mpsc::Receiver<RolloutCmd>,
|
||||
- meta: Option<SessionMeta>,
|
||||
+ mut meta: Option<SessionMeta>,
|
||||
+ cwd: std::path::PathBuf,
|
||||
) {
|
||||
- if let Some(meta) = meta {
|
||||
- if let Ok(json) = serde_json::to_string(&meta) {
|
||||
+ // If we have a meta, collect git info asynchronously and write meta first
|
||||
+ if let Some(session_meta) = meta.take() {
|
||||
+ let git_info = collect_git_info(&cwd).await;
|
||||
+ let session_meta_with_git = SessionMetaWithGit {
|
||||
+ meta: session_meta,
|
||||
+ git: git_info,
|
||||
+ };
|
||||
+
|
||||
+ // Write the SessionMeta as the first item in the file
|
||||
+ if let Ok(json) = serde_json::to_string(&session_meta_with_git) {
|
||||
let _ = file.write_all(json.as_bytes()).await;
|
||||
let _ = file.write_all(b"\n").await;
|
||||
let _ = file.flush().await;
|
||||
}
|
||||
}
|
||||
+
|
||||
+ // Process rollout commands
|
||||
while let Some(cmd) = rx.recv().await {
|
||||
match cmd {
|
||||
RolloutCmd::AddItems(items) => {
|
||||
diff --git a/codex-rs/core/tests/cli_stream.rs b/codex-rs/core/tests/cli_stream.rs
|
||||
index 567279ebd0..4694ba85ed 100644
|
||||
--- a/codex-rs/core/tests/cli_stream.rs
|
||||
+++ b/codex-rs/core/tests/cli_stream.rs
|
||||
@@ -329,6 +329,7 @@ async fn integration_creates_and_checks_session_file() {
|
||||
.env("OPENAI_API_KEY", "dummy")
|
||||
.env("CODEX_RS_SSE_FIXTURE", &fixture)
|
||||
.env("OPENAI_BASE_URL", "http://unused.local");
|
||||
+
|
||||
let output2 = cmd2.output().unwrap();
|
||||
assert!(output2.status.success(), "resume codex-cli run failed");
|
||||
|
||||
@@ -359,3 +360,125 @@ async fn integration_creates_and_checks_session_file() {
|
||||
"rollout missing resumed marker"
|
||||
);
|
||||
}
|
||||
+
|
||||
+/// Integration test to verify git info is collected and recorded in session files.
|
||||
+#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
+async fn integration_git_info_unit_test() {
|
||||
+ // This test verifies git info collection works independently
|
||||
+ // without depending on the full CLI integration
|
||||
+
|
||||
+ // 1. Create temp directory for git repo
|
||||
+ let temp_dir = TempDir::new().unwrap();
|
||||
+ let git_repo = temp_dir.path().to_path_buf();
|
||||
+
|
||||
+ // 2. Initialize a git repository with some content
|
||||
+ let init_output = std::process::Command::new("git")
|
||||
+ .args(["init"])
|
||||
+ .current_dir(&git_repo)
|
||||
+ .output()
|
||||
+ .unwrap();
|
||||
+ assert!(init_output.status.success(), "git init failed");
|
||||
+
|
||||
+ // Configure git user (required for commits)
|
||||
+ std::process::Command::new("git")
|
||||
+ .args(["config", "user.name", "Integration Test"])
|
||||
+ .current_dir(&git_repo)
|
||||
+ .output()
|
||||
+ .unwrap();
|
||||
+
|
||||
+ std::process::Command::new("git")
|
||||
+ .args(["config", "user.email", "test@example.com"])
|
||||
+ .current_dir(&git_repo)
|
||||
+ .output()
|
||||
+ .unwrap();
|
||||
+
|
||||
+ // Create a test file and commit it
|
||||
+ let test_file = git_repo.join("test.txt");
|
||||
+ std::fs::write(&test_file, "integration test content").unwrap();
|
||||
+
|
||||
+ std::process::Command::new("git")
|
||||
+ .args(["add", "."])
|
||||
+ .current_dir(&git_repo)
|
||||
+ .output()
|
||||
+ .unwrap();
|
||||
+
|
||||
+ let commit_output = std::process::Command::new("git")
|
||||
+ .args(["commit", "-m", "Integration test commit"])
|
||||
+ .current_dir(&git_repo)
|
||||
+ .output()
|
||||
+ .unwrap();
|
||||
+ assert!(commit_output.status.success(), "git commit failed");
|
||||
+
|
||||
+ // Create a branch to test branch detection
|
||||
+ std::process::Command::new("git")
|
||||
+ .args(["checkout", "-b", "integration-test-branch"])
|
||||
+ .current_dir(&git_repo)
|
||||
+ .output()
|
||||
+ .unwrap();
|
||||
+
|
||||
+ // Add a remote to test repository URL detection
|
||||
+ std::process::Command::new("git")
|
||||
+ .args([
|
||||
+ "remote",
|
||||
+ "add",
|
||||
+ "origin",
|
||||
+ "https://github.com/example/integration-test.git",
|
||||
+ ])
|
||||
+ .current_dir(&git_repo)
|
||||
+ .output()
|
||||
+ .unwrap();
|
||||
+
|
||||
+ // 3. Test git info collection directly
|
||||
+ let git_info = codex_core::git_info::collect_git_info(&git_repo).await;
|
||||
+
|
||||
+ // 4. Verify git info is present and contains expected data
|
||||
+ assert!(git_info.is_some(), "Git info should be collected");
|
||||
+
|
||||
+ let git_info = git_info.unwrap();
|
||||
+
|
||||
+ // Check that we have a commit hash
|
||||
+ assert!(
|
||||
+ git_info.commit_hash.is_some(),
|
||||
+ "Git info should contain commit_hash"
|
||||
+ );
|
||||
+ let commit_hash = git_info.commit_hash.as_ref().unwrap();
|
||||
+ assert_eq!(commit_hash.len(), 40, "Commit hash should be 40 characters");
|
||||
+ assert!(
|
||||
+ commit_hash.chars().all(|c| c.is_ascii_hexdigit()),
|
||||
+ "Commit hash should be hexadecimal"
|
||||
+ );
|
||||
+
|
||||
+ // Check that we have the correct branch
|
||||
+ assert!(git_info.branch.is_some(), "Git info should contain branch");
|
||||
+ let branch = git_info.branch.as_ref().unwrap();
|
||||
+ assert_eq!(
|
||||
+ branch, "integration-test-branch",
|
||||
+ "Branch should match what we created"
|
||||
+ );
|
||||
+
|
||||
+ // Check that we have the repository URL
|
||||
+ assert!(
|
||||
+ git_info.repository_url.is_some(),
|
||||
+ "Git info should contain repository_url"
|
||||
+ );
|
||||
+ let repo_url = git_info.repository_url.as_ref().unwrap();
|
||||
+ assert_eq!(
|
||||
+ repo_url, "https://github.com/example/integration-test.git",
|
||||
+ "Repository URL should match what we configured"
|
||||
+ );
|
||||
+
|
||||
+ println!("✅ Git info collection test passed!");
|
||||
+ println!(" Commit: {commit_hash}");
|
||||
+ println!(" Branch: {branch}");
|
||||
+ println!(" Repo: {repo_url}");
|
||||
+
|
||||
+ // 5. Test serialization to ensure it works in SessionMeta
|
||||
+ let serialized = serde_json::to_string(&git_info).unwrap();
|
||||
+ let deserialized: codex_core::git_info::GitInfo = serde_json::from_str(&serialized).unwrap();
|
||||
+
|
||||
+ assert_eq!(git_info.commit_hash, deserialized.commit_hash);
|
||||
+ assert_eq!(git_info.branch, deserialized.branch);
|
||||
+ assert_eq!(git_info.repository_url, deserialized.repository_url);
|
||||
+
|
||||
+ println!("✅ Git info serialization test passed!");
|
||||
+}
|
||||
```
|
||||
|
||||
## Review Comments
|
||||
|
||||
### codex-rs/core/src/codex.rs
|
||||
|
||||
- Created: 2025-07-17 22:55:29 UTC | Link: https://github.com/openai/codex/pull/1598#discussion_r2214451100
|
||||
|
||||
```diff
|
||||
@@ -652,7 +652,9 @@ async fn submission_loop(
|
||||
// overlapping rollout file. Consider passing RolloutRecorder
|
||||
// from above.
|
||||
let rollout_recorder =
|
||||
- match RolloutRecorder::new(&config, session_id, instructions.clone()).await {
|
||||
+ match RolloutRecorder::new(&config, session_id, instructions.clone(), &cwd)
|
||||
```
|
||||
|
||||
> `cwd` is available on `Config`?
|
||||
|
||||
### codex-rs/core/src/git_info.rs
|
||||
|
||||
- Created: 2025-07-22 15:26:26 UTC | Link: https://github.com/openai/codex/pull/1598#discussion_r2222934571
|
||||
|
||||
```diff
|
||||
@@ -0,0 +1,307 @@
|
||||
+use std::path::Path;
|
||||
+
|
||||
+use serde::Deserialize;
|
||||
+use serde::Serialize;
|
||||
+use tokio::process::Command;
|
||||
+use tokio::time::Duration as TokioDuration;
|
||||
+use tokio::time::timeout;
|
||||
+
|
||||
+/// Timeout for git commands to prevent freezing on large repositories
|
||||
+const GIT_COMMAND_TIMEOUT: TokioDuration = TokioDuration::from_secs(5);
|
||||
+
|
||||
+#[derive(Serialize, Deserialize, Clone)]
|
||||
+pub struct GitInfo {
|
||||
+ /// Current commit hash (SHA)
|
||||
+ #[serde(skip_serializing_if = "Option::is_none")]
|
||||
+ pub commit_hash: Option<String>,
|
||||
+ /// Current branch name
|
||||
+ #[serde(skip_serializing_if = "Option::is_none")]
|
||||
+ pub branch: Option<String>,
|
||||
+ /// Repository URL (if available from remote)
|
||||
+ #[serde(skip_serializing_if = "Option::is_none")]
|
||||
+ pub repository_url: Option<String>,
|
||||
+}
|
||||
+
|
||||
+/// Run a git command with a timeout to prevent blocking on large repositories
|
||||
+async fn run_git_command_with_timeout(args: &[&str], cwd: &Path) -> Option<std::process::Output> {
|
||||
+ let result = timeout(
|
||||
+ GIT_COMMAND_TIMEOUT,
|
||||
+ Command::new("git").args(args).current_dir(cwd).output(),
|
||||
+ )
|
||||
+ .await;
|
||||
+
|
||||
+ match result {
|
||||
+ Ok(Ok(output)) => Some(output),
|
||||
+ _ => None, // Timeout or error
|
||||
+ }
|
||||
+}
|
||||
+
|
||||
+/// Collect git repository information from the given working directory using command-line git.
|
||||
+/// Returns None if no git repository is found or if git operations fail.
|
||||
+/// Uses timeouts to prevent freezing on large repositories.
|
||||
+/// All git commands (except the initial repo check) run in parallel for better performance.
|
||||
+pub async fn collect_git_info(cwd: &Path) -> Option<GitInfo> {
|
||||
```
|
||||
|
||||
> Please list this after `pub struct GitInfo` so it is at the top since it's the main "export" after the struct.
|
||||
|
||||
### codex-rs/core/src/rollout.rs
|
||||
|
||||
- Created: 2025-07-17 19:05:42 UTC | Link: https://github.com/openai/codex/pull/1598#discussion_r2214082920
|
||||
|
||||
```diff
|
||||
@@ -22,12 +27,105 @@ use crate::models::ResponseItem;
|
||||
/// Folder inside `~/.codex` that holds saved rollouts.
|
||||
const SESSIONS_SUBDIR: &str = "sessions";
|
||||
|
||||
+#[derive(Serialize)]
|
||||
+struct GitInfo {
|
||||
+ /// Current commit hash (SHA)
|
||||
+ #[serde(skip_serializing_if = "Option::is_none")]
|
||||
+ commit_hash: Option<String>,
|
||||
+ /// Current branch name
|
||||
+ #[serde(skip_serializing_if = "Option::is_none")]
|
||||
+ branch: Option<String>,
|
||||
+ /// Repository URL (if available from remote)
|
||||
+ #[serde(skip_serializing_if = "Option::is_none")]
|
||||
+ repository_url: Option<String>,
|
||||
+}
|
||||
+
|
||||
#[derive(Serialize)]
|
||||
struct SessionMeta {
|
||||
id: String,
|
||||
timestamp: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
instructions: Option<String>,
|
||||
+ #[serde(skip_serializing_if = "Option::is_none")]
|
||||
+ git: Option<GitInfo>,
|
||||
+}
|
||||
+
|
||||
+/// Timeout for git commands to prevent freezing on large repositories
|
||||
+const GIT_COMMAND_TIMEOUT: Duration = Duration::from_secs(3);
|
||||
+
|
||||
+/// Run a git command with a timeout to prevent blocking on large repositories
|
||||
+fn run_git_command_with_timeout(args: &[&str], cwd: &Path) -> Option<std::process::Output> {
|
||||
```
|
||||
|
||||
> Can you use tokio::Command and make this `async` instead? It's cheaper to create tokio tasks than POSIX threads. You should then update `collect_git_info()` to make all these calls in parallel.
|
||||
|
||||
- Created: 2025-07-17 22:57:23 UTC | Link: https://github.com/openai/codex/pull/1598#discussion_r2214453622
|
||||
|
||||
```diff
|
||||
@@ -22,12 +26,105 @@ use crate::models::ResponseItem;
|
||||
/// Folder inside `~/.codex` that holds saved rollouts.
|
||||
const SESSIONS_SUBDIR: &str = "sessions";
|
||||
|
||||
+#[derive(Serialize)]
|
||||
```
|
||||
|
||||
> Can you please move all this new code to a separate file? `git_info.rs` perhaps?
|
||||
|
||||
- Created: 2025-07-17 22:58:16 UTC | Link: https://github.com/openai/codex/pull/1598#discussion_r2214454353
|
||||
|
||||
```diff
|
||||
@@ -183,3 +285,212 @@ fn create_log_file(config: &Config, session_id: Uuid) -> std::io::Result<LogFile
|
||||
timestamp,
|
||||
})
|
||||
}
|
||||
+
|
||||
+#[cfg(test)]
|
||||
```
|
||||
|
||||
> Thanks for the nice test!
|
||||
|
||||
- Created: 2025-07-17 23:06:24 UTC | Link: https://github.com/openai/codex/pull/1598#discussion_r2214462855
|
||||
|
||||
```diff
|
||||
@@ -67,16 +165,20 @@ impl RolloutRecorder {
|
||||
.format(timestamp_format)
|
||||
.map_err(|e| IoError::other(format!("failed to format timestamp: {e}")))?;
|
||||
|
||||
+ // Collect git repository information
|
||||
+ let git_info = collect_git_info(cwd).await;
|
||||
```
|
||||
|
||||
> I appreciate the timeouts in `collect_git_info()`, though if I am reading it correctly, I suppose this could add ~6s to startup in the worst case? It would be nice to figure out how to make this truly async, since `RolloutRecorder::new()` is on the critical path to startup.
|
||||
>
|
||||
> The challenge seems to be that we have these lines below:
|
||||
>
|
||||
> ```rust
|
||||
> recorder.record_item(&meta).await?;
|
||||
> Ok(recorder)
|
||||
> ```
|
||||
>
|
||||
> That is, we don't want `new()` to exit until the first item has recorded and now that is dependent on `collect_git_info()`. Certainly this is fixable, but the bookkeeping may be a bit ugly. What do you think?
|
||||
|
||||
- Created: 2025-07-17 23:15:31 UTC | Link: https://github.com/openai/codex/pull/1598#discussion_r2214474763
|
||||
|
||||
```diff
|
||||
@@ -67,16 +165,20 @@ impl RolloutRecorder {
|
||||
.format(timestamp_format)
|
||||
.map_err(|e| IoError::other(format!("failed to format timestamp: {e}")))?;
|
||||
|
||||
+ // Collect git repository information
|
||||
+ let git_info = collect_git_info(cwd).await;
|
||||
```
|
||||
|
||||
> Actually, what if we move `collect_git_info(cwd).await` into the lambda passed to `tokio::task::spawn` and then ensure it is written to `file` before the `while let Some(line) = rx.recv().await` loop starts?
|
||||
>
|
||||
> You could also increase the `git` timeout to 5s maybe?
|
||||
|
||||
- Created: 2025-07-22 15:29:16 UTC | Link: https://github.com/openai/codex/pull/1598#discussion_r2222944436
|
||||
|
||||
```diff
|
||||
@@ -27,6 +29,8 @@ pub struct SessionMeta {
|
||||
pub id: Uuid,
|
||||
pub timestamp: String,
|
||||
pub instructions: Option<String>,
|
||||
+ #[serde(skip_serializing_if = "Option::is_none")]
|
||||
```
|
||||
|
||||
> In general, it's preferable to avoid mutability, so I would rather keep `SessionMeta` as it was before... (see `rollout.rs`)
|
||||
|
||||
- Created: 2025-07-22 15:32:35 UTC | Link: https://github.com/openai/codex/pull/1598#discussion_r2222956970
|
||||
|
||||
```diff
|
||||
@@ -196,7 +203,12 @@ impl RolloutRecorder {
|
||||
.open(path)?;
|
||||
|
||||
let (tx, rx) = mpsc::channel::<RolloutCmd>(256);
|
||||
- tokio::task::spawn(rollout_writer(tokio::fs::File::from_std(file), rx, None));
|
||||
+ tokio::task::spawn(rollout_writer(
|
||||
```
|
||||
|
||||
> Please update the callsite to `resume()` so that it passes `cwd` (it's available on `Op::ConfigureSession`) and then update the third arg to `rollout_writer` to be `PathBuf` instead of `Option<PathBuf>`.
|
||||
|
||||
- Created: 2025-07-22 15:33:02 UTC | Link: https://github.com/openai/codex/pull/1598#discussion_r2222958878
|
||||
|
||||
```diff
|
||||
@@ -250,15 +262,36 @@ fn create_log_file(config: &Config, session_id: Uuid) -> std::io::Result<LogFile
|
||||
async fn rollout_writer(
|
||||
mut file: tokio::fs::File,
|
||||
mut rx: mpsc::Receiver<RolloutCmd>,
|
||||
- meta: Option<SessionMeta>,
|
||||
+ mut meta: Option<SessionMeta>,
|
||||
+ cwd: Option<std::path::PathBuf>,
|
||||
) {
|
||||
- if let Some(meta) = meta {
|
||||
- if let Ok(json) = serde_json::to_string(&meta) {
|
||||
+ // If we have a meta and cwd, collect git info asynchronously and write meta first
|
||||
+ if let (Some(mut session_meta), Some(cwd)) = (meta.take(), cwd) {
|
||||
+ // Skip git collection if disabled via environment variable (for tests)
|
||||
+ let git_info = if std::env::var("CODEX_DISABLE_GIT_INFO").is_ok() {
|
||||
```
|
||||
|
||||
> If increasing the number of threads makes the test pass reliably, then let's remove this?
|
||||
|
||||
- Created: 2025-07-22 15:35:51 UTC | Link: https://github.com/openai/codex/pull/1598#discussion_r2222969734
|
||||
|
||||
```diff
|
||||
@@ -250,15 +262,36 @@ fn create_log_file(config: &Config, session_id: Uuid) -> std::io::Result<LogFile
|
||||
async fn rollout_writer(
|
||||
mut file: tokio::fs::File,
|
||||
mut rx: mpsc::Receiver<RolloutCmd>,
|
||||
- meta: Option<SessionMeta>,
|
||||
+ mut meta: Option<SessionMeta>,
|
||||
+ cwd: Option<std::path::PathBuf>,
|
||||
) {
|
||||
- if let Some(meta) = meta {
|
||||
- if let Ok(json) = serde_json::to_string(&meta) {
|
||||
+ // If we have a meta and cwd, collect git info asynchronously and write meta first
|
||||
+ if let (Some(mut session_meta), Some(cwd)) = (meta.take(), cwd) {
|
||||
+ // Skip git collection if disabled via environment variable (for tests)
|
||||
+ let git_info = if std::env::var("CODEX_DISABLE_GIT_INFO").is_ok() {
|
||||
+ None
|
||||
+ } else {
|
||||
+ // Collect git repository information asynchronously without blocking startup
|
||||
+ collect_git_info(&cwd).await
|
||||
+ };
|
||||
+ session_meta.git = git_info;
|
||||
```
|
||||
|
||||
> Because I suggested removing `git` from `SessionMeta` so we don't have an optional/mutable field, here you can do something like:
|
||||
>
|
||||
> ```suggestion
|
||||
> let SessionMeta { id, timestamp, instructions } = session_meta;
|
||||
> session_meta = json!({
|
||||
> "id" : id,
|
||||
> "timestamp": timestamp,
|
||||
> "instructions": instructions,
|
||||
> "git": git_info,
|
||||
> });
|
||||
> ```
|
||||
>
|
||||
> Or you could introduce a separate struct with the extra field and use `serde_json::to_string()` as before.
|
||||
|
||||
- Created: 2025-07-23 00:42:50 UTC | Link: https://github.com/openai/codex/pull/1598#discussion_r2224080928
|
||||
|
||||
```diff
|
||||
@@ -29,6 +31,14 @@ pub struct SessionMeta {
|
||||
pub instructions: Option<String>,
|
||||
}
|
||||
|
||||
+#[derive(Serialize)]
|
||||
+struct SessionMetaWithGit {
|
||||
+ #[serde(flatten)]
|
||||
```
|
||||
|
||||
> 👍
|
||||
|
||||
- Created: 2025-07-23 00:43:10 UTC | Link: https://github.com/openai/codex/pull/1598#discussion_r2224081213
|
||||
|
||||
```diff
|
||||
@@ -297,3 +335,9 @@ async fn rollout_writer(
|
||||
}
|
||||
}
|
||||
}
|
||||
+
|
||||
```
|
||||
|
||||
> Remove this block?
|
||||
|
||||
- Created: 2025-07-23 00:49:13 UTC | Link: https://github.com/openai/codex/pull/1598#discussion_r2224085948
|
||||
|
||||
```diff
|
||||
@@ -250,15 +262,36 @@ fn create_log_file(config: &Config, session_id: Uuid) -> std::io::Result<LogFile
|
||||
async fn rollout_writer(
|
||||
mut file: tokio::fs::File,
|
||||
mut rx: mpsc::Receiver<RolloutCmd>,
|
||||
- meta: Option<SessionMeta>,
|
||||
+ mut meta: Option<SessionMeta>,
|
||||
+ cwd: Option<std::path::PathBuf>,
|
||||
) {
|
||||
- if let Some(meta) = meta {
|
||||
- if let Ok(json) = serde_json::to_string(&meta) {
|
||||
+ // If we have a meta and cwd, collect git info asynchronously and write meta first
|
||||
+ if let (Some(mut session_meta), Some(cwd)) = (meta.take(), cwd) {
|
||||
+ // Skip git collection if disabled via environment variable (for tests)
|
||||
+ let git_info = if std::env::var("CODEX_DISABLE_GIT_INFO").is_ok() {
|
||||
```
|
||||
|
||||
> https://github.com/openai/codex/pull/1647 might be the fix for this? I would really like to avoid `experimental_disable_git_metadata`, if possible.
|
||||
|
||||
### codex-rs/core/tests/cli_stream.rs
|
||||
|
||||
- Created: 2025-07-22 15:24:13 UTC | Link: https://github.com/openai/codex/pull/1598#discussion_r2222926115
|
||||
|
||||
```diff
|
||||
@@ -328,7 +330,9 @@ async fn integration_creates_and_checks_session_file() {
|
||||
cmd2.env("CODEX_HOME", home.path())
|
||||
.env("OPENAI_API_KEY", "dummy")
|
||||
.env("CODEX_RS_SSE_FIXTURE", &fixture)
|
||||
- .env("OPENAI_BASE_URL", "http://unused.local");
|
||||
+ .env("OPENAI_BASE_URL", "http://unused.local")
|
||||
+ // Disable git info collection to avoid blocking message processing in tests
|
||||
+ .env("CODEX_DISABLE_GIT_INFO", "1");
|
||||
```
|
||||
|
||||
> I have been trying to eliminate support for environment variables in favor of using configuration. Can we just added a config option (prefixed with "experimental" like `experimental_resume`) for this?
|
||||
|
||||
- Created: 2025-07-22 15:25:28 UTC | Link: https://github.com/openai/codex/pull/1598#discussion_r2222930978
|
||||
|
||||
```diff
|
||||
@@ -161,7 +161,9 @@ async fn integration_creates_and_checks_session_file() {
|
||||
.env("OPENAI_API_KEY", "dummy")
|
||||
.env("CODEX_RS_SSE_FIXTURE", &fixture)
|
||||
// Required for CLI arg parsing even though fixture short-circuits network usage.
|
||||
- .env("OPENAI_BASE_URL", "http://unused.local");
|
||||
+ .env("OPENAI_BASE_URL", "http://unused.local")
|
||||
+ // Disable git info collection to avoid blocking message processing in tests
|
||||
```
|
||||
|
||||
> Alternatively, should we increase the number of threads, so increase `2` in this line?
|
||||
>
|
||||
> ```
|
||||
> #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
> ```
|
||||
267
prs/bolinfest/PR-1599.md
Normal file
267
prs/bolinfest/PR-1599.md
Normal file
@@ -0,0 +1,267 @@
|
||||
# PR #1599: Implement redraw debounce
|
||||
|
||||
- URL: https://github.com/openai/codex/pull/1599
|
||||
- Author: aibrahim-oai
|
||||
- Created: 2025-07-17 17:18:15 UTC
|
||||
- Updated: 2025-07-17 19:55:03 UTC
|
||||
- Changes: +49/-7, Files changed: 5, Commits: 6
|
||||
|
||||
## Description
|
||||
|
||||
## Summary
|
||||
- debouce redraw events so repeated requests don't overwhelm the terminal
|
||||
- add `RequestRedraw` event and schedule redraws after 100ms
|
||||
|
||||
## Testing
|
||||
- `cargo clippy --tests`
|
||||
- `cargo test` *(fails: Sandbox Denied errors in landlock tests)*
|
||||
|
||||
------
|
||||
https://chatgpt.com/codex/tasks/task_i_68792a65b8b483218ec90a8f68746cd8
|
||||
|
||||
## Full Diff
|
||||
|
||||
```diff
|
||||
diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs
|
||||
index ac69bef2e9..397c0dc9a4 100644
|
||||
--- a/codex-rs/tui/src/app.rs
|
||||
+++ b/codex-rs/tui/src/app.rs
|
||||
@@ -18,8 +18,15 @@ use crossterm::event::KeyEvent;
|
||||
use crossterm::event::MouseEvent;
|
||||
use crossterm::event::MouseEventKind;
|
||||
use std::path::PathBuf;
|
||||
+use std::sync::Arc;
|
||||
+use std::sync::Mutex;
|
||||
use std::sync::mpsc::Receiver;
|
||||
use std::sync::mpsc::channel;
|
||||
+use std::thread;
|
||||
+use std::time::Duration;
|
||||
+
|
||||
+/// Time window for debouncing redraw requests.
|
||||
+const REDRAW_DEBOUNCE: Duration = Duration::from_millis(100);
|
||||
|
||||
/// Top-level application state: which full-screen view is currently active.
|
||||
#[allow(clippy::large_enum_variant)]
|
||||
@@ -46,6 +53,9 @@ pub(crate) struct App<'a> {
|
||||
|
||||
file_search: FileSearchManager,
|
||||
|
||||
+ /// True when a redraw has been scheduled but not yet executed.
|
||||
+ pending_redraw: Arc<Mutex<bool>>,
|
||||
+
|
||||
/// Stored parameters needed to instantiate the ChatWidget later, e.g.,
|
||||
/// after dismissing the Git-repo warning.
|
||||
chat_args: Option<ChatWidgetArgs>,
|
||||
@@ -70,6 +80,7 @@ impl App<'_> {
|
||||
) -> Self {
|
||||
let (app_event_tx, app_event_rx) = channel();
|
||||
let app_event_tx = AppEventSender::new(app_event_tx);
|
||||
+ let pending_redraw = Arc::new(Mutex::new(false));
|
||||
let scroll_event_helper = ScrollEventHelper::new(app_event_tx.clone());
|
||||
|
||||
// Spawn a dedicated thread for reading the crossterm event loop and
|
||||
@@ -83,7 +94,7 @@ impl App<'_> {
|
||||
app_event_tx.send(AppEvent::KeyEvent(key_event));
|
||||
}
|
||||
crossterm::event::Event::Resize(_, _) => {
|
||||
- app_event_tx.send(AppEvent::Redraw);
|
||||
+ app_event_tx.send(AppEvent::RequestRedraw);
|
||||
}
|
||||
crossterm::event::Event::Mouse(MouseEvent {
|
||||
kind: MouseEventKind::ScrollUp,
|
||||
@@ -152,6 +163,7 @@ impl App<'_> {
|
||||
app_state,
|
||||
config,
|
||||
file_search,
|
||||
+ pending_redraw,
|
||||
chat_args,
|
||||
}
|
||||
}
|
||||
@@ -162,6 +174,29 @@ impl App<'_> {
|
||||
self.app_event_tx.clone()
|
||||
}
|
||||
|
||||
+ /// Schedule a redraw if one is not already pending.
|
||||
+ #[allow(clippy::unwrap_used)]
|
||||
+ fn schedule_redraw(&self) {
|
||||
+ {
|
||||
+ #[allow(clippy::unwrap_used)]
|
||||
+ let mut flag = self.pending_redraw.lock().unwrap();
|
||||
+ if *flag {
|
||||
+ return;
|
||||
+ }
|
||||
+ *flag = true;
|
||||
+ }
|
||||
+
|
||||
+ let tx = self.app_event_tx.clone();
|
||||
+ let pending_redraw = self.pending_redraw.clone();
|
||||
+ thread::spawn(move || {
|
||||
+ thread::sleep(REDRAW_DEBOUNCE);
|
||||
+ tx.send(AppEvent::Redraw);
|
||||
+ #[allow(clippy::unwrap_used)]
|
||||
+ let mut f = pending_redraw.lock().unwrap();
|
||||
+ *f = false;
|
||||
+ });
|
||||
+ }
|
||||
+
|
||||
pub(crate) fn run(
|
||||
&mut self,
|
||||
terminal: &mut tui::Tui,
|
||||
@@ -169,10 +204,13 @@ impl App<'_> {
|
||||
) -> Result<()> {
|
||||
// Insert an event to trigger the first render.
|
||||
let app_event_tx = self.app_event_tx.clone();
|
||||
- app_event_tx.send(AppEvent::Redraw);
|
||||
+ app_event_tx.send(AppEvent::RequestRedraw);
|
||||
|
||||
while let Ok(event) = self.app_event_rx.recv() {
|
||||
match event {
|
||||
+ AppEvent::RequestRedraw => {
|
||||
+ self.schedule_redraw();
|
||||
+ }
|
||||
AppEvent::Redraw => {
|
||||
self.draw_next_frame(terminal)?;
|
||||
}
|
||||
@@ -249,7 +287,7 @@ impl App<'_> {
|
||||
Vec::new(),
|
||||
));
|
||||
self.app_state = AppState::Chat { widget: new_widget };
|
||||
- self.app_event_tx.send(AppEvent::Redraw);
|
||||
+ self.app_event_tx.send(AppEvent::RequestRedraw);
|
||||
}
|
||||
SlashCommand::ToggleMouseMode => {
|
||||
if let Err(e) = mouse_capture.toggle() {
|
||||
@@ -336,7 +374,7 @@ impl App<'_> {
|
||||
args.initial_images,
|
||||
));
|
||||
self.app_state = AppState::Chat { widget };
|
||||
- self.app_event_tx.send(AppEvent::Redraw);
|
||||
+ self.app_event_tx.send(AppEvent::RequestRedraw);
|
||||
}
|
||||
GitWarningOutcome::Quit => {
|
||||
self.app_event_tx.send(AppEvent::ExitRequest);
|
||||
diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs
|
||||
index fd6b2479ee..3aaa789760 100644
|
||||
--- a/codex-rs/tui/src/app_event.rs
|
||||
+++ b/codex-rs/tui/src/app_event.rs
|
||||
@@ -8,6 +8,10 @@ use crate::slash_command::SlashCommand;
|
||||
pub(crate) enum AppEvent {
|
||||
CodexEvent(Event),
|
||||
|
||||
+ /// Request a redraw which will be debounced by the [`App`].
|
||||
+ RequestRedraw,
|
||||
+
|
||||
+ /// Actually draw the next frame.
|
||||
Redraw,
|
||||
|
||||
KeyEvent(KeyEvent),
|
||||
diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs
|
||||
index e4ea1d3823..2a91655cc5 100644
|
||||
--- a/codex-rs/tui/src/bottom_pane/mod.rs
|
||||
+++ b/codex-rs/tui/src/bottom_pane/mod.rs
|
||||
@@ -212,7 +212,7 @@ impl BottomPane<'_> {
|
||||
}
|
||||
|
||||
pub(crate) fn request_redraw(&self) {
|
||||
- self.app_event_tx.send(AppEvent::Redraw)
|
||||
+ self.app_event_tx.send(AppEvent::RequestRedraw)
|
||||
}
|
||||
|
||||
/// Returns true when a popup inside the composer is visible.
|
||||
diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs
|
||||
index 860439ffb6..7c825acd41 100644
|
||||
--- a/codex-rs/tui/src/chatwidget.rs
|
||||
+++ b/codex-rs/tui/src/chatwidget.rs
|
||||
@@ -431,7 +431,7 @@ impl ChatWidget<'_> {
|
||||
}
|
||||
|
||||
fn request_redraw(&mut self) {
|
||||
- self.app_event_tx.send(AppEvent::Redraw);
|
||||
+ self.app_event_tx.send(AppEvent::RequestRedraw);
|
||||
}
|
||||
|
||||
pub(crate) fn add_diff_output(&mut self, diff_output: String) {
|
||||
diff --git a/codex-rs/tui/src/status_indicator_widget.rs b/codex-rs/tui/src/status_indicator_widget.rs
|
||||
index f9b71a23cb..dda61d0bd0 100644
|
||||
--- a/codex-rs/tui/src/status_indicator_widget.rs
|
||||
+++ b/codex-rs/tui/src/status_indicator_widget.rs
|
||||
@@ -65,7 +65,7 @@ impl StatusIndicatorWidget {
|
||||
std::thread::sleep(Duration::from_millis(200));
|
||||
counter = counter.wrapping_add(1);
|
||||
frame_idx_clone.store(counter, Ordering::Relaxed);
|
||||
- app_event_tx_clone.send(AppEvent::Redraw);
|
||||
+ app_event_tx_clone.send(AppEvent::RequestRedraw);
|
||||
}
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
## Review Comments
|
||||
|
||||
### codex-rs/tui/src/app.rs
|
||||
|
||||
- Created: 2025-07-17 18:27:52 UTC | Link: https://github.com/openai/codex/pull/1599#discussion_r2214016700
|
||||
|
||||
```diff
|
||||
@@ -162,17 +174,39 @@ impl<'a> App<'a> {
|
||||
self.app_event_tx.clone()
|
||||
}
|
||||
|
||||
+ /// Schedule a redraw if one is not already pending.
|
||||
+ #[allow(clippy::unwrap_used)]
|
||||
```
|
||||
|
||||
> Can you move this to just above where you use it like on 180?
|
||||
|
||||
- Created: 2025-07-17 18:31:36 UTC | Link: https://github.com/openai/codex/pull/1599#discussion_r2214023745
|
||||
|
||||
```diff
|
||||
@@ -162,17 +174,39 @@ impl<'a> App<'a> {
|
||||
self.app_event_tx.clone()
|
||||
}
|
||||
|
||||
+ /// Schedule a redraw if one is not already pending.
|
||||
+ #[allow(clippy::unwrap_used)]
|
||||
+ fn schedule_redraw(&self) {
|
||||
+ let mut flag = self.pending_redraw.lock().unwrap();
|
||||
+ if *flag {
|
||||
+ return;
|
||||
+ }
|
||||
+ *flag = true;
|
||||
+ let tx = self.app_event_tx.clone();
|
||||
+ let pending = Arc::clone(&self.pending_redraw);
|
||||
+ thread::spawn(move || {
|
||||
+ thread::sleep(REDRAW_DEBOUNCE);
|
||||
+ tx.send(AppEvent::Redraw);
|
||||
+ #[allow(clippy::unwrap_used)]
|
||||
+ let mut f = pending.lock().unwrap();
|
||||
+ *f = false;
|
||||
+ });
|
||||
```
|
||||
|
||||
> This is slightly better because it results in holding the lock for a shorter amount of time.
|
||||
>
|
||||
> That extra level of scoping around the use of `self.pending_redraw.lock()` and `flag` ensures that after `*flag = true`, the lock is dropped.
|
||||
>
|
||||
> Also, `Arc::clone()` is less canonical than just invoking `.clone()`, in my experience.
|
||||
>
|
||||
> ```suggestion
|
||||
> {
|
||||
> #[allow(clippy::unwrap_used)]
|
||||
> let mut flag = self.pending_redraw.lock().unwrap();
|
||||
> if *flag {
|
||||
> return;
|
||||
> }
|
||||
> *flag = true;
|
||||
> }
|
||||
>
|
||||
> let tx = self.app_event_tx.clone();
|
||||
> let pending_redraw = &self.pending_redraw.clone();
|
||||
> thread::spawn(move || {
|
||||
> thread::sleep(REDRAW_DEBOUNCE);
|
||||
> tx.send(AppEvent::Redraw);
|
||||
> #[allow(clippy::unwrap_used)]
|
||||
> let mut f = pending.lock().unwrap();
|
||||
> *f = false;
|
||||
> });
|
||||
> ```
|
||||
1308
prs/bolinfest/PR-1601.md
Normal file
1308
prs/bolinfest/PR-1601.md
Normal file
File diff suppressed because it is too large
Load Diff
915
prs/bolinfest/PR-1602.md
Normal file
915
prs/bolinfest/PR-1602.md
Normal file
@@ -0,0 +1,915 @@
|
||||
# PR #1602: Add session loading support to Codex
|
||||
|
||||
- URL: https://github.com/openai/codex/pull/1602
|
||||
- Author: aibrahim-oai
|
||||
- Created: 2025-07-17 17:57:28 UTC
|
||||
- Updated: 2025-07-27 18:58:21 UTC
|
||||
- Changes: +376/-108, Files changed: 6, Commits: 42
|
||||
|
||||
## Description
|
||||
|
||||
## Summary
|
||||
- extend rollout format to store all session data in JSON
|
||||
- add resume/write helpers for rollouts
|
||||
- track session state after each conversation
|
||||
- support `LoadSession` op to resume a previous rollout
|
||||
- allow starting Codex with an existing session via `experimental_resume` config variable
|
||||
|
||||
We need a way later for exploring the available sessions in a user friendly way.
|
||||
|
||||
## Testing
|
||||
- `cargo test --no-run` *(fails: `cargo: command not found`)*
|
||||
|
||||
------
|
||||
https://chatgpt.com/codex/tasks/task_i_68792a29dd5c832190bf6930d3466fba
|
||||
|
||||
This video is outdated. you should use `-c experimental_resume:<full path>` instead of `--resume <full path>`
|
||||
|
||||
https://github.com/user-attachments/assets/7a9975c7-aa04-4f4e-899a-9e87defd947a
|
||||
|
||||
## Full Diff
|
||||
|
||||
```diff
|
||||
diff --git a/codex-rs/common/src/config_override.rs b/codex-rs/common/src/config_override.rs
|
||||
index 610195d6d1..c9b18edc7c 100644
|
||||
--- a/codex-rs/common/src/config_override.rs
|
||||
+++ b/codex-rs/common/src/config_override.rs
|
||||
@@ -64,7 +64,11 @@ impl CliConfigOverrides {
|
||||
// `-c model=o3` without the quotes.
|
||||
let value: Value = match parse_toml_value(value_str) {
|
||||
Ok(v) => v,
|
||||
- Err(_) => Value::String(value_str.to_string()),
|
||||
+ Err(_) => {
|
||||
+ // Strip leading/trailing quotes if present
|
||||
+ let trimmed = value_str.trim().trim_matches(|c| c == '"' || c == '\'');
|
||||
+ Value::String(trimmed.to_string())
|
||||
+ }
|
||||
};
|
||||
|
||||
Ok((key.to_string(), value))
|
||||
diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs
|
||||
index df1ffad50d..c82f66e939 100644
|
||||
--- a/codex-rs/core/src/codex.rs
|
||||
+++ b/codex-rs/core/src/codex.rs
|
||||
@@ -102,6 +102,9 @@ impl Codex {
|
||||
/// of `Codex` and the ID of the `SessionInitialized` event that was
|
||||
/// submitted to start the session.
|
||||
pub async fn spawn(config: Config, ctrl_c: Arc<Notify>) -> CodexResult<(Codex, String)> {
|
||||
+ // experimental resume path (undocumented)
|
||||
+ let resume_path = config.experimental_resume.clone();
|
||||
+ info!("resume_path: {resume_path:?}");
|
||||
let (tx_sub, rx_sub) = async_channel::bounded(64);
|
||||
let (tx_event, rx_event) = async_channel::bounded(1600);
|
||||
|
||||
@@ -117,6 +120,7 @@ impl Codex {
|
||||
disable_response_storage: config.disable_response_storage,
|
||||
notify: config.notify.clone(),
|
||||
cwd: config.cwd.clone(),
|
||||
+ resume_path: resume_path.clone(),
|
||||
};
|
||||
|
||||
let config = Arc::new(config);
|
||||
@@ -306,24 +310,30 @@ impl Session {
|
||||
/// transcript, if enabled.
|
||||
async fn record_conversation_items(&self, items: &[ResponseItem]) {
|
||||
debug!("Recording items for conversation: {items:?}");
|
||||
- self.record_rollout_items(items).await;
|
||||
+ self.record_state_snapshot(items).await;
|
||||
|
||||
if let Some(transcript) = self.state.lock().unwrap().zdr_transcript.as_mut() {
|
||||
transcript.record_items(items);
|
||||
}
|
||||
}
|
||||
|
||||
- /// Append the given items to the session's rollout transcript (if enabled)
|
||||
- /// and persist them to disk.
|
||||
- async fn record_rollout_items(&self, items: &[ResponseItem]) {
|
||||
- // Clone the recorder outside of the mutex so we don't hold the lock
|
||||
- // across an await point (MutexGuard is not Send).
|
||||
+ async fn record_state_snapshot(&self, items: &[ResponseItem]) {
|
||||
+ let snapshot = {
|
||||
+ let state = self.state.lock().unwrap();
|
||||
+ crate::rollout::SessionStateSnapshot {
|
||||
+ previous_response_id: state.previous_response_id.clone(),
|
||||
+ }
|
||||
+ };
|
||||
+
|
||||
let recorder = {
|
||||
let guard = self.rollout.lock().unwrap();
|
||||
guard.as_ref().cloned()
|
||||
};
|
||||
|
||||
if let Some(rec) = recorder {
|
||||
+ if let Err(e) = rec.record_state(snapshot).await {
|
||||
+ error!("failed to record rollout state: {e:#}");
|
||||
+ }
|
||||
if let Err(e) = rec.record_items(items).await {
|
||||
error!("failed to record rollout items: {e:#}");
|
||||
}
|
||||
@@ -517,7 +527,7 @@ async fn submission_loop(
|
||||
ctrl_c: Arc<Notify>,
|
||||
) {
|
||||
// Generate a unique ID for the lifetime of this Codex session.
|
||||
- let session_id = Uuid::new_v4();
|
||||
+ let mut session_id = Uuid::new_v4();
|
||||
|
||||
let mut sess: Option<Arc<Session>> = None;
|
||||
// shorthand - send an event when there is no active session
|
||||
@@ -570,8 +580,11 @@ async fn submission_loop(
|
||||
disable_response_storage,
|
||||
notify,
|
||||
cwd,
|
||||
+ resume_path,
|
||||
} => {
|
||||
- info!("Configuring session: model={model}; provider={provider:?}");
|
||||
+ info!(
|
||||
+ "Configuring session: model={model}; provider={provider:?}; resume={resume_path:?}"
|
||||
+ );
|
||||
if !cwd.is_absolute() {
|
||||
let message = format!("cwd is not absolute: {cwd:?}");
|
||||
error!(message);
|
||||
@@ -584,6 +597,41 @@ async fn submission_loop(
|
||||
}
|
||||
return;
|
||||
}
|
||||
+ // Optionally resume an existing rollout.
|
||||
+ let mut restored_items: Option<Vec<ResponseItem>> = None;
|
||||
+ let mut restored_prev_id: Option<String> = None;
|
||||
+ let rollout_recorder: Option<RolloutRecorder> =
|
||||
+ if let Some(path) = resume_path.as_ref() {
|
||||
+ match RolloutRecorder::resume(path).await {
|
||||
+ Ok((rec, saved)) => {
|
||||
+ session_id = saved.session_id;
|
||||
+ restored_prev_id = saved.state.previous_response_id;
|
||||
+ if !saved.items.is_empty() {
|
||||
+ restored_items = Some(saved.items);
|
||||
+ }
|
||||
+ Some(rec)
|
||||
+ }
|
||||
+ Err(e) => {
|
||||
+ warn!("failed to resume rollout from {path:?}: {e}");
|
||||
+ None
|
||||
+ }
|
||||
+ }
|
||||
+ } else {
|
||||
+ None
|
||||
+ };
|
||||
+
|
||||
+ let rollout_recorder = match rollout_recorder {
|
||||
+ Some(rec) => Some(rec),
|
||||
+ None => match RolloutRecorder::new(&config, session_id, instructions.clone())
|
||||
+ .await
|
||||
+ {
|
||||
+ Ok(r) => Some(r),
|
||||
+ Err(e) => {
|
||||
+ warn!("failed to initialise rollout recorder: {e}");
|
||||
+ None
|
||||
+ }
|
||||
+ },
|
||||
+ };
|
||||
|
||||
let client = ModelClient::new(
|
||||
config.clone(),
|
||||
@@ -644,21 +692,6 @@ async fn submission_loop(
|
||||
});
|
||||
}
|
||||
}
|
||||
-
|
||||
- // Attempt to create a RolloutRecorder *before* moving the
|
||||
- // `instructions` value into the Session struct.
|
||||
- // TODO: if ConfigureSession is sent twice, we will create an
|
||||
- // overlapping rollout file. Consider passing RolloutRecorder
|
||||
- // from above.
|
||||
- let rollout_recorder =
|
||||
- match RolloutRecorder::new(&config, session_id, instructions.clone()).await {
|
||||
- Ok(r) => Some(r),
|
||||
- Err(e) => {
|
||||
- warn!("failed to initialise rollout recorder: {e}");
|
||||
- None
|
||||
- }
|
||||
- };
|
||||
-
|
||||
sess = Some(Arc::new(Session {
|
||||
client,
|
||||
tx_event: tx_event.clone(),
|
||||
@@ -676,6 +709,19 @@ async fn submission_loop(
|
||||
codex_linux_sandbox_exe: config.codex_linux_sandbox_exe.clone(),
|
||||
}));
|
||||
|
||||
+ // Patch restored state into the newly created session.
|
||||
+ if let Some(sess_arc) = &sess {
|
||||
+ if restored_prev_id.is_some() || restored_items.is_some() {
|
||||
+ let mut st = sess_arc.state.lock().unwrap();
|
||||
+ st.previous_response_id = restored_prev_id;
|
||||
+ if let (Some(hist), Some(items)) =
|
||||
+ (st.zdr_transcript.as_mut(), restored_items.as_ref())
|
||||
+ {
|
||||
+ hist.record_items(items.iter());
|
||||
+ }
|
||||
+ }
|
||||
+ }
|
||||
+
|
||||
// Gather history metadata for SessionConfiguredEvent.
|
||||
let (history_log_id, history_entry_count) =
|
||||
crate::message_history::history_metadata(&config).await;
|
||||
@@ -744,6 +790,8 @@ async fn submission_loop(
|
||||
}
|
||||
}
|
||||
Op::AddToHistory { text } => {
|
||||
+ // TODO: What should we do if we got AddToHistory before ConfigureSession?
|
||||
+ // currently, if ConfigureSession has resume path, this history will be ignored
|
||||
let id = session_id;
|
||||
let config = config.clone();
|
||||
tokio::spawn(async move {
|
||||
diff --git a/codex-rs/core/src/config.rs b/codex-rs/core/src/config.rs
|
||||
index d5b2845398..f1d0dd9d60 100644
|
||||
--- a/codex-rs/core/src/config.rs
|
||||
+++ b/codex-rs/core/src/config.rs
|
||||
@@ -137,6 +137,9 @@ pub struct Config {
|
||||
|
||||
/// Base URL for requests to ChatGPT (as opposed to the OpenAI API).
|
||||
pub chatgpt_base_url: String,
|
||||
+
|
||||
+ /// Experimental rollout resume path (absolute path to .jsonl; undocumented).
|
||||
+ pub experimental_resume: Option<PathBuf>,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
@@ -321,6 +324,9 @@ pub struct ConfigToml {
|
||||
|
||||
/// Base URL for requests to ChatGPT (as opposed to the OpenAI API).
|
||||
pub chatgpt_base_url: Option<String>,
|
||||
+
|
||||
+ /// Experimental rollout resume path (absolute path to .jsonl; undocumented).
|
||||
+ pub experimental_resume: Option<PathBuf>,
|
||||
}
|
||||
|
||||
impl ConfigToml {
|
||||
@@ -448,6 +454,9 @@ impl Config {
|
||||
.as_ref()
|
||||
.map(|info| info.max_output_tokens)
|
||||
});
|
||||
+
|
||||
+ let experimental_resume = cfg.experimental_resume;
|
||||
+
|
||||
let config = Self {
|
||||
model,
|
||||
model_context_window,
|
||||
@@ -494,6 +503,8 @@ impl Config {
|
||||
.chatgpt_base_url
|
||||
.or(cfg.chatgpt_base_url)
|
||||
.unwrap_or("https://chatgpt.com/backend-api/".to_string()),
|
||||
+
|
||||
+ experimental_resume,
|
||||
};
|
||||
Ok(config)
|
||||
}
|
||||
@@ -806,6 +817,7 @@ disable_response_storage = true
|
||||
model_reasoning_summary: ReasoningSummary::Detailed,
|
||||
model_supports_reasoning_summaries: false,
|
||||
chatgpt_base_url: "https://chatgpt.com/backend-api/".to_string(),
|
||||
+ experimental_resume: None,
|
||||
},
|
||||
o3_profile_config
|
||||
);
|
||||
@@ -852,6 +864,7 @@ disable_response_storage = true
|
||||
model_reasoning_summary: ReasoningSummary::default(),
|
||||
model_supports_reasoning_summaries: false,
|
||||
chatgpt_base_url: "https://chatgpt.com/backend-api/".to_string(),
|
||||
+ experimental_resume: None,
|
||||
};
|
||||
|
||||
assert_eq!(expected_gpt3_profile_config, gpt3_profile_config);
|
||||
@@ -913,6 +926,7 @@ disable_response_storage = true
|
||||
model_reasoning_summary: ReasoningSummary::default(),
|
||||
model_supports_reasoning_summaries: false,
|
||||
chatgpt_base_url: "https://chatgpt.com/backend-api/".to_string(),
|
||||
+ experimental_resume: None,
|
||||
};
|
||||
|
||||
assert_eq!(expected_zdr_profile_config, zdr_profile_config);
|
||||
diff --git a/codex-rs/core/src/protocol.rs b/codex-rs/core/src/protocol.rs
|
||||
index b233d4f27b..08d55b9749 100644
|
||||
--- a/codex-rs/core/src/protocol.rs
|
||||
+++ b/codex-rs/core/src/protocol.rs
|
||||
@@ -69,6 +69,10 @@ pub enum Op {
|
||||
/// `ConfigureSession` operation so that the business-logic layer can
|
||||
/// operate deterministically.
|
||||
cwd: std::path::PathBuf,
|
||||
+
|
||||
+ /// Path to a rollout file to resume from.
|
||||
+ #[serde(skip_serializing_if = "Option::is_none")]
|
||||
+ resume_path: Option<std::path::PathBuf>,
|
||||
},
|
||||
|
||||
/// Abort current task.
|
||||
diff --git a/codex-rs/core/src/rollout.rs b/codex-rs/core/src/rollout.rs
|
||||
index 0ff2e94a3a..bb2abe45cd 100644
|
||||
--- a/codex-rs/core/src/rollout.rs
|
||||
+++ b/codex-rs/core/src/rollout.rs
|
||||
@@ -1,33 +1,47 @@
|
||||
-//! Functionality to persist a Codex conversation *rollout* – a linear list of
|
||||
-//! [`ResponseItem`] objects exchanged during a session – to disk so that
|
||||
-//! sessions can be replayed or inspected later (mirrors the behaviour of the
|
||||
-//! upstream TypeScript implementation).
|
||||
+//! Persist Codex session rollouts (.jsonl) so sessions can be replayed or inspected later.
|
||||
|
||||
use std::fs::File;
|
||||
use std::fs::{self};
|
||||
use std::io::Error as IoError;
|
||||
+use std::path::Path;
|
||||
|
||||
+use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
+use serde_json::Value;
|
||||
use time::OffsetDateTime;
|
||||
use time::format_description::FormatItem;
|
||||
use time::macros::format_description;
|
||||
use tokio::io::AsyncWriteExt;
|
||||
use tokio::sync::mpsc::Sender;
|
||||
use tokio::sync::mpsc::{self};
|
||||
+use tracing::info;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::config::Config;
|
||||
use crate::models::ResponseItem;
|
||||
|
||||
-/// Folder inside `~/.codex` that holds saved rollouts.
|
||||
const SESSIONS_SUBDIR: &str = "sessions";
|
||||
|
||||
-#[derive(Serialize)]
|
||||
-struct SessionMeta {
|
||||
- id: String,
|
||||
- timestamp: String,
|
||||
- #[serde(skip_serializing_if = "Option::is_none")]
|
||||
- instructions: Option<String>,
|
||||
+#[derive(Serialize, Deserialize, Clone, Default)]
|
||||
+pub struct SessionMeta {
|
||||
+ pub id: Uuid,
|
||||
+ pub timestamp: String,
|
||||
+ pub instructions: Option<String>,
|
||||
+}
|
||||
+
|
||||
+#[derive(Serialize, Deserialize, Default, Clone)]
|
||||
+pub struct SessionStateSnapshot {
|
||||
+ pub previous_response_id: Option<String>,
|
||||
+}
|
||||
+
|
||||
+#[derive(Serialize, Deserialize, Default, Clone)]
|
||||
+pub struct SavedSession {
|
||||
+ pub session: SessionMeta,
|
||||
+ #[serde(default)]
|
||||
+ pub items: Vec<ResponseItem>,
|
||||
+ #[serde(default)]
|
||||
+ pub state: SessionStateSnapshot,
|
||||
+ pub session_id: Uuid,
|
||||
}
|
||||
|
||||
/// Records all [`ResponseItem`]s for a session and flushes them to disk after
|
||||
@@ -41,7 +55,13 @@ struct SessionMeta {
|
||||
/// ```
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct RolloutRecorder {
|
||||
- tx: Sender<String>,
|
||||
+ tx: Sender<RolloutCmd>,
|
||||
+}
|
||||
+
|
||||
+#[derive(Clone)]
|
||||
+enum RolloutCmd {
|
||||
+ AddItems(Vec<ResponseItem>),
|
||||
+ UpdateState(SessionStateSnapshot),
|
||||
}
|
||||
|
||||
impl RolloutRecorder {
|
||||
@@ -59,7 +79,6 @@ impl RolloutRecorder {
|
||||
timestamp,
|
||||
} = create_log_file(config, uuid)?;
|
||||
|
||||
- // Build the static session metadata JSON first.
|
||||
let timestamp_format: &[FormatItem] = format_description!(
|
||||
"[year]-[month]-[day]T[hour]:[minute]:[second].[subsecond digits:3]Z"
|
||||
);
|
||||
@@ -69,46 +88,29 @@ impl RolloutRecorder {
|
||||
|
||||
let meta = SessionMeta {
|
||||
timestamp,
|
||||
- id: session_id.to_string(),
|
||||
+ id: session_id,
|
||||
instructions,
|
||||
};
|
||||
|
||||
// A reasonably-sized bounded channel. If the buffer fills up the send
|
||||
// future will yield, which is fine – we only need to ensure we do not
|
||||
// perform *blocking* I/O on the caller’s thread.
|
||||
- let (tx, mut rx) = mpsc::channel::<String>(256);
|
||||
+ let (tx, rx) = mpsc::channel::<RolloutCmd>(256);
|
||||
|
||||
// Spawn a Tokio task that owns the file handle and performs async
|
||||
// writes. Using `tokio::fs::File` keeps everything on the async I/O
|
||||
// driver instead of blocking the runtime.
|
||||
- tokio::task::spawn(async move {
|
||||
- let mut file = tokio::fs::File::from_std(file);
|
||||
-
|
||||
- while let Some(line) = rx.recv().await {
|
||||
- // Write line + newline, then flush to disk.
|
||||
- if let Err(e) = file.write_all(line.as_bytes()).await {
|
||||
- tracing::warn!("rollout writer: failed to write line: {e}");
|
||||
- break;
|
||||
- }
|
||||
- if let Err(e) = file.write_all(b"\n").await {
|
||||
- tracing::warn!("rollout writer: failed to write newline: {e}");
|
||||
- break;
|
||||
- }
|
||||
- if let Err(e) = file.flush().await {
|
||||
- tracing::warn!("rollout writer: failed to flush: {e}");
|
||||
- break;
|
||||
- }
|
||||
- }
|
||||
- });
|
||||
+ tokio::task::spawn(rollout_writer(
|
||||
+ tokio::fs::File::from_std(file),
|
||||
+ rx,
|
||||
+ Some(meta),
|
||||
+ ));
|
||||
|
||||
- let recorder = Self { tx };
|
||||
- // Ensure SessionMeta is the first item in the file.
|
||||
- recorder.record_item(&meta).await?;
|
||||
- Ok(recorder)
|
||||
+ Ok(Self { tx })
|
||||
}
|
||||
|
||||
- /// Append `items` to the rollout file.
|
||||
pub(crate) async fn record_items(&self, items: &[ResponseItem]) -> std::io::Result<()> {
|
||||
+ let mut filtered = Vec::new();
|
||||
for item in items {
|
||||
match item {
|
||||
// Note that function calls may look a bit strange if they are
|
||||
@@ -117,27 +119,86 @@ impl RolloutRecorder {
|
||||
ResponseItem::Message { .. }
|
||||
| ResponseItem::LocalShellCall { .. }
|
||||
| ResponseItem::FunctionCall { .. }
|
||||
- | ResponseItem::FunctionCallOutput { .. } => {}
|
||||
+ | ResponseItem::FunctionCallOutput { .. } => filtered.push(item.clone()),
|
||||
ResponseItem::Reasoning { .. } | ResponseItem::Other => {
|
||||
// These should never be serialized.
|
||||
continue;
|
||||
}
|
||||
}
|
||||
- self.record_item(item).await?;
|
||||
}
|
||||
- Ok(())
|
||||
+ if filtered.is_empty() {
|
||||
+ return Ok(());
|
||||
+ }
|
||||
+ self.tx
|
||||
+ .send(RolloutCmd::AddItems(filtered))
|
||||
+ .await
|
||||
+ .map_err(|e| IoError::other(format!("failed to queue rollout items: {e}")))
|
||||
}
|
||||
|
||||
- async fn record_item(&self, item: &impl Serialize) -> std::io::Result<()> {
|
||||
- // Serialize the item to JSON first so that the writer thread only has
|
||||
- // to perform the actual write.
|
||||
- let json = serde_json::to_string(item)
|
||||
- .map_err(|e| IoError::other(format!("failed to serialize response items: {e}")))?;
|
||||
-
|
||||
+ pub(crate) async fn record_state(&self, state: SessionStateSnapshot) -> std::io::Result<()> {
|
||||
self.tx
|
||||
- .send(json)
|
||||
+ .send(RolloutCmd::UpdateState(state))
|
||||
.await
|
||||
- .map_err(|e| IoError::other(format!("failed to queue rollout item: {e}")))
|
||||
+ .map_err(|e| IoError::other(format!("failed to queue rollout state: {e}")))
|
||||
+ }
|
||||
+
|
||||
+ pub async fn resume(path: &Path) -> std::io::Result<(Self, SavedSession)> {
|
||||
+ info!("Resuming rollout from {path:?}");
|
||||
+ let text = tokio::fs::read_to_string(path).await?;
|
||||
+ let mut lines = text.lines();
|
||||
+ let meta_line = lines
|
||||
+ .next()
|
||||
+ .ok_or_else(|| IoError::other("empty session file"))?;
|
||||
+ let session: SessionMeta = serde_json::from_str(meta_line)
|
||||
+ .map_err(|e| IoError::other(format!("failed to parse session meta: {e}")))?;
|
||||
+ let mut items = Vec::new();
|
||||
+ let mut state = SessionStateSnapshot::default();
|
||||
+
|
||||
+ for line in lines {
|
||||
+ if line.trim().is_empty() {
|
||||
+ continue;
|
||||
+ }
|
||||
+ let v: Value = match serde_json::from_str(line) {
|
||||
+ Ok(v) => v,
|
||||
+ Err(_) => continue,
|
||||
+ };
|
||||
+ if v.get("record_type")
|
||||
+ .and_then(|rt| rt.as_str())
|
||||
+ .map(|s| s == "state")
|
||||
+ .unwrap_or(false)
|
||||
+ {
|
||||
+ if let Ok(s) = serde_json::from_value::<SessionStateSnapshot>(v.clone()) {
|
||||
+ state = s
|
||||
+ }
|
||||
+ continue;
|
||||
+ }
|
||||
+ if let Ok(item) = serde_json::from_value::<ResponseItem>(v.clone()) {
|
||||
+ match item {
|
||||
+ ResponseItem::Message { .. }
|
||||
+ | ResponseItem::LocalShellCall { .. }
|
||||
+ | ResponseItem::FunctionCall { .. }
|
||||
+ | ResponseItem::FunctionCallOutput { .. } => items.push(item),
|
||||
+ ResponseItem::Reasoning { .. } | ResponseItem::Other => {}
|
||||
+ }
|
||||
+ }
|
||||
+ }
|
||||
+
|
||||
+ let saved = SavedSession {
|
||||
+ session: session.clone(),
|
||||
+ items: items.clone(),
|
||||
+ state: state.clone(),
|
||||
+ session_id: session.id,
|
||||
+ };
|
||||
+
|
||||
+ let file = std::fs::OpenOptions::new()
|
||||
+ .append(true)
|
||||
+ .read(true)
|
||||
+ .open(path)?;
|
||||
+
|
||||
+ let (tx, rx) = mpsc::channel::<RolloutCmd>(256);
|
||||
+ tokio::task::spawn(rollout_writer(tokio::fs::File::from_std(file), rx, None));
|
||||
+ info!("Resumed rollout successfully from {path:?}");
|
||||
+ Ok((Self { tx }, saved))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -185,3 +246,54 @@ fn create_log_file(config: &Config, session_id: Uuid) -> std::io::Result<LogFile
|
||||
timestamp,
|
||||
})
|
||||
}
|
||||
+
|
||||
+async fn rollout_writer(
|
||||
+ mut file: tokio::fs::File,
|
||||
+ mut rx: mpsc::Receiver<RolloutCmd>,
|
||||
+ meta: Option<SessionMeta>,
|
||||
+) {
|
||||
+ if let Some(meta) = meta {
|
||||
+ if let Ok(json) = serde_json::to_string(&meta) {
|
||||
+ let _ = file.write_all(json.as_bytes()).await;
|
||||
+ let _ = file.write_all(b"\n").await;
|
||||
+ let _ = file.flush().await;
|
||||
+ }
|
||||
+ }
|
||||
+ while let Some(cmd) = rx.recv().await {
|
||||
+ match cmd {
|
||||
+ RolloutCmd::AddItems(items) => {
|
||||
+ for item in items {
|
||||
+ match item {
|
||||
+ ResponseItem::Message { .. }
|
||||
+ | ResponseItem::LocalShellCall { .. }
|
||||
+ | ResponseItem::FunctionCall { .. }
|
||||
+ | ResponseItem::FunctionCallOutput { .. } => {
|
||||
+ if let Ok(json) = serde_json::to_string(&item) {
|
||||
+ let _ = file.write_all(json.as_bytes()).await;
|
||||
+ let _ = file.write_all(b"\n").await;
|
||||
+ }
|
||||
+ }
|
||||
+ ResponseItem::Reasoning { .. } | ResponseItem::Other => {}
|
||||
+ }
|
||||
+ }
|
||||
+ let _ = file.flush().await;
|
||||
+ }
|
||||
+ RolloutCmd::UpdateState(state) => {
|
||||
+ #[derive(Serialize)]
|
||||
+ struct StateLine<'a> {
|
||||
+ record_type: &'static str,
|
||||
+ #[serde(flatten)]
|
||||
+ state: &'a SessionStateSnapshot,
|
||||
+ }
|
||||
+ if let Ok(json) = serde_json::to_string(&StateLine {
|
||||
+ record_type: "state",
|
||||
+ state: &state,
|
||||
+ }) {
|
||||
+ let _ = file.write_all(json.as_bytes()).await;
|
||||
+ let _ = file.write_all(b"\n").await;
|
||||
+ let _ = file.flush().await;
|
||||
+ }
|
||||
+ }
|
||||
+ }
|
||||
+ }
|
||||
+}
|
||||
diff --git a/codex-rs/core/tests/cli_stream.rs b/codex-rs/core/tests/cli_stream.rs
|
||||
index 23ee0a3cbc..567279ebd0 100644
|
||||
--- a/codex-rs/core/tests/cli_stream.rs
|
||||
+++ b/codex-rs/core/tests/cli_stream.rs
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
use assert_cmd::Command as AssertCommand;
|
||||
use codex_core::exec::CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR;
|
||||
-use serde_json::Value;
|
||||
use std::time::Duration;
|
||||
use std::time::Instant;
|
||||
use tempfile::TempDir;
|
||||
@@ -123,6 +122,7 @@ async fn responses_api_stream_cli() {
|
||||
assert!(stdout.contains("fixture hello"));
|
||||
}
|
||||
|
||||
+/// End-to-end: create a session (writes rollout), verify the file, then resume and confirm append.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn integration_creates_and_checks_session_file() {
|
||||
// Honor sandbox network restrictions for CI parity with the other tests.
|
||||
@@ -170,45 +170,66 @@ async fn integration_creates_and_checks_session_file() {
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
|
||||
- // 5. Sessions are written asynchronously; wait briefly for the directory to appear.
|
||||
+ // Wait for sessions dir to appear.
|
||||
let sessions_dir = home.path().join("sessions");
|
||||
- let start = Instant::now();
|
||||
- while !sessions_dir.exists() && start.elapsed() < Duration::from_secs(3) {
|
||||
+ let dir_deadline = Instant::now() + Duration::from_secs(5);
|
||||
+ while !sessions_dir.exists() && Instant::now() < dir_deadline {
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
}
|
||||
+ assert!(sessions_dir.exists(), "sessions directory never appeared");
|
||||
|
||||
- // 6. Scan all session files and find the one that contains our marker.
|
||||
- let mut matching_files = vec![];
|
||||
- for entry in WalkDir::new(&sessions_dir) {
|
||||
- let entry = entry.unwrap();
|
||||
- if entry.file_type().is_file() && entry.file_name().to_string_lossy().ends_with(".jsonl") {
|
||||
+ // Find the session file that contains `marker`.
|
||||
+ let deadline = Instant::now() + Duration::from_secs(10);
|
||||
+ let mut matching_path: Option<std::path::PathBuf> = None;
|
||||
+ while Instant::now() < deadline && matching_path.is_none() {
|
||||
+ for entry in WalkDir::new(&sessions_dir) {
|
||||
+ let entry = match entry {
|
||||
+ Ok(e) => e,
|
||||
+ Err(_) => continue,
|
||||
+ };
|
||||
+ if !entry.file_type().is_file() {
|
||||
+ continue;
|
||||
+ }
|
||||
+ if !entry.file_name().to_string_lossy().ends_with(".jsonl") {
|
||||
+ continue;
|
||||
+ }
|
||||
let path = entry.path();
|
||||
- let content = std::fs::read_to_string(path).unwrap();
|
||||
+ let Ok(content) = std::fs::read_to_string(path) else {
|
||||
+ continue;
|
||||
+ };
|
||||
let mut lines = content.lines();
|
||||
- // Skip SessionMeta (first line)
|
||||
- let _ = lines.next();
|
||||
+ if lines.next().is_none() {
|
||||
+ continue;
|
||||
+ }
|
||||
for line in lines {
|
||||
- let item: Value = serde_json::from_str(line).unwrap();
|
||||
- if let Some("message") = item.get("type").and_then(|t| t.as_str()) {
|
||||
- if let Some(content) = item.get("content") {
|
||||
- if content.to_string().contains(&marker) {
|
||||
- matching_files.push(path.to_owned());
|
||||
+ if line.trim().is_empty() {
|
||||
+ continue;
|
||||
+ }
|
||||
+ let item: serde_json::Value = match serde_json::from_str(line) {
|
||||
+ Ok(v) => v,
|
||||
+ Err(_) => continue,
|
||||
+ };
|
||||
+ if item.get("type").and_then(|t| t.as_str()) == Some("message") {
|
||||
+ if let Some(c) = item.get("content") {
|
||||
+ if c.to_string().contains(&marker) {
|
||||
+ matching_path = Some(path.to_path_buf());
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+ if matching_path.is_none() {
|
||||
+ std::thread::sleep(Duration::from_millis(50));
|
||||
+ }
|
||||
}
|
||||
- assert_eq!(
|
||||
- matching_files.len(),
|
||||
- 1,
|
||||
- "Expected exactly one session file containing the marker, found {}",
|
||||
- matching_files.len()
|
||||
- );
|
||||
- let path = &matching_files[0];
|
||||
|
||||
- // 7. Verify directory structure: sessions/YYYY/MM/DD/filename.jsonl
|
||||
+ let path = match matching_path {
|
||||
+ Some(p) => p,
|
||||
+ None => panic!("No session file containing the marker was found"),
|
||||
+ };
|
||||
+
|
||||
+ // Basic sanity checks on location and metadata.
|
||||
let rel = match path.strip_prefix(&sessions_dir) {
|
||||
Ok(r) => r,
|
||||
Err(_) => panic!("session file should live under sessions/"),
|
||||
@@ -237,7 +258,6 @@ async fn integration_creates_and_checks_session_file() {
|
||||
day.len() == 2 && day.chars().all(|c| c.is_ascii_digit()),
|
||||
"Day dir not zero-padded 2-digit numeric: {day}"
|
||||
);
|
||||
- // Range checks (best-effort; won't fail on leading zeros)
|
||||
if let Ok(m) = month.parse::<u8>() {
|
||||
assert!((1..=12).contains(&m), "Month out of range: {m}");
|
||||
}
|
||||
@@ -245,23 +265,32 @@ async fn integration_creates_and_checks_session_file() {
|
||||
assert!((1..=31).contains(&d), "Day out of range: {d}");
|
||||
}
|
||||
|
||||
- // 8. Parse SessionMeta line and basic sanity checks.
|
||||
- let content = std::fs::read_to_string(path).unwrap();
|
||||
+ let content =
|
||||
+ std::fs::read_to_string(&path).unwrap_or_else(|_| panic!("Failed to read session file"));
|
||||
let mut lines = content.lines();
|
||||
- let meta: Value = serde_json::from_str(lines.next().unwrap()).unwrap();
|
||||
+ let meta_line = lines
|
||||
+ .next()
|
||||
+ .ok_or("missing session meta line")
|
||||
+ .unwrap_or_else(|_| panic!("missing session meta line"));
|
||||
+ let meta: serde_json::Value = serde_json::from_str(meta_line)
|
||||
+ .unwrap_or_else(|_| panic!("Failed to parse session meta line as JSON"));
|
||||
assert!(meta.get("id").is_some(), "SessionMeta missing id");
|
||||
assert!(
|
||||
meta.get("timestamp").is_some(),
|
||||
"SessionMeta missing timestamp"
|
||||
);
|
||||
|
||||
- // 9. Confirm at least one message contains the marker.
|
||||
let mut found_message = false;
|
||||
for line in lines {
|
||||
- let item: Value = serde_json::from_str(line).unwrap();
|
||||
- if item.get("type").map(|t| t == "message").unwrap_or(false) {
|
||||
- if let Some(content) = item.get("content") {
|
||||
- if content.to_string().contains(&marker) {
|
||||
+ if line.trim().is_empty() {
|
||||
+ continue;
|
||||
+ }
|
||||
+ let Ok(item) = serde_json::from_str::<serde_json::Value>(line) else {
|
||||
+ continue;
|
||||
+ };
|
||||
+ if item.get("type").and_then(|t| t.as_str()) == Some("message") {
|
||||
+ if let Some(c) = item.get("content") {
|
||||
+ if c.to_string().contains(&marker) {
|
||||
found_message = true;
|
||||
break;
|
||||
}
|
||||
@@ -272,4 +301,61 @@ async fn integration_creates_and_checks_session_file() {
|
||||
found_message,
|
||||
"No message found in session file containing the marker"
|
||||
);
|
||||
+
|
||||
+ // Second run: resume and append.
|
||||
+ let orig_len = content.lines().count();
|
||||
+ let marker2 = format!("integration-resume-{}", Uuid::new_v4());
|
||||
+ let prompt2 = format!("echo {marker2}");
|
||||
+ // Cross‑platform safe resume override. On Windows, backslashes in a TOML string must be escaped
|
||||
+ // or the parse will fail and the raw literal (including quotes) may be preserved all the way down
|
||||
+ // to Config, which in turn breaks resume because the path is invalid. Normalize to forward slashes
|
||||
+ // to sidestep the issue.
|
||||
+ let resume_path_str = path.to_string_lossy().replace('\\', "/");
|
||||
+ let resume_override = format!("experimental_resume=\"{resume_path_str}\"");
|
||||
+ let mut cmd2 = AssertCommand::new("cargo");
|
||||
+ cmd2.arg("run")
|
||||
+ .arg("-p")
|
||||
+ .arg("codex-cli")
|
||||
+ .arg("--quiet")
|
||||
+ .arg("--")
|
||||
+ .arg("exec")
|
||||
+ .arg("--skip-git-repo-check")
|
||||
+ .arg("-c")
|
||||
+ .arg(&resume_override)
|
||||
+ .arg("-C")
|
||||
+ .arg(env!("CARGO_MANIFEST_DIR"))
|
||||
+ .arg(&prompt2);
|
||||
+ cmd2.env("CODEX_HOME", home.path())
|
||||
+ .env("OPENAI_API_KEY", "dummy")
|
||||
+ .env("CODEX_RS_SSE_FIXTURE", &fixture)
|
||||
+ .env("OPENAI_BASE_URL", "http://unused.local");
|
||||
+ let output2 = cmd2.output().unwrap();
|
||||
+ assert!(output2.status.success(), "resume codex-cli run failed");
|
||||
+
|
||||
+ // The rollout writer runs on a background async task; give it a moment to flush.
|
||||
+ let mut new_len = orig_len;
|
||||
+ let deadline = Instant::now() + Duration::from_secs(5);
|
||||
+ let mut content2 = String::new();
|
||||
+ while Instant::now() < deadline {
|
||||
+ if let Ok(c) = std::fs::read_to_string(&path) {
|
||||
+ let count = c.lines().count();
|
||||
+ if count > orig_len {
|
||||
+ content2 = c;
|
||||
+ new_len = count;
|
||||
+ break;
|
||||
+ }
|
||||
+ }
|
||||
+ std::thread::sleep(Duration::from_millis(50));
|
||||
+ }
|
||||
+ if content2.is_empty() {
|
||||
+ // last attempt
|
||||
+ content2 = std::fs::read_to_string(&path).unwrap();
|
||||
+ new_len = content2.lines().count();
|
||||
+ }
|
||||
+ assert!(new_len > orig_len, "rollout file did not grow after resume");
|
||||
+ assert!(content2.contains(&marker), "rollout lost original marker");
|
||||
+ assert!(
|
||||
+ content2.contains(&marker2),
|
||||
+ "rollout missing resumed marker"
|
||||
+ );
|
||||
}
|
||||
```
|
||||
|
||||
## Review Comments
|
||||
|
||||
### codex-rs/common/src/config_override.rs
|
||||
|
||||
- Created: 2025-07-19 03:52:58 UTC | Link: https://github.com/openai/codex/pull/1602#discussion_r2217130715
|
||||
|
||||
```diff
|
||||
@@ -64,7 +64,11 @@ impl CliConfigOverrides {
|
||||
// `-c model=o3` without the quotes.
|
||||
let value: Value = match parse_toml_value(value_str) {
|
||||
Ok(v) => v,
|
||||
- Err(_) => Value::String(value_str.to_string()),
|
||||
+ Err(_) => {
|
||||
```
|
||||
|
||||
> Why was this added? Why do we believe this is the right thing to do? Can you please add an example/test?
|
||||
|
||||
- Created: 2025-07-27 18:58:21 UTC | Link: https://github.com/openai/codex/pull/1602#discussion_r2234100674
|
||||
|
||||
```diff
|
||||
@@ -64,7 +64,11 @@ impl CliConfigOverrides {
|
||||
// `-c model=o3` without the quotes.
|
||||
let value: Value = match parse_toml_value(value_str) {
|
||||
Ok(v) => v,
|
||||
- Err(_) => Value::String(value_str.to_string()),
|
||||
+ Err(_) => {
|
||||
```
|
||||
|
||||
> @aibrahim-oai Can you please track this down? Historically, we have not had problems with flakiness in the Windows build. We should understand what is going on before making changes like this.
|
||||
>
|
||||
> If this turns out to be the right fix, then this feels like it merits a regression test.
|
||||
|
||||
### codex-rs/core/src/codex.rs
|
||||
|
||||
- Created: 2025-07-21 22:08:39 UTC | Link: https://github.com/openai/codex/pull/1602#discussion_r2220458552
|
||||
|
||||
```diff
|
||||
@@ -517,7 +527,7 @@ async fn submission_loop(
|
||||
ctrl_c: Arc<Notify>,
|
||||
) {
|
||||
// Generate a unique ID for the lifetime of this Codex session.
|
||||
- let session_id = Uuid::new_v4();
|
||||
+ let mut session_id = Uuid::new_v4();
|
||||
```
|
||||
|
||||
> This feels a bit wrong to me that this is mutable. I think it should be decided earlier in this flow whether we are restoring an existing session versus creating a new one.
|
||||
|
||||
### codex-rs/core/src/rollout.rs
|
||||
|
||||
- Created: 2025-07-19 03:54:30 UTC | Link: https://github.com/openai/codex/pull/1602#discussion_r2217131075
|
||||
|
||||
```diff
|
||||
@@ -185,3 +246,54 @@ fn create_log_file(config: &Config, session_id: Uuid) -> std::io::Result<LogFile
|
||||
timestamp,
|
||||
})
|
||||
}
|
||||
+
|
||||
+async fn rollout_writer(
|
||||
+ mut file: tokio::fs::File,
|
||||
+ mut rx: mpsc::Receiver<RolloutCmd>,
|
||||
+ meta: Option<SessionMeta>,
|
||||
+) {
|
||||
+ if let Some(meta) = meta {
|
||||
```
|
||||
|
||||
> Why do we ignore all the io errors instead of returning `io::Result<()>`?
|
||||
|
||||
### codex-rs/core/tests/cli_stream.rs
|
||||
|
||||
- Created: 2025-07-19 03:58:28 UTC | Link: https://github.com/openai/codex/pull/1602#discussion_r2217132129
|
||||
|
||||
```diff
|
||||
@@ -272,4 +301,61 @@ async fn integration_creates_and_checks_session_file() {
|
||||
found_message,
|
||||
"No message found in session file containing the marker"
|
||||
);
|
||||
+
|
||||
+ // Second run: resume and append.
|
||||
+ let orig_len = content.lines().count();
|
||||
+ let marker2 = format!("integration-resume-{}", Uuid::new_v4());
|
||||
+ let prompt2 = format!("echo {marker2}");
|
||||
+ // Cross‑platform safe resume override. On Windows, backslashes in a TOML string must be escaped
|
||||
+ // or the parse will fail and the raw literal (including quotes) may be preserved all the way down
|
||||
+ // to Config, which in turn breaks resume because the path is invalid. Normalize to forward slashes
|
||||
+ // to sidestep the issue.
|
||||
+ let resume_path_str = path.to_string_lossy().replace('\\', "/");
|
||||
+ let resume_override = format!("experimental_resume=\"{resume_path_str}\"");
|
||||
+ let mut cmd2 = AssertCommand::new("cargo");
|
||||
```
|
||||
|
||||
> I know I have made this comment before, but why not use `Command::cargo_bin`?
|
||||
430
prs/bolinfest/PR-1610.md
Normal file
430
prs/bolinfest/PR-1610.md
Normal file
@@ -0,0 +1,430 @@
|
||||
# PR #1610: Interrupt bug
|
||||
|
||||
- URL: https://github.com/openai/codex/pull/1610
|
||||
- Author: aibrahim-oai
|
||||
- Created: 2025-07-18 07:13:16 UTC
|
||||
- Updated: 2025-07-18 17:04:41 UTC
|
||||
- Changes: +164/-5, Files changed: 2, Commits: 2
|
||||
|
||||
## Description
|
||||
|
||||
Interrupt is currently buggy. It uses the buffered deltas
|
||||
|
||||
## Full Diff
|
||||
|
||||
```diff
|
||||
diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs
|
||||
index 2c5baf152f..d42d056a76 100644
|
||||
--- a/codex-rs/core/src/codex.rs
|
||||
+++ b/codex-rs/core/src/codex.rs
|
||||
@@ -990,6 +990,52 @@ async fn run_task(sess: Arc<Session>, sub_id: String, input: Vec<InputItem>) {
|
||||
sess.tx_event.send(event).await.ok();
|
||||
}
|
||||
|
||||
+// ---
|
||||
+// Helpers --------------------------------------------------------------------
|
||||
+//
|
||||
+// When a turn is interrupted before Codex can deliver tool output(s) back to
|
||||
+// the model, the next request can fail with a 400 from the OpenAI API:
|
||||
+// {"error": {"message": "No tool output found for function call call_XXXXX", ...}}
|
||||
+// Historically this manifested as a confusing retry loop ("stream error: 400 …")
|
||||
+// because we never learned about the missing `call_id` (the stream was aborted
|
||||
+// before we observed the `ResponseEvent::OutputItemDone` that would have let us
|
||||
+// record it in `pending_call_ids`).
|
||||
+//
|
||||
+// To make interruption robust we parse the error body for the offending call id
|
||||
+// and add it to `pending_call_ids` so the very next retry can inject a synthetic
|
||||
+// `FunctionCallOutput { content: "aborted" }` and satisfy the API contract.
|
||||
+// -----------------------------------------------------------------------------
|
||||
+fn extract_missing_tool_call_id(body: &str) -> Option<String> {
|
||||
+ // Try to parse the canonical JSON error shape first.
|
||||
+ if let Ok(v) = serde_json::from_str::<serde_json::Value>(body) {
|
||||
+ if let Some(msg) = v
|
||||
+ .get("error")
|
||||
+ .and_then(|e| e.get("message"))
|
||||
+ .and_then(|m| m.as_str())
|
||||
+ {
|
||||
+ if let Some(id) = extract_missing_tool_call_id_from_msg(msg) {
|
||||
+ return Some(id);
|
||||
+ }
|
||||
+ }
|
||||
+ }
|
||||
+ // Fallback: scan the raw body.
|
||||
+ extract_missing_tool_call_id_from_msg(body)
|
||||
+}
|
||||
+
|
||||
+fn extract_missing_tool_call_id_from_msg(msg: &str) -> Option<String> {
|
||||
+ const NEEDLE: &str = "No tool output found for function call";
|
||||
+ let idx = msg.find(NEEDLE)?;
|
||||
+ let rest = &msg[idx + NEEDLE.len()..];
|
||||
+ // Find the beginning of the call id (typically starts with "call_").
|
||||
+ let start = rest.find("call_")?;
|
||||
+ let rest = &rest[start..];
|
||||
+ // Capture valid id chars [A-Za-z0-9_-/]. Hyphen shows up in some IDs; be permissive.
|
||||
+ let end = rest
|
||||
+ .find(|c: char| !(c.is_ascii_alphanumeric() || c == '_' || c == '-' || c == '/'))
|
||||
+ .unwrap_or(rest.len());
|
||||
+ Some(rest[..end].to_string())
|
||||
+}
|
||||
+
|
||||
async fn run_turn(
|
||||
sess: &Session,
|
||||
sub_id: String,
|
||||
@@ -1024,6 +1070,50 @@ async fn run_turn(
|
||||
Ok(output) => return Ok(output),
|
||||
Err(CodexErr::Interrupted) => return Err(CodexErr::Interrupted),
|
||||
Err(CodexErr::EnvVar(var)) => return Err(CodexErr::EnvVar(var)),
|
||||
+ Err(CodexErr::UnexpectedStatus(status, body)) => {
|
||||
+ // Detect the specific 400 "No tool output found for function call ..." error that
|
||||
+ // occurs when a user interrupted before Codex could answer a tool call.
|
||||
+ if status == reqwest::StatusCode::BAD_REQUEST {
|
||||
+ if let Some(call_id) = extract_missing_tool_call_id(&body) {
|
||||
+ {
|
||||
+ let mut state = sess.state.lock().unwrap();
|
||||
+ state.pending_call_ids.insert(call_id.clone());
|
||||
+ }
|
||||
+ // Surface a friendlier background event so users understand the recovery.
|
||||
+ sess
|
||||
+ .notify_background_event(
|
||||
+ &sub_id,
|
||||
+ format!(
|
||||
+ "previous turn interrupted before responding to tool {call_id}; sending aborted output and retrying…",
|
||||
+ ),
|
||||
+ )
|
||||
+ .await;
|
||||
+ // Immediately retry the turn without consuming a provider stream retry budget.
|
||||
+ continue;
|
||||
+ }
|
||||
+ }
|
||||
+ // Fall through to generic retry path if we could not auto‑recover.
|
||||
+ let e = CodexErr::UnexpectedStatus(status, body);
|
||||
+ // Use the configured provider-specific stream retry budget.
|
||||
+ let max_retries = sess.client.get_provider().stream_max_retries();
|
||||
+ if retries < max_retries {
|
||||
+ retries += 1;
|
||||
+ let delay = backoff(retries);
|
||||
+ warn!(
|
||||
+ "stream disconnected - retrying turn ({retries}/{max_retries} in {delay:?})...",
|
||||
+ );
|
||||
+ sess.notify_background_event(
|
||||
+ &sub_id,
|
||||
+ format!(
|
||||
+ "stream error: {e}; retrying {retries}/{max_retries} in {delay:?}…",
|
||||
+ ),
|
||||
+ )
|
||||
+ .await;
|
||||
+ tokio::time::sleep(delay).await;
|
||||
+ } else {
|
||||
+ return Err(e);
|
||||
+ }
|
||||
+ }
|
||||
Err(e) => {
|
||||
// Use the configured provider-specific stream retry budget.
|
||||
let max_retries = sess.client.get_provider().stream_max_retries();
|
||||
@@ -1040,7 +1130,7 @@ async fn run_turn(
|
||||
sess.notify_background_event(
|
||||
&sub_id,
|
||||
format!(
|
||||
- "stream error: {e}; retrying {retries}/{max_retries} in {delay:?}…"
|
||||
+ "stream error: {e}; retrying {retries}/{max_retries} in {delay:?}…",
|
||||
),
|
||||
)
|
||||
.await;
|
||||
diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs
|
||||
index 7c825acd41..0f72f417dc 100644
|
||||
--- a/codex-rs/tui/src/chatwidget.rs
|
||||
+++ b/codex-rs/tui/src/chatwidget.rs
|
||||
@@ -53,6 +53,7 @@ pub(crate) struct ChatWidget<'a> {
|
||||
token_usage: TokenUsage,
|
||||
reasoning_buffer: String,
|
||||
answer_buffer: String,
|
||||
+ active_task_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Eq, PartialEq)]
|
||||
@@ -141,6 +142,7 @@ impl ChatWidget<'_> {
|
||||
token_usage: TokenUsage::default(),
|
||||
reasoning_buffer: String::new(),
|
||||
answer_buffer: String::new(),
|
||||
+ active_task_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -222,10 +224,30 @@ impl ChatWidget<'_> {
|
||||
self.conversation_history.add_user_message(text);
|
||||
}
|
||||
self.conversation_history.scroll_to_bottom();
|
||||
+
|
||||
+ // IMPORTANT: Starting a *new* user turn. Clear any partially streamed
|
||||
+ // answer from a previous turn (e.g., one that was interrupted) so that
|
||||
+ // the next AgentMessageDelta spawns a fresh agent message cell instead
|
||||
+ // of overwriting the last one.
|
||||
+ self.answer_buffer.clear();
|
||||
+ self.reasoning_buffer.clear();
|
||||
}
|
||||
|
||||
pub(crate) fn handle_codex_event(&mut self, event: Event) {
|
||||
- let Event { id, msg } = event;
|
||||
+ // Retain the event ID so we can refer to it after destructuring.
|
||||
+ let event_id = event.id.clone();
|
||||
+ let Event { id: _, msg } = event;
|
||||
+
|
||||
+ // When we are in the middle of a task (active_task_id is Some) we drop
|
||||
+ // streaming text/reasoning events for *other* task IDs. This prevents
|
||||
+ // late tokens from an interrupted run from bleeding into the current
|
||||
+ // answer.
|
||||
+ let should_drop_streaming = self
|
||||
+ .active_task_id
|
||||
+ .as_ref()
|
||||
+ .map(|active| active != &event_id)
|
||||
+ .unwrap_or(false);
|
||||
+
|
||||
match msg {
|
||||
EventMsg::SessionConfigured(event) => {
|
||||
// Record session information at the top of the conversation.
|
||||
@@ -246,6 +268,9 @@ impl ChatWidget<'_> {
|
||||
self.request_redraw();
|
||||
}
|
||||
EventMsg::AgentMessage(AgentMessageEvent { message }) => {
|
||||
+ if should_drop_streaming {
|
||||
+ return;
|
||||
+ }
|
||||
// if the answer buffer is empty, this means we haven't received any
|
||||
// delta. Thus, we need to print the message as a new answer.
|
||||
if self.answer_buffer.is_empty() {
|
||||
@@ -259,6 +284,9 @@ impl ChatWidget<'_> {
|
||||
self.request_redraw();
|
||||
}
|
||||
EventMsg::AgentMessageDelta(AgentMessageDeltaEvent { delta }) => {
|
||||
+ if should_drop_streaming {
|
||||
+ return;
|
||||
+ }
|
||||
if self.answer_buffer.is_empty() {
|
||||
self.conversation_history
|
||||
.add_agent_message(&self.config, "".to_string());
|
||||
@@ -269,6 +297,9 @@ impl ChatWidget<'_> {
|
||||
self.request_redraw();
|
||||
}
|
||||
EventMsg::AgentReasoningDelta(AgentReasoningDeltaEvent { delta }) => {
|
||||
+ if should_drop_streaming {
|
||||
+ return;
|
||||
+ }
|
||||
if self.reasoning_buffer.is_empty() {
|
||||
self.conversation_history
|
||||
.add_agent_reasoning(&self.config, "".to_string());
|
||||
@@ -279,6 +310,9 @@ impl ChatWidget<'_> {
|
||||
self.request_redraw();
|
||||
}
|
||||
EventMsg::AgentReasoning(AgentReasoningEvent { text }) => {
|
||||
+ if should_drop_streaming {
|
||||
+ return;
|
||||
+ }
|
||||
// if the reasoning buffer is empty, this means we haven't received any
|
||||
// delta. Thus, we need to print the message as a new reasoning.
|
||||
if self.reasoning_buffer.is_empty() {
|
||||
@@ -293,6 +327,10 @@ impl ChatWidget<'_> {
|
||||
self.request_redraw();
|
||||
}
|
||||
EventMsg::TaskStarted => {
|
||||
+ // New task has begun – update state and clear any stale buffers.
|
||||
+ self.active_task_id = Some(event_id);
|
||||
+ self.answer_buffer.clear();
|
||||
+ self.reasoning_buffer.clear();
|
||||
self.bottom_pane.clear_ctrl_c_quit_hint();
|
||||
self.bottom_pane.set_task_running(true);
|
||||
self.request_redraw();
|
||||
@@ -300,6 +338,10 @@ impl ChatWidget<'_> {
|
||||
EventMsg::TaskComplete(TaskCompleteEvent {
|
||||
last_agent_message: _,
|
||||
}) => {
|
||||
+ // Task finished; clear active_task_id so that subsequent events are processed.
|
||||
+ if self.active_task_id.as_ref() == Some(&event_id) {
|
||||
+ self.active_task_id = None;
|
||||
+ }
|
||||
self.bottom_pane.set_task_running(false);
|
||||
self.request_redraw();
|
||||
}
|
||||
@@ -309,16 +351,25 @@ impl ChatWidget<'_> {
|
||||
.set_token_usage(self.token_usage.clone(), self.config.model_context_window);
|
||||
}
|
||||
EventMsg::Error(ErrorEvent { message }) => {
|
||||
+ // Error events always get surfaced (even for stale task IDs) so that the user sees
|
||||
+ // why a run stopped. However, only clear the running indicator if this is the
|
||||
+ // active task.
|
||||
+ if self.active_task_id.as_ref() == Some(&event_id) {
|
||||
+ self.bottom_pane.set_task_running(false);
|
||||
+ self.active_task_id = None;
|
||||
+ }
|
||||
self.conversation_history.add_error(message);
|
||||
- self.bottom_pane.set_task_running(false);
|
||||
}
|
||||
EventMsg::ExecApprovalRequest(ExecApprovalRequestEvent {
|
||||
command,
|
||||
cwd,
|
||||
reason,
|
||||
}) => {
|
||||
+ if should_drop_streaming {
|
||||
+ return;
|
||||
+ }
|
||||
let request = ApprovalRequest::Exec {
|
||||
- id,
|
||||
+ id: event_id,
|
||||
command,
|
||||
cwd,
|
||||
reason,
|
||||
@@ -330,6 +381,9 @@ impl ChatWidget<'_> {
|
||||
reason,
|
||||
grant_root,
|
||||
}) => {
|
||||
+ if should_drop_streaming {
|
||||
+ return;
|
||||
+ }
|
||||
// ------------------------------------------------------------------
|
||||
// Before we even prompt the user for approval we surface the patch
|
||||
// summary in the main conversation so that the dialog appears in a
|
||||
@@ -348,7 +402,7 @@ impl ChatWidget<'_> {
|
||||
|
||||
// Now surface the approval request in the BottomPane as before.
|
||||
let request = ApprovalRequest::ApplyPatch {
|
||||
- id,
|
||||
+ id: event_id,
|
||||
reason,
|
||||
grant_root,
|
||||
};
|
||||
@@ -360,6 +414,9 @@ impl ChatWidget<'_> {
|
||||
command,
|
||||
cwd: _,
|
||||
}) => {
|
||||
+ if should_drop_streaming {
|
||||
+ return;
|
||||
+ }
|
||||
self.conversation_history
|
||||
.add_active_exec_command(call_id, command);
|
||||
self.request_redraw();
|
||||
@@ -369,6 +426,9 @@ impl ChatWidget<'_> {
|
||||
auto_approved,
|
||||
changes,
|
||||
}) => {
|
||||
+ if should_drop_streaming {
|
||||
+ return;
|
||||
+ }
|
||||
// Even when a patch is auto‑approved we still display the
|
||||
// summary so the user can follow along.
|
||||
self.conversation_history
|
||||
@@ -384,6 +444,9 @@ impl ChatWidget<'_> {
|
||||
stdout,
|
||||
stderr,
|
||||
}) => {
|
||||
+ if should_drop_streaming {
|
||||
+ return;
|
||||
+ }
|
||||
self.conversation_history
|
||||
.record_completed_exec_command(call_id, stdout, stderr, exit_code);
|
||||
self.request_redraw();
|
||||
@@ -394,11 +457,17 @@ impl ChatWidget<'_> {
|
||||
tool,
|
||||
arguments,
|
||||
}) => {
|
||||
+ if should_drop_streaming {
|
||||
+ return;
|
||||
+ }
|
||||
self.conversation_history
|
||||
.add_active_mcp_tool_call(call_id, server, tool, arguments);
|
||||
self.request_redraw();
|
||||
}
|
||||
EventMsg::McpToolCallEnd(mcp_tool_call_end_event) => {
|
||||
+ if should_drop_streaming {
|
||||
+ return;
|
||||
+ }
|
||||
let success = mcp_tool_call_end_event.is_success();
|
||||
let McpToolCallEndEvent { call_id, result } = mcp_tool_call_end_event;
|
||||
self.conversation_history
|
||||
```
|
||||
|
||||
## Review Comments
|
||||
|
||||
### codex-rs/tui/src/chatwidget.rs
|
||||
|
||||
- Created: 2025-07-18 07:16:35 UTC | Link: https://github.com/openai/codex/pull/1610#discussion_r2215214532
|
||||
|
||||
```diff
|
||||
@@ -222,10 +224,30 @@ impl ChatWidget<'_> {
|
||||
self.conversation_history.add_user_message(text);
|
||||
}
|
||||
self.conversation_history.scroll_to_bottom();
|
||||
+
|
||||
+ // IMPORTANT: Starting a *new* user turn. Clear any partially streamed
|
||||
+ // answer from a previous turn (e.g., one that was interrupted) so that
|
||||
+ // the next AgentMessageDelta spawns a fresh agent message cell instead
|
||||
+ // of overwriting the last one.
|
||||
+ self.answer_buffer.clear();
|
||||
+ self.reasoning_buffer.clear();
|
||||
}
|
||||
|
||||
pub(crate) fn handle_codex_event(&mut self, event: Event) {
|
||||
- let Event { id, msg } = event;
|
||||
+ // Retain the event ID so we can refer to it after destructuring.
|
||||
+ let event_id = event.id.clone();
|
||||
+ let Event { id: _, msg } = event;
|
||||
```
|
||||
|
||||
> Why did this change? Why not destructure as before without cloning? I would defer the `clone()` until it is necessary (i.e., you need to pass the id by value to another function).
|
||||
>
|
||||
> If you just want to change the name:
|
||||
>
|
||||
> ```rust
|
||||
> let Event { id: event_id, msg } = event;
|
||||
> ```
|
||||
|
||||
- Created: 2025-07-18 07:18:25 UTC | Link: https://github.com/openai/codex/pull/1610#discussion_r2215218104
|
||||
|
||||
```diff
|
||||
@@ -246,6 +268,9 @@ impl ChatWidget<'_> {
|
||||
self.request_redraw();
|
||||
}
|
||||
EventMsg::AgentMessage(AgentMessageEvent { message }) => {
|
||||
+ if should_drop_streaming {
|
||||
+ return;
|
||||
+ }
|
||||
```
|
||||
|
||||
> Admittedly, this is my personal style, but I think it has merit: anytime you have an early `return` from a block of code, I would put a blank line after the closing `}` of the early `return` to help call attention to the fact that it is not straight-line code.
|
||||
|
||||
- Created: 2025-07-18 07:20:56 UTC | Link: https://github.com/openai/codex/pull/1610#discussion_r2215223225
|
||||
|
||||
```diff
|
||||
@@ -309,16 +351,25 @@ impl ChatWidget<'_> {
|
||||
.set_token_usage(self.token_usage.clone(), self.config.model_context_window);
|
||||
}
|
||||
EventMsg::Error(ErrorEvent { message }) => {
|
||||
+ // Error events always get surfaced (even for stale task IDs) so that the user sees
|
||||
+ // why a run stopped. However, only clear the running indicator if this is the
|
||||
+ // active task.
|
||||
+ if self.active_task_id.as_ref() == Some(&event_id) {
|
||||
+ self.bottom_pane.set_task_running(false);
|
||||
+ self.active_task_id = None;
|
||||
+ }
|
||||
self.conversation_history.add_error(message);
|
||||
- self.bottom_pane.set_task_running(false);
|
||||
}
|
||||
EventMsg::ExecApprovalRequest(ExecApprovalRequestEvent {
|
||||
command,
|
||||
cwd,
|
||||
reason,
|
||||
}) => {
|
||||
+ if should_drop_streaming {
|
||||
```
|
||||
|
||||
> Are you sure we should drop in this case: don't we need to ensure this request is displayed to the user? Am I misunderstanding?
|
||||
|
||||
- Created: 2025-07-18 07:22:12 UTC | Link: https://github.com/openai/codex/pull/1610#discussion_r2215226140
|
||||
|
||||
```diff
|
||||
@@ -330,6 +381,9 @@ impl ChatWidget<'_> {
|
||||
reason,
|
||||
grant_root,
|
||||
}) => {
|
||||
+ if should_drop_streaming {
|
||||
```
|
||||
|
||||
> Seeing this `if` in what feels like the majority of cases makes me wonder if there's a cleaner way to do this so we don't have to copy/paste this so much?
|
||||
257
prs/bolinfest/PR-1614.md
Normal file
257
prs/bolinfest/PR-1614.md
Normal file
@@ -0,0 +1,257 @@
|
||||
# PR #1614: Ensure session ID header is sent
|
||||
|
||||
- URL: https://github.com/openai/codex/pull/1614
|
||||
- Author: pakrym-oai
|
||||
- Created: 2025-07-18 15:11:17 UTC
|
||||
- Updated: 2025-07-18 16:59:15 UTC
|
||||
- Changes: +123/-0, Files changed: 3, Commits: 3
|
||||
|
||||
## Description
|
||||
|
||||
Include the current session id in Responses API requests.
|
||||
|
||||
## Full Diff
|
||||
|
||||
```diff
|
||||
diff --git a/codex-rs/core/src/client.rs b/codex-rs/core/src/client.rs
|
||||
index 8ec68d02e8..ae7904b8ff 100644
|
||||
--- a/codex-rs/core/src/client.rs
|
||||
+++ b/codex-rs/core/src/client.rs
|
||||
@@ -15,6 +15,7 @@ use tokio_util::io::ReaderStream;
|
||||
use tracing::debug;
|
||||
use tracing::trace;
|
||||
use tracing::warn;
|
||||
+use uuid::Uuid;
|
||||
|
||||
use crate::chat_completions::AggregateStreamExt;
|
||||
use crate::chat_completions::stream_chat_completions;
|
||||
@@ -44,6 +45,7 @@ pub struct ModelClient {
|
||||
config: Arc<Config>,
|
||||
client: reqwest::Client,
|
||||
provider: ModelProviderInfo,
|
||||
+ session_id: Uuid,
|
||||
effort: ReasoningEffortConfig,
|
||||
summary: ReasoningSummaryConfig,
|
||||
}
|
||||
@@ -54,11 +56,13 @@ impl ModelClient {
|
||||
provider: ModelProviderInfo,
|
||||
effort: ReasoningEffortConfig,
|
||||
summary: ReasoningSummaryConfig,
|
||||
+ session_id: Uuid,
|
||||
) -> Self {
|
||||
Self {
|
||||
config,
|
||||
client: reqwest::Client::new(),
|
||||
provider,
|
||||
+ session_id,
|
||||
effort,
|
||||
summary,
|
||||
}
|
||||
@@ -143,6 +147,7 @@ impl ModelClient {
|
||||
.provider
|
||||
.create_request_builder(&self.client)?
|
||||
.header("OpenAI-Beta", "responses=experimental")
|
||||
+ .header("session_id", self.session_id.to_string())
|
||||
.header(reqwest::header::ACCEPT, "text/event-stream")
|
||||
.json(&payload);
|
||||
|
||||
diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs
|
||||
index d4e73b2ebf..246198c006 100644
|
||||
--- a/codex-rs/core/src/codex.rs
|
||||
+++ b/codex-rs/core/src/codex.rs
|
||||
@@ -591,6 +591,7 @@ async fn submission_loop(
|
||||
provider.clone(),
|
||||
model_reasoning_effort,
|
||||
model_reasoning_summary,
|
||||
+ session_id,
|
||||
);
|
||||
|
||||
// abort any current running session and clone its state
|
||||
diff --git a/codex-rs/core/tests/client.rs b/codex-rs/core/tests/client.rs
|
||||
new file mode 100644
|
||||
index 0000000000..f4fb58f5a4
|
||||
--- /dev/null
|
||||
+++ b/codex-rs/core/tests/client.rs
|
||||
@@ -0,0 +1,117 @@
|
||||
+use std::time::Duration;
|
||||
+
|
||||
+use codex_core::Codex;
|
||||
+use codex_core::ModelProviderInfo;
|
||||
+use codex_core::exec::CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR;
|
||||
+use codex_core::protocol::EventMsg;
|
||||
+use codex_core::protocol::InputItem;
|
||||
+use codex_core::protocol::Op;
|
||||
+use codex_core::protocol::SessionConfiguredEvent;
|
||||
+mod test_support;
|
||||
+use tempfile::TempDir;
|
||||
+use test_support::load_default_config_for_test;
|
||||
+use test_support::load_sse_fixture_with_id;
|
||||
+use tokio::time::timeout;
|
||||
+use wiremock::Mock;
|
||||
+use wiremock::MockServer;
|
||||
+use wiremock::ResponseTemplate;
|
||||
+use wiremock::matchers::method;
|
||||
+use wiremock::matchers::path;
|
||||
+
|
||||
+/// Build minimal SSE stream with completed marker using the JSON fixture.
|
||||
+fn sse_completed(id: &str) -> String {
|
||||
+ load_sse_fixture_with_id("tests/fixtures/completed_template.json", id)
|
||||
+}
|
||||
+
|
||||
+#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
+async fn includes_session_id_and_model_headers_in_request() {
|
||||
+ #![allow(clippy::unwrap_used)]
|
||||
+
|
||||
+ if std::env::var(CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR).is_ok() {
|
||||
+ println!(
|
||||
+ "Skipping test because it cannot execute when network is disabled in a Codex sandbox."
|
||||
+ );
|
||||
+ return;
|
||||
+ }
|
||||
+
|
||||
+ // Mock server
|
||||
+ let server = MockServer::start().await;
|
||||
+
|
||||
+ // First request – must NOT include `previous_response_id`.
|
||||
+ let first = ResponseTemplate::new(200)
|
||||
+ .insert_header("content-type", "text/event-stream")
|
||||
+ .set_body_raw(sse_completed("resp1"), "text/event-stream");
|
||||
+
|
||||
+ Mock::given(method("POST"))
|
||||
+ .and(path("/v1/responses"))
|
||||
+ .respond_with(first)
|
||||
+ .expect(1)
|
||||
+ .mount(&server)
|
||||
+ .await;
|
||||
+
|
||||
+ // Environment
|
||||
+ // Update environment – `set_var` is `unsafe` starting with the 2024
|
||||
+ // edition so we group the calls into a single `unsafe { … }` block.
|
||||
+ unsafe {
|
||||
+ std::env::set_var("OPENAI_REQUEST_MAX_RETRIES", "0");
|
||||
+ std::env::set_var("OPENAI_STREAM_MAX_RETRIES", "0");
|
||||
+ }
|
||||
+ let model_provider = ModelProviderInfo {
|
||||
+ name: "openai".into(),
|
||||
+ base_url: format!("{}/v1", server.uri()),
|
||||
+ // Environment variable that should exist in the test environment.
|
||||
+ // ModelClient will return an error if the environment variable for the
|
||||
+ // provider is not set.
|
||||
+ env_key: Some("PATH".into()),
|
||||
+ env_key_instructions: None,
|
||||
+ wire_api: codex_core::WireApi::Responses,
|
||||
+ query_params: None,
|
||||
+ http_headers: Some(
|
||||
+ [("originator".to_string(), "codex_cli_rs".to_string())]
|
||||
+ .into_iter()
|
||||
+ .collect(),
|
||||
+ ),
|
||||
+ env_http_headers: None,
|
||||
+ };
|
||||
+
|
||||
+ // Init session
|
||||
+ let codex_home = TempDir::new().unwrap();
|
||||
+ let mut config = load_default_config_for_test(&codex_home);
|
||||
+ config.model_provider = model_provider;
|
||||
+ let ctrl_c = std::sync::Arc::new(tokio::sync::Notify::new());
|
||||
+ let (codex, _init_id) = Codex::spawn(config, ctrl_c.clone()).await.unwrap();
|
||||
+
|
||||
+ codex
|
||||
+ .submit(Op::UserInput {
|
||||
+ items: vec![InputItem::Text {
|
||||
+ text: "hello".into(),
|
||||
+ }],
|
||||
+ })
|
||||
+ .await
|
||||
+ .unwrap();
|
||||
+
|
||||
+ let mut current_session_id = None;
|
||||
+ // Wait for TaskComplete
|
||||
+ loop {
|
||||
+ let ev = timeout(Duration::from_secs(1), codex.next_event())
|
||||
+ .await
|
||||
+ .unwrap()
|
||||
+ .unwrap();
|
||||
+
|
||||
+ if let EventMsg::SessionConfigured(SessionConfiguredEvent { session_id, .. }) = ev.msg {
|
||||
+ current_session_id = Some(session_id.to_string());
|
||||
+ }
|
||||
+ if matches!(ev.msg, EventMsg::TaskComplete(_)) {
|
||||
+ break;
|
||||
+ }
|
||||
+ }
|
||||
+
|
||||
+ // get request from the server
|
||||
+ let request = &server.received_requests().await.unwrap()[0];
|
||||
+ let request_body = request.headers.get("session_id").unwrap();
|
||||
+ let originator = request.headers.get("originator").unwrap();
|
||||
+
|
||||
+ assert!(current_session_id.is_some());
|
||||
+ assert_eq!(request_body.to_str().unwrap(), ¤t_session_id.unwrap());
|
||||
+ assert_eq!(originator.to_str().unwrap(), "codex_cli_rs");
|
||||
+}
|
||||
```
|
||||
|
||||
## Review Comments
|
||||
|
||||
### codex-rs/core/tests/client.rs
|
||||
|
||||
- Created: 2025-07-18 16:33:22 UTC | Link: https://github.com/openai/codex/pull/1614#discussion_r2216455616
|
||||
|
||||
```diff
|
||||
@@ -0,0 +1,111 @@
|
||||
+use std::time::Duration;
|
||||
+
|
||||
+use codex_core::Codex;
|
||||
+use codex_core::ModelProviderInfo;
|
||||
+use codex_core::exec::CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR;
|
||||
+use codex_core::protocol::EventMsg;
|
||||
+use codex_core::protocol::InputItem;
|
||||
+use codex_core::protocol::Op;
|
||||
+use codex_core::protocol::SessionConfiguredEvent;
|
||||
+mod test_support;
|
||||
+use tempfile::TempDir;
|
||||
+use test_support::load_default_config_for_test;
|
||||
+use test_support::load_sse_fixture_with_id;
|
||||
+use tokio::time::timeout;
|
||||
+use wiremock::Mock;
|
||||
+use wiremock::MockServer;
|
||||
+use wiremock::ResponseTemplate;
|
||||
+use wiremock::matchers::method;
|
||||
+use wiremock::matchers::path;
|
||||
+
|
||||
+/// Build minimal SSE stream with completed marker using the JSON fixture.
|
||||
+fn sse_completed(id: &str) -> String {
|
||||
+ load_sse_fixture_with_id("tests/fixtures/completed_template.json", id)
|
||||
+}
|
||||
+
|
||||
+#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
+async fn includes_session_id_in_request() {
|
||||
+ #![allow(clippy::unwrap_used)]
|
||||
+
|
||||
+ if std::env::var(CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR).is_ok() {
|
||||
+ println!(
|
||||
+ "Skipping test because it cannot execute when network is disabled in a Codex sandbox."
|
||||
+ );
|
||||
+ return;
|
||||
+ }
|
||||
+
|
||||
+ // Mock server
|
||||
+ let server = MockServer::start().await;
|
||||
+
|
||||
+ // First request – must NOT include `previous_response_id`.
|
||||
+ let first = ResponseTemplate::new(200)
|
||||
+ .insert_header("content-type", "text/event-stream")
|
||||
+ .set_body_raw(sse_completed("resp1"), "text/event-stream");
|
||||
+
|
||||
+ Mock::given(method("POST"))
|
||||
+ .and(path("/v1/responses"))
|
||||
+ .respond_with(first)
|
||||
+ .expect(1)
|
||||
+ .mount(&server)
|
||||
+ .await;
|
||||
+
|
||||
+ // Environment
|
||||
```
|
||||
|
||||
> FYI @aibrahim-oai this will have to be updated as part of #1601 assuming this is merged first.
|
||||
3633
prs/bolinfest/PR-1621.md
Normal file
3633
prs/bolinfest/PR-1621.md
Normal file
File diff suppressed because it is too large
Load Diff
513
prs/bolinfest/PR-1623.md
Normal file
513
prs/bolinfest/PR-1623.md
Normal file
@@ -0,0 +1,513 @@
|
||||
# PR #1623: feat: leverage elicitations in the MCP server
|
||||
|
||||
- URL: https://github.com/openai/codex/pull/1623
|
||||
- Author: bolinfest
|
||||
- Created: 2025-07-19 00:19:07 UTC
|
||||
- Updated: 2025-07-20 15:15:38 UTC
|
||||
- Changes: +149/-22, Files changed: 9, Commits: 1
|
||||
|
||||
## Description
|
||||
|
||||
This updates the MCP server so that if it receives an `ExecApprovalRequest` from the `Codex` session, it in turn sends an [MCP elicitation](https://modelcontextprotocol.io/specification/draft/client/elicitation) to the client to ask for the approval decision. Upon getting a response, it forwards the client's decision via `Op::ExecApproval`.
|
||||
|
||||
Admittedly, we should be doing the same thing for `ApplyPatchApprovalRequest`, but this is our first time experimenting with elicitations, so I'm inclined to defer wiring that code path up until we feel good about how this one works.
|
||||
|
||||
---
|
||||
[//]: # (BEGIN SAPLING FOOTER)
|
||||
Stack created with [Sapling](https://sapling-scm.com). Best reviewed with [ReviewStack](https://reviewstack.dev/openai/codex/pull/1623).
|
||||
* __->__ #1623
|
||||
* #1622
|
||||
* #1621
|
||||
* #1620
|
||||
|
||||
## Full Diff
|
||||
|
||||
```diff
|
||||
diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock
|
||||
index a25e0f8be0..9171369ae2 100644
|
||||
--- a/codex-rs/Cargo.lock
|
||||
+++ b/codex-rs/Cargo.lock
|
||||
@@ -799,6 +799,7 @@ dependencies = [
|
||||
"schemars 0.8.22",
|
||||
"serde",
|
||||
"serde_json",
|
||||
+ "shlex",
|
||||
"tokio",
|
||||
"toml 0.9.1",
|
||||
"tracing",
|
||||
diff --git a/codex-rs/core/src/mcp_connection_manager.rs b/codex-rs/core/src/mcp_connection_manager.rs
|
||||
index cb91bc6127..886e4f8bf7 100644
|
||||
--- a/codex-rs/core/src/mcp_connection_manager.rs
|
||||
+++ b/codex-rs/core/src/mcp_connection_manager.rs
|
||||
@@ -18,6 +18,7 @@ use mcp_types::ClientCapabilities;
|
||||
use mcp_types::Implementation;
|
||||
use mcp_types::Tool;
|
||||
|
||||
+use serde_json::json;
|
||||
use sha1::Digest;
|
||||
use sha1::Sha1;
|
||||
use tokio::task::JoinSet;
|
||||
@@ -135,7 +136,9 @@ impl McpConnectionManager {
|
||||
experimental: None,
|
||||
roots: None,
|
||||
sampling: None,
|
||||
- elicitation: None,
|
||||
+ // https://modelcontextprotocol.io/specification/2025-06-18/client/elicitation#capabilities
|
||||
+ // indicates this should be an empty object.
|
||||
+ elicitation: Some(json!({})),
|
||||
},
|
||||
client_info: Implementation {
|
||||
name: "codex-mcp-client".to_owned(),
|
||||
diff --git a/codex-rs/mcp-server/Cargo.toml b/codex-rs/mcp-server/Cargo.toml
|
||||
index f91a3dc8f8..640a999317 100644
|
||||
--- a/codex-rs/mcp-server/Cargo.toml
|
||||
+++ b/codex-rs/mcp-server/Cargo.toml
|
||||
@@ -22,6 +22,7 @@ mcp-types = { path = "../mcp-types" }
|
||||
schemars = "0.8.22"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
+shlex = "1.3.0"
|
||||
toml = "0.9"
|
||||
tracing = { version = "0.1.41", features = ["log"] }
|
||||
tracing-subscriber = { version = "0.3", features = ["fmt", "env-filter"] }
|
||||
diff --git a/codex-rs/mcp-server/src/codex_tool_runner.rs b/codex-rs/mcp-server/src/codex_tool_runner.rs
|
||||
index a20566d61c..3036df5188 100644
|
||||
--- a/codex-rs/mcp-server/src/codex_tool_runner.rs
|
||||
+++ b/codex-rs/mcp-server/src/codex_tool_runner.rs
|
||||
@@ -4,18 +4,27 @@
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
+use codex_core::Codex;
|
||||
use codex_core::codex_wrapper::init_codex;
|
||||
use codex_core::config::Config as CodexConfig;
|
||||
use codex_core::protocol::AgentMessageEvent;
|
||||
use codex_core::protocol::EventMsg;
|
||||
+use codex_core::protocol::ExecApprovalRequestEvent;
|
||||
use codex_core::protocol::InputItem;
|
||||
use codex_core::protocol::Op;
|
||||
+use codex_core::protocol::ReviewDecision;
|
||||
use codex_core::protocol::Submission;
|
||||
use codex_core::protocol::TaskCompleteEvent;
|
||||
use mcp_types::CallToolResult;
|
||||
use mcp_types::ContentBlock;
|
||||
+use mcp_types::ElicitRequest;
|
||||
+use mcp_types::ElicitRequestParamsRequestedSchema;
|
||||
+use mcp_types::ModelContextProtocolRequest;
|
||||
use mcp_types::RequestId;
|
||||
use mcp_types::TextContent;
|
||||
+use serde::Deserialize;
|
||||
+use serde_json::json;
|
||||
+use tracing::error;
|
||||
|
||||
use crate::outgoing_message::OutgoingMessageSender;
|
||||
|
||||
@@ -45,6 +54,7 @@ pub async fn run_codex_tool_session(
|
||||
return;
|
||||
}
|
||||
};
|
||||
+ let codex = Arc::new(codex);
|
||||
|
||||
// Send initial SessionConfigured event.
|
||||
outgoing.send_event_as_notification(&first_event).await;
|
||||
@@ -58,7 +68,7 @@ pub async fn run_codex_tool_session(
|
||||
};
|
||||
|
||||
let submission = Submission {
|
||||
- id: sub_id,
|
||||
+ id: sub_id.clone(),
|
||||
op: Op::UserInput {
|
||||
items: vec![InputItem::Text {
|
||||
text: initial_prompt.clone(),
|
||||
@@ -77,18 +87,50 @@ pub async fn run_codex_tool_session(
|
||||
Ok(event) => {
|
||||
outgoing.send_event_as_notification(&event).await;
|
||||
|
||||
- match &event.msg {
|
||||
- EventMsg::ExecApprovalRequest(_) => {
|
||||
- let result = CallToolResult {
|
||||
- content: vec![ContentBlock::TextContent(TextContent {
|
||||
- r#type: "text".to_string(),
|
||||
- text: "EXEC_APPROVAL_REQUIRED".to_string(),
|
||||
- annotations: None,
|
||||
- })],
|
||||
- is_error: None,
|
||||
- structured_content: None,
|
||||
- };
|
||||
- outgoing.send_response(id.clone(), result.into()).await;
|
||||
+ match event.msg {
|
||||
+ EventMsg::ExecApprovalRequest(ExecApprovalRequestEvent {
|
||||
+ command,
|
||||
+ cwd,
|
||||
+ reason: _,
|
||||
+ }) => {
|
||||
+ let escaped_command = shlex::try_join(command.iter().map(|s| s.as_str()))
|
||||
+ .unwrap_or_else(|_| command.join(" "));
|
||||
+ let message = format!("Allow Codex to run `{escaped_command}` in {cwd:?}?");
|
||||
+
|
||||
+ let params = json!({
|
||||
+ // These fields are required so that `params`
|
||||
+ // conforms to ElicitRequestParams.
|
||||
+ "message": message,
|
||||
+ "requestedSchema": ElicitRequestParamsRequestedSchema {
|
||||
+ r#type: "object".to_string(),
|
||||
+ properties: json!({}),
|
||||
+ required: None,
|
||||
+ },
|
||||
+
|
||||
+ // These are additional fields the client can use to
|
||||
+ // correlate the request with the codex tool call.
|
||||
+ "codex_elicitation": "exec-approval",
|
||||
+ "codex_mcp_tool_call_id": sub_id,
|
||||
+ "codex_event_id": event.id,
|
||||
+ "codex_command": command,
|
||||
+ // Could convert it to base64 encoded bytes if we
|
||||
+ // don't want to use to_string_lossy() here?
|
||||
+ "codex_cwd": cwd.to_string_lossy().to_string()
|
||||
+ });
|
||||
+ let on_response = outgoing
|
||||
+ .send_request(ElicitRequest::METHOD, Some(params))
|
||||
+ .await;
|
||||
+
|
||||
+ // Listen for the response on a separate task so we do
|
||||
+ // not block the main loop of this function.
|
||||
+ {
|
||||
+ let codex = codex.clone();
|
||||
+ let event_id = event.id.clone();
|
||||
+ tokio::spawn(async move {
|
||||
+ on_exec_approval_response(event_id, on_response, codex).await;
|
||||
+ });
|
||||
+ }
|
||||
+
|
||||
break;
|
||||
}
|
||||
EventMsg::ApplyPatchApprovalRequest(_) => {
|
||||
@@ -172,3 +214,42 @@ pub async fn run_codex_tool_session(
|
||||
}
|
||||
}
|
||||
}
|
||||
+
|
||||
+async fn on_exec_approval_response(
|
||||
+ event_id: String,
|
||||
+ receiver: tokio::sync::oneshot::Receiver<mcp_types::Result>,
|
||||
+ codex: Arc<Codex>,
|
||||
+) {
|
||||
+ let response = receiver.await;
|
||||
+ let value = match response {
|
||||
+ Ok(value) => value,
|
||||
+ Err(err) => {
|
||||
+ error!("request failed: {err:?}");
|
||||
+ return;
|
||||
+ }
|
||||
+ };
|
||||
+
|
||||
+ // Try to deserialize `value` and then make the appropriate call to `codex`.
|
||||
+ let response = match serde_json::from_value::<ExecApprovalResponse>(value) {
|
||||
+ Ok(response) => response,
|
||||
+ Err(err) => {
|
||||
+ error!("failed to deserialize ExecApprovalResponse: {err}");
|
||||
+ return;
|
||||
+ }
|
||||
+ };
|
||||
+
|
||||
+ if let Err(err) = codex
|
||||
+ .submit(Op::ExecApproval {
|
||||
+ id: event_id,
|
||||
+ decision: response.decision,
|
||||
+ })
|
||||
+ .await
|
||||
+ {
|
||||
+ error!("failed to submit ExecApproval: {err}");
|
||||
+ }
|
||||
+}
|
||||
+
|
||||
+#[derive(Debug, Deserialize)]
|
||||
+pub struct ExecApprovalResponse {
|
||||
+ pub decision: ReviewDecision,
|
||||
+}
|
||||
diff --git a/codex-rs/mcp-server/src/lib.rs b/codex-rs/mcp-server/src/lib.rs
|
||||
index b968b4976e..3b984ecf13 100644
|
||||
--- a/codex-rs/mcp-server/src/lib.rs
|
||||
+++ b/codex-rs/mcp-server/src/lib.rs
|
||||
@@ -72,7 +72,7 @@ pub async fn run_main(codex_linux_sandbox_exe: Option<PathBuf>) -> IoResult<()>
|
||||
while let Some(msg) = incoming_rx.recv().await {
|
||||
match msg {
|
||||
JSONRPCMessage::Request(r) => processor.process_request(r).await,
|
||||
- JSONRPCMessage::Response(r) => processor.process_response(r),
|
||||
+ JSONRPCMessage::Response(r) => processor.process_response(r).await,
|
||||
JSONRPCMessage::Notification(n) => processor.process_notification(n),
|
||||
JSONRPCMessage::Error(e) => processor.process_error(e),
|
||||
}
|
||||
diff --git a/codex-rs/mcp-server/src/message_processor.rs b/codex-rs/mcp-server/src/message_processor.rs
|
||||
index aad7f21132..d994d8a7a2 100644
|
||||
--- a/codex-rs/mcp-server/src/message_processor.rs
|
||||
+++ b/codex-rs/mcp-server/src/message_processor.rs
|
||||
@@ -101,8 +101,10 @@ impl MessageProcessor {
|
||||
}
|
||||
|
||||
/// Handle a standalone JSON-RPC response originating from the peer.
|
||||
- pub(crate) fn process_response(&mut self, response: JSONRPCResponse) {
|
||||
+ pub(crate) async fn process_response(&mut self, response: JSONRPCResponse) {
|
||||
tracing::info!("<- response: {:?}", response);
|
||||
+ let JSONRPCResponse { id, result, .. } = response;
|
||||
+ self.outgoing.notify_client_response(id, result).await
|
||||
}
|
||||
|
||||
/// Handle a fire-and-forget JSON-RPC notification.
|
||||
diff --git a/codex-rs/mcp-server/src/outgoing_message.rs b/codex-rs/mcp-server/src/outgoing_message.rs
|
||||
index 93a760d36b..a1eea65f25 100644
|
||||
--- a/codex-rs/mcp-server/src/outgoing_message.rs
|
||||
+++ b/codex-rs/mcp-server/src/outgoing_message.rs
|
||||
@@ -1,3 +1,4 @@
|
||||
+use std::collections::HashMap;
|
||||
use std::sync::atomic::AtomicI64;
|
||||
use std::sync::atomic::Ordering;
|
||||
|
||||
@@ -12,11 +13,15 @@ use mcp_types::JSONRPCResponse;
|
||||
use mcp_types::RequestId;
|
||||
use mcp_types::Result;
|
||||
use serde::Serialize;
|
||||
+use tokio::sync::Mutex;
|
||||
use tokio::sync::mpsc;
|
||||
+use tokio::sync::oneshot;
|
||||
+use tracing::warn;
|
||||
|
||||
pub(crate) struct OutgoingMessageSender {
|
||||
next_request_id: AtomicI64,
|
||||
sender: mpsc::Sender<OutgoingMessage>,
|
||||
+ request_id_to_callback: Mutex<HashMap<RequestId, oneshot::Sender<Result>>>,
|
||||
}
|
||||
|
||||
impl OutgoingMessageSender {
|
||||
@@ -24,17 +29,48 @@ impl OutgoingMessageSender {
|
||||
Self {
|
||||
next_request_id: AtomicI64::new(0),
|
||||
sender,
|
||||
+ request_id_to_callback: Mutex::new(HashMap::new()),
|
||||
}
|
||||
}
|
||||
|
||||
- #[allow(dead_code)]
|
||||
- pub(crate) async fn send_request(&self, method: &str, params: Option<serde_json::Value>) {
|
||||
+ pub(crate) async fn send_request(
|
||||
+ &self,
|
||||
+ method: &str,
|
||||
+ params: Option<serde_json::Value>,
|
||||
+ ) -> oneshot::Receiver<Result> {
|
||||
+ let id = RequestId::Integer(self.next_request_id.fetch_add(1, Ordering::Relaxed));
|
||||
+ let outgoing_message_id = id.clone();
|
||||
+ let (tx_approve, rx_approve) = oneshot::channel();
|
||||
+ {
|
||||
+ let mut request_id_to_callback = self.request_id_to_callback.lock().await;
|
||||
+ request_id_to_callback.insert(id, tx_approve);
|
||||
+ }
|
||||
+
|
||||
let outgoing_message = OutgoingMessage::Request(OutgoingRequest {
|
||||
- id: RequestId::Integer(self.next_request_id.fetch_add(1, Ordering::Relaxed)),
|
||||
+ id: outgoing_message_id,
|
||||
method: method.to_string(),
|
||||
params,
|
||||
});
|
||||
let _ = self.sender.send(outgoing_message).await;
|
||||
+ rx_approve
|
||||
+ }
|
||||
+
|
||||
+ pub(crate) async fn notify_client_response(&self, id: RequestId, result: Result) {
|
||||
+ let entry = {
|
||||
+ let mut request_id_to_callback = self.request_id_to_callback.lock().await;
|
||||
+ request_id_to_callback.remove_entry(&id)
|
||||
+ };
|
||||
+
|
||||
+ match entry {
|
||||
+ Some((id, sender)) => {
|
||||
+ if let Err(err) = sender.send(result) {
|
||||
+ warn!("could not notify callback for {id:?} due to: {err:?}");
|
||||
+ }
|
||||
+ }
|
||||
+ None => {
|
||||
+ warn!("could not find callback for {id:?}");
|
||||
+ }
|
||||
+ }
|
||||
}
|
||||
|
||||
pub(crate) async fn send_response(&self, id: RequestId, result: Result) {
|
||||
diff --git a/codex-rs/mcp-types/generate_mcp_types.py b/codex-rs/mcp-types/generate_mcp_types.py
|
||||
index 224e04c0a5..38f57e9a1b 100755
|
||||
--- a/codex-rs/mcp-types/generate_mcp_types.py
|
||||
+++ b/codex-rs/mcp-types/generate_mcp_types.py
|
||||
@@ -18,6 +18,9 @@
|
||||
JSONRPC_VERSION = "2.0"
|
||||
|
||||
STANDARD_DERIVE = "#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]\n"
|
||||
+STANDARD_HASHABLE_DERIVE = (
|
||||
+ "#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, Hash, Eq)]\n"
|
||||
+)
|
||||
|
||||
# Will be populated with the schema's `definitions` map in `main()` so that
|
||||
# helper functions (for example `define_any_of`) can perform look-ups while
|
||||
@@ -391,7 +394,7 @@ def define_string_enum(
|
||||
|
||||
|
||||
def define_untagged_enum(name: str, type_list: list[str], out: list[str]) -> None:
|
||||
- out.append(STANDARD_DERIVE)
|
||||
+ out.append(STANDARD_HASHABLE_DERIVE)
|
||||
out.append("#[serde(untagged)]\n")
|
||||
out.append(f"pub enum {name} {{\n")
|
||||
for simple_type in type_list:
|
||||
diff --git a/codex-rs/mcp-types/src/lib.rs b/codex-rs/mcp-types/src/lib.rs
|
||||
index 6341fb62b4..cf09d67e35 100644
|
||||
--- a/codex-rs/mcp-types/src/lib.rs
|
||||
+++ b/codex-rs/mcp-types/src/lib.rs
|
||||
@@ -931,7 +931,7 @@ pub struct ProgressNotificationParams {
|
||||
pub total: Option<f64>,
|
||||
}
|
||||
|
||||
-#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
|
||||
+#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, Hash, Eq)]
|
||||
#[serde(untagged)]
|
||||
pub enum ProgressToken {
|
||||
String(String),
|
||||
@@ -1031,7 +1031,7 @@ pub struct Request {
|
||||
pub params: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
-#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
|
||||
+#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, Hash, Eq)]
|
||||
#[serde(untagged)]
|
||||
pub enum RequestId {
|
||||
String(String),
|
||||
```
|
||||
|
||||
## Review Comments
|
||||
|
||||
### codex-rs/mcp-server/src/codex_tool_runner.rs
|
||||
|
||||
- Created: 2025-07-20 14:51:18 UTC | Link: https://github.com/openai/codex/pull/1623#discussion_r2217850709
|
||||
|
||||
```diff
|
||||
@@ -77,18 +87,50 @@ pub async fn run_codex_tool_session(
|
||||
Ok(event) => {
|
||||
outgoing.send_event_as_notification(&event).await;
|
||||
|
||||
- match &event.msg {
|
||||
- EventMsg::ExecApprovalRequest(_) => {
|
||||
- let result = CallToolResult {
|
||||
- content: vec![ContentBlock::TextContent(TextContent {
|
||||
- r#type: "text".to_string(),
|
||||
- text: "EXEC_APPROVAL_REQUIRED".to_string(),
|
||||
- annotations: None,
|
||||
- })],
|
||||
- is_error: None,
|
||||
- structured_content: None,
|
||||
- };
|
||||
- outgoing.send_response(id.clone(), result.into()).await;
|
||||
+ match event.msg {
|
||||
+ EventMsg::ExecApprovalRequest(ExecApprovalRequestEvent {
|
||||
+ command,
|
||||
+ cwd,
|
||||
+ reason: _,
|
||||
+ }) => {
|
||||
+ let escaped_command = shlex::try_join(command.iter().map(|s| s.as_str()))
|
||||
+ .unwrap_or_else(|_| command.join(" "));
|
||||
+ let message = format!("Allow Codex to run `{escaped_command}` in {cwd:?}?");
|
||||
+
|
||||
+ let params = json!({
|
||||
+ // These fields are required so that `params`
|
||||
+ // conforms to ElicitRequestParams.
|
||||
+ "message": message,
|
||||
+ "requestedSchema": ElicitRequestParamsRequestedSchema {
|
||||
+ r#type: "object".to_string(),
|
||||
+ properties: json!({}),
|
||||
+ required: None,
|
||||
+ },
|
||||
+
|
||||
+ // These are additional fields the client can use to
|
||||
+ // correlate the request with the codex tool call.
|
||||
+ "codex_elicitation": "exec-approval",
|
||||
+ "codex_mcp_tool_call_id": sub_id,
|
||||
+ "codex_event_id": event.id,
|
||||
+ "codex_command": command,
|
||||
+ // Could convert it to base64 encoded bytes if we
|
||||
+ // don't want to use to_string_lossy() here?
|
||||
+ "codex_cwd": cwd.to_string_lossy().to_string()
|
||||
+ });
|
||||
+ let on_response = outgoing
|
||||
+ .send_request(ElicitRequest::METHOD, Some(params))
|
||||
+ .await;
|
||||
```
|
||||
|
||||
> The `await` on the `send_request()` will only block if the underlying channel is full, which should not really happen.
|
||||
>
|
||||
> The bigger blocker would be waiting for the response, which is why I `tokio::spawn()` below.
|
||||
>
|
||||
> > One of them will await first but would that prevent us from sending a second approval?
|
||||
>
|
||||
> I believe that one async task would be scheduled for each pending approval via `tokio::spawn()`, so it should not be a problem?
|
||||
|
||||
- Created: 2025-07-20 14:51:27 UTC | Link: https://github.com/openai/codex/pull/1623#discussion_r2217850762
|
||||
|
||||
```diff
|
||||
@@ -77,18 +87,50 @@ pub async fn run_codex_tool_session(
|
||||
Ok(event) => {
|
||||
outgoing.send_event_as_notification(&event).await;
|
||||
|
||||
- match &event.msg {
|
||||
- EventMsg::ExecApprovalRequest(_) => {
|
||||
- let result = CallToolResult {
|
||||
- content: vec![ContentBlock::TextContent(TextContent {
|
||||
- r#type: "text".to_string(),
|
||||
- text: "EXEC_APPROVAL_REQUIRED".to_string(),
|
||||
- annotations: None,
|
||||
- })],
|
||||
- is_error: None,
|
||||
- structured_content: None,
|
||||
- };
|
||||
- outgoing.send_response(id.clone(), result.into()).await;
|
||||
+ match event.msg {
|
||||
+ EventMsg::ExecApprovalRequest(ExecApprovalRequestEvent {
|
||||
+ command,
|
||||
+ cwd,
|
||||
+ reason: _,
|
||||
+ }) => {
|
||||
+ let escaped_command = shlex::try_join(command.iter().map(|s| s.as_str()))
|
||||
+ .unwrap_or_else(|_| command.join(" "));
|
||||
+ let message = format!("Allow Codex to run `{escaped_command}` in {cwd:?}?");
|
||||
+
|
||||
+ let params = json!({
|
||||
+ // These fields are required so that `params`
|
||||
+ // conforms to ElicitRequestParams.
|
||||
```
|
||||
|
||||
> Good point, will fix.
|
||||
|
||||
- Created: 2025-07-20 14:52:02 UTC | Link: https://github.com/openai/codex/pull/1623#discussion_r2217850910
|
||||
|
||||
```diff
|
||||
@@ -172,3 +214,42 @@ pub async fn run_codex_tool_session(
|
||||
}
|
||||
}
|
||||
}
|
||||
+
|
||||
+async fn on_exec_approval_response(
|
||||
+ event_id: String,
|
||||
+ receiver: tokio::sync::oneshot::Receiver<mcp_types::Result>,
|
||||
+ codex: Arc<Codex>,
|
||||
+) {
|
||||
+ let response = receiver.await;
|
||||
+ let value = match response {
|
||||
+ Ok(value) => value,
|
||||
+ Err(err) => {
|
||||
+ error!("request failed: {err:?}");
|
||||
+ return;
|
||||
+ }
|
||||
+ };
|
||||
+
|
||||
+ // Try to deserialize `value` and then make the appropriate call to `codex`.
|
||||
+ let response = match serde_json::from_value::<ExecApprovalResponse>(value) {
|
||||
+ Ok(response) => response,
|
||||
+ Err(err) => {
|
||||
+ error!("failed to deserialize ExecApprovalResponse: {err}");
|
||||
+ return;
|
||||
```
|
||||
|
||||
> Good point!
|
||||
70
prs/bolinfest/PR-1629.md
Normal file
70
prs/bolinfest/PR-1629.md
Normal file
@@ -0,0 +1,70 @@
|
||||
# PR #1629: Don't drop sessions on elicitation responses
|
||||
|
||||
- URL: https://github.com/openai/codex/pull/1629
|
||||
- Author: gpeal
|
||||
- Created: 2025-07-20 01:57:01 UTC
|
||||
- Updated: 2025-07-20 17:31:29 UTC
|
||||
- Changes: +4/-2, Files changed: 1, Commits: 1
|
||||
|
||||
## Description
|
||||
|
||||
(No description.)
|
||||
|
||||
## Full Diff
|
||||
|
||||
```diff
|
||||
diff --git a/codex-rs/mcp-server/src/codex_tool_runner.rs b/codex-rs/mcp-server/src/codex_tool_runner.rs
|
||||
index 3036df5188..ae54599f9d 100644
|
||||
--- a/codex-rs/mcp-server/src/codex_tool_runner.rs
|
||||
+++ b/codex-rs/mcp-server/src/codex_tool_runner.rs
|
||||
@@ -131,7 +131,8 @@ pub async fn run_codex_tool_session(
|
||||
});
|
||||
}
|
||||
|
||||
- break;
|
||||
+ // Continue, don't break so the session continues.
|
||||
+ continue;
|
||||
}
|
||||
EventMsg::ApplyPatchApprovalRequest(_) => {
|
||||
let result = CallToolResult {
|
||||
@@ -144,7 +145,8 @@ pub async fn run_codex_tool_session(
|
||||
structured_content: None,
|
||||
};
|
||||
outgoing.send_response(id.clone(), result.into()).await;
|
||||
- break;
|
||||
+ // Continue, don't break so the session continues.
|
||||
+ continue;
|
||||
}
|
||||
EventMsg::TaskComplete(TaskCompleteEvent { last_agent_message }) => {
|
||||
let text = match last_agent_message {
|
||||
```
|
||||
|
||||
## Review Comments
|
||||
|
||||
### codex-rs/mcp-server/src/codex_tool_runner.rs
|
||||
|
||||
- Created: 2025-07-20 06:01:40 UTC | Link: https://github.com/openai/codex/pull/1629#discussion_r2217587164
|
||||
|
||||
```diff
|
||||
@@ -131,7 +131,8 @@ pub async fn run_codex_tool_session(
|
||||
});
|
||||
}
|
||||
|
||||
- break;
|
||||
+ // Continue, don't break so the session continues.
|
||||
```
|
||||
|
||||
> Should we keep this comment?
|
||||
|
||||
- Created: 2025-07-20 06:01:52 UTC | Link: https://github.com/openai/codex/pull/1629#discussion_r2217587503
|
||||
|
||||
```diff
|
||||
@@ -144,7 +145,8 @@ pub async fn run_codex_tool_session(
|
||||
structured_content: None,
|
||||
};
|
||||
outgoing.send_response(id.clone(), result.into()).await;
|
||||
- break;
|
||||
+ // Continue, don't break so the session continues.
|
||||
```
|
||||
|
||||
> And here?
|
||||
490
prs/bolinfest/PR-1630.md
Normal file
490
prs/bolinfest/PR-1630.md
Normal file
@@ -0,0 +1,490 @@
|
||||
# PR #1630: chores: refactoring tests
|
||||
|
||||
- URL: https://github.com/openai/codex/pull/1630
|
||||
- Author: aibrahim-oai
|
||||
- Created: 2025-07-20 05:56:44 UTC
|
||||
- Updated: 2025-07-24 18:59:50 UTC
|
||||
- Changes: +75/-46, Files changed: 7, Commits: 35
|
||||
|
||||
## Description
|
||||
|
||||
- Used `Command::cargo_bin` instead of `AssertCommand::new("cargo")`
|
||||
|
||||
## Full Diff
|
||||
|
||||
```diff
|
||||
diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock
|
||||
index 3e4b84a435..050d497c2c 100644
|
||||
--- a/codex-rs/Cargo.lock
|
||||
+++ b/codex-rs/Cargo.lock
|
||||
@@ -626,6 +626,7 @@ name = "codex-cli"
|
||||
version = "0.0.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
+ "assert_cmd",
|
||||
"clap",
|
||||
"clap_complete",
|
||||
"codex-chatgpt",
|
||||
@@ -636,10 +637,15 @@ dependencies = [
|
||||
"codex-login",
|
||||
"codex-mcp-server",
|
||||
"codex-tui",
|
||||
+ "predicates",
|
||||
"serde_json",
|
||||
+ "tempfile",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
+ "uuid",
|
||||
+ "walkdir",
|
||||
+ "wiremock",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
diff --git a/codex-rs/cli/Cargo.toml b/codex-rs/cli/Cargo.toml
|
||||
index 943788157b..60d40f3451 100644
|
||||
--- a/codex-rs/cli/Cargo.toml
|
||||
+++ b/codex-rs/cli/Cargo.toml
|
||||
@@ -26,6 +26,7 @@ codex-login = { path = "../login" }
|
||||
codex-linux-sandbox = { path = "../linux-sandbox" }
|
||||
codex-mcp-server = { path = "../mcp-server" }
|
||||
codex-tui = { path = "../tui" }
|
||||
+predicates = "3.1.3"
|
||||
serde_json = "1"
|
||||
tokio = { version = "1", features = [
|
||||
"io-std",
|
||||
@@ -36,3 +37,10 @@ tokio = { version = "1", features = [
|
||||
] }
|
||||
tracing = "0.1.41"
|
||||
tracing-subscriber = "0.3.19"
|
||||
+
|
||||
+[dev-dependencies]
|
||||
+assert_cmd = "2"
|
||||
+tempfile = "3"
|
||||
+uuid = { version = "1", features = ["serde", "v4"] }
|
||||
+walkdir = "2.5.0"
|
||||
+wiremock = "0.6"
|
||||
diff --git a/codex-rs/core/tests/cli_responses_fixture.sse b/codex-rs/cli/tests/cli_responses_fixture.sse
|
||||
similarity index 100%
|
||||
rename from codex-rs/core/tests/cli_responses_fixture.sse
|
||||
rename to codex-rs/cli/tests/cli_responses_fixture.sse
|
||||
diff --git a/codex-rs/core/tests/cli_stream.rs b/codex-rs/cli/tests/cli_stream.rs
|
||||
similarity index 93%
|
||||
rename from codex-rs/core/tests/cli_stream.rs
|
||||
rename to codex-rs/cli/tests/cli_stream.rs
|
||||
index 567279ebd0..42a8d135f7 100644
|
||||
--- a/codex-rs/core/tests/cli_stream.rs
|
||||
+++ b/codex-rs/cli/tests/cli_stream.rs
|
||||
@@ -1,7 +1,8 @@
|
||||
#![expect(clippy::unwrap_used)]
|
||||
|
||||
-use assert_cmd::Command as AssertCommand;
|
||||
+use assert_cmd::prelude::*;
|
||||
use codex_core::exec::CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR;
|
||||
+use std::process::Command;
|
||||
use std::time::Duration;
|
||||
use std::time::Instant;
|
||||
use tempfile::TempDir;
|
||||
@@ -50,13 +51,8 @@ async fn chat_mode_stream_cli() {
|
||||
"model_providers.mock={{ name = \"mock\", base_url = \"{}/v1\", env_key = \"PATH\", wire_api = \"chat\" }}",
|
||||
server.uri()
|
||||
);
|
||||
- let mut cmd = AssertCommand::new("cargo");
|
||||
- cmd.arg("run")
|
||||
- .arg("-p")
|
||||
- .arg("codex-cli")
|
||||
- .arg("--quiet")
|
||||
- .arg("--")
|
||||
- .arg("exec")
|
||||
+ let mut cmd = Command::cargo_bin("codex").unwrap();
|
||||
+ cmd.arg("exec")
|
||||
.arg("--skip-git-repo-check")
|
||||
.arg("-c")
|
||||
.arg(&provider_override)
|
||||
@@ -100,13 +96,8 @@ async fn responses_api_stream_cli() {
|
||||
std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/cli_responses_fixture.sse");
|
||||
|
||||
let home = TempDir::new().unwrap();
|
||||
- let mut cmd = AssertCommand::new("cargo");
|
||||
- cmd.arg("run")
|
||||
- .arg("-p")
|
||||
- .arg("codex-cli")
|
||||
- .arg("--quiet")
|
||||
- .arg("--")
|
||||
- .arg("exec")
|
||||
+ let mut cmd = Command::cargo_bin("codex").unwrap();
|
||||
+ cmd.arg("exec")
|
||||
.arg("--skip-git-repo-check")
|
||||
.arg("-C")
|
||||
.arg(env!("CARGO_MANIFEST_DIR"))
|
||||
@@ -146,13 +137,8 @@ async fn integration_creates_and_checks_session_file() {
|
||||
|
||||
// 4. Run the codex CLI through cargo (ensures the right bin is built) and invoke `exec`,
|
||||
// which is what records a session.
|
||||
- let mut cmd = AssertCommand::new("cargo");
|
||||
- cmd.arg("run")
|
||||
- .arg("-p")
|
||||
- .arg("codex-cli")
|
||||
- .arg("--quiet")
|
||||
- .arg("--")
|
||||
- .arg("exec")
|
||||
+ let mut cmd = Command::cargo_bin("codex").unwrap();
|
||||
+ cmd.arg("exec")
|
||||
.arg("--skip-git-repo-check")
|
||||
.arg("-C")
|
||||
.arg(env!("CARGO_MANIFEST_DIR"))
|
||||
@@ -185,7 +171,9 @@ async fn integration_creates_and_checks_session_file() {
|
||||
for entry in WalkDir::new(&sessions_dir) {
|
||||
let entry = match entry {
|
||||
Ok(e) => e,
|
||||
- Err(_) => continue,
|
||||
+ Err(_) => {
|
||||
+ continue;
|
||||
+ }
|
||||
};
|
||||
if !entry.file_type().is_file() {
|
||||
continue;
|
||||
@@ -207,7 +195,9 @@ async fn integration_creates_and_checks_session_file() {
|
||||
}
|
||||
let item: serde_json::Value = match serde_json::from_str(line) {
|
||||
Ok(v) => v,
|
||||
- Err(_) => continue,
|
||||
+ Err(_) => {
|
||||
+ continue;
|
||||
+ }
|
||||
};
|
||||
if item.get("type").and_then(|t| t.as_str()) == Some("message") {
|
||||
if let Some(c) = item.get("content") {
|
||||
@@ -228,7 +218,6 @@ async fn integration_creates_and_checks_session_file() {
|
||||
Some(p) => p,
|
||||
None => panic!("No session file containing the marker was found"),
|
||||
};
|
||||
-
|
||||
// Basic sanity checks on location and metadata.
|
||||
let rel = match path.strip_prefix(&sessions_dir) {
|
||||
Ok(r) => r,
|
||||
@@ -312,13 +301,8 @@ async fn integration_creates_and_checks_session_file() {
|
||||
// to sidestep the issue.
|
||||
let resume_path_str = path.to_string_lossy().replace('\\', "/");
|
||||
let resume_override = format!("experimental_resume=\"{resume_path_str}\"");
|
||||
- let mut cmd2 = AssertCommand::new("cargo");
|
||||
- cmd2.arg("run")
|
||||
- .arg("-p")
|
||||
- .arg("codex-cli")
|
||||
- .arg("--quiet")
|
||||
- .arg("--")
|
||||
- .arg("exec")
|
||||
+ let mut cmd2 = Command::cargo_bin("codex").unwrap();
|
||||
+ cmd2.arg("exec")
|
||||
.arg("--skip-git-repo-check")
|
||||
.arg("-c")
|
||||
.arg(&resume_override)
|
||||
diff --git a/codex-rs/core/tests/live_cli.rs b/codex-rs/cli/tests/live_cli.rs
|
||||
similarity index 98%
|
||||
rename from codex-rs/core/tests/live_cli.rs
|
||||
rename to codex-rs/cli/tests/live_cli.rs
|
||||
index d79e242c4d..05f72fb443 100644
|
||||
--- a/codex-rs/core/tests/live_cli.rs
|
||||
+++ b/codex-rs/cli/tests/live_cli.rs
|
||||
@@ -30,7 +30,7 @@ fn run_live(prompt: &str) -> (assert_cmd::assert::Assert, TempDir) {
|
||||
// implementation). Instead we configure the std `Command` ourselves, then later hand the
|
||||
// resulting `Output` to `assert_cmd` for the familiar assertions.
|
||||
|
||||
- let mut cmd = Command::cargo_bin("codex-rs").unwrap();
|
||||
+ let mut cmd = Command::cargo_bin("codex-cli").unwrap();
|
||||
cmd.current_dir(dir.path());
|
||||
cmd.env("OPENAI_API_KEY", require_api_key());
|
||||
|
||||
diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs
|
||||
index 4cc888b62e..f35348b779 100644
|
||||
--- a/codex-rs/core/src/codex.rs
|
||||
+++ b/codex-rs/core/src/codex.rs
|
||||
@@ -594,7 +594,7 @@ async fn submission_loop(
|
||||
let mut restored_items: Option<Vec<ResponseItem>> = None;
|
||||
let rollout_recorder: Option<RolloutRecorder> =
|
||||
if let Some(path) = resume_path.as_ref() {
|
||||
- match RolloutRecorder::resume(path).await {
|
||||
+ match RolloutRecorder::resume(path, cwd.clone()).await {
|
||||
Ok((rec, saved)) => {
|
||||
session_id = saved.session_id;
|
||||
if !saved.items.is_empty() {
|
||||
diff --git a/codex-rs/core/src/rollout.rs b/codex-rs/core/src/rollout.rs
|
||||
index 7f0f61b9eb..3e6de34d96 100644
|
||||
--- a/codex-rs/core/src/rollout.rs
|
||||
+++ b/codex-rs/core/src/rollout.rs
|
||||
@@ -20,6 +20,8 @@ use tracing::warn;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::config::Config;
|
||||
+use crate::git_info::GitInfo;
|
||||
+use crate::git_info::collect_git_info;
|
||||
use crate::models::ResponseItem;
|
||||
|
||||
const SESSIONS_SUBDIR: &str = "sessions";
|
||||
@@ -31,6 +33,14 @@ pub struct SessionMeta {
|
||||
pub instructions: Option<String>,
|
||||
}
|
||||
|
||||
+#[derive(Serialize)]
|
||||
+struct SessionMetaWithGit {
|
||||
+ #[serde(flatten)]
|
||||
+ meta: SessionMeta,
|
||||
+ #[serde(skip_serializing_if = "Option::is_none")]
|
||||
+ git: Option<GitInfo>,
|
||||
+}
|
||||
+
|
||||
#[derive(Serialize, Deserialize, Default, Clone)]
|
||||
pub struct SessionStateSnapshot {}
|
||||
|
||||
@@ -86,15 +96,12 @@ impl RolloutRecorder {
|
||||
.format(timestamp_format)
|
||||
.map_err(|e| IoError::other(format!("failed to format timestamp: {e}")))?;
|
||||
|
||||
- let meta = SessionMeta {
|
||||
- timestamp,
|
||||
- id: session_id,
|
||||
- instructions,
|
||||
- };
|
||||
+ // Clone the cwd for the spawned task to collect git info asynchronously
|
||||
+ let cwd = config.cwd.clone();
|
||||
|
||||
// A reasonably-sized bounded channel. If the buffer fills up the send
|
||||
// future will yield, which is fine – we only need to ensure we do not
|
||||
- // perform *blocking* I/O on the caller’s thread.
|
||||
+ // perform *blocking* I/O on the caller's thread.
|
||||
let (tx, rx) = mpsc::channel::<RolloutCmd>(256);
|
||||
|
||||
// Spawn a Tokio task that owns the file handle and performs async
|
||||
@@ -103,7 +110,12 @@ impl RolloutRecorder {
|
||||
tokio::task::spawn(rollout_writer(
|
||||
tokio::fs::File::from_std(file),
|
||||
rx,
|
||||
- Some(meta),
|
||||
+ Some(SessionMeta {
|
||||
+ timestamp,
|
||||
+ id: session_id,
|
||||
+ instructions,
|
||||
+ }),
|
||||
+ cwd,
|
||||
));
|
||||
|
||||
Ok(Self { tx })
|
||||
@@ -143,7 +155,10 @@ impl RolloutRecorder {
|
||||
.map_err(|e| IoError::other(format!("failed to queue rollout state: {e}")))
|
||||
}
|
||||
|
||||
- pub async fn resume(path: &Path) -> std::io::Result<(Self, SavedSession)> {
|
||||
+ pub async fn resume(
|
||||
+ path: &Path,
|
||||
+ cwd: std::path::PathBuf,
|
||||
+ ) -> std::io::Result<(Self, SavedSession)> {
|
||||
info!("Resuming rollout from {path:?}");
|
||||
let text = tokio::fs::read_to_string(path).await?;
|
||||
let mut lines = text.lines();
|
||||
@@ -201,7 +216,12 @@ impl RolloutRecorder {
|
||||
.open(path)?;
|
||||
|
||||
let (tx, rx) = mpsc::channel::<RolloutCmd>(256);
|
||||
- tokio::task::spawn(rollout_writer(tokio::fs::File::from_std(file), rx, None));
|
||||
+ tokio::task::spawn(rollout_writer(
|
||||
+ tokio::fs::File::from_std(file),
|
||||
+ rx,
|
||||
+ None,
|
||||
+ cwd,
|
||||
+ ));
|
||||
info!("Resumed rollout successfully from {path:?}");
|
||||
Ok((Self { tx }, saved))
|
||||
}
|
||||
@@ -270,15 +290,26 @@ fn create_log_file(config: &Config, session_id: Uuid) -> std::io::Result<LogFile
|
||||
async fn rollout_writer(
|
||||
mut file: tokio::fs::File,
|
||||
mut rx: mpsc::Receiver<RolloutCmd>,
|
||||
- meta: Option<SessionMeta>,
|
||||
+ mut meta: Option<SessionMeta>,
|
||||
+ cwd: std::path::PathBuf,
|
||||
) {
|
||||
- if let Some(meta) = meta {
|
||||
- if let Ok(json) = serde_json::to_string(&meta) {
|
||||
+ // If we have a meta, collect git info asynchronously and write meta first
|
||||
+ if let Some(session_meta) = meta.take() {
|
||||
+ let git_info = collect_git_info(&cwd).await;
|
||||
+ let session_meta_with_git = SessionMetaWithGit {
|
||||
+ meta: session_meta,
|
||||
+ git: git_info,
|
||||
+ };
|
||||
+
|
||||
+ // Write the SessionMeta as the first item in the file
|
||||
+ if let Ok(json) = serde_json::to_string(&session_meta_with_git) {
|
||||
let _ = file.write_all(json.as_bytes()).await;
|
||||
let _ = file.write_all(b"\n").await;
|
||||
let _ = file.flush().await;
|
||||
}
|
||||
}
|
||||
+
|
||||
+ // Process rollout commands
|
||||
while let Some(cmd) = rx.recv().await {
|
||||
match cmd {
|
||||
RolloutCmd::AddItems(items) => {
|
||||
```
|
||||
|
||||
## Review Comments
|
||||
|
||||
### codex-rs/cli/Cargo.toml
|
||||
|
||||
- Created: 2025-07-23 23:48:31 UTC | Link: https://github.com/openai/codex/pull/1630#discussion_r2226967201
|
||||
|
||||
```diff
|
||||
@@ -36,3 +36,11 @@ tokio = { version = "1", features = [
|
||||
] }
|
||||
tracing = "0.1.41"
|
||||
tracing-subscriber = "0.3.19"
|
||||
+predicates = "3.1.3"
|
||||
```
|
||||
|
||||
> Is this needed? If so, alpha sort and make sure it shouldn't go in `dev-dependencies`?
|
||||
|
||||
### codex-rs/core/src/rollout.rs
|
||||
|
||||
- Created: 2025-07-23 19:24:34 UTC | Link: https://github.com/openai/codex/pull/1630#discussion_r2226450285
|
||||
|
||||
```diff
|
||||
@@ -285,15 +340,66 @@ async fn rollout_writer(
|
||||
#[serde(flatten)]
|
||||
state: &'a SessionStateSnapshot,
|
||||
}
|
||||
- if let Ok(json) = serde_json::to_string(&StateLine {
|
||||
+ let line = StateLine {
|
||||
record_type: "state",
|
||||
state: &state,
|
||||
- }) {
|
||||
- let _ = file.write_all(json.as_bytes()).await;
|
||||
- let _ = file.write_all(b"\n").await;
|
||||
- let _ = file.flush().await;
|
||||
+ };
|
||||
+ if let Err(e) = write_json_line(&mut file, &line).await {
|
||||
+ warn!("Failed to write state: {e}");
|
||||
+ }
|
||||
+ }
|
||||
+ RolloutCmd::Sync { exit, ack } => {
|
||||
+ if let Err(e) = file.flush().await {
|
||||
+ warn!("Failed to flush on sync: {e}");
|
||||
+ }
|
||||
+ let _ = ack.send(());
|
||||
+ if exit {
|
||||
+ break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+
|
||||
+pub async fn prepare_rollout_recorder(
|
||||
+ config: &Config,
|
||||
+ mut session_id: Uuid,
|
||||
+ instructions: Option<String>,
|
||||
+ resume_path: Option<&Path>,
|
||||
+) -> (
|
||||
```
|
||||
|
||||
> agreed
|
||||
|
||||
- Created: 2025-07-23 19:25:59 UTC | Link: https://github.com/openai/codex/pull/1630#discussion_r2226453238
|
||||
|
||||
```diff
|
||||
@@ -252,13 +250,30 @@ async fn rollout_writer(
|
||||
mut rx: mpsc::Receiver<RolloutCmd>,
|
||||
meta: Option<SessionMeta>,
|
||||
) {
|
||||
+ // Helper to serialize and write a single line (JSON + newline)
|
||||
+ async fn write_json_line<T: serde::Serialize>(
|
||||
+ file: &mut tokio::fs::File,
|
||||
+ value: &T,
|
||||
+ ) -> std::io::Result<()> {
|
||||
+ let mut buf = serde_json::to_vec(value)?;
|
||||
+ buf.push(b'\n');
|
||||
+ file.write_all(&buf).await?;
|
||||
+ // TODO: decide if we want to flush here or TaskComplete is enough.
|
||||
```
|
||||
|
||||
> I think we want to flush here?
|
||||
|
||||
- Created: 2025-07-23 23:49:36 UTC | Link: https://github.com/openai/codex/pull/1630#discussion_r2226968364
|
||||
|
||||
```diff
|
||||
@@ -235,12 +240,11 @@ struct LogFileInfo {
|
||||
|
||||
fn create_log_file(config: &Config, session_id: Uuid) -> std::io::Result<LogFileInfo> {
|
||||
// Resolve ~/.codex/sessions/YYYY/MM/DD and create it if missing.
|
||||
- let timestamp = OffsetDateTime::now_local()
|
||||
- .map_err(|e| IoError::other(format!("failed to get local time: {e}")))?;
|
||||
+ let timestamp = OffsetDateTime::now_utc();
|
||||
```
|
||||
|
||||
> I think we want the time in the user's local timezone?
|
||||
|
||||
- Created: 2025-07-23 23:50:14 UTC | Link: https://github.com/openai/codex/pull/1630#discussion_r2226968966
|
||||
|
||||
```diff
|
||||
@@ -272,13 +276,29 @@ async fn rollout_writer(
|
||||
mut rx: mpsc::Receiver<RolloutCmd>,
|
||||
meta: Option<SessionMeta>,
|
||||
) {
|
||||
+ // Helper to serialize and write a single line (JSON + newline)
|
||||
+ async fn write_json_line<T: serde::Serialize>(
|
||||
+ file: &mut tokio::fs::File,
|
||||
+ value: &T,
|
||||
+ ) -> std::io::Result<()> {
|
||||
+ let mut buf = serde_json::to_vec(value)?;
|
||||
+ buf.push(b'\n');
|
||||
+ file.write_all(&buf).await?;
|
||||
+ file.flush().await?;
|
||||
+ Ok(())
|
||||
+ }
|
||||
+
|
||||
+ // Write meta line if present
|
||||
if let Some(meta) = meta {
|
||||
- if let Ok(json) = serde_json::to_string(&meta) {
|
||||
- let _ = file.write_all(json.as_bytes()).await;
|
||||
- let _ = file.write_all(b"\n").await;
|
||||
- let _ = file.flush().await;
|
||||
+ if let Err(e) = write_json_line(&mut file, &meta).await {
|
||||
+ warn!("Failed to write session meta: {e}");
|
||||
+ }
|
||||
+ if let Err(e) = file.flush().await {
|
||||
```
|
||||
|
||||
> `write_json_line` already does the `flush()`, no?
|
||||
|
||||
- Created: 2025-07-23 23:51:09 UTC | Link: https://github.com/openai/codex/pull/1630#discussion_r2226969822
|
||||
|
||||
```diff
|
||||
@@ -321,3 +339,43 @@ async fn rollout_writer(
|
||||
}
|
||||
}
|
||||
}
|
||||
+
|
||||
+pub async fn prepare_rollout_recorder(
|
||||
+ config: &Config,
|
||||
+ session_id: Uuid,
|
||||
+ instructions: Option<String>,
|
||||
+ resume_path: Option<&Path>,
|
||||
+) -> RolloutSetup {
|
||||
+ // Try to resume
|
||||
+ let (mut restored_items, mut recorder_opt) = (None, None);
|
||||
```
|
||||
|
||||
> Can you please make these two separate declarations? I don't feel like this is idiomatic Rust.
|
||||
|
||||
- Created: 2025-07-24 00:21:41 UTC | Link: https://github.com/openai/codex/pull/1630#discussion_r2227001129
|
||||
|
||||
```diff
|
||||
@@ -235,12 +240,11 @@ struct LogFileInfo {
|
||||
|
||||
fn create_log_file(config: &Config, session_id: Uuid) -> std::io::Result<LogFileInfo> {
|
||||
// Resolve ~/.codex/sessions/YYYY/MM/DD and create it if missing.
|
||||
- let timestamp = OffsetDateTime::now_local()
|
||||
- .map_err(|e| IoError::other(format!("failed to get local time: {e}")))?;
|
||||
+ let timestamp = OffsetDateTime::now_utc();
|
||||
```
|
||||
|
||||
> OK, as discussed on Slack, we'll stick with UTC, ultimately giving the user tools that will convert to local timezone to make this easier to reason about.
|
||||
1726
prs/bolinfest/PR-1642.md
Normal file
1726
prs/bolinfest/PR-1642.md
Normal file
File diff suppressed because it is too large
Load Diff
782
prs/bolinfest/PR-1643.md
Normal file
782
prs/bolinfest/PR-1643.md
Normal file
@@ -0,0 +1,782 @@
|
||||
# PR #1643: [mcp-server] Add reply tool call
|
||||
|
||||
- URL: https://github.com/openai/codex/pull/1643
|
||||
- Author: dylan-hurd-oai
|
||||
- Created: 2025-07-21 21:58:46 UTC
|
||||
- Updated: 2025-07-22 04:02:03 UTC
|
||||
- Changes: +301/-47, Files changed: 14, Commits: 8
|
||||
|
||||
## Description
|
||||
|
||||
## Summary
|
||||
Adds a new mcp tool call, `codex-reply`, so we can continue existing sessions. This is a first draft and does not yet support sessions from previous processes.
|
||||
|
||||
## Testing
|
||||
- [x] tested with mcp client
|
||||
|
||||
## Full Diff
|
||||
|
||||
```diff
|
||||
diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock
|
||||
index 6a8e76dd8a..9c604e7948 100644
|
||||
--- a/codex-rs/Cargo.lock
|
||||
+++ b/codex-rs/Cargo.lock
|
||||
@@ -807,6 +807,7 @@ dependencies = [
|
||||
"toml 0.9.1",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
+ "uuid",
|
||||
"wiremock",
|
||||
]
|
||||
|
||||
diff --git a/codex-rs/cli/src/proto.rs b/codex-rs/cli/src/proto.rs
|
||||
index 148699552a..ec395dd108 100644
|
||||
--- a/codex-rs/cli/src/proto.rs
|
||||
+++ b/codex-rs/cli/src/proto.rs
|
||||
@@ -35,7 +35,7 @@ pub async fn run_main(opts: ProtoCli) -> anyhow::Result<()> {
|
||||
|
||||
let config = Config::load_with_cli_overrides(overrides_vec, ConfigOverrides::default())?;
|
||||
let ctrl_c = notify_on_sigint();
|
||||
- let (codex, _init_id) = Codex::spawn(config, ctrl_c.clone()).await?;
|
||||
+ let (codex, _init_id, _session_id) = Codex::spawn(config, ctrl_c.clone()).await?;
|
||||
let codex = Arc::new(codex);
|
||||
|
||||
// Task that reads JSON lines from stdin and forwards to Submission Queue
|
||||
diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs
|
||||
index d23981b95f..392e84ea10 100644
|
||||
--- a/codex-rs/core/src/codex.rs
|
||||
+++ b/codex-rs/core/src/codex.rs
|
||||
@@ -101,7 +101,7 @@ impl Codex {
|
||||
/// Spawn a new [`Codex`] and initialize the session. Returns the instance
|
||||
/// of `Codex` and the ID of the `SessionInitialized` event that was
|
||||
/// submitted to start the session.
|
||||
- pub async fn spawn(config: Config, ctrl_c: Arc<Notify>) -> CodexResult<(Codex, String)> {
|
||||
+ pub async fn spawn(config: Config, ctrl_c: Arc<Notify>) -> CodexResult<(Codex, String, Uuid)> {
|
||||
// experimental resume path (undocumented)
|
||||
let resume_path = config.experimental_resume.clone();
|
||||
info!("resume_path: {resume_path:?}");
|
||||
@@ -124,7 +124,12 @@ impl Codex {
|
||||
};
|
||||
|
||||
let config = Arc::new(config);
|
||||
- tokio::spawn(submission_loop(config, rx_sub, tx_event, ctrl_c));
|
||||
+
|
||||
+ // Generate a unique ID for the lifetime of this Codex session.
|
||||
+ let session_id = Uuid::new_v4();
|
||||
+ tokio::spawn(submission_loop(
|
||||
+ session_id, config, rx_sub, tx_event, ctrl_c,
|
||||
+ ));
|
||||
let codex = Codex {
|
||||
next_id: AtomicU64::new(0),
|
||||
tx_sub,
|
||||
@@ -132,7 +137,7 @@ impl Codex {
|
||||
};
|
||||
let init_id = codex.submit(configure_session).await?;
|
||||
|
||||
- Ok((codex, init_id))
|
||||
+ Ok((codex, init_id, session_id))
|
||||
}
|
||||
|
||||
/// Submit the `op` wrapped in a `Submission` with a unique ID.
|
||||
@@ -521,14 +526,12 @@ impl AgentTask {
|
||||
}
|
||||
|
||||
async fn submission_loop(
|
||||
+ mut session_id: Uuid,
|
||||
config: Arc<Config>,
|
||||
rx_sub: Receiver<Submission>,
|
||||
tx_event: Sender<Event>,
|
||||
ctrl_c: Arc<Notify>,
|
||||
) {
|
||||
- // Generate a unique ID for the lifetime of this Codex session.
|
||||
- let mut session_id = Uuid::new_v4();
|
||||
-
|
||||
let mut sess: Option<Arc<Session>> = None;
|
||||
// shorthand - send an event when there is no active session
|
||||
let send_no_session_event = |sub_id: String| async {
|
||||
diff --git a/codex-rs/core/src/codex_wrapper.rs b/codex-rs/core/src/codex_wrapper.rs
|
||||
index f2ece22da7..31f8295ed4 100644
|
||||
--- a/codex-rs/core/src/codex_wrapper.rs
|
||||
+++ b/codex-rs/core/src/codex_wrapper.rs
|
||||
@@ -6,15 +6,16 @@ use crate::protocol::Event;
|
||||
use crate::protocol::EventMsg;
|
||||
use crate::util::notify_on_sigint;
|
||||
use tokio::sync::Notify;
|
||||
+use uuid::Uuid;
|
||||
|
||||
/// Spawn a new [`Codex`] and initialize the session.
|
||||
///
|
||||
/// Returns the wrapped [`Codex`] **and** the `SessionInitialized` event that
|
||||
/// is received as a response to the initial `ConfigureSession` submission so
|
||||
/// that callers can surface the information to the UI.
|
||||
-pub async fn init_codex(config: Config) -> anyhow::Result<(Codex, Event, Arc<Notify>)> {
|
||||
+pub async fn init_codex(config: Config) -> anyhow::Result<(Codex, Event, Arc<Notify>, Uuid)> {
|
||||
let ctrl_c = notify_on_sigint();
|
||||
- let (codex, init_id) = Codex::spawn(config, ctrl_c.clone()).await?;
|
||||
+ let (codex, init_id, session_id) = Codex::spawn(config, ctrl_c.clone()).await?;
|
||||
|
||||
// The first event must be `SessionInitialized`. Validate and forward it to
|
||||
// the caller so that they can display it in the conversation history.
|
||||
@@ -33,5 +34,5 @@ pub async fn init_codex(config: Config) -> anyhow::Result<(Codex, Event, Arc<Not
|
||||
));
|
||||
}
|
||||
|
||||
- Ok((codex, event, ctrl_c))
|
||||
+ Ok((codex, event, ctrl_c, session_id))
|
||||
}
|
||||
diff --git a/codex-rs/core/tests/client.rs b/codex-rs/core/tests/client.rs
|
||||
index 964710b83f..fe4710c89b 100644
|
||||
--- a/codex-rs/core/tests/client.rs
|
||||
+++ b/codex-rs/core/tests/client.rs
|
||||
@@ -75,7 +75,7 @@ async fn includes_session_id_and_model_headers_in_request() {
|
||||
let mut config = load_default_config_for_test(&codex_home);
|
||||
config.model_provider = model_provider;
|
||||
let ctrl_c = std::sync::Arc::new(tokio::sync::Notify::new());
|
||||
- let (codex, _init_id) = Codex::spawn(config, ctrl_c.clone()).await.unwrap();
|
||||
+ let (codex, _init_id, _session_id) = Codex::spawn(config, ctrl_c.clone()).await.unwrap();
|
||||
|
||||
codex
|
||||
.submit(Op::UserInput {
|
||||
diff --git a/codex-rs/core/tests/live_agent.rs b/codex-rs/core/tests/live_agent.rs
|
||||
index 26a5539dd7..0be6110571 100644
|
||||
--- a/codex-rs/core/tests/live_agent.rs
|
||||
+++ b/codex-rs/core/tests/live_agent.rs
|
||||
@@ -49,7 +49,8 @@ async fn spawn_codex() -> Result<Codex, CodexErr> {
|
||||
let mut config = load_default_config_for_test(&codex_home);
|
||||
config.model_provider.request_max_retries = Some(2);
|
||||
config.model_provider.stream_max_retries = Some(2);
|
||||
- let (agent, _init_id) = Codex::spawn(config, std::sync::Arc::new(Notify::new())).await?;
|
||||
+ let (agent, _init_id, _session_id) =
|
||||
+ Codex::spawn(config, std::sync::Arc::new(Notify::new())).await?;
|
||||
|
||||
Ok(agent)
|
||||
}
|
||||
diff --git a/codex-rs/core/tests/previous_response_id.rs b/codex-rs/core/tests/previous_response_id.rs
|
||||
index 9630cc1028..6523c76441 100644
|
||||
--- a/codex-rs/core/tests/previous_response_id.rs
|
||||
+++ b/codex-rs/core/tests/previous_response_id.rs
|
||||
@@ -113,7 +113,7 @@ async fn keeps_previous_response_id_between_tasks() {
|
||||
let mut config = load_default_config_for_test(&codex_home);
|
||||
config.model_provider = model_provider;
|
||||
let ctrl_c = std::sync::Arc::new(tokio::sync::Notify::new());
|
||||
- let (codex, _init_id) = Codex::spawn(config, ctrl_c.clone()).await.unwrap();
|
||||
+ let (codex, _init_id, _session_id) = Codex::spawn(config, ctrl_c.clone()).await.unwrap();
|
||||
|
||||
// Task 1 – triggers first request (no previous_response_id)
|
||||
codex
|
||||
diff --git a/codex-rs/core/tests/stream_no_completed.rs b/codex-rs/core/tests/stream_no_completed.rs
|
||||
index f2de5de188..1a0455be7c 100644
|
||||
--- a/codex-rs/core/tests/stream_no_completed.rs
|
||||
+++ b/codex-rs/core/tests/stream_no_completed.rs
|
||||
@@ -95,7 +95,7 @@ async fn retries_on_early_close() {
|
||||
let codex_home = TempDir::new().unwrap();
|
||||
let mut config = load_default_config_for_test(&codex_home);
|
||||
config.model_provider = model_provider;
|
||||
- let (codex, _init_id) = Codex::spawn(config, ctrl_c).await.unwrap();
|
||||
+ let (codex, _init_id, _session_id) = Codex::spawn(config, ctrl_c).await.unwrap();
|
||||
|
||||
codex
|
||||
.submit(Op::UserInput {
|
||||
diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs
|
||||
index b557c89397..769d3c3b01 100644
|
||||
--- a/codex-rs/exec/src/lib.rs
|
||||
+++ b/codex-rs/exec/src/lib.rs
|
||||
@@ -153,7 +153,7 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option<PathBuf>) -> any
|
||||
.with_writer(std::io::stderr)
|
||||
.try_init();
|
||||
|
||||
- let (codex_wrapper, event, ctrl_c) = codex_wrapper::init_codex(config).await?;
|
||||
+ let (codex_wrapper, event, ctrl_c, _session_id) = codex_wrapper::init_codex(config).await?;
|
||||
let codex = Arc::new(codex_wrapper);
|
||||
info!("Codex initialized with event: {event:?}");
|
||||
|
||||
diff --git a/codex-rs/mcp-server/Cargo.toml b/codex-rs/mcp-server/Cargo.toml
|
||||
index f43b101bd9..e524576a88 100644
|
||||
--- a/codex-rs/mcp-server/Cargo.toml
|
||||
+++ b/codex-rs/mcp-server/Cargo.toml
|
||||
@@ -33,6 +33,7 @@ tokio = { version = "1", features = [
|
||||
"rt-multi-thread",
|
||||
"signal",
|
||||
] }
|
||||
+uuid = { version = "1", features = ["serde", "v4"] }
|
||||
|
||||
[dev-dependencies]
|
||||
assert_cmd = "2"
|
||||
diff --git a/codex-rs/mcp-server/src/codex_tool_config.rs b/codex-rs/mcp-server/src/codex_tool_config.rs
|
||||
index 9a31dbcccc..54d108c0fd 100644
|
||||
--- a/codex-rs/mcp-server/src/codex_tool_config.rs
|
||||
+++ b/codex-rs/mcp-server/src/codex_tool_config.rs
|
||||
@@ -160,6 +160,47 @@ impl CodexToolCallParam {
|
||||
}
|
||||
}
|
||||
|
||||
+#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
|
||||
+#[serde(rename_all = "camelCase")]
|
||||
+pub(crate) struct CodexToolCallReplyParam {
|
||||
+ /// The *session id* for this conversation.
|
||||
+ pub session_id: String,
|
||||
+
|
||||
+ /// The *next user prompt* to continue the Codex conversation.
|
||||
+ pub prompt: String,
|
||||
+}
|
||||
+
|
||||
+/// Builds a `Tool` definition for the `codex-reply` tool-call.
|
||||
+pub(crate) fn create_tool_for_codex_tool_call_reply_param() -> Tool {
|
||||
+ let schema = SchemaSettings::draft2019_09()
|
||||
+ .with(|s| {
|
||||
+ s.inline_subschemas = true;
|
||||
+ s.option_add_null_type = false;
|
||||
+ })
|
||||
+ .into_generator()
|
||||
+ .into_root_schema_for::<CodexToolCallReplyParam>();
|
||||
+
|
||||
+ #[expect(clippy::expect_used)]
|
||||
+ let schema_value =
|
||||
+ serde_json::to_value(&schema).expect("Codex reply tool schema should serialise to JSON");
|
||||
+
|
||||
+ let tool_input_schema =
|
||||
+ serde_json::from_value::<ToolInputSchema>(schema_value).unwrap_or_else(|e| {
|
||||
+ panic!("failed to create Tool from schema: {e}");
|
||||
+ });
|
||||
+
|
||||
+ Tool {
|
||||
+ name: "codex-reply".to_string(),
|
||||
+ title: Some("Codex Reply".to_string()),
|
||||
+ input_schema: tool_input_schema,
|
||||
+ output_schema: None,
|
||||
+ description: Some(
|
||||
+ "Continue a Codex session by providing the session id and prompt.".to_string(),
|
||||
+ ),
|
||||
+ annotations: None,
|
||||
+ }
|
||||
+}
|
||||
+
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -235,4 +276,34 @@ mod tests {
|
||||
});
|
||||
assert_eq!(expected_tool_json, tool_json);
|
||||
}
|
||||
+
|
||||
+ #[test]
|
||||
+ fn verify_codex_tool_reply_json_schema() {
|
||||
+ let tool = create_tool_for_codex_tool_call_reply_param();
|
||||
+ #[expect(clippy::expect_used)]
|
||||
+ let tool_json = serde_json::to_value(&tool).expect("tool serializes");
|
||||
+ let expected_tool_json = serde_json::json!({
|
||||
+ "description": "Continue a Codex session by providing the session id and prompt.",
|
||||
+ "inputSchema": {
|
||||
+ "properties": {
|
||||
+ "prompt": {
|
||||
+ "description": "The *next user prompt* to continue the Codex conversation.",
|
||||
+ "type": "string"
|
||||
+ },
|
||||
+ "sessionId": {
|
||||
+ "description": "The *session id* for this conversation.",
|
||||
+ "type": "string"
|
||||
+ },
|
||||
+ },
|
||||
+ "required": [
|
||||
+ "prompt",
|
||||
+ "sessionId",
|
||||
+ ],
|
||||
+ "type": "object",
|
||||
+ },
|
||||
+ "name": "codex-reply",
|
||||
+ "title": "Codex Reply",
|
||||
+ });
|
||||
+ assert_eq!(expected_tool_json, tool_json);
|
||||
+ }
|
||||
}
|
||||
diff --git a/codex-rs/mcp-server/src/codex_tool_runner.rs b/codex-rs/mcp-server/src/codex_tool_runner.rs
|
||||
index 163055de5c..3893a48595 100644
|
||||
--- a/codex-rs/mcp-server/src/codex_tool_runner.rs
|
||||
+++ b/codex-rs/mcp-server/src/codex_tool_runner.rs
|
||||
@@ -2,6 +2,7 @@
|
||||
//! Tokio task. Separated from `message_processor.rs` to keep that file small
|
||||
//! and to make future feature-growth easier to manage.
|
||||
|
||||
+use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -27,7 +28,9 @@ use mcp_types::TextContent;
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
use serde_json::json;
|
||||
+use tokio::sync::Mutex;
|
||||
use tracing::error;
|
||||
+use uuid::Uuid;
|
||||
|
||||
use crate::outgoing_message::OutgoingMessageSender;
|
||||
|
||||
@@ -42,8 +45,9 @@ pub async fn run_codex_tool_session(
|
||||
initial_prompt: String,
|
||||
config: CodexConfig,
|
||||
outgoing: Arc<OutgoingMessageSender>,
|
||||
+ session_map: Arc<Mutex<HashMap<Uuid, Arc<Codex>>>>,
|
||||
) {
|
||||
- let (codex, first_event, _ctrl_c) = match init_codex(config).await {
|
||||
+ let (codex, first_event, _ctrl_c, session_id) = match init_codex(config).await {
|
||||
Ok(res) => res,
|
||||
Err(e) => {
|
||||
let result = CallToolResult {
|
||||
@@ -61,6 +65,11 @@ pub async fn run_codex_tool_session(
|
||||
};
|
||||
let codex = Arc::new(codex);
|
||||
|
||||
+ // update the session map so we can retrieve the session in a reply, and then drop it, since
|
||||
+ // we no longer need it for this function
|
||||
+ session_map.lock().await.insert(session_id, codex.clone());
|
||||
+ drop(session_map);
|
||||
+
|
||||
// Send initial SessionConfigured event.
|
||||
outgoing.send_event_as_notification(&first_event).await;
|
||||
|
||||
@@ -85,6 +94,37 @@ pub async fn run_codex_tool_session(
|
||||
tracing::error!("Failed to submit initial prompt: {e}");
|
||||
}
|
||||
|
||||
+ run_codex_tool_session_inner(codex, outgoing, id).await;
|
||||
+}
|
||||
+
|
||||
+pub async fn run_codex_tool_session_reply(
|
||||
+ codex: Arc<Codex>,
|
||||
+ outgoing: Arc<OutgoingMessageSender>,
|
||||
+ request_id: RequestId,
|
||||
+ prompt: String,
|
||||
+) {
|
||||
+ if let Err(e) = codex
|
||||
+ .submit(Op::UserInput {
|
||||
+ items: vec![InputItem::Text { text: prompt }],
|
||||
+ })
|
||||
+ .await
|
||||
+ {
|
||||
+ tracing::error!("Failed to submit user input: {e}");
|
||||
+ }
|
||||
+
|
||||
+ run_codex_tool_session_inner(codex, outgoing, request_id).await;
|
||||
+}
|
||||
+
|
||||
+async fn run_codex_tool_session_inner(
|
||||
+ codex: Arc<Codex>,
|
||||
+ outgoing: Arc<OutgoingMessageSender>,
|
||||
+ request_id: RequestId,
|
||||
+) {
|
||||
+ let sub_id = match &request_id {
|
||||
+ RequestId::String(s) => s.clone(),
|
||||
+ RequestId::Integer(n) => n.to_string(),
|
||||
+ };
|
||||
+
|
||||
// Stream events until the task needs to pause for user interaction or
|
||||
// completes.
|
||||
loop {
|
||||
@@ -128,7 +168,7 @@ pub async fn run_codex_tool_session(
|
||||
|
||||
outgoing
|
||||
.send_error(
|
||||
- id.clone(),
|
||||
+ request_id.clone(),
|
||||
JSONRPCErrorError {
|
||||
code: INVALID_PARAMS_ERROR_CODE,
|
||||
message,
|
||||
@@ -168,7 +208,9 @@ pub async fn run_codex_tool_session(
|
||||
is_error: None,
|
||||
structured_content: None,
|
||||
};
|
||||
- outgoing.send_response(id.clone(), result.into()).await;
|
||||
+ outgoing
|
||||
+ .send_response(request_id.clone(), result.into())
|
||||
+ .await;
|
||||
// Continue, don't break so the session continues.
|
||||
continue;
|
||||
}
|
||||
@@ -186,7 +228,9 @@ pub async fn run_codex_tool_session(
|
||||
is_error: None,
|
||||
structured_content: None,
|
||||
};
|
||||
- outgoing.send_response(id.clone(), result.into()).await;
|
||||
+ outgoing
|
||||
+ .send_response(request_id.clone(), result.into())
|
||||
+ .await;
|
||||
break;
|
||||
}
|
||||
EventMsg::SessionConfigured(_) => {
|
||||
@@ -234,7 +278,9 @@ pub async fn run_codex_tool_session(
|
||||
// structured way.
|
||||
structured_content: None,
|
||||
};
|
||||
- outgoing.send_response(id.clone(), result.into()).await;
|
||||
+ outgoing
|
||||
+ .send_response(request_id.clone(), result.into())
|
||||
+ .await;
|
||||
break;
|
||||
}
|
||||
}
|
||||
diff --git a/codex-rs/mcp-server/src/message_processor.rs b/codex-rs/mcp-server/src/message_processor.rs
|
||||
index 61c320edb9..e72a52e006 100644
|
||||
--- a/codex-rs/mcp-server/src/message_processor.rs
|
||||
+++ b/codex-rs/mcp-server/src/message_processor.rs
|
||||
@@ -1,10 +1,14 @@
|
||||
+use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::codex_tool_config::CodexToolCallParam;
|
||||
+use crate::codex_tool_config::CodexToolCallReplyParam;
|
||||
use crate::codex_tool_config::create_tool_for_codex_tool_call_param;
|
||||
+use crate::codex_tool_config::create_tool_for_codex_tool_call_reply_param;
|
||||
use crate::outgoing_message::OutgoingMessageSender;
|
||||
|
||||
+use codex_core::Codex;
|
||||
use codex_core::config::Config as CodexConfig;
|
||||
use mcp_types::CallToolRequestParams;
|
||||
use mcp_types::CallToolResult;
|
||||
@@ -22,12 +26,15 @@ use mcp_types::ServerCapabilitiesTools;
|
||||
use mcp_types::ServerNotification;
|
||||
use mcp_types::TextContent;
|
||||
use serde_json::json;
|
||||
+use tokio::sync::Mutex;
|
||||
use tokio::task;
|
||||
+use uuid::Uuid;
|
||||
|
||||
pub(crate) struct MessageProcessor {
|
||||
outgoing: Arc<OutgoingMessageSender>,
|
||||
initialized: bool,
|
||||
codex_linux_sandbox_exe: Option<PathBuf>,
|
||||
+ session_map: Arc<Mutex<HashMap<Uuid, Arc<Codex>>>>,
|
||||
}
|
||||
|
||||
impl MessageProcessor {
|
||||
@@ -41,6 +48,7 @@ impl MessageProcessor {
|
||||
outgoing: Arc::new(outgoing),
|
||||
initialized: false,
|
||||
codex_linux_sandbox_exe,
|
||||
+ session_map: Arc::new(Mutex::new(HashMap::new())),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -272,7 +280,10 @@ impl MessageProcessor {
|
||||
) {
|
||||
tracing::trace!("tools/list -> {params:?}");
|
||||
let result = ListToolsResult {
|
||||
- tools: vec![create_tool_for_codex_tool_call_param()],
|
||||
+ tools: vec![
|
||||
+ create_tool_for_codex_tool_call_param(),
|
||||
+ create_tool_for_codex_tool_call_reply_param(),
|
||||
+ ],
|
||||
next_cursor: None,
|
||||
};
|
||||
|
||||
@@ -288,23 +299,29 @@ impl MessageProcessor {
|
||||
tracing::info!("tools/call -> params: {:?}", params);
|
||||
let CallToolRequestParams { name, arguments } = params;
|
||||
|
||||
- // We only support the "codex" tool for now.
|
||||
- if name != "codex" {
|
||||
- // Tool not found – return error result so the LLM can react.
|
||||
- let result = CallToolResult {
|
||||
- content: vec![ContentBlock::TextContent(TextContent {
|
||||
- r#type: "text".to_string(),
|
||||
- text: format!("Unknown tool '{name}'"),
|
||||
- annotations: None,
|
||||
- })],
|
||||
- is_error: Some(true),
|
||||
- structured_content: None,
|
||||
- };
|
||||
- self.send_response::<mcp_types::CallToolRequest>(id, result)
|
||||
- .await;
|
||||
- return;
|
||||
+ match name.as_str() {
|
||||
+ "codex" => self.handle_tool_call_codex(id, arguments).await,
|
||||
+ "codex-reply" => {
|
||||
+ self.handle_tool_call_codex_session_reply(id, arguments)
|
||||
+ .await
|
||||
+ }
|
||||
+ _ => {
|
||||
+ let result = CallToolResult {
|
||||
+ content: vec![ContentBlock::TextContent(TextContent {
|
||||
+ r#type: "text".to_string(),
|
||||
+ text: format!("Unknown tool '{name}'"),
|
||||
+ annotations: None,
|
||||
+ })],
|
||||
+ is_error: Some(true),
|
||||
+ structured_content: None,
|
||||
+ };
|
||||
+ self.send_response::<mcp_types::CallToolRequest>(id, result)
|
||||
+ .await;
|
||||
+ }
|
||||
}
|
||||
+ }
|
||||
|
||||
+ async fn handle_tool_call_codex(&self, id: RequestId, arguments: Option<serde_json::Value>) {
|
||||
let (initial_prompt, config): (String, CodexConfig) = match arguments {
|
||||
Some(json_val) => match serde_json::from_value::<CodexToolCallParam>(json_val) {
|
||||
Ok(tool_cfg) => match tool_cfg.into_config(self.codex_linux_sandbox_exe.clone()) {
|
||||
@@ -359,15 +376,127 @@ impl MessageProcessor {
|
||||
}
|
||||
};
|
||||
|
||||
- // Clone outgoing sender to move into async task.
|
||||
+ // Clone outgoing and session map to move into async task.
|
||||
let outgoing = self.outgoing.clone();
|
||||
+ let session_map = self.session_map.clone();
|
||||
|
||||
// Spawn an async task to handle the Codex session so that we do not
|
||||
// block the synchronous message-processing loop.
|
||||
task::spawn(async move {
|
||||
// Run the Codex session and stream events back to the client.
|
||||
- crate::codex_tool_runner::run_codex_tool_session(id, initial_prompt, config, outgoing)
|
||||
- .await;
|
||||
+ crate::codex_tool_runner::run_codex_tool_session(
|
||||
+ id,
|
||||
+ initial_prompt,
|
||||
+ config,
|
||||
+ outgoing,
|
||||
+ session_map,
|
||||
+ )
|
||||
+ .await;
|
||||
+ });
|
||||
+ }
|
||||
+
|
||||
+ async fn handle_tool_call_codex_session_reply(
|
||||
+ &self,
|
||||
+ request_id: RequestId,
|
||||
+ arguments: Option<serde_json::Value>,
|
||||
+ ) {
|
||||
+ tracing::info!("tools/call -> params: {:?}", arguments);
|
||||
+
|
||||
+ // parse arguments
|
||||
+ let CodexToolCallReplyParam { session_id, prompt } = match arguments {
|
||||
+ Some(json_val) => match serde_json::from_value::<CodexToolCallReplyParam>(json_val) {
|
||||
+ Ok(params) => params,
|
||||
+ Err(e) => {
|
||||
+ tracing::error!("Failed to parse Codex tool call reply parameters: {e}");
|
||||
+ let result = CallToolResult {
|
||||
+ content: vec![ContentBlock::TextContent(TextContent {
|
||||
+ r#type: "text".to_owned(),
|
||||
+ text: format!("Failed to parse configuration for Codex tool: {e}"),
|
||||
+ annotations: None,
|
||||
+ })],
|
||||
+ is_error: Some(true),
|
||||
+ structured_content: None,
|
||||
+ };
|
||||
+ self.send_response::<mcp_types::CallToolRequest>(request_id, result)
|
||||
+ .await;
|
||||
+ return;
|
||||
+ }
|
||||
+ },
|
||||
+ None => {
|
||||
+ tracing::error!(
|
||||
+ "Missing arguments for codex-reply tool-call; the `session_id` and `prompt` fields are required."
|
||||
+ );
|
||||
+ let result = CallToolResult {
|
||||
+ content: vec![ContentBlock::TextContent(TextContent {
|
||||
+ r#type: "text".to_owned(),
|
||||
+ text: "Missing arguments for codex-reply tool-call; the `session_id` and `prompt` fields are required.".to_owned(),
|
||||
+ annotations: None,
|
||||
+ })],
|
||||
+ is_error: Some(true),
|
||||
+ structured_content: None,
|
||||
+ };
|
||||
+ self.send_response::<mcp_types::CallToolRequest>(request_id, result)
|
||||
+ .await;
|
||||
+ return;
|
||||
+ }
|
||||
+ };
|
||||
+ let session_id = match Uuid::parse_str(&session_id) {
|
||||
+ Ok(id) => id,
|
||||
+ Err(e) => {
|
||||
+ tracing::error!("Failed to parse session_id: {e}");
|
||||
+ let result = CallToolResult {
|
||||
+ content: vec![ContentBlock::TextContent(TextContent {
|
||||
+ r#type: "text".to_owned(),
|
||||
+ text: format!("Failed to parse session_id: {e}"),
|
||||
+ annotations: None,
|
||||
+ })],
|
||||
+ is_error: Some(true),
|
||||
+ structured_content: None,
|
||||
+ };
|
||||
+ self.send_response::<mcp_types::CallToolRequest>(request_id, result)
|
||||
+ .await;
|
||||
+ return;
|
||||
+ }
|
||||
+ };
|
||||
+
|
||||
+ // load codex from session map
|
||||
+ let session_map_mutex = Arc::clone(&self.session_map);
|
||||
+
|
||||
+ // Clone outgoing and session map to move into async task.
|
||||
+ let outgoing = self.outgoing.clone();
|
||||
+
|
||||
+ // Spawn an async task to handle the Codex session so that we do not
|
||||
+ // block the synchronous message-processing loop.
|
||||
+ task::spawn(async move {
|
||||
+ let session_map = session_map_mutex.lock().await;
|
||||
+ let codex = match session_map.get(&session_id) {
|
||||
+ Some(codex) => codex,
|
||||
+ None => {
|
||||
+ tracing::warn!("Session not found for session_id: {session_id}");
|
||||
+ let result = CallToolResult {
|
||||
+ content: vec![ContentBlock::TextContent(TextContent {
|
||||
+ r#type: "text".to_owned(),
|
||||
+ text: format!("Session not found for session_id: {session_id}"),
|
||||
+ annotations: None,
|
||||
+ })],
|
||||
+ is_error: Some(true),
|
||||
+ structured_content: None,
|
||||
+ };
|
||||
+ // unwrap_or_default is fine here because we know the result is valid JSON
|
||||
+ outgoing
|
||||
+ .send_response(request_id, serde_json::to_value(result).unwrap_or_default())
|
||||
+ .await;
|
||||
+ return;
|
||||
+ }
|
||||
+ };
|
||||
+
|
||||
+ crate::codex_tool_runner::run_codex_tool_session_reply(
|
||||
+ codex.clone(),
|
||||
+ outgoing,
|
||||
+ request_id,
|
||||
+ prompt.clone(),
|
||||
+ )
|
||||
+ .await;
|
||||
});
|
||||
}
|
||||
|
||||
diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs
|
||||
index c22bbf9704..c70c6f6d72 100644
|
||||
--- a/codex-rs/tui/src/chatwidget.rs
|
||||
+++ b/codex-rs/tui/src/chatwidget.rs
|
||||
@@ -96,14 +96,15 @@ impl ChatWidget<'_> {
|
||||
// Create the Codex asynchronously so the UI loads as quickly as possible.
|
||||
let config_for_agent_loop = config.clone();
|
||||
tokio::spawn(async move {
|
||||
- let (codex, session_event, _ctrl_c) = match init_codex(config_for_agent_loop).await {
|
||||
- Ok(vals) => vals,
|
||||
- Err(e) => {
|
||||
- // TODO: surface this error to the user.
|
||||
- tracing::error!("failed to initialize codex: {e}");
|
||||
- return;
|
||||
- }
|
||||
- };
|
||||
+ let (codex, session_event, _ctrl_c, _session_id) =
|
||||
+ match init_codex(config_for_agent_loop).await {
|
||||
+ Ok(vals) => vals,
|
||||
+ Err(e) => {
|
||||
+ // TODO: surface this error to the user.
|
||||
+ tracing::error!("failed to initialize codex: {e}");
|
||||
+ return;
|
||||
+ }
|
||||
+ };
|
||||
|
||||
// Forward the captured `SessionInitialized` event that was consumed
|
||||
// inside `init_codex()` so it can be rendered in the UI.
|
||||
```
|
||||
|
||||
## Review Comments
|
||||
|
||||
### codex-rs/core/src/codex.rs
|
||||
|
||||
- Created: 2025-07-21 22:02:50 UTC | Link: https://github.com/openai/codex/pull/1643#discussion_r2220451615
|
||||
|
||||
```diff
|
||||
@@ -101,7 +101,7 @@ impl Codex {
|
||||
/// Spawn a new [`Codex`] and initialize the session. Returns the instance
|
||||
/// of `Codex` and the ID of the `SessionInitialized` event that was
|
||||
/// submitted to start the session.
|
||||
- pub async fn spawn(config: Config, ctrl_c: Arc<Notify>) -> CodexResult<(Codex, String)> {
|
||||
+ pub async fn spawn(config: Config, ctrl_c: Arc<Notify>) -> CodexResult<(Codex, String, Uuid)> {
|
||||
```
|
||||
|
||||
> We should probably move from a tuple to a struct at this point.
|
||||
|
||||
- Created: 2025-07-21 22:09:36 UTC | Link: https://github.com/openai/codex/pull/1643#discussion_r2220460262
|
||||
|
||||
```diff
|
||||
@@ -521,14 +526,12 @@ impl AgentTask {
|
||||
}
|
||||
|
||||
async fn submission_loop(
|
||||
+ mut session_id: Uuid,
|
||||
```
|
||||
|
||||
> Hmm, I see this became `mut` in https://github.com/openai/codex/pull/1602. That doesn't seem quite right to me, but it's outside the scope of this PR to change it.
|
||||
|
||||
### codex-rs/mcp-server/src/codex_tool_config.rs
|
||||
|
||||
- Created: 2025-07-22 00:21:58 UTC | Link: https://github.com/openai/codex/pull/1643#discussion_r2220644822
|
||||
|
||||
```diff
|
||||
@@ -160,6 +160,16 @@ impl CodexToolCallParam {
|
||||
}
|
||||
}
|
||||
|
||||
+#[derive(Debug, Clone, Deserialize, JsonSchema)]
|
||||
+#[serde(rename_all = "kebab-case")]
|
||||
```
|
||||
|
||||
> admittedly, we can name these however we want, but MCP seems to prefer `camelCase` (presumably due to its TypeScript influence since the `.ts` is the authority for the schema rather than `.json`?)
|
||||
|
||||
### codex-rs/mcp-server/src/codex_tool_runner.rs
|
||||
|
||||
- Created: 2025-07-21 22:18:01 UTC | Link: https://github.com/openai/codex/pull/1643#discussion_r2220471852
|
||||
|
||||
```diff
|
||||
@@ -61,6 +65,8 @@ pub async fn run_codex_tool_session(
|
||||
};
|
||||
let codex = Arc::new(codex);
|
||||
|
||||
+ session_map.lock().await.insert(session_id, codex.clone());
|
||||
+
|
||||
```
|
||||
|
||||
> `drop(session_map)` since this long-running function doesn't need it anymore
|
||||
|
||||
### codex-rs/mcp-server/src/message_processor.rs
|
||||
|
||||
- Created: 2025-07-22 00:23:40 UTC | Link: https://github.com/openai/codex/pull/1643#discussion_r2220648650
|
||||
|
||||
```diff
|
||||
@@ -359,15 +372,81 @@ impl MessageProcessor {
|
||||
}
|
||||
};
|
||||
|
||||
- // Clone outgoing sender to move into async task.
|
||||
+ // Clone outgoing and session map to move into async task.
|
||||
let outgoing = self.outgoing.clone();
|
||||
+ let session_map = self.session_map.clone();
|
||||
|
||||
// Spawn an async task to handle the Codex session so that we do not
|
||||
// block the synchronous message-processing loop.
|
||||
task::spawn(async move {
|
||||
// Run the Codex session and stream events back to the client.
|
||||
- crate::codex_tool_runner::run_codex_tool_session(id, initial_prompt, config, outgoing)
|
||||
- .await;
|
||||
+ crate::codex_tool_runner::run_codex_tool_session(
|
||||
+ id,
|
||||
+ initial_prompt,
|
||||
+ config,
|
||||
+ outgoing,
|
||||
+ session_map,
|
||||
+ )
|
||||
+ .await;
|
||||
+ });
|
||||
+ }
|
||||
+
|
||||
+ async fn handle_tool_call_codex_session_reply(
|
||||
+ &self,
|
||||
+ request_id: RequestId,
|
||||
+ arguments: Option<serde_json::Value>,
|
||||
+ ) {
|
||||
+ tracing::info!("tools/call -> params: {:?}", arguments);
|
||||
+
|
||||
+ // parse arguments
|
||||
+ let CodexToolCallReplyParam { session_id, prompt } = match arguments {
|
||||
+ Some(json_val) => match serde_json::from_value::<CodexToolCallReplyParam>(json_val) {
|
||||
+ Ok(params) => params,
|
||||
+ Err(e) => {
|
||||
```
|
||||
|
||||
> In general, we should also reply with an error to the request.
|
||||
839
prs/bolinfest/PR-1645.md
Normal file
839
prs/bolinfest/PR-1645.md
Normal file
@@ -0,0 +1,839 @@
|
||||
# PR #1645: Add support for custom base instructions
|
||||
|
||||
- URL: https://github.com/openai/codex/pull/1645
|
||||
- Author: pakrym-oai
|
||||
- Created: 2025-07-22 00:49:03 UTC
|
||||
- Updated: 2025-07-22 16:42:30 UTC
|
||||
- Changes: +265/-62, Files changed: 12, Commits: 9
|
||||
|
||||
## Description
|
||||
|
||||
Allows providing custom instructions file as a config parameter and custom instruction text via MCP tool call.
|
||||
|
||||
## Full Diff
|
||||
|
||||
```diff
|
||||
diff --git a/codex-rs/core/src/client_common.rs b/codex-rs/core/src/client_common.rs
|
||||
index 3e3c2e7efa..94d09e7fd4 100644
|
||||
--- a/codex-rs/core/src/client_common.rs
|
||||
+++ b/codex-rs/core/src/client_common.rs
|
||||
@@ -34,11 +34,18 @@ pub struct Prompt {
|
||||
/// the "fully qualified" tool name (i.e., prefixed with the server name),
|
||||
/// which should be reported to the model in place of Tool::name.
|
||||
pub extra_tools: HashMap<String, mcp_types::Tool>,
|
||||
+
|
||||
+ /// Optional override for the built-in BASE_INSTRUCTIONS.
|
||||
+ pub base_instructions_override: Option<String>,
|
||||
}
|
||||
|
||||
impl Prompt {
|
||||
pub(crate) fn get_full_instructions(&self, model: &str) -> Cow<'_, str> {
|
||||
- let mut sections: Vec<&str> = vec![BASE_INSTRUCTIONS];
|
||||
+ let base = self
|
||||
+ .base_instructions_override
|
||||
+ .as_deref()
|
||||
+ .unwrap_or(BASE_INSTRUCTIONS);
|
||||
+ let mut sections: Vec<&str> = vec![base];
|
||||
if let Some(ref user) = self.user_instructions {
|
||||
sections.push(user);
|
||||
}
|
||||
diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs
|
||||
index 392e84ea10..6eb1715fbf 100644
|
||||
--- a/codex-rs/core/src/codex.rs
|
||||
+++ b/codex-rs/core/src/codex.rs
|
||||
@@ -108,13 +108,15 @@ impl Codex {
|
||||
let (tx_sub, rx_sub) = async_channel::bounded(64);
|
||||
let (tx_event, rx_event) = async_channel::bounded(1600);
|
||||
|
||||
- let instructions = get_user_instructions(&config).await;
|
||||
+ let user_instructions = get_user_instructions(&config).await;
|
||||
+
|
||||
let configure_session = Op::ConfigureSession {
|
||||
provider: config.model_provider.clone(),
|
||||
model: config.model.clone(),
|
||||
model_reasoning_effort: config.model_reasoning_effort,
|
||||
model_reasoning_summary: config.model_reasoning_summary,
|
||||
- instructions,
|
||||
+ user_instructions,
|
||||
+ base_instructions: config.base_instructions.clone(),
|
||||
approval_policy: config.approval_policy,
|
||||
sandbox_policy: config.sandbox_policy.clone(),
|
||||
disable_response_storage: config.disable_response_storage,
|
||||
@@ -183,7 +185,8 @@ pub(crate) struct Session {
|
||||
/// the model as well as sandbox policies are resolved against this path
|
||||
/// instead of `std::env::current_dir()`.
|
||||
cwd: PathBuf,
|
||||
- instructions: Option<String>,
|
||||
+ base_instructions: Option<String>,
|
||||
+ user_instructions: Option<String>,
|
||||
approval_policy: AskForApproval,
|
||||
sandbox_policy: SandboxPolicy,
|
||||
shell_environment_policy: ShellEnvironmentPolicy,
|
||||
@@ -577,7 +580,8 @@ async fn submission_loop(
|
||||
model,
|
||||
model_reasoning_effort,
|
||||
model_reasoning_summary,
|
||||
- instructions,
|
||||
+ user_instructions,
|
||||
+ base_instructions,
|
||||
approval_policy,
|
||||
sandbox_policy,
|
||||
disable_response_storage,
|
||||
@@ -625,15 +629,17 @@ async fn submission_loop(
|
||||
|
||||
let rollout_recorder = match rollout_recorder {
|
||||
Some(rec) => Some(rec),
|
||||
- None => match RolloutRecorder::new(&config, session_id, instructions.clone())
|
||||
- .await
|
||||
- {
|
||||
- Ok(r) => Some(r),
|
||||
- Err(e) => {
|
||||
- warn!("failed to initialise rollout recorder: {e}");
|
||||
- None
|
||||
+ None => {
|
||||
+ match RolloutRecorder::new(&config, session_id, user_instructions.clone())
|
||||
+ .await
|
||||
+ {
|
||||
+ Ok(r) => Some(r),
|
||||
+ Err(e) => {
|
||||
+ warn!("failed to initialise rollout recorder: {e}");
|
||||
+ None
|
||||
+ }
|
||||
}
|
||||
- },
|
||||
+ }
|
||||
};
|
||||
|
||||
let client = ModelClient::new(
|
||||
@@ -699,7 +705,8 @@ async fn submission_loop(
|
||||
client,
|
||||
tx_event: tx_event.clone(),
|
||||
ctrl_c: Arc::clone(&ctrl_c),
|
||||
- instructions,
|
||||
+ user_instructions,
|
||||
+ base_instructions,
|
||||
approval_policy,
|
||||
sandbox_policy,
|
||||
shell_environment_policy: config.shell_environment_policy.clone(),
|
||||
@@ -1067,9 +1074,10 @@ async fn run_turn(
|
||||
let prompt = Prompt {
|
||||
input,
|
||||
prev_id,
|
||||
- user_instructions: sess.instructions.clone(),
|
||||
+ user_instructions: sess.user_instructions.clone(),
|
||||
store,
|
||||
extra_tools,
|
||||
+ base_instructions_override: sess.base_instructions.clone(),
|
||||
};
|
||||
|
||||
let mut retries = 0;
|
||||
diff --git a/codex-rs/core/src/config.rs b/codex-rs/core/src/config.rs
|
||||
index f1d0dd9d60..8ed06c45af 100644
|
||||
--- a/codex-rs/core/src/config.rs
|
||||
+++ b/codex-rs/core/src/config.rs
|
||||
@@ -63,7 +63,10 @@ pub struct Config {
|
||||
pub disable_response_storage: bool,
|
||||
|
||||
/// User-provided instructions from instructions.md.
|
||||
- pub instructions: Option<String>,
|
||||
+ pub user_instructions: Option<String>,
|
||||
+
|
||||
+ /// Base instructions override.
|
||||
+ pub base_instructions: Option<String>,
|
||||
|
||||
/// Optional external notifier command. When set, Codex will spawn this
|
||||
/// program after each completed *turn* (i.e. when the agent finishes
|
||||
@@ -327,6 +330,9 @@ pub struct ConfigToml {
|
||||
|
||||
/// Experimental rollout resume path (absolute path to .jsonl; undocumented).
|
||||
pub experimental_resume: Option<PathBuf>,
|
||||
+
|
||||
+ /// Experimental path to a file whose contents replace the built-in BASE_INSTRUCTIONS.
|
||||
+ pub experimental_instructions_file: Option<PathBuf>,
|
||||
}
|
||||
|
||||
impl ConfigToml {
|
||||
@@ -359,6 +365,7 @@ pub struct ConfigOverrides {
|
||||
pub model_provider: Option<String>,
|
||||
pub config_profile: Option<String>,
|
||||
pub codex_linux_sandbox_exe: Option<PathBuf>,
|
||||
+ pub base_instructions: Option<String>,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
@@ -369,7 +376,7 @@ impl Config {
|
||||
overrides: ConfigOverrides,
|
||||
codex_home: PathBuf,
|
||||
) -> std::io::Result<Self> {
|
||||
- let instructions = Self::load_instructions(Some(&codex_home));
|
||||
+ let user_instructions = Self::load_instructions(Some(&codex_home));
|
||||
|
||||
// Destructure ConfigOverrides fully to ensure all overrides are applied.
|
||||
let ConfigOverrides {
|
||||
@@ -380,6 +387,7 @@ impl Config {
|
||||
model_provider,
|
||||
config_profile: config_profile_key,
|
||||
codex_linux_sandbox_exe,
|
||||
+ base_instructions,
|
||||
} = overrides;
|
||||
|
||||
let config_profile = match config_profile_key.as_ref().or(cfg.profile.as_ref()) {
|
||||
@@ -457,6 +465,10 @@ impl Config {
|
||||
|
||||
let experimental_resume = cfg.experimental_resume;
|
||||
|
||||
+ let base_instructions = base_instructions.or(Self::get_base_instructions(
|
||||
+ cfg.experimental_instructions_file.as_ref(),
|
||||
+ ));
|
||||
+
|
||||
let config = Self {
|
||||
model,
|
||||
model_context_window,
|
||||
@@ -475,7 +487,8 @@ impl Config {
|
||||
.or(cfg.disable_response_storage)
|
||||
.unwrap_or(false),
|
||||
notify: cfg.notify,
|
||||
- instructions,
|
||||
+ user_instructions,
|
||||
+ base_instructions,
|
||||
mcp_servers: cfg.mcp_servers,
|
||||
model_providers,
|
||||
project_doc_max_bytes: cfg.project_doc_max_bytes.unwrap_or(PROJECT_DOC_MAX_BYTES),
|
||||
@@ -525,6 +538,15 @@ impl Config {
|
||||
}
|
||||
})
|
||||
}
|
||||
+
|
||||
+ fn get_base_instructions(path: Option<&PathBuf>) -> Option<String> {
|
||||
+ let path = path.as_ref()?;
|
||||
+
|
||||
+ std::fs::read_to_string(path)
|
||||
+ .ok()
|
||||
+ .map(|s| s.trim().to_string())
|
||||
+ .filter(|s| !s.is_empty())
|
||||
+ }
|
||||
}
|
||||
|
||||
fn default_model() -> String {
|
||||
@@ -801,7 +823,7 @@ disable_response_storage = true
|
||||
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
||||
shell_environment_policy: ShellEnvironmentPolicy::default(),
|
||||
disable_response_storage: false,
|
||||
- instructions: None,
|
||||
+ user_instructions: None,
|
||||
notify: None,
|
||||
cwd: fixture.cwd(),
|
||||
mcp_servers: HashMap::new(),
|
||||
@@ -818,6 +840,7 @@ disable_response_storage = true
|
||||
model_supports_reasoning_summaries: false,
|
||||
chatgpt_base_url: "https://chatgpt.com/backend-api/".to_string(),
|
||||
experimental_resume: None,
|
||||
+ base_instructions: None,
|
||||
},
|
||||
o3_profile_config
|
||||
);
|
||||
@@ -848,7 +871,7 @@ disable_response_storage = true
|
||||
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
||||
shell_environment_policy: ShellEnvironmentPolicy::default(),
|
||||
disable_response_storage: false,
|
||||
- instructions: None,
|
||||
+ user_instructions: None,
|
||||
notify: None,
|
||||
cwd: fixture.cwd(),
|
||||
mcp_servers: HashMap::new(),
|
||||
@@ -865,6 +888,7 @@ disable_response_storage = true
|
||||
model_supports_reasoning_summaries: false,
|
||||
chatgpt_base_url: "https://chatgpt.com/backend-api/".to_string(),
|
||||
experimental_resume: None,
|
||||
+ base_instructions: None,
|
||||
};
|
||||
|
||||
assert_eq!(expected_gpt3_profile_config, gpt3_profile_config);
|
||||
@@ -910,7 +934,7 @@ disable_response_storage = true
|
||||
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
||||
shell_environment_policy: ShellEnvironmentPolicy::default(),
|
||||
disable_response_storage: true,
|
||||
- instructions: None,
|
||||
+ user_instructions: None,
|
||||
notify: None,
|
||||
cwd: fixture.cwd(),
|
||||
mcp_servers: HashMap::new(),
|
||||
@@ -927,6 +951,7 @@ disable_response_storage = true
|
||||
model_supports_reasoning_summaries: false,
|
||||
chatgpt_base_url: "https://chatgpt.com/backend-api/".to_string(),
|
||||
experimental_resume: None,
|
||||
+ base_instructions: None,
|
||||
};
|
||||
|
||||
assert_eq!(expected_zdr_profile_config, zdr_profile_config);
|
||||
diff --git a/codex-rs/core/src/project_doc.rs b/codex-rs/core/src/project_doc.rs
|
||||
index ab9d46186f..9f46159d1d 100644
|
||||
--- a/codex-rs/core/src/project_doc.rs
|
||||
+++ b/codex-rs/core/src/project_doc.rs
|
||||
@@ -27,16 +27,16 @@ const PROJECT_DOC_SEPARATOR: &str = "\n\n--- project-doc ---\n\n";
|
||||
/// string of instructions.
|
||||
pub(crate) async fn get_user_instructions(config: &Config) -> Option<String> {
|
||||
match find_project_doc(config).await {
|
||||
- Ok(Some(project_doc)) => match &config.instructions {
|
||||
+ Ok(Some(project_doc)) => match &config.user_instructions {
|
||||
Some(original_instructions) => Some(format!(
|
||||
"{original_instructions}{PROJECT_DOC_SEPARATOR}{project_doc}"
|
||||
)),
|
||||
None => Some(project_doc),
|
||||
},
|
||||
- Ok(None) => config.instructions.clone(),
|
||||
+ Ok(None) => config.user_instructions.clone(),
|
||||
Err(e) => {
|
||||
error!("error trying to find project doc: {e:#}");
|
||||
- config.instructions.clone()
|
||||
+ config.user_instructions.clone()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -159,7 +159,7 @@ mod tests {
|
||||
config.cwd = root.path().to_path_buf();
|
||||
config.project_doc_max_bytes = limit;
|
||||
|
||||
- config.instructions = instructions.map(ToOwned::to_owned);
|
||||
+ config.user_instructions = instructions.map(ToOwned::to_owned);
|
||||
config
|
||||
}
|
||||
|
||||
diff --git a/codex-rs/core/src/protocol.rs b/codex-rs/core/src/protocol.rs
|
||||
index 08d55b9749..9f6e004b67 100644
|
||||
--- a/codex-rs/core/src/protocol.rs
|
||||
+++ b/codex-rs/core/src/protocol.rs
|
||||
@@ -44,8 +44,12 @@ pub enum Op {
|
||||
model_reasoning_effort: ReasoningEffortConfig,
|
||||
model_reasoning_summary: ReasoningSummaryConfig,
|
||||
|
||||
- /// Model instructions
|
||||
- instructions: Option<String>,
|
||||
+ /// Model instructions that are appended to the base instructions.
|
||||
+ user_instructions: Option<String>,
|
||||
+
|
||||
+ /// Base instructions override.
|
||||
+ base_instructions: Option<String>,
|
||||
+
|
||||
/// When to escalate for approval for execution
|
||||
approval_policy: AskForApproval,
|
||||
/// How to sandbox commands executed in the system
|
||||
diff --git a/codex-rs/core/tests/client.rs b/codex-rs/core/tests/client.rs
|
||||
index fe4710c89b..5a6b6100eb 100644
|
||||
--- a/codex-rs/core/tests/client.rs
|
||||
+++ b/codex-rs/core/tests/client.rs
|
||||
@@ -1,5 +1,3 @@
|
||||
-use std::time::Duration;
|
||||
-
|
||||
use codex_core::Codex;
|
||||
use codex_core::ModelProviderInfo;
|
||||
use codex_core::exec::CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR;
|
||||
@@ -11,7 +9,6 @@ mod test_support;
|
||||
use tempfile::TempDir;
|
||||
use test_support::load_default_config_for_test;
|
||||
use test_support::load_sse_fixture_with_id;
|
||||
-use tokio::time::timeout;
|
||||
use wiremock::Mock;
|
||||
use wiremock::MockServer;
|
||||
use wiremock::ResponseTemplate;
|
||||
@@ -86,21 +83,15 @@ async fn includes_session_id_and_model_headers_in_request() {
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
- let mut current_session_id = None;
|
||||
- // Wait for TaskComplete
|
||||
- loop {
|
||||
- let ev = timeout(Duration::from_secs(1), codex.next_event())
|
||||
+ let EventMsg::SessionConfigured(SessionConfiguredEvent { session_id, .. }) =
|
||||
+ test_support::wait_for_event(&codex, |ev| matches!(ev, EventMsg::SessionConfigured(_)))
|
||||
.await
|
||||
- .unwrap()
|
||||
- .unwrap();
|
||||
-
|
||||
- if let EventMsg::SessionConfigured(SessionConfiguredEvent { session_id, .. }) = ev.msg {
|
||||
- current_session_id = Some(session_id.to_string());
|
||||
- }
|
||||
- if matches!(ev.msg, EventMsg::TaskComplete(_)) {
|
||||
- break;
|
||||
- }
|
||||
- }
|
||||
+ else {
|
||||
+ unreachable!()
|
||||
+ };
|
||||
+
|
||||
+ let current_session_id = Some(session_id.to_string());
|
||||
+ test_support::wait_for_event(&codex, |ev| matches!(ev, EventMsg::TaskComplete(_))).await;
|
||||
|
||||
// get request from the server
|
||||
let request = &server.received_requests().await.unwrap()[0];
|
||||
@@ -108,6 +99,76 @@ async fn includes_session_id_and_model_headers_in_request() {
|
||||
let originator = request.headers.get("originator").unwrap();
|
||||
|
||||
assert!(current_session_id.is_some());
|
||||
- assert_eq!(request_body.to_str().unwrap(), ¤t_session_id.unwrap());
|
||||
+ assert_eq!(
|
||||
+ request_body.to_str().unwrap(),
|
||||
+ current_session_id.as_ref().unwrap()
|
||||
+ );
|
||||
assert_eq!(originator.to_str().unwrap(), "codex_cli_rs");
|
||||
}
|
||||
+
|
||||
+#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
+async fn includes_base_instructions_override_in_request() {
|
||||
+ #![allow(clippy::unwrap_used)]
|
||||
+
|
||||
+ // Mock server
|
||||
+ let server = MockServer::start().await;
|
||||
+
|
||||
+ // First request – must NOT include `previous_response_id`.
|
||||
+ let first = ResponseTemplate::new(200)
|
||||
+ .insert_header("content-type", "text/event-stream")
|
||||
+ .set_body_raw(sse_completed("resp1"), "text/event-stream");
|
||||
+
|
||||
+ Mock::given(method("POST"))
|
||||
+ .and(path("/v1/responses"))
|
||||
+ .respond_with(first)
|
||||
+ .expect(1)
|
||||
+ .mount(&server)
|
||||
+ .await;
|
||||
+
|
||||
+ let model_provider = ModelProviderInfo {
|
||||
+ name: "openai".into(),
|
||||
+ base_url: format!("{}/v1", server.uri()),
|
||||
+ // Environment variable that should exist in the test environment.
|
||||
+ // ModelClient will return an error if the environment variable for the
|
||||
+ // provider is not set.
|
||||
+ env_key: Some("PATH".into()),
|
||||
+ env_key_instructions: None,
|
||||
+ wire_api: codex_core::WireApi::Responses,
|
||||
+ query_params: None,
|
||||
+ http_headers: None,
|
||||
+ env_http_headers: None,
|
||||
+ request_max_retries: Some(0),
|
||||
+ stream_max_retries: Some(0),
|
||||
+ stream_idle_timeout_ms: None,
|
||||
+ };
|
||||
+
|
||||
+ let codex_home = TempDir::new().unwrap();
|
||||
+ let mut config = load_default_config_for_test(&codex_home);
|
||||
+
|
||||
+ config.base_instructions = Some("test instructions".to_string());
|
||||
+ config.model_provider = model_provider;
|
||||
+
|
||||
+ let ctrl_c = std::sync::Arc::new(tokio::sync::Notify::new());
|
||||
+ let (codex, ..) = Codex::spawn(config, ctrl_c.clone()).await.unwrap();
|
||||
+
|
||||
+ codex
|
||||
+ .submit(Op::UserInput {
|
||||
+ items: vec![InputItem::Text {
|
||||
+ text: "hello".into(),
|
||||
+ }],
|
||||
+ })
|
||||
+ .await
|
||||
+ .unwrap();
|
||||
+
|
||||
+ test_support::wait_for_event(&codex, |ev| matches!(ev, EventMsg::TaskComplete(_))).await;
|
||||
+
|
||||
+ let request = &server.received_requests().await.unwrap()[0];
|
||||
+ let request_body = request.body_json::<serde_json::Value>().unwrap();
|
||||
+
|
||||
+ assert!(
|
||||
+ request_body["instructions"]
|
||||
+ .as_str()
|
||||
+ .unwrap()
|
||||
+ .contains("test instructions")
|
||||
+ );
|
||||
+}
|
||||
diff --git a/codex-rs/core/tests/test_support.rs b/codex-rs/core/tests/test_support.rs
|
||||
index 7d1e3a7fef..83b8a14793 100644
|
||||
--- a/codex-rs/core/tests/test_support.rs
|
||||
+++ b/codex-rs/core/tests/test_support.rs
|
||||
@@ -76,3 +76,24 @@ pub fn load_sse_fixture_with_id(path: impl AsRef<std::path::Path>, id: &str) ->
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
+
|
||||
+#[allow(dead_code)]
|
||||
+pub async fn wait_for_event<F>(
|
||||
+ codex: &codex_core::Codex,
|
||||
+ mut predicate: F,
|
||||
+) -> codex_core::protocol::EventMsg
|
||||
+where
|
||||
+ F: FnMut(&codex_core::protocol::EventMsg) -> bool,
|
||||
+{
|
||||
+ use tokio::time::Duration;
|
||||
+ use tokio::time::timeout;
|
||||
+ loop {
|
||||
+ let ev = timeout(Duration::from_secs(1), codex.next_event())
|
||||
+ .await
|
||||
+ .expect("timeout waiting for event")
|
||||
+ .expect("stream ended unexpectedly");
|
||||
+ if predicate(&ev.msg) {
|
||||
+ return ev.msg;
|
||||
+ }
|
||||
+ }
|
||||
+}
|
||||
diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs
|
||||
index 769d3c3b01..620ab82327 100644
|
||||
--- a/codex-rs/exec/src/lib.rs
|
||||
+++ b/codex-rs/exec/src/lib.rs
|
||||
@@ -110,6 +110,7 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option<PathBuf>) -> any
|
||||
cwd: cwd.map(|p| p.canonicalize().unwrap_or(p)),
|
||||
model_provider: None,
|
||||
codex_linux_sandbox_exe,
|
||||
+ base_instructions: None,
|
||||
};
|
||||
// Parse `-c` overrides.
|
||||
let cli_kv_overrides = match config_overrides.parse_overrides() {
|
||||
diff --git a/codex-rs/mcp-server/src/codex_tool_config.rs b/codex-rs/mcp-server/src/codex_tool_config.rs
|
||||
index 54d108c0fd..6357c94bd1 100644
|
||||
--- a/codex-rs/mcp-server/src/codex_tool_config.rs
|
||||
+++ b/codex-rs/mcp-server/src/codex_tool_config.rs
|
||||
@@ -14,7 +14,7 @@ use std::path::PathBuf;
|
||||
use crate::json_to_toml::json_to_toml;
|
||||
|
||||
/// Client-supplied configuration for a `codex` tool-call.
|
||||
-#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
|
||||
+#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Default)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub struct CodexToolCallParam {
|
||||
/// The *initial user prompt* to start the Codex conversation.
|
||||
@@ -46,6 +46,10 @@ pub struct CodexToolCallParam {
|
||||
/// CODEX_HOME/config.toml.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub config: Option<HashMap<String, serde_json::Value>>,
|
||||
+
|
||||
+ /// The set of instructions to use instead of the default ones.
|
||||
+ #[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
+ pub base_instructions: Option<String>,
|
||||
}
|
||||
|
||||
/// Custom enum mirroring [`AskForApproval`], but has an extra dependency on
|
||||
@@ -135,6 +139,7 @@ impl CodexToolCallParam {
|
||||
approval_policy,
|
||||
sandbox,
|
||||
config: cli_overrides,
|
||||
+ base_instructions,
|
||||
} = self;
|
||||
|
||||
// Build the `ConfigOverrides` recognised by codex-core.
|
||||
@@ -146,6 +151,7 @@ impl CodexToolCallParam {
|
||||
sandbox_mode: sandbox.map(Into::into),
|
||||
model_provider: None,
|
||||
codex_linux_sandbox_exe,
|
||||
+ base_instructions,
|
||||
};
|
||||
|
||||
let cli_overrides = cli_overrides
|
||||
@@ -268,6 +274,10 @@ mod tests {
|
||||
"description": "The *initial user prompt* to start the Codex conversation.",
|
||||
"type": "string"
|
||||
},
|
||||
+ "base-instructions": {
|
||||
+ "description": "The set of instructions to use instead of the default ones.",
|
||||
+ "type": "string"
|
||||
+ },
|
||||
},
|
||||
"required": [
|
||||
"prompt"
|
||||
diff --git a/codex-rs/mcp-server/tests/elicitation.rs b/codex-rs/mcp-server/tests/codex_tool.rs
|
||||
similarity index 81%
|
||||
rename from codex-rs/mcp-server/tests/elicitation.rs
|
||||
rename to codex-rs/mcp-server/tests/codex_tool.rs
|
||||
index ac9435e874..d36813ce9f 100644
|
||||
--- a/codex-rs/mcp-server/tests/elicitation.rs
|
||||
+++ b/codex-rs/mcp-server/tests/codex_tool.rs
|
||||
@@ -8,6 +8,7 @@ use std::path::PathBuf;
|
||||
use codex_core::exec::CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR;
|
||||
use codex_core::protocol::FileChange;
|
||||
use codex_core::protocol::ReviewDecision;
|
||||
+use codex_mcp_server::CodexToolCallParam;
|
||||
use codex_mcp_server::ExecApprovalElicitRequestParams;
|
||||
use codex_mcp_server::ExecApprovalResponse;
|
||||
use codex_mcp_server::PatchApprovalElicitRequestParams;
|
||||
@@ -76,7 +77,10 @@ async fn shell_command_approval_triggers_elicitation() -> anyhow::Result<()> {
|
||||
// In turn, it should reply with a tool call, which the MCP should forward
|
||||
// as an elicitation.
|
||||
let codex_request_id = mcp_process
|
||||
- .send_codex_tool_call(None, "run `git init`")
|
||||
+ .send_codex_tool_call(CodexToolCallParam {
|
||||
+ prompt: "run `git init`".to_string(),
|
||||
+ ..Default::default()
|
||||
+ })
|
||||
.await?;
|
||||
let elicitation_request = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
@@ -209,10 +213,11 @@ async fn patch_approval_triggers_elicitation() -> anyhow::Result<()> {
|
||||
|
||||
// Send a "codex" tool request that will trigger the apply_patch command
|
||||
let codex_request_id = mcp_process
|
||||
- .send_codex_tool_call(
|
||||
- Some(cwd.path().to_string_lossy().to_string()),
|
||||
- "please modify the test file",
|
||||
- )
|
||||
+ .send_codex_tool_call(CodexToolCallParam {
|
||||
+ cwd: Some(cwd.path().to_string_lossy().to_string()),
|
||||
+ prompt: "please modify the test file".to_string(),
|
||||
+ ..Default::default()
|
||||
+ })
|
||||
.await?;
|
||||
let elicitation_request = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
@@ -279,6 +284,75 @@ async fn patch_approval_triggers_elicitation() -> anyhow::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
+#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
+async fn test_codex_tool_passes_base_instructions() {
|
||||
+ if std::env::var(CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR).is_ok() {
|
||||
+ println!(
|
||||
+ "Skipping test because it cannot execute when network is disabled in a Codex sandbox."
|
||||
+ );
|
||||
+ return;
|
||||
+ }
|
||||
+
|
||||
+ // Apparently `#[tokio::test]` must return `()`, so we create a helper
|
||||
+ // function that returns `Result` so we can use `?` in favor of `unwrap`.
|
||||
+ if let Err(err) = codex_tool_passes_base_instructions().await {
|
||||
+ panic!("failure: {err}");
|
||||
+ }
|
||||
+}
|
||||
+
|
||||
+async fn codex_tool_passes_base_instructions() -> anyhow::Result<()> {
|
||||
+ #![allow(clippy::unwrap_used)]
|
||||
+
|
||||
+ let server =
|
||||
+ create_mock_chat_completions_server(vec![create_final_assistant_message_sse_response(
|
||||
+ "Enjoy!",
|
||||
+ )?])
|
||||
+ .await;
|
||||
+
|
||||
+ // Run `codex mcp` with a specific config.toml.
|
||||
+ let codex_home = TempDir::new()?;
|
||||
+ create_config_toml(codex_home.path(), &server.uri())?;
|
||||
+ let mut mcp_process = McpProcess::new(codex_home.path()).await?;
|
||||
+ timeout(DEFAULT_READ_TIMEOUT, mcp_process.initialize()).await??;
|
||||
+
|
||||
+ // Send a "codex" tool request, which should hit the completions endpoint.
|
||||
+ let codex_request_id = mcp_process
|
||||
+ .send_codex_tool_call(CodexToolCallParam {
|
||||
+ prompt: "How are you?".to_string(),
|
||||
+ base_instructions: Some("You are a helpful assistant.".to_string()),
|
||||
+ ..Default::default()
|
||||
+ })
|
||||
+ .await?;
|
||||
+
|
||||
+ let codex_response = timeout(
|
||||
+ DEFAULT_READ_TIMEOUT,
|
||||
+ mcp_process.read_stream_until_response_message(RequestId::Integer(codex_request_id)),
|
||||
+ )
|
||||
+ .await??;
|
||||
+ assert_eq!(
|
||||
+ JSONRPCResponse {
|
||||
+ jsonrpc: JSONRPC_VERSION.into(),
|
||||
+ id: RequestId::Integer(codex_request_id),
|
||||
+ result: json!({
|
||||
+ "content": [
|
||||
+ {
|
||||
+ "text": "Enjoy!",
|
||||
+ "type": "text"
|
||||
+ }
|
||||
+ ]
|
||||
+ }),
|
||||
+ },
|
||||
+ codex_response
|
||||
+ );
|
||||
+
|
||||
+ let requests = server.received_requests().await.unwrap();
|
||||
+ let request = requests[0].body_json::<serde_json::Value>().unwrap();
|
||||
+ let instructions = request["messages"][0]["content"].as_str().unwrap();
|
||||
+ assert!(instructions.starts_with("You are a helpful assistant."));
|
||||
+
|
||||
+ Ok(())
|
||||
+}
|
||||
+
|
||||
fn create_expected_patch_approval_elicitation_request(
|
||||
elicitation_request_id: RequestId,
|
||||
changes: HashMap<PathBuf, FileChange>,
|
||||
diff --git a/codex-rs/mcp-server/tests/common/mcp_process.rs b/codex-rs/mcp-server/tests/common/mcp_process.rs
|
||||
index df9cc98acf..a86deaab75 100644
|
||||
--- a/codex-rs/mcp-server/tests/common/mcp_process.rs
|
||||
+++ b/codex-rs/mcp-server/tests/common/mcp_process.rs
|
||||
@@ -141,20 +141,11 @@ impl McpProcess {
|
||||
/// correlating notifications.
|
||||
pub async fn send_codex_tool_call(
|
||||
&mut self,
|
||||
- cwd: Option<String>,
|
||||
- prompt: &str,
|
||||
+ params: CodexToolCallParam,
|
||||
) -> anyhow::Result<i64> {
|
||||
let codex_tool_call_params = CallToolRequestParams {
|
||||
name: "codex".to_string(),
|
||||
- arguments: Some(serde_json::to_value(CodexToolCallParam {
|
||||
- cwd,
|
||||
- prompt: prompt.to_string(),
|
||||
- model: None,
|
||||
- profile: None,
|
||||
- approval_policy: None,
|
||||
- sandbox: None,
|
||||
- config: None,
|
||||
- })?),
|
||||
+ arguments: Some(serde_json::to_value(params)?),
|
||||
};
|
||||
self.send_request(
|
||||
mcp_types::CallToolRequest::METHOD,
|
||||
diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs
|
||||
index 4ca305b35e..05a55edc7b 100644
|
||||
--- a/codex-rs/tui/src/lib.rs
|
||||
+++ b/codex-rs/tui/src/lib.rs
|
||||
@@ -75,6 +75,7 @@ pub fn run_main(cli: Cli, codex_linux_sandbox_exe: Option<PathBuf>) -> std::io::
|
||||
model_provider: None,
|
||||
config_profile: cli.config_profile.clone(),
|
||||
codex_linux_sandbox_exe,
|
||||
+ base_instructions: None,
|
||||
};
|
||||
// Parse `-c` overrides from the CLI.
|
||||
let cli_kv_overrides = match cli.config_overrides.parse_overrides() {
|
||||
```
|
||||
|
||||
## Review Comments
|
||||
|
||||
### codex-rs/core/tests/client.rs
|
||||
|
||||
- Created: 2025-07-22 16:19:17 UTC | Link: https://github.com/openai/codex/pull/1645#discussion_r2223088011
|
||||
|
||||
```diff
|
||||
@@ -11,7 +9,7 @@ mod test_support;
|
||||
use tempfile::TempDir;
|
||||
use test_support::load_default_config_for_test;
|
||||
use test_support::load_sse_fixture_with_id;
|
||||
-use tokio::time::timeout;
|
||||
+
|
||||
```
|
||||
|
||||
> remove blank line?
|
||||
|
||||
- Created: 2025-07-22 16:21:43 UTC | Link: https://github.com/openai/codex/pull/1645#discussion_r2223093798
|
||||
|
||||
```diff
|
||||
@@ -86,28 +84,92 @@ async fn includes_session_id_and_model_headers_in_request() {
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
- let mut current_session_id = None;
|
||||
- // Wait for TaskComplete
|
||||
- loop {
|
||||
- let ev = timeout(Duration::from_secs(1), codex.next_event())
|
||||
+ let EventMsg::SessionConfigured(SessionConfiguredEvent { session_id, .. }) =
|
||||
+ test_support::wait_for_event(&codex, |ev| matches!(ev, EventMsg::SessionConfigured(_)))
|
||||
.await
|
||||
- .unwrap()
|
||||
- .unwrap();
|
||||
-
|
||||
- if let EventMsg::SessionConfigured(SessionConfiguredEvent { session_id, .. }) = ev.msg {
|
||||
- current_session_id = Some(session_id.to_string());
|
||||
- }
|
||||
- if matches!(ev.msg, EventMsg::TaskComplete(_)) {
|
||||
- break;
|
||||
- }
|
||||
- }
|
||||
+ else {
|
||||
+ unreachable!()
|
||||
+ };
|
||||
+
|
||||
+ let current_session_id = Some(session_id.to_string());
|
||||
+ test_support::wait_for_event(&codex, |ev| matches!(ev, EventMsg::TaskComplete(_))).await;
|
||||
|
||||
// get request from the server
|
||||
let request = &server.received_requests().await.unwrap()[0];
|
||||
let request_body = request.headers.get("session_id").unwrap();
|
||||
let originator = request.headers.get("originator").unwrap();
|
||||
|
||||
assert!(current_session_id.is_some());
|
||||
- assert_eq!(request_body.to_str().unwrap(), ¤t_session_id.unwrap());
|
||||
+ assert_eq!(
|
||||
+ request_body.to_str().unwrap(),
|
||||
+ current_session_id.as_ref().unwrap()
|
||||
+ );
|
||||
assert_eq!(originator.to_str().unwrap(), "codex_cli_rs");
|
||||
}
|
||||
+
|
||||
+#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
+async fn includes_base_instructions_override_in_request() {
|
||||
+ #![allow(clippy::unwrap_used)]
|
||||
+
|
||||
+ // Mock server
|
||||
+ let server = MockServer::start().await;
|
||||
+
|
||||
+ // First request – must NOT include `previous_response_id`.
|
||||
+ let first = ResponseTemplate::new(200)
|
||||
+ .insert_header("content-type", "text/event-stream")
|
||||
+ .set_body_raw(sse_completed("resp1"), "text/event-stream");
|
||||
+
|
||||
+ Mock::given(method("POST"))
|
||||
+ .and(path("/v1/responses"))
|
||||
+ .respond_with(first)
|
||||
+ .expect(1)
|
||||
+ .mount(&server)
|
||||
+ .await;
|
||||
+
|
||||
+ let model_provider = ModelProviderInfo {
|
||||
+ name: "openai".into(),
|
||||
+ base_url: format!("{}/v1", server.uri()),
|
||||
+ // Environment variable that should exist in the test environment.
|
||||
+ // ModelClient will return an error if the environment variable for the
|
||||
+ // provider is not set.
|
||||
+ env_key: Some("PATH".into()),
|
||||
+ env_key_instructions: None,
|
||||
+ wire_api: codex_core::WireApi::Responses,
|
||||
+ query_params: None,
|
||||
+ http_headers: None,
|
||||
+ env_http_headers: None,
|
||||
+ request_max_retries: Some(0),
|
||||
+ stream_max_retries: Some(0),
|
||||
+ stream_idle_timeout_ms: None,
|
||||
+ };
|
||||
+
|
||||
+ let codex_home = TempDir::new().unwrap();
|
||||
+ let mut config = load_default_config_for_test(&codex_home);
|
||||
+
|
||||
+ config.base_instructions = Some("test instructions".to_string());
|
||||
+ config.model_provider = model_provider;
|
||||
+
|
||||
+ let ctrl_c = std::sync::Arc::new(tokio::sync::Notify::new());
|
||||
+ let (codex, _init_id) = Codex::spawn(config, ctrl_c.clone()).await.unwrap();
|
||||
```
|
||||
|
||||
> This tuple has a third value now, right?
|
||||
|
||||
### codex-rs/core/tests/test_support.rs
|
||||
|
||||
- Created: 2025-07-22 16:22:32 UTC | Link: https://github.com/openai/codex/pull/1645#discussion_r2223096525
|
||||
|
||||
```diff
|
||||
@@ -76,3 +76,24 @@ pub fn load_sse_fixture_with_id(path: impl AsRef<std::path::Path>, id: &str) ->
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
+
|
||||
+#[allow(dead_code)]
|
||||
```
|
||||
|
||||
> May we need an "integration tests util" crate so we don't have to do this `dead_code` thing, which is distracting/misleading.
|
||||
|
||||
### codex-rs/mcp-server/src/codex_tool_config.rs
|
||||
|
||||
- Created: 2025-07-22 16:23:13 UTC | Link: https://github.com/openai/codex/pull/1645#discussion_r2223099237
|
||||
|
||||
```diff
|
||||
@@ -46,6 +46,10 @@ pub struct CodexToolCallParam {
|
||||
/// CODEX_HOME/config.toml.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub config: Option<HashMap<String, serde_json::Value>>,
|
||||
+
|
||||
+ /// The set of instructions to use instead of the default ones.
|
||||
+ #[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
+ pub base_instructions: Option<String>,
|
||||
```
|
||||
|
||||
> So the idea is that this is the literal value of the instructions rather than the path to file on disk, correct?
|
||||
1134
prs/bolinfest/PR-1646.md
Normal file
1134
prs/bolinfest/PR-1646.md
Normal file
File diff suppressed because it is too large
Load Diff
1068
prs/bolinfest/PR-1647.md
Normal file
1068
prs/bolinfest/PR-1647.md
Normal file
File diff suppressed because it is too large
Load Diff
131
prs/bolinfest/PR-1653.md
Normal file
131
prs/bolinfest/PR-1653.md
Normal file
@@ -0,0 +1,131 @@
|
||||
# PR #1653: feat: support dotenv (including ~/.codex/.env)
|
||||
|
||||
- URL: https://github.com/openai/codex/pull/1653
|
||||
- Author: bolinfest
|
||||
- Created: 2025-07-22 17:48:58 UTC
|
||||
- Updated: 2025-07-22 22:54:41 UTC
|
||||
- Changes: +23/-1, Files changed: 4, Commits: 1
|
||||
|
||||
## Description
|
||||
|
||||
This PR adds a `load_dotenv()` helper function to the `codex-common` crate that is available when the `cli` feature is enabled. The function uses [`dotenvy`](https://crates.io/crates/dotenvy) to update the environment from:
|
||||
|
||||
- `$CODEX_HOME/.env`
|
||||
- `$(pwd)/.env`
|
||||
|
||||
To test:
|
||||
|
||||
- ran `printenv OPENAI_API_KEY` to verify the env var exists in my environment
|
||||
- ran `just codex exec hello` to verify the CLI uses my `OPENAI_API_KEY`
|
||||
- ran `unset OPENAI_API_KEY`
|
||||
- ran `just codex exec hello` again and got **ERROR: Missing environment variable: `OPENAI_API_KEY`**, as expected
|
||||
- created `~/.codex/.env` and added `OPENAI_API_KEY=sk-proj-...` (also ran `chmod 400 ~/.codex/.env` for good measure)
|
||||
- ran `just codex exec hello` again and it worked, verifying it picked up `OPENAI_API_KEY` from `~/.codex/.env`
|
||||
|
||||
Note this functionality was available in the TypeScript CLI: https://github.com/openai/codex/pull/122 and was recently requested over on https://github.com/openai/codex/issues/1262#issuecomment-3093203551.
|
||||
|
||||
## Full Diff
|
||||
|
||||
```diff
|
||||
diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock
|
||||
index 9b4a4e32d4..3e4b84a435 100644
|
||||
--- a/codex-rs/Cargo.lock
|
||||
+++ b/codex-rs/Cargo.lock
|
||||
@@ -756,7 +756,9 @@ version = "0.0.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"clap",
|
||||
+ "codex-common",
|
||||
"codex-core",
|
||||
+ "dotenvy",
|
||||
"landlock",
|
||||
"libc",
|
||||
"seccompiler",
|
||||
@@ -1272,6 +1274,12 @@ version = "0.3.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fea41bba32d969b513997752735605054bc0dfa92b4c56bf1189f2e174be7a10"
|
||||
|
||||
+[[package]]
|
||||
+name = "dotenvy"
|
||||
+version = "0.15.7"
|
||||
+source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
+checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b"
|
||||
+
|
||||
[[package]]
|
||||
name = "dupe"
|
||||
version = "0.9.1"
|
||||
diff --git a/codex-rs/core/src/config.rs b/codex-rs/core/src/config.rs
|
||||
index 8ed06c45af..2dfd3e55fe 100644
|
||||
--- a/codex-rs/core/src/config.rs
|
||||
+++ b/codex-rs/core/src/config.rs
|
||||
@@ -561,7 +561,7 @@ fn default_model() -> String {
|
||||
/// function will Err if the path does not exist.
|
||||
/// - If `CODEX_HOME` is not set, this function does not verify that the
|
||||
/// directory exists.
|
||||
-fn find_codex_home() -> std::io::Result<PathBuf> {
|
||||
+pub fn find_codex_home() -> std::io::Result<PathBuf> {
|
||||
// Honor the `CODEX_HOME` environment variable when it is set to allow users
|
||||
// (and tests) to override the default location.
|
||||
if let Ok(val) = std::env::var("CODEX_HOME") {
|
||||
diff --git a/codex-rs/linux-sandbox/Cargo.toml b/codex-rs/linux-sandbox/Cargo.toml
|
||||
index c8cd1078c0..5c2dea6083 100644
|
||||
--- a/codex-rs/linux-sandbox/Cargo.toml
|
||||
+++ b/codex-rs/linux-sandbox/Cargo.toml
|
||||
@@ -17,7 +17,9 @@ workspace = true
|
||||
[dependencies]
|
||||
anyhow = "1"
|
||||
clap = { version = "4", features = ["derive"] }
|
||||
+codex-common = { path = "../common", features = ["cli"] }
|
||||
codex-core = { path = "../core" }
|
||||
+dotenvy = "0.15.7"
|
||||
tokio = { version = "1", features = ["rt-multi-thread"] }
|
||||
|
||||
[dev-dependencies]
|
||||
diff --git a/codex-rs/linux-sandbox/src/lib.rs b/codex-rs/linux-sandbox/src/lib.rs
|
||||
index 568f015822..960678467c 100644
|
||||
--- a/codex-rs/linux-sandbox/src/lib.rs
|
||||
+++ b/codex-rs/linux-sandbox/src/lib.rs
|
||||
@@ -43,6 +43,10 @@ where
|
||||
crate::run_main();
|
||||
}
|
||||
|
||||
+ // This modifies the environment, which is not thread-safe, so do this
|
||||
+ // before creating any threads/the Tokio runtime.
|
||||
+ load_dotenv();
|
||||
+
|
||||
// Regular invocation – create a Tokio runtime and execute the provided
|
||||
// async entry-point.
|
||||
let runtime = tokio::runtime::Runtime::new()?;
|
||||
@@ -61,3 +65,11 @@ where
|
||||
pub fn run_main() -> ! {
|
||||
panic!("codex-linux-sandbox is only supported on Linux");
|
||||
}
|
||||
+
|
||||
+/// Load env vars from ~/.codex/.env and `$(pwd)/.env`.
|
||||
+fn load_dotenv() {
|
||||
+ if let Ok(codex_home) = codex_core::config::find_codex_home() {
|
||||
+ dotenvy::from_path(codex_home.join(".env")).ok();
|
||||
+ }
|
||||
+ dotenvy::dotenv().ok();
|
||||
+}
|
||||
```
|
||||
|
||||
## Review Comments
|
||||
|
||||
### codex-rs/common/src/dotenv.rs
|
||||
|
||||
- Created: 2025-07-22 21:54:08 UTC | Link: https://github.com/openai/codex/pull/1653#discussion_r2223902461
|
||||
|
||||
```diff
|
||||
@@ -0,0 +1,7 @@
|
||||
+/// Load env vars from ~/.codex/.env and `$(pwd)/.env`.
|
||||
+pub fn load_dotenv() {
|
||||
+ if let Ok(codex_home) = codex_core::config::find_codex_home() {
|
||||
+ dotenvy::from_path(codex_home.join(".env")).ok();
|
||||
+ }
|
||||
+ dotenvy::dotenv().ok();
|
||||
```
|
||||
|
||||
> I think this could be argued either way, but I think there is a technical reason to do this as early as possible (before we know what the `Config` even is), which is that setting environment variables for the current process is not thread-safe, so it should really be done before any threads have been created.
|
||||
>
|
||||
> I just reworked this so that `load_dotenv()` is now called before we set up the Tokio runtime.
|
||||
410
prs/bolinfest/PR-1661.md
Normal file
410
prs/bolinfest/PR-1661.md
Normal file
@@ -0,0 +1,410 @@
|
||||
# PR #1661: [client] Add item ids for deltas and messages
|
||||
|
||||
- URL: https://github.com/openai/codex/pull/1661
|
||||
- Author: dylan-hurd-oai
|
||||
- Created: 2025-07-23 20:25:15 UTC
|
||||
- Updated: 2025-08-19 22:05:25 UTC
|
||||
- Changes: +61/-27, Files changed: 10, Commits: 1
|
||||
|
||||
## Description
|
||||
|
||||
## Summary
|
||||
Includes `id` in Message and Reasoning from `response.output_item.done` streaming events, and `item_id` in .delta events, so mcp clients can match deltas to their eventual final event.
|
||||
|
||||
Open Question: I think we re-use the same `ResponseItem::Message` struct for API input and output, which leads to some unnecessary `id: Option<String>` fields. Should we separate these? @bolinfest
|
||||
|
||||
## Test Plan
|
||||
- [x] Tested locally with mcp client
|
||||
|
||||
## Full Diff
|
||||
|
||||
```diff
|
||||
diff --git a/codex-rs/core/src/chat_completions.rs b/codex-rs/core/src/chat_completions.rs
|
||||
index 35045c8e1b..54f9841c3f 100644
|
||||
--- a/codex-rs/core/src/chat_completions.rs
|
||||
+++ b/codex-rs/core/src/chat_completions.rs
|
||||
@@ -41,7 +41,12 @@ pub(crate) async fn stream_chat_completions(
|
||||
|
||||
for item in &prompt.input {
|
||||
match item {
|
||||
- ResponseItem::Message { role, content } => {
|
||||
+ ResponseItem::Message {
|
||||
+ // id will always be None for input items
|
||||
+ id: _,
|
||||
+ role,
|
||||
+ content,
|
||||
+ } => {
|
||||
let mut text = String::new();
|
||||
for c in content {
|
||||
match c {
|
||||
@@ -255,6 +260,7 @@ async fn process_chat_sse<S>(
|
||||
.and_then(|c| c.as_str())
|
||||
{
|
||||
let item = ResponseItem::Message {
|
||||
+ id: None,
|
||||
role: "assistant".to_string(),
|
||||
content: vec![ContentItem::OutputText {
|
||||
text: content.to_string(),
|
||||
@@ -402,6 +408,7 @@ where
|
||||
}))) => {
|
||||
if !this.cumulative.is_empty() {
|
||||
let aggregated_item = crate::models::ResponseItem::Message {
|
||||
+ id: None,
|
||||
role: "assistant".to_string(),
|
||||
content: vec![crate::models::ContentItem::OutputText {
|
||||
text: std::mem::take(&mut this.cumulative),
|
||||
@@ -430,8 +437,8 @@ where
|
||||
// will never appear in a Chat Completions stream.
|
||||
continue;
|
||||
}
|
||||
- Poll::Ready(Some(Ok(ResponseEvent::OutputTextDelta(_))))
|
||||
- | Poll::Ready(Some(Ok(ResponseEvent::ReasoningSummaryDelta(_)))) => {
|
||||
+ Poll::Ready(Some(Ok(ResponseEvent::OutputTextDelta { .. })))
|
||||
+ | Poll::Ready(Some(Ok(ResponseEvent::ReasoningSummaryDelta { .. }))) => {
|
||||
// Deltas are ignored here since aggregation waits for the
|
||||
// final OutputItemDone.
|
||||
continue;
|
||||
diff --git a/codex-rs/core/src/client.rs b/codex-rs/core/src/client.rs
|
||||
index beeaa453ad..d2c968e827 100644
|
||||
--- a/codex-rs/core/src/client.rs
|
||||
+++ b/codex-rs/core/src/client.rs
|
||||
@@ -230,6 +230,7 @@ struct SseEvent {
|
||||
response: Option<Value>,
|
||||
item: Option<Value>,
|
||||
delta: Option<String>,
|
||||
+ item_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -360,21 +361,22 @@ async fn process_sse<S>(
|
||||
};
|
||||
|
||||
let event = ResponseEvent::OutputItemDone(item);
|
||||
+ trace!(?event, "output_item.done");
|
||||
if tx_event.send(Ok(event)).await.is_err() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
"response.output_text.delta" => {
|
||||
- if let Some(delta) = event.delta {
|
||||
- let event = ResponseEvent::OutputTextDelta(delta);
|
||||
+ if let (Some(delta), Some(item_id)) = (event.delta, event.item_id) {
|
||||
+ let event = ResponseEvent::OutputTextDelta { delta, item_id };
|
||||
if tx_event.send(Ok(event)).await.is_err() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
"response.reasoning_summary_text.delta" => {
|
||||
- if let Some(delta) = event.delta {
|
||||
- let event = ResponseEvent::ReasoningSummaryDelta(delta);
|
||||
+ if let (Some(delta), Some(item_id)) = (event.delta, event.item_id) {
|
||||
+ let event = ResponseEvent::ReasoningSummaryDelta { delta, item_id };
|
||||
if tx_event.send(Ok(event)).await.is_err() {
|
||||
return;
|
||||
}
|
||||
diff --git a/codex-rs/core/src/client_common.rs b/codex-rs/core/src/client_common.rs
|
||||
index 94d09e7fd4..d16ceb3dd6 100644
|
||||
--- a/codex-rs/core/src/client_common.rs
|
||||
+++ b/codex-rs/core/src/client_common.rs
|
||||
@@ -56,6 +56,8 @@ impl Prompt {
|
||||
}
|
||||
}
|
||||
|
||||
+/// Events emitted by the response stream.
|
||||
+/// https://platform.openai.com/docs/api-reference/responses-streaming/response
|
||||
#[derive(Debug)]
|
||||
pub enum ResponseEvent {
|
||||
Created,
|
||||
@@ -64,8 +66,14 @@ pub enum ResponseEvent {
|
||||
response_id: String,
|
||||
token_usage: Option<TokenUsage>,
|
||||
},
|
||||
- OutputTextDelta(String),
|
||||
- ReasoningSummaryDelta(String),
|
||||
+ OutputTextDelta {
|
||||
+ delta: String,
|
||||
+ item_id: String,
|
||||
+ },
|
||||
+ ReasoningSummaryDelta {
|
||||
+ delta: String,
|
||||
+ item_id: String,
|
||||
+ },
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs
|
||||
index 6eb1715fbf..eb3b0852db 100644
|
||||
--- a/codex-rs/core/src/codex.rs
|
||||
+++ b/codex-rs/core/src/codex.rs
|
||||
@@ -1248,17 +1248,17 @@ async fn try_run_turn(
|
||||
state.previous_response_id = Some(response_id);
|
||||
return Ok(output);
|
||||
}
|
||||
- ResponseEvent::OutputTextDelta(delta) => {
|
||||
+ ResponseEvent::OutputTextDelta { delta, item_id } => {
|
||||
let event = Event {
|
||||
id: sub_id.to_string(),
|
||||
- msg: EventMsg::AgentMessageDelta(AgentMessageDeltaEvent { delta }),
|
||||
+ msg: EventMsg::AgentMessageDelta(AgentMessageDeltaEvent { delta, item_id }),
|
||||
};
|
||||
sess.tx_event.send(event).await.ok();
|
||||
}
|
||||
- ResponseEvent::ReasoningSummaryDelta(delta) => {
|
||||
+ ResponseEvent::ReasoningSummaryDelta { delta, item_id } => {
|
||||
let event = Event {
|
||||
id: sub_id.to_string(),
|
||||
- msg: EventMsg::AgentReasoningDelta(AgentReasoningDeltaEvent { delta }),
|
||||
+ msg: EventMsg::AgentReasoningDelta(AgentReasoningDeltaEvent { delta, item_id }),
|
||||
};
|
||||
sess.tx_event.send(event).await.ok();
|
||||
}
|
||||
@@ -1273,26 +1273,32 @@ async fn handle_response_item(
|
||||
) -> CodexResult<Option<ResponseInputItem>> {
|
||||
debug!(?item, "Output item");
|
||||
let output = match item {
|
||||
- ResponseItem::Message { content, .. } => {
|
||||
+ ResponseItem::Message { content, id, .. } => {
|
||||
for item in content {
|
||||
if let ContentItem::OutputText { text } = item {
|
||||
let event = Event {
|
||||
id: sub_id.to_string(),
|
||||
- msg: EventMsg::AgentMessage(AgentMessageEvent { message: text }),
|
||||
+ msg: EventMsg::AgentMessage(AgentMessageEvent {
|
||||
+ id: id.clone(),
|
||||
+ message: text,
|
||||
+ }),
|
||||
};
|
||||
sess.tx_event.send(event).await.ok();
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
- ResponseItem::Reasoning { id: _, summary } => {
|
||||
+ ResponseItem::Reasoning { id, summary } => {
|
||||
for item in summary {
|
||||
let text = match item {
|
||||
ReasoningItemReasoningSummary::SummaryText { text } => text,
|
||||
};
|
||||
let event = Event {
|
||||
id: sub_id.to_string(),
|
||||
- msg: EventMsg::AgentReasoning(AgentReasoningEvent { text }),
|
||||
+ msg: EventMsg::AgentReasoning(AgentReasoningEvent {
|
||||
+ id: id.clone(),
|
||||
+ text,
|
||||
+ }),
|
||||
};
|
||||
sess.tx_event.send(event).await.ok();
|
||||
}
|
||||
@@ -2092,7 +2098,7 @@ fn format_exec_output(output: &str, exit_code: i32, duration: Duration) -> Strin
|
||||
|
||||
fn get_last_assistant_message_from_turn(responses: &[ResponseItem]) -> Option<String> {
|
||||
responses.iter().rev().find_map(|item| {
|
||||
- if let ResponseItem::Message { role, content } = item {
|
||||
+ if let ResponseItem::Message { role, content, .. } = item {
|
||||
if role == "assistant" {
|
||||
content.iter().rev().find_map(|ci| {
|
||||
if let ContentItem::OutputText { text } = ci {
|
||||
diff --git a/codex-rs/core/src/models.rs b/codex-rs/core/src/models.rs
|
||||
index 6b392fb19d..706a0ae1c3 100644
|
||||
--- a/codex-rs/core/src/models.rs
|
||||
+++ b/codex-rs/core/src/models.rs
|
||||
@@ -37,6 +37,7 @@ pub enum ContentItem {
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum ResponseItem {
|
||||
Message {
|
||||
+ id: Option<String>,
|
||||
role: String,
|
||||
content: Vec<ContentItem>,
|
||||
},
|
||||
@@ -78,7 +79,11 @@ pub enum ResponseItem {
|
||||
impl From<ResponseInputItem> for ResponseItem {
|
||||
fn from(item: ResponseInputItem) -> Self {
|
||||
match item {
|
||||
- ResponseInputItem::Message { role, content } => Self::Message { role, content },
|
||||
+ ResponseInputItem::Message { role, content } => Self::Message {
|
||||
+ id: None,
|
||||
+ role,
|
||||
+ content,
|
||||
+ },
|
||||
ResponseInputItem::FunctionCallOutput { call_id, output } => {
|
||||
Self::FunctionCallOutput { call_id, output }
|
||||
}
|
||||
diff --git a/codex-rs/core/src/protocol.rs b/codex-rs/core/src/protocol.rs
|
||||
index 9f6e004b67..294488b29f 100644
|
||||
--- a/codex-rs/core/src/protocol.rs
|
||||
+++ b/codex-rs/core/src/protocol.rs
|
||||
@@ -351,22 +351,26 @@ pub struct TokenUsage {
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct AgentMessageEvent {
|
||||
+ pub id: Option<String>,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct AgentMessageDeltaEvent {
|
||||
pub delta: String,
|
||||
+ pub item_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct AgentReasoningEvent {
|
||||
+ pub id: String,
|
||||
pub text: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct AgentReasoningDeltaEvent {
|
||||
pub delta: String,
|
||||
+ pub item_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
diff --git a/codex-rs/core/tests/live_agent.rs b/codex-rs/core/tests/live_agent.rs
|
||||
index 0be6110571..b9b0563641 100644
|
||||
--- a/codex-rs/core/tests/live_agent.rs
|
||||
+++ b/codex-rs/core/tests/live_agent.rs
|
||||
@@ -120,7 +120,7 @@ async fn live_streaming_and_prev_id_reset() {
|
||||
.expect("agent closed");
|
||||
|
||||
match &ev.msg {
|
||||
- EventMsg::AgentMessage(AgentMessageEvent { message })
|
||||
+ EventMsg::AgentMessage(AgentMessageEvent { id: _, message })
|
||||
if message.contains("second turn succeeded") =>
|
||||
{
|
||||
got_expected = true;
|
||||
diff --git a/codex-rs/exec/src/event_processor_with_human_output.rs b/codex-rs/exec/src/event_processor_with_human_output.rs
|
||||
index 7b39071116..7721d295f5 100644
|
||||
--- a/codex-rs/exec/src/event_processor_with_human_output.rs
|
||||
+++ b/codex-rs/exec/src/event_processor_with_human_output.rs
|
||||
@@ -174,7 +174,7 @@ impl EventProcessor for EventProcessorWithHumanOutput {
|
||||
EventMsg::TokenCount(TokenUsage { total_tokens, .. }) => {
|
||||
ts_println!(self, "tokens used: {total_tokens}");
|
||||
}
|
||||
- EventMsg::AgentMessageDelta(AgentMessageDeltaEvent { delta }) => {
|
||||
+ EventMsg::AgentMessageDelta(AgentMessageDeltaEvent { delta, item_id: _ }) => {
|
||||
if !self.answer_started {
|
||||
ts_println!(self, "{}\n", "codex".style(self.italic).style(self.magenta));
|
||||
self.answer_started = true;
|
||||
@@ -183,7 +183,7 @@ impl EventProcessor for EventProcessorWithHumanOutput {
|
||||
#[allow(clippy::expect_used)]
|
||||
std::io::stdout().flush().expect("could not flush stdout");
|
||||
}
|
||||
- EventMsg::AgentReasoningDelta(AgentReasoningDeltaEvent { delta }) => {
|
||||
+ EventMsg::AgentReasoningDelta(AgentReasoningDeltaEvent { delta, item_id: _ }) => {
|
||||
if !self.show_agent_reasoning {
|
||||
return;
|
||||
}
|
||||
@@ -199,7 +199,7 @@ impl EventProcessor for EventProcessorWithHumanOutput {
|
||||
#[allow(clippy::expect_used)]
|
||||
std::io::stdout().flush().expect("could not flush stdout");
|
||||
}
|
||||
- EventMsg::AgentMessage(AgentMessageEvent { message }) => {
|
||||
+ EventMsg::AgentMessage(AgentMessageEvent { id: _, message }) => {
|
||||
// if answer_started is false, this means we haven't received any
|
||||
// delta. Thus, we need to print the message as a new answer.
|
||||
if !self.answer_started {
|
||||
diff --git a/codex-rs/mcp-server/src/outgoing_message.rs b/codex-rs/mcp-server/src/outgoing_message.rs
|
||||
index a1eea65f25..08f6a9dc51 100644
|
||||
--- a/codex-rs/mcp-server/src/outgoing_message.rs
|
||||
+++ b/codex-rs/mcp-server/src/outgoing_message.rs
|
||||
@@ -16,6 +16,7 @@ use serde::Serialize;
|
||||
use tokio::sync::Mutex;
|
||||
use tokio::sync::mpsc;
|
||||
use tokio::sync::oneshot;
|
||||
+use tracing::trace;
|
||||
use tracing::warn;
|
||||
|
||||
pub(crate) struct OutgoingMessageSender {
|
||||
@@ -79,6 +80,7 @@ impl OutgoingMessageSender {
|
||||
}
|
||||
|
||||
pub(crate) async fn send_event_as_notification(&self, event: &Event) {
|
||||
+ trace!(?event, "sending event as notification");
|
||||
#[expect(clippy::expect_used)]
|
||||
let params = Some(serde_json::to_value(event).expect("Event must serialize"));
|
||||
let outgoing_message = OutgoingMessage::Notification(OutgoingNotification {
|
||||
diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs
|
||||
index c70c6f6d72..7182ed7942 100644
|
||||
--- a/codex-rs/tui/src/chatwidget.rs
|
||||
+++ b/codex-rs/tui/src/chatwidget.rs
|
||||
@@ -246,7 +246,7 @@ impl ChatWidget<'_> {
|
||||
|
||||
self.request_redraw();
|
||||
}
|
||||
- EventMsg::AgentMessage(AgentMessageEvent { message }) => {
|
||||
+ EventMsg::AgentMessage(AgentMessageEvent { id: _, message }) => {
|
||||
// if the answer buffer is empty, this means we haven't received any
|
||||
// delta. Thus, we need to print the message as a new answer.
|
||||
if self.answer_buffer.is_empty() {
|
||||
@@ -259,7 +259,7 @@ impl ChatWidget<'_> {
|
||||
self.answer_buffer.clear();
|
||||
self.request_redraw();
|
||||
}
|
||||
- EventMsg::AgentMessageDelta(AgentMessageDeltaEvent { delta }) => {
|
||||
+ EventMsg::AgentMessageDelta(AgentMessageDeltaEvent { delta, .. }) => {
|
||||
if self.answer_buffer.is_empty() {
|
||||
self.conversation_history
|
||||
.add_agent_message(&self.config, "".to_string());
|
||||
@@ -269,7 +269,7 @@ impl ChatWidget<'_> {
|
||||
.replace_prev_agent_message(&self.config, self.answer_buffer.clone());
|
||||
self.request_redraw();
|
||||
}
|
||||
- EventMsg::AgentReasoningDelta(AgentReasoningDeltaEvent { delta }) => {
|
||||
+ EventMsg::AgentReasoningDelta(AgentReasoningDeltaEvent { delta, .. }) => {
|
||||
if self.reasoning_buffer.is_empty() {
|
||||
self.conversation_history
|
||||
.add_agent_reasoning(&self.config, "".to_string());
|
||||
@@ -279,7 +279,7 @@ impl ChatWidget<'_> {
|
||||
.replace_prev_agent_reasoning(&self.config, self.reasoning_buffer.clone());
|
||||
self.request_redraw();
|
||||
}
|
||||
- EventMsg::AgentReasoning(AgentReasoningEvent { text }) => {
|
||||
+ EventMsg::AgentReasoning(AgentReasoningEvent { id: _, text }) => {
|
||||
// if the reasoning buffer is empty, this means we haven't received any
|
||||
// delta. Thus, we need to print the message as a new reasoning.
|
||||
if self.reasoning_buffer.is_empty() {
|
||||
```
|
||||
|
||||
## Review Comments
|
||||
|
||||
### codex-rs/core/src/codex.rs
|
||||
|
||||
- Created: 2025-07-23 21:06:21 UTC | Link: https://github.com/openai/codex/pull/1661#discussion_r2226689096
|
||||
|
||||
```diff
|
||||
@@ -1273,26 +1273,32 @@ async fn handle_response_item(
|
||||
) -> CodexResult<Option<ResponseInputItem>> {
|
||||
debug!(?item, "Output item");
|
||||
let output = match item {
|
||||
- ResponseItem::Message { content, .. } => {
|
||||
+ ResponseItem::Message { content, id, .. } => {
|
||||
for item in content {
|
||||
if let ContentItem::OutputText { text } = item {
|
||||
let event = Event {
|
||||
id: sub_id.to_string(),
|
||||
- msg: EventMsg::AgentMessage(AgentMessageEvent { message: text }),
|
||||
+ msg: EventMsg::AgentMessage(AgentMessageEvent {
|
||||
+ id: id.clone(),
|
||||
+ message: text,
|
||||
+ }),
|
||||
};
|
||||
sess.tx_event.send(event).await.ok();
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
- ResponseItem::Reasoning { id: _, summary } => {
|
||||
+ ResponseItem::Reasoning { id, summary } => {
|
||||
for item in summary {
|
||||
let text = match item {
|
||||
ReasoningItemReasoningSummary::SummaryText { text } => text,
|
||||
};
|
||||
let event = Event {
|
||||
id: sub_id.to_string(),
|
||||
- msg: EventMsg::AgentReasoning(AgentReasoningEvent { text }),
|
||||
+ msg: EventMsg::AgentReasoning(AgentReasoningEvent {
|
||||
```
|
||||
|
||||
> Why can't you use the `id` on `Event`?
|
||||
135
prs/bolinfest/PR-1664.md
Normal file
135
prs/bolinfest/PR-1664.md
Normal file
@@ -0,0 +1,135 @@
|
||||
# PR #1664: Fix flaky test
|
||||
|
||||
- URL: https://github.com/openai/codex/pull/1664
|
||||
- Author: gpeal
|
||||
- Created: 2025-07-23 22:25:20 UTC
|
||||
- Updated: 2025-07-23 22:40:10 UTC
|
||||
- Changes: +21/-19, Files changed: 3, Commits: 2
|
||||
|
||||
## Description
|
||||
|
||||
(No description.)
|
||||
|
||||
## Full Diff
|
||||
|
||||
```diff
|
||||
diff --git a/codex-rs/chatgpt/src/apply_command.rs b/codex-rs/chatgpt/src/apply_command.rs
|
||||
index 4209d958e1..52ab205a0c 100644
|
||||
--- a/codex-rs/chatgpt/src/apply_command.rs
|
||||
+++ b/codex-rs/chatgpt/src/apply_command.rs
|
||||
@@ -1,3 +1,5 @@
|
||||
+use std::path::PathBuf;
|
||||
+
|
||||
use clap::Parser;
|
||||
use codex_common::CliConfigOverrides;
|
||||
use codex_core::config::Config;
|
||||
@@ -17,7 +19,10 @@ pub struct ApplyCommand {
|
||||
#[clap(flatten)]
|
||||
pub config_overrides: CliConfigOverrides,
|
||||
}
|
||||
-pub async fn run_apply_command(apply_cli: ApplyCommand) -> anyhow::Result<()> {
|
||||
+pub async fn run_apply_command(
|
||||
+ apply_cli: ApplyCommand,
|
||||
+ cwd: Option<PathBuf>,
|
||||
+) -> anyhow::Result<()> {
|
||||
let config = Config::load_with_cli_overrides(
|
||||
apply_cli
|
||||
.config_overrides
|
||||
@@ -29,10 +34,13 @@ pub async fn run_apply_command(apply_cli: ApplyCommand) -> anyhow::Result<()> {
|
||||
init_chatgpt_token_from_auth(&config.codex_home).await?;
|
||||
|
||||
let task_response = get_task(&config, apply_cli.task_id).await?;
|
||||
- apply_diff_from_task(task_response).await
|
||||
+ apply_diff_from_task(task_response, cwd).await
|
||||
}
|
||||
|
||||
-pub async fn apply_diff_from_task(task_response: GetTaskResponse) -> anyhow::Result<()> {
|
||||
+pub async fn apply_diff_from_task(
|
||||
+ task_response: GetTaskResponse,
|
||||
+ cwd: Option<PathBuf>,
|
||||
+) -> anyhow::Result<()> {
|
||||
let diff_turn = match task_response.current_diff_task_turn {
|
||||
Some(turn) => turn,
|
||||
None => anyhow::bail!("No diff turn found"),
|
||||
@@ -42,13 +50,17 @@ pub async fn apply_diff_from_task(task_response: GetTaskResponse) -> anyhow::Res
|
||||
_ => None,
|
||||
});
|
||||
match output_diff {
|
||||
- Some(output_diff) => apply_diff(&output_diff.diff).await,
|
||||
+ Some(output_diff) => apply_diff(&output_diff.diff, cwd).await,
|
||||
None => anyhow::bail!("No PR output item found"),
|
||||
}
|
||||
}
|
||||
|
||||
-async fn apply_diff(diff: &str) -> anyhow::Result<()> {
|
||||
- let toplevel_output = tokio::process::Command::new("git")
|
||||
+async fn apply_diff(diff: &str, cwd: Option<PathBuf>) -> anyhow::Result<()> {
|
||||
+ let mut cmd = tokio::process::Command::new("git");
|
||||
+ if let Some(cwd) = cwd {
|
||||
+ cmd.current_dir(cwd);
|
||||
+ }
|
||||
+ let toplevel_output = cmd
|
||||
.args(vec!["rev-parse", "--show-toplevel"])
|
||||
.output()
|
||||
.await?;
|
||||
diff --git a/codex-rs/chatgpt/tests/apply_command_e2e.rs b/codex-rs/chatgpt/tests/apply_command_e2e.rs
|
||||
index e395e4f155..45c33bedb4 100644
|
||||
--- a/codex-rs/chatgpt/tests/apply_command_e2e.rs
|
||||
+++ b/codex-rs/chatgpt/tests/apply_command_e2e.rs
|
||||
@@ -78,17 +78,7 @@ async fn test_apply_command_creates_fibonacci_file() {
|
||||
.await
|
||||
.expect("Failed to load fixture");
|
||||
|
||||
- let original_dir = std::env::current_dir().expect("Failed to get current dir");
|
||||
- std::env::set_current_dir(repo_path).expect("Failed to change directory");
|
||||
- struct DirGuard(std::path::PathBuf);
|
||||
- impl Drop for DirGuard {
|
||||
- fn drop(&mut self) {
|
||||
- let _ = std::env::set_current_dir(&self.0);
|
||||
- }
|
||||
- }
|
||||
- let _guard = DirGuard(original_dir);
|
||||
-
|
||||
- apply_diff_from_task(task_response)
|
||||
+ apply_diff_from_task(task_response, Some(repo_path.to_path_buf()))
|
||||
.await
|
||||
.expect("Failed to apply diff from task");
|
||||
|
||||
@@ -173,7 +163,7 @@ console.log(fib(10));
|
||||
.await
|
||||
.expect("Failed to load fixture");
|
||||
|
||||
- let apply_result = apply_diff_from_task(task_response).await;
|
||||
+ let apply_result = apply_diff_from_task(task_response, Some(repo_path.to_path_buf())).await;
|
||||
|
||||
assert!(
|
||||
apply_result.is_err(),
|
||||
diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs
|
||||
index 7e23782d75..e397b0ca6a 100644
|
||||
--- a/codex-rs/cli/src/main.rs
|
||||
+++ b/codex-rs/cli/src/main.rs
|
||||
@@ -145,7 +145,7 @@ async fn cli_main(codex_linux_sandbox_exe: Option<PathBuf>) -> anyhow::Result<()
|
||||
},
|
||||
Some(Subcommand::Apply(mut apply_cli)) => {
|
||||
prepend_config_flags(&mut apply_cli.config_overrides, cli.config_overrides);
|
||||
- run_apply_command(apply_cli).await?;
|
||||
+ run_apply_command(apply_cli, None).await?;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Review Comments
|
||||
|
||||
### codex-rs/chatgpt/tests/apply_command_e2e.rs
|
||||
|
||||
- Created: 2025-07-23 22:29:13 UTC | Link: https://github.com/openai/codex/pull/1664#discussion_r2226856589
|
||||
|
||||
```diff
|
||||
@@ -78,17 +78,7 @@ async fn test_apply_command_creates_fibonacci_file() {
|
||||
.await
|
||||
.expect("Failed to load fixture");
|
||||
|
||||
- let original_dir = std::env::current_dir().expect("Failed to get current dir");
|
||||
```
|
||||
|
||||
> 🙏
|
||||
571
prs/bolinfest/PR-1668.md
Normal file
571
prs/bolinfest/PR-1668.md
Normal file
@@ -0,0 +1,571 @@
|
||||
# PR #1668: feat: expand the set of commands that can be safely identified as "trusted"
|
||||
|
||||
- URL: https://github.com/openai/codex/pull/1668
|
||||
- Author: bolinfest
|
||||
- Created: 2025-07-24 05:57:54 UTC
|
||||
- Updated: 2025-07-24 21:13:37 UTC
|
||||
- Changes: +291/-132, Files changed: 3, Commits: 1
|
||||
|
||||
## Description
|
||||
|
||||
This PR updates `is_known_safe_command()` to account for "safe operators" to expand the set of commands that can be run without approval. This concept existed in the TypeScript CLI, and we are [finally!] porting it to the Rust one:
|
||||
|
||||
https://github.com/openai/codex/blob/c9e2def49487585cfe6f8bb7b2be442e8c0b5e1b/codex-cli/src/approvals.ts#L531-L541
|
||||
|
||||
The idea is that if we have `EXPR1 SAFE_OP EXPR2` and `EXPR1` and `EXPR2` are considered safe independently, then `EXPR1 SAFE_OP EXPR2` should be considered safe. Currently, `SAFE_OP` includes `&&`, `||`, `;`, and `|`.
|
||||
|
||||
In the TypeScript implementation, we relied on https://www.npmjs.com/package/shell-quote to parse the string of Bash, as it could provide a "lightweight" parse tree, parsing `'beep || boop > /byte'` as:
|
||||
|
||||
```
|
||||
[ 'beep', { op: '||' }, 'boop', { op: '>' }, '/byte' ]
|
||||
```
|
||||
|
||||
Though in this PR, we introduce the use of https://crates.io/crates/tree-sitter-bash for parsing (which incidentally we were already using in [`codex-apply-patch`](https://github.com/openai/codex/blob/c9e2def49487585cfe6f8bb7b2be442e8c0b5e1b/codex-rs/apply-patch/Cargo.toml#L18)), which gives us a richer parse tree. (Incidentally, if you have never played with tree-sitter, try the [playground](https://tree-sitter.github.io/tree-sitter/7-playground.html) and select **Bash** from the dropdown to see how it parses various expressions.)
|
||||
|
||||
As a concrete example, prior to this change, our implementation of `is_known_safe_command()` could verify things like:
|
||||
|
||||
```
|
||||
["bash", "-lc", "grep -R \"Cargo.toml\" -n"]
|
||||
```
|
||||
|
||||
but not:
|
||||
|
||||
```
|
||||
["bash", "-lc", "grep -R \"Cargo.toml\" -n || true"]
|
||||
```
|
||||
|
||||
With this change, the version with `|| true` is also accepted.
|
||||
|
||||
Admittedly, this PR does not expand the safety check to support subshells, so it would reject, e.g. `bash -lc 'ls || (pwd && echo hi)'`, but that can be addressed in a subsequent PR.
|
||||
|
||||
## Full Diff
|
||||
|
||||
```diff
|
||||
diff --git a/codex-rs/core/src/bash.rs b/codex-rs/core/src/bash.rs
|
||||
new file mode 100644
|
||||
index 0000000000..b9cd444356
|
||||
--- /dev/null
|
||||
+++ b/codex-rs/core/src/bash.rs
|
||||
@@ -0,0 +1,219 @@
|
||||
+use tree_sitter::Parser;
|
||||
+use tree_sitter::Tree;
|
||||
+use tree_sitter_bash::LANGUAGE as BASH;
|
||||
+
|
||||
+/// Parse the provided bash source using tree-sitter-bash, returning a Tree on
|
||||
+/// success or None if parsing failed.
|
||||
+pub fn try_parse_bash(bash_lc_arg: &str) -> Option<Tree> {
|
||||
+ let lang = BASH.into();
|
||||
+ let mut parser = Parser::new();
|
||||
+ #[expect(clippy::expect_used)]
|
||||
+ parser.set_language(&lang).expect("load bash grammar");
|
||||
+ let old_tree: Option<&Tree> = None;
|
||||
+ parser.parse(bash_lc_arg, old_tree)
|
||||
+}
|
||||
+
|
||||
+/// Parse a script which may contain multiple simple commands joined only by
|
||||
+/// the safe logical/pipe/sequencing operators: `&&`, `||`, `;`, `|`.
|
||||
+///
|
||||
+/// Returns `Some(Vec<command_words>)` if every command is a plain word‑only
|
||||
+/// command and the parse tree does not contain disallowed constructs
|
||||
+/// (parentheses, redirections, substitutions, control flow, etc.). Otherwise
|
||||
+/// returns `None`.
|
||||
+pub fn try_parse_word_only_commands_sequence(tree: &Tree, src: &str) -> Option<Vec<Vec<String>>> {
|
||||
+ if tree.root_node().has_error() {
|
||||
+ return None;
|
||||
+ }
|
||||
+
|
||||
+ // List of allowed (named) node kinds for a "word only commands sequence".
|
||||
+ // If we encounter a named node that is not in this list we reject.
|
||||
+ const ALLOWED_KINDS: &[&str] = &[
|
||||
+ // top level containers
|
||||
+ "program",
|
||||
+ "list",
|
||||
+ "pipeline",
|
||||
+ // commands & words
|
||||
+ "command",
|
||||
+ "command_name",
|
||||
+ "word",
|
||||
+ "string",
|
||||
+ "string_content",
|
||||
+ "raw_string",
|
||||
+ "number",
|
||||
+ ];
|
||||
+ // Allow only safe punctuation / operator tokens; anything else causes reject.
|
||||
+ const ALLOWED_PUNCT_TOKENS: &[&str] = &["&&", "||", ";", "|", "\"", "'"];
|
||||
+
|
||||
+ let root = tree.root_node();
|
||||
+ let mut cursor = root.walk();
|
||||
+ let mut stack = vec![root];
|
||||
+ let mut command_nodes = Vec::new();
|
||||
+ while let Some(node) = stack.pop() {
|
||||
+ let kind = node.kind();
|
||||
+ if node.is_named() {
|
||||
+ if !ALLOWED_KINDS.contains(&kind) {
|
||||
+ return None;
|
||||
+ }
|
||||
+ if kind == "command" {
|
||||
+ command_nodes.push(node);
|
||||
+ }
|
||||
+ } else {
|
||||
+ // Reject any punctuation / operator tokens that are not explicitly allowed.
|
||||
+ if kind.chars().any(|c| "&;|".contains(c)) && !ALLOWED_PUNCT_TOKENS.contains(&kind) {
|
||||
+ return None;
|
||||
+ }
|
||||
+ if !(ALLOWED_PUNCT_TOKENS.contains(&kind) || kind.trim().is_empty()) {
|
||||
+ // If it's a quote token or operator it's allowed above; we also allow whitespace tokens.
|
||||
+ // Any other punctuation like parentheses, braces, redirects, backticks, etc are rejected.
|
||||
+ return None;
|
||||
+ }
|
||||
+ }
|
||||
+ for child in node.children(&mut cursor) {
|
||||
+ stack.push(child);
|
||||
+ }
|
||||
+ }
|
||||
+
|
||||
+ let mut commands = Vec::new();
|
||||
+ for node in command_nodes {
|
||||
+ if let Some(words) = parse_plain_command_from_node(node, src) {
|
||||
+ commands.push(words);
|
||||
+ } else {
|
||||
+ return None;
|
||||
+ }
|
||||
+ }
|
||||
+ Some(commands)
|
||||
+}
|
||||
+
|
||||
+fn parse_plain_command_from_node(cmd: tree_sitter::Node, src: &str) -> Option<Vec<String>> {
|
||||
+ if cmd.kind() != "command" {
|
||||
+ return None;
|
||||
+ }
|
||||
+ let mut words = Vec::new();
|
||||
+ let mut cursor = cmd.walk();
|
||||
+ for child in cmd.named_children(&mut cursor) {
|
||||
+ match child.kind() {
|
||||
+ "command_name" => {
|
||||
+ let word_node = child.named_child(0)?;
|
||||
+ if word_node.kind() != "word" {
|
||||
+ return None;
|
||||
+ }
|
||||
+ words.push(word_node.utf8_text(src.as_bytes()).ok()?.to_owned());
|
||||
+ }
|
||||
+ "word" | "number" => {
|
||||
+ words.push(child.utf8_text(src.as_bytes()).ok()?.to_owned());
|
||||
+ }
|
||||
+ "string" => {
|
||||
+ if child.child_count() == 3
|
||||
+ && child.child(0)?.kind() == "\""
|
||||
+ && child.child(1)?.kind() == "string_content"
|
||||
+ && child.child(2)?.kind() == "\""
|
||||
+ {
|
||||
+ words.push(child.child(1)?.utf8_text(src.as_bytes()).ok()?.to_owned());
|
||||
+ } else {
|
||||
+ return None;
|
||||
+ }
|
||||
+ }
|
||||
+ "raw_string" => {
|
||||
+ let raw_string = child.utf8_text(src.as_bytes()).ok()?;
|
||||
+ let stripped = raw_string
|
||||
+ .strip_prefix('\'')
|
||||
+ .and_then(|s| s.strip_suffix('\''));
|
||||
+ if let Some(s) = stripped {
|
||||
+ words.push(s.to_owned());
|
||||
+ } else {
|
||||
+ return None;
|
||||
+ }
|
||||
+ }
|
||||
+ _ => return None,
|
||||
+ }
|
||||
+ }
|
||||
+ Some(words)
|
||||
+}
|
||||
+
|
||||
+#[cfg(test)]
|
||||
+mod tests {
|
||||
+ #![allow(clippy::unwrap_used)]
|
||||
+ use super::*;
|
||||
+
|
||||
+ fn parse_seq(src: &str) -> Option<Vec<Vec<String>>> {
|
||||
+ let tree = try_parse_bash(src)?;
|
||||
+ try_parse_word_only_commands_sequence(&tree, src)
|
||||
+ }
|
||||
+
|
||||
+ #[test]
|
||||
+ fn accepts_single_simple_command() {
|
||||
+ let cmds = parse_seq("ls -1").unwrap();
|
||||
+ assert_eq!(cmds, vec![vec!["ls".to_string(), "-1".to_string()]]);
|
||||
+ }
|
||||
+
|
||||
+ #[test]
|
||||
+ fn accepts_multiple_commands_with_allowed_operators() {
|
||||
+ let src = "ls && pwd; echo 'hi there' | wc -l";
|
||||
+ let cmds = parse_seq(src).unwrap();
|
||||
+ let expected: Vec<Vec<String>> = vec![
|
||||
+ vec!["wc".to_string(), "-l".to_string()],
|
||||
+ vec!["echo".to_string(), "hi there".to_string()],
|
||||
+ vec!["pwd".to_string()],
|
||||
+ vec!["ls".to_string()],
|
||||
+ ];
|
||||
+ assert_eq!(cmds, expected);
|
||||
+ }
|
||||
+
|
||||
+ #[test]
|
||||
+ fn extracts_double_and_single_quoted_strings() {
|
||||
+ let cmds = parse_seq("echo \"hello world\"").unwrap();
|
||||
+ assert_eq!(
|
||||
+ cmds,
|
||||
+ vec![vec!["echo".to_string(), "hello world".to_string()]]
|
||||
+ );
|
||||
+
|
||||
+ let cmds2 = parse_seq("echo 'hi there'").unwrap();
|
||||
+ assert_eq!(
|
||||
+ cmds2,
|
||||
+ vec![vec!["echo".to_string(), "hi there".to_string()]]
|
||||
+ );
|
||||
+ }
|
||||
+
|
||||
+ #[test]
|
||||
+ fn accepts_numbers_as_words() {
|
||||
+ let cmds = parse_seq("echo 123 456").unwrap();
|
||||
+ assert_eq!(
|
||||
+ cmds,
|
||||
+ vec![vec![
|
||||
+ "echo".to_string(),
|
||||
+ "123".to_string(),
|
||||
+ "456".to_string()
|
||||
+ ]]
|
||||
+ );
|
||||
+ }
|
||||
+
|
||||
+ #[test]
|
||||
+ fn rejects_parentheses_and_subshells() {
|
||||
+ assert!(parse_seq("(ls)").is_none());
|
||||
+ assert!(parse_seq("ls || (pwd && echo hi)").is_none());
|
||||
+ }
|
||||
+
|
||||
+ #[test]
|
||||
+ fn rejects_redirections_and_unsupported_operators() {
|
||||
+ assert!(parse_seq("ls > out.txt").is_none());
|
||||
+ assert!(parse_seq("echo hi & echo bye").is_none());
|
||||
+ }
|
||||
+
|
||||
+ #[test]
|
||||
+ fn rejects_command_and_process_substitutions_and_expansions() {
|
||||
+ assert!(parse_seq("echo $(pwd)").is_none());
|
||||
+ assert!(parse_seq("echo `pwd`").is_none());
|
||||
+ assert!(parse_seq("echo $HOME").is_none());
|
||||
+ assert!(parse_seq("echo \"hi $USER\"").is_none());
|
||||
+ }
|
||||
+
|
||||
+ #[test]
|
||||
+ fn rejects_variable_assignment_prefix() {
|
||||
+ assert!(parse_seq("FOO=bar ls").is_none());
|
||||
+ }
|
||||
+
|
||||
+ #[test]
|
||||
+ fn rejects_trailing_operator_parse_error() {
|
||||
+ assert!(parse_seq("ls &&").is_none());
|
||||
+ }
|
||||
+}
|
||||
diff --git a/codex-rs/core/src/is_safe_command.rs b/codex-rs/core/src/is_safe_command.rs
|
||||
index 493650a4b5..f5f453f8d8 100644
|
||||
--- a/codex-rs/core/src/is_safe_command.rs
|
||||
+++ b/codex-rs/core/src/is_safe_command.rs
|
||||
@@ -1,22 +1,34 @@
|
||||
-use tree_sitter::Parser;
|
||||
-use tree_sitter::Tree;
|
||||
-use tree_sitter_bash::LANGUAGE as BASH;
|
||||
+use crate::bash::try_parse_bash;
|
||||
+use crate::bash::try_parse_word_only_commands_sequence;
|
||||
|
||||
pub fn is_known_safe_command(command: &[String]) -> bool {
|
||||
if is_safe_to_call_with_exec(command) {
|
||||
return true;
|
||||
}
|
||||
|
||||
- // TODO(mbolin): Also support safe commands that are piped together such
|
||||
- // as `cat foo | wc -l`.
|
||||
- matches!(
|
||||
- command,
|
||||
- [bash, flag, script]
|
||||
- if bash == "bash"
|
||||
- && flag == "-lc"
|
||||
- && try_parse_bash(script).and_then(|tree|
|
||||
- try_parse_single_word_only_command(&tree, script)).is_some_and(|parsed_bash_command| is_safe_to_call_with_exec(&parsed_bash_command))
|
||||
- )
|
||||
+ // Support `bash -lc "..."` where the script consists solely of one or
|
||||
+ // more "plain" commands (only bare words / quoted strings) combined with
|
||||
+ // a conservative allow‑list of shell operators that themselves do not
|
||||
+ // introduce side effects ( "&&", "||", ";", and "|" ). If every
|
||||
+ // individual command in the script is itself a known‑safe command, then
|
||||
+ // the composite expression is considered safe.
|
||||
+ if let [bash, flag, script] = command {
|
||||
+ if bash == "bash" && flag == "-lc" {
|
||||
+ if let Some(tree) = try_parse_bash(script) {
|
||||
+ if let Some(all_commands) = try_parse_word_only_commands_sequence(&tree, script) {
|
||||
+ if !all_commands.is_empty()
|
||||
+ && all_commands
|
||||
+ .iter()
|
||||
+ .all(|cmd| is_safe_to_call_with_exec(cmd))
|
||||
+ {
|
||||
+ return true;
|
||||
+ }
|
||||
+ }
|
||||
+ }
|
||||
+ }
|
||||
+ }
|
||||
+
|
||||
+ false
|
||||
}
|
||||
|
||||
fn is_safe_to_call_with_exec(command: &[String]) -> bool {
|
||||
@@ -109,90 +121,7 @@ fn is_safe_to_call_with_exec(command: &[String]) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
-fn try_parse_bash(bash_lc_arg: &str) -> Option<Tree> {
|
||||
- let lang = BASH.into();
|
||||
- let mut parser = Parser::new();
|
||||
- #[expect(clippy::expect_used)]
|
||||
- parser.set_language(&lang).expect("load bash grammar");
|
||||
-
|
||||
- let old_tree: Option<&Tree> = None;
|
||||
- parser.parse(bash_lc_arg, old_tree)
|
||||
-}
|
||||
-
|
||||
-/// If `tree` represents a single Bash command whose name and every argument is
|
||||
-/// an ordinary `word`, return those words in order; otherwise, return `None`.
|
||||
-///
|
||||
-/// `src` must be the exact source string that was parsed into `tree`, so we can
|
||||
-/// extract the text for every node.
|
||||
-pub fn try_parse_single_word_only_command(tree: &Tree, src: &str) -> Option<Vec<String>> {
|
||||
- // Any parse error is an immediate rejection.
|
||||
- if tree.root_node().has_error() {
|
||||
- return None;
|
||||
- }
|
||||
-
|
||||
- // (program …) with exactly one statement
|
||||
- let root = tree.root_node();
|
||||
- if root.kind() != "program" || root.named_child_count() != 1 {
|
||||
- return None;
|
||||
- }
|
||||
-
|
||||
- let cmd = root.named_child(0)?; // (command …)
|
||||
- if cmd.kind() != "command" {
|
||||
- return None;
|
||||
- }
|
||||
-
|
||||
- let mut words = Vec::new();
|
||||
- let mut cursor = cmd.walk();
|
||||
-
|
||||
- for child in cmd.named_children(&mut cursor) {
|
||||
- match child.kind() {
|
||||
- // The command name node wraps one `word` child.
|
||||
- "command_name" => {
|
||||
- let word_node = child.named_child(0)?; // make sure it's only a word
|
||||
- if word_node.kind() != "word" {
|
||||
- return None;
|
||||
- }
|
||||
- words.push(word_node.utf8_text(src.as_bytes()).ok()?.to_owned());
|
||||
- }
|
||||
- // Positional‑argument word (allowed).
|
||||
- "word" | "number" => {
|
||||
- words.push(child.utf8_text(src.as_bytes()).ok()?.to_owned());
|
||||
- }
|
||||
- "string" => {
|
||||
- if child.child_count() == 3
|
||||
- && child.child(0)?.kind() == "\""
|
||||
- && child.child(1)?.kind() == "string_content"
|
||||
- && child.child(2)?.kind() == "\""
|
||||
- {
|
||||
- words.push(child.child(1)?.utf8_text(src.as_bytes()).ok()?.to_owned());
|
||||
- } else {
|
||||
- // Anything else means the command is *not* plain words.
|
||||
- return None;
|
||||
- }
|
||||
- }
|
||||
- "concatenation" => {
|
||||
- // TODO: Consider things like `'ab\'a'`.
|
||||
- return None;
|
||||
- }
|
||||
- "raw_string" => {
|
||||
- // Raw string is a single word, but we need to strip the quotes.
|
||||
- let raw_string = child.utf8_text(src.as_bytes()).ok()?;
|
||||
- let stripped = raw_string
|
||||
- .strip_prefix('\'')
|
||||
- .and_then(|s| s.strip_suffix('\''));
|
||||
- if let Some(stripped) = stripped {
|
||||
- words.push(stripped.to_owned());
|
||||
- } else {
|
||||
- return None;
|
||||
- }
|
||||
- }
|
||||
- // Anything else means the command is *not* plain words.
|
||||
- _ => return None,
|
||||
- }
|
||||
- }
|
||||
-
|
||||
- Some(words)
|
||||
-}
|
||||
+// (bash parsing helpers implemented in crate::bash)
|
||||
|
||||
/* ----------------------------------------------------------
|
||||
Example
|
||||
@@ -230,6 +159,7 @@ fn is_valid_sed_n_arg(arg: Option<&str>) -> bool {
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
+
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#![allow(clippy::unwrap_used)]
|
||||
@@ -353,6 +283,30 @@ mod tests {
|
||||
])));
|
||||
}
|
||||
|
||||
+ #[test]
|
||||
+ fn bash_lc_safe_examples_with_operators() {
|
||||
+ assert!(is_known_safe_command(&vec_str(&[
|
||||
+ "bash",
|
||||
+ "-lc",
|
||||
+ "grep -R \"Cargo.toml\" -n || true"
|
||||
+ ])));
|
||||
+ assert!(is_known_safe_command(&vec_str(&[
|
||||
+ "bash",
|
||||
+ "-lc",
|
||||
+ "ls && pwd"
|
||||
+ ])));
|
||||
+ assert!(is_known_safe_command(&vec_str(&[
|
||||
+ "bash",
|
||||
+ "-lc",
|
||||
+ "echo 'hi' ; ls"
|
||||
+ ])));
|
||||
+ assert!(is_known_safe_command(&vec_str(&[
|
||||
+ "bash",
|
||||
+ "-lc",
|
||||
+ "ls | wc -l"
|
||||
+ ])));
|
||||
+ }
|
||||
+
|
||||
#[test]
|
||||
fn bash_lc_unsafe_examples() {
|
||||
assert!(
|
||||
@@ -366,44 +320,29 @@ mod tests {
|
||||
|
||||
assert!(
|
||||
!is_known_safe_command(&vec_str(&["bash", "-lc", "find . -name file.txt -delete"])),
|
||||
- "Unsafe find option should not be auto‑approved."
|
||||
+ "Unsafe find option should not be auto-approved."
|
||||
);
|
||||
- }
|
||||
|
||||
- #[test]
|
||||
- fn test_try_parse_single_word_only_command() {
|
||||
- let script_with_single_quoted_string = "sed -n '1,5p' file.txt";
|
||||
- let parsed_words = try_parse_bash(script_with_single_quoted_string)
|
||||
- .and_then(|tree| {
|
||||
- try_parse_single_word_only_command(&tree, script_with_single_quoted_string)
|
||||
- })
|
||||
- .unwrap();
|
||||
- assert_eq!(
|
||||
- vec![
|
||||
- "sed".to_string(),
|
||||
- "-n".to_string(),
|
||||
- // Ensure the single quotes are properly removed.
|
||||
- "1,5p".to_string(),
|
||||
- "file.txt".to_string()
|
||||
- ],
|
||||
- parsed_words,
|
||||
+ // Disallowed because of unsafe command in sequence.
|
||||
+ assert!(
|
||||
+ !is_known_safe_command(&vec_str(&["bash", "-lc", "ls && rm -rf /"])),
|
||||
+ "Sequence containing unsafe command must be rejected"
|
||||
);
|
||||
|
||||
- let script_with_number_arg = "ls -1";
|
||||
- let parsed_words = try_parse_bash(script_with_number_arg)
|
||||
- .and_then(|tree| try_parse_single_word_only_command(&tree, script_with_number_arg))
|
||||
- .unwrap();
|
||||
- assert_eq!(vec!["ls", "-1"], parsed_words,);
|
||||
-
|
||||
- let script_with_double_quoted_string_with_no_funny_stuff_arg = "grep -R \"Cargo.toml\" -n";
|
||||
- let parsed_words = try_parse_bash(script_with_double_quoted_string_with_no_funny_stuff_arg)
|
||||
- .and_then(|tree| {
|
||||
- try_parse_single_word_only_command(
|
||||
- &tree,
|
||||
- script_with_double_quoted_string_with_no_funny_stuff_arg,
|
||||
- )
|
||||
- })
|
||||
- .unwrap();
|
||||
- assert_eq!(vec!["grep", "-R", "Cargo.toml", "-n"], parsed_words);
|
||||
+ // Disallowed because of parentheses / subshell.
|
||||
+ assert!(
|
||||
+ !is_known_safe_command(&vec_str(&["bash", "-lc", "(ls)"])),
|
||||
+ "Parentheses (subshell) are not provably safe with the current parser"
|
||||
+ );
|
||||
+ assert!(
|
||||
+ !is_known_safe_command(&vec_str(&["bash", "-lc", "ls || (pwd && echo hi)"])),
|
||||
+ "Nested parentheses are not provably safe with the current parser"
|
||||
+ );
|
||||
+
|
||||
+ // Disallowed redirection.
|
||||
+ assert!(
|
||||
+ !is_known_safe_command(&vec_str(&["bash", "-lc", "ls > out.txt"])),
|
||||
+ "> redirection should be rejected"
|
||||
+ );
|
||||
}
|
||||
}
|
||||
diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs
|
||||
index 4e69e94b55..12321e0abc 100644
|
||||
--- a/codex-rs/core/src/lib.rs
|
||||
+++ b/codex-rs/core/src/lib.rs
|
||||
@@ -5,6 +5,7 @@
|
||||
// the TUI or the tracing stack).
|
||||
#![deny(clippy::print_stdout, clippy::print_stderr)]
|
||||
|
||||
+mod bash;
|
||||
mod chat_completions;
|
||||
mod client;
|
||||
mod client_common;
|
||||
```
|
||||
|
||||
## Review Comments
|
||||
|
||||
### codex-rs/core/src/bash.rs
|
||||
|
||||
- Created: 2025-07-24 21:05:51 UTC | Link: https://github.com/openai/codex/pull/1668#discussion_r2229574980
|
||||
|
||||
```diff
|
||||
@@ -0,0 +1,219 @@
|
||||
+use tree_sitter::Parser;
|
||||
+use tree_sitter::Tree;
|
||||
+use tree_sitter_bash::LANGUAGE as BASH;
|
||||
+
|
||||
+/// Parse the provided bash source using tree-sitter-bash, returning a Tree on
|
||||
+/// success or None if parsing failed.
|
||||
+pub fn try_parse_bash(bash_lc_arg: &str) -> Option<Tree> {
|
||||
+ let lang = BASH.into();
|
||||
+ let mut parser = Parser::new();
|
||||
+ #[expect(clippy::expect_used)]
|
||||
+ parser.set_language(&lang).expect("load bash grammar");
|
||||
+ let old_tree: Option<&Tree> = None;
|
||||
+ parser.parse(bash_lc_arg, old_tree)
|
||||
+}
|
||||
+
|
||||
+/// Parse a script which may contain multiple simple commands joined only by
|
||||
+/// the safe logical/pipe/sequencing operators: `&&`, `||`, `;`, `|`.
|
||||
+///
|
||||
+/// Returns `Some(Vec<command_words>)` if every command is a plain word‑only
|
||||
+/// command and the parse tree does not contain disallowed constructs
|
||||
+/// (parentheses, redirections, substitutions, control flow, etc.). Otherwise
|
||||
+/// returns `None`.
|
||||
+pub fn try_parse_word_only_commands_sequence(tree: &Tree, src: &str) -> Option<Vec<Vec<String>>> {
|
||||
+ if tree.root_node().has_error() {
|
||||
+ return None;
|
||||
+ }
|
||||
+
|
||||
+ // List of allowed (named) node kinds for a "word only commands sequence".
|
||||
+ // If we encounter a named node that is not in this list we reject.
|
||||
+ const ALLOWED_KINDS: &[&str] = &[
|
||||
```
|
||||
|
||||
> The tree sitter API returns a `String` from `node.kind()`, so it's not our API to control, unfortunately.
|
||||
1742
prs/bolinfest/PR-1672.md
Normal file
1742
prs/bolinfest/PR-1672.md
Normal file
File diff suppressed because it is too large
Load Diff
93
prs/bolinfest/PR-1675.md
Normal file
93
prs/bolinfest/PR-1675.md
Normal file
@@ -0,0 +1,93 @@
|
||||
# PR #1675: Update render name in tui for approval_policy to match with config values
|
||||
|
||||
- URL: https://github.com/openai/codex/pull/1675
|
||||
- Author: pbezglasny
|
||||
- Created: 2025-07-24 19:41:10 UTC
|
||||
- Updated: 2025-07-24 21:18:05 UTC
|
||||
- Changes: +6/-3, Files changed: 3, Commits: 4
|
||||
|
||||
## Description
|
||||
|
||||
Currently, codex on start shows the value for the approval policy as name of [AskForApproval](https://github.com/openai/codex/blob/2437a8d17a0cf972d1a6e7f303d469b6e2f57eae/codex-rs/core/src/protocol.rs#L128) enum, which differs from [approval_policy](https://github.com/openai/codex/blob/2437a8d17a0cf972d1a6e7f303d469b6e2f57eae/codex-rs/config.md#approval_policy) config values.
|
||||
E.g. "untrusted" becomes "UnlessTrusted", "on-failure" -> "OnFailure", "never" -> "Never".
|
||||
This PR changes render names of the approval policy to match with configuration values.
|
||||
|
||||
## Full Diff
|
||||
|
||||
```diff
|
||||
diff --git a/codex-rs/core/src/protocol.rs b/codex-rs/core/src/protocol.rs
|
||||
index 0c375e455d..3111b42292 100644
|
||||
--- a/codex-rs/core/src/protocol.rs
|
||||
+++ b/codex-rs/core/src/protocol.rs
|
||||
@@ -11,6 +11,7 @@ use std::str::FromStr;
|
||||
use mcp_types::CallToolResult;
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
+use strum_macros::Display;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::config_types::ReasoningEffort as ReasoningEffortConfig;
|
||||
@@ -123,14 +124,16 @@ pub enum Op {
|
||||
|
||||
/// Determines the conditions under which the user is consulted to approve
|
||||
/// running the command proposed by Codex.
|
||||
-#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
+#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize, Display)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
+#[strum(serialize_all = "kebab-case")]
|
||||
pub enum AskForApproval {
|
||||
/// Under this policy, only "known safe" commands—as determined by
|
||||
/// `is_safe_command()`—that **only read files** are auto‑approved.
|
||||
/// Everything else will ask the user to approve.
|
||||
#[default]
|
||||
#[serde(rename = "untrusted")]
|
||||
+ #[strum(serialize = "untrusted")]
|
||||
UnlessTrusted,
|
||||
|
||||
/// *All* commands are auto‑approved, but they are expected to run inside a
|
||||
diff --git a/codex-rs/exec/src/event_processor.rs b/codex-rs/exec/src/event_processor.rs
|
||||
index a7edb96af2..741f89d7cb 100644
|
||||
--- a/codex-rs/exec/src/event_processor.rs
|
||||
+++ b/codex-rs/exec/src/event_processor.rs
|
||||
@@ -25,7 +25,7 @@ pub(crate) fn create_config_summary_entries(config: &Config) -> Vec<(&'static st
|
||||
("workdir", config.cwd.display().to_string()),
|
||||
("model", config.model.clone()),
|
||||
("provider", config.model_provider_id.clone()),
|
||||
- ("approval", format!("{:?}", config.approval_policy)),
|
||||
+ ("approval", config.approval_policy.to_string()),
|
||||
("sandbox", summarize_sandbox_policy(&config.sandbox_policy)),
|
||||
];
|
||||
if config.model_provider.wire_api == WireApi::Responses
|
||||
diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs
|
||||
index b481313405..13bec71b46 100644
|
||||
--- a/codex-rs/tui/src/history_cell.rs
|
||||
+++ b/codex-rs/tui/src/history_cell.rs
|
||||
@@ -156,7 +156,7 @@ impl HistoryCell {
|
||||
("workdir", config.cwd.display().to_string()),
|
||||
("model", config.model.clone()),
|
||||
("provider", config.model_provider_id.clone()),
|
||||
- ("approval", format!("{:?}", config.approval_policy)),
|
||||
+ ("approval", config.approval_policy.to_string()),
|
||||
("sandbox", summarize_sandbox_policy(&config.sandbox_policy)),
|
||||
];
|
||||
if config.model_provider.wire_api == WireApi::Responses
|
||||
```
|
||||
|
||||
## Review Comments
|
||||
|
||||
### codex-rs/exec/src/event_processor.rs
|
||||
|
||||
- Created: 2025-07-24 20:31:09 UTC | Link: https://github.com/openai/codex/pull/1675#discussion_r2229515238
|
||||
|
||||
```diff
|
||||
@@ -25,7 +25,13 @@ pub(crate) fn create_config_summary_entries(config: &Config) -> Vec<(&'static st
|
||||
("workdir", config.cwd.display().to_string()),
|
||||
("model", config.model.clone()),
|
||||
("provider", config.model_provider_id.clone()),
|
||||
- ("approval", format!("{:?}", config.approval_policy)),
|
||||
+ (
|
||||
+ "approval",
|
||||
+ serde_json::to_string(&config.approval_policy)
|
||||
```
|
||||
|
||||
> I like this direction, but instead of this complexity, what about adding `#[strum(serialize_all = "kebab-case")]` to the `AskForApproval` enum?
|
||||
407
prs/bolinfest/PR-1677.md
Normal file
407
prs/bolinfest/PR-1677.md
Normal file
@@ -0,0 +1,407 @@
|
||||
# PR #1677: chore: update Codex::spawn() to return a struct instead of a tuple
|
||||
|
||||
- URL: https://github.com/openai/codex/pull/1677
|
||||
- Author: bolinfest
|
||||
- Created: 2025-07-24 21:49:59 UTC
|
||||
- Updated: 2025-07-28 03:19:21 UTC
|
||||
- Changes: +81/-28, Files changed: 10, Commits: 1
|
||||
|
||||
## Description
|
||||
|
||||
Also update `init_codex()` to return a `struct` instead of a tuple, as well.
|
||||
|
||||
## Full Diff
|
||||
|
||||
```diff
|
||||
diff --git a/codex-rs/cli/src/proto.rs b/codex-rs/cli/src/proto.rs
|
||||
index ec395dd108..64b292d50b 100644
|
||||
--- a/codex-rs/cli/src/proto.rs
|
||||
+++ b/codex-rs/cli/src/proto.rs
|
||||
@@ -4,6 +4,7 @@ use std::sync::Arc;
|
||||
use clap::Parser;
|
||||
use codex_common::CliConfigOverrides;
|
||||
use codex_core::Codex;
|
||||
+use codex_core::CodexSpawnOk;
|
||||
use codex_core::config::Config;
|
||||
use codex_core::config::ConfigOverrides;
|
||||
use codex_core::protocol::Submission;
|
||||
@@ -35,7 +36,7 @@ pub async fn run_main(opts: ProtoCli) -> anyhow::Result<()> {
|
||||
|
||||
let config = Config::load_with_cli_overrides(overrides_vec, ConfigOverrides::default())?;
|
||||
let ctrl_c = notify_on_sigint();
|
||||
- let (codex, _init_id, _session_id) = Codex::spawn(config, ctrl_c.clone()).await?;
|
||||
+ let CodexSpawnOk { codex, .. } = Codex::spawn(config, ctrl_c.clone()).await?;
|
||||
let codex = Arc::new(codex);
|
||||
|
||||
// Task that reads JSON lines from stdin and forwards to Submission Queue
|
||||
diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs
|
||||
index d9d40a8a47..5764440e79 100644
|
||||
--- a/codex-rs/core/src/codex.rs
|
||||
+++ b/codex-rs/core/src/codex.rs
|
||||
@@ -97,11 +97,18 @@ pub struct Codex {
|
||||
rx_event: Receiver<Event>,
|
||||
}
|
||||
|
||||
+/// Wrapper returned by [`Codex::spawn`] containing the spawned [`Codex`],
|
||||
+/// the submission id for the initial `ConfigureSession` request and the
|
||||
+/// unique session id.
|
||||
+pub struct CodexSpawnOk {
|
||||
+ pub codex: Codex,
|
||||
+ pub init_id: String,
|
||||
+ pub session_id: Uuid,
|
||||
+}
|
||||
+
|
||||
impl Codex {
|
||||
- /// Spawn a new [`Codex`] and initialize the session. Returns the instance
|
||||
- /// of `Codex` and the ID of the `SessionInitialized` event that was
|
||||
- /// submitted to start the session.
|
||||
- pub async fn spawn(config: Config, ctrl_c: Arc<Notify>) -> CodexResult<(Codex, String, Uuid)> {
|
||||
+ /// Spawn a new [`Codex`] and initialize the session.
|
||||
+ pub async fn spawn(config: Config, ctrl_c: Arc<Notify>) -> CodexResult<CodexSpawnOk> {
|
||||
// experimental resume path (undocumented)
|
||||
let resume_path = config.experimental_resume.clone();
|
||||
info!("resume_path: {resume_path:?}");
|
||||
@@ -139,7 +146,11 @@ impl Codex {
|
||||
};
|
||||
let init_id = codex.submit(configure_session).await?;
|
||||
|
||||
- Ok((codex, init_id, session_id))
|
||||
+ Ok(CodexSpawnOk {
|
||||
+ codex,
|
||||
+ init_id,
|
||||
+ session_id,
|
||||
+ })
|
||||
}
|
||||
|
||||
/// Submit the `op` wrapped in a `Submission` with a unique ID.
|
||||
diff --git a/codex-rs/core/src/codex_wrapper.rs b/codex-rs/core/src/codex_wrapper.rs
|
||||
index 31f8295ed4..b80579297a 100644
|
||||
--- a/codex-rs/core/src/codex_wrapper.rs
|
||||
+++ b/codex-rs/core/src/codex_wrapper.rs
|
||||
@@ -1,6 +1,7 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::Codex;
|
||||
+use crate::CodexSpawnOk;
|
||||
use crate::config::Config;
|
||||
use crate::protocol::Event;
|
||||
use crate::protocol::EventMsg;
|
||||
@@ -8,14 +9,27 @@ use crate::util::notify_on_sigint;
|
||||
use tokio::sync::Notify;
|
||||
use uuid::Uuid;
|
||||
|
||||
+/// Represents an active Codex conversation, including the first event
|
||||
+/// (which is [`EventMsg::SessionConfigured`]).
|
||||
+pub struct CodexConversation {
|
||||
+ pub codex: Codex,
|
||||
+ pub session_id: Uuid,
|
||||
+ pub session_configured: Event,
|
||||
+ pub ctrl_c: Arc<Notify>,
|
||||
+}
|
||||
+
|
||||
/// Spawn a new [`Codex`] and initialize the session.
|
||||
///
|
||||
/// Returns the wrapped [`Codex`] **and** the `SessionInitialized` event that
|
||||
/// is received as a response to the initial `ConfigureSession` submission so
|
||||
/// that callers can surface the information to the UI.
|
||||
-pub async fn init_codex(config: Config) -> anyhow::Result<(Codex, Event, Arc<Notify>, Uuid)> {
|
||||
+pub async fn init_codex(config: Config) -> anyhow::Result<CodexConversation> {
|
||||
let ctrl_c = notify_on_sigint();
|
||||
- let (codex, init_id, session_id) = Codex::spawn(config, ctrl_c.clone()).await?;
|
||||
+ let CodexSpawnOk {
|
||||
+ codex,
|
||||
+ init_id,
|
||||
+ session_id,
|
||||
+ } = Codex::spawn(config, ctrl_c.clone()).await?;
|
||||
|
||||
// The first event must be `SessionInitialized`. Validate and forward it to
|
||||
// the caller so that they can display it in the conversation history.
|
||||
@@ -34,5 +48,10 @@ pub async fn init_codex(config: Config) -> anyhow::Result<(Codex, Event, Arc<Not
|
||||
));
|
||||
}
|
||||
|
||||
- Ok((codex, event, ctrl_c, session_id))
|
||||
+ Ok(CodexConversation {
|
||||
+ codex,
|
||||
+ session_id,
|
||||
+ session_configured: event,
|
||||
+ ctrl_c,
|
||||
+ })
|
||||
}
|
||||
diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs
|
||||
index 2b82a3f045..f390038c0c 100644
|
||||
--- a/codex-rs/core/src/lib.rs
|
||||
+++ b/codex-rs/core/src/lib.rs
|
||||
@@ -11,6 +11,7 @@ mod client;
|
||||
mod client_common;
|
||||
pub mod codex;
|
||||
pub use codex::Codex;
|
||||
+pub use codex::CodexSpawnOk;
|
||||
pub mod codex_wrapper;
|
||||
pub mod config;
|
||||
pub mod config_profile;
|
||||
diff --git a/codex-rs/core/tests/client.rs b/codex-rs/core/tests/client.rs
|
||||
index cb60fb216f..9de2d56036 100644
|
||||
--- a/codex-rs/core/tests/client.rs
|
||||
+++ b/codex-rs/core/tests/client.rs
|
||||
@@ -1,4 +1,5 @@
|
||||
use codex_core::Codex;
|
||||
+use codex_core::CodexSpawnOk;
|
||||
use codex_core::ModelProviderInfo;
|
||||
use codex_core::exec::CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR;
|
||||
use codex_core::protocol::EventMsg;
|
||||
@@ -72,7 +73,7 @@ async fn includes_session_id_and_model_headers_in_request() {
|
||||
let mut config = load_default_config_for_test(&codex_home);
|
||||
config.model_provider = model_provider;
|
||||
let ctrl_c = std::sync::Arc::new(tokio::sync::Notify::new());
|
||||
- let (codex, _init_id, _session_id) = Codex::spawn(config, ctrl_c.clone()).await.unwrap();
|
||||
+ let CodexSpawnOk { codex, .. } = Codex::spawn(config, ctrl_c.clone()).await.unwrap();
|
||||
|
||||
codex
|
||||
.submit(Op::UserInput {
|
||||
@@ -148,7 +149,7 @@ async fn includes_base_instructions_override_in_request() {
|
||||
config.model_provider = model_provider;
|
||||
|
||||
let ctrl_c = std::sync::Arc::new(tokio::sync::Notify::new());
|
||||
- let (codex, ..) = Codex::spawn(config, ctrl_c.clone()).await.unwrap();
|
||||
+ let CodexSpawnOk { codex, .. } = Codex::spawn(config, ctrl_c.clone()).await.unwrap();
|
||||
|
||||
codex
|
||||
.submit(Op::UserInput {
|
||||
diff --git a/codex-rs/core/tests/live_agent.rs b/codex-rs/core/tests/live_agent.rs
|
||||
index 9d81225b9f..9895343045 100644
|
||||
--- a/codex-rs/core/tests/live_agent.rs
|
||||
+++ b/codex-rs/core/tests/live_agent.rs
|
||||
@@ -20,6 +20,7 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use codex_core::Codex;
|
||||
+use codex_core::CodexSpawnOk;
|
||||
use codex_core::error::CodexErr;
|
||||
use codex_core::protocol::AgentMessageEvent;
|
||||
use codex_core::protocol::ErrorEvent;
|
||||
@@ -48,7 +49,7 @@ async fn spawn_codex() -> Result<Codex, CodexErr> {
|
||||
let mut config = load_default_config_for_test(&codex_home);
|
||||
config.model_provider.request_max_retries = Some(2);
|
||||
config.model_provider.stream_max_retries = Some(2);
|
||||
- let (agent, _init_id, _session_id) =
|
||||
+ let CodexSpawnOk { codex: agent, .. } =
|
||||
Codex::spawn(config, std::sync::Arc::new(Notify::new())).await?;
|
||||
|
||||
Ok(agent)
|
||||
diff --git a/codex-rs/core/tests/stream_no_completed.rs b/codex-rs/core/tests/stream_no_completed.rs
|
||||
index 153330bf10..8e5d83a03e 100644
|
||||
--- a/codex-rs/core/tests/stream_no_completed.rs
|
||||
+++ b/codex-rs/core/tests/stream_no_completed.rs
|
||||
@@ -4,6 +4,7 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use codex_core::Codex;
|
||||
+use codex_core::CodexSpawnOk;
|
||||
use codex_core::ModelProviderInfo;
|
||||
use codex_core::exec::CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR;
|
||||
use codex_core::protocol::EventMsg;
|
||||
@@ -94,7 +95,7 @@ async fn retries_on_early_close() {
|
||||
let codex_home = TempDir::new().unwrap();
|
||||
let mut config = load_default_config_for_test(&codex_home);
|
||||
config.model_provider = model_provider;
|
||||
- let (codex, _init_id, _session_id) = Codex::spawn(config, ctrl_c).await.unwrap();
|
||||
+ let CodexSpawnOk { codex, .. } = Codex::spawn(config, ctrl_c).await.unwrap();
|
||||
|
||||
codex
|
||||
.submit(Op::UserInput {
|
||||
diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs
|
||||
index 126e92f597..f966d200a1 100644
|
||||
--- a/codex-rs/exec/src/lib.rs
|
||||
+++ b/codex-rs/exec/src/lib.rs
|
||||
@@ -9,7 +9,8 @@ use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
pub use cli::Cli;
|
||||
-use codex_core::codex_wrapper;
|
||||
+use codex_core::codex_wrapper::CodexConversation;
|
||||
+use codex_core::codex_wrapper::{self};
|
||||
use codex_core::config::Config;
|
||||
use codex_core::config::ConfigOverrides;
|
||||
use codex_core::config_types::SandboxMode;
|
||||
@@ -155,9 +156,14 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option<PathBuf>) -> any
|
||||
.with_writer(std::io::stderr)
|
||||
.try_init();
|
||||
|
||||
- let (codex_wrapper, event, ctrl_c, _session_id) = codex_wrapper::init_codex(config).await?;
|
||||
+ let CodexConversation {
|
||||
+ codex: codex_wrapper,
|
||||
+ session_configured,
|
||||
+ ctrl_c,
|
||||
+ ..
|
||||
+ } = codex_wrapper::init_codex(config).await?;
|
||||
let codex = Arc::new(codex_wrapper);
|
||||
- info!("Codex initialized with event: {event:?}");
|
||||
+ info!("Codex initialized with event: {session_configured:?}");
|
||||
|
||||
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<Event>();
|
||||
{
|
||||
diff --git a/codex-rs/mcp-server/src/codex_tool_runner.rs b/codex-rs/mcp-server/src/codex_tool_runner.rs
|
||||
index f2cacf6c8e..22a36b8366 100644
|
||||
--- a/codex-rs/mcp-server/src/codex_tool_runner.rs
|
||||
+++ b/codex-rs/mcp-server/src/codex_tool_runner.rs
|
||||
@@ -6,6 +6,7 @@ use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use codex_core::Codex;
|
||||
+use codex_core::codex_wrapper::CodexConversation;
|
||||
use codex_core::codex_wrapper::init_codex;
|
||||
use codex_core::config::Config as CodexConfig;
|
||||
use codex_core::protocol::AgentMessageEvent;
|
||||
@@ -42,7 +43,12 @@ pub async fn run_codex_tool_session(
|
||||
session_map: Arc<Mutex<HashMap<Uuid, Arc<Codex>>>>,
|
||||
running_requests_id_to_codex_uuid: Arc<Mutex<HashMap<RequestId, Uuid>>>,
|
||||
) {
|
||||
- let (codex, first_event, _ctrl_c, session_id) = match init_codex(config).await {
|
||||
+ let CodexConversation {
|
||||
+ codex,
|
||||
+ session_configured,
|
||||
+ session_id,
|
||||
+ ..
|
||||
+ } = match init_codex(config).await {
|
||||
Ok(res) => res,
|
||||
Err(e) => {
|
||||
let result = CallToolResult {
|
||||
@@ -66,7 +72,9 @@ pub async fn run_codex_tool_session(
|
||||
drop(session_map);
|
||||
|
||||
// Send initial SessionConfigured event.
|
||||
- outgoing.send_event_as_notification(&first_event).await;
|
||||
+ outgoing
|
||||
+ .send_event_as_notification(&session_configured)
|
||||
+ .await;
|
||||
|
||||
// Use the original MCP request ID as the `sub_id` for the Codex submission so that
|
||||
// any events emitted for this tool-call can be correlated with the
|
||||
diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs
|
||||
index 6744707319..6ca8d4c4be 100644
|
||||
--- a/codex-rs/tui/src/chatwidget.rs
|
||||
+++ b/codex-rs/tui/src/chatwidget.rs
|
||||
@@ -1,6 +1,7 @@
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
+use codex_core::codex_wrapper::CodexConversation;
|
||||
use codex_core::codex_wrapper::init_codex;
|
||||
use codex_core::config::Config;
|
||||
use codex_core::protocol::AgentMessageDeltaEvent;
|
||||
@@ -96,19 +97,22 @@ impl ChatWidget<'_> {
|
||||
// Create the Codex asynchronously so the UI loads as quickly as possible.
|
||||
let config_for_agent_loop = config.clone();
|
||||
tokio::spawn(async move {
|
||||
- let (codex, session_event, _ctrl_c, _session_id) =
|
||||
- match init_codex(config_for_agent_loop).await {
|
||||
- Ok(vals) => vals,
|
||||
- Err(e) => {
|
||||
- // TODO: surface this error to the user.
|
||||
- tracing::error!("failed to initialize codex: {e}");
|
||||
- return;
|
||||
- }
|
||||
- };
|
||||
+ let CodexConversation {
|
||||
+ codex,
|
||||
+ session_configured,
|
||||
+ ..
|
||||
+ } = match init_codex(config_for_agent_loop).await {
|
||||
+ Ok(vals) => vals,
|
||||
+ Err(e) => {
|
||||
+ // TODO: surface this error to the user.
|
||||
+ tracing::error!("failed to initialize codex: {e}");
|
||||
+ return;
|
||||
+ }
|
||||
+ };
|
||||
|
||||
// Forward the captured `SessionInitialized` event that was consumed
|
||||
// inside `init_codex()` so it can be rendered in the UI.
|
||||
- app_event_tx_clone.send(AppEvent::CodexEvent(session_event.clone()));
|
||||
+ app_event_tx_clone.send(AppEvent::CodexEvent(session_configured.clone()));
|
||||
let codex = Arc::new(codex);
|
||||
let codex_clone = codex.clone();
|
||||
tokio::spawn(async move {
|
||||
```
|
||||
|
||||
## Review Comments
|
||||
|
||||
### codex-rs/core/src/codex.rs
|
||||
|
||||
- Created: 2025-07-28 03:17:38 UTC | Link: https://github.com/openai/codex/pull/1677#discussion_r2234546249
|
||||
|
||||
```diff
|
||||
@@ -139,7 +146,11 @@ impl Codex {
|
||||
};
|
||||
let init_id = codex.submit(configure_session).await?;
|
||||
|
||||
- Ok((codex, init_id, session_id))
|
||||
+ Ok(CodexSpawnOk {
|
||||
```
|
||||
|
||||
> My other thought was `CodexSpawnResult`, but it isn't a `Result`, it is the `Ok` variant of a `Result`.
|
||||
|
||||
- Created: 2025-07-28 03:18:47 UTC | Link: https://github.com/openai/codex/pull/1677#discussion_r2234548656
|
||||
|
||||
```diff
|
||||
@@ -139,7 +146,11 @@ impl Codex {
|
||||
};
|
||||
let init_id = codex.submit(configure_session).await?;
|
||||
|
||||
- Ok((codex, init_id, session_id))
|
||||
+ Ok(CodexSpawnOk {
|
||||
```
|
||||
|
||||
> I don't feel amazing about these, but as you can see, there are some callers that use `spawn()` directly without the extra tidying up that `init_codex()` provides.
|
||||
|
||||
### codex-rs/core/src/codex_wrapper.rs
|
||||
|
||||
- Created: 2025-07-28 03:16:48 UTC | Link: https://github.com/openai/codex/pull/1677#discussion_r2234544337
|
||||
|
||||
```diff
|
||||
@@ -1,21 +1,35 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::Codex;
|
||||
+use crate::CodexSpawnOk;
|
||||
use crate::config::Config;
|
||||
use crate::protocol::Event;
|
||||
use crate::protocol::EventMsg;
|
||||
use crate::util::notify_on_sigint;
|
||||
use tokio::sync::Notify;
|
||||
use uuid::Uuid;
|
||||
|
||||
+/// Represents an active Codex conversation, including the first event
|
||||
+/// (which is [`EventMsg::SessionConfigured`]).
|
||||
+pub struct CodexConversation {
|
||||
+ pub codex: Codex,
|
||||
+ pub session_id: Uuid,
|
||||
+ pub session_configured: Event,
|
||||
```
|
||||
|
||||
> Mainly as a convenience for the caller. Because the client just gets `Event`, it cannot guarantee that it is of type `EventMsg::SessionConfigured` on its own without doing `matches!()` again or something and `init_codex()` has already done that work.
|
||||
|
||||
- Created: 2025-07-28 03:19:21 UTC | Link: https://github.com/openai/codex/pull/1677#discussion_r2234550116
|
||||
|
||||
```diff
|
||||
@@ -1,21 +1,35 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::Codex;
|
||||
+use crate::CodexSpawnOk;
|
||||
use crate::config::Config;
|
||||
use crate::protocol::Event;
|
||||
use crate::protocol::EventMsg;
|
||||
use crate::util::notify_on_sigint;
|
||||
use tokio::sync::Notify;
|
||||
use uuid::Uuid;
|
||||
|
||||
+/// Represents an active Codex conversation, including the first event
|
||||
+/// (which is [`EventMsg::SessionConfigured`]).
|
||||
+pub struct CodexConversation {
|
||||
+ pub codex: Codex,
|
||||
+ pub session_id: Uuid,
|
||||
```
|
||||
|
||||
> I think that should be done when we rename `session_id` more generally across the codebase.
|
||||
908
prs/bolinfest/PR-1678.md
Normal file
908
prs/bolinfest/PR-1678.md
Normal file
@@ -0,0 +1,908 @@
|
||||
# PR #1678: Optionally run using user profile
|
||||
|
||||
- URL: https://github.com/openai/codex/pull/1678
|
||||
- Author: pakrym-oai
|
||||
- Created: 2025-07-25 01:27:01 UTC
|
||||
- Updated: 2025-07-25 18:45:31 UTC
|
||||
- Changes: +260/-1, Files changed: 8, Commits: 8
|
||||
|
||||
## Description
|
||||
|
||||
(No description.)
|
||||
|
||||
## Full Diff
|
||||
|
||||
```diff
|
||||
diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock
|
||||
index df1b0235a7..f654ce49e6 100644
|
||||
--- a/codex-rs/Cargo.lock
|
||||
+++ b/codex-rs/Cargo.lock
|
||||
@@ -683,6 +683,7 @@ dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha1",
|
||||
+ "shlex",
|
||||
"strum_macros 0.27.2",
|
||||
"tempfile",
|
||||
"thiserror 2.0.12",
|
||||
@@ -696,6 +697,7 @@ dependencies = [
|
||||
"tree-sitter-bash",
|
||||
"uuid",
|
||||
"walkdir",
|
||||
+ "whoami",
|
||||
"wildmatch",
|
||||
"wiremock",
|
||||
]
|
||||
@@ -5127,6 +5129,12 @@ dependencies = [
|
||||
"wit-bindgen-rt",
|
||||
]
|
||||
|
||||
+[[package]]
|
||||
+name = "wasite"
|
||||
+version = "0.1.0"
|
||||
+source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
+checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b"
|
||||
+
|
||||
[[package]]
|
||||
name = "wasm-bindgen"
|
||||
version = "0.2.100"
|
||||
@@ -5227,6 +5235,17 @@ version = "0.1.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a751b3277700db47d3e574514de2eced5e54dc8a5436a3bf7a0b248b2cee16f3"
|
||||
|
||||
+[[package]]
|
||||
+name = "whoami"
|
||||
+version = "1.6.0"
|
||||
+source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
+checksum = "6994d13118ab492c3c80c1f81928718159254c53c472bf9ce36f8dae4add02a7"
|
||||
+dependencies = [
|
||||
+ "redox_syscall",
|
||||
+ "wasite",
|
||||
+ "web-sys",
|
||||
+]
|
||||
+
|
||||
[[package]]
|
||||
name = "wildmatch"
|
||||
version = "2.4.0"
|
||||
diff --git a/codex-rs/core/Cargo.toml b/codex-rs/core/Cargo.toml
|
||||
index 62e462bf97..2e0489c9b5 100644
|
||||
--- a/codex-rs/core/Cargo.toml
|
||||
+++ b/codex-rs/core/Cargo.toml
|
||||
@@ -30,6 +30,7 @@ reqwest = { version = "0.12", features = ["json", "stream"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
sha1 = "0.10.6"
|
||||
+shlex = "1.3.0"
|
||||
strum_macros = "0.27.2"
|
||||
thiserror = "2.0.12"
|
||||
time = { version = "0.3", features = ["formatting", "local-offset", "macros"] }
|
||||
@@ -47,6 +48,8 @@ tree-sitter = "0.25.8"
|
||||
tree-sitter-bash = "0.25.0"
|
||||
uuid = { version = "1", features = ["serde", "v4"] }
|
||||
wildmatch = "2.4.0"
|
||||
+whoami = "1.6.0"
|
||||
+
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
landlock = "0.4.1"
|
||||
diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs
|
||||
index f35348b779..d9d40a8a47 100644
|
||||
--- a/codex-rs/core/src/codex.rs
|
||||
+++ b/codex-rs/core/src/codex.rs
|
||||
@@ -85,6 +85,7 @@ use crate::rollout::RolloutRecorder;
|
||||
use crate::safety::SafetyCheck;
|
||||
use crate::safety::assess_command_safety;
|
||||
use crate::safety::assess_patch_safety;
|
||||
+use crate::shell;
|
||||
use crate::user_notification::UserNotification;
|
||||
use crate::util::backoff;
|
||||
|
||||
@@ -204,6 +205,7 @@ pub(crate) struct Session {
|
||||
rollout: Mutex<Option<RolloutRecorder>>,
|
||||
state: Mutex<State>,
|
||||
codex_linux_sandbox_exe: Option<PathBuf>,
|
||||
+ user_shell: shell::Shell,
|
||||
}
|
||||
|
||||
impl Session {
|
||||
@@ -676,6 +678,7 @@ async fn submission_loop(
|
||||
});
|
||||
}
|
||||
}
|
||||
+ let default_shell = shell::default_user_shell().await;
|
||||
sess = Some(Arc::new(Session {
|
||||
client,
|
||||
tx_event: tx_event.clone(),
|
||||
@@ -693,6 +696,7 @@ async fn submission_loop(
|
||||
rollout: Mutex::new(rollout_recorder),
|
||||
codex_linux_sandbox_exe: config.codex_linux_sandbox_exe.clone(),
|
||||
disable_response_storage,
|
||||
+ user_shell: default_shell,
|
||||
}));
|
||||
|
||||
// Patch restored state into the newly created session.
|
||||
@@ -1383,6 +1387,18 @@ fn parse_container_exec_arguments(
|
||||
}
|
||||
}
|
||||
|
||||
+fn maybe_run_with_user_profile(params: ExecParams, sess: &Session) -> ExecParams {
|
||||
+ if sess.shell_environment_policy.use_profile {
|
||||
+ let command = sess
|
||||
+ .user_shell
|
||||
+ .format_default_shell_invocation(params.command.clone());
|
||||
+ if let Some(command) = command {
|
||||
+ return ExecParams { command, ..params };
|
||||
+ }
|
||||
+ }
|
||||
+ params
|
||||
+}
|
||||
+
|
||||
async fn handle_container_exec_with_params(
|
||||
params: ExecParams,
|
||||
sess: &Session,
|
||||
@@ -1469,6 +1485,7 @@ async fn handle_container_exec_with_params(
|
||||
sess.notify_exec_command_begin(&sub_id, &call_id, ¶ms)
|
||||
.await;
|
||||
|
||||
+ let params = maybe_run_with_user_profile(params, sess);
|
||||
let output_result = process_exec_tool_call(
|
||||
params.clone(),
|
||||
sandbox_type,
|
||||
diff --git a/codex-rs/core/src/config_types.rs b/codex-rs/core/src/config_types.rs
|
||||
index 83fe613c86..ef835fb6e1 100644
|
||||
--- a/codex-rs/core/src/config_types.rs
|
||||
+++ b/codex-rs/core/src/config_types.rs
|
||||
@@ -143,6 +143,8 @@ pub struct ShellEnvironmentPolicyToml {
|
||||
|
||||
/// List of regular expressions.
|
||||
pub include_only: Option<Vec<String>>,
|
||||
+
|
||||
+ pub experimental_use_profile: Option<bool>,
|
||||
}
|
||||
|
||||
pub type EnvironmentVariablePattern = WildMatchPattern<'*', '?'>;
|
||||
@@ -171,6 +173,9 @@ pub struct ShellEnvironmentPolicy {
|
||||
|
||||
/// Environment variable names to retain in the environment.
|
||||
pub include_only: Vec<EnvironmentVariablePattern>,
|
||||
+
|
||||
+ /// If true, the shell profile will be used to run the command.
|
||||
+ pub use_profile: bool,
|
||||
}
|
||||
|
||||
impl From<ShellEnvironmentPolicyToml> for ShellEnvironmentPolicy {
|
||||
@@ -190,6 +195,7 @@ impl From<ShellEnvironmentPolicyToml> for ShellEnvironmentPolicy {
|
||||
.into_iter()
|
||||
.map(|s| EnvironmentVariablePattern::new_case_insensitive(&s))
|
||||
.collect();
|
||||
+ let use_profile = toml.experimental_use_profile.unwrap_or(false);
|
||||
|
||||
Self {
|
||||
inherit,
|
||||
@@ -197,6 +203,7 @@ impl From<ShellEnvironmentPolicyToml> for ShellEnvironmentPolicy {
|
||||
exclude,
|
||||
r#set,
|
||||
include_only,
|
||||
+ use_profile,
|
||||
}
|
||||
}
|
||||
}
|
||||
diff --git a/codex-rs/core/src/exec.rs b/codex-rs/core/src/exec.rs
|
||||
index 4b33b0b3b5..230c4ec134 100644
|
||||
--- a/codex-rs/core/src/exec.rs
|
||||
+++ b/codex-rs/core/src/exec.rs
|
||||
@@ -17,6 +17,7 @@ use tokio::io::BufReader;
|
||||
use tokio::process::Child;
|
||||
use tokio::process::Command;
|
||||
use tokio::sync::Notify;
|
||||
+use tracing::trace;
|
||||
|
||||
use crate::error::CodexErr;
|
||||
use crate::error::Result;
|
||||
@@ -82,7 +83,8 @@ pub async fn process_exec_tool_call(
|
||||
) -> Result<ExecToolCallOutput> {
|
||||
let start = Instant::now();
|
||||
|
||||
- let raw_output_result = match sandbox_type {
|
||||
+ let raw_output_result: std::result::Result<RawExecToolCallOutput, CodexErr> = match sandbox_type
|
||||
+ {
|
||||
SandboxType::None => exec(params, sandbox_policy, ctrl_c).await,
|
||||
SandboxType::MacosSeatbelt => {
|
||||
let ExecParams {
|
||||
@@ -372,6 +374,10 @@ async fn spawn_child_async(
|
||||
stdio_policy: StdioPolicy,
|
||||
env: HashMap<String, String>,
|
||||
) -> std::io::Result<Child> {
|
||||
+ trace!(
|
||||
+ "spawn_child_async: {program:?} {args:?} {arg0:?} {cwd:?} {sandbox_policy:?} {stdio_policy:?} {env:?}"
|
||||
+ );
|
||||
+
|
||||
let mut cmd = Command::new(&program);
|
||||
#[cfg(unix)]
|
||||
cmd.arg0(arg0.map_or_else(|| program.to_string_lossy().to_string(), String::from));
|
||||
diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs
|
||||
index 12321e0abc..2b82a3f045 100644
|
||||
--- a/codex-rs/core/src/lib.rs
|
||||
+++ b/codex-rs/core/src/lib.rs
|
||||
@@ -36,6 +36,7 @@ mod project_doc;
|
||||
pub mod protocol;
|
||||
mod rollout;
|
||||
mod safety;
|
||||
+pub mod shell;
|
||||
mod user_notification;
|
||||
pub mod util;
|
||||
|
||||
diff --git a/codex-rs/core/src/shell.rs b/codex-rs/core/src/shell.rs
|
||||
new file mode 100644
|
||||
index 0000000000..463651234c
|
||||
--- /dev/null
|
||||
+++ b/codex-rs/core/src/shell.rs
|
||||
@@ -0,0 +1,204 @@
|
||||
+use shlex;
|
||||
+
|
||||
+#[derive(Debug, PartialEq, Eq)]
|
||||
+pub struct ZshShell {
|
||||
+ shell_path: String,
|
||||
+ zshrc_path: String,
|
||||
+}
|
||||
+
|
||||
+#[derive(Debug, PartialEq, Eq)]
|
||||
+pub enum Shell {
|
||||
+ Zsh(ZshShell),
|
||||
+ Unknown,
|
||||
+}
|
||||
+
|
||||
+impl Shell {
|
||||
+ pub fn format_default_shell_invocation(&self, command: Vec<String>) -> Option<Vec<String>> {
|
||||
+ match self {
|
||||
+ Shell::Zsh(zsh) => {
|
||||
+ if !std::path::Path::new(&zsh.zshrc_path).exists() {
|
||||
+ return None;
|
||||
+ }
|
||||
+
|
||||
+ let mut result = vec![zsh.shell_path.clone(), "-c".to_string()];
|
||||
+ if let Ok(joined) = shlex::try_join(command.iter().map(|s| s.as_str())) {
|
||||
+ result.push(format!("source {} && ({joined})", zsh.zshrc_path));
|
||||
+ } else {
|
||||
+ return None;
|
||||
+ }
|
||||
+ Some(result)
|
||||
+ }
|
||||
+ Shell::Unknown => None,
|
||||
+ }
|
||||
+ }
|
||||
+}
|
||||
+
|
||||
+#[cfg(target_os = "macos")]
|
||||
+pub async fn default_user_shell() -> Shell {
|
||||
+ use tokio::process::Command;
|
||||
+ use whoami;
|
||||
+
|
||||
+ let user = whoami::username();
|
||||
+ let home = format!("/Users/{user}");
|
||||
+ let output = Command::new("dscl")
|
||||
+ .args([".", "-read", &home, "UserShell"])
|
||||
+ .output()
|
||||
+ .await
|
||||
+ .ok();
|
||||
+ match output {
|
||||
+ Some(o) => {
|
||||
+ if !o.status.success() {
|
||||
+ return Shell::Unknown;
|
||||
+ }
|
||||
+ let stdout = String::from_utf8_lossy(&o.stdout);
|
||||
+ for line in stdout.lines() {
|
||||
+ if let Some(shell_path) = line.strip_prefix("UserShell: ") {
|
||||
+ if shell_path.ends_with("/zsh") {
|
||||
+ return Shell::Zsh(ZshShell {
|
||||
+ shell_path: shell_path.to_string(),
|
||||
+ zshrc_path: format!("{home}/.zshrc"),
|
||||
+ });
|
||||
+ }
|
||||
+ }
|
||||
+ }
|
||||
+
|
||||
+ Shell::Unknown
|
||||
+ }
|
||||
+ _ => Shell::Unknown,
|
||||
+ }
|
||||
+}
|
||||
+
|
||||
+#[cfg(not(target_os = "macos"))]
|
||||
+pub async fn default_user_shell() -> Shell {
|
||||
+ Shell::Unknown
|
||||
+}
|
||||
+
|
||||
+#[cfg(test)]
|
||||
+#[cfg(target_os = "macos")]
|
||||
+mod tests {
|
||||
+ use super::*;
|
||||
+ use std::process::Command;
|
||||
+
|
||||
+ #[tokio::test]
|
||||
+ #[expect(clippy::unwrap_used)]
|
||||
+ async fn test_current_shell_detects_zsh() {
|
||||
+ let shell = Command::new("sh")
|
||||
+ .arg("-c")
|
||||
+ .arg("echo $SHELL")
|
||||
+ .output()
|
||||
+ .unwrap();
|
||||
+
|
||||
+ let home = std::env::var("HOME").unwrap();
|
||||
+ let shell_path = String::from_utf8_lossy(&shell.stdout).trim().to_string();
|
||||
+ if shell_path.ends_with("/zsh") {
|
||||
+ assert_eq!(
|
||||
+ default_user_shell().await,
|
||||
+ Shell::Zsh(ZshShell {
|
||||
+ shell_path: shell_path.to_string(),
|
||||
+ zshrc_path: format!("{home}/.zshrc",),
|
||||
+ })
|
||||
+ );
|
||||
+ }
|
||||
+ }
|
||||
+
|
||||
+ #[tokio::test]
|
||||
+ async fn test_run_with_profile_zshrc_not_exists() {
|
||||
+ let shell = Shell::Zsh(ZshShell {
|
||||
+ shell_path: "/bin/zsh".to_string(),
|
||||
+ zshrc_path: "/does/not/exist/.zshrc".to_string(),
|
||||
+ });
|
||||
+ let actual_cmd = shell.format_default_shell_invocation(vec!["myecho".to_string()]);
|
||||
+ assert_eq!(actual_cmd, None);
|
||||
+ }
|
||||
+
|
||||
+ #[expect(clippy::unwrap_used)]
|
||||
+ #[tokio::test]
|
||||
+ async fn test_run_with_profile_escaping_and_execution() {
|
||||
+ let shell_path = "/bin/zsh";
|
||||
+
|
||||
+ let cases = vec![
|
||||
+ (
|
||||
+ vec!["myecho"],
|
||||
+ vec![shell_path, "-c", "source ZSHRC_PATH && (myecho)"],
|
||||
+ Some("It works!\n"),
|
||||
+ ),
|
||||
+ (
|
||||
+ vec!["bash", "-lc", "echo 'single' \"double\""],
|
||||
+ vec![
|
||||
+ shell_path,
|
||||
+ "-c",
|
||||
+ "source ZSHRC_PATH && (bash -lc \"echo 'single' \\\"double\\\"\")",
|
||||
+ ],
|
||||
+ Some("single double\n"),
|
||||
+ ),
|
||||
+ ];
|
||||
+ for (input, expected_cmd, expected_output) in cases {
|
||||
+ use std::collections::HashMap;
|
||||
+ use std::path::PathBuf;
|
||||
+ use std::sync::Arc;
|
||||
+
|
||||
+ use tokio::sync::Notify;
|
||||
+
|
||||
+ use crate::exec::ExecParams;
|
||||
+ use crate::exec::SandboxType;
|
||||
+ use crate::exec::process_exec_tool_call;
|
||||
+ use crate::protocol::SandboxPolicy;
|
||||
+
|
||||
+ // create a temp directory with a zshrc file in it
|
||||
+ let temp_home = tempfile::tempdir().unwrap();
|
||||
+ let zshrc_path = temp_home.path().join(".zshrc");
|
||||
+ std::fs::write(
|
||||
+ &zshrc_path,
|
||||
+ r#"
|
||||
+ set -x
|
||||
+ function myecho {
|
||||
+ echo 'It works!'
|
||||
+ }
|
||||
+ "#,
|
||||
+ )
|
||||
+ .unwrap();
|
||||
+ let shell = Shell::Zsh(ZshShell {
|
||||
+ shell_path: shell_path.to_string(),
|
||||
+ zshrc_path: zshrc_path.to_str().unwrap().to_string(),
|
||||
+ });
|
||||
+
|
||||
+ let actual_cmd = shell
|
||||
+ .format_default_shell_invocation(input.iter().map(|s| s.to_string()).collect());
|
||||
+ let expected_cmd = expected_cmd
|
||||
+ .iter()
|
||||
+ .map(|s| {
|
||||
+ s.replace("ZSHRC_PATH", zshrc_path.to_str().unwrap())
|
||||
+ .to_string()
|
||||
+ })
|
||||
+ .collect();
|
||||
+
|
||||
+ assert_eq!(actual_cmd, Some(expected_cmd));
|
||||
+ // Actually run the command and check output/exit code
|
||||
+ let output = process_exec_tool_call(
|
||||
+ ExecParams {
|
||||
+ command: actual_cmd.unwrap(),
|
||||
+ cwd: PathBuf::from(temp_home.path()),
|
||||
+ timeout_ms: None,
|
||||
+ env: HashMap::from([(
|
||||
+ "HOME".to_string(),
|
||||
+ temp_home.path().to_str().unwrap().to_string(),
|
||||
+ )]),
|
||||
+ },
|
||||
+ SandboxType::None,
|
||||
+ Arc::new(Notify::new()),
|
||||
+ &SandboxPolicy::DangerFullAccess,
|
||||
+ &None,
|
||||
+ )
|
||||
+ .await
|
||||
+ .unwrap();
|
||||
+
|
||||
+ assert_eq!(output.exit_code, 0, "input: {input:?} output: {output:?}");
|
||||
+ if let Some(expected) = expected_output {
|
||||
+ assert_eq!(
|
||||
+ output.stdout, expected,
|
||||
+ "input: {input:?} output: {output:?}"
|
||||
+ );
|
||||
+ }
|
||||
+ }
|
||||
+ }
|
||||
+}
|
||||
diff --git a/codex-rs/mcp-server/src/lib.rs b/codex-rs/mcp-server/src/lib.rs
|
||||
index 79981e4992..aaf67571b4 100644
|
||||
--- a/codex-rs/mcp-server/src/lib.rs
|
||||
+++ b/codex-rs/mcp-server/src/lib.rs
|
||||
@@ -13,6 +13,7 @@ use tokio::sync::mpsc;
|
||||
use tracing::debug;
|
||||
use tracing::error;
|
||||
use tracing::info;
|
||||
+use tracing_subscriber::EnvFilter;
|
||||
|
||||
mod codex_tool_config;
|
||||
mod codex_tool_runner;
|
||||
@@ -43,6 +44,7 @@ pub async fn run_main(codex_linux_sandbox_exe: Option<PathBuf>) -> IoResult<()>
|
||||
// control the log level with `RUST_LOG`.
|
||||
tracing_subscriber::fmt()
|
||||
.with_writer(std::io::stderr)
|
||||
+ .with_env_filter(EnvFilter::from_default_env())
|
||||
.init();
|
||||
|
||||
// Set up channels.
|
||||
```
|
||||
|
||||
## Review Comments
|
||||
|
||||
### codex-rs/core/Cargo.toml
|
||||
|
||||
- Created: 2025-07-25 18:28:59 UTC | Link: https://github.com/openai/codex/pull/1678#discussion_r2231771262
|
||||
|
||||
```diff
|
||||
@@ -47,6 +47,8 @@ tree-sitter = "0.25.8"
|
||||
tree-sitter-bash = "0.25.0"
|
||||
uuid = { version = "1", features = ["serde", "v4"] }
|
||||
wildmatch = "2.4.0"
|
||||
+whoami = "1.6.0"
|
||||
+shlex = "1.3.0"
|
||||
```
|
||||
|
||||
> alpha sort?
|
||||
|
||||
### codex-rs/core/src/codex.rs
|
||||
|
||||
- Created: 2025-07-25 05:06:29 UTC | Link: https://github.com/openai/codex/pull/1678#discussion_r2230147821
|
||||
|
||||
```diff
|
||||
@@ -204,6 +205,7 @@ pub(crate) struct Session {
|
||||
rollout: Mutex<Option<RolloutRecorder>>,
|
||||
state: Mutex<State>,
|
||||
codex_linux_sandbox_exe: Option<PathBuf>,
|
||||
+ user_shell: Option<shell::Shell>,
|
||||
```
|
||||
|
||||
> Should this be non-`Option` and be `Unknown` instead of `None`?
|
||||
|
||||
- Created: 2025-07-25 05:07:50 UTC | Link: https://github.com/openai/codex/pull/1678#discussion_r2230149272
|
||||
|
||||
```diff
|
||||
@@ -1469,8 +1483,9 @@ async fn handle_container_exec_with_params(
|
||||
sess.notify_exec_command_begin(&sub_id, &call_id, ¶ms)
|
||||
.await;
|
||||
|
||||
+ let processed_params = maybe_run_with_shell(params.clone(), sess);
|
||||
```
|
||||
|
||||
> Consider just shadowing, which avoids `clone`?
|
||||
>
|
||||
> ```suggestion
|
||||
> let params = maybe_run_with_shell(params.clone(), sess);
|
||||
> ```
|
||||
|
||||
### codex-rs/core/src/config_types.rs
|
||||
|
||||
- Created: 2025-07-25 05:17:56 UTC | Link: https://github.com/openai/codex/pull/1678#discussion_r2230159268
|
||||
|
||||
```diff
|
||||
@@ -171,6 +173,9 @@ pub struct ShellEnvironmentPolicy {
|
||||
|
||||
/// Environment variable names to retain in the environment.
|
||||
pub include_only: Vec<EnvironmentVariablePattern>,
|
||||
+
|
||||
+ /// If true, the shell profile will be used to run the command.
|
||||
+ pub use_profile: bool,
|
||||
```
|
||||
|
||||
> Currently, I am tempted to get rid of `ShellEnvironmentPolicy` altogether and make the user responsible for sanitizing the environment with which they invoke `codex`. Or at least, make the default behavior to inherit the current environment and the user has to take action to configure something else.
|
||||
>
|
||||
> Thoughts?
|
||||
|
||||
### codex-rs/core/src/shell.rs
|
||||
|
||||
- Created: 2025-07-25 04:56:21 UTC | Link: https://github.com/openai/codex/pull/1678#discussion_r2230137095
|
||||
|
||||
```diff
|
||||
@@ -0,0 +1,125 @@
|
||||
+use shlex;
|
||||
+use std::process::Command;
|
||||
+use whoami;
|
||||
+
|
||||
+#[derive(Debug, PartialEq, Eq)]
|
||||
+pub enum Shell {
|
||||
+ Zsh(String),
|
||||
+ Unknown,
|
||||
+}
|
||||
+
|
||||
+impl Shell {
|
||||
+ pub fn run_with_profile(&self, command: Vec<String>) -> Option<Vec<String>> {
|
||||
+ match self {
|
||||
+ Shell::Zsh(shell_path) => {
|
||||
+ let mut result = vec![shell_path.clone(), "-c".to_string()];
|
||||
+ if let Ok(joined) = shlex::try_join(command.iter().map(|s| s.as_str())) {
|
||||
+ result.push(format!("source ~/.zshrc && ({joined})"));
|
||||
+ } else {
|
||||
+ return None;
|
||||
+ }
|
||||
+ Some(result)
|
||||
+ }
|
||||
+ Shell::Unknown => None,
|
||||
+ }
|
||||
+ }
|
||||
+}
|
||||
+
|
||||
+#[cfg(target_os = "macos")]
|
||||
+pub fn current_shell() -> Option<Shell> {
|
||||
+ let user = whoami::username();
|
||||
+ let output = Command::new("dscl")
|
||||
+ .args([".", "-read", &format!("/Users/{user}"), "UserShell"])
|
||||
+ .output()
|
||||
+ .ok()?;
|
||||
+ if !output.status.success() {
|
||||
+ return Some(Shell::Unknown);
|
||||
+ }
|
||||
+ let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
+ for line in stdout.lines() {
|
||||
+ if let Some(shell_path) = line.strip_prefix("UserShell: ") {
|
||||
+ if shell_path.ends_with("/zsh") {
|
||||
+ return Some(Shell::Zsh(shell_path.to_string()));
|
||||
+ } else {
|
||||
+ return Some(Shell::Unknown);
|
||||
+ }
|
||||
+ }
|
||||
+ }
|
||||
+ Some(Shell::Unknown)
|
||||
+}
|
||||
+
|
||||
+#[cfg(test)]
|
||||
+mod tests {
|
||||
+ use super::*;
|
||||
+ use std::process::Command;
|
||||
+
|
||||
+ #[test]
|
||||
+ #[cfg(target_os = "macos")]
|
||||
+ #[expect(clippy::unwrap_used)]
|
||||
+ fn test_current_shell_detects_zsh() {
|
||||
+ let output = Command::new("sh")
|
||||
+ .arg("-c")
|
||||
+ .arg("echo $SHELL")
|
||||
+ .output()
|
||||
+ .unwrap();
|
||||
+ let shell_path = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
+ if shell_path.ends_with("/zsh") {
|
||||
+ assert_eq!(current_shell(), Some(Shell::Zsh(shell_path)));
|
||||
+ }
|
||||
+ }
|
||||
+
|
||||
+ #[cfg(target_os = "macos")]
|
||||
+ #[expect(clippy::unwrap_used)]
|
||||
+ #[tokio::test]
|
||||
+ async fn test_run_with_profile_escaping_and_execution() {
|
||||
+ let shell_path = "/bin/zsh";
|
||||
+ let shell = Shell::Zsh(shell_path.to_string());
|
||||
+ let cases = vec![(
|
||||
+ vec!["bash", "-lc", "echo 'single' \"double\""],
|
||||
+ vec![
|
||||
+ shell_path,
|
||||
+ "-c",
|
||||
+ "source ~/.zshrc && (bash -lc \"echo 'single' \\\"double\\\"\")",
|
||||
```
|
||||
|
||||
> I don't think this is a safe thing to run in tests.
|
||||
>
|
||||
> Maybe we should be using https://docs.rs/dirs/6.0.0/dirs/fn.home_dir.html to construct the path to `~/.zshrc` and then you can run this test with `HOME` set to a temp dir?
|
||||
|
||||
- Created: 2025-07-25 05:03:35 UTC | Link: https://github.com/openai/codex/pull/1678#discussion_r2230145115
|
||||
|
||||
```diff
|
||||
@@ -0,0 +1,125 @@
|
||||
+use shlex;
|
||||
+use std::process::Command;
|
||||
+use whoami;
|
||||
+
|
||||
+#[derive(Debug, PartialEq, Eq)]
|
||||
+pub enum Shell {
|
||||
+ Zsh(String),
|
||||
+ Unknown,
|
||||
+}
|
||||
+
|
||||
+impl Shell {
|
||||
+ pub fn run_with_profile(&self, command: Vec<String>) -> Option<Vec<String>> {
|
||||
+ match self {
|
||||
+ Shell::Zsh(shell_path) => {
|
||||
+ let mut result = vec![shell_path.clone(), "-c".to_string()];
|
||||
+ if let Ok(joined) = shlex::try_join(command.iter().map(|s| s.as_str())) {
|
||||
+ result.push(format!("source ~/.zshrc && ({joined})"));
|
||||
+ } else {
|
||||
+ return None;
|
||||
+ }
|
||||
+ Some(result)
|
||||
+ }
|
||||
+ Shell::Unknown => None,
|
||||
+ }
|
||||
+ }
|
||||
+}
|
||||
+
|
||||
+#[cfg(target_os = "macos")]
|
||||
+pub fn current_shell() -> Option<Shell> {
|
||||
+ let user = whoami::username();
|
||||
```
|
||||
|
||||
> We should probably check `$SHELL` first and then do this as a fallback?
|
||||
|
||||
- Created: 2025-07-25 05:05:26 UTC | Link: https://github.com/openai/codex/pull/1678#discussion_r2230146829
|
||||
|
||||
```diff
|
||||
@@ -0,0 +1,125 @@
|
||||
+use shlex;
|
||||
+use std::process::Command;
|
||||
+use whoami;
|
||||
+
|
||||
+#[derive(Debug, PartialEq, Eq)]
|
||||
+pub enum Shell {
|
||||
+ Zsh(String),
|
||||
+ Unknown,
|
||||
+}
|
||||
+
|
||||
+impl Shell {
|
||||
+ pub fn run_with_profile(&self, command: Vec<String>) -> Option<Vec<String>> {
|
||||
+ match self {
|
||||
+ Shell::Zsh(shell_path) => {
|
||||
+ let mut result = vec![shell_path.clone(), "-c".to_string()];
|
||||
+ if let Ok(joined) = shlex::try_join(command.iter().map(|s| s.as_str())) {
|
||||
+ result.push(format!("source ~/.zshrc && ({joined})"));
|
||||
+ } else {
|
||||
+ return None;
|
||||
+ }
|
||||
+ Some(result)
|
||||
+ }
|
||||
+ Shell::Unknown => None,
|
||||
+ }
|
||||
+ }
|
||||
+}
|
||||
+
|
||||
+#[cfg(target_os = "macos")]
|
||||
+pub fn current_shell() -> Option<Shell> {
|
||||
+ let user = whoami::username();
|
||||
+ let output = Command::new("dscl")
|
||||
```
|
||||
|
||||
> Consider `tokio::Command` and use `async` on the off chance invoking `dscl` gets wedged?
|
||||
|
||||
- Created: 2025-07-25 05:16:21 UTC | Link: https://github.com/openai/codex/pull/1678#discussion_r2230157756
|
||||
|
||||
```diff
|
||||
@@ -0,0 +1,125 @@
|
||||
+use shlex;
|
||||
+use std::process::Command;
|
||||
+use whoami;
|
||||
+
|
||||
+#[derive(Debug, PartialEq, Eq)]
|
||||
+pub enum Shell {
|
||||
+ Zsh(String),
|
||||
+ Unknown,
|
||||
+}
|
||||
+
|
||||
+impl Shell {
|
||||
+ pub fn run_with_profile(&self, command: Vec<String>) -> Option<Vec<String>> {
|
||||
+ match self {
|
||||
+ Shell::Zsh(shell_path) => {
|
||||
+ let mut result = vec![shell_path.clone(), "-c".to_string()];
|
||||
+ if let Ok(joined) = shlex::try_join(command.iter().map(|s| s.as_str())) {
|
||||
+ result.push(format!("source ~/.zshrc && ({joined})"));
|
||||
```
|
||||
|
||||
> Is `~/.zshrc` required to exist? Should `;` be used instead of `&&` to ignore a failure in that case?
|
||||
|
||||
- Created: 2025-07-25 05:22:37 UTC | Link: https://github.com/openai/codex/pull/1678#discussion_r2230164598
|
||||
|
||||
```diff
|
||||
@@ -0,0 +1,125 @@
|
||||
+use shlex;
|
||||
+use std::process::Command;
|
||||
+use whoami;
|
||||
+
|
||||
+#[derive(Debug, PartialEq, Eq)]
|
||||
+pub enum Shell {
|
||||
+ Zsh(String),
|
||||
+ Unknown,
|
||||
+}
|
||||
+
|
||||
+impl Shell {
|
||||
+ pub fn run_with_profile(&self, command: Vec<String>) -> Option<Vec<String>> {
|
||||
+ match self {
|
||||
+ Shell::Zsh(shell_path) => {
|
||||
+ let mut result = vec![shell_path.clone(), "-c".to_string()];
|
||||
+ if let Ok(joined) = shlex::try_join(command.iter().map(|s| s.as_str())) {
|
||||
+ result.push(format!("source ~/.zshrc && ({joined})"));
|
||||
```
|
||||
|
||||
> A well-behaved `~/.zshrc` should not do this, but it could write to stdout/stderr and interfere with the tool call output, no?
|
||||
|
||||
- Created: 2025-07-25 05:27:20 UTC | Link: https://github.com/openai/codex/pull/1678#discussion_r2230170271
|
||||
|
||||
```diff
|
||||
@@ -0,0 +1,125 @@
|
||||
+use shlex;
|
||||
+use std::process::Command;
|
||||
+use whoami;
|
||||
+
|
||||
+#[derive(Debug, PartialEq, Eq)]
|
||||
+pub enum Shell {
|
||||
+ Zsh(String),
|
||||
+ Unknown,
|
||||
+}
|
||||
+
|
||||
+impl Shell {
|
||||
+ pub fn run_with_profile(&self, command: Vec<String>) -> Option<Vec<String>> {
|
||||
+ match self {
|
||||
+ Shell::Zsh(shell_path) => {
|
||||
+ let mut result = vec![shell_path.clone(), "-c".to_string()];
|
||||
+ if let Ok(joined) = shlex::try_join(command.iter().map(|s| s.as_str())) {
|
||||
+ result.push(format!("source ~/.zshrc && ({joined})"));
|
||||
```
|
||||
|
||||
> Out of curiosity, are the parens around necessary?
|
||||
>
|
||||
> Though I did try the following and it still exits `42`, at least:
|
||||
>
|
||||
> ```
|
||||
> ls && (python -c 'import sys; sys.exit(42)')
|
||||
> ```
|
||||
|
||||
- Created: 2025-07-25 18:33:06 UTC | Link: https://github.com/openai/codex/pull/1678#discussion_r2231777388
|
||||
|
||||
```diff
|
||||
@@ -0,0 +1,203 @@
|
||||
+use shlex;
|
||||
+
|
||||
+#[derive(Debug, PartialEq, Eq)]
|
||||
+pub struct ZshShell {
|
||||
+ shell_path: String,
|
||||
+ zshrc_path: String,
|
||||
+}
|
||||
+
|
||||
+#[derive(Debug, PartialEq, Eq)]
|
||||
+pub enum Shell {
|
||||
+ Zsh(ZshShell),
|
||||
+ Unknown,
|
||||
+}
|
||||
+
|
||||
+impl Shell {
|
||||
+ pub fn run_with_profile(&self, command: Vec<String>) -> Option<Vec<String>> {
|
||||
+ match self {
|
||||
+ Shell::Zsh(zsh) => {
|
||||
+ if !std::path::Path::new(&zsh.zshrc_path).exists() {
|
||||
+ return None;
|
||||
+ }
|
||||
+
|
||||
+ let mut result = vec![zsh.shell_path.clone(), "-c".to_string()];
|
||||
+ if let Ok(joined) = shlex::try_join(command.iter().map(|s| s.as_str())) {
|
||||
+ result.push(format!("source {} && ({joined})", zsh.zshrc_path));
|
||||
+ } else {
|
||||
+ return None;
|
||||
+ }
|
||||
+ Some(result)
|
||||
+ }
|
||||
+ Shell::Unknown => None,
|
||||
+ }
|
||||
+ }
|
||||
+}
|
||||
+
|
||||
+#[cfg(target_os = "macos")]
|
||||
+pub async fn default_user_shell() -> Shell {
|
||||
+ use tokio::process::Command;
|
||||
+ use whoami;
|
||||
+
|
||||
+ let user = whoami::username();
|
||||
+ let home = format!("/Users/{user}");
|
||||
+ let output = Command::new("dscl")
|
||||
+ .args([".", "-read", &home, "UserShell"])
|
||||
+ .output()
|
||||
+ .await
|
||||
+ .ok();
|
||||
+ match output {
|
||||
+ Some(o) => {
|
||||
+ if !o.status.success() {
|
||||
+ return Shell::Unknown;
|
||||
+ }
|
||||
+ let stdout = String::from_utf8_lossy(&o.stdout);
|
||||
+ for line in stdout.lines() {
|
||||
+ if let Some(shell_path) = line.strip_prefix("UserShell: ") {
|
||||
+ if shell_path.ends_with("/zsh") {
|
||||
+ return Shell::Zsh(ZshShell {
|
||||
+ shell_path: shell_path.to_string(),
|
||||
+ zshrc_path: format!("{home}/.zshrc"),
|
||||
+ });
|
||||
+ }
|
||||
+ }
|
||||
+ }
|
||||
+
|
||||
+ Shell::Unknown
|
||||
+ }
|
||||
+ _ => Shell::Unknown,
|
||||
+ }
|
||||
+}
|
||||
+
|
||||
+#[cfg(not(target_os = "macos"))]
|
||||
+pub async fn default_user_shell() -> Shell {
|
||||
+ Shell::Unknown
|
||||
+}
|
||||
+
|
||||
+#[cfg(test)]
|
||||
+#[cfg(target_os = "macos")]
|
||||
+mod tests {
|
||||
+ use super::*;
|
||||
+ use std::process::Command;
|
||||
+
|
||||
+ #[tokio::test]
|
||||
+ #[expect(clippy::unwrap_used)]
|
||||
+ async fn test_current_shell_detects_zsh() {
|
||||
+ let shell = Command::new("sh")
|
||||
+ .arg("-c")
|
||||
+ .arg("echo $SHELL")
|
||||
+ .output()
|
||||
+ .unwrap();
|
||||
+
|
||||
+ let home = std::env::var("HOME").unwrap();
|
||||
+ let shell_path = String::from_utf8_lossy(&shell.stdout).trim().to_string();
|
||||
+ if shell_path.ends_with("/zsh") {
|
||||
+ assert_eq!(
|
||||
```
|
||||
|
||||
> I guess it is fair to assume this test should pass on an arbitrary Mac?
|
||||
|
||||
- Created: 2025-07-25 18:35:41 UTC | Link: https://github.com/openai/codex/pull/1678#discussion_r2231780950
|
||||
|
||||
```diff
|
||||
@@ -0,0 +1,203 @@
|
||||
+use shlex;
|
||||
+
|
||||
+#[derive(Debug, PartialEq, Eq)]
|
||||
+pub struct ZshShell {
|
||||
+ shell_path: String,
|
||||
+ zshrc_path: String,
|
||||
+}
|
||||
+
|
||||
+#[derive(Debug, PartialEq, Eq)]
|
||||
+pub enum Shell {
|
||||
+ Zsh(ZshShell),
|
||||
+ Unknown,
|
||||
+}
|
||||
+
|
||||
+impl Shell {
|
||||
+ pub fn run_with_profile(&self, command: Vec<String>) -> Option<Vec<String>> {
|
||||
```
|
||||
|
||||
> This doesn't actually "run" anything: it just rewrites the command? Maybe `create_command_shell_invocation()`? I don't know, naming is hard...
|
||||
168
prs/bolinfest/PR-1680.md
Normal file
168
prs/bolinfest/PR-1680.md
Normal file
@@ -0,0 +1,168 @@
|
||||
# PR #1680: Check ripgrep availability before suggesting it
|
||||
|
||||
- URL: https://github.com/openai/codex/pull/1680
|
||||
- Author: ae-openai
|
||||
- Created: 2025-07-25 17:46:08 UTC
|
||||
- Updated: 2025-07-25 21:08:57 UTC
|
||||
- Changes: +28/-2, Files changed: 2, Commits: 1
|
||||
|
||||
## Description
|
||||
|
||||
## Summary
|
||||
- avoid recommending rg when it is not installed
|
||||
- detect rg once and adjust base instructions
|
||||
|
||||
## Testing
|
||||
- `just fmt` *(fails: cargo not found)*
|
||||
- `just fix` *(fails: cargo not found)*
|
||||
- `cargo test --all-features` *(fails: cargo not found)*
|
||||
|
||||
------
|
||||
https://chatgpt.com/codex/tasks/task_i_6883ac0090c4832cb8434edc024685b7
|
||||
|
||||
## Full Diff
|
||||
|
||||
```diff
|
||||
diff --git a/codex-rs/core/Cargo.toml b/codex-rs/core/Cargo.toml
|
||||
index 62e462bf97..1db49fd21b 100644
|
||||
--- a/codex-rs/core/Cargo.toml
|
||||
+++ b/codex-rs/core/Cargo.toml
|
||||
@@ -25,6 +25,7 @@ futures = "0.3"
|
||||
libc = "0.2.174"
|
||||
mcp-types = { path = "../mcp-types" }
|
||||
mime_guess = "2.0"
|
||||
+once_cell = "1"
|
||||
rand = "0.9"
|
||||
reqwest = { version = "0.12", features = ["json", "stream"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
diff --git a/codex-rs/core/src/client_common.rs b/codex-rs/core/src/client_common.rs
|
||||
index afd2f04556..caf5a0af11 100644
|
||||
--- a/codex-rs/core/src/client_common.rs
|
||||
+++ b/codex-rs/core/src/client_common.rs
|
||||
@@ -12,11 +12,35 @@ use std::pin::Pin;
|
||||
use std::task::Context;
|
||||
use std::task::Poll;
|
||||
use tokio::sync::mpsc;
|
||||
+use once_cell::sync::Lazy;
|
||||
+use std::process::Command;
|
||||
+use std::process::Stdio;
|
||||
|
||||
/// The `instructions` field in the payload sent to a model should always start
|
||||
/// with this content.
|
||||
const BASE_INSTRUCTIONS: &str = include_str!("../prompt.md");
|
||||
|
||||
+static RG_AVAILABLE: Lazy<bool> = Lazy::new(|| {
|
||||
+ Command::new("rg")
|
||||
+ .arg("--version")
|
||||
+ .stdout(Stdio::null())
|
||||
+ .stderr(Stdio::null())
|
||||
+ .status()
|
||||
+ .map(|s| s.success())
|
||||
+ .unwrap_or(false)
|
||||
+});
|
||||
+
|
||||
+const RG_LINE: &str = "- Do not use `ls -R`, `find`, or `grep` - these are slow in large repos. Use `rg` and `rg --files`.";
|
||||
+const RG_LINE_NO_RG: &str = "- Do not use `ls -R`, `find`, or `grep` - these are slow in large repos.";
|
||||
+
|
||||
+fn base_instructions() -> Cow<'static, str> {
|
||||
+ if *RG_AVAILABLE {
|
||||
+ Cow::Borrowed(BASE_INSTRUCTIONS)
|
||||
+ } else {
|
||||
+ Cow::Owned(BASE_INSTRUCTIONS.replace(RG_LINE, RG_LINE_NO_RG))
|
||||
+ }
|
||||
+}
|
||||
+
|
||||
/// API request payload for a single model turn.
|
||||
#[derive(Default, Debug, Clone)]
|
||||
pub struct Prompt {
|
||||
@@ -42,8 +66,9 @@ impl Prompt {
|
||||
let base = self
|
||||
.base_instructions_override
|
||||
.as_deref()
|
||||
- .unwrap_or(BASE_INSTRUCTIONS);
|
||||
- let mut sections: Vec<&str> = vec![base];
|
||||
+ .map(Cow::Borrowed)
|
||||
+ .unwrap_or_else(|| base_instructions());
|
||||
+ let mut sections: Vec<&str> = vec![base.as_ref()];
|
||||
if let Some(ref user) = self.user_instructions {
|
||||
sections.push(user);
|
||||
}
|
||||
```
|
||||
|
||||
## Review Comments
|
||||
|
||||
### codex-rs/core/src/client_common.rs
|
||||
|
||||
- Created: 2025-07-25 21:04:26 UTC | Link: https://github.com/openai/codex/pull/1680#discussion_r2232005875
|
||||
|
||||
```diff
|
||||
@@ -12,11 +12,35 @@ use std::pin::Pin;
|
||||
use std::task::Context;
|
||||
use std::task::Poll;
|
||||
use tokio::sync::mpsc;
|
||||
+use once_cell::sync::Lazy;
|
||||
+use std::process::Command;
|
||||
+use std::process::Stdio;
|
||||
|
||||
/// The `instructions` field in the payload sent to a model should always start
|
||||
/// with this content.
|
||||
const BASE_INSTRUCTIONS: &str = include_str!("../prompt.md");
|
||||
|
||||
+static RG_AVAILABLE: Lazy<bool> = Lazy::new(|| {
|
||||
+ Command::new("rg")
|
||||
+ .arg("--version")
|
||||
+ .stdout(Stdio::null())
|
||||
+ .stderr(Stdio::null())
|
||||
+ .status()
|
||||
+ .map(|s| s.success())
|
||||
+ .unwrap_or(false)
|
||||
+});
|
||||
+
|
||||
+const RG_LINE: &str = "- Do not use `ls -R`, `find`, or `grep` - these are slow in large repos. Use `rg` and `rg --files`.";
|
||||
+const RG_LINE_NO_RG: &str = "- Do not use `ls -R`, `find`, or `grep` - these are slow in large repos.";
|
||||
+
|
||||
+fn base_instructions() -> Cow<'static, str> {
|
||||
+ if *RG_AVAILABLE {
|
||||
+ Cow::Borrowed(BASE_INSTRUCTIONS)
|
||||
+ } else {
|
||||
+ Cow::Owned(BASE_INSTRUCTIONS.replace(RG_LINE, RG_LINE_NO_RG))
|
||||
```
|
||||
|
||||
> I feel like we need a test to verify that `RG_LINE` exists in `BASE_INSTRUCTIONS`. That way, if we update `prompt.md`, we are careful to update this function, as well.
|
||||
|
||||
- Created: 2025-07-25 21:08:40 UTC | Link: https://github.com/openai/codex/pull/1680#discussion_r2232011072
|
||||
|
||||
```diff
|
||||
@@ -12,11 +12,35 @@ use std::pin::Pin;
|
||||
use std::task::Context;
|
||||
use std::task::Poll;
|
||||
use tokio::sync::mpsc;
|
||||
+use once_cell::sync::Lazy;
|
||||
+use std::process::Command;
|
||||
+use std::process::Stdio;
|
||||
|
||||
/// The `instructions` field in the payload sent to a model should always start
|
||||
/// with this content.
|
||||
const BASE_INSTRUCTIONS: &str = include_str!("../prompt.md");
|
||||
|
||||
+static RG_AVAILABLE: Lazy<bool> = Lazy::new(|| {
|
||||
+ Command::new("rg")
|
||||
+ .arg("--version")
|
||||
+ .stdout(Stdio::null())
|
||||
+ .stderr(Stdio::null())
|
||||
+ .status()
|
||||
+ .map(|s| s.success())
|
||||
+ .unwrap_or(false)
|
||||
+});
|
||||
+
|
||||
+const RG_LINE: &str = "- Do not use `ls -R`, `find`, or `grep` - these are slow in large repos. Use `rg` and `rg --files`.";
|
||||
+const RG_LINE_NO_RG: &str = "- Do not use `ls -R`, `find`, or `grep` - these are slow in large repos.";
|
||||
+
|
||||
+fn base_instructions() -> Cow<'static, str> {
|
||||
+ if *RG_AVAILABLE {
|
||||
+ Cow::Borrowed(BASE_INSTRUCTIONS)
|
||||
+ } else {
|
||||
+ Cow::Owned(BASE_INSTRUCTIONS.replace(RG_LINE, RG_LINE_NO_RG))
|
||||
```
|
||||
|
||||
> Also, there appear to be backslashes in front of the backticks in `prompt.md`, so are we sure this matches?
|
||||
259
prs/bolinfest/PR-1683.md
Normal file
259
prs/bolinfest/PR-1683.md
Normal file
@@ -0,0 +1,259 @@
|
||||
# PR #1683: fix: crash on resize
|
||||
|
||||
- URL: https://github.com/openai/codex/pull/1683
|
||||
- Author: nornagon-openai
|
||||
- Created: 2025-07-25 20:47:32 UTC
|
||||
- Updated: 2025-07-25 21:23:45 UTC
|
||||
- Changes: +44/-32, Files changed: 1, Commits: 2
|
||||
|
||||
## Description
|
||||
|
||||
Without this, resizing the terminal prints "Error: The cursor position could not be read within a normal duration" and quits the app.
|
||||
|
||||
## Full Diff
|
||||
|
||||
```diff
|
||||
diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs
|
||||
index 4e2133a6be..e7097e6af0 100644
|
||||
--- a/codex-rs/tui/src/app.rs
|
||||
+++ b/codex-rs/tui/src/app.rs
|
||||
@@ -88,39 +88,51 @@ impl App<'_> {
|
||||
{
|
||||
let app_event_tx = app_event_tx.clone();
|
||||
std::thread::spawn(move || {
|
||||
- while let Ok(event) = crossterm::event::read() {
|
||||
- match event {
|
||||
- crossterm::event::Event::Key(key_event) => {
|
||||
- app_event_tx.send(AppEvent::KeyEvent(key_event));
|
||||
- }
|
||||
- crossterm::event::Event::Resize(_, _) => {
|
||||
- app_event_tx.send(AppEvent::RequestRedraw);
|
||||
- }
|
||||
- crossterm::event::Event::Mouse(MouseEvent {
|
||||
- kind: MouseEventKind::ScrollUp,
|
||||
- ..
|
||||
- }) => {
|
||||
- scroll_event_helper.scroll_up();
|
||||
- }
|
||||
- crossterm::event::Event::Mouse(MouseEvent {
|
||||
- kind: MouseEventKind::ScrollDown,
|
||||
- ..
|
||||
- }) => {
|
||||
- scroll_event_helper.scroll_down();
|
||||
- }
|
||||
- crossterm::event::Event::Paste(pasted) => {
|
||||
- // Many terminals convert newlines to \r when
|
||||
- // pasting, e.g. [iTerm2][]. But [tui-textarea
|
||||
- // expects \n][tui-textarea]. This seems like a bug
|
||||
- // in tui-textarea IMO, but work around it for now.
|
||||
- // [tui-textarea]: https://github.com/rhysd/tui-textarea/blob/4d18622eeac13b309e0ff6a55a46ac6706da68cf/src/textarea.rs#L782-L783
|
||||
- // [iTerm2]: https://github.com/gnachman/iTerm2/blob/5d0c0d9f68523cbd0494dad5422998964a2ecd8d/sources/iTermPasteHelper.m#L206-L216
|
||||
- let pasted = pasted.replace("\r", "\n");
|
||||
- app_event_tx.send(AppEvent::Paste(pasted));
|
||||
- }
|
||||
- _ => {
|
||||
- // Ignore any other events.
|
||||
+ loop {
|
||||
+ // This timeout is necessary to avoid holding the event lock
|
||||
+ // that crossterm::event::read() acquires. In particular,
|
||||
+ // reading the cursor position (crossterm::cursor::position())
|
||||
+ // needs to acquire the event lock, and so will fail if it
|
||||
+ // can't acquire it within 2 sec. Resizing the terminal
|
||||
+ // crashes the app if the cursor position can't be read.
|
||||
+ if let Ok(true) = crossterm::event::poll(Duration::from_millis(100)) {
|
||||
+ if let Ok(event) = crossterm::event::read() {
|
||||
+ match event {
|
||||
+ crossterm::event::Event::Key(key_event) => {
|
||||
+ app_event_tx.send(AppEvent::KeyEvent(key_event));
|
||||
+ }
|
||||
+ crossterm::event::Event::Resize(_, _) => {
|
||||
+ app_event_tx.send(AppEvent::RequestRedraw);
|
||||
+ }
|
||||
+ crossterm::event::Event::Mouse(MouseEvent {
|
||||
+ kind: MouseEventKind::ScrollUp,
|
||||
+ ..
|
||||
+ }) => {
|
||||
+ scroll_event_helper.scroll_up();
|
||||
+ }
|
||||
+ crossterm::event::Event::Mouse(MouseEvent {
|
||||
+ kind: MouseEventKind::ScrollDown,
|
||||
+ ..
|
||||
+ }) => {
|
||||
+ scroll_event_helper.scroll_down();
|
||||
+ }
|
||||
+ crossterm::event::Event::Paste(pasted) => {
|
||||
+ // Many terminals convert newlines to \r when
|
||||
+ // pasting, e.g. [iTerm2][]. But [tui-textarea
|
||||
+ // expects \n][tui-textarea]. This seems like a bug
|
||||
+ // in tui-textarea IMO, but work around it for now.
|
||||
+ // [tui-textarea]: https://github.com/rhysd/tui-textarea/blob/4d18622eeac13b309e0ff6a55a46ac6706da68cf/src/textarea.rs#L782-L783
|
||||
+ // [iTerm2]: https://github.com/gnachman/iTerm2/blob/5d0c0d9f68523cbd0494dad5422998964a2ecd8d/sources/iTermPasteHelper.m#L206-L216
|
||||
+ let pasted = pasted.replace("\r", "\n");
|
||||
+ app_event_tx.send(AppEvent::Paste(pasted));
|
||||
+ }
|
||||
+ _ => {
|
||||
+ // Ignore any other events.
|
||||
+ }
|
||||
+ }
|
||||
}
|
||||
+ } else {
|
||||
+ // Timeout expired, no `Event` is available
|
||||
}
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
## Review Comments
|
||||
|
||||
### codex-rs/tui/src/app.rs
|
||||
|
||||
- Created: 2025-07-25 21:12:39 UTC | Link: https://github.com/openai/codex/pull/1683#discussion_r2232016460
|
||||
|
||||
```diff
|
||||
@@ -88,39 +88,51 @@ impl App<'_> {
|
||||
{
|
||||
let app_event_tx = app_event_tx.clone();
|
||||
std::thread::spawn(move || {
|
||||
- while let Ok(event) = crossterm::event::read() {
|
||||
- match event {
|
||||
- crossterm::event::Event::Key(key_event) => {
|
||||
- app_event_tx.send(AppEvent::KeyEvent(key_event));
|
||||
- }
|
||||
- crossterm::event::Event::Resize(_, _) => {
|
||||
- app_event_tx.send(AppEvent::RequestRedraw);
|
||||
- }
|
||||
- crossterm::event::Event::Mouse(MouseEvent {
|
||||
- kind: MouseEventKind::ScrollUp,
|
||||
- ..
|
||||
- }) => {
|
||||
- scroll_event_helper.scroll_up();
|
||||
- }
|
||||
- crossterm::event::Event::Mouse(MouseEvent {
|
||||
- kind: MouseEventKind::ScrollDown,
|
||||
- ..
|
||||
- }) => {
|
||||
- scroll_event_helper.scroll_down();
|
||||
- }
|
||||
- crossterm::event::Event::Paste(pasted) => {
|
||||
- // Many terminals convert newlines to \r when
|
||||
- // pasting, e.g. [iTerm2][]. But [tui-textarea
|
||||
- // expects \n][tui-textarea]. This seems like a bug
|
||||
- // in tui-textarea IMO, but work around it for now.
|
||||
- // [tui-textarea]: https://github.com/rhysd/tui-textarea/blob/4d18622eeac13b309e0ff6a55a46ac6706da68cf/src/textarea.rs#L782-L783
|
||||
- // [iTerm2]: https://github.com/gnachman/iTerm2/blob/5d0c0d9f68523cbd0494dad5422998964a2ecd8d/sources/iTermPasteHelper.m#L206-L216
|
||||
- let pasted = pasted.replace("\r", "\n");
|
||||
- app_event_tx.send(AppEvent::Paste(pasted));
|
||||
- }
|
||||
- _ => {
|
||||
- // Ignore any other events.
|
||||
+ loop {
|
||||
+ // This timeout is necessary to avoid holding the event lock
|
||||
+ // that crossterm::event::read() acquires. In particular,
|
||||
+ // reading the cursor position (crossterm::cursor::position())
|
||||
+ // needs to acquire the event lock, and so will fail if it
|
||||
+ // can't acquire it within 2 sec. Resizing the terminal
|
||||
+ // crashes the app if the cursor position can't be read.
|
||||
+ if let Ok(true) = crossterm::event::poll(Duration::from_millis(100)) {
|
||||
```
|
||||
|
||||
> This is...surprising. Maybe link to https://github.com/ratatui/ratatui/blob/9836f0760d4a053d9d1eba78171be89cb22dc850/examples/apps/inline/src/main.rs#L98-L118 to cite an example from Ratatui itself?
|
||||
|
||||
- Created: 2025-07-25 21:12:49 UTC | Link: https://github.com/openai/codex/pull/1683#discussion_r2232017016
|
||||
|
||||
```diff
|
||||
@@ -88,39 +88,51 @@ impl App<'_> {
|
||||
{
|
||||
let app_event_tx = app_event_tx.clone();
|
||||
std::thread::spawn(move || {
|
||||
- while let Ok(event) = crossterm::event::read() {
|
||||
- match event {
|
||||
- crossterm::event::Event::Key(key_event) => {
|
||||
- app_event_tx.send(AppEvent::KeyEvent(key_event));
|
||||
- }
|
||||
- crossterm::event::Event::Resize(_, _) => {
|
||||
- app_event_tx.send(AppEvent::RequestRedraw);
|
||||
- }
|
||||
- crossterm::event::Event::Mouse(MouseEvent {
|
||||
- kind: MouseEventKind::ScrollUp,
|
||||
- ..
|
||||
- }) => {
|
||||
- scroll_event_helper.scroll_up();
|
||||
- }
|
||||
- crossterm::event::Event::Mouse(MouseEvent {
|
||||
- kind: MouseEventKind::ScrollDown,
|
||||
- ..
|
||||
- }) => {
|
||||
- scroll_event_helper.scroll_down();
|
||||
- }
|
||||
- crossterm::event::Event::Paste(pasted) => {
|
||||
- // Many terminals convert newlines to \r when
|
||||
- // pasting, e.g. [iTerm2][]. But [tui-textarea
|
||||
- // expects \n][tui-textarea]. This seems like a bug
|
||||
- // in tui-textarea IMO, but work around it for now.
|
||||
- // [tui-textarea]: https://github.com/rhysd/tui-textarea/blob/4d18622eeac13b309e0ff6a55a46ac6706da68cf/src/textarea.rs#L782-L783
|
||||
- // [iTerm2]: https://github.com/gnachman/iTerm2/blob/5d0c0d9f68523cbd0494dad5422998964a2ecd8d/sources/iTermPasteHelper.m#L206-L216
|
||||
- let pasted = pasted.replace("\r", "\n");
|
||||
- app_event_tx.send(AppEvent::Paste(pasted));
|
||||
- }
|
||||
- _ => {
|
||||
- // Ignore any other events.
|
||||
+ loop {
|
||||
+ // This timeout is necessary to avoid holding the event lock
|
||||
+ // that crossterm::event::read() acquires. In particular,
|
||||
+ // reading the cursor position (crossterm::cursor::position())
|
||||
+ // needs to acquire the event lock, and so will fail if it
|
||||
+ // can't acquire it within 2 sec. Resizing the terminal
|
||||
```
|
||||
|
||||
> 2 sec or 100ms?
|
||||
|
||||
- Created: 2025-07-25 21:14:37 UTC | Link: https://github.com/openai/codex/pull/1683#discussion_r2232019640
|
||||
|
||||
```diff
|
||||
@@ -88,39 +88,51 @@ impl App<'_> {
|
||||
{
|
||||
let app_event_tx = app_event_tx.clone();
|
||||
std::thread::spawn(move || {
|
||||
- while let Ok(event) = crossterm::event::read() {
|
||||
- match event {
|
||||
- crossterm::event::Event::Key(key_event) => {
|
||||
- app_event_tx.send(AppEvent::KeyEvent(key_event));
|
||||
- }
|
||||
- crossterm::event::Event::Resize(_, _) => {
|
||||
- app_event_tx.send(AppEvent::RequestRedraw);
|
||||
- }
|
||||
- crossterm::event::Event::Mouse(MouseEvent {
|
||||
- kind: MouseEventKind::ScrollUp,
|
||||
- ..
|
||||
- }) => {
|
||||
- scroll_event_helper.scroll_up();
|
||||
- }
|
||||
- crossterm::event::Event::Mouse(MouseEvent {
|
||||
- kind: MouseEventKind::ScrollDown,
|
||||
- ..
|
||||
- }) => {
|
||||
- scroll_event_helper.scroll_down();
|
||||
- }
|
||||
- crossterm::event::Event::Paste(pasted) => {
|
||||
- // Many terminals convert newlines to \r when
|
||||
- // pasting, e.g. [iTerm2][]. But [tui-textarea
|
||||
- // expects \n][tui-textarea]. This seems like a bug
|
||||
- // in tui-textarea IMO, but work around it for now.
|
||||
- // [tui-textarea]: https://github.com/rhysd/tui-textarea/blob/4d18622eeac13b309e0ff6a55a46ac6706da68cf/src/textarea.rs#L782-L783
|
||||
- // [iTerm2]: https://github.com/gnachman/iTerm2/blob/5d0c0d9f68523cbd0494dad5422998964a2ecd8d/sources/iTermPasteHelper.m#L206-L216
|
||||
- let pasted = pasted.replace("\r", "\n");
|
||||
- app_event_tx.send(AppEvent::Paste(pasted));
|
||||
- }
|
||||
- _ => {
|
||||
- // Ignore any other events.
|
||||
+ loop {
|
||||
+ // This timeout is necessary to avoid holding the event lock
|
||||
+ // that crossterm::event::read() acquires. In particular,
|
||||
+ // reading the cursor position (crossterm::cursor::position())
|
||||
+ // needs to acquire the event lock, and so will fail if it
|
||||
+ // can't acquire it within 2 sec. Resizing the terminal
|
||||
+ // crashes the app if the cursor position can't be read.
|
||||
+ if let Ok(true) = crossterm::event::poll(Duration::from_millis(100)) {
|
||||
```
|
||||
|
||||
> Also, it looks like that example is doing something slightly different: should we be modeling it more closely?
|
||||
331
prs/bolinfest/PR-1684.md
Normal file
331
prs/bolinfest/PR-1684.md
Normal file
@@ -0,0 +1,331 @@
|
||||
# PR #1684: Changing method in MCP notifications
|
||||
|
||||
- URL: https://github.com/openai/codex/pull/1684
|
||||
- Author: aibrahim-oai
|
||||
- Created: 2025-07-26 00:11:20 UTC
|
||||
- Updated: 2025-07-27 03:43:38 UTC
|
||||
- Changes: +50/-12, Files changed: 3, Commits: 12
|
||||
|
||||
## Description
|
||||
|
||||
- Changing the codex/event type
|
||||
|
||||
## Full Diff
|
||||
|
||||
```diff
|
||||
diff --git a/codex-rs/core/src/protocol.rs b/codex-rs/core/src/protocol.rs
|
||||
index cf6e8b5191..1a6313db92 100644
|
||||
--- a/codex-rs/core/src/protocol.rs
|
||||
+++ b/codex-rs/core/src/protocol.rs
|
||||
@@ -278,8 +278,9 @@ pub struct Event {
|
||||
}
|
||||
|
||||
/// Response event from the agent
|
||||
-#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
+#[derive(Debug, Clone, Deserialize, Serialize, Display)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
+#[strum(serialize_all = "lowercase")]
|
||||
pub enum EventMsg {
|
||||
/// Error while executing a submission
|
||||
Error(ErrorEvent),
|
||||
diff --git a/codex-rs/mcp-server/src/outgoing_message.rs b/codex-rs/mcp-server/src/outgoing_message.rs
|
||||
index a1eea65f25..e4af1f78cd 100644
|
||||
--- a/codex-rs/mcp-server/src/outgoing_message.rs
|
||||
+++ b/codex-rs/mcp-server/src/outgoing_message.rs
|
||||
@@ -83,11 +83,26 @@ impl OutgoingMessageSender {
|
||||
let params = Some(serde_json::to_value(event).expect("Event must serialize"));
|
||||
let outgoing_message = OutgoingMessage::Notification(OutgoingNotification {
|
||||
method: "codex/event".to_string(),
|
||||
+ params: params.clone(),
|
||||
+ });
|
||||
+ let _ = self.sender.send(outgoing_message).await;
|
||||
+
|
||||
+ self.send_event_as_notification_new_schema(event, params)
|
||||
+ .await;
|
||||
+ }
|
||||
+ // should be backwards compatible.
|
||||
+ // it will replace send_event_as_notification eventually.
|
||||
+ async fn send_event_as_notification_new_schema(
|
||||
+ &self,
|
||||
+ event: &Event,
|
||||
+ params: Option<serde_json::Value>,
|
||||
+ ) {
|
||||
+ let outgoing_message = OutgoingMessage::Notification(OutgoingNotification {
|
||||
+ method: event.msg.to_string(),
|
||||
params,
|
||||
});
|
||||
let _ = self.sender.send(outgoing_message).await;
|
||||
}
|
||||
-
|
||||
pub(crate) async fn send_error(&self, id: RequestId, error: JSONRPCErrorError) {
|
||||
let outgoing_message = OutgoingMessage::Error(OutgoingError { id, error });
|
||||
let _ = self.sender.send(outgoing_message).await;
|
||||
diff --git a/codex-rs/mcp-server/tests/common/mcp_process.rs b/codex-rs/mcp-server/tests/common/mcp_process.rs
|
||||
index b27a96eb89..528a40152f 100644
|
||||
--- a/codex-rs/mcp-server/tests/common/mcp_process.rs
|
||||
+++ b/codex-rs/mcp-server/tests/common/mcp_process.rs
|
||||
@@ -270,27 +270,49 @@ impl McpProcess {
|
||||
pub async fn read_stream_until_configured_response_message(
|
||||
&mut self,
|
||||
) -> anyhow::Result<String> {
|
||||
+ let mut sid_old: Option<String> = None;
|
||||
+ let mut sid_new: Option<String> = None;
|
||||
loop {
|
||||
let message = self.read_jsonrpc_message().await?;
|
||||
eprint!("message: {message:?}");
|
||||
|
||||
match message {
|
||||
JSONRPCMessage::Notification(notification) => {
|
||||
- if notification.method == "codex/event" {
|
||||
- if let Some(params) = notification.params {
|
||||
+ if let Some(params) = notification.params {
|
||||
+ // Back-compat schema: method == "codex/event" and msg.type == "session_configured"
|
||||
+ if notification.method == "codex/event" {
|
||||
if let Some(msg) = params.get("msg") {
|
||||
- if let Some(msg_type) = msg.get("type") {
|
||||
- if msg_type == "session_configured" {
|
||||
- if let Some(session_id) = msg.get("session_id") {
|
||||
- return Ok(session_id
|
||||
- .to_string()
|
||||
- .trim_matches('"')
|
||||
- .to_string());
|
||||
- }
|
||||
+ if msg.get("type").and_then(|v| v.as_str())
|
||||
+ == Some("session_configured")
|
||||
+ {
|
||||
+ if let Some(session_id) =
|
||||
+ msg.get("session_id").and_then(|v| v.as_str())
|
||||
+ {
|
||||
+ sid_old = Some(session_id.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+ // New schema: method is the Display of EventMsg::SessionConfigured => "SessionConfigured"
|
||||
+ if notification.method == "sessionconfigured" {
|
||||
+ if let Some(msg) = params.get("msg") {
|
||||
+ if let Some(session_id) =
|
||||
+ msg.get("session_id").and_then(|v| v.as_str())
|
||||
+ {
|
||||
+ sid_new = Some(session_id.to_string());
|
||||
+ }
|
||||
+ }
|
||||
+ }
|
||||
+ }
|
||||
+
|
||||
+ if sid_old.is_some() && sid_new.is_some() {
|
||||
+ // Both seen, they must match
|
||||
+ assert_eq!(
|
||||
+ sid_old.as_ref().unwrap(),
|
||||
+ sid_new.as_ref().unwrap(),
|
||||
+ "session_id mismatch between old and new schema"
|
||||
+ );
|
||||
+ return Ok(sid_old.unwrap());
|
||||
}
|
||||
}
|
||||
JSONRPCMessage::Request(_) => {
|
||||
```
|
||||
|
||||
## Review Comments
|
||||
|
||||
### codex-rs/core/src/protocol.rs
|
||||
|
||||
- Created: 2025-07-26 03:09:43 UTC | Link: https://github.com/openai/codex/pull/1684#discussion_r2232408024
|
||||
|
||||
```diff
|
||||
@@ -338,6 +338,34 @@ pub enum EventMsg {
|
||||
ShutdownComplete,
|
||||
}
|
||||
|
||||
+impl fmt::Display for EventMsg {
|
||||
```
|
||||
|
||||
> Please use strum macros instead and use lowercase?
|
||||
|
||||
- Created: 2025-07-27 03:03:59 UTC | Link: https://github.com/openai/codex/pull/1684#discussion_r2233616677
|
||||
|
||||
```diff
|
||||
@@ -278,8 +278,9 @@ pub struct Event {
|
||||
}
|
||||
|
||||
/// Response event from the agent
|
||||
-#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
+#[derive(Debug, Clone, Deserialize, Serialize, Display)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
+#[strum(serialize_all = "lowercase")]
|
||||
```
|
||||
|
||||
> I misspoke, I think this should not be plain `lowercase`, but likely one of `snake_case` or `camelCase`, whatever we decide. While Rust prefers `snake_case`, MCP prefers `camelCase`, so perhaps we should adopt that here since the primary serialization use case is MCP?
|
||||
|
||||
### codex-rs/mcp-server/src/outgoing_message.rs
|
||||
|
||||
- Created: 2025-07-27 03:04:47 UTC | Link: https://github.com/openai/codex/pull/1684#discussion_r2233616831
|
||||
|
||||
```diff
|
||||
@@ -83,11 +83,26 @@ impl OutgoingMessageSender {
|
||||
let params = Some(serde_json::to_value(event).expect("Event must serialize"));
|
||||
let outgoing_message = OutgoingMessage::Notification(OutgoingNotification {
|
||||
method: "codex/event".to_string(),
|
||||
+ params: params.clone(),
|
||||
+ });
|
||||
+ let _ = self.sender.send(outgoing_message).await;
|
||||
+
|
||||
+ self.send_event_as_notification_new_schema(event, params)
|
||||
+ .await;
|
||||
+ }
|
||||
+ // should be backwards compatible.
|
||||
```
|
||||
|
||||
> I believe this is a net-new set of notifications. The old ones all have `codex/event` as the method whereas the new ones use the specific notification type. It should be fine to disambiguate.
|
||||
|
||||
- Created: 2025-07-27 03:05:20 UTC | Link: https://github.com/openai/codex/pull/1684#discussion_r2233617072
|
||||
|
||||
```diff
|
||||
@@ -83,11 +83,26 @@ impl OutgoingMessageSender {
|
||||
let params = Some(serde_json::to_value(event).expect("Event must serialize"));
|
||||
let outgoing_message = OutgoingMessage::Notification(OutgoingNotification {
|
||||
method: "codex/event".to_string(),
|
||||
+ params: params.clone(),
|
||||
+ });
|
||||
+ let _ = self.sender.send(outgoing_message).await;
|
||||
+
|
||||
+ self.send_event_as_notification_new_schema(event, params)
|
||||
+ .await;
|
||||
+ }
|
||||
+ // should be backwards compatible.
|
||||
+ // it will replace send_event_as_notification eventually.
|
||||
+ async fn send_event_as_notification_new_schema(
|
||||
+ &self,
|
||||
+ event: &Event,
|
||||
+ params: Option<serde_json::Value>,
|
||||
```
|
||||
|
||||
> Though we also plan to change the `params` in the new schema, but that is not obvious from this general datatype...
|
||||
|
||||
- Created: 2025-07-27 03:05:31 UTC | Link: https://github.com/openai/codex/pull/1684#discussion_r2233617124
|
||||
|
||||
```diff
|
||||
@@ -83,11 +83,26 @@ impl OutgoingMessageSender {
|
||||
let params = Some(serde_json::to_value(event).expect("Event must serialize"));
|
||||
let outgoing_message = OutgoingMessage::Notification(OutgoingNotification {
|
||||
method: "codex/event".to_string(),
|
||||
+ params: params.clone(),
|
||||
+ });
|
||||
+ let _ = self.sender.send(outgoing_message).await;
|
||||
+
|
||||
+ self.send_event_as_notification_new_schema(event, params)
|
||||
+ .await;
|
||||
+ }
|
||||
+ // should be backwards compatible.
|
||||
+ // it will replace send_event_as_notification eventually.
|
||||
+ async fn send_event_as_notification_new_schema(
|
||||
+ &self,
|
||||
+ event: &Event,
|
||||
+ params: Option<serde_json::Value>,
|
||||
+ ) {
|
||||
+ let outgoing_message = OutgoingMessage::Notification(OutgoingNotification {
|
||||
+ method: event.msg.to_string(),
|
||||
params,
|
||||
});
|
||||
let _ = self.sender.send(outgoing_message).await;
|
||||
}
|
||||
-
|
||||
```
|
||||
|
||||
> restore?
|
||||
|
||||
### codex-rs/mcp-server/tests/common/mcp_process.rs
|
||||
|
||||
- Created: 2025-07-27 01:36:44 UTC | Link: https://github.com/openai/codex/pull/1684#discussion_r2233580571
|
||||
|
||||
```diff
|
||||
@@ -270,27 +270,49 @@ impl McpProcess {
|
||||
pub async fn read_stream_until_configured_response_message(
|
||||
&mut self,
|
||||
) -> anyhow::Result<String> {
|
||||
+ let mut sid_old: Option<String> = None;
|
||||
+ let mut sid_new: Option<String> = None;
|
||||
loop {
|
||||
let message = self.read_jsonrpc_message().await?;
|
||||
eprint!("message: {message:?}");
|
||||
|
||||
match message {
|
||||
JSONRPCMessage::Notification(notification) => {
|
||||
- if notification.method == "codex/event" {
|
||||
- if let Some(params) = notification.params {
|
||||
+ if let Some(params) = notification.params {
|
||||
+ // Back-compat schema: method == "codex/event" and msg.type == "session_configured"
|
||||
+ if notification.method == "codex/event" {
|
||||
if let Some(msg) = params.get("msg") {
|
||||
- if let Some(msg_type) = msg.get("type") {
|
||||
- if msg_type == "session_configured" {
|
||||
- if let Some(session_id) = msg.get("session_id") {
|
||||
- return Ok(session_id
|
||||
- .to_string()
|
||||
- .trim_matches('"')
|
||||
- .to_string());
|
||||
- }
|
||||
+ if msg.get("type").and_then(|v| v.as_str())
|
||||
+ == Some("session_configured")
|
||||
+ {
|
||||
+ if let Some(session_id) =
|
||||
+ msg.get("session_id").and_then(|v| v.as_str())
|
||||
+ {
|
||||
+ sid_old = Some(session_id.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+ // New schema: method is the Display of EventMsg::SessionConfigured => "SessionConfigured"
|
||||
+ if notification.method == "sessionconfigured" {
|
||||
```
|
||||
|
||||
> It is normal to do this if you use serde or strum macros or something. You can also use these to override the name for individual variants.
|
||||
|
||||
- Created: 2025-07-27 03:08:24 UTC | Link: https://github.com/openai/codex/pull/1684#discussion_r2233618044
|
||||
|
||||
```diff
|
||||
@@ -270,27 +270,49 @@ impl McpProcess {
|
||||
pub async fn read_stream_until_configured_response_message(
|
||||
&mut self,
|
||||
) -> anyhow::Result<String> {
|
||||
+ let mut sid_old: Option<String> = None;
|
||||
+ let mut sid_new: Option<String> = None;
|
||||
loop {
|
||||
let message = self.read_jsonrpc_message().await?;
|
||||
eprint!("message: {message:?}");
|
||||
|
||||
match message {
|
||||
JSONRPCMessage::Notification(notification) => {
|
||||
- if notification.method == "codex/event" {
|
||||
- if let Some(params) = notification.params {
|
||||
+ if let Some(params) = notification.params {
|
||||
+ // Back-compat schema: method == "codex/event" and msg.type == "session_configured"
|
||||
+ if notification.method == "codex/event" {
|
||||
if let Some(msg) = params.get("msg") {
|
||||
- if let Some(msg_type) = msg.get("type") {
|
||||
- if msg_type == "session_configured" {
|
||||
- if let Some(session_id) = msg.get("session_id") {
|
||||
- return Ok(session_id
|
||||
- .to_string()
|
||||
- .trim_matches('"')
|
||||
- .to_string());
|
||||
- }
|
||||
+ if msg.get("type").and_then(|v| v.as_str())
|
||||
+ == Some("session_configured")
|
||||
+ {
|
||||
+ if let Some(session_id) =
|
||||
+ msg.get("session_id").and_then(|v| v.as_str())
|
||||
+ {
|
||||
+ sid_old = Some(session_id.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+ // New schema: method is the Display of EventMsg::SessionConfigured => "SessionConfigured"
|
||||
+ if notification.method == "sessionconfigured" {
|
||||
```
|
||||
|
||||
> Note we were also doing this before: it's just that it was on our custom `type` field instead of the `method` field.
|
||||
>
|
||||
> In practice, if you have good integration tests, it should catch the fact if someone renames the variant without thinking about the serialization implications. It's that or you end up with a lot of copy paste where ever `rename` line matches the variant name.
|
||||
1616
prs/bolinfest/PR-1685.md
Normal file
1616
prs/bolinfest/PR-1685.md
Normal file
File diff suppressed because it is too large
Load Diff
594
prs/bolinfest/PR-1686.md
Normal file
594
prs/bolinfest/PR-1686.md
Normal file
@@ -0,0 +1,594 @@
|
||||
# PR #1686: Introduce a new function to just send user message [Stack 3/3]
|
||||
|
||||
- URL: https://github.com/openai/codex/pull/1686
|
||||
- Author: aibrahim-oai
|
||||
- Created: 2025-07-26 01:29:04 UTC
|
||||
- Updated: 2025-08-01 17:04:20 UTC
|
||||
- Changes: +358/-32, Files changed: 6, Commits: 76
|
||||
|
||||
## Description
|
||||
|
||||
- MCP server: add send-user-message tool to send user input to a running Codex session
|
||||
- Added an integration tests for the happy and sad paths
|
||||
|
||||
Changes:
|
||||
• Add tool definition and schema.
|
||||
• Expose tool in capabilities.
|
||||
• Route and handle tool requests with validation.
|
||||
• Tests for success, bad UUID, and missing session.
|
||||
|
||||
|
||||
follow‑ups
|
||||
• Listen path not implemented yet; the tool is present but marked “don’t use yet” in code comments.
|
||||
• Session run flag reset: clear running_session_id_set appropriately after turn completion/errors.
|
||||
|
||||
This is the third PR in a stack.
|
||||
Stack:
|
||||
Final: #1686
|
||||
Intermediate: #1751
|
||||
First: #1750
|
||||
|
||||
## Full Diff
|
||||
|
||||
```diff
|
||||
diff --git a/codex-rs/mcp-server/src/lib.rs b/codex-rs/mcp-server/src/lib.rs
|
||||
index ebef8ca903..6b3c0ddbe6 100644
|
||||
--- a/codex-rs/mcp-server/src/lib.rs
|
||||
+++ b/codex-rs/mcp-server/src/lib.rs
|
||||
@@ -20,9 +20,10 @@ mod codex_tool_runner;
|
||||
mod exec_approval;
|
||||
mod json_to_toml;
|
||||
pub mod mcp_protocol;
|
||||
-mod message_processor;
|
||||
+pub(crate) mod message_processor;
|
||||
mod outgoing_message;
|
||||
mod patch_approval;
|
||||
+pub(crate) mod tool_handlers;
|
||||
|
||||
use crate::message_processor::MessageProcessor;
|
||||
use crate::outgoing_message::OutgoingMessage;
|
||||
diff --git a/codex-rs/mcp-server/src/mcp_protocol.rs b/codex-rs/mcp-server/src/mcp_protocol.rs
|
||||
index 23304dc43d..287890bf5e 100644
|
||||
--- a/codex-rs/mcp-server/src/mcp_protocol.rs
|
||||
+++ b/codex-rs/mcp-server/src/mcp_protocol.rs
|
||||
@@ -132,32 +132,32 @@ impl From<ToolCallResponse> for CallToolResult {
|
||||
is_error,
|
||||
result,
|
||||
} = val;
|
||||
- let (content, structured_content, is_error_out) = match result {
|
||||
+ match result {
|
||||
Some(res) => match serde_json::to_value(&res) {
|
||||
- Ok(v) => {
|
||||
- let content = vec![ContentBlock::TextContent(TextContent {
|
||||
+ Ok(v) => CallToolResult {
|
||||
+ content: vec![ContentBlock::TextContent(TextContent {
|
||||
r#type: "text".to_string(),
|
||||
text: v.to_string(),
|
||||
annotations: None,
|
||||
- })];
|
||||
- (content, Some(v), is_error)
|
||||
- }
|
||||
- Err(e) => {
|
||||
- let content = vec![ContentBlock::TextContent(TextContent {
|
||||
+ })],
|
||||
+ is_error,
|
||||
+ structured_content: Some(v),
|
||||
+ },
|
||||
+ Err(e) => CallToolResult {
|
||||
+ content: vec![ContentBlock::TextContent(TextContent {
|
||||
r#type: "text".to_string(),
|
||||
text: format!("Failed to serialize tool result: {e}"),
|
||||
annotations: None,
|
||||
- })];
|
||||
- (content, None, Some(true))
|
||||
- }
|
||||
+ })],
|
||||
+ is_error: Some(true),
|
||||
+ structured_content: None,
|
||||
+ },
|
||||
+ },
|
||||
+ None => CallToolResult {
|
||||
+ content: vec![],
|
||||
+ is_error,
|
||||
+ structured_content: None,
|
||||
},
|
||||
- None => (vec![], None, is_error),
|
||||
- };
|
||||
-
|
||||
- CallToolResult {
|
||||
- content,
|
||||
- is_error: is_error_out,
|
||||
- structured_content,
|
||||
}
|
||||
}
|
||||
}
|
||||
diff --git a/codex-rs/mcp-server/src/message_processor.rs b/codex-rs/mcp-server/src/message_processor.rs
|
||||
index a4013cc3a3..14e9bb3887 100644
|
||||
--- a/codex-rs/mcp-server/src/message_processor.rs
|
||||
+++ b/codex-rs/mcp-server/src/message_processor.rs
|
||||
@@ -1,4 +1,5 @@
|
||||
use std::collections::HashMap;
|
||||
+use std::collections::HashSet;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -7,11 +8,15 @@ use crate::codex_tool_config::CodexToolCallReplyParam;
|
||||
use crate::codex_tool_config::create_tool_for_codex_tool_call_param;
|
||||
use crate::codex_tool_config::create_tool_for_codex_tool_call_reply_param;
|
||||
use crate::mcp_protocol::ToolCallRequestParams;
|
||||
+use crate::mcp_protocol::ToolCallResponse;
|
||||
+use crate::mcp_protocol::ToolCallResponseResult;
|
||||
use crate::outgoing_message::OutgoingMessageSender;
|
||||
+use crate::tool_handlers::send_message::handle_send_message;
|
||||
|
||||
use codex_core::Codex;
|
||||
use codex_core::config::Config as CodexConfig;
|
||||
use codex_core::protocol::Submission;
|
||||
+use mcp_types::CallToolRequest;
|
||||
use mcp_types::CallToolRequestParams;
|
||||
use mcp_types::CallToolResult;
|
||||
use mcp_types::ClientRequest;
|
||||
@@ -38,6 +43,7 @@ pub(crate) struct MessageProcessor {
|
||||
codex_linux_sandbox_exe: Option<PathBuf>,
|
||||
session_map: Arc<Mutex<HashMap<Uuid, Arc<Codex>>>>,
|
||||
running_requests_id_to_codex_uuid: Arc<Mutex<HashMap<RequestId, Uuid>>>,
|
||||
+ running_session_ids: Arc<Mutex<HashSet<Uuid>>>,
|
||||
}
|
||||
|
||||
impl MessageProcessor {
|
||||
@@ -53,9 +59,18 @@ impl MessageProcessor {
|
||||
codex_linux_sandbox_exe,
|
||||
session_map: Arc::new(Mutex::new(HashMap::new())),
|
||||
running_requests_id_to_codex_uuid: Arc::new(Mutex::new(HashMap::new())),
|
||||
+ running_session_ids: Arc::new(Mutex::new(HashSet::new())),
|
||||
}
|
||||
}
|
||||
|
||||
+ pub(crate) fn session_map(&self) -> Arc<Mutex<HashMap<Uuid, Arc<Codex>>>> {
|
||||
+ self.session_map.clone()
|
||||
+ }
|
||||
+
|
||||
+ pub(crate) fn running_session_ids(&self) -> Arc<Mutex<HashSet<Uuid>>> {
|
||||
+ self.running_session_ids.clone()
|
||||
+ }
|
||||
+
|
||||
pub(crate) async fn process_request(&mut self, request: JSONRPCRequest) {
|
||||
// Hold on to the ID so we can respond.
|
||||
let request_id = request.id.clone();
|
||||
@@ -332,19 +347,25 @@ impl MessageProcessor {
|
||||
}
|
||||
}
|
||||
}
|
||||
- async fn handle_new_tool_calls(&self, request_id: RequestId, _params: ToolCallRequestParams) {
|
||||
- // TODO: implement the new tool calls
|
||||
- let result = CallToolResult {
|
||||
- content: vec![ContentBlock::TextContent(TextContent {
|
||||
- r#type: "text".to_string(),
|
||||
- text: "Unknown tool".to_string(),
|
||||
- annotations: None,
|
||||
- })],
|
||||
- is_error: Some(true),
|
||||
- structured_content: None,
|
||||
- };
|
||||
- self.send_response::<mcp_types::CallToolRequest>(request_id, result)
|
||||
- .await;
|
||||
+ async fn handle_new_tool_calls(&self, request_id: RequestId, params: ToolCallRequestParams) {
|
||||
+ match params {
|
||||
+ ToolCallRequestParams::ConversationSendMessage(args) => {
|
||||
+ handle_send_message(self, request_id, args).await;
|
||||
+ }
|
||||
+ _ => {
|
||||
+ let result = CallToolResult {
|
||||
+ content: vec![ContentBlock::TextContent(TextContent {
|
||||
+ r#type: "text".to_string(),
|
||||
+ text: "Unknown tool".to_string(),
|
||||
+ annotations: None,
|
||||
+ })],
|
||||
+ is_error: Some(true),
|
||||
+ structured_content: None,
|
||||
+ };
|
||||
+ self.send_response::<CallToolRequest>(request_id, result)
|
||||
+ .await;
|
||||
+ }
|
||||
+ }
|
||||
}
|
||||
|
||||
async fn handle_tool_call_codex(&self, id: RequestId, arguments: Option<serde_json::Value>) {
|
||||
@@ -654,4 +675,20 @@ impl MessageProcessor {
|
||||
) {
|
||||
tracing::info!("notifications/message -> params: {:?}", params);
|
||||
}
|
||||
+
|
||||
+ pub(crate) async fn send_response_with_optional_error(
|
||||
+ &self,
|
||||
+ id: RequestId,
|
||||
+ message: Option<ToolCallResponseResult>,
|
||||
+ error: Option<bool>,
|
||||
+ ) {
|
||||
+ let response = ToolCallResponse {
|
||||
+ request_id: id.clone(),
|
||||
+ is_error: error,
|
||||
+ result: message,
|
||||
+ };
|
||||
+ let result: CallToolResult = response.into();
|
||||
+ self.send_response::<mcp_types::CallToolRequest>(id.clone(), result)
|
||||
+ .await;
|
||||
+ }
|
||||
}
|
||||
diff --git a/codex-rs/mcp-server/src/tool_handlers/mod.rs b/codex-rs/mcp-server/src/tool_handlers/mod.rs
|
||||
new file mode 100644
|
||||
index 0000000000..1907ec64e0
|
||||
--- /dev/null
|
||||
+++ b/codex-rs/mcp-server/src/tool_handlers/mod.rs
|
||||
@@ -0,0 +1 @@
|
||||
+pub(crate) mod send_message;
|
||||
diff --git a/codex-rs/mcp-server/src/tool_handlers/send_message.rs b/codex-rs/mcp-server/src/tool_handlers/send_message.rs
|
||||
new file mode 100644
|
||||
index 0000000000..894176bef6
|
||||
--- /dev/null
|
||||
+++ b/codex-rs/mcp-server/src/tool_handlers/send_message.rs
|
||||
@@ -0,0 +1,124 @@
|
||||
+use std::collections::HashMap;
|
||||
+use std::sync::Arc;
|
||||
+
|
||||
+use codex_core::Codex;
|
||||
+use codex_core::protocol::Op;
|
||||
+use codex_core::protocol::Submission;
|
||||
+use mcp_types::RequestId;
|
||||
+use tokio::sync::Mutex;
|
||||
+use uuid::Uuid;
|
||||
+
|
||||
+use crate::mcp_protocol::ConversationSendMessageArgs;
|
||||
+use crate::mcp_protocol::ConversationSendMessageResult;
|
||||
+use crate::mcp_protocol::ToolCallResponseResult;
|
||||
+use crate::message_processor::MessageProcessor;
|
||||
+
|
||||
+pub(crate) async fn handle_send_message(
|
||||
+ message_processor: &MessageProcessor,
|
||||
+ id: RequestId,
|
||||
+ arguments: ConversationSendMessageArgs,
|
||||
+) {
|
||||
+ let ConversationSendMessageArgs {
|
||||
+ conversation_id,
|
||||
+ content: items,
|
||||
+ parent_message_id: _,
|
||||
+ conversation_overrides: _,
|
||||
+ } = arguments;
|
||||
+
|
||||
+ if items.is_empty() {
|
||||
+ message_processor
|
||||
+ .send_response_with_optional_error(
|
||||
+ id,
|
||||
+ Some(ToolCallResponseResult::ConversationSendMessage(
|
||||
+ ConversationSendMessageResult::Error {
|
||||
+ message: "No content items provided".to_string(),
|
||||
+ },
|
||||
+ )),
|
||||
+ Some(true),
|
||||
+ )
|
||||
+ .await;
|
||||
+ return;
|
||||
+ }
|
||||
+
|
||||
+ let session_id = conversation_id.0;
|
||||
+ let Some(codex) = get_session(session_id, message_processor.session_map()).await else {
|
||||
+ message_processor
|
||||
+ .send_response_with_optional_error(
|
||||
+ id,
|
||||
+ Some(ToolCallResponseResult::ConversationSendMessage(
|
||||
+ ConversationSendMessageResult::Error {
|
||||
+ message: "Session does not exist".to_string(),
|
||||
+ },
|
||||
+ )),
|
||||
+ Some(true),
|
||||
+ )
|
||||
+ .await;
|
||||
+ return;
|
||||
+ };
|
||||
+
|
||||
+ let running = {
|
||||
+ let running_sessions = message_processor.running_session_ids();
|
||||
+ let mut running_sessions = running_sessions.lock().await;
|
||||
+ !running_sessions.insert(session_id)
|
||||
+ };
|
||||
+
|
||||
+ if running {
|
||||
+ message_processor
|
||||
+ .send_response_with_optional_error(
|
||||
+ id,
|
||||
+ Some(ToolCallResponseResult::ConversationSendMessage(
|
||||
+ ConversationSendMessageResult::Error {
|
||||
+ message: "Session is already running".to_string(),
|
||||
+ },
|
||||
+ )),
|
||||
+ Some(true),
|
||||
+ )
|
||||
+ .await;
|
||||
+ return;
|
||||
+ }
|
||||
+
|
||||
+ let request_id_string = match &id {
|
||||
+ RequestId::String(s) => s.clone(),
|
||||
+ RequestId::Integer(i) => i.to_string(),
|
||||
+ };
|
||||
+
|
||||
+ let submit_res = codex
|
||||
+ .submit_with_id(Submission {
|
||||
+ id: request_id_string,
|
||||
+ op: Op::UserInput { items },
|
||||
+ })
|
||||
+ .await;
|
||||
+
|
||||
+ if let Err(e) = submit_res {
|
||||
+ message_processor
|
||||
+ .send_response_with_optional_error(
|
||||
+ id,
|
||||
+ Some(ToolCallResponseResult::ConversationSendMessage(
|
||||
+ ConversationSendMessageResult::Error {
|
||||
+ message: format!("Failed to submit user input: {e}"),
|
||||
+ },
|
||||
+ )),
|
||||
+ Some(true),
|
||||
+ )
|
||||
+ .await;
|
||||
+ return;
|
||||
+ }
|
||||
+
|
||||
+ message_processor
|
||||
+ .send_response_with_optional_error(
|
||||
+ id,
|
||||
+ Some(ToolCallResponseResult::ConversationSendMessage(
|
||||
+ ConversationSendMessageResult::Ok,
|
||||
+ )),
|
||||
+ Some(false),
|
||||
+ )
|
||||
+ .await;
|
||||
+}
|
||||
+
|
||||
+pub(crate) async fn get_session(
|
||||
+ session_id: Uuid,
|
||||
+ session_map: Arc<Mutex<HashMap<Uuid, Arc<Codex>>>>,
|
||||
+) -> Option<Arc<Codex>> {
|
||||
+ let guard = session_map.lock().await;
|
||||
+ guard.get(&session_id).cloned()
|
||||
+}
|
||||
diff --git a/codex-rs/mcp-server/tests/send_message.rs b/codex-rs/mcp-server/tests/send_message.rs
|
||||
new file mode 100644
|
||||
index 0000000000..fd4b210b0b
|
||||
--- /dev/null
|
||||
+++ b/codex-rs/mcp-server/tests/send_message.rs
|
||||
@@ -0,0 +1,163 @@
|
||||
+#![allow(clippy::expect_used)]
|
||||
+
|
||||
+use std::path::Path;
|
||||
+use std::thread::sleep;
|
||||
+use std::time::Duration;
|
||||
+
|
||||
+use codex_mcp_server::CodexToolCallParam;
|
||||
+use mcp_test_support::McpProcess;
|
||||
+use mcp_test_support::create_final_assistant_message_sse_response;
|
||||
+use mcp_test_support::create_mock_chat_completions_server;
|
||||
+use mcp_types::JSONRPC_VERSION;
|
||||
+use mcp_types::JSONRPCResponse;
|
||||
+use mcp_types::RequestId;
|
||||
+use pretty_assertions::assert_eq;
|
||||
+use serde_json::json;
|
||||
+use tempfile::TempDir;
|
||||
+use tokio::time::timeout;
|
||||
+
|
||||
+const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
|
||||
+
|
||||
+#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
+async fn test_send_message_success() {
|
||||
+ // Spin up a mock completions server that immediately ends the Codex turn.
|
||||
+ // Two Codex turns hit the mock model (session start + send-user-message). Provide two SSE responses.
|
||||
+ let responses = vec![
|
||||
+ create_final_assistant_message_sse_response("Done").expect("build mock assistant message"),
|
||||
+ create_final_assistant_message_sse_response("Done").expect("build mock assistant message"),
|
||||
+ ];
|
||||
+ let server = create_mock_chat_completions_server(responses).await;
|
||||
+
|
||||
+ // Create a temporary Codex home with config pointing at the mock server.
|
||||
+ let codex_home = TempDir::new().expect("create temp dir");
|
||||
+ create_config_toml(codex_home.path(), &server.uri()).expect("write config.toml");
|
||||
+
|
||||
+ // Start MCP server process and initialize.
|
||||
+ let mut mcp_process = McpProcess::new(codex_home.path())
|
||||
+ .await
|
||||
+ .expect("spawn mcp process");
|
||||
+ timeout(DEFAULT_READ_TIMEOUT, mcp_process.initialize())
|
||||
+ .await
|
||||
+ .expect("init timed out")
|
||||
+ .expect("init failed");
|
||||
+
|
||||
+ // Kick off a Codex session so we have a valid session_id.
|
||||
+ let codex_request_id = mcp_process
|
||||
+ .send_codex_tool_call(CodexToolCallParam {
|
||||
+ prompt: "Start a session".to_string(),
|
||||
+ ..Default::default()
|
||||
+ })
|
||||
+ .await
|
||||
+ .expect("send codex tool call");
|
||||
+
|
||||
+ // Wait for the session_configured event to get the session_id.
|
||||
+ let session_id = mcp_process
|
||||
+ .read_stream_until_configured_response_message()
|
||||
+ .await
|
||||
+ .expect("read session_configured");
|
||||
+
|
||||
+ // The original codex call will finish quickly given our mock; consume its response.
|
||||
+ timeout(
|
||||
+ DEFAULT_READ_TIMEOUT,
|
||||
+ mcp_process.read_stream_until_response_message(RequestId::Integer(codex_request_id)),
|
||||
+ )
|
||||
+ .await
|
||||
+ .expect("codex response timeout")
|
||||
+ .expect("codex response error");
|
||||
+
|
||||
+ // Now exercise the send-user-message tool.
|
||||
+ let send_msg_request_id = mcp_process
|
||||
+ .send_user_message_tool_call("Hello again", &session_id)
|
||||
+ .await
|
||||
+ .expect("send send-message tool call");
|
||||
+
|
||||
+ let response: JSONRPCResponse = timeout(
|
||||
+ DEFAULT_READ_TIMEOUT,
|
||||
+ mcp_process.read_stream_until_response_message(RequestId::Integer(send_msg_request_id)),
|
||||
+ )
|
||||
+ .await
|
||||
+ .expect("send-user-message response timeout")
|
||||
+ .expect("send-user-message response error");
|
||||
+
|
||||
+ assert_eq!(
|
||||
+ JSONRPCResponse {
|
||||
+ jsonrpc: JSONRPC_VERSION.into(),
|
||||
+ id: RequestId::Integer(send_msg_request_id),
|
||||
+ result: json!({
|
||||
+ "content": [
|
||||
+ {
|
||||
+ "text": "{\"status\":\"ok\"}",
|
||||
+ "type": "text",
|
||||
+ }
|
||||
+ ],
|
||||
+ "isError": false,
|
||||
+ "structuredContent": {
|
||||
+ "status": "ok"
|
||||
+ }
|
||||
+ }),
|
||||
+ },
|
||||
+ response
|
||||
+ );
|
||||
+ // wait for the server to hear the user message
|
||||
+ sleep(Duration::from_secs(1));
|
||||
+
|
||||
+ // Ensure the server and tempdir live until end of test
|
||||
+ drop(server);
|
||||
+}
|
||||
+
|
||||
+#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
+async fn test_send_message_session_not_found() {
|
||||
+ // Start MCP without creating a Codex session
|
||||
+ let codex_home = TempDir::new().expect("tempdir");
|
||||
+ let mut mcp = McpProcess::new(codex_home.path()).await.expect("spawn");
|
||||
+ timeout(DEFAULT_READ_TIMEOUT, mcp.initialize())
|
||||
+ .await
|
||||
+ .expect("timeout")
|
||||
+ .expect("init");
|
||||
+
|
||||
+ let unknown = uuid::Uuid::new_v4().to_string();
|
||||
+ let req_id = mcp
|
||||
+ .send_user_message_tool_call("ping", &unknown)
|
||||
+ .await
|
||||
+ .expect("send tool");
|
||||
+
|
||||
+ let resp: JSONRPCResponse = timeout(
|
||||
+ DEFAULT_READ_TIMEOUT,
|
||||
+ mcp.read_stream_until_response_message(RequestId::Integer(req_id)),
|
||||
+ )
|
||||
+ .await
|
||||
+ .expect("timeout")
|
||||
+ .expect("resp");
|
||||
+
|
||||
+ let result = resp.result.clone();
|
||||
+ let content = result["content"][0]["text"].as_str().unwrap_or("");
|
||||
+ assert!(content.contains("Session does not exist"));
|
||||
+ assert_eq!(result["isError"], json!(true));
|
||||
+}
|
||||
+
|
||||
+// ---------------------------------------------------------------------------
|
||||
+// Helpers
|
||||
+// ---------------------------------------------------------------------------
|
||||
+
|
||||
+fn create_config_toml(codex_home: &Path, server_uri: &str) -> std::io::Result<()> {
|
||||
+ let config_toml = codex_home.join("config.toml");
|
||||
+ std::fs::write(
|
||||
+ config_toml,
|
||||
+ format!(
|
||||
+ r#"
|
||||
+model = "mock-model"
|
||||
+approval_policy = "never"
|
||||
+sandbox_mode = "danger-full-access"
|
||||
+
|
||||
+model_provider = "mock_provider"
|
||||
+
|
||||
+[model_providers.mock_provider]
|
||||
+name = "Mock provider for test"
|
||||
+base_url = "{server_uri}/v1"
|
||||
+wire_api = "chat"
|
||||
+request_max_retries = 0
|
||||
+stream_max_retries = 0
|
||||
+"#
|
||||
+ ),
|
||||
+ )
|
||||
+}
|
||||
```
|
||||
|
||||
## Review Comments
|
||||
|
||||
### codex-rs/mcp-server/src/message_processor.rs
|
||||
|
||||
- Created: 2025-08-01 16:24:13 UTC | Link: https://github.com/openai/codex/pull/1686#discussion_r2248384283
|
||||
|
||||
```diff
|
||||
@@ -654,4 +674,20 @@ impl MessageProcessor {
|
||||
) {
|
||||
tracing::info!("notifications/message -> params: {:?}", params);
|
||||
}
|
||||
+
|
||||
+ pub(crate) async fn send_response_with_optional_error(
|
||||
+ &self,
|
||||
+ id: RequestId,
|
||||
+ message: Option<ToolCallResponseResult>,
|
||||
+ error: Option<bool>,
|
||||
+ ) {
|
||||
+ let response = ToolCallResponse {
|
||||
+ request_id: id.clone(),
|
||||
+ is_error: error,
|
||||
+ result: message,
|
||||
+ };
|
||||
+ let (result, id): (CallToolResult, RequestId) = response.into();
|
||||
+ self.send_response::<mcp_types::CallToolRequest>(id.clone(), result)
|
||||
```
|
||||
|
||||
> In practice, it was really annoying for something that I believe is extremely unlikely. This calls through to:
|
||||
>
|
||||
> https://github.com/openai/codex/blob/b67c485d84eba0d087203a850762da0cfe3361f9/codex-rs/mcp-server/src/outgoing_message.rs#L77-L80
|
||||
>
|
||||
> where `sender` is `tokio::sync::mpsc::Sender<OutgoingMessage>`which is created by `tokio::sync::mpsc::channel`:
|
||||
>
|
||||
> https://docs.rs/tokio/latest/tokio/sync/mpsc/fn.channel.html
|
||||
>
|
||||
> so it is a bounded channel with size `128`:
|
||||
>
|
||||
> https://github.com/openai/codex/blob/b67c485d84eba0d087203a850762da0cfe3361f9/codex-rs/mcp-server/src/lib.rs#L41
|
||||
>
|
||||
> From https://docs.rs/tokio/latest/tokio/sync/mpsc/struct.Sender.html#method.send
|
||||
>
|
||||
> > If the receive half of the channel is closed, either due to [close](https://docs.rs/tokio/latest/tokio/sync/mpsc/struct.Receiver.html#method.close) being called or the [Receiver](https://docs.rs/tokio/latest/tokio/sync/mpsc/struct.Receiver.html) handle dropping, the function returns an error. The error includes the value passed to send.
|
||||
>
|
||||
> so there isn't much we can do besides log?
|
||||
|
||||
- Created: 2025-08-01 16:25:45 UTC | Link: https://github.com/openai/codex/pull/1686#discussion_r2248386945
|
||||
|
||||
```diff
|
||||
@@ -654,4 +674,20 @@ impl MessageProcessor {
|
||||
) {
|
||||
tracing::info!("notifications/message -> params: {:?}", params);
|
||||
}
|
||||
+
|
||||
+ pub(crate) async fn send_response_with_optional_error(
|
||||
+ &self,
|
||||
+ id: RequestId,
|
||||
+ message: Option<ToolCallResponseResult>,
|
||||
+ error: Option<bool>,
|
||||
+ ) {
|
||||
+ let response = ToolCallResponse {
|
||||
+ request_id: id.clone(),
|
||||
+ is_error: error,
|
||||
+ result: message,
|
||||
+ };
|
||||
+ let (result, id): (CallToolResult, RequestId) = response.into();
|
||||
+ self.send_response::<mcp_types::CallToolRequest>(id.clone(), result)
|
||||
```
|
||||
|
||||
> OK, so perhaps it is not "unlikely," but I don't expect the caller to do anything meaningful with `Err`. If we have this helper return `Result`, then it becomes `Result` all the way up and we inevitably convert to generic `anyhow::Result` whereas I would rather pass more propagate a specific `Result` type, when possible.
|
||||
268
prs/bolinfest/PR-1691.md
Normal file
268
prs/bolinfest/PR-1691.md
Normal file
@@ -0,0 +1,268 @@
|
||||
# PR #1691: Fix invisible commands while approving
|
||||
|
||||
- URL: https://github.com/openai/codex/pull/1691
|
||||
- Author: easong-openai
|
||||
- Created: 2025-07-26 19:35:50 UTC
|
||||
- Updated: 2025-07-27 18:23:05 UTC
|
||||
- Changes: +178/-4, Files changed: 2, Commits: 3
|
||||
|
||||
## Description
|
||||
|
||||
Fixes disappearing approvals and adds tests.
|
||||
|
||||
## Full Diff
|
||||
|
||||
```diff
|
||||
diff --git a/codex-rs/exec/src/event_processor_with_human_output.rs b/codex-rs/exec/src/event_processor_with_human_output.rs
|
||||
index bc647c683e..8099bf9289 100644
|
||||
--- a/codex-rs/exec/src/event_processor_with_human_output.rs
|
||||
+++ b/codex-rs/exec/src/event_processor_with_human_output.rs
|
||||
@@ -3,10 +3,12 @@ use codex_core::config::Config;
|
||||
use codex_core::protocol::AgentMessageDeltaEvent;
|
||||
use codex_core::protocol::AgentMessageEvent;
|
||||
use codex_core::protocol::AgentReasoningDeltaEvent;
|
||||
+use codex_core::protocol::ApplyPatchApprovalRequestEvent;
|
||||
use codex_core::protocol::BackgroundEventEvent;
|
||||
use codex_core::protocol::ErrorEvent;
|
||||
use codex_core::protocol::Event;
|
||||
use codex_core::protocol::EventMsg;
|
||||
+use codex_core::protocol::ExecApprovalRequestEvent;
|
||||
use codex_core::protocol::ExecCommandBeginEvent;
|
||||
use codex_core::protocol::ExecCommandEndEvent;
|
||||
use codex_core::protocol::FileChange;
|
||||
@@ -474,11 +476,45 @@ impl EventProcessor for EventProcessorWithHumanOutput {
|
||||
println!("{}", line.style(self.dimmed));
|
||||
}
|
||||
}
|
||||
- EventMsg::ExecApprovalRequest(_) => {
|
||||
- // Should we exit?
|
||||
+ EventMsg::ExecApprovalRequest(ExecApprovalRequestEvent {
|
||||
+ command,
|
||||
+ cwd,
|
||||
+ reason,
|
||||
+ ..
|
||||
+ }) => {
|
||||
+ ts_println!(
|
||||
+ self,
|
||||
+ "{} {} in {}",
|
||||
+ "approval required for".style(self.magenta),
|
||||
+ escape_command(&command).style(self.bold),
|
||||
+ cwd.to_string_lossy(),
|
||||
+ );
|
||||
+ if let Some(r) = reason {
|
||||
+ ts_println!(self, "{r}");
|
||||
+ }
|
||||
+ return CodexStatus::InitiateShutdown;
|
||||
}
|
||||
- EventMsg::ApplyPatchApprovalRequest(_) => {
|
||||
- // Should we exit?
|
||||
+ EventMsg::ApplyPatchApprovalRequest(ApplyPatchApprovalRequestEvent {
|
||||
+ changes,
|
||||
+ reason,
|
||||
+ ..
|
||||
+ }) => {
|
||||
+ ts_println!(
|
||||
+ self,
|
||||
+ "{}:",
|
||||
+ "approval required for apply_patch".style(self.magenta),
|
||||
+ );
|
||||
+ for (path, change) in changes.iter() {
|
||||
+ println!(
|
||||
+ " {} {}",
|
||||
+ format_file_change(change).style(self.cyan),
|
||||
+ path.to_string_lossy(),
|
||||
+ );
|
||||
+ }
|
||||
+ if let Some(r) = reason {
|
||||
+ ts_println!(self, "{r}");
|
||||
+ }
|
||||
+ return CodexStatus::InitiateShutdown;
|
||||
}
|
||||
EventMsg::AgentReasoning(agent_reasoning_event) => {
|
||||
if self.show_agent_reasoning {
|
||||
@@ -538,3 +574,42 @@ fn format_file_change(change: &FileChange) -> &'static str {
|
||||
} => "M",
|
||||
}
|
||||
}
|
||||
+
|
||||
+#[cfg(test)]
|
||||
+mod tests {
|
||||
+ use super::*;
|
||||
+ use codex_core::config::Config;
|
||||
+ use codex_core::config::ConfigOverrides;
|
||||
+ use codex_core::config::ConfigToml;
|
||||
+ use codex_core::protocol::Event;
|
||||
+ use codex_core::protocol::EventMsg;
|
||||
+ use codex_core::protocol::ExecApprovalRequestEvent;
|
||||
+
|
||||
+ fn test_config() -> Config {
|
||||
+ Config::load_from_base_config_with_overrides(
|
||||
+ ConfigToml::default(),
|
||||
+ ConfigOverrides::default(),
|
||||
+ std::env::temp_dir(),
|
||||
+ )
|
||||
+ .unwrap_or_else(|e| panic!("failed to load test configuration: {e}"))
|
||||
+ }
|
||||
+
|
||||
+ #[test]
|
||||
+ fn exec_approval_request_displays_command() {
|
||||
+ let config = test_config();
|
||||
+ let mut processor = EventProcessorWithHumanOutput::create_with_ansi(false, &config, None);
|
||||
+
|
||||
+ let event = Event {
|
||||
+ id: "1".into(),
|
||||
+ msg: EventMsg::ExecApprovalRequest(ExecApprovalRequestEvent {
|
||||
+ call_id: "c1".into(),
|
||||
+ command: vec!["rm".into(), "-rf".into(), "/".into()],
|
||||
+ cwd: PathBuf::from("/tmp"),
|
||||
+ reason: None,
|
||||
+ }),
|
||||
+ };
|
||||
+
|
||||
+ let status = processor.process_event(event);
|
||||
+ assert!(matches!(status, CodexStatus::InitiateShutdown));
|
||||
+ }
|
||||
+}
|
||||
diff --git a/codex-rs/tui/src/user_approval_widget.rs b/codex-rs/tui/src/user_approval_widget.rs
|
||||
index 431f85a268..c492de19e0 100644
|
||||
--- a/codex-rs/tui/src/user_approval_widget.rs
|
||||
+++ b/codex-rs/tui/src/user_approval_widget.rs
|
||||
@@ -368,3 +368,102 @@ impl WidgetRef for &UserApprovalWidget<'_> {
|
||||
Widget::render(List::new(lines), response_chunk, buf);
|
||||
}
|
||||
}
|
||||
+
|
||||
+#[cfg(test)]
|
||||
+mod tests {
|
||||
+ use super::*;
|
||||
+ use crate::app_event::AppEvent;
|
||||
+ use crate::app_event_sender::AppEventSender;
|
||||
+ use ratatui::buffer::Buffer;
|
||||
+ use ratatui::layout::Rect;
|
||||
+ use ratatui::widgets::WidgetRef;
|
||||
+ use std::path::PathBuf;
|
||||
+ use std::sync::mpsc::channel;
|
||||
+
|
||||
+ #[test]
|
||||
+ fn exec_command_is_visible_in_small_viewport() {
|
||||
+ let long_reason = "This is a very long explanatory reason that would normally occupy many lines in the confirmation prompt. \
|
||||
+It should not cause the actual command or the response options to be scrolled out of the visible area.";
|
||||
+
|
||||
+ let (tx, _rx) = channel::<AppEvent>();
|
||||
+ let app_tx = AppEventSender::new(tx);
|
||||
+
|
||||
+ let cwd = PathBuf::from("/home/alice/project");
|
||||
+ let command = vec![
|
||||
+ "bash".to_string(),
|
||||
+ "-lc".to_string(),
|
||||
+ "echo 123 && printf 'hello'".to_string(),
|
||||
+ ];
|
||||
+
|
||||
+ let widget = UserApprovalWidget::new(
|
||||
+ ApprovalRequest::Exec {
|
||||
+ id: "test-id".to_string(),
|
||||
+ command: command.clone(),
|
||||
+ cwd: cwd.clone(),
|
||||
+ reason: Some(long_reason.to_string()),
|
||||
+ },
|
||||
+ app_tx,
|
||||
+ );
|
||||
+
|
||||
+ let area = Rect::new(0, 0, 50, 12);
|
||||
+ let mut buf = Buffer::empty(area);
|
||||
+ (&widget).render_ref(area, &mut buf);
|
||||
+
|
||||
+ let mut rendered = String::new();
|
||||
+ for y in 0..area.height {
|
||||
+ for x in 0..area.width {
|
||||
+ let cell = &buf[(x, y)];
|
||||
+ rendered.push(cell.symbol().chars().next().unwrap_or('\0'));
|
||||
+ }
|
||||
+ rendered.push('\n');
|
||||
+ }
|
||||
+
|
||||
+ assert!(
|
||||
+ rendered.contains("echo 123 && printf 'hello'"),
|
||||
+ "rendered buffer did not contain the command.\n--- buffer ---\n{rendered}\n----------------"
|
||||
+ );
|
||||
+ assert!(rendered.contains("Yes (y)"));
|
||||
+ }
|
||||
+
|
||||
+ #[test]
|
||||
+ fn all_options_visible_in_reasonable_viewport() {
|
||||
+ let (tx, _rx) = channel::<AppEvent>();
|
||||
+ let app_tx = AppEventSender::new(tx);
|
||||
+
|
||||
+ let widget = UserApprovalWidget::new(
|
||||
+ ApprovalRequest::Exec {
|
||||
+ id: "test-id".to_string(),
|
||||
+ command: vec![
|
||||
+ "bash".into(),
|
||||
+ "-lc".into(),
|
||||
+ "echo 123 && printf 'hello'".into(),
|
||||
+ ],
|
||||
+ cwd: PathBuf::from("/home/alice/project"),
|
||||
+ reason: Some("short reason".into()),
|
||||
+ },
|
||||
+ app_tx,
|
||||
+ );
|
||||
+
|
||||
+ // Use a generous area to avoid truncation of either the prompt or the options.
|
||||
+ let area = Rect::new(0, 0, 100, 30);
|
||||
+ let mut buf = Buffer::empty(area);
|
||||
+ (&widget).render_ref(area, &mut buf);
|
||||
+
|
||||
+ let mut rendered = String::new();
|
||||
+ for y in 0..area.height {
|
||||
+ for x in 0..area.width {
|
||||
+ let cell = &buf[(x, y)];
|
||||
+ rendered.push(cell.symbol().chars().next().unwrap_or('\0'));
|
||||
+ }
|
||||
+ rendered.push('\n');
|
||||
+ }
|
||||
+
|
||||
+ for opt in super::SELECT_OPTIONS {
|
||||
+ assert!(
|
||||
+ rendered.contains(opt.label),
|
||||
+ "expected option label to be visible: {}\n--- buffer ---\n{rendered}\n----------------",
|
||||
+ opt.label
|
||||
+ );
|
||||
+ }
|
||||
+ }
|
||||
+}
|
||||
```
|
||||
|
||||
## Review Comments
|
||||
|
||||
### codex-rs/exec/src/event_processor_with_human_output.rs
|
||||
|
||||
- Created: 2025-07-27 16:37:04 UTC | Link: https://github.com/openai/codex/pull/1691#discussion_r2234053080
|
||||
|
||||
```diff
|
||||
@@ -474,11 +476,45 @@ impl EventProcessor for EventProcessorWithHumanOutput {
|
||||
println!("{}", line.style(self.dimmed));
|
||||
}
|
||||
}
|
||||
- EventMsg::ExecApprovalRequest(_) => {
|
||||
- // Should we exit?
|
||||
+ EventMsg::ExecApprovalRequest(ExecApprovalRequestEvent {
|
||||
```
|
||||
|
||||
> Please change this to what it was before: the `codex exec` command is hardcoded to _never_ ask for approvals:
|
||||
>
|
||||
> https://github.com/openai/codex/blob/5a0079fea2d325d2638e2b1857cba0871fba6402/codex-rs/exec/src/lib.rs#L106-L108
|
||||
|
||||
### codex-rs/tui/src/user_approval_widget.rs
|
||||
|
||||
- Created: 2025-07-27 16:38:24 UTC | Link: https://github.com/openai/codex/pull/1691#discussion_r2234053504
|
||||
|
||||
```diff
|
||||
@@ -368,3 +368,102 @@ impl WidgetRef for &UserApprovalWidget<'_> {
|
||||
Widget::render(List::new(lines), response_chunk, buf);
|
||||
}
|
||||
}
|
||||
+
|
||||
+#[cfg(test)]
|
||||
```
|
||||
|
||||
> I see tests in this PR, but no logic to change behavior to "fix invisible commands," so I'm confused.
|
||||
77
prs/bolinfest/PR-1693.md
Normal file
77
prs/bolinfest/PR-1693.md
Normal file
@@ -0,0 +1,77 @@
|
||||
# PR #1693: Update config.md to clarify the configuration for Azure OpenAI provider
|
||||
|
||||
- URL: https://github.com/openai/codex/pull/1693
|
||||
- Author: chenxizhang
|
||||
- Created: 2025-07-26 23:00:35 UTC
|
||||
- Updated: 2025-08-07 03:05:07 UTC
|
||||
- Changes: +20/-3, Files changed: 1, Commits: 2
|
||||
|
||||
## Description
|
||||
|
||||
(No description.)
|
||||
|
||||
## Full Diff
|
||||
|
||||
```diff
|
||||
diff --git a/codex-rs/config.md b/codex-rs/config.md
|
||||
index c45d81180d..51ece5c793 100644
|
||||
--- a/codex-rs/config.md
|
||||
+++ b/codex-rs/config.md
|
||||
@@ -65,17 +65,34 @@ base_url = "https://api.mistral.ai/v1"
|
||||
env_key = "MISTRAL_API_KEY"
|
||||
```
|
||||
|
||||
-Note that Azure requires `api-version` to be passed as a query parameter, so be sure to specify it as part of `query_params` when defining the Azure provider:
|
||||
+
|
||||
+Note that Azure requires `api-version` to be passed as a query parameter, so be sure to specify it as part of `query_params` when defining the Azure provider, please copy the endpoint from Azure AI Foundry and then extract the `base_url` and `api_version` from the endpoint carefully.
|
||||
+
|
||||
+If you want to use `codex-mini` or other newer models that support the `responses` API, use the following configuration:
|
||||
|
||||
```toml
|
||||
[model_providers.azure]
|
||||
name = "Azure"
|
||||
# Make sure you set the appropriate subdomain for this URL.
|
||||
-base_url = "https://YOUR_PROJECT_NAME.openai.azure.com/openai"
|
||||
-env_key = "AZURE_OPENAI_API_KEY" # Or "OPENAI_API_KEY", whichever you use.
|
||||
+base_url = "https://YOUR_PROJECT_NAME.openai.azure.com/openai"
|
||||
+env_key = "AZURE_OPENAI_API_KEY"
|
||||
query_params = { api-version = "2025-04-01-preview" }
|
||||
+wire_api: "responses"
|
||||
```
|
||||
|
||||
+If you want to use gpt-4.1 or o4-mini, which only support the chat API, use the following configuration:
|
||||
+
|
||||
+```toml
|
||||
+[model_providers.azure]
|
||||
+name = "Azure"
|
||||
+# Make sure you set the appropriate subdomain for this URL.
|
||||
+base_url = "https://YOUR_PROJECT_NAME.openai.azure.com/openai/deployments/YOUR_DEPLOYMENT_NAME"
|
||||
+env_key = "AZURE_OPENAI_API_KEY"
|
||||
+query_params = { api-version = "2025-04-01-preview" }
|
||||
+wire_api: "chat"
|
||||
+```
|
||||
+
|
||||
+
|
||||
It is also possible to configure a provider to include extra HTTP headers with a request. These can be hardcoded values (`http_headers`) or values read from environment variables (`env_http_headers`):
|
||||
|
||||
```toml
|
||||
```
|
||||
|
||||
## Review Comments
|
||||
|
||||
### codex-rs/config.md
|
||||
|
||||
- Created: 2025-07-27 03:13:00 UTC | Link: https://github.com/openai/codex/pull/1693#discussion_r2233620285
|
||||
|
||||
```diff
|
||||
@@ -71,7 +71,7 @@ Note that Azure requires `api-version` to be passed as a query parameter, so be
|
||||
[model_providers.azure]
|
||||
name = "Azure"
|
||||
# Make sure you set the appropriate subdomain for this URL.
|
||||
-base_url = "https://YOUR_PROJECT_NAME.openai.azure.com/openai"
|
||||
+base_url = "https://YOUR_PROJECT_NAME.openai.azure.com/openai" # If you are using `chat` as the `wire_api` (e.g., when using models like `gpt-4o` or `o3-mini`), the `base_url` should be like **https://YOUR_PROJECT_NAME.openai.azure.com/openai/deployments/YOUR_DEPLOYMENT_NAME**. Please note that `codex-mini` has been onboarded into Azure AI Foundry, when using this model, set `wire_api` to `responses` and use a `base_url` like **https://YOUR_PROJECT_NAME.openai.azure.com/openai**. Be sure to copy the exact URL from Azure AI Foundry, as it may vary by region or deployment method.
|
||||
```
|
||||
|
||||
> It seems more appropriate to add `wire_api = "chat"` to this example and to put this comment there.
|
||||
>
|
||||
> Please put the comment before `wire_api` and break it up at 80 or 100 cols like normal source code so it is easier to read when this is rendered as Markdown. The current implementation causes horizontal scrolling in the Markdown, which you can verify here in GitHub using the **Display the rich diff** button.
|
||||
1483
prs/bolinfest/PR-1695.md
Normal file
1483
prs/bolinfest/PR-1695.md
Normal file
File diff suppressed because it is too large
Load Diff
637
prs/bolinfest/PR-1696.md
Normal file
637
prs/bolinfest/PR-1696.md
Normal file
@@ -0,0 +1,637 @@
|
||||
# PR #1696: Fix approval workflow
|
||||
|
||||
- URL: https://github.com/openai/codex/pull/1696
|
||||
- Author: easong-openai
|
||||
- Created: 2025-07-27 20:29:06 UTC
|
||||
- Updated: 2025-07-28 19:00:16 UTC
|
||||
- Changes: +168/-13, Files changed: 5, Commits: 5
|
||||
|
||||
## Description
|
||||
|
||||
(Hopefully) temporary solution to the invisible approvals problem - prints commands to history when they need approval and then also prints the result of the approval. In the near future we should be able to do some fancy stuff with updating commands before writing them to permanent history.
|
||||
|
||||
Also, ctr-c while in the approval modal now acts as esc (aborts command) and puts the TUI in the state where one additional ctr-c will exit.
|
||||
|
||||
## Full Diff
|
||||
|
||||
```diff
|
||||
diff --git a/codex-rs/tui/src/bottom_pane/approval_modal_view.rs b/codex-rs/tui/src/bottom_pane/approval_modal_view.rs
|
||||
index ba5b07b93c..376135ef31 100644
|
||||
--- a/codex-rs/tui/src/bottom_pane/approval_modal_view.rs
|
||||
+++ b/codex-rs/tui/src/bottom_pane/approval_modal_view.rs
|
||||
@@ -9,6 +9,7 @@ use crate::user_approval_widget::UserApprovalWidget;
|
||||
|
||||
use super::BottomPane;
|
||||
use super::BottomPaneView;
|
||||
+use super::CancellationEvent;
|
||||
|
||||
/// Modal overlay asking the user to approve/deny a sequence of requests.
|
||||
pub(crate) struct ApprovalModalView<'a> {
|
||||
@@ -46,6 +47,12 @@ impl<'a> BottomPaneView<'a> for ApprovalModalView<'a> {
|
||||
self.maybe_advance();
|
||||
}
|
||||
|
||||
+ fn on_ctrl_c(&mut self, _pane: &mut BottomPane<'a>) -> CancellationEvent {
|
||||
+ self.current.on_ctrl_c();
|
||||
+ self.queue.clear();
|
||||
+ CancellationEvent::Handled
|
||||
+ }
|
||||
+
|
||||
fn is_complete(&self) -> bool {
|
||||
self.current.is_complete() && self.queue.is_empty()
|
||||
}
|
||||
@@ -59,3 +66,39 @@ impl<'a> BottomPaneView<'a> for ApprovalModalView<'a> {
|
||||
None
|
||||
}
|
||||
}
|
||||
+
|
||||
+#[cfg(test)]
|
||||
+mod tests {
|
||||
+ use super::*;
|
||||
+ use crate::app_event::AppEvent;
|
||||
+ use std::path::PathBuf;
|
||||
+ use std::sync::mpsc::channel;
|
||||
+
|
||||
+ fn make_exec_request() -> ApprovalRequest {
|
||||
+ ApprovalRequest::Exec {
|
||||
+ id: "test".to_string(),
|
||||
+ command: vec!["echo".to_string(), "hi".to_string()],
|
||||
+ cwd: PathBuf::from("/tmp"),
|
||||
+ reason: None,
|
||||
+ }
|
||||
+ }
|
||||
+
|
||||
+ #[test]
|
||||
+ fn ctrl_c_aborts_and_clears_queue() {
|
||||
+ let (tx_raw, _rx) = channel::<AppEvent>();
|
||||
+ let tx = AppEventSender::new(tx_raw);
|
||||
+ let first = make_exec_request();
|
||||
+ let mut view = ApprovalModalView::new(first, tx);
|
||||
+ view.enqueue_request(make_exec_request());
|
||||
+
|
||||
+ let (tx_raw2, _rx2) = channel::<AppEvent>();
|
||||
+ let mut pane = BottomPane::new(super::super::BottomPaneParams {
|
||||
+ app_event_tx: AppEventSender::new(tx_raw2),
|
||||
+ has_input_focus: true,
|
||||
+ });
|
||||
+ assert_eq!(CancellationEvent::Handled, view.on_ctrl_c(&mut pane));
|
||||
+ assert!(view.queue.is_empty());
|
||||
+ assert!(view.current.is_complete());
|
||||
+ assert!(view.is_complete());
|
||||
+ }
|
||||
+}
|
||||
diff --git a/codex-rs/tui/src/bottom_pane/bottom_pane_view.rs b/codex-rs/tui/src/bottom_pane/bottom_pane_view.rs
|
||||
index 677d6db95b..96922d94e7 100644
|
||||
--- a/codex-rs/tui/src/bottom_pane/bottom_pane_view.rs
|
||||
+++ b/codex-rs/tui/src/bottom_pane/bottom_pane_view.rs
|
||||
@@ -4,6 +4,7 @@ use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::Rect;
|
||||
|
||||
use super::BottomPane;
|
||||
+use super::CancellationEvent;
|
||||
|
||||
/// Type to use for a method that may require a redraw of the UI.
|
||||
pub(crate) enum ConditionalUpdate {
|
||||
@@ -22,6 +23,11 @@ pub(crate) trait BottomPaneView<'a> {
|
||||
false
|
||||
}
|
||||
|
||||
+ /// Handle Ctrl-C while this view is active.
|
||||
+ fn on_ctrl_c(&mut self, _pane: &mut BottomPane<'a>) -> CancellationEvent {
|
||||
+ CancellationEvent::Ignored
|
||||
+ }
|
||||
+
|
||||
/// Render the view: this will be displayed in place of the composer.
|
||||
fn render(&self, area: Rect, buf: &mut Buffer);
|
||||
|
||||
diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs
|
||||
index 0ddb36f635..4ec1ba4b3e 100644
|
||||
--- a/codex-rs/tui/src/bottom_pane/mod.rs
|
||||
+++ b/codex-rs/tui/src/bottom_pane/mod.rs
|
||||
@@ -20,6 +20,12 @@ mod command_popup;
|
||||
mod file_search_popup;
|
||||
mod status_indicator_view;
|
||||
|
||||
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
+pub(crate) enum CancellationEvent {
|
||||
+ Ignored,
|
||||
+ Handled,
|
||||
+}
|
||||
+
|
||||
pub(crate) use chat_composer::ChatComposer;
|
||||
pub(crate) use chat_composer::InputResult;
|
||||
|
||||
@@ -80,6 +86,33 @@ impl BottomPane<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
+ /// Handle Ctrl-C in the bottom pane. If a modal view is active it gets a
|
||||
+ /// chance to consume the event (e.g. to dismiss itself).
|
||||
+ pub(crate) fn on_ctrl_c(&mut self) -> CancellationEvent {
|
||||
+ let mut view = match self.active_view.take() {
|
||||
+ Some(view) => view,
|
||||
+ None => return CancellationEvent::Ignored,
|
||||
+ };
|
||||
+
|
||||
+ let event = view.on_ctrl_c(self);
|
||||
+ match event {
|
||||
+ CancellationEvent::Handled => {
|
||||
+ if !view.is_complete() {
|
||||
+ self.active_view = Some(view);
|
||||
+ } else if self.is_task_running {
|
||||
+ self.active_view = Some(Box::new(StatusIndicatorView::new(
|
||||
+ self.app_event_tx.clone(),
|
||||
+ )));
|
||||
+ }
|
||||
+ self.show_ctrl_c_quit_hint();
|
||||
+ }
|
||||
+ CancellationEvent::Ignored => {
|
||||
+ self.active_view = Some(view);
|
||||
+ }
|
||||
+ }
|
||||
+ event
|
||||
+ }
|
||||
+
|
||||
pub fn handle_paste(&mut self, pasted: String) {
|
||||
if self.active_view.is_none() {
|
||||
let needs_redraw = self.composer.handle_paste(pasted);
|
||||
@@ -234,3 +267,34 @@ impl WidgetRef for &BottomPane<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
+
|
||||
+#[cfg(test)]
|
||||
+mod tests {
|
||||
+ use super::*;
|
||||
+ use crate::app_event::AppEvent;
|
||||
+ use std::path::PathBuf;
|
||||
+ use std::sync::mpsc::channel;
|
||||
+
|
||||
+ fn exec_request() -> ApprovalRequest {
|
||||
+ ApprovalRequest::Exec {
|
||||
+ id: "1".to_string(),
|
||||
+ command: vec!["echo".into(), "ok".into()],
|
||||
+ cwd: PathBuf::from("."),
|
||||
+ reason: None,
|
||||
+ }
|
||||
+ }
|
||||
+
|
||||
+ #[test]
|
||||
+ fn ctrl_c_on_modal_consumes_and_shows_quit_hint() {
|
||||
+ let (tx_raw, _rx) = channel::<AppEvent>();
|
||||
+ let tx = AppEventSender::new(tx_raw);
|
||||
+ let mut pane = BottomPane::new(BottomPaneParams {
|
||||
+ app_event_tx: tx,
|
||||
+ has_input_focus: true,
|
||||
+ });
|
||||
+ pane.push_approval_request(exec_request());
|
||||
+ assert_eq!(CancellationEvent::Handled, pane.on_ctrl_c());
|
||||
+ assert!(pane.ctrl_c_quit_hint_visible());
|
||||
+ assert_eq!(CancellationEvent::Ignored, pane.on_ctrl_c());
|
||||
+ }
|
||||
+}
|
||||
diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs
|
||||
index 01285c02c9..5475fc2bf1 100644
|
||||
--- a/codex-rs/tui/src/chatwidget.rs
|
||||
+++ b/codex-rs/tui/src/chatwidget.rs
|
||||
@@ -33,8 +33,10 @@ use crate::app_event::AppEvent;
|
||||
use crate::app_event_sender::AppEventSender;
|
||||
use crate::bottom_pane::BottomPane;
|
||||
use crate::bottom_pane::BottomPaneParams;
|
||||
+use crate::bottom_pane::CancellationEvent;
|
||||
use crate::bottom_pane::InputResult;
|
||||
use crate::conversation_history_widget::ConversationHistoryWidget;
|
||||
+use crate::exec_command::strip_bash_lc_and_escape;
|
||||
use crate::history_cell::PatchEventType;
|
||||
use crate::user_approval_widget::ApprovalRequest;
|
||||
use codex_file_search::FileMatch;
|
||||
@@ -297,6 +299,20 @@ impl ChatWidget<'_> {
|
||||
cwd,
|
||||
reason,
|
||||
}) => {
|
||||
+ // Print the command to the history so it is visible in the
|
||||
+ // transcript *before* the modal asks for approval.
|
||||
+ let cmdline = strip_bash_lc_and_escape(&command);
|
||||
+ let text = format!(
|
||||
+ "command requires approval:\n$ {cmdline}{reason}",
|
||||
+ reason = reason
|
||||
+ .as_ref()
|
||||
+ .map(|r| format!("\n{r}"))
|
||||
+ .unwrap_or_default()
|
||||
+ );
|
||||
+ self.conversation_history.add_background_event(text);
|
||||
+ self.emit_last_history_entry();
|
||||
+ self.conversation_history.scroll_to_bottom();
|
||||
+
|
||||
let request = ApprovalRequest::Exec {
|
||||
id,
|
||||
command,
|
||||
@@ -304,6 +320,7 @@ impl ChatWidget<'_> {
|
||||
reason,
|
||||
};
|
||||
self.bottom_pane.push_approval_request(request);
|
||||
+ self.request_redraw();
|
||||
}
|
||||
EventMsg::ApplyPatchApprovalRequest(ApplyPatchApprovalRequestEvent {
|
||||
call_id: _,
|
||||
@@ -449,21 +466,25 @@ impl ChatWidget<'_> {
|
||||
}
|
||||
|
||||
/// Handle Ctrl-C key press.
|
||||
- /// Returns true if the key press was handled, false if it was not.
|
||||
- /// If the key press was not handled, the caller should handle it (likely by exiting the process).
|
||||
- pub(crate) fn on_ctrl_c(&mut self) -> bool {
|
||||
+ /// Returns CancellationEvent::Handled if the event was consumed by the UI, or
|
||||
+ /// CancellationEvent::Ignored if the caller should handle it (e.g. exit).
|
||||
+ pub(crate) fn on_ctrl_c(&mut self) -> CancellationEvent {
|
||||
+ match self.bottom_pane.on_ctrl_c() {
|
||||
+ CancellationEvent::Handled => return CancellationEvent::Handled,
|
||||
+ CancellationEvent::Ignored => {}
|
||||
+ }
|
||||
if self.bottom_pane.is_task_running() {
|
||||
self.bottom_pane.clear_ctrl_c_quit_hint();
|
||||
self.submit_op(Op::Interrupt);
|
||||
self.answer_buffer.clear();
|
||||
self.reasoning_buffer.clear();
|
||||
- false
|
||||
+ CancellationEvent::Ignored
|
||||
} else if self.bottom_pane.ctrl_c_quit_hint_visible() {
|
||||
self.submit_op(Op::Shutdown);
|
||||
- true
|
||||
+ CancellationEvent::Handled
|
||||
} else {
|
||||
self.bottom_pane.show_ctrl_c_quit_hint();
|
||||
- false
|
||||
+ CancellationEvent::Ignored
|
||||
}
|
||||
}
|
||||
|
||||
diff --git a/codex-rs/tui/src/user_approval_widget.rs b/codex-rs/tui/src/user_approval_widget.rs
|
||||
index 431f85a268..a161c2c399 100644
|
||||
--- a/codex-rs/tui/src/user_approval_widget.rs
|
||||
+++ b/codex-rs/tui/src/user_approval_widget.rs
|
||||
@@ -203,6 +203,12 @@ impl UserApprovalWidget<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
+ /// Handle Ctrl-C pressed by the user while the modal is visible.
|
||||
+ /// Behaves like pressing Escape: abort the request and close the modal.
|
||||
+ pub(crate) fn on_ctrl_c(&mut self) {
|
||||
+ self.send_decision(ReviewDecision::Abort);
|
||||
+ }
|
||||
+
|
||||
fn handle_select_key(&mut self, key_event: KeyEvent) {
|
||||
match key_event.code {
|
||||
KeyCode::Up => {
|
||||
@@ -265,7 +271,28 @@ impl UserApprovalWidget<'_> {
|
||||
self.send_decision_with_feedback(decision, String::new())
|
||||
}
|
||||
|
||||
- fn send_decision_with_feedback(&mut self, decision: ReviewDecision, _feedback: String) {
|
||||
+ fn send_decision_with_feedback(&mut self, decision: ReviewDecision, feedback: String) {
|
||||
+ let mut lines: Vec<Line<'static>> = Vec::new();
|
||||
+ match &self.approval_request {
|
||||
+ ApprovalRequest::Exec { command, .. } => {
|
||||
+ let cmd = strip_bash_lc_and_escape(command);
|
||||
+ lines.push(Line::from("approval decision"));
|
||||
+ lines.push(Line::from(format!("$ {cmd}")));
|
||||
+ lines.push(Line::from(format!("decision: {decision:?}")));
|
||||
+ }
|
||||
+ ApprovalRequest::ApplyPatch { .. } => {
|
||||
+ lines.push(Line::from(format!("patch approval decision: {decision:?}")));
|
||||
+ }
|
||||
+ }
|
||||
+ if !feedback.trim().is_empty() {
|
||||
+ lines.push(Line::from("feedback:"));
|
||||
+ for l in feedback.lines() {
|
||||
+ lines.push(Line::from(l.to_string()));
|
||||
+ }
|
||||
+ }
|
||||
+ lines.push(Line::from(""));
|
||||
+ self.app_event_tx.send(AppEvent::InsertHistory(lines));
|
||||
+
|
||||
let op = match &self.approval_request {
|
||||
ApprovalRequest::Exec { id, .. } => Op::ExecApproval {
|
||||
id: id.clone(),
|
||||
@@ -277,12 +304,6 @@ impl UserApprovalWidget<'_> {
|
||||
},
|
||||
};
|
||||
|
||||
- // Ignore feedback for now – the current `Op` variants do not carry it.
|
||||
-
|
||||
- // Forward the Op to the agent. The caller (ChatWidget) will trigger a
|
||||
- // redraw after it processes the resulting state change, so we avoid
|
||||
- // issuing an extra Redraw here to prevent a transient frame where the
|
||||
- // modal is still visible.
|
||||
self.app_event_tx.send(AppEvent::CodexOp(op));
|
||||
self.done = true;
|
||||
}
|
||||
```
|
||||
|
||||
## Review Comments
|
||||
|
||||
### codex-rs/tui/src/bottom_pane/approval_modal_view.rs
|
||||
|
||||
- Created: 2025-07-27 21:27:09 UTC | Link: https://github.com/openai/codex/pull/1696#discussion_r2234139205
|
||||
|
||||
```diff
|
||||
@@ -59,3 +67,42 @@ impl<'a> BottomPaneView<'a> for ApprovalModalView<'a> {
|
||||
None
|
||||
}
|
||||
}
|
||||
+
|
||||
+#[cfg(test)]
|
||||
+mod tests {
|
||||
+ use super::*;
|
||||
+ use crate::app_event::AppEvent;
|
||||
+ use std::path::PathBuf;
|
||||
+ use std::sync::mpsc::channel;
|
||||
+
|
||||
+ fn make_sender() -> AppEventSender {
|
||||
+ let (tx, _rx) = channel::<AppEvent>();
|
||||
+ AppEventSender::new(tx)
|
||||
+ }
|
||||
+
|
||||
+ fn make_exec_request() -> ApprovalRequest {
|
||||
+ ApprovalRequest::Exec {
|
||||
+ id: "test".to_string(),
|
||||
+ command: vec!["echo".to_string(), "hi".to_string()],
|
||||
+ cwd: PathBuf::from("/tmp"),
|
||||
+ reason: None,
|
||||
+ }
|
||||
+ }
|
||||
+
|
||||
+ #[test]
|
||||
+ fn ctrl_c_aborts_and_clears_queue() {
|
||||
+ let tx = make_sender();
|
||||
```
|
||||
|
||||
> FYI, I would not use the `make_sender()` helper and just do the following:
|
||||
>
|
||||
> ```suggestion
|
||||
> let (tx, _rx) = channel::<AppEvent>();
|
||||
> ```
|
||||
>
|
||||
> The reason is that, with `make_sender()`, `_rx` is dropped when `make_sender()` exits, which means that `tx.send()` should fail with `Err(SendError)`, which I don't think is what you want?
|
||||
>
|
||||
> If you take the suggestion above, `_rx` will not be dropped until the block exits, which in this case, is the lifetime of the `ctrl_c_aborts_and_clears_queue()` test itself.
|
||||
|
||||
### codex-rs/tui/src/bottom_pane/mod.rs
|
||||
|
||||
- Created: 2025-07-27 21:30:22 UTC | Link: https://github.com/openai/codex/pull/1696#discussion_r2234139977
|
||||
|
||||
```diff
|
||||
@@ -80,6 +80,30 @@ impl BottomPane<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
+ /// Handle Ctrl-C in the bottom pane. If a modal view is active it gets a
|
||||
+ /// chance to consume the event (e.g. to dismiss itself). Returns true when
|
||||
+ /// handled.
|
||||
+ pub(crate) fn on_ctrl_c(&mut self) -> bool {
|
||||
```
|
||||
|
||||
> I would consider introducing a tiny `enum` type to make the contract self-documenting, like:
|
||||
>
|
||||
> ```rust
|
||||
> enum CancellationEvent {
|
||||
> Ignored,
|
||||
> Handled, // or maybe Consumed
|
||||
> }
|
||||
> ```
|
||||
>
|
||||
> and using it as the return value of this function.
|
||||
|
||||
- Created: 2025-07-27 21:41:43 UTC | Link: https://github.com/openai/codex/pull/1696#discussion_r2234142873
|
||||
|
||||
```diff
|
||||
@@ -80,6 +80,30 @@ impl BottomPane<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
+ /// Handle Ctrl-C in the bottom pane. If a modal view is active it gets a
|
||||
+ /// chance to consume the event (e.g. to dismiss itself). Returns true when
|
||||
+ /// handled.
|
||||
+ pub(crate) fn on_ctrl_c(&mut self) -> bool {
|
||||
+ if let Some(mut view) = self.active_view.take() {
|
||||
+ if view.on_ctrl_c(self) {
|
||||
+ if !view.is_complete() {
|
||||
+ self.active_view = Some(view);
|
||||
+ } else if self.is_task_running {
|
||||
+ self.active_view = Some(Box::new(StatusIndicatorView::new(
|
||||
+ self.app_event_tx.clone(),
|
||||
+ )));
|
||||
+ }
|
||||
+ // Show the standard Ctrl-C quit hint so a subsequent Ctrl-C will exit.
|
||||
+ self.show_ctrl_c_quit_hint();
|
||||
+ return true;
|
||||
+ } else {
|
||||
+ // Put the view back unchanged
|
||||
+ self.active_view = Some(view);
|
||||
+ }
|
||||
+ }
|
||||
+ false
|
||||
+ }
|
||||
```
|
||||
|
||||
> Taking the above suggestion into account, I would rearrange things slightly to leverage an early return and thereby reduce indenting a bit:
|
||||
>
|
||||
> ```suggestion
|
||||
> let mut view = match self.active_view.take() {
|
||||
> Some(view) => view,
|
||||
> None => return CancellationEvent::Ignored,
|
||||
> };
|
||||
>
|
||||
> let event = view.on_ctrl_c(self);
|
||||
> match event {
|
||||
> CancellationEvent::Handled => {
|
||||
> if !view.is_complete() {
|
||||
> self.active_view = Some(view);
|
||||
> } else if self.is_task_running {
|
||||
> self.active_view = Some(Box::new(StatusIndicatorView::new(
|
||||
> self.app_event_tx.clone(),
|
||||
> )));
|
||||
> }
|
||||
> // Show the standard Ctrl-C quit hint so a subsequent Ctrl-C will exit.
|
||||
> self.show_ctrl_c_quit_hint();
|
||||
> }
|
||||
> CancellationEvent::Ignored => {
|
||||
> // Put the view back unchanged
|
||||
> self.active_view = Some(view);
|
||||
> }
|
||||
> }
|
||||
> event
|
||||
> }
|
||||
> ```
|
||||
|
||||
- Created: 2025-07-27 21:42:25 UTC | Link: https://github.com/openai/codex/pull/1696#discussion_r2234143115
|
||||
|
||||
```diff
|
||||
@@ -234,3 +258,38 @@ impl WidgetRef for &BottomPane<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
+
|
||||
+#[cfg(test)]
|
||||
+mod tests {
|
||||
+ use super::*;
|
||||
+ use crate::app_event::AppEvent;
|
||||
+ use std::path::PathBuf;
|
||||
+ use std::sync::mpsc::channel;
|
||||
+
|
||||
+ fn make_sender() -> AppEventSender {
|
||||
+ let (tx, _rx) = channel::<AppEvent>();
|
||||
+ AppEventSender::new(tx)
|
||||
+ }
|
||||
+
|
||||
+ fn exec_request() -> ApprovalRequest {
|
||||
+ ApprovalRequest::Exec {
|
||||
+ id: "1".to_string(),
|
||||
+ command: vec!["echo".into(), "ok".into()],
|
||||
+ cwd: PathBuf::from("."),
|
||||
+ reason: None,
|
||||
+ }
|
||||
+ }
|
||||
+
|
||||
+ #[test]
|
||||
+ fn ctrl_c_on_modal_consumes_and_shows_quit_hint() {
|
||||
+ let tx = make_sender();
|
||||
```
|
||||
|
||||
> Same comments about retaining `rx` here.
|
||||
|
||||
- Created: 2025-07-27 21:44:16 UTC | Link: https://github.com/openai/codex/pull/1696#discussion_r2234143671
|
||||
|
||||
```diff
|
||||
@@ -234,3 +258,38 @@ impl WidgetRef for &BottomPane<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
+
|
||||
+#[cfg(test)]
|
||||
+mod tests {
|
||||
+ use super::*;
|
||||
+ use crate::app_event::AppEvent;
|
||||
+ use std::path::PathBuf;
|
||||
+ use std::sync::mpsc::channel;
|
||||
+
|
||||
+ fn make_sender() -> AppEventSender {
|
||||
+ let (tx, _rx) = channel::<AppEvent>();
|
||||
+ AppEventSender::new(tx)
|
||||
+ }
|
||||
+
|
||||
+ fn exec_request() -> ApprovalRequest {
|
||||
+ ApprovalRequest::Exec {
|
||||
+ id: "1".to_string(),
|
||||
+ command: vec!["echo".into(), "ok".into()],
|
||||
+ cwd: PathBuf::from("."),
|
||||
+ reason: None,
|
||||
+ }
|
||||
+ }
|
||||
+
|
||||
+ #[test]
|
||||
+ fn ctrl_c_on_modal_consumes_and_shows_quit_hint() {
|
||||
+ let tx = make_sender();
|
||||
+ let mut pane = BottomPane::new(BottomPaneParams {
|
||||
+ app_event_tx: tx,
|
||||
+ has_input_focus: true,
|
||||
+ });
|
||||
+ pane.push_approval_request(exec_request());
|
||||
+ assert!(pane.on_ctrl_c());
|
||||
+ assert!(pane.ctrl_c_quit_hint_visible());
|
||||
+ assert!(!pane.on_ctrl_c());
|
||||
```
|
||||
|
||||
> This would change slightly, as well:
|
||||
>
|
||||
> ```suggestion
|
||||
> assert_eq!(CancellationEvent::Handled, pane.on_ctrl_c());
|
||||
> assert!(pane.ctrl_c_quit_hint_visible());
|
||||
> assert_eq!(CancellationEvent::Ignored, pane.on_ctrl_c());
|
||||
> ```
|
||||
|
||||
### codex-rs/tui/src/chatwidget.rs
|
||||
|
||||
- Created: 2025-07-27 21:50:53 UTC | Link: https://github.com/openai/codex/pull/1696#discussion_r2234145446
|
||||
|
||||
```diff
|
||||
@@ -297,13 +298,29 @@ impl ChatWidget<'_> {
|
||||
cwd,
|
||||
reason,
|
||||
}) => {
|
||||
+ // Print the command to the history so it is visible in the
|
||||
+ // transcript *before* the modal asks for approval.
|
||||
+ let cmdline = strip_bash_lc_and_escape(&command);
|
||||
+ let mut text = String::new();
|
||||
+ text.push_str("command requires approval:\n");
|
||||
+ text.push_str("$ ");
|
||||
+ text.push_str(&cmdline);
|
||||
```
|
||||
|
||||
> I would generally lean on `format!()` when possible (using the `{}` for placeholders).
|
||||
>
|
||||
> ```suggestion
|
||||
> let mut text = format!("command requires approval:\n$ {cmdline}");
|
||||
> ```
|
||||
>
|
||||
> You could get even fancier doing something like:
|
||||
>
|
||||
> ```rust
|
||||
> let text = format!("command requires approval:\n$ {cmdline}{reason}", reason = reason.map(|r| format!("\n{r}")).unwrap_or_default());
|
||||
> ```
|
||||
|
||||
- Created: 2025-07-28 18:09:42 UTC | Link: https://github.com/openai/codex/pull/1696#discussion_r2237487224
|
||||
|
||||
```diff
|
||||
@@ -449,9 +466,11 @@ impl ChatWidget<'_> {
|
||||
}
|
||||
|
||||
/// Handle Ctrl-C key press.
|
||||
- /// Returns true if the key press was handled, false if it was not.
|
||||
- /// If the key press was not handled, the caller should handle it (likely by exiting the process).
|
||||
pub(crate) fn on_ctrl_c(&mut self) -> bool {
|
||||
+ match self.bottom_pane.on_ctrl_c() {
|
||||
+ CancellationEvent::Handled => return true,
|
||||
```
|
||||
|
||||
> Should this function also return `CancellationEvent` instead of `bool`?
|
||||
|
||||
### codex-rs/tui/src/user_approval_widget.rs
|
||||
|
||||
- Created: 2025-07-27 21:52:56 UTC | Link: https://github.com/openai/codex/pull/1696#discussion_r2234146066
|
||||
|
||||
```diff
|
||||
@@ -265,7 +271,36 @@ impl UserApprovalWidget<'_> {
|
||||
self.send_decision_with_feedback(decision, String::new())
|
||||
}
|
||||
|
||||
- fn send_decision_with_feedback(&mut self, decision: ReviewDecision, _feedback: String) {
|
||||
+ fn send_decision_with_feedback(&mut self, decision: ReviewDecision, feedback: String) {
|
||||
+ // Emit a short summary into the history so the transcript captures the user's decision.
|
||||
+ let mut lines: Vec<Line<'static>> = Vec::new();
|
||||
+ match &self.approval_request {
|
||||
+ ApprovalRequest::Exec { command, .. } => {
|
||||
+ let cmd = strip_bash_lc_and_escape(command);
|
||||
+ lines.push(Line::from("approval decision"));
|
||||
+ lines.push(Line::from(format!("$ {cmd}")));
|
||||
+ lines.push(Line::from(format!("decision: {decision:?}")));
|
||||
+ if !feedback.trim().is_empty() {
|
||||
+ lines.push(Line::from("feedback:"));
|
||||
+ for l in feedback.lines() {
|
||||
+ lines.push(Line::from(l.to_string()));
|
||||
+ }
|
||||
+ }
|
||||
+ lines.push(Line::from(""));
|
||||
+ }
|
||||
+ ApprovalRequest::ApplyPatch { .. } => {
|
||||
+ lines.push(Line::from(format!("patch approval decision: {decision:?}")));
|
||||
+ if !feedback.trim().is_empty() {
|
||||
+ lines.push(Line::from("feedback:"));
|
||||
+ for l in feedback.lines() {
|
||||
+ lines.push(Line::from(l.to_string()));
|
||||
+ }
|
||||
+ }
|
||||
+ lines.push(Line::from(""));
|
||||
```
|
||||
|
||||
> This is the same as the above case, right? So maybe do it once after the `match`?
|
||||
1032
prs/bolinfest/PR-1705.md
Normal file
1032
prs/bolinfest/PR-1705.md
Normal file
File diff suppressed because it is too large
Load Diff
306
prs/bolinfest/PR-1710.md
Normal file
306
prs/bolinfest/PR-1710.md
Normal file
@@ -0,0 +1,306 @@
|
||||
# PR #1710: fix: long lines incorrectly wrapped
|
||||
|
||||
- URL: https://github.com/openai/codex/pull/1710
|
||||
- Author: nornagon-openai
|
||||
- Created: 2025-07-28 17:36:34 UTC
|
||||
- Updated: 2025-07-28 19:19:13 UTC
|
||||
- Changes: +113/-30, Files changed: 1, Commits: 8
|
||||
|
||||
## Description
|
||||
|
||||
fix to #1685.
|
||||
|
||||
## Full Diff
|
||||
|
||||
```diff
|
||||
diff --git a/codex-rs/tui/src/insert_history.rs b/codex-rs/tui/src/insert_history.rs
|
||||
index 7948436cd8..1e8b1f5392 100644
|
||||
--- a/codex-rs/tui/src/insert_history.rs
|
||||
+++ b/codex-rs/tui/src/insert_history.rs
|
||||
@@ -1,7 +1,9 @@
|
||||
+use std::fmt;
|
||||
use std::io;
|
||||
use std::io::Write;
|
||||
|
||||
use crate::tui;
|
||||
+use crossterm::Command;
|
||||
use crossterm::queue;
|
||||
use crossterm::style::Color as CColor;
|
||||
use crossterm::style::Colors;
|
||||
@@ -11,46 +13,127 @@ use crossterm::style::SetBackgroundColor;
|
||||
use crossterm::style::SetColors;
|
||||
use crossterm::style::SetForegroundColor;
|
||||
use ratatui::layout::Position;
|
||||
+use ratatui::layout::Size;
|
||||
use ratatui::prelude::Backend;
|
||||
use ratatui::style::Color;
|
||||
use ratatui::style::Modifier;
|
||||
use ratatui::text::Line;
|
||||
use ratatui::text::Span;
|
||||
|
||||
+/// Insert `lines` above the viewport.
|
||||
pub(crate) fn insert_history_lines(terminal: &mut tui::Tui, lines: Vec<Line<'static>>) {
|
||||
- let screen_height = terminal
|
||||
- .backend()
|
||||
- .size()
|
||||
- .map(|s| s.height)
|
||||
- .unwrap_or(0xffffu16);
|
||||
+ let screen_size = terminal.backend().size().unwrap_or(Size::new(0, 0));
|
||||
+
|
||||
let mut area = terminal.get_frame().area();
|
||||
- // We scroll up one line at a time because we can't position the cursor
|
||||
- // above the top of the screen. i.e. if
|
||||
- // lines.len() > screen_height - area.top()
|
||||
- // we would need to print the first line above the top of the screen, which
|
||||
- // can't be done.
|
||||
- for line in lines.into_iter() {
|
||||
- // 1. Scroll everything above the viewport up by one line
|
||||
- if area.bottom() >= screen_height {
|
||||
- let top = area.top();
|
||||
- terminal.backend_mut().scroll_region_up(0..top, 1).ok();
|
||||
- // 2. Move the cursor to the blank line
|
||||
- terminal.set_cursor_position(Position::new(0, top - 1)).ok();
|
||||
- } else {
|
||||
- // If the viewport isn't at the bottom of the screen, scroll down instead
|
||||
- terminal
|
||||
- .backend_mut()
|
||||
- .scroll_region_down(area.top()..area.bottom() + 1, 1)
|
||||
- .ok();
|
||||
- terminal
|
||||
- .set_cursor_position(Position::new(0, area.top()))
|
||||
- .ok();
|
||||
- area.y += 1;
|
||||
- }
|
||||
- // 3. Write the line
|
||||
+
|
||||
+ let wrapped_lines = wrapped_line_count(&lines, area.width);
|
||||
+ let cursor_top = if area.bottom() < screen_size.height {
|
||||
+ // If the viewport is not at the bottom of the screen, scroll it down to make room.
|
||||
+ // Don't scroll it past the bottom of the screen.
|
||||
+ let scroll_amount = wrapped_lines.min(screen_size.height - area.bottom());
|
||||
+ terminal
|
||||
+ .backend_mut()
|
||||
+ .scroll_region_down(area.top()..screen_size.height, scroll_amount)
|
||||
+ .ok();
|
||||
+ let cursor_top = area.top() - 1;
|
||||
+ area.y += scroll_amount;
|
||||
+ terminal.set_viewport_area(area);
|
||||
+ cursor_top
|
||||
+ } else {
|
||||
+ area.top() - 1
|
||||
+ };
|
||||
+
|
||||
+ // Limit the scroll region to the lines from the top of the screen to the
|
||||
+ // top of the viewport. With this in place, when we add lines inside this
|
||||
+ // area, only the lines in this area will be scrolled. We place the cursor
|
||||
+ // at the end of the scroll region, and add lines starting there.
|
||||
+ //
|
||||
+ // ┌─Screen───────────────────────┐
|
||||
+ // │┌╌Scroll region╌╌╌╌╌╌╌╌╌╌╌╌╌╌┐│
|
||||
+ // │┆ ┆│
|
||||
+ // │┆ ┆│
|
||||
+ // │┆ ┆│
|
||||
+ // │█╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┘│
|
||||
+ // │╭─Viewport───────────────────╮│
|
||||
+ // ││ ││
|
||||
+ // │╰────────────────────────────╯│
|
||||
+ // └──────────────────────────────┘
|
||||
+ queue!(std::io::stdout(), SetScrollRegion(1..area.top())).ok();
|
||||
+
|
||||
+ terminal
|
||||
+ .set_cursor_position(Position::new(0, cursor_top))
|
||||
+ .ok();
|
||||
+
|
||||
+ for line in lines {
|
||||
+ queue!(std::io::stdout(), Print("\r\n")).ok();
|
||||
write_spans(&mut std::io::stdout(), line.iter()).ok();
|
||||
}
|
||||
- terminal.set_viewport_area(area);
|
||||
+
|
||||
+ queue!(std::io::stdout(), ResetScrollRegion).ok();
|
||||
+}
|
||||
+
|
||||
+fn wrapped_line_count(lines: &[Line], width: u16) -> u16 {
|
||||
+ let mut count = 0;
|
||||
+ for line in lines {
|
||||
+ count += line_height(line, width);
|
||||
+ }
|
||||
+ count
|
||||
+}
|
||||
+
|
||||
+fn line_height(line: &Line, width: u16) -> u16 {
|
||||
+ use unicode_width::UnicodeWidthStr;
|
||||
+ // get the total display width of the line, accounting for double-width chars
|
||||
+ let total_width = line
|
||||
+ .spans
|
||||
+ .iter()
|
||||
+ .map(|span| span.content.width())
|
||||
+ .sum::<usize>();
|
||||
+ // divide by width to get the number of lines, rounding up
|
||||
+ if width == 0 {
|
||||
+ 1
|
||||
+ } else {
|
||||
+ (total_width as u16).div_ceil(width).max(1)
|
||||
+ }
|
||||
+}
|
||||
+
|
||||
+#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
+pub struct SetScrollRegion(pub std::ops::Range<u16>);
|
||||
+
|
||||
+impl Command for SetScrollRegion {
|
||||
+ fn write_ansi(&self, f: &mut impl fmt::Write) -> fmt::Result {
|
||||
+ write!(f, "\x1b[{};{}r", self.0.start, self.0.end)
|
||||
+ }
|
||||
+
|
||||
+ #[cfg(windows)]
|
||||
+ fn execute_winapi(&self) -> std::io::Result<()> {
|
||||
+ panic!("tried to execute SetScrollRegion command using WinAPI, use ANSI instead");
|
||||
+ }
|
||||
+
|
||||
+ #[cfg(windows)]
|
||||
+ fn is_ansi_code_supported(&self) -> bool {
|
||||
+ // TODO(nornagon): is this supported on Windows?
|
||||
+ true
|
||||
+ }
|
||||
+}
|
||||
+
|
||||
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
+pub struct ResetScrollRegion;
|
||||
+
|
||||
+impl Command for ResetScrollRegion {
|
||||
+ fn write_ansi(&self, f: &mut impl fmt::Write) -> fmt::Result {
|
||||
+ write!(f, "\x1b[r")
|
||||
+ }
|
||||
+
|
||||
+ #[cfg(windows)]
|
||||
+ fn execute_winapi(&self) -> std::io::Result<()> {
|
||||
+ panic!("tried to execute ResetScrollRegion command using WinAPI, use ANSI instead");
|
||||
+ }
|
||||
+
|
||||
+ #[cfg(windows)]
|
||||
+ fn is_ansi_code_supported(&self) -> bool {
|
||||
+ // TODO(nornagon): is this supported on Windows?
|
||||
+ true
|
||||
+ }
|
||||
}
|
||||
|
||||
struct ModifierDiff {
|
||||
```
|
||||
|
||||
## Review Comments
|
||||
|
||||
### codex-rs/tui/src/insert_history.rs
|
||||
|
||||
- Created: 2025-07-28 18:12:00 UTC | Link: https://github.com/openai/codex/pull/1710#discussion_r2237491320
|
||||
|
||||
```diff
|
||||
@@ -11,46 +11,26 @@ use crossterm::style::SetBackgroundColor;
|
||||
use crossterm::style::SetColors;
|
||||
use crossterm::style::SetForegroundColor;
|
||||
use ratatui::layout::Position;
|
||||
-use ratatui::prelude::Backend;
|
||||
use ratatui::style::Color;
|
||||
use ratatui::style::Modifier;
|
||||
use ratatui::text::Line;
|
||||
use ratatui::text::Span;
|
||||
|
||||
pub(crate) fn insert_history_lines(terminal: &mut tui::Tui, lines: Vec<Line<'static>>) {
|
||||
- let screen_height = terminal
|
||||
- .backend()
|
||||
- .size()
|
||||
- .map(|s| s.height)
|
||||
- .unwrap_or(0xffffu16);
|
||||
- let mut area = terminal.get_frame().area();
|
||||
- // We scroll up one line at a time because we can't position the cursor
|
||||
- // above the top of the screen. i.e. if
|
||||
- // lines.len() > screen_height - area.top()
|
||||
- // we would need to print the first line above the top of the screen, which
|
||||
- // can't be done.
|
||||
- for line in lines.into_iter() {
|
||||
- // 1. Scroll everything above the viewport up by one line
|
||||
- if area.bottom() >= screen_height {
|
||||
- let top = area.top();
|
||||
- terminal.backend_mut().scroll_region_up(0..top, 1).ok();
|
||||
- // 2. Move the cursor to the blank line
|
||||
- terminal.set_cursor_position(Position::new(0, top - 1)).ok();
|
||||
- } else {
|
||||
- // If the viewport isn't at the bottom of the screen, scroll down instead
|
||||
- terminal
|
||||
- .backend_mut()
|
||||
- .scroll_region_down(area.top()..area.bottom() + 1, 1)
|
||||
- .ok();
|
||||
- terminal
|
||||
- .set_cursor_position(Position::new(0, area.top()))
|
||||
- .ok();
|
||||
- area.y += 1;
|
||||
- }
|
||||
- // 3. Write the line
|
||||
+ let area = terminal.get_frame().area();
|
||||
+
|
||||
+ queue!(std::io::stdout(), Print(format!("\x1b[1;{}r", area.top()))).ok();
|
||||
```
|
||||
|
||||
> Can you add a comment because I don't know ANSI codes offhand?
|
||||
|
||||
- Created: 2025-07-28 18:14:44 UTC | Link: https://github.com/openai/codex/pull/1710#discussion_r2237497109
|
||||
|
||||
```diff
|
||||
@@ -11,46 +11,26 @@ use crossterm::style::SetBackgroundColor;
|
||||
use crossterm::style::SetColors;
|
||||
use crossterm::style::SetForegroundColor;
|
||||
use ratatui::layout::Position;
|
||||
-use ratatui::prelude::Backend;
|
||||
use ratatui::style::Color;
|
||||
use ratatui::style::Modifier;
|
||||
use ratatui::text::Line;
|
||||
use ratatui::text::Span;
|
||||
|
||||
pub(crate) fn insert_history_lines(terminal: &mut tui::Tui, lines: Vec<Line<'static>>) {
|
||||
- let screen_height = terminal
|
||||
- .backend()
|
||||
- .size()
|
||||
- .map(|s| s.height)
|
||||
- .unwrap_or(0xffffu16);
|
||||
- let mut area = terminal.get_frame().area();
|
||||
- // We scroll up one line at a time because we can't position the cursor
|
||||
- // above the top of the screen. i.e. if
|
||||
- // lines.len() > screen_height - area.top()
|
||||
- // we would need to print the first line above the top of the screen, which
|
||||
- // can't be done.
|
||||
- for line in lines.into_iter() {
|
||||
- // 1. Scroll everything above the viewport up by one line
|
||||
- if area.bottom() >= screen_height {
|
||||
- let top = area.top();
|
||||
- terminal.backend_mut().scroll_region_up(0..top, 1).ok();
|
||||
- // 2. Move the cursor to the blank line
|
||||
- terminal.set_cursor_position(Position::new(0, top - 1)).ok();
|
||||
- } else {
|
||||
- // If the viewport isn't at the bottom of the screen, scroll down instead
|
||||
- terminal
|
||||
- .backend_mut()
|
||||
- .scroll_region_down(area.top()..area.bottom() + 1, 1)
|
||||
- .ok();
|
||||
- terminal
|
||||
- .set_cursor_position(Position::new(0, area.top()))
|
||||
- .ok();
|
||||
- area.y += 1;
|
||||
- }
|
||||
- // 3. Write the line
|
||||
+ let area = terminal.get_frame().area();
|
||||
+
|
||||
+ queue!(std::io::stdout(), Print(format!("\x1b[1;{}r", area.top()))).ok();
|
||||
+
|
||||
+ terminal
|
||||
+ .set_cursor_position(Position::new(0, area.top() - 1))
|
||||
+ .ok();
|
||||
+
|
||||
+ for line in lines {
|
||||
+ queue!(std::io::stdout(), Print("\r\n")).ok();
|
||||
write_spans(&mut std::io::stdout(), line.iter()).ok();
|
||||
}
|
||||
- terminal.set_viewport_area(area);
|
||||
+
|
||||
+ queue!(std::io::stdout(), Print("\x1b[r")).ok();
|
||||
```
|
||||
|
||||
> Same here?
|
||||
1770
prs/bolinfest/PR-1712.md
Normal file
1770
prs/bolinfest/PR-1712.md
Normal file
File diff suppressed because it is too large
Load Diff
544
prs/bolinfest/PR-1713.md
Normal file
544
prs/bolinfest/PR-1713.md
Normal file
@@ -0,0 +1,544 @@
|
||||
# PR #1713: replace login screen with a simple prompt
|
||||
|
||||
- URL: https://github.com/openai/codex/pull/1713
|
||||
- Author: nornagon-openai
|
||||
- Created: 2025-07-28 21:44:12 UTC
|
||||
- Updated: 2025-07-29 00:25:21 UTC
|
||||
- Changes: +47/-107, Files changed: 6, Commits: 7
|
||||
|
||||
## Description
|
||||
|
||||
Perhaps there was an intention to make the login screen prettier, but it feels quite silly right now to just have a screen that says "press q", so replace it with something that lets the user directly login without having to quit the app.
|
||||
|
||||
<img width="1283" height="635" alt="Screenshot 2025-07-28 at 2 54 05 PM" src="https://github.com/user-attachments/assets/f19e5595-6ef9-4a2d-b409-aa61b30d3628" />
|
||||
|
||||
## Full Diff
|
||||
|
||||
```diff
|
||||
diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs
|
||||
index efda03bda4..6dd596ff9f 100644
|
||||
--- a/codex-rs/cli/src/main.rs
|
||||
+++ b/codex-rs/cli/src/main.rs
|
||||
@@ -106,7 +106,7 @@ async fn cli_main(codex_linux_sandbox_exe: Option<PathBuf>) -> anyhow::Result<()
|
||||
None => {
|
||||
let mut tui_cli = cli.interactive;
|
||||
prepend_config_flags(&mut tui_cli.config_overrides, cli.config_overrides);
|
||||
- let usage = codex_tui::run_main(tui_cli, codex_linux_sandbox_exe)?;
|
||||
+ let usage = codex_tui::run_main(tui_cli, codex_linux_sandbox_exe).await?;
|
||||
println!("{}", codex_core::protocol::FinalOutput::from(usage));
|
||||
}
|
||||
Some(Subcommand::Exec(mut exec_cli)) => {
|
||||
diff --git a/codex-rs/login/src/lib.rs b/codex-rs/login/src/lib.rs
|
||||
index 99d2f7f983..ab92ecf616 100644
|
||||
--- a/codex-rs/login/src/lib.rs
|
||||
+++ b/codex-rs/login/src/lib.rs
|
||||
@@ -9,6 +9,7 @@ use std::io::Write;
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
use std::path::Path;
|
||||
use std::process::Stdio;
|
||||
+use std::time::Duration;
|
||||
use tokio::process::Command;
|
||||
|
||||
const SOURCE_FOR_PYTHON_SERVER: &str = include_str!("./login_with_chatgpt.py");
|
||||
@@ -73,7 +74,11 @@ pub async fn try_read_auth_json(codex_home: &Path) -> std::io::Result<AuthDotJso
|
||||
let auth_dot_json: AuthDotJson = serde_json::from_str(&contents)?;
|
||||
|
||||
if is_expired(&auth_dot_json) {
|
||||
- let refresh_response = try_refresh_token(&auth_dot_json).await?;
|
||||
+ let refresh_response =
|
||||
+ tokio::time::timeout(Duration::from_secs(60), try_refresh_token(&auth_dot_json))
|
||||
+ .await
|
||||
+ .map_err(|_| std::io::Error::other("timed out while refreshing OpenAI API key"))?
|
||||
+ .map_err(std::io::Error::other)?;
|
||||
let mut auth_dot_json = auth_dot_json;
|
||||
auth_dot_json.tokens.id_token = refresh_response.id_token;
|
||||
if let Some(refresh_token) = refresh_response.refresh_token {
|
||||
diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs
|
||||
index e7097e6af0..b671075ba8 100644
|
||||
--- a/codex-rs/tui/src/app.rs
|
||||
+++ b/codex-rs/tui/src/app.rs
|
||||
@@ -5,7 +5,6 @@ use crate::file_search::FileSearchManager;
|
||||
use crate::get_git_diff::get_git_diff;
|
||||
use crate::git_warning_screen::GitWarningOutcome;
|
||||
use crate::git_warning_screen::GitWarningScreen;
|
||||
-use crate::login_screen::LoginScreen;
|
||||
use crate::scroll_event_helper::ScrollEventHelper;
|
||||
use crate::slash_command::SlashCommand;
|
||||
use crate::tui;
|
||||
@@ -37,8 +36,6 @@ enum AppState<'a> {
|
||||
/// `AppState`.
|
||||
widget: Box<ChatWidget<'a>>,
|
||||
},
|
||||
- /// The login screen for the OpenAI provider.
|
||||
- Login { screen: LoginScreen },
|
||||
/// The start-up warning that recommends running codex inside a Git repo.
|
||||
GitWarning { screen: GitWarningScreen },
|
||||
}
|
||||
@@ -74,7 +71,6 @@ impl App<'_> {
|
||||
pub(crate) fn new(
|
||||
config: Config,
|
||||
initial_prompt: Option<String>,
|
||||
- show_login_screen: bool,
|
||||
show_git_warning: bool,
|
||||
initial_images: Vec<std::path::PathBuf>,
|
||||
) -> Self {
|
||||
@@ -138,18 +134,7 @@ impl App<'_> {
|
||||
});
|
||||
}
|
||||
|
||||
- let (app_state, chat_args) = if show_login_screen {
|
||||
- (
|
||||
- AppState::Login {
|
||||
- screen: LoginScreen::new(app_event_tx.clone(), config.codex_home.clone()),
|
||||
- },
|
||||
- Some(ChatWidgetArgs {
|
||||
- config: config.clone(),
|
||||
- initial_prompt,
|
||||
- initial_images,
|
||||
- }),
|
||||
- )
|
||||
- } else if show_git_warning {
|
||||
+ let (app_state, chat_args) = if show_git_warning {
|
||||
(
|
||||
AppState::GitWarning {
|
||||
screen: GitWarningScreen::new(),
|
||||
@@ -243,7 +228,7 @@ impl App<'_> {
|
||||
AppState::Chat { widget } => {
|
||||
widget.on_ctrl_c();
|
||||
}
|
||||
- AppState::Login { .. } | AppState::GitWarning { .. } => {
|
||||
+ AppState::GitWarning { .. } => {
|
||||
// No-op.
|
||||
}
|
||||
}
|
||||
@@ -264,7 +249,7 @@ impl App<'_> {
|
||||
self.dispatch_key_event(key_event);
|
||||
}
|
||||
}
|
||||
- AppState::Login { .. } | AppState::GitWarning { .. } => {
|
||||
+ AppState::GitWarning { .. } => {
|
||||
self.app_event_tx.send(AppEvent::ExitRequest);
|
||||
}
|
||||
}
|
||||
@@ -288,11 +273,11 @@ impl App<'_> {
|
||||
}
|
||||
AppEvent::CodexOp(op) => match &mut self.app_state {
|
||||
AppState::Chat { widget } => widget.submit_op(op),
|
||||
- AppState::Login { .. } | AppState::GitWarning { .. } => {}
|
||||
+ AppState::GitWarning { .. } => {}
|
||||
},
|
||||
AppEvent::LatestLog(line) => match &mut self.app_state {
|
||||
AppState::Chat { widget } => widget.update_latest_log(line),
|
||||
- AppState::Login { .. } | AppState::GitWarning { .. } => {}
|
||||
+ AppState::GitWarning { .. } => {}
|
||||
},
|
||||
AppEvent::DispatchCommand(command) => match command {
|
||||
SlashCommand::New => {
|
||||
@@ -348,9 +333,7 @@ impl App<'_> {
|
||||
pub(crate) fn token_usage(&self) -> codex_core::protocol::TokenUsage {
|
||||
match &self.app_state {
|
||||
AppState::Chat { widget } => widget.token_usage().clone(),
|
||||
- AppState::Login { .. } | AppState::GitWarning { .. } => {
|
||||
- codex_core::protocol::TokenUsage::default()
|
||||
- }
|
||||
+ AppState::GitWarning { .. } => codex_core::protocol::TokenUsage::default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -361,9 +344,6 @@ impl App<'_> {
|
||||
AppState::Chat { widget } => {
|
||||
terminal.draw(|frame| frame.render_widget_ref(&**widget, frame.area()))?;
|
||||
}
|
||||
- AppState::Login { screen } => {
|
||||
- terminal.draw(|frame| frame.render_widget_ref(&*screen, frame.area()))?;
|
||||
- }
|
||||
AppState::GitWarning { screen } => {
|
||||
terminal.draw(|frame| frame.render_widget_ref(&*screen, frame.area()))?;
|
||||
}
|
||||
@@ -378,7 +358,6 @@ impl App<'_> {
|
||||
AppState::Chat { widget } => {
|
||||
widget.handle_key_event(key_event);
|
||||
}
|
||||
- AppState::Login { screen } => screen.handle_key_event(key_event),
|
||||
AppState::GitWarning { screen } => match screen.handle_key_event(key_event) {
|
||||
GitWarningOutcome::Continue => {
|
||||
// User accepted – switch to chat view.
|
||||
@@ -409,21 +388,21 @@ impl App<'_> {
|
||||
fn dispatch_paste_event(&mut self, pasted: String) {
|
||||
match &mut self.app_state {
|
||||
AppState::Chat { widget } => widget.handle_paste(pasted),
|
||||
- AppState::Login { .. } | AppState::GitWarning { .. } => {}
|
||||
+ AppState::GitWarning { .. } => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn dispatch_scroll_event(&mut self, scroll_delta: i32) {
|
||||
match &mut self.app_state {
|
||||
AppState::Chat { widget } => widget.handle_scroll_delta(scroll_delta),
|
||||
- AppState::Login { .. } | AppState::GitWarning { .. } => {}
|
||||
+ AppState::GitWarning { .. } => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn dispatch_codex_event(&mut self, event: Event) {
|
||||
match &mut self.app_state {
|
||||
AppState::Chat { widget } => widget.handle_codex_event(event),
|
||||
- AppState::Login { .. } | AppState::GitWarning { .. } => {}
|
||||
+ AppState::GitWarning { .. } => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs
|
||||
index 905f0aaf0b..1f660b1aaf 100644
|
||||
--- a/codex-rs/tui/src/lib.rs
|
||||
+++ b/codex-rs/tui/src/lib.rs
|
||||
@@ -14,6 +14,7 @@ use codex_core::util::is_inside_git_repo;
|
||||
use codex_login::try_read_openai_api_key;
|
||||
use log_layer::TuiLogLayer;
|
||||
use std::fs::OpenOptions;
|
||||
+use std::io::Write;
|
||||
use std::path::PathBuf;
|
||||
use tracing_appender::non_blocking;
|
||||
use tracing_subscriber::EnvFilter;
|
||||
@@ -35,7 +36,6 @@ mod git_warning_screen;
|
||||
mod history_cell;
|
||||
mod insert_history;
|
||||
mod log_layer;
|
||||
-mod login_screen;
|
||||
mod markdown;
|
||||
mod scroll_event_helper;
|
||||
mod slash_command;
|
||||
@@ -47,7 +47,7 @@ mod user_approval_widget;
|
||||
|
||||
pub use cli::Cli;
|
||||
|
||||
-pub fn run_main(
|
||||
+pub async fn run_main(
|
||||
cli: Cli,
|
||||
codex_linux_sandbox_exe: Option<PathBuf>,
|
||||
) -> std::io::Result<codex_core::protocol::TokenUsage> {
|
||||
@@ -142,7 +142,25 @@ pub fn run_main(
|
||||
.with(tui_layer)
|
||||
.try_init();
|
||||
|
||||
- let show_login_screen = should_show_login_screen(&config);
|
||||
+ let show_login_screen = should_show_login_screen(&config).await;
|
||||
+ if show_login_screen {
|
||||
+ std::io::stdout().write_all(
|
||||
+ b"Oh dear, we don't seem to have an API key.\nTerribly sorry, but may I open a browser window for you to log in? [Yn] ",
|
||||
+ )?;
|
||||
+ std::io::stdout().flush()?;
|
||||
+ let mut input = String::new();
|
||||
+ std::io::stdin().read_line(&mut input)?;
|
||||
+ let trimmed = input.trim();
|
||||
+ if !(trimmed.is_empty() || trimmed.eq_ignore_ascii_case("y")) {
|
||||
+ std::io::stdout().write_all(b"Right-o, fair enough. See you next time!\n")?;
|
||||
+ std::process::exit(1);
|
||||
+ }
|
||||
+ // Spawn a task to run the login command.
|
||||
+ // Block until the login command is finished.
|
||||
+ let new_key = codex_login::login_with_chatgpt(&config.codex_home, false).await?;
|
||||
+ set_openai_api_key(new_key);
|
||||
+ std::io::stdout().write_all(b"Excellent, looks like that worked. Let's get started!\n")?;
|
||||
+ }
|
||||
|
||||
// Determine whether we need to display the "not a git repo" warning
|
||||
// modal. The flag is shown when the current working directory is *not*
|
||||
@@ -150,14 +168,13 @@ pub fn run_main(
|
||||
// `--allow-no-git-exec` flag.
|
||||
let show_git_warning = !cli.skip_git_repo_check && !is_inside_git_repo(&config);
|
||||
|
||||
- run_ratatui_app(cli, config, show_login_screen, show_git_warning, log_rx)
|
||||
+ run_ratatui_app(cli, config, show_git_warning, log_rx)
|
||||
.map_err(|err| std::io::Error::other(err.to_string()))
|
||||
}
|
||||
|
||||
fn run_ratatui_app(
|
||||
cli: Cli,
|
||||
config: Config,
|
||||
- show_login_screen: bool,
|
||||
show_git_warning: bool,
|
||||
mut log_rx: tokio::sync::mpsc::UnboundedReceiver<String>,
|
||||
) -> color_eyre::Result<codex_core::protocol::TokenUsage> {
|
||||
@@ -172,13 +189,7 @@ fn run_ratatui_app(
|
||||
terminal.clear()?;
|
||||
|
||||
let Cli { prompt, images, .. } = cli;
|
||||
- let mut app = App::new(
|
||||
- config.clone(),
|
||||
- prompt,
|
||||
- show_login_screen,
|
||||
- show_git_warning,
|
||||
- images,
|
||||
- );
|
||||
+ let mut app = App::new(config.clone(), prompt, show_git_warning, images);
|
||||
|
||||
// Bridge log receiver into the AppEvent channel so latest log lines update the UI.
|
||||
{
|
||||
@@ -210,26 +221,17 @@ fn restore() {
|
||||
}
|
||||
}
|
||||
|
||||
-#[allow(clippy::unwrap_used)]
|
||||
-fn should_show_login_screen(config: &Config) -> bool {
|
||||
+async fn should_show_login_screen(config: &Config) -> bool {
|
||||
if is_in_need_of_openai_api_key(config) {
|
||||
// Reading the OpenAI API key is an async operation because it may need
|
||||
// to refresh the token. Block on it.
|
||||
let codex_home = config.codex_home.clone();
|
||||
- let (tx, rx) = tokio::sync::oneshot::channel();
|
||||
- tokio::spawn(async move {
|
||||
- match try_read_openai_api_key(&codex_home).await {
|
||||
- Ok(openai_api_key) => {
|
||||
- set_openai_api_key(openai_api_key);
|
||||
- tx.send(false).unwrap();
|
||||
- }
|
||||
- Err(_) => {
|
||||
- tx.send(true).unwrap();
|
||||
- }
|
||||
- }
|
||||
- });
|
||||
- // TODO(mbolin): Impose some sort of timeout.
|
||||
- tokio::task::block_in_place(|| rx.blocking_recv()).unwrap()
|
||||
+ if let Ok(openai_api_key) = try_read_openai_api_key(&codex_home).await {
|
||||
+ set_openai_api_key(openai_api_key);
|
||||
+ false
|
||||
+ } else {
|
||||
+ true
|
||||
+ }
|
||||
} else {
|
||||
false
|
||||
}
|
||||
diff --git a/codex-rs/tui/src/login_screen.rs b/codex-rs/tui/src/login_screen.rs
|
||||
deleted file mode 100644
|
||||
index 1bd11c19d3..0000000000
|
||||
--- a/codex-rs/tui/src/login_screen.rs
|
||||
+++ /dev/null
|
||||
@@ -1,46 +0,0 @@
|
||||
-use std::path::PathBuf;
|
||||
-
|
||||
-use crossterm::event::KeyCode;
|
||||
-use crossterm::event::KeyEvent;
|
||||
-use ratatui::buffer::Buffer;
|
||||
-use ratatui::layout::Rect;
|
||||
-use ratatui::widgets::Paragraph;
|
||||
-use ratatui::widgets::Widget as _;
|
||||
-use ratatui::widgets::WidgetRef;
|
||||
-
|
||||
-use crate::app_event::AppEvent;
|
||||
-use crate::app_event_sender::AppEventSender;
|
||||
-
|
||||
-pub(crate) struct LoginScreen {
|
||||
- app_event_tx: AppEventSender,
|
||||
-
|
||||
- /// Use this with login_with_chatgpt() in login/src/lib.rs and, if
|
||||
- /// successful, update the in-memory config via
|
||||
- /// codex_core::openai_api_key::set_openai_api_key().
|
||||
- #[allow(dead_code)]
|
||||
- codex_home: PathBuf,
|
||||
-}
|
||||
-
|
||||
-impl LoginScreen {
|
||||
- pub(crate) fn new(app_event_tx: AppEventSender, codex_home: PathBuf) -> Self {
|
||||
- Self {
|
||||
- app_event_tx,
|
||||
- codex_home,
|
||||
- }
|
||||
- }
|
||||
-
|
||||
- pub(crate) fn handle_key_event(&mut self, key_event: KeyEvent) {
|
||||
- if let KeyCode::Char('q') = key_event.code {
|
||||
- self.app_event_tx.send(AppEvent::ExitRequest);
|
||||
- }
|
||||
- }
|
||||
-}
|
||||
-
|
||||
-impl WidgetRef for &LoginScreen {
|
||||
- fn render_ref(&self, area: Rect, buf: &mut Buffer) {
|
||||
- let text = Paragraph::new(
|
||||
- "Login using `codex login` and then run this command again. 'q' to quit.",
|
||||
- );
|
||||
- text.render(area, buf);
|
||||
- }
|
||||
-}
|
||||
diff --git a/codex-rs/tui/src/main.rs b/codex-rs/tui/src/main.rs
|
||||
index 480e56e88e..209febf035 100644
|
||||
--- a/codex-rs/tui/src/main.rs
|
||||
+++ b/codex-rs/tui/src/main.rs
|
||||
@@ -21,7 +21,7 @@ fn main() -> anyhow::Result<()> {
|
||||
.config_overrides
|
||||
.raw_overrides
|
||||
.splice(0..0, top_cli.config_overrides.raw_overrides);
|
||||
- let usage = run_main(inner, codex_linux_sandbox_exe)?;
|
||||
+ let usage = run_main(inner, codex_linux_sandbox_exe).await?;
|
||||
println!("{}", codex_core::protocol::FinalOutput::from(usage));
|
||||
Ok(())
|
||||
})
|
||||
```
|
||||
|
||||
## Review Comments
|
||||
|
||||
### codex-rs/tui/src/lib.rs
|
||||
|
||||
- Created: 2025-07-28 22:22:11 UTC | Link: https://github.com/openai/codex/pull/1713#discussion_r2237962280
|
||||
|
||||
```diff
|
||||
@@ -142,22 +142,39 @@ pub fn run_main(
|
||||
.with(tui_layer)
|
||||
.try_init();
|
||||
|
||||
- let show_login_screen = should_show_login_screen(&config);
|
||||
+ let show_login_screen = should_show_login_screen(&config).await;
|
||||
+ if show_login_screen {
|
||||
+ std::io::stdout().write_all(
|
||||
+ b"Oh dear, we don't seem to have an API key.\nTerribly sorry, but may I open a browser window for you to log in? [Yn] ",
|
||||
+ )?;
|
||||
+ std::io::stdout().flush()?;
|
||||
+ let mut input = String::new();
|
||||
+ std::io::stdin().read_line(&mut input)?;
|
||||
+ let trimmed = input.trim();
|
||||
+ if !(trimmed.is_empty() || trimmed.eq_ignore_ascii_case("y")) {
|
||||
```
|
||||
|
||||
> My mind wants to deMorgan this to...
|
||||
>
|
||||
> ```suggestion
|
||||
> if !trimmed.is_empty() && !trimmed.eq_ignore_ascii_case("y") {
|
||||
> ```
|
||||
|
||||
- Created: 2025-07-28 22:29:13 UTC | Link: https://github.com/openai/codex/pull/1713#discussion_r2237969504
|
||||
|
||||
```diff
|
||||
@@ -47,7 +47,7 @@ mod user_approval_widget;
|
||||
|
||||
pub use cli::Cli;
|
||||
|
||||
-pub fn run_main(
|
||||
+pub async fn run_main(
|
||||
```
|
||||
|
||||
> Hmm, so I wasn't clear on how async-friendly Ratatui is, which is why I avoided it here.
|
||||
>
|
||||
> I guess this is fine?
|
||||
|
||||
- Created: 2025-07-28 22:30:21 UTC | Link: https://github.com/openai/codex/pull/1713#discussion_r2237970644
|
||||
|
||||
```diff
|
||||
@@ -211,25 +222,23 @@ fn restore() {
|
||||
}
|
||||
|
||||
#[allow(clippy::unwrap_used)]
|
||||
-fn should_show_login_screen(config: &Config) -> bool {
|
||||
+async fn should_show_login_screen(config: &Config) -> bool {
|
||||
if is_in_need_of_openai_api_key(config) {
|
||||
// Reading the OpenAI API key is an async operation because it may need
|
||||
// to refresh the token. Block on it.
|
||||
let codex_home = config.codex_home.clone();
|
||||
let (tx, rx) = tokio::sync::oneshot::channel();
|
||||
- tokio::spawn(async move {
|
||||
- match try_read_openai_api_key(&codex_home).await {
|
||||
- Ok(openai_api_key) => {
|
||||
- set_openai_api_key(openai_api_key);
|
||||
- tx.send(false).unwrap();
|
||||
- }
|
||||
- Err(_) => {
|
||||
- tx.send(true).unwrap();
|
||||
- }
|
||||
+ match try_read_openai_api_key(&codex_home).await {
|
||||
```
|
||||
|
||||
> If you aren't going to `tokio::spawn()`, we don't need the `oneshot`, do we?
|
||||
|
||||
- Created: 2025-07-28 22:31:25 UTC | Link: https://github.com/openai/codex/pull/1713#discussion_r2237971705
|
||||
|
||||
```diff
|
||||
@@ -211,25 +222,23 @@ fn restore() {
|
||||
}
|
||||
|
||||
#[allow(clippy::unwrap_used)]
|
||||
-fn should_show_login_screen(config: &Config) -> bool {
|
||||
+async fn should_show_login_screen(config: &Config) -> bool {
|
||||
if is_in_need_of_openai_api_key(config) {
|
||||
// Reading the OpenAI API key is an async operation because it may need
|
||||
// to refresh the token. Block on it.
|
||||
let codex_home = config.codex_home.clone();
|
||||
let (tx, rx) = tokio::sync::oneshot::channel();
|
||||
- tokio::spawn(async move {
|
||||
- match try_read_openai_api_key(&codex_home).await {
|
||||
- Ok(openai_api_key) => {
|
||||
- set_openai_api_key(openai_api_key);
|
||||
- tx.send(false).unwrap();
|
||||
- }
|
||||
- Err(_) => {
|
||||
- tx.send(true).unwrap();
|
||||
- }
|
||||
+ match try_read_openai_api_key(&codex_home).await {
|
||||
+ Ok(openai_api_key) => {
|
||||
+ set_openai_api_key(openai_api_key);
|
||||
+ tx.send(false).unwrap();
|
||||
}
|
||||
- });
|
||||
+ Err(_) => {
|
||||
+ tx.send(true).unwrap();
|
||||
+ }
|
||||
+ }
|
||||
// TODO(mbolin): Impose some sort of timeout.
|
||||
- tokio::task::block_in_place(|| rx.blocking_recv()).unwrap()
|
||||
+ rx.await.unwrap()
|
||||
```
|
||||
|
||||
> You can leverage `tokio::time::timeout` pretty easily to address this TODO.
|
||||
|
||||
- Created: 2025-07-28 23:49:20 UTC | Link: https://github.com/openai/codex/pull/1713#discussion_r2238040525
|
||||
|
||||
```diff
|
||||
@@ -210,26 +222,25 @@ fn restore() {
|
||||
}
|
||||
}
|
||||
|
||||
-#[allow(clippy::unwrap_used)]
|
||||
-fn should_show_login_screen(config: &Config) -> bool {
|
||||
+#[allow(clippy::expect_used)]
|
||||
+#[allow(clippy::print_stderr)]
|
||||
+async fn should_show_login_screen(config: &Config) -> bool {
|
||||
if is_in_need_of_openai_api_key(config) {
|
||||
// Reading the OpenAI API key is an async operation because it may need
|
||||
// to refresh the token. Block on it.
|
||||
let codex_home = config.codex_home.clone();
|
||||
- let (tx, rx) = tokio::sync::oneshot::channel();
|
||||
- tokio::spawn(async move {
|
||||
- match try_read_openai_api_key(&codex_home).await {
|
||||
- Ok(openai_api_key) => {
|
||||
- set_openai_api_key(openai_api_key);
|
||||
- tx.send(false).unwrap();
|
||||
- }
|
||||
- Err(_) => {
|
||||
- tx.send(true).unwrap();
|
||||
- }
|
||||
- }
|
||||
- });
|
||||
- // TODO(mbolin): Impose some sort of timeout.
|
||||
- tokio::task::block_in_place(|| rx.blocking_recv()).unwrap()
|
||||
+ if let Ok(openai_api_key) = tokio::time::timeout(
|
||||
+ Duration::from_secs(60),
|
||||
+ try_read_openai_api_key(&codex_home),
|
||||
+ )
|
||||
+ .await
|
||||
+ .expect("timed out while refreshing OpenAI API key")
|
||||
```
|
||||
|
||||
> If we timeout, we don't want to panic, do we?
|
||||
|
||||
- Created: 2025-07-29 00:07:38 UTC | Link: https://github.com/openai/codex/pull/1713#discussion_r2238055661
|
||||
|
||||
```diff
|
||||
@@ -210,26 +221,19 @@ fn restore() {
|
||||
}
|
||||
}
|
||||
|
||||
-#[allow(clippy::unwrap_used)]
|
||||
-fn should_show_login_screen(config: &Config) -> bool {
|
||||
+#[allow(clippy::expect_used)]
|
||||
```
|
||||
|
||||
> These are no longer necessary, correct?
|
||||
2244
prs/bolinfest/PR-1715.md
Normal file
2244
prs/bolinfest/PR-1715.md
Normal file
File diff suppressed because it is too large
Load Diff
3151
prs/bolinfest/PR-1717.md
Normal file
3151
prs/bolinfest/PR-1717.md
Normal file
File diff suppressed because it is too large
Load Diff
1204
prs/bolinfest/PR-1730.md
Normal file
1204
prs/bolinfest/PR-1730.md
Normal file
File diff suppressed because it is too large
Load Diff
931
prs/bolinfest/PR-1732.md
Normal file
931
prs/bolinfest/PR-1732.md
Normal file
@@ -0,0 +1,931 @@
|
||||
# PR #1732: resizable viewport
|
||||
|
||||
- URL: https://github.com/openai/codex/pull/1732
|
||||
- Author: nornagon-openai
|
||||
- Created: 2025-07-29 23:02:05 UTC
|
||||
- Updated: 2025-07-31 00:07:02 UTC
|
||||
- Changes: +668/-23, Files changed: 11, Commits: 11
|
||||
|
||||
## Description
|
||||
|
||||
Proof of concept for a resizable viewport.
|
||||
|
||||
The general approach here is to duplicate the `Terminal` struct from ratatui, but with our own logic. This is a "light fork" in that we are still using all the base ratatui functions (`Buffer`, `Widget` and so on), but we're doing our own bookkeeping at the top level to determine where to draw everything.
|
||||
|
||||
This approach could use improvement—e.g, when the window is resized to a smaller size, if the UI wraps, we don't correctly clear out the artifacts from wrapping. This is possible with a little work (i.e. tracking what parts of our UI would have been wrapped), but this behavior is at least at par with the existing behavior.
|
||||
|
||||
https://github.com/user-attachments/assets/4eb17689-09fd-4daa-8315-c7ebc654986d
|
||||
|
||||
|
||||
cc @joshka who might have Thoughts™
|
||||
|
||||
## Full Diff
|
||||
|
||||
```diff
|
||||
diff --git a/NOTICE b/NOTICE
|
||||
index ad09ca421e..2805899d56 100644
|
||||
--- a/NOTICE
|
||||
+++ b/NOTICE
|
||||
@@ -1,2 +1,6 @@
|
||||
OpenAI Codex
|
||||
Copyright 2025 OpenAI
|
||||
+
|
||||
+This project includes code derived from [Ratatui](https://github.com/ratatui/ratatui), licensed under the MIT license.
|
||||
+Copyright (c) 2016-2022 Florian Dehau
|
||||
+Copyright (c) 2023-2025 The Ratatui Developers
|
||||
diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs
|
||||
index 6823a83a50..13ceabd7aa 100644
|
||||
--- a/codex-rs/tui/src/app.rs
|
||||
+++ b/codex-rs/tui/src/app.rs
|
||||
@@ -12,6 +12,8 @@ use codex_core::protocol::Event;
|
||||
use color_eyre::eyre::Result;
|
||||
use crossterm::event::KeyCode;
|
||||
use crossterm::event::KeyEvent;
|
||||
+use ratatui::layout::Offset;
|
||||
+use ratatui::prelude::Backend;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::AtomicBool;
|
||||
@@ -321,6 +323,44 @@ impl App<'_> {
|
||||
fn draw_next_frame(&mut self, terminal: &mut tui::Tui) -> Result<()> {
|
||||
// TODO: add a throttle to avoid redrawing too often
|
||||
|
||||
+ let screen_size = terminal.size()?;
|
||||
+ let last_known_screen_size = terminal.last_known_screen_size;
|
||||
+ if screen_size != last_known_screen_size {
|
||||
+ let cursor_pos = terminal.get_cursor_position()?;
|
||||
+ let last_known_cursor_pos = terminal.last_known_cursor_pos;
|
||||
+ if cursor_pos.y != last_known_cursor_pos.y {
|
||||
+ // The terminal was resized. The only point of reference we have for where our viewport
|
||||
+ // was moved is the cursor position.
|
||||
+ // NB this assumes that the cursor was not wrapped as part of the resize.
|
||||
+ let cursor_delta = cursor_pos.y as i32 - last_known_cursor_pos.y as i32;
|
||||
+
|
||||
+ let new_viewport_area = terminal.viewport_area.offset(Offset {
|
||||
+ x: 0,
|
||||
+ y: cursor_delta,
|
||||
+ });
|
||||
+ terminal.set_viewport_area(new_viewport_area);
|
||||
+ terminal.clear()?;
|
||||
+ }
|
||||
+ }
|
||||
+
|
||||
+ let size = terminal.size()?;
|
||||
+ let desired_height = match &self.app_state {
|
||||
+ AppState::Chat { widget } => widget.desired_height(),
|
||||
+ AppState::GitWarning { .. } => 10,
|
||||
+ };
|
||||
+ let mut area = terminal.viewport_area;
|
||||
+ area.height = desired_height;
|
||||
+ area.width = size.width;
|
||||
+ if area.bottom() > size.height {
|
||||
+ terminal
|
||||
+ .backend_mut()
|
||||
+ .scroll_region_up(0..area.top(), area.bottom() - size.height)?;
|
||||
+ area.y = size.height - area.height;
|
||||
+ }
|
||||
+ if area != terminal.viewport_area {
|
||||
+ terminal.clear()?;
|
||||
+ terminal.set_viewport_area(area);
|
||||
+ }
|
||||
match &mut self.app_state {
|
||||
AppState::Chat { widget } => {
|
||||
terminal.draw(|frame| frame.render_widget_ref(&**widget, frame.area()))?;
|
||||
diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs
|
||||
index b15d81f8f5..4d313f14a5 100644
|
||||
--- a/codex-rs/tui/src/bottom_pane/chat_composer.rs
|
||||
+++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs
|
||||
@@ -71,6 +71,15 @@ impl ChatComposer<'_> {
|
||||
this
|
||||
}
|
||||
|
||||
+ pub fn desired_height(&self) -> u16 {
|
||||
+ 2 + self.textarea.lines().len() as u16
|
||||
+ + match &self.active_popup {
|
||||
+ ActivePopup::None => 0u16,
|
||||
+ ActivePopup::Command(c) => c.calculate_required_height(),
|
||||
+ ActivePopup::File(c) => c.calculate_required_height(),
|
||||
+ }
|
||||
+ }
|
||||
+
|
||||
/// Returns true if the composer currently contains no user input.
|
||||
pub(crate) fn is_empty(&self) -> bool {
|
||||
self.textarea.is_empty()
|
||||
@@ -651,7 +660,7 @@ impl WidgetRef for &ChatComposer<'_> {
|
||||
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
|
||||
match &self.active_popup {
|
||||
ActivePopup::Command(popup) => {
|
||||
- let popup_height = popup.calculate_required_height(&area);
|
||||
+ let popup_height = popup.calculate_required_height();
|
||||
|
||||
// Split the provided rect so that the popup is rendered at the
|
||||
// *top* and the textarea occupies the remaining space below.
|
||||
@@ -673,7 +682,7 @@ impl WidgetRef for &ChatComposer<'_> {
|
||||
self.textarea.render(textarea_rect, buf);
|
||||
}
|
||||
ActivePopup::File(popup) => {
|
||||
- let popup_height = popup.calculate_required_height(&area);
|
||||
+ let popup_height = popup.calculate_required_height();
|
||||
|
||||
let popup_rect = Rect {
|
||||
x: area.x,
|
||||
diff --git a/codex-rs/tui/src/bottom_pane/command_popup.rs b/codex-rs/tui/src/bottom_pane/command_popup.rs
|
||||
index fd865047ef..da3b3a8253 100644
|
||||
--- a/codex-rs/tui/src/bottom_pane/command_popup.rs
|
||||
+++ b/codex-rs/tui/src/bottom_pane/command_popup.rs
|
||||
@@ -71,7 +71,7 @@ impl CommandPopup {
|
||||
/// Determine the preferred height of the popup. This is the number of
|
||||
/// rows required to show **at most** `MAX_POPUP_ROWS` commands plus the
|
||||
/// table/border overhead (one line at the top and one at the bottom).
|
||||
- pub(crate) fn calculate_required_height(&self, _area: &Rect) -> u16 {
|
||||
+ pub(crate) fn calculate_required_height(&self) -> u16 {
|
||||
let matches = self.filtered_commands();
|
||||
let row_count = matches.len().clamp(1, MAX_POPUP_ROWS) as u16;
|
||||
// Account for the border added by the Block that wraps the table.
|
||||
diff --git a/codex-rs/tui/src/bottom_pane/file_search_popup.rs b/codex-rs/tui/src/bottom_pane/file_search_popup.rs
|
||||
index 34eb59e4b2..e15f8690ae 100644
|
||||
--- a/codex-rs/tui/src/bottom_pane/file_search_popup.rs
|
||||
+++ b/codex-rs/tui/src/bottom_pane/file_search_popup.rs
|
||||
@@ -109,7 +109,7 @@ impl FileSearchPopup {
|
||||
}
|
||||
|
||||
/// Preferred height (rows) including border.
|
||||
- pub(crate) fn calculate_required_height(&self, _area: &Rect) -> u16 {
|
||||
+ pub(crate) fn calculate_required_height(&self) -> u16 {
|
||||
// Row count depends on whether we already have matches. If no matches
|
||||
// yet (e.g. initial search or query with no results) reserve a single
|
||||
// row so the popup is still visible. When matches are present we show
|
||||
diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs
|
||||
index 4ec1ba4b3e..2ca858d8ce 100644
|
||||
--- a/codex-rs/tui/src/bottom_pane/mod.rs
|
||||
+++ b/codex-rs/tui/src/bottom_pane/mod.rs
|
||||
@@ -64,6 +64,10 @@ impl BottomPane<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
+ pub fn desired_height(&self) -> u16 {
|
||||
+ self.composer.desired_height()
|
||||
+ }
|
||||
+
|
||||
/// Forward a key event to the active view or the composer.
|
||||
pub fn handle_key_event(&mut self, key_event: KeyEvent) -> InputResult {
|
||||
if let Some(mut view) = self.active_view.take() {
|
||||
diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs
|
||||
index fde6978634..33e3ee11e4 100644
|
||||
--- a/codex-rs/tui/src/chatwidget.rs
|
||||
+++ b/codex-rs/tui/src/chatwidget.rs
|
||||
@@ -143,6 +143,10 @@ impl ChatWidget<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
+ pub fn desired_height(&self) -> u16 {
|
||||
+ self.bottom_pane.desired_height()
|
||||
+ }
|
||||
+
|
||||
pub(crate) fn handle_key_event(&mut self, key_event: KeyEvent) {
|
||||
self.bottom_pane.clear_ctrl_c_quit_hint();
|
||||
|
||||
diff --git a/codex-rs/tui/src/custom_terminal.rs b/codex-rs/tui/src/custom_terminal.rs
|
||||
new file mode 100644
|
||||
index 0000000000..1ada679fc1
|
||||
--- /dev/null
|
||||
+++ b/codex-rs/tui/src/custom_terminal.rs
|
||||
@@ -0,0 +1,588 @@
|
||||
+// This is derived from `ratatui::Terminal`, which is licensed under the following terms:
|
||||
+//
|
||||
+// The MIT License (MIT)
|
||||
+// Copyright (c) 2016-2022 Florian Dehau
|
||||
+// Copyright (c) 2023-2025 The Ratatui Developers
|
||||
+//
|
||||
+// Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
+// of this software and associated documentation files (the "Software"), to deal
|
||||
+// in the Software without restriction, including without limitation the rights
|
||||
+// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
+// copies of the Software, and to permit persons to whom the Software is
|
||||
+// furnished to do so, subject to the following conditions:
|
||||
+//
|
||||
+// The above copyright notice and this permission notice shall be included in all
|
||||
+// copies or substantial portions of the Software.
|
||||
+//
|
||||
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
+// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
+// SOFTWARE.
|
||||
+use std::io;
|
||||
+
|
||||
+use ratatui::backend::Backend;
|
||||
+use ratatui::backend::ClearType;
|
||||
+use ratatui::buffer::Buffer;
|
||||
+use ratatui::layout::Position;
|
||||
+use ratatui::layout::Rect;
|
||||
+use ratatui::layout::Size;
|
||||
+use ratatui::widgets::StatefulWidget;
|
||||
+use ratatui::widgets::StatefulWidgetRef;
|
||||
+use ratatui::widgets::Widget;
|
||||
+use ratatui::widgets::WidgetRef;
|
||||
+
|
||||
+#[derive(Debug, Hash)]
|
||||
+pub struct Frame<'a> {
|
||||
+ /// Where should the cursor be after drawing this frame?
|
||||
+ ///
|
||||
+ /// If `None`, the cursor is hidden and its position is controlled by the backend. If `Some((x,
|
||||
+ /// y))`, the cursor is shown and placed at `(x, y)` after the call to `Terminal::draw()`.
|
||||
+ pub(crate) cursor_position: Option<Position>,
|
||||
+
|
||||
+ /// The area of the viewport
|
||||
+ pub(crate) viewport_area: Rect,
|
||||
+
|
||||
+ /// The buffer that is used to draw the current frame
|
||||
+ pub(crate) buffer: &'a mut Buffer,
|
||||
+
|
||||
+ /// The frame count indicating the sequence number of this frame.
|
||||
+ pub(crate) count: usize,
|
||||
+}
|
||||
+
|
||||
+#[allow(dead_code)]
|
||||
+impl Frame<'_> {
|
||||
+ /// The area of the current frame
|
||||
+ ///
|
||||
+ /// This is guaranteed not to change during rendering, so may be called multiple times.
|
||||
+ ///
|
||||
+ /// If your app listens for a resize event from the backend, it should ignore the values from
|
||||
+ /// the event for any calculations that are used to render the current frame and use this value
|
||||
+ /// instead as this is the area of the buffer that is used to render the current frame.
|
||||
+ pub const fn area(&self) -> Rect {
|
||||
+ self.viewport_area
|
||||
+ }
|
||||
+
|
||||
+ /// Render a [`Widget`] to the current buffer using [`Widget::render`].
|
||||
+ ///
|
||||
+ /// Usually the area argument is the size of the current frame or a sub-area of the current
|
||||
+ /// frame (which can be obtained using [`Layout`] to split the total area).
|
||||
+ ///
|
||||
+ /// # Example
|
||||
+ ///
|
||||
+ /// ```rust
|
||||
+ /// # use ratatui::{backend::TestBackend, Terminal};
|
||||
+ /// # let backend = TestBackend::new(5, 5);
|
||||
+ /// # let mut terminal = Terminal::new(backend).unwrap();
|
||||
+ /// # let mut frame = terminal.get_frame();
|
||||
+ /// use ratatui::{layout::Rect, widgets::Block};
|
||||
+ ///
|
||||
+ /// let block = Block::new();
|
||||
+ /// let area = Rect::new(0, 0, 5, 5);
|
||||
+ /// frame.render_widget(block, area);
|
||||
+ /// ```
|
||||
+ ///
|
||||
+ /// [`Layout`]: crate::layout::Layout
|
||||
+ pub fn render_widget<W: Widget>(&mut self, widget: W, area: Rect) {
|
||||
+ widget.render(area, self.buffer);
|
||||
+ }
|
||||
+
|
||||
+ /// Render a [`WidgetRef`] to the current buffer using [`WidgetRef::render_ref`].
|
||||
+ ///
|
||||
+ /// Usually the area argument is the size of the current frame or a sub-area of the current
|
||||
+ /// frame (which can be obtained using [`Layout`] to split the total area).
|
||||
+ ///
|
||||
+ /// # Example
|
||||
+ ///
|
||||
+ /// ```rust
|
||||
+ /// # #[cfg(feature = "unstable-widget-ref")] {
|
||||
+ /// # use ratatui::{backend::TestBackend, Terminal};
|
||||
+ /// # let backend = TestBackend::new(5, 5);
|
||||
+ /// # let mut terminal = Terminal::new(backend).unwrap();
|
||||
+ /// # let mut frame = terminal.get_frame();
|
||||
+ /// use ratatui::{layout::Rect, widgets::Block};
|
||||
+ ///
|
||||
+ /// let block = Block::new();
|
||||
+ /// let area = Rect::new(0, 0, 5, 5);
|
||||
+ /// frame.render_widget_ref(block, area);
|
||||
+ /// # }
|
||||
+ /// ```
|
||||
+ #[allow(clippy::needless_pass_by_value)]
|
||||
+ pub fn render_widget_ref<W: WidgetRef>(&mut self, widget: W, area: Rect) {
|
||||
+ widget.render_ref(area, self.buffer);
|
||||
+ }
|
||||
+
|
||||
+ /// Render a [`StatefulWidget`] to the current buffer using [`StatefulWidget::render`].
|
||||
+ ///
|
||||
+ /// Usually the area argument is the size of the current frame or a sub-area of the current
|
||||
+ /// frame (which can be obtained using [`Layout`] to split the total area).
|
||||
+ ///
|
||||
+ /// The last argument should be an instance of the [`StatefulWidget::State`] associated to the
|
||||
+ /// given [`StatefulWidget`].
|
||||
+ ///
|
||||
+ /// # Example
|
||||
+ ///
|
||||
+ /// ```rust
|
||||
+ /// # use ratatui::{backend::TestBackend, Terminal};
|
||||
+ /// # let backend = TestBackend::new(5, 5);
|
||||
+ /// # let mut terminal = Terminal::new(backend).unwrap();
|
||||
+ /// # let mut frame = terminal.get_frame();
|
||||
+ /// use ratatui::{
|
||||
+ /// layout::Rect,
|
||||
+ /// widgets::{List, ListItem, ListState},
|
||||
+ /// };
|
||||
+ ///
|
||||
+ /// let mut state = ListState::default().with_selected(Some(1));
|
||||
+ /// let list = List::new(vec![ListItem::new("Item 1"), ListItem::new("Item 2")]);
|
||||
+ /// let area = Rect::new(0, 0, 5, 5);
|
||||
+ /// frame.render_stateful_widget(list, area, &mut state);
|
||||
+ /// ```
|
||||
+ ///
|
||||
+ /// [`Layout`]: crate::layout::Layout
|
||||
+ pub fn render_stateful_widget<W>(&mut self, widget: W, area: Rect, state: &mut W::State)
|
||||
+ where
|
||||
+ W: StatefulWidget,
|
||||
+ {
|
||||
+ widget.render(area, self.buffer, state);
|
||||
+ }
|
||||
+
|
||||
+ /// Render a [`StatefulWidgetRef`] to the current buffer using
|
||||
+ /// [`StatefulWidgetRef::render_ref`].
|
||||
+ ///
|
||||
+ /// Usually the area argument is the size of the current frame or a sub-area of the current
|
||||
+ /// frame (which can be obtained using [`Layout`] to split the total area).
|
||||
+ ///
|
||||
+ /// The last argument should be an instance of the [`StatefulWidgetRef::State`] associated to
|
||||
+ /// the given [`StatefulWidgetRef`].
|
||||
+ ///
|
||||
+ /// # Example
|
||||
+ ///
|
||||
+ /// ```rust
|
||||
+ /// # #[cfg(feature = "unstable-widget-ref")] {
|
||||
+ /// # use ratatui::{backend::TestBackend, Terminal};
|
||||
+ /// # let backend = TestBackend::new(5, 5);
|
||||
+ /// # let mut terminal = Terminal::new(backend).unwrap();
|
||||
+ /// # let mut frame = terminal.get_frame();
|
||||
+ /// use ratatui::{
|
||||
+ /// layout::Rect,
|
||||
+ /// widgets::{List, ListItem, ListState},
|
||||
+ /// };
|
||||
+ ///
|
||||
+ /// let mut state = ListState::default().with_selected(Some(1));
|
||||
+ /// let list = List::new(vec![ListItem::new("Item 1"), ListItem::new("Item 2")]);
|
||||
+ /// let area = Rect::new(0, 0, 5, 5);
|
||||
+ /// frame.render_stateful_widget_ref(list, area, &mut state);
|
||||
+ /// # }
|
||||
+ /// ```
|
||||
+ #[allow(clippy::needless_pass_by_value)]
|
||||
+ pub fn render_stateful_widget_ref<W>(&mut self, widget: W, area: Rect, state: &mut W::State)
|
||||
+ where
|
||||
+ W: StatefulWidgetRef,
|
||||
+ {
|
||||
+ widget.render_ref(area, self.buffer, state);
|
||||
+ }
|
||||
+
|
||||
+ /// After drawing this frame, make the cursor visible and put it at the specified (x, y)
|
||||
+ /// coordinates. If this method is not called, the cursor will be hidden.
|
||||
+ ///
|
||||
+ /// Note that this will interfere with calls to [`Terminal::hide_cursor`],
|
||||
+ /// [`Terminal::show_cursor`], and [`Terminal::set_cursor_position`]. Pick one of the APIs and
|
||||
+ /// stick with it.
|
||||
+ ///
|
||||
+ /// [`Terminal::hide_cursor`]: crate::Terminal::hide_cursor
|
||||
+ /// [`Terminal::show_cursor`]: crate::Terminal::show_cursor
|
||||
+ /// [`Terminal::set_cursor_position`]: crate::Terminal::set_cursor_position
|
||||
+ pub fn set_cursor_position<P: Into<Position>>(&mut self, position: P) {
|
||||
+ self.cursor_position = Some(position.into());
|
||||
+ }
|
||||
+
|
||||
+ /// Gets the buffer that this `Frame` draws into as a mutable reference.
|
||||
+ pub fn buffer_mut(&mut self) -> &mut Buffer {
|
||||
+ self.buffer
|
||||
+ }
|
||||
+
|
||||
+ /// Returns the current frame count.
|
||||
+ ///
|
||||
+ /// This method provides access to the frame count, which is a sequence number indicating
|
||||
+ /// how many frames have been rendered up to (but not including) this one. It can be used
|
||||
+ /// for purposes such as animation, performance tracking, or debugging.
|
||||
+ ///
|
||||
+ /// Each time a frame has been rendered, this count is incremented,
|
||||
+ /// providing a consistent way to reference the order and number of frames processed by the
|
||||
+ /// terminal. When count reaches its maximum value (`usize::MAX`), it wraps around to zero.
|
||||
+ ///
|
||||
+ /// This count is particularly useful when dealing with dynamic content or animations where the
|
||||
+ /// state of the display changes over time. By tracking the frame count, developers can
|
||||
+ /// synchronize updates or changes to the content with the rendering process.
|
||||
+ ///
|
||||
+ /// # Examples
|
||||
+ ///
|
||||
+ /// ```rust
|
||||
+ /// # use ratatui::{backend::TestBackend, Terminal};
|
||||
+ /// # let backend = TestBackend::new(5, 5);
|
||||
+ /// # let mut terminal = Terminal::new(backend).unwrap();
|
||||
+ /// # let mut frame = terminal.get_frame();
|
||||
+ /// let current_count = frame.count();
|
||||
+ /// println!("Current frame count: {}", current_count);
|
||||
+ /// ```
|
||||
+ pub const fn count(&self) -> usize {
|
||||
+ self.count
|
||||
+ }
|
||||
+}
|
||||
+
|
||||
+#[derive(Debug, Default, Clone, Eq, PartialEq, Hash)]
|
||||
+pub struct Terminal<B>
|
||||
+where
|
||||
+ B: Backend,
|
||||
+{
|
||||
+ /// The backend used to interface with the terminal
|
||||
+ backend: B,
|
||||
+ /// Holds the results of the current and previous draw calls. The two are compared at the end
|
||||
+ /// of each draw pass to output the necessary updates to the terminal
|
||||
+ buffers: [Buffer; 2],
|
||||
+ /// Index of the current buffer in the previous array
|
||||
+ current: usize,
|
||||
+ /// Whether the cursor is currently hidden
|
||||
+ hidden_cursor: bool,
|
||||
+ /// Area of the viewport
|
||||
+ pub viewport_area: Rect,
|
||||
+ /// Last known size of the terminal. Used to detect if the internal buffers have to be resized.
|
||||
+ pub last_known_screen_size: Size,
|
||||
+ /// Last known position of the cursor. Used to find the new area when the viewport is inlined
|
||||
+ /// and the terminal resized.
|
||||
+ pub last_known_cursor_pos: Position,
|
||||
+ /// Number of frames rendered up until current time.
|
||||
+ frame_count: usize,
|
||||
+}
|
||||
+
|
||||
+impl<B> Drop for Terminal<B>
|
||||
+where
|
||||
+ B: Backend,
|
||||
+{
|
||||
+ #[allow(clippy::print_stderr)]
|
||||
+ fn drop(&mut self) {
|
||||
+ // Attempt to restore the cursor state
|
||||
+ if self.hidden_cursor {
|
||||
+ if let Err(err) = self.show_cursor() {
|
||||
+ eprintln!("Failed to show the cursor: {err}");
|
||||
+ }
|
||||
+ }
|
||||
+ }
|
||||
+}
|
||||
+
|
||||
+impl<B> Terminal<B>
|
||||
+where
|
||||
+ B: Backend,
|
||||
+{
|
||||
+ /// Creates a new [`Terminal`] with the given [`Backend`] and [`TerminalOptions`].
|
||||
+ ///
|
||||
+ /// # Example
|
||||
+ ///
|
||||
+ /// ```rust
|
||||
+ /// use std::io::stdout;
|
||||
+ ///
|
||||
+ /// use ratatui::{backend::CrosstermBackend, layout::Rect, Terminal, TerminalOptions, Viewport};
|
||||
+ ///
|
||||
+ /// let backend = CrosstermBackend::new(stdout());
|
||||
+ /// let viewport = Viewport::Fixed(Rect::new(0, 0, 10, 10));
|
||||
+ /// let terminal = Terminal::with_options(backend, TerminalOptions { viewport })?;
|
||||
+ /// # std::io::Result::Ok(())
|
||||
+ /// ```
|
||||
+ pub fn with_options(mut backend: B) -> io::Result<Self> {
|
||||
+ let screen_size = backend.size()?;
|
||||
+ let cursor_pos = backend.get_cursor_position()?;
|
||||
+ Ok(Self {
|
||||
+ backend,
|
||||
+ buffers: [
|
||||
+ Buffer::empty(Rect::new(0, 0, 0, 0)),
|
||||
+ Buffer::empty(Rect::new(0, 0, 0, 0)),
|
||||
+ ],
|
||||
+ current: 0,
|
||||
+ hidden_cursor: false,
|
||||
+ viewport_area: Rect::new(0, cursor_pos.y, 0, 0),
|
||||
+ last_known_screen_size: screen_size,
|
||||
+ last_known_cursor_pos: cursor_pos,
|
||||
+ frame_count: 0,
|
||||
+ })
|
||||
+ }
|
||||
+
|
||||
+ /// Get a Frame object which provides a consistent view into the terminal state for rendering.
|
||||
+ pub fn get_frame(&mut self) -> Frame {
|
||||
+ let count = self.frame_count;
|
||||
+ Frame {
|
||||
+ cursor_position: None,
|
||||
+ viewport_area: self.viewport_area,
|
||||
+ buffer: self.current_buffer_mut(),
|
||||
+ count,
|
||||
+ }
|
||||
+ }
|
||||
+
|
||||
+ /// Gets the current buffer as a mutable reference.
|
||||
+ pub fn current_buffer_mut(&mut self) -> &mut Buffer {
|
||||
+ &mut self.buffers[self.current]
|
||||
+ }
|
||||
+
|
||||
+ /// Gets the backend
|
||||
+ pub const fn backend(&self) -> &B {
|
||||
+ &self.backend
|
||||
+ }
|
||||
+
|
||||
+ /// Gets the backend as a mutable reference
|
||||
+ pub fn backend_mut(&mut self) -> &mut B {
|
||||
+ &mut self.backend
|
||||
+ }
|
||||
+
|
||||
+ /// Obtains a difference between the previous and the current buffer and passes it to the
|
||||
+ /// current backend for drawing.
|
||||
+ pub fn flush(&mut self) -> io::Result<()> {
|
||||
+ let previous_buffer = &self.buffers[1 - self.current];
|
||||
+ let current_buffer = &self.buffers[self.current];
|
||||
+ let updates = previous_buffer.diff(current_buffer);
|
||||
+ if let Some((col, row, _)) = updates.last() {
|
||||
+ self.last_known_cursor_pos = Position { x: *col, y: *row };
|
||||
+ }
|
||||
+ self.backend.draw(updates.into_iter())
|
||||
+ }
|
||||
+
|
||||
+ /// Updates the Terminal so that internal buffers match the requested area.
|
||||
+ ///
|
||||
+ /// Requested area will be saved to remain consistent when rendering. This leads to a full clear
|
||||
+ /// of the screen.
|
||||
+ pub fn resize(&mut self, screen_size: Size) -> io::Result<()> {
|
||||
+ self.last_known_screen_size = screen_size;
|
||||
+ Ok(())
|
||||
+ }
|
||||
+
|
||||
+ /// Sets the viewport area.
|
||||
+ pub fn set_viewport_area(&mut self, area: Rect) {
|
||||
+ self.buffers[self.current].resize(area);
|
||||
+ self.buffers[1 - self.current].resize(area);
|
||||
+ self.viewport_area = area;
|
||||
+ }
|
||||
+
|
||||
+ /// Queries the backend for size and resizes if it doesn't match the previous size.
|
||||
+ pub fn autoresize(&mut self) -> io::Result<()> {
|
||||
+ let screen_size = self.size()?;
|
||||
+ if screen_size != self.last_known_screen_size {
|
||||
+ self.resize(screen_size)?;
|
||||
+ }
|
||||
+ Ok(())
|
||||
+ }
|
||||
+
|
||||
+ /// Draws a single frame to the terminal.
|
||||
+ ///
|
||||
+ /// Returns a [`CompletedFrame`] if successful, otherwise a [`std::io::Error`].
|
||||
+ ///
|
||||
+ /// If the render callback passed to this method can fail, use [`try_draw`] instead.
|
||||
+ ///
|
||||
+ /// Applications should call `draw` or [`try_draw`] in a loop to continuously render the
|
||||
+ /// terminal. These methods are the main entry points for drawing to the terminal.
|
||||
+ ///
|
||||
+ /// [`try_draw`]: Terminal::try_draw
|
||||
+ ///
|
||||
+ /// This method will:
|
||||
+ ///
|
||||
+ /// - autoresize the terminal if necessary
|
||||
+ /// - call the render callback, passing it a [`Frame`] reference to render to
|
||||
+ /// - flush the current internal state by copying the current buffer to the backend
|
||||
+ /// - move the cursor to the last known position if it was set during the rendering closure
|
||||
+ ///
|
||||
+ /// The render callback should fully render the entire frame when called, including areas that
|
||||
+ /// are unchanged from the previous frame. This is because each frame is compared to the
|
||||
+ /// previous frame to determine what has changed, and only the changes are written to the
|
||||
+ /// terminal. If the render callback does not fully render the frame, the terminal will not be
|
||||
+ /// in a consistent state.
|
||||
+ ///
|
||||
+ /// # Examples
|
||||
+ ///
|
||||
+ /// ```
|
||||
+ /// # let backend = ratatui::backend::TestBackend::new(10, 10);
|
||||
+ /// # let mut terminal = ratatui::Terminal::new(backend)?;
|
||||
+ /// use ratatui::{layout::Position, widgets::Paragraph};
|
||||
+ ///
|
||||
+ /// // with a closure
|
||||
+ /// terminal.draw(|frame| {
|
||||
+ /// let area = frame.area();
|
||||
+ /// frame.render_widget(Paragraph::new("Hello World!"), area);
|
||||
+ /// frame.set_cursor_position(Position { x: 0, y: 0 });
|
||||
+ /// })?;
|
||||
+ ///
|
||||
+ /// // or with a function
|
||||
+ /// terminal.draw(render)?;
|
||||
+ ///
|
||||
+ /// fn render(frame: &mut ratatui::Frame) {
|
||||
+ /// frame.render_widget(Paragraph::new("Hello World!"), frame.area());
|
||||
+ /// }
|
||||
+ /// # std::io::Result::Ok(())
|
||||
+ /// ```
|
||||
+ pub fn draw<F>(&mut self, render_callback: F) -> io::Result<()>
|
||||
+ where
|
||||
+ F: FnOnce(&mut Frame),
|
||||
+ {
|
||||
+ self.try_draw(|frame| {
|
||||
+ render_callback(frame);
|
||||
+ io::Result::Ok(())
|
||||
+ })
|
||||
+ }
|
||||
+
|
||||
+ /// Tries to draw a single frame to the terminal.
|
||||
+ ///
|
||||
+ /// Returns [`Result::Ok`] containing a [`CompletedFrame`] if successful, otherwise
|
||||
+ /// [`Result::Err`] containing the [`std::io::Error`] that caused the failure.
|
||||
+ ///
|
||||
+ /// This is the equivalent of [`Terminal::draw`] but the render callback is a function or
|
||||
+ /// closure that returns a `Result` instead of nothing.
|
||||
+ ///
|
||||
+ /// Applications should call `try_draw` or [`draw`] in a loop to continuously render the
|
||||
+ /// terminal. These methods are the main entry points for drawing to the terminal.
|
||||
+ ///
|
||||
+ /// [`draw`]: Terminal::draw
|
||||
+ ///
|
||||
+ /// This method will:
|
||||
+ ///
|
||||
+ /// - autoresize the terminal if necessary
|
||||
+ /// - call the render callback, passing it a [`Frame`] reference to render to
|
||||
+ /// - flush the current internal state by copying the current buffer to the backend
|
||||
+ /// - move the cursor to the last known position if it was set during the rendering closure
|
||||
+ /// - return a [`CompletedFrame`] with the current buffer and the area of the terminal
|
||||
+ ///
|
||||
+ /// The render callback passed to `try_draw` can return any [`Result`] with an error type that
|
||||
+ /// can be converted into an [`std::io::Error`] using the [`Into`] trait. This makes it possible
|
||||
+ /// to use the `?` operator to propagate errors that occur during rendering. If the render
|
||||
+ /// callback returns an error, the error will be returned from `try_draw` as an
|
||||
+ /// [`std::io::Error`] and the terminal will not be updated.
|
||||
+ ///
|
||||
+ /// The [`CompletedFrame`] returned by this method can be useful for debugging or testing
|
||||
+ /// purposes, but it is often not used in regular applicationss.
|
||||
+ ///
|
||||
+ /// The render callback should fully render the entire frame when called, including areas that
|
||||
+ /// are unchanged from the previous frame. This is because each frame is compared to the
|
||||
+ /// previous frame to determine what has changed, and only the changes are written to the
|
||||
+ /// terminal. If the render function does not fully render the frame, the terminal will not be
|
||||
+ /// in a consistent state.
|
||||
+ ///
|
||||
+ /// # Examples
|
||||
+ ///
|
||||
+ /// ```should_panic
|
||||
+ /// # use ratatui::layout::Position;;
|
||||
+ /// # let backend = ratatui::backend::TestBackend::new(10, 10);
|
||||
+ /// # let mut terminal = ratatui::Terminal::new(backend)?;
|
||||
+ /// use std::io;
|
||||
+ ///
|
||||
+ /// use ratatui::widgets::Paragraph;
|
||||
+ ///
|
||||
+ /// // with a closure
|
||||
+ /// terminal.try_draw(|frame| {
|
||||
+ /// let value: u8 = "not a number".parse().map_err(io::Error::other)?;
|
||||
+ /// let area = frame.area();
|
||||
+ /// frame.render_widget(Paragraph::new("Hello World!"), area);
|
||||
+ /// frame.set_cursor_position(Position { x: 0, y: 0 });
|
||||
+ /// io::Result::Ok(())
|
||||
+ /// })?;
|
||||
+ ///
|
||||
+ /// // or with a function
|
||||
+ /// terminal.try_draw(render)?;
|
||||
+ ///
|
||||
+ /// fn render(frame: &mut ratatui::Frame) -> io::Result<()> {
|
||||
+ /// let value: u8 = "not a number".parse().map_err(io::Error::other)?;
|
||||
+ /// frame.render_widget(Paragraph::new("Hello World!"), frame.area());
|
||||
+ /// Ok(())
|
||||
+ /// }
|
||||
+ /// # io::Result::Ok(())
|
||||
+ /// ```
|
||||
+ pub fn try_draw<F, E>(&mut self, render_callback: F) -> io::Result<()>
|
||||
+ where
|
||||
+ F: FnOnce(&mut Frame) -> Result<(), E>,
|
||||
+ E: Into<io::Error>,
|
||||
+ {
|
||||
+ // Autoresize - otherwise we get glitches if shrinking or potential desync between widgets
|
||||
+ // and the terminal (if growing), which may OOB.
|
||||
+ self.autoresize()?;
|
||||
+
|
||||
+ let mut frame = self.get_frame();
|
||||
+
|
||||
+ render_callback(&mut frame).map_err(Into::into)?;
|
||||
+
|
||||
+ // We can't change the cursor position right away because we have to flush the frame to
|
||||
+ // stdout first. But we also can't keep the frame around, since it holds a &mut to
|
||||
+ // Buffer. Thus, we're taking the important data out of the Frame and dropping it.
|
||||
+ let cursor_position = frame.cursor_position;
|
||||
+
|
||||
+ // Draw to stdout
|
||||
+ self.flush()?;
|
||||
+
|
||||
+ match cursor_position {
|
||||
+ None => self.hide_cursor()?,
|
||||
+ Some(position) => {
|
||||
+ self.show_cursor()?;
|
||||
+ self.set_cursor_position(position)?;
|
||||
+ }
|
||||
+ }
|
||||
+
|
||||
+ self.swap_buffers();
|
||||
+
|
||||
+ // Flush
|
||||
+ self.backend.flush()?;
|
||||
+
|
||||
+ // increment frame count before returning from draw
|
||||
+ self.frame_count = self.frame_count.wrapping_add(1);
|
||||
+
|
||||
+ Ok(())
|
||||
+ }
|
||||
+
|
||||
+ /// Hides the cursor.
|
||||
+ pub fn hide_cursor(&mut self) -> io::Result<()> {
|
||||
+ self.backend.hide_cursor()?;
|
||||
+ self.hidden_cursor = true;
|
||||
+ Ok(())
|
||||
+ }
|
||||
+
|
||||
+ /// Shows the cursor.
|
||||
+ pub fn show_cursor(&mut self) -> io::Result<()> {
|
||||
+ self.backend.show_cursor()?;
|
||||
+ self.hidden_cursor = false;
|
||||
+ Ok(())
|
||||
+ }
|
||||
+
|
||||
+ /// Gets the current cursor position.
|
||||
+ ///
|
||||
+ /// This is the position of the cursor after the last draw call.
|
||||
+ #[allow(dead_code)]
|
||||
+ pub fn get_cursor_position(&mut self) -> io::Result<Position> {
|
||||
+ self.backend.get_cursor_position()
|
||||
+ }
|
||||
+
|
||||
+ /// Sets the cursor position.
|
||||
+ pub fn set_cursor_position<P: Into<Position>>(&mut self, position: P) -> io::Result<()> {
|
||||
+ let position = position.into();
|
||||
+ self.backend.set_cursor_position(position)?;
|
||||
+ self.last_known_cursor_pos = position;
|
||||
+ Ok(())
|
||||
+ }
|
||||
+
|
||||
+ /// Clear the terminal and force a full redraw on the next draw call.
|
||||
+ pub fn clear(&mut self) -> io::Result<()> {
|
||||
+ if self.viewport_area.is_empty() {
|
||||
+ return Ok(());
|
||||
+ }
|
||||
+ self.backend
|
||||
+ .set_cursor_position(self.viewport_area.as_position())?;
|
||||
+ self.backend.clear_region(ClearType::AfterCursor)?;
|
||||
+ // Reset the back buffer to make sure the next update will redraw everything.
|
||||
+ self.buffers[1 - self.current].reset();
|
||||
+ Ok(())
|
||||
+ }
|
||||
+
|
||||
+ /// Clears the inactive buffer and swaps it with the current buffer
|
||||
+ pub fn swap_buffers(&mut self) {
|
||||
+ self.buffers[1 - self.current].reset();
|
||||
+ self.current = 1 - self.current;
|
||||
+ }
|
||||
+
|
||||
+ /// Queries the real size of the backend.
|
||||
+ pub fn size(&self) -> io::Result<Size> {
|
||||
+ self.backend.size()
|
||||
+ }
|
||||
+}
|
||||
diff --git a/codex-rs/tui/src/insert_history.rs b/codex-rs/tui/src/insert_history.rs
|
||||
index 32d0b4b297..54faf4beb8 100644
|
||||
--- a/codex-rs/tui/src/insert_history.rs
|
||||
+++ b/codex-rs/tui/src/insert_history.rs
|
||||
@@ -4,6 +4,7 @@ use std::io::Write;
|
||||
|
||||
use crate::tui;
|
||||
use crossterm::Command;
|
||||
+use crossterm::cursor::MoveTo;
|
||||
use crossterm::queue;
|
||||
use crossterm::style::Color as CColor;
|
||||
use crossterm::style::Colors;
|
||||
@@ -12,7 +13,6 @@ use crossterm::style::SetAttribute;
|
||||
use crossterm::style::SetBackgroundColor;
|
||||
use crossterm::style::SetColors;
|
||||
use crossterm::style::SetForegroundColor;
|
||||
-use ratatui::layout::Position;
|
||||
use ratatui::layout::Size;
|
||||
use ratatui::prelude::Backend;
|
||||
use ratatui::style::Color;
|
||||
@@ -23,6 +23,7 @@ use ratatui::text::Span;
|
||||
/// Insert `lines` above the viewport.
|
||||
pub(crate) fn insert_history_lines(terminal: &mut tui::Tui, lines: Vec<Line>) {
|
||||
let screen_size = terminal.backend().size().unwrap_or(Size::new(0, 0));
|
||||
+ let cursor_pos = terminal.get_cursor_position().ok();
|
||||
|
||||
let mut area = terminal.get_frame().area();
|
||||
|
||||
@@ -60,9 +61,10 @@ pub(crate) fn insert_history_lines(terminal: &mut tui::Tui, lines: Vec<Line>) {
|
||||
// └──────────────────────────────┘
|
||||
queue!(std::io::stdout(), SetScrollRegion(1..area.top())).ok();
|
||||
|
||||
- terminal
|
||||
- .set_cursor_position(Position::new(0, cursor_top))
|
||||
- .ok();
|
||||
+ // NB: we are using MoveTo instead of set_cursor_position here to avoid messing with the
|
||||
+ // terminal's last_known_cursor_position, which hopefully will still be accurate after we
|
||||
+ // fetch/restore the cursor position. insert_history_lines should be cursor-position-neutral :)
|
||||
+ queue!(std::io::stdout(), MoveTo(0, cursor_top)).ok();
|
||||
|
||||
for line in lines {
|
||||
queue!(std::io::stdout(), Print("\r\n")).ok();
|
||||
@@ -70,6 +72,11 @@ pub(crate) fn insert_history_lines(terminal: &mut tui::Tui, lines: Vec<Line>) {
|
||||
}
|
||||
|
||||
queue!(std::io::stdout(), ResetScrollRegion).ok();
|
||||
+
|
||||
+ // Restore the cursor position to where it was before we started.
|
||||
+ if let Some(cursor_pos) = cursor_pos {
|
||||
+ queue!(std::io::stdout(), MoveTo(cursor_pos.x, cursor_pos.y)).ok();
|
||||
+ }
|
||||
}
|
||||
|
||||
fn wrapped_line_count(lines: &[Line], width: u16) -> u16 {
|
||||
diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs
|
||||
index 424b5ac2fc..351fab4df8 100644
|
||||
--- a/codex-rs/tui/src/lib.rs
|
||||
+++ b/codex-rs/tui/src/lib.rs
|
||||
@@ -25,6 +25,7 @@ mod bottom_pane;
|
||||
mod chatwidget;
|
||||
mod citation_regex;
|
||||
mod cli;
|
||||
+mod custom_terminal;
|
||||
mod exec_command;
|
||||
mod file_search;
|
||||
mod get_git_diff;
|
||||
diff --git a/codex-rs/tui/src/tui.rs b/codex-rs/tui/src/tui.rs
|
||||
index 66ae1cfb96..1b215961ab 100644
|
||||
--- a/codex-rs/tui/src/tui.rs
|
||||
+++ b/codex-rs/tui/src/tui.rs
|
||||
@@ -5,14 +5,13 @@ use std::io::stdout;
|
||||
use codex_core::config::Config;
|
||||
use crossterm::event::DisableBracketedPaste;
|
||||
use crossterm::event::EnableBracketedPaste;
|
||||
-use ratatui::Terminal;
|
||||
-use ratatui::TerminalOptions;
|
||||
-use ratatui::Viewport;
|
||||
use ratatui::backend::CrosstermBackend;
|
||||
use ratatui::crossterm::execute;
|
||||
use ratatui::crossterm::terminal::disable_raw_mode;
|
||||
use ratatui::crossterm::terminal::enable_raw_mode;
|
||||
|
||||
+use crate::custom_terminal::Terminal;
|
||||
+
|
||||
/// A type alias for the terminal type used in this application
|
||||
pub type Tui = Terminal<CrosstermBackend<Stdout>>;
|
||||
|
||||
@@ -23,19 +22,8 @@ pub fn init(_config: &Config) -> Result<Tui> {
|
||||
enable_raw_mode()?;
|
||||
set_panic_hook();
|
||||
|
||||
- // Reserve a fixed number of lines for the interactive viewport (composer,
|
||||
- // status, popups). History is injected above using `insert_before`. This
|
||||
- // is an initial step of the refactor – later the height can become
|
||||
- // dynamic. For now a conservative default keeps enough room for the
|
||||
- // multi‑line composer while not occupying the whole screen.
|
||||
- const BOTTOM_VIEWPORT_HEIGHT: u16 = 8;
|
||||
let backend = CrosstermBackend::new(stdout());
|
||||
- let tui = Terminal::with_options(
|
||||
- backend,
|
||||
- TerminalOptions {
|
||||
- viewport: Viewport::Inline(BOTTOM_VIEWPORT_HEIGHT),
|
||||
- },
|
||||
- )?;
|
||||
+ let tui = Terminal::with_options(backend)?;
|
||||
Ok(tui)
|
||||
}
|
||||
```
|
||||
|
||||
## Review Comments
|
||||
|
||||
### codex-rs/tui/src/insert_history.rs
|
||||
|
||||
- Created: 2025-07-30 23:51:07 UTC | Link: https://github.com/openai/codex/pull/1732#discussion_r2244070783
|
||||
|
||||
```diff
|
||||
@@ -60,16 +62,17 @@ pub(crate) fn insert_history_lines(terminal: &mut tui::Tui, lines: Vec<Line>) {
|
||||
// └──────────────────────────────┘
|
||||
queue!(std::io::stdout(), SetScrollRegion(1..area.top())).ok();
|
||||
|
||||
- terminal
|
||||
- .set_cursor_position(Position::new(0, cursor_top))
|
||||
- .ok();
|
||||
+ queue!(std::io::stdout(), MoveTo(0, cursor_top)).ok();
|
||||
```
|
||||
|
||||
> Should you memorialize this as a comment in the code?
|
||||
|
||||
- Created: 2025-07-30 23:51:22 UTC | Link: https://github.com/openai/codex/pull/1732#discussion_r2244071035
|
||||
|
||||
```diff
|
||||
@@ -60,16 +61,17 @@ pub(crate) fn insert_history_lines(terminal: &mut tui::Tui, lines: Vec<Line>) {
|
||||
// └──────────────────────────────┘
|
||||
queue!(std::io::stdout(), SetScrollRegion(1..area.top())).ok();
|
||||
|
||||
- terminal
|
||||
- .set_cursor_position(Position::new(0, cursor_top))
|
||||
- .ok();
|
||||
+ queue!(std::io::stdout(), MoveTo(0, cursor_top)).ok();
|
||||
|
||||
for line in lines {
|
||||
queue!(std::io::stdout(), Print("\r\n")).ok();
|
||||
write_spans(&mut std::io::stdout(), line.iter()).ok();
|
||||
}
|
||||
|
||||
queue!(std::io::stdout(), ResetScrollRegion).ok();
|
||||
+ if let Some(cursor_pos) = cursor_pos {
|
||||
```
|
||||
|
||||
> Does this also merit a comment?
|
||||
1147
prs/bolinfest/PR-1763.md
Normal file
1147
prs/bolinfest/PR-1763.md
Normal file
File diff suppressed because it is too large
Load Diff
1106
prs/bolinfest/PR-1764.md
Normal file
1106
prs/bolinfest/PR-1764.md
Normal file
File diff suppressed because it is too large
Load Diff
809
prs/bolinfest/PR-1765.md
Normal file
809
prs/bolinfest/PR-1765.md
Normal file
@@ -0,0 +1,809 @@
|
||||
# PR #1765: feat: make .git read-only within a writable root when using Seatbelt
|
||||
|
||||
- URL: https://github.com/openai/codex/pull/1765
|
||||
- Author: bolinfest
|
||||
- Created: 2025-07-31 20:47:00 UTC
|
||||
- Updated: 2025-08-01 23:11:33 UTC
|
||||
- Changes: +480/-30, Files changed: 7, Commits: 1
|
||||
|
||||
## Description
|
||||
|
||||
To make `--full-auto` safer, this PR updates the Seatbelt policy so that a `SandboxPolicy` with a `writable_root` that contains a `.git/` _directory_ will make `.git/` _read-only_ (though as a follow-up, we should also consider the case where `.git` is a _file_ with a `gitdir: /path/to/actual/repo/.git` entry that should also be protected).
|
||||
|
||||
The two major changes in this PR:
|
||||
|
||||
- Updating `SandboxPolicy::get_writable_roots_with_cwd()` to return a `Vec<WritableRoot>` instead of a `Vec<PathBuf>` where a `WritableRoot` can specify a list of read-only subpaths.
|
||||
- Updating `create_seatbelt_command_args()` to honor the read-only subpaths in `WritableRoot`.
|
||||
|
||||
The logic to update the policy is a fairly straightforward update to `create_seatbelt_command_args()`, but perhaps the more interesting part of this PR is the introduction of an integration test in `tests/sandbox.rs`. Leveraging the new API in #1785, we test `SandboxPolicy` under various conditions, including ones where `$TMPDIR` is not readable, which is critical for verifying the new behavior.
|
||||
|
||||
To ensure that Codex can run its own tests, e.g.:
|
||||
|
||||
```
|
||||
just codex debug seatbelt --full-auto -- cargo test if_git_repo_is_writable_root_then_dot_git_folder_is_read_only
|
||||
```
|
||||
|
||||
I had to introduce the use of `CODEX_SANDBOX=sandbox`, which is comparable to how `CODEX_SANDBOX_NETWORK_DISABLED=1` was already being used.
|
||||
|
||||
Adding a comparable change for Landlock will be done in a subsequent PR.
|
||||
|
||||
## Full Diff
|
||||
|
||||
```diff
|
||||
diff --git a/AGENTS.md b/AGENTS.md
|
||||
index 27af48ae60..5c3f659c35 100644
|
||||
--- a/AGENTS.md
|
||||
+++ b/AGENTS.md
|
||||
@@ -2,7 +2,9 @@
|
||||
|
||||
In the codex-rs folder where the rust code lives:
|
||||
|
||||
-- Never add or modify any code related to `CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR`. You operate in a sandbox where `CODEX_SANDBOX_NETWORK_DISABLED=1` will be set whenever you use the `shell` tool. Any existing code that uses `CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR` was authored with this fact in mind. It is often used to early exit out of tests that the author knew you would not be able to run given your sandbox limitations.
|
||||
+- Never add or modify any code related to `CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR` or `CODEX_SANDBOX_ENV_VAR`.
|
||||
+ - You operate in a sandbox where `CODEX_SANDBOX_NETWORK_DISABLED=1` will be set whenever you use the `shell` tool. Any existing code that uses `CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR` was authored with this fact in mind. It is often used to early exit out of tests that the author knew you would not be able to run given your sandbox limitations.
|
||||
+ - Similarly, when you spawn a process using Seatbelt (`/usr/bin/sandbox-exec`), `CODEX_SANDBOX=seatbelt` will be set on the child process. Integration tests that want to run Seatbelt themselves cannot be run under Seatbelt, so checks for `CODEX_SANDBOX=seatbelt` are also often used to early exit out of tests, as appropriate.
|
||||
|
||||
Before creating a pull request with changes to `codex-rs`, run `just fmt` (in `codex-rs` directory) to format the code and `just fix` (in `codex-rs` directory) to fix any linter issues in the code, ensure the test suite passes by running `cargo test --all-features` in the `codex-rs` directory.
|
||||
|
||||
diff --git a/codex-rs/config.md b/codex-rs/config.md
|
||||
index 1a407a239b..c7dfe42a75 100644
|
||||
--- a/codex-rs/config.md
|
||||
+++ b/codex-rs/config.md
|
||||
@@ -259,6 +259,8 @@ disk, but attempts to write a file or access the network will be blocked.
|
||||
|
||||
A more relaxed policy is `workspace-write`. When specified, the current working directory for the Codex task will be writable (as well as `$TMPDIR` on macOS). Note that the CLI defaults to using the directory where it was spawned as `cwd`, though this can be overridden using `--cwd/-C`.
|
||||
|
||||
+On macOS (and soon Linux), all writable roots (including `cwd`) that contain a `.git/` folder _as an immediate child_ will configure the `.git/` folder to be read-only while the rest of the Git repository will be writable. This means that commands like `git commit` will fail, by default (as it entails writing to `.git/`), and will require Codex to ask for permission.
|
||||
+
|
||||
```toml
|
||||
# same as `--sandbox workspace-write`
|
||||
sandbox_mode = "workspace-write"
|
||||
diff --git a/codex-rs/core/src/protocol.rs b/codex-rs/core/src/protocol.rs
|
||||
index af65f4d3a8..1bfeee56ab 100644
|
||||
--- a/codex-rs/core/src/protocol.rs
|
||||
+++ b/codex-rs/core/src/protocol.rs
|
||||
@@ -189,6 +189,16 @@ pub enum SandboxPolicy {
|
||||
},
|
||||
}
|
||||
|
||||
+/// A writable root path accompanied by a list of subpaths that should remain
|
||||
+/// read‑only even when the root is writable. This is primarily used to ensure
|
||||
+/// top‑level VCS metadata directories (e.g. `.git`) under a writable root are
|
||||
+/// not modified by the agent.
|
||||
+#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
+pub struct WritableRoot {
|
||||
+ pub root: PathBuf,
|
||||
+ pub read_only_subpaths: Vec<PathBuf>,
|
||||
+}
|
||||
+
|
||||
fn default_true() -> bool {
|
||||
true
|
||||
}
|
||||
@@ -240,9 +250,10 @@ impl SandboxPolicy {
|
||||
}
|
||||
}
|
||||
|
||||
- /// Returns the list of writable roots that should be passed down to the
|
||||
- /// Landlock rules installer, tailored to the current working directory.
|
||||
- pub fn get_writable_roots_with_cwd(&self, cwd: &Path) -> Vec<PathBuf> {
|
||||
+ /// Returns the list of writable roots (tailored to the current working
|
||||
+ /// directory) together with subpaths that should remain read‑only under
|
||||
+ /// each writable root.
|
||||
+ pub fn get_writable_roots_with_cwd(&self, cwd: &Path) -> Vec<WritableRoot> {
|
||||
match self {
|
||||
SandboxPolicy::DangerFullAccess => Vec::new(),
|
||||
SandboxPolicy::ReadOnly => Vec::new(),
|
||||
@@ -251,24 +262,39 @@ impl SandboxPolicy {
|
||||
include_default_writable_roots,
|
||||
..
|
||||
} => {
|
||||
- if !*include_default_writable_roots {
|
||||
- return writable_roots.clone();
|
||||
- }
|
||||
-
|
||||
- let mut roots = writable_roots.clone();
|
||||
- roots.push(cwd.to_path_buf());
|
||||
-
|
||||
- // Also include the per-user tmp dir on macOS.
|
||||
- // Note this is added dynamically rather than storing it in
|
||||
- // writable_roots because writable_roots contains only static
|
||||
- // values deserialized from the config file.
|
||||
- if cfg!(target_os = "macos") {
|
||||
- if let Some(tmpdir) = std::env::var_os("TMPDIR") {
|
||||
- roots.push(PathBuf::from(tmpdir));
|
||||
+ // Start from explicitly configured writable roots.
|
||||
+ let mut roots: Vec<PathBuf> = writable_roots.clone();
|
||||
+
|
||||
+ // Optionally include defaults (cwd and TMPDIR on macOS).
|
||||
+ if *include_default_writable_roots {
|
||||
+ roots.push(cwd.to_path_buf());
|
||||
+
|
||||
+ // Also include the per-user tmp dir on macOS.
|
||||
+ // Note this is added dynamically rather than storing it in
|
||||
+ // `writable_roots` because `writable_roots` contains only static
|
||||
+ // values deserialized from the config file.
|
||||
+ if cfg!(target_os = "macos") {
|
||||
+ if let Some(tmpdir) = std::env::var_os("TMPDIR") {
|
||||
+ roots.push(PathBuf::from(tmpdir));
|
||||
+ }
|
||||
}
|
||||
}
|
||||
|
||||
+ // For each root, compute subpaths that should remain read-only.
|
||||
roots
|
||||
+ .into_iter()
|
||||
+ .map(|writable_root| {
|
||||
+ let mut subpaths = Vec::new();
|
||||
+ let top_level_git = writable_root.join(".git");
|
||||
+ if top_level_git.is_dir() {
|
||||
+ subpaths.push(top_level_git);
|
||||
+ }
|
||||
+ WritableRoot {
|
||||
+ root: writable_root,
|
||||
+ read_only_subpaths: subpaths,
|
||||
+ }
|
||||
+ })
|
||||
+ .collect()
|
||||
}
|
||||
}
|
||||
}
|
||||
diff --git a/codex-rs/core/src/seatbelt.rs b/codex-rs/core/src/seatbelt.rs
|
||||
index be2acb1bdc..0364840b1a 100644
|
||||
--- a/codex-rs/core/src/seatbelt.rs
|
||||
+++ b/codex-rs/core/src/seatbelt.rs
|
||||
@@ -4,6 +4,7 @@ use std::path::PathBuf;
|
||||
use tokio::process::Child;
|
||||
|
||||
use crate::protocol::SandboxPolicy;
|
||||
+use crate::spawn::CODEX_SANDBOX_ENV_VAR;
|
||||
use crate::spawn::StdioPolicy;
|
||||
use crate::spawn::spawn_child_async;
|
||||
|
||||
@@ -20,10 +21,11 @@ pub async fn spawn_command_under_seatbelt(
|
||||
sandbox_policy: &SandboxPolicy,
|
||||
cwd: PathBuf,
|
||||
stdio_policy: StdioPolicy,
|
||||
- env: HashMap<String, String>,
|
||||
+ mut env: HashMap<String, String>,
|
||||
) -> std::io::Result<Child> {
|
||||
let args = create_seatbelt_command_args(command, sandbox_policy, &cwd);
|
||||
let arg0 = None;
|
||||
+ env.insert(CODEX_SANDBOX_ENV_VAR.to_string(), "seatbelt".to_string());
|
||||
spawn_child_async(
|
||||
PathBuf::from(MACOS_PATH_TO_SEATBELT_EXECUTABLE),
|
||||
args,
|
||||
@@ -50,16 +52,38 @@ fn create_seatbelt_command_args(
|
||||
)
|
||||
} else {
|
||||
let writable_roots = sandbox_policy.get_writable_roots_with_cwd(cwd);
|
||||
- let (writable_folder_policies, cli_args): (Vec<String>, Vec<String>) = writable_roots
|
||||
- .iter()
|
||||
- .enumerate()
|
||||
- .map(|(index, root)| {
|
||||
- let param_name = format!("WRITABLE_ROOT_{index}");
|
||||
- let policy: String = format!("(subpath (param \"{param_name}\"))");
|
||||
- let cli_arg = format!("-D{param_name}={}", root.to_string_lossy());
|
||||
- (policy, cli_arg)
|
||||
- })
|
||||
- .unzip();
|
||||
+
|
||||
+ let mut writable_folder_policies: Vec<String> = Vec::new();
|
||||
+ let mut cli_args: Vec<String> = Vec::new();
|
||||
+
|
||||
+ for (index, wr) in writable_roots.iter().enumerate() {
|
||||
+ // Canonicalize to avoid mismatches like /var vs /private/var on macOS.
|
||||
+ let canonical_root = wr.root.canonicalize().unwrap_or_else(|_| wr.root.clone());
|
||||
+ let root_param = format!("WRITABLE_ROOT_{index}");
|
||||
+ cli_args.push(format!(
|
||||
+ "-D{root_param}={}",
|
||||
+ canonical_root.to_string_lossy()
|
||||
+ ));
|
||||
+
|
||||
+ if wr.read_only_subpaths.is_empty() {
|
||||
+ writable_folder_policies.push(format!("(subpath (param \"{root_param}\"))"));
|
||||
+ } else {
|
||||
+ // Add parameters for each read-only subpath and generate
|
||||
+ // the `(require-not ...)` clauses.
|
||||
+ let mut require_parts: Vec<String> = Vec::new();
|
||||
+ require_parts.push(format!("(subpath (param \"{root_param}\"))"));
|
||||
+ for (subpath_index, ro) in wr.read_only_subpaths.iter().enumerate() {
|
||||
+ let canonical_ro = ro.canonicalize().unwrap_or_else(|_| ro.clone());
|
||||
+ let ro_param = format!("WRITABLE_ROOT_{index}_RO_{subpath_index}");
|
||||
+ cli_args.push(format!("-D{ro_param}={}", canonical_ro.to_string_lossy()));
|
||||
+ require_parts
|
||||
+ .push(format!("(require-not (subpath (param \"{ro_param}\")))"));
|
||||
+ }
|
||||
+ let policy_component = format!("(require-all {} )", require_parts.join(" "));
|
||||
+ writable_folder_policies.push(policy_component);
|
||||
+ }
|
||||
+ }
|
||||
+
|
||||
if writable_folder_policies.is_empty() {
|
||||
("".to_string(), Vec::<String>::new())
|
||||
} else {
|
||||
@@ -88,9 +112,201 @@ fn create_seatbelt_command_args(
|
||||
let full_policy = format!(
|
||||
"{MACOS_SEATBELT_BASE_POLICY}\n{file_read_policy}\n{file_write_policy}\n{network_policy}"
|
||||
);
|
||||
+
|
||||
let mut seatbelt_args: Vec<String> = vec!["-p".to_string(), full_policy];
|
||||
seatbelt_args.extend(extra_cli_args);
|
||||
seatbelt_args.push("--".to_string());
|
||||
seatbelt_args.extend(command);
|
||||
seatbelt_args
|
||||
}
|
||||
+
|
||||
+#[cfg(test)]
|
||||
+mod tests {
|
||||
+ #![expect(clippy::expect_used)]
|
||||
+ use super::MACOS_SEATBELT_BASE_POLICY;
|
||||
+ use super::create_seatbelt_command_args;
|
||||
+ use crate::protocol::SandboxPolicy;
|
||||
+ use pretty_assertions::assert_eq;
|
||||
+ use std::fs;
|
||||
+ use std::path::Path;
|
||||
+ use std::path::PathBuf;
|
||||
+ use tempfile::TempDir;
|
||||
+
|
||||
+ #[test]
|
||||
+ fn create_seatbelt_args_with_read_only_git_subpath() {
|
||||
+ // Create a temporary workspace with two writable roots: one containing
|
||||
+ // a top-level .git directory and one without it.
|
||||
+ let tmp = TempDir::new().expect("tempdir");
|
||||
+ let PopulatedTmp {
|
||||
+ root_with_git,
|
||||
+ root_without_git,
|
||||
+ root_with_git_canon,
|
||||
+ root_with_git_git_canon,
|
||||
+ root_without_git_canon,
|
||||
+ } = populate_tmpdir(tmp.path());
|
||||
+
|
||||
+ // Build a policy that only includes the two test roots as writable and
|
||||
+ // does not automatically include defaults like cwd or TMPDIR.
|
||||
+ let policy = SandboxPolicy::WorkspaceWrite {
|
||||
+ writable_roots: vec![root_with_git.clone(), root_without_git.clone()],
|
||||
+ network_access: false,
|
||||
+ include_default_writable_roots: false,
|
||||
+ };
|
||||
+
|
||||
+ let args = create_seatbelt_command_args(
|
||||
+ vec!["/bin/echo".to_string(), "hello".to_string()],
|
||||
+ &policy,
|
||||
+ tmp.path(),
|
||||
+ );
|
||||
+
|
||||
+ // Build the expected policy text using a raw string for readability.
|
||||
+ // Note that the policy includes:
|
||||
+ // - the base policy,
|
||||
+ // - read-only access to the filesystem,
|
||||
+ // - write access to WRITABLE_ROOT_0 (but not its .git) and WRITABLE_ROOT_1.
|
||||
+ let expected_policy = format!(
|
||||
+ r#"{MACOS_SEATBELT_BASE_POLICY}
|
||||
+; allow read-only file operations
|
||||
+(allow file-read*)
|
||||
+(allow file-write*
|
||||
+(require-all (subpath (param "WRITABLE_ROOT_0")) (require-not (subpath (param "WRITABLE_ROOT_0_RO_0"))) ) (subpath (param "WRITABLE_ROOT_1"))
|
||||
+)
|
||||
+"#,
|
||||
+ );
|
||||
+
|
||||
+ let expected_args = vec![
|
||||
+ "-p".to_string(),
|
||||
+ expected_policy,
|
||||
+ format!(
|
||||
+ "-DWRITABLE_ROOT_0={}",
|
||||
+ root_with_git_canon.to_string_lossy()
|
||||
+ ),
|
||||
+ format!(
|
||||
+ "-DWRITABLE_ROOT_0_RO_0={}",
|
||||
+ root_with_git_git_canon.to_string_lossy()
|
||||
+ ),
|
||||
+ format!(
|
||||
+ "-DWRITABLE_ROOT_1={}",
|
||||
+ root_without_git_canon.to_string_lossy()
|
||||
+ ),
|
||||
+ "--".to_string(),
|
||||
+ "/bin/echo".to_string(),
|
||||
+ "hello".to_string(),
|
||||
+ ];
|
||||
+
|
||||
+ assert_eq!(args, expected_args);
|
||||
+ }
|
||||
+
|
||||
+ #[test]
|
||||
+ fn create_seatbelt_args_for_cwd_as_git_repo() {
|
||||
+ // Create a temporary workspace with two writable roots: one containing
|
||||
+ // a top-level .git directory and one without it.
|
||||
+ let tmp = TempDir::new().expect("tempdir");
|
||||
+ let PopulatedTmp {
|
||||
+ root_with_git,
|
||||
+ root_with_git_canon,
|
||||
+ root_with_git_git_canon,
|
||||
+ ..
|
||||
+ } = populate_tmpdir(tmp.path());
|
||||
+
|
||||
+ // Build a policy that does not specify any writable_roots, but does
|
||||
+ // use the default ones (cwd and TMPDIR) and verifies the `.git` check
|
||||
+ // is done properly for cwd.
|
||||
+ let policy = SandboxPolicy::WorkspaceWrite {
|
||||
+ writable_roots: vec![],
|
||||
+ network_access: false,
|
||||
+ include_default_writable_roots: true,
|
||||
+ };
|
||||
+
|
||||
+ let args = create_seatbelt_command_args(
|
||||
+ vec!["/bin/echo".to_string(), "hello".to_string()],
|
||||
+ &policy,
|
||||
+ root_with_git.as_path(),
|
||||
+ );
|
||||
+
|
||||
+ let tmpdir_env_var = if cfg!(target_os = "macos") {
|
||||
+ std::env::var("TMPDIR")
|
||||
+ .ok()
|
||||
+ .map(PathBuf::from)
|
||||
+ .and_then(|p| p.canonicalize().ok())
|
||||
+ .map(|p| p.to_string_lossy().to_string())
|
||||
+ } else {
|
||||
+ None
|
||||
+ };
|
||||
+ let tempdir_policy_entry = if tmpdir_env_var.is_some() {
|
||||
+ " (subpath (param \"WRITABLE_ROOT_1\"))"
|
||||
+ } else {
|
||||
+ ""
|
||||
+ };
|
||||
+
|
||||
+ // Build the expected policy text using a raw string for readability.
|
||||
+ // Note that the policy includes:
|
||||
+ // - the base policy,
|
||||
+ // - read-only access to the filesystem,
|
||||
+ // - write access to WRITABLE_ROOT_0 (but not its .git) and WRITABLE_ROOT_1.
|
||||
+ let expected_policy = format!(
|
||||
+ r#"{MACOS_SEATBELT_BASE_POLICY}
|
||||
+; allow read-only file operations
|
||||
+(allow file-read*)
|
||||
+(allow file-write*
|
||||
+(require-all (subpath (param "WRITABLE_ROOT_0")) (require-not (subpath (param "WRITABLE_ROOT_0_RO_0"))) ){tempdir_policy_entry}
|
||||
+)
|
||||
+"#,
|
||||
+ );
|
||||
+
|
||||
+ let mut expected_args = vec![
|
||||
+ "-p".to_string(),
|
||||
+ expected_policy,
|
||||
+ format!(
|
||||
+ "-DWRITABLE_ROOT_0={}",
|
||||
+ root_with_git_canon.to_string_lossy()
|
||||
+ ),
|
||||
+ format!(
|
||||
+ "-DWRITABLE_ROOT_0_RO_0={}",
|
||||
+ root_with_git_git_canon.to_string_lossy()
|
||||
+ ),
|
||||
+ ];
|
||||
+
|
||||
+ if let Some(p) = tmpdir_env_var {
|
||||
+ expected_args.push(format!("-DWRITABLE_ROOT_1={p}"));
|
||||
+ }
|
||||
+
|
||||
+ expected_args.extend(vec![
|
||||
+ "--".to_string(),
|
||||
+ "/bin/echo".to_string(),
|
||||
+ "hello".to_string(),
|
||||
+ ]);
|
||||
+
|
||||
+ assert_eq!(args, expected_args);
|
||||
+ }
|
||||
+
|
||||
+ struct PopulatedTmp {
|
||||
+ root_with_git: PathBuf,
|
||||
+ root_without_git: PathBuf,
|
||||
+ root_with_git_canon: PathBuf,
|
||||
+ root_with_git_git_canon: PathBuf,
|
||||
+ root_without_git_canon: PathBuf,
|
||||
+ }
|
||||
+
|
||||
+ fn populate_tmpdir(tmp: &Path) -> PopulatedTmp {
|
||||
+ let root_with_git = tmp.join("with_git");
|
||||
+ let root_without_git = tmp.join("no_git");
|
||||
+ fs::create_dir_all(&root_with_git).expect("create with_git");
|
||||
+ fs::create_dir_all(&root_without_git).expect("create no_git");
|
||||
+ fs::create_dir_all(root_with_git.join(".git")).expect("create .git");
|
||||
+
|
||||
+ // Ensure we have canonical paths for -D parameter matching.
|
||||
+ let root_with_git_canon = root_with_git.canonicalize().expect("canonicalize with_git");
|
||||
+ let root_with_git_git_canon = root_with_git_canon.join(".git");
|
||||
+ let root_without_git_canon = root_without_git
|
||||
+ .canonicalize()
|
||||
+ .expect("canonicalize no_git");
|
||||
+ PopulatedTmp {
|
||||
+ root_with_git,
|
||||
+ root_without_git,
|
||||
+ root_with_git_canon,
|
||||
+ root_with_git_git_canon,
|
||||
+ root_without_git_canon,
|
||||
+ }
|
||||
+ }
|
||||
+}
|
||||
diff --git a/codex-rs/core/src/spawn.rs b/codex-rs/core/src/spawn.rs
|
||||
index 9fde26539b..1c82df3180 100644
|
||||
--- a/codex-rs/core/src/spawn.rs
|
||||
+++ b/codex-rs/core/src/spawn.rs
|
||||
@@ -17,6 +17,11 @@ use crate::protocol::SandboxPolicy;
|
||||
/// attributes, so this may change in the future.
|
||||
pub const CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR: &str = "CODEX_SANDBOX_NETWORK_DISABLED";
|
||||
|
||||
+/// Should be set when the process is spawned under a sandbox. Currently, the
|
||||
+/// value is "seatbelt" for macOS, but it may change in the future to
|
||||
+/// accommodate sandboxing configuration and other sandboxing mechanisms.
|
||||
+pub const CODEX_SANDBOX_ENV_VAR: &str = "CODEX_SANDBOX";
|
||||
+
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub enum StdioPolicy {
|
||||
RedirectForShellTool,
|
||||
diff --git a/codex-rs/core/tests/sandbox.rs b/codex-rs/core/tests/sandbox.rs
|
||||
new file mode 100644
|
||||
index 0000000000..e85156bf05
|
||||
--- /dev/null
|
||||
+++ b/codex-rs/core/tests/sandbox.rs
|
||||
@@ -0,0 +1,195 @@
|
||||
+#![cfg(target_os = "macos")]
|
||||
+#![expect(clippy::expect_used)]
|
||||
+
|
||||
+use std::collections::HashMap;
|
||||
+use std::path::Path;
|
||||
+use std::path::PathBuf;
|
||||
+
|
||||
+use codex_core::protocol::SandboxPolicy;
|
||||
+use codex_core::seatbelt::spawn_command_under_seatbelt;
|
||||
+use codex_core::spawn::CODEX_SANDBOX_ENV_VAR;
|
||||
+use codex_core::spawn::StdioPolicy;
|
||||
+use tempfile::TempDir;
|
||||
+
|
||||
+struct TestScenario {
|
||||
+ repo_parent: PathBuf,
|
||||
+ file_outside_repo: PathBuf,
|
||||
+ repo_root: PathBuf,
|
||||
+ file_in_repo_root: PathBuf,
|
||||
+ file_in_dot_git_dir: PathBuf,
|
||||
+}
|
||||
+
|
||||
+struct TestExpectations {
|
||||
+ file_outside_repo_is_writable: bool,
|
||||
+ file_in_repo_root_is_writable: bool,
|
||||
+ file_in_dot_git_dir_is_writable: bool,
|
||||
+}
|
||||
+
|
||||
+impl TestScenario {
|
||||
+ async fn run_test(&self, policy: &SandboxPolicy, expectations: TestExpectations) {
|
||||
+ if std::env::var(CODEX_SANDBOX_ENV_VAR) == Ok("seatbelt".to_string()) {
|
||||
+ eprintln!("{CODEX_SANDBOX_ENV_VAR} is set to 'seatbelt', skipping test.");
|
||||
+ return;
|
||||
+ }
|
||||
+
|
||||
+ assert_eq!(
|
||||
+ touch(&self.file_outside_repo, policy).await,
|
||||
+ expectations.file_outside_repo_is_writable
|
||||
+ );
|
||||
+ assert_eq!(
|
||||
+ self.file_outside_repo.exists(),
|
||||
+ expectations.file_outside_repo_is_writable
|
||||
+ );
|
||||
+
|
||||
+ assert_eq!(
|
||||
+ touch(&self.file_in_repo_root, policy).await,
|
||||
+ expectations.file_in_repo_root_is_writable
|
||||
+ );
|
||||
+ assert_eq!(
|
||||
+ self.file_in_repo_root.exists(),
|
||||
+ expectations.file_in_repo_root_is_writable
|
||||
+ );
|
||||
+
|
||||
+ assert_eq!(
|
||||
+ touch(&self.file_in_dot_git_dir, policy).await,
|
||||
+ expectations.file_in_dot_git_dir_is_writable
|
||||
+ );
|
||||
+ assert_eq!(
|
||||
+ self.file_in_dot_git_dir.exists(),
|
||||
+ expectations.file_in_dot_git_dir_is_writable
|
||||
+ );
|
||||
+ }
|
||||
+}
|
||||
+
|
||||
+/// If the user has added a workspace root that is not a Git repo root, then
|
||||
+/// the user has to specify `--skip-git-repo-check` or go through some
|
||||
+/// interstitial that indicates they are taking on some risk because Git
|
||||
+/// cannot be used to backup their work before the agent begins.
|
||||
+///
|
||||
+/// Because the user has agreed to this risk, we do not try find all .git
|
||||
+/// folders in the workspace and block them (though we could change our
|
||||
+/// position on this in the future).
|
||||
+#[tokio::test]
|
||||
+async fn if_parent_of_repo_is_writable_then_dot_git_folder_is_writable() {
|
||||
+ let tmp = TempDir::new().expect("should be able to create temp dir");
|
||||
+ let test_scenario = create_test_scenario(&tmp);
|
||||
+ let policy = SandboxPolicy::WorkspaceWrite {
|
||||
+ writable_roots: vec![test_scenario.repo_parent.clone()],
|
||||
+ network_access: false,
|
||||
+ include_default_writable_roots: false,
|
||||
+ };
|
||||
+
|
||||
+ test_scenario
|
||||
+ .run_test(
|
||||
+ &policy,
|
||||
+ TestExpectations {
|
||||
+ file_outside_repo_is_writable: true,
|
||||
+ file_in_repo_root_is_writable: true,
|
||||
+ file_in_dot_git_dir_is_writable: true,
|
||||
+ },
|
||||
+ )
|
||||
+ .await;
|
||||
+}
|
||||
+
|
||||
+/// When the writable root is the root of a Git repository (as evidenced by the
|
||||
+/// presence of a .git folder), then the .git folder should be read-only if
|
||||
+/// the policy is `WorkspaceWrite`.
|
||||
+#[tokio::test]
|
||||
+async fn if_git_repo_is_writable_root_then_dot_git_folder_is_read_only() {
|
||||
+ let tmp = TempDir::new().expect("should be able to create temp dir");
|
||||
+ let test_scenario = create_test_scenario(&tmp);
|
||||
+ let policy = SandboxPolicy::WorkspaceWrite {
|
||||
+ writable_roots: vec![test_scenario.repo_root.clone()],
|
||||
+ network_access: false,
|
||||
+ include_default_writable_roots: false,
|
||||
+ };
|
||||
+
|
||||
+ test_scenario
|
||||
+ .run_test(
|
||||
+ &policy,
|
||||
+ TestExpectations {
|
||||
+ file_outside_repo_is_writable: false,
|
||||
+ file_in_repo_root_is_writable: true,
|
||||
+ file_in_dot_git_dir_is_writable: false,
|
||||
+ },
|
||||
+ )
|
||||
+ .await;
|
||||
+}
|
||||
+
|
||||
+/// Under DangerFullAccess, all writes should be permitted anywhere on disk,
|
||||
+/// including inside the .git folder.
|
||||
+#[tokio::test]
|
||||
+async fn danger_full_access_allows_all_writes() {
|
||||
+ let tmp = TempDir::new().expect("should be able to create temp dir");
|
||||
+ let test_scenario = create_test_scenario(&tmp);
|
||||
+ let policy = SandboxPolicy::DangerFullAccess;
|
||||
+
|
||||
+ test_scenario
|
||||
+ .run_test(
|
||||
+ &policy,
|
||||
+ TestExpectations {
|
||||
+ file_outside_repo_is_writable: true,
|
||||
+ file_in_repo_root_is_writable: true,
|
||||
+ file_in_dot_git_dir_is_writable: true,
|
||||
+ },
|
||||
+ )
|
||||
+ .await;
|
||||
+}
|
||||
+
|
||||
+/// Under ReadOnly, writes should not be permitted anywhere on disk.
|
||||
+#[tokio::test]
|
||||
+async fn read_only_forbids_all_writes() {
|
||||
+ let tmp = TempDir::new().expect("should be able to create temp dir");
|
||||
+ let test_scenario = create_test_scenario(&tmp);
|
||||
+ let policy = SandboxPolicy::ReadOnly;
|
||||
+
|
||||
+ test_scenario
|
||||
+ .run_test(
|
||||
+ &policy,
|
||||
+ TestExpectations {
|
||||
+ file_outside_repo_is_writable: false,
|
||||
+ file_in_repo_root_is_writable: false,
|
||||
+ file_in_dot_git_dir_is_writable: false,
|
||||
+ },
|
||||
+ )
|
||||
+ .await;
|
||||
+}
|
||||
+
|
||||
+fn create_test_scenario(tmp: &TempDir) -> TestScenario {
|
||||
+ let repo_parent = tmp.path().to_path_buf();
|
||||
+ let repo_root = repo_parent.join("repo");
|
||||
+ let dot_git_dir = repo_root.join(".git");
|
||||
+
|
||||
+ std::fs::create_dir(&repo_root).expect("should be able to create repo root");
|
||||
+ std::fs::create_dir(&dot_git_dir).expect("should be able to create .git dir");
|
||||
+
|
||||
+ TestScenario {
|
||||
+ file_outside_repo: repo_parent.join("outside.txt"),
|
||||
+ repo_parent,
|
||||
+ file_in_repo_root: repo_root.join("repo_file.txt"),
|
||||
+ repo_root,
|
||||
+ file_in_dot_git_dir: dot_git_dir.join("dot_git_file.txt"),
|
||||
+ }
|
||||
+}
|
||||
+
|
||||
+/// Note that `path` must be absolute.
|
||||
+async fn touch(path: &Path, policy: &SandboxPolicy) -> bool {
|
||||
+ assert!(path.is_absolute(), "Path must be absolute: {path:?}");
|
||||
+ let mut child = spawn_command_under_seatbelt(
|
||||
+ vec![
|
||||
+ "/usr/bin/touch".to_string(),
|
||||
+ path.to_string_lossy().to_string(),
|
||||
+ ],
|
||||
+ policy,
|
||||
+ std::env::current_dir().expect("should be able to get current dir"),
|
||||
+ StdioPolicy::RedirectForShellTool,
|
||||
+ HashMap::new(),
|
||||
+ )
|
||||
+ .await
|
||||
+ .expect("should be able to spawn command under seatbelt");
|
||||
+ child
|
||||
+ .wait()
|
||||
+ .await
|
||||
+ .expect("should be able to wait for child process")
|
||||
+ .success()
|
||||
+}
|
||||
diff --git a/codex-rs/linux-sandbox/src/landlock.rs b/codex-rs/linux-sandbox/src/landlock.rs
|
||||
index 326e2cb487..e13e3c8b75 100644
|
||||
--- a/codex-rs/linux-sandbox/src/landlock.rs
|
||||
+++ b/codex-rs/linux-sandbox/src/landlock.rs
|
||||
@@ -36,7 +36,11 @@ pub(crate) fn apply_sandbox_policy_to_current_thread(
|
||||
}
|
||||
|
||||
if !sandbox_policy.has_full_disk_write_access() {
|
||||
- let writable_roots = sandbox_policy.get_writable_roots_with_cwd(cwd);
|
||||
+ let writable_roots = sandbox_policy
|
||||
+ .get_writable_roots_with_cwd(cwd)
|
||||
+ .into_iter()
|
||||
+ .map(|writable_root| writable_root.root)
|
||||
+ .collect();
|
||||
install_filesystem_landlock_rules_on_current_thread(writable_roots)?;
|
||||
}
|
||||
```
|
||||
|
||||
## Review Comments
|
||||
|
||||
### codex-rs/core/src/seatbelt.rs
|
||||
|
||||
- Created: 2025-08-01 21:15:07 UTC | Link: https://github.com/openai/codex/pull/1765#discussion_r2248881207
|
||||
|
||||
```diff
|
||||
@@ -50,16 +52,45 @@ fn create_seatbelt_command_args(
|
||||
)
|
||||
} else {
|
||||
let writable_roots = sandbox_policy.get_writable_roots_with_cwd(cwd);
|
||||
- let (writable_folder_policies, cli_args): (Vec<String>, Vec<String>) = writable_roots
|
||||
- .iter()
|
||||
- .enumerate()
|
||||
- .map(|(index, root)| {
|
||||
- let param_name = format!("WRITABLE_ROOT_{index}");
|
||||
- let policy: String = format!("(subpath (param \"{param_name}\"))");
|
||||
- let cli_arg = format!("-D{param_name}={}", root.to_string_lossy());
|
||||
- (policy, cli_arg)
|
||||
- })
|
||||
- .unzip();
|
||||
+
|
||||
+ let mut writable_folder_policies: Vec<String> = Vec::new();
|
||||
+ let mut cli_args: Vec<String> = Vec::new();
|
||||
+
|
||||
+ for (index, root) in writable_roots.iter().enumerate() {
|
||||
+ // Canonicalize to avoid mismatches like /var vs /private/var on macOS.
|
||||
+ let canonical_root = root.canonicalize().unwrap_or_else(|_| root.clone());
|
||||
+ let param_name = format!("WRITABLE_ROOT_{index}");
|
||||
+ cli_args.push(format!(
|
||||
+ "-D{param_name}={}",
|
||||
+ canonical_root.to_string_lossy()
|
||||
+ ));
|
||||
+
|
||||
+ // For WorkspaceWrite, if the writable root itself looks like a
|
||||
+ // git repository (i.e., contains a top-level ".git" directory),
|
||||
+ // then disallow writes specifically under that top-level ".git".
|
||||
+ // Do NOT block ".git" directories under subdirectories: those
|
||||
+ // are allowed when the parent of the repo is writable.
|
||||
+ let policy_component = if let SandboxPolicy::WorkspaceWrite { .. } = sandbox_policy
|
||||
```
|
||||
|
||||
> Thanks, this is a good callout. I just let Codex do its thing here. Fixing...
|
||||
|
||||
- Created: 2025-08-01 22:10:58 UTC | Link: https://github.com/openai/codex/pull/1765#discussion_r2248943087
|
||||
|
||||
```diff
|
||||
@@ -20,10 +21,11 @@ pub async fn spawn_command_under_seatbelt(
|
||||
sandbox_policy: &SandboxPolicy,
|
||||
cwd: PathBuf,
|
||||
stdio_policy: StdioPolicy,
|
||||
- env: HashMap<String, String>,
|
||||
+ mut env: HashMap<String, String>,
|
||||
```
|
||||
|
||||
> It is passed by value, though? Do you want me to change it so it is passed by reference?
|
||||
|
||||
- Created: 2025-08-01 22:13:58 UTC | Link: https://github.com/openai/codex/pull/1765#discussion_r2248945683
|
||||
|
||||
```diff
|
||||
@@ -88,9 +112,93 @@ fn create_seatbelt_command_args(
|
||||
let full_policy = format!(
|
||||
"{MACOS_SEATBELT_BASE_POLICY}\n{file_read_policy}\n{file_write_policy}\n{network_policy}"
|
||||
);
|
||||
+
|
||||
let mut seatbelt_args: Vec<String> = vec!["-p".to_string(), full_policy];
|
||||
seatbelt_args.extend(extra_cli_args);
|
||||
seatbelt_args.push("--".to_string());
|
||||
seatbelt_args.extend(command);
|
||||
seatbelt_args
|
||||
}
|
||||
+
|
||||
+#[cfg(test)]
|
||||
+mod tests {
|
||||
+ #![expect(clippy::expect_used)]
|
||||
+ use super::MACOS_SEATBELT_BASE_POLICY;
|
||||
+ use super::create_seatbelt_command_args;
|
||||
+ use crate::protocol::SandboxPolicy;
|
||||
+ use std::fs;
|
||||
+ use std::path::Path;
|
||||
+ use tempfile::TempDir;
|
||||
+
|
||||
+ #[test]
|
||||
+ fn create_seatbelt_args_with_read_only_git_subpath() {
|
||||
```
|
||||
|
||||
> Do you mean a test where `cwd` has a `.git` folder?
|
||||
|
||||
- Created: 2025-08-01 22:15:05 UTC | Link: https://github.com/openai/codex/pull/1765#discussion_r2248946539
|
||||
|
||||
```diff
|
||||
@@ -88,9 +112,93 @@ fn create_seatbelt_command_args(
|
||||
let full_policy = format!(
|
||||
"{MACOS_SEATBELT_BASE_POLICY}\n{file_read_policy}\n{file_write_policy}\n{network_policy}"
|
||||
);
|
||||
+
|
||||
let mut seatbelt_args: Vec<String> = vec!["-p".to_string(), full_policy];
|
||||
seatbelt_args.extend(extra_cli_args);
|
||||
seatbelt_args.push("--".to_string());
|
||||
seatbelt_args.extend(command);
|
||||
seatbelt_args
|
||||
}
|
||||
+
|
||||
+#[cfg(test)]
|
||||
+mod tests {
|
||||
+ #![expect(clippy::expect_used)]
|
||||
+ use super::MACOS_SEATBELT_BASE_POLICY;
|
||||
+ use super::create_seatbelt_command_args;
|
||||
+ use crate::protocol::SandboxPolicy;
|
||||
+ use std::fs;
|
||||
+ use std::path::Path;
|
||||
+ use tempfile::TempDir;
|
||||
+
|
||||
+ #[test]
|
||||
+ fn create_seatbelt_args_with_read_only_git_subpath() {
|
||||
```
|
||||
|
||||
> And therefore `include_default_writable_roots` is `true`?
|
||||
|
||||
- Created: 2025-08-01 22:48:27 UTC | Link: https://github.com/openai/codex/pull/1765#discussion_r2248972389
|
||||
|
||||
```diff
|
||||
@@ -88,9 +112,93 @@ fn create_seatbelt_command_args(
|
||||
let full_policy = format!(
|
||||
"{MACOS_SEATBELT_BASE_POLICY}\n{file_read_policy}\n{file_write_policy}\n{network_policy}"
|
||||
);
|
||||
+
|
||||
let mut seatbelt_args: Vec<String> = vec!["-p".to_string(), full_policy];
|
||||
seatbelt_args.extend(extra_cli_args);
|
||||
seatbelt_args.push("--".to_string());
|
||||
seatbelt_args.extend(command);
|
||||
seatbelt_args
|
||||
}
|
||||
+
|
||||
+#[cfg(test)]
|
||||
+mod tests {
|
||||
+ #![expect(clippy::expect_used)]
|
||||
+ use super::MACOS_SEATBELT_BASE_POLICY;
|
||||
+ use super::create_seatbelt_command_args;
|
||||
+ use crate::protocol::SandboxPolicy;
|
||||
+ use std::fs;
|
||||
+ use std::path::Path;
|
||||
+ use tempfile::TempDir;
|
||||
+
|
||||
+ #[test]
|
||||
+ fn create_seatbelt_args_with_read_only_git_subpath() {
|
||||
```
|
||||
|
||||
> Added test: `create_seatbelt_args_for_cwd_as_git_repo()`
|
||||
220
prs/bolinfest/PR-1766.md
Normal file
220
prs/bolinfest/PR-1766.md
Normal file
@@ -0,0 +1,220 @@
|
||||
# PR #1766: Supporting both shift+enter and ctrl+j and adding kpp check + unit test
|
||||
|
||||
- URL: https://github.com/openai/codex/pull/1766
|
||||
- Author: pap-openai
|
||||
- Created: 2025-07-31 21:05:22 UTC
|
||||
- Updated: 2025-08-01 00:32:14 UTC
|
||||
- Changes: +98/-1, Files changed: 5, Commits: 8
|
||||
|
||||
## Description
|
||||
|
||||
Apple terminal shows ctrl+j
|
||||
|
||||
<img width="682" height="483" alt="image" src="https://github.com/user-attachments/assets/8572223d-4a13-46fd-9a16-2281b796faa0" />
|
||||
|
||||
iTerm that supports KPP shows shift+enter
|
||||
|
||||
<img width="682" height="570" alt="image" src="https://github.com/user-attachments/assets/e8f7dd4c-ec63-4f1c-8f1e-e35235554783" />
|
||||
|
||||
## Full Diff
|
||||
|
||||
```diff
|
||||
diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock
|
||||
index 87d59b21be..e6b5c11bd9 100644
|
||||
--- a/codex-rs/Cargo.lock
|
||||
+++ b/codex-rs/Cargo.lock
|
||||
@@ -854,6 +854,7 @@ dependencies = [
|
||||
"image",
|
||||
"insta",
|
||||
"lazy_static",
|
||||
+ "libc",
|
||||
"mcp-types",
|
||||
"path-clean",
|
||||
"pretty_assertions",
|
||||
diff --git a/codex-rs/tui/Cargo.toml b/codex-rs/tui/Cargo.toml
|
||||
index 63d287ca11..e5739ec505 100644
|
||||
--- a/codex-rs/tui/Cargo.toml
|
||||
+++ b/codex-rs/tui/Cargo.toml
|
||||
@@ -32,6 +32,7 @@ color-eyre = "0.6.3"
|
||||
crossterm = { version = "0.28.1", features = ["bracketed-paste"] }
|
||||
image = { version = "^0.25.6", default-features = false, features = ["jpeg"] }
|
||||
lazy_static = "1"
|
||||
+libc = "0.2"
|
||||
mcp-types = { path = "../mcp-types" }
|
||||
path-clean = "1.0.1"
|
||||
ratatui = { version = "0.29.0", features = [
|
||||
diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs
|
||||
index 3bc573a003..6b7017f6f0 100644
|
||||
--- a/codex-rs/tui/src/bottom_pane/chat_composer.rs
|
||||
+++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs
|
||||
@@ -712,11 +712,16 @@ impl WidgetRef for &ChatComposer<'_> {
|
||||
Span::from(" to quit"),
|
||||
]
|
||||
} else {
|
||||
+ let newline_hint = if crate::tui::is_kkp_enabled() {
|
||||
+ "Shift+⏎"
|
||||
+ } else {
|
||||
+ "Ctrl+J"
|
||||
+ };
|
||||
vec![
|
||||
Span::from(" "),
|
||||
"⏎".set_style(key_hint_style),
|
||||
Span::from(" send "),
|
||||
- "Shift+⏎".set_style(key_hint_style),
|
||||
+ newline_hint.set_style(key_hint_style),
|
||||
Span::from(" newline "),
|
||||
"Ctrl+C".set_style(key_hint_style),
|
||||
Span::from(" quit"),
|
||||
@@ -961,6 +966,9 @@ mod tests {
|
||||
use ratatui::Terminal;
|
||||
use ratatui::backend::TestBackend;
|
||||
|
||||
+ // First, run snapshots with KKP enabled so hints show Shift+⏎.
|
||||
+ crate::tui::set_kkp_for_tests(true);
|
||||
+
|
||||
let (tx, _rx) = std::sync::mpsc::channel();
|
||||
let sender = AppEventSender::new(tx);
|
||||
let mut terminal = match Terminal::new(TestBackend::new(100, 10)) {
|
||||
@@ -1005,6 +1013,18 @@ mod tests {
|
||||
|
||||
assert_snapshot!(name, terminal.backend());
|
||||
}
|
||||
+
|
||||
+ // Also add one snapshot with KKP disabled so we still see Ctrl+J.
|
||||
+ crate::tui::set_kkp_for_tests(false);
|
||||
+ let mut terminal_ctrlj = match Terminal::new(TestBackend::new(100, 10)) {
|
||||
+ Ok(t) => t,
|
||||
+ Err(e) => panic!("Failed to create terminal: {e}"),
|
||||
+ };
|
||||
+ let composer = ChatComposer::new(true, sender.clone());
|
||||
+ terminal_ctrlj
|
||||
+ .draw(|f| f.render_widget_ref(&composer, f.area()))
|
||||
+ .unwrap_or_else(|e| panic!("Failed to draw empty_ctrlj composer: {e}"));
|
||||
+ assert_snapshot!("empty_ctrlj", terminal_ctrlj.backend());
|
||||
}
|
||||
|
||||
#[test]
|
||||
diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__empty_ctrlj.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__empty_ctrlj.snap
|
||||
new file mode 100644
|
||||
index 0000000000..f798f1af16
|
||||
--- /dev/null
|
||||
+++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__empty_ctrlj.snap
|
||||
@@ -0,0 +1,14 @@
|
||||
+---
|
||||
+source: tui/src/bottom_pane/chat_composer.rs
|
||||
+expression: terminal_ctrlj.backend()
|
||||
+---
|
||||
+"▌ ... "
|
||||
+"▌ "
|
||||
+"▌ "
|
||||
+"▌ "
|
||||
+"▌ "
|
||||
+"▌ "
|
||||
+"▌ "
|
||||
+"▌ "
|
||||
+"▌ "
|
||||
+" ⏎ send Ctrl+J newline Ctrl+C quit "
|
||||
diff --git a/codex-rs/tui/src/tui.rs b/codex-rs/tui/src/tui.rs
|
||||
index 268483cbcf..81ec9aebdc 100644
|
||||
--- a/codex-rs/tui/src/tui.rs
|
||||
+++ b/codex-rs/tui/src/tui.rs
|
||||
@@ -1,6 +1,8 @@
|
||||
use std::io::Result;
|
||||
use std::io::Stdout;
|
||||
use std::io::stdout;
|
||||
+use std::sync::atomic::AtomicBool;
|
||||
+use std::sync::atomic::Ordering;
|
||||
|
||||
use codex_core::config::Config;
|
||||
use crossterm::event::DisableBracketedPaste;
|
||||
@@ -18,6 +20,60 @@ use crate::custom_terminal::Terminal;
|
||||
/// A type alias for the terminal type used in this application
|
||||
pub type Tui = Terminal<CrosstermBackend<Stdout>>;
|
||||
|
||||
+// Global flag indicating whether Kitty Keyboard Protocol (KKP) appears enabled.
|
||||
+static KKP_ENABLED: AtomicBool = AtomicBool::new(false);
|
||||
+
|
||||
+/// Return whether KKP (alternate key reporting) appears enabled.
|
||||
+pub(crate) fn is_kkp_enabled() -> bool {
|
||||
+ KKP_ENABLED.load(Ordering::Relaxed)
|
||||
+}
|
||||
+
|
||||
+#[cfg(test)]
|
||||
+pub(crate) fn set_kkp_for_tests(value: bool) {
|
||||
+ KKP_ENABLED.store(value, Ordering::Relaxed);
|
||||
+}
|
||||
+
|
||||
+/// Try to detect Kitty Keyboard Protocol support by issuing a progressive
|
||||
+/// enhancement query and waiting briefly for a response.
|
||||
+#[cfg(unix)]
|
||||
+fn detect_kitty_protocol() -> std::io::Result<bool> {
|
||||
+ use std::io::Read;
|
||||
+ use std::io::Write;
|
||||
+ use std::io::{self};
|
||||
+ use std::os::unix::io::AsRawFd;
|
||||
+
|
||||
+ let mut stdout = io::stdout();
|
||||
+ let mut stdin = io::stdin();
|
||||
+
|
||||
+ // Send query for progressive enhancement + DA1
|
||||
+ write!(stdout, "\x1b[?u\x1b[c")?;
|
||||
+ stdout.flush()?;
|
||||
+
|
||||
+ // Wait up to ~200ms for a response
|
||||
+ let fd = stdin.as_raw_fd();
|
||||
+ let mut pfd = libc::pollfd {
|
||||
+ fd,
|
||||
+ events: libc::POLLIN,
|
||||
+ revents: 0,
|
||||
+ };
|
||||
+ let rc = unsafe { libc::poll(&mut pfd as *mut libc::pollfd, 1, 200) };
|
||||
+ if rc > 0 && (pfd.revents & libc::POLLIN) != 0 {
|
||||
+ let mut buf = [0u8; 256];
|
||||
+ if let Ok(n) = stdin.read(&mut buf) {
|
||||
+ let response = String::from_utf8_lossy(&buf[..n]);
|
||||
+ if response.contains("[?") && response.contains('u') {
|
||||
+ return Ok(true);
|
||||
+ }
|
||||
+ }
|
||||
+ }
|
||||
+ Ok(false)
|
||||
+}
|
||||
+
|
||||
+#[cfg(not(unix))]
|
||||
+fn detect_kitty_protocol() -> std::io::Result<bool> {
|
||||
+ Ok(false)
|
||||
+}
|
||||
+
|
||||
/// Initialize the terminal (inline viewport; history stays in normal scrollback)
|
||||
pub fn init(_config: &Config) -> Result<Tui> {
|
||||
execute!(stdout(), EnableBracketedPaste)?;
|
||||
@@ -34,6 +90,11 @@ pub fn init(_config: &Config) -> Result<Tui> {
|
||||
| KeyboardEnhancementFlags::REPORT_ALTERNATE_KEYS
|
||||
)
|
||||
)?;
|
||||
+
|
||||
+ // Detect KKP availability; used to adjust UI hints in the composer.
|
||||
+ let kkp = detect_kitty_protocol().unwrap_or(false);
|
||||
+ KKP_ENABLED.store(kkp, Ordering::Relaxed);
|
||||
+
|
||||
set_panic_hook();
|
||||
|
||||
let backend = CrosstermBackend::new(stdout());
|
||||
```
|
||||
|
||||
## Review Comments
|
||||
|
||||
### codex-rs/tui/src/tui.rs
|
||||
|
||||
- Created: 2025-08-01 00:17:33 UTC | Link: https://github.com/openai/codex/pull/1766#discussion_r2246611560
|
||||
|
||||
```diff
|
||||
@@ -18,6 +20,60 @@ use crate::custom_terminal::Terminal;
|
||||
/// A type alias for the terminal type used in this application
|
||||
pub type Tui = Terminal<CrosstermBackend<Stdout>>;
|
||||
|
||||
+// Global flag indicating whether Kitty Keyboard Protocol (KKP) appears enabled.
|
||||
+static KKP_ENABLED: AtomicBool = AtomicBool::new(false);
|
||||
```
|
||||
|
||||
> This feels like we are going to end up with flaky tests due to race conditions?
|
||||
839
prs/bolinfest/PR-1768.md
Normal file
839
prs/bolinfest/PR-1768.md
Normal file
@@ -0,0 +1,839 @@
|
||||
# PR #1768: lighter approval modal
|
||||
|
||||
- URL: https://github.com/openai/codex/pull/1768
|
||||
- Author: nornagon-openai
|
||||
- Created: 2025-07-31 23:05:23 UTC
|
||||
- Updated: 2025-08-01 00:11:00 UTC
|
||||
- Changes: +162/-196, Files changed: 3, Commits: 2
|
||||
|
||||
## Description
|
||||
|
||||
The yellow hazard stripes were too scary :)
|
||||
|
||||
This also has the added benefit of not rendering anything at the full width of the terminal, so resizing is a little easier to handle.
|
||||
|
||||
<img width="860" height="390" alt="Screenshot 2025-07-31 at 4 03 29 PM" src="https://github.com/user-attachments/assets/18476e1a-065d-4da9-92fe-e94978ab0fce" />
|
||||
|
||||
<img width="860" height="390" alt="Screenshot 2025-07-31 at 4 05 03 PM" src="https://github.com/user-attachments/assets/337db0da-de40-48c6-ae71-0e40f24b87e7" />
|
||||
|
||||
## Full Diff
|
||||
|
||||
```diff
|
||||
diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs
|
||||
index 44c1875d40..6f4bcb26e6 100644
|
||||
--- a/codex-rs/tui/src/app.rs
|
||||
+++ b/codex-rs/tui/src/app.rs
|
||||
@@ -10,11 +10,11 @@ use crate::tui;
|
||||
use codex_core::config::Config;
|
||||
use codex_core::protocol::Event;
|
||||
use codex_core::protocol::EventMsg;
|
||||
-use codex_core::protocol::ExecApprovalRequestEvent;
|
||||
use color_eyre::eyre::Result;
|
||||
use crossterm::SynchronizedUpdate;
|
||||
use crossterm::event::KeyCode;
|
||||
use crossterm::event::KeyEvent;
|
||||
+use crossterm::event::KeyEventKind;
|
||||
use ratatui::layout::Offset;
|
||||
use ratatui::prelude::Backend;
|
||||
use std::path::PathBuf;
|
||||
@@ -211,6 +211,7 @@ impl App<'_> {
|
||||
KeyEvent {
|
||||
code: KeyCode::Char('c'),
|
||||
modifiers: crossterm::event::KeyModifiers::CONTROL,
|
||||
+ kind: KeyEventKind::Press,
|
||||
..
|
||||
} => {
|
||||
match &mut self.app_state {
|
||||
@@ -225,6 +226,7 @@ impl App<'_> {
|
||||
KeyEvent {
|
||||
code: KeyCode::Char('d'),
|
||||
modifiers: crossterm::event::KeyModifiers::CONTROL,
|
||||
+ kind: KeyEventKind::Press,
|
||||
..
|
||||
} => {
|
||||
match &mut self.app_state {
|
||||
@@ -302,14 +304,41 @@ impl App<'_> {
|
||||
}
|
||||
#[cfg(debug_assertions)]
|
||||
SlashCommand::TestApproval => {
|
||||
+ use std::collections::HashMap;
|
||||
+
|
||||
+ use codex_core::protocol::ApplyPatchApprovalRequestEvent;
|
||||
+ use codex_core::protocol::FileChange;
|
||||
+
|
||||
self.app_event_tx.send(AppEvent::CodexEvent(Event {
|
||||
id: "1".to_string(),
|
||||
- msg: EventMsg::ExecApprovalRequest(ExecApprovalRequestEvent {
|
||||
- call_id: "1".to_string(),
|
||||
- command: vec!["git".into(), "apply".into()],
|
||||
- cwd: self.config.cwd.clone(),
|
||||
- reason: Some("test".to_string()),
|
||||
- }),
|
||||
+ // msg: EventMsg::ExecApprovalRequest(ExecApprovalRequestEvent {
|
||||
+ // call_id: "1".to_string(),
|
||||
+ // command: vec!["git".into(), "apply".into()],
|
||||
+ // cwd: self.config.cwd.clone(),
|
||||
+ // reason: Some("test".to_string()),
|
||||
+ // }),
|
||||
+ msg: EventMsg::ApplyPatchApprovalRequest(
|
||||
+ ApplyPatchApprovalRequestEvent {
|
||||
+ call_id: "1".to_string(),
|
||||
+ changes: HashMap::from([
|
||||
+ (
|
||||
+ PathBuf::from("/tmp/test.txt"),
|
||||
+ FileChange::Add {
|
||||
+ content: "test".to_string(),
|
||||
+ },
|
||||
+ ),
|
||||
+ (
|
||||
+ PathBuf::from("/tmp/test2.txt"),
|
||||
+ FileChange::Update {
|
||||
+ unified_diff: "+test\n-test2".to_string(),
|
||||
+ move_path: None,
|
||||
+ },
|
||||
+ ),
|
||||
+ ]),
|
||||
+ reason: None,
|
||||
+ grant_root: Some(PathBuf::from("/tmp")),
|
||||
+ },
|
||||
+ ),
|
||||
}));
|
||||
}
|
||||
},
|
||||
diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs
|
||||
index 3ee724e687..83ea792002 100644
|
||||
--- a/codex-rs/tui/src/chatwidget.rs
|
||||
+++ b/codex-rs/tui/src/chatwidget.rs
|
||||
@@ -25,6 +25,7 @@ use codex_core::protocol::PatchApplyBeginEvent;
|
||||
use codex_core::protocol::TaskCompleteEvent;
|
||||
use codex_core::protocol::TokenUsage;
|
||||
use crossterm::event::KeyEvent;
|
||||
+use crossterm::event::KeyEventKind;
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::widgets::Widget;
|
||||
@@ -157,7 +158,9 @@ impl ChatWidget<'_> {
|
||||
}
|
||||
|
||||
pub(crate) fn handle_key_event(&mut self, key_event: KeyEvent) {
|
||||
- self.bottom_pane.clear_ctrl_c_quit_hint();
|
||||
+ if key_event.kind == KeyEventKind::Press {
|
||||
+ self.bottom_pane.clear_ctrl_c_quit_hint();
|
||||
+ }
|
||||
|
||||
match self.bottom_pane.handle_key_event(key_event) {
|
||||
InputResult::Submitted(text) => {
|
||||
diff --git a/codex-rs/tui/src/user_approval_widget.rs b/codex-rs/tui/src/user_approval_widget.rs
|
||||
index 855a7ea3db..91febde208 100644
|
||||
--- a/codex-rs/tui/src/user_approval_widget.rs
|
||||
+++ b/codex-rs/tui/src/user_approval_widget.rs
|
||||
@@ -7,23 +7,24 @@
|
||||
//! driven workflow – a fully‑fledged visual match is not required.
|
||||
|
||||
use std::path::PathBuf;
|
||||
+use std::sync::LazyLock;
|
||||
|
||||
use codex_core::protocol::Op;
|
||||
use codex_core::protocol::ReviewDecision;
|
||||
use crossterm::event::KeyCode;
|
||||
use crossterm::event::KeyEvent;
|
||||
+use crossterm::event::KeyEventKind;
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::prelude::*;
|
||||
use ratatui::text::Line;
|
||||
-use ratatui::text::Span;
|
||||
-use ratatui::widgets::List;
|
||||
+use ratatui::widgets::Block;
|
||||
+use ratatui::widgets::BorderType;
|
||||
+use ratatui::widgets::Borders;
|
||||
use ratatui::widgets::Paragraph;
|
||||
use ratatui::widgets::Widget;
|
||||
use ratatui::widgets::WidgetRef;
|
||||
use ratatui::widgets::Wrap;
|
||||
-use tui_input::Input;
|
||||
-use tui_input::backend::crossterm::EventHandler;
|
||||
|
||||
use crate::app_event::AppEvent;
|
||||
use crate::app_event_sender::AppEventSender;
|
||||
@@ -47,68 +48,62 @@ pub(crate) enum ApprovalRequest {
|
||||
|
||||
/// Options displayed in the *select* mode.
|
||||
struct SelectOption {
|
||||
- label: &'static str,
|
||||
- decision: Option<ReviewDecision>,
|
||||
- /// `true` when this option switches the widget to *input* mode.
|
||||
- enters_input_mode: bool,
|
||||
+ label: Line<'static>,
|
||||
+ description: &'static str,
|
||||
+ key: KeyCode,
|
||||
+ decision: ReviewDecision,
|
||||
}
|
||||
|
||||
-// keep in same order as in the TS implementation
|
||||
-const SELECT_OPTIONS: &[SelectOption] = &[
|
||||
- SelectOption {
|
||||
- label: "Yes (y)",
|
||||
- decision: Some(ReviewDecision::Approved),
|
||||
-
|
||||
- enters_input_mode: false,
|
||||
- },
|
||||
- SelectOption {
|
||||
- label: "Yes, always approve this exact command for this session (a)",
|
||||
- decision: Some(ReviewDecision::ApprovedForSession),
|
||||
-
|
||||
- enters_input_mode: false,
|
||||
- },
|
||||
- SelectOption {
|
||||
- label: "Edit or give feedback (e)",
|
||||
- decision: None,
|
||||
-
|
||||
- enters_input_mode: true,
|
||||
- },
|
||||
- SelectOption {
|
||||
- label: "No, and keep going (n)",
|
||||
- decision: Some(ReviewDecision::Denied),
|
||||
-
|
||||
- enters_input_mode: false,
|
||||
- },
|
||||
- SelectOption {
|
||||
- label: "No, and stop for now (esc)",
|
||||
- decision: Some(ReviewDecision::Abort),
|
||||
-
|
||||
- enters_input_mode: false,
|
||||
- },
|
||||
-];
|
||||
-
|
||||
-/// Internal mode the widget is in – mirrors the TypeScript component.
|
||||
-#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
-enum Mode {
|
||||
- Select,
|
||||
- Input,
|
||||
-}
|
||||
+static COMMAND_SELECT_OPTIONS: LazyLock<Vec<SelectOption>> = LazyLock::new(|| {
|
||||
+ vec![
|
||||
+ SelectOption {
|
||||
+ label: Line::from(vec!["Y".underlined(), "es".into()]),
|
||||
+ description: "Approve and run the command",
|
||||
+ key: KeyCode::Char('y'),
|
||||
+ decision: ReviewDecision::Approved,
|
||||
+ },
|
||||
+ SelectOption {
|
||||
+ label: Line::from(vec!["A".underlined(), "lways".into()]),
|
||||
+ description: "Approve the command for the remainder of this session",
|
||||
+ key: KeyCode::Char('a'),
|
||||
+ decision: ReviewDecision::ApprovedForSession,
|
||||
+ },
|
||||
+ SelectOption {
|
||||
+ label: Line::from(vec!["N".underlined(), "o".into()]),
|
||||
+ description: "Do not run the command",
|
||||
+ key: KeyCode::Char('n'),
|
||||
+ decision: ReviewDecision::Denied,
|
||||
+ },
|
||||
+ ]
|
||||
+});
|
||||
+
|
||||
+static PATCH_SELECT_OPTIONS: LazyLock<Vec<SelectOption>> = LazyLock::new(|| {
|
||||
+ vec![
|
||||
+ SelectOption {
|
||||
+ label: Line::from(vec!["Y".underlined(), "es".into()]),
|
||||
+ description: "Approve and apply the changes",
|
||||
+ key: KeyCode::Char('y'),
|
||||
+ decision: ReviewDecision::Approved,
|
||||
+ },
|
||||
+ SelectOption {
|
||||
+ label: Line::from(vec!["N".underlined(), "o".into()]),
|
||||
+ description: "Do not apply the changes",
|
||||
+ key: KeyCode::Char('n'),
|
||||
+ decision: ReviewDecision::Denied,
|
||||
+ },
|
||||
+ ]
|
||||
+});
|
||||
|
||||
/// A modal prompting the user to approve or deny the pending request.
|
||||
pub(crate) struct UserApprovalWidget<'a> {
|
||||
approval_request: ApprovalRequest,
|
||||
app_event_tx: AppEventSender,
|
||||
confirmation_prompt: Paragraph<'a>,
|
||||
+ select_options: &'a Vec<SelectOption>,
|
||||
|
||||
/// Currently selected index in *select* mode.
|
||||
selected_option: usize,
|
||||
|
||||
- /// State for the optional input widget.
|
||||
- input: Input,
|
||||
-
|
||||
- /// Current mode.
|
||||
- mode: Mode,
|
||||
-
|
||||
/// Set to `true` once a decision has been sent – the parent view can then
|
||||
/// remove this widget from its queue.
|
||||
done: bool,
|
||||
@@ -116,7 +111,6 @@ pub(crate) struct UserApprovalWidget<'a> {
|
||||
|
||||
impl UserApprovalWidget<'_> {
|
||||
pub(crate) fn new(approval_request: ApprovalRequest, app_event_tx: AppEventSender) -> Self {
|
||||
- let input = Input::default();
|
||||
let confirmation_prompt = match &approval_request {
|
||||
ApprovalRequest::Exec {
|
||||
command,
|
||||
@@ -132,25 +126,20 @@ impl UserApprovalWidget<'_> {
|
||||
None => cwd.display().to_string(),
|
||||
};
|
||||
let mut contents: Vec<Line> = vec![
|
||||
- Line::from(vec![
|
||||
- Span::from(cwd_str).dim(),
|
||||
- Span::from("$"),
|
||||
- Span::from(format!(" {cmd}")),
|
||||
- ]),
|
||||
+ Line::from(vec!["codex".bold().magenta(), " wants to run:".into()]),
|
||||
+ Line::from(vec![cwd_str.dim(), "$".into(), format!(" {cmd}").into()]),
|
||||
Line::from(""),
|
||||
];
|
||||
if let Some(reason) = reason {
|
||||
contents.push(Line::from(reason.clone().italic()));
|
||||
contents.push(Line::from(""));
|
||||
}
|
||||
- contents.extend(vec![Line::from("Allow command?"), Line::from("")]);
|
||||
Paragraph::new(contents).wrap(Wrap { trim: false })
|
||||
}
|
||||
ApprovalRequest::ApplyPatch {
|
||||
reason, grant_root, ..
|
||||
} => {
|
||||
- let mut contents: Vec<Line> =
|
||||
- vec![Line::from("Apply patch".bold()), Line::from("")];
|
||||
+ let mut contents: Vec<Line> = vec![];
|
||||
|
||||
if let Some(r) = reason {
|
||||
contents.push(Line::from(r.clone().italic()));
|
||||
@@ -165,20 +154,19 @@ impl UserApprovalWidget<'_> {
|
||||
contents.push(Line::from(""));
|
||||
}
|
||||
|
||||
- contents.push(Line::from("Allow changes?"));
|
||||
- contents.push(Line::from(""));
|
||||
-
|
||||
- Paragraph::new(contents)
|
||||
+ Paragraph::new(contents).wrap(Wrap { trim: false })
|
||||
}
|
||||
};
|
||||
|
||||
Self {
|
||||
+ select_options: match &approval_request {
|
||||
+ ApprovalRequest::Exec { .. } => &COMMAND_SELECT_OPTIONS,
|
||||
+ ApprovalRequest::ApplyPatch { .. } => &PATCH_SELECT_OPTIONS,
|
||||
+ },
|
||||
approval_request,
|
||||
app_event_tx,
|
||||
confirmation_prompt,
|
||||
selected_option: 0,
|
||||
- input,
|
||||
- mode: Mode::Select,
|
||||
done: false,
|
||||
}
|
||||
}
|
||||
@@ -194,9 +182,8 @@ impl UserApprovalWidget<'_> {
|
||||
/// captures input while visible, we don’t need to report whether the event
|
||||
/// was consumed—callers can assume it always is.
|
||||
pub(crate) fn handle_key_event(&mut self, key: KeyEvent) {
|
||||
- match self.mode {
|
||||
- Mode::Select => self.handle_select_key(key),
|
||||
- Mode::Input => self.handle_input_key(key),
|
||||
+ if key.kind == KeyEventKind::Press {
|
||||
+ self.handle_select_key(key);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -208,58 +195,24 @@ impl UserApprovalWidget<'_> {
|
||||
|
||||
fn handle_select_key(&mut self, key_event: KeyEvent) {
|
||||
match key_event.code {
|
||||
- KeyCode::Up => {
|
||||
- if self.selected_option == 0 {
|
||||
- self.selected_option = SELECT_OPTIONS.len() - 1;
|
||||
- } else {
|
||||
- self.selected_option -= 1;
|
||||
- }
|
||||
+ KeyCode::Left => {
|
||||
+ self.selected_option = (self.selected_option + self.select_options.len() - 1)
|
||||
+ % self.select_options.len();
|
||||
}
|
||||
- KeyCode::Down => {
|
||||
- self.selected_option = (self.selected_option + 1) % SELECT_OPTIONS.len();
|
||||
- }
|
||||
- KeyCode::Char('y') => {
|
||||
- self.send_decision(ReviewDecision::Approved);
|
||||
- }
|
||||
- KeyCode::Char('a') => {
|
||||
- self.send_decision(ReviewDecision::ApprovedForSession);
|
||||
- }
|
||||
- KeyCode::Char('n') => {
|
||||
- self.send_decision(ReviewDecision::Denied);
|
||||
- }
|
||||
- KeyCode::Char('e') => {
|
||||
- self.mode = Mode::Input;
|
||||
+ KeyCode::Right => {
|
||||
+ self.selected_option = (self.selected_option + 1) % self.select_options.len();
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
- let opt = &SELECT_OPTIONS[self.selected_option];
|
||||
- if opt.enters_input_mode {
|
||||
- self.mode = Mode::Input;
|
||||
- } else if let Some(decision) = opt.decision {
|
||||
- self.send_decision(decision);
|
||||
- }
|
||||
+ let opt = &self.select_options[self.selected_option];
|
||||
+ self.send_decision(opt.decision);
|
||||
}
|
||||
KeyCode::Esc => {
|
||||
self.send_decision(ReviewDecision::Abort);
|
||||
}
|
||||
- _ => {}
|
||||
- }
|
||||
- }
|
||||
-
|
||||
- fn handle_input_key(&mut self, key_event: KeyEvent) {
|
||||
- // Handle special keys first.
|
||||
- match key_event.code {
|
||||
- KeyCode::Enter => {
|
||||
- let feedback = self.input.value().to_string();
|
||||
- self.send_decision_with_feedback(ReviewDecision::Denied, feedback);
|
||||
- }
|
||||
- KeyCode::Esc => {
|
||||
- // Cancel input – treat as deny without feedback.
|
||||
- self.send_decision(ReviewDecision::Denied);
|
||||
- }
|
||||
- _ => {
|
||||
- // Feed into input widget for normal editing.
|
||||
- let ct_event = crossterm::event::Event::Key(key_event);
|
||||
- self.input.handle_event(&ct_event);
|
||||
+ other => {
|
||||
+ if let Some(opt) = self.select_options.iter().find(|opt| opt.key == other) {
|
||||
+ self.send_decision(opt.decision);
|
||||
+ }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -312,87 +265,68 @@ impl UserApprovalWidget<'_> {
|
||||
}
|
||||
|
||||
pub(crate) fn desired_height(&self, width: u16) -> u16 {
|
||||
- self.get_confirmation_prompt_height(width - 2) + SELECT_OPTIONS.len() as u16 + 2
|
||||
+ self.get_confirmation_prompt_height(width) + self.select_options.len() as u16
|
||||
}
|
||||
}
|
||||
|
||||
-const PLAIN: Style = Style::new();
|
||||
-const BLUE_FG: Style = Style::new().fg(Color::LightCyan);
|
||||
-
|
||||
impl WidgetRef for &UserApprovalWidget<'_> {
|
||||
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
|
||||
- // Take the area, wrap it in a block with a border, and divide up the
|
||||
- // remaining area into two chunks: one for the confirmation prompt and
|
||||
- // one for the response.
|
||||
- let inner = area.inner(Margin::new(0, 2));
|
||||
-
|
||||
- // Determine how many rows we can allocate for the static confirmation
|
||||
- // prompt while *always* keeping enough space for the interactive
|
||||
- // response area (select list or input field). When the full prompt
|
||||
- // would exceed the available height we truncate it so the response
|
||||
- // options never get pushed out of view. This keeps the approval modal
|
||||
- // usable even when the overall bottom viewport is small.
|
||||
-
|
||||
- // Full height of the prompt (may be larger than the available area).
|
||||
- let full_prompt_height = self.get_confirmation_prompt_height(inner.width);
|
||||
-
|
||||
- // Minimum rows that must remain for the interactive section.
|
||||
- let min_response_rows = match self.mode {
|
||||
- Mode::Select => SELECT_OPTIONS.len() as u16,
|
||||
- // In input mode we need exactly two rows: one for the guidance
|
||||
- // prompt and one for the single-line input field.
|
||||
- Mode::Input => 2,
|
||||
- };
|
||||
-
|
||||
- // Clamp prompt height so confirmation + response never exceed the
|
||||
- // available space. `saturating_sub` avoids underflow when the area is
|
||||
- // too small even for the minimal layout – in this unlikely case we
|
||||
- // fall back to zero-height prompt so at least the options are
|
||||
- // visible.
|
||||
- let prompt_height = full_prompt_height.min(inner.height.saturating_sub(min_response_rows));
|
||||
-
|
||||
- let chunks = Layout::default()
|
||||
+ let prompt_height = self.get_confirmation_prompt_height(area.width);
|
||||
+ let [prompt_chunk, response_chunk] = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([Constraint::Length(prompt_height), Constraint::Min(0)])
|
||||
- .split(inner);
|
||||
- let prompt_chunk = chunks[0];
|
||||
- let response_chunk = chunks[1];
|
||||
-
|
||||
- // Build the inner lines based on the mode. Collect them into a List of
|
||||
- // non-wrapping lines rather than a Paragraph for predictable layout.
|
||||
- let lines = match self.mode {
|
||||
- Mode::Select => SELECT_OPTIONS
|
||||
- .iter()
|
||||
- .enumerate()
|
||||
- .map(|(idx, opt)| {
|
||||
- let (prefix, style) = if idx == self.selected_option {
|
||||
- ("▶", BLUE_FG)
|
||||
- } else {
|
||||
- (" ", PLAIN)
|
||||
- };
|
||||
- Line::styled(format!(" {prefix} {}", opt.label), style)
|
||||
- })
|
||||
- .collect(),
|
||||
- Mode::Input => {
|
||||
- vec![
|
||||
- Line::from("Give the model feedback on this command:"),
|
||||
- Line::from(self.input.value()),
|
||||
- ]
|
||||
- }
|
||||
+ .areas(area);
|
||||
+
|
||||
+ let lines: Vec<Line> = self
|
||||
+ .select_options
|
||||
+ .iter()
|
||||
+ .enumerate()
|
||||
+ .map(|(idx, opt)| {
|
||||
+ let style = if idx == self.selected_option {
|
||||
+ Style::new().bg(Color::Cyan).fg(Color::Black)
|
||||
+ } else {
|
||||
+ Style::new().bg(Color::DarkGray)
|
||||
+ };
|
||||
+ opt.label.clone().alignment(Alignment::Center).style(style)
|
||||
+ })
|
||||
+ .collect();
|
||||
+
|
||||
+ let [title_area, button_area, description_area] = Layout::vertical([
|
||||
+ Constraint::Length(1),
|
||||
+ Constraint::Length(1),
|
||||
+ Constraint::Min(0),
|
||||
+ ])
|
||||
+ .areas(response_chunk.inner(Margin::new(1, 0)));
|
||||
+ let title = match &self.approval_request {
|
||||
+ ApprovalRequest::Exec { .. } => "Allow command?",
|
||||
+ ApprovalRequest::ApplyPatch { .. } => "Apply changes?",
|
||||
};
|
||||
-
|
||||
- let border = ("◢◤")
|
||||
- .repeat((area.width / 2).into())
|
||||
- .fg(Color::LightYellow);
|
||||
-
|
||||
- border.render_ref(area, buf);
|
||||
- Paragraph::new(" Execution Request ".bold().black().on_light_yellow())
|
||||
- .alignment(Alignment::Center)
|
||||
- .render_ref(area, buf);
|
||||
+ Line::from(title).render(title_area, buf);
|
||||
|
||||
self.confirmation_prompt.clone().render(prompt_chunk, buf);
|
||||
- List::new(lines).render_ref(response_chunk, buf);
|
||||
+ let areas = Layout::horizontal(
|
||||
+ lines
|
||||
+ .iter()
|
||||
+ .map(|l| Constraint::Length(l.width() as u16 + 2)),
|
||||
+ )
|
||||
+ .spacing(1)
|
||||
+ .split(button_area);
|
||||
+ for (idx, area) in areas.iter().enumerate() {
|
||||
+ let line = &lines[idx];
|
||||
+ line.render(*area, buf);
|
||||
+ }
|
||||
|
||||
- border.render_ref(Rect::new(0, area.y + area.height - 1, area.width, 1), buf);
|
||||
+ Line::from(self.select_options[self.selected_option].description)
|
||||
+ .style(Style::new().italic().fg(Color::DarkGray))
|
||||
+ .render(description_area.inner(Margin::new(1, 0)), buf);
|
||||
+
|
||||
+ Block::bordered()
|
||||
+ .border_type(BorderType::QuadrantOutside)
|
||||
+ .border_style(Style::default().fg(Color::Cyan))
|
||||
+ .borders(Borders::LEFT)
|
||||
+ .render_ref(
|
||||
+ Rect::new(0, response_chunk.y, 1, response_chunk.height),
|
||||
+ buf,
|
||||
+ );
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Review Comments
|
||||
|
||||
### codex-rs/tui/src/chatwidget.rs
|
||||
|
||||
- Created: 2025-07-31 23:10:35 UTC | Link: https://github.com/openai/codex/pull/1768#discussion_r2246547801
|
||||
|
||||
```diff
|
||||
@@ -157,7 +158,9 @@ impl ChatWidget<'_> {
|
||||
}
|
||||
|
||||
pub(crate) fn handle_key_event(&mut self, key_event: KeyEvent) {
|
||||
- self.bottom_pane.clear_ctrl_c_quit_hint();
|
||||
+ if key_event.kind == KeyEventKind::Press {
|
||||
```
|
||||
|
||||
> Why are we special casing `Press` now?
|
||||
|
||||
### codex-rs/tui/src/user_approval_widget.rs
|
||||
|
||||
- Created: 2025-07-31 23:46:03 UTC | Link: https://github.com/openai/codex/pull/1768#discussion_r2246581651
|
||||
|
||||
```diff
|
||||
@@ -47,76 +47,72 @@ pub(crate) enum ApprovalRequest {
|
||||
|
||||
/// Options displayed in the *select* mode.
|
||||
struct SelectOption {
|
||||
- label: &'static str,
|
||||
- decision: Option<ReviewDecision>,
|
||||
- /// `true` when this option switches the widget to *input* mode.
|
||||
- enters_input_mode: bool,
|
||||
+ label: Line<'static>,
|
||||
+ description: &'static str,
|
||||
+ key: KeyCode,
|
||||
+ decision: ReviewDecision,
|
||||
}
|
||||
|
||||
// keep in same order as in the TS implementation
|
||||
-const SELECT_OPTIONS: &[SelectOption] = &[
|
||||
- SelectOption {
|
||||
- label: "Yes (y)",
|
||||
- decision: Some(ReviewDecision::Approved),
|
||||
-
|
||||
- enters_input_mode: false,
|
||||
- },
|
||||
- SelectOption {
|
||||
- label: "Yes, always approve this exact command for this session (a)",
|
||||
- decision: Some(ReviewDecision::ApprovedForSession),
|
||||
-
|
||||
- enters_input_mode: false,
|
||||
- },
|
||||
- SelectOption {
|
||||
- label: "Edit or give feedback (e)",
|
||||
- decision: None,
|
||||
-
|
||||
- enters_input_mode: true,
|
||||
- },
|
||||
- SelectOption {
|
||||
- label: "No, and keep going (n)",
|
||||
- decision: Some(ReviewDecision::Denied),
|
||||
-
|
||||
- enters_input_mode: false,
|
||||
- },
|
||||
- SelectOption {
|
||||
- label: "No, and stop for now (esc)",
|
||||
- decision: Some(ReviewDecision::Abort),
|
||||
-
|
||||
- enters_input_mode: false,
|
||||
- },
|
||||
-];
|
||||
-
|
||||
-/// Internal mode the widget is in – mirrors the TypeScript component.
|
||||
-#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
-enum Mode {
|
||||
- Select,
|
||||
- Input,
|
||||
-}
|
||||
+use std::sync::LazyLock;
|
||||
```
|
||||
|
||||
> `// keep in same order as in the TS implementation` can probably go?
|
||||
|
||||
- Created: 2025-07-31 23:46:37 UTC | Link: https://github.com/openai/codex/pull/1768#discussion_r2246582054
|
||||
|
||||
```diff
|
||||
@@ -47,76 +47,72 @@ pub(crate) enum ApprovalRequest {
|
||||
|
||||
/// Options displayed in the *select* mode.
|
||||
struct SelectOption {
|
||||
- label: &'static str,
|
||||
- decision: Option<ReviewDecision>,
|
||||
- /// `true` when this option switches the widget to *input* mode.
|
||||
- enters_input_mode: bool,
|
||||
+ label: Line<'static>,
|
||||
+ description: &'static str,
|
||||
+ key: KeyCode,
|
||||
+ decision: ReviewDecision,
|
||||
}
|
||||
|
||||
// keep in same order as in the TS implementation
|
||||
-const SELECT_OPTIONS: &[SelectOption] = &[
|
||||
- SelectOption {
|
||||
- label: "Yes (y)",
|
||||
- decision: Some(ReviewDecision::Approved),
|
||||
-
|
||||
- enters_input_mode: false,
|
||||
- },
|
||||
- SelectOption {
|
||||
- label: "Yes, always approve this exact command for this session (a)",
|
||||
- decision: Some(ReviewDecision::ApprovedForSession),
|
||||
-
|
||||
- enters_input_mode: false,
|
||||
- },
|
||||
- SelectOption {
|
||||
- label: "Edit or give feedback (e)",
|
||||
- decision: None,
|
||||
-
|
||||
- enters_input_mode: true,
|
||||
- },
|
||||
- SelectOption {
|
||||
- label: "No, and keep going (n)",
|
||||
- decision: Some(ReviewDecision::Denied),
|
||||
-
|
||||
- enters_input_mode: false,
|
||||
- },
|
||||
- SelectOption {
|
||||
- label: "No, and stop for now (esc)",
|
||||
- decision: Some(ReviewDecision::Abort),
|
||||
-
|
||||
- enters_input_mode: false,
|
||||
- },
|
||||
-];
|
||||
-
|
||||
-/// Internal mode the widget is in – mirrors the TypeScript component.
|
||||
-#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
-enum Mode {
|
||||
- Select,
|
||||
- Input,
|
||||
-}
|
||||
+use std::sync::LazyLock;
|
||||
+
|
||||
+static COMMAND_SELECT_OPTIONS: LazyLock<Vec<SelectOption>> = LazyLock::new(|| {
|
||||
+ vec![
|
||||
+ SelectOption {
|
||||
+ label: Line::from(vec!["Y".underlined(), "es".into()]),
|
||||
+ description: "Approve and run the command",
|
||||
+ key: KeyCode::Char('y'),
|
||||
+ decision: ReviewDecision::Approved,
|
||||
+ },
|
||||
+ SelectOption {
|
||||
+ label: Line::from(vec!["A".underlined(), "lways".into()]),
|
||||
+ description: "Approve the command for the remainder of this session",
|
||||
+ key: KeyCode::Char('a'),
|
||||
+ decision: ReviewDecision::ApprovedForSession,
|
||||
+ },
|
||||
+ SelectOption {
|
||||
+ label: Line::from(vec!["N".underlined(), "o".into()]),
|
||||
+ description: "Do not run the command",
|
||||
+ key: KeyCode::Char('n'),
|
||||
+ decision: ReviewDecision::Denied,
|
||||
+ },
|
||||
+ ]
|
||||
+});
|
||||
+
|
||||
+static PATCH_SELECT_OPTIONS: LazyLock<Vec<SelectOption>> = LazyLock::new(|| {
|
||||
+ vec![
|
||||
+ SelectOption {
|
||||
+ label: Line::from(vec!["Y".underlined(), "es".into()]),
|
||||
+ description: "Approve and apply the changes",
|
||||
+ key: KeyCode::Char('y'),
|
||||
+ decision: ReviewDecision::Approved,
|
||||
+ },
|
||||
+ SelectOption {
|
||||
+ label: Line::from(vec!["N".underlined(), "o".into()]),
|
||||
+ description: "Do not apply the changes",
|
||||
+ key: KeyCode::Char('n'),
|
||||
+ decision: ReviewDecision::Denied,
|
||||
+ },
|
||||
+ ]
|
||||
+});
|
||||
|
||||
/// A modal prompting the user to approve or deny the pending request.
|
||||
pub(crate) struct UserApprovalWidget<'a> {
|
||||
approval_request: ApprovalRequest,
|
||||
app_event_tx: AppEventSender,
|
||||
confirmation_prompt: Paragraph<'a>,
|
||||
+ select_options: &'a Vec<SelectOption>,
|
||||
```
|
||||
|
||||
> 👍
|
||||
|
||||
- Created: 2025-07-31 23:51:28 UTC | Link: https://github.com/openai/codex/pull/1768#discussion_r2246586346
|
||||
|
||||
```diff
|
||||
@@ -312,87 +267,68 @@ impl UserApprovalWidget<'_> {
|
||||
}
|
||||
|
||||
pub(crate) fn desired_height(&self, width: u16) -> u16 {
|
||||
- self.get_confirmation_prompt_height(width - 2) + SELECT_OPTIONS.len() as u16 + 2
|
||||
+ self.get_confirmation_prompt_height(width) + self.select_options.len() as u16
|
||||
}
|
||||
}
|
||||
|
||||
-const PLAIN: Style = Style::new();
|
||||
-const BLUE_FG: Style = Style::new().fg(Color::LightCyan);
|
||||
-
|
||||
impl WidgetRef for &UserApprovalWidget<'_> {
|
||||
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
|
||||
- // Take the area, wrap it in a block with a border, and divide up the
|
||||
- // remaining area into two chunks: one for the confirmation prompt and
|
||||
- // one for the response.
|
||||
- let inner = area.inner(Margin::new(0, 2));
|
||||
-
|
||||
- // Determine how many rows we can allocate for the static confirmation
|
||||
- // prompt while *always* keeping enough space for the interactive
|
||||
- // response area (select list or input field). When the full prompt
|
||||
- // would exceed the available height we truncate it so the response
|
||||
- // options never get pushed out of view. This keeps the approval modal
|
||||
- // usable even when the overall bottom viewport is small.
|
||||
-
|
||||
- // Full height of the prompt (may be larger than the available area).
|
||||
- let full_prompt_height = self.get_confirmation_prompt_height(inner.width);
|
||||
-
|
||||
- // Minimum rows that must remain for the interactive section.
|
||||
- let min_response_rows = match self.mode {
|
||||
- Mode::Select => SELECT_OPTIONS.len() as u16,
|
||||
- // In input mode we need exactly two rows: one for the guidance
|
||||
- // prompt and one for the single-line input field.
|
||||
- Mode::Input => 2,
|
||||
- };
|
||||
-
|
||||
- // Clamp prompt height so confirmation + response never exceed the
|
||||
- // available space. `saturating_sub` avoids underflow when the area is
|
||||
- // too small even for the minimal layout – in this unlikely case we
|
||||
- // fall back to zero-height prompt so at least the options are
|
||||
- // visible.
|
||||
- let prompt_height = full_prompt_height.min(inner.height.saturating_sub(min_response_rows));
|
||||
-
|
||||
- let chunks = Layout::default()
|
||||
+ let prompt_height = self.get_confirmation_prompt_height(area.width);
|
||||
+ let [prompt_chunk, response_chunk] = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([Constraint::Length(prompt_height), Constraint::Min(0)])
|
||||
- .split(inner);
|
||||
- let prompt_chunk = chunks[0];
|
||||
- let response_chunk = chunks[1];
|
||||
-
|
||||
- // Build the inner lines based on the mode. Collect them into a List of
|
||||
- // non-wrapping lines rather than a Paragraph for predictable layout.
|
||||
- let lines = match self.mode {
|
||||
- Mode::Select => SELECT_OPTIONS
|
||||
- .iter()
|
||||
- .enumerate()
|
||||
- .map(|(idx, opt)| {
|
||||
- let (prefix, style) = if idx == self.selected_option {
|
||||
- ("▶", BLUE_FG)
|
||||
- } else {
|
||||
- (" ", PLAIN)
|
||||
- };
|
||||
- Line::styled(format!(" {prefix} {}", opt.label), style)
|
||||
- })
|
||||
- .collect(),
|
||||
- Mode::Input => {
|
||||
- vec![
|
||||
- Line::from("Give the model feedback on this command:"),
|
||||
- Line::from(self.input.value()),
|
||||
- ]
|
||||
- }
|
||||
+ .areas(area);
|
||||
+
|
||||
+ let lines: Vec<Line> = self
|
||||
+ .select_options
|
||||
+ .iter()
|
||||
+ .enumerate()
|
||||
+ .map(|(idx, opt)| {
|
||||
+ let style = if idx == self.selected_option {
|
||||
+ Style::new().bg(Color::Cyan).fg(Color::Black)
|
||||
+ } else {
|
||||
+ Style::new().bg(Color::DarkGray)
|
||||
```
|
||||
|
||||
> This just made me think: have you been testing on both light and dark backgrounds?
|
||||
9402
prs/bolinfest/PR-1770.md
Normal file
9402
prs/bolinfest/PR-1770.md
Normal file
File diff suppressed because it is too large
Load Diff
162
prs/bolinfest/PR-1785.md
Normal file
162
prs/bolinfest/PR-1785.md
Normal file
@@ -0,0 +1,162 @@
|
||||
# PR #1785: chore: introduce SandboxPolicy::WorkspaceWrite::include_default_writable_roots
|
||||
|
||||
- URL: https://github.com/openai/codex/pull/1785
|
||||
- Author: bolinfest
|
||||
- Created: 2025-08-01 18:31:32 UTC
|
||||
- Updated: 2025-08-01 21:16:04 UTC
|
||||
- Changes: +27/-1, Files changed: 4, Commits: 1
|
||||
|
||||
## Description
|
||||
|
||||
Without this change, it is challenging to create integration tests to
|
||||
verify that the folders not included in `writable_roots` in
|
||||
`SandboxPolicy::WorkspaceWrite` are read-only because, by default,
|
||||
`get_writable_roots_with_cwd()` includes `TMPDIR`, which is where most integration
|
||||
tests do their work.
|
||||
|
||||
This introduces a `include_default_writable_roots` option to disable the default
|
||||
includes returned by `get_writable_roots_with_cwd()`.
|
||||
|
||||
---
|
||||
[//]: # (BEGIN SAPLING FOOTER)
|
||||
Stack created with [Sapling](https://sapling-scm.com). Best reviewed with [ReviewStack](https://reviewstack.dev/openai/codex/pull/1785).
|
||||
* #1765
|
||||
* __->__ #1785
|
||||
|
||||
## Full Diff
|
||||
|
||||
```diff
|
||||
diff --git a/codex-rs/common/src/sandbox_summary.rs b/codex-rs/common/src/sandbox_summary.rs
|
||||
index 3d33d92836..e0e309a9d9 100644
|
||||
--- a/codex-rs/common/src/sandbox_summary.rs
|
||||
+++ b/codex-rs/common/src/sandbox_summary.rs
|
||||
@@ -7,6 +7,7 @@ pub fn summarize_sandbox_policy(sandbox_policy: &SandboxPolicy) -> String {
|
||||
SandboxPolicy::WorkspaceWrite {
|
||||
writable_roots,
|
||||
network_access,
|
||||
+ include_default_writable_roots,
|
||||
} => {
|
||||
let mut summary = "workspace-write".to_string();
|
||||
if !writable_roots.is_empty() {
|
||||
@@ -19,6 +20,9 @@ pub fn summarize_sandbox_policy(sandbox_policy: &SandboxPolicy) -> String {
|
||||
.join(", ")
|
||||
));
|
||||
}
|
||||
+ if !*include_default_writable_roots {
|
||||
+ summary.push_str(" (exact writable roots)");
|
||||
+ }
|
||||
if *network_access {
|
||||
summary.push_str(" (network access enabled)");
|
||||
}
|
||||
diff --git a/codex-rs/core/src/config.rs b/codex-rs/core/src/config.rs
|
||||
index a65ec09674..20bdebe789 100644
|
||||
--- a/codex-rs/core/src/config.rs
|
||||
+++ b/codex-rs/core/src/config.rs
|
||||
@@ -350,6 +350,7 @@ impl ConfigToml {
|
||||
Some(s) => SandboxPolicy::WorkspaceWrite {
|
||||
writable_roots: s.writable_roots.clone(),
|
||||
network_access: s.network_access,
|
||||
+ include_default_writable_roots: true,
|
||||
},
|
||||
None => SandboxPolicy::new_workspace_write_policy(),
|
||||
},
|
||||
@@ -720,6 +721,7 @@ writable_roots = [
|
||||
SandboxPolicy::WorkspaceWrite {
|
||||
writable_roots: vec![PathBuf::from("/tmp")],
|
||||
network_access: false,
|
||||
+ include_default_writable_roots: true,
|
||||
},
|
||||
sandbox_workspace_write_cfg.derive_sandbox_policy(sandbox_mode_override)
|
||||
);
|
||||
diff --git a/codex-rs/core/src/protocol.rs b/codex-rs/core/src/protocol.rs
|
||||
index bc922eb0e2..e333373c7b 100644
|
||||
--- a/codex-rs/core/src/protocol.rs
|
||||
+++ b/codex-rs/core/src/protocol.rs
|
||||
@@ -175,9 +175,19 @@ pub enum SandboxPolicy {
|
||||
/// default.
|
||||
#[serde(default)]
|
||||
network_access: bool,
|
||||
+
|
||||
+ /// When set to `true`, will include defaults like the current working
|
||||
+ /// directory and TMPDIR (on macOS). When `false`, only `writable_roots`
|
||||
+ /// are used. (Mainly used for testing.)
|
||||
+ #[serde(default = "default_true")]
|
||||
+ include_default_writable_roots: bool,
|
||||
},
|
||||
}
|
||||
|
||||
+fn default_true() -> bool {
|
||||
+ true
|
||||
+}
|
||||
+
|
||||
impl FromStr for SandboxPolicy {
|
||||
type Err = serde_json::Error;
|
||||
|
||||
@@ -199,6 +209,7 @@ impl SandboxPolicy {
|
||||
SandboxPolicy::WorkspaceWrite {
|
||||
writable_roots: vec![],
|
||||
network_access: false,
|
||||
+ include_default_writable_roots: true,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -230,7 +241,15 @@ impl SandboxPolicy {
|
||||
match self {
|
||||
SandboxPolicy::DangerFullAccess => Vec::new(),
|
||||
SandboxPolicy::ReadOnly => Vec::new(),
|
||||
- SandboxPolicy::WorkspaceWrite { writable_roots, .. } => {
|
||||
+ SandboxPolicy::WorkspaceWrite {
|
||||
+ writable_roots,
|
||||
+ include_default_writable_roots,
|
||||
+ ..
|
||||
+ } => {
|
||||
+ if !*include_default_writable_roots {
|
||||
+ return writable_roots.clone();
|
||||
+ }
|
||||
+
|
||||
let mut roots = writable_roots.clone();
|
||||
roots.push(cwd.to_path_buf());
|
||||
|
||||
diff --git a/codex-rs/linux-sandbox/tests/landlock.rs b/codex-rs/linux-sandbox/tests/landlock.rs
|
||||
index 7eacda46c1..472a54e604 100644
|
||||
--- a/codex-rs/linux-sandbox/tests/landlock.rs
|
||||
+++ b/codex-rs/linux-sandbox/tests/landlock.rs
|
||||
@@ -49,6 +49,7 @@ async fn run_cmd(cmd: &[&str], writable_roots: &[PathBuf], timeout_ms: u64) {
|
||||
let sandbox_policy = SandboxPolicy::WorkspaceWrite {
|
||||
writable_roots: writable_roots.to_vec(),
|
||||
network_access: false,
|
||||
+ include_default_writable_roots: true,
|
||||
};
|
||||
let sandbox_program = env!("CARGO_BIN_EXE_codex-linux-sandbox");
|
||||
let codex_linux_sandbox_exe = Some(PathBuf::from(sandbox_program));
|
||||
```
|
||||
|
||||
## Review Comments
|
||||
|
||||
### codex-rs/core/src/protocol.rs
|
||||
|
||||
- Created: 2025-08-01 20:48:47 UTC | Link: https://github.com/openai/codex/pull/1785#discussion_r2248846220
|
||||
|
||||
```diff
|
||||
@@ -199,6 +203,7 @@ impl SandboxPolicy {
|
||||
SandboxPolicy::WorkspaceWrite {
|
||||
writable_roots: vec![],
|
||||
network_access: false,
|
||||
+ use_exact_writable_roots: false,
|
||||
```
|
||||
|
||||
> I usually try to name things so that it makes sense for the default value to be `false`, but I agree it reads in a strange way in this case, so I'll flip it.
|
||||
>
|
||||
> I think an appropriate name would be `include_default_writable_roots` because it affects both `TMPDIR` and `cwd`.
|
||||
|
||||
- Created: 2025-08-01 21:09:45 UTC | Link: https://github.com/openai/codex/pull/1785#discussion_r2248873827
|
||||
|
||||
```diff
|
||||
@@ -199,6 +203,7 @@ impl SandboxPolicy {
|
||||
SandboxPolicy::WorkspaceWrite {
|
||||
writable_roots: vec![],
|
||||
network_access: false,
|
||||
+ use_exact_writable_roots: false,
|
||||
```
|
||||
|
||||
> Flipped, though I had to add `#[serde(default = "default_true")]`.
|
||||
6014
prs/bolinfest/PR-1792.md
Normal file
6014
prs/bolinfest/PR-1792.md
Normal file
File diff suppressed because it is too large
Load Diff
427
prs/bolinfest/PR-1793.md
Normal file
427
prs/bolinfest/PR-1793.md
Normal file
@@ -0,0 +1,427 @@
|
||||
# PR #1793: Require config to turn on planning tool
|
||||
|
||||
- URL: https://github.com/openai/codex/pull/1793
|
||||
- Author: easong-openai
|
||||
- Created: 2025-08-02 01:46:59 UTC
|
||||
- Updated: 2025-08-06 16:55:22 UTC
|
||||
- Changes: +181/-13, Files changed: 8, Commits: 5
|
||||
|
||||
## Description
|
||||
|
||||
Codex-mini gets extremely distracted by the planning tool.
|
||||
|
||||
## Full Diff
|
||||
|
||||
```diff
|
||||
diff --git a/codex-rs/config.md b/codex-rs/config.md
|
||||
index c7dfe42a75..c4e9cc7d0a 100644
|
||||
--- a/codex-rs/config.md
|
||||
+++ b/codex-rs/config.md
|
||||
@@ -243,6 +243,21 @@ By default, `reasoning` is only set on requests to OpenAI models that are known
|
||||
model_supports_reasoning_summaries = true
|
||||
```
|
||||
|
||||
+## experimental_include_plan_tool
|
||||
+
|
||||
+Controls whether to expose the experimental plan tool (named `update_plan`) to the model and include the corresponding guidance in the system prompt.
|
||||
+
|
||||
+Default behavior:
|
||||
+- For known models (anything hardcoded in the models list), this is disabled by default.
|
||||
+- For unknown models whose name starts with "gpt-", this is enabled by default so new GPT-family models get the feature without a CLI update.
|
||||
+
|
||||
+When enabled, the model can call `update_plan` to keep an up-to-date, step-by-step plan for the task and Codex will render plan updates in the UI. When disabled, the tool is not advertised to the model and the “Plan updates” section is omitted from the prompt; any unsolicited `update_plan` calls will be treated as unsupported.
|
||||
+
|
||||
+```toml
|
||||
+# Enable the experimental plan tool and prompt instructions
|
||||
+experimental_include_plan_tool = true
|
||||
+```
|
||||
+
|
||||
## sandbox_mode
|
||||
|
||||
Codex executes model-generated shell commands inside an OS-level sandbox.
|
||||
diff --git a/codex-rs/core/prompt.md b/codex-rs/core/prompt.md
|
||||
index 0a4578270a..8bbe618589 100644
|
||||
--- a/codex-rs/core/prompt.md
|
||||
+++ b/codex-rs/core/prompt.md
|
||||
@@ -97,6 +97,7 @@ You can invoke apply_patch like:
|
||||
shell {"command":["apply_patch","*** Begin Patch\n*** Add File: hello.txt\n+Hello, world!\n*** End Patch\n"]}
|
||||
```
|
||||
|
||||
+<!-- PLAN_TOOL:START -->
|
||||
Plan updates
|
||||
|
||||
A tool named `update_plan` is available. Use it to keep an up‑to‑date, step‑by‑step plan for the task so you can follow your progress. When making your plans, keep in mind that you are a deployed coding agent - `update_plan` calls should not involve doing anything that you aren't capable of doing. For example, `update_plan` calls should NEVER contain tasks to merge your own pull requests. Only stop to ask the user if you genuinely need their feedback on a change.
|
||||
@@ -105,3 +106,4 @@ A tool named `update_plan` is available. Use it to keep an up‑to‑date, step
|
||||
- Whenever you finish a step, call `update_plan` again, marking the finished step as `completed` and the next step as `in_progress`.
|
||||
- If your plan needs to change, call `update_plan` with the revised steps and include an `explanation` describing the change.
|
||||
- When all steps are complete, make a final `update_plan` call with all steps marked `completed`.
|
||||
+<!-- PLAN_TOOL:END -->
|
||||
diff --git a/codex-rs/core/src/chat_completions.rs b/codex-rs/core/src/chat_completions.rs
|
||||
index 5ede774b1c..ddbc392339 100644
|
||||
--- a/codex-rs/core/src/chat_completions.rs
|
||||
+++ b/codex-rs/core/src/chat_completions.rs
|
||||
@@ -37,7 +37,8 @@ pub(crate) async fn stream_chat_completions(
|
||||
// Build messages array
|
||||
let mut messages = Vec::<serde_json::Value>::new();
|
||||
|
||||
- let full_instructions = prompt.get_full_instructions(model);
|
||||
+ let instr_cfg = crate::client_common::InstructionsConfig::for_model(model, include_plan_tool);
|
||||
+ let full_instructions = prompt.get_full_instructions(&instr_cfg);
|
||||
messages.push(json!({"role": "system", "content": full_instructions}));
|
||||
|
||||
if let Some(instr) = &prompt.user_instructions {
|
||||
diff --git a/codex-rs/core/src/client.rs b/codex-rs/core/src/client.rs
|
||||
index b9ea6b13f4..462f5072dd 100644
|
||||
--- a/codex-rs/core/src/client.rs
|
||||
+++ b/codex-rs/core/src/client.rs
|
||||
@@ -141,7 +141,11 @@ impl ModelClient {
|
||||
|
||||
let token = auth.get_token().await?;
|
||||
|
||||
- let full_instructions = prompt.get_full_instructions(&self.config.model);
|
||||
+ let instr_cfg = crate::client_common::InstructionsConfig::for_model(
|
||||
+ &self.config.model,
|
||||
+ self.config.include_plan_tool,
|
||||
+ );
|
||||
+ let full_instructions = prompt.get_full_instructions(&instr_cfg);
|
||||
let tools_json = create_tools_json_for_responses_api(
|
||||
prompt,
|
||||
&self.config.model,
|
||||
diff --git a/codex-rs/core/src/client_common.rs b/codex-rs/core/src/client_common.rs
|
||||
index 157f35872a..278055dead 100644
|
||||
--- a/codex-rs/core/src/client_common.rs
|
||||
+++ b/codex-rs/core/src/client_common.rs
|
||||
@@ -37,15 +37,60 @@ pub struct Prompt {
|
||||
pub base_instructions_override: Option<String>,
|
||||
}
|
||||
|
||||
+/// Options that influence how the full instructions are composed for a request.
|
||||
+#[derive(Debug, Default, Clone)]
|
||||
+pub struct InstructionsConfig {
|
||||
+ pub include_plan_tool: bool,
|
||||
+ pub extra_sections: Vec<&'static str>,
|
||||
+}
|
||||
+
|
||||
+impl InstructionsConfig {
|
||||
+ pub fn for_model(model: &str, include_plan_tool: bool) -> Self {
|
||||
+ let mut extra_sections = Vec::new();
|
||||
+ if model.starts_with("gpt-4.1") {
|
||||
+ extra_sections.push(APPLY_PATCH_TOOL_INSTRUCTIONS);
|
||||
+ }
|
||||
+ Self {
|
||||
+ include_plan_tool,
|
||||
+ extra_sections,
|
||||
+ }
|
||||
+ }
|
||||
+}
|
||||
+
|
||||
impl Prompt {
|
||||
- pub(crate) fn get_full_instructions(&self, model: &str) -> Cow<'_, str> {
|
||||
- let base = self
|
||||
+ pub(crate) fn get_full_instructions(&self, cfg: &InstructionsConfig) -> Cow<'_, str> {
|
||||
+ let mut base = self
|
||||
.base_instructions_override
|
||||
.as_deref()
|
||||
- .unwrap_or(BASE_INSTRUCTIONS);
|
||||
- let mut sections: Vec<&str> = vec![base];
|
||||
- if model.starts_with("gpt-4.1") {
|
||||
- sections.push(APPLY_PATCH_TOOL_INSTRUCTIONS);
|
||||
+ .unwrap_or(BASE_INSTRUCTIONS)
|
||||
+ .to_string();
|
||||
+
|
||||
+ if !cfg.include_plan_tool {
|
||||
+ // Remove the plan-tool section if present. Prefer explicit markers
|
||||
+ // for robustness, but fall back to trimming from the "Plan updates"
|
||||
+ // heading if markers are missing.
|
||||
+ let start_marker = "<!-- PLAN_TOOL:START -->";
|
||||
+ let end_marker = "<!-- PLAN_TOOL:END -->";
|
||||
+ if let (Some(start), Some(end)) = (base.find(start_marker), base.find(end_marker)) {
|
||||
+ if end > start {
|
||||
+ let mut edited = String::with_capacity(base.len());
|
||||
+ edited.push_str(&base[..start]);
|
||||
+ edited.push_str(&base[end + end_marker.len()..]);
|
||||
+ base = edited;
|
||||
+ }
|
||||
+ } else if let Some(idx) = base
|
||||
+ .find("\n\nPlan updates")
|
||||
+ .or_else(|| base.find("\nPlan updates"))
|
||||
+ .or_else(|| base.find("Plan updates"))
|
||||
+ {
|
||||
+ base.truncate(idx);
|
||||
+ }
|
||||
+ base = base.trim_end().to_string();
|
||||
+ }
|
||||
+
|
||||
+ let mut sections: Vec<&str> = vec![&base];
|
||||
+ for s in &cfg.extra_sections {
|
||||
+ sections.push(s);
|
||||
}
|
||||
Cow::Owned(sections.join("\n"))
|
||||
}
|
||||
@@ -197,7 +242,18 @@ mod tests {
|
||||
..Default::default()
|
||||
};
|
||||
let expected = format!("{BASE_INSTRUCTIONS}\n{APPLY_PATCH_TOOL_INSTRUCTIONS}");
|
||||
- let full = prompt.get_full_instructions("gpt-4.1");
|
||||
+ let cfg = InstructionsConfig::for_model("gpt-4.1", true);
|
||||
+ let full = prompt.get_full_instructions(&cfg);
|
||||
assert_eq!(full, expected);
|
||||
}
|
||||
+
|
||||
+ #[test]
|
||||
+ fn plan_section_removed_when_disabled() {
|
||||
+ let prompt = Prompt::default();
|
||||
+ let cfg = InstructionsConfig::for_model("gpt-4.1", false);
|
||||
+ let full = prompt.get_full_instructions(&cfg);
|
||||
+ assert!(!full.contains("Plan updates"));
|
||||
+ assert!(!full.contains("update_plan"));
|
||||
+ assert!(full.contains(APPLY_PATCH_TOOL_INSTRUCTIONS));
|
||||
+ }
|
||||
}
|
||||
diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs
|
||||
index 6e21642da1..a3939da60f 100644
|
||||
--- a/codex-rs/core/src/codex.rs
|
||||
+++ b/codex-rs/core/src/codex.rs
|
||||
@@ -225,6 +225,9 @@ pub(crate) struct Session {
|
||||
state: Mutex<State>,
|
||||
codex_linux_sandbox_exe: Option<PathBuf>,
|
||||
user_shell: shell::Shell,
|
||||
+
|
||||
+ /// Whether the experimental plan tool is enabled for this session.
|
||||
+ include_plan_tool: bool,
|
||||
}
|
||||
|
||||
impl Session {
|
||||
@@ -791,6 +794,7 @@ async fn submission_loop(
|
||||
codex_linux_sandbox_exe: config.codex_linux_sandbox_exe.clone(),
|
||||
disable_response_storage,
|
||||
user_shell: default_shell,
|
||||
+ include_plan_tool: config.include_plan_tool,
|
||||
}));
|
||||
|
||||
// Patch restored state into the newly created session.
|
||||
@@ -1531,7 +1535,19 @@ async fn handle_function_call(
|
||||
};
|
||||
handle_container_exec_with_params(params, sess, sub_id, call_id).await
|
||||
}
|
||||
- "update_plan" => handle_update_plan(sess, arguments, sub_id, call_id).await,
|
||||
+ "update_plan" => {
|
||||
+ if sess.include_plan_tool {
|
||||
+ handle_update_plan(sess, arguments, sub_id, call_id).await
|
||||
+ } else {
|
||||
+ ResponseInputItem::FunctionCallOutput {
|
||||
+ call_id,
|
||||
+ output: FunctionCallOutputPayload {
|
||||
+ content: format!("unsupported call: {name}"),
|
||||
+ success: None,
|
||||
+ },
|
||||
+ }
|
||||
+ }
|
||||
+ }
|
||||
_ => {
|
||||
match sess.mcp_connection_manager.parse_tool_name(&name) {
|
||||
Some((server, tool_name)) => {
|
||||
diff --git a/codex-rs/core/src/config.rs b/codex-rs/core/src/config.rs
|
||||
index 87629d821a..f7f159e5bb 100644
|
||||
--- a/codex-rs/core/src/config.rs
|
||||
+++ b/codex-rs/core/src/config.rs
|
||||
@@ -342,6 +342,10 @@ pub struct ConfigToml {
|
||||
|
||||
/// The value for the `originator` header included with Responses API requests.
|
||||
pub internal_originator: Option<String>,
|
||||
+
|
||||
+ /// Include an experimental plan tool that the model can use to update its current plan and status of each step.
|
||||
+ /// This is experimental and may be removed in the future.
|
||||
+ pub experimental_include_plan_tool: Option<bool>,
|
||||
}
|
||||
|
||||
impl ConfigToml {
|
||||
@@ -476,6 +480,7 @@ impl Config {
|
||||
});
|
||||
|
||||
let experimental_resume = cfg.experimental_resume;
|
||||
+ let is_unknown_gpt = openai_model_info.is_none() && model.starts_with("gpt-");
|
||||
|
||||
// Load base instructions override from a file if specified. If the
|
||||
// path is relative, resolve it against the effective cwd so the
|
||||
@@ -527,7 +532,7 @@ impl Config {
|
||||
|
||||
model_supports_reasoning_summaries: cfg
|
||||
.model_supports_reasoning_summaries
|
||||
- .unwrap_or(false),
|
||||
+ .unwrap_or(is_unknown_gpt),
|
||||
|
||||
chatgpt_base_url: config_profile
|
||||
.chatgpt_base_url
|
||||
@@ -535,7 +540,9 @@ impl Config {
|
||||
.unwrap_or("https://chatgpt.com/backend-api/".to_string()),
|
||||
|
||||
experimental_resume,
|
||||
- include_plan_tool: include_plan_tool.unwrap_or(false),
|
||||
+ include_plan_tool: include_plan_tool
|
||||
+ .or(cfg.experimental_include_plan_tool)
|
||||
+ .unwrap_or(is_unknown_gpt),
|
||||
internal_originator: cfg.internal_originator,
|
||||
};
|
||||
Ok(config)
|
||||
@@ -649,6 +656,73 @@ mod tests {
|
||||
use pretty_assertions::assert_eq;
|
||||
use tempfile::TempDir;
|
||||
|
||||
+ #[test]
|
||||
+ fn test_plan_and_reasoning_defaults_known_vs_unknown() -> std::io::Result<()> {
|
||||
+ let fixture = create_test_fixture()?;
|
||||
+
|
||||
+ // Unknown GPT-like model -> defaults ON for plan tool and reasoning summaries override.
|
||||
+ let unknown_gpt_overrides = ConfigOverrides {
|
||||
+ model: Some("gpt-unknown-2025".to_string()),
|
||||
+ cwd: Some(fixture.cwd()),
|
||||
+ ..Default::default()
|
||||
+ };
|
||||
+ let unknown_gpt_cfg = Config::load_from_base_config_with_overrides(
|
||||
+ fixture.cfg.clone(),
|
||||
+ unknown_gpt_overrides,
|
||||
+ fixture.codex_home(),
|
||||
+ )?;
|
||||
+ assert!(
|
||||
+ unknown_gpt_cfg.include_plan_tool,
|
||||
+ "plan tool should default to ON for unknown GPT-like models"
|
||||
+ );
|
||||
+ assert!(
|
||||
+ unknown_gpt_cfg.model_supports_reasoning_summaries,
|
||||
+ "reasoning summaries should default to ON for unknown GPT-like models"
|
||||
+ );
|
||||
+
|
||||
+ // Unknown non-GPT model -> defaults OFF for both.
|
||||
+ let unknown_non_gpt_overrides = ConfigOverrides {
|
||||
+ model: Some("my-new-model".to_string()),
|
||||
+ cwd: Some(fixture.cwd()),
|
||||
+ ..Default::default()
|
||||
+ };
|
||||
+ let unknown_non_gpt_cfg = Config::load_from_base_config_with_overrides(
|
||||
+ fixture.cfg.clone(),
|
||||
+ unknown_non_gpt_overrides,
|
||||
+ fixture.codex_home(),
|
||||
+ )?;
|
||||
+ assert!(
|
||||
+ !unknown_non_gpt_cfg.include_plan_tool,
|
||||
+ "plan tool should default to OFF for unknown non-GPT models"
|
||||
+ );
|
||||
+ assert!(
|
||||
+ !unknown_non_gpt_cfg.model_supports_reasoning_summaries,
|
||||
+ "reasoning summaries should default to OFF for unknown non-GPT models"
|
||||
+ );
|
||||
+
|
||||
+ // Known model -> defaults OFF for plan tool and reasoning summaries override value.
|
||||
+ let known_overrides = ConfigOverrides {
|
||||
+ model: Some("gpt-3.5-turbo".to_string()),
|
||||
+ cwd: Some(fixture.cwd()),
|
||||
+ ..Default::default()
|
||||
+ };
|
||||
+ let known_cfg = Config::load_from_base_config_with_overrides(
|
||||
+ fixture.cfg.clone(),
|
||||
+ known_overrides,
|
||||
+ fixture.codex_home(),
|
||||
+ )?;
|
||||
+ assert!(
|
||||
+ !known_cfg.include_plan_tool,
|
||||
+ "plan tool should default to OFF for known models"
|
||||
+ );
|
||||
+ assert!(
|
||||
+ !known_cfg.model_supports_reasoning_summaries,
|
||||
+ "reasoning summaries override should default to OFF for known models"
|
||||
+ );
|
||||
+
|
||||
+ Ok(())
|
||||
+ }
|
||||
+
|
||||
#[test]
|
||||
fn test_toml_parsing() {
|
||||
let history_with_persistence = r#"
|
||||
diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs
|
||||
index 0ec9be6153..d1c9f96695 100644
|
||||
--- a/codex-rs/tui/src/lib.rs
|
||||
+++ b/codex-rs/tui/src/lib.rs
|
||||
@@ -80,7 +80,7 @@ pub async fn run_main(
|
||||
config_profile: cli.config_profile.clone(),
|
||||
codex_linux_sandbox_exe,
|
||||
base_instructions: None,
|
||||
- include_plan_tool: Some(true),
|
||||
+ include_plan_tool: None,
|
||||
};
|
||||
// Parse `-c` overrides from the CLI.
|
||||
let cli_kv_overrides = match cli.config_overrides.parse_overrides() {
|
||||
```
|
||||
|
||||
## Review Comments
|
||||
|
||||
### codex-rs/config.md
|
||||
|
||||
- Created: 2025-08-02 01:51:11 UTC | Link: https://github.com/openai/codex/pull/1793#discussion_r2249062359
|
||||
|
||||
```diff
|
||||
@@ -243,6 +243,17 @@ By default, `reasoning` is only set on requests to OpenAI models that are known
|
||||
model_supports_reasoning_summaries = true
|
||||
```
|
||||
|
||||
+## include_plan_tool
|
||||
+
|
||||
+Controls whether to expose the experimental plan tool (named `update_plan`) to the model and include the corresponding guidance in the system prompt.
|
||||
+
|
||||
+When enabled, the model can call `update_plan` to keep an up-to-date, step-by-step plan for the task and Codex will render plan updates in the UI. When disabled, the tool is not advertised to the model and the “Plan updates” section is omitted from the prompt; any unsolicited `update_plan` calls will be treated as unsupported.
|
||||
+
|
||||
+```toml
|
||||
+# Enable the experimental plan tool and prompt instructions
|
||||
+include_plan_tool = true
|
||||
```
|
||||
|
||||
> Can we prefix this with `experimental_` since I wouldn't be surprised if this goes away? Note we have `experimental_resume`.
|
||||
|
||||
### codex-rs/core/prompt.md
|
||||
|
||||
- Created: 2025-08-02 01:56:25 UTC | Link: https://github.com/openai/codex/pull/1793#discussion_r2249072057
|
||||
|
||||
```diff
|
||||
@@ -97,6 +97,7 @@ You can invoke apply_patch like:
|
||||
shell {"command":["apply_patch","*** Begin Patch\n*** Add File: hello.txt\n+Hello, world!\n*** End Patch\n"]}
|
||||
```
|
||||
|
||||
+<!-- PLAN_TOOL:START -->
|
||||
```
|
||||
|
||||
> We could also make it additive instead of subtractive if you want only one marker?
|
||||
|
||||
### codex-rs/core/src/client_common.rs
|
||||
|
||||
- Created: 2025-08-02 01:53:12 UTC | Link: https://github.com/openai/codex/pull/1793#discussion_r2249062876
|
||||
|
||||
```diff
|
||||
@@ -38,12 +38,41 @@ pub struct Prompt {
|
||||
}
|
||||
|
||||
impl Prompt {
|
||||
- pub(crate) fn get_full_instructions(&self, model: &str) -> Cow<'_, str> {
|
||||
- let base = self
|
||||
+ pub(crate) fn get_full_instructions(
|
||||
```
|
||||
|
||||
> Can you please update `mod tests {` in this file to verify this change?
|
||||
|
||||
- Created: 2025-08-02 01:55:53 UTC | Link: https://github.com/openai/codex/pull/1793#discussion_r2249070006
|
||||
|
||||
```diff
|
||||
@@ -38,12 +38,41 @@ pub struct Prompt {
|
||||
}
|
||||
|
||||
impl Prompt {
|
||||
- pub(crate) fn get_full_instructions(&self, model: &str) -> Cow<'_, str> {
|
||||
- let base = self
|
||||
+ pub(crate) fn get_full_instructions(
|
||||
+ &self,
|
||||
+ model: &str,
|
||||
+ include_plan_tool: bool,
|
||||
```
|
||||
|
||||
> Can we introduce `struct InstructionsConfig { include_plan_tool: bool }` and pass that in so it's more self-documenting?
|
||||
>
|
||||
> We could probably eliminate `model` as a param and make `extra_apply_patch_tool_instructions` or something a field of `InstructionsConfig` and then do `InstructionsConfig::new(model: &str)` so it sets `extra_apply_patch_tool_instructions` based on `model`?
|
||||
4441
prs/bolinfest/PR-1810.md
Normal file
4441
prs/bolinfest/PR-1810.md
Normal file
File diff suppressed because it is too large
Load Diff
111
prs/bolinfest/PR-1823.md
Normal file
111
prs/bolinfest/PR-1823.md
Normal file
@@ -0,0 +1,111 @@
|
||||
# PR #1823: test(core): add seatbelt sem lock tests
|
||||
|
||||
- URL: https://github.com/openai/codex/pull/1823
|
||||
- Author: davidhao3300
|
||||
- Created: 2025-08-04 18:14:23 UTC
|
||||
- Updated: 2025-08-14 22:16:59 UTC
|
||||
- Changes: +53/-0, Files changed: 1, Commits: 2
|
||||
|
||||
## Description
|
||||
|
||||
## Summary
|
||||
- add a unit test to ensure the macOS seatbelt policy allows POSIX semaphores
|
||||
- add a macOS-only test that runs a Python multiprocessing Lock under Seatbelt
|
||||
|
||||
## Testing
|
||||
- `cargo test -p codex_core seatbelt_base_policy_allows_ipc_posix_sem --no-fail-fast` (failed: failed to download from `https://static.crates.io/crates/tokio-stream/0.1.17/download`)
|
||||
- `cargo test -p codex_core seatbelt_base_policy_allows_ipc_posix_sem --no-fail-fast --offline` (failed: attempting to make an HTTP request, but --offline was specified)
|
||||
- `cargo test --all-features --no-fail-fast --offline` (failed: attempting to make an HTTP request, but --offline was specified)
|
||||
- `just fmt` (failed: command not found: just)
|
||||
- `just fix` (failed: command not found: just)
|
||||
|
||||
Ran tests locally to confirm it passes on master and failed before my previous change
|
||||
|
||||
------
|
||||
https://chatgpt.com/codex/tasks/task_i_6890f221e0a4833381cfb53e11499bcc
|
||||
|
||||
## Full Diff
|
||||
|
||||
```diff
|
||||
diff --git a/codex-rs/core/src/seatbelt.rs b/codex-rs/core/src/seatbelt.rs
|
||||
index 0364840b1a..045395f809 100644
|
||||
--- a/codex-rs/core/src/seatbelt.rs
|
||||
+++ b/codex-rs/core/src/seatbelt.rs
|
||||
@@ -280,6 +280,59 @@ mod tests {
|
||||
assert_eq!(args, expected_args);
|
||||
}
|
||||
|
||||
+ #[test]
|
||||
+ fn seatbelt_base_policy_allows_ipc_posix_sem() {
|
||||
+ assert!(
|
||||
+ MACOS_SEATBELT_BASE_POLICY.contains("(allow ipc-posix-sem)"),
|
||||
+ "base policy should allow ipc-posix-sem"
|
||||
+ );
|
||||
+ }
|
||||
+
|
||||
+ #[cfg(target_os = "macos")]
|
||||
+ #[tokio::test]
|
||||
+ async fn python_multiprocessing_lock_works_under_seatbelt() {
|
||||
+ use super::spawn_command_under_seatbelt;
|
||||
+ use crate::spawn::StdioPolicy;
|
||||
+ use std::collections::HashMap;
|
||||
+
|
||||
+ let policy = SandboxPolicy::WorkspaceWrite {
|
||||
+ writable_roots: vec![],
|
||||
+ network_access: false,
|
||||
+ include_default_writable_roots: true,
|
||||
+ };
|
||||
+
|
||||
+ let python_code = r#"import multiprocessing
|
||||
+from multiprocessing import Lock, Process
|
||||
+
|
||||
+def f(lock):
|
||||
+ with lock:
|
||||
+ print("Lock acquired in child process")
|
||||
+
|
||||
+if __name__ == '__main__':
|
||||
+ lock = Lock()
|
||||
+ p = Process(target=f, args=(lock,))
|
||||
+ p.start()
|
||||
+ p.join()
|
||||
+"#;
|
||||
+
|
||||
+ let mut child = spawn_command_under_seatbelt(
|
||||
+ vec![
|
||||
+ "python3".to_string(),
|
||||
+ "-c".to_string(),
|
||||
+ python_code.to_string(),
|
||||
+ ],
|
||||
+ &policy,
|
||||
+ std::env::current_dir().expect("should be able to get current dir"),
|
||||
+ StdioPolicy::RedirectForShellTool,
|
||||
+ HashMap::new(),
|
||||
+ )
|
||||
+ .await
|
||||
+ .expect("should be able to spawn python under seatbelt");
|
||||
+
|
||||
+ let status = child.wait().await.expect("should wait for child process");
|
||||
+ assert!(status.success(), "python exited with {status:?}");
|
||||
+ }
|
||||
+
|
||||
struct PopulatedTmp {
|
||||
root_with_git: PathBuf,
|
||||
root_without_git: PathBuf,
|
||||
```
|
||||
|
||||
## Review Comments
|
||||
|
||||
### codex-rs/core/src/seatbelt.rs
|
||||
|
||||
- Created: 2025-08-05 00:31:28 UTC | Link: https://github.com/openai/codex/pull/1823#discussion_r2252843653
|
||||
|
||||
```diff
|
||||
@@ -280,6 +280,59 @@ mod tests {
|
||||
assert_eq!(args, expected_args);
|
||||
}
|
||||
|
||||
+ #[test]
|
||||
+ fn seatbelt_base_policy_allows_ipc_posix_sem() {
|
||||
```
|
||||
|
||||
> This test can be removed.
|
||||
513
prs/bolinfest/PR-1824.md
Normal file
513
prs/bolinfest/PR-1824.md
Normal file
@@ -0,0 +1,513 @@
|
||||
# PR #1824: show a transient history cell for commands
|
||||
|
||||
- URL: https://github.com/openai/codex/pull/1824
|
||||
- Author: nornagon-openai
|
||||
- Created: 2025-08-04 18:28:02 UTC
|
||||
- Updated: 2025-08-06 19:03:54 UTC
|
||||
- Changes: +119/-71, Files changed: 2, Commits: 8
|
||||
|
||||
## Description
|
||||
|
||||
Adds a new "active history cell" for history bits that need to render more than once before they're inserted into the history. Only used for commands right now.
|
||||
|
||||
https://github.com/user-attachments/assets/925f01a0-e56d-4613-bc25-fdaa85d8aea5
|
||||
|
||||
## Full Diff
|
||||
|
||||
```diff
|
||||
diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs
|
||||
index 6d03be783b..d7120e80ff 100644
|
||||
--- a/codex-rs/tui/src/chatwidget.rs
|
||||
+++ b/codex-rs/tui/src/chatwidget.rs
|
||||
@@ -30,6 +30,8 @@ use codex_core::protocol::TurnDiffEvent;
|
||||
use crossterm::event::KeyEvent;
|
||||
use crossterm::event::KeyEventKind;
|
||||
use ratatui::buffer::Buffer;
|
||||
+use ratatui::layout::Constraint;
|
||||
+use ratatui::layout::Layout;
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::widgets::Widget;
|
||||
use ratatui::widgets::WidgetRef;
|
||||
@@ -62,6 +64,7 @@ pub(crate) struct ChatWidget<'a> {
|
||||
app_event_tx: AppEventSender,
|
||||
codex_op_tx: UnboundedSender<Op>,
|
||||
bottom_pane: BottomPane<'a>,
|
||||
+ active_history_cell: Option<HistoryCell>,
|
||||
config: Config,
|
||||
initial_user_message: Option<UserMessage>,
|
||||
token_usage: TokenUsage,
|
||||
@@ -107,6 +110,17 @@ fn create_initial_user_message(text: String, image_paths: Vec<PathBuf>) -> Optio
|
||||
}
|
||||
|
||||
impl ChatWidget<'_> {
|
||||
+ fn layout_areas(&self, area: Rect) -> [Rect; 2] {
|
||||
+ Layout::vertical([
|
||||
+ Constraint::Max(
|
||||
+ self.active_history_cell
|
||||
+ .as_ref()
|
||||
+ .map_or(0, |c| c.desired_height(area.width)),
|
||||
+ ),
|
||||
+ Constraint::Min(self.bottom_pane.desired_height(area.width)),
|
||||
+ ])
|
||||
+ .areas(area)
|
||||
+ }
|
||||
fn emit_stream_header(&mut self, kind: StreamKind) {
|
||||
use ratatui::text::Line as RLine;
|
||||
if self.stream_header_emitted {
|
||||
@@ -178,6 +192,7 @@ impl ChatWidget<'_> {
|
||||
has_input_focus: true,
|
||||
enhanced_keys_supported,
|
||||
}),
|
||||
+ active_history_cell: None,
|
||||
config,
|
||||
initial_user_message: create_initial_user_message(
|
||||
initial_prompt.unwrap_or_default(),
|
||||
@@ -197,6 +212,10 @@ impl ChatWidget<'_> {
|
||||
|
||||
pub fn desired_height(&self, width: u16) -> u16 {
|
||||
self.bottom_pane.desired_height(width)
|
||||
+ + self
|
||||
+ .active_history_cell
|
||||
+ .as_ref()
|
||||
+ .map_or(0, |c| c.desired_height(width))
|
||||
}
|
||||
|
||||
pub(crate) fn handle_key_event(&mut self, key_event: KeyEvent) {
|
||||
@@ -425,9 +444,11 @@ impl ChatWidget<'_> {
|
||||
cwd: cwd.clone(),
|
||||
},
|
||||
);
|
||||
- self.add_to_history(HistoryCell::new_active_exec_command(command));
|
||||
+ self.active_history_cell = Some(HistoryCell::new_active_exec_command(command));
|
||||
+ }
|
||||
+ EventMsg::ExecCommandOutputDelta(_) => {
|
||||
+ // TODO
|
||||
}
|
||||
- EventMsg::ExecCommandOutputDelta(_) => {}
|
||||
EventMsg::PatchApplyBegin(PatchApplyBeginEvent {
|
||||
call_id: _,
|
||||
auto_approved,
|
||||
@@ -438,8 +459,12 @@ impl ChatWidget<'_> {
|
||||
changes,
|
||||
));
|
||||
}
|
||||
- EventMsg::PatchApplyEnd(patch_apply_end_event) => {
|
||||
- self.add_to_history(HistoryCell::new_patch_end_event(patch_apply_end_event));
|
||||
+ EventMsg::PatchApplyEnd(event) => {
|
||||
+ self.add_to_history(HistoryCell::new_patch_apply_end(
|
||||
+ event.stdout,
|
||||
+ event.stderr,
|
||||
+ event.success,
|
||||
+ ));
|
||||
}
|
||||
EventMsg::ExecCommandEnd(ExecCommandEndEvent {
|
||||
call_id,
|
||||
@@ -450,6 +475,7 @@ impl ChatWidget<'_> {
|
||||
}) => {
|
||||
// Compute summary before moving stdout into the history cell.
|
||||
let cmd = self.running_commands.remove(&call_id);
|
||||
+ self.active_history_cell = None;
|
||||
self.add_to_history(HistoryCell::new_completed_exec_command(
|
||||
cmd.map(|cmd| cmd.command).unwrap_or_else(|| vec![call_id]),
|
||||
CommandOutput {
|
||||
@@ -543,6 +569,7 @@ impl ChatWidget<'_> {
|
||||
CancellationEvent::Ignored => {}
|
||||
}
|
||||
if self.bottom_pane.is_task_running() {
|
||||
+ self.active_history_cell = None;
|
||||
self.bottom_pane.clear_ctrl_c_quit_hint();
|
||||
self.submit_op(Op::Interrupt);
|
||||
self.bottom_pane.set_task_running(false);
|
||||
@@ -586,7 +613,8 @@ impl ChatWidget<'_> {
|
||||
}
|
||||
|
||||
pub fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> {
|
||||
- self.bottom_pane.cursor_pos(area)
|
||||
+ let [_, bottom_pane_area] = self.layout_areas(area);
|
||||
+ self.bottom_pane.cursor_pos(bottom_pane_area)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -690,10 +718,11 @@ impl ChatWidget<'_> {
|
||||
|
||||
impl WidgetRef for &ChatWidget<'_> {
|
||||
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
|
||||
- // In the hybrid inline viewport mode we only draw the interactive
|
||||
- // bottom pane; history entries are injected directly into scrollback
|
||||
- // via `Terminal::insert_before`.
|
||||
- (&self.bottom_pane).render(area, buf);
|
||||
+ let [active_cell_area, bottom_pane_area] = self.layout_areas(area);
|
||||
+ (&self.bottom_pane).render(bottom_pane_area, buf);
|
||||
+ if let Some(cell) = &self.active_history_cell {
|
||||
+ cell.render_ref(active_cell_area, buf);
|
||||
+ }
|
||||
}
|
||||
}
|
||||
|
||||
diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs
|
||||
index 5b7d9246f7..facb0e0a8f 100644
|
||||
--- a/codex-rs/tui/src/history_cell.rs
|
||||
+++ b/codex-rs/tui/src/history_cell.rs
|
||||
@@ -11,7 +11,6 @@ use codex_core::plan_tool::StepStatus;
|
||||
use codex_core::plan_tool::UpdatePlanArgs;
|
||||
use codex_core::protocol::FileChange;
|
||||
use codex_core::protocol::McpInvocation;
|
||||
-use codex_core::protocol::PatchApplyEndEvent;
|
||||
use codex_core::protocol::SessionConfiguredEvent;
|
||||
use codex_core::protocol::TokenUsage;
|
||||
use image::DynamicImage;
|
||||
@@ -24,6 +23,9 @@ use ratatui::style::Modifier;
|
||||
use ratatui::style::Style;
|
||||
use ratatui::text::Line as RtLine;
|
||||
use ratatui::text::Span as RtSpan;
|
||||
+use ratatui::widgets::Paragraph;
|
||||
+use ratatui::widgets::WidgetRef;
|
||||
+use ratatui::widgets::Wrap;
|
||||
use std::collections::HashMap;
|
||||
use std::io::Cursor;
|
||||
use std::path::PathBuf;
|
||||
@@ -62,35 +64,23 @@ fn line_to_static(line: &Line) -> Line<'static> {
|
||||
/// scrollable list.
|
||||
pub(crate) enum HistoryCell {
|
||||
/// Welcome message.
|
||||
- WelcomeMessage {
|
||||
- view: TextBlock,
|
||||
- },
|
||||
+ WelcomeMessage { view: TextBlock },
|
||||
|
||||
/// Message from the user.
|
||||
- UserPrompt {
|
||||
- view: TextBlock,
|
||||
- },
|
||||
+ UserPrompt { view: TextBlock },
|
||||
|
||||
// AgentMessage and AgentReasoning variants were unused and have been removed.
|
||||
/// An exec tool call that has not finished yet.
|
||||
- ActiveExecCommand {
|
||||
- view: TextBlock,
|
||||
- },
|
||||
+ ActiveExecCommand { view: TextBlock },
|
||||
|
||||
/// Completed exec tool call.
|
||||
- CompletedExecCommand {
|
||||
- view: TextBlock,
|
||||
- },
|
||||
+ CompletedExecCommand { view: TextBlock },
|
||||
|
||||
/// An MCP tool call that has not finished yet.
|
||||
- ActiveMcpToolCall {
|
||||
- view: TextBlock,
|
||||
- },
|
||||
+ ActiveMcpToolCall { view: TextBlock },
|
||||
|
||||
/// Completed MCP tool call where we show the result serialized as JSON.
|
||||
- CompletedMcpToolCall {
|
||||
- view: TextBlock,
|
||||
- },
|
||||
+ CompletedMcpToolCall { view: TextBlock },
|
||||
|
||||
/// Completed MCP tool call where the result is an image.
|
||||
/// Admittedly, [mcp_types::CallToolResult] can have multiple content types,
|
||||
@@ -100,51 +90,34 @@ pub(crate) enum HistoryCell {
|
||||
// resized version avoids doing the potentially expensive rescale twice
|
||||
// because the scroll-view first calls `height()` for layouting and then
|
||||
// `render_window()` for painting.
|
||||
- CompletedMcpToolCallWithImageOutput {
|
||||
- _image: DynamicImage,
|
||||
- },
|
||||
+ CompletedMcpToolCallWithImageOutput { _image: DynamicImage },
|
||||
|
||||
/// Background event.
|
||||
- BackgroundEvent {
|
||||
- view: TextBlock,
|
||||
- },
|
||||
+ BackgroundEvent { view: TextBlock },
|
||||
|
||||
/// Output from the `/diff` command.
|
||||
- GitDiffOutput {
|
||||
- view: TextBlock,
|
||||
- },
|
||||
+ GitDiffOutput { view: TextBlock },
|
||||
|
||||
/// Output from the `/status` command.
|
||||
- StatusOutput {
|
||||
- view: TextBlock,
|
||||
- },
|
||||
+ StatusOutput { view: TextBlock },
|
||||
|
||||
/// Error event from the backend.
|
||||
- ErrorEvent {
|
||||
- view: TextBlock,
|
||||
- },
|
||||
+ ErrorEvent { view: TextBlock },
|
||||
|
||||
/// Info describing the newly-initialized session.
|
||||
- SessionInfo {
|
||||
- view: TextBlock,
|
||||
- },
|
||||
+ SessionInfo { view: TextBlock },
|
||||
|
||||
/// A pending code patch that is awaiting user approval. Mirrors the
|
||||
/// behaviour of `ActiveExecCommand` so the user sees *what* patch the
|
||||
/// model wants to apply before being prompted to approve or deny it.
|
||||
- PendingPatch {
|
||||
- view: TextBlock,
|
||||
- },
|
||||
-
|
||||
- PatchEventEnd {
|
||||
- view: TextBlock,
|
||||
- },
|
||||
+ PendingPatch { view: TextBlock },
|
||||
|
||||
/// A human‑friendly rendering of the model's current plan and step
|
||||
/// statuses provided via the `update_plan` tool.
|
||||
- PlanUpdate {
|
||||
- view: TextBlock,
|
||||
- },
|
||||
+ PlanUpdate { view: TextBlock },
|
||||
+
|
||||
+ /// Result of applying a patch (success or failure) with optional output.
|
||||
+ PatchApplyResult { view: TextBlock },
|
||||
}
|
||||
|
||||
const TOOL_CALL_MAX_LINES: usize = 5;
|
||||
@@ -165,8 +138,8 @@ impl HistoryCell {
|
||||
| HistoryCell::CompletedExecCommand { view }
|
||||
| HistoryCell::CompletedMcpToolCall { view }
|
||||
| HistoryCell::PendingPatch { view }
|
||||
- | HistoryCell::PatchEventEnd { view }
|
||||
| HistoryCell::PlanUpdate { view }
|
||||
+ | HistoryCell::PatchApplyResult { view }
|
||||
| HistoryCell::ActiveExecCommand { view, .. }
|
||||
| HistoryCell::ActiveMcpToolCall { view, .. } => {
|
||||
view.lines.iter().map(line_to_static).collect()
|
||||
@@ -177,6 +150,15 @@ impl HistoryCell {
|
||||
],
|
||||
}
|
||||
}
|
||||
+
|
||||
+ pub(crate) fn desired_height(&self, width: u16) -> u16 {
|
||||
+ Paragraph::new(Text::from(self.plain_lines()))
|
||||
+ .wrap(Wrap { trim: false })
|
||||
+ .line_count(width)
|
||||
+ .try_into()
|
||||
+ .unwrap_or(0)
|
||||
+ }
|
||||
+
|
||||
pub(crate) fn new_session_info(
|
||||
config: &Config,
|
||||
event: SessionConfiguredEvent,
|
||||
@@ -612,7 +594,10 @@ impl HistoryCell {
|
||||
PatchEventType::ApplyBegin {
|
||||
auto_approved: false,
|
||||
} => {
|
||||
- let lines = vec![Line::from("patch applied".magenta().bold())];
|
||||
+ let lines: Vec<Line<'static>> = vec![
|
||||
+ Line::from("applying patch".magenta().bold()),
|
||||
+ Line::from(""),
|
||||
+ ];
|
||||
return Self::PendingPatch {
|
||||
view: TextBlock::new(lines),
|
||||
};
|
||||
@@ -661,29 +646,63 @@ impl HistoryCell {
|
||||
}
|
||||
}
|
||||
|
||||
- pub(crate) fn new_patch_end_event(patch_apply_end_event: PatchApplyEndEvent) -> Self {
|
||||
- let PatchApplyEndEvent {
|
||||
- call_id: _,
|
||||
- stdout: _,
|
||||
- stderr,
|
||||
- success,
|
||||
- } = patch_apply_end_event;
|
||||
+ pub(crate) fn new_patch_apply_end(stdout: String, stderr: String, success: bool) -> Self {
|
||||
+ let mut lines: Vec<Line<'static>> = Vec::new();
|
||||
+
|
||||
+ let status = if success {
|
||||
+ RtSpan::styled("patch applied", Style::default().fg(Color::Green))
|
||||
+ } else {
|
||||
+ RtSpan::styled(
|
||||
+ "patch failed",
|
||||
+ Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
|
||||
+ )
|
||||
+ };
|
||||
+ lines.push(RtLine::from(vec![
|
||||
+ "patch".magenta().bold(),
|
||||
+ " ".into(),
|
||||
+ status,
|
||||
+ ]));
|
||||
|
||||
- let mut lines: Vec<Line<'static>> = if success {
|
||||
- vec![Line::from("patch applied successfully".italic())]
|
||||
+ let src = if success {
|
||||
+ if stdout.trim().is_empty() {
|
||||
+ &stderr
|
||||
+ } else {
|
||||
+ &stdout
|
||||
+ }
|
||||
+ } else if stderr.trim().is_empty() {
|
||||
+ &stdout
|
||||
} else {
|
||||
- let mut lines = vec![Line::from("patch failed".italic())];
|
||||
- lines.extend(stderr.lines().map(|l| Line::from(l.to_string())));
|
||||
- lines
|
||||
+ &stderr
|
||||
};
|
||||
+
|
||||
+ if !src.trim().is_empty() {
|
||||
+ lines.push(Line::from(""));
|
||||
+ let mut iter = src.lines();
|
||||
+ for raw in iter.by_ref().take(TOOL_CALL_MAX_LINES) {
|
||||
+ lines.push(ansi_escape_line(raw).dim());
|
||||
+ }
|
||||
+ let remaining = iter.count();
|
||||
+ if remaining > 0 {
|
||||
+ lines.push(Line::from(format!("... {remaining} additional lines")).dim());
|
||||
+ }
|
||||
+ }
|
||||
+
|
||||
lines.push(Line::from(""));
|
||||
|
||||
- HistoryCell::PatchEventEnd {
|
||||
+ HistoryCell::PatchApplyResult {
|
||||
view: TextBlock::new(lines),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+impl WidgetRef for &HistoryCell {
|
||||
+ fn render_ref(&self, area: Rect, buf: &mut Buffer) {
|
||||
+ Paragraph::new(Text::from(self.plain_lines()))
|
||||
+ .wrap(Wrap { trim: false })
|
||||
+ .render(area, buf);
|
||||
+ }
|
||||
+}
|
||||
+
|
||||
fn create_diff_summary(changes: HashMap<PathBuf, FileChange>) -> Vec<String> {
|
||||
// Build a concise, human‑readable summary list similar to the
|
||||
// `git status` short format so the user can reason about the
|
||||
```
|
||||
|
||||
## Review Comments
|
||||
|
||||
### codex-rs/tui/src/chatwidget.rs
|
||||
|
||||
- Created: 2025-08-05 00:34:29 UTC | Link: https://github.com/openai/codex/pull/1824#discussion_r2252846298
|
||||
|
||||
```diff
|
||||
@@ -511,16 +523,34 @@ impl ChatWidget<'_> {
|
||||
}
|
||||
|
||||
pub fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> {
|
||||
- self.bottom_pane.cursor_pos(area)
|
||||
+ let [_, bottom_pane_area] = Layout::vertical([
|
||||
+ Constraint::Max(
|
||||
+ self.active_history_cell
|
||||
+ .as_ref()
|
||||
+ .map_or(0, |c| c.desired_height(area.width)),
|
||||
+ ),
|
||||
+ Constraint::Min(self.bottom_pane.desired_height(area.width)),
|
||||
+ ])
|
||||
+ .areas(area);
|
||||
+ self.bottom_pane.cursor_pos(bottom_pane_area)
|
||||
}
|
||||
}
|
||||
|
||||
impl WidgetRef for &ChatWidget<'_> {
|
||||
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
|
||||
- // In the hybrid inline viewport mode we only draw the interactive
|
||||
- // bottom pane; history entries are injected directly into scrollback
|
||||
- // via `Terminal::insert_before`.
|
||||
- (&self.bottom_pane).render(area, buf);
|
||||
+ let [active_cell_area, bottom_pane_area] = Layout::vertical([
|
||||
+ Constraint::Max(
|
||||
+ self.active_history_cell
|
||||
+ .as_ref()
|
||||
+ .map_or(0, |c| c.desired_height(area.width)),
|
||||
+ ),
|
||||
+ Constraint::Min(self.bottom_pane.desired_height(area.width)),
|
||||
+ ])
|
||||
+ .areas(area);
|
||||
```
|
||||
|
||||
> Should this be moved into a helper function so that `cursor_pos()` can reuse it?
|
||||
|
||||
- Created: 2025-08-06 06:01:22 UTC | Link: https://github.com/openai/codex/pull/1824#discussion_r2255946730
|
||||
|
||||
```diff
|
||||
@@ -104,6 +107,17 @@ fn create_initial_user_message(text: String, image_paths: Vec<PathBuf>) -> Optio
|
||||
}
|
||||
|
||||
impl ChatWidget<'_> {
|
||||
+ fn layout_areas(&self, area: Rect) -> [Rect; 2] {
|
||||
+ Layout::vertical([
|
||||
+ Constraint::Max(
|
||||
+ self.active_history_cell
|
||||
+ .as_ref()
|
||||
+ .map_or(0, |c| c.desired_height(area.width)),
|
||||
+ ),
|
||||
+ Constraint::Min(self.bottom_pane.desired_height(area.width)),
|
||||
+ ])
|
||||
+ .areas(area)
|
||||
+ }
|
||||
```
|
||||
|
||||
> \n?
|
||||
|
||||
- Created: 2025-08-06 06:11:01 UTC | Link: https://github.com/openai/codex/pull/1824#discussion_r2255961356
|
||||
|
||||
```diff
|
||||
@@ -435,6 +456,13 @@ impl ChatWidget<'_> {
|
||||
changes,
|
||||
));
|
||||
}
|
||||
+ EventMsg::PatchApplyEnd(event) => {
|
||||
+ self.add_to_history(HistoryCell::new_patch_apply_end(
|
||||
+ event.stdout,
|
||||
```
|
||||
|
||||
> I would just pass the whole `PatchApplyEndEvent` rather than pull the fields off here.
|
||||
|
||||
### codex-rs/tui/src/history_cell.rs
|
||||
|
||||
- Created: 2025-08-06 06:08:44 UTC | Link: https://github.com/openai/codex/pull/1824#discussion_r2255957518
|
||||
|
||||
```diff
|
||||
@@ -598,6 +616,62 @@ impl HistoryCell {
|
||||
view: TextBlock::new(lines),
|
||||
}
|
||||
}
|
||||
+
|
||||
+ pub(crate) fn new_patch_apply_end(stdout: String, stderr: String, success: bool) -> Self {
|
||||
+ let mut lines: Vec<Line<'static>> = Vec::new();
|
||||
+
|
||||
+ let status = if success {
|
||||
+ RtSpan::styled("patch applied", Style::default().fg(Color::Green))
|
||||
+ } else {
|
||||
+ RtSpan::styled(
|
||||
+ "patch failed",
|
||||
+ Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
|
||||
+ )
|
||||
+ };
|
||||
+ lines.push(RtLine::from(vec![
|
||||
+ "patch".magenta().bold(),
|
||||
+ " ".into(),
|
||||
+ status,
|
||||
+ ]));
|
||||
+
|
||||
+ let src = if success {
|
||||
+ if stdout.trim().is_empty() {
|
||||
+ &stderr
|
||||
+ } else {
|
||||
+ &stdout
|
||||
+ }
|
||||
+ } else if stderr.trim().is_empty() {
|
||||
+ &stdout
|
||||
+ } else {
|
||||
+ &stderr
|
||||
+ };
|
||||
```
|
||||
|
||||
> The way `apply_patch` works, `stdout` should never be empty on `success`. If anything, it is slightly redundant with what was just shown for `new_patch_event()`, so it might be fine to omit it. You can see that in #1866, I tried to be stingy on the amount of output.
|
||||
>
|
||||
> Up to you.
|
||||
1042
prs/bolinfest/PR-1826.md
Normal file
1042
prs/bolinfest/PR-1826.md
Normal file
File diff suppressed because it is too large
Load Diff
146
prs/bolinfest/PR-1827.md
Normal file
146
prs/bolinfest/PR-1827.md
Normal file
@@ -0,0 +1,146 @@
|
||||
# PR #1827: Get rid of unnecessary ERROR messages
|
||||
|
||||
- URL: https://github.com/openai/codex/pull/1827
|
||||
- Author: easong-openai
|
||||
- Created: 2025-08-04 21:32:49 UTC
|
||||
- Updated: 2025-08-06 05:46:21 UTC
|
||||
- Changes: +17/-7, Files changed: 4, Commits: 3
|
||||
|
||||
## Description
|
||||
|
||||
When interrupting a model turn with ctr-c or at end of exec, we print an ERROR message, which is jank. Let's not do that.
|
||||
|
||||
## Full Diff
|
||||
|
||||
```diff
|
||||
diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs
|
||||
index 568d87c4a8..f2b5101ed2 100644
|
||||
--- a/codex-rs/core/src/codex.rs
|
||||
+++ b/codex-rs/core/src/codex.rs
|
||||
@@ -627,8 +627,8 @@ impl AgentTask {
|
||||
self.handle.abort();
|
||||
let event = Event {
|
||||
id: self.sub_id,
|
||||
- msg: EventMsg::Error(ErrorEvent {
|
||||
- message: "Turn interrupted".to_string(),
|
||||
+ msg: EventMsg::BackgroundEvent(BackgroundEventEvent {
|
||||
+ message: "Response interrupted.".to_string(),
|
||||
}),
|
||||
};
|
||||
let tx_event = self.sess.tx_event.clone();
|
||||
diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs
|
||||
index ce4d7f65cc..fe3b058970 100644
|
||||
--- a/codex-rs/exec/src/lib.rs
|
||||
+++ b/codex-rs/exec/src/lib.rs
|
||||
@@ -181,20 +181,22 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option<PathBuf>) -> any
|
||||
)
|
||||
.await;
|
||||
|
||||
- // Exit the inner loop and return to the main input prompt. The codex
|
||||
- // will emit a `TurnInterrupted` (Error) event which is drained later.
|
||||
break;
|
||||
}
|
||||
res = codex.next_event() => match res {
|
||||
Ok(event) => {
|
||||
+ let is_shutdown = matches!(event.msg, EventMsg::ShutdownComplete);
|
||||
debug!("Received event: {event:?}");
|
||||
if let Err(e) = tx.send(event) {
|
||||
error!("Error sending event: {e:?}");
|
||||
break;
|
||||
}
|
||||
+ if is_shutdown {
|
||||
+ break;
|
||||
+ }
|
||||
},
|
||||
Err(e) => {
|
||||
- error!("Error receiving event: {e:?}");
|
||||
+ debug!("Event stream ended: {e:?}");
|
||||
break;
|
||||
}
|
||||
}
|
||||
diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs
|
||||
index cab78bbe3f..c8c88e3a24 100644
|
||||
--- a/codex-rs/tui/src/bottom_pane/mod.rs
|
||||
+++ b/codex-rs/tui/src/bottom_pane/mod.rs
|
||||
@@ -286,8 +286,8 @@ impl BottomPane<'_> {
|
||||
impl WidgetRef for &BottomPane<'_> {
|
||||
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
|
||||
// Show BottomPaneView if present.
|
||||
- if let Some(ov) = &self.active_view {
|
||||
- ov.render(area, buf);
|
||||
+ if let Some(active_view) = &self.active_view {
|
||||
+ active_view.render(area, buf);
|
||||
} else {
|
||||
(&self.composer).render_ref(area, buf);
|
||||
}
|
||||
diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs
|
||||
index e5ebf58a07..68778f5ec0 100644
|
||||
--- a/codex-rs/tui/src/chatwidget.rs
|
||||
+++ b/codex-rs/tui/src/chatwidget.rs
|
||||
@@ -10,6 +10,7 @@ use codex_core::protocol::AgentMessageEvent;
|
||||
use codex_core::protocol::AgentReasoningDeltaEvent;
|
||||
use codex_core::protocol::AgentReasoningEvent;
|
||||
use codex_core::protocol::ApplyPatchApprovalRequestEvent;
|
||||
+use codex_core::protocol::BackgroundEventEvent;
|
||||
use codex_core::protocol::ErrorEvent;
|
||||
use codex_core::protocol::Event;
|
||||
use codex_core::protocol::EventMsg;
|
||||
@@ -220,6 +221,13 @@ impl ChatWidget<'_> {
|
||||
pub(crate) fn handle_codex_event(&mut self, event: Event) {
|
||||
let Event { id, msg } = event;
|
||||
match msg {
|
||||
+ EventMsg::BackgroundEvent(BackgroundEventEvent { message }) => {
|
||||
+ self.add_to_history(HistoryCell::new_background_event(message.clone()));
|
||||
+ if message.contains("Turn interrupted") {
|
||||
+ self.bottom_pane.set_task_running(false);
|
||||
+ }
|
||||
+ self.request_redraw();
|
||||
+ }
|
||||
EventMsg::SessionConfigured(event) => {
|
||||
self.bottom_pane
|
||||
.set_history_metadata(event.history_log_id, event.history_entry_count);
|
||||
```
|
||||
|
||||
## Review Comments
|
||||
|
||||
### codex-rs/core/src/codex.rs
|
||||
|
||||
- Created: 2025-08-04 21:52:31 UTC | Link: https://github.com/openai/codex/pull/1827#discussion_r2252649250
|
||||
|
||||
```diff
|
||||
@@ -601,8 +601,8 @@ impl AgentTask {
|
||||
self.handle.abort();
|
||||
let event = Event {
|
||||
id: self.sub_id,
|
||||
- msg: EventMsg::Error(ErrorEvent {
|
||||
- message: "Turn interrupted".to_string(),
|
||||
+ msg: EventMsg::BackgroundEvent(BackgroundEventEvent {
|
||||
```
|
||||
|
||||
> Let's introduce a new variant in `EventMsg` for this and then let the UI decide how it wants to present it.
|
||||
|
||||
### codex-rs/exec/src/lib.rs
|
||||
|
||||
- Created: 2025-08-04 21:56:07 UTC | Link: https://github.com/openai/codex/pull/1827#discussion_r2252653778
|
||||
|
||||
```diff
|
||||
@@ -181,20 +181,22 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option<PathBuf>) -> any
|
||||
)
|
||||
.await;
|
||||
|
||||
- // Exit the inner loop and return to the main input prompt. The codex
|
||||
- // will emit a `TurnInterrupted` (Error) event which is drained later.
|
||||
break;
|
||||
}
|
||||
res = codex.next_event() => match res {
|
||||
Ok(event) => {
|
||||
+ let is_shutdown = matches!(event.msg, EventMsg::ShutdownComplete);
|
||||
debug!("Received event: {event:?}");
|
||||
if let Err(e) = tx.send(event) {
|
||||
error!("Error sending event: {e:?}");
|
||||
break;
|
||||
}
|
||||
+ if is_shutdown {
|
||||
```
|
||||
|
||||
> I don't think this is a safe way to change the control flow.
|
||||
1285
prs/bolinfest/PR-1830.md
Normal file
1285
prs/bolinfest/PR-1830.md
Normal file
File diff suppressed because it is too large
Load Diff
133
prs/bolinfest/PR-1831.md
Normal file
133
prs/bolinfest/PR-1831.md
Normal file
@@ -0,0 +1,133 @@
|
||||
# PR #1831: handle PatchApplyEnd
|
||||
|
||||
- URL: https://github.com/openai/codex/pull/1831
|
||||
- Author: nornagon-openai
|
||||
- Created: 2025-08-04 22:52:35 UTC
|
||||
- Updated: 2025-08-13 17:44:48 UTC
|
||||
- Changes: +28/-0, Files changed: 2, Commits: 1
|
||||
|
||||
## Description
|
||||
|
||||
This is more to drop the ugly unhandled `PatchApplyEnd` messages than it is about showing patch failures cleanly (I haven't been able to get a patch failure to occur so it's hard for me to test the failure message).
|
||||
|
||||
## Full Diff
|
||||
|
||||
```diff
|
||||
diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs
|
||||
index e5ebf58a07..d90882bd05 100644
|
||||
--- a/codex-rs/tui/src/chatwidget.rs
|
||||
+++ b/codex-rs/tui/src/chatwidget.rs
|
||||
@@ -21,6 +21,7 @@ use codex_core::protocol::McpToolCallBeginEvent;
|
||||
use codex_core::protocol::McpToolCallEndEvent;
|
||||
use codex_core::protocol::Op;
|
||||
use codex_core::protocol::PatchApplyBeginEvent;
|
||||
+use codex_core::protocol::PatchApplyEndEvent;
|
||||
use codex_core::protocol::TaskCompleteEvent;
|
||||
use codex_core::protocol::TokenUsage;
|
||||
use crossterm::event::KeyEvent;
|
||||
@@ -386,6 +387,16 @@ impl ChatWidget<'_> {
|
||||
changes,
|
||||
));
|
||||
}
|
||||
+ EventMsg::PatchApplyEnd(PatchApplyEndEvent {
|
||||
+ call_id: _,
|
||||
+ stdout,
|
||||
+ stderr,
|
||||
+ success,
|
||||
+ }) => {
|
||||
+ if !success {
|
||||
+ self.add_to_history(HistoryCell::new_patch_failed_event(stdout, stderr));
|
||||
+ }
|
||||
+ }
|
||||
EventMsg::ExecCommandEnd(ExecCommandEndEvent {
|
||||
call_id,
|
||||
exit_code,
|
||||
diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs
|
||||
index c2aafdd522..3d9cf85f03 100644
|
||||
--- a/codex-rs/tui/src/history_cell.rs
|
||||
+++ b/codex-rs/tui/src/history_cell.rs
|
||||
@@ -113,6 +113,9 @@ pub(crate) enum HistoryCell {
|
||||
/// model wants to apply before being prompted to approve or deny it.
|
||||
PendingPatch { view: TextBlock },
|
||||
|
||||
+ /// A patch failed to apply.
|
||||
+ PatchFailed { view: TextBlock },
|
||||
+
|
||||
/// A human‑friendly rendering of the model's current plan and step
|
||||
/// statuses provided via the `update_plan` tool.
|
||||
PlanUpdate { view: TextBlock },
|
||||
@@ -137,6 +140,7 @@ impl HistoryCell {
|
||||
| HistoryCell::CompletedExecCommand { view }
|
||||
| HistoryCell::CompletedMcpToolCall { view }
|
||||
| HistoryCell::PendingPatch { view }
|
||||
+ | HistoryCell::PatchFailed { view }
|
||||
| HistoryCell::PlanUpdate { view }
|
||||
| HistoryCell::ActiveExecCommand { view, .. }
|
||||
| HistoryCell::ActiveMcpToolCall { view, .. } => {
|
||||
@@ -148,6 +152,7 @@ impl HistoryCell {
|
||||
],
|
||||
}
|
||||
}
|
||||
+
|
||||
pub(crate) fn new_session_info(
|
||||
config: &Config,
|
||||
event: SessionConfiguredEvent,
|
||||
@@ -629,6 +634,18 @@ impl HistoryCell {
|
||||
view: TextBlock::new(lines),
|
||||
}
|
||||
}
|
||||
+
|
||||
+ pub fn new_patch_failed_event(stdout: String, stderr: String) -> Self {
|
||||
+ let lines: Vec<Line<'static>> = vec![
|
||||
+ Line::from("patch failed".red().bold()),
|
||||
+ Line::from(stdout),
|
||||
+ Line::from(stderr),
|
||||
+ Line::from(""),
|
||||
+ ];
|
||||
+ HistoryCell::PatchFailed {
|
||||
+ view: TextBlock::new(lines),
|
||||
+ }
|
||||
+ }
|
||||
}
|
||||
|
||||
fn create_diff_summary(changes: HashMap<PathBuf, FileChange>) -> Vec<String> {
|
||||
```
|
||||
|
||||
## Review Comments
|
||||
|
||||
### codex-rs/tui/src/chatwidget.rs
|
||||
|
||||
- Created: 2025-08-04 23:37:45 UTC | Link: https://github.com/openai/codex/pull/1831#discussion_r2252795192
|
||||
|
||||
```diff
|
||||
@@ -386,6 +387,16 @@ impl ChatWidget<'_> {
|
||||
changes,
|
||||
));
|
||||
}
|
||||
+ EventMsg::PatchApplyEnd(PatchApplyEndEvent {
|
||||
+ call_id: _,
|
||||
+ stdout,
|
||||
+ stderr,
|
||||
+ success,
|
||||
+ }) => {
|
||||
+ if !success {
|
||||
```
|
||||
|
||||
> I feel like it might be nice to have the `changes` from the `PatchApplyBegin` for better UI presentation, but I guess we can start with this.
|
||||
|
||||
### codex-rs/tui/src/history_cell.rs
|
||||
|
||||
- Created: 2025-08-04 23:36:29 UTC | Link: https://github.com/openai/codex/pull/1831#discussion_r2252794001
|
||||
|
||||
```diff
|
||||
@@ -629,6 +634,18 @@ impl HistoryCell {
|
||||
view: TextBlock::new(lines),
|
||||
}
|
||||
}
|
||||
+
|
||||
+ pub fn new_patch_failed_event(stdout: String, stderr: String) -> Self {
|
||||
+ let lines: Vec<Line<'static>> = vec![
|
||||
+ Line::from("patch failed".red().bold()),
|
||||
```
|
||||
|
||||
> Not sure how intuitive this UI will be. You might want to omit `Line::from()` if `stdout` or `stderr` is empty?
|
||||
940
prs/bolinfest/PR-1847.md
Normal file
940
prs/bolinfest/PR-1847.md
Normal file
@@ -0,0 +1,940 @@
|
||||
# PR #1847: feat: add a built-in model provider named "oss"
|
||||
|
||||
- URL: https://github.com/openai/codex/pull/1847
|
||||
- Author: bolinfest
|
||||
- Created: 2025-08-05 09:13:36 UTC
|
||||
- Updated: 2025-08-05 18:32:16 UTC
|
||||
- Changes: +686/-44, Files changed: 13, Commits: 1
|
||||
|
||||
## Description
|
||||
|
||||
Builds off of the work in https://github.com/openai/codex/pull/1813, but ports only the business logic. Introduces a new built-in provider named `oss` rather than trying to write one to the user's `config.toml`.
|
||||
|
||||
## Full Diff
|
||||
|
||||
```diff
|
||||
diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock
|
||||
index 2e20a7d624..eb630c6fe5 100644
|
||||
--- a/codex-rs/Cargo.lock
|
||||
+++ b/codex-rs/Cargo.lock
|
||||
@@ -838,6 +838,23 @@ dependencies = [
|
||||
"wiremock",
|
||||
]
|
||||
|
||||
+[[package]]
|
||||
+name = "codex-ollama"
|
||||
+version = "0.0.0"
|
||||
+dependencies = [
|
||||
+ "async-stream",
|
||||
+ "bytes",
|
||||
+ "codex-core",
|
||||
+ "futures",
|
||||
+ "reqwest",
|
||||
+ "serde_json",
|
||||
+ "tempfile",
|
||||
+ "tokio",
|
||||
+ "toml 0.9.4",
|
||||
+ "tracing",
|
||||
+ "wiremock",
|
||||
+]
|
||||
+
|
||||
[[package]]
|
||||
name = "codex-tui"
|
||||
version = "0.0.0"
|
||||
@@ -852,6 +869,7 @@ dependencies = [
|
||||
"codex-core",
|
||||
"codex-file-search",
|
||||
"codex-login",
|
||||
+ "codex-ollama",
|
||||
"color-eyre",
|
||||
"crossterm",
|
||||
"image",
|
||||
diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml
|
||||
index 0f8085c7e5..0ed8852228 100644
|
||||
--- a/codex-rs/Cargo.toml
|
||||
+++ b/codex-rs/Cargo.toml
|
||||
@@ -14,6 +14,7 @@ members = [
|
||||
"mcp-client",
|
||||
"mcp-server",
|
||||
"mcp-types",
|
||||
+ "ollama",
|
||||
"tui",
|
||||
]
|
||||
resolver = "2"
|
||||
diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs
|
||||
index f9c608b554..965cb77bf1 100644
|
||||
--- a/codex-rs/core/src/lib.rs
|
||||
+++ b/codex-rs/core/src/lib.rs
|
||||
@@ -28,6 +28,7 @@ mod mcp_connection_manager;
|
||||
mod mcp_tool_call;
|
||||
mod message_history;
|
||||
mod model_provider_info;
|
||||
+pub use model_provider_info::BUILT_IN_OSS_MODEL_PROVIDER_ID;
|
||||
pub use model_provider_info::ModelProviderInfo;
|
||||
pub use model_provider_info::WireApi;
|
||||
pub use model_provider_info::built_in_model_providers;
|
||||
diff --git a/codex-rs/core/src/model_provider_info.rs b/codex-rs/core/src/model_provider_info.rs
|
||||
index 49478660f4..595f05ef75 100644
|
||||
--- a/codex-rs/core/src/model_provider_info.rs
|
||||
+++ b/codex-rs/core/src/model_provider_info.rs
|
||||
@@ -226,53 +226,93 @@ impl ModelProviderInfo {
|
||||
}
|
||||
}
|
||||
|
||||
+const DEFAULT_OLLAMA_PORT: u32 = 11434;
|
||||
+
|
||||
+pub const BUILT_IN_OSS_MODEL_PROVIDER_ID: &str = "oss";
|
||||
+
|
||||
/// Built-in default provider list.
|
||||
pub fn built_in_model_providers() -> HashMap<String, ModelProviderInfo> {
|
||||
use ModelProviderInfo as P;
|
||||
|
||||
- // We do not want to be in the business of adjucating which third-party
|
||||
- // providers are bundled with Codex CLI, so we only include the OpenAI
|
||||
- // provider by default. Users are encouraged to add to `model_providers`
|
||||
- // in config.toml to add their own providers.
|
||||
- [(
|
||||
- "openai",
|
||||
- P {
|
||||
- name: "OpenAI".into(),
|
||||
- // Allow users to override the default OpenAI endpoint by
|
||||
- // exporting `OPENAI_BASE_URL`. This is useful when pointing
|
||||
- // Codex at a proxy, mock server, or Azure-style deployment
|
||||
- // without requiring a full TOML override for the built-in
|
||||
- // OpenAI provider.
|
||||
- base_url: std::env::var("OPENAI_BASE_URL")
|
||||
+ // These CODEX_OSS_ environment variables are experimental: we may
|
||||
+ // switch to reading values from config.toml instead.
|
||||
+ let codex_oss_base_url = match std::env::var("CODEX_OSS_BASE_URL")
|
||||
+ .ok()
|
||||
+ .filter(|v| !v.trim().is_empty())
|
||||
+ {
|
||||
+ Some(url) => url,
|
||||
+ None => format!(
|
||||
+ "http://localhost:{port}/v1",
|
||||
+ port = std::env::var("CODEX_OSS_PORT")
|
||||
.ok()
|
||||
- .filter(|v| !v.trim().is_empty()),
|
||||
- env_key: None,
|
||||
- env_key_instructions: None,
|
||||
- wire_api: WireApi::Responses,
|
||||
- query_params: None,
|
||||
- http_headers: Some(
|
||||
- [("version".to_string(), env!("CARGO_PKG_VERSION").to_string())]
|
||||
+ .filter(|v| !v.trim().is_empty())
|
||||
+ .and_then(|v| v.parse::<u32>().ok())
|
||||
+ .unwrap_or(DEFAULT_OLLAMA_PORT)
|
||||
+ ),
|
||||
+ };
|
||||
+
|
||||
+ // We do not want to be in the business of adjucating which third-party
|
||||
+ // providers are bundled with Codex CLI, so we only include the OpenAI and
|
||||
+ // open source ("oss") providers by default. Users are encouraged to add to
|
||||
+ // `model_providers` in config.toml to add their own providers.
|
||||
+ [
|
||||
+ (
|
||||
+ "openai",
|
||||
+ P {
|
||||
+ name: "OpenAI".into(),
|
||||
+ // Allow users to override the default OpenAI endpoint by
|
||||
+ // exporting `OPENAI_BASE_URL`. This is useful when pointing
|
||||
+ // Codex at a proxy, mock server, or Azure-style deployment
|
||||
+ // without requiring a full TOML override for the built-in
|
||||
+ // OpenAI provider.
|
||||
+ base_url: std::env::var("OPENAI_BASE_URL")
|
||||
+ .ok()
|
||||
+ .filter(|v| !v.trim().is_empty()),
|
||||
+ env_key: None,
|
||||
+ env_key_instructions: None,
|
||||
+ wire_api: WireApi::Responses,
|
||||
+ query_params: None,
|
||||
+ http_headers: Some(
|
||||
+ [("version".to_string(), env!("CARGO_PKG_VERSION").to_string())]
|
||||
+ .into_iter()
|
||||
+ .collect(),
|
||||
+ ),
|
||||
+ env_http_headers: Some(
|
||||
+ [
|
||||
+ (
|
||||
+ "OpenAI-Organization".to_string(),
|
||||
+ "OPENAI_ORGANIZATION".to_string(),
|
||||
+ ),
|
||||
+ ("OpenAI-Project".to_string(), "OPENAI_PROJECT".to_string()),
|
||||
+ ]
|
||||
.into_iter()
|
||||
.collect(),
|
||||
- ),
|
||||
- env_http_headers: Some(
|
||||
- [
|
||||
- (
|
||||
- "OpenAI-Organization".to_string(),
|
||||
- "OPENAI_ORGANIZATION".to_string(),
|
||||
- ),
|
||||
- ("OpenAI-Project".to_string(), "OPENAI_PROJECT".to_string()),
|
||||
- ]
|
||||
- .into_iter()
|
||||
- .collect(),
|
||||
- ),
|
||||
- // Use global defaults for retry/timeout unless overridden in config.toml.
|
||||
- request_max_retries: None,
|
||||
- stream_max_retries: None,
|
||||
- stream_idle_timeout_ms: None,
|
||||
- requires_auth: true,
|
||||
- },
|
||||
- )]
|
||||
+ ),
|
||||
+ // Use global defaults for retry/timeout unless overridden in config.toml.
|
||||
+ request_max_retries: None,
|
||||
+ stream_max_retries: None,
|
||||
+ stream_idle_timeout_ms: None,
|
||||
+ requires_auth: true,
|
||||
+ },
|
||||
+ ),
|
||||
+ (
|
||||
+ BUILT_IN_OSS_MODEL_PROVIDER_ID,
|
||||
+ P {
|
||||
+ name: "Open Source".into(),
|
||||
+ base_url: Some(codex_oss_base_url),
|
||||
+ env_key: None,
|
||||
+ env_key_instructions: None,
|
||||
+ wire_api: WireApi::Chat,
|
||||
+ query_params: None,
|
||||
+ http_headers: None,
|
||||
+ env_http_headers: None,
|
||||
+ request_max_retries: None,
|
||||
+ stream_max_retries: None,
|
||||
+ stream_idle_timeout_ms: None,
|
||||
+ requires_auth: false,
|
||||
+ },
|
||||
+ ),
|
||||
+ ]
|
||||
.into_iter()
|
||||
.map(|(k, v)| (k.to_string(), v))
|
||||
.collect()
|
||||
diff --git a/codex-rs/ollama/Cargo.toml b/codex-rs/ollama/Cargo.toml
|
||||
new file mode 100644
|
||||
index 0000000000..ead9a06494
|
||||
--- /dev/null
|
||||
+++ b/codex-rs/ollama/Cargo.toml
|
||||
@@ -0,0 +1,32 @@
|
||||
+[package]
|
||||
+edition = "2024"
|
||||
+name = "codex-ollama"
|
||||
+version = { workspace = true }
|
||||
+
|
||||
+[lib]
|
||||
+name = "codex_ollama"
|
||||
+path = "src/lib.rs"
|
||||
+
|
||||
+[lints]
|
||||
+workspace = true
|
||||
+
|
||||
+[dependencies]
|
||||
+async-stream = "0.3"
|
||||
+bytes = "1.10.1"
|
||||
+codex-core = { path = "../core" }
|
||||
+futures = "0.3"
|
||||
+reqwest = { version = "0.12", features = ["json", "stream"] }
|
||||
+serde_json = "1"
|
||||
+tokio = { version = "1", features = [
|
||||
+ "io-std",
|
||||
+ "macros",
|
||||
+ "process",
|
||||
+ "rt-multi-thread",
|
||||
+ "signal",
|
||||
+] }
|
||||
+toml = "0.9.2"
|
||||
+tracing = { version = "0.1.41", features = ["log"] }
|
||||
+wiremock = "0.6"
|
||||
+
|
||||
+[dev-dependencies]
|
||||
+tempfile = "3"
|
||||
diff --git a/codex-rs/ollama/src/client.rs b/codex-rs/ollama/src/client.rs
|
||||
new file mode 100644
|
||||
index 0000000000..45190e8238
|
||||
--- /dev/null
|
||||
+++ b/codex-rs/ollama/src/client.rs
|
||||
@@ -0,0 +1,255 @@
|
||||
+use bytes::BytesMut;
|
||||
+use futures::StreamExt;
|
||||
+use futures::stream::BoxStream;
|
||||
+use serde_json::Value as JsonValue;
|
||||
+use std::collections::VecDeque;
|
||||
+use std::io;
|
||||
+
|
||||
+use codex_core::WireApi;
|
||||
+
|
||||
+use crate::parser::pull_events_from_value;
|
||||
+use crate::pull::PullEvent;
|
||||
+use crate::pull::PullProgressReporter;
|
||||
+use crate::url::base_url_to_host_root;
|
||||
+use crate::url::is_openai_compatible_base_url;
|
||||
+
|
||||
+/// Client for interacting with a local Ollama instance.
|
||||
+pub struct OllamaClient {
|
||||
+ client: reqwest::Client,
|
||||
+ host_root: String,
|
||||
+ uses_openai_compat: bool,
|
||||
+}
|
||||
+
|
||||
+impl OllamaClient {
|
||||
+ pub fn from_oss_provider() -> Self {
|
||||
+ #![allow(clippy::expect_used)]
|
||||
+ // Use the built-in OSS provider's base URL.
|
||||
+ let built_in_model_providers = codex_core::built_in_model_providers();
|
||||
+ let provider = built_in_model_providers
|
||||
+ .get(codex_core::BUILT_IN_OSS_MODEL_PROVIDER_ID)
|
||||
+ .expect("oss provider must exist");
|
||||
+ let base_url = provider
|
||||
+ .base_url
|
||||
+ .as_ref()
|
||||
+ .expect("oss provider must have a base_url");
|
||||
+ Self::from_provider(base_url, provider.wire_api)
|
||||
+ }
|
||||
+
|
||||
+ /// Build a client from a provider definition. Falls back to the default
|
||||
+ /// local URL if no base_url is configured.
|
||||
+ fn from_provider(base_url: &str, wire_api: WireApi) -> Self {
|
||||
+ let uses_openai_compat = is_openai_compatible_base_url(base_url)
|
||||
+ || matches!(wire_api, WireApi::Chat) && is_openai_compatible_base_url(base_url);
|
||||
+ let host_root = base_url_to_host_root(base_url);
|
||||
+ let client = reqwest::Client::builder()
|
||||
+ .connect_timeout(std::time::Duration::from_secs(5))
|
||||
+ .build()
|
||||
+ .unwrap_or_else(|_| reqwest::Client::new());
|
||||
+ Self {
|
||||
+ client,
|
||||
+ host_root,
|
||||
+ uses_openai_compat,
|
||||
+ }
|
||||
+ }
|
||||
+
|
||||
+ pub fn get_host(&self) -> &str {
|
||||
+ &self.host_root
|
||||
+ }
|
||||
+
|
||||
+ /// Low-level constructor given a raw host root, e.g. "http://localhost:11434".
|
||||
+ #[cfg(test)]
|
||||
+ fn from_host_root(host_root: impl Into<String>) -> Self {
|
||||
+ let client = reqwest::Client::builder()
|
||||
+ .connect_timeout(std::time::Duration::from_secs(5))
|
||||
+ .build()
|
||||
+ .unwrap_or_else(|_| reqwest::Client::new());
|
||||
+ Self {
|
||||
+ client,
|
||||
+ host_root: host_root.into(),
|
||||
+ uses_openai_compat: false,
|
||||
+ }
|
||||
+ }
|
||||
+
|
||||
+ /// Probe whether the server is reachable by hitting the appropriate health endpoint.
|
||||
+ pub async fn probe_server(&self) -> io::Result<bool> {
|
||||
+ let url = if self.uses_openai_compat {
|
||||
+ format!("{}/v1/models", self.host_root.trim_end_matches('/'))
|
||||
+ } else {
|
||||
+ format!("{}/api/tags", self.host_root.trim_end_matches('/'))
|
||||
+ };
|
||||
+ let resp = self.client.get(url).send().await;
|
||||
+ Ok(matches!(resp, Ok(r) if r.status().is_success()))
|
||||
+ }
|
||||
+
|
||||
+ /// Return the list of model names known to the local Ollama instance.
|
||||
+ pub async fn fetch_models(&self) -> io::Result<Vec<String>> {
|
||||
+ let tags_url = format!("{}/api/tags", self.host_root.trim_end_matches('/'));
|
||||
+ let resp = self
|
||||
+ .client
|
||||
+ .get(tags_url)
|
||||
+ .send()
|
||||
+ .await
|
||||
+ .map_err(io::Error::other)?;
|
||||
+ if !resp.status().is_success() {
|
||||
+ return Ok(Vec::new());
|
||||
+ }
|
||||
+ let val = resp.json::<JsonValue>().await.map_err(io::Error::other)?;
|
||||
+ let names = val
|
||||
+ .get("models")
|
||||
+ .and_then(|m| m.as_array())
|
||||
+ .map(|arr| {
|
||||
+ arr.iter()
|
||||
+ .filter_map(|v| v.get("name").and_then(|n| n.as_str()))
|
||||
+ .map(|s| s.to_string())
|
||||
+ .collect::<Vec<_>>()
|
||||
+ })
|
||||
+ .unwrap_or_default();
|
||||
+ Ok(names)
|
||||
+ }
|
||||
+
|
||||
+ /// Start a model pull and emit streaming events. The returned stream ends when
|
||||
+ /// a Success event is observed or the server closes the connection.
|
||||
+ pub async fn pull_model_stream(
|
||||
+ &self,
|
||||
+ model: &str,
|
||||
+ ) -> io::Result<BoxStream<'static, PullEvent>> {
|
||||
+ let url = format!("{}/api/pull", self.host_root.trim_end_matches('/'));
|
||||
+ let resp = self
|
||||
+ .client
|
||||
+ .post(url)
|
||||
+ .json(&serde_json::json!({"model": model, "stream": true}))
|
||||
+ .send()
|
||||
+ .await
|
||||
+ .map_err(io::Error::other)?;
|
||||
+ if !resp.status().is_success() {
|
||||
+ return Err(io::Error::other(format!(
|
||||
+ "failed to start pull: HTTP {}",
|
||||
+ resp.status()
|
||||
+ )));
|
||||
+ }
|
||||
+
|
||||
+ let mut stream = resp.bytes_stream();
|
||||
+ let mut buf = BytesMut::new();
|
||||
+ let _pending: VecDeque<PullEvent> = VecDeque::new();
|
||||
+
|
||||
+ // Using an async stream adaptor backed by unfold-like manual loop.
|
||||
+ let s = async_stream::stream! {
|
||||
+ while let Some(chunk) = stream.next().await {
|
||||
+ match chunk {
|
||||
+ Ok(bytes) => {
|
||||
+ buf.extend_from_slice(&bytes);
|
||||
+ while let Some(pos) = buf.iter().position(|b| *b == b'\n') {
|
||||
+ let line = buf.split_to(pos + 1);
|
||||
+ if let Ok(text) = std::str::from_utf8(&line) {
|
||||
+ let text = text.trim();
|
||||
+ if text.is_empty() { continue; }
|
||||
+ if let Ok(value) = serde_json::from_str::<JsonValue>(text) {
|
||||
+ for ev in pull_events_from_value(&value) { yield ev; }
|
||||
+ if let Some(err_msg) = value.get("error").and_then(|e| e.as_str()) {
|
||||
+ yield PullEvent::Status(format!("error: {err_msg}"));
|
||||
+ return;
|
||||
+ }
|
||||
+ if let Some(status) = value.get("status").and_then(|s| s.as_str()) {
|
||||
+ if status == "success" { yield PullEvent::Success; return; }
|
||||
+ }
|
||||
+ }
|
||||
+ }
|
||||
+ }
|
||||
+ }
|
||||
+ Err(_) => {
|
||||
+ // Connection error: end the stream.
|
||||
+ return;
|
||||
+ }
|
||||
+ }
|
||||
+ }
|
||||
+ };
|
||||
+
|
||||
+ Ok(Box::pin(s))
|
||||
+ }
|
||||
+
|
||||
+ /// High-level helper to pull a model and drive a progress reporter.
|
||||
+ pub async fn pull_with_reporter(
|
||||
+ &self,
|
||||
+ model: &str,
|
||||
+ reporter: &mut dyn PullProgressReporter,
|
||||
+ ) -> io::Result<()> {
|
||||
+ reporter.on_event(&PullEvent::Status(format!("Pulling model {model}...")))?;
|
||||
+ let mut stream = self.pull_model_stream(model).await?;
|
||||
+ while let Some(event) = stream.next().await {
|
||||
+ reporter.on_event(&event)?;
|
||||
+ if matches!(event, PullEvent::Success) {
|
||||
+ break;
|
||||
+ }
|
||||
+ }
|
||||
+ Ok(())
|
||||
+ }
|
||||
+}
|
||||
+
|
||||
+#[cfg(test)]
|
||||
+mod tests {
|
||||
+ #![allow(clippy::expect_used, clippy::unwrap_used)]
|
||||
+ use super::*;
|
||||
+
|
||||
+ // Happy-path tests using a mock HTTP server; skip if sandbox network is disabled.
|
||||
+ #[tokio::test]
|
||||
+ async fn test_fetch_models_happy_path() {
|
||||
+ if std::env::var(codex_core::spawn::CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR).is_ok() {
|
||||
+ tracing::info!(
|
||||
+ "{} is set; skipping test_fetch_models_happy_path",
|
||||
+ codex_core::spawn::CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR
|
||||
+ );
|
||||
+ return;
|
||||
+ }
|
||||
+
|
||||
+ let server = wiremock::MockServer::start().await;
|
||||
+ wiremock::Mock::given(wiremock::matchers::method("GET"))
|
||||
+ .and(wiremock::matchers::path("/api/tags"))
|
||||
+ .respond_with(
|
||||
+ wiremock::ResponseTemplate::new(200).set_body_raw(
|
||||
+ serde_json::json!({
|
||||
+ "models": [ {"name": "llama3.2:3b"}, {"name":"mistral"} ]
|
||||
+ })
|
||||
+ .to_string(),
|
||||
+ "application/json",
|
||||
+ ),
|
||||
+ )
|
||||
+ .mount(&server)
|
||||
+ .await;
|
||||
+
|
||||
+ let client = OllamaClient::from_host_root(server.uri());
|
||||
+ let models = client.fetch_models().await.expect("fetch models");
|
||||
+ assert!(models.contains(&"llama3.2:3b".to_string()));
|
||||
+ assert!(models.contains(&"mistral".to_string()));
|
||||
+ }
|
||||
+
|
||||
+ #[tokio::test]
|
||||
+ async fn test_probe_server_happy_path_openai_compat_and_native() {
|
||||
+ if std::env::var(codex_core::spawn::CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR).is_ok() {
|
||||
+ tracing::info!(
|
||||
+ "{} set; skipping test_probe_server_happy_path_openai_compat_and_native",
|
||||
+ codex_core::spawn::CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR
|
||||
+ );
|
||||
+ return;
|
||||
+ }
|
||||
+
|
||||
+ let server = wiremock::MockServer::start().await;
|
||||
+
|
||||
+ // Native endpoint
|
||||
+ wiremock::Mock::given(wiremock::matchers::method("GET"))
|
||||
+ .and(wiremock::matchers::path("/api/tags"))
|
||||
+ .respond_with(wiremock::ResponseTemplate::new(200))
|
||||
+ .mount(&server)
|
||||
+ .await;
|
||||
+ let native = OllamaClient::from_host_root(server.uri());
|
||||
+ assert!(native.probe_server().await.expect("probe native"));
|
||||
+
|
||||
+ // OpenAI compatibility endpoint
|
||||
+ wiremock::Mock::given(wiremock::matchers::method("GET"))
|
||||
+ .and(wiremock::matchers::path("/v1/models"))
|
||||
+ .respond_with(wiremock::ResponseTemplate::new(200))
|
||||
+ .mount(&server)
|
||||
+ .await;
|
||||
+ let ollama_client = OllamaClient::from_provider(&server.uri(), WireApi::Chat);
|
||||
+ assert!(ollama_client.probe_server().await.expect("probe compat"));
|
||||
+ }
|
||||
+}
|
||||
diff --git a/codex-rs/ollama/src/lib.rs b/codex-rs/ollama/src/lib.rs
|
||||
new file mode 100644
|
||||
index 0000000000..671e02a01e
|
||||
--- /dev/null
|
||||
+++ b/codex-rs/ollama/src/lib.rs
|
||||
@@ -0,0 +1,6 @@
|
||||
+mod client;
|
||||
+mod parser;
|
||||
+mod pull;
|
||||
+mod url;
|
||||
+
|
||||
+pub use client::OllamaClient;
|
||||
diff --git a/codex-rs/ollama/src/parser.rs b/codex-rs/ollama/src/parser.rs
|
||||
new file mode 100644
|
||||
index 0000000000..b3ed2ca8c3
|
||||
--- /dev/null
|
||||
+++ b/codex-rs/ollama/src/parser.rs
|
||||
@@ -0,0 +1,82 @@
|
||||
+use serde_json::Value as JsonValue;
|
||||
+
|
||||
+use crate::pull::PullEvent;
|
||||
+
|
||||
+// Convert a single JSON object representing a pull update into one or more events.
|
||||
+pub(crate) fn pull_events_from_value(value: &JsonValue) -> Vec<PullEvent> {
|
||||
+ let mut events = Vec::new();
|
||||
+ if let Some(status) = value.get("status").and_then(|s| s.as_str()) {
|
||||
+ events.push(PullEvent::Status(status.to_string()));
|
||||
+ if status == "success" {
|
||||
+ events.push(PullEvent::Success);
|
||||
+ }
|
||||
+ }
|
||||
+ let digest = value
|
||||
+ .get("digest")
|
||||
+ .and_then(|d| d.as_str())
|
||||
+ .unwrap_or("")
|
||||
+ .to_string();
|
||||
+ let total = value.get("total").and_then(|t| t.as_u64());
|
||||
+ let completed = value.get("completed").and_then(|t| t.as_u64());
|
||||
+ if total.is_some() || completed.is_some() {
|
||||
+ events.push(PullEvent::ChunkProgress {
|
||||
+ digest,
|
||||
+ total,
|
||||
+ completed,
|
||||
+ });
|
||||
+ }
|
||||
+ events
|
||||
+}
|
||||
+
|
||||
+#[cfg(test)]
|
||||
+mod tests {
|
||||
+ use super::*;
|
||||
+
|
||||
+ #[test]
|
||||
+ fn test_pull_events_decoder_status_and_success() {
|
||||
+ let v: JsonValue = serde_json::json!({"status":"verifying"});
|
||||
+ let events = pull_events_from_value(&v);
|
||||
+ assert!(matches!(events.as_slice(), [PullEvent::Status(s)] if s == "verifying"));
|
||||
+
|
||||
+ let v2: JsonValue = serde_json::json!({"status":"success"});
|
||||
+ let events2 = pull_events_from_value(&v2);
|
||||
+ assert_eq!(events2.len(), 2);
|
||||
+ assert!(matches!(events2[0], PullEvent::Status(ref s) if s == "success"));
|
||||
+ assert!(matches!(events2[1], PullEvent::Success));
|
||||
+ }
|
||||
+
|
||||
+ #[test]
|
||||
+ fn test_pull_events_decoder_progress() {
|
||||
+ let v: JsonValue = serde_json::json!({"digest":"sha256:abc","total":100});
|
||||
+ let events = pull_events_from_value(&v);
|
||||
+ assert_eq!(events.len(), 1);
|
||||
+ match &events[0] {
|
||||
+ PullEvent::ChunkProgress {
|
||||
+ digest,
|
||||
+ total,
|
||||
+ completed,
|
||||
+ } => {
|
||||
+ assert_eq!(digest, "sha256:abc");
|
||||
+ assert_eq!(*total, Some(100));
|
||||
+ assert_eq!(*completed, None);
|
||||
+ }
|
||||
+ _ => panic!("expected ChunkProgress"),
|
||||
+ }
|
||||
+
|
||||
+ let v2: JsonValue = serde_json::json!({"digest":"sha256:def","completed":42});
|
||||
+ let events2 = pull_events_from_value(&v2);
|
||||
+ assert_eq!(events2.len(), 1);
|
||||
+ match &events2[0] {
|
||||
+ PullEvent::ChunkProgress {
|
||||
+ digest,
|
||||
+ total,
|
||||
+ completed,
|
||||
+ } => {
|
||||
+ assert_eq!(digest, "sha256:def");
|
||||
+ assert_eq!(*total, None);
|
||||
+ assert_eq!(*completed, Some(42));
|
||||
+ }
|
||||
+ _ => panic!("expected ChunkProgress"),
|
||||
+ }
|
||||
+ }
|
||||
+}
|
||||
diff --git a/codex-rs/ollama/src/pull.rs b/codex-rs/ollama/src/pull.rs
|
||||
new file mode 100644
|
||||
index 0000000000..aebca698eb
|
||||
--- /dev/null
|
||||
+++ b/codex-rs/ollama/src/pull.rs
|
||||
@@ -0,0 +1,139 @@
|
||||
+use std::collections::HashMap;
|
||||
+use std::io;
|
||||
+use std::io::Write;
|
||||
+
|
||||
+/// Events emitted while pulling a model from Ollama.
|
||||
+#[derive(Debug, Clone)]
|
||||
+pub enum PullEvent {
|
||||
+ /// A human-readable status message (e.g., "verifying", "writing").
|
||||
+ Status(String),
|
||||
+ /// Byte-level progress update for a specific layer digest.
|
||||
+ ChunkProgress {
|
||||
+ digest: String,
|
||||
+ total: Option<u64>,
|
||||
+ completed: Option<u64>,
|
||||
+ },
|
||||
+ /// The pull finished successfully.
|
||||
+ Success,
|
||||
+}
|
||||
+
|
||||
+/// A simple observer for pull progress events. Implementations decide how to
|
||||
+/// render progress (CLI, TUI, logs, ...).
|
||||
+pub trait PullProgressReporter {
|
||||
+ fn on_event(&mut self, event: &PullEvent) -> io::Result<()>;
|
||||
+}
|
||||
+
|
||||
+/// A minimal CLI reporter that writes inline progress to stderr.
|
||||
+pub struct CliProgressReporter {
|
||||
+ printed_header: bool,
|
||||
+ last_line_len: usize,
|
||||
+ last_completed_sum: u64,
|
||||
+ last_instant: std::time::Instant,
|
||||
+ totals_by_digest: HashMap<String, (u64, u64)>,
|
||||
+}
|
||||
+
|
||||
+impl Default for CliProgressReporter {
|
||||
+ fn default() -> Self {
|
||||
+ Self::new()
|
||||
+ }
|
||||
+}
|
||||
+
|
||||
+impl CliProgressReporter {
|
||||
+ pub fn new() -> Self {
|
||||
+ Self {
|
||||
+ printed_header: false,
|
||||
+ last_line_len: 0,
|
||||
+ last_completed_sum: 0,
|
||||
+ last_instant: std::time::Instant::now(),
|
||||
+ totals_by_digest: HashMap::new(),
|
||||
+ }
|
||||
+ }
|
||||
+}
|
||||
+
|
||||
+impl PullProgressReporter for CliProgressReporter {
|
||||
+ fn on_event(&mut self, event: &PullEvent) -> io::Result<()> {
|
||||
+ let mut out = std::io::stderr();
|
||||
+ match event {
|
||||
+ PullEvent::Status(status) => {
|
||||
+ // Avoid noisy manifest messages; otherwise show status inline.
|
||||
+ if status.eq_ignore_ascii_case("pulling manifest") {
|
||||
+ return Ok(());
|
||||
+ }
|
||||
+ let pad = self.last_line_len.saturating_sub(status.len());
|
||||
+ let line = format!("\r{status}{}", " ".repeat(pad));
|
||||
+ self.last_line_len = status.len();
|
||||
+ out.write_all(line.as_bytes())?;
|
||||
+ out.flush()
|
||||
+ }
|
||||
+ PullEvent::ChunkProgress {
|
||||
+ digest,
|
||||
+ total,
|
||||
+ completed,
|
||||
+ } => {
|
||||
+ if let Some(t) = *total {
|
||||
+ self.totals_by_digest
|
||||
+ .entry(digest.clone())
|
||||
+ .or_insert((0, 0))
|
||||
+ .0 = t;
|
||||
+ }
|
||||
+ if let Some(c) = *completed {
|
||||
+ self.totals_by_digest
|
||||
+ .entry(digest.clone())
|
||||
+ .or_insert((0, 0))
|
||||
+ .1 = c;
|
||||
+ }
|
||||
+
|
||||
+ let (sum_total, sum_completed) = self
|
||||
+ .totals_by_digest
|
||||
+ .values()
|
||||
+ .fold((0u64, 0u64), |acc, (t, c)| (acc.0 + *t, acc.1 + *c));
|
||||
+ if sum_total > 0 {
|
||||
+ if !self.printed_header {
|
||||
+ let gb = (sum_total as f64) / (1024.0 * 1024.0 * 1024.0);
|
||||
+ let header = format!("Downloading model: total {gb:.2} GB\n");
|
||||
+ out.write_all(b"\r\x1b[2K")?;
|
||||
+ out.write_all(header.as_bytes())?;
|
||||
+ self.printed_header = true;
|
||||
+ }
|
||||
+ let now = std::time::Instant::now();
|
||||
+ let dt = now
|
||||
+ .duration_since(self.last_instant)
|
||||
+ .as_secs_f64()
|
||||
+ .max(0.001);
|
||||
+ let dbytes = sum_completed.saturating_sub(self.last_completed_sum) as f64;
|
||||
+ let speed_mb_s = dbytes / (1024.0 * 1024.0) / dt;
|
||||
+ self.last_completed_sum = sum_completed;
|
||||
+ self.last_instant = now;
|
||||
+
|
||||
+ let done_gb = (sum_completed as f64) / (1024.0 * 1024.0 * 1024.0);
|
||||
+ let total_gb = (sum_total as f64) / (1024.0 * 1024.0 * 1024.0);
|
||||
+ let pct = (sum_completed as f64) * 100.0 / (sum_total as f64);
|
||||
+ let text =
|
||||
+ format!("{done_gb:.2}/{total_gb:.2} GB ({pct:.1}%) {speed_mb_s:.1} MB/s");
|
||||
+ let pad = self.last_line_len.saturating_sub(text.len());
|
||||
+ let line = format!("\r{text}{}", " ".repeat(pad));
|
||||
+ self.last_line_len = text.len();
|
||||
+ out.write_all(line.as_bytes())?;
|
||||
+ out.flush()
|
||||
+ } else {
|
||||
+ Ok(())
|
||||
+ }
|
||||
+ }
|
||||
+ PullEvent::Success => {
|
||||
+ out.write_all(b"\n")?;
|
||||
+ out.flush()
|
||||
+ }
|
||||
+ }
|
||||
+ }
|
||||
+}
|
||||
+
|
||||
+/// For now the TUI reporter delegates to the CLI reporter. This keeps UI and
|
||||
+/// CLI behavior aligned until a dedicated TUI integration is implemented.
|
||||
+#[derive(Default)]
|
||||
+pub struct TuiProgressReporter(CliProgressReporter);
|
||||
+
|
||||
+impl PullProgressReporter for TuiProgressReporter {
|
||||
+ fn on_event(&mut self, event: &PullEvent) -> io::Result<()> {
|
||||
+ self.0.on_event(event)
|
||||
+ }
|
||||
+}
|
||||
diff --git a/codex-rs/ollama/src/url.rs b/codex-rs/ollama/src/url.rs
|
||||
new file mode 100644
|
||||
index 0000000000..7c143ce426
|
||||
--- /dev/null
|
||||
+++ b/codex-rs/ollama/src/url.rs
|
||||
@@ -0,0 +1,39 @@
|
||||
+/// Identify whether a base_url points at an OpenAI-compatible root (".../v1").
|
||||
+pub(crate) fn is_openai_compatible_base_url(base_url: &str) -> bool {
|
||||
+ base_url.trim_end_matches('/').ends_with("/v1")
|
||||
+}
|
||||
+
|
||||
+/// Convert a provider base_url into the native Ollama host root.
|
||||
+/// For example, "http://localhost:11434/v1" -> "http://localhost:11434".
|
||||
+pub fn base_url_to_host_root(base_url: &str) -> String {
|
||||
+ let trimmed = base_url.trim_end_matches('/');
|
||||
+ if trimmed.ends_with("/v1") {
|
||||
+ trimmed
|
||||
+ .trim_end_matches("/v1")
|
||||
+ .trim_end_matches('/')
|
||||
+ .to_string()
|
||||
+ } else {
|
||||
+ trimmed.to_string()
|
||||
+ }
|
||||
+}
|
||||
+
|
||||
+#[cfg(test)]
|
||||
+mod tests {
|
||||
+ use super::*;
|
||||
+
|
||||
+ #[test]
|
||||
+ fn test_base_url_to_host_root() {
|
||||
+ assert_eq!(
|
||||
+ base_url_to_host_root("http://localhost:11434/v1"),
|
||||
+ "http://localhost:11434"
|
||||
+ );
|
||||
+ assert_eq!(
|
||||
+ base_url_to_host_root("http://localhost:11434"),
|
||||
+ "http://localhost:11434"
|
||||
+ );
|
||||
+ assert_eq!(
|
||||
+ base_url_to_host_root("http://localhost:11434/"),
|
||||
+ "http://localhost:11434"
|
||||
+ );
|
||||
+ }
|
||||
+}
|
||||
diff --git a/codex-rs/tui/Cargo.toml b/codex-rs/tui/Cargo.toml
|
||||
index 60af056a2d..254373e156 100644
|
||||
--- a/codex-rs/tui/Cargo.toml
|
||||
+++ b/codex-rs/tui/Cargo.toml
|
||||
@@ -33,6 +33,7 @@ codex-common = { path = "../common", features = [
|
||||
codex-core = { path = "../core" }
|
||||
codex-file-search = { path = "../file-search" }
|
||||
codex-login = { path = "../login" }
|
||||
+codex-ollama = { path = "../ollama" }
|
||||
color-eyre = "0.6.3"
|
||||
crossterm = { version = "0.28.1", features = ["bracketed-paste"] }
|
||||
image = { version = "^0.25.6", default-features = false, features = ["jpeg"] }
|
||||
@@ -70,11 +71,9 @@ unicode-segmentation = "1.12.0"
|
||||
unicode-width = "0.1"
|
||||
uuid = "1"
|
||||
|
||||
-
|
||||
-
|
||||
[dev-dependencies]
|
||||
+chrono = { version = "0.4", features = ["serde"] }
|
||||
insta = "1.43.1"
|
||||
pretty_assertions = "1"
|
||||
rand = "0.8"
|
||||
-chrono = { version = "0.4", features = ["serde"] }
|
||||
vt100 = "0.16.2"
|
||||
diff --git a/codex-rs/tui/src/cli.rs b/codex-rs/tui/src/cli.rs
|
||||
index cb1b725a64..85dffbebb3 100644
|
||||
--- a/codex-rs/tui/src/cli.rs
|
||||
+++ b/codex-rs/tui/src/cli.rs
|
||||
@@ -17,6 +17,12 @@ pub struct Cli {
|
||||
#[arg(long, short = 'm')]
|
||||
pub model: Option<String>,
|
||||
|
||||
+ /// Convenience flag to select the local open source model provider.
|
||||
+ /// Equivalent to -c model_provider=oss; verifies a local Ollama server is
|
||||
+ /// running.
|
||||
+ #[arg(long = "oss", default_value_t = false)]
|
||||
+ pub oss: bool,
|
||||
+
|
||||
/// Configuration profile from config.toml to specify default options.
|
||||
#[arg(long = "profile", short = 'p')]
|
||||
pub config_profile: Option<String>,
|
||||
diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs
|
||||
index c619ce8ff0..64b75769bd 100644
|
||||
--- a/codex-rs/tui/src/lib.rs
|
||||
+++ b/codex-rs/tui/src/lib.rs
|
||||
@@ -3,12 +3,14 @@
|
||||
// alternate‑screen mode starts; that file opts‑out locally via `allow`.
|
||||
#![deny(clippy::print_stdout, clippy::print_stderr)]
|
||||
use app::App;
|
||||
+use codex_core::BUILT_IN_OSS_MODEL_PROVIDER_ID;
|
||||
use codex_core::config::Config;
|
||||
use codex_core::config::ConfigOverrides;
|
||||
use codex_core::config_types::SandboxMode;
|
||||
use codex_core::protocol::AskForApproval;
|
||||
use codex_core::util::is_inside_git_repo;
|
||||
use codex_login::load_auth;
|
||||
+use codex_ollama::OllamaClient;
|
||||
use log_layer::TuiLogLayer;
|
||||
use std::fs::OpenOptions;
|
||||
use std::io::Write;
|
||||
@@ -70,6 +72,11 @@ pub async fn run_main(
|
||||
)
|
||||
};
|
||||
|
||||
+ let model_provider_override = if cli.oss {
|
||||
+ Some(BUILT_IN_OSS_MODEL_PROVIDER_ID.to_owned())
|
||||
+ } else {
|
||||
+ None
|
||||
+ };
|
||||
let config = {
|
||||
// Load configuration and support CLI overrides.
|
||||
let overrides = ConfigOverrides {
|
||||
@@ -77,7 +84,7 @@ pub async fn run_main(
|
||||
approval_policy,
|
||||
sandbox_mode,
|
||||
cwd: cli.cwd.clone().map(|p| p.canonicalize().unwrap_or(p)),
|
||||
- model_provider: None,
|
||||
+ model_provider: model_provider_override,
|
||||
config_profile: cli.config_profile.clone(),
|
||||
codex_linux_sandbox_exe,
|
||||
base_instructions: None,
|
||||
@@ -177,6 +184,23 @@ pub async fn run_main(
|
||||
eprintln!("");
|
||||
}
|
||||
|
||||
+ if cli.oss {
|
||||
+ // Should maybe load the client using `config.model_provider`?
|
||||
+ let ollama_client = OllamaClient::from_oss_provider();
|
||||
+ let is_ollama_available = ollama_client.probe_server().await?;
|
||||
+ #[allow(clippy::print_stderr)]
|
||||
+ if !is_ollama_available {
|
||||
+ eprintln!(
|
||||
+ "Ollama server is not reachable at {}. Please ensure Ollama is running.",
|
||||
+ ollama_client.get_host()
|
||||
+ );
|
||||
+ std::process::exit(1);
|
||||
+ }
|
||||
+
|
||||
+ // TODO(easong): Check if the model is available, and if not, prompt the
|
||||
+ // user to pull it.
|
||||
+ }
|
||||
+
|
||||
let show_login_screen = should_show_login_screen(&config);
|
||||
if show_login_screen {
|
||||
std::io::stdout()
|
||||
```
|
||||
|
||||
## Review Comments
|
||||
|
||||
### codex-rs/tui/src/cli.rs
|
||||
|
||||
- Created: 2025-08-05 09:46:00 UTC | Link: https://github.com/openai/codex/pull/1847#discussion_r2253770695
|
||||
|
||||
```diff
|
||||
@@ -17,6 +17,12 @@ pub struct Cli {
|
||||
#[arg(long, short = 'm')]
|
||||
pub model: Option<String>,
|
||||
|
||||
+ /// Convenience flag to select the local open source model provider.
|
||||
+ /// Equivalent to -c model_provider=oss; verifies a local Ollama server is
|
||||
```
|
||||
|
||||
> should really be a macro for `--profile oss` so you can have a default model for your `oss` profile that differs from the default model for your default profile
|
||||
|
||||
### codex-rs/tui/src/lib.rs
|
||||
|
||||
- Created: 2025-08-05 09:48:08 UTC | Link: https://github.com/openai/codex/pull/1847#discussion_r2253777890
|
||||
|
||||
```diff
|
||||
@@ -177,6 +184,23 @@ pub async fn run_main(
|
||||
eprintln!("");
|
||||
}
|
||||
|
||||
+ if cli.oss {
|
||||
```
|
||||
|
||||
> Also, we need a comparable change to `codex exec`, but it seemed easier to test it out here first.
|
||||
114
prs/bolinfest/PR-1852.md
Normal file
114
prs/bolinfest/PR-1852.md
Normal file
@@ -0,0 +1,114 @@
|
||||
# PR #1852: exec: timeout on grandchildren
|
||||
|
||||
- URL: https://github.com/openai/codex/pull/1852
|
||||
- Author: md-oai
|
||||
- Created: 2025-08-05 18:04:58 UTC
|
||||
- Updated: 2025-08-05 21:18:43 UTC
|
||||
- Changes: +43/-2, Files changed: 1, Commits: 3
|
||||
|
||||
## Description
|
||||
|
||||
We were enforcing the 10 s wall-clock limit only on the child process. If that child (bash) spawns grandchildren and we kill it on timeout, those grandchildren still have the original stdout/err pipe open, so the background tasks that are draining the pipes block forever
|
||||
|
||||
## Full Diff
|
||||
|
||||
```diff
|
||||
diff --git a/codex-rs/core/src/exec.rs b/codex-rs/core/src/exec.rs
|
||||
index dce02cc5e2..a8bbd0f52c 100644
|
||||
--- a/codex-rs/core/src/exec.rs
|
||||
+++ b/codex-rs/core/src/exec.rs
|
||||
@@ -344,8 +344,49 @@ pub(crate) async fn consume_truncated_output(
|
||||
}
|
||||
};
|
||||
|
||||
- let stdout = stdout_handle.await??;
|
||||
- let stderr = stderr_handle.await??;
|
||||
+ // Wait for the stdout/stderr collection tasks but guard against them
|
||||
+ // hanging forever. In the normal case both pipes are closed once the child
|
||||
+ // terminates so the tasks exit quickly. However, if the child process
|
||||
+ // spawned grandchildren that inherited its stdout/stderr file descriptors
|
||||
+ // those pipes may stay open after we `kill` the direct child on timeout.
|
||||
+ // That would cause the `read_capped` tasks to block on `read()`
|
||||
+ // indefinitely, effectively hanging the whole agent.
|
||||
+
|
||||
+ const IO_DRAIN_TIMEOUT_MS: u64 = 2_000; // 2 s should be plenty for local pipes
|
||||
+
|
||||
+ // We need mutable bindings so we can `abort()` them on timeout.
|
||||
+ use tokio::task::JoinHandle;
|
||||
+
|
||||
+ async fn await_with_timeout(
|
||||
+ handle: &mut JoinHandle<std::io::Result<Vec<u8>>>,
|
||||
+ timeout: Duration,
|
||||
+ ) -> std::io::Result<Vec<u8>> {
|
||||
+ match tokio::time::timeout(timeout, &mut *handle).await {
|
||||
+ Ok(join_res) => match join_res {
|
||||
+ Ok(io_res) => io_res,
|
||||
+ Err(join_err) => Err(std::io::Error::other(join_err)),
|
||||
+ },
|
||||
+ Err(_elapsed) => {
|
||||
+ // Timeout: abort the task to avoid hanging on open pipes.
|
||||
+ handle.abort();
|
||||
+ Ok(Vec::new())
|
||||
+ }
|
||||
+ }
|
||||
+ }
|
||||
+
|
||||
+ let mut stdout_handle = stdout_handle;
|
||||
+ let mut stderr_handle = stderr_handle;
|
||||
+
|
||||
+ let stdout = await_with_timeout(
|
||||
+ &mut stdout_handle,
|
||||
+ Duration::from_millis(IO_DRAIN_TIMEOUT_MS),
|
||||
+ )
|
||||
+ .await?;
|
||||
+ let stderr = await_with_timeout(
|
||||
+ &mut stderr_handle,
|
||||
+ Duration::from_millis(IO_DRAIN_TIMEOUT_MS),
|
||||
+ )
|
||||
+ .await?;
|
||||
|
||||
Ok(RawExecToolCallOutput {
|
||||
exit_status,
|
||||
```
|
||||
|
||||
## Review Comments
|
||||
|
||||
### codex-rs/core/src/exec.rs
|
||||
|
||||
- Created: 2025-08-05 18:42:23 UTC | Link: https://github.com/openai/codex/pull/1852#discussion_r2255069686
|
||||
|
||||
```diff
|
||||
@@ -344,8 +344,50 @@ pub(crate) async fn consume_truncated_output(
|
||||
}
|
||||
};
|
||||
|
||||
- let stdout = stdout_handle.await??;
|
||||
- let stderr = stderr_handle.await??;
|
||||
+ // Wait for the stdout/stderr collection tasks but guard against them
|
||||
+ // hanging forever. In the normal case both pipes are closed once the child
|
||||
+ // terminates so the tasks exit quickly. However, if the child process
|
||||
+ // spawned grandchildren that inherited its stdout/stderr file descriptors
|
||||
+ // those pipes may stay open after we `kill` the direct child on timeout.
|
||||
+ // That would cause the `read_capped` tasks to block on `read()`
|
||||
+ // indefinitely, effectively hanging the whole agent.
|
||||
+
|
||||
+ const IO_DRAIN_TIMEOUT_MS: u64 = 2_000; // 2 s should be plenty for local pipes
|
||||
+
|
||||
+ // We need mutable bindings so we can `abort()` them on timeout.
|
||||
+ use tokio::task::JoinHandle;
|
||||
+
|
||||
+ async fn await_with_timeout(
|
||||
+ handle: &mut JoinHandle<std::io::Result<Vec<u8>>>,
|
||||
+ timeout: Duration,
|
||||
+ ) -> std::io::Result<Vec<u8>> {
|
||||
+ tokio::select! {
|
||||
+ join_res = &mut *handle => {
|
||||
+ match join_res {
|
||||
+ Ok(io_res) => io_res,
|
||||
+ Err(join_err) => Err(std::io::Error::other(join_err)),
|
||||
+ }
|
||||
+ },
|
||||
+ _ = tokio::time::sleep(timeout) => {
|
||||
```
|
||||
|
||||
> Can/should we use `use tokio::time::timeout` instead?
|
||||
407
prs/bolinfest/PR-1861.md
Normal file
407
prs/bolinfest/PR-1861.md
Normal file
@@ -0,0 +1,407 @@
|
||||
# PR #1861: Prefer env var auth over default codex auth
|
||||
|
||||
- URL: https://github.com/openai/codex/pull/1861
|
||||
- Author: pakrym-oai
|
||||
- Created: 2025-08-05 20:51:39 UTC
|
||||
- Updated: 2025-08-06 20:02:09 UTC
|
||||
- Changes: +107/-30, Files changed: 6, Commits: 4
|
||||
|
||||
## Description
|
||||
|
||||
## Summary
|
||||
- Prioritize provider-specific API keys over default Codex auth when building requests
|
||||
- Add test to ensure provider env var auth overrides default auth
|
||||
|
||||
## Testing
|
||||
- `just fmt`
|
||||
- `just fix` *(fails: `let` expressions in this position are unstable)*
|
||||
- `cargo test --all-features` *(fails: `let` expressions in this position are unstable)*
|
||||
|
||||
------
|
||||
https://chatgpt.com/codex/tasks/task_i_68926a104f7483208f2c8fd36763e0e3
|
||||
|
||||
## Full Diff
|
||||
|
||||
```diff
|
||||
diff --git a/codex-rs/core/src/client.rs b/codex-rs/core/src/client.rs
|
||||
index 9748cde7cb..ed05fb5db0 100644
|
||||
--- a/codex-rs/core/src/client.rs
|
||||
+++ b/codex-rs/core/src/client.rs
|
||||
@@ -623,7 +623,7 @@ mod tests {
|
||||
request_max_retries: Some(0),
|
||||
stream_max_retries: Some(0),
|
||||
stream_idle_timeout_ms: Some(1000),
|
||||
- requires_auth: false,
|
||||
+ requires_openai_auth: false,
|
||||
};
|
||||
|
||||
let events = collect_events(
|
||||
@@ -683,7 +683,7 @@ mod tests {
|
||||
request_max_retries: Some(0),
|
||||
stream_max_retries: Some(0),
|
||||
stream_idle_timeout_ms: Some(1000),
|
||||
- requires_auth: false,
|
||||
+ requires_openai_auth: false,
|
||||
};
|
||||
|
||||
let events = collect_events(&[sse1.as_bytes()], provider).await;
|
||||
@@ -786,7 +786,7 @@ mod tests {
|
||||
request_max_retries: Some(0),
|
||||
stream_max_retries: Some(0),
|
||||
stream_idle_timeout_ms: Some(1000),
|
||||
- requires_auth: false,
|
||||
+ requires_openai_auth: false,
|
||||
};
|
||||
|
||||
let out = run_sse(evs, provider).await;
|
||||
diff --git a/codex-rs/core/src/config.rs b/codex-rs/core/src/config.rs
|
||||
index f48cc9340b..63a2e5949f 100644
|
||||
--- a/codex-rs/core/src/config.rs
|
||||
+++ b/codex-rs/core/src/config.rs
|
||||
@@ -842,7 +842,7 @@ disable_response_storage = true
|
||||
request_max_retries: Some(4),
|
||||
stream_max_retries: Some(10),
|
||||
stream_idle_timeout_ms: Some(300_000),
|
||||
- requires_auth: false,
|
||||
+ requires_openai_auth: false,
|
||||
};
|
||||
let model_provider_map = {
|
||||
let mut model_provider_map = built_in_model_providers();
|
||||
diff --git a/codex-rs/core/src/model_provider_info.rs b/codex-rs/core/src/model_provider_info.rs
|
||||
index db369df3b7..a980211199 100644
|
||||
--- a/codex-rs/core/src/model_provider_info.rs
|
||||
+++ b/codex-rs/core/src/model_provider_info.rs
|
||||
@@ -9,7 +9,6 @@ use codex_login::AuthMode;
|
||||
use codex_login::CodexAuth;
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
-use std::borrow::Cow;
|
||||
use std::collections::HashMap;
|
||||
use std::env::VarError;
|
||||
use std::time::Duration;
|
||||
@@ -79,7 +78,7 @@ pub struct ModelProviderInfo {
|
||||
|
||||
/// Whether this provider requires some form of standard authentication (API key, ChatGPT token).
|
||||
#[serde(default)]
|
||||
- pub requires_auth: bool,
|
||||
+ pub requires_openai_auth: bool,
|
||||
}
|
||||
|
||||
impl ModelProviderInfo {
|
||||
@@ -87,26 +86,32 @@ impl ModelProviderInfo {
|
||||
/// reqwest Client applying:
|
||||
/// • provider-specific headers (static + env based)
|
||||
/// • Bearer auth header when an API key is available.
|
||||
+ /// • Auth token for OAuth.
|
||||
///
|
||||
- /// When `require_api_key` is true and the provider declares an `env_key`
|
||||
- /// but the variable is missing/empty, returns an [`Err`] identical to the
|
||||
+ /// If the provider declares an `env_key` but the variable is missing/empty, returns an [`Err`] identical to the
|
||||
/// one produced by [`ModelProviderInfo::api_key`].
|
||||
pub async fn create_request_builder<'a>(
|
||||
&'a self,
|
||||
client: &'a reqwest::Client,
|
||||
auth: &Option<CodexAuth>,
|
||||
) -> crate::error::Result<reqwest::RequestBuilder> {
|
||||
- let auth: Cow<'_, Option<CodexAuth>> = if auth.is_some() {
|
||||
- Cow::Borrowed(auth)
|
||||
- } else {
|
||||
- Cow::Owned(self.get_fallback_auth()?)
|
||||
+ let effective_auth = match self.api_key() {
|
||||
+ Ok(Some(key)) => Some(CodexAuth::from_api_key(key)),
|
||||
+ Ok(None) => auth.clone(),
|
||||
+ Err(err) => {
|
||||
+ if auth.is_some() {
|
||||
+ auth.clone()
|
||||
+ } else {
|
||||
+ return Err(err);
|
||||
+ }
|
||||
+ }
|
||||
};
|
||||
|
||||
- let url = self.get_full_url(&auth);
|
||||
+ let url = self.get_full_url(&effective_auth);
|
||||
|
||||
let mut builder = client.post(url);
|
||||
|
||||
- if let Some(auth) = auth.as_ref() {
|
||||
+ if let Some(auth) = effective_auth.as_ref() {
|
||||
builder = builder.bearer_auth(auth.get_token().await?);
|
||||
}
|
||||
|
||||
@@ -216,14 +221,6 @@ impl ModelProviderInfo {
|
||||
.map(Duration::from_millis)
|
||||
.unwrap_or(Duration::from_millis(DEFAULT_STREAM_IDLE_TIMEOUT_MS))
|
||||
}
|
||||
-
|
||||
- fn get_fallback_auth(&self) -> crate::error::Result<Option<CodexAuth>> {
|
||||
- let api_key = self.api_key()?;
|
||||
- if let Some(api_key) = api_key {
|
||||
- return Ok(Some(CodexAuth::from_api_key(api_key)));
|
||||
- }
|
||||
- Ok(None)
|
||||
- }
|
||||
}
|
||||
|
||||
const DEFAULT_OLLAMA_PORT: u32 = 11434;
|
||||
@@ -275,7 +272,7 @@ pub fn built_in_model_providers() -> HashMap<String, ModelProviderInfo> {
|
||||
request_max_retries: None,
|
||||
stream_max_retries: None,
|
||||
stream_idle_timeout_ms: None,
|
||||
- requires_auth: true,
|
||||
+ requires_openai_auth: true,
|
||||
},
|
||||
),
|
||||
(BUILT_IN_OSS_MODEL_PROVIDER_ID, create_oss_provider()),
|
||||
@@ -319,7 +316,7 @@ pub fn create_oss_provider_with_base_url(base_url: &str) -> ModelProviderInfo {
|
||||
request_max_retries: None,
|
||||
stream_max_retries: None,
|
||||
stream_idle_timeout_ms: None,
|
||||
- requires_auth: false,
|
||||
+ requires_openai_auth: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -347,7 +344,7 @@ base_url = "http://localhost:11434/v1"
|
||||
request_max_retries: None,
|
||||
stream_max_retries: None,
|
||||
stream_idle_timeout_ms: None,
|
||||
- requires_auth: false,
|
||||
+ requires_openai_auth: false,
|
||||
};
|
||||
|
||||
let provider: ModelProviderInfo = toml::from_str(azure_provider_toml).unwrap();
|
||||
@@ -376,7 +373,7 @@ query_params = { api-version = "2025-04-01-preview" }
|
||||
request_max_retries: None,
|
||||
stream_max_retries: None,
|
||||
stream_idle_timeout_ms: None,
|
||||
- requires_auth: false,
|
||||
+ requires_openai_auth: false,
|
||||
};
|
||||
|
||||
let provider: ModelProviderInfo = toml::from_str(azure_provider_toml).unwrap();
|
||||
@@ -408,7 +405,7 @@ env_http_headers = { "X-Example-Env-Header" = "EXAMPLE_ENV_VAR" }
|
||||
request_max_retries: None,
|
||||
stream_max_retries: None,
|
||||
stream_idle_timeout_ms: None,
|
||||
- requires_auth: false,
|
||||
+ requires_openai_auth: false,
|
||||
};
|
||||
|
||||
let provider: ModelProviderInfo = toml::from_str(azure_provider_toml).unwrap();
|
||||
diff --git a/codex-rs/core/tests/client.rs b/codex-rs/core/tests/client.rs
|
||||
index 00f91a879e..60eb922474 100644
|
||||
--- a/codex-rs/core/tests/client.rs
|
||||
+++ b/codex-rs/core/tests/client.rs
|
||||
@@ -458,7 +458,7 @@ async fn azure_overrides_assign_properties_used_for_responses_url() {
|
||||
request_max_retries: None,
|
||||
stream_max_retries: None,
|
||||
stream_idle_timeout_ms: None,
|
||||
- requires_auth: false,
|
||||
+ requires_openai_auth: false,
|
||||
};
|
||||
|
||||
// Init session
|
||||
@@ -481,6 +481,86 @@ async fn azure_overrides_assign_properties_used_for_responses_url() {
|
||||
wait_for_event(&codex, |ev| matches!(ev, EventMsg::TaskComplete(_))).await;
|
||||
}
|
||||
|
||||
+#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
+async fn env_var_overrides_loaded_auth() {
|
||||
+ #![allow(clippy::unwrap_used)]
|
||||
+
|
||||
+ let existing_env_var_with_random_value = if cfg!(windows) { "USERNAME" } else { "USER" };
|
||||
+
|
||||
+ // Mock server
|
||||
+ let server = MockServer::start().await;
|
||||
+
|
||||
+ // First request – must NOT include `previous_response_id`.
|
||||
+ let first = ResponseTemplate::new(200)
|
||||
+ .insert_header("content-type", "text/event-stream")
|
||||
+ .set_body_raw(sse_completed("resp1"), "text/event-stream");
|
||||
+
|
||||
+ // Expect POST to /openai/responses with api-version query param
|
||||
+ Mock::given(method("POST"))
|
||||
+ .and(path("/openai/responses"))
|
||||
+ .and(query_param("api-version", "2025-04-01-preview"))
|
||||
+ .and(header_regex("Custom-Header", "Value"))
|
||||
+ .and(header_regex(
|
||||
+ "Authorization",
|
||||
+ format!(
|
||||
+ "Bearer {}",
|
||||
+ std::env::var(existing_env_var_with_random_value).unwrap()
|
||||
+ )
|
||||
+ .as_str(),
|
||||
+ ))
|
||||
+ .respond_with(first)
|
||||
+ .expect(1)
|
||||
+ .mount(&server)
|
||||
+ .await;
|
||||
+
|
||||
+ let provider = ModelProviderInfo {
|
||||
+ name: "custom".to_string(),
|
||||
+ base_url: Some(format!("{}/openai", server.uri())),
|
||||
+ // Reuse the existing environment variable to avoid using unsafe code
|
||||
+ env_key: Some(existing_env_var_with_random_value.to_string()),
|
||||
+ query_params: Some(std::collections::HashMap::from([(
|
||||
+ "api-version".to_string(),
|
||||
+ "2025-04-01-preview".to_string(),
|
||||
+ )])),
|
||||
+ env_key_instructions: None,
|
||||
+ wire_api: WireApi::Responses,
|
||||
+ http_headers: Some(std::collections::HashMap::from([(
|
||||
+ "Custom-Header".to_string(),
|
||||
+ "Value".to_string(),
|
||||
+ )])),
|
||||
+ env_http_headers: None,
|
||||
+ request_max_retries: None,
|
||||
+ stream_max_retries: None,
|
||||
+ stream_idle_timeout_ms: None,
|
||||
+ requires_openai_auth: false,
|
||||
+ };
|
||||
+
|
||||
+ // Init session
|
||||
+ let codex_home = TempDir::new().unwrap();
|
||||
+ let mut config = load_default_config_for_test(&codex_home);
|
||||
+ config.model_provider = provider;
|
||||
+
|
||||
+ let ctrl_c = std::sync::Arc::new(tokio::sync::Notify::new());
|
||||
+ let CodexSpawnOk { codex, .. } = Codex::spawn(
|
||||
+ config,
|
||||
+ Some(auth_from_token("Default Access Token".to_string())),
|
||||
+ ctrl_c.clone(),
|
||||
+ )
|
||||
+ .await
|
||||
+ .unwrap();
|
||||
+
|
||||
+ codex
|
||||
+ .submit(Op::UserInput {
|
||||
+ items: vec![InputItem::Text {
|
||||
+ text: "hello".into(),
|
||||
+ }],
|
||||
+ })
|
||||
+ .await
|
||||
+ .unwrap();
|
||||
+
|
||||
+ wait_for_event(&codex, |ev| matches!(ev, EventMsg::TaskComplete(_))).await;
|
||||
+}
|
||||
+
|
||||
fn auth_from_token(id_token: String) -> CodexAuth {
|
||||
CodexAuth::new(
|
||||
None,
|
||||
diff --git a/codex-rs/core/tests/stream_no_completed.rs b/codex-rs/core/tests/stream_no_completed.rs
|
||||
index 3e30d93709..8a4216b129 100644
|
||||
--- a/codex-rs/core/tests/stream_no_completed.rs
|
||||
+++ b/codex-rs/core/tests/stream_no_completed.rs
|
||||
@@ -90,7 +90,7 @@ async fn retries_on_early_close() {
|
||||
request_max_retries: Some(0),
|
||||
stream_max_retries: Some(1),
|
||||
stream_idle_timeout_ms: Some(2000),
|
||||
- requires_auth: false,
|
||||
+ requires_openai_auth: false,
|
||||
};
|
||||
|
||||
let ctrl_c = std::sync::Arc::new(tokio::sync::Notify::new());
|
||||
diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs
|
||||
index 50535e5967..0228a56859 100644
|
||||
--- a/codex-rs/tui/src/lib.rs
|
||||
+++ b/codex-rs/tui/src/lib.rs
|
||||
@@ -287,7 +287,7 @@ fn restore() {
|
||||
|
||||
#[allow(clippy::unwrap_used)]
|
||||
fn should_show_login_screen(config: &Config) -> bool {
|
||||
- if config.model_provider.requires_auth {
|
||||
+ if config.model_provider.requires_openai_auth {
|
||||
// Reading the OpenAI API key is an async operation because it may need
|
||||
// to refresh the token. Block on it.
|
||||
let codex_home = config.codex_home.clone();
|
||||
```
|
||||
|
||||
## Review Comments
|
||||
|
||||
### codex-rs/core/src/model_provider_info.rs
|
||||
|
||||
- Created: 2025-08-05 23:42:16 UTC | Link: https://github.com/openai/codex/pull/1861#discussion_r2255546130
|
||||
|
||||
```diff
|
||||
@@ -96,17 +95,23 @@ impl ModelProviderInfo {
|
||||
client: &'a reqwest::Client,
|
||||
auth: &Option<CodexAuth>,
|
||||
) -> crate::error::Result<reqwest::RequestBuilder> {
|
||||
- let auth: Cow<'_, Option<CodexAuth>> = if auth.is_some() {
|
||||
- Cow::Borrowed(auth)
|
||||
- } else {
|
||||
- Cow::Owned(self.get_fallback_auth()?)
|
||||
+ let effective_auth = match self.api_key() {
|
||||
```
|
||||
|
||||
> docstring for this function seems to be out of date?
|
||||
|
||||
### codex-rs/core/tests/client.rs
|
||||
|
||||
- Created: 2025-08-05 23:46:12 UTC | Link: https://github.com/openai/codex/pull/1861#discussion_r2255550295
|
||||
|
||||
```diff
|
||||
@@ -460,6 +460,86 @@ async fn azure_overrides_assign_properties_used_for_responses_url() {
|
||||
wait_for_event(&codex, |ev| matches!(ev, EventMsg::TaskComplete(_))).await;
|
||||
}
|
||||
|
||||
+#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
+async fn env_var_overrides_loaded_auth() {
|
||||
+ #![allow(clippy::unwrap_used)]
|
||||
+
|
||||
+ let existing_env_var_with_random_value = if cfg!(windows) { "USERNAME" } else { "USER" };
|
||||
+
|
||||
+ // Mock server
|
||||
+ let server = MockServer::start().await;
|
||||
+
|
||||
+ // First request – must NOT include `previous_response_id`.
|
||||
+ let first = ResponseTemplate::new(200)
|
||||
+ .insert_header("content-type", "text/event-stream")
|
||||
+ .set_body_raw(sse_completed("resp1"), "text/event-stream");
|
||||
+
|
||||
+ // Expect POST to /openai/responses with api-version query param
|
||||
+ Mock::given(method("POST"))
|
||||
+ .and(path("/openai/responses"))
|
||||
+ .and(query_param("api-version", "2025-04-01-preview"))
|
||||
+ .and(header_regex("Custom-Header", "Value"))
|
||||
+ .and(header_regex(
|
||||
+ "Authorization",
|
||||
+ format!(
|
||||
+ "Bearer {}",
|
||||
+ std::env::var(existing_env_var_with_random_value).unwrap()
|
||||
+ )
|
||||
+ .as_str(),
|
||||
+ ))
|
||||
+ .respond_with(first)
|
||||
+ .expect(1)
|
||||
+ .mount(&server)
|
||||
+ .await;
|
||||
+
|
||||
+ let provider = ModelProviderInfo {
|
||||
+ name: "custom".to_string(),
|
||||
+ base_url: Some(format!("{}/openai", server.uri())),
|
||||
+ // Reuse the existing environment variable to avoid using unsafe code
|
||||
+ env_key: Some(existing_env_var_with_random_value.to_string()),
|
||||
+ query_params: Some(std::collections::HashMap::from([(
|
||||
+ "api-version".to_string(),
|
||||
+ "2025-04-01-preview".to_string(),
|
||||
+ )])),
|
||||
+ env_key_instructions: None,
|
||||
+ wire_api: WireApi::Responses,
|
||||
+ http_headers: Some(std::collections::HashMap::from([(
|
||||
+ "Custom-Header".to_string(),
|
||||
+ "Value".to_string(),
|
||||
+ )])),
|
||||
+ env_http_headers: None,
|
||||
+ request_max_retries: None,
|
||||
+ stream_max_retries: None,
|
||||
+ stream_idle_timeout_ms: None,
|
||||
+ requires_auth: false,
|
||||
```
|
||||
|
||||
> Should this be `true`?
|
||||
>
|
||||
> Moreover, is this docstring still accurate?
|
||||
>
|
||||
> https://github.com/openai/codex/blob/f6c8d1117cfe8b17de3c5f5d077126279be44d8f/codex-rs/core/src/model_provider_info.rs#L80-L82
|
||||
777
prs/bolinfest/PR-1865.md
Normal file
777
prs/bolinfest/PR-1865.md
Normal file
@@ -0,0 +1,777 @@
|
||||
# PR #1865: [approval_policy] Add OnRequest approval_policy
|
||||
|
||||
- URL: https://github.com/openai/codex/pull/1865
|
||||
- Author: dylan-hurd-oai
|
||||
- Created: 2025-08-06 01:45:27 UTC
|
||||
- Updated: 2025-08-06 03:44:28 UTC
|
||||
- Changes: +320/-37, Files changed: 13, Commits: 7
|
||||
|
||||
## Description
|
||||
|
||||
## Summary
|
||||
A split-up PR of #1763 , stacked on top of a tools refactor #1858 to make the change clearer. From the previous summary:
|
||||
|
||||
> Let's try something new: tell the model about the sandbox, and let it decide when it will need to break the sandbox. Some local testing suggests that it works pretty well with zero iteration on the prompt!
|
||||
|
||||
## Testing
|
||||
- [x] Added unit tests
|
||||
- [x] Tested locally and it appears to work smoothly!
|
||||
|
||||
## Full Diff
|
||||
|
||||
```diff
|
||||
diff --git a/codex-rs/common/src/approval_mode_cli_arg.rs b/codex-rs/common/src/approval_mode_cli_arg.rs
|
||||
index a74ceb2b81..e8c0682687 100644
|
||||
--- a/codex-rs/common/src/approval_mode_cli_arg.rs
|
||||
+++ b/codex-rs/common/src/approval_mode_cli_arg.rs
|
||||
@@ -18,6 +18,9 @@ pub enum ApprovalModeCliArg {
|
||||
/// will escalate to the user to ask for un-sandboxed execution.
|
||||
OnFailure,
|
||||
|
||||
+ /// The model decides when to ask the user for approval.
|
||||
+ OnRequest,
|
||||
+
|
||||
/// Never ask for user approval
|
||||
/// Execution failures are immediately returned to the model.
|
||||
Never,
|
||||
@@ -28,6 +31,7 @@ impl From<ApprovalModeCliArg> for AskForApproval {
|
||||
match value {
|
||||
ApprovalModeCliArg::Untrusted => AskForApproval::UnlessTrusted,
|
||||
ApprovalModeCliArg::OnFailure => AskForApproval::OnFailure,
|
||||
+ ApprovalModeCliArg::OnRequest => AskForApproval::OnRequest,
|
||||
ApprovalModeCliArg::Never => AskForApproval::Never,
|
||||
}
|
||||
}
|
||||
diff --git a/codex-rs/config.md b/codex-rs/config.md
|
||||
index 992fe1aacc..f93a35ebca 100644
|
||||
--- a/codex-rs/config.md
|
||||
+++ b/codex-rs/config.md
|
||||
@@ -148,12 +148,20 @@ Determines when the user should be prompted to approve whether Codex can execute
|
||||
approval_policy = "untrusted"
|
||||
```
|
||||
|
||||
+If you want to be notified whenever a command fails, use "on-failure":
|
||||
```toml
|
||||
# If the command fails when run in the sandbox, Codex asks for permission to
|
||||
# retry the command outside the sandbox.
|
||||
approval_policy = "on-failure"
|
||||
```
|
||||
|
||||
+If you want the model to run until it decides that it needs to ask you for escalated permissions, use "on-request":
|
||||
+```toml
|
||||
+# The model decides when to escalate
|
||||
+approval_policy = "on-request"
|
||||
+```
|
||||
+
|
||||
+Alternatively, you can have the model run until it is done, and never ask to run a command with escalated permissions:
|
||||
```toml
|
||||
# User is never prompted: if the command fails, Codex will automatically try
|
||||
# something out. Note the `exec` subcommand always uses this mode.
|
||||
diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs
|
||||
index 9c3a25c200..a7ab664ee0 100644
|
||||
--- a/codex-rs/core/src/codex.rs
|
||||
+++ b/codex-rs/core/src/codex.rs
|
||||
@@ -813,7 +813,12 @@ async fn submission_loop(
|
||||
let default_shell = shell::default_user_shell().await;
|
||||
sess = Some(Arc::new(Session {
|
||||
client,
|
||||
- tools_config: ToolsConfig::new(&config.model_family, config.include_plan_tool),
|
||||
+ tools_config: ToolsConfig::new(
|
||||
+ &config.model_family,
|
||||
+ approval_policy,
|
||||
+ sandbox_policy.clone(),
|
||||
+ config.include_plan_tool,
|
||||
+ ),
|
||||
tx_event: tx_event.clone(),
|
||||
ctrl_c: Arc::clone(&ctrl_c),
|
||||
user_instructions,
|
||||
@@ -1588,6 +1593,8 @@ async fn handle_response_item(
|
||||
command: action.command,
|
||||
workdir: action.working_directory,
|
||||
timeout_ms: action.timeout_ms,
|
||||
+ with_escalated_permissions: None,
|
||||
+ justification: None,
|
||||
};
|
||||
let effective_call_id = match (call_id, id) {
|
||||
(Some(call_id), _) => call_id,
|
||||
@@ -1676,6 +1683,8 @@ fn to_exec_params(params: ShellToolCallParams, sess: &Session) -> ExecParams {
|
||||
cwd: sess.resolve_path(params.workdir.clone()),
|
||||
timeout_ms: params.timeout_ms,
|
||||
env: create_env(&sess.shell_environment_policy),
|
||||
+ with_escalated_permissions: params.with_escalated_permissions,
|
||||
+ justification: params.justification,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1776,13 +1785,19 @@ async fn handle_container_exec_with_params(
|
||||
cwd: cwd.clone(),
|
||||
timeout_ms: params.timeout_ms,
|
||||
env: HashMap::new(),
|
||||
+ with_escalated_permissions: params.with_escalated_permissions,
|
||||
+ justification: params.justification.clone(),
|
||||
};
|
||||
let safety = if *user_explicitly_approved_this_action {
|
||||
SafetyCheck::AutoApprove {
|
||||
sandbox_type: SandboxType::None,
|
||||
}
|
||||
} else {
|
||||
- assess_safety_for_untrusted_command(sess.approval_policy, &sess.sandbox_policy)
|
||||
+ assess_safety_for_untrusted_command(
|
||||
+ sess.approval_policy,
|
||||
+ &sess.sandbox_policy,
|
||||
+ params.with_escalated_permissions.unwrap_or(false),
|
||||
+ )
|
||||
};
|
||||
(
|
||||
params,
|
||||
@@ -1798,6 +1813,7 @@ async fn handle_container_exec_with_params(
|
||||
sess.approval_policy,
|
||||
&sess.sandbox_policy,
|
||||
&state.approved_commands,
|
||||
+ params.with_escalated_permissions.unwrap_or(false),
|
||||
)
|
||||
};
|
||||
let command_for_display = params.command.clone();
|
||||
@@ -1814,7 +1830,7 @@ async fn handle_container_exec_with_params(
|
||||
call_id.clone(),
|
||||
params.command.clone(),
|
||||
params.cwd.clone(),
|
||||
- None,
|
||||
+ params.justification.clone(),
|
||||
)
|
||||
.await;
|
||||
match rx_approve.await.unwrap_or_default() {
|
||||
@@ -1952,17 +1968,21 @@ async fn handle_sandbox_error(
|
||||
let cwd = exec_command_context.cwd.clone();
|
||||
let is_apply_patch = exec_command_context.apply_patch.is_some();
|
||||
|
||||
- // Early out if the user never wants to be asked for approval; just return to the model immediately
|
||||
- if sess.approval_policy == AskForApproval::Never {
|
||||
- return ResponseInputItem::FunctionCallOutput {
|
||||
- call_id,
|
||||
- output: FunctionCallOutputPayload {
|
||||
- content: format!(
|
||||
- "failed in sandbox {sandbox_type:?} with execution error: {error}"
|
||||
- ),
|
||||
- success: Some(false),
|
||||
- },
|
||||
- };
|
||||
+ // Early out if either the user never wants to be asked for approval, or
|
||||
+ // we're letting the model manage escalation requests. Otherwise, continue
|
||||
+ match sess.approval_policy {
|
||||
+ AskForApproval::Never | AskForApproval::OnRequest => {
|
||||
+ return ResponseInputItem::FunctionCallOutput {
|
||||
+ call_id,
|
||||
+ output: FunctionCallOutputPayload {
|
||||
+ content: format!(
|
||||
+ "failed in sandbox {sandbox_type:?} with execution error: {error}"
|
||||
+ ),
|
||||
+ success: Some(false),
|
||||
+ },
|
||||
+ };
|
||||
+ }
|
||||
+ AskForApproval::UnlessTrusted | AskForApproval::OnFailure => (),
|
||||
}
|
||||
|
||||
// similarly, if the command timed out, we can simply return this failure to the model
|
||||
diff --git a/codex-rs/core/src/exec.rs b/codex-rs/core/src/exec.rs
|
||||
index e98aeeaece..10606b6821 100644
|
||||
--- a/codex-rs/core/src/exec.rs
|
||||
+++ b/codex-rs/core/src/exec.rs
|
||||
@@ -49,6 +49,8 @@ pub struct ExecParams {
|
||||
pub cwd: PathBuf,
|
||||
pub timeout_ms: Option<u64>,
|
||||
pub env: HashMap<String, String>,
|
||||
+ pub with_escalated_permissions: Option<bool>,
|
||||
+ pub justification: Option<String>,
|
||||
}
|
||||
|
||||
impl ExecParams {
|
||||
diff --git a/codex-rs/core/src/models.rs b/codex-rs/core/src/models.rs
|
||||
index fb48b53070..e052bc43a4 100644
|
||||
--- a/codex-rs/core/src/models.rs
|
||||
+++ b/codex-rs/core/src/models.rs
|
||||
@@ -191,6 +191,10 @@ pub struct ShellToolCallParams {
|
||||
// The wire format uses `timeout`, which has ambiguous units, so we use
|
||||
// `timeout_ms` as the field name so it is clear in code.
|
||||
pub timeout_ms: Option<u64>,
|
||||
+ #[serde(skip_serializing_if = "Option::is_none")]
|
||||
+ pub with_escalated_permissions: Option<bool>,
|
||||
+ #[serde(skip_serializing_if = "Option::is_none")]
|
||||
+ pub justification: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
@@ -302,6 +306,8 @@ mod tests {
|
||||
command: vec!["ls".to_string(), "-l".to_string()],
|
||||
workdir: Some("/tmp".to_string()),
|
||||
timeout_ms: Some(1000),
|
||||
+ with_escalated_permissions: None,
|
||||
+ justification: None,
|
||||
},
|
||||
params
|
||||
);
|
||||
diff --git a/codex-rs/core/src/openai_tools.rs b/codex-rs/core/src/openai_tools.rs
|
||||
index 1dac70819e..1c92c07c12 100644
|
||||
--- a/codex-rs/core/src/openai_tools.rs
|
||||
+++ b/codex-rs/core/src/openai_tools.rs
|
||||
@@ -6,6 +6,8 @@ use std::collections::HashMap;
|
||||
|
||||
use crate::model_family::ModelFamily;
|
||||
use crate::plan_tool::PLAN_TOOL;
|
||||
+use crate::protocol::AskForApproval;
|
||||
+use crate::protocol::SandboxPolicy;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, PartialEq)]
|
||||
pub struct ResponsesApiTool {
|
||||
@@ -32,6 +34,7 @@ pub(crate) enum OpenAiTool {
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum ConfigShellToolType {
|
||||
DefaultShell,
|
||||
+ ShellWithRequest { sandbox_policy: SandboxPolicy },
|
||||
LocalShell,
|
||||
}
|
||||
|
||||
@@ -42,12 +45,22 @@ pub struct ToolsConfig {
|
||||
}
|
||||
|
||||
impl ToolsConfig {
|
||||
- pub fn new(model_family: &ModelFamily, include_plan_tool: bool) -> Self {
|
||||
- let shell_type = if model_family.uses_local_shell_tool {
|
||||
+ pub fn new(
|
||||
+ model_family: &ModelFamily,
|
||||
+ approval_policy: AskForApproval,
|
||||
+ sandbox_policy: SandboxPolicy,
|
||||
+ include_plan_tool: bool,
|
||||
+ ) -> Self {
|
||||
+ let mut shell_type = if model_family.uses_local_shell_tool {
|
||||
ConfigShellToolType::LocalShell
|
||||
} else {
|
||||
ConfigShellToolType::DefaultShell
|
||||
};
|
||||
+ if matches!(approval_policy, AskForApproval::OnRequest) {
|
||||
+ shell_type = ConfigShellToolType::ShellWithRequest {
|
||||
+ sandbox_policy: sandbox_policy.clone(),
|
||||
+ }
|
||||
+ }
|
||||
|
||||
Self {
|
||||
shell_type,
|
||||
@@ -60,10 +73,23 @@ impl ToolsConfig {
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(tag = "type", rename_all = "lowercase")]
|
||||
pub(crate) enum JsonSchema {
|
||||
- String,
|
||||
- Number,
|
||||
+ Boolean {
|
||||
+ #[serde(skip_serializing_if = "Option::is_none")]
|
||||
+ description: Option<String>,
|
||||
+ },
|
||||
+ String {
|
||||
+ #[serde(skip_serializing_if = "Option::is_none")]
|
||||
+ description: Option<String>,
|
||||
+ },
|
||||
+ Number {
|
||||
+ #[serde(skip_serializing_if = "Option::is_none")]
|
||||
+ description: Option<String>,
|
||||
+ },
|
||||
Array {
|
||||
items: Box<JsonSchema>,
|
||||
+
|
||||
+ #[serde(skip_serializing_if = "Option::is_none")]
|
||||
+ description: Option<String>,
|
||||
},
|
||||
Object {
|
||||
properties: BTreeMap<String, JsonSchema>,
|
||||
@@ -77,16 +103,23 @@ pub(crate) enum JsonSchema {
|
||||
},
|
||||
}
|
||||
|
||||
-pub(crate) fn create_shell_tool() -> OpenAiTool {
|
||||
+fn create_shell_tool() -> OpenAiTool {
|
||||
let mut properties = BTreeMap::new();
|
||||
properties.insert(
|
||||
"command".to_string(),
|
||||
JsonSchema::Array {
|
||||
- items: Box::new(JsonSchema::String),
|
||||
+ items: Box::new(JsonSchema::String { description: None }),
|
||||
+ description: None,
|
||||
},
|
||||
);
|
||||
- properties.insert("workdir".to_string(), JsonSchema::String);
|
||||
- properties.insert("timeout".to_string(), JsonSchema::Number);
|
||||
+ properties.insert(
|
||||
+ "workdir".to_string(),
|
||||
+ JsonSchema::String { description: None },
|
||||
+ );
|
||||
+ properties.insert(
|
||||
+ "timeout".to_string(),
|
||||
+ JsonSchema::Number { description: None },
|
||||
+ );
|
||||
|
||||
OpenAiTool::Function(ResponsesApiTool {
|
||||
name: "shell".to_string(),
|
||||
@@ -100,6 +133,105 @@ pub(crate) fn create_shell_tool() -> OpenAiTool {
|
||||
})
|
||||
}
|
||||
|
||||
+fn create_shell_tool_for_sandbox(sandbox_policy: &SandboxPolicy) -> OpenAiTool {
|
||||
+ let mut properties = BTreeMap::new();
|
||||
+ properties.insert(
|
||||
+ "command".to_string(),
|
||||
+ JsonSchema::Array {
|
||||
+ items: Box::new(JsonSchema::String { description: None }),
|
||||
+ description: Some("The command to execute".to_string()),
|
||||
+ },
|
||||
+ );
|
||||
+ properties.insert(
|
||||
+ "workdir".to_string(),
|
||||
+ JsonSchema::String {
|
||||
+ description: Some("The working directory to execute the command in".to_string()),
|
||||
+ },
|
||||
+ );
|
||||
+ properties.insert(
|
||||
+ "timeout".to_string(),
|
||||
+ JsonSchema::Number {
|
||||
+ description: Some("The timeout for the command in milliseconds".to_string()),
|
||||
+ },
|
||||
+ );
|
||||
+
|
||||
+ if matches!(sandbox_policy, SandboxPolicy::WorkspaceWrite { .. }) {
|
||||
+ properties.insert(
|
||||
+ "with_escalated_permissions".to_string(),
|
||||
+ JsonSchema::Boolean {
|
||||
+ description: Some("Whether to request escalated permissions. Set to true if command needs to be run without sandbox restrictions".to_string()),
|
||||
+ },
|
||||
+ );
|
||||
+ properties.insert(
|
||||
+ "justification".to_string(),
|
||||
+ JsonSchema::String {
|
||||
+ description: Some("Only set if ask_for_escalated_permissions is true. 1-sentence explanation of why we want to run this command.".to_string()),
|
||||
+ },
|
||||
+ );
|
||||
+ }
|
||||
+
|
||||
+ let description = match sandbox_policy {
|
||||
+ SandboxPolicy::WorkspaceWrite {
|
||||
+ network_access,
|
||||
+ ..
|
||||
+ } => {
|
||||
+ format!(
|
||||
+ r#"
|
||||
+The shell tool is used to execute shell commands.
|
||||
+- When invoking the shell tool, your call will be running in a landlock sandbox, and some shell commands will require escalated privileges:
|
||||
+ - Types of actions that require escalated privileges:
|
||||
+ - Reading files outside the current directory
|
||||
+ - Writing files outside the current directory, and protected folders like .git or .env{}
|
||||
+ - Examples of commands that require escalated privileges:
|
||||
+ - git commit
|
||||
+ - npm install or pnpm install
|
||||
+ - cargo build
|
||||
+ - cargo test
|
||||
+- When invoking a command that will require escalated privileges:
|
||||
+ - Provide the with_escalated_permissions parameter with the boolean value true
|
||||
+ - Include a short, 1 sentence explanation for why we need to run with_escalated_permissions in the justification parameter."#,
|
||||
+ if !network_access {
|
||||
+ "\n - Commands that require network access\n"
|
||||
+ } else {
|
||||
+ ""
|
||||
+ }
|
||||
+ )
|
||||
+ }
|
||||
+ SandboxPolicy::DangerFullAccess => {
|
||||
+ "Runs a shell command and returns its output.".to_string()
|
||||
+ }
|
||||
+ SandboxPolicy::ReadOnly => {
|
||||
+ r#"
|
||||
+The shell tool is used to execute shell commands.
|
||||
+- When invoking the shell tool, your call will be running in a landlock sandbox, and some shell commands (including apply_patch) will require escalated permissions:
|
||||
+ - Types of actions that require escalated privileges:
|
||||
+ - Reading files outside the current directory
|
||||
+ - Writing files
|
||||
+ - Applying patches
|
||||
+ - Examples of commands that require escalated privileges:
|
||||
+ - apply_patch
|
||||
+ - git commit
|
||||
+ - npm install or pnpm install
|
||||
+ - cargo build
|
||||
+ - cargo test
|
||||
+- When invoking a command that will require escalated privileges:
|
||||
+ - Provide the with_escalated_permissions parameter with the boolean value true
|
||||
+ - Include a short, 1 sentence explanation for why we need to run with_escalated_permissions in the justification parameter"#.to_string()
|
||||
+ }
|
||||
+ };
|
||||
+
|
||||
+ OpenAiTool::Function(ResponsesApiTool {
|
||||
+ name: "shell".to_string(),
|
||||
+ description,
|
||||
+ strict: false,
|
||||
+ parameters: JsonSchema::Object {
|
||||
+ properties,
|
||||
+ required: Some(vec!["command".to_string()]),
|
||||
+ additional_properties: Some(false),
|
||||
+ },
|
||||
+ })
|
||||
+}
|
||||
+
|
||||
/// Returns JSON values that are compatible with Function Calling in the
|
||||
/// Responses API:
|
||||
/// https://platform.openai.com/docs/guides/function-calling?api-mode=responses
|
||||
@@ -184,10 +316,13 @@ pub(crate) fn get_openai_tools(
|
||||
) -> Vec<OpenAiTool> {
|
||||
let mut tools: Vec<OpenAiTool> = Vec::new();
|
||||
|
||||
- match config.shell_type {
|
||||
+ match &config.shell_type {
|
||||
ConfigShellToolType::DefaultShell => {
|
||||
tools.push(create_shell_tool());
|
||||
}
|
||||
+ ConfigShellToolType::ShellWithRequest { sandbox_policy } => {
|
||||
+ tools.push(create_shell_tool_for_sandbox(sandbox_policy));
|
||||
+ }
|
||||
ConfigShellToolType::LocalShell => {
|
||||
tools.push(OpenAiTool::LocalShell {});
|
||||
}
|
||||
@@ -245,7 +380,12 @@ mod tests {
|
||||
fn test_get_openai_tools() {
|
||||
let model_family = find_family_for_model("codex-mini-latest")
|
||||
.expect("codex-mini-latest should be a valid model family");
|
||||
- let config = ToolsConfig::new(&model_family, true);
|
||||
+ let config = ToolsConfig::new(
|
||||
+ &model_family,
|
||||
+ AskForApproval::Never,
|
||||
+ SandboxPolicy::ReadOnly,
|
||||
+ true,
|
||||
+ );
|
||||
let tools = get_openai_tools(&config, Some(HashMap::new()));
|
||||
|
||||
assert_eq_tool_names(&tools, &["local_shell", "update_plan"]);
|
||||
@@ -254,7 +394,12 @@ mod tests {
|
||||
#[test]
|
||||
fn test_get_openai_tools_default_shell() {
|
||||
let model_family = find_family_for_model("o3").expect("o3 should be a valid model family");
|
||||
- let config = ToolsConfig::new(&model_family, true);
|
||||
+ let config = ToolsConfig::new(
|
||||
+ &model_family,
|
||||
+ AskForApproval::Never,
|
||||
+ SandboxPolicy::ReadOnly,
|
||||
+ true,
|
||||
+ );
|
||||
let tools = get_openai_tools(&config, Some(HashMap::new()));
|
||||
|
||||
assert_eq_tool_names(&tools, &["shell", "update_plan"]);
|
||||
@@ -263,7 +408,12 @@ mod tests {
|
||||
#[test]
|
||||
fn test_get_openai_tools_mcp_tools() {
|
||||
let model_family = find_family_for_model("o3").expect("o3 should be a valid model family");
|
||||
- let config = ToolsConfig::new(&model_family, false);
|
||||
+ let config = ToolsConfig::new(
|
||||
+ &model_family,
|
||||
+ AskForApproval::Never,
|
||||
+ SandboxPolicy::ReadOnly,
|
||||
+ false,
|
||||
+ );
|
||||
let tools = get_openai_tools(
|
||||
&config,
|
||||
Some(HashMap::from([(
|
||||
@@ -310,14 +460,26 @@ mod tests {
|
||||
name: "test_server/do_something_cool".to_string(),
|
||||
parameters: JsonSchema::Object {
|
||||
properties: BTreeMap::from([
|
||||
- ("string_argument".to_string(), JsonSchema::String),
|
||||
- ("number_argument".to_string(), JsonSchema::Number),
|
||||
+ (
|
||||
+ "string_argument".to_string(),
|
||||
+ JsonSchema::String { description: None }
|
||||
+ ),
|
||||
+ (
|
||||
+ "number_argument".to_string(),
|
||||
+ JsonSchema::Number { description: None }
|
||||
+ ),
|
||||
(
|
||||
"object_argument".to_string(),
|
||||
JsonSchema::Object {
|
||||
properties: BTreeMap::from([
|
||||
- ("string_property".to_string(), JsonSchema::String),
|
||||
- ("number_property".to_string(), JsonSchema::Number),
|
||||
+ (
|
||||
+ "string_property".to_string(),
|
||||
+ JsonSchema::String { description: None }
|
||||
+ ),
|
||||
+ (
|
||||
+ "number_property".to_string(),
|
||||
+ JsonSchema::Number { description: None }
|
||||
+ ),
|
||||
]),
|
||||
required: Some(vec![
|
||||
"string_property".to_string(),
|
||||
diff --git a/codex-rs/core/src/plan_tool.rs b/codex-rs/core/src/plan_tool.rs
|
||||
index cfc26a4021..bba5363266 100644
|
||||
--- a/codex-rs/core/src/plan_tool.rs
|
||||
+++ b/codex-rs/core/src/plan_tool.rs
|
||||
@@ -39,10 +39,14 @@ pub struct UpdatePlanArgs {
|
||||
|
||||
pub(crate) static PLAN_TOOL: LazyLock<OpenAiTool> = LazyLock::new(|| {
|
||||
let mut plan_item_props = BTreeMap::new();
|
||||
- plan_item_props.insert("step".to_string(), JsonSchema::String);
|
||||
- plan_item_props.insert("status".to_string(), JsonSchema::String);
|
||||
+ plan_item_props.insert("step".to_string(), JsonSchema::String { description: None });
|
||||
+ plan_item_props.insert(
|
||||
+ "status".to_string(),
|
||||
+ JsonSchema::String { description: None },
|
||||
+ );
|
||||
|
||||
let plan_items_schema = JsonSchema::Array {
|
||||
+ description: Some("The list of steps".to_string()),
|
||||
items: Box::new(JsonSchema::Object {
|
||||
properties: plan_item_props,
|
||||
required: Some(vec!["step".to_string(), "status".to_string()]),
|
||||
@@ -51,7 +55,10 @@ pub(crate) static PLAN_TOOL: LazyLock<OpenAiTool> = LazyLock::new(|| {
|
||||
};
|
||||
|
||||
let mut properties = BTreeMap::new();
|
||||
- properties.insert("explanation".to_string(), JsonSchema::String);
|
||||
+ properties.insert(
|
||||
+ "explanation".to_string(),
|
||||
+ JsonSchema::String { description: None },
|
||||
+ );
|
||||
properties.insert("plan".to_string(), plan_items_schema);
|
||||
|
||||
OpenAiTool::Function(ResponsesApiTool {
|
||||
diff --git a/codex-rs/core/src/protocol.rs b/codex-rs/core/src/protocol.rs
|
||||
index aa330f6bae..9bf85ec49a 100644
|
||||
--- a/codex-rs/core/src/protocol.rs
|
||||
+++ b/codex-rs/core/src/protocol.rs
|
||||
@@ -150,6 +150,9 @@ pub enum AskForApproval {
|
||||
/// the user to approve execution without a sandbox.
|
||||
OnFailure,
|
||||
|
||||
+ /// The model decides when to ask the user for approval.
|
||||
+ OnRequest,
|
||||
+
|
||||
/// Never ask the user to approve commands. Failures are immediately returned
|
||||
/// to the model, and never escalated to the user for approval.
|
||||
Never,
|
||||
diff --git a/codex-rs/core/src/safety.rs b/codex-rs/core/src/safety.rs
|
||||
index 224705f8f3..860a728def 100644
|
||||
--- a/codex-rs/core/src/safety.rs
|
||||
+++ b/codex-rs/core/src/safety.rs
|
||||
@@ -11,7 +11,7 @@ use crate::is_safe_command::is_known_safe_command;
|
||||
use crate::protocol::AskForApproval;
|
||||
use crate::protocol::SandboxPolicy;
|
||||
|
||||
-#[derive(Debug)]
|
||||
+#[derive(Debug, PartialEq)]
|
||||
pub enum SafetyCheck {
|
||||
AutoApprove { sandbox_type: SandboxType },
|
||||
AskUser,
|
||||
@@ -31,7 +31,7 @@ pub fn assess_patch_safety(
|
||||
}
|
||||
|
||||
match policy {
|
||||
- AskForApproval::OnFailure | AskForApproval::Never => {
|
||||
+ AskForApproval::OnFailure | AskForApproval::Never | AskForApproval::OnRequest => {
|
||||
// Continue to see if this can be auto-approved.
|
||||
}
|
||||
// TODO(ragona): I'm not sure this is actually correct? I believe in this case
|
||||
@@ -76,6 +76,7 @@ pub fn assess_command_safety(
|
||||
approval_policy: AskForApproval,
|
||||
sandbox_policy: &SandboxPolicy,
|
||||
approved: &HashSet<Vec<String>>,
|
||||
+ with_escalated_permissions: bool,
|
||||
) -> SafetyCheck {
|
||||
// A command is "trusted" because either:
|
||||
// - it belongs to a set of commands we consider "safe" by default, or
|
||||
@@ -96,12 +97,13 @@ pub fn assess_command_safety(
|
||||
};
|
||||
}
|
||||
|
||||
- assess_safety_for_untrusted_command(approval_policy, sandbox_policy)
|
||||
+ assess_safety_for_untrusted_command(approval_policy, sandbox_policy, with_escalated_permissions)
|
||||
}
|
||||
|
||||
pub(crate) fn assess_safety_for_untrusted_command(
|
||||
approval_policy: AskForApproval,
|
||||
sandbox_policy: &SandboxPolicy,
|
||||
+ with_escalated_permissions: bool,
|
||||
) -> SafetyCheck {
|
||||
use AskForApproval::*;
|
||||
use SandboxPolicy::*;
|
||||
@@ -113,9 +115,23 @@ pub(crate) fn assess_safety_for_untrusted_command(
|
||||
// commands.
|
||||
SafetyCheck::AskUser
|
||||
}
|
||||
- (OnFailure, DangerFullAccess) | (Never, DangerFullAccess) => SafetyCheck::AutoApprove {
|
||||
+ (OnFailure, DangerFullAccess)
|
||||
+ | (Never, DangerFullAccess)
|
||||
+ | (OnRequest, DangerFullAccess) => SafetyCheck::AutoApprove {
|
||||
sandbox_type: SandboxType::None,
|
||||
},
|
||||
+ (OnRequest, ReadOnly) | (OnRequest, WorkspaceWrite { .. }) => {
|
||||
+ if with_escalated_permissions {
|
||||
+ SafetyCheck::AskUser
|
||||
+ } else {
|
||||
+ match get_platform_sandbox() {
|
||||
+ Some(sandbox_type) => SafetyCheck::AutoApprove { sandbox_type },
|
||||
+ // Fall back to asking since the command is untrusted and
|
||||
+ // we do not have a sandbox available
|
||||
+ None => SafetyCheck::AskUser,
|
||||
+ }
|
||||
+ }
|
||||
+ }
|
||||
(Never, ReadOnly)
|
||||
| (Never, WorkspaceWrite { .. })
|
||||
| (OnFailure, ReadOnly)
|
||||
@@ -264,4 +280,47 @@ mod tests {
|
||||
&cwd,
|
||||
))
|
||||
}
|
||||
+
|
||||
+ #[test]
|
||||
+ fn test_request_escalated_privileges() {
|
||||
+ // Should not be a trusted command
|
||||
+ let command = vec!["git commit".to_string()];
|
||||
+ let approval_policy = AskForApproval::OnRequest;
|
||||
+ let sandbox_policy = SandboxPolicy::ReadOnly;
|
||||
+ let approved: HashSet<Vec<String>> = HashSet::new();
|
||||
+ let request_escalated_privileges = true;
|
||||
+
|
||||
+ let safety_check = assess_command_safety(
|
||||
+ &command,
|
||||
+ approval_policy,
|
||||
+ &sandbox_policy,
|
||||
+ &approved,
|
||||
+ request_escalated_privileges,
|
||||
+ );
|
||||
+
|
||||
+ assert_eq!(safety_check, SafetyCheck::AskUser);
|
||||
+ }
|
||||
+
|
||||
+ #[test]
|
||||
+ fn test_request_escalated_privileges_no_sandbox_fallback() {
|
||||
+ let command = vec!["git".to_string(), "commit".to_string()];
|
||||
+ let approval_policy = AskForApproval::OnRequest;
|
||||
+ let sandbox_policy = SandboxPolicy::ReadOnly;
|
||||
+ let approved: HashSet<Vec<String>> = HashSet::new();
|
||||
+ let request_escalated_privileges = false;
|
||||
+
|
||||
+ let safety_check = assess_command_safety(
|
||||
+ &command,
|
||||
+ approval_policy,
|
||||
+ &sandbox_policy,
|
||||
+ &approved,
|
||||
+ request_escalated_privileges,
|
||||
+ );
|
||||
+
|
||||
+ let expected = match get_platform_sandbox() {
|
||||
+ Some(sandbox_type) => SafetyCheck::AutoApprove { sandbox_type },
|
||||
+ None => SafetyCheck::AskUser,
|
||||
+ };
|
||||
+ assert_eq!(safety_check, expected);
|
||||
+ }
|
||||
}
|
||||
diff --git a/codex-rs/core/src/shell.rs b/codex-rs/core/src/shell.rs
|
||||
index 1e895a3701..de0764f75e 100644
|
||||
--- a/codex-rs/core/src/shell.rs
|
||||
+++ b/codex-rs/core/src/shell.rs
|
||||
@@ -215,6 +215,8 @@ mod tests {
|
||||
"HOME".to_string(),
|
||||
temp_home.path().to_str().unwrap().to_string(),
|
||||
)]),
|
||||
+ with_escalated_permissions: None,
|
||||
+ justification: None,
|
||||
},
|
||||
SandboxType::None,
|
||||
Arc::new(Notify::new()),
|
||||
diff --git a/codex-rs/core/tests/exec.rs b/codex-rs/core/tests/exec.rs
|
||||
index da169296ed..f1b9e78e67 100644
|
||||
--- a/codex-rs/core/tests/exec.rs
|
||||
+++ b/codex-rs/core/tests/exec.rs
|
||||
@@ -28,6 +28,8 @@ async fn run_test_cmd(tmp: TempDir, cmd: Vec<&str>, should_be_ok: bool) {
|
||||
cwd: tmp.path().to_path_buf(),
|
||||
timeout_ms: Some(1000),
|
||||
env: HashMap::new(),
|
||||
+ with_escalated_permissions: None,
|
||||
+ justification: None,
|
||||
};
|
||||
|
||||
let ctrl_c = Arc::new(Notify::new());
|
||||
diff --git a/codex-rs/core/tests/exec_stream_events.rs b/codex-rs/core/tests/exec_stream_events.rs
|
||||
index 50f6888f73..534b25513a 100644
|
||||
--- a/codex-rs/core/tests/exec_stream_events.rs
|
||||
+++ b/codex-rs/core/tests/exec_stream_events.rs
|
||||
@@ -53,6 +53,8 @@ async fn test_exec_stdout_stream_events_echo() {
|
||||
cwd: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
|
||||
timeout_ms: Some(5_000),
|
||||
env: HashMap::new(),
|
||||
+ with_escalated_permissions: None,
|
||||
+ justification: None,
|
||||
};
|
||||
|
||||
let ctrl_c = Arc::new(Notify::new());
|
||||
@@ -103,6 +105,8 @@ async fn test_exec_stderr_stream_events_echo() {
|
||||
cwd: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
|
||||
timeout_ms: Some(5_000),
|
||||
env: HashMap::new(),
|
||||
+ with_escalated_permissions: None,
|
||||
+ justification: None,
|
||||
};
|
||||
|
||||
let ctrl_c = Arc::new(Notify::new());
|
||||
diff --git a/codex-rs/linux-sandbox/tests/landlock.rs b/codex-rs/linux-sandbox/tests/landlock.rs
|
||||
index 1375a4c686..041e64e208 100644
|
||||
--- a/codex-rs/linux-sandbox/tests/landlock.rs
|
||||
+++ b/codex-rs/linux-sandbox/tests/landlock.rs
|
||||
@@ -44,6 +44,8 @@ async fn run_cmd(cmd: &[&str], writable_roots: &[PathBuf], timeout_ms: u64) {
|
||||
cwd: std::env::current_dir().expect("cwd should exist"),
|
||||
timeout_ms: Some(timeout_ms),
|
||||
env: create_env_from_core_vars(),
|
||||
+ with_escalated_permissions: None,
|
||||
+ justification: None,
|
||||
};
|
||||
|
||||
let sandbox_policy = SandboxPolicy::WorkspaceWrite {
|
||||
@@ -139,6 +141,8 @@ async fn assert_network_blocked(cmd: &[&str]) {
|
||||
// do not stall the suite.
|
||||
timeout_ms: Some(NETWORK_TIMEOUT_MS),
|
||||
env: create_env_from_core_vars(),
|
||||
+ with_escalated_permissions: None,
|
||||
+ justification: None,
|
||||
};
|
||||
|
||||
let sandbox_policy = SandboxPolicy::new_read_only_policy();
|
||||
```
|
||||
|
||||
## Review Comments
|
||||
|
||||
### codex-rs/config.md
|
||||
|
||||
- Created: 2025-08-06 02:27:46 UTC | Link: https://github.com/openai/codex/pull/1865#discussion_r2255715701
|
||||
|
||||
```diff
|
||||
@@ -153,6 +153,10 @@ approval_policy = "untrusted"
|
||||
# retry the command outside the sandbox.
|
||||
approval_policy = "on-failure"
|
||||
```
|
||||
+```toml
|
||||
```
|
||||
|
||||
> Maybe have text between these fenced code blocks?
|
||||
|
||||
### codex-rs/core/src/codex.rs
|
||||
|
||||
- Created: 2025-08-06 02:30:49 UTC | Link: https://github.com/openai/codex/pull/1865#discussion_r2255718265
|
||||
|
||||
```diff
|
||||
@@ -1952,8 +1968,11 @@ async fn handle_sandbox_error(
|
||||
let cwd = exec_command_context.cwd.clone();
|
||||
let is_apply_patch = exec_command_context.apply_patch.is_some();
|
||||
|
||||
- // Early out if the user never wants to be asked for approval; just return to the model immediately
|
||||
- if sess.approval_policy == AskForApproval::Never {
|
||||
+ // Early out if either the user never wants to be asked for approval, or
|
||||
+ // we're letting the model manage escalation requests.
|
||||
+ if sess.approval_policy == AskForApproval::Never
|
||||
```
|
||||
|
||||
> Using `match` is slightly better (or making this a method of `AskForApproval`) just so that if we add another variant, we are forced to make a decision about this.
|
||||
59
prs/bolinfest/PR-1891.md
Normal file
59
prs/bolinfest/PR-1891.md
Normal file
@@ -0,0 +1,59 @@
|
||||
# PR #1891: fix: support $CODEX_HOME/AGENTS.md instead of $CODEX_HOME/instructions.md
|
||||
|
||||
- URL: https://github.com/openai/codex/pull/1891
|
||||
- Author: bolinfest
|
||||
- Created: 2025-08-06 18:11:44 UTC
|
||||
- Updated: 2025-08-06 18:48:11 UTC
|
||||
- Changes: +2/-2, Files changed: 1, Commits: 1
|
||||
|
||||
## Description
|
||||
|
||||
The docs and code do not match. It turns out the docs are "right" in they are what we have been meaning to support, so this PR updates the code:
|
||||
|
||||
https://github.com/openai/codex/blob/ae88b69b09f876a3017196a9cd66f83dac79d9d7/README.md#L298-L302
|
||||
|
||||
Support for `instructions.md` is a holdover from the TypeScript CLI, so we are just going to drop support for it altogether rather than maintain it in perpetuity.
|
||||
|
||||
## Full Diff
|
||||
|
||||
```diff
|
||||
diff --git a/codex-rs/core/src/config.rs b/codex-rs/core/src/config.rs
|
||||
index 0b53df5ab7..f48cc9340b 100644
|
||||
--- a/codex-rs/core/src/config.rs
|
||||
+++ b/codex-rs/core/src/config.rs
|
||||
@@ -70,7 +70,7 @@ pub struct Config {
|
||||
/// who have opted into Zero Data Retention (ZDR).
|
||||
pub disable_response_storage: bool,
|
||||
|
||||
- /// User-provided instructions from instructions.md.
|
||||
+ /// User-provided instructions from AGENTS.md.
|
||||
pub user_instructions: Option<String>,
|
||||
|
||||
/// Base instructions override.
|
||||
@@ -575,7 +575,7 @@ impl Config {
|
||||
None => return None,
|
||||
};
|
||||
|
||||
- p.push("instructions.md");
|
||||
+ p.push("AGENTS.md");
|
||||
std::fs::read_to_string(&p).ok().and_then(|s| {
|
||||
let s = s.trim();
|
||||
if s.is_empty() {
|
||||
```
|
||||
|
||||
## Review Comments
|
||||
|
||||
### codex-rs/core/src/config.rs
|
||||
|
||||
- Created: 2025-08-06 18:18:54 UTC | Link: https://github.com/openai/codex/pull/1891#discussion_r2257937839
|
||||
|
||||
```diff
|
||||
@@ -575,7 +575,7 @@ impl Config {
|
||||
None => return None,
|
||||
};
|
||||
|
||||
- p.push("instructions.md");
|
||||
+ p.push("AGENTS.md");
|
||||
```
|
||||
|
||||
> I thought about it, but I don't think we want to honor `instructions.md` in perpetuity.
|
||||
202
prs/bolinfest/PR-1897.md
Normal file
202
prs/bolinfest/PR-1897.md
Normal file
@@ -0,0 +1,202 @@
|
||||
# PR #1897: Run command UI
|
||||
|
||||
- URL: https://github.com/openai/codex/pull/1897
|
||||
- Author: aibrahim-oai
|
||||
- Created: 2025-08-06 21:37:53 UTC
|
||||
- Updated: 2025-08-07 00:11:07 UTC
|
||||
- Changes: +27/-24, Files changed: 2, Commits: 7
|
||||
|
||||
## Description
|
||||
|
||||
Edit how commands show:
|
||||
|
||||
<img width="243" height="119" alt="image" src="https://github.com/user-attachments/assets/13d5608e-3b66-4b8d-8fe7-ce464310d85d" />
|
||||
|
||||
## Full Diff
|
||||
|
||||
```diff
|
||||
diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs
|
||||
index 128e1ae8c1..50fa776ec3 100644
|
||||
--- a/codex-rs/tui/src/chatwidget.rs
|
||||
+++ b/codex-rs/tui/src/chatwidget.rs
|
||||
@@ -485,7 +485,7 @@ impl ChatWidget<'_> {
|
||||
EventMsg::ExecCommandEnd(ExecCommandEndEvent {
|
||||
call_id,
|
||||
exit_code,
|
||||
- duration,
|
||||
+ duration: _,
|
||||
stdout,
|
||||
stderr,
|
||||
}) => {
|
||||
@@ -498,7 +498,6 @@ impl ChatWidget<'_> {
|
||||
exit_code,
|
||||
stdout,
|
||||
stderr,
|
||||
- duration,
|
||||
},
|
||||
));
|
||||
}
|
||||
diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs
|
||||
index c577ce17a0..5caedf98ab 100644
|
||||
--- a/codex-rs/tui/src/history_cell.rs
|
||||
+++ b/codex-rs/tui/src/history_cell.rs
|
||||
@@ -38,7 +38,6 @@ pub(crate) struct CommandOutput {
|
||||
pub(crate) exit_code: i32,
|
||||
pub(crate) stdout: String,
|
||||
pub(crate) stderr: String,
|
||||
- pub(crate) duration: Duration,
|
||||
}
|
||||
|
||||
pub(crate) enum PatchEventType {
|
||||
@@ -122,7 +121,7 @@ pub(crate) enum HistoryCell {
|
||||
PatchApplyResult { view: TextBlock },
|
||||
}
|
||||
|
||||
-const TOOL_CALL_MAX_LINES: usize = 5;
|
||||
+const TOOL_CALL_MAX_LINES: usize = 3;
|
||||
|
||||
impl HistoryCell {
|
||||
/// Return a cloned, plain representation of the cell's lines suitable for
|
||||
@@ -232,8 +231,11 @@ impl HistoryCell {
|
||||
let command_escaped = strip_bash_lc_and_escape(&command);
|
||||
|
||||
let lines: Vec<Line<'static>> = vec![
|
||||
- Line::from(vec!["command".magenta(), " running...".dim()]),
|
||||
- Line::from(format!("$ {command_escaped}")),
|
||||
+ Line::from(vec![
|
||||
+ "▌ ".cyan(),
|
||||
+ "Running command ".magenta(),
|
||||
+ command_escaped.into(),
|
||||
+ ]),
|
||||
Line::from(""),
|
||||
];
|
||||
|
||||
@@ -247,34 +249,36 @@ impl HistoryCell {
|
||||
exit_code,
|
||||
stdout,
|
||||
stderr,
|
||||
- duration,
|
||||
} = output;
|
||||
|
||||
let mut lines: Vec<Line<'static>> = Vec::new();
|
||||
-
|
||||
- // Title depends on whether we have output yet.
|
||||
- let title_line = Line::from(vec![
|
||||
- "command".magenta(),
|
||||
- format!(
|
||||
- " (code: {}, duration: {})",
|
||||
- exit_code,
|
||||
- format_duration(duration)
|
||||
- )
|
||||
- .dim(),
|
||||
- ]);
|
||||
- lines.push(title_line);
|
||||
+ let command_escaped = strip_bash_lc_and_escape(&command);
|
||||
+ lines.push(Line::from(vec![
|
||||
+ "⚡Ran command ".magenta(),
|
||||
+ command_escaped.into(),
|
||||
+ ]));
|
||||
|
||||
let src = if exit_code == 0 { stdout } else { stderr };
|
||||
|
||||
- let cmdline = strip_bash_lc_and_escape(&command);
|
||||
- lines.push(Line::from(format!("$ {cmdline}")));
|
||||
let mut lines_iter = src.lines();
|
||||
- for raw in lines_iter.by_ref().take(TOOL_CALL_MAX_LINES) {
|
||||
- lines.push(ansi_escape_line(raw).dim());
|
||||
+ for (idx, raw) in lines_iter.by_ref().take(TOOL_CALL_MAX_LINES).enumerate() {
|
||||
+ let mut line = ansi_escape_line(raw);
|
||||
+ let prefix = if idx == 0 { " ⎿ " } else { " " };
|
||||
+ line.spans.insert(0, prefix.into());
|
||||
+ line.spans.iter_mut().for_each(|span| {
|
||||
+ span.style = span.style.add_modifier(Modifier::DIM);
|
||||
+ });
|
||||
+ lines.push(line);
|
||||
}
|
||||
let remaining = lines_iter.count();
|
||||
if remaining > 0 {
|
||||
- lines.push(Line::from(format!("... {remaining} additional lines")).dim());
|
||||
+ let mut more = Line::from(format!("... +{remaining} lines"));
|
||||
+ // Continuation/ellipsis is treated as a subsequent line for prefixing
|
||||
+ more.spans.insert(0, " ".into());
|
||||
+ more.spans.iter_mut().for_each(|span| {
|
||||
+ span.style = span.style.add_modifier(Modifier::DIM);
|
||||
+ });
|
||||
+ lines.push(more);
|
||||
}
|
||||
lines.push(Line::from(""));
|
||||
```
|
||||
|
||||
## Review Comments
|
||||
|
||||
### codex-rs/tui/src/history_cell.rs
|
||||
|
||||
- Created: 2025-08-06 22:38:09 UTC | Link: https://github.com/openai/codex/pull/1897#discussion_r2258456321
|
||||
|
||||
```diff
|
||||
@@ -36,6 +36,7 @@ pub(crate) struct CommandOutput {
|
||||
pub(crate) exit_code: i32,
|
||||
pub(crate) stdout: String,
|
||||
pub(crate) stderr: String,
|
||||
+ #[allow(dead_code)]
|
||||
```
|
||||
|
||||
> Let's just delete this if we aren't going to use it.
|
||||
|
||||
- Created: 2025-08-06 22:39:59 UTC | Link: https://github.com/openai/codex/pull/1897#discussion_r2258458477
|
||||
|
||||
```diff
|
||||
@@ -120,7 +121,7 @@ pub(crate) enum HistoryCell {
|
||||
PatchApplyResult { view: TextBlock },
|
||||
}
|
||||
|
||||
-const TOOL_CALL_MAX_LINES: usize = 5;
|
||||
+const TOOL_CALL_MAX_LINES: usize = 3;
|
||||
```
|
||||
|
||||
> In a separate PR, it might be nice to make this overridable via `Config`.
|
||||
>
|
||||
> I thought this had been requested in the past, though it seems it was the number of lines available to the model rather than in the UI: https://github.com/openai/codex/pull/575
|
||||
|
||||
- Created: 2025-08-06 22:53:09 UTC | Link: https://github.com/openai/codex/pull/1897#discussion_r2258473587
|
||||
|
||||
```diff
|
||||
@@ -242,34 +246,39 @@ impl HistoryCell {
|
||||
exit_code,
|
||||
stdout,
|
||||
stderr,
|
||||
- duration,
|
||||
+ duration: _,
|
||||
} = output;
|
||||
|
||||
let mut lines: Vec<Line<'static>> = Vec::new();
|
||||
-
|
||||
- // Title depends on whether we have output yet.
|
||||
- let title_line = Line::from(vec![
|
||||
- "command".magenta(),
|
||||
- format!(
|
||||
- " (code: {}, duration: {})",
|
||||
- exit_code,
|
||||
- format_duration(duration)
|
||||
- )
|
||||
- .dim(),
|
||||
- ]);
|
||||
- lines.push(title_line);
|
||||
+ let command_escaped = strip_bash_lc_and_escape(&command);
|
||||
+ lines.push(Line::from(vec![
|
||||
+ "⚡Ran command ".magenta(),
|
||||
+ command_escaped.into(),
|
||||
+ ]));
|
||||
|
||||
let src = if exit_code == 0 { stdout } else { stderr };
|
||||
|
||||
- let cmdline = strip_bash_lc_and_escape(&command);
|
||||
- lines.push(Line::from(format!("$ {cmdline}")));
|
||||
let mut lines_iter = src.lines();
|
||||
+ let mut is_first = true;
|
||||
for raw in lines_iter.by_ref().take(TOOL_CALL_MAX_LINES) {
|
||||
- lines.push(ansi_escape_line(raw).dim());
|
||||
+ let mut line = ansi_escape_line(raw);
|
||||
```
|
||||
|
||||
> Note you can also use `enumerate()` to get the `index` in addition to the value and then you can check `index == 0` instead of maintaining this `mut is_first`.
|
||||
96
prs/bolinfest/PR-1899.md
Normal file
96
prs/bolinfest/PR-1899.md
Normal file
@@ -0,0 +1,96 @@
|
||||
# PR #1899: Add 2025-08-06 model family
|
||||
|
||||
- URL: https://github.com/openai/codex/pull/1899
|
||||
- Author: pakrym-oai
|
||||
- Created: 2025-08-06 23:08:48 UTC
|
||||
- Updated: 2025-08-06 23:22:59 UTC
|
||||
- Changes: +19/-0, Files changed: 3, Commits: 3
|
||||
|
||||
## Description
|
||||
|
||||
(No description.)
|
||||
|
||||
## Full Diff
|
||||
|
||||
```diff
|
||||
diff --git a/codex-rs/core/src/client.rs b/codex-rs/core/src/client.rs
|
||||
index 9748cde7cb..c3721ceb18 100644
|
||||
--- a/codex-rs/core/src/client.rs
|
||||
+++ b/codex-rs/core/src/client.rs
|
||||
@@ -127,6 +127,15 @@ impl ModelClient {
|
||||
|
||||
let auth_mode = auth.as_ref().map(|a| a.mode);
|
||||
|
||||
+ if self.config.model_family.family == "2025-08-06-model"
|
||||
+ && auth_mode != Some(AuthMode::ChatGPT)
|
||||
+ {
|
||||
+ return Err(CodexErr::UnexpectedStatus(
|
||||
+ StatusCode::BAD_REQUEST,
|
||||
+ "2025-08-06-model is only supported with ChatGPT auth, run `codex login status` to check your auth status and `codex login` to login with ChatGPT".to_string(),
|
||||
+ ));
|
||||
+ }
|
||||
+
|
||||
let store = prompt.store && auth_mode != Some(AuthMode::ChatGPT);
|
||||
|
||||
let full_instructions = prompt.get_full_instructions(&self.config.model_family);
|
||||
diff --git a/codex-rs/core/src/model_family.rs b/codex-rs/core/src/model_family.rs
|
||||
index 7c4a9de6c2..cadbceca1e 100644
|
||||
--- a/codex-rs/core/src/model_family.rs
|
||||
+++ b/codex-rs/core/src/model_family.rs
|
||||
@@ -89,6 +89,11 @@ pub fn find_family_for_model(slug: &str) -> Option<ModelFamily> {
|
||||
simple_model_family!(slug, "gpt-oss")
|
||||
} else if slug.starts_with("gpt-3.5") {
|
||||
simple_model_family!(slug, "gpt-3.5")
|
||||
+ } else if slug.starts_with("2025-08-06-model") {
|
||||
+ model_family!(
|
||||
+ slug, "2025-08-06-model",
|
||||
+ supports_reasoning_summaries: true,
|
||||
+ )
|
||||
} else {
|
||||
None
|
||||
}
|
||||
diff --git a/codex-rs/core/src/openai_model_info.rs b/codex-rs/core/src/openai_model_info.rs
|
||||
index 935eb8be4f..0ce94267d3 100644
|
||||
--- a/codex-rs/core/src/openai_model_info.rs
|
||||
+++ b/codex-rs/core/src/openai_model_info.rs
|
||||
@@ -77,6 +77,11 @@ pub(crate) fn get_model_info(model_family: &ModelFamily) -> Option<ModelInfo> {
|
||||
max_output_tokens: 4_096,
|
||||
}),
|
||||
|
||||
+ "2025-08-06-model" => Some(ModelInfo {
|
||||
+ context_window: 200_000,
|
||||
+ max_output_tokens: 100_000,
|
||||
+ }),
|
||||
+
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Review Comments
|
||||
|
||||
### codex-rs/core/src/client.rs
|
||||
|
||||
- Created: 2025-08-06 23:16:09 UTC | Link: https://github.com/openai/codex/pull/1899#discussion_r2258507364
|
||||
|
||||
```diff
|
||||
@@ -127,6 +127,15 @@ impl ModelClient {
|
||||
|
||||
let auth_mode = auth.as_ref().map(|a| a.mode);
|
||||
|
||||
+ if self.config.model_family.family == "2025-08-06-model"
|
||||
```
|
||||
|
||||
> Can we have `requires_chatgpt_auth` as a new field of `ModelFamily`? The point was to get away from slug-specific checks.
|
||||
|
||||
- Created: 2025-08-06 23:22:59 UTC | Link: https://github.com/openai/codex/pull/1899#discussion_r2258517098
|
||||
|
||||
```diff
|
||||
@@ -127,6 +127,15 @@ impl ModelClient {
|
||||
|
||||
let auth_mode = auth.as_ref().map(|a| a.mode);
|
||||
|
||||
+ if self.config.model_family.family == "2025-08-06-model"
|
||||
```
|
||||
|
||||
> https://github.com/openai/codex/pull/1900
|
||||
351
prs/bolinfest/PR-1908.md
Normal file
351
prs/bolinfest/PR-1908.md
Normal file
@@ -0,0 +1,351 @@
|
||||
# PR #1908: Ensure exec command end always emitted
|
||||
|
||||
- URL: https://github.com/openai/codex/pull/1908
|
||||
- Author: aibrahim-oai
|
||||
- Created: 2025-08-07 01:08:48 UTC
|
||||
- Updated: 2025-08-07 06:26:05 UTC
|
||||
- Changes: +120/-73, Files changed: 2, Commits: 12
|
||||
|
||||
## Description
|
||||
|
||||
## Summary
|
||||
- defer ExecCommandEnd emission until after sandbox resolution
|
||||
- make sandbox error handler return final exec output and response
|
||||
- align sandbox error stderr with response content and rename to `final_output`
|
||||
- replace unstable `let` chains in client command header logic
|
||||
|
||||
## Testing
|
||||
- `just fmt`
|
||||
- `just fix`
|
||||
- `cargo test --all-features` *(fails: NotPresent in core/tests/client.rs)*
|
||||
|
||||
------
|
||||
https://chatgpt.com/codex/tasks/task_i_6893e63b0c408321a8e1ff2a052c4c51
|
||||
|
||||
## Full Diff
|
||||
|
||||
```diff
|
||||
diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs
|
||||
index 98d13b4cd6..4a4faa84ee 100644
|
||||
--- a/codex-rs/core/src/codex.rs
|
||||
+++ b/codex-rs/core/src/codex.rs
|
||||
@@ -46,6 +46,7 @@ use crate::conversation_history::ConversationHistory;
|
||||
use crate::error::CodexErr;
|
||||
use crate::error::Result as CodexResult;
|
||||
use crate::error::SandboxErr;
|
||||
+use crate::error::get_error_message_ui;
|
||||
use crate::exec::ExecParams;
|
||||
use crate::exec::ExecToolCallOutput;
|
||||
use crate::exec::SandboxType;
|
||||
@@ -468,6 +469,57 @@ impl Session {
|
||||
}
|
||||
}
|
||||
}
|
||||
+ /// Runs the exec tool call and emits events for the begin and end of the
|
||||
+ /// command even on error.
|
||||
+ ///
|
||||
+ /// Returns the output of the exec tool call.
|
||||
+ async fn run_exec_with_events<'a>(
|
||||
+ &self,
|
||||
+ turn_diff_tracker: &mut TurnDiffTracker,
|
||||
+ begin_ctx: ExecCommandContext,
|
||||
+ exec_args: ExecInvokeArgs<'a>,
|
||||
+ ) -> crate::error::Result<ExecToolCallOutput> {
|
||||
+ let is_apply_patch = begin_ctx.apply_patch.is_some();
|
||||
+ let sub_id = begin_ctx.sub_id.clone();
|
||||
+ let call_id = begin_ctx.call_id.clone();
|
||||
+
|
||||
+ self.on_exec_command_begin(turn_diff_tracker, begin_ctx.clone())
|
||||
+ .await;
|
||||
+
|
||||
+ let result = process_exec_tool_call(
|
||||
+ exec_args.params,
|
||||
+ exec_args.sandbox_type,
|
||||
+ exec_args.ctrl_c,
|
||||
+ exec_args.sandbox_policy,
|
||||
+ exec_args.codex_linux_sandbox_exe,
|
||||
+ exec_args.stdout_stream,
|
||||
+ )
|
||||
+ .await;
|
||||
+
|
||||
+ let output_stderr;
|
||||
+ let borrowed: &ExecToolCallOutput = match &result {
|
||||
+ Ok(output) => output,
|
||||
+ Err(e) => {
|
||||
+ output_stderr = ExecToolCallOutput {
|
||||
+ exit_code: -1,
|
||||
+ stdout: String::new(),
|
||||
+ stderr: get_error_message_ui(e),
|
||||
+ duration: Duration::default(),
|
||||
+ };
|
||||
+ &output_stderr
|
||||
+ }
|
||||
+ };
|
||||
+ self.on_exec_command_end(
|
||||
+ turn_diff_tracker,
|
||||
+ &sub_id,
|
||||
+ &call_id,
|
||||
+ borrowed,
|
||||
+ is_apply_patch,
|
||||
+ )
|
||||
+ .await;
|
||||
+
|
||||
+ result
|
||||
+ }
|
||||
|
||||
/// Helper that emits a BackgroundEvent with the given message. This keeps
|
||||
/// the call‑sites terse so adding more diagnostics does not clutter the
|
||||
@@ -1717,6 +1769,15 @@ fn parse_container_exec_arguments(
|
||||
}
|
||||
}
|
||||
|
||||
+pub struct ExecInvokeArgs<'a> {
|
||||
+ pub params: ExecParams,
|
||||
+ pub sandbox_type: SandboxType,
|
||||
+ pub ctrl_c: Arc<Notify>,
|
||||
+ pub sandbox_policy: &'a SandboxPolicy,
|
||||
+ pub codex_linux_sandbox_exe: &'a Option<PathBuf>,
|
||||
+ pub stdout_stream: Option<StdoutStream>,
|
||||
+}
|
||||
+
|
||||
fn maybe_run_with_user_profile(params: ExecParams, sess: &Session) -> ExecParams {
|
||||
if sess.shell_environment_policy.use_profile {
|
||||
let command = sess
|
||||
@@ -1887,23 +1948,26 @@ async fn handle_container_exec_with_params(
|
||||
},
|
||||
),
|
||||
};
|
||||
- sess.on_exec_command_begin(turn_diff_tracker, exec_command_context.clone())
|
||||
- .await;
|
||||
|
||||
let params = maybe_run_with_user_profile(params, sess);
|
||||
- let output_result = process_exec_tool_call(
|
||||
- params.clone(),
|
||||
- sandbox_type,
|
||||
- sess.ctrl_c.clone(),
|
||||
- &sess.sandbox_policy,
|
||||
- &sess.codex_linux_sandbox_exe,
|
||||
- Some(StdoutStream {
|
||||
- sub_id: sub_id.clone(),
|
||||
- call_id: call_id.clone(),
|
||||
- tx_event: sess.tx_event.clone(),
|
||||
- }),
|
||||
- )
|
||||
- .await;
|
||||
+ let output_result = sess
|
||||
+ .run_exec_with_events(
|
||||
+ turn_diff_tracker,
|
||||
+ exec_command_context.clone(),
|
||||
+ ExecInvokeArgs {
|
||||
+ params: params.clone(),
|
||||
+ sandbox_type,
|
||||
+ ctrl_c: sess.ctrl_c.clone(),
|
||||
+ sandbox_policy: &sess.sandbox_policy,
|
||||
+ codex_linux_sandbox_exe: &sess.codex_linux_sandbox_exe,
|
||||
+ stdout_stream: Some(StdoutStream {
|
||||
+ sub_id: sub_id.clone(),
|
||||
+ call_id: call_id.clone(),
|
||||
+ tx_event: sess.tx_event.clone(),
|
||||
+ }),
|
||||
+ },
|
||||
+ )
|
||||
+ .await;
|
||||
|
||||
match output_result {
|
||||
Ok(output) => {
|
||||
@@ -1914,24 +1978,14 @@ async fn handle_container_exec_with_params(
|
||||
duration,
|
||||
} = &output;
|
||||
|
||||
- sess.on_exec_command_end(
|
||||
- turn_diff_tracker,
|
||||
- &sub_id,
|
||||
- &call_id,
|
||||
- &output,
|
||||
- exec_command_context.apply_patch.is_some(),
|
||||
- )
|
||||
- .await;
|
||||
-
|
||||
let is_success = *exit_code == 0;
|
||||
let content = format_exec_output(
|
||||
if is_success { stdout } else { stderr },
|
||||
*exit_code,
|
||||
*duration,
|
||||
);
|
||||
-
|
||||
ResponseInputItem::FunctionCallOutput {
|
||||
- call_id,
|
||||
+ call_id: call_id.clone(),
|
||||
output: FunctionCallOutputPayload {
|
||||
content,
|
||||
success: Some(is_success),
|
||||
@@ -1949,16 +2003,13 @@ async fn handle_container_exec_with_params(
|
||||
)
|
||||
.await
|
||||
}
|
||||
- Err(e) => {
|
||||
- // Handle non-sandbox errors
|
||||
- ResponseInputItem::FunctionCallOutput {
|
||||
- call_id,
|
||||
- output: FunctionCallOutputPayload {
|
||||
- content: format!("execution error: {e}"),
|
||||
- success: None,
|
||||
- },
|
||||
- }
|
||||
- }
|
||||
+ Err(e) => ResponseInputItem::FunctionCallOutput {
|
||||
+ call_id: call_id.clone(),
|
||||
+ output: FunctionCallOutputPayload {
|
||||
+ content: format!("execution error: {e}"),
|
||||
+ success: None,
|
||||
+ },
|
||||
+ },
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1973,7 +2024,6 @@ async fn handle_sandbox_error(
|
||||
let call_id = exec_command_context.call_id.clone();
|
||||
let sub_id = exec_command_context.sub_id.clone();
|
||||
let cwd = exec_command_context.cwd.clone();
|
||||
- let is_apply_patch = exec_command_context.apply_patch.is_some();
|
||||
|
||||
// Early out if either the user never wants to be asked for approval, or
|
||||
// we're letting the model manage escalation requests. Otherwise, continue
|
||||
@@ -2039,24 +2089,26 @@ async fn handle_sandbox_error(
|
||||
sess.notify_background_event(&sub_id, "retrying command without sandbox")
|
||||
.await;
|
||||
|
||||
- sess.on_exec_command_begin(turn_diff_tracker, exec_command_context)
|
||||
- .await;
|
||||
-
|
||||
// This is an escalated retry; the policy will not be
|
||||
// examined and the sandbox has been set to `None`.
|
||||
- let retry_output_result = process_exec_tool_call(
|
||||
- params,
|
||||
- SandboxType::None,
|
||||
- sess.ctrl_c.clone(),
|
||||
- &sess.sandbox_policy,
|
||||
- &sess.codex_linux_sandbox_exe,
|
||||
- Some(StdoutStream {
|
||||
- sub_id: sub_id.clone(),
|
||||
- call_id: call_id.clone(),
|
||||
- tx_event: sess.tx_event.clone(),
|
||||
- }),
|
||||
- )
|
||||
- .await;
|
||||
+ let retry_output_result = sess
|
||||
+ .run_exec_with_events(
|
||||
+ turn_diff_tracker,
|
||||
+ exec_command_context.clone(),
|
||||
+ ExecInvokeArgs {
|
||||
+ params,
|
||||
+ sandbox_type: SandboxType::None,
|
||||
+ ctrl_c: sess.ctrl_c.clone(),
|
||||
+ sandbox_policy: &sess.sandbox_policy,
|
||||
+ codex_linux_sandbox_exe: &sess.codex_linux_sandbox_exe,
|
||||
+ stdout_stream: Some(StdoutStream {
|
||||
+ sub_id: sub_id.clone(),
|
||||
+ call_id: call_id.clone(),
|
||||
+ tx_event: sess.tx_event.clone(),
|
||||
+ }),
|
||||
+ },
|
||||
+ )
|
||||
+ .await;
|
||||
|
||||
match retry_output_result {
|
||||
Ok(retry_output) => {
|
||||
@@ -2067,15 +2119,6 @@ async fn handle_sandbox_error(
|
||||
duration,
|
||||
} = &retry_output;
|
||||
|
||||
- sess.on_exec_command_end(
|
||||
- turn_diff_tracker,
|
||||
- &sub_id,
|
||||
- &call_id,
|
||||
- &retry_output,
|
||||
- is_apply_patch,
|
||||
- )
|
||||
- .await;
|
||||
-
|
||||
let is_success = *exit_code == 0;
|
||||
let content = format_exec_output(
|
||||
if is_success { stdout } else { stderr },
|
||||
@@ -2084,23 +2127,20 @@ async fn handle_sandbox_error(
|
||||
);
|
||||
|
||||
ResponseInputItem::FunctionCallOutput {
|
||||
- call_id,
|
||||
+ call_id: call_id.clone(),
|
||||
output: FunctionCallOutputPayload {
|
||||
content,
|
||||
success: Some(is_success),
|
||||
},
|
||||
}
|
||||
}
|
||||
- Err(e) => {
|
||||
- // Handle retry failure
|
||||
- ResponseInputItem::FunctionCallOutput {
|
||||
- call_id,
|
||||
- output: FunctionCallOutputPayload {
|
||||
- content: format!("retry failed: {e}"),
|
||||
- success: None,
|
||||
- },
|
||||
- }
|
||||
- }
|
||||
+ Err(e) => ResponseInputItem::FunctionCallOutput {
|
||||
+ call_id: call_id.clone(),
|
||||
+ output: FunctionCallOutputPayload {
|
||||
+ content: format!("retry failed: {e}"),
|
||||
+ success: None,
|
||||
+ },
|
||||
+ },
|
||||
}
|
||||
}
|
||||
ReviewDecision::Denied | ReviewDecision::Abort => {
|
||||
diff --git a/codex-rs/core/src/error.rs b/codex-rs/core/src/error.rs
|
||||
index 9cdc4eb544..537f4a0361 100644
|
||||
--- a/codex-rs/core/src/error.rs
|
||||
+++ b/codex-rs/core/src/error.rs
|
||||
@@ -132,3 +132,10 @@ impl CodexErr {
|
||||
(self as &dyn std::any::Any).downcast_ref::<T>()
|
||||
}
|
||||
}
|
||||
+
|
||||
+pub fn get_error_message_ui(e: &CodexErr) -> String {
|
||||
+ match e {
|
||||
+ CodexErr::Sandbox(SandboxErr::Denied(_, _, stderr)) => stderr.to_string(),
|
||||
+ _ => e.to_string(),
|
||||
+ }
|
||||
+}
|
||||
```
|
||||
|
||||
## Review Comments
|
||||
|
||||
### codex-rs/core/src/codex.rs
|
||||
|
||||
- Created: 2025-08-07 06:05:20 UTC | Link: https://github.com/openai/codex/pull/1908#discussion_r2259168708
|
||||
|
||||
```diff
|
||||
@@ -1717,6 +1766,15 @@ fn parse_container_exec_arguments(
|
||||
}
|
||||
}
|
||||
|
||||
+pub struct ExecInvokeArgs<'a> {
|
||||
+ pub params: crate::exec::ExecParams,
|
||||
```
|
||||
|
||||
> I would make more of these top-level imports so this is a bit easier to read.
|
||||
|
||||
- Created: 2025-08-07 06:11:56 UTC | Link: https://github.com/openai/codex/pull/1908#discussion_r2259189591
|
||||
|
||||
```diff
|
||||
@@ -469,6 +470,54 @@ impl Session {
|
||||
}
|
||||
}
|
||||
|
||||
+ async fn run_exec_with_events<'a>(
|
||||
```
|
||||
|
||||
> This function returns `Result`, so can you add a docstring to comment that we guarantee that both `on_exec_command_begin()` and `on_exec_command_end()` are called, even if this function returns `Err`?
|
||||
>
|
||||
> Also update to note that this is used both when the shell call is done originally as well as for the sandbox retry.
|
||||
3089
prs/bolinfest/PR-1920.md
Normal file
3089
prs/bolinfest/PR-1920.md
Normal file
File diff suppressed because it is too large
Load Diff
95
prs/bolinfest/PR-1927.md
Normal file
95
prs/bolinfest/PR-1927.md
Normal file
@@ -0,0 +1,95 @@
|
||||
# PR #1927: Reorganize README + move usage & contributing to /docs.
|
||||
|
||||
- URL: https://github.com/openai/codex/pull/1927
|
||||
- Author: ReubenNarad
|
||||
- Created: 2025-08-07 07:13:16 UTC
|
||||
- Updated: 2025-08-12 05:04:57 UTC
|
||||
- Changes: +0/-0, Files changed: 0, Commits: 0
|
||||
|
||||
## Description
|
||||
|
||||
Broke the monolithic README into a cleaner structure, shortening the README and moving detailed content to docs/ as contributing.md, usage.md, and config.md.
|
||||
|
||||
## Full Diff
|
||||
|
||||
```diff
|
||||
|
||||
```
|
||||
|
||||
## Review Comments
|
||||
|
||||
### README.md
|
||||
|
||||
- Created: 2025-08-07 18:31:11 UTC | Link: https://github.com/openai/codex/pull/1927#discussion_r2261128282
|
||||
|
||||
```diff
|
||||
@@ -3,67 +3,15 @@
|
||||
|
||||
<p align="center"><code>npm i -g @openai/codex</code><br />or <code>brew install codex</code></p>
|
||||
|
||||
-This is the home of the **Codex CLI**, which is a coding agent from OpenAI that runs locally on your computer. If you are looking for the _cloud-based agent_ from OpenAI, **Codex [Web]**, see <https://chatgpt.com/codex>.
|
||||
+This is the home of the **Codex CLI**, OpenAI's coding agent that runs locally on your computer. If you are looking for the _cloud-based agent_ from OpenAI, **Codex [Web]**, see <https://chatgpt.com/codex>.
|
||||
|
||||
<!--  -->
|
||||
|
||||
---
|
||||
|
||||
-<details>
|
||||
-<summary><strong>Table of contents</strong></summary>
|
||||
-
|
||||
-<!-- Begin ToC -->
|
||||
-
|
||||
-- [Experimental technology disclaimer](#experimental-technology-disclaimer)
|
||||
-- [Quickstart](#quickstart)
|
||||
- - [OpenAI API Users](#openai-api-users)
|
||||
- - [OpenAI Plus/Pro Users](#openai-pluspro-users)
|
||||
-- [Using Open Source Models](#using-open-source-models)
|
||||
-- [Why Codex?](#why-codex)
|
||||
-- [Security model & permissions](#security-model--permissions)
|
||||
- - [Platform sandboxing details](#platform-sandboxing-details)
|
||||
-- [System requirements](#system-requirements)
|
||||
-- [CLI reference](#cli-reference)
|
||||
-- [Memory & project docs](#memory--project-docs)
|
||||
-- [Non-interactive / CI mode](#non-interactive--ci-mode)
|
||||
-- [Model Context Protocol (MCP)](#model-context-protocol-mcp)
|
||||
-- [Tracing / verbose logging](#tracing--verbose-logging)
|
||||
-- [Recipes](#recipes)
|
||||
-- [Installation](#installation)
|
||||
- - [DotSlash](#dotslash)
|
||||
-- [Configuration](#configuration)
|
||||
-- [FAQ](#faq)
|
||||
-- [Zero data retention (ZDR) usage](#zero-data-retention-zdr-usage)
|
||||
-- [Codex open source fund](#codex-open-source-fund)
|
||||
-- [Contributing](#contributing)
|
||||
- - [Development workflow](#development-workflow)
|
||||
- - [Writing high-impact code changes](#writing-high-impact-code-changes)
|
||||
- - [Opening a pull request](#opening-a-pull-request)
|
||||
- - [Review process](#review-process)
|
||||
- - [Community values](#community-values)
|
||||
- - [Getting help](#getting-help)
|
||||
- - [Contributor license agreement (CLA)](#contributor-license-agreement-cla)
|
||||
- - [Quick fixes](#quick-fixes)
|
||||
- - [Releasing `codex`](#releasing-codex)
|
||||
-- [Security & responsible AI](#security--responsible-ai)
|
||||
-- [License](#license)
|
||||
-
|
||||
-<!-- End ToC -->
|
||||
-
|
||||
-</details>
|
||||
-
|
||||
----
|
||||
-
|
||||
-## Experimental technology disclaimer
|
||||
-
|
||||
-Codex CLI is an experimental project under active development. It is not yet stable, may contain bugs, incomplete features, or undergo breaking changes. We're building it in the open with the community and welcome:
|
||||
-
|
||||
-- Bug reports
|
||||
-- Feature requests
|
||||
-- Pull requests
|
||||
-- Good vibes
|
||||
-
|
||||
-Help us improve by filing issues or submitting PRs (see the section below for how to contribute)!
|
||||
+> ⚠️ **Experimental**
|
||||
```
|
||||
|
||||
> Please do not signal this is experimental.
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user