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 = " 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"] ]