thwip-cli 1.6.2__tar.gz → 1.8.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.
- {thwip_cli-1.6.2 → thwip_cli-1.8.0}/PKG-INFO +43 -1
- {thwip_cli-1.6.2 → thwip_cli-1.8.0}/README.md +42 -0
- {thwip_cli-1.6.2 → thwip_cli-1.8.0}/docs/verification.md +5 -2
- {thwip_cli-1.6.2 → thwip_cli-1.8.0}/pyproject.toml +1 -1
- thwip_cli-1.8.0/tests/test_memory.py +210 -0
- {thwip_cli-1.6.2 → thwip_cli-1.8.0}/tests/test_native_agents.py +73 -1
- {thwip_cli-1.6.2 → thwip_cli-1.8.0}/tests/test_native_print.py +50 -0
- {thwip_cli-1.6.2 → thwip_cli-1.8.0}/tests/test_parity_commands.py +27 -0
- {thwip_cli-1.6.2 → thwip_cli-1.8.0}/tests/test_session.py +21 -0
- {thwip_cli-1.6.2 → thwip_cli-1.8.0}/thwip/__init__.py +1 -1
- {thwip_cli-1.6.2 → thwip_cli-1.8.0}/thwip/agents/base.py +2 -0
- {thwip_cli-1.6.2 → thwip_cli-1.8.0}/thwip/agents/native_agent.py +64 -25
- {thwip_cli-1.6.2 → thwip_cli-1.8.0}/thwip/agents/native_common.py +25 -0
- {thwip_cli-1.6.2 → thwip_cli-1.8.0}/thwip/agents/native_print.py +36 -9
- {thwip_cli-1.6.2 → thwip_cli-1.8.0}/thwip/cli.py +296 -9
- {thwip_cli-1.6.2 → thwip_cli-1.8.0}/thwip/config.py +31 -0
- thwip_cli-1.8.0/thwip/data/context_template.md +44 -0
- thwip_cli-1.8.0/thwip/data/thwip.1 +133 -0
- thwip_cli-1.8.0/thwip/memory.py +335 -0
- {thwip_cli-1.6.2 → thwip_cli-1.8.0}/thwip/session.py +37 -1
- {thwip_cli-1.6.2 → thwip_cli-1.8.0}/thwip/shortcuts.py +3 -0
- {thwip_cli-1.6.2 → thwip_cli-1.8.0}/thwip/theme.py +1 -0
- {thwip_cli-1.6.2 → thwip_cli-1.8.0}/uv.lock +1 -1
- {thwip_cli-1.6.2 → thwip_cli-1.8.0}/.github/workflows/publish.yml +0 -0
- {thwip_cli-1.6.2 → thwip_cli-1.8.0}/.gitignore +0 -0
- {thwip_cli-1.6.2 → thwip_cli-1.8.0}/LICENSE +0 -0
- {thwip_cli-1.6.2 → thwip_cli-1.8.0}/docs/handoff-research.md +0 -0
- {thwip_cli-1.6.2 → thwip_cli-1.8.0}/install.sh +0 -0
- {thwip_cli-1.6.2 → thwip_cli-1.8.0}/tests/__init__.py +0 -0
- {thwip_cli-1.6.2 → thwip_cli-1.8.0}/tests/fake_providers.py +0 -0
- {thwip_cli-1.6.2 → thwip_cli-1.8.0}/tests/test_agents.py +0 -0
- {thwip_cli-1.6.2 → thwip_cli-1.8.0}/tests/test_audit_regressions.py +0 -0
- {thwip_cli-1.6.2 → thwip_cli-1.8.0}/tests/test_catalog.py +0 -0
- {thwip_cli-1.6.2 → thwip_cli-1.8.0}/tests/test_cli.py +0 -0
- {thwip_cli-1.6.2 → thwip_cli-1.8.0}/tests/test_compatible_streaming.py +0 -0
- {thwip_cli-1.6.2 → thwip_cli-1.8.0}/tests/test_config.py +0 -0
- {thwip_cli-1.6.2 → thwip_cli-1.8.0}/tests/test_detector.py +0 -0
- {thwip_cli-1.6.2 → thwip_cli-1.8.0}/tests/test_direct_providers_e2e.py +0 -0
- {thwip_cli-1.6.2 → thwip_cli-1.8.0}/tests/test_handoff.py +0 -0
- {thwip_cli-1.6.2 → thwip_cli-1.8.0}/tests/test_native_launcher.py +0 -0
- {thwip_cli-1.6.2 → thwip_cli-1.8.0}/tests/test_native_rpc.py +0 -0
- {thwip_cli-1.6.2 → thwip_cli-1.8.0}/tests/test_repair_verification.py +0 -0
- {thwip_cli-1.6.2 → thwip_cli-1.8.0}/tests/test_tools.py +0 -0
- {thwip_cli-1.6.2 → thwip_cli-1.8.0}/tests/test_utils.py +0 -0
- {thwip_cli-1.6.2 → thwip_cli-1.8.0}/thwip/__main__.py +0 -0
- {thwip_cli-1.6.2 → thwip_cli-1.8.0}/thwip/agents/__init__.py +0 -0
- {thwip_cli-1.6.2 → thwip_cli-1.8.0}/thwip/agents/catalog.py +0 -0
- {thwip_cli-1.6.2 → thwip_cli-1.8.0}/thwip/agents/chat_messages.py +0 -0
- {thwip_cli-1.6.2 → thwip_cli-1.8.0}/thwip/agents/claude_agent.py +0 -0
- {thwip_cli-1.6.2 → thwip_cli-1.8.0}/thwip/agents/deepseek_agent.py +0 -0
- {thwip_cli-1.6.2 → thwip_cli-1.8.0}/thwip/agents/google_agent.py +0 -0
- {thwip_cli-1.6.2 → thwip_cli-1.8.0}/thwip/agents/groq_agent.py +0 -0
- {thwip_cli-1.6.2 → thwip_cli-1.8.0}/thwip/agents/native_rpc.py +0 -0
- {thwip_cli-1.6.2 → thwip_cli-1.8.0}/thwip/agents/ollama_agent.py +0 -0
- {thwip_cli-1.6.2 → thwip_cli-1.8.0}/thwip/agents/openai_agent.py +0 -0
- {thwip_cli-1.6.2 → thwip_cli-1.8.0}/thwip/agents/openrouter_agent.py +0 -0
- {thwip_cli-1.6.2 → thwip_cli-1.8.0}/thwip/detector.py +0 -0
- {thwip_cli-1.6.2 → thwip_cli-1.8.0}/thwip/endpoints.py +0 -0
- {thwip_cli-1.6.2 → thwip_cli-1.8.0}/thwip/handoff.py +0 -0
- {thwip_cli-1.6.2 → thwip_cli-1.8.0}/thwip/limits.py +0 -0
- {thwip_cli-1.6.2 → thwip_cli-1.8.0}/thwip/tools/__init__.py +0 -0
- {thwip_cli-1.6.2 → thwip_cli-1.8.0}/thwip/tools/code_runner.py +0 -0
- {thwip_cli-1.6.2 → thwip_cli-1.8.0}/thwip/tools/file_editor.py +0 -0
- {thwip_cli-1.6.2 → thwip_cli-1.8.0}/thwip/tools/git_ops.py +0 -0
- {thwip_cli-1.6.2 → thwip_cli-1.8.0}/thwip/tools/terminal.py +0 -0
- {thwip_cli-1.6.2 → thwip_cli-1.8.0}/thwip/utils.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: thwip-cli
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.8.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
|
|
@@ -55,6 +55,7 @@ Description-Content-Type: text/markdown
|
|
|
55
55
|
- **Tool Layer**: Shared file editing, code execution (Python, Node, Shell), and Git integration across connected models.
|
|
56
56
|
- **Session Persistence**: Save and resume sessions across projects with `/session save` and `/session load`.
|
|
57
57
|
- **Familiar Flow**: The same everyday commands as Codex, Claude Code, and Antigravity (`/model`, `/new`, `/resume`, `/compact`, `/diff`, `/copy`, `!cmd`, `@file`), plus autocompletion, Ctrl+S / Ctrl+T shortcuts, and streaming Markdown.
|
|
58
|
+
- **Project Memory and Second Brain**: One `context.md` per project that every agent reads and maintains, filed into your Obsidian vault with cross-project links by stack, area, and tag.
|
|
58
59
|
- **Live Model Lists**: Model catalogs come from the connected CLI or the provider's list-models endpoint, never from a hardcoded list.
|
|
59
60
|
|
|
60
61
|
---
|
|
@@ -85,6 +86,8 @@ Start the interactive terminal in your current project directory:
|
|
|
85
86
|
thwip
|
|
86
87
|
thwip --project ~/code/my-app # open a specific project
|
|
87
88
|
thwip --version
|
|
89
|
+
thwip man # read the manual page
|
|
90
|
+
thwip install-man # then `man thwip` works
|
|
88
91
|
```
|
|
89
92
|
|
|
90
93
|
---
|
|
@@ -142,6 +145,16 @@ not in the catalog is passed to the CLI for validation instead of being rejected
|
|
|
142
145
|
a bundled list. A model available in a desktop app may still be absent from the
|
|
143
146
|
installed CLI's catalog or account access.
|
|
144
147
|
|
|
148
|
+
Each provider keeps a warm native session. The first message to a provider sends the
|
|
149
|
+
portable conversation once; after that only new messages are sent. Codex keeps one
|
|
150
|
+
`app-server` process running and resumes its thread; Claude Code uses `--session-id`
|
|
151
|
+
and `--resume`; Antigravity uses `--conversation`. When you come back to a provider after
|
|
152
|
+
chatting with another one, it receives a short catch-up block containing only what it
|
|
153
|
+
missed. The native session IDs are stored in the thwip session file next to the
|
|
154
|
+
transcript, validated on load, shown by `/status`, and cleared by `/new`, `/clear`,
|
|
155
|
+
`/compact`, and a project change. If a CLI can no longer continue a session, thwip says
|
|
156
|
+
so and resends the full conversation to a fresh one.
|
|
157
|
+
|
|
145
158
|
Native connections are read-only by default. Codex starts with a read-only sandbox
|
|
146
159
|
and asks before operations outside it; approval requests appear in Thwip with the
|
|
147
160
|
command or file list and default to denial. Claude Code and the Antigravity CLI run
|
|
@@ -163,6 +176,35 @@ replaces Thwip with the Codex CLI itself in the selected project. Conversation
|
|
|
163
176
|
history is not transferred by the launcher; use `/session load` after restarting
|
|
164
177
|
Thwip to resume.
|
|
165
178
|
|
|
179
|
+
## Project memory and your second brain
|
|
180
|
+
|
|
181
|
+
Every project gets one canonical memory file, `context.md` in the project root, that
|
|
182
|
+
every agent you use through thwip reads and helps maintain. It records the snapshot,
|
|
183
|
+
current work (Now, Blocked, Next), architecture notes, decisions, known issues, and
|
|
184
|
+
dated recent changes. Whichever provider you switch to receives it as instructions, so
|
|
185
|
+
the project's state survives agent switches and sessions, not just the conversation.
|
|
186
|
+
|
|
187
|
+
On first run thwip offers to connect a second-brain vault: it detects vaults registered
|
|
188
|
+
with the Obsidian desktop app, or creates a new Markdown folder. In the vault each
|
|
189
|
+
project gets a stable index card under `Projects/`, hub notes under `Stack/`, `Areas/`,
|
|
190
|
+
and `Tags/`, and a `Projects.md` dashboard. Projects that share a stack, area, or tag
|
|
191
|
+
link to each other through those hubs and a "Related projects" list on every card.
|
|
192
|
+
thwip only writes notes it created itself (marked `generated_by: thwip`); hand-written
|
|
193
|
+
notes are never touched.
|
|
194
|
+
|
|
195
|
+
| Command | Action |
|
|
196
|
+
|:---|:---|
|
|
197
|
+
| `/memory` | Show this project's memory file |
|
|
198
|
+
| `/memory init [area]` | Create it from the template with detected stack and entry points |
|
|
199
|
+
| `/memory update` | Ask the current model to revise it from the conversation; a diff is shown and written only after you confirm |
|
|
200
|
+
| `/memory edit` | Open it in `$EDITOR` |
|
|
201
|
+
| `/memory sync` | File the project into the vault and refresh hubs and the dashboard |
|
|
202
|
+
| `/memory vault <path>` | Connect or change the vault |
|
|
203
|
+
| `/memory link` | Add a pointer to `AGENTS.md` and `CLAUDE.md` so the CLIs read it outside thwip too |
|
|
204
|
+
|
|
205
|
+
When you `/quit` after a real conversation, thwip offers the update once. Settings live under
|
|
206
|
+
`[memory]` in `~/.thwip/config.toml`: `enabled`, `file`, `vault`, `offer_update_on_quit`.
|
|
207
|
+
|
|
166
208
|
## Live model lists
|
|
167
209
|
|
|
168
210
|
Model lists are not hardcoded. Each connected source supplies its own list:
|
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
- **Tool Layer**: Shared file editing, code execution (Python, Node, Shell), and Git integration across connected models.
|
|
19
19
|
- **Session Persistence**: Save and resume sessions across projects with `/session save` and `/session load`.
|
|
20
20
|
- **Familiar Flow**: The same everyday commands as Codex, Claude Code, and Antigravity (`/model`, `/new`, `/resume`, `/compact`, `/diff`, `/copy`, `!cmd`, `@file`), plus autocompletion, Ctrl+S / Ctrl+T shortcuts, and streaming Markdown.
|
|
21
|
+
- **Project Memory and Second Brain**: One `context.md` per project that every agent reads and maintains, filed into your Obsidian vault with cross-project links by stack, area, and tag.
|
|
21
22
|
- **Live Model Lists**: Model catalogs come from the connected CLI or the provider's list-models endpoint, never from a hardcoded list.
|
|
22
23
|
|
|
23
24
|
---
|
|
@@ -48,6 +49,8 @@ Start the interactive terminal in your current project directory:
|
|
|
48
49
|
thwip
|
|
49
50
|
thwip --project ~/code/my-app # open a specific project
|
|
50
51
|
thwip --version
|
|
52
|
+
thwip man # read the manual page
|
|
53
|
+
thwip install-man # then `man thwip` works
|
|
51
54
|
```
|
|
52
55
|
|
|
53
56
|
---
|
|
@@ -105,6 +108,16 @@ not in the catalog is passed to the CLI for validation instead of being rejected
|
|
|
105
108
|
a bundled list. A model available in a desktop app may still be absent from the
|
|
106
109
|
installed CLI's catalog or account access.
|
|
107
110
|
|
|
111
|
+
Each provider keeps a warm native session. The first message to a provider sends the
|
|
112
|
+
portable conversation once; after that only new messages are sent. Codex keeps one
|
|
113
|
+
`app-server` process running and resumes its thread; Claude Code uses `--session-id`
|
|
114
|
+
and `--resume`; Antigravity uses `--conversation`. When you come back to a provider after
|
|
115
|
+
chatting with another one, it receives a short catch-up block containing only what it
|
|
116
|
+
missed. The native session IDs are stored in the thwip session file next to the
|
|
117
|
+
transcript, validated on load, shown by `/status`, and cleared by `/new`, `/clear`,
|
|
118
|
+
`/compact`, and a project change. If a CLI can no longer continue a session, thwip says
|
|
119
|
+
so and resends the full conversation to a fresh one.
|
|
120
|
+
|
|
108
121
|
Native connections are read-only by default. Codex starts with a read-only sandbox
|
|
109
122
|
and asks before operations outside it; approval requests appear in Thwip with the
|
|
110
123
|
command or file list and default to denial. Claude Code and the Antigravity CLI run
|
|
@@ -126,6 +139,35 @@ replaces Thwip with the Codex CLI itself in the selected project. Conversation
|
|
|
126
139
|
history is not transferred by the launcher; use `/session load` after restarting
|
|
127
140
|
Thwip to resume.
|
|
128
141
|
|
|
142
|
+
## Project memory and your second brain
|
|
143
|
+
|
|
144
|
+
Every project gets one canonical memory file, `context.md` in the project root, that
|
|
145
|
+
every agent you use through thwip reads and helps maintain. It records the snapshot,
|
|
146
|
+
current work (Now, Blocked, Next), architecture notes, decisions, known issues, and
|
|
147
|
+
dated recent changes. Whichever provider you switch to receives it as instructions, so
|
|
148
|
+
the project's state survives agent switches and sessions, not just the conversation.
|
|
149
|
+
|
|
150
|
+
On first run thwip offers to connect a second-brain vault: it detects vaults registered
|
|
151
|
+
with the Obsidian desktop app, or creates a new Markdown folder. In the vault each
|
|
152
|
+
project gets a stable index card under `Projects/`, hub notes under `Stack/`, `Areas/`,
|
|
153
|
+
and `Tags/`, and a `Projects.md` dashboard. Projects that share a stack, area, or tag
|
|
154
|
+
link to each other through those hubs and a "Related projects" list on every card.
|
|
155
|
+
thwip only writes notes it created itself (marked `generated_by: thwip`); hand-written
|
|
156
|
+
notes are never touched.
|
|
157
|
+
|
|
158
|
+
| Command | Action |
|
|
159
|
+
|:---|:---|
|
|
160
|
+
| `/memory` | Show this project's memory file |
|
|
161
|
+
| `/memory init [area]` | Create it from the template with detected stack and entry points |
|
|
162
|
+
| `/memory update` | Ask the current model to revise it from the conversation; a diff is shown and written only after you confirm |
|
|
163
|
+
| `/memory edit` | Open it in `$EDITOR` |
|
|
164
|
+
| `/memory sync` | File the project into the vault and refresh hubs and the dashboard |
|
|
165
|
+
| `/memory vault <path>` | Connect or change the vault |
|
|
166
|
+
| `/memory link` | Add a pointer to `AGENTS.md` and `CLAUDE.md` so the CLIs read it outside thwip too |
|
|
167
|
+
|
|
168
|
+
When you `/quit` after a real conversation, thwip offers the update once. Settings live under
|
|
169
|
+
`[memory]` in `~/.thwip/config.toml`: `enabled`, `file`, `vault`, `offer_update_on_quit`.
|
|
170
|
+
|
|
129
171
|
## Live model lists
|
|
130
172
|
|
|
131
173
|
Model lists are not hardcoded. Each connected source supplies its own list:
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# Verification status
|
|
2
2
|
|
|
3
|
-
Verified locally on 2026-09-24 for v1.
|
|
4
|
-
passes
|
|
3
|
+
Verified locally on 2026-09-24 for v1.8.0. The Python suite
|
|
4
|
+
passes 282 offline tests on Python 3.11, 3.12, and 3.13. Exhaustive behavior across every provider and
|
|
5
5
|
configuration has not been established.
|
|
6
6
|
|
|
7
7
|
## Native CLI connections (2026-09-24)
|
|
@@ -19,6 +19,9 @@ each using its existing sign-in. No API keys were configured.
|
|
|
19
19
|
| Ctrl+C during a response | Turn cancelled, child process terminated, REPL continued, unanswered message removed |
|
|
20
20
|
| Ctrl+C at a Codex permission prompt | Turn cancelled, no file created, no leftover process, REPL continued |
|
|
21
21
|
| Usage-limit failover | With a test-only shim making Codex report "You've hit your usage limit", the real REPL showed the alternatives, switched to Claude Code on `1`, retried the message, and answered; history held one clean pair |
|
|
22
|
+
| Project memory and vault (v1.8.0) | Live in a temp project with a temp config and vault: first-run onboarding listed the real Obsidian vault and accepted a typed path; `/memory init` detected the stack and filed card, hub, and dashboard notes; a Codex turn received the memory as instructions; `/memory update` produced a diff written only after `y`; `/status` and the quit-time offer behaved. Offline: frontmatter, template init, injection bounds, Obsidian registry detection, vault sync with related links and hand-written-note protection, update flow, invalid model output rejection, onboarding choices |
|
|
23
|
+
| Warm native sessions (v1.7.0) | Live: Claude Code recalled a code word on its second turn through `--resume`; Codex received the full transcript on first switch and answered from it; returning to Claude delivered a catch-up block; a second Codex turn reused the running app-server; `/status` showed each CLI session ID and synced count; `/new` cleared them; no leftover processes. Offline: resume, catch-up, and fallback paths for Codex, Claude Code, and Antigravity; session record validation and persistence |
|
|
24
|
+
| Manual page (v1.7.0) | `thwip install-man` then `man -w thwip` and `man thwip` resolve from `~/.local/share/man` |
|
|
22
25
|
| Terminal hygiene (v1.6.2) | Cursor-position queries disabled, keyboard echo off during turns, stray input flushed before each prompt; fixes `^[`/`^R` noise and phantom empty prompts seen in a real Ghostty session during a slow Antigravity reply. Permission prompt and chat re-verified live afterwards |
|
|
23
26
|
| Ollama adapter (v1.6.1) | End to end against the fake Ollama routes: model list, streamed text with usage, tool call, HTTP 500 and unreachable server now raise clear errors instead of ending the turn silently |
|
|
24
27
|
| Dependency audits (v1.6.1) | pip-audit reports no known vulnerabilities; npm audit reports zero |
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
"""Project memory file, vault filing with cross-project links, onboarding, and model-driven updates."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
from thwip.agents.base import AgentDone, TextDelta
|
|
8
|
+
from thwip.cli import ThwipCLI
|
|
9
|
+
from thwip.config import ThwipConfig
|
|
10
|
+
from thwip.memory import ProjectMemory, Vault, detect_obsidian_vaults, parse_frontmatter, render_frontmatter
|
|
11
|
+
from thwip.session import Session
|
|
12
|
+
from thwip.tools import ToolManager
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def test_frontmatter_round_trip():
|
|
16
|
+
data, body = parse_frontmatter("---\nproject: Demo\nstack: [Python, Docker]\nupdated: 2026-09-24\n---\n\n# Demo\n")
|
|
17
|
+
assert data == {"project": "Demo", "stack": ["Python", "Docker"], "updated": "2026-09-24"} and body.startswith("# Demo")
|
|
18
|
+
assert render_frontmatter({"a": "x", "b": ["1", "2"], "c": ""}) == "---\na: x\nb: [1, 2]\n---"
|
|
19
|
+
assert parse_frontmatter("no frontmatter") == ({}, "no frontmatter")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def test_init_detects_stack_and_never_overwrites(tmp_path):
|
|
23
|
+
(tmp_path / "pyproject.toml").write_text("[project]\nname='demo'\n")
|
|
24
|
+
(tmp_path / "package.json").write_text("{}")
|
|
25
|
+
memory = ProjectMemory(str(tmp_path))
|
|
26
|
+
text = memory.init(area="Developer Tools", tags=["cli"])
|
|
27
|
+
data, body = parse_frontmatter(text)
|
|
28
|
+
assert data["project"] == tmp_path.name and data["stack"] == ["Python", "JavaScript"] and data["area"] == "Developer Tools"
|
|
29
|
+
assert data["type"] == "cli-tool" and data["tags"] == ["cli"] and "## Current Work" in body
|
|
30
|
+
memory.write("---\nproject: Custom\n---\n\n# Custom\n")
|
|
31
|
+
assert memory.init() .startswith("---\nproject: Custom"), "init never replaces an existing file"
|
|
32
|
+
assert "Project memory (context.md)" in memory.injection() and "# Custom" in memory.injection()
|
|
33
|
+
memory.write("---\nproject: Big\n---\n" + "x" * 20000)
|
|
34
|
+
assert memory.injection().endswith("[Project memory truncated; read the full file with /memory]")
|
|
35
|
+
assert memory.touch_updated("---\nupdated: 2000-01-01\nproject: Big\n---\n\nbody").split("\n")[1] != "updated: 2000-01-01"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def test_detect_obsidian_vaults_reads_registry(tmp_path, monkeypatch):
|
|
39
|
+
registry = tmp_path / "Library" / "Application Support" / "obsidian" / "obsidian.json"
|
|
40
|
+
registry.parent.mkdir(parents=True)
|
|
41
|
+
vault = tmp_path / "MyVault"
|
|
42
|
+
vault.mkdir()
|
|
43
|
+
registry.write_text(json.dumps({"vaults": {"a": {"path": str(vault)}, "b": {"path": str(tmp_path / "missing")}}}))
|
|
44
|
+
monkeypatch.setattr("thwip.memory.Path.home", lambda: tmp_path)
|
|
45
|
+
assert detect_obsidian_vaults() == [str(vault)]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def test_vault_sync_files_cards_hubs_and_related_links(tmp_path):
|
|
49
|
+
vault = Vault(str(tmp_path / "brain"))
|
|
50
|
+
vault.create()
|
|
51
|
+
alpha = tmp_path / "alpha"
|
|
52
|
+
beta = tmp_path / "beta"
|
|
53
|
+
for folder, extra in ((alpha, "Rust"), (beta, "Go")):
|
|
54
|
+
folder.mkdir()
|
|
55
|
+
ProjectMemory(str(folder)).write(
|
|
56
|
+
f"---\nproject: {folder.name}\npurpose: {folder.name} tool\narea: Developer Tools\nstatus: active\n"
|
|
57
|
+
f"updated: 2026-09-24\nstack: [Python, {extra}]\ntags: [cli]\n---\n\n# {folder.name} Context\n")
|
|
58
|
+
report = vault.sync(ProjectMemory(str(alpha)))
|
|
59
|
+
assert any(path.endswith("Projects/alpha.md") for path in report["written"])
|
|
60
|
+
card = (vault.root / "Projects" / "alpha.md").read_text()
|
|
61
|
+
assert "generated_by: thwip" in card and "None yet" in card
|
|
62
|
+
report = vault.sync(ProjectMemory(str(beta)))
|
|
63
|
+
beta_card = (vault.root / "Projects" / "beta.md").read_text()
|
|
64
|
+
assert "[[Projects/alpha|alpha]]: shares area Developer Tools, stack Python, tag cli" in beta_card
|
|
65
|
+
hub = (vault.root / "Stack" / "Python.md").read_text()
|
|
66
|
+
assert "[[Projects/alpha|alpha]]" in hub and "[[Projects/beta|beta]]" in hub
|
|
67
|
+
assert (vault.root / "Areas" / "Developer Tools.md").is_file() and (vault.root / "Tags" / "cli.md").is_file()
|
|
68
|
+
dashboard = (vault.root / "Projects.md").read_text()
|
|
69
|
+
assert "| [[Projects/alpha|alpha]] | Developer Tools | active | Python, Rust | 2026-09-24 |" in dashboard
|
|
70
|
+
# Re-filing alpha now links back to beta.
|
|
71
|
+
vault.sync(ProjectMemory(str(alpha)))
|
|
72
|
+
assert "[[Projects/beta|beta]]" in (vault.root / "Projects" / "alpha.md").read_text()
|
|
73
|
+
# A hand-written note is never overwritten.
|
|
74
|
+
(vault.root / "Projects" / "gamma.md").write_text("# my own note\n")
|
|
75
|
+
gamma = tmp_path / "gamma"
|
|
76
|
+
gamma.mkdir()
|
|
77
|
+
ProjectMemory(str(gamma)).write("---\nproject: gamma\n---\n\n# gamma\n")
|
|
78
|
+
report = vault.sync(ProjectMemory(str(gamma)))
|
|
79
|
+
assert report["skipped"] == [str(vault.root / "Projects" / "gamma.md")]
|
|
80
|
+
assert (vault.root / "Projects" / "gamma.md").read_text() == "# my own note\n"
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class FakeAgent:
|
|
84
|
+
name = "openai"
|
|
85
|
+
display_name = "Fake"
|
|
86
|
+
company = "OpenAI"
|
|
87
|
+
native_tools = False
|
|
88
|
+
|
|
89
|
+
def __init__(self, reply):
|
|
90
|
+
self.reply = reply
|
|
91
|
+
self.prompts = []
|
|
92
|
+
|
|
93
|
+
def is_configured(self):
|
|
94
|
+
return True
|
|
95
|
+
|
|
96
|
+
async def chat(self, **kwargs):
|
|
97
|
+
self.prompts.append(kwargs)
|
|
98
|
+
yield TextDelta(content=self.reply)
|
|
99
|
+
yield AgentDone()
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
@pytest.fixture
|
|
103
|
+
def cli(tmp_path, monkeypatch):
|
|
104
|
+
monkeypatch.setenv("THWIP_CONFIG_DIR", str(tmp_path / "config"))
|
|
105
|
+
cli = ThwipCLI.__new__(ThwipCLI)
|
|
106
|
+
cli.config = ThwipConfig(project=str(tmp_path / "proj"), auto_save=False)
|
|
107
|
+
(tmp_path / "proj").mkdir()
|
|
108
|
+
cli.config.memory.vault = str(tmp_path / "brain")
|
|
109
|
+
cli.config.memory.onboarded = True
|
|
110
|
+
cli.tool_manager = ToolManager(str(tmp_path / "proj"))
|
|
111
|
+
cli.session = Session(project_path=str(tmp_path / "proj"), current_agent="openai", current_model="m")
|
|
112
|
+
cli.session.add_user_message("we decided to use sqlite")
|
|
113
|
+
cli.session.add_assistant_message("noted", agent_name="openai", model="m")
|
|
114
|
+
return cli
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
@pytest.mark.asyncio
|
|
118
|
+
async def test_memory_update_writes_after_confirmation_and_files_in_vault(cli, monkeypatch):
|
|
119
|
+
memory = cli._memory()
|
|
120
|
+
memory.init(area="Data")
|
|
121
|
+
proposed = memory.read().replace("- Now:", "- Now: use sqlite for storage").replace("## Decisions\n", "## Decisions\n\n- SQLite chosen for zero-setup storage.\n")
|
|
122
|
+
cli.current_agent = FakeAgent("```markdown\n" + proposed + "\n```")
|
|
123
|
+
answers = iter(["n", "y"])
|
|
124
|
+
|
|
125
|
+
async def ask(question):
|
|
126
|
+
return next(answers)
|
|
127
|
+
monkeypatch.setattr(cli, "_ask_text", ask)
|
|
128
|
+
assert await cli._memory_update() is False and "sqlite" not in memory.read()
|
|
129
|
+
assert await cli._memory_update() is True
|
|
130
|
+
text = memory.read()
|
|
131
|
+
assert "- Now: use sqlite for storage" in text and "SQLite chosen" in text
|
|
132
|
+
assert "we decided to use sqlite" in cli.current_agent.prompts[0]["messages"][0]["content"]
|
|
133
|
+
assert (tmp_path_card := cli._vault().root / "Projects" / (memory.name() + ".md")).is_file(), tmp_path_card
|
|
134
|
+
# Instructions sent to agents include the memory file.
|
|
135
|
+
assert "Project memory (context.md)" in cli._system_prompt_with_memory() and "SQLite chosen" in cli._system_prompt_with_memory()
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
@pytest.mark.asyncio
|
|
139
|
+
async def test_memory_update_rejects_invalid_model_output(cli, monkeypatch):
|
|
140
|
+
cli._memory().init()
|
|
141
|
+
before = cli._memory().read()
|
|
142
|
+
cli.current_agent = FakeAgent("Sure! Here is a summary instead of a file.")
|
|
143
|
+
assert await cli._memory_update() is False and cli._memory().read() == before
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
@pytest.mark.asyncio
|
|
147
|
+
async def test_memory_commands_show_init_vault_link(cli, monkeypatch, tmp_path):
|
|
148
|
+
await cli.cmd_memory("show")
|
|
149
|
+
await cli.cmd_memory("init", "Developer Tools")
|
|
150
|
+
assert cli._memory().frontmatter()["area"] == "Developer Tools"
|
|
151
|
+
await cli.cmd_memory("vault", str(tmp_path / "other-brain"))
|
|
152
|
+
assert cli.config.memory.vault == str((tmp_path / "other-brain").resolve())
|
|
153
|
+
assert (tmp_path / "other-brain" / "Projects.md").is_file()
|
|
154
|
+
cli._memory_link()
|
|
155
|
+
assert "context.md" in (tmp_path / "proj" / "AGENTS.md").read_text() and "context.md" in (tmp_path / "proj" / "CLAUDE.md").read_text()
|
|
156
|
+
cli._memory_link() # idempotent
|
|
157
|
+
assert (tmp_path / "proj" / "AGENTS.md").read_text().count("context.md") == 1
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
@pytest.mark.asyncio
|
|
161
|
+
async def test_onboarding_offers_detected_vault_and_saves_choice(cli, monkeypatch, tmp_path):
|
|
162
|
+
cli.config.memory.onboarded = False
|
|
163
|
+
cli.config.memory.vault = ""
|
|
164
|
+
existing = tmp_path / "ObsidianVault"
|
|
165
|
+
existing.mkdir()
|
|
166
|
+
monkeypatch.setattr("thwip.cli.detect_obsidian_vaults", lambda: [str(existing)])
|
|
167
|
+
monkeypatch.setattr("thwip.cli.sys.stdin.isatty", lambda: True)
|
|
168
|
+
from pathlib import Path
|
|
169
|
+
monkeypatch.setattr(Path, "home", classmethod(lambda cls: tmp_path / "home"))
|
|
170
|
+
|
|
171
|
+
async def choose_first(question):
|
|
172
|
+
return "1"
|
|
173
|
+
monkeypatch.setattr(cli, "_ask_text", choose_first)
|
|
174
|
+
await cli._memory_onboarding()
|
|
175
|
+
assert cli.config.memory.vault == str(existing) and cli.config.memory.onboarded
|
|
176
|
+
# Second start: no question asked again.
|
|
177
|
+
async def boom(question):
|
|
178
|
+
raise AssertionError("should not ask again")
|
|
179
|
+
monkeypatch.setattr(cli, "_ask_text", boom)
|
|
180
|
+
await cli._memory_onboarding()
|
|
181
|
+
# Skip choice leaves memory enabled but no vault.
|
|
182
|
+
cli.config.memory.onboarded = False
|
|
183
|
+
async def skip(question):
|
|
184
|
+
return "3"
|
|
185
|
+
monkeypatch.setattr(cli, "_ask_text", skip)
|
|
186
|
+
await cli._memory_onboarding()
|
|
187
|
+
assert cli.config.memory.vault == "" and cli.config.memory.onboarded
|
|
188
|
+
# Creating the default new vault lands under the (patched) home directory.
|
|
189
|
+
cli.config.memory.onboarded = False
|
|
190
|
+
async def create(question):
|
|
191
|
+
return "2"
|
|
192
|
+
monkeypatch.setattr(cli, "_ask_text", create)
|
|
193
|
+
await cli._memory_onboarding()
|
|
194
|
+
assert cli.config.memory.vault == str(tmp_path / "home" / "thwip-brain") and (tmp_path / "home" / "thwip-brain" / "Projects").is_dir()
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
@pytest.mark.asyncio
|
|
198
|
+
async def test_quit_offer_only_with_real_conversation(cli, monkeypatch):
|
|
199
|
+
asked = []
|
|
200
|
+
async def ask(question):
|
|
201
|
+
asked.append(question)
|
|
202
|
+
return "n"
|
|
203
|
+
monkeypatch.setattr(cli, "_ask_text", ask)
|
|
204
|
+
monkeypatch.setattr("thwip.cli.sys.stdin.isatty", lambda: True)
|
|
205
|
+
cli.current_agent = FakeAgent("x")
|
|
206
|
+
await cli._offer_memory_update("quitting")
|
|
207
|
+
assert len(asked) == 1 and "before quitting" in asked[0]
|
|
208
|
+
cli.session.clear_context()
|
|
209
|
+
await cli._offer_memory_update("quitting")
|
|
210
|
+
assert len(asked) == 1
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"""Native protocol behavior without credentials or model requests."""
|
|
2
2
|
|
|
3
3
|
import asyncio
|
|
4
|
+
from types import SimpleNamespace
|
|
4
5
|
|
|
5
6
|
import pytest
|
|
6
7
|
|
|
@@ -16,6 +17,8 @@ class FakeRPC:
|
|
|
16
17
|
self.closed = False
|
|
17
18
|
self.provider = provider
|
|
18
19
|
self.failure = failure
|
|
20
|
+
self.process = SimpleNamespace(returncode=None) # looks alive, like a running app-server
|
|
21
|
+
self.resume_fails = False
|
|
19
22
|
|
|
20
23
|
async def request(self, method, params, **kwargs):
|
|
21
24
|
self.requests.append((method, params))
|
|
@@ -28,6 +31,10 @@ class FakeRPC:
|
|
|
28
31
|
'availableModels': [{'modelId': 'future-model', 'name': 'Future'}]}}
|
|
29
32
|
if method == 'thread/start':
|
|
30
33
|
return {'thread': {'id': 't'}}
|
|
34
|
+
if method == 'thread/resume':
|
|
35
|
+
if self.resume_fails:
|
|
36
|
+
raise RuntimeError('Native CLI rejected thread/resume (code -32600): unknown thread')
|
|
37
|
+
return {'thread': {'id': params['threadId']}}
|
|
31
38
|
if method == 'turn/start':
|
|
32
39
|
await self.events.put({'id': 99, 'method': 'item/commandExecution/requestApproval',
|
|
33
40
|
'params': {'command': 'touch example'}})
|
|
@@ -52,6 +59,7 @@ class FakeRPC:
|
|
|
52
59
|
|
|
53
60
|
async def close(self):
|
|
54
61
|
self.closed = True
|
|
62
|
+
self.process = SimpleNamespace(returncode=0)
|
|
55
63
|
|
|
56
64
|
|
|
57
65
|
@pytest.mark.asyncio
|
|
@@ -88,9 +96,11 @@ async def test_native_permission_response_and_completion(provider, approve, monk
|
|
|
88
96
|
if isinstance(event, NativePermission):
|
|
89
97
|
event.approved = approve
|
|
90
98
|
assert any(isinstance(event, TextDelta) and event.content == 'done' for event in events)
|
|
91
|
-
assert isinstance(events[-1], AgentDone)
|
|
99
|
+
assert isinstance(events[-1], AgentDone) and events[-1].native_session == {'id': 't'}
|
|
92
100
|
response = rpc.sent[0]['result']
|
|
93
101
|
assert response['decision'] == ('accept' if approve else 'decline')
|
|
102
|
+
assert not rpc.closed, 'the app-server stays alive between turns'
|
|
103
|
+
await agent.close()
|
|
94
104
|
assert rpc.closed
|
|
95
105
|
|
|
96
106
|
|
|
@@ -150,6 +160,7 @@ async def test_codex_usage_limit_failure_becomes_limit_hit(monkeypatch):
|
|
|
150
160
|
monkeypatch.setattr(agent, '_connect', connect)
|
|
151
161
|
events = [event async for event in agent.chat([{'role': 'user', 'content': 'hi'}], model='future-model')]
|
|
152
162
|
assert isinstance(events[-1], LimitHit) and events[-1].error_type == LimitStatus.QUOTA_EXHAUSTED
|
|
163
|
+
await agent.close()
|
|
153
164
|
assert rpc.closed
|
|
154
165
|
|
|
155
166
|
|
|
@@ -198,3 +209,64 @@ async def test_codex_rate_limit_notification_is_recorded(monkeypatch):
|
|
|
198
209
|
assert agent.limit_windows == [
|
|
199
210
|
{'label': '5h', 'used_percent': 2, 'resets_at': 1790212672},
|
|
200
211
|
{'label': '7d', 'used_percent': 23, 'resets_at': None}]
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _turn_prompt(rpc):
|
|
215
|
+
return next(params for method, params in rpc.requests if method == 'turn/start')['input'][0]['text']
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
@pytest.mark.asyncio
|
|
219
|
+
async def test_codex_resumes_thread_and_sends_only_new_messages(monkeypatch):
|
|
220
|
+
agent = NativeAgent('openai', '.')
|
|
221
|
+
rpc = FakeRPC('openai')
|
|
222
|
+
async def connect():
|
|
223
|
+
return rpc
|
|
224
|
+
monkeypatch.setattr(agent, '_connect', connect)
|
|
225
|
+
history = [{'role': 'user', 'content': 'first'}, {'role': 'assistant', 'content': 'one'},
|
|
226
|
+
{'role': 'user', 'content': 'second'}]
|
|
227
|
+
events = [e async for e in agent.chat(history, model='future-model', resume={'id': 'thread-9', 'synced': 2})]
|
|
228
|
+
assert isinstance(events[-1], AgentDone) and events[-1].native_session == {'id': 'thread-9'}
|
|
229
|
+
methods = [m for m, _ in rpc.requests]
|
|
230
|
+
assert 'thread/resume' in methods and 'thread/start' not in methods
|
|
231
|
+
assert _turn_prompt(rpc) == 'second', 'only the new message is sent to a resumed thread'
|
|
232
|
+
# Same process, same thread: no second resume request.
|
|
233
|
+
rpc.requests.clear()
|
|
234
|
+
history += [{'role': 'assistant', 'content': 'two'}, {'role': 'user', 'content': 'third'}]
|
|
235
|
+
[e async for e in agent.chat(history, model='future-model', resume={'id': 'thread-9', 'synced': 4})]
|
|
236
|
+
assert 'thread/resume' not in [m for m, _ in rpc.requests] and _turn_prompt(rpc) == 'third'
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
@pytest.mark.asyncio
|
|
240
|
+
async def test_codex_catch_up_block_after_other_providers_answered(monkeypatch):
|
|
241
|
+
agent = NativeAgent('openai', '.')
|
|
242
|
+
rpc = FakeRPC('openai')
|
|
243
|
+
async def connect():
|
|
244
|
+
return rpc
|
|
245
|
+
monkeypatch.setattr(agent, '_connect', connect)
|
|
246
|
+
history = [{'role': 'user', 'content': 'a'}, {'role': 'assistant', 'content': 'b'},
|
|
247
|
+
{'role': 'user', 'content': 'c'}, {'role': 'assistant', 'content': 'd by claude'},
|
|
248
|
+
{'role': 'user', 'content': 'e'}]
|
|
249
|
+
[e async for e in agent.chat(history, model='future-model', resume={'id': 'thread-9', 'synced': 2})]
|
|
250
|
+
prompt = _turn_prompt(rpc)
|
|
251
|
+
assert 'Missed conversation' in prompt and 'd by claude' in prompt and prompt.endswith('Final user message:\ne')
|
|
252
|
+
assert '[User]\na' not in prompt, 'messages the thread already saw are not resent'
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
@pytest.mark.asyncio
|
|
256
|
+
async def test_codex_falls_back_to_new_thread_when_resume_fails(monkeypatch):
|
|
257
|
+
from thwip.agents.base import NativeActivity
|
|
258
|
+
|
|
259
|
+
agent = NativeAgent('openai', '.')
|
|
260
|
+
rpc = FakeRPC('openai')
|
|
261
|
+
rpc.resume_fails = True
|
|
262
|
+
async def connect():
|
|
263
|
+
return rpc
|
|
264
|
+
monkeypatch.setattr(agent, '_connect', connect)
|
|
265
|
+
history = [{'role': 'user', 'content': 'first'}, {'role': 'assistant', 'content': 'one'},
|
|
266
|
+
{'role': 'user', 'content': 'second'}]
|
|
267
|
+
events = [e async for e in agent.chat(history, model='future-model', resume={'id': 'gone', 'synced': 2})]
|
|
268
|
+
assert any(isinstance(e, NativeActivity) and 'unavailable' in e.description for e in events)
|
|
269
|
+
start = next(params for method, params in rpc.requests if method == 'thread/start')
|
|
270
|
+
assert start['ephemeral'] is False
|
|
271
|
+
assert '[User]\nfirst' in _turn_prompt(rpc), 'full transcript goes to the new thread'
|
|
272
|
+
assert events[-1].native_session == {'id': 't'}
|
|
@@ -233,3 +233,53 @@ async def test_antigravity_network_reset_gets_a_network_hint(monkeypatch):
|
|
|
233
233
|
"API error (attempt 1): request failed: Post \"https://example.googleapis.com\": read tcp 10.0.0.2:5->1.2.3.4:443: read: connection reset by peer"}}])
|
|
234
234
|
with pytest.raises(RuntimeError, match="connection reset by peer.*network or a firewall"):
|
|
235
235
|
await collect(agent, model="gemini-3.8-flash-high")
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
@pytest.mark.asyncio
|
|
239
|
+
async def test_claude_new_session_id_then_resume_flag(monkeypatch):
|
|
240
|
+
agent = PrintAgent("claude", ".")
|
|
241
|
+
calls = install_fake(monkeypatch, agent, [{"type": "system", "subtype": "init", "session_id": "abc-123"},
|
|
242
|
+
{"type": "result", "subtype": "success", "result": "hi", "usage": {}}])
|
|
243
|
+
events = await collect(agent)
|
|
244
|
+
assert events[-1].native_session == {"id": "abc-123"}
|
|
245
|
+
command, stdin_text = calls[0]
|
|
246
|
+
assert "--session-id" in command and "--resume" not in command and "--append-system-prompt" in command
|
|
247
|
+
assert stdin_text == "hello"
|
|
248
|
+
# Resume: only the new message, no system prompt re-sent, --resume flag used.
|
|
249
|
+
calls.clear()
|
|
250
|
+
history = [{"role": "user", "content": "hello"}, {"role": "assistant", "content": "hi"}, {"role": "user", "content": "next"}]
|
|
251
|
+
events = [e async for e in agent.chat(history, model="fable", system_prompt="Be brief.", resume={"id": "abc-123", "synced": 2})]
|
|
252
|
+
command, stdin_text = calls[0]
|
|
253
|
+
assert command[command.index("--resume") + 1] == "abc-123" and "--append-system-prompt" not in command
|
|
254
|
+
assert stdin_text == "next" and events[-1].native_session == {"id": "abc-123"}
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
@pytest.mark.asyncio
|
|
258
|
+
async def test_antigravity_conversation_resume_and_fallback(monkeypatch):
|
|
259
|
+
agent = PrintAgent("google", ".")
|
|
260
|
+
ok = [{"event": "init", "conversation_id": "conv-1", "init": {}},
|
|
261
|
+
{"event": "result", "result": {"status": "SUCCESS", "response": "hi", "usage": {}}}]
|
|
262
|
+
calls = install_fake(monkeypatch, agent, ok)
|
|
263
|
+
events = await collect(agent, model="gemini-3.8-flash-high")
|
|
264
|
+
assert events[-1].native_session == {"id": "conv-1"} and "--conversation" not in calls[0][0]
|
|
265
|
+
|
|
266
|
+
# Resumed turn passes --conversation and only the new text.
|
|
267
|
+
calls.clear()
|
|
268
|
+
history = [{"role": "user", "content": "hello"}, {"role": "assistant", "content": "hi"}, {"role": "user", "content": "next"}]
|
|
269
|
+
events = [e async for e in agent.chat(history, model="gemini-3.8-flash-high", resume={"id": "conv-1", "synced": 2})]
|
|
270
|
+
command = calls[0][0]
|
|
271
|
+
assert command[command.index("--conversation") + 1] == "conv-1" and command[command.index("--print") + 1] == "next"
|
|
272
|
+
|
|
273
|
+
# If the resumed process fails, a fresh conversation gets the full transcript.
|
|
274
|
+
attempts = []
|
|
275
|
+
async def start(command, stdin_text=None):
|
|
276
|
+
attempts.append(command)
|
|
277
|
+
if "--conversation" in command:
|
|
278
|
+
return FakeProcess([{"event": "result", "result": {"status": "ERROR", "error": "conversation not found"}}])
|
|
279
|
+
return FakeProcess(ok)
|
|
280
|
+
monkeypatch.setattr(agent, "_start_process", start)
|
|
281
|
+
events = [e async for e in agent.chat(history, model="gemini-3.8-flash-high", resume={"id": "conv-1", "synced": 2})]
|
|
282
|
+
assert len(attempts) == 2 and "--conversation" not in attempts[1]
|
|
283
|
+
assert "[User]\nhello" in attempts[1][attempts[1].index("--print") + 1]
|
|
284
|
+
assert any(isinstance(e, NativeActivity) and "unavailable" in e.description for e in events)
|
|
285
|
+
assert events[-1].native_session == {"id": "conv-1"}
|
|
@@ -171,3 +171,30 @@ def test_mentions_attach_project_files_only(cli, tmp_path):
|
|
|
171
171
|
assert "secret plan" in expanded and "[Attached file: notes.md]" in expanded
|
|
172
172
|
assert "missing.txt]" not in expanded and "passwd]" not in expanded
|
|
173
173
|
assert cli._expand_mentions("email me at user@example.com") == "email me at user@example.com"
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def test_incremental_prompt_builder():
|
|
177
|
+
from thwip.agents.native_common import build_incremental_prompt
|
|
178
|
+
|
|
179
|
+
history = [{"role": "user", "content": "a"}, {"role": "assistant", "content": "b"}, {"role": "user", "content": "c"}]
|
|
180
|
+
full, is_full = build_incremental_prompt(history, "sys", 0)
|
|
181
|
+
assert is_full and "[User]\na" in full and full.endswith("c")
|
|
182
|
+
only_new, is_full = build_incremental_prompt(history, "sys", 2)
|
|
183
|
+
assert (only_new, is_full) == ("c", False)
|
|
184
|
+
_bad, is_full = build_incremental_prompt(history, None, 7)
|
|
185
|
+
assert is_full, "a synced count beyond the history falls back to the full transcript"
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def test_man_page_commands(tmp_path, monkeypatch, capsys):
|
|
189
|
+
from thwip import cli as cli_module
|
|
190
|
+
|
|
191
|
+
monkeypatch.setenv("THWIP_MAN_DIR", str(tmp_path / "man1"))
|
|
192
|
+
assert cli_module.MAN_PAGE.is_file() and cli_module.MAN_PAGE.read_text().startswith(".TH THWIP 1")
|
|
193
|
+
assert cli_module.run_man_command("install-man") == 0
|
|
194
|
+
assert (tmp_path / "man1" / "thwip.1").read_text() == cli_module.MAN_PAGE.read_text()
|
|
195
|
+
monkeypatch.setattr("thwip.cli.shutil.which", lambda name: None)
|
|
196
|
+
assert cli_module.run_man_command("man") == 0
|
|
197
|
+
assert "universal coding agent multiplexer" in capsys.readouterr().out
|
|
198
|
+
with pytest.raises(SystemExit) as info:
|
|
199
|
+
cli_module.main(["install-man"])
|
|
200
|
+
assert info.value.code == 0
|
|
@@ -128,3 +128,24 @@ def test_config_file_permissions_and_source_are_preserved(tmp_path, monkeypatch)
|
|
|
128
128
|
|
|
129
129
|
assert loaded.key_sources["openai"] == "config.toml"
|
|
130
130
|
assert get_config_path().stat().st_mode & 0o777 == 0o600
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def test_native_sessions_are_tracked_validated_and_persisted(tmp_path, monkeypatch):
|
|
134
|
+
from thwip.session import Session
|
|
135
|
+
|
|
136
|
+
monkeypatch.setenv("THWIP_CONFIG_DIR", str(tmp_path))
|
|
137
|
+
session = Session(project_path=str(tmp_path), current_agent="openai", current_model="m")
|
|
138
|
+
assert session.native_session("openai") is None
|
|
139
|
+
session.add_user_message("q")
|
|
140
|
+
session.add_assistant_message("a", agent_name="openai", model="m")
|
|
141
|
+
session.set_native_session("openai", "thread-1", "m")
|
|
142
|
+
assert session.native_session("openai") == {"id": "thread-1", "synced": 2, "model": "m"}
|
|
143
|
+
session.native_sessions["claude"] = {"id": "x", "synced": 99}
|
|
144
|
+
assert session.native_session("claude") is None, "a record that claims more messages than exist is ignored"
|
|
145
|
+
path = session.save("native-track")
|
|
146
|
+
loaded = Session.load("native-track")
|
|
147
|
+
assert loaded.native_session("openai") == {"id": "thread-1", "synced": 2, "model": "m"}
|
|
148
|
+
loaded.clear_context()
|
|
149
|
+
assert loaded.native_sessions == {}
|
|
150
|
+
path.write_text(path.read_text().replace('"synced": 2', '"synced": "2"'))
|
|
151
|
+
assert Session.load("native-track") is None, "malformed native session records reject the file"
|
|
@@ -144,6 +144,8 @@ class AgentDone:
|
|
|
144
144
|
usage: TokenUsage = field(default_factory=TokenUsage)
|
|
145
145
|
stop_reason: str = "end_turn"
|
|
146
146
|
native_state: dict[str, Any] = field(default_factory=dict)
|
|
147
|
+
# Identifier of the native CLI session that produced this turn, so it can be resumed next time.
|
|
148
|
+
native_session: dict[str, Any] = field(default_factory=dict)
|
|
147
149
|
|
|
148
150
|
|
|
149
151
|
@dataclass
|