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.
- {swarph_cli-0.2.0/src/swarph_cli.egg-info → swarph_cli-0.3.0}/PKG-INFO +53 -12
- {swarph_cli-0.2.0 → swarph_cli-0.3.0}/README.md +50 -9
- {swarph_cli-0.2.0 → swarph_cli-0.3.0}/pyproject.toml +5 -3
- {swarph_cli-0.2.0 → swarph_cli-0.3.0}/src/swarph_cli/__init__.py +1 -1
- swarph_cli-0.3.0/src/swarph_cli/commands/chat.py +348 -0
- {swarph_cli-0.2.0 → swarph_cli-0.3.0}/src/swarph_cli/main.py +5 -2
- {swarph_cli-0.2.0 → swarph_cli-0.3.0/src/swarph_cli.egg-info}/PKG-INFO +53 -12
- {swarph_cli-0.2.0 → swarph_cli-0.3.0}/src/swarph_cli.egg-info/SOURCES.txt +3 -0
- {swarph_cli-0.2.0 → swarph_cli-0.3.0}/src/swarph_cli.egg-info/requires.txt +1 -1
- swarph_cli-0.3.0/tests/test_chat_command.py +444 -0
- swarph_cli-0.3.0/tests/test_smoke_chat.py +61 -0
- {swarph_cli-0.2.0 → swarph_cli-0.3.0}/LICENSE +0 -0
- {swarph_cli-0.2.0 → swarph_cli-0.3.0}/setup.cfg +0 -0
- {swarph_cli-0.2.0 → swarph_cli-0.3.0}/src/swarph_cli/caller.py +0 -0
- {swarph_cli-0.2.0 → swarph_cli-0.3.0}/src/swarph_cli/commands/__init__.py +0 -0
- {swarph_cli-0.2.0 → swarph_cli-0.3.0}/src/swarph_cli/commands/import_session.py +0 -0
- {swarph_cli-0.2.0 → swarph_cli-0.3.0}/src/swarph_cli/parsers/__init__.py +0 -0
- {swarph_cli-0.2.0 → swarph_cli-0.3.0}/src/swarph_cli/parsers/claude.py +0 -0
- {swarph_cli-0.2.0 → swarph_cli-0.3.0}/src/swarph_cli.egg-info/dependency_links.txt +0 -0
- {swarph_cli-0.2.0 → swarph_cli-0.3.0}/src/swarph_cli.egg-info/entry_points.txt +0 -0
- {swarph_cli-0.2.0 → swarph_cli-0.3.0}/src/swarph_cli.egg-info/top_level.txt +0 -0
- {swarph_cli-0.2.0 → swarph_cli-0.3.0}/tests/test_claude_parser.py +0 -0
- {swarph_cli-0.2.0 → swarph_cli-0.3.0}/tests/test_import_command.py +0 -0
- {swarph_cli-0.2.0 → swarph_cli-0.3.0}/tests/test_main.py +0 -0
- {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.
|
|
4
|
-
Summary: The `swarph` binary — multi-LLM CLI with mesh-gateway integration. Phase 2 one-shot
|
|
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.
|
|
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.
|
|
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 (
|
|
55
|
-
2. `swarph
|
|
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 (
|
|
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**
|
|
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.
|
|
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.
|
|
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 (
|
|
23
|
-
2. `swarph
|
|
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 (
|
|
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**
|
|
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.
|
|
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.
|
|
8
|
-
description = "The `swarph` binary — multi-LLM CLI with mesh-gateway integration. Phase 2 one-shot
|
|
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
|
-
|
|
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
|
|
|
@@ -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
|
|
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
|
-
|
|
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.
|
|
4
|
-
Summary: The `swarph` binary — multi-LLM CLI with mesh-gateway integration. Phase 2 one-shot
|
|
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.
|
|
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.
|
|
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 (
|
|
55
|
-
2. `swarph
|
|
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 (
|
|
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**
|
|
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.
|
|
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
|
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|