diff --git a/coding-agent/.opencode/prompts/tasks/codex-provider-responses-task-packet.md b/coding-agent/.opencode/prompts/tasks/codex-provider-responses-task-packet.md new file mode 100644 index 000000000..28b7b4580 --- /dev/null +++ b/coding-agent/.opencode/prompts/tasks/codex-provider-responses-task-packet.md @@ -0,0 +1,39 @@ +Goal: +Replace the current `codex` provider's Chat Completions wire path with the official Codex Responses HTTP SSE path for ChatGPT-authenticated Codex traffic. + +Scope: +- Add a Codex-specific Responses provider that posts to `/responses`. +- Convert existing agent messages/tools into Responses API request payloads. +- Parse Responses SSE events into `TextEvent`, `ThinkingEvent`, `ToolCallEvent`, `UsageEvent`, and `DoneEvent`. +- Wire `provider="codex"` to the new provider and update focused tests. + +Out of scope: +- Responses WebSocket transport and warmup/reuse. +- Browser-cookie reuse beyond the existing ChatGPT bearer token flow. +- Changes to non-Codex OpenAI-compatible providers. + +Context: +- ADRs: + - `docs/adr/0050-codex-oauth-provider.md` + - `docs/adr/0057-codex-provider-responses-wire-protocol.md` +- Relevant files: + - `src/coding_agent/providers/codex_responses.py` + - `src/coding_agent/plugins/llm_provider.py` + - `tests/providers/test_codex_responses.py` + - `tests/coding_agent/plugins/test_llm_provider.py` + +Target tests: +- `uv run pytest tests/providers/test_codex_responses.py tests/coding_agent/plugins/test_llm_provider.py -k "codex" -v` +- `uv run ruff check src/coding_agent/providers/codex_responses.py src/coding_agent/plugins/llm_provider.py tests/providers/test_codex_responses.py tests/coding_agent/plugins/test_llm_provider.py` + +Loop policy: +- Engineer implements the smallest correct change and runs the target tests. +- Reviewer reviews only the resulting diff and affected tests. +- Reviewer reports only P1/P2 findings. +- Engineer fixes only accepted P1/P2 findings and reruns the same target tests. +- Verifier reruns the exact target tests and reports pass/fail only. + +Stop conditions: +- At most one review/fix/retest cycle. +- Escalate architectural redirection or scope expansion to the human. +- Ignore non-blocking optimization suggestions. diff --git a/coding-agent/.opencode/prompts/tasks/local-cli-runtime-boundary-task-packet.md b/coding-agent/.opencode/prompts/tasks/local-cli-runtime-boundary-task-packet.md new file mode 100644 index 000000000..f60234e32 --- /dev/null +++ b/coding-agent/.opencode/prompts/tasks/local-cli-runtime-boundary-task-packet.md @@ -0,0 +1,48 @@ +Goal: +Separate the local interactive CLI runtime from the HTTP/control-plane session +manager while preserving current REPL behavior. + +Scope: +- Identify the smallest behavior-preserving boundary between + `coding_agent.cli.repl.InteractiveSession` and server session management. +- Introduce or prepare a local CLI runtime/session abstraction for pipeline, + adapter, tape id, approval memory, model switching, and checkpoint restore. +- Remove direct local REPL dependence on remote/control-plane-only concepts from + `server.SessionManager` where practical in the first slice. + +Out of scope: +- Renaming HTTP endpoints or remote CLI commands. +- Moving executor, Bee, Docker, or workspace provider modules. +- Changing checkpoint restore semantics or tape storage formats. + +Context: +- ADRs: + - `docs/adr/0048-application-structure-refactor-boundaries.md` + - `docs/adr/0054-executor-runtime-terminology-and-resume-first-direction.md` + - `docs/adr/0055-session-resume-and-interrupted-run-semantics.md` + - `docs/adr/0056-local-cli-control-plane-and-workspace-product-boundaries.md` +- Relevant files: + - `src/coding_agent/cli/repl.py` + - `src/coding_agent/cli/commands.py` + - `src/coding_agent/app.py` + - `src/coding_agent/adapter.py` + - `src/coding_agent/server/session_manager.py` + - `tests/cli/test_repl.py` + - `tests/cli/test_commands.py` + +Target tests: +- `uv run pytest tests/cli/test_repl.py tests/cli/test_commands.py -k "managed_session or model or checkpoint or process_message" -v` +- `uv run pytest tests/ui/test_session_manager_public_api.py tests/ui/test_session_manager_runtime.py -k "file_session_store or checkpoint_restore or runtime" -v` + +Loop policy: +- Engineer implements the smallest correct change and runs the target tests. +- Reviewer reviews only the resulting diff and affected tests. +- Reviewer reports only P1/P2 findings. +- Engineer fixes only accepted P1/P2 findings and reruns the same target tests. +- Verifier reruns the exact target tests and reports pass/fail only. + +Stop conditions: +- At most one review/fix/retest cycle. +- Escalate if local CLI behavior requires changing HTTP server semantics. +- Escalate if the slice would require migrating persisted session data. +- Ignore non-blocking optimization suggestions. diff --git a/coding-agent/.opencode/prompts/tasks/workspace-sandbox-product-boundary-task-packet.md b/coding-agent/.opencode/prompts/tasks/workspace-sandbox-product-boundary-task-packet.md new file mode 100644 index 000000000..9ebf0b67f --- /dev/null +++ b/coding-agent/.opencode/prompts/tasks/workspace-sandbox-product-boundary-task-packet.md @@ -0,0 +1,54 @@ +Goal: +Clarify Workspace as the execution boundary across local repositories, Docker +sandbox workspaces, future OS-native sandboxes, cloud workspaces, and external +executor workspace references. + +Scope: +- Document and enforce product terminology around `ExecutionBinding`, + `Environment`, and `WorkspaceProvider`. +- Keep session metadata responsible for workspace binding, not for filesystem or + shell implementation details. +- Prepare executor-related code to sit behind an execution-plane boundary while + preserving existing compatibility paths. + +Out of scope: +- Implementing OS-native sandbox support. +- Rewriting Docker provider behavior. +- Renaming public remote HTTP protocol fields in this slice. +- Changing action-safety approval policy. + +Context: +- ADRs: + - `docs/adr/0038-workspace-provider-and-sandbox-mvp-boundaries.md` + - `docs/adr/0051-external-worker-execution-control-plane.md` + - `docs/adr/0054-executor-runtime-terminology-and-resume-first-direction.md` + - `docs/adr/0056-local-cli-control-plane-and-workspace-product-boundaries.md` +- Relevant files: + - `src/coding_agent/server/execution_binding.py` + - `src/coding_agent/environment/local.py` + - `src/coding_agent/environment/cloud.py` + - `src/coding_agent/environment/workspace_provider.py` + - `src/coding_agent/environment/docker_workspace_provider.py` + - `src/coding_agent/server/session_manager.py` + - `src/coding_agent/external_executor.py` + - `tests/ui/test_execution_binding.py` + - `tests/coding_agent/environment/` + +Target tests: +- `uv run pytest tests/ui/test_execution_binding.py -v` +- `uv run pytest tests/coding_agent/environment/test_local_environment.py tests/coding_agent/environment/test_cloud_environment.py -v` +- `uv run pytest tests/coding_agent/environment/test_docker_workspace_provider.py -k "binding or unavailable or provider_instance" -v` +- `uv run pytest tests/coding_agent/test_external_executor.py -k "capability or authorized or workspace" -v` + +Loop policy: +- Engineer implements the smallest correct change and runs the target tests. +- Reviewer reviews only the resulting diff and affected tests. +- Reviewer reports only P1/P2 findings. +- Engineer fixes only accepted P1/P2 findings and reruns the same target tests. +- Verifier reruns the exact target tests and reports pass/fail only. + +Stop conditions: +- At most one review/fix/retest cycle. +- Escalate if the slice would require a protocol migration. +- Escalate if Docker or hosted infrastructure becomes required for deterministic tests. +- Ignore non-blocking optimization suggestions. diff --git a/coding-agent/docs/adr/0056-local-cli-control-plane-and-workspace-product-boundaries.md b/coding-agent/docs/adr/0056-local-cli-control-plane-and-workspace-product-boundaries.md new file mode 100644 index 000000000..06354a152 --- /dev/null +++ b/coding-agent/docs/adr/0056-local-cli-control-plane-and-workspace-product-boundaries.md @@ -0,0 +1,94 @@ +# ADR-0056: Local CLI, Control Plane, and Workspace Product Boundaries + +**Status**: Proposed +**Date**: 2026-06-01 + +## Context + +`coding_agent` now contains three product shapes that grew from the same +runtime: a local interactive CLI, a remote HTTP control plane, and an executor +plane for sandboxed or attached execution. The current implementation shares +large session/runtime objects across those shapes. For example, the local REPL +uses the server `SessionManager`, and the server session model stores both +durable metadata and in-process runtime handles. + +This makes the product meaning of `local`, `remote`, `session`, `workspace`, +and `executor` unclear. A local CLI session should behave like a Codex or Claude +Code session: one user, one local workspace, an interactive REPL, local approval +prompts, tape persistence, model switching, and checkpoint restore. The remote +server should be a control plane for sessions, runs, workspaces, approvals, and +events. Executors should be an execution plane that consumes authorized plans +and reports status and results. + +The existing runtime direction remains valid: AgentKit owns the generic +Pipeline, Hook Runtime, Tape, Plugin, Tool, Provider, Directive, Approval, and +Checkpoint mechanisms. Coding Agent owns product policy and workspace semantics. + +## Decision + +Separate product meanings before moving large amounts of code. + +The local CLI product must depend on a local runtime/session abstraction rather +than on the HTTP control-plane `SessionManager`. That abstraction may reuse +`app.create_agent`, `PipelineAdapter`, tape stores, checkpoint stores, and +approval components, but it should not expose remote session, workspace +inventory, owner lease, executor claim, or HTTP event-queue concerns to the REPL. + +The server/control-plane session service remains responsible for HTTP sessions, +runs, workspace lifecycle, approval interactions, event streaming, resume +metadata, and executor placement. It should evolve toward a split between +durable records and in-process runtime handles. + +Workspace is the execution boundary for file, patch, shell, archive, diff, +publish, cleanup, and validation operations. Sessions should record their +workspace through `ExecutionBinding`/workspace metadata. Local repositories, +Docker sandbox workspaces, future OS-native sandboxes, cloud workspaces, and +external executor workspace references should be represented as explicit +workspace bindings or provider-backed environments instead of being inferred +from unrelated session fields. + +Dogfood this refactor in two bounded tasks: + +1. Local CLI runtime boundary: introduce or prepare a local CLI runtime/session + abstraction and remove the local REPL's direct dependency on the server + `SessionManager` in the smallest behavior-preserving slice. +2. Workspace/sandbox/control-plane boundary: clarify workspace binding/provider + terminology and prepare executor-related code to live behind an execution + plane boundary without changing existing remote protocols. + +## Alternatives Rejected + +- Keep sharing `server.SessionManager` with local CLI — rejected because it + makes the simplest local product path depend on remote/control-plane and + executor concepts. +- Move all session, workspace, remote, and executor files in one refactor — + rejected because it would create high-risk import churn before the product + terms are stable. +- Put workspace provider semantics into AgentKit — rejected because repository + paths, Docker, cloud clients, sandbox policy, and workspace lifecycle are + Coding Agent product concerns. +- Treat Docker sandbox as the only workspace abstraction — rejected because the + intended model also includes local repositories, future OS-native sandboxing, + cloud workspaces, and external executor workspace references. + +## Acceptance Criteria + +- [ ] Local CLI task packet exists and names target tests for the REPL/session boundary. +- [ ] Workspace/sandbox task packet exists and names target tests for workspace binding/provider semantics. +- [ ] `test_repl_does_not_import_server_session_manager_for_local_runtime` +- [ ] `test_local_cli_runtime_preserves_model_switch_and_checkpoint_restore` +- [ ] `test_workspace_binding_product_terms_round_trip` +- [ ] `uv run pytest tests/cli/test_repl.py tests/cli/test_commands.py -k "managed_session or model or checkpoint or local_runtime" -v` +- [ ] `uv run pytest tests/ui/test_execution_binding.py tests/coding_agent/environment/test_local_environment.py tests/coding_agent/environment/test_docker_workspace_provider.py -k "binding or workspace_provider or unavailable" -v` + +## References + +- `docs/adr/0038-workspace-provider-and-sandbox-mvp-boundaries.md` +- `docs/adr/0048-application-structure-refactor-boundaries.md` +- `docs/adr/0054-executor-runtime-terminology-and-resume-first-direction.md` +- `docs/adr/0055-session-resume-and-interrupted-run-semantics.md` +- `src/coding_agent/cli/repl.py` +- `src/coding_agent/server/session_manager.py` +- `src/coding_agent/server/execution_binding.py` +- `src/coding_agent/environment/` +- `src/coding_agent/external_executor.py` diff --git a/coding-agent/docs/adr/0057-codex-provider-responses-wire-protocol.md b/coding-agent/docs/adr/0057-codex-provider-responses-wire-protocol.md new file mode 100644 index 000000000..7c0d4551a --- /dev/null +++ b/coding-agent/docs/adr/0057-codex-provider-responses-wire-protocol.md @@ -0,0 +1,39 @@ +# ADR-0057: Codex Provider Uses Responses Wire Protocol + +**Status**: Proposed +**Date**: 2026-06-02 + +## Context + +The current `codex` provider reuses the OpenAI-compatible Chat Completions provider and sends requests to `https://chatgpt.com/backend-api/codex/chat/completions`. The official Codex CLI does not use that path for ChatGPT-authenticated Codex traffic. + +OpenAI Codex CLI uses the Responses wire API against `https://chatgpt.com/backend-api/codex/responses`, with ChatGPT bearer auth plus `ChatGPT-Account-ID`, Codex identity headers, and SSE/WebSocket streaming semantics. Treating the ChatGPT Codex backend as Chat Completions causes Cloudflare/browser-gate failures and protocol-level mismatches. + +## Decision + +Replace the `codex` provider implementation with a Codex-specific Responses provider. The first implementation will support HTTP SSE against `/responses`; WebSocket prewarm/reuse remains a later enhancement. + +The `openai` and `openai_compat` providers continue to use Chat Completions through `OpenAICompatProvider`. Only `provider="codex"` switches to the Codex Responses provider. + +## Alternatives Rejected + +- Keep `codex` on `OpenAICompatProvider` — rejected because the official Codex CLI no longer uses Chat Completions for Codex traffic. +- Add a new parallel `codex-responses` provider — rejected because the existing `codex` provider name already means ChatGPT-authenticated Codex and should have correct semantics. +- Implement WebSocket first — rejected to keep the first repair narrow; the official CLI also has HTTP Responses fallback behavior. + +## Acceptance Criteria + +- [ ] `test_codex_provider_uses_responses_provider` +- [ ] `test_stream_posts_to_codex_responses_with_chatgpt_headers` +- [ ] `test_stream_converts_function_call_output_item_done` +- [ ] `uv run pytest tests/providers/test_codex_responses.py tests/coding_agent/plugins/test_llm_provider.py -k "codex" -v` + +## References + +- `src/coding_agent/providers/codex_responses.py` +- `src/coding_agent/plugins/llm_provider.py` +- `tests/providers/test_codex_responses.py` +- `tests/coding_agent/plugins/test_llm_provider.py` +- `https://github.com/openai/codex/blob/main/codex-rs/model-provider-info/src/lib.rs` +- `https://github.com/openai/codex/blob/main/codex-rs/codex-api/src/endpoint/responses.rs` +- `https://github.com/openai/codex/blob/main/codex-rs/model-provider/src/bearer_auth_provider.rs` diff --git a/coding-agent/src/coding_agent/cli/local_runtime.py b/coding-agent/src/coding_agent/cli/local_runtime.py new file mode 100644 index 000000000..44affd5ac --- /dev/null +++ b/coding-agent/src/coding_agent/cli/local_runtime.py @@ -0,0 +1,55 @@ +"""Local CLI runtime/session boundary. + +This module is the CLI-facing seam for local interactive sessions. The first +slice intentionally delegates to the existing server SessionManager so behavior +stays unchanged while the REPL stops depending on control-plane symbols. +""" + +from __future__ import annotations + +from typing import Protocol, runtime_checkable, Any + +from coding_agent.server.session_manager import SessionManager + + +@runtime_checkable +class LocalCliSessionManager(Protocol): + """Subset of session behavior used by the interactive local REPL.""" + + async def create_session(self, **kwargs: Any) -> str: ... + + def get_session(self, session_id: str) -> Any: ... + + async def get_session_async(self, session_id: str) -> Any: ... + + async def ensure_session_runtime(self, session_id: str) -> Any: ... + + async def replace_session_runtime_config( + self, + session_id: str, + *, + model_name: str, + ) -> Any: ... + + async def restore_checkpoint(self, session_id: str, checkpoint_id: str) -> None: ... + + async def run_agent(self, session_id: str, prompt: str) -> None: ... + + +def create_local_cli_session_manager() -> LocalCliSessionManager: + """Create the local REPL session manager. + + The concrete implementation remains the existing managed local session + service for now. Keeping this construction here prevents REPL code from + importing server/control-plane types directly. + """ + + return SessionManager( + storage_config={ + "http_session_backend": "fs", + "runtime_backend": "jsonl", + } + ) + + +__all__ = ["LocalCliSessionManager", "create_local_cli_session_manager"] diff --git a/coding-agent/src/coding_agent/cli/repl.py b/coding-agent/src/coding_agent/cli/repl.py index 286ab09f3..484f61252 100644 --- a/coding-agent/src/coding_agent/cli/repl.py +++ b/coding-agent/src/coding_agent/cli/repl.py @@ -14,6 +14,10 @@ from coding_agent.approval import ApprovalPolicy from coding_agent.cli.commands import handle_command from coding_agent.cli.input_handler import InputHandler, expand_pasted_refs +from coding_agent.cli.local_runtime import ( + LocalCliSessionManager, + create_local_cli_session_manager, +) from coding_agent.cli.terminal_output import ( get_prompt_output, print_pt, @@ -25,7 +29,6 @@ from coding_agent.ui.stream_renderer import StreamingRenderer from coding_agent.ui.rich_consumer import RichConsumer from coding_agent.ui.status_footer import StatusFooter -from coding_agent.server.session_manager import SessionManager from coding_agent.wire.protocol import TurnEnd, WireMessage @@ -469,13 +472,8 @@ def _is_root_turn_end(message: WireMessage) -> bool: return isinstance(message, TurnEnd) and not message.agent_id -def _local_session_manager() -> SessionManager: - return SessionManager( - storage_config={ - "http_session_backend": "fs", - "runtime_backend": "jsonl", - } - ) +def _local_session_manager() -> LocalCliSessionManager: + return create_local_cli_session_manager() async def run_repl(config: Config): diff --git a/coding-agent/src/coding_agent/plugins/llm_provider.py b/coding-agent/src/coding_agent/plugins/llm_provider.py index b4f7106a5..7069801fc 100644 --- a/coding-agent/src/coding_agent/plugins/llm_provider.py +++ b/coding-agent/src/coding_agent/plugins/llm_provider.py @@ -118,10 +118,9 @@ def provide_llm(self, **kwargs: Any) -> LLMProvider: base_url=self._base_url or "https://api.stepfun.com/v1", ) elif self._provider_name == "codex": - from coding_agent.oauth.auth import OAuthBearerAuth from coding_agent.oauth.codex import CODEX_BASE_URL from coding_agent.oauth.store import OAuthStore - from coding_agent.providers.openai_compat import OpenAICompatProvider + from coding_agent.providers.codex_responses import CodexResponsesProvider store = OAuthStore() record = store.get_provider("codex") @@ -132,17 +131,12 @@ def provide_llm(self, **kwargs: Any) -> LLMProvider: ) token_source = StoreBackedCodexTokenSource(store) - auth = OAuthBearerAuth( - token_source, - provider_name="codex", - ) base_url = self._base_url or CODEX_BASE_URL - self._instance = OpenAICompatProvider( + self._instance = CodexResponsesProvider( model=self._model, - api_key="oauth-managed", + token_source=token_source, base_url=base_url, - httpx_auth=auth, ) else: raise ValueError(f"unsupported provider: {self._provider_name}") diff --git a/coding-agent/src/coding_agent/providers/__init__.py b/coding-agent/src/coding_agent/providers/__init__.py index de8011d72..54a238216 100644 --- a/coding-agent/src/coding_agent/providers/__init__.py +++ b/coding-agent/src/coding_agent/providers/__init__.py @@ -3,11 +3,13 @@ from agentkit.providers.models import DoneEvent, StreamEvent, TextEvent, ToolCallEvent from agentkit.providers.protocol import LLMProvider from coding_agent.providers.anthropic import AnthropicProvider +from coding_agent.providers.codex_responses import CodexResponsesProvider from coding_agent.providers.copilot import CopilotProvider from coding_agent.providers.openai_compat import OpenAICompatProvider __all__ = [ "AnthropicProvider", + "CodexResponsesProvider", "CopilotProvider", "DoneEvent", "LLMProvider", diff --git a/coding-agent/src/coding_agent/providers/codex_responses.py b/coding-agent/src/coding_agent/providers/codex_responses.py new file mode 100644 index 000000000..2c013f7c5 --- /dev/null +++ b/coding-agent/src/coding_agent/providers/codex_responses.py @@ -0,0 +1,404 @@ +"""Codex ChatGPT-authenticated Responses provider.""" + +from __future__ import annotations + +import json +import logging +import os +import uuid +from collections.abc import AsyncIterator +from typing import Any + +import httpx + +from agentkit.providers.models import ( + DoneEvent, + StreamEvent, + TextEvent, + ThinkingEvent, + ToolCallEvent, + UsageEvent, +) +from coding_agent.oauth.types import OAuthTokenSource, TokenSnapshot + +logger = logging.getLogger(__name__) + +DEFAULT_ORIGINATOR = "codex_cli_rs" +ORIGINATOR_OVERRIDE_ENV = "CODEX_INTERNAL_ORIGINATOR_OVERRIDE" +DEFAULT_USER_AGENT = "codex_cli_rs/0.135.0" + + +class CodexResponsesProvider: + """Codex provider using the official ChatGPT-backed Responses wire API.""" + + CONTEXT_SIZES: dict[str, int] = { + "gpt-5.5": 400000, + "gpt-5.4": 400000, + "gpt-5.2": 400000, + } + + def __init__( + self, + *, + model: str, + token_source: OAuthTokenSource, + base_url: str, + timeout: float = 60.0, + originator: str | None = None, + user_agent: str = DEFAULT_USER_AGENT, + transport: httpx.AsyncBaseTransport | None = None, + ) -> None: + self._model = model + self._token_source = token_source + self._base_url = base_url.rstrip("/") + self._originator = ( + os.environ.get(ORIGINATOR_OVERRIDE_ENV) or originator or DEFAULT_ORIGINATOR + ) + self._user_agent = user_agent + self._http_client = httpx.AsyncClient( + timeout=httpx.Timeout(timeout), + transport=transport, + ) + + @property + def model_name(self) -> str: + return self._model + + @property + def max_context_size(self) -> int: + return self.CONTEXT_SIZES.get(self._model, 128000) + + async def stream( + self, + messages: list[dict[str, Any]], + tools: list[Any] | None = None, + **kwargs: Any, + ) -> AsyncIterator[StreamEvent]: + request = self._build_request(messages, tools=tools, extra=kwargs) + snapshot = await self._token_source.get_token() + + async for event in self._stream_with_snapshot(request, snapshot): + yield event + + async def _stream_with_snapshot( + self, + request: dict[str, Any], + snapshot: TokenSnapshot, + ) -> AsyncIterator[StreamEvent]: + refreshed_after_unauthorized = False + current_snapshot = snapshot + + while True: + async with self._http_client.stream( + "POST", + f"{self._base_url}/responses", + headers=self._headers(current_snapshot), + json=request, + ) as response: + if response.status_code == 401 and not refreshed_after_unauthorized: + await response.aread() + refreshed_after_unauthorized = True + current_snapshot = await self._token_source.refresh_token() + continue + + if response.status_code >= 400: + text = await response.aread() + raise RuntimeError( + f"Codex Responses request failed with status " + f"{response.status_code}: " + f"{text.decode('utf-8', errors='replace')}" + ) + + async for event in self._events_from_response(response): + yield event + return + + def _headers(self, snapshot: TokenSnapshot) -> dict[str, str]: + account_id = snapshot.account.chatgpt_account_id + if not account_id: + raise RuntimeError( + "Codex OAuth record is missing ChatGPT account id. " + "Run `coding-agent oauth login codex` again." + ) + + thread_id = str(uuid.uuid4()) + return { + "Authorization": f"Bearer {snapshot.access_token}", + "ChatGPT-Account-ID": account_id, + "Accept": "text/event-stream", + "Content-Type": "application/json", + "originator": self._originator, + "User-Agent": self._user_agent, + "session-id": str(uuid.uuid4()), + "thread-id": thread_id, + "x-client-request-id": thread_id, + "x-codex-installation-id": "coding-agent", + } + + async def _events_from_response( + self, + response: httpx.Response, + ) -> AsyncIterator[StreamEvent]: + pending_event: str | None = None + data_lines: list[str] = [] + + async for raw_line in response.aiter_lines(): + line = raw_line.rstrip("\r") + if line == "": + async for event in self._events_from_sse_payload( + pending_event, + "\n".join(data_lines), + ): + yield event + pending_event = None + data_lines = [] + continue + if line.startswith("event:"): + pending_event = line.removeprefix("event:").strip() + elif line.startswith("data:"): + data_lines.append(line.removeprefix("data:").lstrip()) + + if data_lines: + async for event in self._events_from_sse_payload( + pending_event, + "\n".join(data_lines), + ): + yield event + + async def _events_from_sse_payload( + self, + event_name: str | None, + data: str, + ) -> AsyncIterator[StreamEvent]: + if not data or data == "[DONE]": + return + + try: + payload = json.loads(data) + except json.JSONDecodeError: + logger.debug("Ignoring malformed Codex SSE payload for %s", event_name) + return + + kind = _str(payload.get("type")) or event_name + if kind == "response.output_text.delta": + delta = _str(payload.get("delta")) + if delta: + yield TextEvent(text=delta) + return + + if kind in ("response.reasoning_text.delta", "response.reasoning_summary_text.delta"): + delta = _str(payload.get("delta")) + if delta: + yield ThinkingEvent(text=delta) + return + + if kind == "response.output_item.done": + item = payload.get("item") + if isinstance(item, dict) and item.get("type") == "function_call": + yield _tool_call_event_from_item(item) + return + + if kind == "response.failed": + raise RuntimeError(_response_error_message(payload, "Codex response failed")) + + if kind == "response.incomplete": + raise RuntimeError(_response_error_message(payload, "Codex response incomplete")) + + if kind == "response.completed": + response = payload.get("response") + usage = response.get("usage") if isinstance(response, dict) else None + if isinstance(usage, dict): + yield UsageEvent( + input_tokens=_int(usage.get("input_tokens")), + output_tokens=_int(usage.get("output_tokens")), + provider_name=self._model, + ) + yield DoneEvent() + + def _build_request( + self, + messages: list[dict[str, Any]], + *, + tools: list[Any] | None, + extra: dict[str, Any], + ) -> dict[str, Any]: + instructions, input_items = _convert_messages(messages) + if not instructions: + raise ValueError("Codex Responses requests require a system instruction message") + + body: dict[str, Any] = { + "model": self._model, + "instructions": instructions, + "input": input_items, + "stream": True, + "store": False, + "parallel_tool_calls": True, + "client_metadata": {"x-codex-installation-id": "coding-agent"}, + } + converted_tools = _convert_tools(tools) + if converted_tools: + body["tools"] = converted_tools + body.update(extra) + return body + + async def close(self) -> None: + await self._http_client.aclose() + + async def __aenter__(self) -> CodexResponsesProvider: + return self + + async def __aexit__(self, exc_type: object, exc_val: object, exc_tb: object) -> None: + await self.close() + + +def _convert_messages(messages: list[dict[str, Any]]) -> tuple[str, list[dict[str, Any]]]: + instructions: list[str] = [] + input_items: list[dict[str, Any]] = [] + + for message in messages: + role = _str(message.get("role")) + if role == "system": + text = _message_text(message.get("content")) + if text: + instructions.append(text) + continue + + if role in ("user", "assistant"): + text = _message_text(message.get("content")) + if text: + input_items.append(_message_item(role, text)) + for tool_call in _message_tool_calls(message): + input_items.append(tool_call) + continue + + if role == "tool": + call_id = _str(message.get("tool_call_id")) + if not call_id: + raise ValueError("tool messages require tool_call_id for Codex Responses") + input_items.append( + { + "type": "function_call_output", + "call_id": call_id, + "output": _message_text(message.get("content")), + } + ) + + return "\n\n".join(instructions), input_items + + +def _message_item(role: str, text: str) -> dict[str, Any]: + return { + "type": "message", + "role": role, + "content": [{"type": "input_text", "text": text}], + } + + +def _message_tool_calls(message: dict[str, Any]) -> list[dict[str, Any]]: + raw_calls = message.get("tool_calls") + if not isinstance(raw_calls, list): + return [] + + calls: list[dict[str, Any]] = [] + for raw_call in raw_calls: + if not isinstance(raw_call, dict): + continue + function = raw_call.get("function") + if not isinstance(function, dict): + continue + call_id = _str(raw_call.get("id")) + name = _str(function.get("name")) + arguments = function.get("arguments") + if not call_id or not name: + raise ValueError("assistant tool_calls require id and function.name") + calls.append( + { + "type": "function_call", + "call_id": call_id, + "name": name, + "arguments": arguments if isinstance(arguments, str) else json.dumps(arguments), + } + ) + return calls + + +def _convert_tools(tools: list[Any] | None) -> list[dict[str, Any]]: + if not tools: + return [] + + converted: list[dict[str, Any]] = [] + for tool in tools: + tool_dict = tool.to_openai_format() if hasattr(tool, "to_openai_format") else tool + if not isinstance(tool_dict, dict) or tool_dict.get("type") != "function": + continue + function = tool_dict.get("function") + if not isinstance(function, dict): + continue + name = _str(function.get("name")) + if not name: + raise ValueError("function tools require a name") + converted.append( + { + "type": "function", + "name": name, + "description": _str(function.get("description")) or "", + "parameters": function.get("parameters") or {"type": "object"}, + } + ) + return converted + + +def _tool_call_event_from_item(item: dict[str, Any]) -> ToolCallEvent: + arguments_raw = item.get("arguments") + arguments: dict[str, Any] + if isinstance(arguments_raw, str) and arguments_raw: + try: + parsed = json.loads(arguments_raw) + except json.JSONDecodeError: + parsed = {} + arguments = parsed if isinstance(parsed, dict) else {} + elif isinstance(arguments_raw, dict): + arguments = arguments_raw + else: + arguments = {} + + return ToolCallEvent( + tool_call_id=_str(item.get("call_id")) or _str(item.get("id")), + name=_str(item.get("name")), + arguments=arguments, + ) + + +def _message_text(content: object) -> str: + if content is None: + return "" + if isinstance(content, str): + return content + if isinstance(content, list): + chunks: list[str] = [] + for item in content: + if isinstance(item, dict): + text = item.get("text") + if isinstance(text, str): + chunks.append(text) + return "\n".join(chunks) + return json.dumps(content) + + +def _response_error_message(payload: dict[str, Any], fallback: str) -> str: + response = payload.get("response") + if not isinstance(response, dict): + return fallback + error = response.get("error") + if not isinstance(error, dict): + return fallback + message = error.get("message") + return message if isinstance(message, str) and message else fallback + + +def _str(value: object) -> str: + return value if isinstance(value, str) else "" + + +def _int(value: object) -> int: + return value if isinstance(value, int) else 0 diff --git a/coding-agent/tests/cli/test_repl.py b/coding-agent/tests/cli/test_repl.py index 717f0c647..bebf79d08 100644 --- a/coding-agent/tests/cli/test_repl.py +++ b/coding-agent/tests/cli/test_repl.py @@ -1,10 +1,10 @@ """Tests for REPL functionality.""" from collections.abc import Callable +from inspect import isawaitable, getsource from pathlib import Path from types import SimpleNamespace from typing import Any, Literal, cast -from inspect import isawaitable from unittest.mock import AsyncMock, MagicMock import asyncio @@ -281,6 +281,17 @@ def test_repl_session_creation_requires_config(self): with pytest.raises(TypeError): session_constructor() + def test_repl_does_not_import_server_session_manager_for_local_runtime(self): + import coding_agent.cli.repl as repl_module + + source = getsource(repl_module) + + assert ( + "from coding_agent.server.session_manager import SessionManager" + not in source + ) + assert "create_local_cli_session_manager" in source + class TestBashIntegration: def test_bang_detected_in_repl(self): diff --git a/coding-agent/tests/coding_agent/plugins/test_llm_provider.py b/coding-agent/tests/coding_agent/plugins/test_llm_provider.py index 651e5165d..9bbfbe090 100644 --- a/coding-agent/tests/coding_agent/plugins/test_llm_provider.py +++ b/coding-agent/tests/coding_agent/plugins/test_llm_provider.py @@ -5,6 +5,7 @@ from coding_agent.oauth.store import OAuthStore from coding_agent.oauth.types import OAuthProviderRecord, OAuthTokens from coding_agent.providers.anthropic import AnthropicProvider +from coding_agent.providers.codex_responses import CodexResponsesProvider from coding_agent.providers.copilot import CopilotProvider from coding_agent.providers.openai_compat import OpenAICompatProvider @@ -61,10 +62,9 @@ def test_codex_provider_requires_login(self, monkeypatch, tmp_path): with pytest.raises(RuntimeError, match="oauth login codex"): plugin.provide_llm() - def test_codex_provider_uses_oauth_backed_openai_compat( - self, monkeypatch, tmp_path - ): + def test_codex_provider_uses_responses_provider(self, monkeypatch, tmp_path): import coding_agent.oauth.store as store_module + from coding_agent.oauth.types import OAuthAccount auth_path = tmp_path / "auth.json" monkeypatch.setattr(store_module, "DEFAULT_AUTH_PATH", auth_path) @@ -76,15 +76,16 @@ def test_codex_provider_uses_oauth_backed_openai_compat( token_endpoint="https://auth.openai.com/oauth/token", base_url="https://chatgpt.com/backend-api/codex", tokens=OAuthTokens(access_token="access-token"), + account=OAuthAccount(chatgpt_account_id="account-123"), ), ) plugin = LLMProviderPlugin(provider="codex", model="codex-mini", api_key="") result = plugin.provide_llm() - assert isinstance(result, OpenAICompatProvider) + assert isinstance(result, CodexResponsesProvider) assert result.model_name == "codex-mini" - assert "chatgpt.com/backend-api/codex" in str(result._client.base_url) + assert result._base_url == "https://chatgpt.com/backend-api/codex" class TestKimiProvider: diff --git a/coding-agent/tests/providers/test_codex_responses.py b/coding-agent/tests/providers/test_codex_responses.py new file mode 100644 index 000000000..bf8e376dc --- /dev/null +++ b/coding-agent/tests/providers/test_codex_responses.py @@ -0,0 +1,200 @@ +from __future__ import annotations + +import json +from collections.abc import Callable + +import httpx +import pytest + +from agentkit.providers.models import DoneEvent, TextEvent, ToolCallEvent, UsageEvent +from coding_agent.oauth.types import OAuthAccount, TokenSnapshot +from coding_agent.providers.codex_responses import CodexResponsesProvider + + +class MemoryTokenSource: + def __init__(self, access_token: str = "access-token") -> None: + self.access_token = access_token + self.refresh_count = 0 + + async def get_token(self) -> TokenSnapshot: + return TokenSnapshot( + provider_name="codex", + access_token=self.access_token, + account=OAuthAccount(chatgpt_account_id="account-123"), + ) + + async def refresh_token(self) -> TokenSnapshot: + self.refresh_count += 1 + self.access_token = "new-token" + return await self.get_token() + + +def sse_event(kind: str, payload: dict[str, object]) -> str: + return f"event: {kind}\ndata: {json.dumps(payload)}\n\n" + + +def provider_with_handler( + handler: Callable[[httpx.Request], httpx.Response], + *, + token_source: MemoryTokenSource | None = None, +) -> CodexResponsesProvider: + return CodexResponsesProvider( + model="gpt-5.5", + token_source=token_source or MemoryTokenSource(), + base_url="https://chatgpt.com/backend-api/codex", + transport=httpx.MockTransport(handler), + ) + + +@pytest.mark.asyncio +async def test_stream_posts_to_codex_responses_with_chatgpt_headers( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("CODEX_INTERNAL_ORIGINATOR_OVERRIDE", raising=False) + captured: dict[str, object] = {} + + def handler(request: httpx.Request) -> httpx.Response: + captured["url"] = str(request.url) + captured["headers"] = dict(request.headers) + captured["body"] = json.loads(request.content) + body = "".join( + [ + sse_event( + "response.output_text.delta", + {"type": "response.output_text.delta", "delta": "hel"}, + ), + sse_event( + "response.output_text.delta", + {"type": "response.output_text.delta", "delta": "lo"}, + ), + sse_event( + "response.completed", + { + "type": "response.completed", + "response": { + "id": "resp_1", + "usage": { + "input_tokens": 7, + "output_tokens": 2, + "total_tokens": 9, + }, + }, + }, + ), + ] + ) + return httpx.Response(200, text=body, request=request) + + provider = provider_with_handler(handler) + + events = [ + event + async for event in provider.stream( + [ + {"role": "system", "content": "You are concise."}, + {"role": "user", "content": "Say hello"}, + ] + ) + ] + + assert captured["url"] == "https://chatgpt.com/backend-api/codex/responses" + headers = captured["headers"] + assert isinstance(headers, dict) + assert headers["authorization"] == "Bearer access-token" + assert headers["chatgpt-account-id"] == "account-123" + assert headers["accept"] == "text/event-stream" + assert headers["originator"] == "codex_cli_rs" + + body = captured["body"] + assert isinstance(body, dict) + assert body["model"] == "gpt-5.5" + assert body["instructions"] == "You are concise." + assert body["stream"] is True + assert body["input"] == [ + { + "type": "message", + "role": "user", + "content": [{"type": "input_text", "text": "Say hello"}], + } + ] + + assert events == [ + TextEvent(text="hel"), + TextEvent(text="lo"), + UsageEvent(input_tokens=7, output_tokens=2, provider_name="gpt-5.5"), + DoneEvent(), + ] + + +@pytest.mark.asyncio +async def test_stream_converts_function_call_output_item_done() -> None: + def handler(request: httpx.Request) -> httpx.Response: + body = sse_event( + "response.output_item.done", + { + "type": "response.output_item.done", + "item": { + "type": "function_call", + "call_id": "call_123", + "name": "shell", + "arguments": '{"command": "pwd"}', + }, + }, + ) + sse_event( + "response.completed", + {"type": "response.completed", "response": {"id": "resp_1"}}, + ) + return httpx.Response(200, text=body, request=request) + + provider = provider_with_handler(handler) + + events = [ + event + async for event in provider.stream( + [ + {"role": "system", "content": "You are concise."}, + {"role": "user", "content": "Run pwd"}, + ] + ) + ] + + assert events == [ + ToolCallEvent( + tool_call_id="call_123", + name="shell", + arguments={"command": "pwd"}, + ), + DoneEvent(), + ] + + +@pytest.mark.asyncio +async def test_stream_refreshes_token_once_on_unauthorized() -> None: + seen_auth: list[str] = [] + token_source = MemoryTokenSource("old-token") + + def handler(request: httpx.Request) -> httpx.Response: + seen_auth.append(request.headers["authorization"]) + if len(seen_auth) == 1: + return httpx.Response(401, json={"detail": "expired"}, request=request) + body = sse_event( + "response.completed", + {"type": "response.completed", "response": {"id": "resp_1"}}, + ) + return httpx.Response(200, text=body, request=request) + + provider = provider_with_handler(handler, token_source=token_source) + + events = [ + event + async for event in provider.stream( + [ + {"role": "system", "content": "You are concise."}, + {"role": "user", "content": "Say ok"}, + ] + ) + ] + + assert seen_auth == ["Bearer old-token", "Bearer new-token"] + assert token_source.refresh_count == 1 + assert events == [DoneEvent()]