From aafeb4171f483c930bcf8e1cb51bc5307583abf3 Mon Sep 17 00:00:00 2001
From: Ali Khokhar <20476625+Alishahryar1@users.noreply.github.com>
Date: Sun, 27 Sep 2026 13:57:46 -0700
Subject: [PATCH 1/5] patch: Separate code-session history reads from live
state (#1924)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## Why
Opening a saved code session loaded its entire transcript into an
execution owner before returning a small history page. Completed
transcript content then accumulated in both application state and the
Codex protocol adapter.
## How
Read history directly from SQLite in a consistent snapshot, selecting
the requested page plus live questions, reviews, and current-turn items.
Use a session lock shared with execution and a monotonic process-wide
update version so history reads and live events remain ordered.
Keep execution state limited to the latest run and live or unsaved
records. Look up historical identities and answer receipts in SQLite
when needed, preserve the transcript sequence high-water mark, and
reconcile native history temporarily during resume. Add an index for
active prompts.
Scope protocol accumulators by thread, turn, and item, release them at
turn completion or connection teardown, and project historical items
without caching them. Idle native connections continue delivering late
subagent reviews, questions, notices, and context updates. Full native
history may still be materialized temporarily during recovery.
Confidence Score: 4/5
The confirmed performance concern does not block merging.
Findings
1. **Active history reads grow unbounded** ▶Summary
The PR moves paginated code-session history reads into SQLite while
retaining execution identities for live updates and recovery. People
refreshing a long-running turn may see slower detail reads because each
request fetches and decodes the entire active run.
Reviews (1) · Last reviewed commit: ["Separate code-session history
reads
from..."](https://github.com/alishahryar1/free-claude-code/commit/145ad4679ea9bd0578caffae7bd63bf7290f172d)
---
.../application/code_sessions/models.py | 21 ++
.../application/code_sessions/ports.py | 33 +++
.../application/code_sessions/service.py | 260 +++++++++++-------
.../application/code_sessions/state.py | 60 ++--
.../runtime/code_sessions_sqlite.py | 239 ++++++++++++++--
.../runtime/codex_app_server.py | 2 +
.../runtime/codex_protocol.py | 36 ++-
.../runtime/sqlite_migrations/__init__.py | 2 +
.../v0005_active_code_prompts.py | 10 +
tests/application/test_code_sessions.py | 206 +++++++++++++-
tests/application/test_code_sessions_state.py | 14 +-
tests/runtime/test_code_sessions_sqlite.py | 110 +++++++-
tests/runtime/test_codex_protocol.py | 139 ++++++++++
tests/runtime/test_sqlite_database.py | 17 +-
14 files changed, 985 insertions(+), 164 deletions(-)
create mode 100644 src/free_claude_code/runtime/sqlite_migrations/v0005_active_code_prompts.py
diff --git a/src/free_claude_code/application/code_sessions/models.py b/src/free_claude_code/application/code_sessions/models.py
index 5e2bb885d4..c54ec7413d 100644
--- a/src/free_claude_code/application/code_sessions/models.py
+++ b/src/free_claude_code/application/code_sessions/models.py
@@ -115,6 +115,27 @@ class CodeItemPage:
next_before: tuple[int, int] | None = None
+@dataclass(frozen=True, slots=True)
+class CodeHistory:
+ session: CodeSession
+ run: CodeRun | None
+ items: tuple[CodeItem, ...]
+ prompts: tuple[CodePrompt, ...]
+ runs: tuple[CodeRun, ...]
+ active_prompt_ids: tuple[str, ...]
+ next_before: tuple[int, int] | None
+
+
+@dataclass(frozen=True, slots=True)
+class CodeExecutionSeed:
+ session: CodeSession
+ run: CodeRun | None
+ sequence: int
+ items: tuple[CodeItem, ...]
+ prompts: tuple[CodePrompt, ...]
+ runs: tuple[CodeRun, ...]
+
+
class CodeModel(Record):
id: str
display_name: str
diff --git a/src/free_claude_code/application/code_sessions/ports.py b/src/free_claude_code/application/code_sessions/ports.py
index f65ad9a8f8..2bf7e0238c 100644
--- a/src/free_claude_code/application/code_sessions/ports.py
+++ b/src/free_claude_code/application/code_sessions/ports.py
@@ -9,6 +9,8 @@
from .models import (
CodeCatalog,
CodeDetail,
+ CodeExecutionSeed,
+ CodeHistory,
CodeItem,
CodeItemPage,
CodeMode,
@@ -86,6 +88,37 @@ async def open_history(self, cwd: str, sink: EventSink) -> HarnessConnection: ..
class CodeStore(Protocol):
+ async def execution_seed(self, session_id: str) -> CodeExecutionSeed: ...
+
+ async def read_history(
+ self,
+ session_id: str,
+ before: tuple[int, int] | None,
+ include_item_ids: Sequence[str],
+ ) -> CodeHistory: ...
+
+ async def get_native_item(
+ self,
+ session_id: str,
+ turn_id: str,
+ item_id: str,
+ ) -> CodeItem | None: ...
+
+ async def run_items(self, session_id: str, run_id: str) -> tuple[CodeItem, ...]: ...
+
+ async def get_prompt(
+ self, session_id: str, prompt_id: str
+ ) -> CodePrompt | None: ...
+
+ async def has_prompt_request(
+ self,
+ session_id: str,
+ generation: str,
+ request_id: str | int,
+ ) -> bool: ...
+
+ async def has_native_turn(self, session_id: str, turn_id: str) -> bool: ...
+
async def start(self) -> None: ...
async def close(self) -> None: ...
diff --git a/src/free_claude_code/application/code_sessions/service.py b/src/free_claude_code/application/code_sessions/service.py
index dc756a75f8..b8ec855df6 100644
--- a/src/free_claude_code/application/code_sessions/service.py
+++ b/src/free_claude_code/application/code_sessions/service.py
@@ -2,7 +2,8 @@
import asyncio
import uuid
-from collections.abc import Coroutine, Sequence
+from collections.abc import AsyncIterator, Callable, Coroutine, Sequence
+from contextlib import asynccontextmanager
from dataclasses import dataclass, field
from pathlib import Path
@@ -37,14 +38,35 @@
from .state import SessionProgress, SessionState
+@dataclass(slots=True)
+class _SessionGate:
+ on_idle: Callable[[], None]
+ lock: asyncio.Lock = field(default_factory=asyncio.Lock)
+ users: int = 0
+
+ async def __aenter__(self) -> _SessionGate:
+ self.users += 1
+ try:
+ await self.lock.acquire()
+ except BaseException:
+ self.users -= 1
+ self.on_idle()
+ raise
+ return self
+
+ async def __aexit__(self, *_: object) -> None:
+ self.lock.release()
+ self.users -= 1
+ self.on_idle()
+
+
@dataclass(slots=True)
class _SessionRuntime:
state: SessionState
- lock: asyncio.Lock = field(default_factory=asyncio.Lock)
+ lock: _SessionGate
finished: asyncio.Event = field(default_factory=asyncio.Event)
connection: HarnessConnection | None = None
generation: str | None = None
- version: int = 0
job: asyncio.Task[None] | None = None
flush_task: asyncio.Task[None] | None = None
interrupt_task: asyncio.Task[None] | None = None
@@ -60,7 +82,8 @@ def __init__(self, store: CodeStore, harness: HarnessFactory) -> None:
self._store = store
self._harness = harness
self._owners: dict[str, _SessionRuntime] = {}
- self._load_lock = asyncio.Lock()
+ self._gates: dict[str, _SessionGate] = {}
+ self._version = 0
self._events = EventPublisher()
self.epoch = str(uuid.uuid4())
self._commands: set[asyncio.Task] = set()
@@ -168,7 +191,7 @@ async def _close(self) -> None:
await asyncio.wait(jobs, timeout=5)
await asyncio.gather(*(self._close_connection(owner) for owner in owners))
for owner in owners:
- async with owner.lock:
+ async with self._locked(owner):
if owner.state.busy and not owner.storage_failed:
try:
await self._finish_locked(
@@ -213,23 +236,44 @@ def _require_available(self) -> None:
if not self._accepting:
raise CodeUnavailableError(self._message or "Code sessions is unavailable.")
+ def _gate(self, session_id: str) -> _SessionGate:
+ if session_id not in self._gates:
+ self._gates[session_id] = _SessionGate(
+ lambda: self._discard_gate(session_id)
+ )
+ return self._gates[session_id]
+
+ def _discard_gate(self, session_id: str) -> None:
+ gate = self._gates.get(session_id)
+ if gate is not None and not gate.users and session_id not in self._owners:
+ del self._gates[session_id]
+
+ @asynccontextmanager
+ async def _locked(self, owner: _SessionRuntime) -> AsyncIterator[None]:
+ async with owner.lock:
+ try:
+ yield
+ finally:
+ if not owner.storage_failed:
+ owner.state.prune()
+
async def _owner(self, session_id: str) -> _SessionRuntime:
await self._wait_for_store()
_validate_id(session_id)
- async with self._load_lock:
+ async with self._gate(session_id) as gate:
owner = self._owners.get(session_id)
if owner is None:
- session = await self._store.get_session(session_id)
- items = await self._store.items(session_id, None, None)
- runs = await self._store.runs(session_id)
+ seed = await self._store.execution_seed(session_id)
owner = _SessionRuntime(
SessionState(
- session,
- await self._store.latest_run(session_id),
- items,
- await self._store.prompts(session_id),
- runs,
- )
+ seed.session,
+ seed.run,
+ seed.items,
+ seed.prompts,
+ seed.runs,
+ sequence=seed.sequence,
+ ),
+ gate,
)
if not owner.state.busy:
owner.finished.set()
@@ -259,7 +303,7 @@ async def _create(self, session_id: str, cwd: str) -> CodeSession:
)
)
owner = await self._owner(session.id)
- async with owner.lock:
+ async with self._locked(owner):
self._publish(owner, "session.updated")
return session
@@ -301,68 +345,33 @@ async def get_detail(
before: tuple[int, int] | None = None,
include_item_ids: Sequence[str] = (),
) -> CodeDetail:
- owner = await self._owner(session_id)
- async with owner.lock:
- self._check_owner(owner)
- await self._flush_locked(owner)
- page_before = before
- if owner.state.busy and owner.state.run:
- active_start = (owner.state.run.ordinal, 0)
- page_before = min(before, active_start) if before else active_start
- page = await self._store.item_page(session_id, page_before, 50)
- included_ids = {
- *owner.state.active_review_ids(owner.generation),
- *include_item_ids,
- }
- extra = [
- item
- for item in owner.state.items.values()
- if (
- owner.state.busy
- and owner.state.run
- and item.run_id == owner.state.run.id
- )
- or item.id in included_ids
- or (
- item.kind == "prompt"
- and owner.state.prompts[item.id].status in {"pending", "answering"}
- )
- ]
- selected = sorted(
- {item.id: item for item in [*page.items, *extra]}.values(),
- key=lambda item: (owner.state.runs[item.run_id].ordinal, item.sequence),
- )
- prompt_ids = {item.id for item in selected if item.kind == "prompt"}
- prompts = tuple(
- prompt
- for prompt in owner.state.prompts.values()
- if prompt.id in prompt_ids
+ await self._wait_for_store()
+ _validate_id(session_id)
+ async with self._gate(session_id):
+ owner = self._owners.get(session_id)
+ active_reviews: tuple[str, ...] = ()
+ if owner is not None:
+ self._check_owner(owner)
+ await self._flush_locked(owner)
+ active_reviews = owner.state.active_review_ids(owner.generation)
+ owner.state.prune()
+ snapshot = await self._store.read_history(
+ session_id,
+ before,
+ (*include_item_ids, *active_reviews),
)
return CodeDetail(
- owner.state.session,
- owner.state.run,
- tuple(selected),
- prompts,
+ snapshot.session,
+ snapshot.run,
+ snapshot.items,
+ snapshot.prompts,
self.epoch,
- owner.version,
+ self._version,
self.cursor,
- page.next_before,
- tuple(
- {
- run.id: run
- for run in (
- *page.runs,
- *(owner.state.runs[item.run_id] for item in extra),
- *((owner.state.run,) if owner.state.run else ()),
- )
- }.values()
- ),
- tuple(
- prompt.id
- for prompt in owner.state.prompts.values()
- if prompt.status in {"pending", "answering"}
- ),
- owner.state.active_review_ids(owner.generation),
+ snapshot.next_before,
+ snapshot.runs,
+ snapshot.active_prompt_ids,
+ active_reviews,
)
async def update_settings(
@@ -375,7 +384,7 @@ async def _update_settings(
) -> CodeSession:
SessionState.validate_settings_fields(changes)
owner = await self._owner(session_id)
- async with owner.lock:
+ async with self._locked(owner):
self._editable(owner, revision)
updates = owner.state.settings_updates(changes)
catalog = (
@@ -417,7 +426,7 @@ async def _send(
"Enter a message of at most 1,000,000 characters."
)
owner = await self._owner(session_id)
- async with owner.lock:
+ async with self._locked(owner):
previous = await self._store.get_run(session_id, operation_id)
if previous:
owner.state.check_receipt(previous, text)
@@ -432,7 +441,7 @@ async def _send(
selection = await self._harness.prepare(
selected.model, selected.reasoning_effort, selected.mode
)
- async with owner.lock:
+ async with self._locked(owner):
previous = await self._store.get_run(session_id, operation_id)
if previous:
owner.state.check_receipt(previous, text)
@@ -465,7 +474,7 @@ async def _stop(
) -> CodeRun:
_validate_id(operation_id)
owner = await self._owner(session_id)
- async with owner.lock:
+ async with self._locked(owner):
receipt = await self._store.get_run(session_id, operation_id)
if receipt is None or receipt.session_id != session_id:
raise CodeNotFoundError("Code turn not found.")
@@ -493,9 +502,13 @@ async def _answer(
) -> CodePrompt:
_validate_id(response_id)
owner = await self._owner(session_id)
- async with owner.lock:
+ async with self._locked(owner):
self._require_available()
connection = owner.connection
+ if prompt_id not in owner.state.prompts:
+ saved = await self._store.get_prompt(session_id, prompt_id)
+ if saved is not None:
+ owner.state.accept_prompt(saved)
prompt = owner.state.answerable_prompt(
prompt_id,
response_id,
@@ -523,7 +536,7 @@ async def _delete(self, session_id: str, revision: int) -> CodeSession | None:
if await self._store.is_deleted(session_id):
return None
owner = await self._owner(session_id)
- async with owner.lock:
+ async with self._locked(owner):
self._check_owner(owner)
if owner.state.session.status != "ready":
if owner.state.session.status == "delete_uncertain" and (
@@ -563,7 +576,7 @@ async def _work(
owner.generation = connection.generation
thread_id = owner.state.session.native_thread_id
if thread_id is None:
- async with owner.lock:
+ async with self._locked(owner):
if owner.state.run and owner.state.run.stop_requested:
await self._finish_locked(owner, "interrupted")
return
@@ -577,7 +590,7 @@ async def _work(
native = await connection.create_thread()
else:
native = None
- async with owner.lock:
+ async with self._locked(owner):
if native is not None:
await self._commit_progress(
owner,
@@ -587,6 +600,7 @@ async def _work(
)
owner.loaded_thread_id = native.id
await self._recover_locked(owner, native)
+ native = None
run = owner.state.active_run(run_id)
if run is None:
return
@@ -601,7 +615,7 @@ async def _work(
)
assert run is not None and defaults is not None
turn_id = await connection.start_turn(run.text, selection, run.id, defaults)
- async with owner.lock:
+ async with self._locked(owner):
if owner.state.active_run(run_id) is None:
return
await self._commit_progress(owner, owner.state.bind_turn(turn_id))
@@ -625,7 +639,12 @@ async def _work(
async def _recover_locked(
self, owner: _SessionRuntime, native: NativeThread
) -> None:
- for turn, run in owner.state.match_history(native):
+ saved_runs = await self._store.runs(owner.state.session.id)
+ for turn, run in owner.state.match_history(native, saved_runs):
+ owner.state.remember_items(
+ await self._store.run_items(owner.state.session.id, run.id),
+ (run,),
+ )
for item in turn.items:
self._update_item(owner, item, run, historical=True)
items = owner.state.pending_items
@@ -639,6 +658,7 @@ async def _recover_locked(
runs=[run.model_dump(mode="json")],
items=[item.model_dump(mode="json") for item in items],
)
+ owner.state.prune()
def _schedule_interrupt(self, owner: _SessionRuntime) -> None:
run, connection = owner.state.run, owner.connection
@@ -671,7 +691,7 @@ async def _interrupt(
return
except Exception:
pass
- async with owner.lock:
+ async with self._locked(owner):
if (
owner.connection is not connection
or owner.state.run is None
@@ -681,7 +701,7 @@ async def _interrupt(
return
self._detach_connection_locked(owner)
await connection.close()
- async with owner.lock:
+ async with self._locked(owner):
if owner.state.run and owner.state.run.id == run.id and owner.state.busy:
await self._finish_locked(
owner,
@@ -696,14 +716,14 @@ async def _deliver_answer(
if connection is None:
return
try:
- async with owner.lock:
+ async with self._locked(owner):
if (
owner.generation != prompt.generation
or owner.state.pending_answer(prompt.id, prompt.generation) is None
):
return
await connection.respond(prompt.request_id, response)
- async with owner.lock:
+ async with self._locked(owner):
if owner.generation == prompt.generation:
resolved = owner.state.resolve_answer(prompt.id, prompt.generation)
if resolved is not None:
@@ -713,7 +733,7 @@ async def _deliver_answer(
)
self._publish_prompt(owner, resolved)
except CodeConflictError:
- async with owner.lock:
+ async with self._locked(owner):
expired = owner.state.expire_answer(prompt.id)
if expired is not None:
await self._commit_progress(
@@ -733,7 +753,7 @@ async def _deliver_answer(
async def _event(self, owner: _SessionRuntime, event: HarnessEvent) -> None:
try:
- async with owner.lock:
+ async with self._locked(owner):
if (
owner.deleted
or event.generation != owner.generation
@@ -747,7 +767,17 @@ async def _event(self, owner: _SessionRuntime, event: HarnessEvent) -> None:
self._publish(owner, "session.updated")
return
run = owner.state.run
- matches = owner.state.matches_turn(event)
+ known_turn = False
+ if (
+ event.kind == "turn_started"
+ and event.turn_id is not None
+ and run is not None
+ and run.native_turn_id is None
+ ):
+ known_turn = await self._store.has_native_turn(
+ owner.state.session.id, event.turn_id
+ )
+ matches = owner.state.matches_turn(event, known_turn=known_turn)
if (
event.kind in {"turn_started", "turn_completed"}
and matches
@@ -770,13 +800,14 @@ async def _event(self, owner: _SessionRuntime, event: HarnessEvent) -> None:
and event.item.kind == "subagent_auto_review"
and run is not None
):
+ await self._remember_native_item(owner, event.item)
item, changed = owner.state.stage_review(
event.item, event.generation
)
if item is None:
return
if changed:
- owner.version += 1
+ self._version += 1
await self._flush_locked(owner)
elif (
event.kind == "item"
@@ -784,6 +815,7 @@ async def _event(self, owner: _SessionRuntime, event: HarnessEvent) -> None:
and matches
and run is not None
):
+ await self._remember_native_item(owner, event.item)
self._update_item(owner, event.item, run)
if event.item.complete or owner.state.dirty_characters >= 4096:
await self._flush_locked(owner)
@@ -793,7 +825,13 @@ async def _event(self, owner: _SessionRuntime, event: HarnessEvent) -> None:
request = event.prompt
if request.turn_id is not None and not matches:
return
- if owner.state.has_prompt(request, event.generation):
+ if owner.state.has_prompt(
+ request, event.generation
+ ) or await self._store.has_prompt_request(
+ owner.state.session.id,
+ event.generation,
+ request.request_id,
+ ):
return
await self._flush_locked(owner)
progress = owner.state.prepare_prompt(request, event.generation)
@@ -840,6 +878,21 @@ async def _event(self, owner: _SessionRuntime, event: HarnessEvent) -> None:
type(exc).__name__,
)
+ async def _remember_native_item(
+ self, owner: _SessionRuntime, update: ItemUpdate
+ ) -> None:
+ if owner.state.native_item(update) is not None:
+ return
+ saved = await self._store.get_native_item(
+ owner.state.session.id, update.turn_id, update.item_id
+ )
+ if saved is not None:
+ run = owner.state.runs.get(saved.run_id)
+ if run is None:
+ run = await self._store.get_run(owner.state.session.id, saved.run_id)
+ assert run is not None
+ owner.state.remember_items((saved,), (run,))
+
def _update_item(
self,
owner: _SessionRuntime,
@@ -850,12 +903,12 @@ def _update_item(
) -> None:
_, changed = owner.state.stage_item(update, run, historical=historical)
if changed:
- owner.version += 1
+ self._version += 1
async def _flush_later(self, owner: _SessionRuntime) -> None:
await asyncio.sleep(0.25)
try:
- async with owner.lock:
+ async with self._locked(owner):
if not owner.deleted and not owner.storage_failed:
await self._flush_locked(owner)
except Exception as exc:
@@ -948,11 +1001,11 @@ def _detach_connection_locked(
return connection
async def _close_connection(self, owner: _SessionRuntime) -> None:
- async with owner.lock:
+ async with self._locked(owner):
connection = self._detach_connection_locked(owner)
if connection is not None:
await connection.close()
- async with owner.lock:
+ async with self._locked(owner):
if not owner.deleted and not owner.storage_failed:
try:
if (
@@ -975,7 +1028,7 @@ async def _fail(
*,
interrupted: bool = False,
) -> None:
- async with owner.lock:
+ async with self._locked(owner):
if (
owner.state.run is None
or owner.state.run.id != run_id
@@ -984,7 +1037,7 @@ async def _fail(
return
try:
await self._close_connection(owner)
- async with owner.lock:
+ async with self._locked(owner):
if (
owner.state.run
and owner.state.run.id == run_id
@@ -1035,12 +1088,11 @@ async def _delete_native(
pass
native_complete = True
await self._close_connection(owner)
- async with owner.lock:
+ async with self._locked(owner):
await self._store.delete(owner.state.session.id)
owner.deleted = True
owner.state.discard_deleted_history()
self._publish(owner, "session.deleted")
- async with self._load_lock:
if self._owners.get(owner.state.session.id) is owner:
del self._owners[owner.state.session.id]
except Exception as exc:
@@ -1048,7 +1100,7 @@ async def _delete_native(
status = (
"ready" if isinstance(exc, CodeConflictError) else "delete_uncertain"
)
- async with owner.lock:
+ async with self._locked(owner):
progress = owner.state.deletion_failed(status, _error_message(exc))
try:
await self._commit_progress(owner, progress)
@@ -1080,12 +1132,12 @@ def _summary(self, owner: _SessionRuntime) -> JsonObject:
"session": owner.state.session.model_dump(mode="json"),
"run": owner.state.run.model_dump(mode="json") if owner.state.run else None,
"active_review_ids": list(owner.state.active_review_ids(owner.generation)),
- "version": owner.version,
+ "version": self._version,
"epoch": self.epoch,
}
def _publish(self, owner: _SessionRuntime, event: str, **data: JsonValue) -> None:
- owner.version += 1
+ self._version += 1
payload = self._summary(owner)
for key, value in data.items():
payload[key] = value
diff --git a/src/free_claude_code/application/code_sessions/state.py b/src/free_claude_code/application/code_sessions/state.py
index cdb353a529..117350bc0e 100644
--- a/src/free_claude_code/application/code_sessions/state.py
+++ b/src/free_claude_code/application/code_sessions/state.py
@@ -48,16 +48,17 @@ def __init__(
items: Sequence[CodeItem],
prompts: Sequence[CodePrompt],
runs: Sequence[CodeRun],
+ *,
+ sequence: int = 0,
) -> None:
self._session = session
self._run = run
self._items = {item.id: item for item in items}
self._prompts = {prompt.id: prompt for prompt in prompts}
self._runs = {run.id: run for run in runs}
- self._sequence = max((item.sequence for item in items), default=0)
- self._known_turns = {run.native_turn_id for run in runs if run.native_turn_id}
- if run and run.native_turn_id:
- self._known_turns.add(run.native_turn_id)
+ self._sequence = max(
+ sequence, max((item.sequence for item in items), default=0)
+ )
self._dirty: set[str] = set()
self._dirty_characters = 0
self._review_generations: dict[str, str] = {}
@@ -134,8 +135,6 @@ def apply_progress(self, progress: SessionProgress) -> None:
self._runs[run.id] = run
if self._run is not None and self._run.id == run.id:
self._run = run
- if run.native_turn_id:
- self._known_turns.add(run.native_turn_id)
for item in progress.items:
self._items[item.id] = item
self._sequence = max(self._sequence, item.sequence)
@@ -146,6 +145,34 @@ def acknowledge_flush(self) -> None:
self._dirty.clear()
self._dirty_characters = 0
+ def remember_items(
+ self, items: Sequence[CodeItem], runs: Sequence[CodeRun]
+ ) -> None:
+ """Load durable identities needed by one serialized operation."""
+ for item in items:
+ self._items.setdefault(item.id, item)
+ for run in runs:
+ self._runs.setdefault(run.id, run)
+
+ def prune(self) -> None:
+ """Release acknowledged history after its events have been published."""
+ self._prompts = {
+ key: prompt
+ for key, prompt in self._prompts.items()
+ if prompt.status in {"pending", "answering"}
+ }
+ pinned = self._dirty | self._prompts.keys() | self._review_generations.keys()
+ self._items = {
+ key: item
+ for key, item in self._items.items()
+ if key in pinned
+ or (self.busy and self._run and item.run_id == self._run.id)
+ }
+ run_ids = {item.run_id for item in self._items.values()}
+ if self._run is not None:
+ run_ids.add(self._run.id)
+ self._runs = {key: run for key, run in self._runs.items() if key in run_ids}
+
def discard_deleted_history(self) -> None:
self._items.clear()
self._prompts.clear()
@@ -323,7 +350,7 @@ def accepts_event(self, event: HarnessEvent) -> bool:
self._session.native_thread_id,
}
- def matches_turn(self, event: HarnessEvent) -> bool:
+ def matches_turn(self, event: HarnessEvent, *, known_turn: bool) -> bool:
run = self._run
return bool(
run is not None
@@ -335,7 +362,7 @@ def matches_turn(self, event: HarnessEvent) -> bool:
or (
run.native_turn_id is None
and event.kind == "turn_started"
- and event.turn_id not in self._known_turns
+ and not known_turn
)
)
)
@@ -348,8 +375,9 @@ def context_usage(self, used_tokens: int | None) -> SessionProgress | None:
)
def match_history(
- self, native: NativeThread
+ self, native: NativeThread, runs: Sequence[CodeRun]
) -> tuple[tuple[NativeTurn, CodeRun], ...]:
+ saved = {run.id: run for run in runs}
mapped: dict[str, CodeRun] = {}
for turn in native.turns:
identities = {
@@ -359,11 +387,11 @@ def match_history(
}
candidates = [
run
- for run in self._runs.values()
+ for run in saved.values()
if run.native_turn_id == turn.id or run.id in identities
]
if len(candidates) > 1 or any(
- identity not in self._runs for identity in identities
+ identity not in saved for identity in identities
):
raise CodeUnavailableError(
"Codex history contains conflicting turn identities. Saved history was retained."
@@ -382,7 +410,7 @@ def match_history(
unmatched = [turn for turn in native.turns if turn.id not in mapped]
pending = [
run
- for run in self._runs.values()
+ for run in saved.values()
if run.submission_started
and run.native_turn_id is None
and run.id not in {value.id for value in mapped.values()}
@@ -410,7 +438,7 @@ def match_history(
for turn in native.turns
)
- def _native_item(self, update: ItemUpdate) -> CodeItem | None:
+ def native_item(self, update: ItemUpdate) -> CodeItem | None:
return next(
(
item
@@ -424,9 +452,7 @@ def _native_item(self, update: ItemUpdate) -> CodeItem | None:
def stage_item(
self, update: ItemUpdate, run: CodeRun, *, historical: bool = False
) -> tuple[CodeItem, bool]:
- if update.kind != "subagent_auto_review":
- self._known_turns.add(update.turn_id)
- existing = self._native_item(update)
+ existing = self.native_item(update)
if existing is None and update.kind == "user":
if update.client_id is not None and update.client_id != run.id:
raise CodeUnavailableError(
@@ -475,7 +501,7 @@ def stage_review(
) -> tuple[CodeItem | None, bool]:
if self._run is None:
return None, False
- existing = self._native_item(update)
+ existing = self.native_item(update)
if existing is not None and existing.complete and not update.complete:
return None, False
target = self._runs[existing.run_id] if existing else self._run
diff --git a/src/free_claude_code/runtime/code_sessions_sqlite.py b/src/free_claude_code/runtime/code_sessions_sqlite.py
index b3d5227917..c9846f3279 100644
--- a/src/free_claude_code/runtime/code_sessions_sqlite.py
+++ b/src/free_claude_code/runtime/code_sessions_sqlite.py
@@ -7,7 +7,10 @@
from contextlib import closing
from free_claude_code.application.code_sessions.models import (
+ ACTIVE_RUN_STATUSES,
CodeConflictError,
+ CodeExecutionSeed,
+ CodeHistory,
CodeItem,
CodeItemPage,
CodeNotFoundError,
@@ -100,6 +103,80 @@ def _session(connection: sqlite3.Connection, session_id: str) -> CodeSession:
return _record(CodeSession, row)
+def _by_ids[T: Record](
+ connection: sqlite3.Connection,
+ model: type[T],
+ session_id: str,
+ ids: Sequence[str],
+) -> tuple[T, ...]:
+ tables: dict[type[Record], str] = {
+ CodeItem: "code_items",
+ CodeRun: "code_runs",
+ CodePrompt: "code_prompts",
+ }
+ table = tables[model]
+ records: list[T] = []
+ unique = tuple(dict.fromkeys(ids))
+ for start in range(0, len(unique), 500):
+ batch = unique[start : start + 500]
+ records.extend(
+ _record(model, row)
+ for row in connection.execute(
+ f"SELECT * FROM {table} WHERE session_id = ? AND id IN ({','.join('?' for _ in batch)})",
+ (session_id, *batch),
+ )
+ )
+ return tuple(records)
+
+
+def _latest_run(connection: sqlite3.Connection, session_id: str) -> CodeRun | None:
+ row = connection.execute(
+ "SELECT * FROM code_runs WHERE session_id = ? ORDER BY ordinal DESC LIMIT 1",
+ (session_id,),
+ ).fetchone()
+ return _record(CodeRun, row) if row else None
+
+
+def _active_prompts(
+ connection: sqlite3.Connection, session_id: str
+) -> tuple[CodePrompt, ...]:
+ return tuple(
+ _record(CodePrompt, row)
+ for row in connection.execute(
+ "SELECT * FROM code_prompts WHERE session_id = ? AND status IN ('pending', 'answering') ORDER BY id",
+ (session_id,),
+ )
+ )
+
+
+def _item_page(
+ connection: sqlite3.Connection,
+ session_id: str,
+ before: tuple[int, int] | None,
+ limit: int | None,
+) -> CodeItemPage:
+ parameters: list[object] = [session_id]
+ query = "SELECT i.*, r.ordinal AS run_ordinal FROM code_items i JOIN code_runs r ON (r.session_id, r.id) = (i.session_id, i.run_id) WHERE i.session_id = ?"
+ if before:
+ query += " AND (r.ordinal, i.sequence) < (?, ?)"
+ parameters.extend(before)
+ query += " ORDER BY r.ordinal DESC, i.sequence DESC"
+ if limit is not None:
+ query += " LIMIT ?"
+ parameters.append(limit + 1)
+ rows = connection.execute(query, parameters).fetchall()
+ selected = rows if limit is None else rows[:limit]
+ runs = _by_ids(connection, CodeRun, session_id, [row["run_id"] for row in selected])
+ last = (
+ selected[-1] if selected and limit is not None and len(rows) > limit else None
+ )
+ return CodeItemPage(
+ tuple(_record(CodeItem, row) for row in reversed(selected)),
+ tuple(sorted(runs, key=lambda run: run.ordinal)),
+ (last["run_ordinal"], last["sequence"]) if last else None,
+ )
+
+
def _idle(connection: sqlite3.Connection, session_id: str) -> None:
if (
connection.execute(
@@ -470,39 +547,151 @@ async def item_page(
) -> CodeItemPage:
def operation(connection: sqlite3.Connection) -> CodeItemPage:
connection.execute("BEGIN")
- parameters: list[object] = [session_id]
- query = "SELECT i.*, r.ordinal AS run_ordinal FROM code_items i JOIN code_runs r ON (r.session_id, r.id) = (i.session_id, i.run_id) WHERE i.session_id = ?"
- if before:
- query += " AND (r.ordinal, i.sequence) < (?, ?)"
- parameters.extend(before)
- query += " ORDER BY r.ordinal DESC, i.sequence DESC"
- if limit is not None:
- query += " LIMIT ?"
- parameters.append(limit + 1)
- rows = connection.execute(query, parameters).fetchall()
- selected = rows if limit is None else rows[:limit]
- run_ids = {row["run_id"] for row in selected}
- runs = tuple(
- _record(CodeRun, row)
+ return _item_page(connection, session_id, before, limit)
+
+ return await self._run(operation)
+
+ async def read_history(
+ self,
+ session_id: str,
+ before: tuple[int, int] | None,
+ include_item_ids: Sequence[str],
+ ) -> CodeHistory:
+ def operation(connection: sqlite3.Connection) -> CodeHistory:
+ connection.execute("BEGIN")
+ session = _session(connection, session_id)
+ run = _latest_run(connection, session_id)
+ active = run is not None and run.status in ACTIVE_RUN_STATUSES
+ boundary = before
+ if active and run is not None:
+ start = (run.ordinal, 0)
+ boundary = min(before, start) if before else start
+ page = _item_page(connection, session_id, boundary, 50)
+ active_prompts = _active_prompts(connection, session_id)
+ extras = _by_ids(
+ connection,
+ CodeItem,
+ session_id,
+ (*include_item_ids, *(prompt.id for prompt in active_prompts)),
+ )
+ items = {item.id: item for item in (*page.items, *extras)}
+ if active and run is not None:
for row in connection.execute(
- "SELECT * FROM code_runs WHERE session_id = ? ORDER BY ordinal",
- (session_id,),
+ "SELECT * FROM code_items WHERE session_id = ? AND run_id = ?",
+ (session_id, run.id),
+ ):
+ item = _record(CodeItem, row)
+ items[item.id] = item
+ run_ids = {item.run_id for item in items.values()}
+ if run is not None:
+ run_ids.add(run.id)
+ runs = _by_ids(connection, CodeRun, session_id, tuple(run_ids))
+ ordinals = {run.id: run.ordinal for run in runs}
+ selected = tuple(
+ sorted(
+ items.values(),
+ key=lambda item: (ordinals[item.run_id], item.sequence),
)
- if row["id"] in run_ids
)
- last = (
- selected[-1]
- if selected and limit is not None and len(rows) > limit
- else None
+ prompts = _by_ids(
+ connection,
+ CodePrompt,
+ session_id,
+ [item.id for item in selected if item.kind == "prompt"],
+ )
+ return CodeHistory(
+ session,
+ run,
+ selected,
+ prompts,
+ tuple(sorted(runs, key=lambda run: run.ordinal)),
+ tuple(prompt.id for prompt in active_prompts),
+ page.next_before,
+ )
+
+ return await self._run(operation)
+
+ async def execution_seed(self, session_id: str) -> CodeExecutionSeed:
+ def operation(connection: sqlite3.Connection) -> CodeExecutionSeed:
+ connection.execute("BEGIN")
+ session = _session(connection, session_id)
+ run = _latest_run(connection, session_id)
+ sequence = connection.execute(
+ "SELECT coalesce(max(sequence), 0) FROM code_items WHERE session_id = ?",
+ (session_id,),
+ ).fetchone()[0]
+ prompts = _active_prompts(connection, session_id)
+ items = _by_ids(
+ connection, CodeItem, session_id, [prompt.id for prompt in prompts]
)
- return CodeItemPage(
- tuple(_record(CodeItem, row) for row in reversed(selected)),
- runs,
- (last["run_ordinal"], last["sequence"]) if last else None,
+ ids = {item.run_id for item in items}
+ if run is not None:
+ ids.add(run.id)
+ return CodeExecutionSeed(
+ session,
+ run,
+ sequence,
+ items,
+ prompts,
+ _by_ids(connection, CodeRun, session_id, tuple(ids)),
)
return await self._run(operation)
+ async def get_native_item(
+ self, session_id: str, turn_id: str, item_id: str
+ ) -> CodeItem | None:
+ def operation(connection: sqlite3.Connection) -> CodeItem | None:
+ row = connection.execute(
+ "SELECT * FROM code_items WHERE session_id = ? AND native_turn_id = ? AND native_item_id = ?",
+ (session_id, turn_id, item_id),
+ ).fetchone()
+ return _record(CodeItem, row) if row else None
+
+ return await self._run(operation)
+
+ async def run_items(self, session_id: str, run_id: str) -> tuple[CodeItem, ...]:
+ return await self._run(
+ lambda connection: tuple(
+ _record(CodeItem, row)
+ for row in connection.execute(
+ "SELECT * FROM code_items WHERE session_id = ? AND run_id = ? ORDER BY sequence",
+ (session_id, run_id),
+ )
+ )
+ )
+
+ async def get_prompt(self, session_id: str, prompt_id: str) -> CodePrompt | None:
+ return await self._run(
+ lambda connection: next(
+ iter(_by_ids(connection, CodePrompt, session_id, (prompt_id,))), None
+ )
+ )
+
+ async def has_prompt_request(
+ self, session_id: str, generation: str, request_id: str | int
+ ) -> bool:
+ return await self._run(
+ lambda connection: (
+ connection.execute(
+ "SELECT 1 FROM code_prompts WHERE session_id = ? AND generation = ? AND request_id = ?",
+ (session_id, generation, json.dumps(request_id)),
+ ).fetchone()
+ is not None
+ )
+ )
+
+ async def has_native_turn(self, session_id: str, turn_id: str) -> bool:
+ return await self._run(
+ lambda connection: (
+ connection.execute(
+ "SELECT 1 FROM code_runs WHERE session_id = ? AND native_turn_id = ?",
+ (session_id, turn_id),
+ ).fetchone()
+ is not None
+ )
+ )
+
async def prompts(self, session_id: str) -> tuple[CodePrompt, ...]:
return await self._run(
lambda connection: tuple(
diff --git a/src/free_claude_code/runtime/codex_app_server.py b/src/free_claude_code/runtime/codex_app_server.py
index 996dcb5113..78531574df 100644
--- a/src/free_claude_code/runtime/codex_app_server.py
+++ b/src/free_claude_code/runtime/codex_app_server.py
@@ -287,6 +287,7 @@ async def _read(self) -> None:
future.set_exception(CodeUnavailableError(message))
self._pending.clear()
self._requests.clear()
+ self._protocol.clear()
# A failed pipe does not prove the native agent has stopped working.
# Keep the session reserved until its owned process is actually gone.
await self._reap_owned_process()
@@ -526,6 +527,7 @@ async def _close(self) -> None:
await self._reap_owned_process()
if self._reader is not None:
await self._reader
+ self._protocol.clear()
if self._dispatcher is not None:
await self._dispatcher
diff --git a/src/free_claude_code/runtime/codex_protocol.py b/src/free_claude_code/runtime/codex_protocol.py
index 5dc7bf6f2d..694dda34ac 100644
--- a/src/free_claude_code/runtime/codex_protocol.py
+++ b/src/free_claude_code/runtime/codex_protocol.py
@@ -46,7 +46,10 @@ class _Item:
class CodexProtocol:
def __init__(self, generation: str) -> None:
self.generation = generation
- self._items: dict[tuple[str, str], _Item] = {}
+ self._items: dict[tuple[str, str, str], _Item] = {}
+
+ def clear(self) -> None:
+ self._items.clear()
def notification(self, method: str, params: JsonObject) -> HarnessEvent | None:
thread_id = string_value(params.get("threadId")) or None
@@ -74,6 +77,12 @@ def notification(self, method: str, params: JsonObject) -> HarnessEvent | None:
context_used_tokens=used,
)
if method in {"turn/started", "turn/completed"}:
+ if method == "turn/completed":
+ self._items = {
+ key: item
+ for key, item in self._items.items()
+ if key[:2] != (thread_id, turn_id)
+ }
status: RunStatus = "completed"
if turn.get("status") == "interrupted":
status = "interrupted"
@@ -152,7 +161,20 @@ def notification(self, method: str, params: JsonObject) -> HarnessEvent | None:
item_id = string_value(params.get("itemId")) or string_value(raw.get("id"))
if not item_id:
return None
- item = self._items.setdefault((turn_id, item_id), _Item())
+ if method not in {
+ "item/started",
+ "item/completed",
+ "item/agentMessage/delta",
+ "item/reasoning/summaryTextDelta",
+ "item/reasoning/textDelta",
+ "item/reasoning/summaryPartAdded",
+ "item/commandExecution/outputDelta",
+ "item/fileChange/outputDelta",
+ "item/fileChange/patchUpdated",
+ "item/mcpToolCall/progress",
+ }:
+ return None
+ item = self._items.setdefault((thread_id, turn_id, item_id), _Item())
complete = method == "item/completed"
delta = string_value(params.get("delta"))
if method in {"item/started", "item/completed"}:
@@ -209,8 +231,14 @@ def history(self, payload: JsonObject) -> NativeThread:
item_id = string_value(raw.get("id"))
if not turn_id or not item_id:
continue
- accumulated = self._items.setdefault((turn_id, item_id), _Item())
- accumulated.raw = {**accumulated.raw, **raw}
+ live = self._items.get((thread_id, turn_id, item_id), _Item())
+ accumulated = _Item(
+ raw={**live.raw, **raw},
+ text=live.text,
+ output=live.output,
+ summary=dict(live.summary),
+ reasoning=dict(live.reasoning),
+ )
items.append(self._project(turn_id, item_id, accumulated, True))
if turn_id:
error = object_value(turn.get("error"))
diff --git a/src/free_claude_code/runtime/sqlite_migrations/__init__.py b/src/free_claude_code/runtime/sqlite_migrations/__init__.py
index 0dcebe48d5..2be3a48b5f 100644
--- a/src/free_claude_code/runtime/sqlite_migrations/__init__.py
+++ b/src/free_claude_code/runtime/sqlite_migrations/__init__.py
@@ -13,6 +13,7 @@
v0002_code_permissions,
v0003_code_context_usage,
v0004_messaging_schema,
+ v0005_active_code_prompts,
)
MIGRATIONS: tuple[tuple[int, Callable[[sqlite3.Connection], None]], ...] = (
@@ -20,4 +21,5 @@
(2, v0002_code_permissions.upgrade),
(3, v0003_code_context_usage.upgrade),
(4, v0004_messaging_schema.upgrade),
+ (5, v0005_active_code_prompts.upgrade),
)
diff --git a/src/free_claude_code/runtime/sqlite_migrations/v0005_active_code_prompts.py b/src/free_claude_code/runtime/sqlite_migrations/v0005_active_code_prompts.py
new file mode 100644
index 0000000000..16d0c0f8df
--- /dev/null
+++ b/src/free_claude_code/runtime/sqlite_migrations/v0005_active_code_prompts.py
@@ -0,0 +1,10 @@
+"""Index the live prompt working set independently of saved history."""
+
+import sqlite3
+
+
+def upgrade(connection: sqlite3.Connection) -> None:
+ connection.execute(
+ "CREATE INDEX code_prompts_active ON code_prompts(session_id, id) "
+ "WHERE status IN ('pending', 'answering')"
+ )
diff --git a/tests/application/test_code_sessions.py b/tests/application/test_code_sessions.py
index ae001fb83d..caae66aa11 100644
--- a/tests/application/test_code_sessions.py
+++ b/tests/application/test_code_sessions.py
@@ -9,6 +9,9 @@
from free_claude_code.application.code_sessions import CodeConflictError, CodeService
from free_claude_code.application.code_sessions import service as service_module
from free_claude_code.application.code_sessions.models import (
+ CodeItem,
+ CodeRun,
+ CodeSession,
CodeUnavailableError,
CodeValidationError,
HarnessEvent,
@@ -49,6 +52,207 @@ async def session_for(code):
return await service.create_session(new_id(), str(directory))
+@pytest.mark.asyncio
+async def test_history_pages_do_not_create_execution_owners(code, monkeypatch):
+ service, harness, directory = code
+ session = await service._store.create(
+ CodeSession(id=new_id(), cwd=str(directory), model=harness.model)
+ )
+ run = CodeRun(id=new_id(), session_id=session.id, text="old", model=harness.model)
+ user = CodeItem(
+ id=run.id, run_id=run.id, session_id=session.id, sequence=1, kind="user"
+ )
+ session, run = await service._store.admit_run(session, run, user, session.revision)
+ await service._store.save_progress(
+ session,
+ session.revision,
+ run=run.model_copy(update={"status": "completed"}),
+ items=tuple(
+ CodeItem(
+ id=new_id(),
+ run_id=run.id,
+ session_id=session.id,
+ sequence=n,
+ kind="text",
+ text="saved" * 100,
+ complete=True,
+ )
+ for n in range(2, 202)
+ ),
+ )
+ monkeypatch.setattr(
+ service._store, "items", AsyncMock(side_effect=AssertionError("full history"))
+ )
+ monkeypatch.setattr(
+ service._store, "runs", AsyncMock(side_effect=AssertionError("all runs"))
+ )
+ page = await service.get_detail(session.id)
+ assert len(page.items) == 50
+ assert page.next_before is not None
+ older = await service.get_detail(session.id, before=page.next_before)
+ assert len(older.items) == 50
+ assert older.items[-1].sequence < page.items[0].sequence
+ assert not service._owners
+ assert not service._gates
+ assert not harness.connections
+
+
+@pytest.mark.asyncio
+async def test_completed_turn_releases_bodies_but_keeps_connection(code):
+ service, harness, _ = code
+ session = await session_for(code)
+ await service.send(
+ session.id, new_id(), session.revision, "Work", expected_epoch=service.epoch
+ )
+ await harness.wait_inputs(1)
+ connection = harness.connections[0]
+ await harness.started.wait()
+ await connection.text("turn-1", "answer", "saved output", complete=True)
+ await connection.finish("turn-1")
+ await service.wait_idle(session.id)
+ owner = service._owners[session.id]
+ assert not owner.state.items
+ assert not owner.state.prompts
+ assert len(owner.state.runs) == 1
+ assert not connection.closed
+ assert any(
+ item.text == "saved output"
+ for item in (await service.get_detail(session.id)).items
+ )
+
+
+@pytest.mark.asyncio
+async def test_many_turns_release_history_and_preserve_prompt_receipts(code):
+ service, harness, _ = code
+ session = await session_for(code)
+ previous_sequence = 0
+ receipts = []
+ for number in range(1, 13):
+ harness.started.clear()
+ await service.send(
+ session.id,
+ new_id(),
+ session.revision,
+ f"Work {number}",
+ expected_epoch=service.epoch,
+ )
+ await harness.started.wait()
+ connection = harness.connections[0]
+ turn = f"turn-{number}"
+ for kind in ("text", "tool", "reasoning"):
+ await connection.text(
+ turn, kind, f"{kind} output {number}", complete=True, kind=kind
+ )
+ await connection.prompt(number, turn)
+ prompt = next(
+ prompt
+ for prompt in (await service.get_detail(session.id)).prompts
+ if prompt.request_id == number
+ )
+ response_id = new_id()
+ await service.answer(session.id, prompt.id, response_id, {"decision": "accept"})
+ await connection.finish(turn)
+ await service.wait_idle(session.id)
+ receipts.append((prompt.id, response_id))
+ detail = await service.get_detail(session.id)
+ assert detail.items[-1].sequence > previous_sequence
+ previous_sequence = detail.items[-1].sequence
+ session = detail.session
+ owner = service._owners[session.id]
+ assert not owner.state.items and not owner.state.prompts
+ assert tuple(owner.state.runs) == (detail.run.id,)
+ assert len(harness.connections) == 1 and not connection.closed
+ for prompt_id, response_id in receipts:
+ receipt = await service.answer(
+ session.id, prompt_id, response_id, {"decision": "accept"}
+ )
+ assert receipt.response_id == response_id
+ assert not service._owners[session.id].state.prompts
+ all_items = await service._store.items(session.id, None, None)
+ assert [item.sequence for item in all_items] == list(range(1, 61))
+
+
+@pytest.mark.asyncio
+async def test_cold_history_gate_serializes_owner_creation_without_blocking_other_sessions(
+ code, monkeypatch
+):
+ service, harness, directory = code
+ sessions = [
+ await service._store.create(
+ CodeSession(id=new_id(), cwd=str(directory), model=harness.model)
+ )
+ for _ in range(2)
+ ]
+ entered, release = asyncio.Event(), asyncio.Event()
+ read = service._store.read_history
+
+ async def gated_read(session_id, *args):
+ if session_id == sessions[0].id:
+ entered.set()
+ await release.wait()
+ return await read(session_id, *args)
+
+ monkeypatch.setattr(service._store, "read_history", gated_read)
+ reading = asyncio.create_task(service.get_detail(sessions[0].id))
+ await entered.wait()
+ changing = asyncio.create_task(
+ service.update_settings(
+ sessions[0].id, sessions[0].revision, {"title": "Updated"}
+ )
+ )
+ cancelled = asyncio.create_task(service.get_detail(sessions[0].id))
+ try:
+ other = await asyncio.wait_for(service.get_detail(sessions[1].id), 2)
+ assert other.session.id == sessions[1].id
+ assert not changing.done() and not service._owners
+ cancelled.cancel()
+ with pytest.raises(asyncio.CancelledError):
+ await cancelled
+ release.set()
+ before = await reading
+ await changing
+ after = await service.get_detail(sessions[0].id)
+ assert before.session.title != after.session.title == "Updated"
+ assert after.version > before.version
+ assert set(service._gates) == {sessions[0].id}
+ assert service._gates[sessions[0].id].users == 0
+ finally:
+ release.set()
+ await asyncio.gather(reading, changing, cancelled, return_exceptions=True)
+
+
+@pytest.mark.asyncio
+async def test_execution_seed_resumes_sequence_without_hydrating_old_transcript(
+ code, monkeypatch
+):
+ service, harness, directory = code
+ session = await service._store.create(
+ CodeSession(id=new_id(), cwd=str(directory), model=harness.model)
+ )
+ run = CodeRun(id=new_id(), session_id=session.id, text="old", model=harness.model)
+ item = CodeItem(
+ id=run.id, run_id=run.id, session_id=session.id, sequence=900, kind="user"
+ )
+ session, run = await service._store.admit_run(session, run, item, session.revision)
+ await service._store.save_progress(
+ session, session.revision, run=run.model_copy(update={"status": "completed"})
+ )
+ monkeypatch.setattr(
+ service._store, "items", AsyncMock(side_effect=AssertionError("full history"))
+ )
+ monkeypatch.setattr(
+ service._store, "prompts", AsyncMock(side_effect=AssertionError("all prompts"))
+ )
+ await service.send(
+ session.id, new_id(), session.revision, "Next", expected_epoch=service.epoch
+ )
+ await harness.started.wait()
+ detail = await service.get_detail(session.id)
+ assert detail.items[-1].sequence == 901
+ assert run.id not in service._owners[session.id].state.runs
+ await harness.connections[0].finish("turn-1")
+
+
@pytest.mark.asyncio
async def test_catalog_wait_releases_session_lock_and_revalidates_before_admission(
code, monkeypatch
@@ -2079,7 +2283,7 @@ async def test_review_liveness_flush_includes_pending_output_and_keeps_staging_v
writes, published = [], []
async def observe_save(session, revision, **values):
- assert service._owners[session.id].version == before.version + 1
+ assert service._version == before.version + 1
assert service.cursor == before.cursor
writes.append(tuple(values["items"]))
if fail:
diff --git a/tests/application/test_code_sessions_state.py b/tests/application/test_code_sessions_state.py
index 4f72d8c5f8..627c81b483 100644
--- a/tests/application/test_code_sessions_state.py
+++ b/tests/application/test_code_sessions_state.py
@@ -122,7 +122,7 @@ def test_recovery_updates_historical_run_without_replacing_current_run():
),
),
)
- ((turn, recovered),) = state.match_history(native)
+ ((turn, recovered),) = state.match_history(native, (old, current))
assert turn.id == recovered.native_turn_id == "old-turn"
assert recovered.status == "failed" and recovered.finished_at == 100
assert recovered.error == "Connection lost"
@@ -201,16 +201,20 @@ def test_only_new_turn_started_can_bind_an_unacknowledged_submission():
)
state = SessionState(base.session, current, (), (), (base.run, current))
assert not state.matches_turn(
- HarnessEvent("generation", "native", "turn_started", turn_id="turn")
+ HarnessEvent("generation", "native", "turn_started", turn_id="turn"),
+ known_turn=True,
)
assert not state.matches_turn(
- HarnessEvent("generation", "native", "item", turn_id="new-turn")
+ HarnessEvent("generation", "native", "item", turn_id="new-turn"),
+ known_turn=False,
)
assert state.matches_turn(
- HarnessEvent("generation", "native", "turn_started", turn_id="new-turn")
+ HarnessEvent("generation", "native", "turn_started", turn_id="new-turn"),
+ known_turn=False,
)
state.apply_progress(state.bind_turn("new-turn"))
assert state.matches_turn(
- HarnessEvent("generation", "native", "item", turn_id="new-turn")
+ HarnessEvent("generation", "native", "item", turn_id="new-turn"),
+ known_turn=False,
)
assert not state.accepts_event(HarnessEvent("generation", "other-thread", "notice"))
diff --git a/tests/runtime/test_code_sessions_sqlite.py b/tests/runtime/test_code_sessions_sqlite.py
index 49ae7da4f2..db5ffb6b52 100644
--- a/tests/runtime/test_code_sessions_sqlite.py
+++ b/tests/runtime/test_code_sessions_sqlite.py
@@ -17,6 +17,7 @@
CodeSession,
CodeUnavailableError,
)
+from free_claude_code.runtime import code_sessions_sqlite as code_store_module
from free_claude_code.runtime.code_sessions_sqlite import SQLiteCodeStore
from free_claude_code.runtime.sqlite_database import SQLiteDatabase
@@ -160,6 +161,107 @@ async def _session(store):
)
+@pytest.mark.asyncio
+async def test_history_snapshot_decodes_only_selected_records_and_chunks_includes(
+ store, monkeypatch
+):
+ session, run = await _admit(store, await _session(store))
+ items = tuple(
+ CodeItem(
+ id=str(uuid.uuid4()),
+ session_id=session.id,
+ run_id=run.id,
+ sequence=number,
+ kind="text",
+ text=str(number),
+ complete=True,
+ )
+ for number in range(2, 1203)
+ )
+ await store.save_progress(
+ session,
+ session.revision,
+ items=items,
+ run=run.model_copy(update={"status": "completed"}),
+ )
+ for number in range(1203, 1243):
+ session, later = await _admit(store, session, sequence=number)
+ await store.save_progress(
+ session,
+ session.revision,
+ run=later.model_copy(update={"status": "completed"}),
+ )
+ decoded = []
+ record = code_store_module._record
+
+ def observe(model, row):
+ decoded.append(model)
+ return record(model, row)
+
+ monkeypatch.setattr(code_store_module, "_record", observe)
+ first = await store.read_history(session.id, None, ())
+ assert len(first.items) == 50
+ assert decoded.count(CodeItem) == 50
+ assert decoded.count(CodeRun) <= 2 * len(first.runs) + 1
+ decoded.clear()
+ seed = await store.execution_seed(session.id)
+ assert seed.sequence == 1242 and not seed.items and not seed.prompts
+ assert len(seed.runs) == 1 and decoded.count(CodeRun) == 2
+ included = await store.read_history(session.id, None, [item.id for item in items])
+ assert {item.id for item in items} <= {item.id for item in included.items}
+ assert len({item.id for item in included.items}) == len(included.items)
+
+
+@pytest.mark.asyncio
+async def test_history_component_reads_share_one_sqlite_snapshot(store, monkeypatch):
+ session, run = await _admit(store, await _session(store))
+ await store.save_progress(
+ session, session.revision, run=run.model_copy(update={"status": "completed"})
+ )
+ page = code_store_module._item_page
+
+ def change_after_page(connection, session_id, before, limit):
+ result = page(connection, session_id, before, limit)
+ with closing(sqlite3.connect(store.database.path)) as writer, writer:
+ writer.execute(
+ "UPDATE code_sessions SET title = 'new' WHERE id = ?", (session.id,)
+ )
+ writer.execute(
+ "UPDATE code_items SET text = 'changed' WHERE session_id = ?",
+ (session.id,),
+ )
+ return result
+
+ monkeypatch.setattr(code_store_module, "_item_page", change_after_page)
+ detail = await store.read_history(session.id, None, (run.id,))
+ assert detail.session.title == session.title
+ assert detail.items[0].text == "message"
+ assert (await store.get_session(session.id)).title == "new"
+ assert (await store.run_items(session.id, run.id))[0].text == "changed"
+
+
+@pytest.mark.asyncio
+async def test_active_prompt_and_identity_queries_have_indexed_access(store):
+ with closing(sqlite3.connect(store.database.path)) as connection:
+ queries = (
+ (
+ "SELECT * FROM code_prompts WHERE session_id = ? AND status IN ('pending', 'answering')",
+ ("session",),
+ ),
+ ("SELECT max(sequence) FROM code_items WHERE session_id = ?", ("session",)),
+ (
+ "SELECT * FROM code_items WHERE session_id = ? AND native_turn_id = ? AND native_item_id = ?",
+ ("session", "turn", "item"),
+ ),
+ )
+ for query, params in queries:
+ details = [
+ row[3].upper()
+ for row in connection.execute("EXPLAIN QUERY PLAN " + query, params)
+ ]
+ assert any("SEARCH" in detail and "INDEX" in detail for detail in details)
+
+
@pytest.mark.asyncio
async def test_mode_and_original_defaults_survive_restart_and_cannot_be_rewritten(
store, tmp_path
@@ -251,6 +353,7 @@ async def test_version_one_database_gains_mode_without_losing_history(
)
connection.execute("ALTER TABLE code_sessions DROP COLUMN context_used_tokens")
connection.execute("ALTER TABLE code_runs DROP COLUMN mode")
+ connection.execute("DROP INDEX code_prompts_active")
drop_messaging_schema(connection)
connection.execute("PRAGMA user_version = 1")
for _ in range(2):
@@ -265,7 +368,7 @@ async def test_version_one_database_gains_mode_without_losing_history(
assert saved_run is not None and saved_run.mode == "config"
assert await store.items(session.id, None, None) == items
with closing(sqlite3.connect(tmp_path / "code.db")) as connection:
- assert connection.execute("PRAGMA user_version").fetchone()[0] == 4
+ assert connection.execute("PRAGMA user_version").fetchone()[0] == 5
with pytest.raises(sqlite3.IntegrityError):
connection.execute("UPDATE code_sessions SET mode = 'unknown'")
with pytest.raises(sqlite3.IntegrityError):
@@ -285,6 +388,7 @@ async def test_version_two_database_gains_nullable_context_usage(
await store.database.close()
with closing(sqlite3.connect(tmp_path / "code.db")) as connection, connection:
connection.execute("ALTER TABLE code_sessions DROP COLUMN context_used_tokens")
+ connection.execute("DROP INDEX code_prompts_active")
drop_messaging_schema(connection)
connection.execute("PRAGMA user_version = 2")
for _ in range(2):
@@ -294,7 +398,7 @@ async def test_version_two_database_gains_nullable_context_usage(
await store.start()
assert (await store.get_session(session.id)).context_used_tokens is None
with closing(sqlite3.connect(tmp_path / "code.db")) as connection:
- assert connection.execute("PRAGMA user_version").fetchone()[0] == 4
+ assert connection.execute("PRAGMA user_version").fetchone()[0] == 5
column = next(
row
for row in connection.execute("PRAGMA table_info(code_sessions)")
@@ -661,7 +765,7 @@ async def test_legacy_prompts_migrate_once_to_run_ends_without_resequencing(
)
assert saved[prompt.id] == expected
with closing(sqlite3.connect(path)) as connection:
- assert connection.execute("PRAGMA user_version").fetchone()[0] == 4
+ assert connection.execute("PRAGMA user_version").fetchone()[0] == 5
assert connection.execute("PRAGMA foreign_key_check").fetchall() == []
assert any(
row[2] == "code_items"
diff --git a/tests/runtime/test_codex_protocol.py b/tests/runtime/test_codex_protocol.py
index 2a407bd3a6..84d081558b 100644
--- a/tests/runtime/test_codex_protocol.py
+++ b/tests/runtime/test_codex_protocol.py
@@ -5,6 +5,145 @@
from free_claude_code.runtime.codex_protocol import CodexProtocol, NativePrompt
+def test_history_projection_does_not_retain_transcript_buffers():
+ protocol = CodexProtocol("generation")
+ history = protocol.history(
+ {
+ "thread": {
+ "id": "thread",
+ "turns": [
+ {
+ "id": "turn",
+ "items": [
+ {"id": "answer", "type": "agentMessage", "text": "saved"}
+ ],
+ }
+ ],
+ }
+ }
+ )
+ assert history.turns[0].items[0].text == "saved"
+ assert not protocol._items
+
+
+def test_terminal_turn_releases_only_its_thread_buffers():
+ protocol = CodexProtocol("generation")
+ for thread in ("parent", "child"):
+ protocol.notification(
+ "item/agentMessage/delta",
+ {
+ "threadId": thread,
+ "turnId": "turn",
+ "itemId": "answer",
+ "delta": thread,
+ },
+ )
+ event = protocol.notification(
+ "item/completed",
+ {
+ "threadId": "parent",
+ "turnId": "turn",
+ "item": {"id": "answer", "type": "agentMessage"},
+ },
+ )
+ protocol.notification(
+ "turn/completed", {"threadId": "parent", "turn": {"id": "turn"}}
+ )
+ assert event is not None and event.item is not None
+ assert event.item.text == "parent"
+ assert len(protocol._items) == 1
+ child = protocol.notification(
+ "item/completed",
+ {
+ "threadId": "child",
+ "turnId": "turn",
+ "item": {"id": "answer", "type": "agentMessage"},
+ },
+ )
+ assert child is not None and child.item is not None
+ assert child.item.text == "child"
+ protocol.notification(
+ "turn/completed", {"threadId": "child", "turn": {"id": "turn"}}
+ )
+ assert not protocol._items
+
+
+@pytest.mark.parametrize("status", ["completed", "failed", "interrupted"])
+def test_errors_keep_partial_output_until_terminal_event(status):
+ protocol = CodexProtocol("g")
+ identity = {"threadId": "thread", "turnId": "turn", "itemId": "command"}
+ protocol.notification(
+ "item/commandExecution/outputDelta", {**identity, "delta": "captured"}
+ )
+ protocol.notification(
+ "error", {**identity, "willRetry": False, "error": {"message": "failed"}}
+ )
+ projected = protocol.notification(
+ "item/completed",
+ {
+ **identity,
+ "item": {"id": "command", "type": "commandExecution"},
+ },
+ )
+ assert projected is not None and projected.item is not None
+ assert "captured" in projected.item.detail
+ protocol.notification(
+ "turn/completed",
+ {"threadId": "thread", "turn": {"id": "turn", "status": status}},
+ )
+ assert not protocol._items
+ assert "captured" in projected.item.detail
+
+
+def test_history_uses_live_fallback_without_modifying_accumulation():
+ protocol = CodexProtocol("g")
+ identity = {"threadId": "thread", "turnId": "turn", "itemId": "answer"}
+ protocol.notification("item/agentMessage/delta", {**identity, "delta": "live"})
+ history = protocol.history(
+ {
+ "thread": {
+ "id": "thread",
+ "turns": [
+ {
+ "id": "turn",
+ "items": [
+ {
+ "id": "answer",
+ "type": "agentMessage",
+ "text": "historical",
+ },
+ {"id": "other", "type": "agentMessage", "text": "other"},
+ ],
+ }
+ ],
+ }
+ }
+ )
+ assert history.turns[0].items[0].text == "historical"
+ assert len(protocol._items) == 1
+ event = protocol.notification(
+ "item/agentMessage/delta", {**identity, "delta": " output"}
+ )
+ assert (
+ event is not None
+ and event.item is not None
+ and event.item.text == "live output"
+ )
+ protocol.clear()
+ assert not protocol._items
+
+
+def test_unknown_item_notifications_do_not_allocate_buffers():
+ protocol = CodexProtocol("g")
+ assert (
+ protocol.notification(
+ "item/unknown", {"threadId": "thread", "turnId": "turn", "itemId": "item"}
+ )
+ is None
+ )
+ assert not protocol._items
+
+
@pytest.mark.parametrize(
"status,label",
[
diff --git a/tests/runtime/test_sqlite_database.py b/tests/runtime/test_sqlite_database.py
index 10a992597d..e98bf2afb4 100644
--- a/tests/runtime/test_sqlite_database.py
+++ b/tests/runtime/test_sqlite_database.py
@@ -116,7 +116,7 @@ def test_historical_database_moves_and_upgrades_without_losing_records(
assert len(after["code_items"]) == 2
assert after["code_items"][-1]["kind"] == "prompt"
with closing(sqlite3.connect(target)) as connection:
- assert connection.execute("PRAGMA user_version").fetchone() == (4,)
+ assert connection.execute("PRAGMA user_version").fetchone() == (5,)
assert connection.execute("PRAGMA journal_mode").fetchone() == ("wal",)
assert connection.execute("PRAGMA foreign_key_check").fetchall() == []
@@ -153,7 +153,7 @@ def test_orphan_journals_are_not_treated_as_a_fresh_database(
assert not (tmp_path / "fcc.db").exists()
-@pytest.mark.parametrize("version", [-1, 5])
+@pytest.mark.parametrize("version", [-1, 6])
def test_unsupported_version_is_not_moved_or_mutated(tmp_path, version):
old, target = tmp_path / "code/code.db", tmp_path / "fcc.db"
historical_database(old, 3)
@@ -297,7 +297,7 @@ def already_applied(connection):
monkeypatch.setattr(
sqlite_database,
"MIGRATIONS",
- tuple((version, already_applied) for version in (1, 2, 3, 4)),
+ tuple((version, already_applied) for version, _ in sqlite_database.MIGRATIONS),
)
initialize_database(target)
@@ -311,11 +311,18 @@ def test_messaging_schema_failure_rolls_back_without_changing_code_history(
migrations = sqlite_database.MIGRATIONS
def fail(connection):
- migrations[-1][1](connection)
+ migrations[3][1](connection)
raise sqlite3.DatabaseError("interrupted messaging schema")
with monkeypatch.context() as patch:
- patch.setattr(sqlite_database, "MIGRATIONS", (*migrations[:-1], (4, fail)))
+ patch.setattr(
+ sqlite_database,
+ "MIGRATIONS",
+ tuple(
+ (version, fail if version == 4 else upgrade)
+ for version, upgrade in migrations
+ ),
+ )
with pytest.raises(sqlite3.DatabaseError, match="interrupted"):
initialize_database(target)
assert snapshot(target) == before
From 8296b09ee7d8ccf233c006820b06ec5b2ea5524b Mon Sep 17 00:00:00 2001
From: Ali Khokhar <20476625+Alishahryar1@users.noreply.github.com>
Date: Sun, 27 Sep 2026 15:13:32 -0700
Subject: [PATCH 2/5] patch: Remove obsolete Task foreground interception
(#1925)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## Why
FCC still forces tools named `Task` into foreground execution. Current
Claude Code uses `Agent`, so the workaround no longer applies to it and
unnecessarily changes arguments in generic Chat conversion.
## How
Remove the foreground rewrite and its dedicated buffers, including the
unused duplicate in the Anthropic ledger. Task calls now use ordinary
argument streaming, alias restoration, validation, and repair. Update
the affected behavioral coverage and smoke capability descriptions.
This intentionally stops forcing foreground execution for older clients
that still emit `Task`. Current Claude Code background-agent runs
through FCC NIM transport already succeeded without this workaround
participating.
Confidence Score: 3/5
Not safe to merge until both Task tool-call failures are fixed.
Findings
1. **Task boolean becomes a string** ▶
2. **Malformed Task call completes successfully** ▶Summary
The PR removes Task-specific foreground rewriting and argument
buffering, routing Task calls through ordinary Chat tool streaming.
Clients need correctly typed Task arguments and valid JSON when a
tool-use response completes. Text-form background arguments can now have
the wrong type, and malformed arguments can produce a successfully
completed stream containing invalid JSON. Both failures should be fixed
before merging.
Reviews (1) · Last reviewed commit: ["Remove obsolete Task
foreground
intercep..."](https://github.com/alishahryar1/free-claude-code/commit/9db1a2f19f7123e8bbbc50dfcc59c16cdb878ccd)
---
smoke/capabilities.py | 6 +-
smoke/features.py | 2 +-
.../core/anthropic/streaming/ledger.py | 58 -------
.../providers/openai_chat/stream_output.py | 55 ------
.../providers/openai_chat/tool_calls.py | 16 +-
.../providers/openai_chat/transport.py | 3 -
tests/contracts/test_stream_contracts.py | 6 +-
tests/providers/test_nvidia_nim.py | 4 +-
tests/providers/test_streaming_errors.py | 60 ++++---
tests/providers/test_subagent_interception.py | 159 ++++++++++++++++--
10 files changed, 188 insertions(+), 181 deletions(-)
diff --git a/smoke/capabilities.py b/smoke/capabilities.py
index 957ce7a6fe..69a1fbbc1f 100644
--- a/smoke/capabilities.py
+++ b/smoke/capabilities.py
@@ -285,10 +285,10 @@ class CapabilityContract:
"streaming_conversion",
"subagent_task_control",
"subagent_control",
- "free_claude_code.core.anthropic.streaming.AnthropicStreamLedger",
+ "free_claude_code.providers.openai_chat.tool_calls",
"Task tool call arguments",
- "run_in_background=false",
- "invalid JSON flushed as safe object",
+ "arguments preserved with normal alias restoration",
+ "ordinary tool validation and recovery",
("tests/providers/test_subagent_interception.py",),
),
CapabilityContract(
diff --git a/smoke/features.py b/smoke/features.py
index f1919eeb04..1df1601410 100644
--- a/smoke/features.py
+++ b/smoke/features.py
@@ -405,7 +405,7 @@ def has_pytest_coverage(self) -> bool:
),
FeatureCoverage(
"subagent_control",
- "Task-like tool output is rendered and controlled as foreground work",
+ "Task arguments are preserved and tool output is rendered in transcripts",
("tests/providers/test_subagent_interception.py",),
(),
("test_messaging_subagent_control_e2e",),
diff --git a/src/free_claude_code/core/anthropic/streaming/ledger.py b/src/free_claude_code/core/anthropic/streaming/ledger.py
index 77dd9d2be3..4d6a7eaeac 100644
--- a/src/free_claude_code/core/anthropic/streaming/ledger.py
+++ b/src/free_claude_code/core/anthropic/streaming/ledger.py
@@ -1,15 +1,11 @@
"""Anthropic stream state ledger."""
-import hashlib
-import json
import uuid
from collections.abc import Iterator, Mapping
from contextlib import suppress
from dataclasses import dataclass, field
from typing import Any
-from loguru import logger
-
from free_claude_code.core.token_estimation import estimate_text_tokens
from .emitter import AnthropicSseEmitter
@@ -32,8 +28,6 @@ class ToolBlockState:
name: str
extra_content: dict[str, Any] | None = None
started: bool = False
- task_arg_buffer: str = ""
- task_args_emitted: bool = False
pre_start_args: str = ""
@@ -99,53 +93,6 @@ def register_tool_name(self, index: int, name: str) -> None:
elif not prev.startswith(name):
state.name = prev + name
- def buffer_task_args(self, index: int, args: str) -> dict[str, Any] | None:
- state = self.tool_states.get(index)
- if state is None or state.task_args_emitted:
- return None
-
- state.task_arg_buffer += args
- try:
- args_json = json.loads(state.task_arg_buffer)
- except Exception:
- return None
- if not isinstance(args_json, dict):
- return None
-
- _normalize_task_run_in_background(args_json)
- state.task_args_emitted = True
- state.task_arg_buffer = ""
- return args_json
-
- def flush_task_arg_buffers(self) -> list[tuple[int, str]]:
- results: list[tuple[int, str]] = []
- for tool_index, state in list(self.tool_states.items()):
- if not state.task_arg_buffer or state.task_args_emitted:
- continue
-
- out = "{}"
- try:
- args_json = json.loads(state.task_arg_buffer)
- if isinstance(args_json, dict):
- _normalize_task_run_in_background(args_json)
- out = json.dumps(args_json)
- except (json.JSONDecodeError, TypeError, ValueError) as exc:
- digest = hashlib.sha256(
- state.task_arg_buffer.encode("utf-8", errors="replace")
- ).hexdigest()[:16]
- logger.warning(
- "Task args invalid JSON (id={} len={} buffer_sha256_prefix={}): {}",
- state.tool_id or "unknown",
- len(state.task_arg_buffer),
- digest,
- exc,
- )
-
- state.task_args_emitted = True
- state.task_arg_buffer = ""
- results.append((tool_index, out))
- return results
-
class AnthropicStreamLedger:
"""Own mutable Anthropic stream state and produce serialized SSE events."""
@@ -531,8 +478,3 @@ def _clear_active_content_block(self, state: StreamBlockState) -> None:
state.block_type == "thinking" and self.blocks.thinking_index == state.index
):
self.blocks.thinking_started = False
-
-
-def _normalize_task_run_in_background(args_json: dict[str, Any]) -> None:
- if args_json.get("run_in_background") is not False:
- args_json["run_in_background"] = False
diff --git a/src/free_claude_code/providers/openai_chat/stream_output.py b/src/free_claude_code/providers/openai_chat/stream_output.py
index 1b98d0d0d6..86ad9ed1fc 100644
--- a/src/free_claude_code/providers/openai_chat/stream_output.py
+++ b/src/free_claude_code/providers/openai_chat/stream_output.py
@@ -1,6 +1,5 @@
"""Chat-source output writers for Anthropic Messages and OpenAI Responses."""
-import hashlib
import json
import time
import uuid
@@ -8,8 +7,6 @@
from collections.abc import Callable, Iterator, Mapping
from dataclasses import dataclass, field
-from loguru import logger
-
from free_claude_code.core.anthropic.streaming import (
AnthropicStreamLedger,
ToolSchema,
@@ -65,8 +62,6 @@ class ChatToolState:
extra_content: JsonObject | None = None
started: bool = False
open: bool = False
- task_arg_buffer: str = ""
- task_args_emitted: bool = False
pre_start_args: str = ""
argument_parts: list[str] = field(default_factory=list)
@@ -259,51 +254,6 @@ def can_salvage_tool_use(self, schemas: dict[str, ToolSchema]) -> bool:
for state in states
)
- def buffer_task_args(
- self, tool_index: int, arguments: str
- ) -> dict[str, object] | None:
- state = self.tool_states.get(tool_index)
- if state is None or state.task_args_emitted:
- return None
- state.task_arg_buffer += arguments
- try:
- parsed = json.loads(state.task_arg_buffer)
- except json.JSONDecodeError:
- return None
- if not isinstance(parsed, dict):
- return None
- _normalize_task_args(parsed)
- state.task_args_emitted = True
- state.task_arg_buffer = ""
- return parsed
-
- def flush_task_arg_buffers(self) -> list[tuple[int, str]]:
- results: list[tuple[int, str]] = []
- for tool_index, state in self.tool_states.items():
- if not state.task_arg_buffer or state.task_args_emitted:
- continue
- output = "{}"
- try:
- parsed = json.loads(state.task_arg_buffer)
- if isinstance(parsed, dict):
- _normalize_task_args(parsed)
- output = json.dumps(parsed)
- except (json.JSONDecodeError, TypeError, ValueError) as exc:
- digest = hashlib.sha256(
- state.task_arg_buffer.encode("utf-8", errors="replace")
- ).hexdigest()[:16]
- logger.warning(
- "Task args invalid JSON (id={} len={} buffer_sha256_prefix={}): {}",
- state.tool_id or "unknown",
- len(state.task_arg_buffer),
- digest,
- exc,
- )
- state.task_args_emitted = True
- state.task_arg_buffer = ""
- results.append((tool_index, output))
- return results
-
def estimate_output_tokens(self) -> int:
tool_tokens = sum(
estimate_text_tokens(state.name) + estimate_text_tokens(state.content) + 15
@@ -745,8 +695,3 @@ def _responses_usage(usage: ChatStreamUsage) -> dict[str, object]:
"output_tokens_details": {"reasoning_tokens": reasoning_tokens},
"total_tokens": usage.input_tokens + usage.output_tokens,
}
-
-
-def _normalize_task_args(arguments: dict[str, object]) -> None:
- if arguments.get("run_in_background") is not False:
- arguments["run_in_background"] = False
diff --git a/src/free_claude_code/providers/openai_chat/tool_calls.py b/src/free_claude_code/providers/openai_chat/tool_calls.py
index 498d0ea8ab..363f4b3f9f 100644
--- a/src/free_claude_code/providers/openai_chat/tool_calls.py
+++ b/src/free_claude_code/providers/openai_chat/tool_calls.py
@@ -129,10 +129,6 @@ def iter_heuristic_tool_use_events(
decoded_name = tool_names.decode(name)
if decoded_name != name:
tool_use = {**tool_use, "name": decoded_name}
- if tool_use.get("name") == "Task" and isinstance(tool_use.get("input"), dict):
- task_input = tool_use["input"]
- if task_input.get("run_in_background") is not False:
- task_input["run_in_background"] = False
yield from output.close_content_blocks()
tool_index = len(output.tool_states)
output.ensure_tool_state(tool_index)
@@ -299,11 +295,6 @@ def _assign_public_tool_id(self, tool_index: int) -> str:
self._public_tool_ids[tool_index] = public_id
return public_id
- def flush_task_arg_buffers(self, output: ChatStreamOutput) -> Iterator[str]:
- """Emit buffered Task args as a single JSON delta."""
- for tool_index, out in output.flush_task_arg_buffers():
- yield output.emit_tool_delta(tool_index, out)
-
def flush_tool_name_buffers(
self,
output: ChatStreamOutput,
@@ -339,7 +330,7 @@ def flush_tool_argument_alias_buffers(
tool_argument_alias_buffers.pop(tool_index, None)
continue
state = output.tool_states.get(tool_index)
- if state is None or state.name == "Task":
+ if state is None:
continue
aliases = tool_argument_aliases.get(state.name, {})
if not aliases:
@@ -366,11 +357,6 @@ def _emit_tool_arg_delta(
state = output.tool_states.get(tc_index)
if state is None:
return
- if state.name == "Task":
- parsed = output.buffer_task_args(tc_index, args)
- if parsed is not None:
- yield output.emit_tool_delta(tc_index, json.dumps(parsed))
- return
aliases = (
tool_argument_aliases.get(state.name, {}) if tool_argument_aliases else {}
)
diff --git a/src/free_claude_code/providers/openai_chat/transport.py b/src/free_claude_code/providers/openai_chat/transport.py
index c164f24a20..112fdb26ed 100644
--- a/src/free_claude_code/providers/openai_chat/transport.py
+++ b/src/free_claude_code/providers/openai_chat/transport.py
@@ -488,7 +488,6 @@ def prepare_completion(self) -> Iterator[str]:
self._tool_argument_aliases,
self._tool_argument_alias_buffers,
)
- yield from self._tool_calls.flush_task_arg_buffers(self._output)
yield from self._output.close_all_blocks()
completion = usage_int(self._usage_info, "completion_tokens")
@@ -1552,8 +1551,6 @@ async def _repair_tool_args(
block = output.tool_block_for_tool_index(tool_index)
emitted_prefix = block.content if block is not None else ""
repair_prefix = emitted_prefix
- if not repair_prefix and state.name == "Task" and state.task_arg_buffer:
- repair_prefix = state.task_arg_buffer
if not repair_prefix and tool_index in tool_argument_alias_buffers:
repair_prefix = tool_argument_alias_buffers[tool_index]
if (
diff --git a/tests/contracts/test_stream_contracts.py b/tests/contracts/test_stream_contracts.py
index 4c164558cc..0b4a7d8e79 100644
--- a/tests/contracts/test_stream_contracts.py
+++ b/tests/contracts/test_stream_contracts.py
@@ -116,7 +116,7 @@ def test_enable_thinking_false_suppresses_reasoning_only() -> None:
assert text_content(events) == "hello world"
-def test_task_tool_arguments_force_foreground_execution() -> None:
+def test_task_tool_parser_preserves_background_argument() -> None:
parser = HeuristicToolParser()
filtered, detected = parser.feed(
"● Inspect"
@@ -126,9 +126,7 @@ def test_task_tool_arguments_force_foreground_execution() -> None:
assert "trailing" in filtered
task = detected[0]
assert task["name"] == "Task"
- if isinstance(task.get("input"), dict):
- task["input"]["run_in_background"] = False
- assert task["input"]["run_in_background"] is False
+ assert task["input"]["run_in_background"] == "true"
def _interleaved_thinking_text_events(
diff --git a/tests/providers/test_nvidia_nim.py b/tests/providers/test_nvidia_nim.py
index fe91fe1b37..4b62438193 100644
--- a/tests/providers/test_nvidia_nim.py
+++ b/tests/providers/test_nvidia_nim.py
@@ -1275,7 +1275,7 @@ async def mock_stream():
@pytest.mark.asyncio
-async def test_stream_messages_task_tool_still_forces_background_false(nim_provider):
+async def test_stream_messages_task_tool_preserves_background_true(nim_provider):
req = make_request(
tools=[
tool(
@@ -1317,7 +1317,7 @@ async def mock_stream():
deltas = _input_json_deltas(events)
assert len(deltas) == 1
- assert json.loads(deltas[0])["run_in_background"] is False
+ assert json.loads(deltas[0])["run_in_background"] is True
@pytest.mark.asyncio
diff --git a/tests/providers/test_streaming_errors.py b/tests/providers/test_streaming_errors.py
index 6b05a5d8dc..2a34affa87 100644
--- a/tests/providers/test_streaming_errors.py
+++ b/tests/providers/test_streaming_errors.py
@@ -2415,17 +2415,23 @@ async def test_truncated_recovery_stream_closes_block_then_raises(self):
)
@pytest.mark.asyncio
- async def test_incomplete_tool_call_repair_appends_schema_valid_suffix(self):
+ @pytest.mark.parametrize("tool_name", ["echo_smoke", "Task"])
+ async def test_incomplete_tool_call_repair_appends_schema_valid_suffix(
+ self, tool_name
+ ):
"""A truncated tool JSON prefix is repaired append-only before tool_use tail."""
provider = _make_provider()
request = _make_request(
tools=[
{
- "name": "echo_smoke",
+ "name": tool_name,
"description": "Echo",
"input_schema": {
"type": "object",
- "properties": {"message": {"type": "string"}},
+ "properties": {
+ "message": {"type": "string"},
+ "run_in_background": {"type": "boolean"},
+ },
"required": ["message"],
"additionalProperties": False,
},
@@ -2433,7 +2439,9 @@ async def test_incomplete_tool_call_repair_appends_schema_valid_suffix(self):
]
)
tool_chunk = _make_tool_calls_chunk(
- name="echo_smoke", arguments='{"message":', tool_id="call_repair"
+ name=tool_name,
+ arguments='{"run_in_background":true,"message":',
+ tool_id="call_repair",
)
with (
patch.object(
@@ -2460,6 +2468,12 @@ async def test_incomplete_tool_call_repair_appends_schema_valid_suffix(self):
for event in parsed
)
assert not any(event.event == "error" for event in parsed)
+ arguments = [
+ event.data["delta"]["partial_json"]
+ for event in parsed
+ if event.data.get("delta", {}).get("type") == "input_json_delta"
+ ]
+ assert arguments == ['{"run_in_background":true,"message":', '"ok"}']
@pytest.mark.asyncio
async def test_stream_rate_limit_uses_the_execution_retry_session(self):
@@ -2951,8 +2965,8 @@ def test_late_upstream_id_cannot_overwrite_started_public_id(self):
assert later == []
assert sse.tool_states[0].tool_id == generated_id
- def test_task_tool_forces_background_false(self):
- """Task tool with run_in_background=true is forced to false."""
+ def test_task_tool_preserves_background_true(self):
+ """Task tool preserves run_in_background=true."""
provider = _make_provider()
sse = _make_anthropic_output()
args = json.dumps({"run_in_background": True, "prompt": "test"})
@@ -2963,11 +2977,11 @@ def test_task_tool_forces_background_false(self):
}
events = list(_make_tool_assembler(provider).process_tool_call(tc, sse))
event_text = "".join(events)
- # The intercepted args should have run_in_background=false
- assert "false" in event_text.lower()
+ assert sse.tool_states[0].content == args
+ assert "true" in event_text.lower()
- def test_task_tool_chunked_args_forces_background_false(self):
- """Chunked Task args are buffered until valid JSON, then forced to false."""
+ def test_task_tool_streams_chunked_args(self):
+ """Chunked Task args stream without changing the requested execution mode."""
provider = _make_provider()
sse = _make_anthropic_output()
tc1 = {
@@ -2984,14 +2998,18 @@ def test_task_tool_chunked_args_forces_background_false(self):
assembler = _make_tool_assembler(provider)
events1 = list(assembler.process_tool_call(tc1, sse))
assert len(events1) > 0
- assert "false" not in "".join(events1).lower()
+ assert sse.tool_states[0].content == tc1["function"]["arguments"]
events2 = list(assembler.process_tool_call(tc2, sse))
event_text = "".join(events1 + events2)
- assert "false" in event_text.lower()
+ assert "true" in event_text.lower()
+ assert json.loads(sse.tool_states[0].content) == {
+ "run_in_background": True,
+ "prompt": "test",
+ }
- def test_task_tool_invalid_json_logs_warning_on_flush(self, caplog):
- """Invalid JSON args for Task tool emits {} on flush and logs a warning."""
+ def test_task_tool_invalid_json_is_not_replaced(self):
+ """Invalid Task arguments remain available to generic validation and repair."""
provider = _make_provider()
sse = _make_anthropic_output()
tc = {
@@ -3003,11 +3021,7 @@ def test_task_tool_invalid_json_logs_warning_on_flush(self, caplog):
events = list(assembler.process_tool_call(tc, sse))
assert len(events) > 0
- with caplog.at_level("WARNING"):
- flushed = list(assembler.flush_task_arg_buffers(sse))
- assert len(flushed) > 0
- assert "{}" in "".join(flushed)
- assert any("Task args invalid JSON" in r.message for r in caplog.records)
+ assert sse.tool_states[0].content == "not json"
def test_negative_tool_index_fallback(self):
"""tc_index < 0 uses len(tool_indices) as fallback."""
@@ -3135,7 +3149,7 @@ async def test_stream_generator_cleanup_on_exception(self):
assert "Connection reset" in error.message
def test_stream_malformed_tool_args_chunked(self):
- """Chunked tool args that never form valid JSON are flushed with {}."""
+ """Malformed chunks are retained without a Task-specific fallback."""
provider = _make_provider()
sse = _make_anthropic_output()
tc1 = {
@@ -3152,11 +3166,9 @@ def test_stream_malformed_tool_args_chunked(self):
assembler = _make_tool_assembler(provider)
events1 = list(assembler.process_tool_call(tc1, sse))
events2 = list(assembler.process_tool_call(tc2, sse))
- flushed = list(assembler.flush_task_arg_buffers(sse))
-
- event_text = "".join(events1 + events2 + flushed)
+ event_text = "".join(events1 + events2)
assert "tool_use" in event_text
- assert "{}" in event_text
+ assert sse.tool_states[0].content == '{"broken": never valid }'
@pytest.mark.asyncio
diff --git a/tests/providers/test_subagent_interception.py b/tests/providers/test_subagent_interception.py
index 68cefef3d9..51f40f840d 100644
--- a/tests/providers/test_subagent_interception.py
+++ b/tests/providers/test_subagent_interception.py
@@ -1,32 +1,159 @@
+import copy
import json
+import pytest
+
+from free_claude_code.core.anthropic import ReasoningReplayMode
+from free_claude_code.core.anthropic.stream_contracts import parse_sse_text
+from free_claude_code.core.openai_responses import (
+ OpenAIResponsesRequest,
+ build_responses_chat_request,
+)
from free_claude_code.providers.openai_chat.stream_output import (
AnthropicChatStreamOutput,
+ ChatStreamOutput,
+ ChatStreamUsage,
+ ResponsesChatStreamOutput,
)
from free_claude_code.providers.openai_chat.tool_calls import (
+ OpenAIToolCallAssembler,
iter_heuristic_tool_use_events,
)
-def test_task_tool_interception() -> None:
- output = AnthropicChatStreamOutput(
- message_id="msg_test",
- model="test-model",
- input_tokens=1,
+@pytest.fixture(params=["messages", "responses"])
+def output(request) -> ChatStreamOutput:
+ if request.param == "messages":
+ return AnthropicChatStreamOutput(
+ message_id="msg_test", model="test-model", input_tokens=1
+ )
+ prepared = build_responses_chat_request(
+ OpenAIResponsesRequest(model="test-model", input="hello"),
+ reasoning_replay=ReasoningReplayMode.DISABLED,
)
+ return ResponsesChatStreamOutput(prepared.tool_adapter, input_tokens=1)
+
+
+def _argument_deltas(frames: list[str]) -> list[str]:
+ parts = []
+ for event in parse_sse_text("".join(frames)):
+ if event.event == "response.function_call_arguments.delta":
+ parts.append(event.data["delta"])
+ elif event.data.get("delta", {}).get("type") == "input_json_delta":
+ parts.append(event.data["delta"]["partial_json"])
+ return parts
+
+
+@pytest.mark.parametrize(
+ "arguments",
+ [
+ {"run_in_background": True},
+ {"run_in_background": False},
+ {"run_in_background": None},
+ {"prompt": "inspect"},
+ ],
+)
+def test_heuristic_task_preserves_arguments(output, arguments):
tool_use = {
"type": "tool_use",
- "id": "tool_123",
+ "id": "call_task",
"name": "Task",
- "input": {
- "description": "test task",
- "prompt": "do something",
- "run_in_background": True,
- },
+ "input": {**arguments, "nested": {"items": [1, 2]}},
}
+ original = copy.deepcopy(tool_use)
+ frames = list(iter_heuristic_tool_use_events(output, tool_use))
+ assert json.loads("".join(_argument_deltas(frames))) == original["input"]
+ assert tool_use == original
- events = list(iter_heuristic_tool_use_events(output, tool_use))
- delta = next(event for event in events if '"type": "input_json_delta"' in event)
- payload = json.loads(delta.split("data: ", 1)[1])
- args_passed = json.loads(payload["delta"]["partial_json"])
- assert args_passed["run_in_background"] is False
+
+@pytest.mark.parametrize("name", ["Task", "ordinary_tool"])
+@pytest.mark.parametrize(
+ "arguments",
+ [
+ '{"run_in_background":true,"prompt":"inspect"}',
+ "{}",
+ '{"run_in_background":null}',
+ "[]",
+ "not json",
+ '{"broken":',
+ "",
+ ],
+)
+def test_tool_arguments_stream_without_name_specific_rewrites(output, name, arguments):
+ assembler = OpenAIToolCallAssembler(reserved_tool_ids=())
+ frames = output.start_events()
+ split = len(arguments) // 2
+ for part, tool_name in [(arguments[:split], name), (arguments[split:], None)]:
+ emitted = list(
+ assembler.process_tool_call(
+ {
+ "index": 0,
+ "id": "call_task",
+ "function": {"name": tool_name, "arguments": part},
+ },
+ output,
+ )
+ )
+ if isinstance(output, AnthropicChatStreamOutput):
+ assert _argument_deltas(emitted) == ([part] if part else [])
+ frames.extend(emitted)
+ frames.extend(
+ output.finish_success(
+ stop_reason="tool_use",
+ usage=ChatStreamUsage(input_tokens=1, output_tokens=1),
+ )
+ )
+ assert output.tool_states[0].content == arguments
+ assert output.tool_states[0].tool_id == "call_task"
+ events = parse_sse_text("".join(frames))
+ if isinstance(output, ResponsesChatStreamOutput):
+ valid = arguments in (
+ '{"run_in_background":true,"prompt":"inspect"}',
+ "{}",
+ '{"run_in_background":null}',
+ )
+ assert any(e.event == "response.completed" for e in events) == valid
+ assert any(e.event == "response.failed" for e in events) != valid
+ if valid:
+ assert json.loads("".join(_argument_deltas(frames))) == json.loads(
+ arguments
+ )
+ else:
+ assert not _argument_deltas(frames)
+ else:
+ assert events[-1].event == "message_stop"
+ assert "".join(_argument_deltas(frames)) == arguments
+
+
+def test_task_argument_aliases_are_restored_recursively(output):
+ assembler = OpenAIToolCallAssembler(reserved_tool_ids=())
+ buffers = {}
+ frames = []
+ for name, part in [
+ ("Task", '{"run_in_background":true,"wire_prompt":"inspect",'),
+ (None, '"nested":[{"wire_prompt":"child"}]}'),
+ ]:
+ frames.extend(
+ assembler.process_tool_call(
+ {
+ "index": 0,
+ "id": "call_task",
+ "function": {"name": name, "arguments": part},
+ },
+ output,
+ tool_argument_aliases={"Task": {"wire_prompt": "prompt"}},
+ tool_argument_alias_buffers=buffers,
+ )
+ )
+ frames.extend(
+ assembler.flush_tool_argument_alias_buffers(
+ output, {"Task": {"wire_prompt": "prompt"}}, buffers
+ )
+ )
+ frames.extend(output.close_all_blocks())
+ assert json.loads("".join(_argument_deltas(frames))) == {
+ "run_in_background": True,
+ "prompt": "inspect",
+ "nested": [{"prompt": "child"}],
+ }
+ assert not buffers
From 5643391e77142ce7404ab5c45821fd9c7d46d41b Mon Sep 17 00:00:00 2001
From: Ali Khokhar <20476625+Alishahryar1@users.noreply.github.com>
Date: Sun, 27 Sep 2026 15:34:27 -0700
Subject: [PATCH 3/5] patch: Keep messaging Claude tasks headless on Windows
(#1927)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## Why
Telegram and Discord tasks can open a terminal window for the duration
of Claude's work on Windows. Their shared managed launcher redirects
output but does not suppress console creation when FCC runs without a
console.
## How
Use Windows `CREATE_NO_WINDOW` when starting managed Claude and its
process-tree stop helper, keeping messaging execution and cancellation
headless.
Confidence Score: 4/5
The test-coverage concern is non-blocking; it does not make this change
unsafe to merge.
Findings
1. **Windows flag lacks test coverage** ▶Summary
The PR adds Windows no-console flags to managed Claude launches and
taskkill cleanup so messaging and cancellation do not open console
windows. The relevant tests still pass when both flags are removed,
leaving that behavior unprotected against regression.
Reviews (1) · Last reviewed commit: ["Trim headless launch tests
and keep
Wind..."](https://github.com/alishahryar1/free-claude-code/commit/ba8af5752867a95d2d515769a61ca79bb7cfee98)
---
src/free_claude_code/cli/managed/session.py | 4 ++++
src/free_claude_code/cli/process_registry.py | 1 +
tests/cli/test_process_registry.py | 1 +
3 files changed, 6 insertions(+)
diff --git a/src/free_claude_code/cli/managed/session.py b/src/free_claude_code/cli/managed/session.py
index e963bbcded..1eea383a4c 100644
--- a/src/free_claude_code/cli/managed/session.py
+++ b/src/free_claude_code/cli/managed/session.py
@@ -2,6 +2,7 @@
import asyncio
import os
+import subprocess
from collections.abc import AsyncGenerator
from loguru import logger
@@ -136,6 +137,9 @@ async def start_task(
stderr=asyncio.subprocess.PIPE,
cwd=invocation.cwd,
env=invocation.env,
+ creationflags=subprocess.CREATE_NO_WINDOW
+ if os.name == "nt"
+ else 0,
)
self.process = process
if process.pid:
diff --git a/src/free_claude_code/cli/process_registry.py b/src/free_claude_code/cli/process_registry.py
index 3b28964161..a1650bb27d 100644
--- a/src/free_claude_code/cli/process_registry.py
+++ b/src/free_claude_code/cli/process_registry.py
@@ -54,6 +54,7 @@ def kill_pid_tree_best_effort(pid: int) -> None:
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
check=False,
+ creationflags=subprocess.CREATE_NO_WINDOW,
)
except Exception as e:
logger.debug("process_registry: taskkill failed pid=%s: %s", pid, e)
diff --git a/tests/cli/test_process_registry.py b/tests/cli/test_process_registry.py
index cfe6612997..647e2d5d45 100644
--- a/tests/cli/test_process_registry.py
+++ b/tests/cli/test_process_registry.py
@@ -83,6 +83,7 @@ def test_process_registry_kill_pid_tree_windows_uses_taskkill(monkeypatch):
from free_claude_code.cli import process_registry as pr
calls = []
+ monkeypatch.setattr(subprocess, "CREATE_NO_WINDOW", 0x08000000, raising=False)
monkeypatch.setattr(os, "name", "nt", raising=False)
monkeypatch.setattr(subprocess, "run", lambda *a, **kw: calls.append((a, kw)))
From cc573ba8bba7b6c6c3393df9930403e114eeeada Mon Sep 17 00:00:00 2001
From: Ali Khokhar <20476625+Alishahryar1@users.noreply.github.com>
Date: Sun, 27 Sep 2026 18:59:13 -0700
Subject: [PATCH 4/5] patch: preserve native tool arguments and remove text
tool parsers (#1929)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## Why
FCC inferred tool calls from ordinary model text and rejected some
normally completed native function calls with malformed arguments. This
could hide model output or remove the call and the harness's error
result from the next turn.
Removing text buffering also requires reasoning metadata to retain its
identity independently of when thinking and answer blocks finish.
Otherwise late metadata can lose native reasoning, overlap Messages
blocks, or replay as a separate empty assistant turn.
## How
- Delete both generic text-to-tool parsers. Tool-looking markup remains
visible text, and native tool events supply tool calls. Move the
surviving NIM schema helpers into the NIM provider.
- Preserve ordinary native function argument strings through streaming
translation and supported history replay. Keep decoding where
object-valued Messages input or custom/search wrappers require it, and
report a conversion error instead of substituting an empty object.
- Collect native and structured reasoning independently of which
representation is displayed. Preserve fragment order, provenance, and
separation between reasoning groups.
- Reserve Responses reasoning items at the first reasoning event and
finalize metadata on that same item.
- Close Messages thinking before streaming text or tools. Link late
metadata to its original block with an explicit FCC replay association,
then resolve that association within the original assistant message
before any of the three upstream protocol conversions. Existing saved
replay records remain readable.
- Integrate replay finalization with completion and existing
failure/recovery exits. Metadata alone cannot make an empty continuation
successful.
Accepted behavior: answers keep streaming immediately. Normally
completed ordinary arguments are deliberately opaque to FCC, while
malformed string history still cannot become a Messages input object.
Connection retries and interrupted-tool recovery remain in place. This
change does not expand the continuation collector's handling of
recovery-generated opaque reasoning or regroup first-ever opaque
metadata that arrives only after an answer has begun.
Confidence Score: 5/5
No outstanding findings block merging.
Summary
The PR preserves native tool arguments and reasoning during streaming
and replay. Since the previous review, the e2e session-seeding test was
changed to bound each operation individually. The earlier
reasoning-replay issue is fixed.
Reviews (3) · Last reviewed commit: ["test: bound pagination setup
operations
..."](https://github.com/alishahryar1/free-claude-code/commit/dacb3b6efc956486161effffaf3bfec97362cb15)
---
e2e/test_code_sessions.py | 26 +-
smoke/capabilities.py | 15 +-
smoke/features.py | 7 +-
.../core/anthropic/__init__.py | 3 -
.../core/anthropic/native_stream.py | 43 +-
.../core/anthropic/sse_aggregation.py | 26 +-
src/free_claude_code/core/anthropic/tools.py | 493 ------------------
src/free_claude_code/core/history_replay.py | 98 +++-
.../core/openai_responses/__init__.py | 2 -
.../core/openai_responses/chat_request.py | 18 -
.../core/openai_responses/messages_stream.py | 52 +-
.../openai_responses/streaming/completion.py | 24 +-
.../streaming/error_mapping.py | 12 -
.../core/openai_responses/tool_adaptation.py | 73 +--
.../core/openai_responses/tools.py | 24 -
.../providers/anthropic_messages/transport.py | 3 +-
.../providers/history_replay.py | 24 +-
.../providers/lmstudio/client.py | 3 +-
.../nvidia_nim/native_tool_schema.py} | 2 +-
.../nvidia_nim/native_tool_stream.py | 7 +-
.../openai_chat/reasoning_details.py | 122 +++--
.../providers/openai_chat/request_policy.py | 6 +-
.../providers/openai_chat/stream_output.py | 185 +++++--
.../providers/openai_chat/tool_calls.py | 25 -
.../providers/openai_chat/transport.py | 91 +---
.../providers/openai_responses/transport.py | 3 +-
tests/contracts/test_stream_contracts.py | 14 -
.../anthropic/test_native_messages_stream.py | 13 +-
tests/core/anthropic/test_text_tools.py | 258 ---------
.../openai_responses/test_chat_request.py | 17 +-
.../test_client_tool_discovery.py | 80 +--
tests/core/test_history_replay.py | 114 ++++
tests/core/test_history_switching.py | 21 +-
tests/providers/test_history_transports.py | 28 +-
tests/providers/test_native_tool_arguments.py | 309 +++++++++++
.../test_openai_chat_stream_output.py | 16 +-
tests/providers/test_parsers.py | 306 -----------
tests/providers/test_streaming_errors.py | 85 +--
.../test_structured_reasoning_lifecycle.py | 430 +++++++++++++++
tests/providers/test_subagent_interception.py | 47 +-
40 files changed, 1453 insertions(+), 1672 deletions(-)
delete mode 100644 src/free_claude_code/core/anthropic/tools.py
rename src/free_claude_code/{core/anthropic/tool_schema.py => providers/nvidia_nim/native_tool_schema.py} (97%)
delete mode 100644 tests/core/anthropic/test_text_tools.py
create mode 100644 tests/providers/test_native_tool_arguments.py
create mode 100644 tests/providers/test_structured_reasoning_lifecycle.py
diff --git a/e2e/test_code_sessions.py b/e2e/test_code_sessions.py
index 550f094ccb..d31296aedd 100644
--- a/e2e/test_code_sessions.py
+++ b/e2e/test_code_sessions.py
@@ -2055,21 +2055,23 @@ def test_library_reconnect_removes_missed_deletion_and_searches_beyond_first_pag
):
control_feed(page)
- async def seed():
- first = await code_control.service.create_session(
- str(uuid.uuid4()), str(tmp_path)
- )
- await code_control.service.update_settings(
- first.id, first.revision, {"title": "Needle project"}
- )
- for _ in range(26):
- await code_control.service.create_session(str(uuid.uuid4()), str(tmp_path))
- return first.id
-
# Start the isolated service before seeding it.
page.goto(f"{admin_base_url}/admin/code")
expect(page.locator("#codeNew")).to_be_enabled()
- session_id = code_control.run(seed())
+ # Bound each operation, not the cumulative cost of filling a whole page.
+ first = code_control.run(
+ code_control.service.create_session(str(uuid.uuid4()), str(tmp_path))
+ )
+ code_control.run(
+ code_control.service.update_settings(
+ first.id, first.revision, {"title": "Needle project"}
+ )
+ )
+ for _ in range(26):
+ code_control.run(
+ code_control.service.create_session(str(uuid.uuid4()), str(tmp_path))
+ )
+ session_id = first.id
page.get_by_role("searchbox", name="Search titles and folders").fill("Needle")
expect(page.locator(".session-card")).to_have_count(1)
expect(page.locator(".session-card")).to_contain_text("Needle project")
diff --git a/smoke/capabilities.py b/smoke/capabilities.py
index 69a1fbbc1f..a1db5ff724 100644
--- a/smoke/capabilities.py
+++ b/smoke/capabilities.py
@@ -269,13 +269,16 @@ class CapabilityContract:
),
CapabilityContract(
"streaming_conversion",
- "heuristic_tools",
- "heuristic_tool_parser",
- "free_claude_code.core.anthropic.tools",
- "textual tool-call output",
+ "native_tools",
+ "native_tool_assembly",
+ "free_claude_code.providers.openai_chat.tool_calls",
+ "native structured tool-call output",
"structured Anthropic tool_use blocks",
- "text fallback when malformed",
- ("tests/providers/test_parsers.py", "tests/contracts/test_stream_contracts.py"),
+ "raw arguments preserved for harness validation",
+ (
+ "tests/providers/test_native_tool_arguments.py",
+ "tests/contracts/test_stream_contracts.py",
+ ),
(
"test_live_tool_use_when_configured_model_supports_tools",
"test_provider_reasoning_tool_continuation_e2e",
diff --git a/smoke/features.py b/smoke/features.py
index 1df1601410..550d3c3855 100644
--- a/smoke/features.py
+++ b/smoke/features.py
@@ -325,9 +325,12 @@ def has_pytest_coverage(self) -> bool:
"configured providers must not reject adaptive thinking payloads",
),
FeatureCoverage(
- "heuristic_tool_parser",
+ "native_tool_assembly",
"Tool use and tool result continuation survive provider/client paths",
- ("tests/providers/test_parsers.py", "tests/contracts/test_stream_contracts.py"),
+ (
+ "tests/providers/test_native_tool_arguments.py",
+ "tests/contracts/test_stream_contracts.py",
+ ),
("test_live_tool_use_when_configured_model_supports_tools",),
(
"test_provider_interleaved_thinking_tool_e2e",
diff --git a/src/free_claude_code/core/anthropic/__init__.py b/src/free_claude_code/core/anthropic/__init__.py
index 9b52792658..78c7dbddf1 100644
--- a/src/free_claude_code/core/anthropic/__init__.py
+++ b/src/free_claude_code/core/anthropic/__init__.py
@@ -46,7 +46,6 @@
)
from .thinking import ContentChunk, ContentType, ThinkTagParser
from .tokens import get_token_count
-from .tools import FunctionTagToolParser, HeuristicToolParser
from .utils import set_if_not_none
__all__ = [
@@ -64,8 +63,6 @@
"ContentBlockWebSearchToolResult",
"ContentChunk",
"ContentType",
- "FunctionTagToolParser",
- "HeuristicToolParser",
"Message",
"MessagesRequest",
"MessagesResponse",
diff --git a/src/free_claude_code/core/anthropic/native_stream.py b/src/free_claude_code/core/anthropic/native_stream.py
index 2ea0a16644..11a42726ad 100644
--- a/src/free_claude_code/core/anthropic/native_stream.py
+++ b/src/free_claude_code/core/anthropic/native_stream.py
@@ -3,7 +3,6 @@
import json
from collections.abc import Mapping
from dataclasses import dataclass, field, replace
-from typing import cast
from free_claude_code.core.history_replay import (
ReplayOrigin,
@@ -32,6 +31,14 @@ class _Block:
fragments: dict[str, list[str]] = field(default_factory=dict)
+@dataclass(frozen=True, slots=True)
+class CompletedMessagesBlock:
+ """Completed metadata and opaque streamed tool input, kept off the wire."""
+
+ body: JsonObject
+ tool_arguments: str | None = None
+
+
class NativeMessagesStreamState:
"""Validate one upstream lifecycle without rewriting protocol identities."""
@@ -45,7 +52,7 @@ def __init__(self) -> None:
def accept(
self, event_type: str, payload: Mapping[str, JsonValue]
- ) -> JsonObject | None:
+ ) -> CompletedMessagesBlock | None:
if self.completed:
raise NativeMessagesError("Messages event arrived after message_stop.")
try:
@@ -216,21 +223,17 @@ def accept(
raise NativeMessagesError(
"Completed native thinking requires its signature."
)
- if block.body["type"] == "tool_use" and block.parts:
- try:
- value = cast(JsonValue, json.loads("".join(block.parts)))
- if not isinstance(value, Mapping):
- raise NativeMessagesError(
- "Native tool arguments must decode to an object."
- )
- json.dumps(value, ensure_ascii=False, allow_nan=False).encode("utf-8")
- except (ValueError, UnicodeError, RecursionError) as exc:
- raise NativeMessagesError(
- "Native tool arguments are incomplete or invalid JSON."
- ) from exc
- block.body["input"] = dict(value)
+ arguments = None
+ if block.body["type"] == "tool_use":
+ arguments = (
+ "".join(block.parts)
+ if block.parts
+ else json.dumps(
+ block.body["input"], ensure_ascii=False, separators=(",", ":")
+ )
+ )
self._blocks.pop(index)
- return block.body
+ return CompletedMessagesBlock(block.body, arguments)
class NativeMessagesRelay:
@@ -287,10 +290,12 @@ def feed(self, event_type: str, payload: Mapping[str, JsonValue]) -> str:
return ""
if (
completed is not None
- and completed.get("type") == "thinking"
- and completed.get("signature")
+ and completed.body.get("type") == "thinking"
+ and completed.body.get("signature")
):
- signature = encode_replay(ReplayRecord(self._replay_origin, completed))
+ signature = encode_replay(
+ ReplayRecord(self._replay_origin, completed.body)
+ )
prefix = _event(
"content_block_delta",
{
diff --git a/src/free_claude_code/core/anthropic/sse_aggregation.py b/src/free_claude_code/core/anthropic/sse_aggregation.py
index 58940a1fbb..1823392002 100644
--- a/src/free_claude_code/core/anthropic/sse_aggregation.py
+++ b/src/free_claude_code/core/anthropic/sse_aggregation.py
@@ -12,6 +12,8 @@
from collections.abc import AsyncIterator
from typing import Any
+from free_claude_code.core.failures import ExecutionFailure, FailureKind
+
from .stream_contracts import SSEEvent
from .streaming.decoder import AnthropicSSEDecoder
@@ -120,14 +122,22 @@ def handle_event(event: SSEEvent) -> None:
elif btype == "thinking":
block["thinking"] = str(block.get("thinking", "")) + accumulated
block.setdefault("signature", "")
- elif btype == "tool_use":
- if accumulated.strip():
- try:
- block["input"] = json.loads(accumulated)
- except json.JSONDecodeError:
- block["input"] = block.get("input") or {}
- elif not isinstance(block.get("input"), dict):
- block["input"] = {}
+ elif btype == "tool_use" and error is None:
+ try:
+ value = (
+ json.loads(accumulated) if parts.get(idx) else block.get("input")
+ )
+ if not isinstance(value, dict):
+ raise ValueError("Tool input must be an object.")
+ json.dumps(value, ensure_ascii=False, allow_nan=False).encode("utf-8")
+ except (ValueError, UnicodeError, RecursionError) as exc:
+ raise ExecutionFailure(
+ FailureKind.UPSTREAM,
+ 502,
+ "Provider tool arguments cannot be represented as a Messages input object.",
+ False,
+ ) from exc
+ block["input"] = value
content.append(block)
message["content"] = content
diff --git a/src/free_claude_code/core/anthropic/tools.py b/src/free_claude_code/core/anthropic/tools.py
deleted file mode 100644
index 3b56ace541..0000000000
--- a/src/free_claude_code/core/anthropic/tools.py
+++ /dev/null
@@ -1,493 +0,0 @@
-"""Heuristic parser for text-emitted tool calls."""
-
-import json
-import re
-import uuid
-from collections.abc import Mapping
-from dataclasses import dataclass
-from enum import Enum
-from typing import Any
-
-from loguru import logger
-
-from ..openai_tool_names import OpenAIToolNameCodec
-from .models import MessagesRequest
-from .tool_schema import arguments_match_schema, coerce_text_argument
-
-_CONTROL_TOKEN_RE = re.compile(r"<\|[^|>]{1,80}\|>")
-_CONTROL_TOKEN_START = "<|"
-_CONTROL_TOKEN_END = "|>"
-_FUNCTION_TAG_BLOCK_START = ""
-_FUNCTION_TAG_BLOCK_END = ""
-_FUNCTION_TAG_START = ""
-_PARAMETER_TAG_START = ""
-_MAX_FUNCTION_TAG_CANDIDATE_CHARS = 4 * 1024 * 1024
-
-
-@dataclass(frozen=True, slots=True)
-class _RawFunctionTagCall:
- name: str
- arguments: dict[str, str]
-
-
-class _FunctionTagState(Enum):
- SEARCHING = 1
- CANDIDATE = 2
- DISABLED = 3
- FINISHED = 4
-
-
-class FunctionTagToolParser:
- """Parse an exact terminal function-tag envelope into tool use."""
-
- def __init__(self, request: MessagesRequest):
- schemas: dict[str, dict[str, Any]] = {}
- for tool in request.tools or ():
- if tool.name:
- schemas[tool.name] = (
- tool.input_schema
- if tool.input_schema is not None
- else {"type": "object"}
- )
- tool_choice = request.tool_choice
- tool_choice_type = (
- tool_choice.get("type") if isinstance(tool_choice, dict) else None
- )
- self._initialize(
- tool_names=OpenAIToolNameCodec.from_request(request),
- schemas=schemas,
- enabled=tool_choice_type != "none",
- )
-
- @classmethod
- def from_schemas(
- cls,
- *,
- tool_names: OpenAIToolNameCodec,
- schemas: Mapping[str, Mapping[str, Any]],
- enabled: bool,
- ) -> FunctionTagToolParser:
- """Build the parser from a protocol-neutral Chat tool contract."""
- parser = cls.__new__(cls)
- parser._initialize(
- tool_names=tool_names,
- schemas={name: dict(schema) for name, schema in schemas.items()},
- enabled=enabled,
- )
- return parser
-
- def _initialize(
- self,
- *,
- tool_names: OpenAIToolNameCodec,
- schemas: dict[str, dict[str, Any]],
- enabled: bool,
- ) -> None:
- self._tool_names = tool_names
- self._schemas = schemas
- self._state = (
- _FunctionTagState.SEARCHING
- if self._schemas and enabled
- else _FunctionTagState.DISABLED
- )
- self._parts: list[str] = []
- self._length = 0
- self._marker_tail = ""
-
- def feed(self, text: str) -> str:
- """Hold a possible reserved response and return text safe to expose."""
- if not text:
- return ""
- if self._state in {_FunctionTagState.DISABLED, _FunctionTagState.FINISHED}:
- return text
-
- if self._state is _FunctionTagState.CANDIDATE:
- self._parts.append(text)
- self._length += len(text)
- if self._length > _MAX_FUNCTION_TAG_CANDIDATE_CHARS:
- return self.disable()
- return ""
-
- candidate = "".join((self._marker_tail, text))
- marker_index = candidate.find(_FUNCTION_TAG_BLOCK_START)
- if marker_index >= 0:
- visible = candidate[:marker_index]
- control = candidate[marker_index:]
- self._state = _FunctionTagState.CANDIDATE
- self._marker_tail = ""
- self._parts.append(control)
- self._length = len(control)
- if self._length > _MAX_FUNCTION_TAG_CANDIDATE_CHARS:
- return "".join((visible, self.disable()))
- return visible
-
- held_length = _partial_function_tag_marker_suffix_length(candidate)
- if held_length:
- self._marker_tail = candidate[-held_length:]
- return candidate[:-held_length]
- self._marker_tail = ""
- return candidate
-
- def disable(self) -> str:
- """Disable textual recovery and release any held candidate unchanged."""
- if self._state in {_FunctionTagState.DISABLED, _FunctionTagState.FINISHED}:
- return ""
- self._state = _FunctionTagState.DISABLED
- text = "".join((self._marker_tail, *self._parts))
- self._marker_tail = ""
- self._parts.clear()
- self._length = 0
- return text
-
- def finish(self) -> tuple[str, tuple[dict[str, Any], ...]]:
- """Finalize one response atomically as visible text or validated tools."""
- if self._state in {_FunctionTagState.DISABLED, _FunctionTagState.FINISHED}:
- return "", ()
- if self._state is _FunctionTagState.SEARCHING:
- self._state = _FunctionTagState.FINISHED
- text = self._marker_tail
- self._marker_tail = ""
- return text, ()
- self._state = _FunctionTagState.FINISHED
- raw = "".join(self._parts)
- self._parts.clear()
- self._length = 0
- try:
- calls = _parse_function_tag_calls(raw)
- tool_uses = self._validated_tool_uses(calls)
- except ValueError:
- return raw, ()
- return "", tool_uses
-
- def _validated_tool_uses(
- self, calls: tuple[_RawFunctionTagCall, ...]
- ) -> tuple[dict[str, Any], ...]:
- tool_uses: list[dict[str, Any]] = []
- for call in calls:
- name = self._tool_names.decode(call.name)
- schema = self._schemas.get(name)
- if schema is None:
- raise ValueError
- properties = schema.get("properties")
- property_schemas = properties if isinstance(properties, Mapping) else {}
- additional = schema.get("additionalProperties")
- arguments: dict[str, Any] = {}
- for parameter_name, value in call.arguments.items():
- parameter_schema = property_schemas.get(parameter_name)
- if not isinstance(parameter_schema, Mapping):
- parameter_schema = (
- additional if isinstance(additional, Mapping) else {}
- )
- arguments[parameter_name] = coerce_text_argument(
- value,
- parameter_schema,
- )
- if not arguments_match_schema(arguments, schema):
- raise ValueError
- tool_uses.append(
- {
- "type": "tool_use",
- "id": f"toolu_function_tag_{uuid.uuid4().hex[:8]}",
- "name": name,
- "input": arguments,
- }
- )
- return tuple(tool_uses)
-
-
-def _parse_function_tag_calls(text: str) -> tuple[_RawFunctionTagCall, ...]:
- cursor = 0
- calls: list[_RawFunctionTagCall] = []
- while True:
- cursor = _skip_function_tag_whitespace(text, cursor)
- if cursor == len(text):
- break
- if not text.startswith(_FUNCTION_TAG_BLOCK_START, cursor):
- raise ValueError
-
- block_start = cursor + len(_FUNCTION_TAG_BLOCK_START)
- block_end = text.find(_FUNCTION_TAG_BLOCK_END, block_start)
- if block_end < 0:
- raise ValueError
- name, arguments = _parse_function_tag_block(text[block_start:block_end])
- calls.append(_RawFunctionTagCall(name=name, arguments=arguments))
- cursor = block_end + len(_FUNCTION_TAG_BLOCK_END)
-
- if not calls:
- raise ValueError
- return tuple(calls)
-
-
-def _parse_function_tag_block(block: str) -> tuple[str, dict[str, str]]:
- cursor = _skip_function_tag_whitespace(block, 0)
- if not block.startswith(_FUNCTION_TAG_START, cursor):
- raise ValueError
- name_end = block.find(">", cursor + len(_FUNCTION_TAG_START))
- if name_end < 0:
- raise ValueError
- name = block[cursor + len(_FUNCTION_TAG_START) : name_end]
- if not _valid_function_tag_name(name):
- raise ValueError
-
- arguments: dict[str, str] = {}
- cursor = name_end + 1
- while True:
- cursor = _skip_function_tag_whitespace(block, cursor)
- if block.startswith(_FUNCTION_TAG_END, cursor):
- cursor += len(_FUNCTION_TAG_END)
- break
- if cursor == len(block) or not block.startswith(_PARAMETER_TAG_START, cursor):
- raise ValueError
-
- parameter_name_end = block.find(">", cursor + len(_PARAMETER_TAG_START))
- if parameter_name_end < 0:
- raise ValueError
- parameter_name = block[cursor + len(_PARAMETER_TAG_START) : parameter_name_end]
- if not _valid_function_tag_name(parameter_name) or parameter_name in arguments:
- raise ValueError
-
- value_start = parameter_name_end + 1
- value_end = block.find(_PARAMETER_TAG_END, value_start)
- if value_end < 0:
- raise ValueError
- arguments[parameter_name] = _unwrap_function_tag_newlines(
- block[value_start:value_end]
- )
- cursor = value_end + len(_PARAMETER_TAG_END)
-
- if block[cursor:].strip():
- raise ValueError
- return name, arguments
-
-
-def _skip_function_tag_whitespace(text: str, cursor: int) -> int:
- while cursor < len(text) and text[cursor].isspace():
- cursor += 1
- return cursor
-
-
-def _valid_function_tag_name(value: str) -> bool:
- return bool(value) and not any(
- character.isspace() or character in "<>" for character in value
- )
-
-
-def _unwrap_function_tag_newlines(value: str) -> str:
- if value.startswith("\r\n"):
- value = value[2:]
- elif value.startswith("\n"):
- value = value[1:]
- if value.endswith("\r\n"):
- return value[:-2]
- if value.endswith("\n"):
- return value[:-1]
- return value
-
-
-def _partial_function_tag_marker_suffix_length(text: str) -> int:
- max_length = min(len(text), len(_FUNCTION_TAG_BLOCK_START) - 1)
- for length in range(max_length, 0, -1):
- if _FUNCTION_TAG_BLOCK_START.startswith(text[-length:]):
- return length
- return 0
-
-
-class ParserState(Enum):
- TEXT = 1
- MATCHING_FUNCTION = 2
- PARSING_PARAMETERS = 3
-
-
-class HeuristicToolParser:
- """
- Stateful parser for raw text tool calls.
-
- Some OpenAI-compatible models emit tool calls as text rather than structured
- chunks. This parser converts the common ``● `` form into
- Anthropic-style ``tool_use`` blocks.
- """
-
- _FUNC_START_PATTERN = re.compile(r"●\s*]+)>")
- _PARAM_PATTERN = re.compile(
- r"]+)>(.*?)(?:|$)", re.DOTALL
- )
- _WEB_TOOL_JSON_PATTERN = re.compile(
- r"(?is)\b(?:use\s+)?(?PWebFetch|WebSearch)\b.*?(?P\{.*?\})"
- )
-
- def __init__(self):
- self._state = ParserState.TEXT
- self._buffer = ""
- self._current_tool_id = None
- self._current_function_name = None
- self._current_parameters = {}
-
- def _extract_web_tool_json_calls(self) -> tuple[str, list[dict[str, Any]]]:
- detected_tools: list[dict[str, Any]] = []
-
- for match in self._WEB_TOOL_JSON_PATTERN.finditer(self._buffer):
- try:
- tool_input = json.loads(match.group("json"))
- except json.JSONDecodeError:
- continue
- if not isinstance(tool_input, dict):
- continue
-
- tool_name = match.group("tool")
- if tool_name == "WebFetch" and "url" not in tool_input:
- continue
- if tool_name == "WebSearch" and "query" not in tool_input:
- continue
-
- detected_tools.append(
- {
- "type": "tool_use",
- "id": f"toolu_heuristic_{uuid.uuid4().hex[:8]}",
- "name": tool_name,
- "input": tool_input,
- }
- )
- logger.debug(
- "Heuristic bypass: Detected JSON-style tool call '{}'",
- tool_name,
- )
-
- if not detected_tools:
- return self._buffer, []
-
- return "", detected_tools
-
- def _strip_control_tokens(self, text: str) -> str:
- return _CONTROL_TOKEN_RE.sub("", text)
-
- def _split_incomplete_control_token_tail(self) -> str:
- start = self._buffer.rfind(_CONTROL_TOKEN_START)
- if start == -1:
- return ""
- end = self._buffer.find(_CONTROL_TOKEN_END, start)
- if end != -1:
- return ""
-
- prefix = self._buffer[:start]
- self._buffer = self._buffer[start:]
- return prefix
-
- def feed(self, text: str) -> tuple[str, list[dict[str, Any]]]:
- """Feed text and return safe text plus detected tool calls."""
- self._buffer += text
- self._buffer = self._strip_control_tokens(self._buffer)
- self._buffer, detected_tools = self._extract_web_tool_json_calls()
- filtered_output_parts: list[str] = []
-
- while True:
- if self._state == ParserState.TEXT:
- if "●" in self._buffer:
- idx = self._buffer.find("●")
- filtered_output_parts.append(self._buffer[:idx])
- self._buffer = self._buffer[idx:]
- self._state = ParserState.MATCHING_FUNCTION
- else:
- safe_prefix = self._split_incomplete_control_token_tail()
- if safe_prefix:
- filtered_output_parts.append(safe_prefix)
- break
-
- filtered_output_parts.append(self._buffer)
- self._buffer = ""
- break
-
- if self._state == ParserState.MATCHING_FUNCTION:
- match = self._FUNC_START_PATTERN.search(self._buffer)
- if match:
- self._current_function_name = match.group(1).strip()
- self._current_tool_id = f"toolu_heuristic_{uuid.uuid4().hex[:8]}"
- self._current_parameters = {}
- self._buffer = self._buffer[match.end() :]
- self._state = ParserState.PARSING_PARAMETERS
- logger.debug(
- "Heuristic bypass: Detected start of tool call '{}'",
- self._current_function_name,
- )
- elif len(self._buffer) > 100:
- filtered_output_parts.append(self._buffer[0])
- self._buffer = self._buffer[1:]
- self._state = ParserState.TEXT
- else:
- break
-
- if self._state == ParserState.PARSING_PARAMETERS:
- finished_tool_call = False
-
- while True:
- param_match = self._PARAM_PATTERN.search(self._buffer)
- if param_match and "" in param_match.group(0):
- pre_match_text = self._buffer[: param_match.start()]
- if pre_match_text:
- filtered_output_parts.append(pre_match_text)
-
- key = param_match.group(1).strip()
- val = param_match.group(2).strip()
- self._current_parameters[key] = val
- self._buffer = self._buffer[param_match.end() :]
- else:
- break
-
- if "●" in self._buffer:
- idx = self._buffer.find("●")
- if idx > 0:
- filtered_output_parts.append(self._buffer[:idx])
- self._buffer = self._buffer[idx:]
- finished_tool_call = True
- elif len(self._buffer) > 0 and not self._buffer.strip().startswith("<"):
- if " list[dict[str, Any]]:
- """Flush any remaining tool call in the buffer."""
- self._buffer = self._strip_control_tokens(self._buffer)
- detected_tools = []
- if self._state == ParserState.PARSING_PARAMETERS:
- partial_matches = re.finditer(
- r"]+)>(.*)$", self._buffer, re.DOTALL
- )
- for match in partial_matches:
- key = match.group(1).strip()
- val = match.group(2).strip()
- self._current_parameters[key] = val
-
- detected_tools.append(
- {
- "type": "tool_use",
- "id": self._current_tool_id,
- "name": self._current_function_name,
- "input": self._current_parameters,
- }
- )
- self._state = ParserState.TEXT
- self._buffer = ""
-
- return detected_tools
diff --git a/src/free_claude_code/core/history_replay.py b/src/free_claude_code/core/history_replay.py
index 5e8c298fc5..7543ff5242 100644
--- a/src/free_claude_code/core/history_replay.py
+++ b/src/free_claude_code/core/history_replay.py
@@ -2,7 +2,7 @@
import base64
import json
-from collections.abc import Mapping
+from collections.abc import Mapping, Sequence
from copy import deepcopy
from dataclasses import asdict, dataclass
from enum import StrEnum
@@ -13,6 +13,7 @@
type HistoryProtocol = Literal["responses", "messages", "chat"]
_PREFIX = "fcc:history:v1:"
+_ASSOCIATED_PREFIX = "fcc:history:v2:"
_LEGACY_PREFIX = "fcc:anthropic-reasoning:v1:"
@@ -52,6 +53,12 @@ class ReplayRecord:
native: JsonObject
+@dataclass(frozen=True, slots=True)
+class AssociatedReplayRecord(ReplayRecord):
+ group_id: str
+ part: Literal["anchor", "final"]
+
+
def _json_copy(value: object) -> JsonObject:
if not isinstance(value, Mapping):
raise HistoryReplayError("History replay must contain a native object.")
@@ -86,15 +93,22 @@ def is_replay(value: object) -> bool:
def encode_replay(record: ReplayRecord) -> str:
"""Preserve native state, without claiming to encrypt or authenticate it."""
payload = _json_copy({"origin": asdict(record.origin), "native": record.native})
- _validate_record(payload)
+ associated = isinstance(record, AssociatedReplayRecord)
+ if associated:
+ payload.update(group_id=record.group_id, part=record.part)
+ _validate_record(payload, associated=associated)
raw = json.dumps(payload, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
- return _PREFIX + base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=")
+ prefix = _ASSOCIATED_PREFIX if associated else _PREFIX
+ return prefix + base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=")
-def _validate_record(payload: JsonObject) -> ReplayRecord:
+def _validate_record(payload: JsonObject, *, associated: bool = False) -> ReplayRecord:
origin, native = payload.get("origin"), payload.get("native")
+ fields = (
+ {"origin", "native", "group_id", "part"} if associated else {"origin", "native"}
+ )
if (
- set(payload) != {"origin", "native"}
+ set(payload) != fields
or not isinstance(origin, dict)
or set(origin) != {"provider", "protocol", "endpoint", "connection", "model"}
or not all(isinstance(value, str) for value in origin.values())
@@ -129,7 +143,7 @@ def _validate_record(payload: JsonObject) -> ReplayRecord:
)
if protocol == "chat" and not isinstance(native.get("reasoning_details"), list):
raise HistoryReplayError("Chat replay requires structured reasoning details.")
- return ReplayRecord(
+ record = ReplayRecord(
ReplayOrigin(
str(origin["provider"]),
protocol,
@@ -139,11 +153,32 @@ def _validate_record(payload: JsonObject) -> ReplayRecord:
),
native,
)
+ if associated:
+ group_id, part = payload.get("group_id"), payload.get("part")
+ if (
+ protocol != "chat"
+ or not isinstance(group_id, str)
+ or not group_id
+ or not isinstance(part, str)
+ or part not in {"anchor", "final"}
+ ):
+ raise HistoryReplayError("Invalid reasoning replay association.")
+ return AssociatedReplayRecord(
+ record.origin,
+ record.native,
+ group_id,
+ part,
+ )
+ return record
def decode_replay(value: str) -> ReplayRecord:
prefix = next(
- (prefix for prefix in (_PREFIX, _LEGACY_PREFIX) if value.startswith(prefix)),
+ (
+ prefix
+ for prefix in (_PREFIX, _ASSOCIATED_PREFIX, _LEGACY_PREFIX)
+ if value.startswith(prefix)
+ ),
None,
)
if prefix is None:
@@ -175,7 +210,54 @@ def decode_replay(value: str) -> ReplayRecord:
},
"native": payload["block"],
}
- return _validate_record(payload)
+ return _validate_record(payload, associated=prefix == _ASSOCIATED_PREFIX)
+
+
+def resolve_messages_replay(content: Sequence[JsonValue]) -> list[JsonValue]:
+ """Join late metadata within one assistant message on a fresh copy."""
+ blocks = deepcopy(list(content))
+ groups: dict[str, dict[str, tuple[int, str, AssociatedReplayRecord]]] = {}
+ for index, block in enumerate(blocks):
+ if not isinstance(block, dict):
+ continue
+ key = (
+ "signature"
+ if block.get("type") == "thinking"
+ else "data"
+ if block.get("type") == "redacted_thinking"
+ else None
+ )
+ if key is None or not is_replay(value := block.get(key)):
+ continue
+ record = decode_replay(cast(str, value))
+ if not isinstance(record, AssociatedReplayRecord):
+ continue
+ group = groups.setdefault(record.group_id, {})
+ if record.part in group or any(
+ previous.origin != record.origin for _, _, previous in group.values()
+ ):
+ raise HistoryReplayError("Conflicting reasoning replay association.")
+ if record.part == "final" and key != "data":
+ raise HistoryReplayError(
+ "Final reasoning replay requires a redacted carrier."
+ )
+ group[record.part] = (index, key, record)
+ removed: set[int] = set()
+ for group in groups.values():
+ anchor, final = group.get("anchor"), group.get("final")
+ if anchor and final and anchor[0] >= final[0]:
+ raise HistoryReplayError("Reasoning replay final precedes its anchor.")
+ target = anchor or final
+ assert target is not None
+ index, key, record = target
+ if final:
+ record = final[2]
+ block = blocks[index]
+ assert isinstance(block, dict)
+ block[key] = encode_replay(ReplayRecord(record.origin, record.native))
+ if anchor and final:
+ removed.add(final[0])
+ return [block for index, block in enumerate(blocks) if index not in removed]
def readable_reasoning(item: Mapping[str, JsonValue]) -> list[tuple[str, bool]]:
diff --git a/src/free_claude_code/core/openai_responses/__init__.py b/src/free_claude_code/core/openai_responses/__init__.py
index 32b65c1518..b879fa9aec 100644
--- a/src/free_claude_code/core/openai_responses/__init__.py
+++ b/src/free_claude_code/core/openai_responses/__init__.py
@@ -32,7 +32,6 @@
reasoning_output_item,
tool_item,
)
-from .streaming.error_mapping import replay_unsafe_function_call_error
from .streaming.event_builders import ResponseEventBuilder
from .streaming.ledger import ResponsesOutputLedger
from .tokens import estimate_responses_input_tokens
@@ -82,7 +81,6 @@
"openai_error_type_for_failure",
"openai_failure_payload",
"reasoning_output_item",
- "replay_unsafe_function_call_error",
"responses_reasoning_config",
"responses_reasoning_policy",
"responses_stream_failure_from_event",
diff --git a/src/free_claude_code/core/openai_responses/chat_request.py b/src/free_claude_code/core/openai_responses/chat_request.py
index 26814935e7..40bfc71a22 100644
--- a/src/free_claude_code/core/openai_responses/chat_request.py
+++ b/src/free_claude_code/core/openai_responses/chat_request.py
@@ -23,7 +23,6 @@
image_tool_result_label,
)
from free_claude_code.core.openai_tool_names import OpenAIToolNameCodec
-from free_claude_code.core.trace import trace_event
from .errors import ResponsesConversionError
from .models import OpenAIResponsesRequest
@@ -35,7 +34,6 @@
from .tools import (
call_id_from_item,
optional_str,
- parse_arguments,
required_str,
)
@@ -107,7 +105,6 @@ def __init__(
self._structured_reasoning_details = structured_reasoning_details
self._pending_reasoning = _PendingReasoning()
self._pending_rich_output_parts: list[dict[str, object]] = []
- self._quarantined_call_ids: set[str] = set()
self._discovery_outputs: list[dict[str, object]] = []
def add(self, item: JsonValue, *, source_type: str | None = None) -> None:
@@ -208,19 +205,6 @@ def _add_tool_call(self, item: Mapping[str, JsonValue]) -> None:
call_id = call_id_from_item(item)
name = required_str(item.get("name"), "function_call.name")
raw_arguments = item.get("arguments")
- try:
- parse_arguments(raw_arguments)
- except ResponsesConversionError as exc:
- self._quarantined_call_ids.add(call_id)
- self._pending_reasoning.take()
- trace_event(
- stage="responses",
- event="responses.input.function_call_quarantined",
- source="openai_responses",
- call_id=call_id,
- error_type=type(exc).__name__,
- )
- return
arguments = _arguments_text(raw_arguments)
message: dict[str, object] | None = self._last_tool_call_message()
@@ -249,8 +233,6 @@ def _add_tool_output(
) -> None:
function = source_type != "custom_tool_call_output"
call_id = call_id_from_item(item)
- if function and call_id in self._quarantined_call_ids:
- return
if not self._pending_reasoning.empty:
previous = self._last_tool_call_message()
if previous is not None:
diff --git a/src/free_claude_code/core/openai_responses/messages_stream.py b/src/free_claude_code/core/openai_responses/messages_stream.py
index d06b86de3b..cdb22c74d3 100644
--- a/src/free_claude_code/core/openai_responses/messages_stream.py
+++ b/src/free_claude_code/core/openai_responses/messages_stream.py
@@ -8,7 +8,10 @@
from typing import cast
from free_claude_code.core.anthropic.native import NativeMessagesError
-from free_claude_code.core.anthropic.native_stream import NativeMessagesStreamState
+from free_claude_code.core.anthropic.native_stream import (
+ CompletedMessagesBlock,
+ NativeMessagesStreamState,
+)
from free_claude_code.core.failures import ExecutionFailure
from free_claude_code.core.history_replay import (
ReplayOrigin,
@@ -17,7 +20,7 @@
)
from free_claude_code.core.json_types import JsonObject, JsonValue
-from .errors import ResponsesConversionError, openai_error_from_failure
+from .errors import openai_error_from_failure
from .ids import (
new_message_item_id,
new_reasoning_item_id,
@@ -129,7 +132,6 @@ def __init__(
self._completer = ResponseBlockCompleter(
self._ledger,
events=self._events,
- on_invalid_function_call=self._invalid_function,
)
@property
@@ -336,13 +338,13 @@ def _delta(self, index: int, delta: Mapping[str, JsonValue]) -> list[str]:
if isinstance(state, ReasoningBlockState) and kind == "signature_delta":
return []
if isinstance(state, ToolBlockState) and kind == "input_json_delta":
- # The completer emits validated arguments once, after native block stop.
+ # The completer emits the accumulated arguments once, at block stop.
return []
raise NativeMessagesError(
"Responses cannot represent the native content delta."
)
- def _finish_block(self, index: int, block: JsonObject) -> list[str]:
+ def _finish_block(self, index: int, completed: CompletedMessagesBlock) -> list[str]:
state = self._ledger.active_block(index)
if state is None:
raise NativeMessagesError(
@@ -350,32 +352,32 @@ def _finish_block(self, index: int, block: JsonObject) -> list[str]:
)
if isinstance(state, ReasoningBlockState):
state.encrypted_content = encode_replay(
- ReplayRecord(self._replay_origin, block)
+ ReplayRecord(self._replay_origin, completed.body)
)
elif isinstance(state, ToolBlockState):
- arguments = block.get("input")
- if not isinstance(arguments, Mapping):
- raise NativeMessagesError("Native tool input must be an object.")
- if state.kind == "custom" and (
- set(arguments) != {"input"}
- or not isinstance(arguments.get("input"), str)
- ):
- raise NativeMessagesError(
- "Native custom tool input must contain exactly one text input."
- )
- state.argument_parts.append(
- json.dumps(arguments, ensure_ascii=False, separators=(",", ":"))
- )
+ arguments = completed.tool_arguments
+ if arguments is None:
+ raise AssertionError("Completed native tool must retain its arguments.")
+ if state.kind == "custom":
+ try:
+ wrapper = json.loads(arguments)
+ except (ValueError, RecursionError) as exc:
+ raise NativeMessagesError(
+ "Invalid native custom tool wrapper."
+ ) from exc
+ if (
+ not isinstance(wrapper, dict)
+ or set(wrapper) != {"input"}
+ or not isinstance(wrapper["input"], str)
+ ):
+ raise NativeMessagesError(
+ "Native custom tool input must contain exactly one text input."
+ )
+ state.argument_parts.append(arguments)
events = self._completer.complete_block(state)
self._ledger.pop_active_block(index)
return events
- @staticmethod
- def _invalid_function(
- _state: ToolBlockState, error: ResponsesConversionError
- ) -> list[str]:
- raise NativeMessagesError("Native tool arguments are invalid.") from error
-
def terminal_failure(self, failure: ExecutionFailure) -> list[str]:
"""Terminate once without completing partial tools or inventing arguments."""
diff --git a/src/free_claude_code/core/openai_responses/streaming/completion.py b/src/free_claude_code/core/openai_responses/streaming/completion.py
index bb19d19140..658b0e3b04 100644
--- a/src/free_claude_code/core/openai_responses/streaming/completion.py
+++ b/src/free_claude_code/core/openai_responses/streaming/completion.py
@@ -1,21 +1,13 @@
"""Block finalization for OpenAI Responses streams."""
-from collections.abc import Callable
-
-from ..errors import ResponsesConversionError
from ..items import encrypted_reasoning_item, message_item, reasoning_item
from ..tools import (
custom_tool_input_text_from_arguments,
- normalized_function_call_arguments,
)
from .blocks import BlockState, ReasoningBlockState, TextBlockState, ToolBlockState
from .event_builders import ResponseEventBuilder
from .ledger import ResponsesOutputLedger
-InvalidFunctionCallHandler = Callable[
- [ToolBlockState, ResponsesConversionError], list[str]
-]
-
class ResponseBlockCompleter:
"""Finalize active Responses output blocks."""
@@ -25,13 +17,9 @@ def __init__(
ledger: ResponsesOutputLedger,
*,
events: ResponseEventBuilder,
- on_invalid_function_call: InvalidFunctionCallHandler,
- prepare_tool_arguments: Callable[[str, str], str] | None = None,
) -> None:
self._ledger = ledger
self._events = events
- self._on_invalid_function_call = on_invalid_function_call
- self._prepare_tool_arguments = prepare_tool_arguments
def complete_block(self, state: BlockState) -> list[str]:
if isinstance(state, TextBlockState):
@@ -68,17 +56,8 @@ def _complete_reasoning_block(self, state: ReasoningBlockState) -> list[str]:
def _complete_tool_block(self, state: ToolBlockState) -> list[str]:
if state.kind == "custom":
return self._complete_custom_tool_block(state)
- raw_arguments = "".join(state.argument_parts)
- try:
- arguments = (
- self._prepare_tool_arguments(state.name, raw_arguments)
- if self._prepare_tool_arguments is not None
- else normalized_function_call_arguments(raw_arguments or "{}")
- )
- except ResponsesConversionError as exc:
- return self._on_invalid_function_call(state, exc)
+ arguments = "".join(state.argument_parts)
item = tool_item(state, status="completed", arguments=arguments)
- self._ledger.commit_output(state.output_index, item)
chunks: list[str] = []
if arguments:
chunks.append(
@@ -94,6 +73,7 @@ def _complete_tool_block(self, state: ToolBlockState) -> list[str]:
self._events.output_item_done(state.output_index, item),
]
)
+ self._ledger.commit_output(state.output_index, item)
return chunks
def _complete_custom_tool_block(self, state: ToolBlockState) -> list[str]:
diff --git a/src/free_claude_code/core/openai_responses/streaming/error_mapping.py b/src/free_claude_code/core/openai_responses/streaming/error_mapping.py
index ad5878b905..619401b83e 100644
--- a/src/free_claude_code/core/openai_responses/streaming/error_mapping.py
+++ b/src/free_claude_code/core/openai_responses/streaming/error_mapping.py
@@ -14,15 +14,3 @@ def openai_error_from_anthropic_error(data: Mapping[str, Any]) -> dict[str, Any]
"param": None,
"code": None,
}
-
-
-def replay_unsafe_function_call_error() -> dict[str, Any]:
- return {
- "message": (
- "Upstream function_call arguments were not a valid JSON object; "
- "refusing to emit replay-unsafe Responses output."
- ),
- "type": "api_error",
- "param": None,
- "code": None,
- }
diff --git a/src/free_claude_code/core/openai_responses/tool_adaptation.py b/src/free_claude_code/core/openai_responses/tool_adaptation.py
index 0c5b92a8ec..5a7ef66ac8 100644
--- a/src/free_claude_code/core/openai_responses/tool_adaptation.py
+++ b/src/free_claude_code/core/openai_responses/tool_adaptation.py
@@ -416,35 +416,6 @@ def _identity(self, item: Mapping[str, JsonValue]) -> ResponsesToolIdentity | No
raise ResponsesConversionError("Ambiguous tool name returned by provider.")
return next(iter(candidates)) if len(candidates) == 1 else None
- def prepare_arguments(
- self, name: str, arguments: str, *, namespace: str | None = None
- ) -> str:
- """Validate provider arguments before publishing a completed tool call."""
- identity = self._identity({"name": name, "namespace": namespace})
- if identity is not None and identity.kind == "custom":
- return json.dumps(
- {"input": custom_tool_input_text_from_arguments(arguments)},
- ensure_ascii=False,
- separators=(",", ":"),
- )
- try:
- parsed = json.loads(
- arguments,
- parse_float=_canonical_number,
- parse_constant=_reject_json_constant,
- )
- except ValueError as exc:
- raise ResponsesConversionError("Invalid tool call arguments.") from exc
- if not isinstance(parsed, dict):
- raise ResponsesConversionError("Tool call arguments must be a JSON object.")
- return simplejson.dumps(
- parsed,
- ensure_ascii=False,
- separators=(",", ":"),
- allow_nan=False,
- use_decimal=True,
- )
-
def restore_item(self, value: JsonValue) -> JsonValue:
if not isinstance(value, dict):
return value
@@ -456,18 +427,6 @@ def restore_item(self, value: JsonValue) -> JsonValue:
if value.get("type") != "function_call":
return value
identity = self._identity(value)
- if value.get("status") == "completed" and (
- self._policy.client_tool_search or self._policy.flatten_namespaces
- ):
- raw = value.get("arguments")
- value = {
- **value,
- "arguments": self.prepare_arguments(
- _name(value),
- raw if isinstance(raw, str) else "",
- namespace=_namespace(value),
- ),
- }
if self._is_search(value):
arguments = value.get("arguments")
if not is_unfinished_search_call(value):
@@ -662,7 +621,6 @@ def __init__(self, tools: ResponsesToolAdapter) -> None:
self._tools = tools
self._custom_items: set[str] = set()
self._search_items: set[str] = set()
- self._function_items: set[str] = set()
self._sequence = 0
def feed(
@@ -676,34 +634,6 @@ def feed(
item = self._tools.restore_item(original_item)
if isinstance(item, dict):
data["item"] = item
- if item.get("type") == "function_call" and (
- self._tools._policy.client_tool_search
- or self._tools._policy.flatten_namespaces
- ):
- if isinstance(item_id := item.get("id"), str):
- self._function_items.add(item_id)
- if event_type == "response.output_item.added":
- item["arguments"] = ""
- if (
- event_type == "response.output_item.done"
- and item.get("status") == "completed"
- ):
- coordinates = {
- "item_id": item.get("id"),
- "output_index": data.get("output_index"),
- }
- arguments = item.get("arguments", "")
- yield self._emit(
- "response.function_call_arguments.delta",
- {**coordinates, "delta": arguments},
- )
- identity = {
- key: item[key] for key in ("name", "namespace") if key in item
- }
- yield self._emit(
- "response.function_call_arguments.done",
- {**coordinates, **identity, "arguments": arguments},
- )
if item.get("type") == "tool_search_call":
if isinstance(item_id := item.get("id"), str):
self._search_items.add(item_id)
@@ -742,8 +672,7 @@ def feed(
"response.function_call_arguments.delta",
"response.function_call_arguments.done",
}
- and data.get("item_id")
- in self._custom_items | self._search_items | self._function_items
+ and data.get("item_id") in self._custom_items | self._search_items
):
return
if (
diff --git a/src/free_claude_code/core/openai_responses/tools.py b/src/free_claude_code/core/openai_responses/tools.py
index 7375283712..2f19b760f3 100644
--- a/src/free_claude_code/core/openai_responses/tools.py
+++ b/src/free_claude_code/core/openai_responses/tools.py
@@ -41,30 +41,6 @@ def flatten_responses_tool_name(name: str, *, namespace: str | None = None) -> s
return f"{combined[:prefix_len]}_{digest}"
-def parse_arguments(value: Any) -> dict[str, Any]:
- if value is None or value == "":
- return {}
- if isinstance(value, dict):
- return value
- if not isinstance(value, str):
- raise ResponsesConversionError("Responses function_call arguments must be JSON")
- try:
- parsed = json.loads(value)
- except json.JSONDecodeError as exc:
- raise ResponsesConversionError(
- f"Responses function_call arguments are invalid JSON: {exc.msg}"
- ) from exc
- if not isinstance(parsed, dict):
- raise ResponsesConversionError(
- "Responses function_call arguments must decode to an object"
- )
- return parsed
-
-
-def normalized_function_call_arguments(value: Any) -> str:
- return json.dumps(parse_arguments(value), separators=(",", ":"))
-
-
def custom_tool_input_text(value: Any) -> str:
if value is None:
return ""
diff --git a/src/free_claude_code/providers/anthropic_messages/transport.py b/src/free_claude_code/providers/anthropic_messages/transport.py
index 7710ac7d31..b19207bc97 100644
--- a/src/free_claude_code/providers/anthropic_messages/transport.py
+++ b/src/free_claude_code/providers/anthropic_messages/transport.py
@@ -58,6 +58,7 @@
is_retryable_stream_error,
)
from free_claude_code.providers.history_replay import (
+ normalize_messages_history,
replay_origin,
validate_history,
)
@@ -121,7 +122,7 @@ def _messages_body(
capabilities: MessagesModelCapabilities,
preserve_native_controls: bool,
) -> PreparedMessagesRequest:
- validate_history(request.model_dump(mode="json"))
+ request = normalize_messages_history(request)
try:
options = resolve_messages_options(
model=request.model,
diff --git a/src/free_claude_code/providers/history_replay.py b/src/free_claude_code/providers/history_replay.py
index b462a36fba..224c3f4a09 100644
--- a/src/free_claude_code/providers/history_replay.py
+++ b/src/free_claude_code/providers/history_replay.py
@@ -5,10 +5,11 @@
import re
from collections.abc import Iterator, Mapping
from copy import deepcopy
-from typing import TYPE_CHECKING, Any
+from typing import TYPE_CHECKING, Any, cast
from urllib.parse import urlsplit, urlunsplit
from free_claude_code.application.errors import InvalidRequestError
+from free_claude_code.core.anthropic.models import Message, MessagesRequest
from free_claude_code.core.diagnostics import extract_upstream_error_detail
from free_claude_code.core.history_replay import (
HistoryProtocol,
@@ -18,7 +19,9 @@
is_replay,
readable_reasoning,
reasoning_context,
+ resolve_messages_replay,
)
+from free_claude_code.core.json_types import JsonValue
from .endpoint_types import HttpEndpoint
@@ -39,6 +42,25 @@ def validate_history(body: Mapping[str, Any]) -> None:
raise InvalidRequestError(str(error)) from error
+def normalize_messages_history(request: MessagesRequest) -> MessagesRequest:
+ """Resolve persisted associations before any lossy protocol conversion."""
+ validate_history(request.model_dump(mode="json"))
+ messages = []
+ try:
+ for message in request.messages:
+ if message.role != "assistant" or isinstance(message.content, str):
+ messages.append(message)
+ continue
+ body = message.model_dump(mode="json")
+ body["content"] = resolve_messages_replay(
+ cast(list[JsonValue], body["content"])
+ )
+ messages.append(Message.model_validate(body))
+ except HistoryReplayError as error:
+ raise InvalidRequestError(str(error)) from error
+ return request.model_copy(update={"messages": messages})
+
+
def replay_origin(
provider: str,
protocol: HistoryProtocol,
diff --git a/src/free_claude_code/providers/lmstudio/client.py b/src/free_claude_code/providers/lmstudio/client.py
index 3967efebea..ca20888a28 100644
--- a/src/free_claude_code/providers/lmstudio/client.py
+++ b/src/free_claude_code/providers/lmstudio/client.py
@@ -7,8 +7,7 @@
(``[TOOL_CALLS]Read``) and dumping whole tool calls into text
(``Read[ARGS]{...}``), which ends agent runs silently. The OpenAI
``/v1/chat/completions`` path is LM Studio's mature parsing route, and fcc's
-OpenAI provider layers its own tool-call assembly, think-tag parsing, and
-heuristic recovery on top.
+OpenAI provider layers its own native tool-call assembly and think-tag parsing on top.
"""
import asyncio
diff --git a/src/free_claude_code/core/anthropic/tool_schema.py b/src/free_claude_code/providers/nvidia_nim/native_tool_schema.py
similarity index 97%
rename from src/free_claude_code/core/anthropic/tool_schema.py
rename to src/free_claude_code/providers/nvidia_nim/native_tool_schema.py
index b5b9104cba..4a51345264 100644
--- a/src/free_claude_code/core/anthropic/tool_schema.py
+++ b/src/free_claude_code/providers/nvidia_nim/native_tool_schema.py
@@ -1,4 +1,4 @@
-"""JSON Schema helpers for text-emitted Anthropic tool input."""
+"""JSON Schema helpers for NIM model-native tool arguments."""
import json
from collections.abc import Mapping
diff --git a/src/free_claude_code/providers/nvidia_nim/native_tool_stream.py b/src/free_claude_code/providers/nvidia_nim/native_tool_stream.py
index e1d239d519..8bce306965 100644
--- a/src/free_claude_code/providers/nvidia_nim/native_tool_stream.py
+++ b/src/free_claude_code/providers/nvidia_nim/native_tool_stream.py
@@ -8,13 +8,14 @@
from types import SimpleNamespace
from typing import Any
-from free_claude_code.core.anthropic.tool_schema import (
+from free_claude_code.providers.failure_policy import RetryableToolProtocolError
+from free_claude_code.providers.http import maybe_await_aclose
+
+from .native_tool_schema import (
arguments_match_schema,
coerce_text_argument,
schema_type,
)
-from free_claude_code.providers.failure_policy import RetryableToolProtocolError
-from free_claude_code.providers.http import maybe_await_aclose
_NAMESPACE = "]<]minimax[>["
_TOOL_BLOCK_START = f"{_NAMESPACE}"
diff --git a/src/free_claude_code/providers/openai_chat/reasoning_details.py b/src/free_claude_code/providers/openai_chat/reasoning_details.py
index 27eb3db4bc..84d183fab1 100644
--- a/src/free_claude_code/providers/openai_chat/reasoning_details.py
+++ b/src/free_claude_code/providers/openai_chat/reasoning_details.py
@@ -2,22 +2,59 @@
from collections.abc import Iterator, Mapping, Sequence
from copy import deepcopy
+from dataclasses import dataclass, field
from typing import Any, Literal, cast
+from uuid import uuid4
-from free_claude_code.core.history_replay import readable_reasoning
+from free_claude_code.core.history_replay import (
+ ReplayOrigin,
+ ReplayRecord,
+ readable_reasoning,
+)
from free_claude_code.core.json_types import JsonObject, JsonValue
from .stream_output import ChatStreamOutput
+@dataclass(slots=True)
+class _ReasoningGroup:
+ origin: ReplayOrigin
+ id: str = field(default_factory=lambda: uuid4().hex)
+ details: list[dict[str, Any]] = field(default_factory=list)
+ slots: dict[tuple[object, object], int] = field(default_factory=dict)
+ native_parts: list[str] = field(default_factory=list)
+ after_content: bool = False
+
+ def snapshot(self) -> ReplayRecord:
+ native: JsonObject = {
+ "reasoning_details": cast(list[JsonValue], deepcopy(self.details))
+ }
+ if self.native_parts:
+ native["reasoning_content"] = "".join(self.native_parts)
+ return ReplayRecord(self.origin, native)
+
+
class StructuredReasoningStream:
- """Reconcile alternate plaintext reasoning representations for one stream."""
+ """Collect replay groups independently of their visible wire blocks."""
def __init__(self) -> None:
self._text_source: Literal["native", "details"] | None = None
- self._details: list[dict[str, Any]] = []
- self._slots: dict[tuple[object, object], int] = {}
- self._native_parts: list[str] = []
+ self._group: _ReasoningGroup | None = None
+
+ @property
+ def active(self) -> bool:
+ return self._group is not None
+
+ def before_reasoning(self, output: ChatStreamOutput) -> Iterator[str]:
+ if self._group is not None and self._group.after_content:
+ yield from self.finish(output)
+
+ def before_content(self, output: ChatStreamOutput) -> Iterator[str]:
+ if self._group is not None and not self._group.after_content:
+ self._group.after_content = True
+ yield from output.pause_reasoning_record(
+ self._group.id, self._group.snapshot()
+ )
def events(
self,
@@ -26,36 +63,45 @@ def events(
*,
native_reasoning: str | None,
) -> Iterator[str]:
- """Emit plaintext once while preserving every opaque reasoning detail."""
details = _reasoning_details(delta)
- if native_reasoning is not None:
- self._native_parts.append(native_reasoning)
if self._text_source is None:
if native_reasoning:
self._text_source = "native"
elif any(_reasoning_detail_text(detail) for detail in details):
self._text_source = "details"
-
- if self._text_source == "native" and native_reasoning:
- yield from output.ensure_reasoning_block()
- yield output.emit_reasoning_delta(native_reasoning)
-
+ visible = (
+ [native_reasoning]
+ if self._text_source == "native" and native_reasoning
+ else [
+ text for detail in details if (text := _reasoning_detail_text(detail))
+ ]
+ if self._text_source == "details"
+ else []
+ )
+ if visible:
+ yield from self.before_reasoning(output)
+ if self._group is None and (native_reasoning is not None or details):
+ if output.replay_origin is None:
+ raise AssertionError(
+ "Structured reasoning requires attempt provenance."
+ )
+ self._group = _ReasoningGroup(output.replay_origin)
+ yield from output.begin_reasoning_record()
+ group = self._group
+ if group is None:
+ return
+ if native_reasoning is not None:
+ group.native_parts.append(native_reasoning)
for detail in details:
- if self._text_source == "details":
- text = _reasoning_detail_text(detail)
- if text:
- yield from output.ensure_reasoning_block()
- yield output.emit_reasoning_delta(text)
-
if isinstance(detail, Mapping):
value = deepcopy(dict(detail))
identity = value.get("index", value.get("id"))
key = (identity, value.get("type"))
- if isinstance(identity, str | int) and key in self._slots:
- current = self._details[self._slots[key]]
- for field, part in value.items():
+ if isinstance(identity, str | int) and key in group.slots:
+ current = group.details[group.slots[key]]
+ for name, part in value.items():
if (
- field
+ name
in {
"text",
"summary",
@@ -65,27 +111,23 @@ def events(
"signature",
}
and isinstance(part, str)
- and isinstance(current.get(field), str)
+ and isinstance(current.get(name), str)
):
- current[field] += part
+ current[name] += part
else:
- current[field] = part
+ current[name] = part
else:
if isinstance(identity, str | int):
- self._slots[key] = len(self._details)
- self._details.append(value)
-
- def flush(self, output: ChatStreamOutput) -> Iterator[str]:
- if self._details:
- native: JsonObject = {
- "reasoning_details": cast(list[JsonValue], self._details)
- }
- if self._native_parts:
- native["reasoning_content"] = "".join(self._native_parts)
- yield from output.emit_reasoning_replay(native)
- self._details.clear()
- self._slots.clear()
- self._native_parts.clear()
+ group.slots[key] = len(group.details)
+ group.details.append(value)
+ for text in visible:
+ yield from output.ensure_reasoning_block()
+ yield output.emit_reasoning_delta(text)
+
+ def finish(self, output: ChatStreamOutput) -> Iterator[str]:
+ group, self._group = self._group, None
+ if group is not None:
+ yield from output.complete_reasoning_record(group.id, group.snapshot())
def _reasoning_details(delta: Any) -> Sequence[Any]:
diff --git a/src/free_claude_code/providers/openai_chat/request_policy.py b/src/free_claude_code/providers/openai_chat/request_policy.py
index 65378cf627..8cd7108086 100644
--- a/src/free_claude_code/providers/openai_chat/request_policy.py
+++ b/src/free_claude_code/providers/openai_chat/request_policy.py
@@ -19,7 +19,9 @@
encode_openai_chat_tool_names,
)
from free_claude_code.core.reasoning import ReasoningPolicy
-from free_claude_code.providers.history_replay import validate_history
+from free_claude_code.providers.history_replay import (
+ normalize_messages_history,
+)
MaxTokensField = Literal["max_tokens", "max_completion_tokens"]
OpenAIChatPostprocessor = Callable[
@@ -52,7 +54,7 @@ def build_openai_chat_request_body(
postprocessors: Iterable[OpenAIChatPostprocessor] = (),
) -> dict[str, Any]:
"""Build an OpenAI-compatible chat request body from an Anthropic request."""
- validate_history(request_data.model_dump(mode="json"))
+ request_data = normalize_messages_history(request_data)
logger.debug(
"{}_REQUEST: conversion start model={} msgs={}",
policy.provider_name,
diff --git a/src/free_claude_code/providers/openai_chat/stream_output.py b/src/free_claude_code/providers/openai_chat/stream_output.py
index 86ad9ed1fc..9a7a9074c8 100644
--- a/src/free_claude_code/providers/openai_chat/stream_output.py
+++ b/src/free_claude_code/providers/openai_chat/stream_output.py
@@ -1,19 +1,20 @@
"""Chat-source output writers for Anthropic Messages and OpenAI Responses."""
-import json
import time
import uuid
from abc import ABC, abstractmethod
-from collections.abc import Callable, Iterator, Mapping
+from collections.abc import Iterator, Mapping
from dataclasses import dataclass, field
+from typing import Protocol
from free_claude_code.core.anthropic.streaming import (
AnthropicStreamLedger,
ToolSchema,
parse_complete_tool_input,
)
-from free_claude_code.core.failures import ExecutionFailure
+from free_claude_code.core.failures import ExecutionFailure, FailureKind
from free_claude_code.core.history_replay import (
+ AssociatedReplayRecord,
ReplayOrigin,
ReplayRecord,
encode_replay,
@@ -34,11 +35,20 @@
new_response_id,
openai_error_from_failure,
reasoning_output_item,
- replay_unsafe_function_call_error,
tool_item,
)
from free_claude_code.core.token_estimation import estimate_text_tokens
-from free_claude_code.core.trace import trace_event
+
+
+class ReasoningReplayLifecycle(Protocol):
+ @property
+ def active(self) -> bool: ...
+
+ def before_reasoning(self, output: ChatStreamOutput) -> Iterator[str]: ...
+
+ def before_content(self, output: ChatStreamOutput) -> Iterator[str]: ...
+
+ def finish(self, output: ChatStreamOutput) -> Iterator[str]: ...
@dataclass(frozen=True, slots=True)
@@ -78,9 +88,7 @@ class ChatStreamOutput(ABC):
def __init__(self, *, input_tokens: int) -> None:
self.input_tokens = input_tokens
self.replay_origin: ReplayOrigin | None = None
- self.reasoning_replay_events: (
- Callable[[ChatStreamOutput], Iterator[str]] | None
- ) = None
+ self.reasoning_replay: ReasoningReplayLifecycle | None = None
self.tool_states: dict[int, ChatToolState] = {}
self._text_parts: list[str] = []
self._reasoning_parts: list[str] = []
@@ -106,6 +114,8 @@ def start_events(self) -> list[str]:
def ensure_reasoning_block(self) -> list[str]:
events: list[str] = []
+ if self.reasoning_replay is not None:
+ events.extend(self.reasoning_replay.before_reasoning(self))
if self._text_started:
events.extend(self._stop_text_block())
self._text_started = False
@@ -119,29 +129,41 @@ def emit_reasoning_delta(self, content: str) -> str:
self._reasoning_parts.append(content)
return self._emit_reasoning_delta(content)
- def emit_reasoning_replay(self, native: JsonObject) -> list[str]:
- data = json.dumps(native["reasoning_details"], separators=(",", ":"))
- if self.replay_origin is not None:
- data = encode_replay(ReplayRecord(self.replay_origin, native))
- if self._reasoning_started:
- return self._attach_reasoning_replay(data)
- events = self._close_content_blocks()
- events.extend(self._emit_opaque_reasoning(data))
- if data:
- self._content_started = True
- return events
+ def begin_reasoning_record(self) -> list[str]:
+ return []
+
+ def pause_reasoning_record(self, group_id: str, record: ReplayRecord) -> list[str]:
+ return []
+
+ def complete_reasoning_record(
+ self, group_id: str, record: ReplayRecord
+ ) -> list[str]:
+ data = encode_replay(record)
+ if self._reasoning_started:
+ events = self._attach_reasoning_replay(data)
+ events.extend(self._stop_reasoning_block())
+ self._reasoning_started = False
+ return events
+ return self._emit_opaque_reasoning(data)
def flush_reasoning_replay(self) -> list[str]:
- """Save the complete record when parsed output ends a reasoning block."""
- if self.reasoning_replay_events is None:
+ if self.reasoning_replay is None:
return []
- return list(self.reasoning_replay_events(self))
+ return list(self.reasoning_replay.finish(self))
- def ensure_text_block(self) -> list[str]:
- events = self.flush_reasoning_replay()
- if self._reasoning_started:
+ def _pause_reasoning(self) -> list[str]:
+ events: list[str] = []
+ if self.reasoning_replay is not None:
+ events.extend(self.reasoning_replay.before_content(self))
+ if self._reasoning_started and not (
+ self.reasoning_replay and self.reasoning_replay.active
+ ):
events.extend(self._stop_reasoning_block())
self._reasoning_started = False
+ return events
+
+ def ensure_text_block(self) -> list[str]:
+ events = self._pause_reasoning()
if not self._text_started:
events.extend(self._start_text_block())
self._text_started = True
@@ -153,10 +175,20 @@ def emit_text_delta(self, content: str) -> str:
return self._emit_text_delta(content)
def close_content_blocks(self) -> list[str]:
+ events = self._pause_reasoning()
+ if self._text_started:
+ events.extend(self._stop_text_block())
+ self._text_started = False
+ return events
+
+ def finish_reasoning_group(self) -> list[str]:
events = self.flush_reasoning_replay()
events.extend(self._close_content_blocks())
return events
+ def finish_replay_carriers(self) -> list[str]:
+ return []
+
def _close_content_blocks(self) -> list[str]:
events: list[str] = []
if self._reasoning_started:
@@ -215,10 +247,11 @@ def stop_tool_block(self, tool_index: int) -> list[str]:
return self._stop_tool_block(tool_index, state)
def close_all_blocks(self) -> list[str]:
- events = self.close_content_blocks()
+ events = self.finish_reasoning_group()
for tool_index, state in self.tool_states.items():
if state.open:
events.extend(self.stop_tool_block(tool_index))
+ events.extend(self.finish_replay_carriers())
return events
def close_unclosed_blocks(self) -> list[str]:
@@ -231,7 +264,9 @@ def has_content_block(self) -> bool:
return self._content_started
def final_stop_reason(self, fallback: str) -> str:
- return "tool_use" if self.has_emitted_tool_block() else fallback
+ if self.has_emitted_tool_block():
+ return "tool_use"
+ return "end_turn" if fallback == "tool_use" else fallback
def tool_block_for_tool_index(self, tool_index: int) -> ChatToolState | None:
state = self.tool_states.get(tool_index)
@@ -347,6 +382,8 @@ def __init__(
log_raw_events: bool = False,
) -> None:
super().__init__(input_tokens=input_tokens)
+ self._anchored_groups: set[str] = set()
+ self._pending_replay: list[str] = []
self._ledger = AnthropicStreamLedger(
message_id,
model,
@@ -354,9 +391,56 @@ def __init__(
log_raw_events=log_raw_events,
)
+ def pause_reasoning_record(self, group_id: str, record: ReplayRecord) -> list[str]:
+ if group_id in self._anchored_groups:
+ return []
+ if not self._reasoning_started and any(
+ state.open for state in self.tool_states.values()
+ ):
+ return []
+ self._anchored_groups.add(group_id)
+ data = encode_replay(
+ AssociatedReplayRecord(record.origin, record.native, group_id, "anchor")
+ )
+ self._content_started = True
+ if self._reasoning_started:
+ events = self._attach_reasoning_replay(data)
+ events.extend(self._stop_reasoning_block())
+ self._reasoning_started = False
+ return events
+ events = self._close_content_blocks()
+ events.extend(self._emit_opaque_reasoning(data))
+ return events
+
+ def complete_reasoning_record(
+ self, group_id: str, record: ReplayRecord
+ ) -> list[str]:
+ if group_id in self._anchored_groups:
+ self._anchored_groups.remove(group_id)
+ self._pending_replay.append(
+ encode_replay(
+ AssociatedReplayRecord(
+ record.origin, record.native, group_id, "final"
+ )
+ )
+ )
+ return []
+ if self._reasoning_started:
+ return super().complete_reasoning_record(group_id, record)
+ self._pending_replay.append(encode_replay(record))
+ self._content_started = True
+ return []
+
+ def finish_replay_carriers(self) -> list[str]:
+ pending, self._pending_replay = self._pending_replay, []
+ return [
+ event for data in pending for event in self._emit_opaque_reasoning(data)
+ ]
+
def close_unclosed_blocks(self) -> list[str]:
events = self.flush_reasoning_replay()
events.extend(self._ledger.close_unclosed_blocks())
+ events.extend(self.finish_replay_carriers())
self._text_started = False
self._reasoning_started = False
for state in self.tool_states.values():
@@ -452,16 +536,21 @@ def __init__(
self._completer = ResponseBlockCompleter(
self._ledger,
events=self._events,
- on_invalid_function_call=self._fail_invalid_function_call,
- prepare_tool_arguments=tool_adapter.prepare_arguments,
)
self._text_state: TextBlockState | None = None
self._reasoning_state: ReasoningBlockState | None = None
self._tool_output_states: dict[int, ToolBlockState] = {}
self._usage: dict[str, object] | None = None
- self._provisional_error: dict[str, object] | None = None
+ self._conversion_failure: ExecutionFailure | None = None
self._started = False
+ def begin_reasoning_record(self) -> list[str]:
+ if self._reasoning_started:
+ return []
+ self._reasoning_started = True
+ self._content_started = True
+ return self._start_reasoning_block()
+
def _response_payload(
self,
*,
@@ -629,16 +718,23 @@ def _stop_tool_block(self, tool_index: int, state: ChatToolState) -> list[str]:
if output_state is None:
return []
self._ledger.pop_active_block(output_state.index)
- return self._completer.complete_block(output_state)
+ try:
+ return self._completer.complete_block(output_state)
+ except ResponsesConversionError:
+ # Object-valued protocol conversions (such as tool search) cannot
+ # carry opaque malformed JSON. Ordinary function arguments can.
+ self._conversion_failure = ExecutionFailure(
+ FailureKind.UPSTREAM,
+ 502,
+ "Provider tool output cannot be represented in the requested protocol.",
+ False,
+ )
+ return []
def _finish_success(self, *, stop_reason: str, usage: ChatStreamUsage) -> list[str]:
self._usage = _responses_usage(usage)
- if self._provisional_error is not None:
- response = self._response_payload(
- status="failed",
- error=self._provisional_error,
- )
- return [self._events.response_failed(response)]
+ if self._conversion_failure is not None:
+ return self._finish_failure(self._conversion_failure)
if stop_reason in {"length", "max_tokens"}:
response = self._response_payload(
status="incomplete",
@@ -655,21 +751,6 @@ def _finish_failure(self, failure: ExecutionFailure) -> list[str]:
)
return [self._events.response_failed(response)]
- def _fail_invalid_function_call(
- self, state: ToolBlockState, exc: ResponsesConversionError
- ) -> list[str]:
- trace_event(
- stage="responses",
- event="responses.output.function_call_invalid_arguments",
- source="openai_responses",
- call_id=state.call_id,
- tool_name=state.name,
- error_type=type(exc).__name__,
- )
- if self._provisional_error is None:
- self._provisional_error = replay_unsafe_function_call_error()
- return []
-
def _responses_usage(usage: ChatStreamUsage) -> dict[str, object]:
cached_tokens = usage.cached_tokens
diff --git a/src/free_claude_code/providers/openai_chat/tool_calls.py b/src/free_claude_code/providers/openai_chat/tool_calls.py
index 363f4b3f9f..6e2476d751 100644
--- a/src/free_claude_code/providers/openai_chat/tool_calls.py
+++ b/src/free_claude_code/providers/openai_chat/tool_calls.py
@@ -117,31 +117,6 @@ def completed_calls(
return tuple(completed)
-def iter_heuristic_tool_use_events(
- output: ChatStreamOutput,
- tool_use: dict[str, Any],
- *,
- tool_names: OpenAIToolNameCodec | None = None,
-) -> Iterator[str]:
- """Emit SSE for one heuristic tool_use block."""
- name = tool_use.get("name")
- if tool_names is not None and isinstance(name, str):
- decoded_name = tool_names.decode(name)
- if decoded_name != name:
- tool_use = {**tool_use, "name": decoded_name}
- yield from output.close_content_blocks()
- tool_index = len(output.tool_states)
- output.ensure_tool_state(tool_index)
- output.register_tool_name(tool_index, str(tool_use["name"]))
- yield output.start_tool_block(
- tool_index,
- str(tool_use["id"]),
- str(tool_use["name"]),
- )
- yield output.emit_tool_delta(tool_index, json.dumps(tool_use["input"]))
- yield from output.stop_tool_block(tool_index)
-
-
def tool_call_extra_content(tool_call: Any) -> dict[str, Any] | None:
"""Return provider-specific extra tool-call metadata from OpenAI objects."""
if isinstance(tool_call, dict):
diff --git a/src/free_claude_code/providers/openai_chat/transport.py b/src/free_claude_code/providers/openai_chat/transport.py
index 112fdb26ed..db7f27150d 100644
--- a/src/free_claude_code/providers/openai_chat/transport.py
+++ b/src/free_claude_code/providers/openai_chat/transport.py
@@ -18,8 +18,6 @@
from free_claude_code.core.anthropic import (
ContentBlockToolUse,
ContentType,
- FunctionTagToolParser,
- HeuristicToolParser,
ThinkTagParser,
)
from free_claude_code.core.anthropic.models import MessagesRequest
@@ -115,7 +113,6 @@
CompletedOpenAIToolCall,
OpenAIToolCallAssembler,
OpenAIToolCallCollector,
- iter_heuristic_tool_use_events,
tool_call_extra_content,
)
from .usage import (
@@ -147,39 +144,6 @@ def _iter_visible_text_events(
yield output.emit_text_delta(text)
-def _iter_text_parser_events(
- output: ChatStreamOutput,
- parser: HeuristicToolParser,
- text: str,
- *,
- tool_names: OpenAIToolNameCodec,
-) -> Iterator[str]:
- """Route visible text through the established heuristic tool parser."""
- filtered_text, detected_tools = parser.feed(text)
- if filtered_text:
- yield from _iter_visible_text_events(output, filtered_text)
- for tool_use in detected_tools:
- yield from iter_heuristic_tool_use_events(
- output,
- tool_use,
- tool_names=tool_names,
- )
-
-
-def _iter_text_tool_use_events(
- output: ChatStreamOutput,
- tool_uses: tuple[dict[str, Any], ...] | list[dict[str, Any]],
- *,
- tool_names: OpenAIToolNameCodec,
-) -> Iterator[str]:
- for tool_use in tool_uses:
- yield from iter_heuristic_tool_use_events(
- output,
- tool_use,
- tool_names=tool_names,
- )
-
-
@dataclass(frozen=True, slots=True)
class _OpenAIChatCompletion:
finish_reason: Any
@@ -224,7 +188,6 @@ def __init__(
output_reasoning: bool,
tool_names: OpenAIToolNameCodec,
tool_schemas: dict[str, ToolSchema],
- tool_choice_enabled: bool,
tool_calls: OpenAIToolCallAssembler,
extra_reasoning_events: _ExtraReasoningEvents,
) -> None:
@@ -237,21 +200,13 @@ def __init__(
self._tool_calls = tool_calls
self._extra_reasoning_events = extra_reasoning_events
self._think_parser = ThinkTagParser()
- self._function_tag_parser = FunctionTagToolParser.from_schemas(
- tool_names=tool_names,
- schemas={
- name: schema.input_schema for name, schema in tool_schemas.items()
- },
- enabled=tool_choice_enabled,
- )
- self._heuristic_parser = HeuristicToolParser()
self._structured_reasoning = (
StructuredReasoningStream()
if profile.structured_reasoning_details
else None
)
if self._structured_reasoning is not None:
- self._output.reasoning_replay_events = self._structured_reasoning.flush
+ self._output.reasoning_replay = self._structured_reasoning
self._finish_reason: Any = None
self._usage_info: Any = None
self._native_reasoning_seen = False
@@ -367,11 +322,6 @@ def feed(self, chunk: Any) -> Iterator[str]:
yield from self._extra_reasoning_events(delta, self._output)
native_tool_calls = delta.tool_calls
- if native_tool_calls:
- released_text = self._function_tag_parser.disable()
- if released_text:
- yield from _iter_visible_text_events(self._output, released_text)
-
if delta.content:
for part in self._think_parser.feed(delta.content):
if part.type == ContentType.THINKING:
@@ -380,14 +330,7 @@ def feed(self, chunk: Any) -> Iterator[str]:
yield from self._output.ensure_reasoning_block()
yield self._output.emit_reasoning_delta(part.content)
else:
- safe_text = self._function_tag_parser.feed(part.content)
- if safe_text:
- yield from _iter_text_parser_events(
- self._output,
- self._heuristic_parser,
- safe_text,
- tool_names=self._tool_names,
- )
+ yield from _iter_visible_text_events(self._output, part.content)
if native_tool_calls:
yield from self._output.close_content_blocks()
@@ -436,28 +379,8 @@ def finish_upstream(self) -> Iterator[str]:
yield from self._output.ensure_reasoning_block()
yield self._output.emit_reasoning_delta(remaining.content)
else:
- safe_text = self._function_tag_parser.feed(remaining.content)
- if safe_text:
- yield from _iter_text_parser_events(
- self._output,
- self._heuristic_parser,
- safe_text,
- tool_names=self._tool_names,
- )
+ yield from _iter_visible_text_events(self._output, remaining.content)
- fallback_text, function_tag_tools = self._function_tag_parser.finish()
- if fallback_text:
- yield from _iter_visible_text_events(self._output, fallback_text)
- yield from _iter_text_tool_use_events(
- self._output,
- function_tag_tools,
- tool_names=self._tool_names,
- )
- yield from _iter_text_tool_use_events(
- self._output,
- self._heuristic_parser.flush(),
- tool_names=self._tool_names,
- )
yield from self._output.flush_reasoning_replay()
self._upstream_finished = True
@@ -1505,7 +1428,9 @@ async def _recovery_events(
)
text_suffix = continuation_suffix(partial_text, recovered.text)
thinking_suffix = continuation_suffix(partial_thinking, recovered.thinking)
- events: list[str] = []
+ if not (thinking_suffix or text_suffix or recovered.tool_calls):
+ return None
+ events = output.flush_reasoning_replay()
if thinking_suffix:
events.extend(output.ensure_reasoning_block())
events.append(output.emit_reasoning_delta(thinking_suffix))
@@ -1628,10 +1553,6 @@ def extra_reasoning_events(
output_reasoning=output_reasoning,
tool_names=self._tool_names,
tool_schemas=self._tool_schemas,
- tool_choice_enabled=(
- bool(self._body.get("tools"))
- and self._body.get("tool_choice") != "none"
- ),
tool_calls=OpenAIToolCallAssembler(
reserved_tool_ids=self._reserved_tool_ids,
record_extra_content=self._transport._behavior.record_tool_call_extra_content,
diff --git a/src/free_claude_code/providers/openai_responses/transport.py b/src/free_claude_code/providers/openai_responses/transport.py
index 49d3457b0f..4503b39a2c 100644
--- a/src/free_claude_code/providers/openai_responses/transport.py
+++ b/src/free_claude_code/providers/openai_responses/transport.py
@@ -54,6 +54,7 @@
reports_context_window_incomplete,
)
from free_claude_code.providers.history_replay import (
+ normalize_messages_history,
replay_origin,
validate_history,
)
@@ -197,7 +198,7 @@ def _build_messages_body(
model_info: ProviderModelInfo | None = None,
can_disable_reasoning: bool = True,
) -> JsonObject:
- validate_history(request.model_dump(mode="json"))
+ request = normalize_messages_history(request)
request, reasoning = prepare_messages_reasoning(
request,
reasoning,
diff --git a/tests/contracts/test_stream_contracts.py b/tests/contracts/test_stream_contracts.py
index 0b4a7d8e79..87ba64fcbb 100644
--- a/tests/contracts/test_stream_contracts.py
+++ b/tests/contracts/test_stream_contracts.py
@@ -8,7 +8,6 @@
from free_claude_code.core.anthropic import (
AnthropicStreamLedger,
ContentType,
- HeuristicToolParser,
ThinkTagParser,
)
from free_claude_code.core.anthropic.stream_contracts import (
@@ -116,19 +115,6 @@ def test_enable_thinking_false_suppresses_reasoning_only() -> None:
assert text_content(events) == "hello world"
-def test_task_tool_parser_preserves_background_argument() -> None:
- parser = HeuristicToolParser()
- filtered, detected = parser.feed(
- "● Inspect"
- "true trailing"
- )
- detected.extend(parser.flush())
- assert "trailing" in filtered
- task = detected[0]
- assert task["name"] == "Task"
- assert task["input"]["run_in_background"] == "true"
-
-
def _interleaved_thinking_text_events(
parts: tuple[str, str, str, str],
) -> Iterable[str]:
diff --git a/tests/core/anthropic/test_native_messages_stream.py b/tests/core/anthropic/test_native_messages_stream.py
index 32971c4379..18aa25732b 100644
--- a/tests/core/anthropic/test_native_messages_stream.py
+++ b/tests/core/anthropic/test_native_messages_stream.py
@@ -69,7 +69,7 @@ async def test_native_citations_survive_block_completion_and_fragmented_aggregat
block = state.accept(cast(str, event["type"]), event)
if block is not None:
completed = block
- assert completed is not None and completed["citations"] == [citation]
+ assert completed is not None and completed.body["citations"] == [citation]
relay = NativeMessagesRelay(public_model="public")
async def stream() -> AsyncIterator[str]:
@@ -197,7 +197,7 @@ def test_reused_block_indexes_and_events_after_terminal_are_rejected() -> None:
@pytest.mark.parametrize("partial", ['{"x":', "[]", '{"x":NaN}'])
-def test_invalid_json_tools_never_complete(partial: str) -> None:
+def test_invalid_json_tools_are_forwarded_for_harness_validation(partial: str) -> None:
relay = NativeMessagesRelay(public_model="public")
relay.feed("message_start", _START)
relay.feed(
@@ -221,6 +221,9 @@ def test_invalid_json_tools_never_complete(partial: str) -> None:
"delta": {"type": "input_json_delta", "partial_json": partial},
},
)
- with pytest.raises(NativeMessagesError, match="arguments"):
- relay.feed("content_block_stop", {"type": "content_block_stop", "index": 0})
- assert not relay.completed
+ relay.feed("content_block_stop", {"type": "content_block_stop", "index": 0})
+ relay.feed(
+ "message_delta", {"type": "message_delta", "delta": {"stop_reason": "tool_use"}}
+ )
+ relay.feed("message_stop", {"type": "message_stop"})
+ assert relay.completed
diff --git a/tests/core/anthropic/test_text_tools.py b/tests/core/anthropic/test_text_tools.py
deleted file mode 100644
index a57276acb3..0000000000
--- a/tests/core/anthropic/test_text_tools.py
+++ /dev/null
@@ -1,258 +0,0 @@
-import pytest
-
-from free_claude_code.core.anthropic import FunctionTagToolParser
-from free_claude_code.core.anthropic import tools as tool_parsers
-from free_claude_code.core.openai_tool_names import OpenAIToolNameCodec
-from tests.providers.request_factory import make_messages_request
-
-
-def _request(*tools: dict):
- return make_messages_request(
- "test-model",
- messages=[],
- max_tokens=256,
- tools=list(tools),
- )
-
-
-def _tool(name: str, properties: dict, required: list[str] | None = None) -> dict:
- schema = {
- "type": "object",
- "properties": properties,
- "additionalProperties": False,
- }
- if required is not None:
- schema["required"] = required
- return {"name": name, "input_schema": schema}
-
-
-def _call(name: str, **arguments: str) -> str:
- parameters = "".join(
- f"\n{value}\n\n"
- for key, value in arguments.items()
- )
- return f"\n\n{parameters}\n"
-
-
-def _parse(parser: FunctionTagToolParser, *chunks: str):
- visible = "".join(parser.feed(chunk) for chunk in chunks)
- tail, calls = parser.finish()
- return visible + tail, calls
-
-
-def test_function_tag_parser_accepts_exact_reserved_response() -> None:
- parser = FunctionTagToolParser(
- _request(_tool("Bash", {"command": {"type": "string"}}, ["command"]))
- )
-
- visible, calls = _parse(parser, _call("Bash", command="printf ok"))
-
- assert visible == ""
- assert len(calls) == 1
- assert calls[0]["id"].startswith("toolu_function_tag_")
- assert calls[0]["name"] == "Bash"
- assert calls[0]["input"] == {"command": "printf ok"}
-
-
-def test_function_tag_parser_handles_every_two_chunk_split() -> None:
- raw = _call("Read", file_path="README.md")
- request = _request(_tool("Read", {"file_path": {"type": "string"}}, ["file_path"]))
-
- for split in range(len(raw) + 1):
- visible, calls = _parse(
- FunctionTagToolParser(request),
- raw[:split],
- raw[split:],
- )
- assert visible == "", split
- assert [call["input"] for call in calls] == [{"file_path": "README.md"}], split
-
-
-def test_function_tag_parser_streams_prefix_and_parses_terminal_call() -> None:
- request = _request(_tool("Bash", {"command": {"type": "string"}}, ["command"]))
- prefix = "I will run the requested command.\n"
- raw_call = _call("Bash", command="printf ok")
- parser = FunctionTagToolParser(request)
-
- first_visible = parser.feed(prefix + raw_call[:5])
- second_visible = parser.feed(raw_call[5:])
- tail, calls = parser.finish()
-
- assert first_visible == prefix
- assert second_visible == ""
- assert tail == ""
- assert [call["input"] for call in calls] == [{"command": "printf ok"}]
-
-
-def test_function_tag_parser_accepts_multiple_typed_calls_atomically() -> None:
- request = _request(
- _tool(
- "Configure",
- {
- "count": {"type": "integer"},
- "ratio": {"type": "number"},
- "enabled": {"type": "boolean"},
- "nothing": {"type": "null"},
- "items": {"type": "array", "items": {"type": "integer"}},
- "metadata": {"type": "object"},
- "mode": {"enum": ["fast", "safe"]},
- "fixed": {"const": 7},
- },
- [
- "count",
- "ratio",
- "enabled",
- "nothing",
- "items",
- "metadata",
- "mode",
- "fixed",
- ],
- ),
- _tool("Bash", {"command": {"type": "string"}}, ["command"]),
- )
- raw = "\n".join(
- (
- _call(
- "Configure",
- count="2",
- ratio="1.5",
- enabled="true",
- nothing="null",
- items="[1,2]",
- metadata='{"key":"value"}',
- mode="safe",
- fixed="7",
- ),
- _call("Bash", command="printf first\nprintf second"),
- )
- )
-
- visible, calls = _parse(FunctionTagToolParser(request), raw)
-
- assert visible == ""
- assert [call["name"] for call in calls] == ["Configure", "Bash"]
- assert calls[0]["input"] == {
- "count": 2,
- "ratio": 1.5,
- "enabled": True,
- "nothing": None,
- "items": [1, 2],
- "metadata": {"key": "value"},
- "mode": "safe",
- "fixed": 7,
- }
- assert calls[1]["input"] == {"command": "printf first\nprintf second"}
-
-
-def test_function_tag_parser_decodes_openai_tool_alias() -> None:
- original = "mcp__function_tag__" + "x" * 80
- request = _request(_tool(original, {}, []))
- alias = OpenAIToolNameCodec.from_request(request).encode(original)
-
- visible, calls = _parse(FunctionTagToolParser(request), _call(alias))
-
- assert visible == ""
- assert [call["name"] for call in calls] == [original]
-
-
-@pytest.mark.parametrize(
- "raw",
- [
- "Example: {call}\nThat was only an example.",
- "{call}\nDone.",
- "```xml\n{call}\n```",
- "",
- (
- ""
- "x"
- "y"
- ""
- ),
- "",
- "",
- "{wrong_type}",
- "{extra_parameter}",
- ],
-)
-def test_function_tag_parser_preserves_non_control_lookalikes(raw: str) -> None:
- valid_call = _call("Bash", command="printf ok")
- wrong_type = _call("Count", value="not-an-integer")
- extra_parameter = _call("Bash", command="printf ok", extra="no")
- raw = raw.format(
- call=valid_call,
- wrong_type=wrong_type,
- extra_parameter=extra_parameter,
- )
- request = _request(
- _tool("Bash", {"command": {"type": "string"}}, ["command"]),
- _tool("Count", {"value": {"type": "integer"}}, ["value"]),
- )
-
- visible, calls = _parse(FunctionTagToolParser(request), raw)
-
- assert visible == raw
- assert calls == ()
-
-
-def test_function_tag_parser_preserves_whole_multi_call_candidate_on_failure() -> None:
- request = _request(_tool("Bash", {"command": {"type": "string"}}, ["command"]))
- raw = _call("Bash", command="printf ok") + _call("Unknown")
-
- visible, calls = _parse(FunctionTagToolParser(request), raw)
-
- assert visible == raw
- assert calls == ()
-
-
-def test_function_tag_parser_without_tools_is_immediate_passthrough() -> None:
- raw = _call("Bash", command="printf ok")
- parser = FunctionTagToolParser(_request())
-
- assert parser.feed(raw) == raw
- assert parser.finish() == ("", ())
-
-
-def test_function_tag_parser_respects_explicit_none_choice() -> None:
- raw = _call("Bash", command="printf ok")
- request = make_messages_request(
- "test-model",
- messages=[],
- max_tokens=256,
- tools=[_tool("Bash", {"command": {"type": "string"}}, ["command"])],
- tool_choice={"type": "none"},
- )
- parser = FunctionTagToolParser(request)
-
- assert parser.feed(raw) == raw
- assert parser.finish() == ("", ())
-
-
-def test_function_tag_parser_disable_releases_candidate_once() -> None:
- raw = _call("Bash", command="printf ok")
- parser = FunctionTagToolParser(
- _request(_tool("Bash", {"command": {"type": "string"}}, ["command"]))
- )
-
- assert parser.feed(raw[:20]) == ""
- assert parser.disable() == raw[:20]
- assert parser.disable() == ""
- assert parser.feed(raw[20:]) == raw[20:]
- assert parser.finish() == ("", ())
-
-
-def test_function_tag_parser_releases_oversized_candidate(
- monkeypatch: pytest.MonkeyPatch,
-) -> None:
- raw = _call("Bash", command="printf ok")
- monkeypatch.setattr(
- tool_parsers,
- "_MAX_FUNCTION_TAG_CANDIDATE_CHARS",
- len(raw) - 1,
- )
- parser = FunctionTagToolParser(
- _request(_tool("Bash", {"command": {"type": "string"}}, ["command"]))
- )
-
- assert parser.feed(raw) == raw
- assert parser.finish() == ("", ())
diff --git a/tests/core/openai_responses/test_chat_request.py b/tests/core/openai_responses/test_chat_request.py
index 8b6d3f0cd3..8b80317517 100644
--- a/tests/core/openai_responses/test_chat_request.py
+++ b/tests/core/openai_responses/test_chat_request.py
@@ -260,7 +260,7 @@ def test_build_responses_chat_request_uses_provider_reasoning_replay(
}
-def test_build_responses_chat_request_quarantines_one_malformed_call_pair() -> None:
+def test_build_responses_chat_request_keeps_malformed_call_and_error_result() -> None:
translated = build_responses_chat_request(
_request(
input=[
@@ -274,7 +274,7 @@ def test_build_responses_chat_request_quarantines_one_malformed_call_pair() -> N
{
"type": "function_call_output",
"call_id": "call_bad",
- "output": "stale",
+ "output": "Invalid arguments",
},
{
"type": "function_call",
@@ -294,6 +294,19 @@ def test_build_responses_chat_request_quarantines_one_malformed_call_pair() -> N
assert translated.body["messages"] == [
{"role": "user", "content": "Hello"},
+ {
+ "role": "assistant",
+ "content": "",
+ "reasoning_content": "",
+ "tool_calls": [
+ {
+ "id": "call_bad",
+ "type": "function",
+ "function": {"name": "echo", "arguments": "{"},
+ }
+ ],
+ },
+ {"role": "tool", "tool_call_id": "call_bad", "content": "Invalid arguments"},
{
"role": "assistant",
"content": "",
diff --git a/tests/core/openai_responses/test_client_tool_discovery.py b/tests/core/openai_responses/test_client_tool_discovery.py
index cebe5b0663..a3f865dfa1 100644
--- a/tests/core/openai_responses/test_client_tool_discovery.py
+++ b/tests/core/openai_responses/test_client_tool_discovery.py
@@ -177,7 +177,7 @@ def test_native_namespaced_argument_done_event_matches_completed_item() -> None:
},
)
)
- assert done == []
+ assert len(done) == 1
completed = list(
events.feed(
"response.output_item.done",
@@ -192,13 +192,16 @@ def test_native_namespaced_argument_done_event_matches_completed_item() -> None:
)
)
done_event = next(
- data
- for kind, data in completed
- if kind == "response.function_call_arguments.done"
+ data for kind, data in done if kind == "response.function_call_arguments.done"
)
assert done_event["name"] == "spawn_agent"
assert done_event["namespace"] == "agents"
+ assert (
+ done_event["arguments"]
+ == cast(dict[str, Any], completed[-1][1]["item"])["arguments"]
+ )
+
def test_native_rejects_ambiguous_bare_names() -> None:
other = deepcopy(AGENTS)
@@ -313,17 +316,16 @@ def test_native_search_buffers_partial_added_arguments() -> None:
}
-def test_native_does_not_publish_empty_completed_arguments_as_success() -> None:
- adapter = _native_adapter([AGENTS])
- with pytest.raises(ResponsesConversionError, match="arguments"):
- adapter.restore_item(
- {
- "type": "function_call",
- "name": "agents__spawn_agent",
- "status": "completed",
- "arguments": "",
- }
- )
+def test_native_preserves_empty_completed_arguments_for_the_harness() -> None:
+ item = _native_adapter([AGENTS]).restore_item(
+ {
+ "type": "function_call",
+ "name": "agents__spawn_agent",
+ "status": "completed",
+ "arguments": "",
+ }
+ )
+ assert isinstance(item, dict) and item["arguments"] == ""
@pytest.mark.parametrize("terminal", ["failed", "incomplete"])
@@ -827,7 +829,7 @@ def test_flattened_function_collision_is_rejected() -> None:
@pytest.mark.parametrize("search", [True, False])
-def test_integral_json_numbers_are_accepted_by_native_codex(search: bool) -> None:
+def test_only_object_conversion_normalizes_integral_numbers(search: bool) -> None:
adapter = _native_adapter([SEARCH, AGENTS])
value = adapter.restore_item(
{
@@ -840,11 +842,11 @@ def test_integral_json_numbers_are_accepted_by_native_codex(search: bool) -> Non
assert isinstance(value, dict)
arguments = value["arguments"] if search else json.loads(str(value["arguments"]))
assert isinstance(arguments, dict)
- assert type(arguments["limit"]) is int
+ assert type(arguments["limit"]) is (int if search else float)
assert arguments["fraction"] == 0.25
nested = arguments["nested"]
assert isinstance(nested, list)
- assert type(nested[0]) is int
+ assert type(nested[0]) is (int if search else float)
def test_server_search_choice_stays_native() -> None:
@@ -964,7 +966,7 @@ def test_real_unnamespaced_tool_wins_over_bare_namespace_alias() -> None:
@pytest.mark.parametrize("prefix", ["", '{"limit":', '{"limit":8.0}'])
-def test_native_canonical_arguments_agree_across_the_stream(prefix: str) -> None:
+def test_native_original_arguments_agree_across_the_stream(prefix: str) -> None:
adapter = _native_adapter([{**AGENTS, "description": "Agent tools"}])
stream = adapter.event_adapter()
assert stream is not None
@@ -1017,7 +1019,7 @@ def test_native_canonical_arguments_agree_across_the_stream(prefix: str) -> None
done = next(
data for kind, data in events if kind == "response.function_call_arguments.done"
)
- assert delta == done["arguments"] == completed["arguments"]
+ assert prefix + delta == done["arguments"] == completed["arguments"]
sdk: ResponseStreamState[Any] = ResponseStreamState(
input_tools=omit, text_format=omit
)
@@ -1749,6 +1751,17 @@ def test_interleaved_tool_kinds_keep_arguments_and_terminal_output_consistent(
"arguments": "".join(fragments[i]),
}
completed.append(finished)
+ events.extend(
+ data
+ for _, data in stream.feed(
+ "response.function_call_arguments.done",
+ {
+ "item_id": item["id"],
+ "output_index": i,
+ "arguments": finished["arguments"],
+ },
+ )
+ )
events.extend(
data
for _, data in stream.feed(
@@ -1792,7 +1805,7 @@ def test_interleaved_tool_kinds_keep_arguments_and_terminal_output_consistent(
assert final[0]["arguments"] == {"query": "agent"}
assert final[1]["type"] == "function_call"
assert (final[1]["name"], final[1]["namespace"]) == ("lookup", "group")
- assert final[1]["arguments"] == '{"n":8}'
+ assert final[1]["arguments"] == '{"n":8.0}'
assert final[2]["type"] == "custom_tool_call"
assert (final[2]["name"], final[2]["namespace"], final[2]["input"]) == (
"edit",
@@ -1840,7 +1853,7 @@ def test_chat_preserves_custom_result_text_serialization() -> None:
@pytest.mark.parametrize("native", [False, True])
@pytest.mark.parametrize("search", [False, True])
@pytest.mark.parametrize("constant", ["NaN", "Infinity", "-Infinity"])
-def test_non_finite_arguments_cannot_complete_or_corrupt_sse(
+def test_non_finite_arguments_are_opaque_except_in_object_conversion(
native: bool, search: bool, constant: str
) -> None:
request = OpenAIResponsesRequest(
@@ -1848,16 +1861,23 @@ def test_non_finite_arguments_cannot_complete_or_corrupt_sse(
)
name = "fcc_tool_search" if search else "agents__spawn_agent"
arguments = '{"nested":[{"limit":' + constant + "}]}"
- if native:
+ if search and native:
with pytest.raises(ResponsesConversionError, match="arguments"):
- _completed_tool_events(request, native=True, name=name, arguments=arguments)
- else:
+ _completed_tool_events(
+ request, native=native, name=name, arguments=arguments
+ )
+ elif search:
events = _completed_tool_events(
- request, native=False, name=name, arguments=arguments
+ request, native=native, name=name, arguments=arguments
)
assert events[-1]["type"] == "response.failed"
assert events[-1]["response"]["output"] == []
- assert not any(event["type"] == "response.output_item.done" for event in events)
+ else:
+ events = _completed_tool_events(
+ request, native=native, name=name, arguments=arguments
+ )
+ assert events[-1]["type"] == "response.completed"
+ assert events[-1]["response"]["output"][0]["arguments"] == arguments
json.dumps(events, allow_nan=False)
@@ -1894,7 +1914,7 @@ def test_non_finite_spellings_in_json_strings_remain_valid(
item = events[-1]["response"]["output"][0]
arguments = item["arguments"] if search else json.loads(item["arguments"])
assert arguments == {"text": "NaN Infinity -Infinity", "limit": 8}
- assert type(arguments["limit"]) is int
+ assert type(arguments["limit"]) is (int if search else float)
json.dumps(events, allow_nan=False)
@@ -1997,7 +2017,9 @@ def test_argument_numbers_survive_sse_serialization(
else json.loads(item["arguments"], parse_float=Decimal)
)
assert actual == json.loads(arguments, parse_float=Decimal)
- assert type(actual["nested"]["values"][1]) is int
+ assert type(actual["nested"]["values"][1]) is (int if search else Decimal)
+ if not search:
+ assert item["arguments"] == arguments
@pytest.mark.parametrize("custom", [False, True])
diff --git a/tests/core/test_history_replay.py b/tests/core/test_history_replay.py
index 222fe936b7..0d52f2ba49 100644
--- a/tests/core/test_history_replay.py
+++ b/tests/core/test_history_replay.py
@@ -7,6 +7,7 @@
import pytest
from free_claude_code.core.history_replay import (
+ AssociatedReplayRecord,
HistoryReplayError,
HistoryScope,
ReplayOrigin,
@@ -14,6 +15,7 @@
decode_replay,
encode_replay,
prepare_history,
+ resolve_messages_replay,
responses_replay_item,
)
@@ -190,3 +192,115 @@ def test_chat_replay_keeps_full_text_and_summary_meaning(native_text):
else:
assert result["reasoning_content"] == "Full reasoning."
assert body == original
+
+
+def _associated(part, data, *, group_id="group-a", origin=None):
+ return encode_replay(
+ AssociatedReplayRecord(
+ origin or _origin(protocol="chat"),
+ {"reasoning_details": [{"type": "reasoning.encrypted", "data": data}]},
+ group_id,
+ part,
+ )
+ )
+
+
+@pytest.mark.parametrize("readable", [False, True])
+def test_late_replay_replaces_snapshot_at_original_position(readable):
+ anchor = (
+ {
+ "type": "thinking",
+ "thinking": "Plan.",
+ "signature": _associated("anchor", "first"),
+ }
+ if readable
+ else {"type": "redacted_thinking", "data": _associated("anchor", "first")}
+ )
+ text = {"type": "text", "text": "answer"}
+ blocks = [
+ anchor,
+ text,
+ {"type": "redacted_thinking", "data": _associated("final", "firstsecond")},
+ ]
+ original = deepcopy(blocks)
+ resolved = resolve_messages_replay(blocks)
+ assert len(resolved) == 2
+ assert resolved[1] == text
+ first = resolved[0]
+ assert isinstance(first, dict)
+ key = "signature" if readable else "data"
+ value = first[key]
+ assert isinstance(value, str)
+ record = decode_replay(value)
+ assert not isinstance(record, AssociatedReplayRecord)
+ assert record.native["reasoning_details"] == [
+ {"type": "reasoning.encrypted", "data": "firstsecond"}
+ ]
+ if readable:
+ assert first["thinking"] == "Plan."
+ assert blocks == original
+ assert resolve_messages_replay(resolved) == resolved
+
+
+@pytest.mark.parametrize("part", ["anchor", "final"])
+def test_unpaired_replay_preserves_available_data(part):
+ resolved = resolve_messages_replay(
+ [{"type": "redacted_thinking", "data": _associated(part, "available")}]
+ )
+ block = resolved[0]
+ assert isinstance(block, dict)
+ value = block["data"]
+ assert isinstance(value, str)
+ assert decode_replay(value).native["reasoning_details"] == [
+ {"type": "reasoning.encrypted", "data": "available"}
+ ]
+ assert not isinstance(decode_replay(value), AssociatedReplayRecord)
+
+
+@pytest.mark.parametrize(
+ "case",
+ [
+ "duplicate_anchor",
+ "duplicate_final",
+ "reversed",
+ "different_model",
+ "different_connection",
+ "final_in_thinking",
+ ],
+)
+def test_conflicting_replay_associations_are_rejected(case):
+ anchor = {
+ "type": "thinking",
+ "thinking": "plan",
+ "signature": _associated("anchor", "first"),
+ }
+ final = {"type": "redacted_thinking", "data": _associated("final", "firstsecond")}
+ blocks = [anchor, final]
+ if case == "duplicate_anchor":
+ blocks.insert(1, deepcopy(anchor))
+ elif case == "duplicate_final":
+ blocks.append(deepcopy(final))
+ elif case == "reversed":
+ blocks.reverse()
+ elif case in {"different_model", "different_connection"}:
+ origin = (
+ _origin(protocol="chat", model="other")
+ if case == "different_model"
+ else _origin(protocol="chat", connection="other")
+ )
+ final["data"] = _associated("final", "firstsecond", origin=origin)
+ else:
+ blocks[1] = {"type": "thinking", "thinking": "plan", "signature": final["data"]}
+ with pytest.raises(HistoryReplayError):
+ resolve_messages_replay(blocks)
+
+
+def test_unrelated_native_and_older_replay_blocks_are_unchanged():
+ blocks = [
+ {"type": "thinking", "thinking": "native", "signature": "upstream-signature"},
+ {
+ "type": "redacted_thinking",
+ "data": encode_replay(ReplayRecord(_origin(), _native())),
+ },
+ ]
+ assert resolve_messages_replay(blocks) == blocks
diff --git a/tests/core/test_history_switching.py b/tests/core/test_history_switching.py
index 37c048c8e3..ae93dddf7e 100644
--- a/tests/core/test_history_switching.py
+++ b/tests/core/test_history_switching.py
@@ -70,19 +70,34 @@ def test_grouped_tool_calls_keep_both_reasoning_records():
]
-def test_quarantined_tool_call_does_not_donate_its_reasoning():
+def test_malformed_tool_call_keeps_its_reasoning_and_error_result():
messages = _chat(
[
{"role": "user", "content": "continue"},
_reasoning("broken call only", "opaque-broken"),
_call("broken", "{"),
_call("valid"),
+ {
+ "type": "function_call_output",
+ "call_id": "broken",
+ "output": "Invalid JSON",
+ },
{"type": "function_call_output", "call_id": "valid", "output": "17"},
]
)
assistant = next(message for message in messages if message.get("tool_calls"))
- assert not assistant.get("reasoning_content")
- assert "reasoning_details" not in assistant
+ assert assistant["reasoning_content"] == "broken call only"
+ assert [detail["data"] for detail in assistant["reasoning_details"]] == [
+ "opaque-broken"
+ ]
+ assert [
+ (call["id"], call["function"]["arguments"]) for call in assistant["tool_calls"]
+ ] == [("broken", "{"), ("valid", "{}")]
+ assert [
+ (message["tool_call_id"], message["content"])
+ for message in messages
+ if message["role"] == "tool"
+ ] == [("broken", "Invalid JSON"), ("valid", "17")]
def test_completed_hosted_tool_history_remains_readable():
diff --git a/tests/providers/test_history_transports.py b/tests/providers/test_history_transports.py
index 1b823bcf50..cb6997d0ce 100644
--- a/tests/providers/test_history_transports.py
+++ b/tests/providers/test_history_transports.py
@@ -15,7 +15,11 @@
from free_claude_code.core.anthropic.models import MessagesRequest
from free_claude_code.core.anthropic.stream_contracts import parse_sse_text
from free_claude_code.core.failures import ExecutionFailure, FailureKind
-from free_claude_code.core.history_replay import decode_replay, encode_replay
+from free_claude_code.core.history_replay import (
+ decode_replay,
+ encode_replay,
+ resolve_messages_replay,
+)
from free_claude_code.core.openai_responses import OpenAIResponsesRequest
from free_claude_code.core.reasoning import ReasoningPolicy
from free_claude_code.providers.open_router import OpenRouterProvider
@@ -178,7 +182,7 @@ def reply(request):
)
extras = {}
- def stream(wire, history):
+ def stream(wire, history, *, tools=None):
options: dict[str, Any] = dict(
input_tokens=0,
request_id="history-test",
@@ -192,12 +196,14 @@ def stream(wire, history):
if wire == "responses":
return provider.stream_responses(
OpenAIResponsesRequest.model_validate(
- {"model": "requested", "input": history}
+ {"model": "requested", "input": history, "tools": tools}
),
**options,
)
return provider.stream_messages(
- MessagesRequest.model_validate({"model": "requested", "messages": history}),
+ MessagesRequest.model_validate(
+ {"model": "requested", "messages": history, "tools": tools}
+ ),
**options,
)
@@ -224,12 +230,14 @@ def _carrier(history, wire):
return next(
item["encrypted_content"]
for item in history
- if item.get("type") == "reasoning"
+ if item.get("type") == "reasoning" and item.get("encrypted_content")
)
return next(
block.get("signature", block.get("data"))
- for block in history[0]["content"]
- if block["type"] in {"thinking", "redacted_thinking"}
+ for block in resolve_messages_replay(history[0]["content"])
+ if isinstance(block, dict)
+ and block["type"] in {"thinking", "redacted_thinking"}
+ and block.get("signature", block.get("data"))
)
@@ -516,13 +524,13 @@ def _chat_reasoning_events(deltas):
"",
"x",
"stop",
- True,
+ False,
),
(
"● ",
"x",
"stop",
- True,
+ False,
),
("<", None, "tool_calls", True),
("<", "", "length", False),
@@ -745,6 +753,8 @@ async def test_chat_plaintext_beside_encrypted_details_survives_switching(
if wire == "responses":
saved[0]["encrypted_content"] = carrier
else:
+ # Model an older complete v1 transcript before removing its readable field.
+ saved[0]["content"] = resolve_messages_replay(saved[0]["content"])
saved[0]["content"][0]["signature"] = carrier
else:
assert record.native["reasoning_content"] == text
diff --git a/tests/providers/test_native_tool_arguments.py b/tests/providers/test_native_tool_arguments.py
new file mode 100644
index 0000000000..b7406c9ad7
--- /dev/null
+++ b/tests/providers/test_native_tool_arguments.py
@@ -0,0 +1,309 @@
+"""Ordinary tool arguments and harness error feedback survive protocol translation."""
+
+import json
+
+import pytest
+
+from free_claude_code.core.anthropic.sse_aggregation import (
+ aggregate_anthropic_sse_to_message,
+)
+from free_claude_code.core.anthropic.stream_contracts import parse_sse_text
+from free_claude_code.core.failures import ExecutionFailure, FailureKind
+from free_claude_code.core.openai_responses.streaming.event_builders import (
+ ResponseEventBuilder,
+)
+from tests.providers.test_history_transports import _events_for, _harness
+
+ARGUMENTS = [
+ '{"path":"ok"}',
+ '{"path":',
+ "{}",
+ '{"path":42}',
+ "[]",
+ "null",
+ "",
+ '{"x":1e400,"unicode":"\\u263a"}',
+]
+
+
+def tool_events(protocol, arguments):
+ if protocol == "chat":
+ template = _events_for("chat")[0]
+ deltas = [
+ {
+ "tool_calls": [
+ {
+ "index": 0,
+ "id": "call_probe",
+ "type": "function",
+ "function": {"name": "read", "arguments": ""},
+ }
+ ]
+ }
+ ]
+ deltas += [
+ {"tool_calls": [{"index": 0, "function": {"arguments": part}}]}
+ for part in arguments
+ ]
+ return [
+ {
+ **template,
+ "choices": [{"index": 0, "delta": delta, "finish_reason": None}],
+ }
+ for delta in deltas
+ ] + [
+ {
+ **template,
+ "choices": [{"index": 0, "delta": {}, "finish_reason": "tool_calls"}],
+ }
+ ]
+ if protocol == "messages":
+ return [
+ _events_for("messages")[0],
+ {
+ "type": "content_block_start",
+ "index": 0,
+ "content_block": {
+ "type": "tool_use",
+ "id": "call_probe",
+ "name": "read",
+ "input": {},
+ },
+ },
+ *[
+ {
+ "type": "content_block_delta",
+ "index": 0,
+ "delta": {"type": "input_json_delta", "partial_json": part},
+ }
+ for part in arguments or [""]
+ ],
+ {"type": "content_block_stop", "index": 0},
+ {
+ "type": "message_delta",
+ "delta": {"stop_reason": "tool_use"},
+ "usage": {"output_tokens": 10},
+ },
+ {"type": "message_stop"},
+ ]
+ builder = ResponseEventBuilder()
+ item = {
+ "type": "function_call",
+ "id": "fc_probe",
+ "call_id": "call_probe",
+ "name": "read",
+ "arguments": arguments,
+ "status": "completed",
+ }
+ response = {
+ "id": "resp_probe",
+ "object": "response",
+ "created_at": 0,
+ "model": "upstream",
+ "status": "completed",
+ "output": [item],
+ "usage": {"input_tokens": 1, "output_tokens": 10, "total_tokens": 11},
+ }
+ wire = builder.response_created({**response, "status": "in_progress", "output": []})
+ wire += builder.output_item_added(
+ 0, {**item, "status": "in_progress", "arguments": ""}
+ )
+ wire += "".join(
+ builder.function_call_arguments_delta("fc_probe", 0, part) for part in arguments
+ )
+ wire += builder.function_call_arguments_done("fc_probe", 0, arguments)
+ wire += builder.output_item_done(0, item) + builder.response_completed(response)
+ return [event.data for event in parse_sse_text(wire)]
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("protocol", ["chat", "messages", "responses"])
+@pytest.mark.parametrize("wire", ["messages", "responses"])
+@pytest.mark.parametrize("arguments", ARGUMENTS)
+async def test_completed_native_arguments_are_transparent(protocol, wire, arguments):
+ async with _harness(
+ protocol, lambda _: (200, tool_events(protocol, arguments))
+ ) as (send, bodies, _):
+ history = [{"role": "user", "content": "read"}]
+ events = parse_sse_text(
+ "".join(
+ [
+ chunk
+ async for chunk in send(
+ wire,
+ history,
+ tools=[
+ {
+ "type": "function",
+ "name": "read",
+ "parameters": {"type": "object"},
+ }
+ ]
+ if wire == "responses"
+ else [{"name": "read", "input_schema": {"type": "object"}}],
+ )
+ ]
+ )
+ )
+ assert len(bodies) == 1
+ if wire == "responses":
+ assert events[-1].event == "response.completed"
+ call = events[-1].data["response"]["output"][0]
+ assert call["arguments"] == arguments
+ assert call["call_id"] == "call_probe"
+ assert (
+ "".join(
+ e.data["delta"]
+ for e in events
+ if e.event == "response.function_call_arguments.delta"
+ )
+ == arguments
+ )
+ else:
+ assert events[-1].event == "message_stop"
+ assert not any(e.event == "error" for e in events)
+ assert (
+ "".join(
+ e.data["delta"]["partial_json"]
+ for e in events
+ if e.data.get("delta", {}).get("type") == "input_json_delta"
+ )
+ == arguments
+ )
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("protocol", ["chat", "responses"])
+@pytest.mark.parametrize("arguments", ARGUMENTS)
+async def test_harness_tool_error_is_kept_in_next_request(protocol, arguments):
+ async with _harness(
+ protocol, lambda _: (200, tool_events(protocol, arguments))
+ ) as (send, bodies, _):
+ history = [{"role": "user", "content": "read"}]
+ first = parse_sse_text(
+ "".join([chunk async for chunk in send("responses", history)])
+ )
+ call = first[-1].data["response"]["output"][0]
+ history += [
+ call,
+ {
+ "type": "function_call_output",
+ "call_id": call["call_id"],
+ "output": "Invalid arguments. Supply a path string.",
+ },
+ ]
+ _ = [chunk async for chunk in send("responses", history)]
+ if protocol == "chat":
+ sent = bodies[-1]["messages"]
+ assert sent[-2]["tool_calls"][0]["function"]["arguments"] == arguments
+ assert sent[-1]["tool_call_id"] == call["call_id"]
+ assert sent[-1]["content"] == history[-1]["output"]
+ else:
+ assert bodies[-1]["input"][-2:] == history[-2:]
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+ "arguments", ['{"path":', "[]", "null", "", '{"x":NaN}', '{"x":1e400}']
+)
+@pytest.mark.parametrize("upstream_error", [False, True])
+async def test_nonstream_messages_requires_an_object_and_preserves_upstream_errors(
+ arguments, upstream_error
+):
+ error = {"type": "overloaded_error", "message": "Try again"}
+
+ async def stream():
+ for event in tool_events("messages", arguments):
+ yield "data: " + json.dumps(event) + "\n\n"
+ if upstream_error:
+ yield "data: " + json.dumps({"type": "error", "error": error}) + "\n\n"
+
+ if upstream_error:
+ _, actual, _ = await aggregate_anthropic_sse_to_message(stream())
+ assert actual == error
+ else:
+ with pytest.raises(ExecutionFailure) as raised:
+ await aggregate_anthropic_sse_to_message(stream())
+ assert raised.value.kind is FailureKind.UPSTREAM
+ assert raised.value.status_code == 502
+ assert not raised.value.retryable
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("wire", ["messages", "responses"])
+@pytest.mark.parametrize(
+ "text",
+ [
+ "before file after",
+ "before ● file after",
+ 'WebSearch {"query":"example"}',
+ 'WebFetch {"url":"https://example.test"}',
+ "before <|im_end|> after",
+ ],
+)
+async def test_tool_looking_text_stays_literal_across_chunk_boundaries(wire, text):
+ template = _events_for("chat")[0]
+ for split in range(len(text) + 1):
+ events = [
+ {
+ **template,
+ "choices": [
+ {"index": 0, "delta": {"content": part}, "finish_reason": None}
+ ],
+ }
+ for part in [text[:split], text[split:]]
+ ]
+ events.append(
+ {
+ **template,
+ "choices": [{"index": 0, "delta": {}, "finish_reason": "tool_calls"}],
+ }
+ )
+ async with _harness("chat", lambda _, events=events: (200, events)) as (
+ send,
+ _bodies,
+ _,
+ ):
+ output = parse_sse_text(
+ "".join(
+ [
+ chunk
+ async for chunk in send(
+ wire,
+ [{"role": "user", "content": "read"}],
+ tools=[
+ {
+ "type": "function",
+ "name": "read",
+ "parameters": {"type": "object"},
+ }
+ ]
+ if wire == "responses"
+ else [{"name": "read", "input_schema": {"type": "object"}}],
+ )
+ ]
+ )
+ )
+ if wire == "responses":
+ items = output[-1].data["response"]["output"]
+ assert all(item["type"] == "message" for item in items)
+ assert (
+ "".join(part["text"] for item in items for part in item["content"])
+ == text
+ )
+ else:
+ assert not any(
+ e.data.get("content_block", {}).get("type") == "tool_use"
+ for e in output
+ )
+ assert (
+ "".join(e.data.get("delta", {}).get("text", "") for e in output) == text
+ )
+ assert (
+ next(
+ e.data["delta"]["stop_reason"]
+ for e in output
+ if e.event == "message_delta"
+ )
+ == "end_turn"
+ )
diff --git a/tests/providers/test_openai_chat_stream_output.py b/tests/providers/test_openai_chat_stream_output.py
index 91da63a216..2bbcd13820 100644
--- a/tests/providers/test_openai_chat_stream_output.py
+++ b/tests/providers/test_openai_chat_stream_output.py
@@ -377,7 +377,7 @@ def test_responses_chat_output_preserves_custom_tool_free_form_input() -> None:
]
-def test_responses_chat_output_fails_malformed_function_call_once() -> None:
+def test_responses_chat_output_completes_malformed_function_call_once() -> None:
output = _responses_output(
OpenAIResponsesRequest.model_validate(
{
@@ -413,8 +413,14 @@ def test_responses_chat_output_fails_malformed_function_call_once() -> None:
events = _parse_frames(frames)
event_types = [event_type for event_type, _ in events]
- assert event_types.count("response.failed") == 1
- assert "response.completed" not in event_types
+ assert event_types.count("response.completed") == 1
+ assert "response.failed" not in event_types
final = _object_dict(events[-1][1]["response"])
- assert final["status"] == "failed"
- assert _object_dict(final["error"])["type"] == "api_error"
+ assert final["status"] == "completed"
+ assert final["error"] is None
+ final_output = final["output"]
+ assert isinstance(final_output, list)
+ assert len(final_output) == 1
+ item = _object_dict(final_output[0])
+ assert item["arguments"] == '{"q":'
+ assert item["call_id"] == "call_bad"
diff --git a/tests/providers/test_parsers.py b/tests/providers/test_parsers.py
index c382ccff66..793a05adfc 100644
--- a/tests/providers/test_parsers.py
+++ b/tests/providers/test_parsers.py
@@ -2,7 +2,6 @@
from free_claude_code.core.anthropic import (
ContentType,
- HeuristicToolParser,
ThinkTagParser,
)
@@ -35,239 +34,9 @@ def test_think_tag_parser_streaming():
assert chunks[0].content == "reasoning"
-def test_heuristic_tool_parser_basic():
- parser = HeuristicToolParser()
- text = "Let's call a tool. ● hello."
- filtered, tools_initial = parser.feed(text)
- tools_final = parser.flush()
- tools = tools_initial + tools_final
-
- assert "Let's call a tool." in filtered
- assert len(tools) == 1
- assert tools[0]["name"] == "Grep"
- assert tools[0]["input"] == {"pattern": "hello", "path": "."}
-
-
-def test_heuristic_tool_parser_streaming():
- parser = HeuristicToolParser()
-
- # Feed part 1
- _filtered1, tools1 = parser.feed("● ")
- assert tools1 == []
-
- # Feed part 2
- _filtered2, tools2 = parser.feed("test.txt")
- assert tools2 == []
-
- # Feed part 3 (triggering flush or completion)
- filtered3, tools3 = parser.feed("\nDone.")
- assert len(tools3) == 1
- assert tools3[0]["name"] == "Write"
- assert tools3[0]["input"] == {"path": "test.txt"}
- assert "Done." in filtered3
-
-
-def test_heuristic_tool_parser_flush():
- parser = HeuristicToolParser()
- parser.feed("● ls -la")
- tools = parser.flush()
-
- assert len(tools) == 1
- assert tools[0]["name"] == "Bash"
- assert tools[0]["input"] == {"command": "ls -la"}
-
-
-def test_heuristic_tool_parser_strips_control_tokens():
- p = HeuristicToolParser()
- filtered, tools = p.feed("Hello <|tool_call_end|> world")
- tools.extend(p.flush())
-
- assert "<|tool_call_end|>" not in filtered
- assert filtered == "Hello world"
- assert tools == []
-
-
-def test_heuristic_tool_parser_strips_control_tokens_split_across_chunks():
- p = HeuristicToolParser()
- f1, t1 = p.feed("Hello <|tool_call_")
- f2, t2 = p.feed("end|> world")
- tools = t1 + t2 + p.flush()
-
- assert "<|tool_call_end|>" not in (f1 + f2)
- assert (f1 + f2) == "Hello world"
- assert tools == []
-
-
-def test_heuristic_tool_parser_strips_control_tokens_inside_tool_text():
- p = HeuristicToolParser()
- text = (
- "Before <|tool_calls_section_end|> ● "
- "hi After"
- )
- filtered, tools = p.feed(text)
- tools.extend(p.flush())
-
- assert "<|tool_calls_section_end|>" not in filtered
- assert "Before" in filtered
- assert "After" in filtered
- assert len(tools) == 1
- assert tools[0]["name"] == "Grep"
- assert tools[0]["input"] == {"pattern": "hi"}
-
-
-def test_interleaved_thinking_and_tools():
- parser_think = ThinkTagParser()
- parser_tool = HeuristicToolParser()
-
- text = "I need to search for a file. ● test"
-
- # 1. Parse thinking
- chunks = list(parser_think.feed(text))
- thinking = [c for c in chunks if c.type == ContentType.THINKING]
- text_remaining = "".join([c.content for c in chunks if c.type == ContentType.TEXT])
-
- assert len(thinking) == 1
- assert thinking[0].content == "I need to search for a file."
-
- # 2. Parse tool from remaining text
- _filtered, tools = parser_tool.feed(text_remaining)
- tools += parser_tool.flush()
-
- assert len(tools) == 1
- assert tools[0]["name"] == "Grep"
- assert tools[0]["input"] == {"pattern": "test"}
-
-
-def test_partial_interleaved_streaming():
- parser_think = ThinkTagParser()
- parser_tool = HeuristicToolParser()
-
- # Chunk 1: Partial thinking (it emits since it's definitely not the start of )
- chunks1 = list(parser_think.feed("Part 1"))
- assert len(chunks1) == 1
- assert chunks1[0].type == ContentType.THINKING
- assert chunks1[0].content == "Part 1"
-
- # Chunk 2: Thinking ends, tool starts
- chunks2 = list(parser_think.feed(" ends ● test.py"))
- text_rem3 = "".join([c.content for c in chunks3])
- _filtered3, tools3 = parser_tool.feed(text_rem3)
- tools3 += parser_tool.flush()
-
- assert len(tools3) == 1
- assert tools3[0]["name"] == "Read"
- assert tools3[0]["input"] == {"path": "test.py"}
-
-
# --- New Robustness Tests ---
-def test_split_across_markers():
- # Split across the trigger chaaracter
- # "● "
- # Split at various points
- full_text = "● val"
-
- for i in range(len(full_text)):
- p = HeuristicToolParser()
- chunk1 = full_text[:i]
- chunk2 = full_text[i:]
-
- tools = []
- _filtered, t = p.feed(chunk1)
- tools.extend(t)
- _filtered2, t = p.feed(chunk2)
- tools.extend(t)
- tools.extend(p.flush())
-
- if len(tools) != 1:
- print(f"Failed split at index {i}: '{chunk1}' | '{chunk2}'")
-
- assert len(tools) == 1, f"Failed split at index {i}"
- assert tools[0]["name"] == "Test"
- assert tools[0]["input"] == {"arg": "val"}
-
-
-def test_value_with_special_chars():
- parser = HeuristicToolParser()
- # Value with > inside
- text = "● a > b"
- _, tools = parser.feed(text)
- tools.extend(parser.flush())
-
- assert len(tools) == 1
- assert tools[0]["input"]["arg"] == "a > b"
-
-
-def test_multiple_params_split():
- full_text = (
- "● v1v2"
- )
-
- for i in range(len(full_text)):
- p = HeuristicToolParser()
- tools = []
- _, t = p.feed(full_text[:i])
- tools.extend(t)
- _, t = p.feed(full_text[i:])
- tools.extend(t)
- tools.extend(p.flush())
-
- assert len(tools) == 1, f"Failed split at {i}"
- assert tools[0]["input"] == {"p1": "v1", "p2": "v2"}
-
-
-def test_incomplete_tag_flush():
- p = HeuristicToolParser()
- p.feed("● hello")
- tools = p.flush()
-
- assert len(tools) == 1
- assert tools[0]["input"]["msg"] == "hello"
-
-
-def test_garbage_interleaved():
- p = HeuristicToolParser()
- tools = []
- _, t = p.feed("Some text ")
- tools.extend(t)
- _, t = p.feed("● 1")
- tools.extend(t)
- _, t = p.feed(" more text ")
- tools.extend(t)
- _, t = p.feed("● 2")
- tools.extend(t)
- tools.extend(p.flush())
-
- assert len(tools) == 2
- assert tools[0]["name"] == "T1"
- assert tools[1]["name"] == "T2"
-
-
-def test_text_between_params_lost():
- p = HeuristicToolParser()
- # " text1 " is between function end and first param
- # " text2 " is between params
- text = "● text1 1 text2 2"
- filtered, tools = p.feed(text)
- tools.extend(p.flush())
-
- # Check if "text1" and "text2" are preserved in filtered output
- assert "text1" in filtered
- assert "text2" in filtered
- assert tools[0]["input"] == {"a": "1", "b": "2"}
-
-
# --- Orphan Tag Tests (Step Fun AI compatibility) ---
@@ -436,79 +205,4 @@ def test_think_tag_parser_unicode():
assert "日本語" in text
assert "結果" in text
-
-def test_heuristic_tool_parser_empty_input():
- """Empty string input should return empty filtered text and no tools."""
- parser = HeuristicToolParser()
- filtered, tools = parser.feed("")
- assert filtered == ""
- assert tools == []
-
-
-def test_heuristic_tool_parser_flush_no_tool():
- """Flush when no tool is being parsed should return empty list."""
- parser = HeuristicToolParser()
- parser.feed("plain text")
- tools = parser.flush()
- assert tools == []
-
-
-def test_heuristic_tool_parser_json_style_web_fetch_tool_call():
- parser = HeuristicToolParser()
- text = (
- "Use WebFetch on the article.\n\n"
- "{\n"
- ' "url": "https://example.com/article",\n'
- ' "prompt": "Summarize it."\n'
- "}\n"
- )
-
- filtered, tools = parser.feed(text)
- tools.extend(parser.flush())
-
- assert filtered == ""
- assert len(tools) == 1
- assert tools[0]["name"] == "WebFetch"
- assert tools[0]["input"] == {
- "url": "https://example.com/article",
- "prompt": "Summarize it.",
- }
-
-
-def test_heuristic_tool_parser_json_style_web_search_tool_call():
- parser = HeuristicToolParser()
-
- filtered, tools = parser.feed('Use WebSearch {"query": "DeepSeek V4"}')
- tools.extend(parser.flush())
-
- assert filtered == ""
- assert len(tools) == 1
- assert tools[0]["name"] == "WebSearch"
- assert tools[0]["input"] == {"query": "DeepSeek V4"}
-
-
-def test_heuristic_tool_parser_unicode_function_name():
- """Unicode characters in function parameters."""
- parser = HeuristicToolParser()
- text = "● 日本語テスト"
- _filtered, tools = parser.feed(text)
- tools.extend(parser.flush())
- assert len(tools) == 1
- assert tools[0]["name"] == "Search"
- assert tools[0]["input"]["query"] == "日本語テスト"
-
-
-@pytest.mark.parametrize(
- "malformed_text",
- [
- "● ",
- "● v",
- ],
- ids=["empty_name", "empty_name_with_param"],
-)
-def test_heuristic_tool_parser_malformed_function_tag(malformed_text):
- """Malformed function tags should still be handled without crashing."""
- parser = HeuristicToolParser()
- _filtered, tools = parser.feed(malformed_text)
- tools.extend(parser.flush())
# Should not crash; may or may not detect a tool depending on regex match
diff --git a/tests/providers/test_streaming_errors.py b/tests/providers/test_streaming_errors.py
index 2a34affa87..557b2235ca 100644
--- a/tests/providers/test_streaming_errors.py
+++ b/tests/providers/test_streaming_errors.py
@@ -35,7 +35,6 @@
from free_claude_code.providers.openai_chat.tool_calls import (
OpenAIToolCallAssembler,
OpenAIToolCallCollector,
- iter_heuristic_tool_use_events,
)
from free_claude_code.providers.openai_chat.transport import (
_OpenAIChatStreamRunner,
@@ -915,10 +914,8 @@ async def test_clean_eof_after_complete_tool_call_salvages_tool_use(self):
@pytest.mark.asyncio
@pytest.mark.parametrize("finish_reason", ["tool_calls", "stop"])
- async def test_heuristic_only_tool_stream_does_not_emit_fallback_text(
- self, finish_reason
- ):
- """Text-parsed tool calls count as emitted tool output when finalizing."""
+ async def test_legacy_tool_markup_remains_visible_text(self, finish_reason):
+ """Legacy markup cannot manufacture executable tool calls."""
provider = _make_provider()
request = _make_request()
heuristic_tool = (
@@ -941,7 +938,7 @@ async def test_heuristic_only_tool_stream_does_not_emit_fallback_text(
events = await _collect_stream(provider, request)
parsed = parse_sse_text("".join(events))
- assert any(
+ assert not any(
event.event == "content_block_start"
and event.data.get("content_block", {}).get("type") == "tool_use"
for event in parsed
@@ -954,13 +951,18 @@ async def test_heuristic_only_tool_stream_does_not_emit_fallback_text(
)
assert any(
event.event == "message_delta"
- and event.data.get("delta", {}).get("stop_reason") == "tool_use"
+ and event.data.get("delta", {}).get("stop_reason") == "end_turn"
for event in parsed
)
+ assert (
+ "".join(event.data.get("delta", {}).get("text", "") for event in parsed)
+ == heuristic_tool
+ )
+
@pytest.mark.asyncio
- async def test_function_tag_tool_stream_becomes_one_anthropic_tool_use(self):
- """Exact control-only function tags use the established tool lifecycle."""
+ async def test_function_tag_markup_remains_text_beside_reasoning(self):
+ """Function-looking text stays visible after reasoning parsing."""
provider = _make_provider()
request = _make_request(
tools=[
@@ -1017,9 +1019,9 @@ async def test_function_tag_tool_stream_becomes_one_anthropic_tool_use(self):
and event.data.get("delta", {}).get("type") == "text_delta"
)
- assert [block["name"] for block in tool_starts] == ["Bash"]
- assert json.loads(input_json) == {"command": "printf FCC_STEP_TOOL"}
- assert visible_text == "I will invoke Bash now.\n"
+ assert tool_starts == []
+ assert input_json == ""
+ assert visible_text == raw_call.split("", 1)[1]
assert any(
event.event == "content_block_delta"
and event.data.get("delta", {}).get("type") == "thinking_delta"
@@ -1027,7 +1029,7 @@ async def test_function_tag_tool_stream_becomes_one_anthropic_tool_use(self):
)
assert any(
event.event == "message_delta"
- and event.data.get("delta", {}).get("stop_reason") == "tool_use"
+ and event.data.get("delta", {}).get("stop_reason") == "end_turn"
for event in parsed
)
@@ -1133,8 +1135,9 @@ async def test_rejected_function_tag_candidate_bypasses_legacy_heuristics(self):
assert tool_starts == []
@pytest.mark.asyncio
- async def test_function_tag_candidate_is_reset_before_early_retry(self):
- """An abandoned textual candidate cannot leak or duplicate after retry."""
+ @pytest.mark.parametrize("committed", [False, True])
+ async def test_literal_markup_respects_connection_commit_boundary(self, committed):
+ """Literal markup follows ordinary retry and continuation boundaries."""
provider = _make_provider()
request = _make_request(
tools=[
@@ -1149,6 +1152,8 @@ async def test_function_tag_candidate_is_reset_before_early_retry(self):
]
)
abandoned = "\n"
+ if committed:
+ abandoned += "x" * 66000
complete = (
"\n\n\n"
"printf retry\n\n\n"
@@ -1184,8 +1189,10 @@ async def test_function_tag_candidate_is_reset_before_early_retry(self):
]
assert mock_create.await_count == 2
- assert visible_text == ""
- assert [block["name"] for block in tool_starts] == ["Bash"]
+ assert visible_text == (abandoned + complete if committed else complete)
+ assert tool_starts == []
+
+ assert ("tools" not in mock_create.call_args_list[1].kwargs) == committed
@pytest.mark.asyncio
async def test_precommit_retry_discards_abandoned_tool_id_candidate(self):
@@ -2513,25 +2520,6 @@ async def test_stream_rate_limit_uses_the_execution_retry_session(self):
class TestProcessToolCall:
"""Tests for OpenAI tool-call assembly."""
- def test_heuristic_tool_use_sse_marks_committed_tool_output(self):
- """Heuristic tool blocks are emitted content, even without OpenAI tool state."""
- output = _make_anthropic_output()
- events = list(
- iter_heuristic_tool_use_events(
- output,
- {
- "id": "toolu_heuristic",
- "name": "Read",
- "input": {"path": "test.py"},
- },
- )
- )
-
- event_text = "".join(events)
- assert "tool_use" in event_text
- assert output.has_emitted_tool_block()
- assert output.committed_output
-
def test_tool_call_with_id(self):
"""Tool call with id starts a tool block."""
provider = _make_provider()
@@ -2856,31 +2844,6 @@ def test_buffered_collector_decodes_before_schema_validation(self):
assert calls is not None
assert calls[0]["function"]["name"] == original
- def test_heuristic_tool_call_restores_original_name(self):
- """Complete heuristic calls share the same outbound name contract."""
- original = "mcp__heuristic_output__" + "x" * 70
- request = _make_request(
- tools=[{"name": original, "input_schema": {"type": "object"}}]
- )
- codec = OpenAIToolNameCodec.from_request(request)
- sse = _make_anthropic_output()
-
- events = list(
- iter_heuristic_tool_use_events(
- sse,
- {
- "id": "call_heuristic",
- "name": codec.encode(original),
- "input": {},
- },
- tool_names=codec,
- )
- )
-
- event_text = "".join(events)
- assert original in event_text
- assert codec.encode(original) not in event_text
-
def test_tool_call_id_arrives_before_name_still_emits_id_and_name(self):
"""Split-stream tool: id (no name) then name then args; id preserved on start."""
provider = _make_provider()
diff --git a/tests/providers/test_structured_reasoning_lifecycle.py b/tests/providers/test_structured_reasoning_lifecycle.py
new file mode 100644
index 0000000000..2fb281dff3
--- /dev/null
+++ b/tests/providers/test_structured_reasoning_lifecycle.py
@@ -0,0 +1,430 @@
+"""Reasoning stays complete and attached while answers stream."""
+
+import json
+from unittest.mock import AsyncMock, patch
+
+import pytest
+
+from free_claude_code.core.anthropic.models import MessagesRequest
+from free_claude_code.core.anthropic.stream_contracts import parse_sse_text
+from free_claude_code.core.history_replay import (
+ AssociatedReplayRecord,
+ ReplayOrigin,
+ decode_replay,
+)
+from free_claude_code.providers.history_replay import normalize_messages_history
+from free_claude_code.providers.openai_chat.reasoning_details import (
+ StructuredReasoningStream,
+)
+from free_claude_code.providers.openai_chat.stream_output import (
+ AnthropicChatStreamOutput,
+ ChatStreamUsage,
+)
+from free_claude_code.providers.openai_chat.transport import _OpenAIChatStreamRunner
+from tests.providers.test_history_transports import (
+ _carrier,
+ _chat_reasoning_events,
+ _harness,
+ _saved_reply,
+)
+from tests.providers.test_streaming_errors import _recovery_output
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("wire", ["messages", "responses"])
+async def test_distinct_native_reasoning_survives_structured_summary(wire):
+ events = _chat_reasoning_events(
+ [
+ {
+ "reasoning_details": [
+ {
+ "type": "reasoning.summary",
+ "summary": "Short summary.",
+ "index": 0,
+ }
+ ]
+ },
+ {"reasoning_content": "Distinct native thought."},
+ ]
+ )
+ async with _harness("chat", lambda _: (200, events)) as (send, bodies, _):
+ saved = await _saved_reply(
+ send(wire, [{"role": "user", "content": "hello"}]), wire
+ )
+ await _saved_reply(
+ send(wire, [*saved, {"role": "user", "content": "next"}]), wire
+ )
+ body = json.dumps(bodies[-1])
+ assert body.count("Distinct native thought.") == 1
+ assert body.count("Short summary.") == 1
+
+
+@pytest.mark.asyncio
+async def test_encrypted_only_reasoning_stays_with_its_answer():
+ details = [{"type": "reasoning.encrypted", "data": "opaque", "index": 0}]
+ events = _chat_reasoning_events([{"reasoning_details": details}])
+ async with _harness("chat", lambda _: (200, events)) as (send, bodies, _):
+ saved = await _saved_reply(
+ send("responses", [{"role": "user", "content": "hello"}]), "responses"
+ )
+ assert [item["type"] for item in saved] == ["reasoning", "message"]
+ await _saved_reply(
+ send("responses", [*saved, {"role": "user", "content": "next"}]),
+ "responses",
+ )
+ assistants = [msg for msg in bodies[-1]["messages"] if msg["role"] == "assistant"]
+ assert len(assistants) == 1
+ assert assistants[0]["content"] == "17"
+ assert assistants[0]["reasoning_details"] == details
+
+
+@pytest.mark.asyncio
+async def test_late_metadata_does_not_overlap_messages_content_blocks():
+ events = _chat_reasoning_events(
+ [
+ {
+ "reasoning_content": "Plan.",
+ "reasoning_details": [
+ {"type": "reasoning.encrypted", "data": "first", "index": 0}
+ ],
+ },
+ {"content": "Answer."},
+ {
+ "reasoning_details": [
+ {"type": "reasoning.encrypted", "data": "second", "index": 0}
+ ]
+ },
+ ]
+ )
+ async with _harness("chat", lambda _: (200, events)) as (send, bodies, _):
+ stream = [
+ chunk
+ async for chunk in send("messages", [{"role": "user", "content": "hello"}])
+ ]
+ active = None
+ for event in parse_sse_text("".join(stream)):
+ if event.event == "content_block_start":
+ assert active is None
+ active = event.data["index"]
+ elif event.event == "content_block_delta":
+ assert event.data["index"] == active
+ elif event.event == "content_block_stop":
+ assert event.data["index"] == active
+ active = None
+ assert active is None
+ saved = await _saved_reply(
+ send("messages", [{"role": "user", "content": "hello"}]), "messages"
+ )
+ await _saved_reply(
+ send("messages", [*saved, {"role": "user", "content": "next"}]), "messages"
+ )
+ assert bodies[-1]["messages"][0]["reasoning_details"] == [
+ {"type": "reasoning.encrypted", "data": "firstsecond", "index": 0}
+ ]
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("destination", ["chat", "messages", "responses"])
+@pytest.mark.parametrize("readable", [False, True])
+async def test_associated_messages_replay_into_every_transport(destination, readable):
+ first = {
+ "reasoning_details": [
+ {"type": "reasoning.encrypted", "data": "first", "index": 0}
+ ]
+ }
+ if readable:
+ first["reasoning_content"] = "Plan."
+ events = _chat_reasoning_events(
+ [
+ first,
+ {"content": "Answer."},
+ {
+ "reasoning_details": [
+ {"type": "reasoning.encrypted", "data": "second", "index": 0}
+ ]
+ },
+ ]
+ )
+ async with _harness("chat", lambda _: (200, events)) as (send, _, _):
+ saved = await _saved_reply(
+ send("messages", [{"role": "user", "content": "hello"}]), "messages"
+ )
+ history = [*saved, {"role": "user", "content": "next"}]
+ before = json.dumps(history)
+ async with _harness(destination) as (send, bodies, _):
+ await _saved_reply(send("messages", history), "messages")
+ assert json.dumps(history) == before
+ body = json.dumps(bodies[-1])
+ assert "fcc:history" not in body
+ assert body.count("Plan.") == int(readable)
+ if destination == "chat":
+ assert bodies[-1]["messages"][0]["reasoning_details"] == [
+ {"type": "reasoning.encrypted", "data": "firstsecond", "index": 0}
+ ]
+ else:
+ assert "firstsecond" not in body
+
+
+def _messages_writer():
+ output = AnthropicChatStreamOutput(
+ message_id="msg_test", model="public", input_tokens=1
+ )
+ output.replay_origin = ReplayOrigin("test", "chat", "endpoint", "account", "model")
+ reasoning = StructuredReasoningStream()
+ output.reasoning_replay = reasoning
+ return output, reasoning
+
+
+def _readable(reasoning, output, text="Plan.", data="first"):
+ return list(
+ reasoning.events(
+ {
+ "reasoning_details": [
+ {"type": "reasoning.encrypted", "data": data, "index": 0}
+ ]
+ },
+ output,
+ native_reasoning=text,
+ )
+ )
+
+
+def _assert_serial(events):
+ active = None
+ for event in events:
+ if event.event == "content_block_start":
+ assert active is None
+ active = event.data["index"]
+ elif event.event in {"content_block_delta", "content_block_stop"}:
+ assert event.data["index"] == active
+ if event.event == "content_block_stop":
+ active = None
+ assert active is None
+
+
+@pytest.mark.parametrize(
+ "terminal", ["normal", "length", "failure", "tool", "continuation"]
+)
+def test_immediate_text_and_late_replay_survive_every_exit(terminal):
+ output, reasoning = _messages_writer()
+ frames = output.start_events() + _readable(reasoning, output)
+ frames += output.ensure_text_block()
+ frames.append(output.emit_text_delta("Answer."))
+ # Text has already been emitted, while upstream metadata and termination are pending.
+ early = parse_sse_text("".join(frames))
+ assert any(e.data.get("delta", {}).get("text") == "Answer." for e in early)
+ assert not any(e.event == "message_stop" for e in early)
+ if terminal == "tool":
+ frames += output.close_content_blocks()
+ frames.append(output.start_tool_block(0, "call_a", "lookup"))
+ frames.append(output.emit_tool_delta(0, '{"query":"x"}'))
+ frames += list(
+ reasoning.events(
+ {
+ "reasoning_details": [
+ {"type": "reasoning.encrypted", "data": "second", "index": 0}
+ ]
+ },
+ output,
+ native_reasoning=None,
+ )
+ )
+ if terminal == "continuation":
+ frames += output.flush_reasoning_replay()
+ frames += output.ensure_reasoning_block()
+ frames.append(output.emit_reasoning_delta("Recovery thought."))
+ frames += output.ensure_text_block()
+ frames.append(output.emit_text_delta(" Continued."))
+ if terminal == "failure":
+ frames += output.close_unclosed_blocks()
+ assert output.close_unclosed_blocks() == []
+ else:
+ frames += output.finish_success(
+ stop_reason="max_tokens" if terminal == "length" else "end_turn",
+ usage=ChatStreamUsage(input_tokens=1, output_tokens=4),
+ )
+ assert (
+ output.finish_success(
+ stop_reason="end_turn",
+ usage=ChatStreamUsage(input_tokens=1, output_tokens=4),
+ )
+ == []
+ )
+ events = parse_sse_text("".join(frames))
+ _assert_serial(events)
+ finals = [
+ decode_replay(e.data["content_block"]["data"])
+ for e in events
+ if e.event == "content_block_start"
+ and e.data["content_block"]["type"] == "redacted_thinking"
+ ]
+ assert len(finals) == 1
+ assert isinstance(finals[0], AssociatedReplayRecord)
+ assert finals[0].part == "final"
+ assert finals[0].native == {
+ "reasoning_content": "Plan.",
+ "reasoning_details": [
+ {"type": "reasoning.encrypted", "data": "firstsecond", "index": 0}
+ ],
+ }
+
+
+def test_request_copy_preserves_routing_and_does_not_join_across_messages():
+ output, reasoning = _messages_writer()
+ frames = output.start_events() + _readable(reasoning, output)
+ frames += output.ensure_text_block()
+ frames.append(output.emit_text_delta("answer"))
+ frames += output.finish_success(
+ stop_reason="end_turn", usage=ChatStreamUsage(input_tokens=1, output_tokens=1)
+ )
+ events = parse_sse_text("".join(frames))
+ anchor = next(
+ e.data["delta"]["signature"]
+ for e in events
+ if e.data.get("delta", {}).get("type") == "signature_delta"
+ )
+ final = next(
+ e.data["content_block"]["data"]
+ for e in events
+ if e.event == "content_block_start"
+ and e.data["content_block"]["type"] == "redacted_thinking"
+ )
+ request = MessagesRequest(
+ model="route",
+ original_model="original",
+ resolved_provider_model="actual",
+ messages=[
+ {
+ "role": "assistant",
+ "content": [
+ {"type": "thinking", "thinking": "Plan.", "signature": anchor}
+ ],
+ },
+ {"role": "user", "content": "next"},
+ {
+ "role": "assistant",
+ "content": [{"type": "redacted_thinking", "data": final}],
+ },
+ ],
+ )
+ before = request.model_dump_json()
+ normalized = normalize_messages_history(request)
+ assert normalized.original_model == "original"
+ assert normalized.resolved_provider_model == "actual"
+ assert len(normalized.messages) == 3
+ assert request.model_dump_json() == before
+ assert "fcc:history:v2:" not in normalized.model_dump_json()
+ assert normalize_messages_history(normalized) == normalized
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("wire", ["messages", "responses"])
+@pytest.mark.parametrize("committed", [False, True])
+async def test_structured_replay_respects_retry_and_continuation_boundary(
+ wire, committed
+):
+ prefix = "x" * 66000 if committed else "partial"
+ first = _chat_reasoning_events(
+ [
+ {
+ "reasoning_content": "Original thought.",
+ "reasoning_details": [
+ {"type": "reasoning.encrypted", "data": "original", "index": 0}
+ ],
+ },
+ {"content": prefix},
+ ]
+ )[:-1]
+ second = _chat_reasoning_events(
+ [
+ {
+ "reasoning_content": "Recovery thought.",
+ "reasoning_details": [
+ {"type": "reasoning.encrypted", "data": "replacement", "index": 0}
+ ],
+ },
+ {"content": "continued"},
+ ]
+ )
+ async with _harness(
+ "chat", lambda bodies: (200, first if len(bodies) == 1 else second)
+ ) as (send, bodies, _):
+ saved = await _saved_reply(
+ send(wire, [{"role": "user", "content": "hello"}]), wire
+ )
+ assert len(bodies) == 2
+ record = decode_replay(_carrier(saved, wire))
+ assert record.native["reasoning_content"] == (
+ "Original thought." if committed else "Recovery thought."
+ )
+ assert record.native["reasoning_details"] == [
+ {
+ "type": "reasoning.encrypted",
+ "data": "original" if committed else "replacement",
+ "index": 0,
+ }
+ ]
+ assert "continued" in json.dumps(saved)
+ if not committed:
+ assert "Original thought." not in json.dumps(saved)
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("wire", ["messages", "responses"])
+async def test_first_metadata_after_text_does_not_invent_earlier_reasoning(wire):
+ events = _chat_reasoning_events(
+ [
+ {"content": "Earlier answer."},
+ {
+ "reasoning_details": [
+ {"type": "reasoning.encrypted", "data": "late", "index": 0}
+ ]
+ },
+ ]
+ )
+ async with _harness("chat", lambda _: (200, events)) as (send, _, _):
+ if wire == "messages":
+ frames = [
+ part
+ async for part in send(wire, [{"role": "user", "content": "hello"}])
+ ]
+ _assert_serial(parse_sse_text("".join(frames)))
+ saved = await _saved_reply(
+ send(wire, [{"role": "user", "content": "hello"}]), wire
+ )
+ assert (
+ saved[0]["type"] == "message"
+ if wire == "responses"
+ else saved[0]["content"][0]["type"] == "text"
+ )
+
+
+@pytest.mark.asyncio
+async def test_finishing_replay_does_not_make_empty_continuation_successful():
+ events = _chat_reasoning_events(
+ [
+ {
+ "reasoning_content": "Plan.",
+ "reasoning_details": [
+ {"type": "reasoning.encrypted", "data": "opaque", "index": 0}
+ ],
+ },
+ {"content": "x" * 66000},
+ ]
+ )[:-1]
+ async with _harness("chat", lambda _: (200, events)) as (send, _, _):
+ with patch.object(
+ _OpenAIChatStreamRunner,
+ "_collect_recovery_output",
+ new_callable=AsyncMock,
+ return_value=_recovery_output(),
+ ):
+ frames = [
+ frame
+ async for frame in send(
+ "responses", [{"role": "user", "content": "hello"}]
+ )
+ ]
+ parsed = parse_sse_text("".join(frames))
+ assert parsed[-1].event == "response.failed"
+ assert not any(event.event == "response.completed" for event in parsed)
diff --git a/tests/providers/test_subagent_interception.py b/tests/providers/test_subagent_interception.py
index 51f40f840d..f098e8a386 100644
--- a/tests/providers/test_subagent_interception.py
+++ b/tests/providers/test_subagent_interception.py
@@ -1,4 +1,3 @@
-import copy
import json
import pytest
@@ -17,7 +16,6 @@
)
from free_claude_code.providers.openai_chat.tool_calls import (
OpenAIToolCallAssembler,
- iter_heuristic_tool_use_events,
)
@@ -44,28 +42,6 @@ def _argument_deltas(frames: list[str]) -> list[str]:
return parts
-@pytest.mark.parametrize(
- "arguments",
- [
- {"run_in_background": True},
- {"run_in_background": False},
- {"run_in_background": None},
- {"prompt": "inspect"},
- ],
-)
-def test_heuristic_task_preserves_arguments(output, arguments):
- tool_use = {
- "type": "tool_use",
- "id": "call_task",
- "name": "Task",
- "input": {**arguments, "nested": {"items": [1, 2]}},
- }
- original = copy.deepcopy(tool_use)
- frames = list(iter_heuristic_tool_use_events(output, tool_use))
- assert json.loads("".join(_argument_deltas(frames))) == original["input"]
- assert tool_use == original
-
-
@pytest.mark.parametrize("name", ["Task", "ordinary_tool"])
@pytest.mark.parametrize(
"arguments",
@@ -106,23 +82,12 @@ def test_tool_arguments_stream_without_name_specific_rewrites(output, name, argu
assert output.tool_states[0].content == arguments
assert output.tool_states[0].tool_id == "call_task"
events = parse_sse_text("".join(frames))
- if isinstance(output, ResponsesChatStreamOutput):
- valid = arguments in (
- '{"run_in_background":true,"prompt":"inspect"}',
- "{}",
- '{"run_in_background":null}',
- )
- assert any(e.event == "response.completed" for e in events) == valid
- assert any(e.event == "response.failed" for e in events) != valid
- if valid:
- assert json.loads("".join(_argument_deltas(frames))) == json.loads(
- arguments
- )
- else:
- assert not _argument_deltas(frames)
- else:
- assert events[-1].event == "message_stop"
- assert "".join(_argument_deltas(frames)) == arguments
+ assert events[-1].event == (
+ "response.completed"
+ if isinstance(output, ResponsesChatStreamOutput)
+ else "message_stop"
+ )
+ assert "".join(_argument_deltas(frames)) == arguments
def test_task_argument_aliases_are_restored_recursively(output):
From 5dc3a67716468403e7fa93aa6e1a5f5b97c4149b Mon Sep 17 00:00:00 2001
From: Ali Khokhar <20476625+Alishahryar1@users.noreply.github.com>
Date: Sun, 27 Sep 2026 19:29:31 -0700
Subject: [PATCH 5/5] patch: give integrations and messaging dedicated runtime
owners (#1930)
## Why
Integration and messaging changes currently require editing the central
application runtime, where feature state and workflows are mixed with
configuration transactions and process lifecycle coordination. Give each
feature a clear owner so its behavior can be understood and changed
independently.
## How
- Move the five integration workflows, update state, and refresh tasks
into `IntegrationService`. It borrows the existing configuration lock
and reads current runtime state and provider catalogs.
- Move messaging setup, storage initialization, platform and CLI
resources, voice resources, and cleanup gates into `MessagingService`.
- Keep `ApplicationRuntime` as the API facade and lifecycle coordinator.
It cancels all background task groups before joining them and taking the
configuration lock, then closes resources in dependency order. Failed
cleanup retains ownership for retry.
- Share the existing cleanup reporting helper between resource owners
and adapt behavioral tests to their new ownership boundaries.
- Wait for initial startup polling to settle before the browser test
clicks Retry, preventing background reconciliation from disabling the
button during the click.
---
e2e/test_admin_vscode_chat.py | 5 +
src/free_claude_code/runtime/application.py | 794 ++----------------
.../runtime/integration_service.py | 524 ++++++++++++
src/free_claude_code/runtime/lifecycle.py | 38 +
.../runtime/messaging_service.py | 347 ++++++++
tests/api/test_app_lifespan_and_errors.py | 18 +-
tests/api/test_runtime_safe_logging.py | 7 +-
tests/runtime/test_application_runtime.py | 136 +--
tests/runtime/test_config_transactions.py | 61 ++
tests/runtime/test_feature_lifecycle.py | 122 +++
tests/runtime/test_integration_startup.py | 34 +-
tests/runtime/test_messaging_sqlite.py | 8 +-
tests/runtime/test_vscode_chat_sync.py | 12 +-
13 files changed, 1263 insertions(+), 843 deletions(-)
create mode 100644 src/free_claude_code/runtime/integration_service.py
create mode 100644 src/free_claude_code/runtime/lifecycle.py
create mode 100644 src/free_claude_code/runtime/messaging_service.py
create mode 100644 tests/runtime/test_feature_lifecycle.py
diff --git a/e2e/test_admin_vscode_chat.py b/e2e/test_admin_vscode_chat.py
index 6275886b80..e0d5f1d547 100644
--- a/e2e/test_admin_vscode_chat.py
+++ b/e2e/test_admin_vscode_chat.py
@@ -73,6 +73,11 @@ def retry(route):
page.route("**/admin/api/integrations/vscode-chat", status)
page.route("**/admin/api/integrations/vscode-chat/refresh", retry)
page.goto(f"{admin_base_url}/admin/integrations")
+ # Startup reconciliation can temporarily disable Retry during the click.
+ # Exercise the action after initial polling has settled.
+ page.wait_for_function(
+ "state.startup && !state.startupRequest && state.startupTimer === null"
+ )
main = page.locator("#openVSCodeChatIntegration")
secondary = page.locator("#retryVSCodeChatIntegration")
if status_read_failed:
diff --git a/src/free_claude_code/runtime/application.py b/src/free_claude_code/runtime/application.py
index c887cf56c6..d1491eaba4 100644
--- a/src/free_claude_code/runtime/application.py
+++ b/src/free_claude_code/runtime/application.py
@@ -1,17 +1,11 @@
"""Single owner for application startup, shutdown, and runtime operations."""
import asyncio
-import importlib
import inspect
import logging
-import os
-import traceback
import uuid
from collections.abc import Awaitable, Callable, Mapping
-from dataclasses import dataclass, replace
-from functools import partial
-from pathlib import Path
-from typing import TYPE_CHECKING, Literal
+from dataclasses import replace
from loguru import logger
@@ -22,11 +16,8 @@
ConnectedAccountStatus,
)
from free_claude_code.application.errors import (
- ApplicationError,
ApplicationUnavailableError,
- InvalidRequestError,
)
-from free_claude_code.application.model_catalog import ModelCatalog, read_model_catalog
from free_claude_code.application.model_metadata import ProviderModelRefreshResult
from free_claude_code.application.ports import StopResult
from free_claude_code.config.admin.custom_providers import CustomProviderMutation
@@ -37,66 +28,26 @@
from free_claude_code.config.admin.status import provider_config_status
from free_claude_code.config.loader import clear_settings_cache
from free_claude_code.config.model_refs import parse_provider_type
-from free_claude_code.config.paths import (
- claude_desktop_disconnect_path,
- codex_model_catalog_path,
- messaging_state_dir_path,
-)
-from free_claude_code.config.server_urls import local_admin_url, local_proxy_root_url
+from free_claude_code.config.server_urls import local_admin_url
from free_claude_code.config.settings import Settings
+from free_claude_code.core.async_tasks import run_sync_owned
from free_claude_code.core.json_types import JsonObject
-from free_claude_code.harnesses import (
- claude_desktop_integration,
- claude_integration,
- codex_integration,
- jetbrains_acp_integration,
- vscode_chat_integration,
-)
-from free_claude_code.messaging.platforms import factory as messaging_platform_factory
-from free_claude_code.messaging.platforms.factory import MessagingPlatformOptions
-from free_claude_code.messaging.platforms.ports import (
- MessagingPlatformComponents,
- MessagingRuntime,
-)
from free_claude_code.messaging.voice import Transcriber
from free_claude_code.providers.credential_validation import (
CredentialStatus,
check_credentials,
)
-if TYPE_CHECKING:
- import free_claude_code.cli.managed as cli_managed
- import free_claude_code.messaging.workflow as messaging_workflow_module
-
- from .messaging_sqlite import SQLiteMessagingStore
-
-from free_claude_code.application.readiness import InitializationWait
-from free_claude_code.core.async_tasks import run_sync_owned
-
from .configuration import ConfigurationService
from .folder_picker import NativeFolderPicker
+from .integration_service import IntegrationService
+from .lifecycle import best_effort
+from .messaging_service import MessagingService
from .provider_manager import ProviderRuntimeManager
from .retired_chat import remove_retired_chat_history
from .sqlite_database import SQLiteDatabase
RestartCallback = Callable[[], None]
-IntegrationAction = Literal["status", "connect", "disconnect", "refresh"]
-
-
-@dataclass
-class _IntegrationUpdate:
- state: Literal["starting", "ready", "failed"] = "ready"
- changed: bool = False
- message: str | None = None
- task: asyncio.Task[None] | None = None
-
- def snapshot(self) -> JsonObject:
- return {"state": self.state, "changed": self.changed, "message": self.message}
-
- def complete(self, changed: bool = False) -> None:
- self.state = "ready"
- self.changed = changed
- self.message = None
_PROVIDER_CHECK_FAILURE_MESSAGE = (
@@ -104,39 +55,6 @@ def complete(self, changed: bool = False) -> None:
)
-async def best_effort(
- name: str,
- awaitable: Awaitable[object],
- *,
- log_verbose_errors: bool = False,
-) -> bool:
- """Run one cleanup step and report whether it completed.
-
- The lifecycle owner intentionally applies no generic timeout here. Cancelling
- an arbitrary cleanup at a deadline can abandon a half-closed SDK, thread, or
- provider resource; resource-specific cleanup or the process supervisor owns
- any force-termination deadline.
- """
- try:
- await awaitable
- except Exception as exc:
- if log_verbose_errors:
- logger.warning(
- "Shutdown step failed: {}: {}: {}",
- name,
- type(exc).__name__,
- exc,
- )
- else:
- logger.warning(
- "Shutdown step failed: {}: exc_type={}",
- name,
- type(exc).__name__,
- )
- return False
- return True
-
-
def startup_failure_message(settings: Settings, exc: Exception) -> str:
"""Return the existing concise ASGI startup failure message."""
if isinstance(exc, ApplicationUnavailableError):
@@ -198,13 +116,7 @@ def __init__(
self._configuration = configuration
self._code_service = code_service
self._database = database
- self._messaging_store: SQLiteMessagingStore | None = None
- self._messaging_import_task: asyncio.Task[None] | None = None
- self._messaging_warning: str | None = None
- self._messaging_storage_error: Exception | None = None
self._folder_picker = NativeFolderPicker()
- self._transcriber = transcriber
- self._transcriber_factory = transcriber_factory
self._restart_callback = restart_callback
self._connected_accounts = dict(connected_accounts or {})
self._connected_account_revisions = {
@@ -213,11 +125,6 @@ def __init__(
}
self._config_lock = asyncio.Lock()
self._pending_fields: list[str] = []
- self._messaging_runtime: MessagingRuntime | None = None
- self._messaging_workflow: messaging_workflow_module.MessagingWorkflow | None = (
- None
- )
- self._cli_manager: cli_managed.ManagedClaudeSessionManager | None = None
self._started = False
self._instance_id = uuid.uuid4().hex
self._draining = False
@@ -226,17 +133,21 @@ def __init__(
self._connected_accounts_closed = False
self._lifecycle_lock = asyncio.Lock()
self._startup_tasks: list[asyncio.Task[None]] = []
- self._claude_update = _IntegrationUpdate()
- self._desktop_update = _IntegrationUpdate()
- self._codex_update = _IntegrationUpdate()
- self._jetbrains_update = _IntegrationUpdate()
- self._vscode_update = _IntegrationUpdate()
- self._vscode_dirty = False
self._http_ready = asyncio.Event()
- self._messaging_state = (
- "disabled" if self.settings.messaging_platform == "none" else "starting"
+ self._integrations = IntegrationService(
+ provider_manager,
+ config_lock=self._config_lock,
+ is_draining=lambda: self._draining,
+ has_pending_restart=lambda: bool(self._pending_fields),
+ )
+ self._messaging = MessagingService(
+ settings=provider_manager.current_settings,
+ database=database,
+ http_ready=self._http_ready,
+ is_draining=lambda: self._draining,
+ transcriber=transcriber,
+ transcriber_factory=transcriber_factory,
)
- self._messaging_error: str | None = None
@property
def settings(self) -> Settings:
@@ -266,38 +177,22 @@ async def start(self) -> None:
)
self.provider_manager.start_model_list_refresh()
self.provider_manager.set_catalog_changed_callback(
- self._queue_vscode_refresh
+ self._integrations.catalog_changed
)
- self._queue_vscode_refresh()
- await self.refresh_claude_vscode()
- await self.refresh_codex_integration()
- await self.refresh_claude_desktop()
- await self.refresh_jetbrains_acp()
+ await self._integrations.start()
self._startup_tasks.append(
asyncio.create_task(
run_sync_owned(remove_retired_chat_history),
name="fcc-retired-chat-cleanup",
)
)
- if self._database is not None:
- self._messaging_state = "starting"
- self._messaging_import_task = asyncio.create_task(
- self._initialize_messaging_storage(),
- name="fcc-messaging-import",
- )
- self._startup_tasks.append(self._messaging_import_task)
+ self._messaging.start()
if self._code_service is not None:
self._startup_tasks.append(
asyncio.create_task(
self._code_service.start(), name="fcc-code-startup"
)
)
- self._startup_tasks.append(
- asyncio.create_task(
- self._start_messaging_if_configured(),
- name="fcc-messaging-startup",
- )
- )
self._started = True
except asyncio.CancelledError:
await self.close()
@@ -333,9 +228,13 @@ async def close(self) -> bool:
if self._closed:
return True
logger.info("Shutdown requested, cleaning up...")
+ self._integrations.cancel_background_tasks()
+ self._messaging.cancel_background_tasks()
for task in self._startup_tasks:
if not task.done():
task.cancel()
+ await self._integrations.wait_background_tasks()
+ await self._messaging.wait_background_tasks()
results = await asyncio.gather(*self._startup_tasks, return_exceptions=True)
for result in results:
if isinstance(result, Exception):
@@ -457,415 +356,64 @@ async def admin_values(self) -> ValueState:
return await self._configuration.admin_values()
async def vscode_chat_status(self) -> JsonObject:
- return await self._vscode_chat("status")
+ return await self._integrations.vscode_chat_status()
async def connect_vscode_chat(self) -> JsonObject:
- return await self._vscode_chat("connect")
+ return await self._integrations.connect_vscode_chat()
async def disconnect_vscode_chat(self) -> JsonObject:
- return await self._vscode_chat("disconnect")
+ return await self._integrations.disconnect_vscode_chat()
async def refresh_vscode_chat(self) -> JsonObject:
- self._check_integration_available()
- self._queue_vscode_refresh()
- return {"update": self._vscode_update.snapshot()}
-
- def _queue_vscode_refresh(self) -> None:
- if self._draining:
- return
- self._vscode_dirty = True
- update = self._vscode_update
- if update.task is not None and not update.task.done():
- return
- update.state, update.changed, update.message = "starting", False, None
-
- async def drain() -> None:
- while self._vscode_dirty:
- self._vscode_dirty = False
- try:
- result = await self._vscode_chat("refresh")
- update.complete(update.changed or result.get("changed") is True)
- except ApplicationError as exc:
- update.state, update.message = "failed", exc.message
- except Exception as exc:
- update.state = "failed"
- update.message = "Could not update VS Code models. Retry shortly."
- logger.warning(
- "VS Code integration update failed: exc_type={}",
- type(exc).__name__,
- )
- if self._vscode_dirty:
- update.state = "starting"
-
- update.task = asyncio.create_task(drain(), name="fcc-vscode-models")
- self._startup_tasks.append(update.task)
- update.task.add_done_callback(self._startup_tasks.remove)
-
- async def _vscode_chat(self, action: IntegrationAction) -> JsonObject:
- try:
- if action == "refresh":
- async with self._config_lock:
- self._check_integration_available()
- status = await run_sync_owned(
- lambda: vscode_chat_integration.status(
- vscode_chat_integration.config_path()
- )
- )
- if not status["connected"]:
- return {"changed": False}
- while True:
- snapshot = (
- await self.provider_manager.wait_for_catalog()
- if action in {"connect", "refresh"}
- else None
- )
- revision = self.provider_manager.catalog_status()["catalog_revision"]
- async with self._config_lock:
- self._check_integration_available()
- if snapshot is not None and (
- snapshot.current_settings() is not self.settings
- or revision
- != self.provider_manager.catalog_status()["catalog_revision"]
- or self.provider_manager.catalog_status()["catalog"] != "ready"
- ):
- continue
- catalog = (
- read_model_catalog(snapshot) if snapshot is not None else None
- )
- settings = self.settings
-
- def operate(
- catalog: ModelCatalog | None = catalog,
- settings: Settings = settings,
- ) -> JsonObject:
- path = vscode_chat_integration.config_path()
- changed = False
- if action == "disconnect":
- vscode_chat_integration.disconnect(path)
- elif catalog is not None:
- changed = vscode_chat_integration.configure(
- path,
- local_proxy_root_url(settings),
- settings.proxy_auth_token,
- catalog.models,
- only_existing=action == "refresh",
- )
- if action == "refresh":
- return {"changed": changed}
- return vscode_chat_integration.status(path)
-
- result = await run_sync_owned(operate)
- if action in {"connect", "disconnect"}:
- self._vscode_update.complete()
- if action == "status":
- result["update"] = self._vscode_update.snapshot()
- return result
- except ValueError, UnicodeError:
- raise InvalidRequestError(
- "Could not configure VS Code Chat. Check chatLanguageModels.json for invalid JSON or conflicting FCC groups."
- ) from None
- except OSError:
- raise ApplicationUnavailableError(
- "Could not access chatLanguageModels.json. Finish configuration edits, check file permissions, and retry."
- ) from None
+ return await self._integrations.refresh_vscode_chat()
async def claude_vscode_status(self) -> JsonObject:
- return await self._claude_vscode("status")
+ return await self._integrations.claude_vscode_status()
async def connect_claude_vscode(self) -> JsonObject:
- return await self._claude_vscode("connect")
+ return await self._integrations.connect_claude_vscode()
async def disconnect_claude_vscode(self) -> JsonObject:
- return await self._claude_vscode("disconnect")
-
- def _check_integration_available(self) -> None:
- if self._draining or self._pending_fields:
- raise ApplicationUnavailableError(
- "Wait for FCC to restart before changing the integration."
- )
-
- def _start_integration_update(
- self,
- update: _IntegrationUpdate,
- operation: Callable[[], Awaitable[JsonObject]],
- ) -> JsonObject:
- self._check_integration_available()
- if update.task is None or update.task.done():
- update.state, update.changed, update.message = "starting", False, None
-
- async def run() -> None:
- try:
- result = await operation()
- update.complete(result.get("changed") is True)
- except ApplicationError as exc:
- update.state, update.message = "failed", exc.message
- except Exception as exc:
- update.state = "failed"
- update.message = (
- "Could not update integration settings. Retry shortly."
- )
- logger.warning(
- "Integration update failed: exc_type={}", type(exc).__name__
- )
-
- update.task = asyncio.create_task(run(), name="fcc-integration-update")
- self._startup_tasks.append(update.task)
- update.task.add_done_callback(self._startup_tasks.remove)
- return {"update": update.snapshot()}
+ return await self._integrations.disconnect_claude_vscode()
async def refresh_claude_vscode(self) -> JsonObject:
- return self._start_integration_update(
- self._claude_update, partial(self._claude_vscode, "refresh")
- )
+ return await self._integrations.refresh_claude_vscode()
async def refresh_codex_integration(self) -> JsonObject:
- return self._start_integration_update(
- self._codex_update, partial(self._codex_integration, "refresh")
- )
+ return await self._integrations.refresh_codex_integration()
async def jetbrains_acp_status(self) -> JsonObject:
- return await self._jetbrains_acp("status")
+ return await self._integrations.jetbrains_acp_status()
async def connect_jetbrains_acp(self) -> JsonObject:
- return await self._jetbrains_acp("connect")
+ return await self._integrations.connect_jetbrains_acp()
async def disconnect_jetbrains_acp(self) -> JsonObject:
- return await self._jetbrains_acp("disconnect")
+ return await self._integrations.disconnect_jetbrains_acp()
async def refresh_jetbrains_acp(self) -> JsonObject:
- return self._start_integration_update(
- self._jetbrains_update, partial(self._jetbrains_acp, "refresh")
- )
-
- async def _jetbrains_acp(self, action: IntegrationAction) -> JsonObject:
- async with self._config_lock:
- self._check_integration_available()
- settings = self.settings
-
- def operate() -> JsonObject:
- path = jetbrains_acp_integration.config_path()
- url = local_proxy_root_url(settings)
- if action == "refresh":
- return {
- "changed": jetbrains_acp_integration.refresh_connected(
- path, url, settings.proxy_auth_token
- )
- }
- return jetbrains_acp_integration.configure(
- path,
- url,
- settings.proxy_auth_token,
- None if action == "status" else action == "connect",
- )
-
- try:
- result = await run_sync_owned(operate)
- except jetbrains_acp_integration.SetupError as exc:
- raise InvalidRequestError(str(exc)) from None
- except ValueError, UnicodeError:
- raise InvalidRequestError(
- "Could not read JetBrains ACP configuration. Check the JSON in acp.json and the installed Claude Agent metadata."
- ) from None
- except OSError:
- raise ApplicationUnavailableError(
- "Could not access JetBrains ACP files. Finish any configuration edits, check file permissions, and retry."
- ) from None
- if action in {"connect", "disconnect"}:
- self._jetbrains_update.complete()
- if action == "status":
- result["update"] = self._jetbrains_update.snapshot()
- return result
+ return await self._integrations.refresh_jetbrains_acp()
async def claude_desktop_status(self) -> JsonObject:
- return await self._claude_desktop("status")
+ return await self._integrations.claude_desktop_status()
async def connect_claude_desktop(self) -> JsonObject:
- return await self._claude_desktop("connect")
+ return await self._integrations.connect_claude_desktop()
async def disconnect_claude_desktop(self) -> JsonObject:
- return await self._claude_desktop("disconnect")
+ return await self._integrations.disconnect_claude_desktop()
async def refresh_claude_desktop(self) -> JsonObject:
- return self._start_integration_update(
- self._desktop_update, partial(self._claude_desktop, "refresh")
- )
-
- async def _claude_desktop(self, action: IntegrationAction) -> JsonObject:
- async with self._config_lock:
- self._check_integration_available()
- settings = self.settings
-
- def operate() -> JsonObject:
- root = claude_desktop_integration.config_root()
- url = local_proxy_root_url(settings)
- if action == "refresh":
- return {
- "changed": claude_desktop_integration.refresh_connected(
- root,
- url,
- settings.proxy_auth_token,
- disconnect_path=claude_desktop_disconnect_path(),
- )
- }
- return claude_desktop_integration.configure(
- root,
- url,
- settings.proxy_auth_token,
- None if action == "status" else action == "connect",
- disconnect_path=claude_desktop_disconnect_path(),
- )
-
- try:
- result = await run_sync_owned(operate)
- except claude_desktop_integration.ManagedDesktopError:
- raise InvalidRequestError(
- "Claude Desktop is managed by an organization, or its policy could not be read. FCC can configure only unmanaged Desktop installations."
- ) from None
- except claude_desktop_integration.PendingDisconnectError:
- raise InvalidRequestError(
- "Finish disconnecting Claude Desktop before connecting again."
- ) from None
- except claude_desktop_integration.PendingMigrationError:
- raise InvalidRequestError(
- "Claude Desktop has data in its previous Windows location. Launch Claude Desktop once so it can migrate that data, fully quit it, then retry Connect."
- ) from None
- except ValueError, UnicodeError:
- raise InvalidRequestError(
- "Could not configure Claude Desktop. Check its configuration JSON and FCC disconnect record, and ensure FCC uses a localhost address and a nonempty managed token."
- ) from None
- except OSError:
- raise ApplicationUnavailableError(
- "Could not access Claude Desktop settings or the FCC disconnect record. Fully quit Claude Desktop, check file permissions, and retry."
- ) from None
- if action in {"connect", "disconnect"}:
- self._desktop_update.complete()
- if action == "status":
- if self._desktop_update.state != "ready":
- result["connected"] = None
- result["update"] = self._desktop_update.snapshot()
- return result
-
- async def _claude_vscode(self, action: IntegrationAction) -> JsonObject:
- async with self._config_lock:
- self._check_integration_available()
- settings = self.settings
-
- def operate() -> JsonObject:
- path = claude_integration.settings_path()
- state_path = claude_integration.claude_state_path()
- if action == "status" and self._claude_update.state != "ready":
- return {
- "connected": None,
- "paths": {
- "vscode_settings": str(path.resolve()),
- "claude_state": str(state_path.resolve()),
- },
- }
- if action == "refresh":
- return {
- "changed": claude_integration.refresh_connected(
- path,
- state_path,
- local_proxy_root_url(settings),
- settings.proxy_auth_token,
- )
- }
- return claude_integration.configure(
- path,
- state_path,
- local_proxy_root_url(settings),
- settings.proxy_auth_token,
- None if action == "status" else action == "connect",
- )
-
- try:
- result = await run_sync_owned(operate)
- except ValueError, UnicodeError:
- raise InvalidRequestError(
- "Could not read Claude integration settings. Check the JSON in VS Code settings.json and .claude.json."
- ) from None
- except OSError:
- raise ApplicationUnavailableError(
- "Could not access VS Code settings.json or .claude.json. Check file permissions and try again."
- ) from None
- if action in {"connect", "disconnect"}:
- self._claude_update.complete()
- if action == "status":
- result["update"] = self._claude_update.snapshot()
- return result
+ return await self._integrations.refresh_claude_desktop()
async def codex_integration_status(self) -> JsonObject:
- return await self._codex_integration("status")
+ return await self._integrations.codex_integration_status()
async def connect_codex(self) -> JsonObject:
- return await self._codex_integration("connect")
+ return await self._integrations.connect_codex()
async def disconnect_codex(self) -> JsonObject:
- return await self._codex_integration("disconnect")
-
- async def _codex_integration(self, action: IntegrationAction) -> JsonObject:
- wait = InitializationWait(None) if action == "refresh" else InitializationWait()
- needs_catalog = action in {"connect", "refresh"}
- try:
- if action == "refresh":
- async with self._config_lock:
- self._check_integration_available()
- url = local_proxy_root_url(self.settings)
- if not await run_sync_owned(
- lambda: codex_integration.recognizes_connection(
- codex_integration.config_path(), url
- )
- ):
- return {"changed": False}
- while True:
- generation_id = (
- await self.provider_manager.wait_for_catalog_file(wait)
- if needs_catalog
- else None
- )
- async with self._config_lock:
- self._check_integration_available()
- if needs_catalog and (
- generation_id != self.provider_manager.current_generation_id
- or self.provider_manager.catalog_status()["catalog"] != "ready"
- ):
- continue
- url = local_proxy_root_url(self.settings)
-
- def operate(url: str = url) -> JsonObject:
- path = codex_integration.config_path()
- if action == "status" and self._codex_update.state != "ready":
- return {
- "connected": None,
- "paths": {"codex_config": str(path.resolve())},
- }
- if action == "refresh":
- return {
- "changed": codex_integration.refresh_connected(
- path, codex_model_catalog_path(), url
- )
- }
- return codex_integration.configure(
- path,
- codex_model_catalog_path(),
- url,
- None if action == "status" else action == "connect",
- )
-
- result = await run_sync_owned(operate)
- if action in {"connect", "disconnect"}:
- self._codex_update.complete()
- if action == "status":
- result["update"] = self._codex_update.snapshot()
- return result
- except ValueError, UnicodeError:
- raise InvalidRequestError(
- "Could not read Codex settings. Check the TOML in config.toml."
- ) from None
- except OSError:
- raise ApplicationUnavailableError(
- "Could not access Codex config.toml. Check file permissions and try again."
- ) from None
+ return await self._integrations.disconnect_codex()
async def admin_status(self) -> JsonObject:
values = await self.admin_values()
@@ -878,18 +426,8 @@ async def admin_status(self) -> JsonObject:
"code": self._code_service.storage_status()
if self._code_service
else {"state": "disabled"},
- "messaging": {
- "state": self._messaging_state,
- "message": self._messaging_error,
- "warning": self._messaging_warning,
- },
- "integrations": {
- "vscode-chat": self._vscode_update.snapshot(),
- "claude-vscode": self._claude_update.snapshot(),
- "claude-desktop": self._desktop_update.snapshot(),
- "codex": self._codex_update.snapshot(),
- "jetbrains-acp": self._jetbrains_update.snapshot(),
- },
+ "messaging": self._messaging.snapshot(),
+ "integrations": self._integrations.snapshot(),
},
"host": settings.host,
"port": settings.port,
@@ -990,13 +528,7 @@ def _signal_restart(self) -> bool:
return True
async def stop_all(self) -> StopResult | None:
- if self._messaging_workflow is not None:
- outcome = await self._messaging_workflow.stop_all_tasks()
- return StopResult(cancelled_count=outcome.cancelled_count)
- if self._cli_manager is not None:
- await self._cli_manager.stop_all()
- return StopResult(source="cli_manager")
- return None
+ return await self._messaging.stop_all()
async def _commit_admin_update(
self,
@@ -1023,167 +555,10 @@ def _restart_metadata(
result["instance_id"] = self._instance_id
return result
- async def _start_messaging_if_configured(self) -> None:
- if self._messaging_import_task is not None:
- await asyncio.shield(self._messaging_import_task)
- if self.settings.messaging_platform == "none":
- self._messaging_state = "disabled"
- return
- try:
-
- def load_modules() -> None:
- for name in ("cli.managed", "messaging.workflow"):
- importlib.import_module(f"free_claude_code.{name}")
- importlib.import_module(
- f"free_claude_code.messaging.platforms.{self.settings.messaging_platform}"
- )
-
- await run_sync_owned(load_modules)
- if self._transcriber_factory is not None:
- self._transcriber = await self._transcriber_factory(self.settings)
- components = messaging_platform_factory.create_messaging_components(
- self.settings.messaging_platform,
- self._messaging_options(),
- )
- if components is not None:
- await self._start_messaging_workflow(components)
- self._messaging_state = "ready"
- else:
- self._messaging_state = "disabled"
- except ImportError as exc:
- self._messaging_state = "failed"
- self._messaging_error = (
- "Messaging could not start. Check its configuration and restart FCC."
- )
- cleaned = await self._cleanup_messaging()
- if self.settings.log_api_error_tracebacks:
- logger.warning("Messaging module import error: {}", exc)
- else:
- logger.warning(
- "Messaging module import error: exc_type={}",
- type(exc).__name__,
- )
- if not cleaned:
- raise RuntimeError("Messaging startup cleanup incomplete") from exc
- except Exception as exc:
- self._messaging_state = "failed"
- self._messaging_error = (
- "Messaging could not start. Check its configuration and restart FCC."
- )
- cleaned = await self._cleanup_messaging()
- if self.settings.log_api_error_tracebacks:
- logger.error("Failed to start messaging platform: {}", exc)
- logger.error(traceback.format_exc())
- else:
- logger.error(
- "Failed to start messaging platform: exc_type={}",
- type(exc).__name__,
- )
- if not cleaned:
- raise RuntimeError("Messaging startup cleanup incomplete") from exc
-
- def _messaging_options(self) -> MessagingPlatformOptions:
- settings = self.settings
- return MessagingPlatformOptions(
- telegram_bot_token=settings.telegram_bot_token,
- allowed_telegram_user_id=settings.allowed_telegram_user_id,
- telegram_proxy_url=settings.telegram_proxy_url,
- discord_bot_token=settings.discord_bot_token,
- allowed_discord_channels=settings.allowed_discord_channels,
- transcriber=self._transcriber,
- messaging_rate_limit=settings.messaging_rate_limit,
- messaging_rate_window=settings.messaging_rate_window,
- log_raw_messaging_content=settings.log_raw_messaging_content,
- log_messaging_error_details=settings.log_messaging_error_details,
- log_api_error_tracebacks=settings.log_api_error_tracebacks,
- )
-
- async def _initialize_messaging_storage(self) -> None:
- if self._database is None:
- return
- try:
- await self._database.start()
- from .messaging_import import import_legacy
- from .messaging_sqlite import SQLiteMessagingStore
-
- self._messaging_store = SQLiteMessagingStore(
- self._database,
- managed_message_cap=self.settings.max_message_log_entries_per_chat,
- )
- self._messaging_warning = await import_legacy(
- self._database, Path(messaging_state_dir_path()) / "sessions.json"
- )
- await self._messaging_store.trim()
- except Exception as exc:
- self._messaging_storage_error = exc
- logger.error(
- "Messaging storage initialization failed: {}", type(exc).__name__
- )
- return
- if self._messaging_warning:
- logger.warning("{}", self._messaging_warning)
-
- async def _start_messaging_workflow(
- self,
- components: MessagingPlatformComponents,
- ) -> None:
- import free_claude_code.cli.managed as cli_managed
- import free_claude_code.messaging.workflow as messaging_workflow_module
-
- settings = self.settings
- self._messaging_runtime = components.runtime
- workspace = (
- os.path.abspath(settings.allowed_dir)
- if settings.allowed_dir
- else os.getcwd()
- )
- await run_sync_owned(partial(os.makedirs, workspace, exist_ok=True))
- allowed_dirs = [workspace] if settings.allowed_dir else []
-
- self._cli_manager = cli_managed.ManagedClaudeSessionManager(
- workspace_path=workspace,
- proxy_root_url=local_proxy_root_url(settings),
- allowed_dirs=allowed_dirs,
- auth_token=settings.proxy_auth_token,
- log_raw_cli_diagnostics=settings.log_raw_cli_diagnostics,
- log_messaging_error_details=settings.log_messaging_error_details,
- )
- if self._messaging_import_task is not None:
- await asyncio.shield(self._messaging_import_task)
- if self._messaging_storage_error is not None:
- raise ApplicationUnavailableError(
- "Messaging storage is unavailable."
- ) from self._messaging_storage_error
- if self._messaging_store is None:
- raise ApplicationUnavailableError("Messaging storage is unavailable.")
- session_store = self._messaging_store
- workflow = messaging_workflow_module.MessagingWorkflow(
- platform_name=components.name,
- outbound=components.outbound,
- voice_cancellation=components.voice_cancellation,
- cli_manager=self._cli_manager,
- session_store=session_store,
- debug_platform_edits=settings.debug_platform_edits,
- debug_subagent_stack=settings.debug_subagent_stack,
- log_raw_cli_diagnostics=settings.log_raw_cli_diagnostics,
- log_messaging_error_details=settings.log_messaging_error_details,
- )
- self._messaging_workflow = workflow
- await workflow.restore()
- components.runtime.on_message(workflow.handle_message)
- await self._http_ready.wait()
- if self._draining:
- return
- await components.runtime.start()
- await workflow.repair_restored_statuses()
- if components.startup_notice is not None:
- await workflow.publish_startup_notice(components.startup_notice)
- logger.info("{} platform started with messaging workflow", components.name)
-
async def _close_owned_resources(self) -> bool:
if not await best_effort("folder_picker.close", self._folder_picker.close()):
return False
- if not await self._cleanup_messaging():
+ if not await self._messaging.close_delivery():
return False
verbose = self.settings.log_api_error_tracebacks
if self._code_service is not None and not await best_effort(
@@ -1196,7 +571,7 @@ async def _close_owned_resources(self) -> bool:
"database.close", self._database.close()
):
return False
- if not await self._cleanup_transcriber():
+ if not await self._messaging.close_transcriber():
return False
if not self._provider_manager_closed:
self._provider_manager_closed = await best_effort(
@@ -1228,70 +603,3 @@ def _connected_account(self, provider_id: str) -> ConnectedAccountPort:
f"Provider {provider_id!r} does not support connected-account login."
)
return manager
-
- async def _cleanup_messaging(self) -> bool:
- verbose = self.settings.log_api_error_tracebacks
- workflow = self._messaging_workflow
- runtime = self._messaging_runtime
- cli_manager = self._cli_manager
-
- if runtime is not None:
- quiesced = await best_effort(
- "messaging_runtime.quiesce",
- runtime.quiesce(),
- log_verbose_errors=verbose,
- )
- if not quiesced:
- # Delivery must remain available until ingress is known stopped.
- # Retaining the graph lets the next close retry this exact gate.
- return False
-
- if workflow is not None:
- closed = await best_effort(
- "messaging_workflow.close",
- workflow.close(),
- log_verbose_errors=verbose,
- )
- if not closed:
- # Active workflow tasks may still need delivery, transcription,
- # CLI sessions, and providers while a later close retries drain.
- return False
- if self._messaging_workflow is workflow:
- self._messaging_workflow = None
- if self._cli_manager is cli_manager:
- self._cli_manager = None
- elif cli_manager is not None:
- drained = await best_effort(
- "cli_manager.stop_all",
- cli_manager.stop_all(),
- log_verbose_errors=verbose,
- )
- if not drained:
- return False
- if self._cli_manager is cli_manager:
- self._cli_manager = None
-
- if runtime is not None:
- closed = await best_effort(
- "messaging_runtime.close",
- runtime.close(),
- log_verbose_errors=verbose,
- )
- if not closed:
- return False
- if self._messaging_runtime is runtime:
- self._messaging_runtime = None
- return True
-
- async def _cleanup_transcriber(self) -> bool:
- transcriber = self._transcriber
- if transcriber is None:
- return True
- closed = await best_effort(
- "transcriber.close",
- transcriber.close(),
- log_verbose_errors=self.settings.log_api_error_tracebacks,
- )
- if closed and self._transcriber is transcriber:
- self._transcriber = None
- return closed
diff --git a/src/free_claude_code/runtime/integration_service.py b/src/free_claude_code/runtime/integration_service.py
new file mode 100644
index 0000000000..d6e3ffea52
--- /dev/null
+++ b/src/free_claude_code/runtime/integration_service.py
@@ -0,0 +1,524 @@
+"""Own integration workflows while sharing configuration publication ordering."""
+
+import asyncio
+from collections.abc import Awaitable, Callable
+from dataclasses import dataclass
+from functools import partial
+from typing import Literal
+
+from loguru import logger
+
+from free_claude_code.application.errors import (
+ ApplicationError,
+ ApplicationUnavailableError,
+ InvalidRequestError,
+)
+from free_claude_code.application.model_catalog import ModelCatalog, read_model_catalog
+from free_claude_code.application.readiness import InitializationWait
+from free_claude_code.config.paths import (
+ claude_desktop_disconnect_path,
+ codex_model_catalog_path,
+)
+from free_claude_code.config.server_urls import local_proxy_root_url
+from free_claude_code.config.settings import Settings
+from free_claude_code.core.async_tasks import run_sync_owned
+from free_claude_code.core.json_types import JsonObject
+from free_claude_code.harnesses import (
+ claude_desktop_integration,
+ claude_integration,
+ codex_integration,
+ jetbrains_acp_integration,
+ vscode_chat_integration,
+)
+
+from .provider_manager import ProviderRuntimeManager
+
+IntegrationAction = Literal["status", "connect", "disconnect", "refresh"]
+
+
+@dataclass
+class _IntegrationUpdate:
+ state: Literal["starting", "ready", "failed"] = "ready"
+ changed: bool = False
+ message: str | None = None
+ task: asyncio.Task[None] | None = None
+
+ def snapshot(self) -> JsonObject:
+ return {"state": self.state, "changed": self.changed, "message": self.message}
+
+ def complete(self, changed: bool = False) -> None:
+ self.state = "ready"
+ self.changed = changed
+ self.message = None
+
+
+class IntegrationService:
+ """Own integration state and background refreshes, borrowing runtime resources."""
+
+ def __init__(
+ self,
+ provider_manager: ProviderRuntimeManager,
+ *,
+ config_lock: asyncio.Lock,
+ is_draining: Callable[[], bool],
+ has_pending_restart: Callable[[], bool],
+ ) -> None:
+ self.provider_manager = provider_manager
+ self._config_lock = config_lock
+ self._is_draining = is_draining
+ self._has_pending_restart = has_pending_restart
+ self._claude_update = _IntegrationUpdate()
+ self._desktop_update = _IntegrationUpdate()
+ self._codex_update = _IntegrationUpdate()
+ self._jetbrains_update = _IntegrationUpdate()
+ self._vscode_update = _IntegrationUpdate()
+ self._vscode_dirty = False
+
+ @property
+ def settings(self) -> Settings:
+ return self.provider_manager.current_settings()
+
+ def _updates(self) -> dict[str, _IntegrationUpdate]:
+ return {
+ "vscode-chat": self._vscode_update,
+ "claude-vscode": self._claude_update,
+ "claude-desktop": self._desktop_update,
+ "codex": self._codex_update,
+ "jetbrains-acp": self._jetbrains_update,
+ }
+
+ def snapshot(self) -> JsonObject:
+ return {name: update.snapshot() for name, update in self._updates().items()}
+
+ async def start(self) -> None:
+ self.catalog_changed()
+ await self.refresh_claude_vscode()
+ await self.refresh_codex_integration()
+ await self.refresh_claude_desktop()
+ await self.refresh_jetbrains_acp()
+
+ def cancel_background_tasks(self) -> None:
+ for update in self._updates().values():
+ if update.task is not None and not update.task.done():
+ update.task.cancel()
+
+ async def wait_background_tasks(self) -> None:
+ tasks = tuple(
+ update.task
+ for update in self._updates().values()
+ if update.task is not None
+ )
+ results = await asyncio.gather(*tasks, return_exceptions=True)
+ for result in results:
+ if isinstance(result, Exception):
+ logger.warning(
+ "Background initialization ended with exc_type={}",
+ type(result).__name__,
+ )
+
+ async def vscode_chat_status(self) -> JsonObject:
+ return await self._vscode_chat("status")
+
+ async def connect_vscode_chat(self) -> JsonObject:
+ return await self._vscode_chat("connect")
+
+ async def disconnect_vscode_chat(self) -> JsonObject:
+ return await self._vscode_chat("disconnect")
+
+ async def refresh_vscode_chat(self) -> JsonObject:
+ self._check_integration_available()
+ self.catalog_changed()
+ return {"update": self._vscode_update.snapshot()}
+
+ def catalog_changed(self) -> None:
+ if self._is_draining():
+ return
+ self._vscode_dirty = True
+ update = self._vscode_update
+ if update.task is not None and not update.task.done():
+ return
+ update.state, update.changed, update.message = "starting", False, None
+
+ async def drain() -> None:
+ while self._vscode_dirty:
+ self._vscode_dirty = False
+ try:
+ result = await self._vscode_chat("refresh")
+ update.complete(update.changed or result.get("changed") is True)
+ except ApplicationError as exc:
+ update.state, update.message = "failed", exc.message
+ except Exception as exc:
+ update.state = "failed"
+ update.message = "Could not update VS Code models. Retry shortly."
+ logger.warning(
+ "VS Code integration update failed: exc_type={}",
+ type(exc).__name__,
+ )
+ if self._vscode_dirty:
+ update.state = "starting"
+
+ update.task = asyncio.create_task(drain(), name="fcc-vscode-models")
+
+ async def _vscode_chat(self, action: IntegrationAction) -> JsonObject:
+ try:
+ if action == "refresh":
+ async with self._config_lock:
+ self._check_integration_available()
+ status = await run_sync_owned(
+ lambda: vscode_chat_integration.status(
+ vscode_chat_integration.config_path()
+ )
+ )
+ if not status["connected"]:
+ return {"changed": False}
+ while True:
+ snapshot = (
+ await self.provider_manager.wait_for_catalog()
+ if action in {"connect", "refresh"}
+ else None
+ )
+ revision = self.provider_manager.catalog_status()["catalog_revision"]
+ async with self._config_lock:
+ self._check_integration_available()
+ if snapshot is not None and (
+ snapshot.current_settings() is not self.settings
+ or revision
+ != self.provider_manager.catalog_status()["catalog_revision"]
+ or self.provider_manager.catalog_status()["catalog"] != "ready"
+ ):
+ continue
+ catalog = (
+ read_model_catalog(snapshot) if snapshot is not None else None
+ )
+ settings = self.settings
+
+ def operate(
+ catalog: ModelCatalog | None = catalog,
+ settings: Settings = settings,
+ ) -> JsonObject:
+ path = vscode_chat_integration.config_path()
+ changed = False
+ if action == "disconnect":
+ vscode_chat_integration.disconnect(path)
+ elif catalog is not None:
+ changed = vscode_chat_integration.configure(
+ path,
+ local_proxy_root_url(settings),
+ settings.proxy_auth_token,
+ catalog.models,
+ only_existing=action == "refresh",
+ )
+ if action == "refresh":
+ return {"changed": changed}
+ return vscode_chat_integration.status(path)
+
+ result = await run_sync_owned(operate)
+ if action in {"connect", "disconnect"}:
+ self._vscode_update.complete()
+ if action == "status":
+ result["update"] = self._vscode_update.snapshot()
+ return result
+ except ValueError, UnicodeError:
+ raise InvalidRequestError(
+ "Could not configure VS Code Chat. Check chatLanguageModels.json for invalid JSON or conflicting FCC groups."
+ ) from None
+ except OSError:
+ raise ApplicationUnavailableError(
+ "Could not access chatLanguageModels.json. Finish configuration edits, check file permissions, and retry."
+ ) from None
+
+ async def claude_vscode_status(self) -> JsonObject:
+ return await self._claude_vscode("status")
+
+ async def connect_claude_vscode(self) -> JsonObject:
+ return await self._claude_vscode("connect")
+
+ async def disconnect_claude_vscode(self) -> JsonObject:
+ return await self._claude_vscode("disconnect")
+
+ def _check_integration_available(self) -> None:
+ if self._is_draining() or self._has_pending_restart():
+ raise ApplicationUnavailableError(
+ "Wait for FCC to restart before changing the integration."
+ )
+
+ def _start_integration_update(
+ self,
+ update: _IntegrationUpdate,
+ operation: Callable[[], Awaitable[JsonObject]],
+ ) -> JsonObject:
+ self._check_integration_available()
+ if update.task is None or update.task.done():
+ update.state, update.changed, update.message = "starting", False, None
+
+ async def run() -> None:
+ try:
+ result = await operation()
+ update.complete(result.get("changed") is True)
+ except ApplicationError as exc:
+ update.state, update.message = "failed", exc.message
+ except Exception as exc:
+ update.state = "failed"
+ update.message = (
+ "Could not update integration settings. Retry shortly."
+ )
+ logger.warning(
+ "Integration update failed: exc_type={}", type(exc).__name__
+ )
+
+ update.task = asyncio.create_task(run(), name="fcc-integration-update")
+ return {"update": update.snapshot()}
+
+ async def refresh_claude_vscode(self) -> JsonObject:
+ return self._start_integration_update(
+ self._claude_update, partial(self._claude_vscode, "refresh")
+ )
+
+ async def refresh_codex_integration(self) -> JsonObject:
+ return self._start_integration_update(
+ self._codex_update, partial(self._codex_integration, "refresh")
+ )
+
+ async def jetbrains_acp_status(self) -> JsonObject:
+ return await self._jetbrains_acp("status")
+
+ async def connect_jetbrains_acp(self) -> JsonObject:
+ return await self._jetbrains_acp("connect")
+
+ async def disconnect_jetbrains_acp(self) -> JsonObject:
+ return await self._jetbrains_acp("disconnect")
+
+ async def refresh_jetbrains_acp(self) -> JsonObject:
+ return self._start_integration_update(
+ self._jetbrains_update, partial(self._jetbrains_acp, "refresh")
+ )
+
+ async def _jetbrains_acp(self, action: IntegrationAction) -> JsonObject:
+ async with self._config_lock:
+ self._check_integration_available()
+ settings = self.settings
+
+ def operate() -> JsonObject:
+ path = jetbrains_acp_integration.config_path()
+ url = local_proxy_root_url(settings)
+ if action == "refresh":
+ return {
+ "changed": jetbrains_acp_integration.refresh_connected(
+ path, url, settings.proxy_auth_token
+ )
+ }
+ return jetbrains_acp_integration.configure(
+ path,
+ url,
+ settings.proxy_auth_token,
+ None if action == "status" else action == "connect",
+ )
+
+ try:
+ result = await run_sync_owned(operate)
+ except jetbrains_acp_integration.SetupError as exc:
+ raise InvalidRequestError(str(exc)) from None
+ except ValueError, UnicodeError:
+ raise InvalidRequestError(
+ "Could not read JetBrains ACP configuration. Check the JSON in acp.json and the installed Claude Agent metadata."
+ ) from None
+ except OSError:
+ raise ApplicationUnavailableError(
+ "Could not access JetBrains ACP files. Finish any configuration edits, check file permissions, and retry."
+ ) from None
+ if action in {"connect", "disconnect"}:
+ self._jetbrains_update.complete()
+ if action == "status":
+ result["update"] = self._jetbrains_update.snapshot()
+ return result
+
+ async def claude_desktop_status(self) -> JsonObject:
+ return await self._claude_desktop("status")
+
+ async def connect_claude_desktop(self) -> JsonObject:
+ return await self._claude_desktop("connect")
+
+ async def disconnect_claude_desktop(self) -> JsonObject:
+ return await self._claude_desktop("disconnect")
+
+ async def refresh_claude_desktop(self) -> JsonObject:
+ return self._start_integration_update(
+ self._desktop_update, partial(self._claude_desktop, "refresh")
+ )
+
+ async def _claude_desktop(self, action: IntegrationAction) -> JsonObject:
+ async with self._config_lock:
+ self._check_integration_available()
+ settings = self.settings
+
+ def operate() -> JsonObject:
+ root = claude_desktop_integration.config_root()
+ url = local_proxy_root_url(settings)
+ if action == "refresh":
+ return {
+ "changed": claude_desktop_integration.refresh_connected(
+ root,
+ url,
+ settings.proxy_auth_token,
+ disconnect_path=claude_desktop_disconnect_path(),
+ )
+ }
+ return claude_desktop_integration.configure(
+ root,
+ url,
+ settings.proxy_auth_token,
+ None if action == "status" else action == "connect",
+ disconnect_path=claude_desktop_disconnect_path(),
+ )
+
+ try:
+ result = await run_sync_owned(operate)
+ except claude_desktop_integration.ManagedDesktopError:
+ raise InvalidRequestError(
+ "Claude Desktop is managed by an organization, or its policy could not be read. FCC can configure only unmanaged Desktop installations."
+ ) from None
+ except claude_desktop_integration.PendingDisconnectError:
+ raise InvalidRequestError(
+ "Finish disconnecting Claude Desktop before connecting again."
+ ) from None
+ except claude_desktop_integration.PendingMigrationError:
+ raise InvalidRequestError(
+ "Claude Desktop has data in its previous Windows location. Launch Claude Desktop once so it can migrate that data, fully quit it, then retry Connect."
+ ) from None
+ except ValueError, UnicodeError:
+ raise InvalidRequestError(
+ "Could not configure Claude Desktop. Check its configuration JSON and FCC disconnect record, and ensure FCC uses a localhost address and a nonempty managed token."
+ ) from None
+ except OSError:
+ raise ApplicationUnavailableError(
+ "Could not access Claude Desktop settings or the FCC disconnect record. Fully quit Claude Desktop, check file permissions, and retry."
+ ) from None
+ if action in {"connect", "disconnect"}:
+ self._desktop_update.complete()
+ if action == "status":
+ if self._desktop_update.state != "ready":
+ result["connected"] = None
+ result["update"] = self._desktop_update.snapshot()
+ return result
+
+ async def _claude_vscode(self, action: IntegrationAction) -> JsonObject:
+ async with self._config_lock:
+ self._check_integration_available()
+ settings = self.settings
+
+ def operate() -> JsonObject:
+ path = claude_integration.settings_path()
+ state_path = claude_integration.claude_state_path()
+ if action == "status" and self._claude_update.state != "ready":
+ return {
+ "connected": None,
+ "paths": {
+ "vscode_settings": str(path.resolve()),
+ "claude_state": str(state_path.resolve()),
+ },
+ }
+ if action == "refresh":
+ return {
+ "changed": claude_integration.refresh_connected(
+ path,
+ state_path,
+ local_proxy_root_url(settings),
+ settings.proxy_auth_token,
+ )
+ }
+ return claude_integration.configure(
+ path,
+ state_path,
+ local_proxy_root_url(settings),
+ settings.proxy_auth_token,
+ None if action == "status" else action == "connect",
+ )
+
+ try:
+ result = await run_sync_owned(operate)
+ except ValueError, UnicodeError:
+ raise InvalidRequestError(
+ "Could not read Claude integration settings. Check the JSON in VS Code settings.json and .claude.json."
+ ) from None
+ except OSError:
+ raise ApplicationUnavailableError(
+ "Could not access VS Code settings.json or .claude.json. Check file permissions and try again."
+ ) from None
+ if action in {"connect", "disconnect"}:
+ self._claude_update.complete()
+ if action == "status":
+ result["update"] = self._claude_update.snapshot()
+ return result
+
+ async def codex_integration_status(self) -> JsonObject:
+ return await self._codex_integration("status")
+
+ async def connect_codex(self) -> JsonObject:
+ return await self._codex_integration("connect")
+
+ async def disconnect_codex(self) -> JsonObject:
+ return await self._codex_integration("disconnect")
+
+ async def _codex_integration(self, action: IntegrationAction) -> JsonObject:
+ wait = InitializationWait(None) if action == "refresh" else InitializationWait()
+ needs_catalog = action in {"connect", "refresh"}
+ try:
+ if action == "refresh":
+ async with self._config_lock:
+ self._check_integration_available()
+ url = local_proxy_root_url(self.settings)
+ if not await run_sync_owned(
+ lambda: codex_integration.recognizes_connection(
+ codex_integration.config_path(), url
+ )
+ ):
+ return {"changed": False}
+ while True:
+ generation_id = (
+ await self.provider_manager.wait_for_catalog_file(wait)
+ if needs_catalog
+ else None
+ )
+ async with self._config_lock:
+ self._check_integration_available()
+ if needs_catalog and (
+ generation_id != self.provider_manager.current_generation_id
+ or self.provider_manager.catalog_status()["catalog"] != "ready"
+ ):
+ continue
+ url = local_proxy_root_url(self.settings)
+
+ def operate(url: str = url) -> JsonObject:
+ path = codex_integration.config_path()
+ if action == "status" and self._codex_update.state != "ready":
+ return {
+ "connected": None,
+ "paths": {"codex_config": str(path.resolve())},
+ }
+ if action == "refresh":
+ return {
+ "changed": codex_integration.refresh_connected(
+ path, codex_model_catalog_path(), url
+ )
+ }
+ return codex_integration.configure(
+ path,
+ codex_model_catalog_path(),
+ url,
+ None if action == "status" else action == "connect",
+ )
+
+ result = await run_sync_owned(operate)
+ if action in {"connect", "disconnect"}:
+ self._codex_update.complete()
+ if action == "status":
+ result["update"] = self._codex_update.snapshot()
+ return result
+ except ValueError, UnicodeError:
+ raise InvalidRequestError(
+ "Could not read Codex settings. Check the TOML in config.toml."
+ ) from None
+ except OSError:
+ raise ApplicationUnavailableError(
+ "Could not access Codex config.toml. Check file permissions and try again."
+ ) from None
diff --git a/src/free_claude_code/runtime/lifecycle.py b/src/free_claude_code/runtime/lifecycle.py
new file mode 100644
index 0000000000..d75552d0e3
--- /dev/null
+++ b/src/free_claude_code/runtime/lifecycle.py
@@ -0,0 +1,38 @@
+"""Shared cleanup reporting for runtime resource owners."""
+
+from collections.abc import Awaitable
+
+from loguru import logger
+
+
+async def best_effort(
+ name: str,
+ awaitable: Awaitable[object],
+ *,
+ log_verbose_errors: bool = False,
+) -> bool:
+ """Run one cleanup step and report whether it completed.
+
+ The lifecycle owner intentionally applies no generic timeout here. Cancelling
+ an arbitrary cleanup at a deadline can abandon a half-closed SDK, thread, or
+ provider resource; resource-specific cleanup or the process supervisor owns
+ any force-termination deadline.
+ """
+ try:
+ await awaitable
+ except Exception as exc:
+ if log_verbose_errors:
+ logger.warning(
+ "Shutdown step failed: {}: {}: {}",
+ name,
+ type(exc).__name__,
+ exc,
+ )
+ else:
+ logger.warning(
+ "Shutdown step failed: {}: exc_type={}",
+ name,
+ type(exc).__name__,
+ )
+ return False
+ return True
diff --git a/src/free_claude_code/runtime/messaging_service.py b/src/free_claude_code/runtime/messaging_service.py
new file mode 100644
index 0000000000..77e5f3196c
--- /dev/null
+++ b/src/free_claude_code/runtime/messaging_service.py
@@ -0,0 +1,347 @@
+"""Own messaging startup, delivery resources, and their ordered cleanup gates."""
+
+import asyncio
+import importlib
+import os
+import traceback
+from collections.abc import Awaitable, Callable
+from functools import partial
+from pathlib import Path
+from typing import TYPE_CHECKING
+
+from loguru import logger
+
+from free_claude_code.application.errors import ApplicationUnavailableError
+from free_claude_code.application.ports import StopResult
+from free_claude_code.config.paths import messaging_state_dir_path
+from free_claude_code.config.server_urls import local_proxy_root_url
+from free_claude_code.config.settings import Settings
+from free_claude_code.core.async_tasks import run_sync_owned
+from free_claude_code.core.json_types import JsonObject
+from free_claude_code.messaging.platforms import factory as messaging_platform_factory
+from free_claude_code.messaging.platforms.factory import MessagingPlatformOptions
+from free_claude_code.messaging.platforms.ports import (
+ MessagingPlatformComponents,
+ MessagingRuntime,
+)
+from free_claude_code.messaging.voice import Transcriber
+
+from .lifecycle import best_effort
+from .sqlite_database import SQLiteDatabase
+
+if TYPE_CHECKING:
+ import free_claude_code.cli.managed as cli_managed
+ import free_claude_code.messaging.workflow as messaging_workflow_module
+
+ from .messaging_sqlite import SQLiteMessagingStore
+
+
+class MessagingService:
+ """Own one messaging graph, borrowing the database and HTTP readiness signal."""
+
+ def __init__(
+ self,
+ *,
+ settings: Callable[[], Settings],
+ database: SQLiteDatabase | None,
+ http_ready: asyncio.Event,
+ is_draining: Callable[[], bool],
+ transcriber: Transcriber | None,
+ transcriber_factory: Callable[[Settings], Awaitable[Transcriber | None]] | None,
+ ) -> None:
+ self._settings = settings
+ self._database = database
+ self._http_ready = http_ready
+ self._is_draining = is_draining
+ self._transcriber = transcriber
+ self._transcriber_factory = transcriber_factory
+ self._messaging_store: SQLiteMessagingStore | None = None
+ self._messaging_import_task: asyncio.Task[None] | None = None
+ self._messaging_warning: str | None = None
+ self._messaging_storage_error: Exception | None = None
+ self._messaging_runtime: MessagingRuntime | None = None
+ self._messaging_workflow: messaging_workflow_module.MessagingWorkflow | None = (
+ None
+ )
+ self._cli_manager: cli_managed.ManagedClaudeSessionManager | None = None
+ self._messaging_state = (
+ "disabled" if self.settings.messaging_platform == "none" else "starting"
+ )
+ self._messaging_error: str | None = None
+ self._startup_tasks: list[asyncio.Task[None]] = []
+
+ @property
+ def settings(self) -> Settings:
+ return self._settings()
+
+ def snapshot(self) -> JsonObject:
+ return {
+ "state": self._messaging_state,
+ "message": self._messaging_error,
+ "warning": self._messaging_warning,
+ }
+
+ def start(self) -> None:
+ if self._is_draining():
+ raise ApplicationUnavailableError("Application runtime is shutting down.")
+ if self._database is not None:
+ self._messaging_state = "starting"
+ self._messaging_import_task = asyncio.create_task(
+ self._initialize_messaging_storage(), name="fcc-messaging-import"
+ )
+ self._startup_tasks.append(self._messaging_import_task)
+ self._startup_tasks.append(
+ asyncio.create_task(
+ self._start_messaging_if_configured(), name="fcc-messaging-startup"
+ )
+ )
+
+ def cancel_background_tasks(self) -> None:
+ for task in self._startup_tasks:
+ if not task.done():
+ task.cancel()
+
+ async def wait_background_tasks(self) -> None:
+ results = await asyncio.gather(
+ *tuple(self._startup_tasks), return_exceptions=True
+ )
+ for result in results:
+ if isinstance(result, Exception):
+ logger.warning(
+ "Background initialization ended with exc_type={}",
+ type(result).__name__,
+ )
+ self._startup_tasks.clear()
+
+ async def stop_all(self) -> StopResult | None:
+ if self._messaging_workflow is not None:
+ outcome = await self._messaging_workflow.stop_all_tasks()
+ return StopResult(cancelled_count=outcome.cancelled_count)
+ if self._cli_manager is not None:
+ await self._cli_manager.stop_all()
+ return StopResult(source="cli_manager")
+ return None
+
+ async def _start_messaging_if_configured(self) -> None:
+ if self._messaging_import_task is not None:
+ await asyncio.shield(self._messaging_import_task)
+ if self.settings.messaging_platform == "none":
+ self._messaging_state = "disabled"
+ return
+ try:
+
+ def load_modules() -> None:
+ for name in ("cli.managed", "messaging.workflow"):
+ importlib.import_module(f"free_claude_code.{name}")
+ importlib.import_module(
+ f"free_claude_code.messaging.platforms.{self.settings.messaging_platform}"
+ )
+
+ await run_sync_owned(load_modules)
+ if self._transcriber_factory is not None:
+ self._transcriber = await self._transcriber_factory(self.settings)
+ components = messaging_platform_factory.create_messaging_components(
+ self.settings.messaging_platform,
+ self._messaging_options(),
+ )
+ if components is not None:
+ await self._start_messaging_workflow(components)
+ self._messaging_state = "ready"
+ else:
+ self._messaging_state = "disabled"
+ except ImportError as exc:
+ self._messaging_state = "failed"
+ self._messaging_error = (
+ "Messaging could not start. Check its configuration and restart FCC."
+ )
+ cleaned = await self.close_delivery()
+ if self.settings.log_api_error_tracebacks:
+ logger.warning("Messaging module import error: {}", exc)
+ else:
+ logger.warning(
+ "Messaging module import error: exc_type={}",
+ type(exc).__name__,
+ )
+ if not cleaned:
+ raise RuntimeError("Messaging startup cleanup incomplete") from exc
+ except Exception as exc:
+ self._messaging_state = "failed"
+ self._messaging_error = (
+ "Messaging could not start. Check its configuration and restart FCC."
+ )
+ cleaned = await self.close_delivery()
+ if self.settings.log_api_error_tracebacks:
+ logger.error("Failed to start messaging platform: {}", exc)
+ logger.error(traceback.format_exc())
+ else:
+ logger.error(
+ "Failed to start messaging platform: exc_type={}",
+ type(exc).__name__,
+ )
+ if not cleaned:
+ raise RuntimeError("Messaging startup cleanup incomplete") from exc
+
+ def _messaging_options(self) -> MessagingPlatformOptions:
+ settings = self.settings
+ return MessagingPlatformOptions(
+ telegram_bot_token=settings.telegram_bot_token,
+ allowed_telegram_user_id=settings.allowed_telegram_user_id,
+ telegram_proxy_url=settings.telegram_proxy_url,
+ discord_bot_token=settings.discord_bot_token,
+ allowed_discord_channels=settings.allowed_discord_channels,
+ transcriber=self._transcriber,
+ messaging_rate_limit=settings.messaging_rate_limit,
+ messaging_rate_window=settings.messaging_rate_window,
+ log_raw_messaging_content=settings.log_raw_messaging_content,
+ log_messaging_error_details=settings.log_messaging_error_details,
+ log_api_error_tracebacks=settings.log_api_error_tracebacks,
+ )
+
+ async def _initialize_messaging_storage(self) -> None:
+ if self._database is None:
+ return
+ try:
+ await self._database.start()
+ from .messaging_import import import_legacy
+ from .messaging_sqlite import SQLiteMessagingStore
+
+ self._messaging_store = SQLiteMessagingStore(
+ self._database,
+ managed_message_cap=self.settings.max_message_log_entries_per_chat,
+ )
+ self._messaging_warning = await import_legacy(
+ self._database, Path(messaging_state_dir_path()) / "sessions.json"
+ )
+ await self._messaging_store.trim()
+ except Exception as exc:
+ self._messaging_storage_error = exc
+ logger.error(
+ "Messaging storage initialization failed: {}", type(exc).__name__
+ )
+ return
+ if self._messaging_warning:
+ logger.warning("{}", self._messaging_warning)
+
+ async def _start_messaging_workflow(
+ self,
+ components: MessagingPlatformComponents,
+ ) -> None:
+ import free_claude_code.cli.managed as cli_managed
+ import free_claude_code.messaging.workflow as messaging_workflow_module
+
+ settings = self.settings
+ self._messaging_runtime = components.runtime
+ workspace = (
+ os.path.abspath(settings.allowed_dir)
+ if settings.allowed_dir
+ else os.getcwd()
+ )
+ await run_sync_owned(partial(os.makedirs, workspace, exist_ok=True))
+ allowed_dirs = [workspace] if settings.allowed_dir else []
+
+ self._cli_manager = cli_managed.ManagedClaudeSessionManager(
+ workspace_path=workspace,
+ proxy_root_url=local_proxy_root_url(settings),
+ allowed_dirs=allowed_dirs,
+ auth_token=settings.proxy_auth_token,
+ log_raw_cli_diagnostics=settings.log_raw_cli_diagnostics,
+ log_messaging_error_details=settings.log_messaging_error_details,
+ )
+ if self._messaging_import_task is not None:
+ await asyncio.shield(self._messaging_import_task)
+ if self._messaging_storage_error is not None:
+ raise ApplicationUnavailableError(
+ "Messaging storage is unavailable."
+ ) from self._messaging_storage_error
+ if self._messaging_store is None:
+ raise ApplicationUnavailableError("Messaging storage is unavailable.")
+ session_store = self._messaging_store
+ workflow = messaging_workflow_module.MessagingWorkflow(
+ platform_name=components.name,
+ outbound=components.outbound,
+ voice_cancellation=components.voice_cancellation,
+ cli_manager=self._cli_manager,
+ session_store=session_store,
+ debug_platform_edits=settings.debug_platform_edits,
+ debug_subagent_stack=settings.debug_subagent_stack,
+ log_raw_cli_diagnostics=settings.log_raw_cli_diagnostics,
+ log_messaging_error_details=settings.log_messaging_error_details,
+ )
+ self._messaging_workflow = workflow
+ await workflow.restore()
+ components.runtime.on_message(workflow.handle_message)
+ await self._http_ready.wait()
+ if self._is_draining():
+ return
+ await components.runtime.start()
+ await workflow.repair_restored_statuses()
+ if components.startup_notice is not None:
+ await workflow.publish_startup_notice(components.startup_notice)
+ logger.info("{} platform started with messaging workflow", components.name)
+
+ async def close_delivery(self) -> bool:
+ verbose = self.settings.log_api_error_tracebacks
+ workflow = self._messaging_workflow
+ runtime = self._messaging_runtime
+ cli_manager = self._cli_manager
+
+ if runtime is not None:
+ quiesced = await best_effort(
+ "messaging_runtime.quiesce",
+ runtime.quiesce(),
+ log_verbose_errors=verbose,
+ )
+ if not quiesced:
+ # Delivery must remain available until ingress is known stopped.
+ # Retaining the graph lets the next close retry this exact gate.
+ return False
+
+ if workflow is not None:
+ closed = await best_effort(
+ "messaging_workflow.close",
+ workflow.close(),
+ log_verbose_errors=verbose,
+ )
+ if not closed:
+ # Active workflow tasks may still need delivery, transcription,
+ # CLI sessions, and providers while a later close retries drain.
+ return False
+ if self._messaging_workflow is workflow:
+ self._messaging_workflow = None
+ if self._cli_manager is cli_manager:
+ self._cli_manager = None
+ elif cli_manager is not None:
+ drained = await best_effort(
+ "cli_manager.stop_all",
+ cli_manager.stop_all(),
+ log_verbose_errors=verbose,
+ )
+ if not drained:
+ return False
+ if self._cli_manager is cli_manager:
+ self._cli_manager = None
+
+ if runtime is not None:
+ closed = await best_effort(
+ "messaging_runtime.close",
+ runtime.close(),
+ log_verbose_errors=verbose,
+ )
+ if not closed:
+ return False
+ if self._messaging_runtime is runtime:
+ self._messaging_runtime = None
+ return True
+
+ async def close_transcriber(self) -> bool:
+ transcriber = self._transcriber
+ if transcriber is None:
+ return True
+ closed = await best_effort(
+ "transcriber.close",
+ transcriber.close(),
+ log_verbose_errors=self.settings.log_api_error_tracebacks,
+ )
+ if closed and self._transcriber is transcriber:
+ self._transcriber = None
+ return closed
diff --git a/tests/api/test_app_lifespan_and_errors.py b/tests/api/test_app_lifespan_and_errors.py
index f19b7dcb87..aa70da20c5 100644
--- a/tests/api/test_app_lifespan_and_errors.py
+++ b/tests/api/test_app_lifespan_and_errors.py
@@ -56,7 +56,7 @@ async def test_runtime_startup_logs_admin_url_without_printed_server_banner():
patch.object(manager, "start_model_list_refresh") as start_refresh,
patch.object(manager, "close", new=AsyncMock()),
patch(
- "free_claude_code.runtime.application.messaging_platform_factory.create_messaging_components",
+ "free_claude_code.runtime.messaging_service.messaging_platform_factory.create_messaging_components",
return_value=None,
),
patch.object(uvicorn_logger, "info") as log_info,
@@ -165,7 +165,7 @@ def start_refresh() -> None:
) as refresh,
patch.object(manager, "close", new=AsyncMock()),
patch(
- "free_claude_code.runtime.application.messaging_platform_factory.create_messaging_components",
+ "free_claude_code.runtime.messaging_service.messaging_platform_factory.create_messaging_components",
return_value=None,
),
):
@@ -386,11 +386,15 @@ async def test_bootstrap_constructs_isolated_runtime_resource_graphs(
assert isinstance(first_provider, NvidiaNimProvider)
assert isinstance(second_provider, NvidiaNimProvider)
assert first_provider._admission is not second_provider._admission
- assert first.runtime._transcriber is second.runtime._transcriber is None
- assert first.runtime._transcriber_factory is not None
- assert second.runtime._transcriber_factory is not None
- first_voice = await first.runtime._transcriber_factory(settings)
- second_voice = await second.runtime._transcriber_factory(settings)
+ assert (
+ first.runtime._messaging._transcriber
+ is second.runtime._messaging._transcriber
+ is None
+ )
+ assert first.runtime._messaging._transcriber_factory is not None
+ assert second.runtime._messaging._transcriber_factory is not None
+ first_voice = await first.runtime._messaging._transcriber_factory(settings)
+ second_voice = await second.runtime._messaging._transcriber_factory(settings)
assert first_voice is not None and second_voice is not None
assert first_voice is not second_voice
await first_voice.close()
diff --git a/tests/api/test_runtime_safe_logging.py b/tests/api/test_runtime_safe_logging.py
index d606b7bbbf..0f5ba1b6ab 100644
--- a/tests/api/test_runtime_safe_logging.py
+++ b/tests/api/test_runtime_safe_logging.py
@@ -6,8 +6,9 @@
import pytest
from free_claude_code.config.settings import Settings
-from free_claude_code.runtime.application import ApplicationRuntime, best_effort
+from free_claude_code.runtime.application import ApplicationRuntime
from free_claude_code.runtime.configuration import ConfigurationService
+from free_claude_code.runtime.lifecycle import best_effort
from free_claude_code.runtime.provider_manager import ProviderRuntimeManager
@@ -29,12 +30,12 @@ async def test_messaging_start_failure_default_logs_exclude_traceback(caplog):
with (
patch(
- "free_claude_code.runtime.application.messaging_platform_factory.create_messaging_components",
+ "free_claude_code.runtime.messaging_service.messaging_platform_factory.create_messaging_components",
side_effect=RuntimeError("SECRET_RUNTIME_DETAIL"),
),
caplog.at_level(logging.ERROR),
):
- await runtime._start_messaging_if_configured()
+ await runtime._messaging._start_messaging_if_configured()
blob = " | ".join(record.getMessage() for record in caplog.records)
assert "SECRET_RUNTIME_DETAIL" not in blob
diff --git a/tests/runtime/test_application_runtime.py b/tests/runtime/test_application_runtime.py
index 7ee11375f5..7106564b3b 100644
--- a/tests/runtime/test_application_runtime.py
+++ b/tests/runtime/test_application_runtime.py
@@ -335,7 +335,7 @@ async def test_stop_all_maps_messaging_outcome_to_application_count() -> None:
fallback_required=False,
)
)
- runtime._messaging_workflow = workflow
+ runtime._messaging._messaging_workflow = workflow
result = await runtime.stop_all()
@@ -596,11 +596,11 @@ async def test_close_drains_messaging_before_transcriber_and_is_idempotent() ->
configuration=AsyncMock(spec=ConfigurationService),
transcriber=transcriber,
)
- runtime._messaging_runtime = TrackingMessagingRuntime(events)
+ runtime._messaging._messaging_runtime = TrackingMessagingRuntime(events)
workflow = MagicMock()
workflow.close = AsyncMock(side_effect=lambda: events.append("workflow.close"))
- runtime._messaging_workflow = workflow
- runtime._cli_manager = MagicMock()
+ runtime._messaging._messaging_workflow = workflow
+ runtime._messaging._cli_manager = MagicMock()
assert runtime.is_closed is False
assert await runtime.close() is True
@@ -613,9 +613,9 @@ async def test_close_drains_messaging_before_transcriber_and_is_idempotent() ->
"transcriber.close",
]
assert transcriber.close_calls == 1
- assert runtime._transcriber is None
- assert runtime._messaging_runtime is None
- assert runtime._messaging_workflow is None
+ assert runtime._messaging._transcriber is None
+ assert runtime._messaging._messaging_runtime is None
+ assert runtime._messaging._messaging_workflow is None
assert runtime.is_closed is True
@@ -629,7 +629,7 @@ async def test_close_retains_transcriber_ownership_when_close_fails() -> None:
configuration=AsyncMock(spec=ConfigurationService),
transcriber=transcriber,
)
- runtime._messaging_runtime = TrackingMessagingRuntime(events)
+ runtime._messaging._messaging_runtime = TrackingMessagingRuntime(events)
assert await runtime.close() is False
@@ -639,7 +639,7 @@ async def test_close_retains_transcriber_ownership_when_close_fails() -> None:
"transcriber.close",
]
assert transcriber.close_calls == 1
- assert runtime._transcriber is transcriber
+ assert runtime._messaging._transcriber is transcriber
assert runtime._closed is False
await manager.close()
@@ -655,13 +655,13 @@ async def test_close_retries_runtime_before_closing_later_resources() -> None:
transcriber=transcriber,
)
messaging = TrackingMessagingRuntime(events, fail_close_once=True)
- runtime._messaging_runtime = messaging
+ runtime._messaging._messaging_runtime = messaging
assert await runtime.close() is False
assert events == ["messaging.quiesce", "messaging.close"]
- assert runtime._messaging_runtime is messaging
- assert runtime._transcriber is transcriber
+ assert runtime._messaging._messaging_runtime is messaging
+ assert runtime._messaging._transcriber is transcriber
assert runtime._closed is False
assert await runtime.close() is True
@@ -673,8 +673,8 @@ async def test_close_retries_runtime_before_closing_later_resources() -> None:
"messaging.close",
"transcriber.close",
]
- assert runtime._messaging_runtime is None
- assert runtime._transcriber is None
+ assert runtime._messaging._messaging_runtime is None
+ assert runtime._messaging._transcriber is None
assert runtime._closed is True
@@ -762,14 +762,14 @@ async def close_workflow() -> None:
events.append("workflow.close")
workflow.close = AsyncMock(side_effect=close_workflow)
- runtime._messaging_runtime = messaging
- runtime._messaging_workflow = workflow
+ runtime._messaging._messaging_runtime = messaging
+ runtime._messaging._messaging_workflow = workflow
assert await runtime.close() is False
assert events == ["messaging.quiesce"]
- assert runtime._messaging_runtime is messaging
- assert runtime._messaging_workflow is workflow
+ assert runtime._messaging._messaging_runtime is messaging
+ assert runtime._messaging._messaging_workflow is workflow
assert runtime._closed is False
assert await runtime.close() is True
@@ -793,14 +793,14 @@ async def test_close_does_not_drain_workflow_until_ingress_is_quiescent() -> Non
messaging = TrackingMessagingRuntime(events, fail_quiesce_once=True)
workflow = MagicMock()
workflow.close = AsyncMock(side_effect=lambda: events.append("workflow.close"))
- runtime._messaging_runtime = messaging
- runtime._messaging_workflow = workflow
+ runtime._messaging._messaging_runtime = messaging
+ runtime._messaging._messaging_workflow = workflow
assert await runtime.close() is False
workflow.close.assert_not_awaited()
- assert runtime._messaging_runtime is messaging
- assert runtime._messaging_workflow is workflow
+ assert runtime._messaging._messaging_runtime is messaging
+ assert runtime._messaging._messaging_workflow is workflow
assert await runtime.close() is True
@@ -832,13 +832,13 @@ async def close_workflow() -> None:
events.append("workflow.close")
workflow.close = AsyncMock(side_effect=close_workflow)
- runtime._messaging_runtime = messaging
- runtime._messaging_workflow = workflow
+ runtime._messaging._messaging_runtime = messaging
+ runtime._messaging._messaging_workflow = workflow
await runtime.close()
- assert runtime._messaging_workflow is workflow
- assert runtime._messaging_runtime is messaging
+ assert runtime._messaging._messaging_workflow is workflow
+ assert runtime._messaging._messaging_runtime is messaging
assert "messaging.close" not in events
await runtime.close()
@@ -864,10 +864,10 @@ async def test_cancelled_transcriber_close_retains_ownership() -> None:
)
with pytest.raises(asyncio.CancelledError):
- await runtime._cleanup_transcriber()
+ await runtime._messaging.close_transcriber()
assert transcriber.close_calls == 1
- assert runtime._transcriber is transcriber
+ assert runtime._messaging._transcriber is transcriber
await manager.close()
@@ -886,12 +886,12 @@ async def test_cancelled_application_close_remains_retryable() -> None:
await runtime.close()
assert runtime._closed is False
- assert runtime._transcriber is transcriber
+ assert runtime._messaging._transcriber is transcriber
await runtime.close()
assert transcriber.close_calls == 2
- assert runtime._transcriber is None
+ assert runtime._messaging._transcriber is None
assert runtime._closed is True
@@ -933,12 +933,12 @@ async def test_startup_cancellation_cleans_partial_messaging_and_reraises() -> N
entered = asyncio.Event()
async def start_messaging() -> None:
- runtime._messaging_runtime = messaging
+ runtime._messaging._messaging_runtime = messaging
entered.set()
await asyncio.Event().wait()
with patch.object(
- runtime,
+ runtime._messaging,
"_start_messaging_if_configured",
side_effect=start_messaging,
):
@@ -952,8 +952,8 @@ async def start_messaging() -> None:
"transcriber.close",
]
assert runtime._closed is True
- assert runtime._messaging_runtime is None
- assert runtime._transcriber is None
+ assert runtime._messaging._messaging_runtime is None
+ assert runtime._messaging._transcriber is None
@pytest.mark.asyncio
@@ -982,27 +982,27 @@ async def fail_after_publication(
published: MessagingPlatformComponents,
) -> None:
assert published is components
- runtime._messaging_runtime = messaging
- runtime._messaging_workflow = workflow
- runtime._cli_manager = cli_manager
+ runtime._messaging._messaging_runtime = messaging
+ runtime._messaging._messaging_workflow = workflow
+ runtime._messaging._cli_manager = cli_manager
raise startup_failure
with (
patch.object(manager, "start_model_list_refresh"),
patch(
- "free_claude_code.runtime.application.messaging_platform_factory.create_messaging_components",
+ "free_claude_code.runtime.messaging_service.messaging_platform_factory.create_messaging_components",
return_value=components,
),
patch.object(
- runtime,
+ runtime._messaging,
"_start_messaging_workflow",
side_effect=fail_after_publication,
),
):
await runtime.start()
with pytest.raises(RuntimeError, match="cleanup incomplete") as raised:
- await asyncio.gather(*runtime._startup_tasks)
- assert runtime._messaging_state == "failed"
+ await asyncio.gather(*runtime._messaging._startup_tasks)
+ assert runtime._messaging._messaging_state == "failed"
assert await runtime.close() is True
assert raised.value.__cause__ is startup_failure
@@ -1013,9 +1013,9 @@ async def fail_after_publication(
"messaging.close",
]
workflow.close.assert_awaited_once()
- assert runtime._messaging_runtime is None
- assert runtime._messaging_workflow is None
- assert runtime._cli_manager is None
+ assert runtime._messaging._messaging_runtime is None
+ assert runtime._messaging._messaging_workflow is None
+ assert runtime._messaging._cli_manager is None
assert runtime.is_closed is True
@@ -1050,37 +1050,37 @@ async def fail_after_publication(
published: MessagingPlatformComponents,
) -> None:
assert published is components
- runtime._messaging_runtime = messaging
- runtime._messaging_workflow = workflow
- runtime._cli_manager = cli_manager
+ runtime._messaging._messaging_runtime = messaging
+ runtime._messaging._messaging_workflow = workflow
+ runtime._messaging._cli_manager = cli_manager
raise startup_failure
with (
patch.object(manager, "start_model_list_refresh"),
patch(
- "free_claude_code.runtime.application.messaging_platform_factory.create_messaging_components",
+ "free_claude_code.runtime.messaging_service.messaging_platform_factory.create_messaging_components",
return_value=components,
),
patch.object(
- runtime,
+ runtime._messaging,
"_start_messaging_workflow",
side_effect=fail_after_publication,
),
):
await runtime.start()
with pytest.raises(RuntimeError, match="cleanup incomplete") as raised:
- await asyncio.gather(*runtime._startup_tasks)
- assert runtime._messaging_state == "failed"
+ await asyncio.gather(*runtime._messaging._startup_tasks)
+ assert runtime._messaging._messaging_state == "failed"
assert await runtime.close() is False
assert raised.value.__cause__ is startup_failure
assert events == ["messaging.quiesce", "messaging.quiesce"]
workflow.close.assert_not_awaited()
assert transcriber.close_calls == 0
- assert runtime._messaging_runtime is messaging
- assert runtime._messaging_workflow is workflow
- assert runtime._cli_manager is cli_manager
- assert runtime._transcriber is transcriber
+ assert runtime._messaging._messaging_runtime is messaging
+ assert runtime._messaging._messaging_workflow is workflow
+ assert runtime._messaging._cli_manager is cli_manager
+ assert runtime._messaging._transcriber is transcriber
assert runtime._provider_manager_closed is False
assert runtime.is_closed is False
@@ -1107,12 +1107,14 @@ async def test_messaging_start_failure_is_nonfatal_after_complete_cleanup() -> N
with (
patch(
- "free_claude_code.runtime.application.messaging_platform_factory.create_messaging_components",
+ "free_claude_code.runtime.messaging_service.messaging_platform_factory.create_messaging_components",
side_effect=RuntimeError("messaging unavailable"),
),
- patch.object(runtime, "_cleanup_messaging", AsyncMock(return_value=True)),
+ patch.object(
+ runtime._messaging, "close_delivery", AsyncMock(return_value=True)
+ ),
):
- await runtime._start_messaging_if_configured()
+ await runtime._messaging._start_messaging_if_configured()
await manager.close()
@@ -1128,13 +1130,15 @@ async def test_messaging_start_failure_fails_closed_when_cleanup_is_incomplete()
with (
patch(
- "free_claude_code.runtime.application.messaging_platform_factory.create_messaging_components",
+ "free_claude_code.runtime.messaging_service.messaging_platform_factory.create_messaging_components",
side_effect=RuntimeError("messaging unavailable"),
),
- patch.object(runtime, "_cleanup_messaging", AsyncMock(return_value=False)),
+ patch.object(
+ runtime._messaging, "close_delivery", AsyncMock(return_value=False)
+ ),
pytest.raises(RuntimeError, match="cleanup incomplete") as exc_info,
):
- await runtime._start_messaging_if_configured()
+ await runtime._messaging._start_messaging_if_configured()
assert isinstance(exc_info.value.__cause__, RuntimeError)
await manager.close()
@@ -1156,14 +1160,14 @@ async def test_composition_records_runtime_before_workspace_setup() -> None:
with (
patch(
- "free_claude_code.runtime.application.os.makedirs",
+ "free_claude_code.runtime.messaging_service.os.makedirs",
side_effect=OSError("workspace failed"),
),
pytest.raises(OSError, match="workspace failed"),
):
- await runtime._start_messaging_workflow(components)
+ await runtime._messaging._start_messaging_workflow(components)
- assert runtime._messaging_runtime is messaging
+ assert runtime._messaging._messaging_runtime is messaging
await runtime.close()
@@ -1206,14 +1210,14 @@ async def test_composition_publishes_startup_notice_after_runtime_and_repair() -
"free_claude_code.cli.managed.ManagedClaudeSessionManager",
return_value=cli_manager,
) as manager_constructor,
- patch.object(runtime, "_messaging_store", AsyncMock()),
+ patch.object(runtime._messaging, "_messaging_store", AsyncMock()),
patch(
"free_claude_code.messaging.workflow.MessagingWorkflow",
return_value=workflow,
),
):
runtime.http_started()
- await runtime._start_messaging_workflow(components)
+ await runtime._messaging._start_messaging_workflow(components)
assert events == [
"workflow.restore",
diff --git a/tests/runtime/test_config_transactions.py b/tests/runtime/test_config_transactions.py
index c7bc0cbcdd..46a3d3a07a 100644
--- a/tests/runtime/test_config_transactions.py
+++ b/tests/runtime/test_config_transactions.py
@@ -6,12 +6,73 @@
from free_claude_code.application.errors import ApplicationUnavailableError
from free_claude_code.config.loader import ManagedConfigStore
+from free_claude_code.harnesses import claude_integration
from free_claude_code.runtime.application import ApplicationRuntime
from free_claude_code.runtime.configuration import ConfigurationService
from free_claude_code.runtime.provider_manager import ProviderRuntimeManager
from tests.runtime.test_application_runtime import TrackingFactory, _prepared, _settings
+@pytest.mark.asyncio
+@pytest.mark.parametrize("cancel_writer", [False, True])
+async def test_integration_write_finishes_before_configuration_publication(
+ tmp_path, monkeypatch, cancel_writer
+):
+ factory = TrackingFactory()
+ manager = ProviderRuntimeManager(
+ _settings("nvidia_nim/old"), runtime_factory=factory
+ )
+ configuration = AsyncMock(spec=ConfigurationService)
+ prepared = _prepared(_settings("nvidia_nim/new"), tmp_path)
+ configuration.prepare.return_value = prepared
+ configuration.commit.return_value = prepared.applied_response()
+ runtime = ApplicationRuntime(manager, configuration=configuration, transcriber=None)
+ entered, release = threading.Event(), threading.Event()
+ apply_started = asyncio.Event()
+ configure = claude_integration.configure
+
+ def held(*args, **kwargs):
+ entered.set()
+ assert release.wait(5)
+ return configure(*args, **kwargs)
+
+ async def apply_config():
+ apply_started.set()
+ return await runtime.apply_admin_config({})
+
+ monkeypatch.setattr(claude_integration, "configure", held)
+ monkeypatch.setattr(
+ "free_claude_code.runtime.application.check_credentials",
+ AsyncMock(return_value=()),
+ )
+ writer = asyncio.create_task(runtime.connect_claude_vscode())
+ apply = None
+ try:
+ assert await asyncio.to_thread(entered.wait, 5)
+ if cancel_writer:
+ writer.cancel()
+ apply = asyncio.create_task(apply_config())
+ await asyncio.wait_for(apply_started.wait(), 5)
+ configuration.prepare.assert_not_awaited()
+ configuration.commit.assert_not_awaited()
+ assert manager.current_generation_id == 1
+ assert not writer.done()
+ release.set()
+ if cancel_writer:
+ with pytest.raises(asyncio.CancelledError):
+ await asyncio.wait_for(writer, 5)
+ else:
+ assert (await asyncio.wait_for(writer, 5))["connected"] is True
+ assert (await asyncio.wait_for(apply, 5))["applied"] is True
+ assert manager.current_generation_id == 2
+ finally:
+ release.set()
+ await asyncio.gather(
+ writer, *([apply] if apply else []), return_exceptions=True
+ )
+ await runtime.close()
+
+
@pytest.mark.asyncio
async def test_awaitable_restart_callback_is_not_executed_under_apply_lock(tmp_path):
manager = ProviderRuntimeManager(_settings("nvidia_nim/old"))
diff --git a/tests/runtime/test_feature_lifecycle.py b/tests/runtime/test_feature_lifecycle.py
new file mode 100644
index 0000000000..eb375e43cf
--- /dev/null
+++ b/tests/runtime/test_feature_lifecycle.py
@@ -0,0 +1,122 @@
+import asyncio
+from unittest.mock import AsyncMock
+
+import pytest
+
+from free_claude_code.application.code_sessions import CodeService
+from free_claude_code.application.errors import ApplicationUnavailableError
+from free_claude_code.runtime.application import ApplicationRuntime
+from free_claude_code.runtime.configuration import ConfigurationService
+from free_claude_code.runtime.provider_manager import ProviderRuntimeManager
+from free_claude_code.runtime.sqlite_database import SQLiteDatabase
+from tests.runtime.test_application_runtime import (
+ TrackingFactory,
+ TrackingMessagingRuntime,
+ TrackingTranscriber,
+ _settings,
+)
+
+
+@pytest.mark.asyncio
+async def test_close_cancels_both_feature_owners_before_acquiring_config_lock():
+ manager = ProviderRuntimeManager(_settings("nvidia_nim/model"))
+ database = AsyncMock(spec=SQLiteDatabase)
+ import_started = asyncio.Event()
+
+ async def start_database():
+ import_started.set()
+ await asyncio.Event().wait()
+
+ database.start.side_effect = start_database
+ runtime = ApplicationRuntime(
+ manager,
+ configuration=AsyncMock(spec=ConfigurationService),
+ transcriber=None,
+ database=database,
+ )
+ close = None
+ try:
+ async with runtime._config_lock:
+ await runtime.start()
+ await asyncio.wait_for(import_started.wait(), 5)
+ refresh = runtime._integrations._claude_update.task
+ assert refresh is not None and not refresh.done()
+ messaging_tasks = tuple(runtime._messaging._startup_tasks)
+ assert len(messaging_tasks) == 2
+ assert all(not task.done() for task in messaging_tasks)
+ close = asyncio.create_task(runtime.close())
+ results = await asyncio.wait_for(
+ asyncio.gather(refresh, *messaging_tasks, return_exceptions=True), 5
+ )
+ assert all(isinstance(result, asyncio.CancelledError) for result in results)
+ assert not close.done()
+ database.close.assert_not_awaited()
+ assert await asyncio.wait_for(close, 5)
+ database.close.assert_awaited_once()
+ finally:
+ if close is not None:
+ await close
+ await runtime.close()
+
+
+@pytest.mark.asyncio
+async def test_draining_runtime_rejects_new_feature_work():
+ runtime = ApplicationRuntime(
+ ProviderRuntimeManager(_settings("nvidia_nim/model")),
+ configuration=AsyncMock(spec=ConfigurationService),
+ transcriber=None,
+ )
+ try:
+ runtime.begin_shutdown()
+ runtime._integrations.catalog_changed()
+ assert runtime._integrations._vscode_update.task is None
+ with pytest.raises(ApplicationUnavailableError):
+ await runtime.refresh_claude_vscode()
+ with pytest.raises(ApplicationUnavailableError):
+ runtime._messaging.start()
+ assert runtime._messaging._startup_tasks == []
+ finally:
+ assert await runtime.close()
+
+
+@pytest.mark.asyncio
+async def test_failed_messaging_drain_retains_shared_dependencies_until_retry():
+ events = []
+ factory = TrackingFactory()
+ manager = ProviderRuntimeManager(
+ _settings("nvidia_nim/model"), runtime_factory=factory
+ )
+ code = AsyncMock(spec=CodeService)
+ database = AsyncMock(spec=SQLiteDatabase)
+ code.close.side_effect = lambda: events.append("code.close")
+ database.close.side_effect = lambda: events.append("database.close")
+ transcriber = TrackingTranscriber(events)
+ runtime = ApplicationRuntime(
+ manager,
+ configuration=AsyncMock(spec=ConfigurationService),
+ transcriber=transcriber,
+ database=database,
+ code_service=code,
+ )
+ runtime._messaging._messaging_runtime = TrackingMessagingRuntime(
+ events, fail_quiesce_once=True
+ )
+ try:
+ assert not await runtime.close()
+ assert not runtime.is_closed
+ code.close.assert_not_awaited()
+ database.close.assert_not_awaited()
+ assert transcriber.close_calls == 0
+ assert factory.runtimes[0].cleanup_calls == 0
+ assert await runtime.close()
+ assert events == [
+ "messaging.quiesce",
+ "messaging.quiesce",
+ "messaging.close",
+ "code.close",
+ "database.close",
+ "transcriber.close",
+ ]
+ assert factory.runtimes[0].cleanup_calls == 1
+ finally:
+ await runtime.close()
diff --git a/tests/runtime/test_integration_startup.py b/tests/runtime/test_integration_startup.py
index 9efe7cbea6..c182f94f59 100644
--- a/tests/runtime/test_integration_startup.py
+++ b/tests/runtime/test_integration_startup.py
@@ -98,7 +98,7 @@ async def test_jetbrains_startup_refresh_and_failed_discovery_allow_disconnect(
)
try:
await runtime.start()
- await asyncio.wait_for(runtime._jetbrains_update.task, 5)
+ await asyncio.wait_for(runtime._integrations._jetbrains_update.task, 5)
status = await runtime.jetbrains_acp_status()
assert status["connected"] is True
assert status["update"]["changed"] is True
@@ -109,7 +109,7 @@ async def test_jetbrains_startup_refresh_and_failed_discovery_allow_disconnect(
assert entry["env"]["ANTHROPIC_BASE_URL"] == "http://127.0.0.1:8000"
jb.registry_path().unlink()
await runtime.refresh_jetbrains_acp()
- await asyncio.wait_for(runtime._jetbrains_update.task, 5)
+ await asyncio.wait_for(runtime._integrations._jetbrains_update.task, 5)
status = await runtime.jetbrains_acp_status()
assert status["update"]["state"] == "failed"
assert status["connected"] is True
@@ -120,7 +120,11 @@ async def test_jetbrains_startup_refresh_and_failed_discovery_allow_disconnect(
async def finish(runtime):
await asyncio.wait_for(
- asyncio.gather(runtime._claude_update.task, runtime._codex_update.task), 5
+ asyncio.gather(
+ runtime._integrations._claude_update.task,
+ runtime._integrations._codex_update.task,
+ ),
+ 5,
)
@@ -147,7 +151,7 @@ async def test_startup_refreshes_old_connections_and_status_is_read_only(runtime
await runtime.disconnect_claude_vscode()
assert (await runtime.claude_vscode_status())["update"]["changed"] is False
await runtime.refresh_claude_vscode()
- await runtime._claude_update.task
+ await runtime._integrations._claude_update.task
assert (await runtime.claude_vscode_status())["connected"] is False
finally:
await runtime.close()
@@ -174,7 +178,7 @@ async def held(wait):
try:
await asyncio.wait_for(runtime.start(), 1)
await asyncio.wait_for(entered.wait(), 5)
- await asyncio.wait_for(runtime._claude_update.task, 5)
+ await asyncio.wait_for(runtime._integrations._claude_update.task, 5)
async with httpx.AsyncClient(
transport=httpx.ASGITransport(app=app), base_url="http://127.0.0.1"
) as client:
@@ -224,9 +228,9 @@ async def test_update_failure_is_isolated_and_retry_finishes(runtime):
assert (await runtime.claude_vscode_status())["update"]["state"] == "failed"
assert "CLAUDE_CODE_DISABLE_ADVISOR_TOOL" not in claude.read_text()
await runtime.refresh_claude_vscode()
- task = runtime._claude_update.task
+ task = runtime._integrations._claude_update.task
await runtime.refresh_claude_vscode()
- assert runtime._claude_update.task is task
+ assert runtime._integrations._claude_update.task is task
await asyncio.wait_for(task, 5)
assert (await runtime.claude_vscode_status())["connected"] is True
finally:
@@ -357,7 +361,7 @@ async def test_desktop_startup_rotates_selected_profile_and_disconnect_stays_off
)
try:
await runtime.start()
- await runtime._desktop_update.task
+ await runtime._integrations._desktop_update.task
result = await runtime.claude_desktop_status()
assert result["connected"] is True
assert result["update"]["changed"] is True
@@ -366,7 +370,7 @@ async def test_desktop_startup_rotates_selected_profile_and_disconnect_stays_off
assert before == {p: p.stat().st_mtime_ns for p in root.rglob("*.json")}
await runtime.disconnect_claude_desktop()
await runtime.refresh_claude_desktop()
- await runtime._desktop_update.task
+ await runtime._integrations._desktop_update.task
assert (await runtime.claude_desktop_status())["connected"] is False
finally:
await runtime.close()
@@ -380,11 +384,13 @@ def fail(*args, **kwargs):
monkeypatch.setattr(desktop, "refresh_connected", fail)
try:
await runtime.start()
- await runtime._desktop_update.task
+ await runtime._integrations._desktop_update.task
await finish(runtime)
- assert runtime._desktop_update.state == "failed"
- assert "private-invalid-file" not in runtime._desktop_update.message
- assert runtime._claude_update.state == "ready"
- assert runtime._codex_update.state == "ready"
+ assert runtime._integrations._desktop_update.state == "failed"
+ assert (
+ "private-invalid-file" not in runtime._integrations._desktop_update.message
+ )
+ assert runtime._integrations._claude_update.state == "ready"
+ assert runtime._integrations._codex_update.state == "ready"
finally:
await runtime.close()
diff --git a/tests/runtime/test_messaging_sqlite.py b/tests/runtime/test_messaging_sqlite.py
index e331726d90..6fc791801a 100644
--- a/tests/runtime/test_messaging_sqlite.py
+++ b/tests/runtime/test_messaging_sqlite.py
@@ -562,12 +562,12 @@ async def test_startup_partial_import_preserves_links_scopes_and_code(
)
try:
with patch(
- "free_claude_code.runtime.application.messaging_state_dir_path",
+ "free_claude_code.runtime.messaging_service.messaging_state_dir_path",
return_value=str(tmp_path),
):
- await runtime._initialize_messaging_storage()
- assert runtime._messaging_storage_error is None
- assert runtime._messaging_warning
+ await runtime._messaging._initialize_messaging_storage()
+ assert runtime._messaging._messaging_storage_error is None
+ assert runtime._messaging._messaging_warning
assert (await storage.load_conversation_snapshot()).trees == {
valid.identity: valid,
inactive.identity: inactive,
diff --git a/tests/runtime/test_vscode_chat_sync.py b/tests/runtime/test_vscode_chat_sync.py
index a6847f4bca..68f71803ec 100644
--- a/tests/runtime/test_vscode_chat_sync.py
+++ b/tests/runtime/test_vscode_chat_sync.py
@@ -57,7 +57,7 @@ async def construct(*_):
async def settle(runtime):
await runtime.provider_manager.wait_for_catalog()
- task = runtime._vscode_update.task
+ task = runtime._integrations._vscode_update.task
if task:
await asyncio.wait_for(asyncio.shield(task), 5)
@@ -76,7 +76,7 @@ async def test_catalog_updates_connected_file_and_disconnect_stays_removed(runti
assert {m["id"] for m in models} == {"nvidia_nim/one", "nvidia_nim/two"}
assert (await runtime.vscode_chat_status())["connected"]
await runtime.disconnect_vscode_chat()
- runtime._queue_vscode_refresh()
+ runtime._integrations.catalog_changed()
await settle(runtime)
assert json.loads(path.read_text()) == []
assert not (await runtime.vscode_chat_status())["connected"]
@@ -101,7 +101,7 @@ def held(*args, **kwargs):
monkeypatch.setattr(vscode, "configure", held)
try:
- runtime._queue_vscode_refresh()
+ runtime._integrations.catalog_changed()
await asyncio.wait_for(entered.wait(), 5)
runtime.provider_manager.cache_model_infos(
"nvidia_nim", (ProviderModelInfo("late"),)
@@ -126,7 +126,7 @@ async def test_bad_file_does_not_fail_catalog_and_disconnect_needs_no_catalog(
path.write_text("{broken")
await runtime.refresh_vscode_chat()
await settle(runtime)
- assert runtime._vscode_update.state == "failed"
+ assert runtime._integrations._vscode_update.state == "failed"
assert runtime.provider_manager.catalog_status()["catalog_file"] == "ready"
path.unlink()
await runtime.connect_vscode_chat()
@@ -158,7 +158,7 @@ async def test_startup_refreshes_owned_group_and_only_writes_once(runtime):
await runtime.refresh_vscode_chat()
await settle(runtime)
assert path.stat().st_mtime_ns == before
- assert not runtime._vscode_update.changed
+ assert not runtime._integrations._vscode_update.changed
@pytest.mark.asyncio
@@ -239,7 +239,7 @@ async def test_codex_publication_failure_does_not_block_vscode(runtime):
await asyncio.gather(*runtime.provider_manager._publications)
await settle(runtime)
assert runtime.provider_manager.catalog_status()["catalog_file"] == "failed"
- assert runtime._vscode_update.state == "ready"
+ assert runtime._integrations._vscode_update.state == "ready"
assert "nvidia_nim/new" in [
m["id"] for m in json.loads(vscode.config_path().read_text())[0]["models"]
]