From 5f86ec923e43c8a162b6feb86b2cf11eb62f0ace Mon Sep 17 00:00:00 2001 From: Smoke Test Date: Thu, 4 Jun 2026 04:59:10 -0700 Subject: [PATCH 1/2] Refactor CLI entrypoint by concern --- src/foundation/cli.py | 2680 +---------------------------- src/foundation/cli_interactive.py | 1223 +++++++++++++ src/foundation/cli_rendering.py | 1062 ++++++++++++ src/foundation/cli_runtime.py | 507 ++++++ tests/test_cli.py | 100 +- tests/test_cli_gap_handoff.py | 18 +- tests/test_integration_e2e.py | 2 +- 7 files changed, 2890 insertions(+), 2702 deletions(-) create mode 100644 src/foundation/cli_interactive.py create mode 100644 src/foundation/cli_rendering.py create mode 100644 src/foundation/cli_runtime.py diff --git a/src/foundation/cli.py b/src/foundation/cli.py index 6136c10..569b133 100644 --- a/src/foundation/cli.py +++ b/src/foundation/cli.py @@ -6,118 +6,76 @@ import logging import os import shlex -import threading import uuid -from contextlib import suppress from dataclasses import dataclass, field from pathlib import Path from typing import Annotated, Any import click import typer -from rich.console import Console -from rich.markup import escape -from rich.panel import Panel -from rich.table import Table -from rich.text import Text from typer.core import TyperGroup from foundation import __version__ -from foundation.doctor import DoctorReport, DoctorStatus, run_doctor -from foundation.live_turn import LiveTurnRenderer, get_active_renderer, live_ux_disabled +from foundation.cli_interactive import ( + _resolve_chat_session_cwd, + _run_interactive_chat, +) +from foundation.cli_rendering import ( + _emit_output_event, + _render_audit_report, + _render_availability, + _render_chat_turn, + _render_doctor_report, + _render_execution_summary, + _render_file_result, + _render_git_context, + _render_help_lookup, + _render_history_detail, + _render_history_list, + _render_result_output, + _render_search_result, + _render_tool_error, + _render_trace_detail, + _render_trace_list, + _tool_exit_code, + console, +) +from foundation.cli_runtime import ( + _build_history_store, + _build_session_manager, + _build_shell_runtime, + _build_tool_service, + _execute_chat_request, + _record_run_history, + _resolve_cli_request_cwd, +) +from foundation.doctor import run_doctor from foundation.logging import configure_logging from foundation.models import ( - ActionKind, - ApprovalDecisionStatus, - ApprovalRequest, - AuditDetailRef, - AuditReport, - BrainSession, - CapabilityGapHandoff, - CapabilityGapOption, - ChatNotice, - ChatSurfacePolicy, - ChatTurnPresentation, - ExecutionArtifactType, - ExecutionResult, - ExecutionStatus, - GapOptionKind, - HistorySessionDetail, - HistorySessionSummary, - InteractiveDetailCommand, - LoopStopReason, - MemoryEnvelope, - MemoryLayer, - MemorySource, - OrchestrationResult, - PlannedAction, - PlanningStep, - PolicyDecision, - PolicyDecisionType, - PresentationNoticeLevel, - ProviderMessage, - QuestionAction, RenderMode, ResumeTarget, SessionKind, - SessionSnapshot, SessionStatus, - ShellAction, - ShellActionMode, TerminalLogRouting, TraceQuery, - TraceRecord, - TraceSummary, - UserRequest, - VerificationOutcome, -) -from foundation.monitor import ( - EventLogWriter, - LocalHttpSseTransport, - MonitorServer, - TransportStartError, - UnixSocketTransport, - compose_event_sink, ) from foundation.services import ( - ApprovalService, - CapabilityRegistry, - CapabilityStore, ExecutionMode, FileDiscoveryRequest, - FileDiscoveryResult, FileDiscoveryType, GitContextRequest, - GitContextResult, - GuardrailPolicyEngine, HelpLookupRequest, - HelpLookupResult, HelpLookupSource, - HistoryStore, - LocalToolService, OrchestrationError, OrchestrationPlanError, - OutputStream, ProviderError, - RequestOrchestrator, SearchRequest, - SearchResult, - SessionManager, ShellCommandRequest, - ShellCommandResult, ShellExecutionCancelled, ShellExecutionSpawnError, ShellExecutionTimeout, - ShellOutputEvent, - ShellRuntime, - ToolAvailabilityStatus, - ToolBinaryStatus, - ToolErrorCode, ToolExecutionError, - TraceStore, - build_provider_adapter, ) -from foundation.services.gap_handoff import build_issue_url, write_gap_report from foundation.settings import ( ApprovalMode, AppSettings, @@ -221,58 +179,8 @@ def invoke(self, ctx: click.Context) -> Any: app.add_typer(config_app, name="config") app.add_typer(tool_app, name="tools") -console = Console() -stderr_console = Console(stderr=True) logger = logging.getLogger("foundation.cli") -_REPL_DEFAULT_HISTORY_LIMIT = 10 -_REPL_HISTORY_FILENAME = "repl-history.txt" -_REPL_SESSION_DB_FILENAME = "chat-sessions.sqlite3" -_REPL_TRANSCRIPT_OUTPUT_PREVIEW_CHARACTERS = 1200 -_REPL_SHELL_PREFIX = "!" -_REPL_COMMAND_COMPLETIONS: dict[str, Any] = { - "/actions": None, - "/approval": { - "auto": None, - "manual": None, - "prompt": None, - }, - "/clear": None, - "/compact": None, - "/config": { - "locations": None, - }, - "/cwd": None, - "/exit": None, - "/help": None, - "/history": None, - "/memory": { - "append": { - "global": None, - "project": None, - }, - "set": { - "global": None, - "project": None, - }, - "show": { - "global": None, - "project": None, - "session": None, - "summary": None, - "turns": None, - }, - }, - "/model": None, - "/plan": None, - "/quit": None, - "/reset": None, - "/resume": None, - "/sessions": None, - "/summary": None, - "/tools": None, -} - @dataclass(slots=True) class CLIContext: @@ -286,80 +194,6 @@ class CLIContext: monitor_http_port: int | None = None -@dataclass(slots=True) -class InteractiveChatState: - """Mutable interactive state backed by a persistent brain session.""" - - session: BrainSession - last_result: OrchestrationResult | None = None - - @property - def session_id(self) -> str: - """Return the stable session id.""" - return self.session.session_id - - @property - def initial_cwd(self) -> Path: - """Return the initial cwd recorded for this session.""" - return Path(self.session.initial_cwd) - - @property - def current_cwd(self) -> Path: - """Return the active cwd for this interactive shell.""" - return Path(self.session.current_cwd) - - @current_cwd.setter - def current_cwd(self, value: Path) -> None: - self.session.current_cwd = str(value) - - @property - def approval_mode(self) -> ApprovalMode: - """Return the current approval mode.""" - return ApprovalMode(self.session.approval_mode) - - @approval_mode.setter - def approval_mode(self, value: ApprovalMode) -> None: - self.session.approval_mode = value.value - - @property - def model(self) -> str: - """Return the persisted model override for this session.""" - return self.session.model - - @model.setter - def model(self, value: str) -> None: - self.session.model = value - - @property - def provider_name(self) -> str: - """Return the provider recorded for this session.""" - return self.session.provider_name - - @property - def transcript(self) -> list[ProviderMessage]: - """Return the bounded recent turn window.""" - return self.session.recent_turns - - @transcript.setter - def transcript(self, value: list[ProviderMessage]) -> None: - self.session.recent_turns = value - - @property - def summary_text(self) -> str: - """Return the compacted summary for older turns.""" - return self.session.summary_text - - @property - def recovered_from_interruption(self) -> bool: - """Return whether resume restored the last clean checkpoint.""" - return self.session.recovered_from_interruption - - @property - def interrupted_turn(self) -> str | None: - """Return the interrupted user input when the prior run did not finish cleanly.""" - return self.session.interrupted_turn - - def _nested_override(root: dict[str, Any], dotted_path: str, value: Any) -> None: target = root path_parts = dotted_path.split(".") @@ -368,19 +202,6 @@ def _nested_override(root: dict[str, Any], dotted_path: str, value: Any) -> None target[path_parts[-1]] = value -def _render_placeholder(command_name: str, detail: str, settings: AppSettings) -> None: - console.print( - Panel.fit( - f"[bold]{command_name}[/bold]\n\n" - f"{detail}\n\n" - f"Workspace root: [cyan]{settings.workspace_root}[/cyan]\n" - f"Config path: [cyan]{settings.config_path}[/cyan]\n" - f"Approval mode: [cyan]{settings.approval.mode.value}[/cyan]", - title="Foundation CLI", - ) - ) - - def _load_runtime_settings(ctx: typer.Context) -> AppSettings: cli_context = ctx.obj if isinstance(ctx.obj, CLIContext) else CLIContext() @@ -413,1417 +234,6 @@ def _load_runtime_settings(ctx: typer.Context) -> AppSettings: return settings -def _render_doctor_report(report: DoctorReport) -> None: - status_styles = { - DoctorStatus.PASS: "green", - DoctorStatus.WARN: "yellow", - DoctorStatus.FAIL: "red", - } - - for check in report.checks: - style = status_styles[check.status] - console.print( - f"[{style}]{check.status.value.upper():<4}[/{style}] {check.name}: {check.summary}" - ) - if check.detail: - console.print(Text(check.detail, style="dim")) - - -def _build_shell_runtime(settings: AppSettings) -> ShellRuntime: - return ShellRuntime( - workspace_root=settings.workspace_root, - default_timeout_seconds=settings.shell.default_timeout_seconds, - max_timeout_seconds=settings.shell.max_timeout_seconds, - allow_pty=settings.shell.allow_pty, - capture_limit_kb=settings.shell.capture_limit_kb, - enforce_workspace_boundary=settings.shell.enforce_workspace_boundary, - pass_through_foundation_env=settings.shell.pass_through_foundation_env, - ) - - -def _build_tool_service(settings: AppSettings) -> LocalToolService: - return LocalToolService( - workspace_root=settings.workspace_root, - default_timeout_seconds=min(settings.shell.default_timeout_seconds, 30), - capture_limit_kb=settings.shell.capture_limit_kb, - pass_through_foundation_env=settings.shell.pass_through_foundation_env, - ) - - -def _build_history_store(settings: AppSettings) -> HistoryStore: - return TraceStore( - database_path=settings.history.database_path, - retention_days=settings.history.retention_days, - max_entries=settings.history.max_entries, - ) - - -def _build_capability_registry( - settings: AppSettings, - *, - tool_service: LocalToolService | None = None, -) -> CapabilityRegistry: - service = tool_service or _build_tool_service(settings) - return CapabilityRegistry( - store=CapabilityStore(settings.app.data_dir / "capabilities"), - tool_service=service, - ) - - -def _build_session_manager(settings: AppSettings) -> SessionManager: - return SessionManager( - database_path=settings.app.state_dir / _REPL_SESSION_DB_FILENAME, - workspace_root=settings.workspace_root, - config_dir=settings.config_path.parent, - provider_name=settings.provider.name, - ) - - -def _prompt_for_approval(request: ApprovalRequest) -> bool: - risk_text = ", ".join(request.risk_categories) if request.risk_categories else "unknown" - lines = [ - f"Action: [cyan]{escape(request.action_id)}[/cyan]", - ( - f"Capability: [cyan]{escape(request.capability_id)}[/cyan]" - if request.capability_id - else None - ), - f"Summary: {escape(request.summary)}", - f"Reason: {escape(request.reason)}", - f"Risk: [yellow]{escape(risk_text)}[/yellow]", - ] - if request.risk_class is not None: - lines.append(f"Risk class: [yellow]{escape(request.risk_class.value)}[/yellow]") - if request.trust_tier is not None: - lines.append(f"Trust tier: [cyan]{escape(request.trust_tier.value)}[/cyan]") - if request.command_preview: - lines.append(f"Command: [cyan]{escape(request.command_preview)}[/cyan]") - if request.cwd: - lines.append(f"Cwd: [cyan]{escape(request.cwd)}[/cyan]") - if request.paths: - lines.append(f"Paths: [cyan]{escape(', '.join(request.paths))}[/cyan]") - if request.network_hosts: - lines.append(f"Network: [cyan]{escape(', '.join(request.network_hosts))}[/cyan]") - if request.requested_side_effects: - lines.append( - f"Side effects: [yellow]{escape(', '.join(request.requested_side_effects))}[/yellow]" - ) - if request.reason_codes: - reason_text = ", ".join(code.value for code in request.reason_codes) - lines.append(f"Policy reasons: [magenta]{escape(reason_text)}[/magenta]") - if request.constraints is not None and request.constraints.invocation_budget is not None: - budget = request.constraints.invocation_budget - budget_parts: list[str] = [] - if budget.timeout_seconds is not None: - budget_parts.append(f"timeout={budget.timeout_seconds}s") - if budget.output_limit_kb is not None: - budget_parts.append(f"output={budget.output_limit_kb}KB") - if budget.max_invocations is not None: - budget_parts.append(f"max_invocations={budget.max_invocations}") - if budget_parts: - lines.append(f"Constraints: [cyan]{escape(', '.join(budget_parts))}[/cyan]") - panel_text = "\n".join(item for item in lines if item is not None) - renderer = get_active_renderer() - if renderer is not None: - renderer.pause() - try: - console.print(Panel.fit(panel_text, title="Approval Required")) - return typer.confirm("Approve this action?", default=False) - finally: - if renderer is not None: - renderer.resume() - - -def _prompt_for_question(question: QuestionAction) -> str | None: - lines = [f"[bold]{escape(question.prompt)}[/bold]"] - if question.options: - lines.append("") - for index, option in enumerate(question.options, start=1): - lines.append(f" [cyan]{index}[/cyan]. {escape(option)}") - renderer = get_active_renderer() - if renderer is not None: - renderer.pause() - try: - console.print(Panel.fit("\n".join(lines), title="Question")) - try: - raw = str(typer.prompt("Your answer")) - except (EOFError, click.exceptions.Abort): - return None - answer = raw.strip() - if not answer: - return None - # Allow selecting an option by its number. - if question.options and answer.isdigit(): - choice = int(answer) - if 1 <= choice <= len(question.options): - return question.options[choice - 1] - return answer - finally: - if renderer is not None: - renderer.resume() - - -def _prompt_gap_option(handoff: CapabilityGapHandoff) -> CapabilityGapOption | None: - """Present a capability-gap handoff and return the option the user chose.""" - lines = [f"[bold]{escape(handoff.message)}[/bold]", ""] - for index, option in enumerate(handoff.options, start=1): - lines.append(f" [cyan]{index}[/cyan]. {escape(option.label)}") - renderer = get_active_renderer() - if renderer is not None: - renderer.pause() - try: - console.print(Panel.fit("\n".join(lines), title="How would you like to proceed?")) - try: - raw = typer.prompt("Choose an option", default="").strip() - except (EOFError, click.exceptions.Abort): - return None - if raw.isdigit(): - choice = int(raw) - if 1 <= choice <= len(handoff.options): - return handoff.options[choice - 1] - return None - finally: - if renderer is not None: - renderer.resume() - - -def _submit_gap_report(handoff: CapabilityGapHandoff, *, settings: AppSettings) -> None: - """Persist a gap report and show the user where it can be filed/fixed.""" - path = write_gap_report(handoff.report, gaps_dir=settings.app.state_dir / "gaps") - url = build_issue_url(handoff.report) - console.print( - Panel.fit( - "Thanks — I saved a gap report so this can be fixed.\n\n" - f"Saved to: [cyan]{escape(str(path))}[/cyan]\n" - f"File it as an issue: [cyan]{escape(url)}[/cyan]", - title="Reported", - ) - ) - - -def _handle_gap_handoff( - handoff: CapabilityGapHandoff, - *, - settings: AppSettings, -) -> str | None: - """Drive the interactive gap handoff. Return a follow-up request to resume, or None.""" - choice = _prompt_gap_option(handoff) - if choice is None: - return None - if choice.kind is GapOptionKind.REPORT: - _submit_gap_report(handoff, settings=settings) - return None - if choice.kind is GapOptionKind.ALTERNATIVE: - return choice.follow_up_request - console.print(Text("Okay — stopping here.", style="dim")) - return None - - -def _build_orchestrator( - settings: AppSettings, - *, - approval_mode: ApprovalMode | None = None, - shell_output_callback: Any | None = None, -) -> RequestOrchestrator: - effective_approval_mode = approval_mode or settings.approval.mode - tool_service = _build_tool_service(settings) - return RequestOrchestrator( - workspace_root=settings.workspace_root, - approval_mode=effective_approval_mode, - provider=build_provider_adapter(settings), - shell_runtime=_build_shell_runtime(settings), - tool_service=tool_service, - approval_service=ApprovalService( - mode=effective_approval_mode, - prompt_callback=_prompt_for_approval, - ), - history_store=_build_history_store(settings), - shell_output_callback=shell_output_callback, - question_callback=_prompt_for_question, - capability_registry=_build_capability_registry(settings, tool_service=tool_service), - ) - - -def _chat_history_path(settings: AppSettings) -> Path: - history_path = settings.app.state_dir / _REPL_HISTORY_FILENAME - history_path.parent.mkdir(parents=True, exist_ok=True) - return history_path - - -def _settings_for_interactive_session( - settings: AppSettings, - state: InteractiveChatState, -) -> AppSettings: - runtime_settings = settings.model_copy(deep=True) - runtime_settings.provider.name = state.provider_name - runtime_settings.provider.model = state.model - return runtime_settings - - -def _preview_transcript_text(value: str) -> str: - trimmed = value.strip() - if not trimmed: - return "" - if len(trimmed) <= _REPL_TRANSCRIPT_OUTPUT_PREVIEW_CHARACTERS: - return trimmed - return trimmed[:_REPL_TRANSCRIPT_OUTPUT_PREVIEW_CHARACTERS].rstrip() + "\n...[truncated]" - - -def _build_chat_prompt_session(settings: AppSettings) -> Any: - try: - from prompt_toolkit import PromptSession - from prompt_toolkit.auto_suggest import AutoSuggestFromHistory - from prompt_toolkit.completion import NestedCompleter - from prompt_toolkit.history import FileHistory - from prompt_toolkit.key_binding import KeyBindings - except ImportError as exc: - raise RuntimeError( - "Interactive chat requires prompt_toolkit. Reinstall dependencies with " - "`./scripts/uv sync --extra dev`." - ) from exc - - bindings = KeyBindings() - - @bindings.add("enter") - def _submit_input(event: Any) -> None: - event.current_buffer.validate_and_handle() - - @bindings.add("escape", "enter") - def _insert_multiline_newline(event: Any) -> None: - event.current_buffer.insert_text("\n") - - return PromptSession( - history=FileHistory(str(_chat_history_path(settings))), - auto_suggest=AutoSuggestFromHistory(), - completer=NestedCompleter.from_nested_dict(_REPL_COMMAND_COMPLETIONS), - complete_while_typing=True, - key_bindings=bindings, - multiline=True, - reserve_space_for_menu=6, - ) - - -def _resolve_chat_session_cwd(workspace_root: Path, cwd: Path | None) -> Path: - resolved_workspace_root = workspace_root.resolve() - resolved = _resolve_cli_request_cwd(resolved_workspace_root, cwd) - try: - resolved.relative_to(resolved_workspace_root) - except ValueError as exc: - raise ValueError( - "Interactive chat cwd must stay within the configured workspace root." - ) from exc - if not resolved.exists(): - raise ValueError(f"Interactive chat cwd does not exist: {resolved}") - if not resolved.is_dir(): - raise ValueError(f"Interactive chat cwd is not a directory: {resolved}") - return resolved - - -def _format_repl_cwd(workspace_root: Path, cwd: Path) -> str: - try: - relative = cwd.relative_to(workspace_root) - except ValueError: - return str(cwd) - return "." if str(relative) == "." else str(relative) - - -def _chat_prompt(state: InteractiveChatState, *, settings: AppSettings, plan_only: bool) -> str: - mode_suffix = " plan-only" if plan_only else "" - session_id = state.session_id[:8] - return ( - f"foundation[{session_id} {_format_repl_cwd(settings.workspace_root, state.current_cwd)} " - f"{state.approval_mode.value} {state.model}{mode_suffix}]> " - ) - - -def _render_interactive_chat_help() -> None: - help_text = ( - "Natural-language request: send it directly\n" - f"Direct shell command: prefix with `{_REPL_SHELL_PREFIX}`\n" - "Submit input: press Enter\n" - "Insert newline: press Esc then Enter\n" - "/help: show this help\n" - "/history [limit]: list recent persisted sessions\n" - "/sessions [limit]: list persistent chat sessions\n" - "/resume [session-id|latest]: switch to another persistent session\n" - "/plan: inspect the last structured plan\n" - "/actions: inspect the last action results\n" - "/summary: inspect the last orchestration summary\n" - "/memory: show loaded memory layers\n" - "/memory show [global|project|session|summary|turns]: inspect one memory source\n" - "/memory append [global|project] : append to a memory file\n" - "/memory set [global|project] : replace a memory file\n" - "/compact: compact older turns into the session summary now\n" - "/model [name]: show or change the session model\n" - "/tools: show current local tool availability\n" - "/config [locations]: inspect effective config or paths\n" - "/cwd [path]: show or update the interactive working directory\n" - "/approval [auto|manual|prompt]: inspect or change session approval mode\n" - "/clear: clear the terminal and redraw the session header\n" - "/reset: reset session-local cwd, approval mode, model, and transcript state\n" - "/exit or /quit: leave interactive chat" - ) - console.print( - Panel.fit( - Text(help_text), - title="Interactive Chat Help", - ) - ) - - -def _memory_source_label(source: MemorySource) -> str: - labels = { - MemorySource.GLOBAL: "Global user memory", - MemorySource.PROJECT: "Project memory", - MemorySource.SESSION_SUMMARY: "Session summary", - MemorySource.RECENT_TURNS: "Recent turns", - } - return labels[source] - - -def _loaded_memory_labels(envelope: MemoryEnvelope) -> list[str]: - return [ - layer.label - for layer in envelope.layers - if layer.content.strip() or (layer.path is not None and layer.exists) - ] - - -def _render_interactive_chat_welcome( - settings: AppSettings, - state: InteractiveChatState, - *, - plan_only: bool, - render_mode: RenderMode, - memory_envelope: MemoryEnvelope, -) -> None: - plan_only_text = "enabled" if plan_only else "disabled" - loaded_memories = ", ".join(_loaded_memory_labels(memory_envelope)) or "none" - console.print( - Panel.fit( - f"Session: [cyan]{escape(state.session_id)}[/cyan]\n" - f"Workspace root: [cyan]{escape(str(settings.workspace_root))}[/cyan]\n" - f"Request cwd: [cyan]{escape(str(state.current_cwd))}[/cyan]\n" - f"Approval mode: [cyan]{state.approval_mode.value}[/cyan]\n" - f"Provider: [cyan]{escape(state.provider_name)}[/cyan]\n" - f"Model: [cyan]{escape(state.model)}[/cyan]\n" - f"Render mode: [cyan]{render_mode.value}[/cyan]\n" - f"Plan-only default: [cyan]{plan_only_text}[/cyan]\n" - f"Loaded memory: [cyan]{escape(loaded_memories)}[/cyan]\n" - f"Prompt history: [cyan]{escape(str(_chat_history_path(settings)))}[/cyan]\n\n" - "Enter a request to use the planner, prefix with `!` for a direct shell command, " - "or use `/plan`, `/actions`, `/summary`, or `/help` for session commands.", - title="Interactive Chat", - ) - ) - if state.recovered_from_interruption: - interrupted_turn = state.interrupted_turn or "unknown input" - console.print( - Text( - ( - "Recovered the last clean checkpoint after an interrupted turn: " - f"{interrupted_turn}" - ), - style="yellow", - ) - ) - - -def _render_memory_envelope(envelope: MemoryEnvelope) -> None: - table = Table(title="Session Memory") - table.add_column("Source", style="cyan") - table.add_column("Location") - table.add_column("Loaded", justify="center") - for layer in envelope.layers: - table.add_row( - layer.label, - layer.path or "-", - "yes" if layer.content.strip() else "no", - ) - console.print(table) - - -def _render_memory_layer(layer: MemoryLayer) -> None: - title = layer.label if layer.path is None else f"{layer.label}: {layer.path}" - content = layer.content or "[empty]" - console.print(Panel.fit(content, title=title)) - - -def _memory_layer_for_source(envelope: MemoryEnvelope, source: MemorySource) -> MemoryLayer: - for layer in envelope.layers: - if layer.source is source: - return layer - raise ValueError(f"Memory source {source.value!r} is not available.") - - -def _render_brain_sessions(sessions: list[SessionSnapshot]) -> None: - if not sessions: - console.print("No persistent chat sessions recorded yet.") - return - table = Table(title="Chat Sessions") - table.add_column("Session", style="cyan") - table.add_column("Updated") - table.add_column("Turns", justify="right") - table.add_column("Cwd") - table.add_column("Model") - table.add_column("State") - for session in sessions: - state = "interrupted" if session.recovered_from_interruption else "ready" - table.add_row( - session.session_id, - session.updated_at, - str(session.turn_count), - session.current_cwd, - session.model, - state, - ) - console.print(table) - - -def _parse_memory_source(argument: str) -> MemorySource: - normalized = argument.strip().lower() - aliases = { - "global": MemorySource.GLOBAL, - "project": MemorySource.PROJECT, - "session": MemorySource.SESSION_SUMMARY, - "summary": MemorySource.SESSION_SUMMARY, - "turns": MemorySource.RECENT_TURNS, - } - try: - return aliases[normalized] - except KeyError as exc: - choices = ", ".join(sorted(aliases)) - raise ValueError(f"Expected one of: {choices}.") from exc - - -def _record_repl_shell_session( - history_store: HistoryStore, - session_id: str, - *, - request: ShellCommandRequest, - request_cwd: Path, - decision: PolicyDecision, - result: ShellCommandResult | None, - execution_status: str, - summary_text: str, - error: str | None, -) -> None: - history_store.record_command( - session_id, - action_id=None, - source="chat.repl", - command=request.command, - args=list(request.args), - cwd=str(result.cwd if result is not None else request_cwd), - mode=result.mode.value if result is not None else request.mode.value, - policy_decision=decision.decision.value, - policy_reason=decision.reason, - risk_categories=list(decision.risk_categories), - execution_status=execution_status, - exit_code=None if result is None else result.exit_code, - duration_seconds=None if result is None else result.duration_seconds, - stdout="" if result is None else result.stdout, - stderr="" if result is None else result.stderr, - stdout_truncated=False if result is None else result.stdout_truncated, - stderr_truncated=False if result is None else result.stderr_truncated, - error=error, - ) - history_store.record_summary( - session_id, - assistant_message=None, - summary_text=summary_text, - executed_actions=1 if execution_status == "executed" else 0, - pending_approval_actions=1 if execution_status == "pending_approval" else 0, - blocked_actions=1 if execution_status == "blocked" else 0, - failed_actions=1 if execution_status == "failed" else 0, - skipped_actions=0, - ) - history_store.finalize_session( - session_id, - status=( - SessionStatus.FAILED - if execution_status == "failed" - else ( - SessionStatus.PENDING_APPROVAL - if execution_status == "pending_approval" - else SessionStatus.COMPLETED - ) - ), - ) - - -def _execute_repl_shell_command( - *, - raw_command: str, - settings: AppSettings, - state: InteractiveChatState, - session_manager: SessionManager, - history_store: HistoryStore, - shell_runtime: ShellRuntime, - policy_engine: GuardrailPolicyEngine, -) -> None: - request_id = f"req-{uuid.uuid4().hex}" - - def _append_shell_transcript( - *, - summary_text: str, - execution_status: str, - result: ShellCommandResult | None = None, - error: str | None = None, - ) -> None: - lines = [ - f"Direct shell command: `{shlex.join(request.argv)}`", - f"Status: {execution_status}", - f"Outcome: {summary_text}", - ] - if result is not None: - stdout_preview = _preview_transcript_text(result.stdout) - stderr_preview = _preview_transcript_text(result.stderr) - if stdout_preview: - lines.append(f"stdout:\n{stdout_preview}") - if stderr_preview: - lines.append(f"stderr:\n{stderr_preview}") - elif error: - lines.append(f"Error: {error}") - session_manager.record_turn( - state.session, - turn_kind="shell", - user_message=f"{_REPL_SHELL_PREFIX}{raw_command}", - assistant_message="\n".join(lines), - metadata={"execution_status": execution_status}, - ) - - try: - argv = shlex.split(raw_command) - except ValueError as exc: - console.print(f"[bold red]Shell parse error:[/bold red] {exc}") - return - if not argv: - console.print(Text("No shell command was supplied after '!'.", style="dim")) - return - - session_manager.mark_turn_started( - state.session, - user_message=f"{_REPL_SHELL_PREFIX}{raw_command}", - turn_kind="shell", - ) - - request_cwd = state.current_cwd - request = ShellCommandRequest( - command=argv[0], - args=argv[1:], - cwd=request_cwd, - approval_context={"source": "chat.repl", "request_id": request_id}, - mode=ExecutionMode.STREAM, - ) - action = PlannedAction( - id="repl_shell", - kind=ActionKind.SHELL, - summary=f"Run `{shlex.join(request.argv)}` from the interactive session.", - shell=ShellAction( - command=request.command, - args=request.args, - cwd=str(request_cwd), - mode=ShellActionMode.STREAM, - ), - ) - evaluation = policy_engine.evaluate( - action, - request_cwd=request_cwd, - approval_mode=state.approval_mode, - ) - decision = ( - policy_engine.to_policy_decision(evaluation) - if evaluation is not None - else PolicyDecision( - action_id=action.id, - decision=PolicyDecisionType.ALLOW, - reason="Explanation-only actions do not execute anything.", - ) - ) - session_id = history_store.start_session( - kind=SessionKind.RUN, - workspace_root=settings.workspace_root, - request_cwd=request_cwd, - approval_mode=state.approval_mode.value, - command_preview=shlex.join(request.argv), - ) - if evaluation is not None: - history_store.record_policy_evaluation(session_id, record=evaluation) - - if decision.decision is PolicyDecisionType.BLOCK: - console.print(f"[bold red]Execution blocked:[/bold red] {decision.reason}") - _record_repl_shell_session( - history_store, - session_id, - request=request, - request_cwd=request_cwd, - decision=decision, - result=None, - execution_status="blocked", - summary_text=decision.reason, - error=decision.reason, - ) - _append_shell_transcript( - summary_text=decision.reason, - execution_status="blocked", - error=decision.reason, - ) - return - - if decision.decision is PolicyDecisionType.REQUIRE_APPROVAL: - if evaluation is None: - console.print("[bold red]Execution blocked:[/bold red] Missing policy evaluation.") - return - approval_request, approval_resolution = ApprovalService( - mode=state.approval_mode, - prompt_callback=_prompt_for_approval, - ).resolve( - action, - evaluation, - request_cwd=request_cwd, - ) - history_store.record_approval( - session_id, - request=approval_request, - resolution=approval_resolution, - ) - if approval_resolution.status is ApprovalDecisionStatus.PENDING: - console.print( - f"[bold yellow]Approval pending:[/bold yellow] {approval_resolution.reason}" - ) - _record_repl_shell_session( - history_store, - session_id, - request=request, - request_cwd=request_cwd, - decision=decision, - result=None, - execution_status="pending_approval", - summary_text=approval_resolution.reason, - error=None, - ) - _append_shell_transcript( - summary_text=approval_resolution.reason, - execution_status="pending_approval", - ) - return - if approval_resolution.status is ApprovalDecisionStatus.DENIED: - console.print(f"[bold red]Execution blocked:[/bold red] {approval_resolution.reason}") - _record_repl_shell_session( - history_store, - session_id, - request=request, - request_cwd=request_cwd, - decision=decision, - result=None, - execution_status="blocked", - summary_text=approval_resolution.reason, - error=approval_resolution.reason, - ) - _append_shell_transcript( - summary_text=approval_resolution.reason, - execution_status="blocked", - error=approval_resolution.reason, - ) - return - - if evaluation is not None: - policy_engine.register_invocation(evaluation) - - effective_request = request - if evaluation is not None: - budget = ( - evaluation.verdict.constraints or evaluation.policy_input.constraints - ).invocation_budget - if budget is not None: - timeout_seconds = request.timeout_seconds - if budget.timeout_seconds is not None and timeout_seconds is not None: - timeout_seconds = min(timeout_seconds, budget.timeout_seconds) - effective_request = request.model_copy( - update={ - "timeout_seconds": timeout_seconds, - "capture_limit_kb": budget.output_limit_kb, - } - ) - - try: - result = shell_runtime.execute(effective_request, on_event=_emit_output_event) - except ValueError as exc: - console.print(f"[bold red]Execution error:[/bold red] {exc}") - _record_repl_shell_session( - history_store, - session_id, - request=request, - request_cwd=request_cwd, - decision=decision, - result=None, - execution_status="failed", - summary_text=f"Shell execution was rejected: {exc}", - error=str(exc), - ) - _append_shell_transcript( - summary_text=f"Shell execution was rejected: {exc}", - execution_status="failed", - error=str(exc), - ) - return - except ShellExecutionSpawnError as exc: - console.print(f"[bold red]Execution error:[/bold red] {exc}") - _record_repl_shell_session( - history_store, - session_id, - request=request, - request_cwd=request_cwd, - decision=decision, - result=None, - execution_status="failed", - summary_text=f"Shell execution failed to start: {exc}", - error=str(exc), - ) - _append_shell_transcript( - summary_text=f"Shell execution failed to start: {exc}", - execution_status="failed", - error=str(exc), - ) - return - except ShellExecutionTimeout as exc: - if exc.result is not None: - _render_execution_summary(exc.result) - console.print(f"[bold red]Execution error:[/bold red] {exc}") - _record_repl_shell_session( - history_store, - session_id, - request=request, - request_cwd=request_cwd, - decision=decision, - result=exc.result, - execution_status="failed", - summary_text=f"Shell execution timed out: {exc}", - error=str(exc), - ) - _append_shell_transcript( - summary_text=f"Shell execution timed out: {exc}", - execution_status="failed", - result=exc.result, - error=str(exc), - ) - return - except ShellExecutionCancelled as exc: - if exc.result is not None: - _render_execution_summary(exc.result) - console.print(f"[bold red]Execution cancelled:[/bold red] {exc}") - _record_repl_shell_session( - history_store, - session_id, - request=request, - request_cwd=request_cwd, - decision=decision, - result=exc.result, - execution_status="failed", - summary_text=f"Shell execution was cancelled: {exc}", - error=str(exc), - ) - _append_shell_transcript( - summary_text=f"Shell execution was cancelled: {exc}", - execution_status="failed", - result=exc.result, - error=str(exc), - ) - return - - _render_execution_summary(result) - execution_status = "executed" if result.ok else "failed" - summary_text = ( - f"Executed shell command `{result.display_command}`." - if result.ok - else result.stderr or f"Shell command `{result.display_command}` failed." - ) - _record_repl_shell_session( - history_store, - session_id, - request=request, - request_cwd=request_cwd, - decision=decision, - result=result, - execution_status=execution_status, - summary_text=summary_text, - error=None if result.ok else result.stderr or f"Exit code {result.exit_code}", - ) - _append_shell_transcript( - summary_text=summary_text, - execution_status=execution_status, - result=result, - error=None if result.ok else result.stderr or f"Exit code {result.exit_code}", - ) - - -def _execute_chat_request( - settings: AppSettings, - *, - message: str, - conversation_history: list[ProviderMessage] | None = None, - cwd: Path | None, - plan_only: bool, - approval_mode: ApprovalMode | None = None, - shell_output_callback: Any | None = None, - disable_live_ux: bool = False, - disable_monitor: bool = False, - monitor_socket: str | None = None, - monitor_http_port: int | None = None, -) -> OrchestrationResult: - orchestrator = _build_orchestrator( - settings, - approval_mode=approval_mode, - shell_output_callback=shell_output_callback, - ) - request = UserRequest( - message=message, - conversation_history=list(conversation_history or []), - cwd=cwd, - plan_only=plan_only, - ) - use_live_ux = not (disable_live_ux or live_ux_disabled()) - use_monitor = settings.monitor.enabled and not disable_monitor - use_socket = monitor_socket is not None and use_monitor - use_http = monitor_http_port is not None and use_monitor - if not use_live_ux and not use_monitor: - return orchestrator.orchestrate(request) - - monitor_server: MonitorServer | None = None - transports: list[Any] = [] - if use_socket or use_http: - monitor_server = MonitorServer(queue_size=settings.monitor.subscriber_queue_size) - if use_socket: - socket_path = _resolve_monitor_socket_path(settings, override=monitor_socket) - try: - transports.append(UnixSocketTransport(path=socket_path, server=monitor_server)) - except TransportStartError as exc: - console.print(f"[bold yellow]Monitor warning:[/bold yellow] {exc}") - if use_http: - token = _resolve_monitor_http_token(settings) - assert monitor_http_port is not None - try: - transports.append( - LocalHttpSseTransport( - port=monitor_http_port, - token=token, - server=monitor_server, - ) - ) - console.print( - f"[dim]Monitor HTTP transport listening on " - f"127.0.0.1:{monitor_http_port}; bearer token: {token}[/dim]" - ) - except TransportStartError as exc: - console.print(f"[bold yellow]Monitor warning:[/bold yellow] {exc}") - return _run_orchestrate_with_sinks( - orchestrator, - request, - use_live=use_live_ux, - writer=_build_event_log_writer(settings) if use_monitor else None, - monitor_server=monitor_server, - transports=transports, - ) - - -def _resolve_monitor_socket_path(settings: AppSettings, *, override: str | None) -> Path: - if override: - return Path(override).expanduser() - if settings.monitor.socket_path is not None: - return settings.monitor.socket_path - runtime_dir = os.environ.get("XDG_RUNTIME_DIR") or os.environ.get("TMPDIR") or "/tmp" - return Path(runtime_dir).expanduser() / "foundation" / f"{os.getpid()}.sock" - - -def _resolve_monitor_http_token(settings: AppSettings) -> str: - configured = settings.monitor.auth_token - if configured is not None: - token = configured.get_secret_value() - if token: - return token - import secrets - - return secrets.token_urlsafe(24) - - -def _build_event_log_writer(settings: AppSettings) -> EventLogWriter: - return EventLogWriter( - events_dir=settings.monitor.events_dir, - max_sessions=settings.monitor.retention.max_sessions, - max_bytes=settings.monitor.retention.max_bytes, - ) - - -def _run_orchestrate_with_sinks( - orchestrator: RequestOrchestrator, - request: UserRequest, - *, - use_live: bool, - writer: EventLogWriter | None, - monitor_server: MonitorServer | None = None, - transports: list[Any] | None = None, -) -> OrchestrationResult: - set_sink = getattr(orchestrator, "set_event_sink", None) - - def _attach_sink(sink: Any) -> None: - if callable(set_sink): - set_sink(sink) - - transports = transports or [] - - def _build_sinks(extra: list[Any] | None = None) -> list[Any]: - sinks: list[Any] = list(extra or []) - if writer is not None: - sinks.append(writer.write_event) - if monitor_server is not None: - sinks.append(monitor_server.publish) - return sinks - - writer_ctx: Any = writer if writer is not None else _NullContext() - server_ctx: Any = monitor_server if monitor_server is not None else _NullContext() - transport_stack: list[Any] = [] - try: - for transport in transports: - transport.start() - transport_stack.append(transport) - with writer_ctx, server_ctx: - if not use_live: - _attach_sink(compose_event_sink(*_build_sinks())) - try: - return orchestrator.orchestrate(request) - finally: - _attach_sink(None) - - result_box: dict[str, OrchestrationResult | BaseException] = {} - - def worker() -> None: - try: - result_box["result"] = orchestrator.orchestrate(request) - except BaseException as exc: # noqa: BLE001 - re-raised on main thread - result_box["error"] = exc - - with LiveTurnRenderer(console=console) as renderer: - _attach_sink(compose_event_sink(*_build_sinks([renderer.on_event]))) - try: - thread = threading.Thread(target=worker, name="fcli-orchestrate", daemon=True) - thread.start() - try: - renderer.drain_until_finished(worker=thread) - except KeyboardInterrupt: - thread.join(timeout=5.0) - raise - finally: - _attach_sink(None) - - error = result_box.get("error") - if isinstance(error, BaseException): - raise error - result = result_box.get("result") - if isinstance(result, OrchestrationResult): - return result - raise RuntimeError("Orchestration worker finished without a result.") - finally: - for transport in reversed(transport_stack): - with suppress(Exception): - transport.close() - - -class _NullContext: - def __enter__(self) -> None: - return None - - def __exit__(self, *_args: Any) -> None: - return None - - -def _build_transcript_assistant_message(result: OrchestrationResult) -> str: - lines = [result.assistant_message.content, f"Outcome: {result.summary.text}"] - for execution_result in result.execution_results: - lines.append( - f"{execution_result.action_id}: {execution_result.status.value} - " - f"{execution_result.summary}" - ) - return "\n".join(lines) - - -def _append_chat_transcript_turn( - session_manager: SessionManager, - state: InteractiveChatState, - *, - user_message: str, - result: OrchestrationResult, -) -> None: - session_manager.record_turn( - state.session, - turn_kind="chat", - user_message=user_message, - assistant_message=_build_transcript_assistant_message(result), - metadata={"history_session_id": result.session_id or ""}, - ) - - -def _run_interactive_chat( - settings: AppSettings, - *, - initial_cwd: Path, - plan_only: bool, - render_mode: RenderMode, - resume_target: ResumeTarget, - disable_live_ux: bool = False, - disable_monitor: bool = False, - monitor_socket: str | None = None, - monitor_http_port: int | None = None, -) -> None: - try: - prompt_session = _build_chat_prompt_session(settings) - except RuntimeError as exc: - console.print(f"[bold red]Interactive chat error:[/bold red] {exc}") - raise typer.Exit(code=1) from exc - - session_manager = _build_session_manager(settings) - try: - session = session_manager.resolve_session( - resume_target, - initial_cwd=initial_cwd, - approval_mode=settings.approval.mode.value, - model=settings.provider.model, - ) - except ValueError as exc: - console.print(f"[bold red]Interactive chat error:[/bold red] {exc}") - raise typer.Exit(code=2) from exc - - state = InteractiveChatState(session=session) - history_store = _build_history_store(settings) - shell_runtime = _build_shell_runtime(settings) - policy_engine = GuardrailPolicyEngine( - workspace_root=settings.workspace_root, - capability_registry=_build_capability_registry(settings), - ) - _render_interactive_chat_welcome( - settings, - state, - plan_only=plan_only, - render_mode=render_mode, - memory_envelope=session_manager.build_memory_envelope(state.session), - ) - - # When a capability-gap handoff offers a constrained retry and the user - # accepts, the chosen follow-up is queued here and run as the next turn - # without re-prompting. - pending_request: str | None = None - - while True: - if pending_request is not None: - raw_input = pending_request - pending_request = None - console.print(Text(f"↪ Continuing: {raw_input.splitlines()[0]}", style="dim")) - else: - try: - raw_input = prompt_session.prompt( - _chat_prompt(state, settings=settings, plan_only=plan_only) - ) - except KeyboardInterrupt: - console.print( - Text( - "Input cancelled. Use /exit or Ctrl-D to leave the session.", - style="dim", - ) - ) - continue - except EOFError: - console.print(Text("Interactive chat closed.", style="dim")) - return - - text = raw_input.strip() - if not text: - continue - command, _, argument = text.partition(" ") - argument = argument.strip() - - if command in {"/exit", "/quit"}: - console.print(Text("Interactive chat closed.", style="dim")) - return - if command == "/help": - _render_interactive_chat_help() - continue - if command == "/history": - try: - limit = _REPL_DEFAULT_HISTORY_LIMIT if not argument else int(argument) - except ValueError: - console.print("[bold red]History error:[/bold red] Expected an integer limit.") - continue - if limit <= 0: - console.print("[bold red]History error:[/bold red] Limit must be positive.") - continue - _render_history_list(history_store.list_sessions(limit=limit)) - continue - if command == "/sessions": - try: - limit = _REPL_DEFAULT_HISTORY_LIMIT if not argument else int(argument) - except ValueError: - console.print("[bold red]Session error:[/bold red] Expected an integer limit.") - continue - if limit <= 0: - console.print("[bold red]Session error:[/bold red] Limit must be positive.") - continue - _render_brain_sessions(session_manager.list_sessions(limit=limit)) - continue - if command == "/resume": - target = ( - ResumeTarget.latest() - if not argument or argument == "latest" - else ResumeTarget.explicit(argument) - ) - try: - state.session = session_manager.resolve_session( - target, - initial_cwd=initial_cwd, - approval_mode=settings.approval.mode.value, - model=settings.provider.model, - ) - except ValueError as exc: - console.print(f"[bold red]Resume error:[/bold red] {exc}") - continue - state.last_result = None - _render_interactive_chat_welcome( - settings, - state, - plan_only=plan_only, - render_mode=render_mode, - memory_envelope=session_manager.build_memory_envelope(state.session), - ) - continue - if command in { - InteractiveDetailCommand.PLAN.value, - InteractiveDetailCommand.ACTIONS.value, - InteractiveDetailCommand.SUMMARY.value, - }: - if state.last_result is None: - console.print( - Text( - "No assistant turn has completed in this session yet.", - style="dim", - ) - ) - continue - _render_interactive_detail(state.last_result, InteractiveDetailCommand(command)) - continue - if command == "/memory": - if not argument: - _render_memory_envelope(session_manager.build_memory_envelope(state.session)) - continue - subcommand, _, remainder = argument.partition(" ") - subcommand = subcommand.strip().lower() - remainder = remainder.strip() - if subcommand == "show": - if not remainder: - console.print( - "[bold red]Memory error:[/bold red] Expected a memory source to show." - ) - continue - try: - source = _parse_memory_source(remainder) - envelope = session_manager.build_memory_envelope(state.session) - _render_memory_layer(_memory_layer_for_source(envelope, source)) - except ValueError as exc: - console.print(f"[bold red]Memory error:[/bold red] {exc}") - continue - if subcommand in {"append", "set"}: - source_text, _, content = remainder.partition(" ") - if not source_text or not content.strip(): - console.print( - "[bold red]Memory error:[/bold red] Expected " - "`/memory append|set [global|project] `." - ) - continue - try: - layer = session_manager.write_memory( - _parse_memory_source(source_text), - content=content, - append=subcommand == "append", - ) - except ValueError as exc: - console.print(f"[bold red]Memory error:[/bold red] {exc}") - continue - console.print(Text(f"{layer.label} updated.", style="dim")) - continue - console.print( - "[bold red]Memory error:[/bold red] Use `/memory`, " - "`/memory show ...`, `/memory append ...`, or `/memory set ...`." - ) - continue - if command == "/compact": - changed = session_manager.compact_session(state.session, force=True) - if changed: - console.print(Text("Session memory compacted.", style="dim")) - else: - console.print(Text("No compaction was needed.", style="dim")) - continue - if command == "/model": - if not argument: - console.print(Text(f"Current model: {state.model}", style="dim")) - continue - state.model = argument - session_manager.checkpoint(state.session) - console.print(Text(f"Interactive model set to {state.model}.", style="dim")) - continue - if command == "/tools": - _render_availability(_build_tool_service(settings).availability_report()) - continue - if command == "/config": - if argument and argument != "locations": - console.print( - "[bold red]Config error:[/bold red] Use `/config` or `/config locations`." - ) - continue - payload = ( - settings.config_locations() - if argument == "locations" - else render_settings_payload(settings) - ) - console.print_json(data=payload) - continue - if command == "/cwd": - if not argument: - console.print(Text(f"Current cwd: {state.current_cwd}", style="dim")) - continue - try: - state.current_cwd = _resolve_chat_session_cwd( - settings.workspace_root, - Path(argument), - ) - except ValueError as exc: - console.print(f"[bold red]Cwd error:[/bold red] {exc}") - continue - session_manager.checkpoint(state.session) - console.print(Text(f"Interactive cwd set to {state.current_cwd}.", style="dim")) - continue - if command == "/approval": - argument = argument.lower() - if not argument: - console.print( - Text(f"Current approval mode: {state.approval_mode.value}", style="dim") - ) - continue - try: - state.approval_mode = ApprovalMode(argument) - except ValueError: - choices = ", ".join(mode.value for mode in ApprovalMode) - console.print(f"[bold red]Approval error:[/bold red] Expected one of: {choices}.") - continue - session_manager.checkpoint(state.session) - console.print( - Text( - f"Interactive approval mode set to {state.approval_mode.value}.", - style="dim", - ) - ) - continue - if command == "/clear": - console.clear() - _render_interactive_chat_welcome( - settings, - state, - plan_only=plan_only, - render_mode=render_mode, - memory_envelope=session_manager.build_memory_envelope(state.session), - ) - continue - if command == "/reset": - session_manager.reset_session( - state.session, - current_cwd=state.initial_cwd, - approval_mode=settings.approval.mode.value, - model=settings.provider.model, - ) - state.last_result = None - console.print(Text("Session-local state reset.", style="dim")) - continue - if command.startswith("/"): - console.print(f"[bold yellow]Unknown command:[/bold yellow] {text}. Use `/help`.") - continue - if text.startswith(_REPL_SHELL_PREFIX): - _execute_repl_shell_command( - raw_command=text.removeprefix(_REPL_SHELL_PREFIX).strip(), - settings=settings, - state=state, - session_manager=session_manager, - history_store=history_store, - shell_runtime=shell_runtime, - policy_engine=policy_engine, - ) - continue - - session_manager.mark_turn_started( - state.session, - user_message=text, - turn_kind="chat", - ) - memory_envelope = session_manager.build_memory_envelope(state.session) - try: - result = _execute_chat_request( - _settings_for_interactive_session(settings, state), - message=text, - conversation_history=memory_envelope.prompt_messages, - cwd=state.current_cwd, - plan_only=plan_only, - approval_mode=state.approval_mode, - shell_output_callback=_emit_output_event, - disable_live_ux=disable_live_ux, - disable_monitor=disable_monitor, - monitor_socket=monitor_socket, - monitor_http_port=monitor_http_port, - ) - except ProviderError as exc: - console.print(f"[bold red]Provider error:[/bold red] {exc}") - session_manager.record_turn( - state.session, - turn_kind="chat", - user_message=text, - assistant_message=f"Provider error: {exc}", - metadata={"error_type": exc.__class__.__name__, "status": "failed"}, - ) - continue - except OrchestrationPlanError as exc: - console.print(f"[bold red]Planning error:[/bold red] {exc}") - session_manager.record_turn( - state.session, - turn_kind="chat", - user_message=text, - assistant_message=f"Planning error: {exc}", - metadata={"error_type": exc.__class__.__name__, "status": "failed"}, - ) - continue - except OrchestrationError as exc: - console.print(f"[bold red]Orchestration error:[/bold red] {exc}") - session_manager.record_turn( - state.session, - turn_kind="chat", - user_message=text, - assistant_message=f"Orchestration error: {exc}", - metadata={"error_type": exc.__class__.__name__, "status": "failed"}, - ) - continue - - state.last_result = result - _render_chat_turn( - result, - render_mode=render_mode, - interactive=True, - streamed_shell_output=True, - ) - _append_chat_transcript_turn( - session_manager, - state, - user_message=text, - result=result, - ) - - if result.gap_handoff is not None: - pending_request = _handle_gap_handoff(result.gap_handoff, settings=settings) - - def _parse_env_overlays(values: list[str]) -> dict[str, str]: overlay: dict[str, str] = {} for item in values: @@ -1836,1028 +246,6 @@ def _parse_env_overlays(values: list[str]) -> dict[str, str]: return overlay -def _write_output(target: Console, text: str) -> None: - if not text: - return - target.file.write(text) - target.file.flush() - - -def _emit_output_event(event: ShellOutputEvent) -> None: - target = stderr_console if event.stream is OutputStream.STDERR else console - _write_output(target, event.text) - - -def _render_result_output(result: ShellCommandResult, *, streamed: bool) -> None: - if streamed: - return - _write_output(console, result.stdout) - _write_output(stderr_console, result.stderr) - - -def _format_result_status(result: ShellCommandResult) -> str: - if result.timed_out: - return "timed out" - if result.cancelled: - return "cancelled" - if result.exit_code is None: - return "unknown" - if result.exit_code < 0: - return f"signal {-result.exit_code}" - return f"exit code {result.exit_code}" - - -def _render_execution_summary(result: ShellCommandResult) -> None: - style = "green" if result.ok else "red" - lines = [ - f"Command: [cyan]{escape(result.display_command)}[/cyan]", - f"Cwd: [cyan]{escape(str(result.cwd))}[/cyan]", - f"Mode: [cyan]{result.mode.value}[/cyan]", - f"Status: [{style}]{escape(_format_result_status(result))}[/{style}]", - f"Duration: [cyan]{result.duration_seconds:.2f}s[/cyan]", - ] - truncated_streams: list[str] = [] - if result.stdout_truncated: - truncated_streams.append("stdout") - if result.stderr_truncated: - truncated_streams.append("stderr") - if truncated_streams: - lines.append( - f"Captured output truncated: [yellow]{escape(', '.join(truncated_streams))}[/yellow]" - ) - - console.print(Panel.fit("\n".join(lines), title="Execution Summary")) - - -def _render_tool_error(exc: ToolExecutionError) -> None: - console.print(f"[bold red]Tool error:[/bold red] {exc.error.message}") - if exc.error.detail: - console.print(Text(exc.error.detail, style="dim")) - if exc.error.install_hint: - console.print(Text(exc.error.install_hint, style="dim")) - - -def _tool_exit_code(exc: ToolExecutionError) -> int: - if exc.error.code is ToolErrorCode.INVALID_SCOPE: - return 2 - return 1 - - -def _render_availability(availability: list[ToolBinaryStatus]) -> None: - status_styles = { - ToolAvailabilityStatus.AVAILABLE: "green", - ToolAvailabilityStatus.MISSING: "yellow", - } - for item in availability: - style = status_styles[item.status] - resolved = f" ({item.resolved_command}: {item.path})" if item.path else "" - required = "required" if item.required else "optional" - console.print( - f"[{style}]{item.status.value.upper():<9}[/{style}] {item.name}: {required}{resolved}" - ) - if item.status is ToolAvailabilityStatus.MISSING and item.install_hint: - console.print(Text(item.install_hint, style="dim")) - - -def _render_search_result(result: SearchResult) -> None: - if not result.matches: - console.print("No matches found.") - return - - table = Table(title=f"Search Results ({result.scope})") - table.add_column("Path", style="cyan") - table.add_column("Line", justify="right") - table.add_column("Col", justify="right") - table.add_column("Text") - for match in result.matches: - table.add_row( - match.path, - str(match.line_number), - str(match.column_number), - match.line_text, - ) - console.print(table) - if result.truncated: - console.print(Text("Search results were truncated to the requested limit.", style="dim")) - - -def _render_file_result(result: FileDiscoveryResult) -> None: - if not result.paths: - console.print("No paths found.") - return - - table = Table(title=f"Path Discovery ({result.scope})") - table.add_column("Path", style="cyan") - for path in result.paths: - table.add_row(path) - console.print(table) - if result.truncated: - console.print(Text("Path results were truncated to the requested limit.", style="dim")) - - -def _render_git_context(result: GitContextResult) -> None: - console.print( - Panel.fit( - f"Scope: [cyan]{result.scope}[/cyan]\nBranch: [cyan]{result.branch}[/cyan]", - title="Git Context", - ) - ) - - if result.status: - status_table = Table(title="Status") - status_table.add_column("Index", justify="center") - status_table.add_column("Worktree", justify="center") - status_table.add_column("Path", style="cyan") - for entry in result.status: - status_table.add_row(entry.index_status, entry.worktree_status, entry.path) - console.print(status_table) - if result.truncated_status: - console.print(Text("Status output was truncated to the requested limit.", style="dim")) - - if result.unstaged_diff: - diff_table = Table(title="Unstaged Diff") - diff_table.add_column("Path", style="cyan") - diff_table.add_column("+", justify="right") - diff_table.add_column("-", justify="right") - diff_table.add_column("Binary", justify="center") - for diff_entry in result.unstaged_diff: - diff_table.add_row( - diff_entry.path, - "-" if diff_entry.additions is None else str(diff_entry.additions), - "-" if diff_entry.deletions is None else str(diff_entry.deletions), - "yes" if diff_entry.binary else "no", - ) - console.print(diff_table) - - if result.staged_diff: - diff_table = Table(title="Staged Diff") - diff_table.add_column("Path", style="cyan") - diff_table.add_column("+", justify="right") - diff_table.add_column("-", justify="right") - diff_table.add_column("Binary", justify="center") - for diff_entry in result.staged_diff: - diff_table.add_row( - diff_entry.path, - "-" if diff_entry.additions is None else str(diff_entry.additions), - "-" if diff_entry.deletions is None else str(diff_entry.deletions), - "yes" if diff_entry.binary else "no", - ) - console.print(diff_table) - - if result.recent_commits: - commit_table = Table(title="Recent Commits") - commit_table.add_column("Commit", style="cyan") - commit_table.add_column("Summary") - for commit in result.recent_commits: - commit_table.add_row(commit.short_sha, commit.summary) - console.print(commit_table) - - -def _render_help_lookup(result: HelpLookupResult) -> None: - console.print( - Panel.fit( - result.content or "No content returned.", - title=f"{result.source.value}: {result.topic}", - ) - ) - if result.truncated: - console.print(Text("Help output was truncated to the requested limit.", style="dim")) - - -def _render_assistant_message(result: OrchestrationResult) -> None: - console.print(Panel.fit(result.assistant_message.content, title="Assistant")) - - -def _chat_surface_policy(render_mode: RenderMode) -> ChatSurfacePolicy: - return ChatSurfacePolicy(render_mode=render_mode) - - -def _has_hidden_chat_detail(result: OrchestrationResult) -> bool: - return bool(result.plan.actions or result.execution_results or result.policy_decisions) - - -def _detail_ref_for_result(result: OrchestrationResult) -> AuditDetailRef | None: - if result.session_id is None: - return None - return AuditDetailRef( - session_id=result.session_id, - history_hint=f"foundation history --session {result.session_id}", - trace_hint=f"foundation trace --session {result.session_id}", - ) - - -def _notice_level_for_result(result: OrchestrationResult) -> PresentationNoticeLevel: - if result.summary.failed_actions or result.summary.blocked_actions: - return PresentationNoticeLevel.ERROR - if result.summary.pending_approval_actions or result.request.plan_only: - return PresentationNoticeLevel.WARNING - if result.summary.executed_actions or result.summary.skipped_actions: - return PresentationNoticeLevel.DIM - return PresentationNoticeLevel.INFO - - -def _artifact_preview_notice(result: ExecutionResult) -> ChatNotice | None: - if result.artifact_type is None or result.artifact is None: - return None - if result.artifact_type is ExecutionArtifactType.EXPLANATION: - return None - if result.artifact_type is ExecutionArtifactType.SHELL: - shell_result = ShellCommandResult.model_validate(result.artifact) - preview_lines: list[str] = [] - stdout_preview = _preview_transcript_text(shell_result.stdout) - stderr_preview = _preview_transcript_text(shell_result.stderr) - if stdout_preview: - preview_lines.append(f"stdout:\n{stdout_preview}") - if stderr_preview and not shell_result.ok: - preview_lines.append(f"stderr:\n{stderr_preview}") - if not preview_lines: - return None - return ChatNotice( - level=PresentationNoticeLevel.DIM, - text="\n".join(preview_lines), - ) - if result.artifact_type is ExecutionArtifactType.SEARCH: - search_result = SearchResult.model_validate(result.artifact) - if not search_result.matches: - return None - preview_lines = [ - f"{match.path}:{match.line_number} {match.line_text}" - for match in search_result.matches[:3] - ] - if len(search_result.matches) > 3 or search_result.truncated: - preview_lines.append("...") - return ChatNotice( - level=PresentationNoticeLevel.DIM, - text="Search preview:\n" + "\n".join(preview_lines), - ) - if result.artifact_type is ExecutionArtifactType.FILES: - file_result = FileDiscoveryResult.model_validate(result.artifact) - if not file_result.paths: - return None - preview_lines = list(file_result.paths[:5]) - if len(file_result.paths) > 5 or file_result.truncated: - preview_lines.append("...") - return ChatNotice( - level=PresentationNoticeLevel.DIM, - text="Path preview:\n" + "\n".join(preview_lines), - ) - if result.artifact_type is ExecutionArtifactType.GIT: - git_result = GitContextResult.model_validate(result.artifact) - preview_lines = [f"Branch: {git_result.branch}"] - if git_result.status: - changed_paths = [entry.path for entry in git_result.status[:5]] - changed_text = ", ".join(changed_paths) - if len(git_result.status) > 5 or git_result.truncated_status: - changed_text += ", ..." - preview_lines.append(f"Changed: {changed_text}") - if git_result.recent_commits: - preview_lines.append( - "Recent: " - + "; ".join( - f"{commit.short_sha} {commit.summary}" - for commit in git_result.recent_commits[:3] - ) - ) - return ChatNotice( - level=PresentationNoticeLevel.DIM, - text="\n".join(preview_lines), - ) - if result.artifact_type in (ExecutionArtifactType.MAN, ExecutionArtifactType.TLDR): - help_result = HelpLookupResult.model_validate(result.artifact) - content_preview = _preview_transcript_text(help_result.content) - if not content_preview: - return None - return ChatNotice( - level=PresentationNoticeLevel.DIM, - text=f"{help_result.source.value}: {help_result.topic}\n{content_preview}", - ) - return None - - -_CODE_CHANGING_ARTIFACT_TYPES = frozenset( - { - ExecutionArtifactType.FILE_WRITE, - ExecutionArtifactType.FILE_EDIT, - ExecutionArtifactType.FILE_APPLY_DIFF, - } -) - -_CHANGED_FILES_DISPLAY_CAP = 6 -_COMMANDS_RUN_DISPLAY_CAP = 6 - - -def _iteration_changed_files_notice( - result: OrchestrationResult, -) -> ChatNotice | None: - """Dedup and summarize file paths changed across all iterations.""" - seen: list[str] = [] - seen_set: set[str] = set() - for item in result.execution_results: - if item.artifact_type in _CODE_CHANGING_ARTIFACT_TYPES and item.artifact is not None: - path = item.artifact.get("path") - if isinstance(path, str) and path and path not in seen_set: - seen.append(path) - seen_set.add(path) - if not seen: - return None - shown = seen[:_CHANGED_FILES_DISPLAY_CAP] - suffix = "" - if len(seen) > _CHANGED_FILES_DISPLAY_CAP: - suffix = f", +{len(seen) - _CHANGED_FILES_DISPLAY_CAP} more" - label = "Changed file" if len(seen) == 1 else "Changed files" - return ChatNotice( - level=PresentationNoticeLevel.INFO, - text=f"{label}: {', '.join(shown)}{suffix}", - ) - - -def _iteration_commands_notice( - result: OrchestrationResult, -) -> ChatNotice | None: - """Collect shell commands run across iterations, dedup consecutive duplicates.""" - commands: list[str] = [] - for iteration in result.iterations: - for action, exec_result in zip( - iteration.plan.actions, - iteration.execution_results, - strict=False, - ): - if action.kind is not ActionKind.SHELL or action.shell is None: - continue - if exec_result.status is not ExecutionStatus.EXECUTED: - continue - parts = [action.shell.command, *action.shell.args] - display = " ".join(parts).strip() - if not display: - continue - if len(display) > 80: - display = display[:77] + "..." - if commands and commands[-1] == display: - continue - commands.append(display) - if not commands: - return None - shown = commands[:_COMMANDS_RUN_DISPLAY_CAP] - suffix = "" - if len(commands) > _COMMANDS_RUN_DISPLAY_CAP: - suffix = f"\n +{len(commands) - _COMMANDS_RUN_DISPLAY_CAP} more" - label = "Command" if len(commands) == 1 else "Commands" - formatted = "\n ".join(f"$ {cmd}" for cmd in shown) - return ChatNotice( - level=PresentationNoticeLevel.DIM, - text=f"{label} run:\n {formatted}{suffix}", - ) - - -def _verification_outcome_notice( - result: OrchestrationResult, -) -> ChatNotice | None: - """Render the orchestrator's verification notice as a user-facing notice.""" - notice = result.verification_notice - if notice is None: - return None - outcome = notice.outcome - if outcome is VerificationOutcome.PASSED: - cmds = ", ".join(notice.verification_commands_run[:3]) - detail = f" ({cmds})" if cmds else "" - return ChatNotice( - level=PresentationNoticeLevel.INFO, - text=f"Verification: passed{detail}", - ) - if outcome is VerificationOutcome.FAILED: - cmds = ", ".join(notice.verification_commands_run[:3]) - detail = f" ({cmds})" if cmds else "" - return ChatNotice( - level=PresentationNoticeLevel.WARNING, - text=f"Verification: failed{detail}", - ) - if outcome is VerificationOutcome.UNAVAILABLE: - cmds = ", ".join(notice.verification_commands_run[:3]) - detail = f" ({cmds})" if cmds else "" - return ChatNotice( - level=PresentationNoticeLevel.WARNING, - text=f"Verification: unavailable{detail}", - ) - # NOT_ATTEMPTED - return ChatNotice( - level=PresentationNoticeLevel.WARNING, - text="Verification: code changed but no verification command ran", - ) - - -def _approval_required_notice( - result: OrchestrationResult, -) -> ChatNotice | None: - """Surface pending-approval stops as a warning-level notice.""" - if result.stop_reason is not LoopStopReason.PENDING_APPROVAL: - return None - pending = result.summary.pending_approval_actions - plural = "s" if pending != 1 else "" - return ChatNotice( - level=PresentationNoticeLevel.WARNING, - text=( - f"Approval required for {pending} action{plural}. " - "Run `foundation approve` or re-issue with approval mode set." - ), - ) - - -def _awaiting_input_notice( - result: OrchestrationResult, -) -> ChatNotice | None: - """Surface an unanswered question (non-interactive / dismissed) as a notice.""" - if result.stop_reason is not LoopStopReason.AWAITING_USER_INPUT: - return None - prompts = [ - str(item.artifact.get("question", "")).strip() - for item in result.execution_results - if item.artifact_type is ExecutionArtifactType.QUESTION - and item.artifact is not None - and item.status is ExecutionStatus.AWAITING_INPUT - ] - question_text = next((prompt for prompt in prompts if prompt), "a question") - return ChatNotice( - level=PresentationNoticeLevel.WARNING, - text=( - f'Waiting on your input: "{question_text}" ' - "Re-run with your answer in the request to continue." - ), - ) - - -def _build_chat_turn_presentation( - result: OrchestrationResult, - *, - policy: ChatSurfacePolicy, - interactive: bool, -) -> ChatTurnPresentation: - primary_text = result.assistant_message.content.strip() - notices: list[ChatNotice] = [] - explanation_messages = [ - str(item.artifact.get("message", "")).strip() - for item in result.execution_results - if item.artifact_type is ExecutionArtifactType.EXPLANATION and item.artifact is not None - ] - seen_messages = {primary_text} - for message in explanation_messages: - if message and message not in seen_messages: - notices.append( - ChatNotice( - level=PresentationNoticeLevel.DIM, - text=message, - ) - ) - seen_messages.add(message) - - summary_text = result.summary.text.strip() - if (result.plan.actions or result.execution_results) and summary_text not in seen_messages: - notices.insert( - 0, - ChatNotice( - level=_notice_level_for_result(result), - text=summary_text, - ), - ) - seen_messages.add(summary_text) - - for iteration_notice in ( - _iteration_changed_files_notice(result), - _iteration_commands_notice(result), - _verification_outcome_notice(result), - _approval_required_notice(result), - _awaiting_input_notice(result), - ): - if iteration_notice is not None and iteration_notice.text not in seen_messages: - notices.append(iteration_notice) - seen_messages.add(iteration_notice.text) - - # One-shot/non-TTY runs can't prompt for a gap-handoff choice, so surface the - # options and the report link inline. Interactive runs handle this via a prompt. - if result.gap_handoff is not None and not interactive: - handoff = result.gap_handoff - option_lines = "\n".join( - f" {index}. {option.label}" for index, option in enumerate(handoff.options, start=1) - ) - gap_text = ( - f"What you can do:\n{option_lines}\n" - f"To report it so it can be fixed, file: {build_issue_url(handoff.report)}" - ) - if gap_text not in seen_messages: - notices.append(ChatNotice(level=PresentationNoticeLevel.WARNING, text=gap_text)) - seen_messages.add(gap_text) - - for execution_result in result.execution_results: - artifact_notice = _artifact_preview_notice(execution_result) - if artifact_notice is not None and artifact_notice.text not in seen_messages: - notices.append(artifact_notice) - seen_messages.add(artifact_notice.text) - - audit_ref = None - if policy.show_audit_refs and _has_hidden_chat_detail(result): - audit_ref = _detail_ref_for_result(result) - if audit_ref is not None: - hint = ( - f"Session {audit_ref.session_id[:8]} saved. Use " - f"{InteractiveDetailCommand.PLAN.value}, " - f"{InteractiveDetailCommand.ACTIONS.value}, or " - f"{InteractiveDetailCommand.SUMMARY.value} for detail." - if interactive - else ( - f"Session {audit_ref.session_id[:8]} saved. Re-run with `--render verbose` " - f"or inspect with `{audit_ref.trace_hint}`." - ) - ) - notices.append( - ChatNotice( - level=PresentationNoticeLevel.DIM, - text=hint, - ) - ) - - return ChatTurnPresentation( - primary_text=primary_text, - notices=notices, - audit_ref=audit_ref, - ) - - -def _render_action_plan(result: OrchestrationResult) -> None: - if not result.plan.actions: - console.print(Text("No actions planned.", style="dim")) - return - - decisions = {decision.action_id: decision for decision in result.policy_decisions} - evaluations = {record.action_id: record for record in result.policy_evaluations} - table = Table(title="Planned Actions") - table.add_column("Id", style="cyan") - table.add_column("Kind") - table.add_column("Summary") - table.add_column("Policy") - for action in result.plan.actions: - decision = decisions.get(action.id) - evaluation = evaluations.get(action.id) - policy_text = "-" - if evaluation is not None: - policy_text = evaluation.verdict.outcome.value - elif decision is not None: - policy_text = decision.decision.value - table.add_row(action.id, action.kind.value, action.summary, policy_text) - console.print(table) - - -def _render_chat_execution_result( - result: ExecutionResult, - *, - streamed_shell_output: bool = False, -) -> None: - status_styles = { - "executed": "green", - "not_executed": "yellow", - "pending_approval": "yellow", - "blocked": "red", - "failed": "red", - } - style = status_styles[result.status.value] - console.print( - Panel.fit( - f"[{style}]{result.status.value}[/{style}]\n{escape(result.summary)}", - title=f"Action {result.action_id}", - ) - ) - if result.artifact_type is None or result.artifact is None: - return - - if result.artifact_type is ExecutionArtifactType.SHELL: - shell_result = ShellCommandResult.model_validate(result.artifact) - _render_result_output( - shell_result, - streamed=(streamed_shell_output and shell_result.mode is not ExecutionMode.BUFFERED), - ) - _render_execution_summary(shell_result) - return - if result.artifact_type is ExecutionArtifactType.SEARCH: - _render_search_result(SearchResult.model_validate(result.artifact)) - return - if result.artifact_type is ExecutionArtifactType.FILES: - _render_file_result(FileDiscoveryResult.model_validate(result.artifact)) - return - if result.artifact_type is ExecutionArtifactType.GIT: - _render_git_context(GitContextResult.model_validate(result.artifact)) - return - if result.artifact_type in {ExecutionArtifactType.MAN, ExecutionArtifactType.TLDR}: - _render_help_lookup(HelpLookupResult.model_validate(result.artifact)) - return - if result.artifact_type is ExecutionArtifactType.EXPLANATION: - message = result.artifact.get("message", "") - console.print(Text(str(message), style="dim")) - - -def _render_chat_execution_details( - result: OrchestrationResult, - *, - streamed_shell_output: bool, -) -> None: - for execution_result in result.execution_results: - _render_chat_execution_result( - execution_result, - streamed_shell_output=streamed_shell_output, - ) - - -def _render_orchestration_summary(result: OrchestrationResult) -> None: - usage = result.planning_metadata.usage - usage_text = "unknown" - if usage is not None and usage.total_tokens is not None: - usage_text = str(usage.total_tokens) - session_text = result.session_id or "not recorded" - console.print( - Panel.fit( - f"{result.summary.text}\n\n" - f"Session: [cyan]{session_text}[/cyan]\n" - f"Provider: [cyan]{result.planning_metadata.provider}[/cyan]\n" - f"Model: [cyan]{result.planning_metadata.model}[/cyan]\n" - f"Latency: [cyan]{result.planning_metadata.latency_seconds:.2f}s[/cyan]\n" - f"Attempts: [cyan]{result.planning_metadata.attempts}[/cyan]\n" - f"Total tokens: [cyan]{usage_text}[/cyan]", - title="Orchestration Summary", - ) - ) - - -def _render_concise_chat_turn( - result: OrchestrationResult, - *, - policy: ChatSurfacePolicy, - interactive: bool, -) -> None: - presentation = _build_chat_turn_presentation( - result, - policy=policy, - interactive=interactive, - ) - console.print(presentation.primary_text) - style_map = { - PresentationNoticeLevel.INFO: "cyan", - PresentationNoticeLevel.WARNING: "yellow", - PresentationNoticeLevel.ERROR: "bold red", - PresentationNoticeLevel.DIM: "dim", - } - for notice in presentation.notices: - console.print(Text(notice.text, style=style_map[notice.level])) - - -def _render_verbose_chat_turn( - result: OrchestrationResult, - *, - streamed_shell_output: bool, -) -> None: - _render_assistant_message(result) - _render_action_plan(result) - _render_chat_execution_details( - result, - streamed_shell_output=streamed_shell_output, - ) - _render_orchestration_summary(result) - - -def _render_chat_turn( - result: OrchestrationResult, - *, - render_mode: RenderMode, - interactive: bool = False, - streamed_shell_output: bool = False, -) -> None: - if render_mode is RenderMode.VERBOSE: - _render_verbose_chat_turn( - result, - streamed_shell_output=streamed_shell_output, - ) - return - _render_concise_chat_turn( - result, - policy=_chat_surface_policy(render_mode), - interactive=interactive, - ) - - -def _render_interactive_detail( - result: OrchestrationResult, - command: InteractiveDetailCommand, -) -> None: - if command is InteractiveDetailCommand.PLAN: - _render_action_plan(result) - return - if command is InteractiveDetailCommand.ACTIONS: - if not result.execution_results: - console.print( - Text( - "No execution details were recorded for the last request.", - style="dim", - ) - ) - return - _render_chat_execution_details(result, streamed_shell_output=False) - return - _render_orchestration_summary(result) - - -def _render_history_list(sessions: list[HistorySessionSummary]) -> None: - if not sessions: - console.print("No history recorded yet.") - return - - table = Table(title="Session History") - table.add_column("Session", style="cyan") - table.add_column("Kind") - table.add_column("Status") - table.add_column("Started") - table.add_column("Request") - for session in sessions: - request_text = session.request_text or session.command_preview or "-" - table.add_row( - session.session_id, - session.kind.value, - session.status.value, - session.started_at, - request_text, - ) - console.print(table) - - -def _render_history_detail(session: HistorySessionDetail) -> None: - console.print( - Panel.fit( - f"Session: [cyan]{session.session_id}[/cyan]\n" - f"Kind: [cyan]{session.kind.value}[/cyan]\n" - f"Status: [cyan]{session.status.value}[/cyan]\n" - f"Started: [cyan]{session.started_at}[/cyan]\n" - f"Request cwd: [cyan]{session.request_cwd}[/cyan]\n" - f"Approval mode: [cyan]{session.approval_mode}[/cyan]", - title="History Detail", - ) - ) - if session.request_text: - console.print(Panel.fit(session.request_text, title="User Request")) - elif session.command_preview: - console.print(Panel.fit(session.command_preview, title="Command")) - - if session.assistant_message: - console.print(Panel.fit(session.assistant_message, title="Assistant")) - - if session.summary_text: - console.print( - Panel.fit( - f"{session.summary_text}\n\n" - f"Executed: [cyan]{session.executed_actions}[/cyan]\n" - f"Pending approval: [cyan]{session.pending_approval_actions}[/cyan]\n" - f"Blocked: [cyan]{session.blocked_actions}[/cyan]\n" - f"Failed: [cyan]{session.failed_actions}[/cyan]\n" - f"Skipped: [cyan]{session.skipped_actions}[/cyan]", - title="Summary", - ) - ) - - if session.approvals: - table = Table(title="Approvals") - table.add_column("Action", style="cyan") - table.add_column("Capability") - table.add_column("Status") - table.add_column("Mode") - table.add_column("Reason") - for approval in session.approvals: - table.add_row( - approval.action_id or "-", - approval.capability_id or "-", - approval.status.value, - approval.mode, - approval.reason, - ) - console.print(table) - - if session.policy_evaluations: - table = Table(title="Policy Evaluations") - table.add_column("Action", style="cyan") - table.add_column("Capability") - table.add_column("Outcome") - table.add_column("Reasons") - for record in session.policy_evaluations: - reasons = ", ".join(code.value for code in record.verdict.reason_codes) or "-" - table.add_row( - record.action_id, - record.capability_id, - record.verdict.outcome.value, - reasons, - ) - console.print(table) - - if session.tool_calls: - table = Table(title="Tool Calls") - table.add_column("Action", style="cyan") - table.add_column("Tool") - table.add_column("Status") - table.add_column("Policy") - for tool_call in session.tool_calls: - table.add_row( - tool_call.action_id, - tool_call.tool, - tool_call.execution_status, - tool_call.policy_decision or "-", - ) - console.print(table) - - if session.commands: - table = Table(title="Commands") - table.add_column("Action", style="cyan") - table.add_column("Command") - table.add_column("Status") - table.add_column("Exit", justify="right") - for command in session.commands: - table.add_row( - command.action_id or "-", - shlex.join([command.command, *command.args]), - command.execution_status, - "-" if command.exit_code is None else str(command.exit_code), - ) - console.print(table) - - -def _render_trace_list(traces: list[TraceSummary]) -> None: - if not traces: - console.print("No traces recorded yet.") - return - - table = Table(title="Trace History") - table.add_column("Trace", style="cyan") - table.add_column("Status") - table.add_column("Started") - table.add_column("Steps", justify="right") - table.add_column("Capabilities") - table.add_column("Request") - for trace in traces: - capabilities = ", ".join(trace.selected_capability_ids) or "-" - table.add_row( - trace.trace_id, - trace.status.value, - trace.started_at, - str(trace.step_count), - capabilities, - trace.request_text or "-", - ) - console.print(table) - - -def _render_trace_detail(trace: TraceRecord) -> None: - console.print( - Panel.fit( - f"Trace: [cyan]{trace.trace_id}[/cyan]\n" - f"Status: [cyan]{trace.status.value}[/cyan]\n" - f"Started: [cyan]{trace.started_at}[/cyan]\n" - f"Completed: [cyan]{trace.completed_at or '-'}[/cyan]\n" - f"Steps: [cyan]{trace.summary.step_count}[/cyan]", - title="Trace Detail", - ) - ) - if trace.request_text: - console.print(Panel.fit(trace.request_text, title="Request")) - - summary = trace.summary - console.print( - Panel.fit( - f"Executed: [cyan]{summary.executed_steps}[/cyan]\n" - f"Pending approval: [cyan]{summary.pending_approval_steps}[/cyan]\n" - f"Blocked: [cyan]{summary.blocked_steps}[/cyan]\n" - f"Failed: [cyan]{summary.failed_steps}[/cyan]\n" - f"Skipped: [cyan]{summary.skipped_steps}[/cyan]", - title="Trace Summary", - ) - ) - - if trace.edges: - edge_table = Table(title="Trace Edges") - edge_table.add_column("Source", style="cyan") - edge_table.add_column("Target") - edge_table.add_column("Kind") - for edge in trace.edges: - edge_table.add_row(edge.source_step_id, edge.target_step_id, edge.edge_kind.value) - console.print(edge_table) - - if trace.steps: - step_table = Table(title="Trace Steps") - step_table.add_column("Step", style="cyan") - step_table.add_column("Type") - step_table.add_column("Status") - step_table.add_column("Capability") - step_table.add_column("Why") - for step in trace.steps: - if isinstance(step, PlanningStep): - status = "planned" - capability = "-" - why = ", ".join(reason.summary for reason in step.selection_reasons) or "-" - else: - status = step.status.value - capability = step.capability_id or "-" - why = step.selection_reason.detail or step.selection_reason.summary - step_table.add_row( - step.step_id, - step.step_type.value, - status, - capability, - why, - ) - console.print(step_table) - - -def _render_audit_report(report: AuditReport) -> None: - status_text = "pass" if report.completeness_passed else "fail" - console.print( - Panel.fit( - f"Trace: [cyan]{report.trace_summary.trace_id}[/cyan]\n" - f"Completeness: [cyan]{status_text}[/cyan]\n" - f"Inspected step: [cyan]{report.inspected_step_id or 'all'}[/cyan]", - title="Audit Report", - ) - ) - if report.notes: - console.print(Panel.fit("\n".join(report.notes), title="Notes")) - if report.missing_fields_by_step: - table = Table(title="Missing Fields") - table.add_column("Step", style="cyan") - table.add_column("Fields") - for step_id, fields in report.missing_fields_by_step.items(): - table.add_row(step_id, ", ".join(fields)) - console.print(table) - _render_trace_detail( - TraceRecord( - trace_id=report.trace_summary.trace_id, - session_id=report.trace_summary.session_id, - request_text=report.trace_summary.request_text, - status=report.trace_summary.status, - started_at=report.trace_summary.started_at, - completed_at=report.trace_summary.completed_at, - steps=report.steps, - edges=report.edges, - summary=report.trace_summary, - ) - ) - - -def _resolve_cli_request_cwd(workspace_root: Path, cwd: Path | None) -> Path: - if cwd is None: - return workspace_root.resolve() - candidate = cwd if cwd.is_absolute() else workspace_root / cwd - return candidate.resolve() - - -def _record_run_history( - history_store: HistoryStore, - session_id: str, - *, - request: ShellCommandRequest, - request_cwd: Path, - result: ShellCommandResult | None, - error: str | None, - status: SessionStatus, -) -> None: - execution_status = "executed" if result is not None and result.ok else "failed" - history_store.record_command( - session_id, - action_id=None, - source="cli.run", - command=request.command, - args=list(request.args), - cwd=str(result.cwd if result is not None else request_cwd), - mode=result.mode.value if result is not None else request.mode.value, - policy_decision="allow", - policy_reason="The user invoked foundation run directly.", - risk_categories=[], - execution_status=execution_status, - exit_code=None if result is None else result.exit_code, - duration_seconds=None if result is None else result.duration_seconds, - stdout="" if result is None else result.stdout, - stderr="" if result is None else result.stderr, - stdout_truncated=False if result is None else result.stdout_truncated, - stderr_truncated=False if result is None else result.stderr_truncated, - error=error, - ) - - summary_text = ( - f"Executed shell command `{result.display_command}`." - if result is not None and result.ok - else error or f"Shell command `{shlex.join(request.argv)}` failed." - ) - history_store.record_summary( - session_id, - assistant_message=None, - summary_text=summary_text, - executed_actions=1 if result is not None and result.ok else 0, - pending_approval_actions=0, - blocked_actions=0, - failed_actions=0 if result is not None and result.ok else 1, - skipped_actions=0, - ) - history_store.finalize_session(session_id, status=status) - - @app.callback() def callback( ctx: typer.Context, diff --git a/src/foundation/cli_interactive.py b/src/foundation/cli_interactive.py new file mode 100644 index 0000000..c99ae7a --- /dev/null +++ b/src/foundation/cli_interactive.py @@ -0,0 +1,1223 @@ +"""Interactive chat session loop for Foundation CLI.""" + +from __future__ import annotations + +import shlex +import uuid +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +import typer +from rich.markup import escape +from rich.panel import Panel +from rich.table import Table +from rich.text import Text + +from foundation.cli_rendering import ( + _emit_output_event, + _preview_transcript_text, + _render_availability, + _render_chat_turn, + _render_execution_summary, + _render_history_list, + _render_interactive_detail, + console, +) +from foundation.cli_runtime import ( + _build_capability_registry, + _build_history_store, + _build_session_manager, + _build_shell_runtime, + _build_tool_service, + _execute_chat_request, + _handle_gap_handoff, + _prompt_for_approval, + _resolve_cli_request_cwd, +) +from foundation.models import ( + ActionKind, + ApprovalDecisionStatus, + BrainSession, + InteractiveDetailCommand, + MemoryEnvelope, + MemoryLayer, + MemorySource, + OrchestrationResult, + PlannedAction, + PolicyDecision, + PolicyDecisionType, + ProviderMessage, + RenderMode, + ResumeTarget, + SessionKind, + SessionSnapshot, + SessionStatus, + ShellAction, + ShellActionMode, +) +from foundation.services import ( + ApprovalService, + ExecutionMode, + GuardrailPolicyEngine, + HistoryStore, + OrchestrationError, + OrchestrationPlanError, + ProviderError, + SessionManager, + ShellCommandRequest, + ShellCommandResult, + ShellExecutionCancelled, + ShellExecutionSpawnError, + ShellExecutionTimeout, + ShellRuntime, +) +from foundation.settings import ApprovalMode, AppSettings, render_settings_payload + +_REPL_DEFAULT_HISTORY_LIMIT = 10 + + +_REPL_HISTORY_FILENAME = "repl-history.txt" + + +_REPL_SHELL_PREFIX = "!" + + +_REPL_COMMAND_COMPLETIONS: dict[str, Any] = { + "/actions": None, + "/approval": { + "auto": None, + "manual": None, + "prompt": None, + }, + "/clear": None, + "/compact": None, + "/config": { + "locations": None, + }, + "/cwd": None, + "/exit": None, + "/help": None, + "/history": None, + "/memory": { + "append": { + "global": None, + "project": None, + }, + "set": { + "global": None, + "project": None, + }, + "show": { + "global": None, + "project": None, + "session": None, + "summary": None, + "turns": None, + }, + }, + "/model": None, + "/plan": None, + "/quit": None, + "/reset": None, + "/resume": None, + "/sessions": None, + "/summary": None, + "/tools": None, +} + + +@dataclass(slots=True) +class InteractiveChatState: + """Mutable interactive state backed by a persistent brain session.""" + + session: BrainSession + last_result: OrchestrationResult | None = None + + @property + def session_id(self) -> str: + """Return the stable session id.""" + return self.session.session_id + + @property + def initial_cwd(self) -> Path: + """Return the initial cwd recorded for this session.""" + return Path(self.session.initial_cwd) + + @property + def current_cwd(self) -> Path: + """Return the active cwd for this interactive shell.""" + return Path(self.session.current_cwd) + + @current_cwd.setter + def current_cwd(self, value: Path) -> None: + self.session.current_cwd = str(value) + + @property + def approval_mode(self) -> ApprovalMode: + """Return the current approval mode.""" + return ApprovalMode(self.session.approval_mode) + + @approval_mode.setter + def approval_mode(self, value: ApprovalMode) -> None: + self.session.approval_mode = value.value + + @property + def model(self) -> str: + """Return the persisted model override for this session.""" + return self.session.model + + @model.setter + def model(self, value: str) -> None: + self.session.model = value + + @property + def provider_name(self) -> str: + """Return the provider recorded for this session.""" + return self.session.provider_name + + @property + def transcript(self) -> list[ProviderMessage]: + """Return the bounded recent turn window.""" + return self.session.recent_turns + + @transcript.setter + def transcript(self, value: list[ProviderMessage]) -> None: + self.session.recent_turns = value + + @property + def summary_text(self) -> str: + """Return the compacted summary for older turns.""" + return self.session.summary_text + + @property + def recovered_from_interruption(self) -> bool: + """Return whether resume restored the last clean checkpoint.""" + return self.session.recovered_from_interruption + + @property + def interrupted_turn(self) -> str | None: + """Return the interrupted user input when the prior run did not finish cleanly.""" + return self.session.interrupted_turn + + +def _chat_history_path(settings: AppSettings) -> Path: + history_path = settings.app.state_dir / _REPL_HISTORY_FILENAME + history_path.parent.mkdir(parents=True, exist_ok=True) + return history_path + + +def _settings_for_interactive_session( + settings: AppSettings, + state: InteractiveChatState, +) -> AppSettings: + runtime_settings = settings.model_copy(deep=True) + runtime_settings.provider.name = state.provider_name + runtime_settings.provider.model = state.model + return runtime_settings + + +def _build_chat_prompt_session(settings: AppSettings) -> Any: + try: + from prompt_toolkit import PromptSession + from prompt_toolkit.auto_suggest import AutoSuggestFromHistory + from prompt_toolkit.completion import NestedCompleter + from prompt_toolkit.history import FileHistory + from prompt_toolkit.key_binding import KeyBindings + except ImportError as exc: + raise RuntimeError( + "Interactive chat requires prompt_toolkit. Reinstall dependencies with " + "`./scripts/uv sync --extra dev`." + ) from exc + + bindings = KeyBindings() + + @bindings.add("enter") + def _submit_input(event: Any) -> None: + event.current_buffer.validate_and_handle() + + @bindings.add("escape", "enter") + def _insert_multiline_newline(event: Any) -> None: + event.current_buffer.insert_text("\n") + + return PromptSession( + history=FileHistory(str(_chat_history_path(settings))), + auto_suggest=AutoSuggestFromHistory(), + completer=NestedCompleter.from_nested_dict(_REPL_COMMAND_COMPLETIONS), + complete_while_typing=True, + key_bindings=bindings, + multiline=True, + reserve_space_for_menu=6, + ) + + +def _resolve_chat_session_cwd(workspace_root: Path, cwd: Path | None) -> Path: + resolved_workspace_root = workspace_root.resolve() + resolved = _resolve_cli_request_cwd(resolved_workspace_root, cwd) + try: + resolved.relative_to(resolved_workspace_root) + except ValueError as exc: + raise ValueError( + "Interactive chat cwd must stay within the configured workspace root." + ) from exc + if not resolved.exists(): + raise ValueError(f"Interactive chat cwd does not exist: {resolved}") + if not resolved.is_dir(): + raise ValueError(f"Interactive chat cwd is not a directory: {resolved}") + return resolved + + +def _format_repl_cwd(workspace_root: Path, cwd: Path) -> str: + try: + relative = cwd.relative_to(workspace_root) + except ValueError: + return str(cwd) + return "." if str(relative) == "." else str(relative) + + +def _chat_prompt(state: InteractiveChatState, *, settings: AppSettings, plan_only: bool) -> str: + mode_suffix = " plan-only" if plan_only else "" + session_id = state.session_id[:8] + return ( + f"foundation[{session_id} {_format_repl_cwd(settings.workspace_root, state.current_cwd)} " + f"{state.approval_mode.value} {state.model}{mode_suffix}]> " + ) + + +def _render_interactive_chat_help() -> None: + help_text = ( + "Natural-language request: send it directly\n" + f"Direct shell command: prefix with `{_REPL_SHELL_PREFIX}`\n" + "Submit input: press Enter\n" + "Insert newline: press Esc then Enter\n" + "/help: show this help\n" + "/history [limit]: list recent persisted sessions\n" + "/sessions [limit]: list persistent chat sessions\n" + "/resume [session-id|latest]: switch to another persistent session\n" + "/plan: inspect the last structured plan\n" + "/actions: inspect the last action results\n" + "/summary: inspect the last orchestration summary\n" + "/memory: show loaded memory layers\n" + "/memory show [global|project|session|summary|turns]: inspect one memory source\n" + "/memory append [global|project] : append to a memory file\n" + "/memory set [global|project] : replace a memory file\n" + "/compact: compact older turns into the session summary now\n" + "/model [name]: show or change the session model\n" + "/tools: show current local tool availability\n" + "/config [locations]: inspect effective config or paths\n" + "/cwd [path]: show or update the interactive working directory\n" + "/approval [auto|manual|prompt]: inspect or change session approval mode\n" + "/clear: clear the terminal and redraw the session header\n" + "/reset: reset session-local cwd, approval mode, model, and transcript state\n" + "/exit or /quit: leave interactive chat" + ) + console.print( + Panel.fit( + Text(help_text), + title="Interactive Chat Help", + ) + ) + + +def _memory_source_label(source: MemorySource) -> str: + labels = { + MemorySource.GLOBAL: "Global user memory", + MemorySource.PROJECT: "Project memory", + MemorySource.SESSION_SUMMARY: "Session summary", + MemorySource.RECENT_TURNS: "Recent turns", + } + return labels[source] + + +def _loaded_memory_labels(envelope: MemoryEnvelope) -> list[str]: + return [ + layer.label + for layer in envelope.layers + if layer.content.strip() or (layer.path is not None and layer.exists) + ] + + +def _render_interactive_chat_welcome( + settings: AppSettings, + state: InteractiveChatState, + *, + plan_only: bool, + render_mode: RenderMode, + memory_envelope: MemoryEnvelope, +) -> None: + plan_only_text = "enabled" if plan_only else "disabled" + loaded_memories = ", ".join(_loaded_memory_labels(memory_envelope)) or "none" + console.print( + Panel.fit( + f"Session: [cyan]{escape(state.session_id)}[/cyan]\n" + f"Workspace root: [cyan]{escape(str(settings.workspace_root))}[/cyan]\n" + f"Request cwd: [cyan]{escape(str(state.current_cwd))}[/cyan]\n" + f"Approval mode: [cyan]{state.approval_mode.value}[/cyan]\n" + f"Provider: [cyan]{escape(state.provider_name)}[/cyan]\n" + f"Model: [cyan]{escape(state.model)}[/cyan]\n" + f"Render mode: [cyan]{render_mode.value}[/cyan]\n" + f"Plan-only default: [cyan]{plan_only_text}[/cyan]\n" + f"Loaded memory: [cyan]{escape(loaded_memories)}[/cyan]\n" + f"Prompt history: [cyan]{escape(str(_chat_history_path(settings)))}[/cyan]\n\n" + "Enter a request to use the planner, prefix with `!` for a direct shell command, " + "or use `/plan`, `/actions`, `/summary`, or `/help` for session commands.", + title="Interactive Chat", + ) + ) + if state.recovered_from_interruption: + interrupted_turn = state.interrupted_turn or "unknown input" + console.print( + Text( + ( + "Recovered the last clean checkpoint after an interrupted turn: " + f"{interrupted_turn}" + ), + style="yellow", + ) + ) + + +def _render_memory_envelope(envelope: MemoryEnvelope) -> None: + table = Table(title="Session Memory") + table.add_column("Source", style="cyan") + table.add_column("Location") + table.add_column("Loaded", justify="center") + for layer in envelope.layers: + table.add_row( + layer.label, + layer.path or "-", + "yes" if layer.content.strip() else "no", + ) + console.print(table) + + +def _render_memory_layer(layer: MemoryLayer) -> None: + title = layer.label if layer.path is None else f"{layer.label}: {layer.path}" + content = layer.content or "[empty]" + console.print(Panel.fit(content, title=title)) + + +def _memory_layer_for_source(envelope: MemoryEnvelope, source: MemorySource) -> MemoryLayer: + for layer in envelope.layers: + if layer.source is source: + return layer + raise ValueError(f"Memory source {source.value!r} is not available.") + + +def _render_brain_sessions(sessions: list[SessionSnapshot]) -> None: + if not sessions: + console.print("No persistent chat sessions recorded yet.") + return + table = Table(title="Chat Sessions") + table.add_column("Session", style="cyan") + table.add_column("Updated") + table.add_column("Turns", justify="right") + table.add_column("Cwd") + table.add_column("Model") + table.add_column("State") + for session in sessions: + state = "interrupted" if session.recovered_from_interruption else "ready" + table.add_row( + session.session_id, + session.updated_at, + str(session.turn_count), + session.current_cwd, + session.model, + state, + ) + console.print(table) + + +def _parse_memory_source(argument: str) -> MemorySource: + normalized = argument.strip().lower() + aliases = { + "global": MemorySource.GLOBAL, + "project": MemorySource.PROJECT, + "session": MemorySource.SESSION_SUMMARY, + "summary": MemorySource.SESSION_SUMMARY, + "turns": MemorySource.RECENT_TURNS, + } + try: + return aliases[normalized] + except KeyError as exc: + choices = ", ".join(sorted(aliases)) + raise ValueError(f"Expected one of: {choices}.") from exc + + +def _record_repl_shell_session( + history_store: HistoryStore, + session_id: str, + *, + request: ShellCommandRequest, + request_cwd: Path, + decision: PolicyDecision, + result: ShellCommandResult | None, + execution_status: str, + summary_text: str, + error: str | None, +) -> None: + history_store.record_command( + session_id, + action_id=None, + source="chat.repl", + command=request.command, + args=list(request.args), + cwd=str(result.cwd if result is not None else request_cwd), + mode=result.mode.value if result is not None else request.mode.value, + policy_decision=decision.decision.value, + policy_reason=decision.reason, + risk_categories=list(decision.risk_categories), + execution_status=execution_status, + exit_code=None if result is None else result.exit_code, + duration_seconds=None if result is None else result.duration_seconds, + stdout="" if result is None else result.stdout, + stderr="" if result is None else result.stderr, + stdout_truncated=False if result is None else result.stdout_truncated, + stderr_truncated=False if result is None else result.stderr_truncated, + error=error, + ) + history_store.record_summary( + session_id, + assistant_message=None, + summary_text=summary_text, + executed_actions=1 if execution_status == "executed" else 0, + pending_approval_actions=1 if execution_status == "pending_approval" else 0, + blocked_actions=1 if execution_status == "blocked" else 0, + failed_actions=1 if execution_status == "failed" else 0, + skipped_actions=0, + ) + history_store.finalize_session( + session_id, + status=( + SessionStatus.FAILED + if execution_status == "failed" + else ( + SessionStatus.PENDING_APPROVAL + if execution_status == "pending_approval" + else SessionStatus.COMPLETED + ) + ), + ) + + +def _execute_repl_shell_command( + *, + raw_command: str, + settings: AppSettings, + state: InteractiveChatState, + session_manager: SessionManager, + history_store: HistoryStore, + shell_runtime: ShellRuntime, + policy_engine: GuardrailPolicyEngine, +) -> None: + request_id = f"req-{uuid.uuid4().hex}" + + def _append_shell_transcript( + *, + summary_text: str, + execution_status: str, + result: ShellCommandResult | None = None, + error: str | None = None, + ) -> None: + lines = [ + f"Direct shell command: `{shlex.join(request.argv)}`", + f"Status: {execution_status}", + f"Outcome: {summary_text}", + ] + if result is not None: + stdout_preview = _preview_transcript_text(result.stdout) + stderr_preview = _preview_transcript_text(result.stderr) + if stdout_preview: + lines.append(f"stdout:\n{stdout_preview}") + if stderr_preview: + lines.append(f"stderr:\n{stderr_preview}") + elif error: + lines.append(f"Error: {error}") + session_manager.record_turn( + state.session, + turn_kind="shell", + user_message=f"{_REPL_SHELL_PREFIX}{raw_command}", + assistant_message="\n".join(lines), + metadata={"execution_status": execution_status}, + ) + + try: + argv = shlex.split(raw_command) + except ValueError as exc: + console.print(f"[bold red]Shell parse error:[/bold red] {exc}") + return + if not argv: + console.print(Text("No shell command was supplied after '!'.", style="dim")) + return + + session_manager.mark_turn_started( + state.session, + user_message=f"{_REPL_SHELL_PREFIX}{raw_command}", + turn_kind="shell", + ) + + request_cwd = state.current_cwd + request = ShellCommandRequest( + command=argv[0], + args=argv[1:], + cwd=request_cwd, + approval_context={"source": "chat.repl", "request_id": request_id}, + mode=ExecutionMode.STREAM, + ) + action = PlannedAction( + id="repl_shell", + kind=ActionKind.SHELL, + summary=f"Run `{shlex.join(request.argv)}` from the interactive session.", + shell=ShellAction( + command=request.command, + args=request.args, + cwd=str(request_cwd), + mode=ShellActionMode.STREAM, + ), + ) + evaluation = policy_engine.evaluate( + action, + request_cwd=request_cwd, + approval_mode=state.approval_mode, + ) + decision = ( + policy_engine.to_policy_decision(evaluation) + if evaluation is not None + else PolicyDecision( + action_id=action.id, + decision=PolicyDecisionType.ALLOW, + reason="Explanation-only actions do not execute anything.", + ) + ) + session_id = history_store.start_session( + kind=SessionKind.RUN, + workspace_root=settings.workspace_root, + request_cwd=request_cwd, + approval_mode=state.approval_mode.value, + command_preview=shlex.join(request.argv), + ) + if evaluation is not None: + history_store.record_policy_evaluation(session_id, record=evaluation) + + if decision.decision is PolicyDecisionType.BLOCK: + console.print(f"[bold red]Execution blocked:[/bold red] {decision.reason}") + _record_repl_shell_session( + history_store, + session_id, + request=request, + request_cwd=request_cwd, + decision=decision, + result=None, + execution_status="blocked", + summary_text=decision.reason, + error=decision.reason, + ) + _append_shell_transcript( + summary_text=decision.reason, + execution_status="blocked", + error=decision.reason, + ) + return + + if decision.decision is PolicyDecisionType.REQUIRE_APPROVAL: + if evaluation is None: + console.print("[bold red]Execution blocked:[/bold red] Missing policy evaluation.") + return + approval_request, approval_resolution = ApprovalService( + mode=state.approval_mode, + prompt_callback=_prompt_for_approval, + ).resolve( + action, + evaluation, + request_cwd=request_cwd, + ) + history_store.record_approval( + session_id, + request=approval_request, + resolution=approval_resolution, + ) + if approval_resolution.status is ApprovalDecisionStatus.PENDING: + console.print( + f"[bold yellow]Approval pending:[/bold yellow] {approval_resolution.reason}" + ) + _record_repl_shell_session( + history_store, + session_id, + request=request, + request_cwd=request_cwd, + decision=decision, + result=None, + execution_status="pending_approval", + summary_text=approval_resolution.reason, + error=None, + ) + _append_shell_transcript( + summary_text=approval_resolution.reason, + execution_status="pending_approval", + ) + return + if approval_resolution.status is ApprovalDecisionStatus.DENIED: + console.print(f"[bold red]Execution blocked:[/bold red] {approval_resolution.reason}") + _record_repl_shell_session( + history_store, + session_id, + request=request, + request_cwd=request_cwd, + decision=decision, + result=None, + execution_status="blocked", + summary_text=approval_resolution.reason, + error=approval_resolution.reason, + ) + _append_shell_transcript( + summary_text=approval_resolution.reason, + execution_status="blocked", + error=approval_resolution.reason, + ) + return + + if evaluation is not None: + policy_engine.register_invocation(evaluation) + + effective_request = request + if evaluation is not None: + budget = ( + evaluation.verdict.constraints or evaluation.policy_input.constraints + ).invocation_budget + if budget is not None: + timeout_seconds = request.timeout_seconds + if budget.timeout_seconds is not None and timeout_seconds is not None: + timeout_seconds = min(timeout_seconds, budget.timeout_seconds) + effective_request = request.model_copy( + update={ + "timeout_seconds": timeout_seconds, + "capture_limit_kb": budget.output_limit_kb, + } + ) + + try: + result = shell_runtime.execute(effective_request, on_event=_emit_output_event) + except ValueError as exc: + console.print(f"[bold red]Execution error:[/bold red] {exc}") + _record_repl_shell_session( + history_store, + session_id, + request=request, + request_cwd=request_cwd, + decision=decision, + result=None, + execution_status="failed", + summary_text=f"Shell execution was rejected: {exc}", + error=str(exc), + ) + _append_shell_transcript( + summary_text=f"Shell execution was rejected: {exc}", + execution_status="failed", + error=str(exc), + ) + return + except ShellExecutionSpawnError as exc: + console.print(f"[bold red]Execution error:[/bold red] {exc}") + _record_repl_shell_session( + history_store, + session_id, + request=request, + request_cwd=request_cwd, + decision=decision, + result=None, + execution_status="failed", + summary_text=f"Shell execution failed to start: {exc}", + error=str(exc), + ) + _append_shell_transcript( + summary_text=f"Shell execution failed to start: {exc}", + execution_status="failed", + error=str(exc), + ) + return + except ShellExecutionTimeout as exc: + if exc.result is not None: + _render_execution_summary(exc.result) + console.print(f"[bold red]Execution error:[/bold red] {exc}") + _record_repl_shell_session( + history_store, + session_id, + request=request, + request_cwd=request_cwd, + decision=decision, + result=exc.result, + execution_status="failed", + summary_text=f"Shell execution timed out: {exc}", + error=str(exc), + ) + _append_shell_transcript( + summary_text=f"Shell execution timed out: {exc}", + execution_status="failed", + result=exc.result, + error=str(exc), + ) + return + except ShellExecutionCancelled as exc: + if exc.result is not None: + _render_execution_summary(exc.result) + console.print(f"[bold red]Execution cancelled:[/bold red] {exc}") + _record_repl_shell_session( + history_store, + session_id, + request=request, + request_cwd=request_cwd, + decision=decision, + result=exc.result, + execution_status="failed", + summary_text=f"Shell execution was cancelled: {exc}", + error=str(exc), + ) + _append_shell_transcript( + summary_text=f"Shell execution was cancelled: {exc}", + execution_status="failed", + result=exc.result, + error=str(exc), + ) + return + + _render_execution_summary(result) + execution_status = "executed" if result.ok else "failed" + summary_text = ( + f"Executed shell command `{result.display_command}`." + if result.ok + else result.stderr or f"Shell command `{result.display_command}` failed." + ) + _record_repl_shell_session( + history_store, + session_id, + request=request, + request_cwd=request_cwd, + decision=decision, + result=result, + execution_status=execution_status, + summary_text=summary_text, + error=None if result.ok else result.stderr or f"Exit code {result.exit_code}", + ) + _append_shell_transcript( + summary_text=summary_text, + execution_status=execution_status, + result=result, + error=None if result.ok else result.stderr or f"Exit code {result.exit_code}", + ) + + +def _build_transcript_assistant_message(result: OrchestrationResult) -> str: + lines = [result.assistant_message.content, f"Outcome: {result.summary.text}"] + for execution_result in result.execution_results: + lines.append( + f"{execution_result.action_id}: {execution_result.status.value} - " + f"{execution_result.summary}" + ) + return "\n".join(lines) + + +def _append_chat_transcript_turn( + session_manager: SessionManager, + state: InteractiveChatState, + *, + user_message: str, + result: OrchestrationResult, +) -> None: + session_manager.record_turn( + state.session, + turn_kind="chat", + user_message=user_message, + assistant_message=_build_transcript_assistant_message(result), + metadata={"history_session_id": result.session_id or ""}, + ) + + +@dataclass(slots=True) +class InteractiveChatRunner: + """Own the dependencies for one interactive chat loop.""" + + settings: AppSettings + initial_cwd: Path + plan_only: bool + render_mode: RenderMode + resume_target: ResumeTarget + disable_live_ux: bool = False + disable_monitor: bool = False + monitor_socket: str | None = None + monitor_http_port: int | None = None + + def run(self) -> None: + try: + prompt_session = _build_chat_prompt_session(self.settings) + except RuntimeError as exc: + console.print(f"[bold red]Interactive chat error:[/bold red] {exc}") + raise typer.Exit(code=1) from exc + + session_manager = _build_session_manager(self.settings) + try: + session = session_manager.resolve_session( + self.resume_target, + initial_cwd=self.initial_cwd, + approval_mode=self.settings.approval.mode.value, + model=self.settings.provider.model, + ) + except ValueError as exc: + console.print(f"[bold red]Interactive chat error:[/bold red] {exc}") + raise typer.Exit(code=2) from exc + + state = InteractiveChatState(session=session) + history_store = _build_history_store(self.settings) + shell_runtime = _build_shell_runtime(self.settings) + policy_engine = GuardrailPolicyEngine( + workspace_root=self.settings.workspace_root, + capability_registry=_build_capability_registry(self.settings), + ) + _render_interactive_chat_welcome( + self.settings, + state, + plan_only=self.plan_only, + render_mode=self.render_mode, + memory_envelope=session_manager.build_memory_envelope(state.session), + ) + + # When a capability-gap handoff offers a constrained retry and the user + # accepts, the chosen follow-up is queued here and run as the next turn + # without re-prompting. + pending_request: str | None = None + + while True: + if pending_request is not None: + raw_input = pending_request + pending_request = None + console.print(Text(f"↪ Continuing: {raw_input.splitlines()[0]}", style="dim")) + else: + try: + raw_input = prompt_session.prompt( + _chat_prompt(state, settings=self.settings, plan_only=self.plan_only) + ) + except KeyboardInterrupt: + console.print( + Text( + "Input cancelled. Use /exit or Ctrl-D to leave the session.", + style="dim", + ) + ) + continue + except EOFError: + console.print(Text("Interactive chat closed.", style="dim")) + return + + text = raw_input.strip() + if not text: + continue + command, _, argument = text.partition(" ") + argument = argument.strip() + + if command in {"/exit", "/quit"}: + console.print(Text("Interactive chat closed.", style="dim")) + return + if command == "/help": + _render_interactive_chat_help() + continue + if command == "/history": + try: + limit = _REPL_DEFAULT_HISTORY_LIMIT if not argument else int(argument) + except ValueError: + console.print("[bold red]History error:[/bold red] Expected an integer limit.") + continue + if limit <= 0: + console.print("[bold red]History error:[/bold red] Limit must be positive.") + continue + _render_history_list(history_store.list_sessions(limit=limit)) + continue + if command == "/sessions": + try: + limit = _REPL_DEFAULT_HISTORY_LIMIT if not argument else int(argument) + except ValueError: + console.print("[bold red]Session error:[/bold red] Expected an integer limit.") + continue + if limit <= 0: + console.print("[bold red]Session error:[/bold red] Limit must be positive.") + continue + _render_brain_sessions(session_manager.list_sessions(limit=limit)) + continue + if command == "/resume": + target = ( + ResumeTarget.latest() + if not argument or argument == "latest" + else ResumeTarget.explicit(argument) + ) + try: + state.session = session_manager.resolve_session( + target, + initial_cwd=self.initial_cwd, + approval_mode=self.settings.approval.mode.value, + model=self.settings.provider.model, + ) + except ValueError as exc: + console.print(f"[bold red]Resume error:[/bold red] {exc}") + continue + state.last_result = None + _render_interactive_chat_welcome( + self.settings, + state, + plan_only=self.plan_only, + render_mode=self.render_mode, + memory_envelope=session_manager.build_memory_envelope(state.session), + ) + continue + if command in { + InteractiveDetailCommand.PLAN.value, + InteractiveDetailCommand.ACTIONS.value, + InteractiveDetailCommand.SUMMARY.value, + }: + if state.last_result is None: + console.print( + Text( + "No assistant turn has completed in this session yet.", + style="dim", + ) + ) + continue + _render_interactive_detail(state.last_result, InteractiveDetailCommand(command)) + continue + if command == "/memory": + if not argument: + _render_memory_envelope(session_manager.build_memory_envelope(state.session)) + continue + subcommand, _, remainder = argument.partition(" ") + subcommand = subcommand.strip().lower() + remainder = remainder.strip() + if subcommand == "show": + if not remainder: + console.print( + "[bold red]Memory error:[/bold red] Expected a memory source to show." + ) + continue + try: + source = _parse_memory_source(remainder) + envelope = session_manager.build_memory_envelope(state.session) + _render_memory_layer(_memory_layer_for_source(envelope, source)) + except ValueError as exc: + console.print(f"[bold red]Memory error:[/bold red] {exc}") + continue + if subcommand in {"append", "set"}: + source_text, _, content = remainder.partition(" ") + if not source_text or not content.strip(): + console.print( + "[bold red]Memory error:[/bold red] Expected " + "`/memory append|set [global|project] `." + ) + continue + try: + layer = session_manager.write_memory( + _parse_memory_source(source_text), + content=content, + append=subcommand == "append", + ) + except ValueError as exc: + console.print(f"[bold red]Memory error:[/bold red] {exc}") + continue + console.print(Text(f"{layer.label} updated.", style="dim")) + continue + console.print( + "[bold red]Memory error:[/bold red] Use `/memory`, " + "`/memory show ...`, `/memory append ...`, or `/memory set ...`." + ) + continue + if command == "/compact": + changed = session_manager.compact_session(state.session, force=True) + if changed: + console.print(Text("Session memory compacted.", style="dim")) + else: + console.print(Text("No compaction was needed.", style="dim")) + continue + if command == "/model": + if not argument: + console.print(Text(f"Current model: {state.model}", style="dim")) + continue + state.model = argument + session_manager.checkpoint(state.session) + console.print(Text(f"Interactive model set to {state.model}.", style="dim")) + continue + if command == "/tools": + _render_availability(_build_tool_service(self.settings).availability_report()) + continue + if command == "/config": + if argument and argument != "locations": + console.print( + "[bold red]Config error:[/bold red] Use `/config` or `/config locations`." + ) + continue + payload = ( + self.settings.config_locations() + if argument == "locations" + else render_settings_payload(self.settings) + ) + console.print_json(data=payload) + continue + if command == "/cwd": + if not argument: + console.print(Text(f"Current cwd: {state.current_cwd}", style="dim")) + continue + try: + state.current_cwd = _resolve_chat_session_cwd( + self.settings.workspace_root, + Path(argument), + ) + except ValueError as exc: + console.print(f"[bold red]Cwd error:[/bold red] {exc}") + continue + session_manager.checkpoint(state.session) + console.print(Text(f"Interactive cwd set to {state.current_cwd}.", style="dim")) + continue + if command == "/approval": + argument = argument.lower() + if not argument: + console.print( + Text(f"Current approval mode: {state.approval_mode.value}", style="dim") + ) + continue + try: + state.approval_mode = ApprovalMode(argument) + except ValueError: + choices = ", ".join(mode.value for mode in ApprovalMode) + console.print( + f"[bold red]Approval error:[/bold red] Expected one of: {choices}." + ) + continue + session_manager.checkpoint(state.session) + console.print( + Text( + f"Interactive approval mode set to {state.approval_mode.value}.", + style="dim", + ) + ) + continue + if command == "/clear": + console.clear() + _render_interactive_chat_welcome( + self.settings, + state, + plan_only=self.plan_only, + render_mode=self.render_mode, + memory_envelope=session_manager.build_memory_envelope(state.session), + ) + continue + if command == "/reset": + session_manager.reset_session( + state.session, + current_cwd=state.initial_cwd, + approval_mode=self.settings.approval.mode.value, + model=self.settings.provider.model, + ) + state.last_result = None + console.print(Text("Session-local state reset.", style="dim")) + continue + if command.startswith("/"): + console.print(f"[bold yellow]Unknown command:[/bold yellow] {text}. Use `/help`.") + continue + if text.startswith(_REPL_SHELL_PREFIX): + _execute_repl_shell_command( + raw_command=text.removeprefix(_REPL_SHELL_PREFIX).strip(), + settings=self.settings, + state=state, + session_manager=session_manager, + history_store=history_store, + shell_runtime=shell_runtime, + policy_engine=policy_engine, + ) + continue + + session_manager.mark_turn_started( + state.session, + user_message=text, + turn_kind="chat", + ) + memory_envelope = session_manager.build_memory_envelope(state.session) + try: + result = _execute_chat_request( + _settings_for_interactive_session(self.settings, state), + message=text, + conversation_history=memory_envelope.prompt_messages, + cwd=state.current_cwd, + plan_only=self.plan_only, + approval_mode=state.approval_mode, + shell_output_callback=_emit_output_event, + disable_live_ux=self.disable_live_ux, + disable_monitor=self.disable_monitor, + monitor_socket=self.monitor_socket, + monitor_http_port=self.monitor_http_port, + ) + except ProviderError as exc: + console.print(f"[bold red]Provider error:[/bold red] {exc}") + session_manager.record_turn( + state.session, + turn_kind="chat", + user_message=text, + assistant_message=f"Provider error: {exc}", + metadata={"error_type": exc.__class__.__name__, "status": "failed"}, + ) + continue + except OrchestrationPlanError as exc: + console.print(f"[bold red]Planning error:[/bold red] {exc}") + session_manager.record_turn( + state.session, + turn_kind="chat", + user_message=text, + assistant_message=f"Planning error: {exc}", + metadata={"error_type": exc.__class__.__name__, "status": "failed"}, + ) + continue + except OrchestrationError as exc: + console.print(f"[bold red]Orchestration error:[/bold red] {exc}") + session_manager.record_turn( + state.session, + turn_kind="chat", + user_message=text, + assistant_message=f"Orchestration error: {exc}", + metadata={"error_type": exc.__class__.__name__, "status": "failed"}, + ) + continue + + state.last_result = result + _render_chat_turn( + result, + render_mode=self.render_mode, + interactive=True, + streamed_shell_output=True, + ) + _append_chat_transcript_turn( + session_manager, + state, + user_message=text, + result=result, + ) + + if result.gap_handoff is not None: + pending_request = _handle_gap_handoff(result.gap_handoff, settings=self.settings) + + +def _run_interactive_chat( + settings: AppSettings, + *, + initial_cwd: Path, + plan_only: bool, + render_mode: RenderMode, + resume_target: ResumeTarget, + disable_live_ux: bool = False, + disable_monitor: bool = False, + monitor_socket: str | None = None, + monitor_http_port: int | None = None, +) -> None: + InteractiveChatRunner( + settings=settings, + initial_cwd=initial_cwd, + plan_only=plan_only, + render_mode=render_mode, + resume_target=resume_target, + disable_live_ux=disable_live_ux, + disable_monitor=disable_monitor, + monitor_socket=monitor_socket, + monitor_http_port=monitor_http_port, + ).run() diff --git a/src/foundation/cli_rendering.py b/src/foundation/cli_rendering.py new file mode 100644 index 0000000..23d3e11 --- /dev/null +++ b/src/foundation/cli_rendering.py @@ -0,0 +1,1062 @@ +"""Rich rendering helpers for Foundation CLI.""" + +from __future__ import annotations + +import shlex + +from rich.console import Console +from rich.markup import escape +from rich.panel import Panel +from rich.table import Table +from rich.text import Text + +from foundation.doctor import DoctorReport, DoctorStatus +from foundation.models import ( + ActionKind, + AuditDetailRef, + AuditReport, + ChatNotice, + ChatSurfacePolicy, + ChatTurnPresentation, + ExecutionArtifactType, + ExecutionResult, + ExecutionStatus, + HistorySessionDetail, + HistorySessionSummary, + InteractiveDetailCommand, + LoopStopReason, + OrchestrationResult, + PlanningStep, + PresentationNoticeLevel, + RenderMode, + TraceRecord, + TraceSummary, + VerificationOutcome, +) +from foundation.services import ( + ExecutionMode, + FileDiscoveryResult, + GitContextResult, + HelpLookupResult, + OutputStream, + SearchResult, + ShellCommandResult, + ShellOutputEvent, + ToolAvailabilityStatus, + ToolBinaryStatus, + ToolErrorCode, + ToolExecutionError, +) +from foundation.services.gap_handoff import build_issue_url +from foundation.settings import AppSettings + +console = Console() +stderr_console = Console(stderr=True) + +_REPL_TRANSCRIPT_OUTPUT_PREVIEW_CHARACTERS = 1200 + + +def _render_placeholder(command_name: str, detail: str, settings: AppSettings) -> None: + console.print( + Panel.fit( + f"[bold]{command_name}[/bold]\n\n" + f"{detail}\n\n" + f"Workspace root: [cyan]{settings.workspace_root}[/cyan]\n" + f"Config path: [cyan]{settings.config_path}[/cyan]\n" + f"Approval mode: [cyan]{settings.approval.mode.value}[/cyan]", + title="Foundation CLI", + ) + ) + + +def _render_doctor_report(report: DoctorReport) -> None: + status_styles = { + DoctorStatus.PASS: "green", + DoctorStatus.WARN: "yellow", + DoctorStatus.FAIL: "red", + } + + for check in report.checks: + style = status_styles[check.status] + console.print( + f"[{style}]{check.status.value.upper():<4}[/{style}] {check.name}: {check.summary}" + ) + if check.detail: + console.print(Text(check.detail, style="dim")) + + +def _preview_transcript_text(value: str) -> str: + trimmed = value.strip() + if not trimmed: + return "" + if len(trimmed) <= _REPL_TRANSCRIPT_OUTPUT_PREVIEW_CHARACTERS: + return trimmed + return trimmed[:_REPL_TRANSCRIPT_OUTPUT_PREVIEW_CHARACTERS].rstrip() + "\n...[truncated]" + + +def _write_output(target: Console, text: str) -> None: + if not text: + return + target.file.write(text) + target.file.flush() + + +def _emit_output_event(event: ShellOutputEvent) -> None: + target = stderr_console if event.stream is OutputStream.STDERR else console + _write_output(target, event.text) + + +def _render_result_output(result: ShellCommandResult, *, streamed: bool) -> None: + if streamed: + return + _write_output(console, result.stdout) + _write_output(stderr_console, result.stderr) + + +def _format_result_status(result: ShellCommandResult) -> str: + if result.timed_out: + return "timed out" + if result.cancelled: + return "cancelled" + if result.exit_code is None: + return "unknown" + if result.exit_code < 0: + return f"signal {-result.exit_code}" + return f"exit code {result.exit_code}" + + +def _render_execution_summary(result: ShellCommandResult) -> None: + style = "green" if result.ok else "red" + lines = [ + f"Command: [cyan]{escape(result.display_command)}[/cyan]", + f"Cwd: [cyan]{escape(str(result.cwd))}[/cyan]", + f"Mode: [cyan]{result.mode.value}[/cyan]", + f"Status: [{style}]{escape(_format_result_status(result))}[/{style}]", + f"Duration: [cyan]{result.duration_seconds:.2f}s[/cyan]", + ] + truncated_streams: list[str] = [] + if result.stdout_truncated: + truncated_streams.append("stdout") + if result.stderr_truncated: + truncated_streams.append("stderr") + if truncated_streams: + lines.append( + f"Captured output truncated: [yellow]{escape(', '.join(truncated_streams))}[/yellow]" + ) + + console.print(Panel.fit("\n".join(lines), title="Execution Summary")) + + +def _render_tool_error(exc: ToolExecutionError) -> None: + console.print(f"[bold red]Tool error:[/bold red] {exc.error.message}") + if exc.error.detail: + console.print(Text(exc.error.detail, style="dim")) + if exc.error.install_hint: + console.print(Text(exc.error.install_hint, style="dim")) + + +def _tool_exit_code(exc: ToolExecutionError) -> int: + if exc.error.code is ToolErrorCode.INVALID_SCOPE: + return 2 + return 1 + + +def _render_availability(availability: list[ToolBinaryStatus]) -> None: + status_styles = { + ToolAvailabilityStatus.AVAILABLE: "green", + ToolAvailabilityStatus.MISSING: "yellow", + } + for item in availability: + style = status_styles[item.status] + resolved = f" ({item.resolved_command}: {item.path})" if item.path else "" + required = "required" if item.required else "optional" + console.print( + f"[{style}]{item.status.value.upper():<9}[/{style}] {item.name}: {required}{resolved}" + ) + if item.status is ToolAvailabilityStatus.MISSING and item.install_hint: + console.print(Text(item.install_hint, style="dim")) + + +def _render_search_result(result: SearchResult) -> None: + if not result.matches: + console.print("No matches found.") + return + + table = Table(title=f"Search Results ({result.scope})") + table.add_column("Path", style="cyan") + table.add_column("Line", justify="right") + table.add_column("Col", justify="right") + table.add_column("Text") + for match in result.matches: + table.add_row( + match.path, + str(match.line_number), + str(match.column_number), + match.line_text, + ) + console.print(table) + if result.truncated: + console.print(Text("Search results were truncated to the requested limit.", style="dim")) + + +def _render_file_result(result: FileDiscoveryResult) -> None: + if not result.paths: + console.print("No paths found.") + return + + table = Table(title=f"Path Discovery ({result.scope})") + table.add_column("Path", style="cyan") + for path in result.paths: + table.add_row(path) + console.print(table) + if result.truncated: + console.print(Text("Path results were truncated to the requested limit.", style="dim")) + + +def _render_git_context(result: GitContextResult) -> None: + console.print( + Panel.fit( + f"Scope: [cyan]{result.scope}[/cyan]\nBranch: [cyan]{result.branch}[/cyan]", + title="Git Context", + ) + ) + + if result.status: + status_table = Table(title="Status") + status_table.add_column("Index", justify="center") + status_table.add_column("Worktree", justify="center") + status_table.add_column("Path", style="cyan") + for entry in result.status: + status_table.add_row(entry.index_status, entry.worktree_status, entry.path) + console.print(status_table) + if result.truncated_status: + console.print(Text("Status output was truncated to the requested limit.", style="dim")) + + if result.unstaged_diff: + diff_table = Table(title="Unstaged Diff") + diff_table.add_column("Path", style="cyan") + diff_table.add_column("+", justify="right") + diff_table.add_column("-", justify="right") + diff_table.add_column("Binary", justify="center") + for diff_entry in result.unstaged_diff: + diff_table.add_row( + diff_entry.path, + "-" if diff_entry.additions is None else str(diff_entry.additions), + "-" if diff_entry.deletions is None else str(diff_entry.deletions), + "yes" if diff_entry.binary else "no", + ) + console.print(diff_table) + + if result.staged_diff: + diff_table = Table(title="Staged Diff") + diff_table.add_column("Path", style="cyan") + diff_table.add_column("+", justify="right") + diff_table.add_column("-", justify="right") + diff_table.add_column("Binary", justify="center") + for diff_entry in result.staged_diff: + diff_table.add_row( + diff_entry.path, + "-" if diff_entry.additions is None else str(diff_entry.additions), + "-" if diff_entry.deletions is None else str(diff_entry.deletions), + "yes" if diff_entry.binary else "no", + ) + console.print(diff_table) + + if result.recent_commits: + commit_table = Table(title="Recent Commits") + commit_table.add_column("Commit", style="cyan") + commit_table.add_column("Summary") + for commit in result.recent_commits: + commit_table.add_row(commit.short_sha, commit.summary) + console.print(commit_table) + + +def _render_help_lookup(result: HelpLookupResult) -> None: + console.print( + Panel.fit( + result.content or "No content returned.", + title=f"{result.source.value}: {result.topic}", + ) + ) + if result.truncated: + console.print(Text("Help output was truncated to the requested limit.", style="dim")) + + +def _render_assistant_message(result: OrchestrationResult) -> None: + console.print(Panel.fit(result.assistant_message.content, title="Assistant")) + + +def _chat_surface_policy(render_mode: RenderMode) -> ChatSurfacePolicy: + return ChatSurfacePolicy(render_mode=render_mode) + + +def _has_hidden_chat_detail(result: OrchestrationResult) -> bool: + return bool(result.plan.actions or result.execution_results or result.policy_decisions) + + +def _detail_ref_for_result(result: OrchestrationResult) -> AuditDetailRef | None: + if result.session_id is None: + return None + return AuditDetailRef( + session_id=result.session_id, + history_hint=f"foundation history --session {result.session_id}", + trace_hint=f"foundation trace --session {result.session_id}", + ) + + +def _notice_level_for_result(result: OrchestrationResult) -> PresentationNoticeLevel: + if result.summary.failed_actions or result.summary.blocked_actions: + return PresentationNoticeLevel.ERROR + if result.summary.pending_approval_actions or result.request.plan_only: + return PresentationNoticeLevel.WARNING + if result.summary.executed_actions or result.summary.skipped_actions: + return PresentationNoticeLevel.DIM + return PresentationNoticeLevel.INFO + + +def _artifact_preview_notice(result: ExecutionResult) -> ChatNotice | None: + if result.artifact_type is None or result.artifact is None: + return None + if result.artifact_type is ExecutionArtifactType.EXPLANATION: + return None + if result.artifact_type is ExecutionArtifactType.SHELL: + shell_result = ShellCommandResult.model_validate(result.artifact) + preview_lines: list[str] = [] + stdout_preview = _preview_transcript_text(shell_result.stdout) + stderr_preview = _preview_transcript_text(shell_result.stderr) + if stdout_preview: + preview_lines.append(f"stdout:\n{stdout_preview}") + if stderr_preview and not shell_result.ok: + preview_lines.append(f"stderr:\n{stderr_preview}") + if not preview_lines: + return None + return ChatNotice( + level=PresentationNoticeLevel.DIM, + text="\n".join(preview_lines), + ) + if result.artifact_type is ExecutionArtifactType.SEARCH: + search_result = SearchResult.model_validate(result.artifact) + if not search_result.matches: + return None + preview_lines = [ + f"{match.path}:{match.line_number} {match.line_text}" + for match in search_result.matches[:3] + ] + if len(search_result.matches) > 3 or search_result.truncated: + preview_lines.append("...") + return ChatNotice( + level=PresentationNoticeLevel.DIM, + text="Search preview:\n" + "\n".join(preview_lines), + ) + if result.artifact_type is ExecutionArtifactType.FILES: + file_result = FileDiscoveryResult.model_validate(result.artifact) + if not file_result.paths: + return None + preview_lines = list(file_result.paths[:5]) + if len(file_result.paths) > 5 or file_result.truncated: + preview_lines.append("...") + return ChatNotice( + level=PresentationNoticeLevel.DIM, + text="Path preview:\n" + "\n".join(preview_lines), + ) + if result.artifact_type is ExecutionArtifactType.GIT: + git_result = GitContextResult.model_validate(result.artifact) + preview_lines = [f"Branch: {git_result.branch}"] + if git_result.status: + changed_paths = [entry.path for entry in git_result.status[:5]] + changed_text = ", ".join(changed_paths) + if len(git_result.status) > 5 or git_result.truncated_status: + changed_text += ", ..." + preview_lines.append(f"Changed: {changed_text}") + if git_result.recent_commits: + preview_lines.append( + "Recent: " + + "; ".join( + f"{commit.short_sha} {commit.summary}" + for commit in git_result.recent_commits[:3] + ) + ) + return ChatNotice( + level=PresentationNoticeLevel.DIM, + text="\n".join(preview_lines), + ) + if result.artifact_type in (ExecutionArtifactType.MAN, ExecutionArtifactType.TLDR): + help_result = HelpLookupResult.model_validate(result.artifact) + content_preview = _preview_transcript_text(help_result.content) + if not content_preview: + return None + return ChatNotice( + level=PresentationNoticeLevel.DIM, + text=f"{help_result.source.value}: {help_result.topic}\n{content_preview}", + ) + return None + + +_CODE_CHANGING_ARTIFACT_TYPES = frozenset( + { + ExecutionArtifactType.FILE_WRITE, + ExecutionArtifactType.FILE_EDIT, + ExecutionArtifactType.FILE_APPLY_DIFF, + } +) + + +_CHANGED_FILES_DISPLAY_CAP = 6 + + +_COMMANDS_RUN_DISPLAY_CAP = 6 + + +def _iteration_changed_files_notice( + result: OrchestrationResult, +) -> ChatNotice | None: + """Dedup and summarize file paths changed across all iterations.""" + seen: list[str] = [] + seen_set: set[str] = set() + for item in result.execution_results: + if item.artifact_type in _CODE_CHANGING_ARTIFACT_TYPES and item.artifact is not None: + path = item.artifact.get("path") + if isinstance(path, str) and path and path not in seen_set: + seen.append(path) + seen_set.add(path) + if not seen: + return None + shown = seen[:_CHANGED_FILES_DISPLAY_CAP] + suffix = "" + if len(seen) > _CHANGED_FILES_DISPLAY_CAP: + suffix = f", +{len(seen) - _CHANGED_FILES_DISPLAY_CAP} more" + label = "Changed file" if len(seen) == 1 else "Changed files" + return ChatNotice( + level=PresentationNoticeLevel.INFO, + text=f"{label}: {', '.join(shown)}{suffix}", + ) + + +def _iteration_commands_notice( + result: OrchestrationResult, +) -> ChatNotice | None: + """Collect shell commands run across iterations, dedup consecutive duplicates.""" + commands: list[str] = [] + for iteration in result.iterations: + for action, exec_result in zip( + iteration.plan.actions, + iteration.execution_results, + strict=False, + ): + if action.kind is not ActionKind.SHELL or action.shell is None: + continue + if exec_result.status is not ExecutionStatus.EXECUTED: + continue + parts = [action.shell.command, *action.shell.args] + display = " ".join(parts).strip() + if not display: + continue + if len(display) > 80: + display = display[:77] + "..." + if commands and commands[-1] == display: + continue + commands.append(display) + if not commands: + return None + shown = commands[:_COMMANDS_RUN_DISPLAY_CAP] + suffix = "" + if len(commands) > _COMMANDS_RUN_DISPLAY_CAP: + suffix = f"\n +{len(commands) - _COMMANDS_RUN_DISPLAY_CAP} more" + label = "Command" if len(commands) == 1 else "Commands" + formatted = "\n ".join(f"$ {cmd}" for cmd in shown) + return ChatNotice( + level=PresentationNoticeLevel.DIM, + text=f"{label} run:\n {formatted}{suffix}", + ) + + +def _verification_outcome_notice( + result: OrchestrationResult, +) -> ChatNotice | None: + """Render the orchestrator's verification notice as a user-facing notice.""" + notice = result.verification_notice + if notice is None: + return None + outcome = notice.outcome + if outcome is VerificationOutcome.PASSED: + cmds = ", ".join(notice.verification_commands_run[:3]) + detail = f" ({cmds})" if cmds else "" + return ChatNotice( + level=PresentationNoticeLevel.INFO, + text=f"Verification: passed{detail}", + ) + if outcome is VerificationOutcome.FAILED: + cmds = ", ".join(notice.verification_commands_run[:3]) + detail = f" ({cmds})" if cmds else "" + return ChatNotice( + level=PresentationNoticeLevel.WARNING, + text=f"Verification: failed{detail}", + ) + if outcome is VerificationOutcome.UNAVAILABLE: + cmds = ", ".join(notice.verification_commands_run[:3]) + detail = f" ({cmds})" if cmds else "" + return ChatNotice( + level=PresentationNoticeLevel.WARNING, + text=f"Verification: unavailable{detail}", + ) + # NOT_ATTEMPTED + return ChatNotice( + level=PresentationNoticeLevel.WARNING, + text="Verification: code changed but no verification command ran", + ) + + +def _approval_required_notice( + result: OrchestrationResult, +) -> ChatNotice | None: + """Surface pending-approval stops as a warning-level notice.""" + if result.stop_reason is not LoopStopReason.PENDING_APPROVAL: + return None + pending = result.summary.pending_approval_actions + plural = "s" if pending != 1 else "" + return ChatNotice( + level=PresentationNoticeLevel.WARNING, + text=( + f"Approval required for {pending} action{plural}. " + "Run `foundation approve` or re-issue with approval mode set." + ), + ) + + +def _awaiting_input_notice( + result: OrchestrationResult, +) -> ChatNotice | None: + """Surface an unanswered question (non-interactive / dismissed) as a notice.""" + if result.stop_reason is not LoopStopReason.AWAITING_USER_INPUT: + return None + prompts = [ + str(item.artifact.get("question", "")).strip() + for item in result.execution_results + if item.artifact_type is ExecutionArtifactType.QUESTION + and item.artifact is not None + and item.status is ExecutionStatus.AWAITING_INPUT + ] + question_text = next((prompt for prompt in prompts if prompt), "a question") + return ChatNotice( + level=PresentationNoticeLevel.WARNING, + text=( + f'Waiting on your input: "{question_text}" ' + "Re-run with your answer in the request to continue." + ), + ) + + +def _build_chat_turn_presentation( + result: OrchestrationResult, + *, + policy: ChatSurfacePolicy, + interactive: bool, +) -> ChatTurnPresentation: + primary_text = result.assistant_message.content.strip() + notices: list[ChatNotice] = [] + explanation_messages = [ + str(item.artifact.get("message", "")).strip() + for item in result.execution_results + if item.artifact_type is ExecutionArtifactType.EXPLANATION and item.artifact is not None + ] + seen_messages = {primary_text} + for message in explanation_messages: + if message and message not in seen_messages: + notices.append( + ChatNotice( + level=PresentationNoticeLevel.DIM, + text=message, + ) + ) + seen_messages.add(message) + + summary_text = result.summary.text.strip() + if (result.plan.actions or result.execution_results) and summary_text not in seen_messages: + notices.insert( + 0, + ChatNotice( + level=_notice_level_for_result(result), + text=summary_text, + ), + ) + seen_messages.add(summary_text) + + for iteration_notice in ( + _iteration_changed_files_notice(result), + _iteration_commands_notice(result), + _verification_outcome_notice(result), + _approval_required_notice(result), + _awaiting_input_notice(result), + ): + if iteration_notice is not None and iteration_notice.text not in seen_messages: + notices.append(iteration_notice) + seen_messages.add(iteration_notice.text) + + # One-shot/non-TTY runs can't prompt for a gap-handoff choice, so surface the + # options and the report link inline. Interactive runs handle this via a prompt. + if result.gap_handoff is not None and not interactive: + handoff = result.gap_handoff + option_lines = "\n".join( + f" {index}. {option.label}" for index, option in enumerate(handoff.options, start=1) + ) + gap_text = ( + f"What you can do:\n{option_lines}\n" + f"To report it so it can be fixed, file: {build_issue_url(handoff.report)}" + ) + if gap_text not in seen_messages: + notices.append(ChatNotice(level=PresentationNoticeLevel.WARNING, text=gap_text)) + seen_messages.add(gap_text) + + for execution_result in result.execution_results: + artifact_notice = _artifact_preview_notice(execution_result) + if artifact_notice is not None and artifact_notice.text not in seen_messages: + notices.append(artifact_notice) + seen_messages.add(artifact_notice.text) + + audit_ref = None + if policy.show_audit_refs and _has_hidden_chat_detail(result): + audit_ref = _detail_ref_for_result(result) + if audit_ref is not None: + hint = ( + f"Session {audit_ref.session_id[:8]} saved. Use " + f"{InteractiveDetailCommand.PLAN.value}, " + f"{InteractiveDetailCommand.ACTIONS.value}, or " + f"{InteractiveDetailCommand.SUMMARY.value} for detail." + if interactive + else ( + f"Session {audit_ref.session_id[:8]} saved. Re-run with `--render verbose` " + f"or inspect with `{audit_ref.trace_hint}`." + ) + ) + notices.append( + ChatNotice( + level=PresentationNoticeLevel.DIM, + text=hint, + ) + ) + + return ChatTurnPresentation( + primary_text=primary_text, + notices=notices, + audit_ref=audit_ref, + ) + + +def _render_action_plan(result: OrchestrationResult) -> None: + if not result.plan.actions: + console.print(Text("No actions planned.", style="dim")) + return + + decisions = {decision.action_id: decision for decision in result.policy_decisions} + evaluations = {record.action_id: record for record in result.policy_evaluations} + table = Table(title="Planned Actions") + table.add_column("Id", style="cyan") + table.add_column("Kind") + table.add_column("Summary") + table.add_column("Policy") + for action in result.plan.actions: + decision = decisions.get(action.id) + evaluation = evaluations.get(action.id) + policy_text = "-" + if evaluation is not None: + policy_text = evaluation.verdict.outcome.value + elif decision is not None: + policy_text = decision.decision.value + table.add_row(action.id, action.kind.value, action.summary, policy_text) + console.print(table) + + +def _render_chat_execution_result( + result: ExecutionResult, + *, + streamed_shell_output: bool = False, +) -> None: + status_styles = { + "executed": "green", + "not_executed": "yellow", + "pending_approval": "yellow", + "blocked": "red", + "failed": "red", + } + style = status_styles[result.status.value] + console.print( + Panel.fit( + f"[{style}]{result.status.value}[/{style}]\n{escape(result.summary)}", + title=f"Action {result.action_id}", + ) + ) + if result.artifact_type is None or result.artifact is None: + return + + if result.artifact_type is ExecutionArtifactType.SHELL: + shell_result = ShellCommandResult.model_validate(result.artifact) + _render_result_output( + shell_result, + streamed=(streamed_shell_output and shell_result.mode is not ExecutionMode.BUFFERED), + ) + _render_execution_summary(shell_result) + return + if result.artifact_type is ExecutionArtifactType.SEARCH: + _render_search_result(SearchResult.model_validate(result.artifact)) + return + if result.artifact_type is ExecutionArtifactType.FILES: + _render_file_result(FileDiscoveryResult.model_validate(result.artifact)) + return + if result.artifact_type is ExecutionArtifactType.GIT: + _render_git_context(GitContextResult.model_validate(result.artifact)) + return + if result.artifact_type in {ExecutionArtifactType.MAN, ExecutionArtifactType.TLDR}: + _render_help_lookup(HelpLookupResult.model_validate(result.artifact)) + return + if result.artifact_type is ExecutionArtifactType.EXPLANATION: + message = result.artifact.get("message", "") + console.print(Text(str(message), style="dim")) + + +def _render_chat_execution_details( + result: OrchestrationResult, + *, + streamed_shell_output: bool, +) -> None: + for execution_result in result.execution_results: + _render_chat_execution_result( + execution_result, + streamed_shell_output=streamed_shell_output, + ) + + +def _render_orchestration_summary(result: OrchestrationResult) -> None: + usage = result.planning_metadata.usage + usage_text = "unknown" + if usage is not None and usage.total_tokens is not None: + usage_text = str(usage.total_tokens) + session_text = result.session_id or "not recorded" + console.print( + Panel.fit( + f"{result.summary.text}\n\n" + f"Session: [cyan]{session_text}[/cyan]\n" + f"Provider: [cyan]{result.planning_metadata.provider}[/cyan]\n" + f"Model: [cyan]{result.planning_metadata.model}[/cyan]\n" + f"Latency: [cyan]{result.planning_metadata.latency_seconds:.2f}s[/cyan]\n" + f"Attempts: [cyan]{result.planning_metadata.attempts}[/cyan]\n" + f"Total tokens: [cyan]{usage_text}[/cyan]", + title="Orchestration Summary", + ) + ) + + +def _render_concise_chat_turn( + result: OrchestrationResult, + *, + policy: ChatSurfacePolicy, + interactive: bool, +) -> None: + presentation = _build_chat_turn_presentation( + result, + policy=policy, + interactive=interactive, + ) + console.print(presentation.primary_text) + style_map = { + PresentationNoticeLevel.INFO: "cyan", + PresentationNoticeLevel.WARNING: "yellow", + PresentationNoticeLevel.ERROR: "bold red", + PresentationNoticeLevel.DIM: "dim", + } + for notice in presentation.notices: + console.print(Text(notice.text, style=style_map[notice.level])) + + +def _render_verbose_chat_turn( + result: OrchestrationResult, + *, + streamed_shell_output: bool, +) -> None: + _render_assistant_message(result) + _render_action_plan(result) + _render_chat_execution_details( + result, + streamed_shell_output=streamed_shell_output, + ) + _render_orchestration_summary(result) + + +def _render_chat_turn( + result: OrchestrationResult, + *, + render_mode: RenderMode, + interactive: bool = False, + streamed_shell_output: bool = False, +) -> None: + if render_mode is RenderMode.VERBOSE: + _render_verbose_chat_turn( + result, + streamed_shell_output=streamed_shell_output, + ) + return + _render_concise_chat_turn( + result, + policy=_chat_surface_policy(render_mode), + interactive=interactive, + ) + + +def _render_interactive_detail( + result: OrchestrationResult, + command: InteractiveDetailCommand, +) -> None: + if command is InteractiveDetailCommand.PLAN: + _render_action_plan(result) + return + if command is InteractiveDetailCommand.ACTIONS: + if not result.execution_results: + console.print( + Text( + "No execution details were recorded for the last request.", + style="dim", + ) + ) + return + _render_chat_execution_details(result, streamed_shell_output=False) + return + _render_orchestration_summary(result) + + +def _render_history_list(sessions: list[HistorySessionSummary]) -> None: + if not sessions: + console.print("No history recorded yet.") + return + + table = Table(title="Session History") + table.add_column("Session", style="cyan") + table.add_column("Kind") + table.add_column("Status") + table.add_column("Started") + table.add_column("Request") + for session in sessions: + request_text = session.request_text or session.command_preview or "-" + table.add_row( + session.session_id, + session.kind.value, + session.status.value, + session.started_at, + request_text, + ) + console.print(table) + + +def _render_history_detail(session: HistorySessionDetail) -> None: + console.print( + Panel.fit( + f"Session: [cyan]{session.session_id}[/cyan]\n" + f"Kind: [cyan]{session.kind.value}[/cyan]\n" + f"Status: [cyan]{session.status.value}[/cyan]\n" + f"Started: [cyan]{session.started_at}[/cyan]\n" + f"Request cwd: [cyan]{session.request_cwd}[/cyan]\n" + f"Approval mode: [cyan]{session.approval_mode}[/cyan]", + title="History Detail", + ) + ) + if session.request_text: + console.print(Panel.fit(session.request_text, title="User Request")) + elif session.command_preview: + console.print(Panel.fit(session.command_preview, title="Command")) + + if session.assistant_message: + console.print(Panel.fit(session.assistant_message, title="Assistant")) + + if session.summary_text: + console.print( + Panel.fit( + f"{session.summary_text}\n\n" + f"Executed: [cyan]{session.executed_actions}[/cyan]\n" + f"Pending approval: [cyan]{session.pending_approval_actions}[/cyan]\n" + f"Blocked: [cyan]{session.blocked_actions}[/cyan]\n" + f"Failed: [cyan]{session.failed_actions}[/cyan]\n" + f"Skipped: [cyan]{session.skipped_actions}[/cyan]", + title="Summary", + ) + ) + + if session.approvals: + table = Table(title="Approvals") + table.add_column("Action", style="cyan") + table.add_column("Capability") + table.add_column("Status") + table.add_column("Mode") + table.add_column("Reason") + for approval in session.approvals: + table.add_row( + approval.action_id or "-", + approval.capability_id or "-", + approval.status.value, + approval.mode, + approval.reason, + ) + console.print(table) + + if session.policy_evaluations: + table = Table(title="Policy Evaluations") + table.add_column("Action", style="cyan") + table.add_column("Capability") + table.add_column("Outcome") + table.add_column("Reasons") + for record in session.policy_evaluations: + reasons = ", ".join(code.value for code in record.verdict.reason_codes) or "-" + table.add_row( + record.action_id, + record.capability_id, + record.verdict.outcome.value, + reasons, + ) + console.print(table) + + if session.tool_calls: + table = Table(title="Tool Calls") + table.add_column("Action", style="cyan") + table.add_column("Tool") + table.add_column("Status") + table.add_column("Policy") + for tool_call in session.tool_calls: + table.add_row( + tool_call.action_id, + tool_call.tool, + tool_call.execution_status, + tool_call.policy_decision or "-", + ) + console.print(table) + + if session.commands: + table = Table(title="Commands") + table.add_column("Action", style="cyan") + table.add_column("Command") + table.add_column("Status") + table.add_column("Exit", justify="right") + for command in session.commands: + table.add_row( + command.action_id or "-", + shlex.join([command.command, *command.args]), + command.execution_status, + "-" if command.exit_code is None else str(command.exit_code), + ) + console.print(table) + + +def _render_trace_list(traces: list[TraceSummary]) -> None: + if not traces: + console.print("No traces recorded yet.") + return + + table = Table(title="Trace History") + table.add_column("Trace", style="cyan") + table.add_column("Status") + table.add_column("Started") + table.add_column("Steps", justify="right") + table.add_column("Capabilities") + table.add_column("Request") + for trace in traces: + capabilities = ", ".join(trace.selected_capability_ids) or "-" + table.add_row( + trace.trace_id, + trace.status.value, + trace.started_at, + str(trace.step_count), + capabilities, + trace.request_text or "-", + ) + console.print(table) + + +def _render_trace_detail(trace: TraceRecord) -> None: + console.print( + Panel.fit( + f"Trace: [cyan]{trace.trace_id}[/cyan]\n" + f"Status: [cyan]{trace.status.value}[/cyan]\n" + f"Started: [cyan]{trace.started_at}[/cyan]\n" + f"Completed: [cyan]{trace.completed_at or '-'}[/cyan]\n" + f"Steps: [cyan]{trace.summary.step_count}[/cyan]", + title="Trace Detail", + ) + ) + if trace.request_text: + console.print(Panel.fit(trace.request_text, title="Request")) + + summary = trace.summary + console.print( + Panel.fit( + f"Executed: [cyan]{summary.executed_steps}[/cyan]\n" + f"Pending approval: [cyan]{summary.pending_approval_steps}[/cyan]\n" + f"Blocked: [cyan]{summary.blocked_steps}[/cyan]\n" + f"Failed: [cyan]{summary.failed_steps}[/cyan]\n" + f"Skipped: [cyan]{summary.skipped_steps}[/cyan]", + title="Trace Summary", + ) + ) + + if trace.edges: + edge_table = Table(title="Trace Edges") + edge_table.add_column("Source", style="cyan") + edge_table.add_column("Target") + edge_table.add_column("Kind") + for edge in trace.edges: + edge_table.add_row(edge.source_step_id, edge.target_step_id, edge.edge_kind.value) + console.print(edge_table) + + if trace.steps: + step_table = Table(title="Trace Steps") + step_table.add_column("Step", style="cyan") + step_table.add_column("Type") + step_table.add_column("Status") + step_table.add_column("Capability") + step_table.add_column("Why") + for step in trace.steps: + if isinstance(step, PlanningStep): + status = "planned" + capability = "-" + why = ", ".join(reason.summary for reason in step.selection_reasons) or "-" + else: + status = step.status.value + capability = step.capability_id or "-" + why = step.selection_reason.detail or step.selection_reason.summary + step_table.add_row( + step.step_id, + step.step_type.value, + status, + capability, + why, + ) + console.print(step_table) + + +def _render_audit_report(report: AuditReport) -> None: + status_text = "pass" if report.completeness_passed else "fail" + console.print( + Panel.fit( + f"Trace: [cyan]{report.trace_summary.trace_id}[/cyan]\n" + f"Completeness: [cyan]{status_text}[/cyan]\n" + f"Inspected step: [cyan]{report.inspected_step_id or 'all'}[/cyan]", + title="Audit Report", + ) + ) + if report.notes: + console.print(Panel.fit("\n".join(report.notes), title="Notes")) + if report.missing_fields_by_step: + table = Table(title="Missing Fields") + table.add_column("Step", style="cyan") + table.add_column("Fields") + for step_id, fields in report.missing_fields_by_step.items(): + table.add_row(step_id, ", ".join(fields)) + console.print(table) + _render_trace_detail( + TraceRecord( + trace_id=report.trace_summary.trace_id, + session_id=report.trace_summary.session_id, + request_text=report.trace_summary.request_text, + status=report.trace_summary.status, + started_at=report.trace_summary.started_at, + completed_at=report.trace_summary.completed_at, + steps=report.steps, + edges=report.edges, + summary=report.trace_summary, + ) + ) diff --git a/src/foundation/cli_runtime.py b/src/foundation/cli_runtime.py new file mode 100644 index 0000000..9ad88ac --- /dev/null +++ b/src/foundation/cli_runtime.py @@ -0,0 +1,507 @@ +"""Runtime construction and orchestration helpers for Foundation CLI.""" + +from __future__ import annotations + +import os +import shlex +import threading +from contextlib import suppress +from pathlib import Path +from typing import Any + +import click +import typer +from rich.markup import escape +from rich.panel import Panel +from rich.text import Text + +from foundation.cli_rendering import console +from foundation.live_turn import LiveTurnRenderer, get_active_renderer, live_ux_disabled +from foundation.models import ( + ApprovalRequest, + CapabilityGapHandoff, + CapabilityGapOption, + GapOptionKind, + OrchestrationResult, + ProviderMessage, + QuestionAction, + SessionStatus, + UserRequest, +) +from foundation.monitor import ( + EventLogWriter, + LocalHttpSseTransport, + MonitorServer, + TransportStartError, + UnixSocketTransport, + compose_event_sink, +) +from foundation.services import ( + ApprovalService, + CapabilityRegistry, + CapabilityStore, + HistoryStore, + LocalToolService, + RequestOrchestrator, + SessionManager, + ShellCommandRequest, + ShellCommandResult, + ShellRuntime, + TraceStore, + build_provider_adapter, +) +from foundation.services.gap_handoff import build_issue_url, write_gap_report +from foundation.settings import ApprovalMode, AppSettings + +_REPL_SESSION_DB_FILENAME = "chat-sessions.sqlite3" + + +def _build_shell_runtime(settings: AppSettings) -> ShellRuntime: + return ShellRuntime( + workspace_root=settings.workspace_root, + default_timeout_seconds=settings.shell.default_timeout_seconds, + max_timeout_seconds=settings.shell.max_timeout_seconds, + allow_pty=settings.shell.allow_pty, + capture_limit_kb=settings.shell.capture_limit_kb, + enforce_workspace_boundary=settings.shell.enforce_workspace_boundary, + pass_through_foundation_env=settings.shell.pass_through_foundation_env, + ) + + +def _build_tool_service(settings: AppSettings) -> LocalToolService: + return LocalToolService( + workspace_root=settings.workspace_root, + default_timeout_seconds=min(settings.shell.default_timeout_seconds, 30), + capture_limit_kb=settings.shell.capture_limit_kb, + pass_through_foundation_env=settings.shell.pass_through_foundation_env, + ) + + +def _build_history_store(settings: AppSettings) -> HistoryStore: + return TraceStore( + database_path=settings.history.database_path, + retention_days=settings.history.retention_days, + max_entries=settings.history.max_entries, + ) + + +def _build_capability_registry( + settings: AppSettings, + *, + tool_service: LocalToolService | None = None, +) -> CapabilityRegistry: + service = tool_service or _build_tool_service(settings) + return CapabilityRegistry( + store=CapabilityStore(settings.app.data_dir / "capabilities"), + tool_service=service, + ) + + +def _build_session_manager(settings: AppSettings) -> SessionManager: + return SessionManager( + database_path=settings.app.state_dir / _REPL_SESSION_DB_FILENAME, + workspace_root=settings.workspace_root, + config_dir=settings.config_path.parent, + provider_name=settings.provider.name, + ) + + +def _prompt_for_approval(request: ApprovalRequest) -> bool: + risk_text = ", ".join(request.risk_categories) if request.risk_categories else "unknown" + lines = [ + f"Action: [cyan]{escape(request.action_id)}[/cyan]", + ( + f"Capability: [cyan]{escape(request.capability_id)}[/cyan]" + if request.capability_id + else None + ), + f"Summary: {escape(request.summary)}", + f"Reason: {escape(request.reason)}", + f"Risk: [yellow]{escape(risk_text)}[/yellow]", + ] + if request.risk_class is not None: + lines.append(f"Risk class: [yellow]{escape(request.risk_class.value)}[/yellow]") + if request.trust_tier is not None: + lines.append(f"Trust tier: [cyan]{escape(request.trust_tier.value)}[/cyan]") + if request.command_preview: + lines.append(f"Command: [cyan]{escape(request.command_preview)}[/cyan]") + if request.cwd: + lines.append(f"Cwd: [cyan]{escape(request.cwd)}[/cyan]") + if request.paths: + lines.append(f"Paths: [cyan]{escape(', '.join(request.paths))}[/cyan]") + if request.network_hosts: + lines.append(f"Network: [cyan]{escape(', '.join(request.network_hosts))}[/cyan]") + if request.requested_side_effects: + lines.append( + f"Side effects: [yellow]{escape(', '.join(request.requested_side_effects))}[/yellow]" + ) + if request.reason_codes: + reason_text = ", ".join(code.value for code in request.reason_codes) + lines.append(f"Policy reasons: [magenta]{escape(reason_text)}[/magenta]") + if request.constraints is not None and request.constraints.invocation_budget is not None: + budget = request.constraints.invocation_budget + budget_parts: list[str] = [] + if budget.timeout_seconds is not None: + budget_parts.append(f"timeout={budget.timeout_seconds}s") + if budget.output_limit_kb is not None: + budget_parts.append(f"output={budget.output_limit_kb}KB") + if budget.max_invocations is not None: + budget_parts.append(f"max_invocations={budget.max_invocations}") + if budget_parts: + lines.append(f"Constraints: [cyan]{escape(', '.join(budget_parts))}[/cyan]") + panel_text = "\n".join(item for item in lines if item is not None) + renderer = get_active_renderer() + if renderer is not None: + renderer.pause() + try: + console.print(Panel.fit(panel_text, title="Approval Required")) + return typer.confirm("Approve this action?", default=False) + finally: + if renderer is not None: + renderer.resume() + + +def _prompt_for_question(question: QuestionAction) -> str | None: + lines = [f"[bold]{escape(question.prompt)}[/bold]"] + if question.options: + lines.append("") + for index, option in enumerate(question.options, start=1): + lines.append(f" [cyan]{index}[/cyan]. {escape(option)}") + renderer = get_active_renderer() + if renderer is not None: + renderer.pause() + try: + console.print(Panel.fit("\n".join(lines), title="Question")) + try: + raw = str(typer.prompt("Your answer")) + except (EOFError, click.exceptions.Abort): + return None + answer = raw.strip() + if not answer: + return None + # Allow selecting an option by its number. + if question.options and answer.isdigit(): + choice = int(answer) + if 1 <= choice <= len(question.options): + return question.options[choice - 1] + return answer + finally: + if renderer is not None: + renderer.resume() + + +def _prompt_gap_option(handoff: CapabilityGapHandoff) -> CapabilityGapOption | None: + """Present a capability-gap handoff and return the option the user chose.""" + lines = [f"[bold]{escape(handoff.message)}[/bold]", ""] + for index, option in enumerate(handoff.options, start=1): + lines.append(f" [cyan]{index}[/cyan]. {escape(option.label)}") + renderer = get_active_renderer() + if renderer is not None: + renderer.pause() + try: + console.print(Panel.fit("\n".join(lines), title="How would you like to proceed?")) + try: + raw = typer.prompt("Choose an option", default="").strip() + except (EOFError, click.exceptions.Abort): + return None + if raw.isdigit(): + choice = int(raw) + if 1 <= choice <= len(handoff.options): + return handoff.options[choice - 1] + return None + finally: + if renderer is not None: + renderer.resume() + + +def _submit_gap_report(handoff: CapabilityGapHandoff, *, settings: AppSettings) -> None: + """Persist a gap report and show the user where it can be filed/fixed.""" + path = write_gap_report(handoff.report, gaps_dir=settings.app.state_dir / "gaps") + url = build_issue_url(handoff.report) + console.print( + Panel.fit( + "Thanks — I saved a gap report so this can be fixed.\n\n" + f"Saved to: [cyan]{escape(str(path))}[/cyan]\n" + f"File it as an issue: [cyan]{escape(url)}[/cyan]", + title="Reported", + ) + ) + + +def _handle_gap_handoff( + handoff: CapabilityGapHandoff, + *, + settings: AppSettings, +) -> str | None: + """Drive the interactive gap handoff. Return a follow-up request to resume, or None.""" + choice = _prompt_gap_option(handoff) + if choice is None: + return None + if choice.kind is GapOptionKind.REPORT: + _submit_gap_report(handoff, settings=settings) + return None + if choice.kind is GapOptionKind.ALTERNATIVE: + return choice.follow_up_request + console.print(Text("Okay — stopping here.", style="dim")) + return None + + +def _build_orchestrator( + settings: AppSettings, + *, + approval_mode: ApprovalMode | None = None, + shell_output_callback: Any | None = None, +) -> RequestOrchestrator: + effective_approval_mode = approval_mode or settings.approval.mode + tool_service = _build_tool_service(settings) + return RequestOrchestrator( + workspace_root=settings.workspace_root, + approval_mode=effective_approval_mode, + provider=build_provider_adapter(settings), + shell_runtime=_build_shell_runtime(settings), + tool_service=tool_service, + approval_service=ApprovalService( + mode=effective_approval_mode, + prompt_callback=_prompt_for_approval, + ), + history_store=_build_history_store(settings), + shell_output_callback=shell_output_callback, + question_callback=_prompt_for_question, + capability_registry=_build_capability_registry(settings, tool_service=tool_service), + ) + + +def _execute_chat_request( + settings: AppSettings, + *, + message: str, + conversation_history: list[ProviderMessage] | None = None, + cwd: Path | None, + plan_only: bool, + approval_mode: ApprovalMode | None = None, + shell_output_callback: Any | None = None, + disable_live_ux: bool = False, + disable_monitor: bool = False, + monitor_socket: str | None = None, + monitor_http_port: int | None = None, +) -> OrchestrationResult: + orchestrator = _build_orchestrator( + settings, + approval_mode=approval_mode, + shell_output_callback=shell_output_callback, + ) + request = UserRequest( + message=message, + conversation_history=list(conversation_history or []), + cwd=cwd, + plan_only=plan_only, + ) + use_live_ux = not (disable_live_ux or live_ux_disabled()) + use_monitor = settings.monitor.enabled and not disable_monitor + use_socket = monitor_socket is not None and use_monitor + use_http = monitor_http_port is not None and use_monitor + if not use_live_ux and not use_monitor: + return orchestrator.orchestrate(request) + + monitor_server: MonitorServer | None = None + transports: list[Any] = [] + if use_socket or use_http: + monitor_server = MonitorServer(queue_size=settings.monitor.subscriber_queue_size) + if use_socket: + socket_path = _resolve_monitor_socket_path(settings, override=monitor_socket) + try: + transports.append(UnixSocketTransport(path=socket_path, server=monitor_server)) + except TransportStartError as exc: + console.print(f"[bold yellow]Monitor warning:[/bold yellow] {exc}") + if use_http: + token = _resolve_monitor_http_token(settings) + assert monitor_http_port is not None + try: + transports.append( + LocalHttpSseTransport( + port=monitor_http_port, + token=token, + server=monitor_server, + ) + ) + console.print( + f"[dim]Monitor HTTP transport listening on " + f"127.0.0.1:{monitor_http_port}; bearer token: {token}[/dim]" + ) + except TransportStartError as exc: + console.print(f"[bold yellow]Monitor warning:[/bold yellow] {exc}") + return _run_orchestrate_with_sinks( + orchestrator, + request, + use_live=use_live_ux, + writer=_build_event_log_writer(settings) if use_monitor else None, + monitor_server=monitor_server, + transports=transports, + ) + + +def _resolve_monitor_socket_path(settings: AppSettings, *, override: str | None) -> Path: + if override: + return Path(override).expanduser() + if settings.monitor.socket_path is not None: + return settings.monitor.socket_path + runtime_dir = os.environ.get("XDG_RUNTIME_DIR") or os.environ.get("TMPDIR") or "/tmp" + return Path(runtime_dir).expanduser() / "foundation" / f"{os.getpid()}.sock" + + +def _resolve_monitor_http_token(settings: AppSettings) -> str: + configured = settings.monitor.auth_token + if configured is not None: + token = configured.get_secret_value() + if token: + return token + import secrets + + return secrets.token_urlsafe(24) + + +def _build_event_log_writer(settings: AppSettings) -> EventLogWriter: + return EventLogWriter( + events_dir=settings.monitor.events_dir, + max_sessions=settings.monitor.retention.max_sessions, + max_bytes=settings.monitor.retention.max_bytes, + ) + + +def _run_orchestrate_with_sinks( + orchestrator: RequestOrchestrator, + request: UserRequest, + *, + use_live: bool, + writer: EventLogWriter | None, + monitor_server: MonitorServer | None = None, + transports: list[Any] | None = None, +) -> OrchestrationResult: + set_sink = getattr(orchestrator, "set_event_sink", None) + + def _attach_sink(sink: Any) -> None: + if callable(set_sink): + set_sink(sink) + + transports = transports or [] + + def _build_sinks(extra: list[Any] | None = None) -> list[Any]: + sinks: list[Any] = list(extra or []) + if writer is not None: + sinks.append(writer.write_event) + if monitor_server is not None: + sinks.append(monitor_server.publish) + return sinks + + writer_ctx: Any = writer if writer is not None else _NullContext() + server_ctx: Any = monitor_server if monitor_server is not None else _NullContext() + transport_stack: list[Any] = [] + try: + for transport in transports: + transport.start() + transport_stack.append(transport) + with writer_ctx, server_ctx: + if not use_live: + _attach_sink(compose_event_sink(*_build_sinks())) + try: + return orchestrator.orchestrate(request) + finally: + _attach_sink(None) + + result_box: dict[str, OrchestrationResult | BaseException] = {} + + def worker() -> None: + try: + result_box["result"] = orchestrator.orchestrate(request) + except BaseException as exc: # noqa: BLE001 - re-raised on main thread + result_box["error"] = exc + + with LiveTurnRenderer(console=console) as renderer: + _attach_sink(compose_event_sink(*_build_sinks([renderer.on_event]))) + try: + thread = threading.Thread(target=worker, name="fcli-orchestrate", daemon=True) + thread.start() + try: + renderer.drain_until_finished(worker=thread) + except KeyboardInterrupt: + thread.join(timeout=5.0) + raise + finally: + _attach_sink(None) + + error = result_box.get("error") + if isinstance(error, BaseException): + raise error + result = result_box.get("result") + if isinstance(result, OrchestrationResult): + return result + raise RuntimeError("Orchestration worker finished without a result.") + finally: + for transport in reversed(transport_stack): + with suppress(Exception): + transport.close() + + +class _NullContext: + def __enter__(self) -> None: + return None + + def __exit__(self, *_args: Any) -> None: + return None + + +def _resolve_cli_request_cwd(workspace_root: Path, cwd: Path | None) -> Path: + if cwd is None: + return workspace_root.resolve() + candidate = cwd if cwd.is_absolute() else workspace_root / cwd + return candidate.resolve() + + +def _record_run_history( + history_store: HistoryStore, + session_id: str, + *, + request: ShellCommandRequest, + request_cwd: Path, + result: ShellCommandResult | None, + error: str | None, + status: SessionStatus, +) -> None: + execution_status = "executed" if result is not None and result.ok else "failed" + history_store.record_command( + session_id, + action_id=None, + source="cli.run", + command=request.command, + args=list(request.args), + cwd=str(result.cwd if result is not None else request_cwd), + mode=result.mode.value if result is not None else request.mode.value, + policy_decision="allow", + policy_reason="The user invoked foundation run directly.", + risk_categories=[], + execution_status=execution_status, + exit_code=None if result is None else result.exit_code, + duration_seconds=None if result is None else result.duration_seconds, + stdout="" if result is None else result.stdout, + stderr="" if result is None else result.stderr, + stdout_truncated=False if result is None else result.stdout_truncated, + stderr_truncated=False if result is None else result.stderr_truncated, + error=error, + ) + + summary_text = ( + f"Executed shell command `{result.display_command}`." + if result is not None and result.ok + else error or f"Shell command `{shlex.join(request.argv)}` failed." + ) + history_store.record_summary( + session_id, + assistant_message=None, + summary_text=summary_text, + executed_actions=1 if result is not None and result.ok else 0, + pending_approval_actions=0, + blocked_actions=0, + failed_actions=0 if result is not None and result.ok else 1, + skipped_actions=0, + ) + history_store.finalize_session(session_id, status=status) diff --git a/tests/test_cli.py b/tests/test_cli.py index d991983..ad25138 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -20,9 +20,11 @@ AgentInvocationMode, CLIRequestRoute, FoundationGroup, + app, +) +from foundation.cli_interactive import ( _build_chat_prompt_session, _render_interactive_chat_help, - app, ) from foundation.models import ( ActionKind, @@ -642,7 +644,7 @@ def orchestrate(self, request: UserRequest) -> OrchestrationResult: return _chat_result(request.message) monkeypatch.setattr( - "foundation.cli._build_orchestrator", + "foundation.cli_runtime._build_orchestrator", lambda _settings, **_kwargs: StubOrchestrator(), ) @@ -693,7 +695,7 @@ def orchestrate(self, request: UserRequest) -> OrchestrationResult: return _chat_result_with_details(request.message) monkeypatch.setattr( - "foundation.cli._build_orchestrator", + "foundation.cli_runtime._build_orchestrator", lambda _settings, **_kwargs: StubOrchestrator(), ) @@ -722,7 +724,7 @@ def orchestrate(self, request: UserRequest) -> OrchestrationResult: return _chat_result_with_details(request.message) monkeypatch.setattr( - "foundation.cli._build_orchestrator", + "foundation.cli_runtime._build_orchestrator", lambda _settings, **_kwargs: StubOrchestrator(), ) @@ -783,7 +785,7 @@ def orchestrate(self, request: UserRequest) -> OrchestrationResult: return _chat_result_with_details(request.message) monkeypatch.setattr( - "foundation.cli._build_orchestrator", + "foundation.cli_runtime._build_orchestrator", lambda _settings, **_kwargs: StubOrchestrator(), ) @@ -817,7 +819,7 @@ def orchestrate(self, request: UserRequest) -> OrchestrationResult: return _chat_result_with_details(request.message) monkeypatch.setattr( - "foundation.cli._build_orchestrator", + "foundation.cli_runtime._build_orchestrator", lambda _settings, **_kwargs: StubOrchestrator(), ) @@ -856,7 +858,7 @@ def orchestrate(self, request: UserRequest) -> OrchestrationResult: return _chat_result_with_details(request.message) monkeypatch.setattr( - "foundation.cli._build_orchestrator", + "foundation.cli_runtime._build_orchestrator", lambda _settings, **_kwargs: StubOrchestrator(), ) @@ -889,7 +891,7 @@ def test_chat_without_request_starts_interactive_session( requests: list[UserRequest] = [] monkeypatch.setattr("foundation.settings.keyring.get_password", lambda *_args: None) monkeypatch.setattr( - "foundation.cli._build_chat_prompt_session", lambda _settings: prompt_session + "foundation.cli_interactive._build_chat_prompt_session", lambda _settings: prompt_session ) class StubOrchestrator: @@ -898,7 +900,7 @@ def orchestrate(self, request: UserRequest) -> OrchestrationResult: return _chat_result(request.message) monkeypatch.setattr( - "foundation.cli._build_orchestrator", + "foundation.cli_runtime._build_orchestrator", lambda _settings, **_kwargs: StubOrchestrator(), ) @@ -921,10 +923,10 @@ def test_chat_interactive_shell_prefix_routes_to_direct_shell_execution( captured_commands: list[str] = [] monkeypatch.setattr("foundation.settings.keyring.get_password", lambda *_args: None) monkeypatch.setattr( - "foundation.cli._build_chat_prompt_session", lambda _settings: prompt_session + "foundation.cli_interactive._build_chat_prompt_session", lambda _settings: prompt_session ) monkeypatch.setattr( - "foundation.cli._execute_repl_shell_command", + "foundation.cli_interactive._execute_repl_shell_command", lambda **kwargs: captured_commands.append(kwargs["raw_command"]), ) @@ -943,7 +945,7 @@ def test_chat_interactive_can_override_approval_mode( captured_modes: list[ApprovalMode | None] = [] monkeypatch.setattr("foundation.settings.keyring.get_password", lambda *_args: None) monkeypatch.setattr( - "foundation.cli._build_chat_prompt_session", lambda _settings: prompt_session + "foundation.cli_interactive._build_chat_prompt_session", lambda _settings: prompt_session ) class StubOrchestrator: @@ -954,7 +956,7 @@ def _stub_build_orchestrator(_settings: object, **kwargs: object) -> StubOrchest captured_modes.append(kwargs.get("approval_mode")) # type: ignore[arg-type] return StubOrchestrator() - monkeypatch.setattr("foundation.cli._build_orchestrator", _stub_build_orchestrator) + monkeypatch.setattr("foundation.cli_runtime._build_orchestrator", _stub_build_orchestrator) result = runner.invoke(app, ["--config", str(config_path), "chat"]) @@ -974,7 +976,7 @@ def test_chat_interactive_persists_transcript_across_turns_and_restarts( requests: list[UserRequest] = [] monkeypatch.setattr("foundation.settings.keyring.get_password", lambda *_args: None) monkeypatch.setattr( - "foundation.cli._build_chat_prompt_session", + "foundation.cli_interactive._build_chat_prompt_session", lambda _settings: prompt_sessions.pop(0), ) @@ -984,7 +986,7 @@ def orchestrate(self, request: UserRequest) -> OrchestrationResult: return _chat_result(request.message) monkeypatch.setattr( - "foundation.cli._build_orchestrator", + "foundation.cli_runtime._build_orchestrator", lambda _settings, **_kwargs: StubOrchestrator(), ) @@ -1021,7 +1023,7 @@ def test_chat_interactive_detail_commands_render_last_result( ) monkeypatch.setattr("foundation.settings.keyring.get_password", lambda *_args: None) monkeypatch.setattr( - "foundation.cli._build_chat_prompt_session", lambda _settings: prompt_session + "foundation.cli_interactive._build_chat_prompt_session", lambda _settings: prompt_session ) class StubOrchestrator: @@ -1029,7 +1031,7 @@ def orchestrate(self, request: UserRequest) -> OrchestrationResult: return _chat_result_with_details(request.message) monkeypatch.setattr( - "foundation.cli._build_orchestrator", + "foundation.cli_runtime._build_orchestrator", lambda _settings, **_kwargs: StubOrchestrator(), ) @@ -1050,7 +1052,7 @@ def test_chat_interactive_detail_commands_require_a_prior_result( prompt_session = FakePromptSession(["/summary", "/exit"]) monkeypatch.setattr("foundation.settings.keyring.get_password", lambda *_args: None) monkeypatch.setattr( - "foundation.cli._build_chat_prompt_session", lambda _settings: prompt_session + "foundation.cli_interactive._build_chat_prompt_session", lambda _settings: prompt_session ) result = runner.invoke(app, ["--config", str(config_path), "chat"]) @@ -1071,7 +1073,7 @@ def test_chat_interactive_reset_clears_persisted_transcript( requests: list[UserRequest] = [] monkeypatch.setattr("foundation.settings.keyring.get_password", lambda *_args: None) monkeypatch.setattr( - "foundation.cli._build_chat_prompt_session", + "foundation.cli_interactive._build_chat_prompt_session", lambda _settings: prompt_sessions.pop(0), ) @@ -1081,7 +1083,7 @@ def orchestrate(self, request: UserRequest) -> OrchestrationResult: return _chat_result(request.message) monkeypatch.setattr( - "foundation.cli._build_orchestrator", + "foundation.cli_runtime._build_orchestrator", lambda _settings, **_kwargs: StubOrchestrator(), ) @@ -1105,7 +1107,7 @@ def test_chat_interactive_persists_shell_turns_into_transcript( requests: list[UserRequest] = [] monkeypatch.setattr("foundation.settings.keyring.get_password", lambda *_args: None) monkeypatch.setattr( - "foundation.cli._build_chat_prompt_session", + "foundation.cli_interactive._build_chat_prompt_session", lambda _settings: prompt_sessions.pop(0), ) @@ -1115,7 +1117,7 @@ def orchestrate(self, request: UserRequest) -> OrchestrationResult: return _chat_result(request.message) monkeypatch.setattr( - "foundation.cli._build_orchestrator", + "foundation.cli_runtime._build_orchestrator", lambda _settings, **_kwargs: StubOrchestrator(), ) @@ -1141,7 +1143,7 @@ def test_chat_interactive_manual_approval_history_stays_pending( prompt_session = FakePromptSession(["!touch manual.txt", "/exit"]) monkeypatch.setattr("foundation.settings.keyring.get_password", lambda *_args: None) monkeypatch.setattr( - "foundation.cli._build_chat_prompt_session", + "foundation.cli_interactive._build_chat_prompt_session", lambda _settings: prompt_session, ) @@ -1226,7 +1228,7 @@ def test_chat_interactive_memory_command_updates_project_memory( ) monkeypatch.setattr("foundation.settings.keyring.get_password", lambda *_args: None) monkeypatch.setattr( - "foundation.cli._build_chat_prompt_session", + "foundation.cli_interactive._build_chat_prompt_session", lambda _settings: prompt_session, ) @@ -1248,7 +1250,7 @@ def test_chat_interactive_model_command_updates_subsequent_request( captured_models: list[str] = [] monkeypatch.setattr("foundation.settings.keyring.get_password", lambda *_args: None) monkeypatch.setattr( - "foundation.cli._build_chat_prompt_session", + "foundation.cli_interactive._build_chat_prompt_session", lambda _settings: prompt_session, ) @@ -1266,7 +1268,10 @@ def _stub_execute_chat_request( captured_models.append(cast(Any, settings).provider.model) return _chat_result(message) - monkeypatch.setattr("foundation.cli._execute_chat_request", _stub_execute_chat_request) + monkeypatch.setattr( + "foundation.cli_interactive._execute_chat_request", + _stub_execute_chat_request, + ) result = runner.invoke(app, ["--config", str(config_path), "chat"]) @@ -1287,7 +1292,7 @@ def test_chat_interactive_can_resume_specific_persistent_session( requests: list[UserRequest] = [] monkeypatch.setattr("foundation.settings.keyring.get_password", lambda *_args: None) monkeypatch.setattr( - "foundation.cli._build_chat_prompt_session", + "foundation.cli_interactive._build_chat_prompt_session", lambda _settings: prompt_sessions.pop(0), ) @@ -1312,7 +1317,10 @@ def _stub_execute_chat_request( ) return _chat_result(message) - monkeypatch.setattr("foundation.cli._execute_chat_request", _stub_execute_chat_request) + monkeypatch.setattr( + "foundation.cli_interactive._execute_chat_request", + _stub_execute_chat_request, + ) first_result = runner.invoke(app, ["--config", str(config_path), "chat", "--new"]) second_result = runner.invoke(app, ["--config", str(config_path), "chat", "--new"]) @@ -1440,7 +1448,7 @@ def test_render_interactive_chat_help_preserves_argument_placeholders( ) -> None: buffer = StringIO() test_console = Console(file=buffer, force_terminal=False, color_system=None, width=140) - monkeypatch.setattr("foundation.cli.console", test_console) + monkeypatch.setattr("foundation.cli_interactive.console", test_console) _render_interactive_chat_help() @@ -1559,7 +1567,7 @@ def test_bare_foundation_starts_interactive_session( prompt_session = FakePromptSession(["/exit"]) monkeypatch.setattr("foundation.settings.keyring.get_password", lambda *_args: None) monkeypatch.setattr( - "foundation.cli._build_chat_prompt_session", lambda _settings: prompt_session + "foundation.cli_interactive._build_chat_prompt_session", lambda _settings: prompt_session ) result = runner.invoke(app, ["--config", str(config_path)]) @@ -1583,7 +1591,7 @@ def orchestrate(self, request: UserRequest) -> OrchestrationResult: return _chat_result(request.message) monkeypatch.setattr( - "foundation.cli._build_orchestrator", + "foundation.cli_runtime._build_orchestrator", lambda _settings, **_kwargs: StubOrchestrator(), ) @@ -1612,7 +1620,7 @@ def orchestrate(self, request: UserRequest) -> OrchestrationResult: return _chat_result(request.message) monkeypatch.setattr( - "foundation.cli._build_orchestrator", + "foundation.cli_runtime._build_orchestrator", lambda _settings, **_kwargs: StubOrchestrator(), ) @@ -1699,7 +1707,7 @@ def orchestrate(self, request: UserRequest) -> OrchestrationResult: return _chat_result(request.message) monkeypatch.setattr( - "foundation.cli._build_orchestrator", + "foundation.cli_runtime._build_orchestrator", lambda _settings, **_kwargs: StubOrchestrator(), ) @@ -1722,7 +1730,7 @@ def test_chat_alias_parity_interactive( prompt_session = FakePromptSession(["/exit"]) monkeypatch.setattr("foundation.settings.keyring.get_password", lambda *_args: None) monkeypatch.setattr( - "foundation.cli._build_chat_prompt_session", lambda _settings: prompt_session + "foundation.cli_interactive._build_chat_prompt_session", lambda _settings: prompt_session ) result = runner.invoke(app, ["--config", str(config_path), "chat"]) @@ -1746,7 +1754,7 @@ def orchestrate(self, request: UserRequest) -> OrchestrationResult: return _chat_result(request.message) monkeypatch.setattr( - "foundation.cli._build_orchestrator", + "foundation.cli_runtime._build_orchestrator", lambda _settings, **_kwargs: StubOrchestrator(), ) @@ -1808,7 +1816,7 @@ def orchestrate(self, request: UserRequest) -> OrchestrationResult: return _chat_result(request.message) monkeypatch.setattr( - "foundation.cli._build_orchestrator", + "foundation.cli_runtime._build_orchestrator", lambda _settings, **_kwargs: StubOrchestrator(), ) @@ -1961,7 +1969,7 @@ def _iteration_result_for_notices( def test_iteration_changed_files_notice_lists_paths() -> None: - from foundation.cli import _iteration_changed_files_notice + from foundation.cli_rendering import _iteration_changed_files_notice result = _iteration_result_for_notices( changed_paths=["src/a.py", "src/b.py"], @@ -1974,7 +1982,7 @@ def test_iteration_changed_files_notice_lists_paths() -> None: def test_iteration_changed_files_notice_dedups_and_caps() -> None: - from foundation.cli import _iteration_changed_files_notice + from foundation.cli_rendering import _iteration_changed_files_notice # Build a result with execution_results spanning 10 unique writes plus a # duplicate, without tripping the per-plan 5-action cap. The notice @@ -1999,14 +2007,14 @@ def test_iteration_changed_files_notice_dedups_and_caps() -> None: def test_iteration_changed_files_notice_empty() -> None: - from foundation.cli import _iteration_changed_files_notice + from foundation.cli_rendering import _iteration_changed_files_notice result = _iteration_result_for_notices() assert _iteration_changed_files_notice(result) is None def test_awaiting_input_notice_surfaces_question() -> None: - from foundation.cli import _awaiting_input_notice + from foundation.cli_rendering import _awaiting_input_notice from foundation.models import LoopStopReason result = _iteration_result_for_notices(stop_reason=LoopStopReason.AWAITING_USER_INPUT) @@ -2025,7 +2033,7 @@ def test_awaiting_input_notice_surfaces_question() -> None: def test_awaiting_input_notice_none_without_stop_reason() -> None: - from foundation.cli import _awaiting_input_notice + from foundation.cli_rendering import _awaiting_input_notice result = _iteration_result_for_notices() assert _awaiting_input_notice(result) is None @@ -2036,7 +2044,7 @@ def test_prompt_for_question_selects_option_by_number( ) -> None: import typer as _typer - from foundation.cli import _prompt_for_question + from foundation.cli_runtime import _prompt_for_question from foundation.models import QuestionAction monkeypatch.setattr(_typer, "prompt", lambda *_a, **_k: "2") @@ -2049,7 +2057,7 @@ def test_prompt_for_question_accepts_free_text( ) -> None: import typer as _typer - from foundation.cli import _prompt_for_question + from foundation.cli_runtime import _prompt_for_question from foundation.models import QuestionAction monkeypatch.setattr(_typer, "prompt", lambda *_a, **_k: " use msgpack ") @@ -2058,7 +2066,7 @@ def test_prompt_for_question_accepts_free_text( def test_iteration_commands_notice_lists_and_dedups_consecutive() -> None: - from foundation.cli import _iteration_commands_notice + from foundation.cli_rendering import _iteration_commands_notice result = _iteration_result_for_notices( shell_commands=[ @@ -2074,7 +2082,7 @@ def test_iteration_commands_notice_lists_and_dedups_consecutive() -> None: def test_verification_outcome_notice_covers_all_outcomes() -> None: - from foundation.cli import _verification_outcome_notice + from foundation.cli_rendering import _verification_outcome_notice from foundation.models import VerificationOutcome passed = _iteration_result_for_notices( @@ -2120,7 +2128,7 @@ def test_verification_outcome_notice_covers_all_outcomes() -> None: def test_approval_required_notice_fires_only_for_pending_approval_stop() -> None: - from foundation.cli import _approval_required_notice + from foundation.cli_rendering import _approval_required_notice from foundation.models import LoopStopReason pending = _iteration_result_for_notices( diff --git a/tests/test_cli_gap_handoff.py b/tests/test_cli_gap_handoff.py index deb0eb0..6280b97 100644 --- a/tests/test_cli_gap_handoff.py +++ b/tests/test_cli_gap_handoff.py @@ -37,14 +37,14 @@ def test_handle_gap_handoff_report_writes_record( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: - from foundation import cli + from foundation import cli_runtime settings = load_settings(config_path=_write_stage_2_config(tmp_path)) handoff = _missing_capability_handoff() report_option = next(o for o in handoff.options if o.kind is GapOptionKind.REPORT) - monkeypatch.setattr(cli, "_prompt_gap_option", lambda _h: report_option) + monkeypatch.setattr(cli_runtime, "_prompt_gap_option", lambda _h: report_option) - follow_up = cli._handle_gap_handoff(handoff, settings=settings) + follow_up = cli_runtime._handle_gap_handoff(handoff, settings=settings) assert follow_up is None # reporting does not resume work gaps_dir = settings.app.state_dir / "gaps" @@ -56,14 +56,14 @@ def test_handle_gap_handoff_alternative_returns_follow_up( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: - from foundation import cli + from foundation import cli_runtime settings = load_settings(config_path=_write_stage_2_config(tmp_path)) handoff = _missing_capability_handoff() alt_option = next(o for o in handoff.options if o.kind is GapOptionKind.ALTERNATIVE) - monkeypatch.setattr(cli, "_prompt_gap_option", lambda _h: alt_option) + monkeypatch.setattr(cli_runtime, "_prompt_gap_option", lambda _h: alt_option) - follow_up = cli._handle_gap_handoff(handoff, settings=settings) + follow_up = cli_runtime._handle_gap_handoff(handoff, settings=settings) assert follow_up == alt_option.follow_up_request assert "store this in a database" in follow_up @@ -73,10 +73,10 @@ def test_handle_gap_handoff_declined_returns_none( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: - from foundation import cli + from foundation import cli_runtime settings = load_settings(config_path=_write_stage_2_config(tmp_path)) handoff = _missing_capability_handoff() - monkeypatch.setattr(cli, "_prompt_gap_option", lambda _h: None) + monkeypatch.setattr(cli_runtime, "_prompt_gap_option", lambda _h: None) - assert cli._handle_gap_handoff(handoff, settings=settings) is None + assert cli_runtime._handle_gap_handoff(handoff, settings=settings) is None diff --git a/tests/test_integration_e2e.py b/tests/test_integration_e2e.py index da0e90c..90586ff 100644 --- a/tests/test_integration_e2e.py +++ b/tests/test_integration_e2e.py @@ -336,7 +336,7 @@ def test_e2e_concise_and_verbose_presenter_parity_on_happy_path( monkeypatch: pytest.MonkeyPatch, ) -> None: """Happy path renders concise notices + verbose retains full detail.""" - from foundation.cli import _build_chat_turn_presentation + from foundation.cli_rendering import _build_chat_turn_presentation from foundation.models import ChatSurfacePolicy, RenderMode workspace = _git_workspace(tmp_path) From 878214fd882d76d9aa7c0b7e83deb1a0c1696315 Mon Sep 17 00:00:00 2001 From: Smoke Test Date: Thu, 4 Jun 2026 06:25:36 -0700 Subject: [PATCH 2/2] Add Codex subscription provider Add a codex provider backed by local Codex ChatGPT auth instead of an OpenAI API key. Update doctor/settings/docs for the codex provider and polish interactive chat prompt, padding, policy-block wording, and direct-answer tone. --- docs/TECHNICAL.md | 14 + docs/how-to-use.md | 31 +++ src/foundation/cli_interactive.py | 69 +++-- src/foundation/cli_rendering.py | 20 +- src/foundation/doctor.py | 12 +- src/foundation/services/__init__.py | 2 + src/foundation/services/orchestrator.py | 40 ++- src/foundation/services/planner.py | 3 + src/foundation/services/provider.py | 339 +++++++++++++++++++++++- src/foundation/settings.py | 17 +- tests/test_cli.py | 138 +++++++++- tests/test_orchestrator.py | 66 +++++ tests/test_provider.py | 162 +++++++++++ tests/test_settings.py | 23 ++ 14 files changed, 898 insertions(+), 38 deletions(-) diff --git a/docs/TECHNICAL.md b/docs/TECHNICAL.md index 3dd3c1d..df78a80 100644 --- a/docs/TECHNICAL.md +++ b/docs/TECHNICAL.md @@ -141,6 +141,7 @@ Provider selection does not need to live in environment variables. Persist `prov `provider.model`, `provider.base_url`, and `provider.request_timeout_seconds` in the TOML config, or override them per invocation with `--provider`, `--model`, `--base-url`, and `--provider-timeout`. Foundation CLI currently supports: +- `codex` via `codex exec`, reusing the local Codex ChatGPT login rather than an OpenAI API key - `openai` via the OpenAI Responses API - `ollama` via the Ollama Chat API, including local Ollama at `http://localhost:11434/api` and Ollama Cloud at `https://ollama.com/api` @@ -174,6 +175,19 @@ max_timeout_seconds = 3600 mode = "prompt" ``` +Example Codex / ChatGPT subscription config: + +```toml +[provider] +name = "codex" +model = "gpt-5.5" +request_timeout_seconds = 180 +``` + +This route shells out to `codex exec --json` in a read-only sandbox and reuses +the local Codex ChatGPT login. It does not read `OPENAI_API_KEY`; use the +`openai` provider only when you want direct OpenAI Platform API billing. + Example Ollama Cloud config: ```toml diff --git a/docs/how-to-use.md b/docs/how-to-use.md index 7fabf33..0d5e157 100644 --- a/docs/how-to-use.md +++ b/docs/how-to-use.md @@ -67,6 +67,37 @@ Then verify the setup: You want to see `Provider: ollama`, `Base URL: https://ollama.com/api`, and a secret lookup line saying credentials resolved from `$OLLAMA_API_KEY`. +## Codex / ChatGPT subscription + +To use your ChatGPT/Codex subscription instead of an OpenAI API key, sign in to +the Codex CLI with your ChatGPT account, then use the `codex` provider: + +```bash +codex +``` + +Choose ChatGPT sign-in when Codex prompts for authentication. Then configure +Foundation: + +```bash +cat > "$HOME/Library/Application Support/foundation/config.toml" <<'EOF' +[provider] +name = "codex" +model = "gpt-5.5" +request_timeout_seconds = 180 +EOF +``` + +Verify with: + +```bash +./scripts/uv run foundation doctor +``` + +You want to see `Provider: codex`, `Base URL: codex://local`, and a secret +lookup line saying the provider uses local Codex ChatGPT login. This route does +not use `OPENAI_API_KEY`. + ## OpenAI To use OpenAI instead, use this provider config: diff --git a/src/foundation/cli_interactive.py b/src/foundation/cli_interactive.py index c99ae7a..b9ee33d 100644 --- a/src/foundation/cli_interactive.py +++ b/src/foundation/cli_interactive.py @@ -3,6 +3,7 @@ from __future__ import annotations import shlex +import textwrap import uuid from dataclasses import dataclass from pathlib import Path @@ -83,6 +84,12 @@ _REPL_SHELL_PREFIX = "!" +_RECOVERED_TURN_PREVIEW_WIDTH = 80 + + +_REPL_PROMPT_STYLE = "#22c7d6 bold" + + _REPL_COMMAND_COMPLETIONS: dict[str, Any] = { "/actions": None, "/approval": { @@ -207,6 +214,14 @@ def _chat_history_path(settings: AppSettings) -> Path: return history_path +def _display_cwd(settings: AppSettings, state: InteractiveChatState) -> str: + try: + relative = state.current_cwd.relative_to(settings.workspace_root) + except ValueError: + return str(state.current_cwd) + return "." if str(relative) == "." else str(relative) + + def _settings_for_interactive_session( settings: AppSettings, state: InteractiveChatState, @@ -275,13 +290,23 @@ def _format_repl_cwd(workspace_root: Path, cwd: Path) -> str: return "." if str(relative) == "." else str(relative) -def _chat_prompt(state: InteractiveChatState, *, settings: AppSettings, plan_only: bool) -> str: - mode_suffix = " plan-only" if plan_only else "" - session_id = state.session_id[:8] - return ( - f"foundation[{session_id} {_format_repl_cwd(settings.workspace_root, state.current_cwd)} " - f"{state.approval_mode.value} {state.model}{mode_suffix}]> " - ) +def _chat_prompt( + state: InteractiveChatState, + *, + settings: AppSettings, + plan_only: bool, +) -> list[tuple[str, str]]: + cwd = _format_repl_cwd(settings.workspace_root, state.current_cwd) + cwd_suffix = "" if cwd == "." else f" {cwd}" + mode_suffix = " plan" if plan_only else "" + prompt: list[tuple[str, str]] = [ + ("", "\n "), + (_REPL_PROMPT_STYLE, "fcli"), + ] + if cwd_suffix or mode_suffix: + prompt.append(("", f"{cwd_suffix}{mode_suffix}")) + prompt.append((_REPL_PROMPT_STYLE, "> ")) + return prompt def _render_interactive_chat_help() -> None: @@ -349,28 +374,28 @@ def _render_interactive_chat_welcome( loaded_memories = ", ".join(_loaded_memory_labels(memory_envelope)) or "none" console.print( Panel.fit( - f"Session: [cyan]{escape(state.session_id)}[/cyan]\n" - f"Workspace root: [cyan]{escape(str(settings.workspace_root))}[/cyan]\n" - f"Request cwd: [cyan]{escape(str(state.current_cwd))}[/cyan]\n" - f"Approval mode: [cyan]{state.approval_mode.value}[/cyan]\n" - f"Provider: [cyan]{escape(state.provider_name)}[/cyan]\n" - f"Model: [cyan]{escape(state.model)}[/cyan]\n" - f"Render mode: [cyan]{render_mode.value}[/cyan]\n" - f"Plan-only default: [cyan]{plan_only_text}[/cyan]\n" - f"Loaded memory: [cyan]{escape(loaded_memories)}[/cyan]\n" - f"Prompt history: [cyan]{escape(str(_chat_history_path(settings)))}[/cyan]\n\n" - "Enter a request to use the planner, prefix with `!` for a direct shell command, " - "or use `/plan`, `/actions`, `/summary`, or `/help` for session commands.", + f"Session: [cyan]{escape(state.session_id[:8])}[/cyan] | " + f"cwd: [cyan]{escape(_display_cwd(settings, state))}[/cyan]\n" + f"Model: [cyan]{escape(state.provider_name)}/{escape(state.model)}[/cyan] | " + f"approval: [cyan]{state.approval_mode.value}[/cyan] | " + f"render: [cyan]{render_mode.value}[/cyan] | " + f"plan-only: [cyan]{plan_only_text}[/cyan]\n" + f"Memory: [cyan]{escape(loaded_memories)}[/cyan]\n" + "Commands: `!` shell | `/plan` `/actions` `/summary` details | `/help`", title="Interactive Chat", ) ) if state.recovered_from_interruption: - interrupted_turn = state.interrupted_turn or "unknown input" + interrupted_turn = textwrap.shorten( + state.interrupted_turn or "unknown input", + width=_RECOVERED_TURN_PREVIEW_WIDTH, + placeholder="...", + ) console.print( Text( ( - "Recovered the last clean checkpoint after an interrupted turn: " - f"{interrupted_turn}" + "Recovered after an interrupted turn; unfinished request was not rerun.\n" + f'Unfinished: "{interrupted_turn}"' ), style="yellow", ) diff --git a/src/foundation/cli_rendering.py b/src/foundation/cli_rendering.py index 23d3e11..4eb0bf0 100644 --- a/src/foundation/cli_rendering.py +++ b/src/foundation/cli_rendering.py @@ -56,6 +56,16 @@ _REPL_TRANSCRIPT_OUTPUT_PREVIEW_CHARACTERS = 1200 +_INTERACTIVE_CONCISE_OUTPUT_PADDING = " " + + +def _format_interactive_concise_text(text: str) -> str: + return "\n".join( + f"{_INTERACTIVE_CONCISE_OUTPUT_PADDING}{line}" if line else line + for line in text.splitlines() + ) + + def _render_placeholder(command_name: str, detail: str, settings: AppSettings) -> None: console.print( Panel.fit( @@ -756,7 +766,10 @@ def _render_concise_chat_turn( policy=policy, interactive=interactive, ) - console.print(presentation.primary_text) + primary_text = presentation.primary_text + if interactive: + primary_text = _format_interactive_concise_text(primary_text) + console.print(primary_text) style_map = { PresentationNoticeLevel.INFO: "cyan", PresentationNoticeLevel.WARNING: "yellow", @@ -764,7 +777,10 @@ def _render_concise_chat_turn( PresentationNoticeLevel.DIM: "dim", } for notice in presentation.notices: - console.print(Text(notice.text, style=style_map[notice.level])) + notice_text = notice.text + if interactive: + notice_text = _format_interactive_concise_text(notice_text) + console.print(Text(notice_text, style=style_map[notice.level])) def _render_verbose_chat_turn( diff --git a/src/foundation/doctor.py b/src/foundation/doctor.py index 34c3b6b..a9d222f 100644 --- a/src/foundation/doctor.py +++ b/src/foundation/doctor.py @@ -159,12 +159,12 @@ def _provider_readiness_check(settings: AppSettings) -> DoctorCheck: summary="Provider name is missing.", detail="Set [provider].name to a supported provider.", ) - if provider_name not in {"openai", "ollama"}: + if provider_name not in {"codex", "openai", "ollama"}: return DoctorCheck( name="Provider readiness", status=DoctorStatus.FAIL, summary=f"Provider {settings.provider.name!r} is not supported.", - detail="Supported providers: openai, ollama.", + detail="Supported providers: codex, openai, ollama.", ) if not settings.provider.model.strip(): return DoctorCheck( @@ -335,6 +335,14 @@ def _secret_lookup_check( *, environment: Mapping[str, str] | None = None, ) -> DoctorCheck: + if settings.provider.normalized_name() == "codex": + return DoctorCheck( + name="Secret lookup health", + status=DoctorStatus.PASS, + summary="Provider uses local Codex ChatGPT login; no OpenAI API key is required.", + detail="Credential sources: codex:chatgpt-login", + ) + resolution = settings.provider.resolve_api_key( environment=settings.provider_environment(environment), ) diff --git a/src/foundation/services/__init__.py b/src/foundation/services/__init__.py index 72c825a..19c01d8 100644 --- a/src/foundation/services/__init__.py +++ b/src/foundation/services/__init__.py @@ -24,6 +24,7 @@ ) from foundation.services.planner import PlannerService, PlanningError from foundation.services.provider import ( + CodexExecAdapter, OllamaChatAdapter, OpenAIResponsesAdapter, ProviderAdapter, @@ -79,6 +80,7 @@ "CapabilityResolver", "CapabilityStore", "CapabilityPolicyEngine", + "CodexExecAdapter", "ConversationCompactor", "ExecutionMode", "FileService", diff --git a/src/foundation/services/orchestrator.py b/src/foundation/services/orchestrator.py index 7923416..0034248 100644 --- a/src/foundation/services/orchestrator.py +++ b/src/foundation/services/orchestrator.py @@ -2052,6 +2052,16 @@ def _resolve_request_cwd(self, value: Path | None) -> Path: # Summary builder # ------------------------------------------------------------------ + @staticmethod + def _action_count(count: int, label: str) -> str: + suffix = "" if count == 1 else "s" + return f"{count} {label}{suffix}" + + @staticmethod + def _approval_count(count: int) -> str: + suffix = "" if count == 1 else "s" + return f"{count} pending approval{suffix}" + @staticmethod def _build_summary( iterations: list[OrchestrationIteration], @@ -2075,17 +2085,33 @@ def _build_summary( "was requested." ) else: - parts = [f"Executed {executed} action(s)"] + stop_parts: list[str] = [] if pending: - parts.append(f"{pending} pending approval") + stop_parts.append(RequestOrchestrator._approval_count(pending)) if failed: - parts.append(f"{failed} failed") + stop_parts.append(f"{failed} failed") if blocked: - parts.append(f"{blocked} blocked") + stop_parts.append(f"{blocked} blocked by policy") if skipped: - parts.append(f"{skipped} skipped") - text = ", ".join(parts) + "." - if len(iterations) > 1: + stop_parts.append(f"{skipped} skipped") + + if blocked or failed: + if executed: + text = ( + "Stopped after " + f"{RequestOrchestrator._action_count(executed, 'completed action')}; " + f"{', '.join(stop_parts)}." + ) + else: + text = f"Stopped: {', '.join(stop_parts)}." + else: + parts = [f"Executed {RequestOrchestrator._action_count(executed, 'action')}"] + if pending: + parts.append(RequestOrchestrator._approval_count(pending)) + if skipped: + parts.append(f"{skipped} skipped") + text = ", ".join(parts) + "." + if len(iterations) > 1 and not (blocked or failed): text += f" ({len(iterations)} iterations)" return OrchestrationSummary( diff --git a/src/foundation/services/planner.py b/src/foundation/services/planner.py index 5cdb0ef..2a30ed8 100644 --- a/src/foundation/services/planner.py +++ b/src/foundation/services/planner.py @@ -437,6 +437,9 @@ def _base_plan_messages( "user can provide, emit a single `question` action (kind=question) with a clear " "prompt and optional options, rather than guessing. Use this sparingly. " "If the user can be answered directly, return zero actions with your final answer. " + "For direct-answer zero-action turns, keep the answer concise and task-focused. " + "Do not mirror casual address terms from the user (for example, buddy, bro, boss, " + "sir, mate) unless the user explicitly asks you to use that style. " "When returning zero actions to finish, your assistant_message " "becomes the user-facing answer. " "Available capability snapshot:\n" diff --git a/src/foundation/services/provider.py b/src/foundation/services/provider.py index 6f87a9f..14caf01 100644 --- a/src/foundation/services/provider.py +++ b/src/foundation/services/provider.py @@ -5,9 +5,12 @@ import json import logging import re +import subprocess +import tempfile import time from collections.abc import Mapping from enum import StrEnum +from pathlib import Path from typing import Any, Protocol from urllib import error as urllib_error from urllib import request as urllib_request @@ -86,6 +89,20 @@ def post_json( """POST a JSON payload and return a decoded JSON object.""" +class CodexRunner(Protocol): + """Minimal process runner contract for Codex CLI execution.""" + + def run( + self, + args: list[str], + *, + cwd: Path, + input_text: str, + timeout_seconds: int, + ) -> subprocess.CompletedProcess[str]: + """Run Codex and return its completed process result.""" + + class UrllibJsonTransport: """Stdlib JSON transport for the OpenAI responses endpoint.""" @@ -166,6 +183,28 @@ def post_json( return decoded +class SubprocessCodexRunner: + """Subprocess-backed runner for `codex exec`.""" + + def run( + self, + args: list[str], + *, + cwd: Path, + input_text: str, + timeout_seconds: int, + ) -> subprocess.CompletedProcess[str]: + return subprocess.run( + args, + cwd=cwd, + input=input_text, + text=True, + capture_output=True, + timeout=timeout_seconds, + check=False, + ) + + def _provider_error_message(body: str) -> str | None: if not body.strip(): return None @@ -431,6 +470,171 @@ def _parse_json_object(self, text: str) -> dict[str, Any]: return payload +class CodexExecAdapter: + """Codex CLI adapter that reuses local ChatGPT/Codex authentication.""" + + def __init__( + self, + *, + model: str, + workspace_root: Path, + timeout_seconds: int = 60, + sandbox: str = "read-only", + runner: CodexRunner | None = None, + ) -> None: + self._model = model + self._workspace_root = workspace_root + self._timeout_seconds = timeout_seconds + self._sandbox = sandbox + self._runner = runner or SubprocessCodexRunner() + + def complete(self, prompt: ProviderPrompt) -> ProviderResponse: + started_at = time.monotonic() + with tempfile.TemporaryDirectory(prefix="foundation-codex-") as temp_dir_name: + temp_dir = Path(temp_dir_name) + output_path = temp_dir / "final-message.txt" + args = [ + "codex", + "exec", + "--json", + "--ephemeral", + "--sandbox", + self._sandbox, + "--model", + self._model, + "--output-last-message", + str(output_path), + ] + prompt_schema: Mapping[str, Any] | None = None + if prompt.response_format is ProviderResponseFormat.JSON_OBJECT: + assert prompt.output_schema is not None + if _codex_supports_output_schema(prompt.output_schema): + schema_path = temp_dir / "schema.json" + schema_path.write_text( + json.dumps(_codex_strict_schema(prompt.output_schema)), + encoding="utf-8", + ) + args.extend(["--output-schema", str(schema_path)]) + else: + prompt_schema = prompt.output_schema + input_text = self._render_prompt(prompt, prompt_schema=prompt_schema) + args.append("-") + + try: + completed = self._runner.run( + args, + cwd=self._workspace_root, + input_text=input_text, + timeout_seconds=self._timeout_seconds, + ) + except FileNotFoundError as exc: + raise ProviderError( + "Codex CLI was not found on PATH. Install Codex and sign in with ChatGPT.", + code=ProviderErrorCode.BAD_REQUEST, + ) from exc + except subprocess.TimeoutExpired as exc: + raise ProviderError( + f"Codex CLI request timed out after {self._timeout_seconds}s.", + code=ProviderErrorCode.NETWORK, + retryable=True, + ) from exc + except OSError as exc: + raise ProviderError( + f"Codex CLI request failed to start: {exc}", + code=ProviderErrorCode.NETWORK, + retryable=True, + ) from exc + + if completed.returncode != 0: + raise _codex_process_error(completed) + + content = self._read_final_message(output_path, completed.stdout) + structured_output: dict[str, Any] | None = None + if prompt.response_format is ProviderResponseFormat.JSON_OBJECT: + structured_output = self._parse_json_object(content) + + return ProviderResponse( + content=content, + structured_output=structured_output, + metadata=ProviderResponseMetadata( + provider="codex", + model=self._model, + response_id=_extract_codex_thread_id(completed.stdout), + latency_seconds=time.monotonic() - started_at, + attempts=1, + usage=_parse_codex_usage(completed.stdout), + ), + ) + + def _render_prompt( + self, + prompt: ProviderPrompt, + *, + prompt_schema: Mapping[str, Any] | None = None, + ) -> str: + lines = [ + "You are acting as a model provider adapter for Foundation CLI.", + "Answer the conversation below without editing files or running commands.", + ] + if prompt.response_format is ProviderResponseFormat.JSON_OBJECT: + if prompt_schema is None: + lines.append( + "The final response must be a JSON object that matches the supplied schema." + ) + else: + lines.extend( + [ + "The final response must be a JSON object that matches this JSON Schema:", + json.dumps(prompt_schema, indent=2), + ] + ) + lines.append("") + for message in prompt.messages: + lines.extend( + [ + f"<{message.role.value}>", + message.content, + f"", + "", + ] + ) + return "\n".join(lines).strip() + + def _read_final_message(self, output_path: Path, stdout: str) -> str: + if output_path.exists(): + content = output_path.read_text(encoding="utf-8").strip() + if content: + return content + fallback_content = _extract_codex_agent_message(stdout) + if fallback_content: + return fallback_content + raise ProviderError( + "Codex CLI returned no final assistant message.", + code=ProviderErrorCode.INVALID_RESPONSE, + response_text=stdout, + ) + + def _parse_json_object(self, text: str) -> dict[str, Any]: + cleaned = _try_extract_json(text) + try: + payload = json.loads(cleaned) + except json.JSONDecodeError as exc: + raise ProviderError( + "Codex CLI returned invalid JSON for a structured response. " + f"Raw (first 300 chars): {text[:300]!r}", + code=ProviderErrorCode.INVALID_RESPONSE, + response_text=text, + ) from exc + if not isinstance(payload, dict): + raise ProviderError( + "Codex CLI returned structured output that was not a JSON object. " + f"Raw (first 300 chars): {text[:300]!r}", + code=ProviderErrorCode.INVALID_RESPONSE, + response_text=text, + ) + return payload + + class OllamaChatAdapter: """Ollama chat API adapter with retry handling and structured output support.""" @@ -787,6 +991,130 @@ def _coerce_optional_string(value: object) -> str | None: return str(value) +def _codex_process_error( + completed: subprocess.CompletedProcess[str], +) -> ProviderError: + stderr = (completed.stderr or "").strip() + stdout = (completed.stdout or "").strip() + codex_error = _extract_codex_error_message(stdout) + detail = ( + codex_error or stderr or stdout or f"Codex CLI exited with status {completed.returncode}." + ) + lowered = detail.lower() + if any(token in lowered for token in ("auth", "login", "sign in", "unauthorized", "401")): + code = ProviderErrorCode.AUTHENTICATION + retryable = False + elif any(token in lowered for token in ("rate limit", "usage limit", "quota", "429")): + code = ProviderErrorCode.RATE_LIMIT + retryable = True + else: + code = ProviderErrorCode.SERVER_ERROR + retryable = completed.returncode >= 2 + return ProviderError( + detail, + code=code, + retryable=retryable, + response_text="\n".join(part for part in (stdout, stderr) if part), + ) + + +def _iter_codex_events(stdout: str) -> list[Mapping[str, Any]]: + events: list[Mapping[str, Any]] = [] + for line in stdout.splitlines(): + stripped = line.strip() + if not stripped: + continue + try: + payload = json.loads(stripped) + except json.JSONDecodeError: + continue + if isinstance(payload, Mapping): + events.append(payload) + return events + + +def _codex_strict_schema(schema: Mapping[str, Any]) -> dict[str, Any]: + def strict_copy(value: object) -> object: + if isinstance(value, Mapping): + copied = { + str(key): strict_copy(item) for key, item in value.items() if str(key) != "default" + } + if copied.get("type") == "object" or isinstance(copied.get("properties"), Mapping): + copied.setdefault("additionalProperties", False) + properties = copied.get("properties") + if isinstance(properties, Mapping): + copied["required"] = list(properties.keys()) + return copied + if isinstance(value, list): + return [strict_copy(item) for item in value] + return value + + copied_schema = strict_copy(schema) + assert isinstance(copied_schema, dict) + return copied_schema + + +def _codex_supports_output_schema(schema: Mapping[str, Any]) -> bool: + def supports(value: object) -> bool: + if isinstance(value, Mapping): + additional_properties = value.get("additionalProperties") + if additional_properties not in (None, False): + return False + return all(supports(item) for item in value.values()) + if isinstance(value, list): + return all(supports(item) for item in value) + return True + + return supports(schema) + + +def _extract_codex_thread_id(stdout: str) -> str | None: + for event in _iter_codex_events(stdout): + thread_id = event.get("thread_id") + if isinstance(thread_id, str) and thread_id: + return thread_id + return None + + +def _extract_codex_agent_message(stdout: str) -> str | None: + for event in reversed(_iter_codex_events(stdout)): + if event.get("type") != "item.completed": + continue + item = event.get("item") + if not isinstance(item, Mapping) or item.get("type") != "agent_message": + continue + text = item.get("text") + if isinstance(text, str) and text.strip(): + return text.strip() + return None + + +def _extract_codex_error_message(stdout: str) -> str | None: + for event in reversed(_iter_codex_events(stdout)): + event_type = event.get("type") + if event_type == "error": + message = event.get("message") + if isinstance(message, str) and message.strip(): + return message.strip() + if event_type == "turn.failed": + error_payload = event.get("error") + if isinstance(error_payload, Mapping): + message = error_payload.get("message") + if isinstance(message, str) and message.strip(): + return message.strip() + return None + + +def _parse_codex_usage(stdout: str) -> ProviderUsage | None: + for event in reversed(_iter_codex_events(stdout)): + if event.get("type") != "turn.completed": + continue + usage = _parse_usage(event.get("usage")) + if usage is not None: + return usage + return None + + def _parse_usage(value: object) -> ProviderUsage | None: if not isinstance(value, Mapping): return None @@ -832,15 +1160,22 @@ def build_provider_adapter( ) -> ProviderAdapter: """Build the configured provider adapter for Stage 5.""" provider_name = settings.provider.normalized_name() - if provider_name not in {"openai", "ollama"}: + if provider_name not in {"codex", "openai", "ollama"}: raise ProviderError( ( f"Provider {settings.provider.name!r} is not supported in Foundation CLI v0.1. " - "Supported providers: openai, ollama." + "Supported providers: codex, openai, ollama." ), code=ProviderErrorCode.UNSUPPORTED_PROVIDER, ) + if provider_name == "codex": + return CodexExecAdapter( + model=settings.provider.model, + workspace_root=settings.workspace_root, + timeout_seconds=settings.provider.request_timeout_seconds, + ) + resolution = settings.provider.resolve_api_key( environment=settings.provider_environment(), ) diff --git a/src/foundation/settings.py b/src/foundation/settings.py index 8ca56e5..0f3625c 100644 --- a/src/foundation/settings.py +++ b/src/foundation/settings.py @@ -34,6 +34,7 @@ ENV_PREFIX = "FOUNDATION_" OPENAI_DEFAULT_BASE_URL = "https://api.openai.com/v1" OLLAMA_DEFAULT_BASE_URL = "http://localhost:11434/api" +CODEX_DEFAULT_BASE_URL = "codex://local" OPENAI_DEFAULT_API_KEY_ENV_VAR = "OPENAI_API_KEY" OLLAMA_DEFAULT_API_KEY_ENV_VAR = "OLLAMA_API_KEY" DEFAULT_KEYCHAIN_SERVICE = "foundation" @@ -44,13 +45,17 @@ def _provider_default_base_url(provider_name: str) -> str: normalized = provider_name.strip().lower() + if normalized == "codex": + return CODEX_DEFAULT_BASE_URL if normalized == "ollama": return OLLAMA_DEFAULT_BASE_URL return OPENAI_DEFAULT_BASE_URL -def _provider_default_api_key_env_var(provider_name: str) -> str: +def _provider_default_api_key_env_var(provider_name: str) -> str | None: normalized = provider_name.strip().lower() + if normalized == "codex": + return None if normalized == "ollama": return OLLAMA_DEFAULT_API_KEY_ENV_VAR return OPENAI_DEFAULT_API_KEY_ENV_VAR @@ -211,10 +216,14 @@ def normalized_name(self) -> str: def effective_base_url(self) -> str: """Return the provider base URL with the provider default endpoint applied.""" + if self.normalized_name() == "codex": + return CODEX_DEFAULT_BASE_URL return str(self.base_url or _provider_default_base_url(self.normalized_name())) def effective_api_key_env_var(self) -> str | None: """Return the provider credential environment variable.""" + if self.normalized_name() == "codex": + return None if self.api_key_env_var is None: return None if ( @@ -226,6 +235,8 @@ def effective_api_key_env_var(self) -> str | None: def effective_api_key_keychain(self) -> KeychainSecretRef | None: """Return the provider credential keychain coordinates.""" + if self.normalized_name() == "codex": + return None if self.api_key_keychain is None: return None if ( @@ -238,12 +249,16 @@ def effective_api_key_keychain(self) -> KeychainSecretRef | None: def credentials_required(self) -> bool: """Return whether the configured provider requires credentials.""" + if self.normalized_name() == "codex": + return False if self.normalized_name() == "ollama": return not _is_local_base_url(self.effective_base_url()) return True def credential_source_order(self) -> list[str]: """Return the configured secret source priority.""" + if self.normalized_name() == "codex": + return ["codex:chatgpt-login"] sources: list[str] = [] keychain_ref = self.effective_api_key_keychain() if keychain_ref is not None: diff --git a/tests/test_cli.py b/tests/test_cli.py index ad25138..a53c8c8 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -23,24 +23,29 @@ app, ) from foundation.cli_interactive import ( + InteractiveChatState, _build_chat_prompt_session, + _chat_prompt, _render_interactive_chat_help, ) from foundation.models import ( ActionKind, AssistantMessage, AssistantPlan, + BrainSession, ContextSnapshot, ExecutionArtifactType, ExecutionResult, ExecutionStatus, ExecutionStep, + LoopStopReason, OrchestrationResult, OrchestrationSummary, PlannedAction, PlanningStep, ProviderMessageRole, ProviderResponseMetadata, + RenderMode, SelectionReason, SessionKind, SessionStatus, @@ -292,9 +297,9 @@ def _seed_trace_session(config_path: Path) -> str: class FakePromptSession: def __init__(self, responses: list[str]) -> None: self._responses = list(responses) - self.prompts: list[str] = [] + self.prompts: list[Any] = [] - def prompt(self, message: str) -> str: + def prompt(self, message: Any) -> str: self.prompts.append(message) if not self._responses: raise EOFError @@ -488,6 +493,29 @@ def test_doctor_allows_local_ollama_without_credentials( assert "Provider credentials are optional" in result.stdout +def test_doctor_allows_codex_chatgpt_login_without_openai_api_key( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + config_path = _write_stage_2_config(tmp_path) + config_path.write_text( + config_path.read_text(encoding="utf-8") + + '\n[provider]\nname = "codex"\nmodel = "gpt-5.5"\n', + encoding="utf-8", + ) + monkeypatch.setattr("foundation.settings.keyring.get_password", lambda *_args: None) + + result = runner.invoke(app, ["--config", str(config_path), "doctor"]) + + assert result.exit_code == 0 + assert "Provider: codex" in result.stdout + assert "Model: gpt-5.5" in result.stdout + assert "Base URL: codex://local" in result.stdout + assert "Credentials required: no" in result.stdout + assert "local Codex ChatGPT login" in result.stdout + assert "API key is required" in result.stdout + + def test_doctor_reads_ollama_cloud_credentials_from_paired_env_file( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, @@ -912,6 +940,8 @@ def orchestrate(self, request: UserRequest) -> OrchestrationResult: assert requests[0].conversation_history == [] assert requests[0].cwd == (tmp_path / "workspace") assert "Interactive Chat" in result.stdout + assert "Commands:" in result.stdout + assert "Prompt history:" not in result.stdout def test_chat_interactive_shell_prefix_routes_to_direct_shell_execution( @@ -1162,6 +1192,47 @@ def test_chat_interactive_manual_approval_history_stays_pending( assert payload[0]["pending_approval_actions"] == 1 +def test_chat_recovery_notice_does_not_replay_full_interrupted_request( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + config_path = _write_stage_2_config(tmp_path) + prompt_session = FakePromptSession(["/exit"]) + monkeypatch.setattr("foundation.settings.keyring.get_password", lambda *_args: None) + monkeypatch.setattr( + "foundation.cli_interactive._build_chat_prompt_session", + lambda _settings: prompt_session, + ) + settings = load_settings(config_path=config_path) + session_manager = SessionManager( + database_path=settings.app.state_dir / "chat-sessions.sqlite3", + workspace_root=settings.workspace_root, + config_dir=settings.config_path.parent, + provider_name=settings.provider.name, + ) + session = session_manager.create_session( + initial_cwd=settings.workspace_root, + approval_mode="prompt", + model=settings.provider.model, + ) + interrupted = ( + "can you help me with learning; i want you to create a github cheat sheet " + "in the developer dir with most common 20 commands ;" + ) + session_manager.mark_turn_started( + session, + turn_kind="chat", + user_message=interrupted, + ) + + result = runner.invoke(app, ["--config", str(config_path), "chat"]) + + assert result.exit_code == 0 + assert "Recovered after an interrupted turn; unfinished request was not rerun." in result.stdout + assert "Recovered the last clean checkpoint" not in result.stdout + assert interrupted not in result.stdout + + def test_trace_command_lists_seeded_traces_as_json( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, @@ -1443,6 +1514,69 @@ def __init__(self, current_buffer: FakeBuffer) -> None: assert newline_buffer.inserted_text == ["\n"] +def test_chat_prompt_is_compact_with_padding_and_line_gap(tmp_path: Path) -> None: + config_path = _write_stage_2_config(tmp_path) + settings = load_settings(config_path=config_path) + session = BrainSession( + session_id="ac46fdcd1144487c9013b1262416974e", + workspace_root=str(settings.workspace_root), + initial_cwd=str(settings.workspace_root), + current_cwd=str(settings.workspace_root), + approval_mode="prompt", + provider_name="ollama", + model="glm-5.1:cloud", + summary_text="", + recent_turns=[], + turn_count=0, + created_at="2026-06-04T00:00:00Z", + updated_at="2026-06-04T00:00:00Z", + ) + state = InteractiveChatState(session=session) + + assert _chat_prompt(state, settings=settings, plan_only=False) == [ + ("", "\n "), + ("#22c7d6 bold", "fcli"), + ("#22c7d6 bold", "> "), + ] + + src_dir = settings.workspace_root / "src" + src_dir.mkdir() + state.current_cwd = src_dir + + assert _chat_prompt(state, settings=settings, plan_only=True) == [ + ("", "\n "), + ("#22c7d6 bold", "fcli"), + ("", " src plan"), + ("#22c7d6 bold", "> "), + ] + + +def test_interactive_concise_chat_turn_indents_output( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from foundation.cli_rendering import _render_concise_chat_turn + from foundation.models import ChatSurfacePolicy + + buffer = StringIO() + test_console = Console(file=buffer, force_terminal=False, color_system=None, width=140) + monkeypatch.setattr("foundation.cli_rendering.console", test_console) + result = _iteration_result_for_notices( + stop_reason=LoopStopReason.PENDING_APPROVAL, + pending_approval_actions=1, + ) + + _render_concise_chat_turn( + result, + policy=ChatSurfacePolicy(render_mode=RenderMode.CONCISE), + interactive=True, + ) + + output = buffer.getvalue() + lines = output.splitlines() + assert " Done." in lines + assert any(line.startswith(" Approval required for 1 action.") for line in lines) + + def test_render_interactive_chat_help_preserves_argument_placeholders( monkeypatch: pytest.MonkeyPatch, ) -> None: diff --git a/tests/test_orchestrator.py b/tests/test_orchestrator.py index eb8097a..22163ae 100644 --- a/tests/test_orchestrator.py +++ b/tests/test_orchestrator.py @@ -1017,6 +1017,45 @@ def test_out_of_scope_shell_write_is_terminal_policy_block( assert not outside.exists() +def test_terminal_policy_block_summary_reads_as_stopped_not_success_accounting( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + outside = tmp_path / "outside-shell-dir" + provider = StubProvider( + [ + _provider_response( + { + "assistant_message": "Checking cwd before creating the directory.", + "actions": [ + { + "id": "show_cwd", + "kind": "shell", + "summary": "Show the current directory", + "shell": {"command": "pwd", "args": []}, + }, + { + "id": "mkdir_outside", + "kind": "shell", + "summary": "Create an outside directory", + "shell": {"command": "mkdir", "args": [str(outside)]}, + }, + ], + } + ) + ] + ) + orchestrator, runtime, _ = _orchestrator(tmp_path, monkeypatch, provider) + + result = orchestrator.orchestrate(UserRequest(message=f"check cwd then make {outside}")) + + assert result.stop_reason is LoopStopReason.TERMINAL_POLICY_BLOCK + assert runtime.calls == 1 + assert result.summary.executed_actions == 1 + assert result.summary.blocked_actions == 1 + assert result.summary.text == "Stopped after 1 completed action; 1 blocked by policy." + + def test_planner_rejects_relative_parent_directory_write_for_developer_request( tmp_path: Path, ) -> None: @@ -1848,6 +1887,33 @@ def test_zero_action_first_iteration_explanation_only( assert result.summary.executed_actions == 0 +def test_planner_prompt_discourages_mirroring_casual_address( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + provider = StubProvider( + [ + _provider_response( + { + "assistant_message": "Ready when you are.", + "actions": [], + } + ), + ] + ) + orchestrator, _, _workspace_root = _orchestrator( + tmp_path, + monkeypatch, + provider, + ) + + orchestrator.orchestrate(UserRequest(message="hew how you doing ? buddy")) + + prompt_text = provider.calls[0].messages[0].content + assert "Do not mirror casual address terms from the user" in prompt_text + assert "buddy" in prompt_text + + def test_pending_approval_stops_loop( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, diff --git a/tests/test_provider.py b/tests/test_provider.py index acae097..c70509c 100644 --- a/tests/test_provider.py +++ b/tests/test_provider.py @@ -1,6 +1,9 @@ from __future__ import annotations +import json +import subprocess from collections.abc import Mapping +from pathlib import Path from typing import Any import pytest @@ -12,12 +15,15 @@ ProviderResponseFormat, ) from foundation.services.provider import ( + CodexExecAdapter, OllamaChatAdapter, OpenAIResponsesAdapter, ProviderError, ProviderErrorCode, _try_extract_json, + build_provider_adapter, ) +from foundation.settings import AppSettings class FakeTransport: @@ -47,6 +53,42 @@ def post_json( return response +class FakeCodexRunner: + def __init__(self, final_message: str) -> None: + self.final_message = final_message + self.calls: list[dict[str, Any]] = [] + + def run( + self, + args: list[str], + *, + cwd: Path, + input_text: str, + timeout_seconds: int, + ) -> subprocess.CompletedProcess[str]: + output_path = Path(args[args.index("--output-last-message") + 1]) + schema = None + if "--output-schema" in args: + schema_path = Path(args[args.index("--output-schema") + 1]) + schema = json.loads(schema_path.read_text(encoding="utf-8")) + self.calls.append( + { + "args": list(args), + "cwd": cwd, + "input_text": input_text, + "timeout_seconds": timeout_seconds, + "schema": schema, + } + ) + output_path.write_text(self.final_message, encoding="utf-8") + return subprocess.CompletedProcess( + args=args, + returncode=0, + stdout='{"type":"thread.started"}\n{"type":"turn.completed","usage":{"input_tokens":10,"output_tokens":5,"total_tokens":15}}\n', + stderr="", + ) + + def _structured_prompt() -> ProviderPrompt: return ProviderPrompt( messages=[ @@ -61,6 +103,126 @@ def _structured_prompt() -> ProviderPrompt: ) +def test_codex_adapter_runs_codex_exec_with_chatgpt_managed_auth( + tmp_path: Path, +) -> None: + runner = FakeCodexRunner('{"assistant_message":"hello","actions":[]}') + adapter = CodexExecAdapter( + model="gpt-5.5", + workspace_root=tmp_path, + timeout_seconds=90, + runner=runner, + ) + prompt = ProviderPrompt( + messages=[ + ProviderMessage( + role=ProviderMessageRole.USER, + content="Plan this request.", + ) + ], + response_format=ProviderResponseFormat.JSON_OBJECT, + schema_name="assistant_plan", + output_schema={ + "type": "object", + "properties": { + "assistant_message": {"type": "string"}, + "actions": {"type": "array"}, + "mode": {"$ref": "#/$defs/Mode", "default": "auto"}, + "arguments": {"properties": {"path": {"type": "string"}}}, + }, + "$defs": {"Mode": {"type": "string", "enum": ["auto", "manual"]}}, + }, + ) + + response = adapter.complete(prompt) + + assert response.structured_output == {"assistant_message": "hello", "actions": []} + assert response.metadata.provider == "codex" + assert response.metadata.model == "gpt-5.5" + assert response.metadata.usage is not None + assert response.metadata.usage.total_tokens == 15 + assert runner.calls[0]["cwd"] == tmp_path + assert runner.calls[0]["timeout_seconds"] == 90 + assert "\nPlan this request.\n" in runner.calls[0]["input_text"] + assert runner.calls[0]["schema"] == { + "type": "object", + "properties": { + "assistant_message": {"type": "string"}, + "actions": {"type": "array"}, + "mode": {"$ref": "#/$defs/Mode"}, + "arguments": { + "properties": {"path": {"type": "string"}}, + "additionalProperties": False, + "required": ["path"], + }, + }, + "$defs": {"Mode": {"type": "string", "enum": ["auto", "manual"]}}, + "additionalProperties": False, + "required": ["assistant_message", "actions", "mode", "arguments"], + } + assert runner.calls[0]["args"][-1] == "-" + assert runner.calls[0]["args"][:8] == [ + "codex", + "exec", + "--json", + "--ephemeral", + "--sandbox", + "read-only", + "--model", + "gpt-5.5", + ] + + +def test_build_provider_adapter_selects_codex_without_openai_api_key( + tmp_path: Path, +) -> None: + settings = AppSettings( + app={"workspace_root": tmp_path}, + provider={"name": "codex", "model": "gpt-5.5"}, + ) + + adapter = build_provider_adapter(settings) + + assert isinstance(adapter, CodexExecAdapter) + + +def test_codex_adapter_omits_output_schema_for_open_ended_json_schema( + tmp_path: Path, +) -> None: + runner = FakeCodexRunner('{"arguments":{"path":"README.md"}}') + adapter = CodexExecAdapter( + model="gpt-5.5", + workspace_root=tmp_path, + runner=runner, + ) + prompt = ProviderPrompt( + messages=[ + ProviderMessage( + role=ProviderMessageRole.USER, + content="Plan this request.", + ) + ], + response_format=ProviderResponseFormat.JSON_OBJECT, + schema_name="assistant_plan", + output_schema={ + "type": "object", + "properties": { + "arguments": { + "type": "object", + "additionalProperties": True, + } + }, + }, + ) + + response = adapter.complete(prompt) + + assert response.structured_output == {"arguments": {"path": "README.md"}} + assert "--output-schema" not in runner.calls[0]["args"] + assert runner.calls[0]["schema"] is None + assert '"additionalProperties": true' in runner.calls[0]["input_text"] + + def test_openai_adapter_parses_structured_output_and_usage() -> None: transport = FakeTransport( [ diff --git a/tests/test_settings.py b/tests/test_settings.py index 0fa0f07..528c7f5 100644 --- a/tests/test_settings.py +++ b/tests/test_settings.py @@ -200,6 +200,29 @@ def test_ollama_provider_uses_provider_specific_defaults( assert settings.provider.credentials_required() is False +def test_codex_provider_uses_chatgpt_managed_auth( + tmp_path: Path, +) -> None: + config_path = tmp_path / "config.toml" + config_path.write_text( + "\n".join( + [ + "[provider]", + 'name = "codex"', + 'model = "gpt-5.5"', + 'base_url = "https://api.openai.com/v1"', + ] + ), + encoding="utf-8", + ) + + settings = load_settings(config_path=config_path) + + assert settings.provider.effective_base_url() == "codex://local" + assert settings.provider.credential_source_order() == ["codex:chatgpt-login"] + assert settings.provider.credentials_required() is False + + def test_load_settings_rejects_invalid_toml(tmp_path: Path) -> None: config_path = tmp_path / "invalid.toml" config_path.write_text('[app\nworkspace_root = "/tmp"\n', encoding="utf-8")