thwip-cli 1.1.3__tar.gz → 1.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 (48) hide show
  1. {thwip_cli-1.1.3 → thwip_cli-1.3.0}/.github/workflows/publish.yml +4 -2
  2. {thwip_cli-1.1.3 → thwip_cli-1.3.0}/PKG-INFO +55 -5
  3. {thwip_cli-1.1.3 → thwip_cli-1.3.0}/README.md +54 -4
  4. thwip_cli-1.3.0/docs/handoff-research.md +50 -0
  5. thwip_cli-1.3.0/docs/verification.md +28 -0
  6. {thwip_cli-1.1.3 → thwip_cli-1.3.0}/pyproject.toml +1 -1
  7. thwip_cli-1.3.0/tests/test_audit_regressions.py +164 -0
  8. {thwip_cli-1.1.3 → thwip_cli-1.3.0}/tests/test_cli.py +25 -0
  9. thwip_cli-1.3.0/tests/test_compatible_streaming.py +75 -0
  10. thwip_cli-1.3.0/tests/test_handoff.py +222 -0
  11. thwip_cli-1.3.0/tests/test_native_launcher.py +69 -0
  12. thwip_cli-1.3.0/tests/test_repair_verification.py +177 -0
  13. {thwip_cli-1.1.3 → thwip_cli-1.3.0}/tests/test_session.py +41 -0
  14. thwip_cli-1.3.0/tests/test_utils.py +17 -0
  15. {thwip_cli-1.1.3 → thwip_cli-1.3.0}/thwip/__init__.py +1 -1
  16. {thwip_cli-1.1.3 → thwip_cli-1.3.0}/thwip/agents/base.py +5 -0
  17. thwip_cli-1.3.0/thwip/agents/chat_messages.py +20 -0
  18. {thwip_cli-1.1.3 → thwip_cli-1.3.0}/thwip/agents/claude_agent.py +6 -1
  19. {thwip_cli-1.1.3 → thwip_cli-1.3.0}/thwip/agents/deepseek_agent.py +2 -1
  20. {thwip_cli-1.1.3 → thwip_cli-1.3.0}/thwip/agents/google_agent.py +5 -1
  21. {thwip_cli-1.1.3 → thwip_cli-1.3.0}/thwip/agents/groq_agent.py +5 -3
  22. {thwip_cli-1.1.3 → thwip_cli-1.3.0}/thwip/agents/ollama_agent.py +11 -0
  23. {thwip_cli-1.1.3 → thwip_cli-1.3.0}/thwip/agents/openai_agent.py +8 -1
  24. {thwip_cli-1.1.3 → thwip_cli-1.3.0}/thwip/agents/openrouter_agent.py +2 -1
  25. {thwip_cli-1.1.3 → thwip_cli-1.3.0}/thwip/cli.py +194 -41
  26. {thwip_cli-1.1.3 → thwip_cli-1.3.0}/thwip/config.py +27 -0
  27. {thwip_cli-1.1.3 → thwip_cli-1.3.0}/thwip/detector.py +16 -10
  28. thwip_cli-1.3.0/thwip/handoff.py +91 -0
  29. {thwip_cli-1.1.3 → thwip_cli-1.3.0}/thwip/limits.py +33 -4
  30. {thwip_cli-1.1.3 → thwip_cli-1.3.0}/thwip/session.py +49 -3
  31. {thwip_cli-1.1.3 → thwip_cli-1.3.0}/thwip/shortcuts.py +4 -2
  32. {thwip_cli-1.1.3 → thwip_cli-1.3.0}/thwip/theme.py +18 -12
  33. {thwip_cli-1.1.3 → thwip_cli-1.3.0}/thwip/tools/__init__.py +22 -1
  34. {thwip_cli-1.1.3 → thwip_cli-1.3.0}/thwip/tools/code_runner.py +4 -6
  35. {thwip_cli-1.1.3 → thwip_cli-1.3.0}/thwip/tools/file_editor.py +1 -1
  36. {thwip_cli-1.1.3 → thwip_cli-1.3.0}/thwip/tools/terminal.py +35 -5
  37. {thwip_cli-1.1.3 → thwip_cli-1.3.0}/thwip/utils.py +1 -1
  38. {thwip_cli-1.1.3 → thwip_cli-1.3.0}/uv.lock +1 -1
  39. {thwip_cli-1.1.3 → thwip_cli-1.3.0}/.gitignore +0 -0
  40. {thwip_cli-1.1.3 → thwip_cli-1.3.0}/LICENSE +0 -0
  41. {thwip_cli-1.1.3 → thwip_cli-1.3.0}/install.sh +0 -0
  42. {thwip_cli-1.1.3 → thwip_cli-1.3.0}/tests/test_agents.py +0 -0
  43. {thwip_cli-1.1.3 → thwip_cli-1.3.0}/tests/test_config.py +0 -0
  44. {thwip_cli-1.1.3 → thwip_cli-1.3.0}/tests/test_detector.py +0 -0
  45. {thwip_cli-1.1.3 → thwip_cli-1.3.0}/tests/test_tools.py +0 -0
  46. {thwip_cli-1.1.3 → thwip_cli-1.3.0}/thwip/__main__.py +0 -0
  47. {thwip_cli-1.1.3 → thwip_cli-1.3.0}/thwip/agents/__init__.py +0 -0
  48. {thwip_cli-1.1.3 → thwip_cli-1.3.0}/thwip/tools/git_ops.py +0 -0
@@ -49,7 +49,7 @@ jobs:
49
49
  run: uv build
50
50
 
51
51
  - name: Verify package metadata
52
- run: uvx twine check dist/*
52
+ run: uvx twine check dist/*.whl dist/*.tar.gz
53
53
 
54
54
  - name: Publish to PyPI
55
55
  uses: pypa/gh-action-pypi-publish@release/v1
@@ -60,5 +60,7 @@ jobs:
60
60
  uses: softprops/action-gh-release@v2
61
61
  if: startsWith(github.ref, 'refs/tags/')
62
62
  with:
63
- files: dist/*
63
+ files: |
64
+ dist/*.whl
65
+ dist/*.tar.gz
64
66
  generate_release_notes: true
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: thwip-cli
3
- Version: 1.1.3
3
+ Version: 1.3.0
4
4
  Summary: Universal coding agent multiplexer: detect, switch, and route between AI coding agents seamlessly
5
5
  Project-URL: Homepage, https://github.com/tanmayhutt/thwip-cli
6
6
  Project-URL: Repository, https://github.com/tanmayhutt/thwip-cli
@@ -46,7 +46,8 @@ Description-Content-Type: text/markdown
46
46
  ## Features
47
47
 
48
48
  - **Auto-Detection**: Discovers installed AI coding agents (Claude Code, Antigravity, Gemini CLI, OpenAI/Codex, Aider, Copilot, Cursor, Windsurf, Cline, Ollama) and configured credentials.
49
- - **Context Portability**: Switch between Anthropic Claude, Google Gemini, OpenAI Codex, or local Ollama mid-project. Conversation history and working files transfer directly.
49
+ - **Context Portability**: Switch providers with stored conversational text. Working files remain in the selected local project; they are not automatically uploaded.
50
+ - **Handoff Preview**: Inspect text continuity, omitted state, capability changes, approximate context pressure, and a text fingerprint before switching. Runs locally without model calls.
50
51
  - **Dynamic UI**: Terminal interface adapts its status bar, capabilities, and theme based on the active provider.
51
52
  - **Capability Disclaimers**: Highlights when an agent lacks specific capabilities such as file editing or code execution.
52
53
  - **Rate Limit Failover**: Detects HTTP 429 errors or quota exhaustion and prompts instant switching to ready fallback models.
@@ -89,6 +90,8 @@ thwip
89
90
  | Command | Action |
90
91
  |:---|:---|
91
92
  | `/switch [agent] [model]` | Switch active agent or model mid-conversation |
93
+ | `/handoff [agent] [model]` | Preview a target locally without switching or sending data |
94
+ | `/native codex` | Save and leave Thwip for the installed Codex CLI using its own authentication |
92
95
  | `/agents` | Show all detected coding agents, company status, and capabilities |
93
96
  | `/models [agent]` | List available models for current or target agent |
94
97
  | `/key [provider]` | Enter an API key securely without placing it in prompt history |
@@ -103,14 +106,61 @@ thwip
103
106
  | `/cost` | Show estimated session and cumulative cost |
104
107
  | `/project [path]` | View or change project working directory |
105
108
  | `Ctrl + S` | Quick switch agent prompt |
106
-
107
- Short aliases are available for frequent commands: `/a`, `/m`, `/s`, `/k`, `/g`, and `/t`.
108
109
  | `Ctrl + T` | Show agent status |
109
110
  | `Ctrl + H` | View history |
110
111
  | `/quit` | Exit thwip |
111
112
 
113
+ Short aliases are available for frequent commands: `/a`, `/m`, `/s`, `/k`, `/g`, and `/t`.
114
+
115
+ ### Existing Codex login
116
+
117
+ Use `/native codex` to open the installed native CLI without configuring a Thwip API
118
+ key. After confirmation, Thwip saves its session and replaces itself with Codex in
119
+ the selected project, using a read-only sandbox and on-request approvals. Codex
120
+ owns authentication, model selection, permissions, and usage limits. An expired
121
+ or missing login must be resolved in Codex itself.
122
+
123
+ This is a launcher, not an in-REPL provider adapter. Conversation history is not
124
+ transferred, native activity is not included in Thwip usage totals, and exiting
125
+ Codex does not automatically restart Thwip. Restart `thwip` and use `/session load`
126
+ to resume the saved Thwip conversation. Claude and Gemini native launchers are not
127
+ implemented. Direct API mode remains unchanged.
128
+
112
129
  ---
113
130
 
131
+ ## Auditable handoffs
132
+
133
+ ```text
134
+ /handoff
135
+ /handoff google
136
+ /handoff openai gpt-5.6-terra
137
+ ```
138
+
139
+ `/handoff` previews the current target; specifying a provider uses its default model
140
+ unless you supply a model ID. Targets can be inspected without credentials or an
141
+ installed provider. `/switch` shows the same report before changing the active agent.
142
+
143
+ The report includes:
144
+
145
+ - Exact counts of transferred user/assistant text messages and excluded stored records.
146
+ - A count of observed transient tool results that are not in portable history. New
147
+ sessions track from creation; older saved sessions explicitly report partial coverage.
148
+ - Capability gains and losses using the local model catalog.
149
+ - Approximate request size including tool schemas, with up to 4,096 tokens reserved
150
+ for an answer in the advisory calculation. This does not change generation settings.
151
+ - SHA-256 of canonical system-prompt and conversational-text JSON. It stays the same
152
+ across targets when that text is unchanged. Tool schemas and attribution metadata
153
+ are intentionally outside this text fingerprint.
154
+
155
+ The preview makes no model requests, saves no transcript exports, executes no tools,
156
+ and does not trim or summarize history. Token sizing uses UTF-8 bytes divided by four
157
+ plus per-message overhead, not a provider tokenizer. Catalog limits can be stale;
158
+ an apparently fitting request can still fail. Warnings are advisory, not switch gates.
159
+ Hidden reasoning and provider-native state do not transfer. The digest proves neither
160
+ delivery nor semantic understanding, and is not a signature or privacy guarantee.
161
+
162
+ See [research and prior art](docs/handoff-research.md) for the differentiation rationale.
163
+
114
164
  ## Supported Companies and Agents
115
165
 
116
166
  | Company | Agent | Capabilities |
@@ -119,7 +169,7 @@ Short aliases are available for frequent commands: `/a`, `/m`, `/s`, `/k`, `/g`,
119
169
  | Google | Gemini API (3.1 Pro Preview, 3.7 Flash, 3.5 Flash-Lite) | Chat, File Edit, Code Run, Terminal, Git |
120
170
  | OpenAI | OpenAI API (GPT-5.6 Sol, Terra, Luna) | Chat, File Edit, Code Run, Terminal, Git |
121
171
  | DeepSeek | DeepSeek V3 / R1 Reasoner | Chat, File Edit, Code Run, Reasoning |
122
- | Groq | Llama 3.3 70B, Mixtral | Chat, File Edit, Code Run |
172
+ | Groq | GPT-OSS 120B (default); Llama 3.3 for eligible enterprise accounts only | Chat, File Edit, Code Run |
123
173
  | Ollama | Local Models (Llama 3.3, Qwen Coder, DeepSeek R1) | Chat, File Edit, Code Run (Local, Offline) |
124
174
  | OpenRouter | Multi-Company Models | Gateway Routing |
125
175
 
@@ -9,7 +9,8 @@
9
9
  ## Features
10
10
 
11
11
  - **Auto-Detection**: Discovers installed AI coding agents (Claude Code, Antigravity, Gemini CLI, OpenAI/Codex, Aider, Copilot, Cursor, Windsurf, Cline, Ollama) and configured credentials.
12
- - **Context Portability**: Switch between Anthropic Claude, Google Gemini, OpenAI Codex, or local Ollama mid-project. Conversation history and working files transfer directly.
12
+ - **Context Portability**: Switch providers with stored conversational text. Working files remain in the selected local project; they are not automatically uploaded.
13
+ - **Handoff Preview**: Inspect text continuity, omitted state, capability changes, approximate context pressure, and a text fingerprint before switching. Runs locally without model calls.
13
14
  - **Dynamic UI**: Terminal interface adapts its status bar, capabilities, and theme based on the active provider.
14
15
  - **Capability Disclaimers**: Highlights when an agent lacks specific capabilities such as file editing or code execution.
15
16
  - **Rate Limit Failover**: Detects HTTP 429 errors or quota exhaustion and prompts instant switching to ready fallback models.
@@ -52,6 +53,8 @@ thwip
52
53
  | Command | Action |
53
54
  |:---|:---|
54
55
  | `/switch [agent] [model]` | Switch active agent or model mid-conversation |
56
+ | `/handoff [agent] [model]` | Preview a target locally without switching or sending data |
57
+ | `/native codex` | Save and leave Thwip for the installed Codex CLI using its own authentication |
55
58
  | `/agents` | Show all detected coding agents, company status, and capabilities |
56
59
  | `/models [agent]` | List available models for current or target agent |
57
60
  | `/key [provider]` | Enter an API key securely without placing it in prompt history |
@@ -66,14 +69,61 @@ thwip
66
69
  | `/cost` | Show estimated session and cumulative cost |
67
70
  | `/project [path]` | View or change project working directory |
68
71
  | `Ctrl + S` | Quick switch agent prompt |
69
-
70
- Short aliases are available for frequent commands: `/a`, `/m`, `/s`, `/k`, `/g`, and `/t`.
71
72
  | `Ctrl + T` | Show agent status |
72
73
  | `Ctrl + H` | View history |
73
74
  | `/quit` | Exit thwip |
74
75
 
76
+ Short aliases are available for frequent commands: `/a`, `/m`, `/s`, `/k`, `/g`, and `/t`.
77
+
78
+ ### Existing Codex login
79
+
80
+ Use `/native codex` to open the installed native CLI without configuring a Thwip API
81
+ key. After confirmation, Thwip saves its session and replaces itself with Codex in
82
+ the selected project, using a read-only sandbox and on-request approvals. Codex
83
+ owns authentication, model selection, permissions, and usage limits. An expired
84
+ or missing login must be resolved in Codex itself.
85
+
86
+ This is a launcher, not an in-REPL provider adapter. Conversation history is not
87
+ transferred, native activity is not included in Thwip usage totals, and exiting
88
+ Codex does not automatically restart Thwip. Restart `thwip` and use `/session load`
89
+ to resume the saved Thwip conversation. Claude and Gemini native launchers are not
90
+ implemented. Direct API mode remains unchanged.
91
+
75
92
  ---
76
93
 
94
+ ## Auditable handoffs
95
+
96
+ ```text
97
+ /handoff
98
+ /handoff google
99
+ /handoff openai gpt-5.6-terra
100
+ ```
101
+
102
+ `/handoff` previews the current target; specifying a provider uses its default model
103
+ unless you supply a model ID. Targets can be inspected without credentials or an
104
+ installed provider. `/switch` shows the same report before changing the active agent.
105
+
106
+ The report includes:
107
+
108
+ - Exact counts of transferred user/assistant text messages and excluded stored records.
109
+ - A count of observed transient tool results that are not in portable history. New
110
+ sessions track from creation; older saved sessions explicitly report partial coverage.
111
+ - Capability gains and losses using the local model catalog.
112
+ - Approximate request size including tool schemas, with up to 4,096 tokens reserved
113
+ for an answer in the advisory calculation. This does not change generation settings.
114
+ - SHA-256 of canonical system-prompt and conversational-text JSON. It stays the same
115
+ across targets when that text is unchanged. Tool schemas and attribution metadata
116
+ are intentionally outside this text fingerprint.
117
+
118
+ The preview makes no model requests, saves no transcript exports, executes no tools,
119
+ and does not trim or summarize history. Token sizing uses UTF-8 bytes divided by four
120
+ plus per-message overhead, not a provider tokenizer. Catalog limits can be stale;
121
+ an apparently fitting request can still fail. Warnings are advisory, not switch gates.
122
+ Hidden reasoning and provider-native state do not transfer. The digest proves neither
123
+ delivery nor semantic understanding, and is not a signature or privacy guarantee.
124
+
125
+ See [research and prior art](docs/handoff-research.md) for the differentiation rationale.
126
+
77
127
  ## Supported Companies and Agents
78
128
 
79
129
  | Company | Agent | Capabilities |
@@ -82,7 +132,7 @@ Short aliases are available for frequent commands: `/a`, `/m`, `/s`, `/k`, `/g`,
82
132
  | Google | Gemini API (3.1 Pro Preview, 3.7 Flash, 3.5 Flash-Lite) | Chat, File Edit, Code Run, Terminal, Git |
83
133
  | OpenAI | OpenAI API (GPT-5.6 Sol, Terra, Luna) | Chat, File Edit, Code Run, Terminal, Git |
84
134
  | DeepSeek | DeepSeek V3 / R1 Reasoner | Chat, File Edit, Code Run, Reasoning |
85
- | Groq | Llama 3.3 70B, Mixtral | Chat, File Edit, Code Run |
135
+ | Groq | GPT-OSS 120B (default); Llama 3.3 for eligible enterprise accounts only | Chat, File Edit, Code Run |
86
136
  | Ollama | Local Models (Llama 3.3, Qwen Coder, DeepSeek R1) | Chat, File Edit, Code Run (Local, Offline) |
87
137
  | OpenRouter | Multi-Company Models | Gateway Routing |
88
138
 
@@ -0,0 +1,50 @@
1
+ # Handoff preview: research and scope
2
+
3
+ Research date: 2026-09-04. This is a bounded public-source comparison, not a patent
4
+ search or proof of worldwide novelty. Search indexes miss unpublished work, private
5
+ products, and features documented under different names. No "world first" claim is made.
6
+
7
+ ## What already exists
8
+
9
+ | Primary source | Existing approach | Implication for Thwip |
10
+ | --- | --- | --- |
11
+ | [Aider chat modes](https://aider.chat/docs/usage/modes.html) | Architect and editor model pairing | Multi-model collaboration alone is not novel. |
12
+ | [Aider model warnings](https://aider.chat/docs/llms/warnings.html) and [token limits](https://aider.chat/docs/troubleshooting/token-limits.html) | Metadata warnings, token accounting, and overflow guidance | Model-fit diagnostics alone are not novel. |
13
+ | [OpenCode compaction](https://opencode.ai/v2/docs/compaction) | Structured checkpoints plus recent context; durable messages retained separately | Summaries and checkpoints alone are not novel. |
14
+ | [OpenRouter fallbacks](https://openrouter.ai/docs/guides/routing/model-fallbacks) | Ordered fallback models on request failures | Routing and failover alone are not novel. |
15
+ | [OpenRouter message transforms](https://openrouter.ai/docs/guides/features/message-transforms) | Context compression by removing or truncating messages | Automatic fitting can trade away recall. |
16
+ | [Agent Handoff](https://github.com/AniruddhaHumane/handoff) | Portable file-backed resume briefs across agents | Cross-agent memory alone is not novel. |
17
+ | [rosehgal/handoff](https://github.com/rosehgal/handoff) | Append-only action log and rendered handoff document | Tool-action tracking and durable handoffs already exist. |
18
+
19
+ Searches included combinations of "coding agent", "model switch", "handoff",
20
+ "context loss report", "preflight", "capability loss", "fingerprint", and "receipt",
21
+ as well as product-specific documentation queries. Sources were inspected through
22
+ web search retrieval. No restricted pages or private data were scraped.
23
+
24
+ ## Chosen differentiation
25
+
26
+ Thwip already owns the provider switch boundary. Put a local continuity report at
27
+ that boundary: exact text-message coverage, explicit state omissions, capability
28
+ differences, advisory context pressure, and a deterministic text fingerprint together.
29
+ The examined sources establish prior art for the components; they did not establish
30
+ the same integrated report. That is an opportunity hypothesis, not proof of uniqueness.
31
+
32
+ ## Implementation boundaries
33
+
34
+ - `/handoff [provider] [model]` is a non-mutating dry run. `/switch` displays it too.
35
+ - No new dependencies, model calls, summarization costs, or external data storage.
36
+ - Only a result count is added to saved sessions, not raw tool inputs or outputs.
37
+ - Legacy sessions explicitly show incomplete tool-result tracking.
38
+ - SHA-256 covers canonical system prompt and portable text, not provider wire formats,
39
+ delivery, hidden reasoning, tool schemas, project file contents, or model comprehension.
40
+ - Context estimates are heuristic and warnings advisory. Provider-native tool
41
+ continuation correctness remains a separate issue, not solved by this report.
42
+ - Production deployment and other defects from the previous audit remain separate work.
43
+
44
+ ## Validation
45
+
46
+ Offline tests cover stable/change-sensitive fingerprints, exclusions, capability
47
+ gains/losses, context thresholds and unknown limits, unknown targets, private session
48
+ persistence, legacy migration, clear commands, dry-run non-mutation, safe terminal
49
+ rendering, switch integration, and command completion. Live generation is not needed
50
+ to validate this diagnostic, and no claim about live provider success follows from it.
@@ -0,0 +1,28 @@
1
+ # Verification status
2
+
3
+ Verified locally on 2026-09-07 for v1.3.0. The Python suite passes 186 tests.
4
+ Exhaustive behavior across every provider and
5
+ configuration has not been established.
6
+
7
+ | Area | Evidence | Remaining limitation |
8
+ | --- | --- | --- |
9
+ | Commands and aliases | Offline command smoke tests, invalid input cases | Most smoke tests check exceptions, not all rendered content |
10
+ | Sessions | Save/load, separate fresh conversations, malformed metadata, permissions, project rebinding | Concurrent writes to the same explicitly named session are not coordinated |
11
+ | Provider switching and handoff | All seven provider catalogs, portable history, bounded failover | Live account quotas and model availability unverified |
12
+ | Tool execution | Real temporary-file operations, path containment, process timeout/cancellation, invalid arguments | Shell and code tools retain local user privileges; output capture memory is unbounded |
13
+ | Native Codex launcher | Save-before-launch, consent, flags, missing binary, terminal checks, failures | Mocked process replacement; live native interaction unverified; no conversation transfer |
14
+ | Provider responses | Mocked native tool continuations; DeepSeek/Groq/OpenRouter streaming, usage-only chunks, 429/500 errors and serialized tool arguments | Other streaming and error branches still have coverage gaps |
15
+ | Display/config/auth | Configuration validation, display settings, credential boundaries, short-key masking | No full terminal/platform matrix |
16
+ | Usage | Atomic writes, malformed records, valid totals | Unknown catalog pricing may appear as zero estimated cost |
17
+ | Website | Production build and deterministic demo completion/replay test | Real browser, layout, clipboard, keyboard and accessibility checks remain incomplete |
18
+ | Dependencies | npm audit: zero advisories; Python installed-dependency audit: none found | Python audit skipped legacy local `thwip 1.0.0` metadata |
19
+ | Packaging | Wheel/source build and metadata checks | Publication is separately verified through the release workflow |
20
+
21
+ The Python suite measured 69% statement coverage with 160 passing tests before
22
+ the final credential-masking tests were added. Coverage is diagnostic evidence,
23
+ not proof that every feature works. The website test uses a minimal DOM stand-in
24
+ and controlled timers, not a browser.
25
+
26
+ This pass fixed default-session save collisions, malformed session/message
27
+ metadata acceptance, tool-argument display crashes, Rich markup interpretation
28
+ in action output, and short-key masking leakage.
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "thwip-cli"
7
- version = "1.1.3"
7
+ version = "1.3.0"
8
8
  description = "Universal coding agent multiplexer: detect, switch, and route between AI coding agents seamlessly"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -0,0 +1,164 @@
1
+ import json
2
+ from io import StringIO
3
+ from types import SimpleNamespace
4
+
5
+ import pytest
6
+ from rich.console import Console
7
+
8
+ from thwip.agents import AgentRegistry
9
+ from thwip.agents.base import AgentDone, Capability, LimitHit, LimitStatus, TextDelta
10
+ from thwip.cli import ThwipCLI
11
+ from thwip.config import ThwipConfig, get_usage_path
12
+ from thwip.limits import UsageTracker
13
+ from thwip.session import Session
14
+ from thwip.tools import ToolManager
15
+
16
+
17
+ @pytest.fixture
18
+ def cli(tmp_path, monkeypatch):
19
+ monkeypatch.setenv('THWIP_CONFIG_DIR', str(tmp_path / 'config'))
20
+ cli = ThwipCLI.__new__(ThwipCLI)
21
+ cli.config = ThwipConfig(project=str(tmp_path), auto_save=False)
22
+ cli.registry = AgentRegistry(cli.config)
23
+ for a in cli.registry.list_agents():
24
+ monkeypatch.setattr(a, 'is_installed', lambda: True)
25
+ monkeypatch.setattr(a, 'is_configured', lambda: False)
26
+ if a.name == 'ollama':
27
+ a._cached_models = a.get_handoff_models()
28
+ cli.current_agent = cli.registry.get_agent('claude')
29
+ cli.session = Session(project_path=str(tmp_path), current_agent='claude', current_model=cli.current_agent.get_default_model())
30
+ cli.usage_tracker = UsageTracker()
31
+ cli.tool_manager = ToolManager(str(tmp_path))
32
+ cli.detector = SimpleNamespace(scan_all=list)
33
+ monkeypatch.setattr('builtins.input', lambda *args: '')
34
+ monkeypatch.setattr('getpass.getpass', lambda *args: '')
35
+ console = Console(file=StringIO(), width=100, color_system=None)
36
+ monkeypatch.setattr('thwip.cli.console', console)
37
+ monkeypatch.setattr('thwip.theme.console', console)
38
+ return cli
39
+
40
+
41
+ @pytest.mark.parametrize('command', [
42
+ '/help', '/h', '/about', '/guide', '/g', '/info', '/agents', '/a', '/list',
43
+ '/models', '/m', '/models flagship', '/models balanced', '/models fast', '/models google',
44
+ '/tools', '/t', '/status', '/limits', '/detect', '/history', '/clear', '/reset', '/cost',
45
+ '/project', '/session save audit', '/session load missing', '/session list', '/session clear',
46
+ '/key', '/key google', '/key invalid', '/key openai REJECTED_TEST_KEY',
47
+ '/handoff', '/handoff invalid', '/handoff google invalid', '/switch', '/switch invalid',
48
+ '/switch google invalid', '/unknown', '/q', '/exit', '/quit',
49
+ ])
50
+ @pytest.mark.asyncio
51
+ async def test_command_no_exception(cli, command):
52
+ await cli.handle_command(command)
53
+
54
+
55
+ @pytest.mark.parametrize('provider', ['claude','google','openai','deepseek','groq','ollama','openrouter'])
56
+ @pytest.mark.asyncio
57
+ async def test_all_catalogued_handoffs(cli, provider):
58
+ before = list(cli.session.messages)
59
+ for model in cli.registry.get_agent(provider).get_handoff_models():
60
+ await cli.handle_command(f'/handoff {provider} {model.id}')
61
+ assert cli.session.messages == before
62
+
63
+
64
+ @pytest.mark.asyncio
65
+ async def test_project_path_with_spaces(cli, tmp_path):
66
+ project = tmp_path / 'project with spaces'
67
+ project.mkdir()
68
+ await cli.handle_command(f'/project {project}')
69
+ assert cli.session.project_path == str(project)
70
+
71
+
72
+ def test_usage_file_private(cli):
73
+ cli.usage_tracker.record_usage('openai', 'gpt-5.6-terra', 10, 5)
74
+ assert get_usage_path().stat().st_mode & 0o777 == 0o600
75
+
76
+
77
+ @pytest.mark.parametrize('tool,args', [
78
+ ('read_file', {'file_path': 12}),
79
+ ('list_files', {'sub_dir': ['bad']}),
80
+ ('run_python', {'code': None}),
81
+ ])
82
+ def test_invalid_tool_args_return_error_not_exception(cli, tool, args):
83
+ result = cli.tool_manager.execute_tool(tool, args)
84
+ assert isinstance(result, str)
85
+
86
+
87
+ @pytest.mark.asyncio
88
+ async def test_history_displays_literal_markup(cli):
89
+ cli.session.add_user_message('Show [/not-a-tag] literally')
90
+ await cli.handle_command('/history')
91
+
92
+
93
+ @pytest.mark.parametrize('value', ['wrong', None, -1, True])
94
+ def test_corrupted_handoff_counter_rejected_or_sanitized(cli, value):
95
+ path = cli.session.save('damaged')
96
+ data = json.loads(path.read_text())
97
+ data['observed_tool_results'] = value
98
+ path.write_text(json.dumps(data))
99
+ loaded = Session.load('damaged')
100
+ assert loaded is None or (type(loaded.observed_tool_results) is int and loaded.observed_tool_results >= 0)
101
+
102
+
103
+ def test_invalid_config_type_rejected_or_defaulted(cli):
104
+ cli.config._apply_toml({'defaults': {'project': 123, 'confirm_tools': 'false'}})
105
+ assert isinstance(cli.config.project, str)
106
+ assert type(cli.config.confirm_tools) is bool
107
+
108
+
109
+ @pytest.mark.asyncio
110
+ async def test_session_roundtrip_rebinds_tools(cli, tmp_path):
111
+ target = tmp_path / 'other'
112
+ target.mkdir()
113
+ saved = Session(project_path=str(target), current_agent='google', current_model='missing')
114
+ saved.save('loadable')
115
+ await cli.handle_command('/session load loadable')
116
+ assert cli.tool_manager.file_editor.project_path == target
117
+ assert cli.current_agent.name == 'google'
118
+ assert cli.session.current_model == cli.current_agent.get_default_model()
119
+
120
+
121
+ def test_all_tools_and_safety(cli, tmp_path):
122
+ tm = cli.tool_manager
123
+ assert 'Successfully' in tm.execute_tool('write_file', {'file_path':'a.txt','content':'one'})
124
+ assert tm.execute_tool('read_file', {'file_path':'a.txt'}) == 'one'
125
+ assert 'Successfully' in tm.execute_tool('edit_file', {'file_path':'a.txt','old_str':'one','new_str':'two'})
126
+ assert 'a.txt' in tm.execute_tool('list_files', {})
127
+ assert '42' in tm.execute_tool('run_python', {'code':'print(6 * 7)'})
128
+ assert 'Exit code: 0' in tm.execute_tool('run_command', {'command':'printf audit-ok'})
129
+ assert isinstance(tm.execute_tool('git_status', {}), str)
130
+ assert isinstance(tm.execute_tool('git_diff', {}), str)
131
+ assert 'outside' in tm.execute_tool('read_file', {'file_path':'../outside'})
132
+ outside = tmp_path.parent / 'audit-outside-file'
133
+ outside.write_text('not accessible')
134
+ (tmp_path / 'link').symlink_to(outside)
135
+ assert 'outside' in tm.execute_tool('read_file', {'file_path':'link'})
136
+ assert 'Error' in tm.execute_tool('unknown', {})
137
+ assert {t['name'] for t in tm.get_anthropic_tools()} == {t['function']['name'] for t in tm.get_openai_tools()}
138
+
139
+
140
+ @pytest.mark.asyncio
141
+ async def test_failover_does_not_retry_already_failed_provider(cli, monkeypatch):
142
+ agents = [cli.registry.get_agent('claude'), cli.registry.get_agent('google')]
143
+ attempts = []
144
+ for agent in agents:
145
+ monkeypatch.setattr(agent, 'is_configured', lambda: True)
146
+ monkeypatch.setattr(agent, 'get_capabilities_for_model', lambda model: {Capability.CHAT})
147
+
148
+ def make_chat(name):
149
+ async def chat(**kwargs):
150
+ attempts.append(name)
151
+ # Stop safely after four simulated rate limits; no actual network calls.
152
+ if len(attempts) <= 4:
153
+ yield LimitHit(error_type=LimitStatus.RATE_LIMITED, message='test quota')
154
+ else:
155
+ yield TextDelta(content='stop test')
156
+ yield AgentDone()
157
+ return chat
158
+
159
+ monkeypatch.setattr(agent, 'chat', make_chat(agent.name))
160
+ monkeypatch.setattr(cli.registry, 'get_ready_agents', lambda: agents)
161
+ cli.config.fallback.chain = ['claude', 'google']
162
+ cli.config.limits.auto_switch = True
163
+ await cli.process_user_message('hello')
164
+ assert len(attempts) <= len(agents), f'Repeated failed providers: {attempts}'
@@ -67,6 +67,7 @@ async def test_tool_results_are_returned_to_agent(tmp_path):
67
67
 
68
68
  assert len(cli.current_agent.calls) == 2
69
69
  assert cli.session.messages[-1].content == "Used the tool result."
70
+ assert cli.session.observed_tool_results == 1
70
71
 
71
72
 
72
73
  class UnconfiguredAgent(ToolCallingAgent):
@@ -74,6 +75,30 @@ class UnconfiguredAgent(ToolCallingAgent):
74
75
  return False
75
76
 
76
77
 
78
+ @pytest.mark.asyncio
79
+ @pytest.mark.parametrize("arguments", [None, [], "bad", {"file_path": "[broken]"}])
80
+ async def test_invalid_tool_arguments_return_errors_without_crashing(tmp_path, arguments):
81
+ class InvalidToolAgent(ToolCallingAgent):
82
+ async def chat(self, messages, **kwargs):
83
+ self.calls.append(messages)
84
+ if len(self.calls) == 1:
85
+ yield ToolUseStart(tool_id="bad", tool_name="read_file", args=arguments)
86
+ yield AgentDone()
87
+ else:
88
+ assert "Error" in messages[-1]["content"]
89
+ yield TextDelta(content="Recovered from invalid tool arguments.")
90
+ yield AgentDone()
91
+
92
+ cli = ThwipCLI.__new__(ThwipCLI)
93
+ cli.config = SimpleNamespace(stream=True, confirm_tools=True)
94
+ cli.session = Session(current_agent="fake", current_model="fake-model")
95
+ cli.current_agent = InvalidToolAgent()
96
+ cli.tool_manager = ToolManager(tmp_path)
97
+ cli.usage_tracker = FakeUsageTracker()
98
+ await cli.process_user_message("Read")
99
+ assert cli.session.messages[-1].content == "Recovered from invalid tool arguments."
100
+
101
+
77
102
  @pytest.mark.asyncio
78
103
  async def test_unconfigured_agent_shows_setup_guidance(tmp_path):
79
104
  cli = ThwipCLI.__new__(ThwipCLI)
@@ -0,0 +1,75 @@
1
+ """Exercise streaming completion and provider error boundaries without network calls."""
2
+
3
+ import json
4
+ from types import SimpleNamespace
5
+
6
+ import httpx
7
+ import openai
8
+ import pytest
9
+
10
+ from thwip.agents.base import AgentDone, LimitHit, LimitStatus, TextDelta
11
+ from thwip.agents.deepseek_agent import DeepSeekAgent
12
+ from thwip.agents.groq_agent import GroqAgent
13
+ from thwip.agents.openrouter_agent import OpenRouterAgent
14
+
15
+
16
+ @pytest.mark.parametrize("agent_type", [DeepSeekAgent, GroqAgent, OpenRouterAgent])
17
+ @pytest.mark.asyncio
18
+ async def test_tool_continuation_serializes_arguments_without_mutating_history(agent_type):
19
+ original = {"role": "assistant", "content": "", "tool_calls": [{
20
+ "id": "call1", "type": "function", "function": {
21
+ "name": "read_file", "arguments": {"file_path": "note.txt"},
22
+ },
23
+ }], "_native_state": {"other": "private"}}
24
+
25
+ async def create(**kwargs):
26
+ sent = kwargs["messages"][0]
27
+ assert "_native_state" not in sent
28
+ assert json.loads(sent["tool_calls"][0]["function"]["arguments"]) == {"file_path": "note.txt"}
29
+ return SimpleNamespace(choices=[SimpleNamespace(message=SimpleNamespace(content="done", tool_calls=[]))], usage=None)
30
+
31
+ agent = agent_type(api_key="test")
32
+ agent._client = SimpleNamespace(chat=SimpleNamespace(completions=SimpleNamespace(create=create)))
33
+ events = [event async for event in agent.chat([original], stream=False)]
34
+ assert any(isinstance(event, AgentDone) for event in events)
35
+ assert isinstance(original["tool_calls"][0]["function"]["arguments"], dict)
36
+ assert "_native_state" in original
37
+
38
+
39
+ @pytest.mark.parametrize("agent_type", [DeepSeekAgent, GroqAgent, OpenRouterAgent])
40
+ @pytest.mark.asyncio
41
+ async def test_stream_text_and_usage_only_final_chunk(agent_type):
42
+ async def chunks():
43
+ yield SimpleNamespace(choices=[SimpleNamespace(delta=SimpleNamespace(
44
+ content="hello", tool_calls=None))], usage=None)
45
+ yield SimpleNamespace(choices=[], usage=SimpleNamespace(prompt_tokens=9, completion_tokens=3))
46
+
47
+ async def create(**kwargs):
48
+ assert kwargs["stream"] is True
49
+ return chunks()
50
+
51
+ agent = agent_type(api_key="test")
52
+ agent._client = SimpleNamespace(chat=SimpleNamespace(completions=SimpleNamespace(create=create)))
53
+ events = [event async for event in agent.chat([{"role": "user", "content": "hi"}])]
54
+ assert isinstance(events[0], TextDelta) and events[0].content == "hello"
55
+ assert isinstance(events[-1], AgentDone)
56
+ assert events[-1].usage.input_tokens == 9
57
+ assert events[-1].usage.output_tokens == 3
58
+ assert agent.check_limits() == LimitStatus.OK
59
+
60
+
61
+ @pytest.mark.parametrize("agent_type", [DeepSeekAgent, GroqAgent, OpenRouterAgent])
62
+ @pytest.mark.parametrize("status", [429, 500])
63
+ @pytest.mark.asyncio
64
+ async def test_provider_errors_emit_failure_not_success(agent_type, status):
65
+ response = httpx.Response(status, request=httpx.Request("POST", "https://example.invalid"))
66
+ error_type = openai.RateLimitError if status == 429 else openai.APIStatusError
67
+
68
+ async def create(**kwargs):
69
+ raise error_type("simulated failure", response=response, body=None)
70
+
71
+ agent = agent_type(api_key="test")
72
+ agent._client = SimpleNamespace(chat=SimpleNamespace(completions=SimpleNamespace(create=create)))
73
+ events = [event async for event in agent.chat([])]
74
+ assert len(events) == 1 and isinstance(events[0], LimitHit)
75
+ assert events[0].error_type == (LimitStatus.RATE_LIMITED if status == 429 else LimitStatus.UNKNOWN)