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/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 957ce7a6fe..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",
@@ -285,10 +288,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..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",
@@ -405,7 +408,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/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/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/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/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/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 1b98d0d0d6..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,22 +1,20 @@
"""Chat-source output writers for Anthropic Messages and OpenAI Responses."""
-import hashlib
-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 loguru import logger
+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,
@@ -37,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)
@@ -65,8 +72,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)
@@ -83,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] = []
@@ -111,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
@@ -124,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
@@ -158,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:
@@ -220,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]:
@@ -236,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)
@@ -259,51 +289,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
@@ -397,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,
@@ -404,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():
@@ -502,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,
*,
@@ -679,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",
@@ -705,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
@@ -745,8 +776,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..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,35 +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}
- 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)
- 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):
@@ -299,11 +270,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 +305,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 +332,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..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
@@ -488,7 +411,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")
@@ -1506,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))
@@ -1552,8 +1476,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 (
@@ -1631,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/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/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/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/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/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/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/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)))
diff --git a/tests/contracts/test_stream_contracts.py b/tests/contracts/test_stream_contracts.py
index 4c164558cc..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,21 +115,6 @@ def test_enable_thinking_false_suppresses_reasoning_only() -> None:
assert text_content(events) == "hello world"
-def test_task_tool_arguments_force_foreground_execution() -> 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"
- if isinstance(task.get("input"), dict):
- task["input"]["run_in_background"] = False
- assert task["input"]["run_in_background"] is False
-
-
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_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_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 6b05a5d8dc..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):
@@ -2415,17 +2422,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 +2446,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 +2475,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):
@@ -2499,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()
@@ -2842,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()
@@ -2951,8 +2928,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 +2940,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 +2961,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 +2984,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 +3112,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 +3129,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_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 68cefef3d9..f098e8a386 100644
--- a/tests/providers/test_subagent_interception.py
+++ b/tests/providers/test_subagent_interception.py
@@ -1,32 +1,124 @@
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 (
- iter_heuristic_tool_use_events,
+ OpenAIToolCallAssembler,
)
-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,
)
- tool_use = {
- "type": "tool_use",
- "id": "tool_123",
- "name": "Task",
- "input": {
- "description": "test task",
- "prompt": "do something",
- "run_in_background": True,
- },
- }
+ return ResponsesChatStreamOutput(prepared.tool_adapter, input_tokens=1)
+
- 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
+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("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))
+ 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):
+ 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
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_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_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_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
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"]
]