swarph-cli 0.2.0__tar.gz → 0.3.0__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 (25) hide show
  1. {swarph_cli-0.2.0/src/swarph_cli.egg-info → swarph_cli-0.3.0}/PKG-INFO +53 -12
  2. {swarph_cli-0.2.0 → swarph_cli-0.3.0}/README.md +50 -9
  3. {swarph_cli-0.2.0 → swarph_cli-0.3.0}/pyproject.toml +5 -3
  4. {swarph_cli-0.2.0 → swarph_cli-0.3.0}/src/swarph_cli/__init__.py +1 -1
  5. swarph_cli-0.3.0/src/swarph_cli/commands/chat.py +348 -0
  6. {swarph_cli-0.2.0 → swarph_cli-0.3.0}/src/swarph_cli/main.py +5 -2
  7. {swarph_cli-0.2.0 → swarph_cli-0.3.0/src/swarph_cli.egg-info}/PKG-INFO +53 -12
  8. {swarph_cli-0.2.0 → swarph_cli-0.3.0}/src/swarph_cli.egg-info/SOURCES.txt +3 -0
  9. {swarph_cli-0.2.0 → swarph_cli-0.3.0}/src/swarph_cli.egg-info/requires.txt +1 -1
  10. swarph_cli-0.3.0/tests/test_chat_command.py +444 -0
  11. swarph_cli-0.3.0/tests/test_smoke_chat.py +61 -0
  12. {swarph_cli-0.2.0 → swarph_cli-0.3.0}/LICENSE +0 -0
  13. {swarph_cli-0.2.0 → swarph_cli-0.3.0}/setup.cfg +0 -0
  14. {swarph_cli-0.2.0 → swarph_cli-0.3.0}/src/swarph_cli/caller.py +0 -0
  15. {swarph_cli-0.2.0 → swarph_cli-0.3.0}/src/swarph_cli/commands/__init__.py +0 -0
  16. {swarph_cli-0.2.0 → swarph_cli-0.3.0}/src/swarph_cli/commands/import_session.py +0 -0
  17. {swarph_cli-0.2.0 → swarph_cli-0.3.0}/src/swarph_cli/parsers/__init__.py +0 -0
  18. {swarph_cli-0.2.0 → swarph_cli-0.3.0}/src/swarph_cli/parsers/claude.py +0 -0
  19. {swarph_cli-0.2.0 → swarph_cli-0.3.0}/src/swarph_cli.egg-info/dependency_links.txt +0 -0
  20. {swarph_cli-0.2.0 → swarph_cli-0.3.0}/src/swarph_cli.egg-info/entry_points.txt +0 -0
  21. {swarph_cli-0.2.0 → swarph_cli-0.3.0}/src/swarph_cli.egg-info/top_level.txt +0 -0
  22. {swarph_cli-0.2.0 → swarph_cli-0.3.0}/tests/test_claude_parser.py +0 -0
  23. {swarph_cli-0.2.0 → swarph_cli-0.3.0}/tests/test_import_command.py +0 -0
  24. {swarph_cli-0.2.0 → swarph_cli-0.3.0}/tests/test_main.py +0 -0
  25. {swarph_cli-0.2.0 → swarph_cli-0.3.0}/tests/test_smoke_one_shot.py +0 -0
@@ -1,7 +1,7 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: swarph-cli
3
- Version: 0.2.0
4
- Summary: The `swarph` binary — multi-LLM CLI with mesh-gateway integration. Phase 2 one-shot mode shipped (PLAN.md §13).
3
+ Version: 0.3.0
4
+ Summary: The `swarph` binary — multi-LLM CLI with mesh-gateway integration. v0.3.0 ships the Phase 5 `swarph chat` REPL on top of Phase 2 one-shot + Phase 2.5 import (PLAN.md §13).
5
5
  Author: Pierre Samson, Claude Opus
6
6
  License: MIT
7
7
  Project-URL: Homepage, https://github.com/darw007d/swarph-cli
@@ -24,7 +24,7 @@ Classifier: Topic :: Utilities
24
24
  Requires-Python: >=3.10
25
25
  Description-Content-Type: text/markdown
26
26
  License-File: LICENSE
27
- Requires-Dist: swarph-mesh>=0.1.0
27
+ Requires-Dist: swarph-mesh>=0.5.0
28
28
  Requires-Dist: swarph-shared>=0.2.0
29
29
  Provides-Extra: dev
30
30
  Requires-Dist: pytest>=7.0; extra == "dev"
@@ -49,12 +49,52 @@ This is one of three repos in the v0.3.x architecture:
49
49
 
50
50
  ## Status
51
51
 
52
- **v0.2.0 — Phase 2 one-shot + Phase 2.5 import.** Two verbs ship:
52
+ **v0.3.0 — Phase 2 one-shot + Phase 2.5 import + Phase 5 REPL.** Three verbs ship:
53
53
 
54
- 1. `swarph "prompt"` — Phase 2 one-shot mode (against `--provider gemini`)
55
- 2. `swarph import <path>` — Phase 2.5 session import (Claude JSONL → swarph-native, with `--report-only` for honest pre-commit inspection)
54
+ 1. `swarph "prompt"` — Phase 2 one-shot mode (any of five providers)
55
+ 2. `swarph chat` — Phase 5 interactive REPL with multi-turn history + slash commands
56
+ 3. `swarph import <path>` — Phase 2.5 session import (Claude JSONL → swarph-native, with `--report-only` for honest pre-commit inspection)
56
57
 
57
- Subsequent phases extend the CLI surface (REPL, `--ask <peer>`, onboard/ratify, daemon, additional source formats).
58
+ Subsequent phases extend the CLI surface (`--ask <peer>`, onboard/ratify, daemon, additional source formats).
59
+
60
+ ### `swarph chat`
61
+
62
+ Interactive REPL against any of the five swarph-mesh adapters (`gemini` / `deepseek` / `claude` / `openai` / `grok`). Multi-turn conversation history accumulates in-memory; cumulative session cost + token totals tracked.
63
+
64
+ ```bash
65
+ $ swarph chat --provider claude
66
+ swarph chat — Phase 5 REPL
67
+ provider=claude model=(adapter default) caller=cli.repl.ubuntu
68
+
69
+ Type a message and press Enter to send. Slash commands:
70
+ /help /clear /system /provider /model /history /cost /quit
71
+ Ctrl-D to exit.
72
+
73
+ > hello
74
+ Hi! How can I help...
75
+ # 8+12t $0 0.34s
76
+
77
+ > /provider gemini
78
+ [switched to provider=gemini; model reset to adapter default; history cleared]
79
+
80
+ > /cost
81
+ [turns=1 in=8 out=12 cost=$0]
82
+
83
+ > /quit
84
+ [swarph-chat] bye.
85
+ ```
86
+
87
+ **Slash commands:**
88
+ - `/help` — print available commands
89
+ - `/quit`, `/exit` (or Ctrl-D) — exit
90
+ - `/clear`, `/reset` — clear history (keeps system prompt)
91
+ - `/system [prompt]` — set or clear system prompt
92
+ - `/provider <name>` — switch provider (resets history)
93
+ - `/model <name>` — switch model
94
+ - `/history` — print running message list
95
+ - `/cost` — cumulative session cost + tokens
96
+
97
+ **Out of scope until Phase 5.6** (`swarph daemon`): inbox drain coroutine, `/inbox` and `/reply` slash commands. Streaming output ships alongside the cross-adapter `stream()` work in v0.5+ of swarph-mesh.
58
98
 
59
99
  ### `swarph import`
60
100
 
@@ -115,13 +155,14 @@ Pong!
115
155
 
116
156
  | Phase | What lands |
117
157
  |---|---|
118
- | **0** (this) | Scaffold — entry-point + status banner |
119
- | **2** | One-shot mode: `swarph "hello" --provider gemini` |
158
+ | **0** | Scaffold — entry-point + status banner |
159
+ | **2** (v0.1.0) | One-shot mode: `swarph "hello" --provider gemini` |
160
+ | **2.5** (v0.2.0) | `swarph import` — Claude JSONL → swarph-native session format |
161
+ | **5** (v0.3.0 — this release) | **`swarph chat` interactive REPL** — multi-turn against any of five adapters + slash commands (`/help`, `/clear`, `/system`, `/provider`, `/model`, `/history`, `/cost`, `/quit`) |
120
162
  | **3** | `--ask <peer>` mesh-aware one-shot via MeshClient |
121
- | **5** | Interactive REPL — `/inbox`, `/reply`, `/dm`, `/watch` |
122
163
  | **5.5** | `swarph onboard <peer-name>` + `swarph ratify <peer-name>` (PLAN.md §15) |
123
- | **5.7** | `swarph daemon` foreground drain loop + `swarph chat` REPL with drain coroutine (PLAN.md §16) |
124
- | **6** | PyPI publish |
164
+ | **5.6** | `swarph daemon` foreground drain loop + REPL drain coroutine + `/inbox`, `/reply` (PLAN.md §16) |
165
+ | **6** | (already done) PyPI publish |
125
166
 
126
167
  ## Why split CLI from substrate
127
168
 
@@ -17,12 +17,52 @@ This is one of three repos in the v0.3.x architecture:
17
17
 
18
18
  ## Status
19
19
 
20
- **v0.2.0 — Phase 2 one-shot + Phase 2.5 import.** Two verbs ship:
20
+ **v0.3.0 — Phase 2 one-shot + Phase 2.5 import + Phase 5 REPL.** Three verbs ship:
21
21
 
22
- 1. `swarph "prompt"` — Phase 2 one-shot mode (against `--provider gemini`)
23
- 2. `swarph import <path>` — Phase 2.5 session import (Claude JSONL → swarph-native, with `--report-only` for honest pre-commit inspection)
22
+ 1. `swarph "prompt"` — Phase 2 one-shot mode (any of five providers)
23
+ 2. `swarph chat` — Phase 5 interactive REPL with multi-turn history + slash commands
24
+ 3. `swarph import <path>` — Phase 2.5 session import (Claude JSONL → swarph-native, with `--report-only` for honest pre-commit inspection)
24
25
 
25
- Subsequent phases extend the CLI surface (REPL, `--ask <peer>`, onboard/ratify, daemon, additional source formats).
26
+ Subsequent phases extend the CLI surface (`--ask <peer>`, onboard/ratify, daemon, additional source formats).
27
+
28
+ ### `swarph chat`
29
+
30
+ Interactive REPL against any of the five swarph-mesh adapters (`gemini` / `deepseek` / `claude` / `openai` / `grok`). Multi-turn conversation history accumulates in-memory; cumulative session cost + token totals tracked.
31
+
32
+ ```bash
33
+ $ swarph chat --provider claude
34
+ swarph chat — Phase 5 REPL
35
+ provider=claude model=(adapter default) caller=cli.repl.ubuntu
36
+
37
+ Type a message and press Enter to send. Slash commands:
38
+ /help /clear /system /provider /model /history /cost /quit
39
+ Ctrl-D to exit.
40
+
41
+ > hello
42
+ Hi! How can I help...
43
+ # 8+12t $0 0.34s
44
+
45
+ > /provider gemini
46
+ [switched to provider=gemini; model reset to adapter default; history cleared]
47
+
48
+ > /cost
49
+ [turns=1 in=8 out=12 cost=$0]
50
+
51
+ > /quit
52
+ [swarph-chat] bye.
53
+ ```
54
+
55
+ **Slash commands:**
56
+ - `/help` — print available commands
57
+ - `/quit`, `/exit` (or Ctrl-D) — exit
58
+ - `/clear`, `/reset` — clear history (keeps system prompt)
59
+ - `/system [prompt]` — set or clear system prompt
60
+ - `/provider <name>` — switch provider (resets history)
61
+ - `/model <name>` — switch model
62
+ - `/history` — print running message list
63
+ - `/cost` — cumulative session cost + tokens
64
+
65
+ **Out of scope until Phase 5.6** (`swarph daemon`): inbox drain coroutine, `/inbox` and `/reply` slash commands. Streaming output ships alongside the cross-adapter `stream()` work in v0.5+ of swarph-mesh.
26
66
 
27
67
  ### `swarph import`
28
68
 
@@ -83,13 +123,14 @@ Pong!
83
123
 
84
124
  | Phase | What lands |
85
125
  |---|---|
86
- | **0** (this) | Scaffold — entry-point + status banner |
87
- | **2** | One-shot mode: `swarph "hello" --provider gemini` |
126
+ | **0** | Scaffold — entry-point + status banner |
127
+ | **2** (v0.1.0) | One-shot mode: `swarph "hello" --provider gemini` |
128
+ | **2.5** (v0.2.0) | `swarph import` — Claude JSONL → swarph-native session format |
129
+ | **5** (v0.3.0 — this release) | **`swarph chat` interactive REPL** — multi-turn against any of five adapters + slash commands (`/help`, `/clear`, `/system`, `/provider`, `/model`, `/history`, `/cost`, `/quit`) |
88
130
  | **3** | `--ask <peer>` mesh-aware one-shot via MeshClient |
89
- | **5** | Interactive REPL — `/inbox`, `/reply`, `/dm`, `/watch` |
90
131
  | **5.5** | `swarph onboard <peer-name>` + `swarph ratify <peer-name>` (PLAN.md §15) |
91
- | **5.7** | `swarph daemon` foreground drain loop + `swarph chat` REPL with drain coroutine (PLAN.md §16) |
92
- | **6** | PyPI publish |
132
+ | **5.6** | `swarph daemon` foreground drain loop + REPL drain coroutine + `/inbox`, `/reply` (PLAN.md §16) |
133
+ | **6** | (already done) PyPI publish |
93
134
 
94
135
  ## Why split CLI from substrate
95
136
 
@@ -4,8 +4,8 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "swarph-cli"
7
- version = "0.2.0"
8
- description = "The `swarph` binary — multi-LLM CLI with mesh-gateway integration. Phase 2 one-shot mode shipped (PLAN.md §13)."
7
+ version = "0.3.0"
8
+ description = "The `swarph` binary — multi-LLM CLI with mesh-gateway integration. v0.3.0 ships the Phase 5 `swarph chat` REPL on top of Phase 2 one-shot + Phase 2.5 import (PLAN.md §13)."
9
9
  readme = "README.md"
10
10
  license = { text = "MIT" }
11
11
  requires-python = ">=3.10"
@@ -29,7 +29,9 @@ classifiers = [
29
29
  "Topic :: Utilities",
30
30
  ]
31
31
  dependencies = [
32
- "swarph-mesh>=0.1.0",
32
+ # Phase 5 REPL exercises all five adapters; bumped to 0.5.0 for
33
+ # OpenAI + Grok availability in /provider switch.
34
+ "swarph-mesh>=0.5.0",
33
35
  "swarph-shared>=0.2.0",
34
36
  ]
35
37
 
@@ -16,6 +16,6 @@ The architecture splits CLI from substrate so:
16
16
 
17
17
  from __future__ import annotations
18
18
 
19
- __version__ = "0.2.0"
19
+ __version__ = "0.3.0"
20
20
 
21
21
  __all__ = ["__version__"]
@@ -0,0 +1,348 @@
1
+ """``swarph chat`` — interactive REPL.
2
+
3
+ Phase 5 per PLAN.md §13. Multi-turn conversation against any of the
4
+ five swarph-mesh adapters (gemini / deepseek / claude / openai / grok).
5
+ Stdlib-only — uses ``readline`` for line editing + in-process history,
6
+ no third-party REPL framework.
7
+
8
+ Slash commands (typed as the entire input):
9
+
10
+ /help — print this list
11
+ /quit | /exit — exit (Ctrl-D also works)
12
+ /clear | /reset — clear conversation history (keeps system prompt)
13
+ /system [prompt] — show or set the system prompt; ``/system`` alone clears it
14
+ /provider <name> — switch provider (resets the conversation)
15
+ /model <name> — switch model
16
+ /history — print the running message list
17
+ /cost — print cumulative session cost + token totals
18
+
19
+ Out of scope per PLAN.md §13 (lands in 5.6):
20
+ /inbox /reply — require the inbox-drain coroutine
21
+ background drain — Phase 5.6 ``swarph daemon``
22
+
23
+ Streaming output is not wired here — every swarph-mesh adapter raises
24
+ ``NotImplementedError`` on ``stream()`` as of v0.5.0; the REPL awaits
25
+ the full response and prints in one block. Token-by-token streaming
26
+ lands alongside the cross-adapter ``stream()`` work in v0.5+.
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ import argparse
32
+ import asyncio
33
+ import os
34
+ import sys
35
+ from dataclasses import dataclass, field
36
+ from typing import Optional
37
+
38
+ from swarph_cli.caller import default_caller
39
+
40
+
41
+ _BANNER = """\
42
+ swarph chat — Phase 5 REPL
43
+ provider={provider} model={model} caller={caller}{system}
44
+
45
+ Type a message and press Enter to send. Slash commands:
46
+ /help /clear /system /provider /model /history /cost /quit
47
+ Ctrl-D to exit.
48
+ """
49
+
50
+ _HELP = """\
51
+ Slash commands:
52
+ /help — show this list
53
+ /quit, /exit — exit (Ctrl-D works too)
54
+ /clear, /reset — clear conversation history (keeps system prompt)
55
+ /system [prompt] — show or set system prompt; bare /system clears it
56
+ /provider <name> — switch provider (clears history; rebinds adapter)
57
+ /model <name> — switch model
58
+ /history — print the running message list
59
+ /cost — print cumulative cost + token totals
60
+ """
61
+
62
+
63
+ @dataclass
64
+ class ReplState:
65
+ """In-memory REPL state. Lives for the duration of a single
66
+ ``swarph chat`` process."""
67
+
68
+ provider: str
69
+ model: Optional[str]
70
+ caller: str
71
+ system_prompt: Optional[str]
72
+ temperature: float = 0.7
73
+ max_tokens: Optional[int] = None
74
+ messages: list = field(default_factory=list)
75
+ total_input_tokens: int = 0
76
+ total_output_tokens: int = 0
77
+ total_cost_usd: float = 0.0
78
+ turn_count: int = 0
79
+
80
+
81
+ def _build_parser() -> argparse.ArgumentParser:
82
+ p = argparse.ArgumentParser(
83
+ prog="swarph chat",
84
+ description=(
85
+ "Interactive REPL against any swarph-mesh adapter. "
86
+ "Phase 5 per PLAN.md §13."
87
+ ),
88
+ )
89
+ p.add_argument(
90
+ "--provider",
91
+ default="gemini",
92
+ help='LLM provider (gemini / deepseek / claude / openai / grok).',
93
+ )
94
+ p.add_argument(
95
+ "--model",
96
+ default=None,
97
+ help="Provider-specific model id. Defaults to the adapter's default_model.",
98
+ )
99
+ p.add_argument(
100
+ "--caller",
101
+ default=None,
102
+ help='Caller-convention slug. Defaults to "cli.repl.<user>".',
103
+ )
104
+ p.add_argument(
105
+ "--system",
106
+ default=None,
107
+ help="System prompt prepended to every turn.",
108
+ )
109
+ p.add_argument(
110
+ "--temperature",
111
+ type=float,
112
+ default=0.7,
113
+ )
114
+ p.add_argument(
115
+ "--max-tokens",
116
+ type=int,
117
+ default=None,
118
+ )
119
+ return p
120
+
121
+
122
+ def _default_repl_caller() -> str:
123
+ """``cli.repl.<user>`` — distinct from ``cli.oneshot.<user>`` so
124
+ attribution rows distinguish REPL turns from one-shot calls.
125
+
126
+ Falls back to the caller-convention default if env-derived user
127
+ extraction fails."""
128
+ user = os.environ.get("USER") or os.environ.get("LOGNAME")
129
+ if not user:
130
+ return default_caller()
131
+ user_slug = "".join(c if c.isalnum() else "_" for c in user.lower())
132
+ return f"cli.repl.{user_slug}"
133
+
134
+
135
+ def _print(msg: str = "", *, file=None) -> None:
136
+ """Centralized print helper — tests monkeypatch this so they can
137
+ capture REPL output without poking sys.stdout."""
138
+ print(msg, file=file or sys.stdout, flush=True)
139
+
140
+
141
+ def _read_line(prompt: str) -> str:
142
+ """Single-line input. Tests monkeypatch this to inject scripted
143
+ input. Production uses stdlib ``input()`` — readline is auto-loaded
144
+ by import-time on POSIX, giving line editing + history for free."""
145
+ try:
146
+ import readline # noqa: F401 — side-effect-only on POSIX
147
+ except ImportError:
148
+ pass # Windows / minimal builds; raw input still works
149
+ return input(prompt)
150
+
151
+
152
+ def _format_attribution(
153
+ *, input_tokens: int, output_tokens: int, cost_usd: float, duration_s: float, cached: bool
154
+ ) -> str:
155
+ cost_str = f"${cost_usd:.4f}" if cost_usd > 0 else "$0"
156
+ line = (
157
+ f"# {input_tokens}+{output_tokens}t "
158
+ f"{cost_str} {duration_s:.2f}s"
159
+ )
160
+ if cached:
161
+ line += " (cached)"
162
+ return line
163
+
164
+
165
+ def _handle_slash(state: ReplState, line: str) -> str:
166
+ """Apply a slash command to ``state``. Return one of:
167
+
168
+ - ``"continue"`` — keep the REPL running
169
+ - ``"quit"`` — exit the REPL with code 0
170
+ - ``"unknown"`` — unrecognized command (caller prints + continues)
171
+ """
172
+ parts = line.split(maxsplit=1)
173
+ cmd = parts[0].lower()
174
+ arg = parts[1] if len(parts) > 1 else ""
175
+
176
+ if cmd in ("/quit", "/exit"):
177
+ return "quit"
178
+
179
+ if cmd == "/help":
180
+ _print(_HELP)
181
+ return "continue"
182
+
183
+ if cmd in ("/clear", "/reset"):
184
+ state.messages = []
185
+ _print("[cleared conversation history]")
186
+ return "continue"
187
+
188
+ if cmd == "/system":
189
+ if not arg:
190
+ if state.system_prompt:
191
+ _print(f"[system prompt cleared (was: {state.system_prompt!r})]")
192
+ state.system_prompt = None
193
+ else:
194
+ _print("[no system prompt set]")
195
+ else:
196
+ state.system_prompt = arg
197
+ _print(f"[system prompt set: {arg!r}]")
198
+ return "continue"
199
+
200
+ if cmd == "/provider":
201
+ if not arg:
202
+ _print(f"[current provider: {state.provider}]")
203
+ return "continue"
204
+ state.provider = arg
205
+ state.model = None # adapter's default_model picks up
206
+ state.messages = []
207
+ _print(
208
+ f"[switched to provider={arg}; model reset to adapter default; "
209
+ f"history cleared]"
210
+ )
211
+ return "continue"
212
+
213
+ if cmd == "/model":
214
+ if not arg:
215
+ _print(f"[current model: {state.model or '(adapter default)'}]")
216
+ return "continue"
217
+ state.model = arg
218
+ _print(f"[switched to model={arg}]")
219
+ return "continue"
220
+
221
+ if cmd == "/history":
222
+ if not state.messages:
223
+ _print("[no messages yet]")
224
+ return "continue"
225
+ for i, m in enumerate(state.messages):
226
+ _print(f" [{i}] {m.role}: {m.content[:200]}")
227
+ return "continue"
228
+
229
+ if cmd == "/cost":
230
+ cost_str = f"${state.total_cost_usd:.6f}" if state.total_cost_usd > 0 else "$0"
231
+ _print(
232
+ f"[turns={state.turn_count} "
233
+ f"in={state.total_input_tokens} out={state.total_output_tokens} "
234
+ f"cost={cost_str}]"
235
+ )
236
+ return "continue"
237
+
238
+ return "unknown"
239
+
240
+
241
+ async def _send_turn(state: ReplState, user_text: str) -> int:
242
+ """Send one user turn, append assistant reply on success. Returns
243
+ 0 on success, non-zero on adapter error (REPL keeps running either
244
+ way; the return is for tests).
245
+
246
+ On adapter error the user turn is *not* appended to state.messages
247
+ so the user can retry the same input without doubling it up."""
248
+ # Local import — keeps the chat module importable in test contexts
249
+ # that don't have all five adapters installed.
250
+ from swarph_mesh import ChatMessage, SwarphCall
251
+
252
+ state.messages.append(ChatMessage(role="user", content=user_text))
253
+
254
+ try:
255
+ sc = SwarphCall(
256
+ provider=state.provider,
257
+ caller=state.caller,
258
+ model=state.model,
259
+ )
260
+ resp = await sc.chat(
261
+ messages=state.messages,
262
+ system_prompt=state.system_prompt,
263
+ temperature=state.temperature,
264
+ max_tokens=state.max_tokens,
265
+ )
266
+ except Exception as exc:
267
+ # Pop the failed user turn so retry doesn't compound it.
268
+ state.messages.pop()
269
+ _print(f"[error] {exc}", file=sys.stderr)
270
+ return 1
271
+
272
+ state.messages.append(ChatMessage(role="assistant", content=resp.text))
273
+ state.total_input_tokens += resp.input_tokens
274
+ state.total_output_tokens += resp.output_tokens
275
+ state.total_cost_usd += resp.cost_usd
276
+ state.turn_count += 1
277
+
278
+ _print(resp.text)
279
+ _print(
280
+ _format_attribution(
281
+ input_tokens=resp.input_tokens,
282
+ output_tokens=resp.output_tokens,
283
+ cost_usd=resp.cost_usd,
284
+ duration_s=resp.duration_s,
285
+ cached=resp.cached,
286
+ ),
287
+ file=sys.stderr,
288
+ )
289
+ return 0
290
+
291
+
292
+ async def _repl_loop(state: ReplState) -> int:
293
+ """Main REPL loop. Returns process exit code."""
294
+ sys_line = f" system={state.system_prompt!r}" if state.system_prompt else ""
295
+ _print(
296
+ _BANNER.format(
297
+ provider=state.provider,
298
+ model=state.model or "(adapter default)",
299
+ caller=state.caller,
300
+ system=sys_line,
301
+ )
302
+ )
303
+
304
+ while True:
305
+ try:
306
+ line = _read_line("> ")
307
+ except EOFError:
308
+ _print()
309
+ _print("[swarph-chat] bye.")
310
+ return 0
311
+ except KeyboardInterrupt:
312
+ _print()
313
+ _print("[interrupted — type /quit to exit]")
314
+ continue
315
+
316
+ line = line.rstrip()
317
+ if not line:
318
+ continue
319
+
320
+ if line.startswith("/"):
321
+ result = _handle_slash(state, line)
322
+ if result == "quit":
323
+ _print("[swarph-chat] bye.")
324
+ return 0
325
+ if result == "unknown":
326
+ _print(f"[unknown command: {line.split()[0]!r} — try /help]")
327
+ continue
328
+
329
+ await _send_turn(state, line)
330
+
331
+
332
+ def run_chat(argv: list[str]) -> int:
333
+ """Entry point invoked by ``swarph_cli.main`` verb dispatch.
334
+
335
+ Returns process exit code."""
336
+ args = _build_parser().parse_args(argv)
337
+ caller = args.caller or _default_repl_caller()
338
+
339
+ state = ReplState(
340
+ provider=args.provider,
341
+ model=args.model,
342
+ caller=caller,
343
+ system_prompt=args.system,
344
+ temperature=args.temperature,
345
+ max_tokens=args.max_tokens,
346
+ )
347
+
348
+ return asyncio.run(_repl_loop(state))
@@ -36,14 +36,16 @@ swarph v{version}
36
36
 
37
37
  Usage:
38
38
  swarph "your prompt here" [--provider gemini] [--model gemini-2.5-flash]
39
+ swarph chat [--provider deepseek] [--model deepseek-v4-flash] [--system PROMPT]
39
40
  swarph import <path-to-source-session> [--report-only] [--target-session NAME]
40
41
 
41
42
  Examples:
42
43
  swarph "explain Hawkes process briefly"
43
44
  swarph "list 5 tickers" --json
45
+ swarph chat --provider claude
44
46
  swarph import ~/.claude/projects/.../X.jsonl --report-only
45
47
 
46
- Status: Phase 2 one-shot + Phase 2.5 import ready. REPL (Phase 5),
48
+ Status: Phase 2 one-shot + Phase 2.5 import + Phase 5 REPL ready.
47
49
  --ask <peer> (Phase 3), onboard/ratify (Phase 5.5), daemon (Phase 5.7)
48
50
  ship in subsequent releases.
49
51
 
@@ -55,7 +57,8 @@ Spec: https://github.com/darw007d/hedge-fund-mcp/blob/main/research/swarph_cli/P
55
57
  _VERB_HANDLERS: dict[str, str] = {
56
58
  # verb keyword: dotted-path to handler function (lazy-imported)
57
59
  "import": "swarph_cli.commands.import_session.run_import",
58
- # Future: "chat", "daemon", "onboard", "ratify", "list-peers", etc.
60
+ "chat": "swarph_cli.commands.chat.run_chat",
61
+ # Future: "daemon", "onboard", "ratify", "list-peers", etc.
59
62
  }
60
63
 
61
64
 
@@ -1,7 +1,7 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: swarph-cli
3
- Version: 0.2.0
4
- Summary: The `swarph` binary — multi-LLM CLI with mesh-gateway integration. Phase 2 one-shot mode shipped (PLAN.md §13).
3
+ Version: 0.3.0
4
+ Summary: The `swarph` binary — multi-LLM CLI with mesh-gateway integration. v0.3.0 ships the Phase 5 `swarph chat` REPL on top of Phase 2 one-shot + Phase 2.5 import (PLAN.md §13).
5
5
  Author: Pierre Samson, Claude Opus
6
6
  License: MIT
7
7
  Project-URL: Homepage, https://github.com/darw007d/swarph-cli
@@ -24,7 +24,7 @@ Classifier: Topic :: Utilities
24
24
  Requires-Python: >=3.10
25
25
  Description-Content-Type: text/markdown
26
26
  License-File: LICENSE
27
- Requires-Dist: swarph-mesh>=0.1.0
27
+ Requires-Dist: swarph-mesh>=0.5.0
28
28
  Requires-Dist: swarph-shared>=0.2.0
29
29
  Provides-Extra: dev
30
30
  Requires-Dist: pytest>=7.0; extra == "dev"
@@ -49,12 +49,52 @@ This is one of three repos in the v0.3.x architecture:
49
49
 
50
50
  ## Status
51
51
 
52
- **v0.2.0 — Phase 2 one-shot + Phase 2.5 import.** Two verbs ship:
52
+ **v0.3.0 — Phase 2 one-shot + Phase 2.5 import + Phase 5 REPL.** Three verbs ship:
53
53
 
54
- 1. `swarph "prompt"` — Phase 2 one-shot mode (against `--provider gemini`)
55
- 2. `swarph import <path>` — Phase 2.5 session import (Claude JSONL → swarph-native, with `--report-only` for honest pre-commit inspection)
54
+ 1. `swarph "prompt"` — Phase 2 one-shot mode (any of five providers)
55
+ 2. `swarph chat` — Phase 5 interactive REPL with multi-turn history + slash commands
56
+ 3. `swarph import <path>` — Phase 2.5 session import (Claude JSONL → swarph-native, with `--report-only` for honest pre-commit inspection)
56
57
 
57
- Subsequent phases extend the CLI surface (REPL, `--ask <peer>`, onboard/ratify, daemon, additional source formats).
58
+ Subsequent phases extend the CLI surface (`--ask <peer>`, onboard/ratify, daemon, additional source formats).
59
+
60
+ ### `swarph chat`
61
+
62
+ Interactive REPL against any of the five swarph-mesh adapters (`gemini` / `deepseek` / `claude` / `openai` / `grok`). Multi-turn conversation history accumulates in-memory; cumulative session cost + token totals tracked.
63
+
64
+ ```bash
65
+ $ swarph chat --provider claude
66
+ swarph chat — Phase 5 REPL
67
+ provider=claude model=(adapter default) caller=cli.repl.ubuntu
68
+
69
+ Type a message and press Enter to send. Slash commands:
70
+ /help /clear /system /provider /model /history /cost /quit
71
+ Ctrl-D to exit.
72
+
73
+ > hello
74
+ Hi! How can I help...
75
+ # 8+12t $0 0.34s
76
+
77
+ > /provider gemini
78
+ [switched to provider=gemini; model reset to adapter default; history cleared]
79
+
80
+ > /cost
81
+ [turns=1 in=8 out=12 cost=$0]
82
+
83
+ > /quit
84
+ [swarph-chat] bye.
85
+ ```
86
+
87
+ **Slash commands:**
88
+ - `/help` — print available commands
89
+ - `/quit`, `/exit` (or Ctrl-D) — exit
90
+ - `/clear`, `/reset` — clear history (keeps system prompt)
91
+ - `/system [prompt]` — set or clear system prompt
92
+ - `/provider <name>` — switch provider (resets history)
93
+ - `/model <name>` — switch model
94
+ - `/history` — print running message list
95
+ - `/cost` — cumulative session cost + tokens
96
+
97
+ **Out of scope until Phase 5.6** (`swarph daemon`): inbox drain coroutine, `/inbox` and `/reply` slash commands. Streaming output ships alongside the cross-adapter `stream()` work in v0.5+ of swarph-mesh.
58
98
 
59
99
  ### `swarph import`
60
100
 
@@ -115,13 +155,14 @@ Pong!
115
155
 
116
156
  | Phase | What lands |
117
157
  |---|---|
118
- | **0** (this) | Scaffold — entry-point + status banner |
119
- | **2** | One-shot mode: `swarph "hello" --provider gemini` |
158
+ | **0** | Scaffold — entry-point + status banner |
159
+ | **2** (v0.1.0) | One-shot mode: `swarph "hello" --provider gemini` |
160
+ | **2.5** (v0.2.0) | `swarph import` — Claude JSONL → swarph-native session format |
161
+ | **5** (v0.3.0 — this release) | **`swarph chat` interactive REPL** — multi-turn against any of five adapters + slash commands (`/help`, `/clear`, `/system`, `/provider`, `/model`, `/history`, `/cost`, `/quit`) |
120
162
  | **3** | `--ask <peer>` mesh-aware one-shot via MeshClient |
121
- | **5** | Interactive REPL — `/inbox`, `/reply`, `/dm`, `/watch` |
122
163
  | **5.5** | `swarph onboard <peer-name>` + `swarph ratify <peer-name>` (PLAN.md §15) |
123
- | **5.7** | `swarph daemon` foreground drain loop + `swarph chat` REPL with drain coroutine (PLAN.md §16) |
124
- | **6** | PyPI publish |
164
+ | **5.6** | `swarph daemon` foreground drain loop + REPL drain coroutine + `/inbox`, `/reply` (PLAN.md §16) |
165
+ | **6** | (already done) PyPI publish |
125
166
 
126
167
  ## Why split CLI from substrate
127
168
 
@@ -11,10 +11,13 @@ src/swarph_cli.egg-info/entry_points.txt
11
11
  src/swarph_cli.egg-info/requires.txt
12
12
  src/swarph_cli.egg-info/top_level.txt
13
13
  src/swarph_cli/commands/__init__.py
14
+ src/swarph_cli/commands/chat.py
14
15
  src/swarph_cli/commands/import_session.py
15
16
  src/swarph_cli/parsers/__init__.py
16
17
  src/swarph_cli/parsers/claude.py
18
+ tests/test_chat_command.py
17
19
  tests/test_claude_parser.py
18
20
  tests/test_import_command.py
19
21
  tests/test_main.py
22
+ tests/test_smoke_chat.py
20
23
  tests/test_smoke_one_shot.py
@@ -1,4 +1,4 @@
1
- swarph-mesh>=0.1.0
1
+ swarph-mesh>=0.5.0
2
2
  swarph-shared>=0.2.0
3
3
 
4
4
  [dev]
@@ -0,0 +1,444 @@
1
+ """Tests for ``swarph chat`` REPL — offline only, mocked stdin + adapter.
2
+
3
+ Live smoke (one round-trip via REPL) lives in ``test_smoke_chat.py``.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import asyncio
9
+ from unittest.mock import patch
10
+
11
+ import pytest
12
+
13
+ from swarph_cli.commands import chat as chat_cmd
14
+ from swarph_cli.commands.chat import (
15
+ ReplState,
16
+ _default_repl_caller,
17
+ _format_attribution,
18
+ _handle_slash,
19
+ )
20
+
21
+
22
+ # ---------------------------------------------------------------------------
23
+ # Caller default
24
+ # ---------------------------------------------------------------------------
25
+
26
+
27
+ def test_default_repl_caller_uses_user_env(monkeypatch):
28
+ monkeypatch.setenv("USER", "pierre")
29
+ assert _default_repl_caller() == "cli.repl.pierre"
30
+
31
+
32
+ def test_default_repl_caller_slugs_non_alnum(monkeypatch):
33
+ monkeypatch.setenv("USER", "Some.User-1")
34
+ monkeypatch.delenv("LOGNAME", raising=False)
35
+ assert _default_repl_caller() == "cli.repl.some_user_1"
36
+
37
+
38
+ def test_default_repl_caller_falls_back_when_user_unset(monkeypatch):
39
+ monkeypatch.delenv("USER", raising=False)
40
+ monkeypatch.delenv("LOGNAME", raising=False)
41
+ out = _default_repl_caller()
42
+ # caller must satisfy the dotted-lowercase convention even on
43
+ # fallback — swarph_shared.validate_caller is strict.
44
+ assert out
45
+ assert out.islower()
46
+ assert " " not in out
47
+
48
+
49
+ # ---------------------------------------------------------------------------
50
+ # Attribution footer
51
+ # ---------------------------------------------------------------------------
52
+
53
+
54
+ def test_format_attribution_zero_cost_renders_dollar_zero():
55
+ s = _format_attribution(
56
+ input_tokens=10, output_tokens=5, cost_usd=0.0, duration_s=1.23, cached=False
57
+ )
58
+ assert "$0" in s
59
+ assert "$0.0000" not in s # the >0 branch shouldn't fire
60
+
61
+
62
+ def test_format_attribution_nonzero_cost_renders_4dp():
63
+ s = _format_attribution(
64
+ input_tokens=100,
65
+ output_tokens=50,
66
+ cost_usd=0.0123,
67
+ duration_s=2.0,
68
+ cached=False,
69
+ )
70
+ assert "$0.0123" in s
71
+
72
+
73
+ def test_format_attribution_marks_cached():
74
+ s = _format_attribution(
75
+ input_tokens=10, output_tokens=5, cost_usd=0.001, duration_s=0.5, cached=True
76
+ )
77
+ assert "(cached)" in s
78
+
79
+
80
+ # ---------------------------------------------------------------------------
81
+ # Slash commands — pure state mutation
82
+ # ---------------------------------------------------------------------------
83
+
84
+
85
+ def _state(provider="gemini", model=None, system=None) -> ReplState:
86
+ return ReplState(
87
+ provider=provider,
88
+ model=model,
89
+ caller="cli.repl.test",
90
+ system_prompt=system,
91
+ )
92
+
93
+
94
+ def test_slash_quit_returns_quit():
95
+ s = _state()
96
+ assert _handle_slash(s, "/quit") == "quit"
97
+
98
+
99
+ def test_slash_exit_alias_returns_quit():
100
+ s = _state()
101
+ assert _handle_slash(s, "/exit") == "quit"
102
+
103
+
104
+ def test_slash_help_returns_continue():
105
+ s = _state()
106
+ assert _handle_slash(s, "/help") == "continue"
107
+
108
+
109
+ def test_slash_clear_resets_messages():
110
+ s = _state()
111
+ s.messages = ["m1", "m2", "m3"]
112
+ assert _handle_slash(s, "/clear") == "continue"
113
+ assert s.messages == []
114
+
115
+
116
+ def test_slash_reset_alias_clears_messages():
117
+ s = _state()
118
+ s.messages = ["m1"]
119
+ assert _handle_slash(s, "/reset") == "continue"
120
+ assert s.messages == []
121
+
122
+
123
+ def test_slash_clear_keeps_system_prompt():
124
+ s = _state(system="be terse")
125
+ s.messages = ["m1"]
126
+ _handle_slash(s, "/clear")
127
+ assert s.system_prompt == "be terse"
128
+
129
+
130
+ def test_slash_system_with_arg_sets_prompt():
131
+ s = _state()
132
+ _handle_slash(s, "/system you are an assistant")
133
+ assert s.system_prompt == "you are an assistant"
134
+
135
+
136
+ def test_slash_system_bare_clears_prompt():
137
+ s = _state(system="be terse")
138
+ _handle_slash(s, "/system")
139
+ assert s.system_prompt is None
140
+
141
+
142
+ def test_slash_provider_switch_resets_history():
143
+ s = _state(provider="gemini")
144
+ s.messages = ["m1", "m2"]
145
+ s.model = "gemini-2.5-flash"
146
+ _handle_slash(s, "/provider claude")
147
+ assert s.provider == "claude"
148
+ assert s.model is None # picks up adapter default
149
+ assert s.messages == []
150
+
151
+
152
+ def test_slash_provider_bare_shows_current(capsys):
153
+ s = _state(provider="grok")
154
+ _handle_slash(s, "/provider")
155
+ captured = capsys.readouterr()
156
+ assert "grok" in captured.out
157
+
158
+
159
+ def test_slash_model_sets_model():
160
+ s = _state()
161
+ _handle_slash(s, "/model deepseek-v4-pro")
162
+ assert s.model == "deepseek-v4-pro"
163
+
164
+
165
+ def test_slash_history_with_no_messages(capsys):
166
+ s = _state()
167
+ _handle_slash(s, "/history")
168
+ captured = capsys.readouterr()
169
+ assert "no messages" in captured.out
170
+
171
+
172
+ def test_slash_cost_prints_running_totals(capsys):
173
+ s = _state()
174
+ s.turn_count = 3
175
+ s.total_input_tokens = 150
176
+ s.total_output_tokens = 75
177
+ s.total_cost_usd = 0.0042
178
+ _handle_slash(s, "/cost")
179
+ out = capsys.readouterr().out
180
+ assert "turns=3" in out
181
+ assert "in=150" in out
182
+ assert "out=75" in out
183
+ assert "$0.004200" in out
184
+
185
+
186
+ def test_slash_cost_zero_renders_dollar_zero(capsys):
187
+ s = _state()
188
+ _handle_slash(s, "/cost")
189
+ out = capsys.readouterr().out
190
+ assert "cost=$0]" in out
191
+
192
+
193
+ def test_unknown_slash_returns_unknown():
194
+ s = _state()
195
+ assert _handle_slash(s, "/banana split") == "unknown"
196
+
197
+
198
+ # ---------------------------------------------------------------------------
199
+ # _send_turn — adapter wiring with mocked SwarphCall
200
+ # ---------------------------------------------------------------------------
201
+
202
+
203
+ def _mock_response(*, text="ok", in_tok=10, out_tok=5, cost=0.001, dur=1.0, cached=False):
204
+ """Build a minimal LLMResponse-compatible shape."""
205
+ from swarph_mesh.types import LLMResponse
206
+
207
+ return LLMResponse(
208
+ text=text,
209
+ input_tokens=in_tok,
210
+ output_tokens=out_tok,
211
+ cost_usd=cost,
212
+ duration_s=dur,
213
+ cached=cached,
214
+ )
215
+
216
+
217
+ class _FakeSwarphCall:
218
+ """Drop-in for SwarphCall in chat tests. Captures the args it was
219
+ called with so tests can assert on them."""
220
+
221
+ captured: list = []
222
+ response: object = None
223
+ raise_exc: object = None
224
+
225
+ def __init__(self, *, provider, caller, model=None):
226
+ self.provider = provider
227
+ self.caller = caller
228
+ self.model = model
229
+
230
+ async def chat(self, **kwargs):
231
+ # Snapshot the messages list at call time — the REPL mutates
232
+ # the same list after the call returns (appends assistant turn),
233
+ # so a reference-only capture would show post-call state.
234
+ snapshot = dict(kwargs)
235
+ if "messages" in snapshot:
236
+ snapshot["messages"] = list(snapshot["messages"])
237
+ type(self).captured.append({"kwargs": snapshot, "model": self.model})
238
+ if type(self).raise_exc is not None:
239
+ raise type(self).raise_exc
240
+ return type(self).response
241
+
242
+
243
+ @pytest.fixture
244
+ def fake_call(monkeypatch):
245
+ """Patch swarph_mesh.SwarphCall in the chat module's namespace."""
246
+ _FakeSwarphCall.captured = []
247
+ _FakeSwarphCall.response = _mock_response()
248
+ _FakeSwarphCall.raise_exc = None
249
+
250
+ # The chat module imports SwarphCall locally inside _send_turn, so
251
+ # we patch swarph_mesh.SwarphCall directly.
252
+ import swarph_mesh
253
+
254
+ monkeypatch.setattr(swarph_mesh, "SwarphCall", _FakeSwarphCall)
255
+ return _FakeSwarphCall
256
+
257
+
258
+ def test_send_turn_appends_assistant_reply(fake_call):
259
+ s = _state()
260
+ fake_call.response = _mock_response(text="hi there")
261
+ asyncio.run(chat_cmd._send_turn(s, "hello"))
262
+ assert len(s.messages) == 2
263
+ assert s.messages[0].role == "user"
264
+ assert s.messages[0].content == "hello"
265
+ assert s.messages[1].role == "assistant"
266
+ assert s.messages[1].content == "hi there"
267
+
268
+
269
+ def test_send_turn_accumulates_cost_and_tokens(fake_call):
270
+ s = _state()
271
+ fake_call.response = _mock_response(in_tok=20, out_tok=10, cost=0.005)
272
+ asyncio.run(chat_cmd._send_turn(s, "q1"))
273
+ asyncio.run(chat_cmd._send_turn(s, "q2"))
274
+ assert s.turn_count == 2
275
+ assert s.total_input_tokens == 40
276
+ assert s.total_output_tokens == 20
277
+ assert s.total_cost_usd == pytest.approx(0.010)
278
+
279
+
280
+ def test_send_turn_passes_system_prompt_through(fake_call):
281
+ s = _state(system="be terse")
282
+ asyncio.run(chat_cmd._send_turn(s, "q"))
283
+ assert fake_call.captured[0]["kwargs"]["system_prompt"] == "be terse"
284
+
285
+
286
+ def test_send_turn_passes_temperature_and_max_tokens(fake_call):
287
+ s = _state()
288
+ s.temperature = 0.1
289
+ s.max_tokens = 256
290
+ asyncio.run(chat_cmd._send_turn(s, "q"))
291
+ kw = fake_call.captured[0]["kwargs"]
292
+ assert kw["temperature"] == 0.1
293
+ assert kw["max_tokens"] == 256
294
+
295
+
296
+ def test_send_turn_threads_messages_for_multiturn_history(fake_call):
297
+ """Second turn must include the first user+assistant turn so the
298
+ adapter sees the full context."""
299
+ s = _state()
300
+ fake_call.response = _mock_response(text="a1")
301
+ asyncio.run(chat_cmd._send_turn(s, "q1"))
302
+ fake_call.response = _mock_response(text="a2")
303
+ asyncio.run(chat_cmd._send_turn(s, "q2"))
304
+
305
+ second_call_messages = fake_call.captured[1]["kwargs"]["messages"]
306
+ assert len(second_call_messages) == 3 # q1, a1, q2 (the new turn)
307
+ assert second_call_messages[0].content == "q1"
308
+ assert second_call_messages[1].content == "a1"
309
+ assert second_call_messages[2].content == "q2"
310
+
311
+
312
+ def test_send_turn_pops_user_turn_on_adapter_error(fake_call, capsys):
313
+ """Adapter error: user can retry without doubling the input."""
314
+ s = _state()
315
+ fake_call.raise_exc = RuntimeError("provider exploded")
316
+ rc = asyncio.run(chat_cmd._send_turn(s, "q"))
317
+ assert rc == 1
318
+ assert s.messages == [] # popped
319
+ assert s.turn_count == 0
320
+ err = capsys.readouterr().err
321
+ assert "[error]" in err
322
+ assert "provider exploded" in err
323
+
324
+
325
+ # ---------------------------------------------------------------------------
326
+ # REPL loop — script stdin via _read_line monkeypatch
327
+ # ---------------------------------------------------------------------------
328
+
329
+
330
+ def _scripted_input(lines: list[str]):
331
+ """Build a _read_line replacement that returns each line in order,
332
+ then raises EOFError to terminate the loop cleanly."""
333
+ it = iter(lines)
334
+
335
+ def fake(prompt):
336
+ try:
337
+ return next(it)
338
+ except StopIteration:
339
+ raise EOFError()
340
+
341
+ return fake
342
+
343
+
344
+ def test_repl_loop_exits_on_eof(monkeypatch, fake_call, capsys):
345
+ monkeypatch.setattr(chat_cmd, "_read_line", _scripted_input([]))
346
+ s = _state()
347
+ rc = asyncio.run(chat_cmd._repl_loop(s))
348
+ assert rc == 0
349
+ out = capsys.readouterr().out
350
+ assert "swarph chat" in out # banner printed
351
+ assert "bye" in out
352
+
353
+
354
+ def test_repl_loop_exits_on_quit(monkeypatch, fake_call):
355
+ monkeypatch.setattr(chat_cmd, "_read_line", _scripted_input(["/quit"]))
356
+ s = _state()
357
+ rc = asyncio.run(chat_cmd._repl_loop(s))
358
+ assert rc == 0
359
+
360
+
361
+ def test_repl_loop_round_trips_one_message(monkeypatch, fake_call):
362
+ fake_call.response = _mock_response(text="response_text")
363
+ monkeypatch.setattr(chat_cmd, "_read_line", _scripted_input(["hello", "/quit"]))
364
+ s = _state()
365
+ asyncio.run(chat_cmd._repl_loop(s))
366
+ assert s.turn_count == 1
367
+ assert s.messages[0].content == "hello"
368
+ assert s.messages[1].content == "response_text"
369
+
370
+
371
+ def test_repl_loop_skips_empty_lines(monkeypatch, fake_call):
372
+ """Empty/whitespace input shouldn't trigger an LLM call."""
373
+ monkeypatch.setattr(
374
+ chat_cmd, "_read_line", _scripted_input(["", " ", "/quit"])
375
+ )
376
+ s = _state()
377
+ asyncio.run(chat_cmd._repl_loop(s))
378
+ assert s.turn_count == 0
379
+ assert fake_call.captured == []
380
+
381
+
382
+ def test_repl_loop_handles_keyboard_interrupt(monkeypatch, fake_call, capsys):
383
+ """Ctrl-C mid-line should print an interrupt notice and continue,
384
+ not abort the REPL."""
385
+ state_iter = iter([KeyboardInterrupt(), "/quit"])
386
+
387
+ def fake_read(prompt):
388
+ v = next(state_iter)
389
+ if isinstance(v, BaseException):
390
+ raise v
391
+ return v
392
+
393
+ monkeypatch.setattr(chat_cmd, "_read_line", fake_read)
394
+ s = _state()
395
+ rc = asyncio.run(chat_cmd._repl_loop(s))
396
+ assert rc == 0
397
+ out = capsys.readouterr().out
398
+ assert "interrupted" in out
399
+
400
+
401
+ def test_repl_loop_unknown_slash_message(monkeypatch, fake_call, capsys):
402
+ monkeypatch.setattr(
403
+ chat_cmd, "_read_line", _scripted_input(["/banana", "/quit"])
404
+ )
405
+ s = _state()
406
+ asyncio.run(chat_cmd._repl_loop(s))
407
+ out = capsys.readouterr().out
408
+ assert "unknown command" in out
409
+
410
+
411
+ def test_repl_loop_provider_switch_clears_history(monkeypatch, fake_call):
412
+ fake_call.response = _mock_response(text="r1")
413
+ monkeypatch.setattr(
414
+ chat_cmd,
415
+ "_read_line",
416
+ _scripted_input(["q1", "/provider claude", "/quit"]),
417
+ )
418
+ s = _state(provider="gemini")
419
+ asyncio.run(chat_cmd._repl_loop(s))
420
+ assert s.provider == "claude"
421
+ assert s.messages == [] # cleared on switch
422
+
423
+
424
+ # ---------------------------------------------------------------------------
425
+ # Verb dispatch — main.py routes "chat" to run_chat
426
+ # ---------------------------------------------------------------------------
427
+
428
+
429
+ def test_main_dispatches_chat_verb(monkeypatch):
430
+ """``swarph chat ...`` should land in run_chat with rest of argv."""
431
+ from swarph_cli import main as main_mod
432
+
433
+ captured = {}
434
+
435
+ def fake_run_chat(argv):
436
+ captured["argv"] = argv
437
+ return 0
438
+
439
+ monkeypatch.setattr(
440
+ "swarph_cli.commands.chat.run_chat", fake_run_chat
441
+ )
442
+ rc = main_mod.main(["chat", "--provider", "claude"])
443
+ assert rc == 0
444
+ assert captured["argv"] == ["--provider", "claude"]
@@ -0,0 +1,61 @@
1
+ """Live smoke for ``swarph chat`` REPL — Phase 5 falsifiability gate per
2
+ PLAN.md §13 (manual verification proxy).
3
+
4
+ Single round-trip: scripted stdin sends one user turn + /quit, REPL
5
+ calls the real adapter, asserts response written + state correct.
6
+
7
+ Skipped unless GEMINI_API_KEY is set — gemini-flash is the cheapest tier
8
+ across all five adapters and the established Phase 1 falsifiability
9
+ target.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import asyncio
15
+ import os
16
+
17
+ import pytest
18
+
19
+ from swarph_cli.commands import chat as chat_cmd
20
+ from swarph_cli.commands.chat import ReplState
21
+
22
+
23
+ pytestmark = pytest.mark.skipif(
24
+ not os.environ.get("GEMINI_API_KEY"),
25
+ reason="GEMINI_API_KEY not set — Phase 5 live smoke skipped",
26
+ )
27
+
28
+
29
+ def test_phase_5_repl_falsifiability_gate(monkeypatch):
30
+ """One scripted user turn through the real REPL → real adapter →
31
+ real response. State should reflect: 1 turn, history populated,
32
+ cumulative cost > 0."""
33
+ inputs = iter(["say PONG and nothing else", "/quit"])
34
+
35
+ def fake_read(prompt):
36
+ try:
37
+ return next(inputs)
38
+ except StopIteration:
39
+ raise EOFError()
40
+
41
+ monkeypatch.setattr(chat_cmd, "_read_line", fake_read)
42
+
43
+ state = ReplState(
44
+ provider="gemini",
45
+ model=None, # adapter default (flash)
46
+ caller="cli.smoke.phase_5_gate",
47
+ system_prompt=None,
48
+ temperature=0.0,
49
+ max_tokens=8,
50
+ )
51
+ rc = asyncio.run(chat_cmd._repl_loop(state))
52
+
53
+ assert rc == 0
54
+ assert state.turn_count == 1
55
+ assert len(state.messages) == 2
56
+ assert state.messages[0].role == "user"
57
+ assert state.messages[1].role == "assistant"
58
+ assert state.messages[1].content
59
+ assert state.total_input_tokens > 0
60
+ assert state.total_output_tokens > 0
61
+ assert state.total_cost_usd >= 0.0 # Flex tier could rebate near 0
File without changes
File without changes