Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8f53b090f7 | ||
|
|
049c4d1338 | ||
|
|
4924781e2d | ||
|
|
4037c65204 | ||
|
|
e7d3080e0a | ||
|
|
db5e876f40 | ||
|
|
ab7a721fbc | ||
|
|
2cca6a66a6 |
No files matched your search
@@ -23,6 +23,9 @@ jobs:
|
||||
run: |
|
||||
set -eu
|
||||
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"
|
||||
"$RUNNER_TEMP/bootstrap/bin/pip" install uv
|
||||
"$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
|
||||
- name: Codex bridge regression tests
|
||||
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
|
||||
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
|
||||
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
|
||||
|
||||
+5
-2
@@ -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,
|
||||
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.
|
||||
`/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.
|
||||
|
||||
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ış
|
||||
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
|
||||
sunulmamalıdır. macOS ve Linux paketleri bu beta kapsamında doğrulanmamıştır.
|
||||
paketleri imzasız EXE/ZIP olarak yayımlanır; SHA-256 ve gerçek Windows çalıştırma
|
||||
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
|
||||
|
||||
|
||||
@@ -8,6 +8,66 @@ path; broad diagnostic probes should apply this policy to recursive walks.
|
||||
|
||||
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
|
||||
# shell backends alike. The same set is used by context discovery and search
|
||||
|
||||
@@ -32,9 +32,10 @@ import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
import gecis_context
|
||||
import terminal_stream
|
||||
|
||||
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:
|
||||
with open(os.path.abspath(__file__), "rb") as _f:
|
||||
KOD = hashlib.sha256(_f.read()).hexdigest()[:8]
|
||||
@@ -348,6 +349,36 @@ class H(http.server.BaseHTTPRequestHandler):
|
||||
self.end_headers()
|
||||
self.wfile.write(hata)
|
||||
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:
|
||||
self.send_response(durum)
|
||||
for k, v in yanit.headers.items():
|
||||
@@ -359,6 +390,10 @@ class H(http.server.BaseHTTPRequestHandler):
|
||||
event_tail = b""
|
||||
completed = False
|
||||
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:
|
||||
try:
|
||||
parca = yanit.read1(4096) # gelen kadarini hemen verir; bekletmez
|
||||
@@ -370,6 +405,8 @@ class H(http.server.BaseHTTPRequestHandler):
|
||||
if not parca:
|
||||
break
|
||||
toplam += len(parca)
|
||||
if observer is not None:
|
||||
observer.feed(parca)
|
||||
if getattr(self, "_native_return_scope", None):
|
||||
event_tail += parca
|
||||
while b"\n" in event_tail:
|
||||
@@ -382,6 +419,9 @@ class H(http.server.BaseHTTPRequestHandler):
|
||||
pass
|
||||
self.wfile.write(b"%x\r\n" % len(parca) + parca + b"\r\n")
|
||||
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.flush()
|
||||
except (ConnectionAbortedError, BrokenPipeError, ConnectionResetError):
|
||||
@@ -453,6 +493,7 @@ class H(http.server.BaseHTTPRequestHandler):
|
||||
|
||||
def _isle(self):
|
||||
self._native_return_scope = None
|
||||
self._failure_key = None
|
||||
with kilit:
|
||||
sira[0] += 1
|
||||
n = sira[0]
|
||||
@@ -479,20 +520,32 @@ class H(http.server.BaseHTTPRequestHandler):
|
||||
req = {}
|
||||
except Exception:
|
||||
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 "")
|
||||
scope = hashlib.sha256((str(self.headers.get("chatgpt-account-id") or "") + "|" + thread).encode()).hexdigest()
|
||||
source = TRANSFERS.get(scope) if thread else None
|
||||
# A completed return to the subscription ends migration. Historical
|
||||
# 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))
|
||||
if external or mixed:
|
||||
mixed = ek_model_mu(source or "") or (source is None and gecis_context.foreign_history(req))
|
||||
# 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:
|
||||
normalized = gecis_context.normalize(req)
|
||||
except gecis_context.TransferError as e:
|
||||
self._transfer_begin()
|
||||
return self._transfer_fail(str(e))
|
||||
# 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)
|
||||
if prepared is None:
|
||||
return
|
||||
@@ -535,6 +588,10 @@ class H(http.server.BaseHTTPRequestHandler):
|
||||
|
||||
|
||||
class Sunucu(http.server.ThreadingHTTPServer):
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.failure_replays = terminal_stream.FailureReplays()
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
daemon_threads = True
|
||||
# Shipped Python 3.11 defaults to only five pending connections. Model
|
||||
# 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', '')
|
||||
external = module.ek_model_mu(slug)
|
||||
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.
|
||||
if not external and not module.ek_model_mu(source_model) and not gc.foreign(original):
|
||||
return {'request': copy.deepcopy(original), 'window': handler._transfer_window(slug)}
|
||||
if not external and not module.ek_model_mu(source_model) and not gc.foreign_history(original):
|
||||
return {'request': gc.normalize(original)}
|
||||
settings = settings_store.read(module.HOME)
|
||||
actual = (module.yonlendirilen(str(original.get('prompt_cache_key') or ''), slug) or slug) if external else slug
|
||||
window = handler._transfer_window(actual)
|
||||
|
||||
@@ -7,6 +7,7 @@ must not enter the budget preparation path.
|
||||
import copy
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import threading
|
||||
from collections import OrderedDict
|
||||
|
||||
@@ -53,6 +54,22 @@ def foreign(req):
|
||||
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):
|
||||
"""Repair local wrappers/IDs, retaining real OpenAI encrypted compaction.
|
||||
|
||||
@@ -81,6 +98,10 @@ def normalize(req):
|
||||
if any(visible):
|
||||
result.append(ozet_kopru._ozet_mesaji("\n".join(visible)))
|
||||
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"))
|
||||
if prefix and isinstance(identity, str) and identity and not identity.startswith(prefix + "_"):
|
||||
item["id"] = prefix + "_" + identity.split("_", 1)[-1]
|
||||
@@ -104,6 +125,31 @@ def portable_reasoning(req):
|
||||
return out
|
||||
result = []
|
||||
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":
|
||||
result.append(item)
|
||||
continue
|
||||
|
||||
@@ -348,11 +348,13 @@ def yukle(ua="AstraBridge/1.0 (codex)", timeout=30, home=None, ag=True):
|
||||
if not girdiler:
|
||||
raise ValueError("Gecitte kullanilabilir OmniRoute sohbet modeli yok")
|
||||
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
|
||||
ogr = pencere.ogrenilen(home)
|
||||
aracsiz = pencere.aracsizlar(home)
|
||||
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"],
|
||||
bilgi["arac"], bilgi["kaynak"])
|
||||
g["claude"] = pencere.claude_var(g["kind"], g.get("yol"), kayitlar.get(g["id"]))
|
||||
|
||||
@@ -40,7 +40,7 @@ from bekci_watch import MetadataFilter
|
||||
# Installed only by owned native launcher, never an HTTP control endpoint.
|
||||
WATCH_OWNER = None
|
||||
|
||||
SURUM = "13"
|
||||
SURUM = "18"
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
try:
|
||||
with open(os.path.abspath(__file__), "rb") as _f:
|
||||
@@ -363,15 +363,40 @@ def arac_semalarini_duzelt(req):
|
||||
n += bool(degisti)
|
||||
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."""
|
||||
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()
|
||||
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")
|
||||
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":
|
||||
for s in t.get("tools", []):
|
||||
add_tool(s)
|
||||
add_tool(s, ".".join(filter(None, (namespace, t.get("name")))))
|
||||
elif ty == "function":
|
||||
tools.append({"type": "function", "name": t["name"], "description": t.get("description", ""),
|
||||
"parameters": t.get("parameters") or {"type": "object", "properties": {}}, "strict": False})
|
||||
@@ -387,13 +412,34 @@ def flatten_request(req):
|
||||
tools.append(t)
|
||||
for t in req.get("tools", []) or []:
|
||||
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 = []
|
||||
for it in req.get("input", []) or []:
|
||||
ty = it.get("type")
|
||||
if ty == "additional_tools":
|
||||
for t in it.get("tools", []):
|
||||
add_tool(t)
|
||||
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"):
|
||||
# Desktop thread handoffs have no matching tool call. Preserve their
|
||||
# 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
|
||||
geldigi belli olana kadar bekletilir (arac cagrisi ya da baska oge -> commentary; yanit bitti ->
|
||||
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.custom = custom
|
||||
self.tool_names = tool_names or {}
|
||||
self.buf = b""
|
||||
self.custom_ids = set()
|
||||
self.bekleyen = None # phase karari bekleyen "output_item.done" olayi
|
||||
@@ -634,6 +681,12 @@ class SseRewriter:
|
||||
self.bekleyen = d
|
||||
return once
|
||||
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):
|
||||
t = d.get("type", "")
|
||||
item = d.get("item")
|
||||
@@ -655,6 +708,7 @@ class SseRewriter:
|
||||
# Normalize before Codex persists the response. Request-only repair
|
||||
# cannot help when the user later disables the bridge.
|
||||
fix_tool_ids({"input": [d["item"]]})
|
||||
self._restore_tool_name(d["item"])
|
||||
return d
|
||||
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
|
||||
@@ -667,9 +721,10 @@ class SseRewriter:
|
||||
item = {"type": "custom_tool_call", "id": item.get("id"), "call_id": item.get("call_id"),
|
||||
"name": item.get("name"), "input": inp, "status": "completed"}
|
||||
fix_tool_ids({"input": [item]})
|
||||
out.append(item)
|
||||
out.append(self._restore_tool_name(item))
|
||||
d["response"]["output"] = out
|
||||
fix_tool_ids({"input": out})
|
||||
self._restore_tool_name(d.get("item"))
|
||||
return d
|
||||
def feed(self, chunk):
|
||||
self.buf += chunk
|
||||
@@ -980,13 +1035,13 @@ class H(http.server.BaseHTTPRequestHandler):
|
||||
if jev_denetim.muaf_mi(hedef):
|
||||
return self._stream(resp, resp.status, tag, rewriter, izle, basliksiz=True)
|
||||
if gecmedi:
|
||||
# Son katman ne dondurduyse o gider, hicbir sey eklenmez; dondurmediyse hicbir sey gitmez.
|
||||
if son_govde:
|
||||
w(son_govde)
|
||||
else:
|
||||
event = {"type": "response.failed", "response": {"status": "failed", "error": {
|
||||
"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")
|
||||
# Headers already declare SSE. A raw upstream JSON/HTML body
|
||||
# here leaves Codex waiting for a terminal event and retrying.
|
||||
event = {"type": "response.failed", "response": {
|
||||
"id": "resp_astrabridge_%d" % time.time_ns(),
|
||||
"object": "response", "status": "failed", "output": [], "error": {
|
||||
"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")
|
||||
else:
|
||||
for chunk in parcalar:
|
||||
if izle is not None:
|
||||
@@ -1232,7 +1287,7 @@ class H(http.server.BaseHTTPRequestHandler):
|
||||
for model in json.loads(cached["body"]).get("models", []):
|
||||
if model.get("slug") == slug and model.get("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):
|
||||
@@ -1623,11 +1678,12 @@ class H(http.server.BaseHTTPRequestHandler):
|
||||
log(f"{tag} {cikan} resim not ile degistirildi ({kazanc // 1024} KB)")
|
||||
rewriter = None
|
||||
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)
|
||||
if duzelen:
|
||||
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)
|
||||
if route_name == catalog.get('route', '9router') and target.startswith('ag/gemini-'):
|
||||
merged = coalesce_context(req)
|
||||
@@ -1695,7 +1751,7 @@ class H(http.server.BaseHTTPRequestHandler):
|
||||
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"),
|
||||
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}]")
|
||||
# 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);
|
||||
|
||||
@@ -221,7 +221,7 @@ def _yapraklar(dugum, derinlik=0):
|
||||
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.
|
||||
kimlik: gecit kimligi; tur: "combo" | "model"; yol: gecidin aciklamasindaki "saglayici/model" yolu;
|
||||
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 "")
|
||||
yol = (m.group(1) + "/" + yol[len("no-think/"):]) if m else yol
|
||||
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:
|
||||
sonuc = {**sonuc, "arac": False}
|
||||
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):
|
||||
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):
|
||||
req = {"input": [{"type": "function_call_output", "id": "fco_native",
|
||||
"call_id": "call_native", "output": "kept"}]}
|
||||
|
||||
@@ -335,7 +335,7 @@ class Akis(unittest.TestCase):
|
||||
self.assertNotIn(b"yapamam", self.h.wfile.getvalue())
|
||||
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):
|
||||
def read(self):
|
||||
return b'{"error": {"message": "katman 4 hatasi"}}'
|
||||
@@ -344,7 +344,14 @@ class Akis(unittest.TestCase):
|
||||
def ac(ad):
|
||||
raise Hata("HTTP 400")
|
||||
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):
|
||||
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"},
|
||||
], "tools": [{"type": "namespace", "name": "functions", "tools": [{"type": "custom", "name": "probe"}]}]}
|
||||
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)
|
||||
self.assertEqual(json.loads(req["input"][0]["arguments"]), {"input": "remember-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', () => {
|
||||
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}}')
|
||||
|
||||
expect(JSON.parse(serializeCodexLinkSettings(stored, true))).toEqual({
|
||||
enabled: true,
|
||||
mod: 'dogrudan',
|
||||
gelecek: { a: 1 },
|
||||
kapsam: 'hepsi'
|
||||
})
|
||||
@@ -308,3 +313,17 @@ describe('bridge configuration and command', () => {
|
||||
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')
|
||||
})
|
||||
})
|
||||
@@ -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'
|
||||
// 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.
|
||||
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_RUN_KEY = 'HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run'
|
||||
@@ -300,6 +300,7 @@ export function removeCodexLinkLine(source: string): CodexConfigEdit {
|
||||
|
||||
export interface CodexLinkSettings {
|
||||
enabled: boolean
|
||||
mod: 'dogrudan' | 'aiturk'
|
||||
/** Every field of the stored object, so a rewrite keeps what it did not set. */
|
||||
stored: Record<string, unknown>
|
||||
}
|
||||
@@ -307,26 +308,34 @@ export interface CodexLinkSettings {
|
||||
/** Missing, unreadable or malformed means ON: the feature is on by default. */
|
||||
export function parseCodexLinkSettings(raw: null | string | undefined): CodexLinkSettings {
|
||||
if (typeof raw !== 'string') {
|
||||
return { enabled: true, stored: {} }
|
||||
return { enabled: true, mod: 'dogrudan', stored: {} }
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(raw)
|
||||
|
||||
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 {
|
||||
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'
|
||||
|
||||
return `${JSON.stringify({ ...stored, enabled, kapsam }, null, 2)}\n`
|
||||
return `${JSON.stringify({ ...stored, enabled, mod, kapsam }, null, 2)}\n`
|
||||
}
|
||||
|
||||
// ── Bridge configuration ────────────────────────────────────────────────────
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
* 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'
|
||||
|
||||
/** Stable codes; the renderer owns the wording. Detail goes to the desktop log. */
|
||||
@@ -18,6 +20,7 @@ export type CodexLinkError =
|
||||
|
||||
export interface CodexLinkStatus {
|
||||
enabled: boolean
|
||||
mod: CodexLinkMode
|
||||
/** Codex's `config.toml` exists on this machine. */
|
||||
codexFound: boolean
|
||||
/** The IDE's own Python environment exists (first-run setup finished). */
|
||||
@@ -34,6 +37,7 @@ export interface CodexLinkStatus {
|
||||
|
||||
export interface CodexLinkPatch {
|
||||
enabled?: boolean
|
||||
mod?: CodexLinkMode
|
||||
/** "Sync now": re-run the apply without changing the preference. */
|
||||
sync?: boolean
|
||||
}
|
||||
@@ -52,6 +52,7 @@ function createWorld(patch: Partial<World> & { files?: Map<string, string> } = {
|
||||
events: [],
|
||||
files: new Map([
|
||||
[PATHS.codexConfig, USER_CONFIG],
|
||||
[PATHS.settingsFile, JSON.stringify({ enabled: true, mod: 'aiturk' })],
|
||||
[PATHS.bridgeScript, '# bridge'],
|
||||
[PATHS.pythonw, ''],
|
||||
[PATHS.python, '']
|
||||
@@ -192,6 +193,7 @@ describe('applyCodexLink', () => {
|
||||
|
||||
expect(status).toEqual({
|
||||
enabled: true,
|
||||
mod: 'aiturk',
|
||||
codexFound: true,
|
||||
venvReady: true,
|
||||
foreign: false,
|
||||
@@ -328,7 +330,11 @@ describe('applyCodexLink', () => {
|
||||
'autostart clear',
|
||||
'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.
|
||||
expect(world.files.get(PATHS.codexConfig)).toBe(USER_CONFIG)
|
||||
expect(world.autostart).toBeNull()
|
||||
@@ -344,7 +350,8 @@ describe('applyCodexLink', () => {
|
||||
world.configWriteFails = true
|
||||
|
||||
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.autostart).toBe(autostart)
|
||||
@@ -358,7 +365,7 @@ describe('applyCodexLink', () => {
|
||||
|
||||
it('stays off across applies and comes back when turned on', async () => {
|
||||
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(spawns(world)).toEqual([])
|
||||
@@ -367,6 +374,7 @@ describe('applyCodexLink', () => {
|
||||
expect(await link.apply({ enabled: true })).toMatchObject({ enabled: true, bridgeRunning: true })
|
||||
expect(JSON.parse(world.files.get(PATHS.settingsFile) ?? '')).toEqual({
|
||||
enabled: true,
|
||||
mod: 'aiturk',
|
||||
kapsam: 'ide',
|
||||
not: 'ours'
|
||||
})
|
||||
@@ -424,3 +432,44 @@ describe('getStatus', () => {
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -186,6 +186,7 @@ export function createCodexLink(deps: CodexLinkDeps): CodexLink {
|
||||
bridge: BridgeProbe
|
||||
): CodexLinkStatus => ({
|
||||
enabled,
|
||||
mod: readSettings().mod,
|
||||
codexFound: config.found,
|
||||
venvReady: interpreter() !== null,
|
||||
foreign: config.owner === 'foreign',
|
||||
@@ -329,7 +330,8 @@ export function createCodexLink(deps: CodexLinkDeps): CodexLink {
|
||||
return bridge
|
||||
}
|
||||
|
||||
const edit = addCodexLinkLine(config.text ?? '')
|
||||
const edit =
|
||||
readSettings().mod === 'aiturk' ? addCodexLinkLine(config.text ?? '') : removeCodexLinkLine(config.text ?? '')
|
||||
|
||||
if (edit.changed) {
|
||||
try {
|
||||
@@ -364,9 +366,16 @@ export function createCodexLink(deps: CodexLinkDeps): CodexLink {
|
||||
const run = async (patch?: CodexLinkPatch): Promise<CodexLinkStatus> => {
|
||||
lastError = null
|
||||
|
||||
if (typeof patch?.enabled === 'boolean') {
|
||||
if (typeof patch?.enabled === 'boolean' || patch?.mod === 'aiturk' || patch?.mod === 'dogrudan') {
|
||||
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) {
|
||||
// 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.
|
||||
|
||||
@@ -17318,6 +17318,7 @@ ipcMain.handle('aiturk:codex-link:set', async (event, patch) => {
|
||||
|
||||
return codexLink.apply({
|
||||
enabled: typeof patch?.enabled === 'boolean' ? patch.enabled : undefined,
|
||||
mod: patch?.mod === 'aiturk' || patch?.mod === 'dogrudan' ? patch.mod : undefined,
|
||||
sync: patch?.sync === true
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "aiturk-ide",
|
||||
"productName": "AITURK IDE",
|
||||
"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.",
|
||||
"author": "AITURK / TurkServis",
|
||||
"repository": {
|
||||
|
||||
@@ -12,6 +12,7 @@ vi.mock('@/store/notifications', () => ({ notifyError }))
|
||||
|
||||
const RUNNING: CodexLinkStatus = {
|
||||
enabled: true,
|
||||
mod: 'aiturk',
|
||||
codexFound: true,
|
||||
venvReady: true,
|
||||
foreign: false,
|
||||
@@ -198,3 +199,18 @@ describe('Codex Link settings', () => {
|
||||
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') {
|
||||
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' })
|
||||
}
|
||||
|
||||
@@ -170,6 +170,27 @@ export function CodexLinkSettings() {
|
||||
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 />}
|
||||
|
||||
{loadFailed && !status && (
|
||||
|
||||
@@ -6,6 +6,10 @@ import { useI18n } from './context'
|
||||
// AITURK-distribution strings (see ./aiturk): Turkish for `tr`, English for
|
||||
// every other locale.
|
||||
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',
|
||||
title: 'Codex Link',
|
||||
description:
|
||||
@@ -39,6 +43,10 @@ const 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',
|
||||
title: 'Codex Bağlama',
|
||||
description:
|
||||
@@ -71,10 +79,45 @@ const tr: typeof en = {
|
||||
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 function useCodexLinkCopy(): CodexLinkCopy {
|
||||
const { locale } = useI18n()
|
||||
|
||||
return locale === 'tr' ? tr : en
|
||||
if (locale === 'tr') return tr
|
||||
if (locale === 'en') return en
|
||||
return { ...en, ...modeTranslations[locale] }
|
||||
}
|
||||
Generated
+1
-1
@@ -65,7 +65,7 @@
|
||||
},
|
||||
"apps/desktop": {
|
||||
"name": "aiturk-ide",
|
||||
"version": "1.0.0-beta.32",
|
||||
"version": "1.0.0-beta.38",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@assistant-ui/core": "0.2.23",
|
||||
|
||||
@@ -2550,6 +2550,39 @@ function Install-Repository {
|
||||
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 {
|
||||
if ($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."
|
||||
}
|
||||
|
||||
# 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)..."
|
||||
|
||||
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
|
||||
|
||||
from tools.file_operations import ShellFileOperations
|
||||
from tools.environments.local import LocalEnvironment
|
||||
|
||||
# Big enough to prove containment, small enough to keep the test fast.
|
||||
GIANT = 5 * 1024 * 1024 # 5MB single line
|
||||
@@ -27,22 +28,20 @@ GIANT = 5 * 1024 * 1024 # 5MB single line
|
||||
STDOUT_CEILING = 1 * 1024 * 1024
|
||||
|
||||
|
||||
class RecordingEnv:
|
||||
class RecordingEnv(LocalEnvironment):
|
||||
"""Local bash executor that records the largest stdout it returned."""
|
||||
|
||||
def __init__(self, cwd):
|
||||
self.cwd = cwd
|
||||
super().__init__(cwd=cwd)
|
||||
self.max_stdout = 0
|
||||
|
||||
def execute(self, command, timeout=60, **kwargs):
|
||||
proc = subprocess.run(
|
||||
["bash", "-c", command],
|
||||
capture_output=True, text=True, errors="replace",
|
||||
timeout=timeout + 30,
|
||||
)
|
||||
out = proc.stdout + (proc.stderr or "")
|
||||
# Use the application's shell selection/path handling. On Windows a
|
||||
# bare 'bash' may select WSL, whose filesystem is not the native one.
|
||||
result = super().execute(command, timeout=timeout + 30, **kwargs)
|
||||
out = result.get("output", "")
|
||||
self.max_stdout = max(self.max_stdout, len(out))
|
||||
return {"output": out, "returncode": proc.returncode}
|
||||
return result
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
@@ -58,6 +57,8 @@ def _ops(giant_dir, engine):
|
||||
env = RecordingEnv(str(giant_dir))
|
||||
ops = ShellFileOperations(env)
|
||||
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
|
||||
|
||||
|
||||
@@ -96,5 +97,5 @@ def test_line_cap_skipped_for_path_and_count_modes(giant_dir, engine, output_mod
|
||||
if output_mode == "files_only":
|
||||
assert {os.path.basename(f) for f in result.files} == {"trace.json", "small.py"}
|
||||
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
|
||||
@@ -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
@@ -50,7 +50,8 @@ from agent.file_safety import (
|
||||
get_write_denied_error,
|
||||
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
|
||||
|
||||
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()
|
||||
_ACTIVE_FILENAME_SEARCH_ROOTS: set[tuple[str, str, str]] = set()
|
||||
_FILENAME_SEARCH_WAIT_SECONDS = 0.05
|
||||
@@ -3432,7 +3435,31 @@ class ShellFileOperations(FileOperations):
|
||||
def search(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") -> 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.
|
||||
|
||||
@@ -3460,6 +3487,9 @@ class ShellFileOperations(FileOperations):
|
||||
|
||||
# Expand ~ and other shell paths
|
||||
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
|
||||
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.
|
||||
multi = self._try_multi_path_search(
|
||||
pattern, path, target, file_glob, limit, offset, output_mode, context,
|
||||
order,
|
||||
order, allow_broad,
|
||||
)
|
||||
if multi is not None:
|
||||
return multi
|
||||
@@ -3518,6 +3548,8 @@ class ShellFileOperations(FileOperations):
|
||||
f"an unattended privacy prompt: {skipped}. Search a protected "
|
||||
"folder directly when access is intentional."
|
||||
)
|
||||
if scope_notice:
|
||||
result.warning = " ".join(filter(None, [scope_notice, result.warning]))
|
||||
return result
|
||||
|
||||
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,
|
||||
file_glob: Optional[str], limit: int, offset: 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.
|
||||
|
||||
Production trajectories show models passing "dir1 dir2 dir3" (or
|
||||
@@ -3604,9 +3636,14 @@ class ShellFileOperations(FileOperations):
|
||||
parts = path.split()
|
||||
if len(parts) < 2:
|
||||
return None
|
||||
existing, missing = [], []
|
||||
existing, missing, scope_notices = [], [], []
|
||||
for p in parts:
|
||||
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(
|
||||
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])
|
||||
if len(missing) > 3:
|
||||
note += f" (+{len(missing) - 3} more)"
|
||||
warning_parts = [note]
|
||||
warning_parts = [note, *scope_notices]
|
||||
if not merged.error:
|
||||
protected_paths = [
|
||||
absolute
|
||||
@@ -3704,7 +3741,7 @@ class ShellFileOperations(FileOperations):
|
||||
|
||||
glob_expr = f" --glob {self._escape_shell_arg(file_glob)}" if file_glob else ""
|
||||
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"2>/dev/null | head -50",
|
||||
timeout=30,
|
||||
@@ -3723,7 +3760,7 @@ class ShellFileOperations(FileOperations):
|
||||
# files remain diagnosable, but prune heavyweight trees before rg can
|
||||
# recurse into them.
|
||||
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._escape_shell_arg(pattern)} {self._escape_native_tool_arg(path)} "
|
||||
f"2>/dev/null | head -50",
|
||||
@@ -3738,7 +3775,7 @@ class ShellFileOperations(FileOperations):
|
||||
)
|
||||
if re.search(r"[.\[\](){}?*+^$\\|]", pattern):
|
||||
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"2>/dev/null | head -50",
|
||||
timeout=30,
|
||||
@@ -3813,7 +3850,7 @@ class ShellFileOperations(FileOperations):
|
||||
))
|
||||
try:
|
||||
return self._search_files_rg(
|
||||
search_pattern, path, limit, offset, order,
|
||||
pattern, path, limit, offset, order,
|
||||
rg_executable=rg_executable,
|
||||
)
|
||||
finally:
|
||||
@@ -3848,6 +3885,9 @@ class ShellFileOperations(FileOperations):
|
||||
hidden_prune = (
|
||||
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 = [
|
||||
absolute
|
||||
for _root, _relative, absolute
|
||||
@@ -3986,7 +4026,7 @@ class ShellFileOperations(FileOperations):
|
||||
f"cd {self._escape_shell_arg(scoped_common)} && " if scoped_common else ""
|
||||
)
|
||||
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}"
|
||||
)
|
||||
result = self._exec(cmd, timeout=60)
|
||||
@@ -4062,7 +4102,7 @@ class ShellFileOperations(FileOperations):
|
||||
rg_executable = rg_executable or self._resolve_command("rg")
|
||||
if not rg_executable:
|
||||
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
|
||||
# match inside a serialized dump (multi-MB single-line JSON/minified
|
||||
@@ -4100,6 +4140,8 @@ class ShellFileOperations(FileOperations):
|
||||
if file_glob:
|
||||
cmd_parts.extend(["--glob", self._escape_shell_arg(file_glob)])
|
||||
|
||||
cmd_parts.append(self._search_prune_glob_args())
|
||||
|
||||
# Output mode handling
|
||||
if output_mode == "files_only":
|
||||
cmd_parts.append("-l") # Files only
|
||||
@@ -4230,6 +4272,8 @@ class ShellFileOperations(FileOperations):
|
||||
# Exclude hidden directories (matching ripgrep's default behavior).
|
||||
# This prevents searching inside .hub/index-cache/, .git/, etc.
|
||||
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
|
||||
# exclude-dir globs against BASENAMES anywhere in the tree, so
|
||||
|
||||
+6
-2
@@ -2653,7 +2653,7 @@ def search_tool(pattern: str, target: str = "content", path: str = ".",
|
||||
file_glob: str = None, limit: int = 50, offset: int = 0,
|
||||
output_mode: str = "content", context: int = 0,
|
||||
order: str = "discovery",
|
||||
task_id: str = "default") -> str:
|
||||
task_id: str = "default", allow_broad: bool = False) -> str:
|
||||
"""Search for content or files."""
|
||||
try:
|
||||
offset, limit = normalize_search_pagination(offset, limit)
|
||||
@@ -2670,6 +2670,7 @@ def search_tool(pattern: str, target: str = "content", path: str = ".",
|
||||
limit,
|
||||
offset,
|
||||
order,
|
||||
allow_broad,
|
||||
)
|
||||
with _read_tracker_lock:
|
||||
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,
|
||||
limit=limit, offset=offset, output_mode=output_mode, context=context,
|
||||
order=order,
|
||||
**({"allow_broad": True} if allow_broad else {}),
|
||||
)
|
||||
omitted = _filter_read_blocked_search_results(result, task_id)
|
||||
if hasattr(result, 'matches'):
|
||||
@@ -2912,6 +2914,7 @@ SEARCH_FILES_SCHEMA = {
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"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"},
|
||||
"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": "."},
|
||||
@@ -2979,7 +2982,8 @@ def _handle_search_files(args, **kw):
|
||||
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),
|
||||
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():
|
||||
|
||||
Reference in new issue
Block a user