Compare commits

...
Author SHA1 Message Date
yilsem 8f53b090f7 feat(codex-link): persist direct subscription and AITURK connection modes
Server CI and Windows package / verify (push) Successful in 2m54s
Server CI and Windows package / windows-package (push) Successful in 7m17s
2026-10-13 19:56:40 +03:00
yilsem 049c4d1338 fix(installer): preserve healthy Python runtime and Codex connections
Server CI and Windows package / verify (push) Successful in 2m47s
Server CI and Windows package / windows-package (push) Successful in 6m58s
2026-10-13 17:22:28 +03:00
yilsem 4924781e2d fix(bridge): stop replaying explicit terminal provider failures 2026-10-13 17:18:38 +03:00
yilsem 4037c65204 ci: install ripgrep for real search regressions
Server CI and Windows package / verify (push) Successful in 2m51s
Server CI and Windows package / windows-package (push) Successful in 6m14s
2026-10-13 16:35:24 +03:00
yilsem e7d3080e0a fix: preserve native model switches and bound project searches
Server CI and Windows package / verify (push) Failing after 1m40s
Server CI and Windows package / windows-package (push) Skipped
2026-10-13 16:32:50 +03:00
yilsem db5e876f40 fix(bridge): preserve plaintext peer task payloads for external models
Server CI and Windows package / verify (push) Successful in 3m16s
Server CI and Windows package / windows-package (push) Successful in 8m18s
2026-10-13 08:05:00 +03:00
yilsem ab7a721fbc Ship beta34 with external agent tasks, terminal SSE and live token limits
Server CI and Windows package / verify (push) Successful in 3m13s
Server CI and Windows package / windows-package (push) Successful in 7m56s
2026-10-13 07:32:57 +03:00
yilsem 2cca6a66a6 Preserve namespaced Codex tools across external model calls
Server CI and Windows package / verify (push) Successful in 2m11s
Server CI and Windows package / windows-package (push) Successful in 10m44s
2026-10-13 04:54:52 +03:00
35 changed files with 1283 additions and 73 deletions

No files matched your search

+8 -1
View File
@@ -23,6 +23,9 @@ jobs:
run: | run: |
set -eu set -eu
test "$(git rev-parse HEAD)" = "$GITHUB_SHA" test "$(git rev-parse HEAD)" = "$GITHUB_SHA"
apt-get update
apt-get install -y --no-install-recommends ripgrep
rg --version
python3 -m venv "$RUNNER_TEMP/bootstrap" python3 -m venv "$RUNNER_TEMP/bootstrap"
"$RUNNER_TEMP/bootstrap/bin/pip" install uv "$RUNNER_TEMP/bootstrap/bin/pip" install uv
"$RUNNER_TEMP/bootstrap/bin/uv" venv --python 3.11 "$RUNNER_TEMP/ci-venv" "$RUNNER_TEMP/bootstrap/bin/uv" venv --python 3.11 "$RUNNER_TEMP/ci-venv"
@@ -32,8 +35,12 @@ jobs:
"$RUNNER_TEMP/ci-venv/bin/python" -m pytest tests/tools/test_protected_instruction_unattended.py tests/tools/test_file_write_safety.py tests/tools/test_computer_use_approval_isolation.py tests/cli/test_cli_approval_ui.py tests/cli/test_single_query_session_finalize.py -q -p no:cacheprovider -rf "$RUNNER_TEMP/ci-venv/bin/python" -m pytest tests/tools/test_protected_instruction_unattended.py tests/tools/test_file_write_safety.py tests/tools/test_computer_use_approval_isolation.py tests/cli/test_cli_approval_ui.py tests/cli/test_single_query_session_finalize.py -q -p no:cacheprovider -rf
- name: Codex bridge regression tests - name: Codex bridge regression tests
run: '"$RUNNER_TEMP/ci-venv/bin/python" -m unittest discover -s apps/desktop/assets/codex-bridge' run: '"$RUNNER_TEMP/ci-venv/bin/python" -m unittest discover -s apps/desktop/assets/codex-bridge'
- name: Managed Python update preservation
run: '"$RUNNER_TEMP/ci-venv/bin/python" -m pytest tests/test_install_ps1_reuse_healthy_venv.py tests/test_install_ps1_python_fallback_venv.py -q -p no:cacheprovider -rf'
- name: Project scoped and bounded search regressions
run: '"$RUNNER_TEMP/ci-venv/bin/python" -m pytest tests/tools/test_search_project_scope.py tests/tools/test_search_files_cpu_windows.py tests/tools/test_search_zero_match_and_multipath.py tests/tools/test_search_giant_line_containment.py tests/tools/test_search_budget_truncation.py -q -p no:cacheprovider -rf'
- name: Native watch and speech mux pytest regressions - name: Native watch and speech mux pytest regressions
run: '"$RUNNER_TEMP/ci-venv/bin/python" -m pytest apps/desktop/assets/codex-bridge/test_bekci_watch.py apps/desktop/assets/codex-bridge/test_native_speech_mux.py -q -p no:cacheprovider -rf' run: '"$RUNNER_TEMP/ci-venv/bin/python" -m pytest apps/desktop/assets/codex-bridge/test_bekci_watch.py apps/desktop/assets/codex-bridge/test_native_speech_mux.py apps/desktop/assets/codex-bridge/test_live_limits.py -q -p no:cacheprovider -rf'
- name: Mobile and desktop startup regression tests - name: Mobile and desktop startup regression tests
run: | run: |
npm install --prefix "$RUNNER_TEMP/mobile-tests" --no-save --ignore-scripts --no-audit --no-fund --legacy-peer-deps vitest@4.1.10 ws@8.21.1 npm install --prefix "$RUNNER_TEMP/mobile-tests" --no-save --ignore-scripts --no-audit --no-fund --legacy-peer-deps vitest@4.1.10 ws@8.21.1
+5 -2
View File
@@ -25,6 +25,8 @@ ayrıntı seviyesi, korunacak mesajlaşmalar, yüzde/token bütçesi ve otomatik
seçimini sunar. Varsayılanlar: Gemini 3.8 Flash High, yedek Gemini 3 Flash, seçimini sunar. Varsayılanlar: Gemini 3.8 Flash High, yedek Gemini 3 Flash,
10 mesajlaşma, geçişte %70, otomatik tetiklemede %70 ve sonrasında %50. 10 mesajlaşma, geçişte %70, otomatik tetiklemede %70 ve sonrasında %50.
Kapasite bağlantıdan gelen model bilgisiyle hesaplanır; 272K genel tavan yoktur. Kapasite bağlantıdan gelen model bilgisiyle hesaplanır; 272K genel tavan yoktur.
`/models` içindeki doğrulanmış sayısal bağlam bilgisi, model adından çıkarılan
yerel varsayılandan önce gelir; gözlenmiş daha düşük bir gerçek sınır korunur.
Girdi hesabı talimatlar ve araçları da içeren temkinli bir token tahminidir. Girdi hesabı talimatlar ve araçları da içeren temkinli bir token tahminidir.
Abonelik modellerinin kendi aralarındaki istekler ve GPT-Live ses yolu özgün Abonelik modellerinin kendi aralarındaki istekler ve GPT-Live ses yolu özgün
@@ -58,8 +60,9 @@ npm run builder --workspace=apps/desktop -- --win nsis --x64 --publish never
Dağıtılacak derleme temiz, yayımlanmış Git commit'inden üretilmelidir. İlk açılış Dağıtılacak derleme temiz, yayımlanmış Git commit'inden üretilmelidir. İlk açılış
ajan kurulumunu paketteki `install-stamp.json` commit'ine sabitler. Windows ajan kurulumunu paketteki `install-stamp.json` commit'ine sabitler. Windows
imzası ve temiz kurulum doğrulanmadan bir paket doğrulanmış üretim sürümü diye paketleri imzasız EXE/ZIP olarak yayımlanır; SHA-256 ve gerçek Windows çalıştırma
sunulmamalıdır. macOS ve Linux paketleri bu beta kapsamında doğrulanmamıştır. kontrolü gereklidir. Kullanıcı açıkça istemeden imzalama yapılmaz. macOS ve Linux
paketleri bu beta kapsamında doğrulanmamıştır.
## Lisans ve kaynak ## Lisans ve kaynak
+60
View File
@@ -8,6 +8,66 @@ path; broad diagnostic probes should apply this policy to recursive walks.
from __future__ import annotations from __future__ import annotations
from pathlib import Path
SEARCH_RG_THREADS = 2
SEARCH_MAX_CONCURRENT = 2
_PROJECT_MARKERS = ('.git', '.hg', 'pyproject.toml', 'package.json',
'Cargo.toml', 'go.mod', 'CMakeLists.txt')
def _project_root(path):
"""Prefer the enclosing repository over nested monorepo packages."""
package = None
for parent in (path, *path.parents):
if (parent / '.git').exists() or (parent / '.hg').exists():
return parent
if package is None and any((parent / marker).exists() for marker in _PROJECT_MARKERS):
package = parent
return package
def local_search_scope(path, cwd, home):
"""Resolve broad local roots without starting a recursive search process.
Returns (root, notice, error). Only one level of candidate directories is
inspected; no repository-wide discovery is needed to reject an umbrella.
Real paths prevent symlink aliases from bypassing broad-root detection.
"""
root = Path(path)
if not root.is_absolute():
root = Path(cwd) / root
root = root.resolve()
if not root.is_dir():
return path, None, None
home = Path(home).resolve()
project = _project_root(Path(cwd).resolve())
root_project = _project_root(root)
if root_project is not None:
return path, None, None
candidates = []
try:
# Bound metadata work even in directories containing many entries.
from itertools import islice
for child in islice(root.iterdir(), 256):
if child.name in SEARCH_PRUNE_DIR_NAMES or not child.is_dir():
continue
if any((child / marker).exists() for marker in _PROJECT_MARKERS):
candidates.append(str(child))
except OSError:
pass
broad = (root == Path(root.anchor) or root == home or root in home.parents
or len(candidates) > 1 or (project is not None and root in project.parents))
if not broad:
return path, None, None
if project is not None and root in project.parents:
chosen = str(project)
return chosen, f'Search scoped to current project: {chosen}. Set allow_broad=true for the requested umbrella.', None
choices = ', '.join(sorted(candidates)[:12]) or 'choose an explicit project directory'
return path, None, ('Search scope needed: requested root spans projects or a home/drive. '
f'Candidates: {choices}. Set allow_broad=true only for an intentional broad search.')
# Keep this policy conservative and name-based so it works for local and remote # Keep this policy conservative and name-based so it works for local and remote
# shell backends alike. The same set is used by context discovery and search # shell backends alike. The same set is used by context discovery and search
+61 -4
View File
@@ -32,9 +32,10 @@ import time
import urllib.error import urllib.error
import urllib.request import urllib.request
import gecis_context import gecis_context
import terminal_stream
HERE = os.path.dirname(os.path.abspath(__file__)) HERE = os.path.dirname(os.path.abspath(__file__))
SURUM = "13" # AITURK IDE'nin bekledigi kopru surumu (codex-link-config.ts CODEX_BRIDGE_VERSION) SURUM = "18" # AITURK IDE'nin bekledigi kopru surumu (codex-link-config.ts CODEX_BRIDGE_VERSION)
try: try:
with open(os.path.abspath(__file__), "rb") as _f: with open(os.path.abspath(__file__), "rb") as _f:
KOD = hashlib.sha256(_f.read()).hexdigest()[:8] KOD = hashlib.sha256(_f.read()).hexdigest()[:8]
@@ -348,6 +349,36 @@ class H(http.server.BaseHTTPRequestHandler):
self.end_headers() self.end_headers()
self.wfile.write(hata) self.wfile.write(hata)
return True return True
observer = None
first_chunks = []
failure_key = getattr(self, "_failure_key", None)
if (hedef == KOPRU and failure_key is not None and durum == 200
and "text/event-stream" in yanit.headers.get("Content-Type", "")
and yanit.headers.get("Content-Encoding", "identity") == "identity"):
observer = terminal_stream.Observer()
if not started:
# Inspect only until the first SSE frame/comment. Keep normal
# streaming and heartbeats; an error-only response can still
# return a truthful HTTP failure before headers are committed.
try:
while True:
first = yanit.read1(4096)
if not first:
break
first_chunks.append(first)
observer.feed(first)
if observer.error and not observer.payload_seen:
yanit.close()
self._json(400, terminal_stream.error_body(observer.error))
return True
if observer.comment_seen or observer.payload_seen or sum(map(len, first_chunks)) >= observer.LIMIT:
break
except (OSError, http.client.HTTPException):
# A transport interruption without an explicit provider
# failure keeps the client's existing recovery behavior.
yanit.close()
self.close_connection = True
return True
if not started: if not started:
self.send_response(durum) self.send_response(durum)
for k, v in yanit.headers.items(): for k, v in yanit.headers.items():
@@ -359,6 +390,10 @@ class H(http.server.BaseHTTPRequestHandler):
event_tail = b"" event_tail = b""
completed = False completed = False
try: try:
for first in first_chunks:
toplam += len(first)
self.wfile.write(b"%x\r\n" % len(first) + first + b"\r\n")
self.wfile.flush()
while True: while True:
try: try:
parca = yanit.read1(4096) # gelen kadarini hemen verir; bekletmez parca = yanit.read1(4096) # gelen kadarini hemen verir; bekletmez
@@ -370,6 +405,8 @@ class H(http.server.BaseHTTPRequestHandler):
if not parca: if not parca:
break break
toplam += len(parca) toplam += len(parca)
if observer is not None:
observer.feed(parca)
if getattr(self, "_native_return_scope", None): if getattr(self, "_native_return_scope", None):
event_tail += parca event_tail += parca
while b"\n" in event_tail: while b"\n" in event_tail:
@@ -382,6 +419,9 @@ class H(http.server.BaseHTTPRequestHandler):
pass pass
self.wfile.write(b"%x\r\n" % len(parca) + parca + b"\r\n") self.wfile.write(b"%x\r\n" % len(parca) + parca + b"\r\n")
self.wfile.flush() self.wfile.flush()
if observer is not None and observer.error and not observer.completed:
self.server.failure_replays.remember(failure_key, observer.error)
log(f"{tag} terminal worker error; identical automatic replay will return HTTP 400")
self.wfile.write(b"0\r\n\r\n") self.wfile.write(b"0\r\n\r\n")
self.wfile.flush() self.wfile.flush()
except (ConnectionAbortedError, BrokenPipeError, ConnectionResetError): except (ConnectionAbortedError, BrokenPipeError, ConnectionResetError):
@@ -453,6 +493,7 @@ class H(http.server.BaseHTTPRequestHandler):
def _isle(self): def _isle(self):
self._native_return_scope = None self._native_return_scope = None
self._failure_key = None
with kilit: with kilit:
sira[0] += 1 sira[0] += 1
n = sira[0] n = sira[0]
@@ -479,20 +520,32 @@ class H(http.server.BaseHTTPRequestHandler):
req = {} req = {}
except Exception: except Exception:
req = {} req = {}
if external:
self._failure_key = terminal_stream.request_key(req or {"raw": govde.hex()}, self.headers)
previous_failure = self.server.failure_replays.take(self._failure_key)
if previous_failure:
log(f"[{n} {model}] terminal worker error replay stopped before upstream request")
return self._json(400, terminal_stream.error_body(previous_failure))
thread = str(req.get("prompt_cache_key") or "") thread = str(req.get("prompt_cache_key") or "")
scope = hashlib.sha256((str(self.headers.get("chatgpt-account-id") or "") + "|" + thread).encode()).hexdigest() scope = hashlib.sha256((str(self.headers.get("chatgpt-account-id") or "") + "|" + thread).encode()).hexdigest()
source = TRANSFERS.get(scope) if thread else None source = TRANSFERS.get(scope) if thread else None
# A completed return to the subscription ends migration. Historical # A completed return to the subscription ends migration. Historical
# foreign IDs must not keep later native turns in the preparer. # foreign IDs must not keep later native turns in the preparer.
mixed = ek_model_mu(source or "") or (source is None and gecis_context.foreign(req)) mixed = ek_model_mu(source or "") or (source is None and gecis_context.foreign_history(req))
if external or mixed: # Format repair is stateless: the saved transcript can still contain
# foreign wrappers after a successful native return (or a restart).
if external or mixed or gecis_context.foreign(req):
try: try:
normalized = gecis_context.normalize(req) normalized = gecis_context.normalize(req)
except gecis_context.TransferError as e: except gecis_context.TransferError as e:
self._transfer_begin() self._transfer_begin()
return self._transfer_fail(str(e)) return self._transfer_fail(str(e))
# Small returning requests need no summarizer or local worker. # Small returning requests need no summarizer or local worker.
if external or gecis_context.tokens(normalized) > int(32768 * 0.70): # Native opaque state must remain on its owner route. Its byte
# size is not a semantic token budget and cannot trigger a
# foreign summarizer, including when transfer state is cold.
if external or (mixed and not gecis_context.native_opaque(normalized)
and gecis_context.tokens(normalized) > int(32768 * 0.70)):
prepared, started = self._prepare(normalized, source) prepared, started = self._prepare(normalized, source)
if prepared is None: if prepared is None:
return return
@@ -535,6 +588,10 @@ class H(http.server.BaseHTTPRequestHandler):
class Sunucu(http.server.ThreadingHTTPServer): class Sunucu(http.server.ThreadingHTTPServer):
def __init__(self, *args, **kwargs):
self.failure_replays = terminal_stream.FailureReplays()
super().__init__(*args, **kwargs)
daemon_threads = True daemon_threads = True
# Shipped Python 3.11 defaults to only five pending connections. Model # Shipped Python 3.11 defaults to only five pending connections. Model
# discovery, voice and simultaneous chats can arrive in the same burst. # discovery, voice and simultaneous chats can arrive in the same burst.
@@ -180,9 +180,13 @@ def prepare(handler, module, payload, checkpoints):
slug = original.get('model', '') slug = original.get('model', '')
external = module.ek_model_mu(slug) external = module.ek_model_mu(slug)
source_model = payload.get('source_model') or '' source_model = payload.get('source_model') or ''
if not external and gc.native_opaque(original):
# Defense in depth for direct/older splitter callers. Native encrypted
# state never enters owner decoding or cross-provider summarization.
return {'request': gc.normalize(original)}
# Defense in depth: an accidental worker call cannot alter native-only history. # Defense in depth: an accidental worker call cannot alter native-only history.
if not external and not module.ek_model_mu(source_model) and not gc.foreign(original): if not external and not module.ek_model_mu(source_model) and not gc.foreign_history(original):
return {'request': copy.deepcopy(original), 'window': handler._transfer_window(slug)} return {'request': gc.normalize(original)}
settings = settings_store.read(module.HOME) settings = settings_store.read(module.HOME)
actual = (module.yonlendirilen(str(original.get('prompt_cache_key') or ''), slug) or slug) if external else slug actual = (module.yonlendirilen(str(original.get('prompt_cache_key') or ''), slug) or slug) if external else slug
window = handler._transfer_window(actual) window = handler._transfer_window(actual)
@@ -7,6 +7,7 @@ must not enter the budget preparation path.
import copy import copy
import hashlib import hashlib
import json import json
import re
import threading import threading
from collections import OrderedDict from collections import OrderedDict
@@ -53,6 +54,22 @@ def foreign(req):
return False return False
def native_opaque(req):
"""Native encrypted state belongs to the subscription, even in mixed history."""
return any(isinstance(item, dict) and item.get("type") == "compaction" and item.get("encrypted_content")
and not str(item["encrypted_content"]).startswith(ozet_kopru.ISARET)
for item in req.get("input") or [])
def foreign_history(req):
"""Semantic migration evidence, excluding tool ID format mismatches."""
return any(isinstance(item, dict) and (
"chatcmpl-" in str(item.get("id") or "")
or "astrabridge" in str(item.get("id") or "")
or str(item.get("encrypted_content") or "").startswith(ozet_kopru.ISARET))
for item in req.get("input") or [])
def normalize(req): def normalize(req):
"""Repair local wrappers/IDs, retaining real OpenAI encrypted compaction. """Repair local wrappers/IDs, retaining real OpenAI encrypted compaction.
@@ -81,6 +98,10 @@ def normalize(req):
if any(visible): if any(visible):
result.append(ozet_kopru._ozet_mesaji("\n".join(visible))) result.append(ozet_kopru._ozet_mesaji("\n".join(visible)))
continue continue
if ("chatcmpl-" in str(identity) or "astrabridge" in str(identity)) and (
item.get("type") == "message" or item.get("role")):
# Inline message content is portable; its foreign server ID is not.
item.pop("id", None)
prefix = PREFIXES.get(item.get("type")) prefix = PREFIXES.get(item.get("type"))
if prefix and isinstance(identity, str) and identity and not identity.startswith(prefix + "_"): if prefix and isinstance(identity, str) and identity and not identity.startswith(prefix + "_"):
item["id"] = prefix + "_" + identity.split("_", 1)[-1] item["id"] = prefix + "_" + identity.split("_", 1)[-1]
@@ -104,6 +125,31 @@ def portable_reasoning(req):
return out return out
result = [] result = []
for item in items: for item in items:
if isinstance(item, dict) and item.get("type") == "agent_message":
# Peer messages carry delegated tasks/results. External Responses
# translators do not understand this Codex-specific item type.
# Keep provenance as data, never as system/developer authority.
content = item.get("content") or []
if isinstance(content, str):
content = [{"type": "input_text", "text": content}]
portable = []
for part in content:
if isinstance(part, dict) and part.get("type") == "encrypted_content":
# Codex peer delivery can put the literal task/result in
# this wrapper. It is distinct from encrypted reasoning.
# External translators otherwise silently drop the task.
text = part.get("encrypted_content")
if (not isinstance(text, str) or text.startswith("gAAAA")
or re.fullmatch(r"[A-Za-z0-9_+/=-]{128,}", text)):
raise TransferError("Alt ajan iletisi şifreli veya okunamıyor; görev eksik gönderilmedi.")
portable.append({"type": "input_text", "text": text})
else:
portable.append(part)
result.append({"type": "message", "role": "user", "content": [
{"type": "input_text", "text": "Agent message: " + encoded({
"author": item.get("author"), "recipient": item.get("recipient")})}
] + portable})
continue
if not isinstance(item, dict) or item.get("type") != "reasoning": if not isinstance(item, dict) or item.get("type") != "reasoning":
result.append(item) result.append(item)
continue continue
@@ -348,11 +348,13 @@ def yukle(ua="AstraBridge/1.0 (codex)", timeout=30, home=None, ag=True):
if not girdiler: if not girdiler:
raise ValueError("Gecitte kullanilabilir OmniRoute sohbet modeli yok") raise ValueError("Gecitte kullanilabilir OmniRoute sohbet modeli yok")
kayitlar = {m["id"]: m for m in metadata if m.get("id")} kayitlar = {m["id"]: m for m in metadata if m.get("id")}
ilanlar = {m["id"]: m for m in data if m.get("id")}
nano = pencere.nano_yukle(home, ua, ag=ag) # ag=False: acilista yalniz onbellek nano = pencere.nano_yukle(home, ua, ag=ag) # ag=False: acilista yalniz onbellek
ogr = pencere.ogrenilen(home) ogr = pencere.ogrenilen(home)
aracsiz = pencere.aracsizlar(home) aracsiz = pencere.aracsizlar(home)
for g in girdiler: for g in girdiler:
bilgi = pencere.coz(g["id"], g["kind"], g.get("yol"), kayitlar.get(g["id"]), nano, ogr, aracsiz) bilgi = pencere.coz(g["id"], g["kind"], g.get("yol"), kayitlar.get(g["id"]), nano, ogr, aracsiz,
ilan=ilanlar.get(g["id"]))
g["pencere"], g["gorsel"], g["arac"], g["pencere_kaynak"] = (bilgi["pencere"], bilgi["gorsel"], g["pencere"], g["gorsel"], g["arac"], g["pencere_kaynak"] = (bilgi["pencere"], bilgi["gorsel"],
bilgi["arac"], bilgi["kaynak"]) bilgi["arac"], bilgi["kaynak"])
g["claude"] = pencere.claude_var(g["kind"], g.get("yol"), kayitlar.get(g["id"])) g["claude"] = pencere.claude_var(g["kind"], g.get("yol"), kayitlar.get(g["id"]))
+73 -17
View File
@@ -40,7 +40,7 @@ from bekci_watch import MetadataFilter
# Installed only by owned native launcher, never an HTTP control endpoint. # Installed only by owned native launcher, never an HTTP control endpoint.
WATCH_OWNER = None WATCH_OWNER = None
SURUM = "13" SURUM = "18"
HERE = os.path.dirname(os.path.abspath(__file__)) HERE = os.path.dirname(os.path.abspath(__file__))
try: try:
with open(os.path.abspath(__file__), "rb") as _f: with open(os.path.abspath(__file__), "rb") as _f:
@@ -363,15 +363,40 @@ def arac_semalarini_duzelt(req):
n += bool(degisti) n += bool(degisti)
return n return n
def flatten_request(req): def flatten_request(req, tool_names=None):
"""additional_tools/namespace/custom -> duz function tools. Donus: custom arac adlari kumesi.""" """additional_tools/namespace/custom -> duz function tools. Donus: custom arac adlari kumesi."""
if isinstance(req.get("input"), list) and any(
isinstance(item, dict) and item.get("type") == "agent_message" for item in req["input"]):
import gecis_context
req["input"] = gecis_context.portable_reasoning(req)["input"]
custom = set() custom = set()
tools = [] tools = []
def add_tool(t): tool_names = tool_names if tool_names is not None else {}
identities = {}
wire_names = {}
def add_tool(t, namespace=None):
ty = t.get("type") ty = t.get("type")
if ty in ("function", "custom"):
namespace = t.get("namespace") or namespace
name = t["name"]
if namespace:
import hashlib
identity = (namespace, name)
# A hash avoids collisions between underscore-containing namespace/name pairs.
alias = "ns_" + hashlib.sha256(json.dumps(identity).encode()).hexdigest()[:24] + "_" + re.sub(r"[^a-zA-Z0-9_-]", "_", name)[:32]
if alias in tool_names and tool_names[alias] != identity:
raise ValueError("Tool namespace alias collision")
tool_names[alias] = identity
identities[identity] = alias
t = dict(t, name=alias, description=f"Tool {namespace}.{name}. " + t.get("description", ""))
wire = t["name"]
identity = (namespace, name)
if wire in wire_names and wire_names[wire] != identity:
raise ValueError("Tool namespace alias collision")
wire_names[wire] = identity
if ty == "namespace": if ty == "namespace":
for s in t.get("tools", []): for s in t.get("tools", []):
add_tool(s) add_tool(s, ".".join(filter(None, (namespace, t.get("name")))))
elif ty == "function": elif ty == "function":
tools.append({"type": "function", "name": t["name"], "description": t.get("description", ""), tools.append({"type": "function", "name": t["name"], "description": t.get("description", ""),
"parameters": t.get("parameters") or {"type": "object", "properties": {}}, "strict": False}) "parameters": t.get("parameters") or {"type": "object", "properties": {}}, "strict": False})
@@ -387,13 +412,34 @@ def flatten_request(req):
tools.append(t) tools.append(t)
for t in req.get("tools", []) or []: for t in req.get("tools", []) or []:
add_tool(t) add_tool(t)
for it in req.get("input", []) or []:
if it.get("type") == "additional_tools":
for t in it.get("tools", []):
add_tool(t)
plain_names = {t.get("name") for t in tools if t.get("name") not in tool_names}
def flatten_name(item):
namespace, name = item.get("namespace"), item.get("name")
alias = identities.get((namespace, name))
if not namespace and name not in plain_names:
matches = [a for a, identity in tool_names.items() if identity[1] == name]
if len(matches) == 1:
alias = matches[0] # Legacy history recorded before namespaces were preserved.
if alias:
item = dict(item, name=alias)
item.pop("namespace", None)
return item
choice = req.get("tool_choice")
if isinstance(choice, dict):
req["tool_choice"] = flatten_name(choice)
if isinstance(choice.get("tools"), list):
req["tool_choice"] = dict(req["tool_choice"], tools=[flatten_name(t) for t in choice["tools"]])
new_input = [] new_input = []
for it in req.get("input", []) or []: for it in req.get("input", []) or []:
ty = it.get("type") ty = it.get("type")
if ty == "additional_tools": if ty == "additional_tools":
for t in it.get("tools", []):
add_tool(t)
continue continue
if ty in ("function_call", "custom_tool_call"):
it = flatten_name(it)
if ty in ("function_call_output", "custom_tool_call_output") and not it.get("call_id"): if ty in ("function_call_output", "custom_tool_call_output") and not it.get("call_id"):
# Desktop thread handoffs have no matching tool call. Preserve their # Desktop thread handoffs have no matching tool call. Preserve their
# content as imported context, not an invalid tool-role message. # content as imported context, not an invalid tool-role message.
@@ -527,9 +573,10 @@ class SseRewriter:
Ayrica dis modelin asistan mesajlarina phase damgasi koyar: mesajin "bitti" olayi, arkasindan ne Ayrica dis modelin asistan mesajlarina phase damgasi koyar: mesajin "bitti" olayi, arkasindan ne
geldigi belli olana kadar bekletilir (arac cagrisi ya da baska oge -> commentary; yanit bitti -> geldigi belli olana kadar bekletilir (arac cagrisi ya da baska oge -> commentary; yanit bitti ->
final_answer). Metin parcalari (delta) bekletilmez, canli akar.""" final_answer). Metin parcalari (delta) bekletilmez, canli akar."""
def __init__(self, custom, hedef=None, tasma=None, aracsiz=None, dolu=False): def __init__(self, custom, hedef=None, tasma=None, aracsiz=None, dolu=False, tool_names=None):
self.dolu = dolu # istek hedef pencerenin buyuk kismini dolduruyor self.dolu = dolu # istek hedef pencerenin buyuk kismini dolduruyor
self.custom = custom self.custom = custom
self.tool_names = tool_names or {}
self.buf = b"" self.buf = b""
self.custom_ids = set() self.custom_ids = set()
self.bekleyen = None # phase karari bekleyen "output_item.done" olayi self.bekleyen = None # phase karari bekleyen "output_item.done" olayi
@@ -634,6 +681,12 @@ class SseRewriter:
self.bekleyen = d self.bekleyen = d
return once return once
return once + [d] return once + [d]
def _restore_tool_name(self, item):
if isinstance(item, dict) and item.get("type") in ("function_call", "custom_tool_call"):
identity = self.tool_names.get(item.get("name"))
if identity:
item["namespace"], item["name"] = identity
return item
def _fix(self, d): def _fix(self, d):
t = d.get("type", "") t = d.get("type", "")
item = d.get("item") item = d.get("item")
@@ -655,6 +708,7 @@ class SseRewriter:
# Normalize before Codex persists the response. Request-only repair # Normalize before Codex persists the response. Request-only repair
# cannot help when the user later disables the bridge. # cannot help when the user later disables the bridge.
fix_tool_ids({"input": [d["item"]]}) fix_tool_ids({"input": [d["item"]]})
self._restore_tool_name(d["item"])
return d return d
if t.startswith("response.function_call_arguments.") and d.get("item_id") in self.custom_ids: if t.startswith("response.function_call_arguments.") and d.get("item_id") in self.custom_ids:
return None # JSON parcalari custom input'a cevrilemez; done olayinda tam metin gidiyor return None # JSON parcalari custom input'a cevrilemez; done olayinda tam metin gidiyor
@@ -667,9 +721,10 @@ class SseRewriter:
item = {"type": "custom_tool_call", "id": item.get("id"), "call_id": item.get("call_id"), item = {"type": "custom_tool_call", "id": item.get("id"), "call_id": item.get("call_id"),
"name": item.get("name"), "input": inp, "status": "completed"} "name": item.get("name"), "input": inp, "status": "completed"}
fix_tool_ids({"input": [item]}) fix_tool_ids({"input": [item]})
out.append(item) out.append(self._restore_tool_name(item))
d["response"]["output"] = out d["response"]["output"] = out
fix_tool_ids({"input": out}) fix_tool_ids({"input": out})
self._restore_tool_name(d.get("item"))
return d return d
def feed(self, chunk): def feed(self, chunk):
self.buf += chunk self.buf += chunk
@@ -980,11 +1035,11 @@ class H(http.server.BaseHTTPRequestHandler):
if jev_denetim.muaf_mi(hedef): if jev_denetim.muaf_mi(hedef):
return self._stream(resp, resp.status, tag, rewriter, izle, basliksiz=True) return self._stream(resp, resp.status, tag, rewriter, izle, basliksiz=True)
if gecmedi: if gecmedi:
# Son katman ne dondurduyse o gider, hicbir sey eklenmez; dondurmediyse hicbir sey gitmez. # Headers already declare SSE. A raw upstream JSON/HTML body
if son_govde: # here leaves Codex waiting for a terminal event and retrying.
w(son_govde) event = {"type": "response.failed", "response": {
else: "id": "resp_astrabridge_%d" % time.time_ns(),
event = {"type": "response.failed", "response": {"status": "failed", "error": { "object": "response", "status": "failed", "output": [], "error": {
"code": "jev_no_fallback", "message": "Jev denetiminden gecen cevap alinamadi; gecis kurallarini kontrol edin."}}} "code": "jev_no_fallback", "message": "Jev denetiminden gecen cevap alinamadi; gecis kurallarini kontrol edin."}}}
w(b"event: response.failed\ndata: " + json.dumps(event).encode() + b"\n\n") w(b"event: response.failed\ndata: " + json.dumps(event).encode() + b"\n\n")
else: else:
@@ -1232,7 +1287,7 @@ class H(http.server.BaseHTTPRequestHandler):
for model in json.loads(cached["body"]).get("models", []): for model in json.loads(cached["body"]).get("models", []):
if model.get("slug") == slug and model.get("context_window"): if model.get("slug") == slug and model.get("context_window"):
return int(model["context_window"]) return int(model["context_window"])
return 32768 raise gecis_context.TransferError("Abonelik modelinin bağlam kapasitesi katalogda bulunamadı; geçmiş korundu.")
def _prepare_transfer(self, payload): def _prepare_transfer(self, payload):
@@ -1623,11 +1678,12 @@ class H(http.server.BaseHTTPRequestHandler):
log(f"{tag} {cikan} resim not ile degistirildi ({kazanc // 1024} KB)") log(f"{tag} {cikan} resim not ile degistirildi ({kazanc // 1024} KB)")
rewriter = None rewriter = None
if flat if flat is not None else route.get("flatten_tools"): if flat if flat is not None else route.get("flatten_tools"):
custom = flatten_request(req) tool_names = {}
custom = flatten_request(req, tool_names)
duzelen = arac_semalarini_duzelt(req) duzelen = arac_semalarini_duzelt(req)
if duzelen: if duzelen:
log(f"{tag} {duzelen} aracin semasi duzeltildi (ust duzey oneOf/anyOf/allOf)") log(f"{tag} {duzelen} aracin semasi duzeltildi (ust duzey oneOf/anyOf/allOf)")
rewriter = SseRewriter(custom, hedef=target, tasma=self._tasma_ogren, aracsiz=self._aracsiz_ogren) rewriter = SseRewriter(custom, hedef=target, tasma=self._tasma_ogren, aracsiz=self._aracsiz_ogren, tool_names=tool_names)
fix_tool_ids(req) fix_tool_ids(req)
if route_name == catalog.get('route', '9router') and target.startswith('ag/gemini-'): if route_name == catalog.get('route', '9router') and target.startswith('ag/gemini-'):
merged = coalesce_context(req) merged = coalesce_context(req)
@@ -1695,7 +1751,7 @@ class H(http.server.BaseHTTPRequestHandler):
yanit = urllib.request.urlopen(urllib.request.Request( yanit = urllib.request.urlopen(urllib.request.Request(
jev_rota["url"], data=json.dumps(katman_req, ensure_ascii=False).encode("utf-8"), headers=jev_hdrs, method="POST"), jev_rota["url"], data=json.dumps(katman_req, ensure_ascii=False).encode("utf-8"), headers=jev_hdrs, method="POST"),
timeout=UPSTREAM_IDLE_TIMEOUT) timeout=UPSTREAM_IDLE_TIMEOUT)
return (yanit, SseRewriter(custom, hedef=ad, tasma=self._tasma_ogren, aracsiz=self._aracsiz_ogren), return (yanit, SseRewriter(custom, hedef=ad, tasma=self._tasma_ogren, aracsiz=self._aracsiz_ogren, tool_names=tool_names),
f"[{n} {slug}=>{ad}]") f"[{n} {slug}=>{ad}]")
# A 9Router combo (Fusion-*, no provider prefix) runs a whole panel upstream. Once it may # A 9Router combo (Fusion-*, no provider prefix) runs a whole panel upstream. Once it may
# have started (timeout, 504, cut stream) resending it runs and bills it twice (28 Sep 2026); # have started (timeout, 504, cut stream) resending it runs and bills it twice (28 Sep 2026);
+8 -1
View File
@@ -221,7 +221,7 @@ def _yapraklar(dugum, derinlik=0):
return cikti return cikti
def coz(kimlik, tur, yol, kayit=None, nano=None, ogr=None, aracsiz=()): def coz(kimlik, tur, yol, kayit=None, nano=None, ogr=None, aracsiz=(), ilan=None):
"""Bir katalog girdisinin ilan edilecek penceresi ve yetenekleri. """Bir katalog girdisinin ilan edilecek penceresi ve yetenekleri.
kimlik: gecit kimligi; tur: "combo" | "model"; yol: gecidin aciklamasindaki "saglayici/model" yolu; kimlik: gecit kimligi; tur: "combo" | "model"; yol: gecidin aciklamasindaki "saglayici/model" yolu;
kayit: /router/catalog girdisi (combo topolojisi icin). Donus: {"pencere","gorsel","arac","kaynak"}.""" kayit: /router/catalog girdisi (combo topolojisi icin). Donus: {"pencere","gorsel","arac","kaynak"}."""
@@ -246,6 +246,13 @@ def coz(kimlik, tur, yol, kayit=None, nano=None, ogr=None, aracsiz=()):
m = re.match(r"no-think-(?:9router-)?([a-z0-9]+)-", kimlik or "") m = re.match(r"no-think-(?:9router-)?([a-z0-9]+)-", kimlik or "")
yol = (m.group(1) + "/" + yol[len("no-think/"):]) if m else yol yol = (m.group(1) + "/" + yol[len("no-think/"):]) if m else yol
sonuc = yol_bilgisi(yol, nano) or dict(bilinmeyen) sonuc = yol_bilgisi(yol, nano) or dict(bilinmeyen)
# Exact /models metadata is stronger evidence than name-based local
# fallbacks. Do not infer a combo's capacity from its display name.
ilan = ilan if isinstance(ilan, dict) else {}
canli_sinirlar = [ilan[k] for k in ("context_length", "max_input_tokens")
if type(ilan.get(k)) is int and TABAN <= ilan[k] <= 10_000_000]
if canli_sinirlar:
sonuc = {**sonuc, "pencere": min(canli_sinirlar), "kaynak": "saglayici-katalogu"}
if kimlik in aracsiz: if kimlik in aracsiz:
sonuc = {**sonuc, "arac": False} sonuc = {**sonuc, "arac": False}
sinir = (ogr or {}).get(kimlik) sinir = (ogr or {}).get(kimlik)
@@ -0,0 +1,119 @@
"""Transport-only handling of a worker's explicit terminal failure.
Never selects a model, evaluates content, or changes worker/Jev decisions.
If HTTP headers have already gone out, remember the failure for exactly one
identical retry. Codex then receives an HTTP error instead of re-running the
whole exhausted provider chain. Ordinary network interruptions remain retries.
"""
import hashlib
import json
import threading
import time
from collections import OrderedDict
def request_key(request, headers):
principal = "\0".join(headers.get(name, "") for name in
("Authorization", "ChatGPT-Account-Id", "session_id"))
body = json.dumps(request, sort_keys=True, ensure_ascii=False, separators=(",", ":"))
return hashlib.sha256((principal + "\0" + body).encode()).digest()
class FailureReplays:
def __init__(self, ttl=120, capacity=128, clock=time.monotonic):
self.ttl, self.capacity, self.clock = ttl, capacity, clock
self.items = OrderedDict()
self.lock = threading.Lock()
def remember(self, key, error):
with self.lock:
self.items[key] = (self.clock() + self.ttl, error)
self.items.move_to_end(key)
while len(self.items) > self.capacity:
self.items.popitem(last=False)
def take(self, key):
with self.lock:
entry = self.items.pop(key, None)
return entry[1] if entry and entry[0] > self.clock() else None
def error_body(error):
# Preserve the actual provider failure; don't relabel it invalid_prompt,
# fabricate response.completed, or expose a rejected model response.
return {"error": {"type": "upstream_terminal_error", "code": error["code"],
"message": "Model yanıtı hata ile sonlandı. " + error["message"]}}
class Observer:
"""Bounded SSE observer. Forwarded stream bytes are never rewritten."""
LIMIT = 65536
def __init__(self):
self.tail = b""
self.data = []
self.size = 0
self.discard = False
self.discard_line = False
self.comment_seen = False
self.payload_seen = False
self.completed = False
self.error = None
def feed(self, chunk):
# read1() supplies small chunks, but also bound arbitrary caller input.
for offset in range(0, len(chunk), 4096):
self.tail += chunk[offset:offset + 4096]
while b"\n" in self.tail:
line, self.tail = self.tail.split(b"\n", 1)
if self.discard_line:
self.discard_line = False
continue
self._line(line.rstrip(b"\r"))
if len(self.tail) > self.LIMIT:
self.tail = b""
self.discard = self.discard_line = True
def _line(self, line):
if line.startswith(b":"):
self.comment_seen = True
elif line.startswith(b"data:"):
self.size += len(line)
if self.size > self.LIMIT:
self.discard = True
self.data.clear()
elif not self.discard:
self.data.append(line[5:].lstrip())
elif not line:
if self.data and not self.discard:
self._event(b"\n".join(self.data))
elif self.discard:
self.payload_seen = True
self.data, self.size, self.discard = [], 0, False
def _event(self, data):
try:
event = json.loads(data)
except (ValueError, UnicodeError):
self.payload_seen = True
return
if not isinstance(event, dict):
self.payload_seen = True
return
kind = event.get("type")
response = event.get("response")
error = event.get("error") if kind == "error" else response.get("error") if isinstance(response, dict) else None
# Known worker terminal errors only. Preserve context/rate-limit and
# other native error classifications rather than guessing semantics.
is_failure = (kind == "error" and isinstance(error, dict)
and error.get("type") == "upstream_error")
is_failure |= (kind == "response.failed" and isinstance(error, dict)
and error.get("code") == "jev_no_fallback")
if is_failure:
self.error = {"code": str(error.get("code") or "upstream_error")[:100],
"message": str(error.get("message") or "Sağlayıcı yanıtı tamamlanamadı.")[:2000]}
else:
self.payload_seen = True
if kind == "response.completed":
self.completed = True
self.error = None
@@ -0,0 +1,52 @@
"""Regression fixture matching actual Codex child delivery, with no user data."""
import copy
import unittest
import gecis_context
import kopru
class AgentPayloadTests(unittest.TestCase):
def request(self, text):
return {"model": "ide/test", "input": [{
"type": "agent_message", "author": "/root", "recipient": "/root/test",
"content": [
{"type": "input_text", "text": "Message Type: NEW_TASK\nPayload:\n"},
{"type": "encrypted_content", "encrypted_content": text},
],
}]}
def test_actual_peer_wrapper_survives_external_text_extraction(self):
task = "Run exec_command: Write-Output (157 * 29); report the exit code."
request = self.request(task)
original = copy.deepcopy(request)
kopru.flatten_request(request)
# An OpenAI-compatible translator consumes input_text, not Codex's
# encrypted_content part. Checking the JSON string alone misses loss.
text = "\n".join(part["text"] for item in request["input"]
for part in item["content"] if part["type"] == "input_text")
self.assertIn(task, text)
self.assertIn('/root/test', text)
self.assertEqual(request["input"][0]["role"], "user")
self.assertEqual(original["input"][0]["content"][1]["type"], "encrypted_content")
def test_peer_result_and_plain_parts_keep_order_without_source_mutation(self):
request = self.request("Result: 4553; exit code: 0")
original = copy.deepcopy(request)
result = gecis_context.portable_reasoning(request)
self.assertEqual(result["input"][0]["content"][-1]["text"], "Result: 4553; exit code: 0")
self.assertEqual(request, original)
def test_opaque_peer_payload_fails_instead_of_sending_header_only(self):
for opaque in ("gAAAA_native_ciphertext", "A" * 200, None):
with self.subTest(opaque=opaque):
with self.assertRaises(gecis_context.TransferError):
gecis_context.portable_reasoning(self.request(opaque))
def test_reasoning_ciphertext_is_still_not_interpreted_as_a_peer_task(self):
request = {"input": [{"type": "reasoning", "encrypted_content": "Run a command now"}]}
self.assertEqual(gecis_context.portable_reasoning(request)["input"], [])
if __name__ == "__main__":
unittest.main()
@@ -22,6 +22,26 @@ from test_model_switch import env
class History(unittest.TestCase): class History(unittest.TestCase):
def test_agent_task_survives_external_projection_and_recent_history(self):
task = {"type": "agent_message", "id": "amsg_task", "author": "/root",
"recipient": "/root/child", "content": [
{"type": "input_text", "text": "Message Type: NEW_TASK\nRun CHILD_TOOL_410962"}]}
req = {"input": [{"role": "user", "content": "Old setup"}, task]}
before = copy.deepcopy(req)
self.assertEqual(gc.normalize(req), before)
projected = gc.portable_reasoning(req)
message = projected["input"][-1]
self.assertEqual(message["role"], "user")
self.assertEqual(message["content"][1:], task["content"])
self.assertIn('/root/child', message["content"][0]["text"])
self.assertNotIn("id", message)
self.assertEqual(gc.split_history(projected["input"], keep_users=1)[2], [message])
self.assertEqual(req, before)
self.assertEqual(gc.portable_reasoning(projected), projected)
direct = copy.deepcopy(req)
env["flatten_request"](direct)
self.assertEqual(direct["input"][-1], message)
def test_native_function_results_are_not_foreign_or_renamed(self): def test_native_function_results_are_not_foreign_or_renamed(self):
req = {"input": [{"type": "function_call_output", "id": "fco_native", req = {"input": [{"type": "function_call_output", "id": "fco_native",
"call_id": "call_native", "output": "kept"}]} "call_id": "call_native", "output": "kept"}]}
@@ -335,7 +335,7 @@ class Akis(unittest.TestCase):
self.assertNotIn(b"yapamam", self.h.wfile.getvalue()) self.assertNotIn(b"yapamam", self.h.wfile.getvalue())
self.assertIn(b'response.failed', self.sent()) self.assertIn(b'response.failed', self.sent())
def test_the_last_layers_own_error_body_is_delivered_untouched(self): def test_last_layer_http_error_terminates_sse_without_leaking_raw_body(self):
class Hata(Exception): class Hata(Exception):
def read(self): def read(self):
return b'{"error": {"message": "katman 4 hatasi"}}' return b'{"error": {"message": "katman 4 hatasi"}}'
@@ -344,7 +344,14 @@ class Akis(unittest.TestCase):
def ac(ad): def ac(ad):
raise Hata("HTTP 400") raise Hata("HTTP 400")
self.h._denetimli_akis(Yanit(sse("Bunu yapamam.")), "[1]", None, None, "yap", ["Katman-4"], ac) self.h._denetimli_akis(Yanit(sse("Bunu yapamam.")), "[1]", None, None, "yap", ["Katman-4"], ac)
self.assertEqual(self.sent(), b'{"error": {"message": "katman 4 hatasi"}}') raw = self.sent()
self.assertTrue(raw.startswith(b"event: response.failed\ndata: "))
event = json.loads(raw.split(b"data: ", 1)[1])
self.assertEqual(event["response"]["status"], "failed")
self.assertEqual(event["response"]["error"]["code"], "jev_no_fallback")
self.assertEqual(event["response"]["output"], [])
self.assertNotIn(b"katman 4 hatasi", raw)
self.assertNotIn(b"Bunu yapamam", raw)
def test_unreachable_jev_keeps_the_answer(self): def test_unreachable_jev_keeps_the_answer(self):
self.puanlar = [None] self.puanlar = [None]
@@ -0,0 +1,18 @@
import pencere
def test_exact_combo_limits_override_unknown_and_local_defaults():
row = {"context_length": 872000, "max_input_tokens": 872000, "max_output_tokens": 128000}
result = pencere.coz("GPT-6-Astra", "combo", None, ilan=row)
assert result["pencere"] == 872000
assert result["kaynak"] == "saglayici-katalogu"
result = pencere.coz("codex-gpt-6-astra", "model", "codex/gpt-6-astra", ilan=row)
assert result["pencere"] == 872000
def test_learned_actual_limit_still_wins_and_invalid_metadata_is_ignored():
result = pencere.coz("combo", "combo", None, ilan={"context_length": 872000}, ogr={"combo": 500000})
assert result["pencere"] == 500000 and result["kaynak"] == "ogrenilen"
for value in (True, -1, "872000", 999999999):
result = pencere.coz("combo", "combo", None, ilan={"context_length": value})
assert result["pencere"] == pencere.VARSAYILAN
@@ -43,7 +43,12 @@ class ModelSwitch(unittest.TestCase):
{"type": "custom_tool_call_output", "id": "ctco_4827", "call_id": "call_4827", "output": "result-4827"}, {"type": "custom_tool_call_output", "id": "ctco_4827", "call_id": "call_4827", "output": "result-4827"},
], "tools": [{"type": "namespace", "name": "functions", "tools": [{"type": "custom", "name": "probe"}]}]} ], "tools": [{"type": "namespace", "name": "functions", "tools": [{"type": "custom", "name": "probe"}]}]}
req = copy.deepcopy(original) req = copy.deepcopy(original)
self.assertEqual(env["flatten_request"](req), {"probe"}) names = {}
custom = env["flatten_request"](req, names)
alias = req["tools"][0]["name"]
self.assertEqual(custom, {alias})
self.assertEqual(names[alias], ("functions", "probe"))
self.assertEqual(req["input"][0]["name"], alias)
env["fix_tool_ids"](req) env["fix_tool_ids"](req)
self.assertEqual(json.loads(req["input"][0]["arguments"]), {"input": "remember-4827"}) self.assertEqual(json.loads(req["input"][0]["arguments"]), {"input": "remember-4827"})
self.assertEqual(req["input"][1]["output"], "result-4827") self.assertEqual(req["input"][1]["output"], "result-4827")
@@ -0,0 +1,83 @@
"""Native switch regressions using the real HTTP splitter and offline peers."""
import copy
import gzip
import json
import unittest
import ast
from pathlib import Path
import bridge
import gecis_context as gc
import ozet_kopru
import test_ayirici as fixtures
class NativeSwitch(unittest.TestCase):
setUp = fixtures.Ayirici.setUp
tearDown = fixtures.Ayirici.tearDown
iste = fixtures.Ayirici.iste
def test_unknown_native_capacity_is_not_invented(self):
tree = ast.parse(Path(__file__).with_name('kopru.py').read_text(encoding='utf-8'))
method = next(node for node in ast.walk(tree)
if isinstance(node, ast.FunctionDef) and node.name == '_transfer_window')
ns = {'ek_model_mu': lambda _: False, 'LAST_MODELS': {}, 'gecis_context': gc, 'json': json}
exec(compile(ast.Module(body=[method], type_ignores=[]), 'window', 'exec'), ns)
from types import SimpleNamespace
with self.assertRaises(gc.TransferError):
ns['_transfer_window'](SimpleNamespace(headers={}), 'gpt-6-astra')
def test_opaque_native_switch_with_persisted_foreign_residue(self):
self.ozgun.cevap = fixtures.duz(200, b'data: {"type":"response.completed"}\n\n', 'text/event-stream')
compact = {'type': 'compaction', 'id': 'cmp_native',
'encrypted_content': 'gAAAAABp' + 'AqZ09_-jX' * 10000}
req = {'model': 'gpt-6.1-sol', 'prompt_cache_key': 'restart', 'input': [
ozet_kopru.ozet_ogesi('DECISION-4827 must survive'), compact,
{'type': 'custom_tool_call', 'id': 'fc_123', 'call_id': 'call_123',
'name': 'apply_patch', 'input': 'patch text'},
{'type': 'custom_tool_call_output', 'id': 'fco_123',
'call_id': 'call_123', 'output': 'complete'},
{'role': 'user', 'content': 'continue'}]}
before = copy.deepcopy(req)
for cold, model in ((True, 'gpt-6.1-sol'), (False, 'gpt-6-astra'), (True, 'gpt-6-astra')):
if cold:
bridge.TRANSFERS.clear()
req['model'] = model
body = gzip.compress(json.dumps(req).encode())
self.assertEqual(self.iste('POST', '/backend-api/codex/responses', body,
{'Content-Encoding': 'gzip'})[0], 200)
sent = json.loads(self.ozgun.gelen[-1]['govde'])
self.assertEqual(sent['input'][1], compact)
self.assertEqual(sent['input'][2]['id'], 'ctc_123')
self.assertEqual(sent['input'][3]['id'], 'ctco_123')
self.assertEqual(sent['input'][3]['call_id'], 'call_123')
self.assertIn('DECISION-4827', json.dumps(sent))
self.assertEqual(self.kopru.gelen, [])
self.assertEqual(req['input'], before['input'])
def test_clean_native_body_is_byte_identical_even_after_external_source(self):
req = {'model': 'ide/test', 'prompt_cache_key': 'return', 'input': []}
self.iste('POST', '/backend-api/codex/responses', json.dumps(req).encode())
self.kopru.gelen.clear()
req.update(model='gpt-6-astra', input=[{'type': 'compaction',
'encrypted_content': 'gAAAAABp' + 'X' * 100000}])
raw = gzip.compress(json.dumps(req, indent=3).encode())
self.iste('POST', '/backend-api/codex/responses', raw, {'Content-Encoding': 'gzip'})
self.assertEqual(self.ozgun.gelen[-1]['govde'], raw)
self.assertEqual(self.kopru.gelen, [])
def test_cold_native_tool_id_repair_does_not_require_semantic_budget(self):
req = {'model': 'gpt-6-astra', 'input': [
{'type': 'custom_tool_call', 'id': 'fc_old', 'call_id': 'call_old',
'name': 'apply_patch', 'input': 'x' * 100000},
{'type': 'custom_tool_call_output', 'id': 'fco_old',
'call_id': 'call_old', 'output': 'complete'}]}
self.iste('POST', '/backend-api/codex/responses', json.dumps(req).encode())
self.assertEqual(len(self.kopru.gelen), 0)
sent = json.loads(self.ozgun.gelen[-1]['govde'])
self.assertEqual(sent, gc.normalize(req))
self.assertEqual(req['input'][0]['id'], 'fc_old')
if __name__ == '__main__':
unittest.main()
@@ -0,0 +1,143 @@
"""Real-socket regressions for terminal worker errors (no provider calls)."""
import json
import unittest
import test_ayirici as fixtures
import terminal_stream
ERROR = b'event: error\ndata: {"type":"error","error":{"type":"upstream_error","message":"upstream 503"}}\n\n'
COMPLETE = b'event: response.completed\ndata: {"type":"response.completed","response":{"id":"resp_ok","status":"completed","output":[]}}\n\n'
class TerminalStream(unittest.TestCase):
setUp = fixtures.Ayirici.setUp
tearDown = fixtures.Ayirici.tearDown
iste = fixtures.Ayirici.iste
def request(self, token="a", text="check"):
body = json.dumps({"model": "ide/test", "input": [{"role": "user", "content": text}]})
return self.iste("POST", "/backend-api/codex/responses", body,
{"Authorization": "Bearer " + token})
def worker(self, payload):
def respond(h, body):
if h.path.endswith("/prepare-transfer"):
return fixtures.duz(200, json.dumps({"request": json.loads(body)["request"]}).encode())(h, body)
return fixtures.duz(200, payload, "text/event-stream")(h, body)
self.kopru.cevap = respond
def inference_count(self):
return sum(x["yol"].endswith("/responses") for x in self.kopru.gelen)
def test_error_only_stream_is_an_honest_http_failure(self):
self.worker(ERROR)
status, body, _ = self.request()
self.assertEqual(status, 400)
self.assertIn("upstream 503", json.loads(body)["error"]["message"])
self.assertNotIn(b"response.completed", body)
self.assertEqual(self.inference_count(), 1)
def test_error_after_heartbeat_stops_automatic_replay_once(self):
def respond(h, body):
if h.path.endswith("/prepare-transfer"):
return fixtures.duz(200, json.dumps({"request": json.loads(body)["request"]}).encode())(h, body)
h.send_response(200)
h.send_header("Content-Type", "text/event-stream")
h.send_header("Transfer-Encoding", "chunked")
h.end_headers()
for chunk in (b": keepalive\n\n", ERROR):
h.wfile.write(b"%x\r\n" % len(chunk) + chunk + b"\r\n")
h.wfile.flush()
fixtures.time.sleep(.08)
h.wfile.write(b"0\r\n\r\n")
self.kopru.cevap = respond
status, body, _ = self.request()
self.assertEqual((status, body), (200, b": keepalive\n\n" + ERROR))
status, body, _ = self.request()
self.assertEqual(status, 400)
self.assertIn(b"upstream 503", body)
self.assertEqual(self.inference_count(), 1)
# A later explicit retry is allowed; the one-shot guard is consumed.
self.worker(COMPLETE)
self.assertEqual(self.request()[:2], (200, COMPLETE))
self.assertEqual(self.inference_count(), 2)
def test_native_error_stream_is_not_intercepted(self):
self.ozgun.cevap = fixtures.duz(200, ERROR, "text/event-stream")
body = json.dumps({"model": "gpt-6-astra", "input": []})
self.assertEqual(self.iste("POST", "/backend-api/codex/responses", body)[:2], (200, ERROR))
def test_success_stream_is_byte_identical(self):
self.worker(COMPLETE)
self.assertEqual(self.request()[:2], (200, COMPLETE))
self.assertEqual(self.request()[:2], (200, COMPLETE))
self.assertEqual(self.inference_count(), 2)
def test_eof_without_upstream_error_keeps_network_retry(self):
self.worker(b": keepalive\n\n")
self.assertEqual(self.request()[0], 200)
self.assertEqual(self.request()[0], 200)
self.assertEqual(self.inference_count(), 2)
def test_worker_exhausted_failure_is_terminal_without_changing_its_reason(self):
self.worker(b'event: response.failed\ndata: {"type":"response.failed","response":{"status":"failed","error":{"code":"jev_no_fallback","message":"No accepted response"}}}\n\n')
status, body, _ = self.request()
self.assertEqual(status, 400)
self.assertEqual(json.loads(body)["error"]["code"], "jev_no_fallback")
def test_existing_context_error_classification_is_preserved(self):
data = b'event: response.failed\ndata: {"type":"response.failed","response":{"error":{"code":"context_length_exceeded","message":"Context full"}}}\n\n'
self.worker(data)
self.assertEqual(self.request()[:2], (200, data))
class ObserverTests(unittest.TestCase):
def test_fragmented_crlf_and_multiline_json(self):
data = b'event: error\r\ndata: {"type":"error",\r\ndata: "error":{"type":"upstream_error","message":"503"}}\r\n\r\n'
observer = terminal_stream.Observer()
for byte in data:
observer.feed(bytes([byte]))
self.assertEqual(observer.error['message'], '503')
def test_large_and_malformed_frames_are_bounded_and_do_not_crash(self):
observer = terminal_stream.Observer()
observer.feed(b'data: ' + b'x'*200000)
self.assertLessEqual(len(observer.tail), observer.LIMIT)
observer.feed(b'\n\ndata: {"response":[]}\n\n' + ERROR)
self.assertEqual(observer.error['message'], 'upstream 503')
def test_completed_stream_does_not_poison_next_request(self):
observer = terminal_stream.Observer()
observer.feed(ERROR + COMPLETE)
self.assertIsNone(observer.error)
self.assertTrue(observer.completed)
def test_one_shot_expiry_and_capacity(self):
now = [10]
cache = terminal_stream.FailureReplays(ttl=5, capacity=2, clock=lambda: now[0])
cache.remember(b'a', {'code': 'a'})
self.assertEqual(cache.take(b'a'), {'code': 'a'})
self.assertIsNone(cache.take(b'a'))
cache.remember(b'a', {})
now[0] = 16
self.assertIsNone(cache.take(b'a'))
for key in (b'a', b'b', b'c'):
cache.remember(key, {'code': 'error'})
self.assertIsNone(cache.take(b'a'))
self.assertEqual(len(cache.items), 2)
def test_request_identity_separates_accounts_sessions_models_and_inputs(self):
base = {'model': 'ide/a', 'input': []}
headers = {'Authorization': 'Bearer a', 'session_id': 'one'}
key = terminal_stream.request_key(base, headers)
self.assertEqual(key, terminal_stream.request_key(dict(reversed(list(base.items()))), headers))
for request, other in [({'model': 'ide/b', 'input': []}, headers),
({'model': 'ide/a', 'input': ['next']}, headers),
(base, dict(headers, Authorization='Bearer b')),
(base, dict(headers, session_id='two'))]:
self.assertNotEqual(key, terminal_stream.request_key(request, other))
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,66 @@
"""Namespace routing regressions: leaf-only calls cannot dispatch collaboration tools."""
import copy
import json
import unittest
from test_model_switch import env
class ToolNamespaces(unittest.TestCase):
def request(self):
return {"tools": [
{"type": "namespace", "name": "collaboration", "tools": [{"type": "function", "name": "spawn_agent", "parameters": {"type": "object", "properties": {}}}]},
{"type": "namespace", "name": "other", "tools": [{"type": "function", "name": "spawn_agent"}]},
{"type": "namespace", "name": "functions", "tools": [{"type": "custom", "name": "apply_patch"}]},
{"type": "function", "name": "plain"}],
"input": [{"type": "function_call", "namespace": "collaboration", "name": "spawn_agent", "id": "fc_a", "call_id": "call_a", "arguments": "{}"},
{"type": "function_call_output", "call_id": "call_a", "output": "child_123"}]}
def test_namespace_calls_round_trip_at_all_stream_stages(self):
req = self.request(); original = copy.deepcopy(req); names = {}
custom = env["flatten_request"](req, names)
alias = req["tools"][0]["name"]
self.assertNotEqual(alias, req["tools"][1]["name"])
self.assertLessEqual(len(alias), 64)
self.assertEqual(req["input"][0]["name"], alias)
self.assertNotIn("namespace", req["input"][0])
self.assertEqual(req["input"][1], original["input"][1])
adapter = env["SseRewriter"](custom, tool_names=names)
item = {"type": "function_call", "name": alias, "id": "fc_test", "call_id": "call_test", "arguments": "{}"}
for stage in ["response.output_item.added", "response.output_item.done"]:
restored = adapter._fix({"type": stage, "item": dict(item)})["item"]
self.assertEqual((restored["namespace"], restored["name"]), ("collaboration", "spawn_agent"))
self.assertEqual(restored["call_id"], "call_test")
restored = adapter._fix({"type": "response.completed", "response": {"output": [dict(item)]}})["response"]["output"][0]
self.assertEqual((restored["namespace"], restored["name"]), ("collaboration", "spawn_agent"))
def test_custom_tool_namespace_and_ids_survive_chunked_sse(self):
req = self.request(); names = {}; custom = env["flatten_request"](req, names)
alias = req["tools"][2]["name"]
item = {"type": "function_call", "name": alias, "id": "fc_patch", "call_id": "call_patch", "arguments": json.dumps({"input": "patch text"})}
raw = ("data: " + json.dumps({"type": "response.output_item.done", "item": item}) + "\n\n").encode()
a = env["SseRewriter"](custom, tool_names=names)
output = a.feed(raw[:31]) + a.feed(raw[31:]) + a.flush()
restored = json.loads(next(l[6:] for l in output.decode().splitlines() if l.startswith("data: ")))["item"]
self.assertEqual((restored["namespace"], restored["name"], restored["type"]), ("functions", "apply_patch", "custom_tool_call"))
self.assertEqual(restored["id"], "ctc_patch")
self.assertEqual(restored["input"], "patch text")
def test_plain_tools_stay_plain_and_history_not_mutated(self):
req = self.request(); original = copy.deepcopy(req); old_item = req["input"][0]
env["flatten_request"](req, {})
self.assertEqual(req["tools"][-1]["name"], "plain")
self.assertEqual(old_item, original["input"][0])
plain = {"type": "function_call", "name": "plain", "arguments": "{}"}
self.assertEqual(env["SseRewriter"](set())._fix({"item": plain})["item"], plain)
def test_additional_tools_long_names_and_tool_choice(self):
namespace = "provider_" * 20
req = {"tools": [], "input": [{"type": "additional_tools", "tools": [{"type": "namespace", "name": namespace, "tools": [{"type": "function", "name": "read"}]}]}], "tool_choice": {"type": "function", "namespace": namespace, "name": "read"}}
names = {}; env["flatten_request"](req, names)
alias = req["tools"][0]["name"]
self.assertLessEqual(len(alias), 64)
self.assertEqual(req["tool_choice"], {"type": "function", "name": alias})
self.assertEqual(req["input"], [])
self.assertEqual(names[alias], (namespace, "read"))
if __name__ == "__main__": unittest.main()
@@ -225,12 +225,17 @@ describe('preference file', () => {
}) })
it('writes the contract shape and keeps unknown fields', () => { it('writes the contract shape and keeps unknown fields', () => {
expect(JSON.parse(serializeCodexLinkSettings({}, false))).toEqual({ enabled: false, kapsam: 'ide' }) expect(JSON.parse(serializeCodexLinkSettings({}, false))).toEqual({
enabled: false,
mod: 'dogrudan',
kapsam: 'ide'
})
const { stored } = parseCodexLinkSettings('{"enabled": false, "kapsam": "hepsi", "gelecek": {"a": 1}}') const { stored } = parseCodexLinkSettings('{"enabled": false, "kapsam": "hepsi", "gelecek": {"a": 1}}')
expect(JSON.parse(serializeCodexLinkSettings(stored, true))).toEqual({ expect(JSON.parse(serializeCodexLinkSettings(stored, true))).toEqual({
enabled: true, enabled: true,
mod: 'dogrudan',
gelecek: { a: 1 }, gelecek: { a: 1 },
kapsam: 'hepsi' kapsam: 'hepsi'
}) })
@@ -308,3 +313,17 @@ describe('bridge configuration and command', () => {
expect(codexAutostartPlan(`${deep}\\pythonw.exe`, launch, `${deep}\\baslat.pyw`)).toBeNull() expect(codexAutostartPlan(`${deep}\\pythonw.exe`, launch, `${deep}\\baslat.pyw`)).toBeNull()
}) })
}) })
describe('mode preference', () => {
it('uses direct for missing, invalid and malformed modes', () => {
for (const raw of [null, '{', '{}', '{"mod":"bad"}', '{"mod":false}']) {
expect(parseCodexLinkSettings(raw).mod).toBe('dogrudan')
}
expect(parseCodexLinkSettings('{"mod":"aiturk"}').mod).toBe('aiturk')
})
it('preserves other fields when changing mode', () => {
const stored = { enabled: false, mod: 'aiturk', kapsam: 'hepsi', future: { a: 1 } }
expect(JSON.parse(serializeCodexLinkSettings(stored, false, 'dogrudan'))).toEqual({ ...stored, mod: 'dogrudan' })
expect(JSON.parse(serializeCodexLinkSettings(stored, true)).mod).toBe('aiturk')
})
})
+16 -7
View File
@@ -27,7 +27,7 @@ export const CODEX_LINK_REFRESH_URL = `${CODEX_LINK_ORIGIN}/aiturk/codex-baglama
export const CODEX_LINK_MARKER = '# AITURK IDE Codex Baglama' export const CODEX_LINK_MARKER = '# AITURK IDE Codex Baglama'
// Must equal SURUM in assets/codex-bridge/bridge.py; a running bridge that reports // Must equal SURUM in assets/codex-bridge/bridge.py; a running bridge that reports
// another version is restarted so an updated IDE never keeps an old bridge alive. // another version is restarted so an updated IDE never keeps an old bridge alive.
export const CODEX_BRIDGE_VERSION = '13' export const CODEX_BRIDGE_VERSION = '18'
export const CODEX_LINK_LINE = `openai_base_url = "${CODEX_LINK_ORIGIN}/backend-api/codex" ${CODEX_LINK_MARKER}` export const CODEX_LINK_LINE = `openai_base_url = "${CODEX_LINK_ORIGIN}/backend-api/codex" ${CODEX_LINK_MARKER}`
export const CODEX_LINK_RUN_KEY = 'HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run' export const CODEX_LINK_RUN_KEY = 'HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run'
@@ -300,6 +300,7 @@ export function removeCodexLinkLine(source: string): CodexConfigEdit {
export interface CodexLinkSettings { export interface CodexLinkSettings {
enabled: boolean enabled: boolean
mod: 'dogrudan' | 'aiturk'
/** Every field of the stored object, so a rewrite keeps what it did not set. */ /** Every field of the stored object, so a rewrite keeps what it did not set. */
stored: Record<string, unknown> stored: Record<string, unknown>
} }
@@ -307,26 +308,34 @@ export interface CodexLinkSettings {
/** Missing, unreadable or malformed means ON: the feature is on by default. */ /** Missing, unreadable or malformed means ON: the feature is on by default. */
export function parseCodexLinkSettings(raw: null | string | undefined): CodexLinkSettings { export function parseCodexLinkSettings(raw: null | string | undefined): CodexLinkSettings {
if (typeof raw !== 'string') { if (typeof raw !== 'string') {
return { enabled: true, stored: {} } return { enabled: true, mod: 'dogrudan', stored: {} }
} }
try { try {
const parsed = JSON.parse(raw) const parsed = JSON.parse(raw)
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
return { enabled: true, stored: {} } return { enabled: true, mod: 'dogrudan', stored: {} }
} }
return { enabled: parsed.enabled !== false, stored: parsed as Record<string, unknown> } return {
enabled: parsed.enabled !== false,
mod: parsed.mod === 'aiturk' ? 'aiturk' : 'dogrudan',
stored: parsed as Record<string, unknown>
}
} catch { } catch {
return { enabled: true, stored: {} } return { enabled: true, mod: 'dogrudan', stored: {} }
} }
} }
export function serializeCodexLinkSettings(stored: Record<string, unknown>, enabled: boolean): string { export function serializeCodexLinkSettings(
stored: Record<string, unknown>,
enabled: boolean,
mod: 'dogrudan' | 'aiturk' = stored.mod === 'aiturk' ? 'aiturk' : 'dogrudan'
): string {
const kapsam = typeof stored.kapsam === 'string' && stored.kapsam ? stored.kapsam : 'ide' const kapsam = typeof stored.kapsam === 'string' && stored.kapsam ? stored.kapsam : 'ide'
return `${JSON.stringify({ ...stored, enabled, kapsam }, null, 2)}\n` return `${JSON.stringify({ ...stored, enabled, mod, kapsam }, null, 2)}\n`
} }
// ── Bridge configuration ──────────────────────────────────────────────────── // ── Bridge configuration ────────────────────────────────────────────────────
@@ -5,6 +5,8 @@
* code into its program. See codex-link.ts for what the fields mean in action. * code into its program. See codex-link.ts for what the fields mean in action.
*/ */
export type CodexLinkMode = 'dogrudan' | 'aiturk'
export type CodexLinkIdeState = 'anahtar-yok' | 'kapali' | 'tamam' export type CodexLinkIdeState = 'anahtar-yok' | 'kapali' | 'tamam'
/** Stable codes; the renderer owns the wording. Detail goes to the desktop log. */ /** Stable codes; the renderer owns the wording. Detail goes to the desktop log. */
@@ -18,6 +20,7 @@ export type CodexLinkError =
export interface CodexLinkStatus { export interface CodexLinkStatus {
enabled: boolean enabled: boolean
mod: CodexLinkMode
/** Codex's `config.toml` exists on this machine. */ /** Codex's `config.toml` exists on this machine. */
codexFound: boolean codexFound: boolean
/** The IDE's own Python environment exists (first-run setup finished). */ /** The IDE's own Python environment exists (first-run setup finished). */
@@ -34,6 +37,7 @@ export interface CodexLinkStatus {
export interface CodexLinkPatch { export interface CodexLinkPatch {
enabled?: boolean enabled?: boolean
mod?: CodexLinkMode
/** "Sync now": re-run the apply without changing the preference. */ /** "Sync now": re-run the apply without changing the preference. */
sync?: boolean sync?: boolean
} }
+52 -3
View File
@@ -52,6 +52,7 @@ function createWorld(patch: Partial<World> & { files?: Map<string, string> } = {
events: [], events: [],
files: new Map([ files: new Map([
[PATHS.codexConfig, USER_CONFIG], [PATHS.codexConfig, USER_CONFIG],
[PATHS.settingsFile, JSON.stringify({ enabled: true, mod: 'aiturk' })],
[PATHS.bridgeScript, '# bridge'], [PATHS.bridgeScript, '# bridge'],
[PATHS.pythonw, ''], [PATHS.pythonw, ''],
[PATHS.python, ''] [PATHS.python, '']
@@ -192,6 +193,7 @@ describe('applyCodexLink', () => {
expect(status).toEqual({ expect(status).toEqual({
enabled: true, enabled: true,
mod: 'aiturk',
codexFound: true, codexFound: true,
venvReady: true, venvReady: true,
foreign: false, foreign: false,
@@ -328,7 +330,11 @@ describe('applyCodexLink', () => {
'autostart clear', 'autostart clear',
'stop' 'stop'
]) ])
expect(JSON.parse(world.files.get(PATHS.settingsFile) ?? '')).toEqual({ enabled: false, kapsam: 'ide' }) expect(JSON.parse(world.files.get(PATHS.settingsFile) ?? '')).toEqual({
enabled: false,
mod: 'aiturk',
kapsam: 'ide'
})
// Byte for byte what the user had before the feature touched it. // Byte for byte what the user had before the feature touched it.
expect(world.files.get(PATHS.codexConfig)).toBe(USER_CONFIG) expect(world.files.get(PATHS.codexConfig)).toBe(USER_CONFIG)
expect(world.autostart).toBeNull() expect(world.autostart).toBeNull()
@@ -344,7 +350,8 @@ describe('applyCodexLink', () => {
world.configWriteFails = true world.configWriteFails = true
expect(await link.apply({ enabled: false })).toMatchObject({ expect(await link.apply({ enabled: false })).toMatchObject({
bridgeRunning: true, lastError: 'config-write-failed' bridgeRunning: true,
lastError: 'config-write-failed'
}) })
expect(world.files.get(PATHS.codexConfig)).toBe(linkedConfig) expect(world.files.get(PATHS.codexConfig)).toBe(linkedConfig)
expect(world.autostart).toBe(autostart) expect(world.autostart).toBe(autostart)
@@ -358,7 +365,7 @@ describe('applyCodexLink', () => {
it('stays off across applies and comes back when turned on', async () => { it('stays off across applies and comes back when turned on', async () => {
const { link, world } = createWorld() const { link, world } = createWorld()
world.files.set(PATHS.settingsFile, '{"enabled": false, "kapsam": "ide", "not": "ours"}') world.files.set(PATHS.settingsFile, '{"enabled": false, "mod": "aiturk", "kapsam": "ide", "not": "ours"}')
expect(await link.apply()).toMatchObject({ enabled: false, bridgeRunning: false }) expect(await link.apply()).toMatchObject({ enabled: false, bridgeRunning: false })
expect(spawns(world)).toEqual([]) expect(spawns(world)).toEqual([])
@@ -367,6 +374,7 @@ describe('applyCodexLink', () => {
expect(await link.apply({ enabled: true })).toMatchObject({ enabled: true, bridgeRunning: true }) expect(await link.apply({ enabled: true })).toMatchObject({ enabled: true, bridgeRunning: true })
expect(JSON.parse(world.files.get(PATHS.settingsFile) ?? '')).toEqual({ expect(JSON.parse(world.files.get(PATHS.settingsFile) ?? '')).toEqual({
enabled: true, enabled: true,
mod: 'aiturk',
kapsam: 'ide', kapsam: 'ide',
not: 'ours' not: 'ours'
}) })
@@ -424,3 +432,44 @@ describe('getStatus', () => {
expect(effects(world)).toEqual([]) expect(effects(world)).toEqual([])
}) })
}) })
describe('connection mode', () => {
it('defaults to direct, starts bridge and autostart without writing a line', async () => {
const { link, world } = createWorld()
world.files.delete(PATHS.settingsFile)
expect(await link.apply()).toMatchObject({ mod: 'dogrudan', bridgeRunning: true })
expect(world.files.get(PATHS.codexConfig)).toBe(USER_CONFIG)
expect(spawns(world)).toHaveLength(1)
expect(world.autostart).not.toBeNull()
})
it('removes the old line and preserves the current bridge in direct mode', async () => {
const { link, world } = createWorld({ listening: true })
world.files.set(PATHS.settingsFile, '{"mod":"dogrudan"}')
world.files.set(PATHS.codexConfig, `${CODEX_LINK_LINE}\r\n${USER_CONFIG}`)
await link.apply()
expect(world.files.get(PATHS.codexConfig)).toBe(USER_CONFIG)
expect(spawns(world)).toHaveLength(0)
expect(world.events).not.toContain('stop')
expect(world.autostart).not.toBeNull()
})
it('switches both ways, persists mode and preserves unrelated preferences', async () => {
const { link, world } = createWorld({ listening: true })
world.files.set(PATHS.settingsFile, '{"mod":"dogrudan","custom":{"keep":1}}')
await link.apply({ mod: 'aiturk' })
expect(world.files.get(PATHS.codexConfig)).toContain(CODEX_LINK_LINE)
await link.apply({ mod: 'dogrudan' })
expect(world.files.get(PATHS.codexConfig)).toBe(USER_CONFIG)
expect(JSON.parse(world.files.get(PATHS.settingsFile)!)).toMatchObject({ mod: 'dogrudan', custom: { keep: 1 } })
await link.apply()
expect(world.files.get(PATHS.codexConfig)).toBe(USER_CONFIG)
expect(world.events).not.toContain('stop')
})
it.each(['dogrudan', 'aiturk'] as const)('preserves foreign URL in %s', async mod => {
const { link, world } = createWorld()
const foreign = 'openai_base_url = "https://other.example"\n'
world.files.set(PATHS.codexConfig, foreign)
await link.apply({ mod })
expect(world.files.get(PATHS.codexConfig)).toBe(foreign)
expect(spawns(world)).toHaveLength(0)
})
})
+12 -3
View File
@@ -186,6 +186,7 @@ export function createCodexLink(deps: CodexLinkDeps): CodexLink {
bridge: BridgeProbe bridge: BridgeProbe
): CodexLinkStatus => ({ ): CodexLinkStatus => ({
enabled, enabled,
mod: readSettings().mod,
codexFound: config.found, codexFound: config.found,
venvReady: interpreter() !== null, venvReady: interpreter() !== null,
foreign: config.owner === 'foreign', foreign: config.owner === 'foreign',
@@ -329,7 +330,8 @@ export function createCodexLink(deps: CodexLinkDeps): CodexLink {
return bridge return bridge
} }
const edit = addCodexLinkLine(config.text ?? '') const edit =
readSettings().mod === 'aiturk' ? addCodexLinkLine(config.text ?? '') : removeCodexLinkLine(config.text ?? '')
if (edit.changed) { if (edit.changed) {
try { try {
@@ -364,9 +366,16 @@ export function createCodexLink(deps: CodexLinkDeps): CodexLink {
const run = async (patch?: CodexLinkPatch): Promise<CodexLinkStatus> => { const run = async (patch?: CodexLinkPatch): Promise<CodexLinkStatus> => {
lastError = null lastError = null
if (typeof patch?.enabled === 'boolean') { if (typeof patch?.enabled === 'boolean' || patch?.mod === 'aiturk' || patch?.mod === 'dogrudan') {
try { try {
deps.writeTextAtomic(paths.settingsFile, serializeCodexLinkSettings(readSettings().stored, patch.enabled)) deps.writeTextAtomic(
paths.settingsFile,
serializeCodexLinkSettings(
readSettings().stored,
patch?.enabled ?? readSettings().enabled,
patch?.mod ?? readSettings().mod
)
)
} catch (error) { } catch (error) {
// The preference did not stick, so do not act on it either: what the // The preference did not stick, so do not act on it either: what the
// machine does must match what the file says after a restart. // machine does must match what the file says after a restart.
+1
View File
@@ -17318,6 +17318,7 @@ ipcMain.handle('aiturk:codex-link:set', async (event, patch) => {
return codexLink.apply({ return codexLink.apply({
enabled: typeof patch?.enabled === 'boolean' ? patch.enabled : undefined, enabled: typeof patch?.enabled === 'boolean' ? patch.enabled : undefined,
mod: patch?.mod === 'aiturk' || patch?.mod === 'dogrudan' ? patch.mod : undefined,
sync: patch?.sync === true sync: patch?.sync === true
}) })
}) })
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "aiturk-ide", "name": "aiturk-ide",
"productName": "AITURK IDE", "productName": "AITURK IDE",
"private": true, "private": true,
"version": "1.0.0-beta.32", "version": "1.0.0-beta.38",
"description": "TurkServis resmi yapay zekâ geliştirme ortamı. Hermes Agent tabanlıdır.", "description": "TurkServis resmi yapay zekâ geliştirme ortamı. Hermes Agent tabanlıdır.",
"author": "AITURK / TurkServis", "author": "AITURK / TurkServis",
"repository": { "repository": {
@@ -12,6 +12,7 @@ vi.mock('@/store/notifications', () => ({ notifyError }))
const RUNNING: CodexLinkStatus = { const RUNNING: CodexLinkStatus = {
enabled: true, enabled: true,
mod: 'aiturk',
codexFound: true, codexFound: true,
venvReady: true, venvReady: true,
foreign: false, foreign: false,
@@ -198,3 +199,18 @@ describe('Codex Link settings', () => {
expect(get).not.toHaveBeenCalled() expect(get).not.toHaveBeenCalled()
}) })
}) })
describe('connection mode controls', () => {
it('sends the mode and renders the saved direct preference', async () => {
get.mockResolvedValue({ ...RUNNING, mod: 'dogrudan' })
set.mockResolvedValue({ ...RUNNING, mod: 'aiturk' })
renderPage()
const direct = await screen.findByRole('radio', { name: 'Doğrudan (önerilen): abonelik modelleri köprüden bağımsız' })
await waitFor(() => expect((direct as HTMLInputElement).checked).toBe(true))
fireEvent.click(screen.getByRole('radio', { name: 'AITURK: combolar Codex listesinde, tüm modeller köprüden' }))
await waitFor(() => expect(set).toHaveBeenCalledWith({ mod: 'aiturk' }))
await waitFor(() => expect((direct as HTMLInputElement).checked).toBe(false))
expect(screen.getByText("Codex'i yeniden başlatın")).toBeTruthy()
})
})
@@ -57,7 +57,7 @@ export function codexLinkStatusLines(status: CodexLinkStatus, copy: CodexLinkCop
if (status.ideDurum === 'anahtar-yok') { if (status.ideDurum === 'anahtar-yok') {
lines.push({ id: 'ide', text: copy.signIn, tone: 'warn' }) lines.push({ id: 'ide', text: copy.signIn, tone: 'warn' })
} else if (status.ideDurum === 'tamam' && status.ideModel !== null) { } else if (status.mod === 'aiturk' && status.ideDurum === 'tamam' && status.ideModel !== null) {
lines.push({ id: 'ide', text: copy.models(status.ideModel), tone: 'ok' }) lines.push({ id: 'ide', text: copy.models(status.ideModel), tone: 'ok' })
} }
@@ -170,6 +170,27 @@ export function CodexLinkSettings() {
title={copy.title} title={copy.title}
/> />
<ListRow
title={copy.modeTitle}
below={
<fieldset disabled={!status || busy !== null} className="grid gap-2 py-2">
{(['dogrudan', 'aiturk'] as const).map(mod => (
<label key={mod} className="flex min-h-11 cursor-pointer items-center gap-2">
<input
type="radio"
name="codex-link-mode"
value={mod}
checked={(status?.mod ?? 'dogrudan') === mod}
onChange={() => void change({ mod }, 'toggle')}
/>
<span>{mod === 'dogrudan' ? copy.directMode : copy.aiturkMode}</span>
</label>
))}
<p className="text-(--ui-text-tertiary)">{copy.modeRestartNote}</p>
</fieldset>
}
/>
{!status && !loadFailed && <ListRowSkeleton wide />} {!status && !loadFailed && <ListRowSkeleton wide />}
{loadFailed && !status && ( {loadFailed && !status && (
+44 -1
View File
@@ -6,6 +6,10 @@ import { useI18n } from './context'
// AITURK-distribution strings (see ./aiturk): Turkish for `tr`, English for // AITURK-distribution strings (see ./aiturk): Turkish for `tr`, English for
// every other locale. // every other locale.
const en = { const en = {
modeTitle: 'Connection mode',
directMode: 'Direct (recommended): subscription models independent of the bridge',
aiturkMode: 'AITURK: combos in the Codex list, all models through the bridge',
modeRestartNote: 'Restart Codex.',
nav: 'Codex Link', nav: 'Codex Link',
title: 'Codex Link', title: 'Codex Link',
description: description:
@@ -39,6 +43,10 @@ const en = {
} }
const tr: typeof en = { const tr: typeof en = {
modeTitle: 'Bağlantı modu',
directMode: 'Doğrudan (önerilen): abonelik modelleri köprüden bağımsız',
aiturkMode: 'AITURK: combolar Codex listesinde, tüm modeller köprüden',
modeRestartNote: "Codex'i yeniden başlatın",
nav: 'Codex Bağlama', nav: 'Codex Bağlama',
title: 'Codex Bağlama', title: 'Codex Bağlama',
description: description:
@@ -71,10 +79,45 @@ const tr: typeof en = {
restartNote: "Codex'te yeni modelleri görmek için Codex'i yeniden açın." restartNote: "Codex'te yeni modelleri görmek için Codex'i yeniden açın."
} }
const modeTranslations = {
zh: {
modeTitle: '连接模式',
directMode: '直接(推荐):订阅模型独立于桥接',
aiturkMode: 'AITURK:组合显示在 Codex 列表中,所有模型通过桥接',
modeRestartNote: '请重启 Codex。'
},
'zh-hant': {
modeTitle: '連線模式',
directMode: '直接(建議):訂閱模型獨立於橋接',
aiturkMode: 'AITURK:組合顯示在 Codex 清單中,所有模型透過橋接',
modeRestartNote: '請重新啟動 Codex。'
},
ja: {
modeTitle: '接続モード',
directMode: '直接(推奨):サブスクリプションモデルはブリッジから独立',
aiturkMode: 'AITURK:Codex の一覧にコンボを表示、すべてのモデルはブリッジ経由',
modeRestartNote: 'Codex を再起動してください。'
},
ar: {
modeTitle: 'وضع الاتصال',
directMode: 'مباشر (موصى به): نماذج الاشتراك مستقلة عن الجسر',
aiturkMode: 'AITURK: مجموعات النماذج في قائمة Codex، وجميع النماذج عبر الجسر',
modeRestartNote: 'أعد تشغيل Codex.'
},
ru: {
modeTitle: 'Режим подключения',
directMode: 'Напрямую (рекомендуется): модели подписки независимы от моста',
aiturkMode: 'AITURK: комбинации в списке Codex, все модели через мост',
modeRestartNote: 'Перезапустите Codex.'
}
}
export type CodexLinkCopy = typeof en export type CodexLinkCopy = typeof en
export function useCodexLinkCopy(): CodexLinkCopy { export function useCodexLinkCopy(): CodexLinkCopy {
const { locale } = useI18n() const { locale } = useI18n()
return locale === 'tr' ? tr : en if (locale === 'tr') return tr
if (locale === 'en') return en
return { ...en, ...modeTranslations[locale] }
} }
+1 -1
View File
@@ -65,7 +65,7 @@
}, },
"apps/desktop": { "apps/desktop": {
"name": "aiturk-ide", "name": "aiturk-ide",
"version": "1.0.0-beta.32", "version": "1.0.0-beta.38",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@assistant-ui/core": "0.2.23", "@assistant-ui/core": "0.2.23",
+46
View File
@@ -2550,6 +2550,39 @@ function Install-Repository {
Write-Success "Repository ready" Write-Success "Repository ready"
} }
function Test-ReusableManagedVenv {
param([string]$VenvPath, [string]$BasePython, [string]$Version)
$pythonExe = Join-Path $VenvPath "Scripts\python.exe"
if (-not (Test-Path -LiteralPath $pythonExe -PathType Leaf)) { return $false }
$process = New-Object System.Diagnostics.Process
try {
$info = New-Object System.Diagnostics.ProcessStartInfo
$info.FileName = $pythonExe
$info.Arguments = '-I -c "import json,sys,ssl,ctypes,venv,encodings; print(json.dumps(dict(version=str(sys.version_info.major)+''.''+str(sys.version_info.minor),prefix=sys.prefix,base=sys._base_executable)))"'
$info.UseShellExecute = $false
$info.CreateNoWindow = $true
$info.RedirectStandardOutput = $true
$info.RedirectStandardError = $true
$process.StartInfo = $info
if (-not $process.Start()) { return $false }
$stdout = $process.StandardOutput.ReadToEndAsync()
$stderr = $process.StandardError.ReadToEndAsync()
if (-not $process.WaitForExit(15000)) {
try { $process.Kill() } catch { }
return $false
}
if ($process.ExitCode -ne 0) { return $false }
$health = $stdout.Result | ConvertFrom-Json
return ($health.version -eq $Version -and
[string]::Equals([IO.Path]::GetFullPath($health.prefix), [IO.Path]::GetFullPath($VenvPath), [StringComparison]::OrdinalIgnoreCase) -and
[string]::Equals([IO.Path]::GetFullPath($health.base), [IO.Path]::GetFullPath($BasePython), [StringComparison]::OrdinalIgnoreCase))
} catch {
return $false
} finally {
$process.Dispose()
}
}
function Install-Venv { function Install-Venv {
if ($NoVenv) { if ($NoVenv) {
Write-Info "Skipping virtual environment (-NoVenv)" Write-Info "Skipping virtual environment (-NoVenv)"
@@ -2567,6 +2600,19 @@ function Install-Venv {
throw "Hermes-managed Python is unavailable. Run install.ps1 -Stage python first." throw "Hermes-managed Python is unavailable. Run install.ps1 -Stage python first."
} }
# Routine source updates do not require replacing a healthy interpreter.
# Recreating it kills detached Codex bridges sharing this venv and leaves
# every subscription model disconnected until bootstrap finishes. Verify
# version, provenance and stdlib first; the dependency stage still performs
# the locked sync and import checks. Broken/mismatched runtimes retain the
# transactional repair path below.
$existingVenvPath = Join-Path $InstallDir "venv"
if (Test-ReusableManagedVenv -VenvPath $existingVenvPath -BasePython $resolvedPython.Path -Version $resolvedPython.Version) {
$env:UV_PYTHON = Join-Path $existingVenvPath "Scripts\python.exe"
Write-Success "Reusing healthy managed virtual environment; running Codex bridges preserved"
return
}
Write-Info "Creating virtual environment with Python $($resolvedPython.Version)..." Write-Info "Creating virtual environment with Python $($resolvedPython.Version)..."
Push-Location $InstallDir Push-Location $InstallDir
@@ -0,0 +1,77 @@
"""Run the actual installer functions against a real disposable Python venv."""
import json
import os
import shutil
import subprocess
import sys
from pathlib import Path
import pytest
SOURCE = Path(__file__).resolve().parents[1] / 'scripts/install.ps1'
PS = shutil.which('powershell') or shutil.which('pwsh')
pytestmark = pytest.mark.skipif(os.name != 'nt' or not PS, reason='Windows installer runtime test')
def function(source, name):
start = source.index('function ' + name + ' {')
opening = source.index('{', start)
depth = 0
for index in range(opening, len(source)):
if source[index] == '{': depth += 1
elif source[index] == '}': depth -= 1
if depth == 0: return source[start:index + 1]
raise AssertionError(name)
@pytest.fixture(scope='module')
def runtime(tmp_path_factory):
root = tmp_path_factory.mktemp('installer-reuse')
subprocess.run([sys.executable, '-m', 'venv', '--without-pip', str(root / 'venv')], check=True)
return root
def run(runtime, body):
text = SOURCE.read_text(encoding='utf-8-sig')
script = runtime / 'probe.ps1'
prefix = "$ErrorActionPreference = 'Stop'\n"
prefix += function(text, 'Test-ReusableManagedVenv') + '\n'
prefix += function(text, 'Install-Venv') + '\n'
prefix += "$InstallDir = '" + str(runtime).replace("'", "''") + "'\n"
prefix += "$BasePython = '" + sys._base_executable.replace("'", "''") + "'\n"
prefix += "$Version = '" + str(sys.version_info.major) + '.' + str(sys.version_info.minor) + "'\n"
script.write_text(prefix + body, encoding='utf-8-sig')
result = subprocess.run([PS, '-NoProfile', '-File', str(script)], capture_output=True, text=True, timeout=40)
assert result.returncode == 0, result.stdout + result.stderr
return result.stdout
def test_healthy_update_keeps_live_python_and_never_reaches_kill_sweep(runtime):
python = runtime / 'venv/Scripts/python.exe'
live = subprocess.Popen([str(python), '-c', 'import time;time.sleep(90)'])
try:
output = run(runtime, r'''
function Resolve-AvailablePythonVersion { return @{Path=$BasePython;Version=$Version} }
function Write-Success { param($Message); Write-Output $Message }
function Write-Info { throw 'Unexpected venv recreation/kill path' }
function taskkill { throw 'Live process must not be killed' }
Install-Venv
''')
assert 'Reusing healthy' in output
assert live.poll() is None
assert python.is_file()
finally:
live.terminate();live.wait(timeout=10)
def test_wrong_version_or_foreign_interpreter_does_not_reuse(runtime):
output = run(runtime, r'''
$venvPath = Join-Path $InstallDir 'venv'
$results = @(
(Test-ReusableManagedVenv $venvPath $BasePython '0.0'),
(Test-ReusableManagedVenv $venvPath (Join-Path $InstallDir 'other-python.exe') $Version),
(Test-ReusableManagedVenv (Join-Path $InstallDir 'missing') $BasePython $Version)
)
ConvertTo-Json -Compress -InputObject $results
''')
assert json.loads(output) == [False, False, False]
@@ -20,6 +20,7 @@ import subprocess
import pytest import pytest
from tools.file_operations import ShellFileOperations from tools.file_operations import ShellFileOperations
from tools.environments.local import LocalEnvironment
# Big enough to prove containment, small enough to keep the test fast. # Big enough to prove containment, small enough to keep the test fast.
GIANT = 5 * 1024 * 1024 # 5MB single line GIANT = 5 * 1024 * 1024 # 5MB single line
@@ -27,22 +28,20 @@ GIANT = 5 * 1024 * 1024 # 5MB single line
STDOUT_CEILING = 1 * 1024 * 1024 STDOUT_CEILING = 1 * 1024 * 1024
class RecordingEnv: class RecordingEnv(LocalEnvironment):
"""Local bash executor that records the largest stdout it returned.""" """Local bash executor that records the largest stdout it returned."""
def __init__(self, cwd): def __init__(self, cwd):
self.cwd = cwd super().__init__(cwd=cwd)
self.max_stdout = 0 self.max_stdout = 0
def execute(self, command, timeout=60, **kwargs): def execute(self, command, timeout=60, **kwargs):
proc = subprocess.run( # Use the application's shell selection/path handling. On Windows a
["bash", "-c", command], # bare 'bash' may select WSL, whose filesystem is not the native one.
capture_output=True, text=True, errors="replace", result = super().execute(command, timeout=timeout + 30, **kwargs)
timeout=timeout + 30, out = result.get("output", "")
)
out = proc.stdout + (proc.stderr or "")
self.max_stdout = max(self.max_stdout, len(out)) self.max_stdout = max(self.max_stdout, len(out))
return {"output": out, "returncode": proc.returncode} return result
@pytest.fixture() @pytest.fixture()
@@ -58,6 +57,8 @@ def _ops(giant_dir, engine):
env = RecordingEnv(str(giant_dir)) env = RecordingEnv(str(giant_dir))
ops = ShellFileOperations(env) ops = ShellFileOperations(env)
ops._has_command = lambda cmd: cmd == engine ops._has_command = lambda cmd: cmd == engine
resolve = ops._resolve_command
ops._resolve_command = lambda cmd: None if cmd == "rg" and engine != "rg" else resolve(cmd)
return ops, env return ops, env
@@ -96,5 +97,5 @@ def test_line_cap_skipped_for_path_and_count_modes(giant_dir, engine, output_mod
if output_mode == "files_only": if output_mode == "files_only":
assert {os.path.basename(f) for f in result.files} == {"trace.json", "small.py"} assert {os.path.basename(f) for f in result.files} == {"trace.json", "small.py"}
else: else:
assert {os.path.basename(k) for k in result.counts} == {"trace.json", "small.py"} assert {os.path.basename(k) for k, count in result.counts.items() if count} == {"trace.json", "small.py"}
assert env.max_stdout < STDOUT_CEILING assert env.max_stdout < STDOUT_CEILING
+112
View File
@@ -0,0 +1,112 @@
"""Project scope and resource limits: real local paths, no broad subprocesses."""
from unittest.mock import Mock
from tools.file_operations import ShellFileOperations, SearchResult, ExecuteResult
from tools.environments.local import LocalEnvironment
def projects(tmp_path):
for name in ('one', 'two'):
project = tmp_path / name
project.mkdir()
(project / '.git').mkdir()
(project / 'source.py').write_text('needle\n')
return tmp_path / 'one'
def test_ambiguous_scope_starts_no_process(tmp_path):
projects(tmp_path)
ops = ShellFileOperations(LocalEnvironment(str(tmp_path)))
ops._exec = Mock(side_effect=AssertionError('must not start process'))
result = ops.search('needle', str(tmp_path))
assert 'scope' in result.error.lower()
assert 'one' in result.error and 'two' in result.error
ops._exec.assert_not_called()
def test_umbrella_uses_cwd_project_and_reports_it(tmp_path):
project = projects(tmp_path)
ops = ShellFileOperations(LocalEnvironment(str(project)))
ops._exec = Mock(return_value=ExecuteResult(stdout='exists', exit_code=0))
ops._search_content = Mock(return_value=SearchResult())
result = ops.search('needle', str(tmp_path))
assert ops._search_content.call_args.args[1] == str(project)
assert str(project) in result.warning
def test_broad_opt_in_and_explicit_file(tmp_path):
project = projects(tmp_path)
ops = ShellFileOperations(LocalEnvironment(str(project)))
ops._exec = Mock(return_value=ExecuteResult(stdout='exists', exit_code=0))
ops._search_content = Mock(return_value=SearchResult())
for root, broad in ((tmp_path, True), (project / 'source.py', False)):
assert not ops.search('needle', str(root), allow_broad=broad).error
assert ops._search_content.call_args.args[1] == str(root)
def test_rg_commands_bound_threads_and_prune(tmp_path):
project = projects(tmp_path)
ops = ShellFileOperations(LocalEnvironment(str(project)))
ops._exec = Mock(return_value=ExecuteResult(stdout='', exit_code=1))
ops._search_with_rg('needle', str(project), '*.py', 50, 0, 'content', 0, rg_executable='rg')
command = ops._exec.call_args.args[0]
assert '--threads 2' in command
assert 'node_modules' in command and '.git' in command
assert '*.py' in command
ops._search_files_rg('*.py', str(project), 50, 0, rg_executable='rg')
command = ops._exec.call_args.args[0]
assert '--threads 2' in command and 'node_modules' in command
def test_concurrent_searches_have_two_slots_and_release_after_failure(tmp_path):
import threading
from concurrent.futures import ThreadPoolExecutor
project = projects(tmp_path)
lock = threading.Lock()
release = threading.Event()
entered = threading.Event()
active = peak = 0
def run_search(*args):
nonlocal active, peak
with lock:
active += 1
peak = max(peak, active)
if active == 2:
entered.set()
try:
assert release.wait(5)
return SearchResult()
finally:
with lock:
active -= 1
with ThreadPoolExecutor(max_workers=6) as pool:
pending = []
for _ in range(6):
ops = ShellFileOperations(LocalEnvironment(str(project)))
ops._search_scoped = run_search
pending.append(pool.submit(ops.search, 'needle'))
try:
assert entered.wait(3)
assert peak == 2
finally:
release.set()
assert all(not task.result().error for task in pending)
assert peak == 2
def test_real_rg_project_content_and_nested_glob(tmp_path):
project = projects(tmp_path)
(project / 'src').mkdir()
(project / 'src' / 'nested.py').write_text('needle\n')
(project / 'node_modules').mkdir()
(project / 'node_modules' / 'noise.py').write_text('needle\n')
ops = ShellFileOperations(LocalEnvironment(str(project)))
result = ops.search('needle', str(tmp_path), file_glob='*.py')
assert not result.error
assert len(result.matches) == 2
assert all('node_modules' not in item.path for item in result.matches)
result = ops.search('src/*.py', str(project), target='files')
assert not result.error
assert len(result.files) == 1 and 'nested.py' in result.files[0]
+56 -12
View File
@@ -50,7 +50,8 @@ from agent.file_safety import (
get_write_denied_error, get_write_denied_error,
is_write_denied as _shared_is_write_denied, is_write_denied as _shared_is_write_denied,
) )
from agent.search_policy import SEARCH_PRUNE_DIR_NAMES from agent.search_policy import (SEARCH_PRUNE_DIR_NAMES, SEARCH_RG_THREADS,
SEARCH_MAX_CONCURRENT, local_search_scope)
from tools import interrupt as tool_interrupt from tools import interrupt as tool_interrupt
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -73,6 +74,8 @@ _MACOS_TCC_PROTECTED_HOME_DIRS = (
) )
_SEARCH_SLOTS = threading.BoundedSemaphore(SEARCH_MAX_CONCURRENT)
_FILENAME_SEARCH_ADMISSION = threading.Condition() _FILENAME_SEARCH_ADMISSION = threading.Condition()
_ACTIVE_FILENAME_SEARCH_ROOTS: set[tuple[str, str, str]] = set() _ACTIVE_FILENAME_SEARCH_ROOTS: set[tuple[str, str, str]] = set()
_FILENAME_SEARCH_WAIT_SECONDS = 0.05 _FILENAME_SEARCH_WAIT_SECONDS = 0.05
@@ -3432,7 +3435,31 @@ class ShellFileOperations(FileOperations):
def search(self, pattern: str, path: str = ".", target: str = "content", def search(self, pattern: str, path: str = ".", target: str = "content",
file_glob: Optional[str] = None, limit: int = 50, offset: int = 0, file_glob: Optional[str] = None, limit: int = 50, offset: int = 0,
output_mode: str = "content", context: int = 0, output_mode: str = "content", context: int = 0,
order: str = "discovery") -> SearchResult: order: str = "discovery", allow_broad: bool = False) -> SearchResult:
# One admission slot covers discovery, traversal and zero-match probes.
# It is process-wide across file-ops instances, and interruptible.
while not _SEARCH_SLOTS.acquire(timeout=0.05):
if tool_interrupt.is_interrupted():
return SearchResult(error="Search interrupted while waiting for a search slot.")
try:
if tool_interrupt.is_interrupted():
return SearchResult(error="Search interrupted.")
return self._search_scoped(pattern, path, target, file_glob, limit,
offset, output_mode, context, order, allow_broad)
finally:
_SEARCH_SLOTS.release()
def _project_search_scope(self, path, allow_broad=False):
from tools.environments.local import LocalEnvironment, _msys_to_windows_path
if allow_broad or not isinstance(self.env, LocalEnvironment):
return path, None, None
cwd = getattr(self.env, "cwd", None) or self.cwd
return local_search_scope(_msys_to_windows_path(path), cwd, _HOME)
def _search_scoped(self, pattern: str, path: str = ".", target: str = "content",
file_glob: Optional[str] = None, limit: int = 50, offset: int = 0,
output_mode: str = "content", context: int = 0,
order: str = "discovery", allow_broad: bool = False) -> SearchResult:
""" """
Search for content or files. Search for content or files.
@@ -3460,6 +3487,9 @@ class ShellFileOperations(FileOperations):
# Expand ~ and other shell paths # Expand ~ and other shell paths
path = self._expand_path(path) path = self._expand_path(path)
path, scope_notice, scope_error = self._project_search_scope(path, allow_broad)
if scope_error:
return SearchResult(error=scope_error)
# Validate that the path exists before searching # Validate that the path exists before searching
check = self._exec(f"test -e {self._escape_shell_arg(path)} && echo exists || echo not_found") check = self._exec(f"test -e {self._escape_shell_arg(path)} && echo exists || echo not_found")
@@ -3470,7 +3500,7 @@ class ShellFileOperations(FileOperations):
# merge the results, and report the skipped parts. # merge the results, and report the skipped parts.
multi = self._try_multi_path_search( multi = self._try_multi_path_search(
pattern, path, target, file_glob, limit, offset, output_mode, context, pattern, path, target, file_glob, limit, offset, output_mode, context,
order, order, allow_broad,
) )
if multi is not None: if multi is not None:
return multi return multi
@@ -3518,6 +3548,8 @@ class ShellFileOperations(FileOperations):
f"an unattended privacy prompt: {skipped}. Search a protected " f"an unattended privacy prompt: {skipped}. Search a protected "
"folder directly when access is intentional." "folder directly when access is intentional."
) )
if scope_notice:
result.warning = " ".join(filter(None, [scope_notice, result.warning]))
return result return result
def _macos_search_exclusions(self, path: str) -> List[str]: def _macos_search_exclusions(self, path: str) -> List[str]:
@@ -3587,7 +3619,7 @@ class ShellFileOperations(FileOperations):
def _try_multi_path_search(self, pattern: str, path: str, target: str, def _try_multi_path_search(self, pattern: str, path: str, target: str,
file_glob: Optional[str], limit: int, offset: int, file_glob: Optional[str], limit: int, offset: int,
output_mode: str, context: int, output_mode: str, context: int,
order: str = "discovery") -> Optional[SearchResult]: order: str = "discovery", allow_broad: bool = False) -> Optional[SearchResult]:
"""Recover a not-found ``path`` that is really several paths in one string. """Recover a not-found ``path`` that is really several paths in one string.
Production trajectories show models passing "dir1 dir2 dir3" (or Production trajectories show models passing "dir1 dir2 dir3" (or
@@ -3604,9 +3636,14 @@ class ShellFileOperations(FileOperations):
parts = path.split() parts = path.split()
if len(parts) < 2: if len(parts) < 2:
return None return None
existing, missing = [], [] existing, missing, scope_notices = [], [], []
for p in parts: for p in parts:
expanded = self._expand_path(p) expanded = self._expand_path(p)
expanded, notice, error = self._project_search_scope(expanded, allow_broad)
if error:
return SearchResult(error=error)
if notice:
scope_notices.append(notice)
chk = self._exec( chk = self._exec(
f"test -e {self._escape_shell_arg(expanded)} && echo exists || echo not_found" f"test -e {self._escape_shell_arg(expanded)} && echo exists || echo not_found"
) )
@@ -3641,7 +3678,7 @@ class ShellFileOperations(FileOperations):
note += "; skipped missing: " + ", ".join(missing[:3]) note += "; skipped missing: " + ", ".join(missing[:3])
if len(missing) > 3: if len(missing) > 3:
note += f" (+{len(missing) - 3} more)" note += f" (+{len(missing) - 3} more)"
warning_parts = [note] warning_parts = [note, *scope_notices]
if not merged.error: if not merged.error:
protected_paths = [ protected_paths = [
absolute absolute
@@ -3704,7 +3741,7 @@ class ShellFileOperations(FileOperations):
glob_expr = f" --glob {self._escape_shell_arg(file_glob)}" if file_glob else "" glob_expr = f" --glob {self._escape_shell_arg(file_glob)}" if file_glob else ""
probe = self._exec( probe = self._exec(
f"{rg} -i --count-matches{glob_expr} " f"{rg} --threads {SEARCH_RG_THREADS} -i --count-matches{glob_expr} {self._search_prune_glob_args()} "
f"{self._escape_shell_arg(pattern)} {self._escape_native_tool_arg(path)} " f"{self._escape_shell_arg(pattern)} {self._escape_native_tool_arg(path)} "
f"2>/dev/null | head -50", f"2>/dev/null | head -50",
timeout=30, timeout=30,
@@ -3723,7 +3760,7 @@ class ShellFileOperations(FileOperations):
# files remain diagnosable, but prune heavyweight trees before rg can # files remain diagnosable, but prune heavyweight trees before rg can
# recurse into them. # recurse into them.
hidden = self._exec( hidden = self._exec(
f"{rg} --hidden --no-ignore --count-matches{glob_expr}" f"{rg} --threads {SEARCH_RG_THREADS} --hidden --no-ignore --count-matches{glob_expr}"
f" {self._search_prune_glob_args()} " f" {self._search_prune_glob_args()} "
f"{self._escape_shell_arg(pattern)} {self._escape_native_tool_arg(path)} " f"{self._escape_shell_arg(pattern)} {self._escape_native_tool_arg(path)} "
f"2>/dev/null | head -50", f"2>/dev/null | head -50",
@@ -3738,7 +3775,7 @@ class ShellFileOperations(FileOperations):
) )
if re.search(r"[.\[\](){}?*+^$\\|]", pattern): if re.search(r"[.\[\](){}?*+^$\\|]", pattern):
fixed = self._exec( fixed = self._exec(
f"{rg} -F --count-matches{glob_expr} " f"{rg} --threads {SEARCH_RG_THREADS} -F --count-matches{glob_expr} {self._search_prune_glob_args()} "
f"{self._escape_shell_arg(pattern)} {self._escape_native_tool_arg(path)} " f"{self._escape_shell_arg(pattern)} {self._escape_native_tool_arg(path)} "
f"2>/dev/null | head -50", f"2>/dev/null | head -50",
timeout=30, timeout=30,
@@ -3813,7 +3850,7 @@ class ShellFileOperations(FileOperations):
)) ))
try: try:
return self._search_files_rg( return self._search_files_rg(
search_pattern, path, limit, offset, order, pattern, path, limit, offset, order,
rg_executable=rg_executable, rg_executable=rg_executable,
) )
finally: finally:
@@ -3848,6 +3885,9 @@ class ShellFileOperations(FileOperations):
hidden_prune = ( hidden_prune = (
f" \\( -type d -name '.*'{root_exemptions} \\) -prune -o" f" \\( -type d -name '.*'{root_exemptions} \\) -prune -o"
) )
names = " -o ".join(f"-name {self._escape_shell_arg(name)}"
for name in sorted(SEARCH_PRUNE_DIR_NAMES))
hidden_prune += f" \\( -type d \\( {names} \\){root_exemptions} \\) -prune -o"
protected_paths = [ protected_paths = [
absolute absolute
for _root, _relative, absolute for _root, _relative, absolute
@@ -3986,7 +4026,7 @@ class ShellFileOperations(FileOperations):
f"cd {self._escape_shell_arg(scoped_common)} && " if scoped_common else "" f"cd {self._escape_shell_arg(scoped_common)} && " if scoped_common else ""
) )
cmd = ( cmd = (
f"set -o pipefail; {cd_prefix}{rg} --files{sort_arg} -g {self._escape_shell_arg(glob_pattern)}" f"set -o pipefail; {cd_prefix}{rg} --files{sort_arg} --threads {SEARCH_RG_THREADS} -g {self._escape_shell_arg(glob_pattern)} {self._search_prune_glob_args()}"
f"{exclusion_args} -- {root_args} 2>/dev/null | head -n {fetch_limit}" f"{exclusion_args} -- {root_args} 2>/dev/null | head -n {fetch_limit}"
) )
result = self._exec(cmd, timeout=60) result = self._exec(cmd, timeout=60)
@@ -4062,7 +4102,7 @@ class ShellFileOperations(FileOperations):
rg_executable = rg_executable or self._resolve_command("rg") rg_executable = rg_executable or self._resolve_command("rg")
if not rg_executable: if not rg_executable:
return SearchResult(error="Content search requires ripgrep (rg).") return SearchResult(error="Content search requires ripgrep (rg).")
cmd_parts = [self._quote_executable(rg_executable), "--line-number", "--no-heading", "--with-filename"] cmd_parts = [self._quote_executable(rg_executable), "--threads", str(SEARCH_RG_THREADS), "--line-number", "--no-heading", "--with-filename"]
# Giant-single-line containment (ported from cline/cline#13525): a # Giant-single-line containment (ported from cline/cline#13525): a
# match inside a serialized dump (multi-MB single-line JSON/minified # match inside a serialized dump (multi-MB single-line JSON/minified
@@ -4100,6 +4140,8 @@ class ShellFileOperations(FileOperations):
if file_glob: if file_glob:
cmd_parts.extend(["--glob", self._escape_shell_arg(file_glob)]) cmd_parts.extend(["--glob", self._escape_shell_arg(file_glob)])
cmd_parts.append(self._search_prune_glob_args())
# Output mode handling # Output mode handling
if output_mode == "files_only": if output_mode == "files_only":
cmd_parts.append("-l") # Files only cmd_parts.append("-l") # Files only
@@ -4230,6 +4272,8 @@ class ShellFileOperations(FileOperations):
# Exclude hidden directories (matching ripgrep's default behavior). # Exclude hidden directories (matching ripgrep's default behavior).
# This prevents searching inside .hub/index-cache/, .git/, etc. # This prevents searching inside .hub/index-cache/, .git/, etc.
cmd_parts.append("--exclude-dir='.*'") cmd_parts.append("--exclude-dir='.*'")
cmd_parts.extend(f"--exclude-dir={self._escape_shell_arg(name)}"
for name in sorted(SEARCH_PRUNE_DIR_NAMES))
# Protected-dir pruning CANNOT use --exclude-dir here: grep matches # Protected-dir pruning CANNOT use --exclude-dir here: grep matches
# exclude-dir globs against BASENAMES anywhere in the tree, so # exclude-dir globs against BASENAMES anywhere in the tree, so
+6 -2
View File
@@ -2653,7 +2653,7 @@ def search_tool(pattern: str, target: str = "content", path: str = ".",
file_glob: str = None, limit: int = 50, offset: int = 0, file_glob: str = None, limit: int = 50, offset: int = 0,
output_mode: str = "content", context: int = 0, output_mode: str = "content", context: int = 0,
order: str = "discovery", order: str = "discovery",
task_id: str = "default") -> str: task_id: str = "default", allow_broad: bool = False) -> str:
"""Search for content or files.""" """Search for content or files."""
try: try:
offset, limit = normalize_search_pagination(offset, limit) offset, limit = normalize_search_pagination(offset, limit)
@@ -2670,6 +2670,7 @@ def search_tool(pattern: str, target: str = "content", path: str = ".",
limit, limit,
offset, offset,
order, order,
allow_broad,
) )
with _read_tracker_lock: with _read_tracker_lock:
task_data = _read_tracker.setdefault(task_id, { task_data = _read_tracker.setdefault(task_id, {
@@ -2717,6 +2718,7 @@ def search_tool(pattern: str, target: str = "content", path: str = ".",
pattern=pattern, path=path, target=target, file_glob=file_glob, pattern=pattern, path=path, target=target, file_glob=file_glob,
limit=limit, offset=offset, output_mode=output_mode, context=context, limit=limit, offset=offset, output_mode=output_mode, context=context,
order=order, order=order,
**({"allow_broad": True} if allow_broad else {}),
) )
omitted = _filter_read_blocked_search_results(result, task_id) omitted = _filter_read_blocked_search_results(result, task_id)
if hasattr(result, 'matches'): if hasattr(result, 'matches'):
@@ -2912,6 +2914,7 @@ SEARCH_FILES_SCHEMA = {
"parameters": { "parameters": {
"type": "object", "type": "object",
"properties": { "properties": {
"allow_broad": {"type": "boolean", "default": False, "description": "Deliberately search a home, drive or multi-project umbrella. Default scopes to the current project or asks for a scope. Does not remove pruning or CPU limits."},
"pattern": {"type": "string", "description": "Regex pattern for content search, or glob pattern (e.g., '*.py') for file search"}, "pattern": {"type": "string", "description": "Regex pattern for content search, or glob pattern (e.g., '*.py') for file search"},
"target": {"type": "string", "enum": ["content", "files"], "description": "'content' searches inside file contents, 'files' searches for files by name", "default": "content"}, "target": {"type": "string", "enum": ["content", "files"], "description": "'content' searches inside file contents, 'files' searches for files by name", "default": "content"},
"path": {"type": "string", "description": "Directory or file to search in (default: current working directory)", "default": "."}, "path": {"type": "string", "description": "Directory or file to search in (default: current working directory)", "default": "."},
@@ -2979,7 +2982,8 @@ def _handle_search_files(args, **kw):
pattern=args.get("pattern", ""), target=target, path=args.get("path", "."), pattern=args.get("pattern", ""), target=target, path=args.get("path", "."),
file_glob=args.get("file_glob"), limit=args.get("limit", 50), offset=args.get("offset", 0), file_glob=args.get("file_glob"), limit=args.get("limit", 50), offset=args.get("offset", 0),
output_mode=args.get("output_mode", "content"), context=args.get("context", 0), output_mode=args.get("output_mode", "content"), context=args.get("context", 0),
order=args.get("order", "discovery"), task_id=tid) order=args.get("order", "discovery"), task_id=tid,
allow_broad=args.get("allow_broad") is True)
def _read_file_schema_overrides(): def _read_file_schema_overrides():