diff --git a/nightshift/agents.py b/nightshift/agents.py index 90ddee7..fdc652a 100644 --- a/nightshift/agents.py +++ b/nightshift/agents.py @@ -149,6 +149,8 @@ class AgentExecutor: input=prompt, capture_output=True, text=True, + encoding="utf-8", + errors="replace", timeout=self.timeout_seconds, ) duration = time.monotonic() - started @@ -157,8 +159,8 @@ class AgentExecutor: command=agent.command, prompt=prompt, exit_code=completed.returncode, - stdout=completed.stdout, - stderr=completed.stderr, + stdout=_coerce_output(completed.stdout), + stderr=_coerce_output(completed.stderr), duration_seconds=duration, ) except subprocess.TimeoutExpired as exc: @@ -186,6 +188,8 @@ class AgentExecutor: input=prompt, capture_output=True, text=True, + encoding="utf-8", + errors="replace", timeout=self.timeout_seconds, ) duration = time.monotonic() - started @@ -194,8 +198,8 @@ class AgentExecutor: command=command, prompt=prompt, exit_code=completed.returncode, - stdout=completed.stdout, - stderr=completed.stderr, + stdout=_coerce_output(completed.stdout), + stderr=_coerce_output(completed.stderr), duration_seconds=duration, ) except FileNotFoundError as exc: @@ -323,6 +327,9 @@ def parse_review_output(output: str) -> tuple[StageStatus, str, str | None, str def format_agent_invocation(stage_id: str, invocation: AgentInvocation) -> str: + stdout = _coerce_output(invocation.stdout) + stderr = _coerce_output(invocation.stderr) + prompt = _coerce_output(invocation.prompt) return "\n".join( [ f"# Agent Output: {stage_id}", @@ -336,19 +343,19 @@ def format_agent_invocation(stage_id: str, invocation: AgentInvocation) -> str: "## stdout", "", "```text", - invocation.stdout.rstrip(), + stdout.rstrip(), "```", "", "## stderr", "", "```text", - invocation.stderr.rstrip(), + stderr.rstrip(), "```", "", "## Prompt", "", "```markdown", - invocation.prompt.rstrip(), + prompt.rstrip(), "```", "", ] diff --git a/nightshift/commands.py b/nightshift/commands.py index 111359e..267b611 100644 --- a/nightshift/commands.py +++ b/nightshift/commands.py @@ -125,6 +125,8 @@ class CommandExecutor: shell=shell, capture_output=True, text=True, + encoding="utf-8", + errors="replace", timeout=timeout, env=env, ) @@ -132,8 +134,8 @@ class CommandExecutor: return CommandRun( command=normalized, exit_code=completed.returncode, - stdout=completed.stdout, - stderr=completed.stderr, + stdout=_coerce_output(completed.stdout), + stderr=_coerce_output(completed.stderr), duration_seconds=duration, ) except subprocess.TimeoutExpired as exc: @@ -151,6 +153,8 @@ class CommandExecutor: def format_command_runs(stage_id: str, runs: list[CommandRun]) -> str: lines = [f"# Command Output: {stage_id}", ""] for index, run in enumerate(runs, start=1): + stdout = _coerce_output(run.stdout) + stderr = _coerce_output(run.stderr) lines.extend( [ f"## Command {index}", @@ -163,13 +167,13 @@ def format_command_runs(stage_id: str, runs: list[CommandRun]) -> str: "### stdout", "", "```text", - run.stdout.rstrip(), + stdout.rstrip(), "```", "", "### stderr", "", "```text", - run.stderr.rstrip(), + stderr.rstrip(), "```", "", ] diff --git a/nightshift/git.py b/nightshift/git.py index 368bb1d..2831e5d 100644 --- a/nightshift/git.py +++ b/nightshift/git.py @@ -25,11 +25,18 @@ def run_git(project_root: Path, args: list[str], timeout_seconds: int = 15) -> G cwd=project_root, capture_output=True, text=True, + encoding="utf-8", + errors="replace", timeout=timeout_seconds, ) except (OSError, subprocess.TimeoutExpired) as exc: return GitCommandResult(False, -1, "", str(exc)) - return GitCommandResult(completed.returncode == 0, completed.returncode, completed.stdout, completed.stderr) + return GitCommandResult( + completed.returncode == 0, + completed.returncode, + completed.stdout or "", + completed.stderr or "", + ) def get_git_status(project_root: Path) -> GitCommandResult: diff --git a/nightshift/reports.py b/nightshift/reports.py index 72f030e..9a6e808 100644 --- a/nightshift/reports.py +++ b/nightshift/reports.py @@ -213,6 +213,8 @@ def collect_modified_files(project_root: Path) -> list[str]: shell=True, capture_output=True, text=True, + encoding="utf-8", + errors="replace", timeout=10, ) except (OSError, subprocess.TimeoutExpired): diff --git a/tests/test_agents.py b/tests/test_agents.py index 589b4cd..ad45546 100644 --- a/tests/test_agents.py +++ b/tests/test_agents.py @@ -4,6 +4,7 @@ import unittest from unittest.mock import patch from nightshift.agents import AgentExecutor, build_prompt_bundle, parse_review_output +from nightshift.agents import AgentInvocation, format_agent_invocation from nightshift.artifacts import ArtifactStore from nightshift.config import AgentConfig, StageConfig from nightshift.tasks import parse_tasks @@ -131,6 +132,22 @@ class AgentExecutorTests(unittest.TestCase): output = (root / result.output_path).read_text(encoding="utf-8") self.assertIn("ollama run tiny-model", output) + def test_agent_artifact_format_tolerates_missing_streams(self) -> None: + invocation = AgentInvocation( + agent_id="planner", + command="ollama run model", + prompt="prompt", + exit_code=0, + stdout=None, # type: ignore[arg-type] + stderr=None, # type: ignore[arg-type] + duration_seconds=0.1, + ) + + output = format_agent_invocation("plan", invocation) + + self.assertIn("Agent: `planner`", output) + self.assertIn("## stderr", output) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_commands.py b/tests/test_commands.py index 61f4421..b9a2f43 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -4,6 +4,7 @@ import unittest from nightshift.artifacts import ArtifactStore from nightshift.commands import CommandExecutor +from nightshift.commands import CommandRun, format_command_runs from nightshift.config import SafetyConfig, StageConfig from nightshift.errors import CommandError @@ -150,6 +151,23 @@ class CommandExecutorTests(unittest.TestCase): output = (root / result.output_path).read_text(encoding="utf-8") self.assertIn("work", output) + def test_command_artifact_format_tolerates_missing_streams(self) -> None: + output = format_command_runs( + "test", + [ + CommandRun( + command="cmd", + exit_code=0, + stdout=None, # type: ignore[arg-type] + stderr=None, # type: ignore[arg-type] + duration_seconds=0.1, + ) + ], + ) + + self.assertIn("Command: `cmd`", output) + self.assertIn("### stderr", output) + if __name__ == "__main__": unittest.main()