python-agent-harness 1.5.5.3__tar.gz → 1.5.5.4__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (98) hide show
  1. {python_agent_harness-1.5.5.3/python_agent_harness.egg-info → python_agent_harness-1.5.5.4}/PKG-INFO +1 -1
  2. {python_agent_harness-1.5.5.3 → python_agent_harness-1.5.5.4}/pyproject.toml +1 -1
  3. {python_agent_harness-1.5.5.3 → python_agent_harness-1.5.5.4}/python_agent_harness/__init__.py +1 -1
  4. {python_agent_harness-1.5.5.3 → python_agent_harness-1.5.5.4}/python_agent_harness/cli.py +5 -1
  5. {python_agent_harness-1.5.5.3 → python_agent_harness-1.5.5.4}/python_agent_harness/persistence.py +3 -0
  6. {python_agent_harness-1.5.5.3 → python_agent_harness-1.5.5.4}/python_agent_harness/session.py +8 -0
  7. {python_agent_harness-1.5.5.3 → python_agent_harness-1.5.5.4}/python_agent_harness/tools/diffapply.py +2 -2
  8. {python_agent_harness-1.5.5.3 → python_agent_harness-1.5.5.4}/python_agent_harness/tools/grep_win.py +12 -7
  9. {python_agent_harness-1.5.5.3 → python_agent_harness-1.5.5.4}/python_agent_harness/tui/commands.py +71 -15
  10. {python_agent_harness-1.5.5.3 → python_agent_harness-1.5.5.4/python_agent_harness.egg-info}/PKG-INFO +1 -1
  11. {python_agent_harness-1.5.5.3 → python_agent_harness-1.5.5.4}/tests/test_custom_agents.py +43 -0
  12. {python_agent_harness-1.5.5.3 → python_agent_harness-1.5.5.4}/tests/test_persistence.py +16 -0
  13. {python_agent_harness-1.5.5.3 → python_agent_harness-1.5.5.4}/LICENSE +0 -0
  14. {python_agent_harness-1.5.5.3 → python_agent_harness-1.5.5.4}/README.md +0 -0
  15. {python_agent_harness-1.5.5.3 → python_agent_harness-1.5.5.4}/python_agent_harness/__main__.py +0 -0
  16. {python_agent_harness-1.5.5.3 → python_agent_harness-1.5.5.4}/python_agent_harness/agent.py +0 -0
  17. {python_agent_harness-1.5.5.3 → python_agent_harness-1.5.5.4}/python_agent_harness/client.py +0 -0
  18. {python_agent_harness-1.5.5.3 → python_agent_harness-1.5.5.4}/python_agent_harness/commands.py +0 -0
  19. {python_agent_harness-1.5.5.3 → python_agent_harness-1.5.5.4}/python_agent_harness/config.py +0 -0
  20. {python_agent_harness-1.5.5.3 → python_agent_harness-1.5.5.4}/python_agent_harness/context_manager.py +0 -0
  21. {python_agent_harness-1.5.5.3 → python_agent_harness-1.5.5.4}/python_agent_harness/diffrender.py +0 -0
  22. {python_agent_harness-1.5.5.3 → python_agent_harness-1.5.5.4}/python_agent_harness/mcp/__init__.py +0 -0
  23. {python_agent_harness-1.5.5.3 → python_agent_harness-1.5.5.4}/python_agent_harness/mcp/client.py +0 -0
  24. {python_agent_harness-1.5.5.3 → python_agent_harness-1.5.5.4}/python_agent_harness/mcp/config.py +0 -0
  25. {python_agent_harness-1.5.5.3 → python_agent_harness-1.5.5.4}/python_agent_harness/mcp/manager.py +0 -0
  26. {python_agent_harness-1.5.5.3 → python_agent_harness-1.5.5.4}/python_agent_harness/models.py +0 -0
  27. {python_agent_harness-1.5.5.3 → python_agent_harness-1.5.5.4}/python_agent_harness/planmode.py +0 -0
  28. {python_agent_harness-1.5.5.3 → python_agent_harness-1.5.5.4}/python_agent_harness/prompts/agent.md +0 -0
  29. {python_agent_harness-1.5.5.3 → python_agent_harness-1.5.5.4}/python_agent_harness/prompts/agents/reviewer.md +0 -0
  30. {python_agent_harness-1.5.5.3 → python_agent_harness-1.5.5.4}/python_agent_harness/prompts/build-switch.md +0 -0
  31. {python_agent_harness-1.5.5.3 → python_agent_harness-1.5.5.4}/python_agent_harness/prompts/commands/explain.md +0 -0
  32. {python_agent_harness-1.5.5.3 → python_agent_harness-1.5.5.4}/python_agent_harness/prompts/compact.md +0 -0
  33. {python_agent_harness-1.5.5.3 → python_agent_harness-1.5.5.4}/python_agent_harness/prompts/initialize.md +0 -0
  34. {python_agent_harness-1.5.5.3 → python_agent_harness-1.5.5.4}/python_agent_harness/prompts/plan-mode.md +0 -0
  35. {python_agent_harness-1.5.5.3 → python_agent_harness-1.5.5.4}/python_agent_harness/prompts/plan.md +0 -0
  36. {python_agent_harness-1.5.5.3 → python_agent_harness-1.5.5.4}/python_agent_harness/prompts/review.md +0 -0
  37. {python_agent_harness-1.5.5.3 → python_agent_harness-1.5.5.4}/python_agent_harness/prompts/subagent.md +0 -0
  38. {python_agent_harness-1.5.5.3 → python_agent_harness-1.5.5.4}/python_agent_harness/prompts/summary.md +0 -0
  39. {python_agent_harness-1.5.5.3 → python_agent_harness-1.5.5.4}/python_agent_harness/prompts/task-completion-rules.md +0 -0
  40. {python_agent_harness-1.5.5.3 → python_agent_harness-1.5.5.4}/python_agent_harness/prompts/title.md +0 -0
  41. {python_agent_harness-1.5.5.3 → python_agent_harness-1.5.5.4}/python_agent_harness/prompts.py +0 -0
  42. {python_agent_harness-1.5.5.3 → python_agent_harness-1.5.5.4}/python_agent_harness/subagent.py +0 -0
  43. {python_agent_harness-1.5.5.3 → python_agent_harness-1.5.5.4}/python_agent_harness/token_estimator.py +0 -0
  44. {python_agent_harness-1.5.5.3 → python_agent_harness-1.5.5.4}/python_agent_harness/tool_runner.py +0 -0
  45. {python_agent_harness-1.5.5.3 → python_agent_harness-1.5.5.4}/python_agent_harness/tools/__init__.py +0 -0
  46. {python_agent_harness-1.5.5.3 → python_agent_harness-1.5.5.4}/python_agent_harness/tools/agent_tool.py +0 -0
  47. {python_agent_harness-1.5.5.3 → python_agent_harness-1.5.5.4}/python_agent_harness/tools/base.py +0 -0
  48. {python_agent_harness-1.5.5.3 → python_agent_harness-1.5.5.4}/python_agent_harness/tools/bash.py +0 -0
  49. {python_agent_harness-1.5.5.3 → python_agent_harness-1.5.5.4}/python_agent_harness/tools/bash_win.py +0 -0
  50. {python_agent_harness-1.5.5.3 → python_agent_harness-1.5.5.4}/python_agent_harness/tools/edit.py +0 -0
  51. {python_agent_harness-1.5.5.3 → python_agent_harness-1.5.5.4}/python_agent_harness/tools/edit_mac.py +0 -0
  52. {python_agent_harness-1.5.5.3 → python_agent_harness-1.5.5.4}/python_agent_harness/tools/edit_win.py +0 -0
  53. {python_agent_harness-1.5.5.3 → python_agent_harness-1.5.5.4}/python_agent_harness/tools/filesystem.py +0 -0
  54. {python_agent_harness-1.5.5.3 → python_agent_harness-1.5.5.4}/python_agent_harness/tools/glob.py +0 -0
  55. {python_agent_harness-1.5.5.3 → python_agent_harness-1.5.5.4}/python_agent_harness/tools/glob_mac.py +0 -0
  56. {python_agent_harness-1.5.5.3 → python_agent_harness-1.5.5.4}/python_agent_harness/tools/glob_win.py +0 -0
  57. {python_agent_harness-1.5.5.3 → python_agent_harness-1.5.5.4}/python_agent_harness/tools/grep.py +0 -0
  58. {python_agent_harness-1.5.5.3 → python_agent_harness-1.5.5.4}/python_agent_harness/tools/grep_mac.py +0 -0
  59. {python_agent_harness-1.5.5.3 → python_agent_harness-1.5.5.4}/python_agent_harness/tools/insert.py +0 -0
  60. {python_agent_harness-1.5.5.3 → python_agent_harness-1.5.5.4}/python_agent_harness/tools/mcp.py +0 -0
  61. {python_agent_harness-1.5.5.3 → python_agent_harness-1.5.5.4}/python_agent_harness/tools/mkdir.py +0 -0
  62. {python_agent_harness-1.5.5.3 → python_agent_harness-1.5.5.4}/python_agent_harness/tools/planexit.py +0 -0
  63. {python_agent_harness-1.5.5.3 → python_agent_harness-1.5.5.4}/python_agent_harness/tools/question.py +0 -0
  64. {python_agent_harness-1.5.5.3 → python_agent_harness-1.5.5.4}/python_agent_harness/tools/read.py +0 -0
  65. {python_agent_harness-1.5.5.3 → python_agent_harness-1.5.5.4}/python_agent_harness/tools/skill.py +0 -0
  66. {python_agent_harness-1.5.5.3 → python_agent_harness-1.5.5.4}/python_agent_harness/tools/todo.py +0 -0
  67. {python_agent_harness-1.5.5.3 → python_agent_harness-1.5.5.4}/python_agent_harness/tools/write.py +0 -0
  68. {python_agent_harness-1.5.5.3 → python_agent_harness-1.5.5.4}/python_agent_harness/tui/__init__.py +0 -0
  69. {python_agent_harness-1.5.5.3 → python_agent_harness-1.5.5.4}/python_agent_harness/tui/core.py +0 -0
  70. {python_agent_harness-1.5.5.3 → python_agent_harness-1.5.5.4}/python_agent_harness/tui/input.py +0 -0
  71. {python_agent_harness-1.5.5.3 → python_agent_harness-1.5.5.4}/python_agent_harness/tui/render.py +0 -0
  72. {python_agent_harness-1.5.5.3 → python_agent_harness-1.5.5.4}/python_agent_harness.egg-info/SOURCES.txt +0 -0
  73. {python_agent_harness-1.5.5.3 → python_agent_harness-1.5.5.4}/python_agent_harness.egg-info/dependency_links.txt +0 -0
  74. {python_agent_harness-1.5.5.3 → python_agent_harness-1.5.5.4}/python_agent_harness.egg-info/entry_points.txt +0 -0
  75. {python_agent_harness-1.5.5.3 → python_agent_harness-1.5.5.4}/python_agent_harness.egg-info/requires.txt +0 -0
  76. {python_agent_harness-1.5.5.3 → python_agent_harness-1.5.5.4}/python_agent_harness.egg-info/top_level.txt +0 -0
  77. {python_agent_harness-1.5.5.3 → python_agent_harness-1.5.5.4}/setup.cfg +0 -0
  78. {python_agent_harness-1.5.5.3 → python_agent_harness-1.5.5.4}/tests/test_agents_md.py +0 -0
  79. {python_agent_harness-1.5.5.3 → python_agent_harness-1.5.5.4}/tests/test_cli.py +0 -0
  80. {python_agent_harness-1.5.5.3 → python_agent_harness-1.5.5.4}/tests/test_client.py +0 -0
  81. {python_agent_harness-1.5.5.3 → python_agent_harness-1.5.5.4}/tests/test_commands.py +0 -0
  82. {python_agent_harness-1.5.5.3 → python_agent_harness-1.5.5.4}/tests/test_concurrent_subagents.py +0 -0
  83. {python_agent_harness-1.5.5.3 → python_agent_harness-1.5.5.4}/tests/test_config.py +0 -0
  84. {python_agent_harness-1.5.5.3 → python_agent_harness-1.5.5.4}/tests/test_context_rules.py +0 -0
  85. {python_agent_harness-1.5.5.3 → python_agent_harness-1.5.5.4}/tests/test_diffrender.py +0 -0
  86. {python_agent_harness-1.5.5.3 → python_agent_harness-1.5.5.4}/tests/test_filesystem.py +0 -0
  87. {python_agent_harness-1.5.5.3 → python_agent_harness-1.5.5.4}/tests/test_invariants.py +0 -0
  88. {python_agent_harness-1.5.5.3 → python_agent_harness-1.5.5.4}/tests/test_mcp.py +0 -0
  89. {python_agent_harness-1.5.5.3 → python_agent_harness-1.5.5.4}/tests/test_models.py +0 -0
  90. {python_agent_harness-1.5.5.3 → python_agent_harness-1.5.5.4}/tests/test_planmode.py +0 -0
  91. {python_agent_harness-1.5.5.3 → python_agent_harness-1.5.5.4}/tests/test_prompts.py +0 -0
  92. {python_agent_harness-1.5.5.3 → python_agent_harness-1.5.5.4}/tests/test_scenarios.py +0 -0
  93. {python_agent_harness-1.5.5.3 → python_agent_harness-1.5.5.4}/tests/test_session.py +0 -0
  94. {python_agent_harness-1.5.5.3 → python_agent_harness-1.5.5.4}/tests/test_subagent.py +0 -0
  95. {python_agent_harness-1.5.5.3 → python_agent_harness-1.5.5.4}/tests/test_subagent_isolation.py +0 -0
  96. {python_agent_harness-1.5.5.3 → python_agent_harness-1.5.5.4}/tests/test_todos_scope.py +0 -0
  97. {python_agent_harness-1.5.5.3 → python_agent_harness-1.5.5.4}/tests/test_token_estimator.py +0 -0
  98. {python_agent_harness-1.5.5.3 → python_agent_harness-1.5.5.4}/tests/test_tools_misc.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-agent-harness
3
- Version: 1.5.5.3
3
+ Version: 1.5.5.4
4
4
  Summary: A lightweight, hackable mini-OpenCode written in Python.
5
5
  Author-email: Huming Chen <chenhuming@gmail.com>
6
6
  License-Expression: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "python-agent-harness"
7
- version = "1.5.5.3"
7
+ version = "1.5.5.4"
8
8
  description = "A lightweight, hackable mini-OpenCode written in Python."
9
9
  authors = [
10
10
  {name = "Huming Chen", email = "chenhuming@gmail.com"}
@@ -5,7 +5,7 @@ from .mcp.manager import MCPManager
5
5
  from .models import AgentMode, Message, ToolCall, ToolSpec
6
6
  from .session import Session
7
7
 
8
- __version__ = "1.5.5.3"
8
+ __version__ = "1.5.5.4"
9
9
 
10
10
  __all__ = [
11
11
  "Session",
@@ -125,7 +125,11 @@ def make_session(
125
125
  )
126
126
  if default_agent and default_agent != RESERVED_AGENT_NAME:
127
127
  ok, msg = session.switch_agent(default_agent)
128
- if not ok:
128
+ if ok:
129
+ # record the applied agent so saved sessions restore it;
130
+ # switch_agent("default") clears it again
131
+ session.store.agent = default_agent
132
+ else:
129
133
  # A typo'd/missing default_agent must not silently start the
130
134
  # session with the built-in prompt — record the config error
131
135
  # so the TUI can surface it (see Tui.run).
@@ -112,9 +112,11 @@ class SessionPersistence:
112
112
  max_tokens: int | None = None,
113
113
  tool_names: list[str] | None = None,
114
114
  round_times: list[float] | None = None,
115
+ agent: str | None = None,
115
116
  ) -> None:
116
117
  self.project_dir = project_dir
117
118
  self.model = model
119
+ self.agent = agent
118
120
  self.system_prompt = system_prompt
119
121
  self.temperature = temperature
120
122
  self.max_tokens = max_tokens
@@ -171,6 +173,7 @@ class SessionPersistence:
171
173
  pairs = [
172
174
  ("python-agent-harness--project-dir", self.project_dir),
173
175
  ("python-agent-harness--model", self.model),
176
+ ("python-agent-harness--agent", self.agent),
174
177
  ]
175
178
  for name, value in pairs:
176
179
  if value is None:
@@ -162,6 +162,12 @@ class Session:
162
162
  # cache build simultaneously — double-checked locking prevents
163
163
  # redundant filesystem scans and the data race on the cache tuple
164
164
  self._skill_index_lock = threading.Lock()
165
+ # NOTE: the store does NOT record ``default_agent`` here: the
166
+ # store's agent must reflect the agent actually applied, and
167
+ # only a successful switch_agent() can guarantee that (a typo'd
168
+ # default_agent is reported as a startup warning and the session
169
+ # keeps the built-in prompt). cli.make_session applies the
170
+ # default_agent right after construction, which sets it.
165
171
  self.store = SessionPersistence(
166
172
  project_dir=project_dir,
167
173
  model=model,
@@ -731,6 +737,7 @@ class Session:
731
737
  if name == RESERVED_AGENT_NAME:
732
738
  self.system_prompt = self._default_system_prompt
733
739
  self.store.system_prompt = self._default_system_prompt
740
+ self.store.agent = None
734
741
  return True, "switched to default agent"
735
742
 
736
743
  agents = discover_agents()
@@ -749,6 +756,7 @@ class Session:
749
756
  context_path=self._configured_context_path,
750
757
  )
751
758
  self.store.system_prompt = self.system_prompt
759
+ self.store.agent = name
752
760
  return True, f"switched to {name}"
753
761
 
754
762
  # ------------------------------------------------------------------
@@ -48,7 +48,7 @@ class _Hunk:
48
48
  """(kind, content-without-newline) for the lines that must exist
49
49
  in the old file (' ' context and '-' removed lines)."""
50
50
  return [
51
- (kind, content.rstrip("\n")) for kind, content, _ in self.body if kind in (" ", "-")
51
+ (kind, content.rstrip("\r\n")) for kind, content, _ in self.body if kind in (" ", "-")
52
52
  ]
53
53
 
54
54
 
@@ -142,7 +142,7 @@ def _match_hunk(hunk: _Hunk, file_lines: list[str]) -> int | None:
142
142
  mismatches = 0
143
143
  ok = True
144
144
  for i, (kind, content) in enumerate(old_seq):
145
- file_content = file_lines[p + i].rstrip("\n")
145
+ file_content = file_lines[p + i].rstrip("\r\n")
146
146
  if kind == "-":
147
147
  if file_content != content:
148
148
  ok = False
@@ -98,12 +98,17 @@ class GrepWindows(Grep):
98
98
  files = [Path(path)]
99
99
  else:
100
100
  root = Path(path)
101
- files = (
102
- p
103
- for p in root.rglob("*")
104
- if p.is_file()
105
- and not any(part.startswith(".") for part in p.relative_to(root).parts[:-1])
106
- )
101
+ collected: list[Path] = []
102
+ try:
103
+ for p in root.rglob("*"):
104
+ if not p.is_file():
105
+ continue
106
+ if any(part.startswith(".") for part in p.relative_to(root).parts[:-1]):
107
+ continue
108
+ collected.append(p)
109
+ except OSError:
110
+ pass
111
+ files = collected
107
112
 
108
113
  for file_path in files:
109
114
  if match_count >= max_matches:
@@ -124,7 +129,7 @@ class GrepWindows(Grep):
124
129
  start = max(0, i - 1 - context)
125
130
  end = min(len(lines), i + context)
126
131
  for j in range(start, end):
127
- marker = ":" if j + 1 != i else ":"
132
+ marker = "-" if j + 1 != i else ":"
128
133
  results.append(f"{file_path}:{j + 1}{marker}{lines[j].rstrip()}")
129
134
  results.append("") # blank line between matches with context
130
135
  else:
@@ -119,7 +119,7 @@ class CommandMixin:
119
119
  "/review [project] [commit|branch|PR] review code changes\n"
120
120
  "/explain [project] [target] explain code\n"
121
121
  "/sessions list saved sessions\n"
122
- "/restore [path | title | --latest | latest] restore a saved session\n"
122
+ "/restore [path | title | --latest | latest] restore a saved session (conversation + agent + model)\n"
123
123
  "/model [name] switch LLM model profile\n"
124
124
  "/agent [name] switch agent system prompt\n"
125
125
  "Ctrl-C cancels the current execution (app stays open); "
@@ -642,6 +642,59 @@ class CommandMixin:
642
642
  title = title_from_filename(path)
643
643
  if title:
644
644
  self.session.store.title = title
645
+ # Restore agent if saved
646
+ agent = meta.get("python-agent-harness--agent")
647
+ if agent:
648
+ try:
649
+ success, msg = self.session.switch_agent(agent)
650
+ if success:
651
+ self.console.print(f"[green]{msg}[/green]")
652
+ else:
653
+ self.console.print(f"[yellow]warning: {msg}[/yellow]")
654
+ except Exception as e:
655
+ self.console.print(f"[yellow]warning: could not restore agent: {e}[/yellow]")
656
+ # Restore model if saved: /model's profile switching is the
657
+ # canonical path — it also fixes base_url/api_key/temperature
658
+ # and other settings that a bare model-name change would leave
659
+ # pointing at the wrong endpoint. The metadata stores the raw
660
+ # model name, so first try it as a profile name, then fall back
661
+ # to a profile whose model matches; without a matching profile
662
+ # the current model stays active (profile names are not saved).
663
+ model = meta.get("python-agent-harness--model")
664
+ if model:
665
+ try:
666
+ if model == self.session.model:
667
+ # Already the active model: nothing to do, no noise.
668
+ pass
669
+ elif model == self.session.llm_settings.get("model"):
670
+ # The saved model is the session-start default: the
671
+ # "default" pseudo-profile restores exactly those
672
+ # original llm settings (profile names were not saved,
673
+ # so there is no better match).
674
+ success, msg = self.session.switch_model("default")
675
+ self.console.print(f"[green]{msg}[/green]")
676
+ else:
677
+ success, msg = self.session.switch_model(model)
678
+ if not success:
679
+ profile = next(
680
+ (
681
+ name
682
+ for name, p in self.session.model_profiles.items()
683
+ if p.get("model") == model
684
+ ),
685
+ None,
686
+ )
687
+ if profile:
688
+ success, msg = self.session.switch_model(profile)
689
+ if success:
690
+ self.console.print(f"[green]{msg}[/green]")
691
+ else:
692
+ self.console.print(
693
+ f"[yellow]warning: model {model!r} has no matching profile, "
694
+ f"keeping current model[/yellow]"
695
+ )
696
+ except Exception as e:
697
+ self.console.print(f"[yellow]warning: could not restore model: {e}[/yellow]")
645
698
  # Replace conversation history: a new generation. Invalidate any
646
699
  # worker still winding down from a cancelled run so its salvaged
647
700
  # history can't clobber the restored session.
@@ -650,11 +703,11 @@ class CommandMixin:
650
703
  self.session.last_messages = list(messages)
651
704
  self.session.clear_todos()
652
705
  self._history_dirty = True
653
- model = meta.get("python-agent-harness--model", "?")
654
706
  project = meta.get("python-agent-harness--project-dir", "?")
707
+ agent_display = meta.get("python-agent-harness--agent", "default")
655
708
  self.console.print(
656
709
  f"[green]restored:[/green] {os.path.basename(path)} "
657
- f"(model={model}, project={project}, {len(messages)} messages)"
710
+ f"(model={model}, agent={agent_display}, project={project}, {len(messages)} messages)"
658
711
  )
659
712
 
660
713
  @staticmethod
@@ -664,13 +717,15 @@ class CommandMixin:
664
717
  The save format is markdown with **role**: content blocks
665
718
  separated by blank lines.
666
719
 
667
- ``tool`` blocks are dropped: the saved markdown does not keep
668
- ``tool_call_id``/``name`` (assistant tool calls are flattened to
669
- plain text), so a restored ``role="tool"`` message would form an
670
- API-invalid payload (a tool message with no preceding assistant
671
- ``tool_calls``). The following assistant reply already
672
- summarizes the results, so dropping them loses no essential
673
- context.
720
+ ``tool`` and ``system`` blocks are dropped: the saved markdown
721
+ does not keep ``tool_call_id``/``name`` (assistant tool calls are
722
+ flattened to plain text), so a restored ``role="tool"`` message
723
+ would form an API-invalid payload (a tool message with no
724
+ preceding assistant ``tool_calls``). A restored ``system``
725
+ message would duplicate the live system prompt the client
726
+ prepends on every request. The following assistant reply
727
+ already summarizes the results, so dropping them loses no
728
+ essential context.
674
729
 
675
730
  Body lines that merely look like a block header are escaped by
676
731
  the renderer (see `escape_role_headers`) and unescaped here, so
@@ -687,9 +742,10 @@ class CommandMixin:
687
742
  header = split_role_header(line)
688
743
  if header is not None:
689
744
  role, rest = header
690
- # Save the previous block (tool blocks are dropped:
691
- # their tool_call_id/name were not persisted)
692
- if current_role is not None and current_role != "tool":
745
+ # Save the previous block (tool blocks lose their
746
+ # tool_call_id/name; system blocks would duplicate the
747
+ # live system prompt the client prepends per request)
748
+ if current_role is not None and current_role not in ("tool", "system"):
693
749
  content = "\n".join(current_lines).strip()
694
750
  if content:
695
751
  messages.append(Message(role=current_role, content=content))
@@ -698,8 +754,8 @@ class CommandMixin:
698
754
  continue
699
755
  current_lines.append(unescape_role_header(line))
700
756
 
701
- # Don't forget the last block (tool blocks are dropped)
702
- if current_role is not None and current_role != "tool":
757
+ # Don't forget the last block (tool/system blocks dropped, see above)
758
+ if current_role is not None and current_role not in ("tool", "system"):
703
759
  content = "\n".join(current_lines).strip()
704
760
  if content:
705
761
  messages.append(Message(role=current_role, content=content))
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-agent-harness
3
- Version: 1.5.5.3
3
+ Version: 1.5.5.4
4
4
  Summary: A lightweight, hackable mini-OpenCode written in Python.
5
5
  Author-email: Huming Chen <chenhuming@gmail.com>
6
6
  License-Expression: MIT
@@ -163,6 +163,25 @@ class TestSwitchAgent(unittest.TestCase):
163
163
  session.switch_agent("default")
164
164
  self.assertEqual(session.store.system_prompt, "original prompt")
165
165
 
166
+ def test_switch_updates_store_agent(self):
167
+ """switch_agent must keep store.agent in sync so every auto-save
168
+ records the currently active agent in the metadata block."""
169
+ session = _make_session(system_prompt="original prompt")
170
+ self.assertIsNone(session.store.agent) # no custom agent yet
171
+ session.switch_agent("reviewer")
172
+ self.assertEqual(session.store.agent, "reviewer")
173
+ session.switch_agent("default")
174
+ self.assertIsNone(session.store.agent) # default = no custom agent
175
+
176
+ def test_failed_switch_leaves_store_agent_untouched(self):
177
+ """A failed switch (unknown name) must not change store.agent:
178
+ the metadata must never record an agent that is not active."""
179
+ session = _make_session(system_prompt="original prompt")
180
+ session.switch_agent("reviewer")
181
+ ok, _ = session.switch_agent("nonexistent")
182
+ self.assertFalse(ok)
183
+ self.assertEqual(session.store.agent, "reviewer")
184
+
166
185
 
167
186
  class TestDefaultAgentConfig(unittest.TestCase):
168
187
  """config.load_default_agent: reading the default_agent setting."""
@@ -286,6 +305,30 @@ class TestMakeSessionWithDefaultAgent(unittest.TestCase):
286
305
  p.write_text(json.dumps({"default_agent": "reviewer"}), encoding="utf-8")
287
306
  session = make_session("/tmp", config_path=str(p))
288
307
  self.assertEqual(session.startup_warnings, [])
308
+ session.close()
309
+
310
+ def test_valid_default_agent_recorded_in_store(self):
311
+ """A successfully applied default_agent is recorded in store.agent,
312
+ so saved sessions restore it. A typo'd one must NOT be recorded:
313
+ the metadata must never name an agent that is not active."""
314
+ from python_agent_harness.cli import make_session
315
+
316
+ with tempfile.TemporaryDirectory() as d:
317
+ p = Path(d) / "config.json"
318
+ p.write_text(json.dumps({"default_agent": "reviewer"}), encoding="utf-8")
319
+ session = make_session("/tmp", config_path=str(p))
320
+ try:
321
+ self.assertEqual(session.store.agent, "reviewer")
322
+ finally:
323
+ session.close()
324
+ with tempfile.TemporaryDirectory() as d:
325
+ p = Path(d) / "config.json"
326
+ p.write_text(json.dumps({"default_agent": "no-such-agent"}), encoding="utf-8")
327
+ session = make_session("/tmp", config_path=str(p))
328
+ try:
329
+ self.assertIsNone(session.store.agent)
330
+ finally:
331
+ session.close()
289
332
 
290
333
 
291
334
  class TestMcpFailuresAsStartupWarnings(unittest.TestCase):
@@ -59,6 +59,22 @@ class TestSession(unittest.TestCase):
59
59
  store = SessionPersistence(project_dir="/tmp/proj", model="m")
60
60
  self.assertNotIn("round-times", store.metadata_block())
61
61
 
62
+ def test_agent_persisted_in_metadata(self):
63
+ """A session with an active custom agent records it in the
64
+ metadata block so /restore can reapply it."""
65
+ store = SessionPersistence(project_dir="/tmp/proj", model="m", agent="reviewer")
66
+ meta = store.metadata_block()
67
+ self.assertIn("python-agent-harness--agent", meta)
68
+ text = "conversation...\n\n" + meta + "\n"
69
+ parsed = SessionPersistence.parse_metadata(text)
70
+ self.assertEqual(parsed["python-agent-harness--agent"], "reviewer")
71
+
72
+ def test_agent_absent_when_none(self):
73
+ """No custom agent -> no agent line in the metadata block, so
74
+ old sessions and default-agent sessions parse identically."""
75
+ store = SessionPersistence(project_dir="/tmp/proj", model="m", agent=None)
76
+ self.assertNotIn("python-agent-harness--agent", store.metadata_block())
77
+
62
78
  def test_save_and_restore_flow(self):
63
79
  import os
64
80
  import tempfile