thwip-cli 1.1.2__tar.gz → 1.2.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 (41) hide show
  1. {thwip_cli-1.1.2 → thwip_cli-1.2.0}/.github/workflows/publish.yml +18 -6
  2. {thwip_cli-1.1.2 → thwip_cli-1.2.0}/PKG-INFO +38 -3
  3. {thwip_cli-1.1.2 → thwip_cli-1.2.0}/README.md +37 -2
  4. thwip_cli-1.2.0/docs/handoff-research.md +50 -0
  5. {thwip_cli-1.1.2 → thwip_cli-1.2.0}/pyproject.toml +1 -1
  6. {thwip_cli-1.1.2 → thwip_cli-1.2.0}/tests/test_cli.py +18 -1
  7. thwip_cli-1.2.0/tests/test_config.py +21 -0
  8. thwip_cli-1.2.0/tests/test_handoff.py +222 -0
  9. {thwip_cli-1.1.2 → thwip_cli-1.2.0}/tests/test_tools.py +21 -0
  10. {thwip_cli-1.1.2 → thwip_cli-1.2.0}/thwip/__init__.py +1 -1
  11. {thwip_cli-1.1.2 → thwip_cli-1.2.0}/thwip/agents/base.py +4 -0
  12. {thwip_cli-1.1.2 → thwip_cli-1.2.0}/thwip/agents/claude_agent.py +27 -1
  13. {thwip_cli-1.1.2 → thwip_cli-1.2.0}/thwip/agents/google_agent.py +28 -7
  14. {thwip_cli-1.1.2 → thwip_cli-1.2.0}/thwip/agents/ollama_agent.py +11 -0
  15. {thwip_cli-1.1.2 → thwip_cli-1.2.0}/thwip/agents/openai_agent.py +23 -1
  16. {thwip_cli-1.1.2 → thwip_cli-1.2.0}/thwip/cli.py +150 -27
  17. {thwip_cli-1.1.2 → thwip_cli-1.2.0}/thwip/config.py +31 -8
  18. thwip_cli-1.2.0/thwip/handoff.py +91 -0
  19. {thwip_cli-1.1.2 → thwip_cli-1.2.0}/thwip/session.py +37 -6
  20. {thwip_cli-1.1.2 → thwip_cli-1.2.0}/thwip/shortcuts.py +3 -2
  21. {thwip_cli-1.1.2 → thwip_cli-1.2.0}/thwip/theme.py +2 -2
  22. {thwip_cli-1.1.2 → thwip_cli-1.2.0}/thwip/tools/__init__.py +15 -9
  23. {thwip_cli-1.1.2 → thwip_cli-1.2.0}/thwip/tools/file_editor.py +24 -2
  24. {thwip_cli-1.1.2 → thwip_cli-1.2.0}/thwip/tools/terminal.py +4 -0
  25. {thwip_cli-1.1.2 → thwip_cli-1.2.0}/uv.lock +1 -1
  26. {thwip_cli-1.1.2 → thwip_cli-1.2.0}/.gitignore +0 -0
  27. {thwip_cli-1.1.2 → thwip_cli-1.2.0}/LICENSE +0 -0
  28. {thwip_cli-1.1.2 → thwip_cli-1.2.0}/install.sh +0 -0
  29. {thwip_cli-1.1.2 → thwip_cli-1.2.0}/tests/test_agents.py +0 -0
  30. {thwip_cli-1.1.2 → thwip_cli-1.2.0}/tests/test_detector.py +0 -0
  31. {thwip_cli-1.1.2 → thwip_cli-1.2.0}/tests/test_session.py +0 -0
  32. {thwip_cli-1.1.2 → thwip_cli-1.2.0}/thwip/__main__.py +0 -0
  33. {thwip_cli-1.1.2 → thwip_cli-1.2.0}/thwip/agents/__init__.py +0 -0
  34. {thwip_cli-1.1.2 → thwip_cli-1.2.0}/thwip/agents/deepseek_agent.py +0 -0
  35. {thwip_cli-1.1.2 → thwip_cli-1.2.0}/thwip/agents/groq_agent.py +0 -0
  36. {thwip_cli-1.1.2 → thwip_cli-1.2.0}/thwip/agents/openrouter_agent.py +0 -0
  37. {thwip_cli-1.1.2 → thwip_cli-1.2.0}/thwip/detector.py +0 -0
  38. {thwip_cli-1.1.2 → thwip_cli-1.2.0}/thwip/limits.py +0 -0
  39. {thwip_cli-1.1.2 → thwip_cli-1.2.0}/thwip/tools/code_runner.py +0 -0
  40. {thwip_cli-1.1.2 → thwip_cli-1.2.0}/thwip/tools/git_ops.py +0 -0
  41. {thwip_cli-1.1.2 → thwip_cli-1.2.0}/thwip/utils.py +0 -0
@@ -4,17 +4,16 @@ on:
4
4
  push:
5
5
  tags:
6
6
  - 'v*'
7
- workflow_dispatch:
8
7
 
9
8
  jobs:
10
9
  test:
11
10
  runs-on: ubuntu-latest
12
11
  steps:
13
12
  - name: Checkout code
14
- uses: actions/checkout@v4
13
+ uses: actions/checkout@v6
15
14
 
16
15
  - name: Install uv
17
- uses: astral-sh/setup-uv@v5
16
+ uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
18
17
 
19
18
  - name: Set up Python
20
19
  run: uv python install 3.13
@@ -33,14 +32,25 @@ jobs:
33
32
 
34
33
  steps:
35
34
  - name: Checkout code
36
- uses: actions/checkout@v4
35
+ uses: actions/checkout@v6
37
36
 
38
37
  - name: Install uv
39
- uses: astral-sh/setup-uv@v5
38
+ uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
39
+
40
+ - name: Verify tag matches package version
41
+ run: |
42
+ PACKAGE_VERSION=$(uv run python -c "import tomllib; print(tomllib.load(open('pyproject.toml', 'rb'))['project']['version'])")
43
+ test "${GITHUB_REF_NAME}" = "v${PACKAGE_VERSION}" || {
44
+ echo "Tag ${GITHUB_REF_NAME} does not match package version v${PACKAGE_VERSION}."
45
+ exit 1
46
+ }
40
47
 
41
48
  - name: Build package
42
49
  run: uv build
43
50
 
51
+ - name: Verify package metadata
52
+ run: uvx twine check dist/*.whl dist/*.tar.gz
53
+
44
54
  - name: Publish to PyPI
45
55
  uses: pypa/gh-action-pypi-publish@release/v1
46
56
  with:
@@ -50,5 +60,7 @@ jobs:
50
60
  uses: softprops/action-gh-release@v2
51
61
  if: startsWith(github.ref, 'refs/tags/')
52
62
  with:
53
- files: dist/*
63
+ files: |
64
+ dist/*.whl
65
+ dist/*.tar.gz
54
66
  generate_release_notes: true
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: thwip-cli
3
- Version: 1.1.2
3
+ Version: 1.2.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
@@ -39,14 +39,15 @@ Description-Content-Type: text/markdown
39
39
 
40
40
  > Universal Coding Agent Multiplexer
41
41
  >
42
- > Detect all installed AI coding agents on your device, combine them in a unified terminal interface, and hot-swap models mid-conversation when hitting rate limits without losing context.
42
+ > Discover supported local AI tools, connect configured model providers in one terminal interface, and switch providers while preserving completed text conversation history.
43
43
 
44
44
  ---
45
45
 
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,7 @@ 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 |
92
94
  | `/agents` | Show all detected coding agents, company status, and capabilities |
93
95
  | `/models [agent]` | List available models for current or target agent |
94
96
  | `/key [provider]` | Enter an API key securely without placing it in prompt history |
@@ -111,6 +113,39 @@ Short aliases are available for frequent commands: `/a`, `/m`, `/s`, `/k`, `/g`,
111
113
 
112
114
  ---
113
115
 
116
+ ## Auditable handoffs
117
+
118
+ ```text
119
+ /handoff
120
+ /handoff google
121
+ /handoff openai gpt-5.6-terra
122
+ ```
123
+
124
+ `/handoff` previews the current target; specifying a provider uses its default model
125
+ unless you supply a model ID. Targets can be inspected without credentials or an
126
+ installed provider. `/switch` shows the same report before changing the active agent.
127
+
128
+ The report includes:
129
+
130
+ - Exact counts of transferred user/assistant text messages and excluded stored records.
131
+ - A count of observed transient tool results that are not in portable history. New
132
+ sessions track from creation; older saved sessions explicitly report partial coverage.
133
+ - Capability gains and losses using the local model catalog.
134
+ - Approximate request size including tool schemas, with up to 4,096 tokens reserved
135
+ for an answer in the advisory calculation. This does not change generation settings.
136
+ - SHA-256 of canonical system-prompt and conversational-text JSON. It stays the same
137
+ across targets when that text is unchanged. Tool schemas and attribution metadata
138
+ are intentionally outside this text fingerprint.
139
+
140
+ The preview makes no model requests, saves no transcript exports, executes no tools,
141
+ and does not trim or summarize history. Token sizing uses UTF-8 bytes divided by four
142
+ plus per-message overhead, not a provider tokenizer. Catalog limits can be stale;
143
+ an apparently fitting request can still fail. Warnings are advisory, not switch gates.
144
+ Hidden reasoning and provider-native state do not transfer. The digest proves neither
145
+ delivery nor semantic understanding, and is not a signature or privacy guarantee.
146
+
147
+ See [research and prior art](docs/handoff-research.md) for the differentiation rationale.
148
+
114
149
  ## Supported Companies and Agents
115
150
 
116
151
  | Company | Agent | Capabilities |
@@ -2,14 +2,15 @@
2
2
 
3
3
  > Universal Coding Agent Multiplexer
4
4
  >
5
- > Detect all installed AI coding agents on your device, combine them in a unified terminal interface, and hot-swap models mid-conversation when hitting rate limits without losing context.
5
+ > Discover supported local AI tools, connect configured model providers in one terminal interface, and switch providers while preserving completed text conversation history.
6
6
 
7
7
  ---
8
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,7 @@ 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 |
55
57
  | `/agents` | Show all detected coding agents, company status, and capabilities |
56
58
  | `/models [agent]` | List available models for current or target agent |
57
59
  | `/key [provider]` | Enter an API key securely without placing it in prompt history |
@@ -74,6 +76,39 @@ Short aliases are available for frequent commands: `/a`, `/m`, `/s`, `/k`, `/g`,
74
76
 
75
77
  ---
76
78
 
79
+ ## Auditable handoffs
80
+
81
+ ```text
82
+ /handoff
83
+ /handoff google
84
+ /handoff openai gpt-5.6-terra
85
+ ```
86
+
87
+ `/handoff` previews the current target; specifying a provider uses its default model
88
+ unless you supply a model ID. Targets can be inspected without credentials or an
89
+ installed provider. `/switch` shows the same report before changing the active agent.
90
+
91
+ The report includes:
92
+
93
+ - Exact counts of transferred user/assistant text messages and excluded stored records.
94
+ - A count of observed transient tool results that are not in portable history. New
95
+ sessions track from creation; older saved sessions explicitly report partial coverage.
96
+ - Capability gains and losses using the local model catalog.
97
+ - Approximate request size including tool schemas, with up to 4,096 tokens reserved
98
+ for an answer in the advisory calculation. This does not change generation settings.
99
+ - SHA-256 of canonical system-prompt and conversational-text JSON. It stays the same
100
+ across targets when that text is unchanged. Tool schemas and attribution metadata
101
+ are intentionally outside this text fingerprint.
102
+
103
+ The preview makes no model requests, saves no transcript exports, executes no tools,
104
+ and does not trim or summarize history. Token sizing uses UTF-8 bytes divided by four
105
+ plus per-message overhead, not a provider tokenizer. Catalog limits can be stale;
106
+ an apparently fitting request can still fail. Warnings are advisory, not switch gates.
107
+ Hidden reasoning and provider-native state do not transfer. The digest proves neither
108
+ delivery nor semantic understanding, and is not a signature or privacy guarantee.
109
+
110
+ See [research and prior art](docs/handoff-research.md) for the differentiation rationale.
111
+
77
112
  ## Supported Companies and Agents
78
113
 
79
114
  | Company | Agent | Capabilities |
@@ -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.
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "thwip-cli"
7
- version = "1.1.2"
7
+ version = "1.2.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"
@@ -7,7 +7,7 @@ from types import SimpleNamespace
7
7
  import pytest
8
8
 
9
9
  from thwip.agents.base import AgentDone, Capability, TextDelta, ToolUseStart
10
- from thwip.cli import ThwipCLI
10
+ from thwip.cli import SafeFileHistory, ThwipCLI
11
11
  from thwip.session import Session
12
12
  from thwip.tools import ToolManager
13
13
 
@@ -44,6 +44,10 @@ class ToolCallingAgent:
44
44
  yield ToolUseStart(tool_id="read-1", tool_name="read_file", args={"file_path": "note.txt"})
45
45
  yield AgentDone()
46
46
  else:
47
+ assert messages[-2]["role"] == "assistant"
48
+ assert messages[-2]["tool_calls"][0]["id"] == "read-1"
49
+ assert messages[-1]["role"] == "tool"
50
+ assert messages[-1]["tool_call_id"] == "read-1"
47
51
  assert "known result" in messages[-1]["content"]
48
52
  yield TextDelta(content="Used the tool result.")
49
53
  yield AgentDone()
@@ -63,6 +67,7 @@ async def test_tool_results_are_returned_to_agent(tmp_path):
63
67
 
64
68
  assert len(cli.current_agent.calls) == 2
65
69
  assert cli.session.messages[-1].content == "Used the tool result."
70
+ assert cli.session.observed_tool_results == 1
66
71
 
67
72
 
68
73
  class UnconfiguredAgent(ToolCallingAgent):
@@ -94,3 +99,15 @@ def test_inline_api_key_is_rejected():
94
99
 
95
100
  assert cli.config.keys == {}
96
101
  assert cli.config.key_sources == {}
102
+
103
+
104
+ def test_inline_api_key_is_not_stored_in_prompt_history(tmp_path):
105
+ history_path = tmp_path / "history.txt"
106
+ history = SafeFileHistory(str(history_path))
107
+
108
+ history.store_string("/key openai secret-value")
109
+ history.store_string("/key openai")
110
+
111
+ contents = history_path.read_text()
112
+ assert "secret-value" not in contents
113
+ assert "/key openai" in contents
@@ -0,0 +1,21 @@
1
+ """Regression tests for secure configuration persistence."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import tomllib
6
+
7
+ from thwip.config import ThwipConfig
8
+
9
+
10
+ def test_save_does_not_persist_environment_or_discovered_keys(tmp_path, monkeypatch):
11
+ monkeypatch.setenv("THWIP_CONFIG_DIR", str(tmp_path))
12
+ config = ThwipConfig(
13
+ keys={"openai": "from-env", "anthropic": "explicit"},
14
+ key_sources={"openai": "env:OPENAI_API_KEY", "anthropic": "config.toml"},
15
+ )
16
+
17
+ config.save()
18
+
19
+ data = tomllib.loads((tmp_path / "config.toml").read_text())
20
+ assert data["keys"] == {"anthropic": "explicit"}
21
+ assert (tmp_path / "config.toml").stat().st_mode & 0o777 == 0o600
@@ -0,0 +1,222 @@
1
+ """Offline regression coverage for auditable text handoffs."""
2
+
3
+ import importlib
4
+ import json
5
+ from copy import deepcopy
6
+ from io import StringIO
7
+ from types import SimpleNamespace
8
+
9
+ import pytest
10
+ from prompt_toolkit.document import Document
11
+ from rich.console import Console
12
+
13
+ from thwip.agents.base import Capability, ModelInfo
14
+ from thwip.cli import ThwipCLI
15
+ from thwip.handoff import build_handoff_report
16
+ from thwip.session import Message, Session
17
+ from thwip.shortcuts import ThwipCompleter
18
+ from thwip.tools import ToolManager
19
+
20
+
21
+ class OfflineAgent:
22
+ name = "offline"
23
+ company = "Test"
24
+ display_name = "Offline Agent"
25
+ capabilities = {Capability.CHAT, Capability.FILE_EDIT}
26
+
27
+ def __init__(self, window=100_000, tools=True):
28
+ self.model = ModelInfo(id="test-model", name="Test", context_window=window,
29
+ max_output=4096, supports_tools=tools)
30
+ self.available_models = [self.model]
31
+
32
+ def get_model_info(self, model):
33
+ return self.model if model == self.model.id else None
34
+
35
+ def get_default_model(self):
36
+ return self.model.id
37
+
38
+ def get_handoff_models(self):
39
+ return self.available_models
40
+
41
+ def get_capabilities_for_model(self, model):
42
+ return {Capability.CHAT, Capability.FILE_EDIT} if self.model.supports_tools else {Capability.CHAT}
43
+
44
+ def is_installed(self):
45
+ return True
46
+
47
+ def is_configured(self):
48
+ return False
49
+
50
+
51
+ def test_fingerprint_is_stable_and_does_not_mutate_session():
52
+ session = Session(current_model="test-model")
53
+ session.add_user_message("Keep Unicode: नमस्ते")
54
+ session.add_assistant_message("Understood", "offline", "test-model")
55
+ before = deepcopy(session)
56
+ agent = OfflineAgent()
57
+ first = build_handoff_report(session, agent, agent, "test-model")
58
+ second = build_handoff_report(session, agent, agent, "test-model", [{"name": "read"}])
59
+ assert first.text_fingerprint == second.text_fingerprint
60
+ assert second.estimated_input_tokens > first.estimated_input_tokens
61
+ assert session == before
62
+ assert first.transferred_messages == 2
63
+ session.system_prompt += "Changed instruction"
64
+ assert build_handoff_report(session, agent, agent, "test-model").text_fingerprint != first.text_fingerprint
65
+
66
+
67
+ @pytest.mark.parametrize("change", ["text", "order"])
68
+ def test_fingerprint_changes_with_payload(change):
69
+ session = Session()
70
+ session.add_user_message("one")
71
+ session.add_user_message("two")
72
+ agent = OfflineAgent()
73
+ initial = build_handoff_report(session, agent, agent, "test-model")
74
+ if change == "text":
75
+ session.messages[0].content = "different"
76
+ else:
77
+ session.messages.reverse()
78
+ assert build_handoff_report(session, agent, agent, "test-model").text_fingerprint != initial.text_fingerprint
79
+
80
+
81
+ def test_exclusions_and_capability_changes_are_explicit():
82
+ session = Session(current_model="test-model")
83
+ session.messages = [Message(role="assistant", content="text", tool_calls=[{"id": "one"}]),
84
+ Message(role="tool", content="private result")]
85
+ session.record_tool_result()
86
+ source, target = OfflineAgent(), OfflineAgent(tools=False)
87
+ report = build_handoff_report(session, source, target, "test-model")
88
+ assert report.transferred_messages == 1
89
+ assert report.excluded_messages == report.excluded_tool_calls == report.observed_tool_results == 1
90
+ assert report.lost_capabilities == (Capability.FILE_EDIT.display_name,)
91
+ assert report.gained_capabilities == ()
92
+ reverse = build_handoff_report(session, target, source, "test-model")
93
+ assert reverse.gained_capabilities == report.lost_capabilities
94
+ assert "private result" not in repr(report)
95
+
96
+
97
+ @pytest.mark.parametrize("window,expected", [(0, "unknown"), (100, "likely over budget"),
98
+ (5000, "near limit"), (100000, "below advisory budget")])
99
+ def test_advisory_pressure(window, expected):
100
+ agent = OfflineAgent(window=window)
101
+ report = build_handoff_report(Session(), agent, agent, "test-model")
102
+ assert report.context_pressure == expected
103
+
104
+
105
+ def test_unknown_model_is_not_silently_assumed():
106
+ with pytest.raises(ValueError, match="Unknown model"):
107
+ build_handoff_report(Session(), OfflineAgent(), OfflineAgent(), "missing")
108
+
109
+
110
+ def test_tracking_survives_save_and_legacy_load(tmp_path, monkeypatch):
111
+ monkeypatch.setenv("THWIP_CONFIG_DIR", str(tmp_path))
112
+ session = Session(name="tracking")
113
+ session.record_tool_result()
114
+ path = session.save()
115
+ loaded = Session.load("tracking")
116
+ assert loaded.observed_tool_results == 1
117
+ assert loaded.tool_tracking_complete
118
+ assert path.stat().st_mode & 0o777 == 0o600
119
+ data = json.loads(path.read_text())
120
+ del data["observed_tool_results"]
121
+ del data["tool_tracking_complete"]
122
+ path.write_text(json.dumps(data))
123
+ legacy = Session.load("tracking")
124
+ assert not legacy.tool_tracking_complete
125
+ legacy.record_tool_result()
126
+ assert legacy.observed_tool_results == 1
127
+ legacy.clear_context()
128
+ assert legacy.observed_tool_results == 0
129
+ assert legacy.tool_tracking_complete
130
+
131
+
132
+ @pytest.mark.asyncio
133
+ async def test_cli_preview_is_offline_non_mutating_and_safe_to_render(tmp_path, monkeypatch):
134
+ agent = OfflineAgent()
135
+ agent.name = "[red]offline[/red]"
136
+ cli = ThwipCLI.__new__(ThwipCLI)
137
+ cli.current_agent = agent
138
+ cli.registry = SimpleNamespace(get_agent=lambda name: agent if name == "offline" else None)
139
+ cli.session = Session(current_model="test-model")
140
+ cli.session.add_user_message("PRIVATE USER TEXT")
141
+ cli.tool_manager = ToolManager(tmp_path)
142
+ buffer = StringIO()
143
+ monkeypatch.setattr("thwip.cli.console", Console(file=buffer, width=140, color_system=None))
144
+ before = deepcopy(cli.session)
145
+ await cli.handle_command("/handoff offline test-model")
146
+ assert cli.session == before
147
+ assert "PRIVATE USER TEXT" not in buffer.getvalue()
148
+ assert "[red]offline[/red]" in buffer.getvalue()
149
+ assert "No model calls" in " ".join(buffer.getvalue().split())
150
+
151
+
152
+ @pytest.mark.asyncio
153
+ async def test_switch_previews_before_mutating(tmp_path, monkeypatch):
154
+ source, target = OfflineAgent(), OfflineAgent(tools=False)
155
+ cli = ThwipCLI.__new__(ThwipCLI)
156
+ cli.current_agent = source
157
+ cli.registry = SimpleNamespace(get_agent=lambda name: target)
158
+ cli.session = Session(current_model="test-model")
159
+ cli.tool_manager = ToolManager(tmp_path)
160
+ calls = []
161
+
162
+ def preview(*args):
163
+ assert cli.current_agent is source
164
+ calls.append(args)
165
+
166
+ monkeypatch.setattr(cli, "cmd_handoff", preview)
167
+ await cli.cmd_switch("offline", "test-model")
168
+ assert calls == [("offline", "test-model")]
169
+ assert cli.current_agent is target
170
+
171
+
172
+ @pytest.mark.parametrize("command", ["/clear", "/session clear"])
173
+ @pytest.mark.asyncio
174
+ async def test_clear_commands_reset_tracking(command):
175
+ cli = ThwipCLI.__new__(ThwipCLI)
176
+ cli.session = Session(observed_tool_results=2, tool_tracking_complete=False)
177
+ await cli.handle_command(command)
178
+ assert cli.session.observed_tool_results == 0
179
+ assert cli.session.tool_tracking_complete
180
+
181
+
182
+ def test_handoff_completion():
183
+ matches = list(ThwipCompleter(["claude"]).get_completions(Document("/handoff cl"), None))
184
+ assert [match.text for match in matches] == ["/handoff claude"]
185
+
186
+
187
+ def test_ollama_preview_never_discovers_models_over_network(monkeypatch):
188
+ from thwip.agents.ollama_agent import OllamaAgent
189
+
190
+ def forbidden(*args, **kwargs):
191
+ pytest.fail("Preview must not access the network")
192
+
193
+ monkeypatch.setattr("urllib.request.urlopen", forbidden)
194
+ agent = OllamaAgent(host="https://example.invalid")
195
+ session = Session(current_agent="ollama", current_model="llama3.3")
196
+ report = build_handoff_report(session, agent, agent, "llama3.3")
197
+ assert report.context_pressure == "unknown"
198
+ assert agent._cached_models is None
199
+
200
+
201
+ @pytest.mark.parametrize("module,class_name", [
202
+ ("claude", "ClaudeAgent"), ("google", "GoogleAgent"), ("openai", "OpenAIAgent"),
203
+ ("deepseek", "DeepSeekAgent"), ("groq", "GroqAgent"), ("ollama", "OllamaAgent"),
204
+ ("openrouter", "OpenRouterAgent"),
205
+ ])
206
+ def test_every_adapter_supports_offline_catalog_reports(module, class_name, monkeypatch):
207
+ def forbidden(*args, **kwargs):
208
+ pytest.fail("Handoff must not discover providers or generate responses")
209
+
210
+ adapter = getattr(importlib.import_module(f"thwip.agents.{module}_agent"), class_name)
211
+ agent = adapter.__new__(adapter)
212
+ if module == "ollama":
213
+ agent._cached_models = None
214
+ monkeypatch.setattr(agent, "chat", forbidden)
215
+ monkeypatch.setattr(agent, "is_installed", forbidden)
216
+ monkeypatch.setattr(agent, "is_configured", forbidden)
217
+ monkeypatch.setattr("urllib.request.urlopen", forbidden)
218
+ model = agent.get_handoff_models()[0]
219
+ session = Session(current_agent=agent.name, current_model=model.id)
220
+ report = build_handoff_report(session, agent, agent, model.id)
221
+ assert report.lost_capabilities == report.gained_capabilities == ()
222
+ assert len(report.text_fingerprint) == 64
@@ -6,7 +6,10 @@ from __future__ import annotations
6
6
 
7
7
  import tempfile
8
8
 
9
+ import pytest
10
+
9
11
  from thwip.tools import ToolManager
12
+ from thwip.tools.terminal import TerminalRunner
10
13
 
11
14
 
12
15
  def test_file_editor_operations():
@@ -49,3 +52,21 @@ def test_file_editor_blocks_paths_outside_workspace(tmp_path):
49
52
  assert "outside the project workspace" in manager.file_editor.read_file(str(outside))
50
53
  assert "outside the project workspace" in manager.file_editor.write_file("../outside.txt", "changed")
51
54
  assert outside.read_text() == "private"
55
+
56
+
57
+ def test_tool_output_is_bounded(tmp_path):
58
+ manager = ToolManager(project_path=tmp_path)
59
+ (tmp_path / "large.txt").write_text("x" * 25_000)
60
+
61
+ result = manager.execute_tool("read_file", {"file_path": "large.txt", "max_lines": 1})
62
+
63
+ assert len(result) <= 20_100
64
+ assert "Output truncated" in result
65
+
66
+
67
+ @pytest.mark.asyncio
68
+ async def test_async_command_timeout_terminates_process(tmp_path):
69
+ runner = TerminalRunner(tmp_path)
70
+ result = await runner.run_command_async("sleep 5", timeout=0.01)
71
+
72
+ assert "timed out" in result
@@ -1,4 +1,4 @@
1
1
  """thwip: Universal Coding Agent Multiplexer."""
2
2
 
3
- __version__ = "1.1.2"
3
+ __version__ = "1.2.0"
4
4
  __app_name__ = "thwip"
@@ -298,6 +298,10 @@ class BaseAgent(ABC):
298
298
  return m
299
299
  return None
300
300
 
301
+ def get_handoff_models(self) -> list[ModelInfo]:
302
+ """Return local catalog metadata without requesting provider discovery."""
303
+ return list(self.available_models)
304
+
301
305
  def has_capability(self, cap: Capability) -> bool:
302
306
  """Check if this agent supports a capability."""
303
307
  return cap in self.capabilities
@@ -313,11 +313,37 @@ class ClaudeAgent(BaseAgent):
313
313
  if tools:
314
314
  stream = False
315
315
 
316
+ # Convert the portable OpenAI-style tool transcript into Anthropic blocks.
317
+ anthropic_messages: list[dict[str, Any]] = []
318
+ for message in messages:
319
+ if message.get("tool_calls"):
320
+ blocks: list[dict[str, Any]] = []
321
+ if message.get("content"):
322
+ blocks.append({"type": "text", "text": message["content"]})
323
+ for call in message["tool_calls"]:
324
+ function = call["function"]
325
+ arguments = function.get("arguments", {})
326
+ blocks.append({
327
+ "type": "tool_use", "id": call["id"], "name": function["name"],
328
+ "input": json.loads(arguments) if isinstance(arguments, str) else arguments,
329
+ })
330
+ anthropic_messages.append({"role": "assistant", "content": blocks})
331
+ elif message.get("role") == "tool":
332
+ anthropic_messages.append({
333
+ "role": "user",
334
+ "content": [{
335
+ "type": "tool_result", "tool_use_id": message["tool_call_id"],
336
+ "content": message.get("content", ""),
337
+ }],
338
+ })
339
+ else:
340
+ anthropic_messages.append(message)
341
+
316
342
  # Build request kwargs
317
343
  model_info = self.get_model_info(model)
318
344
  kwargs: dict[str, Any] = {
319
345
  "model": model,
320
- "messages": messages,
346
+ "messages": anthropic_messages,
321
347
  "max_tokens": model_info.max_output if model_info else 16_384,
322
348
  }
323
349