turnloop 0.1.3__tar.gz → 0.1.5__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.
- {turnloop-0.1.3 → turnloop-0.1.5}/PKG-INFO +137 -13
- {turnloop-0.1.3 → turnloop-0.1.5}/README.md +136 -12
- {turnloop-0.1.3 → turnloop-0.1.5}/pyproject.toml +1 -1
- turnloop-0.1.5/tests/test_config_screen.py +100 -0
- turnloop-0.1.5/tests/test_configio.py +251 -0
- {turnloop-0.1.3 → turnloop-0.1.5}/tests/test_hooks_mcp_commands.py +52 -0
- turnloop-0.1.5/tests/test_tui.py +397 -0
- {turnloop-0.1.3 → turnloop-0.1.5}/turnloop/__init__.py +1 -1
- {turnloop-0.1.3 → turnloop-0.1.5}/turnloop/cli.py +32 -0
- {turnloop-0.1.3 → turnloop-0.1.5}/turnloop/commands/dispatch.py +22 -2
- {turnloop-0.1.3 → turnloop-0.1.5}/turnloop/config.py +7 -0
- turnloop-0.1.5/turnloop/configio.py +250 -0
- {turnloop-0.1.3 → turnloop-0.1.5}/turnloop/providers/pricing.py +148 -148
- {turnloop-0.1.3 → turnloop-0.1.5}/turnloop/tui/app.py +112 -10
- turnloop-0.1.5/turnloop/tui/config_screen.py +457 -0
- turnloop-0.1.5/turnloop/tui/mcp_screen.py +363 -0
- turnloop-0.1.3/tests/test_tui.py +0 -170
- {turnloop-0.1.3 → turnloop-0.1.5}/.github/workflows/ci.yml +0 -0
- {turnloop-0.1.3 → turnloop-0.1.5}/.gitignore +0 -0
- {turnloop-0.1.3 → turnloop-0.1.5}/.turnloop/.gitignore +0 -0
- {turnloop-0.1.3 → turnloop-0.1.5}/tests/conftest.py +0 -0
- {turnloop-0.1.3 → turnloop-0.1.5}/tests/fixtures/anthropic_tool_call.sse +0 -0
- {turnloop-0.1.3 → turnloop-0.1.5}/tests/fixtures/glm_tool_call.sse +0 -0
- {turnloop-0.1.3 → turnloop-0.1.5}/tests/fixtures/mcp_echo_server.py +0 -0
- {turnloop-0.1.3 → turnloop-0.1.5}/tests/test_bash.py +0 -0
- {turnloop-0.1.3 → turnloop-0.1.5}/tests/test_compaction.py +0 -0
- {turnloop-0.1.3 → turnloop-0.1.5}/tests/test_config.py +0 -0
- {turnloop-0.1.3 → turnloop-0.1.5}/tests/test_experiments.py +0 -0
- {turnloop-0.1.3 → turnloop-0.1.5}/tests/test_loop.py +0 -0
- {turnloop-0.1.3 → turnloop-0.1.5}/tests/test_permissions.py +0 -0
- {turnloop-0.1.3 → turnloop-0.1.5}/tests/test_providers.py +0 -0
- {turnloop-0.1.3 → turnloop-0.1.5}/tests/test_tools_files.py +0 -0
- {turnloop-0.1.3 → turnloop-0.1.5}/tests/test_websearch.py +0 -0
- {turnloop-0.1.3 → turnloop-0.1.5}/turnloop/__main__.py +0 -0
- {turnloop-0.1.3 → turnloop-0.1.5}/turnloop/agent/__init__.py +0 -0
- {turnloop-0.1.3 → turnloop-0.1.5}/turnloop/agent/factory.py +0 -0
- {turnloop-0.1.3 → turnloop-0.1.5}/turnloop/agent/headless.py +0 -0
- {turnloop-0.1.3 → turnloop-0.1.5}/turnloop/agent/loop.py +0 -0
- {turnloop-0.1.3 → turnloop-0.1.5}/turnloop/agent/subagent.py +0 -0
- {turnloop-0.1.3 → turnloop-0.1.5}/turnloop/agent/system_prompt.py +0 -0
- {turnloop-0.1.3 → turnloop-0.1.5}/turnloop/commands/__init__.py +0 -0
- {turnloop-0.1.3 → turnloop-0.1.5}/turnloop/commands/loader.py +0 -0
- {turnloop-0.1.3 → turnloop-0.1.5}/turnloop/context/__init__.py +0 -0
- {turnloop-0.1.3 → turnloop-0.1.5}/turnloop/context/budget.py +0 -0
- {turnloop-0.1.3 → turnloop-0.1.5}/turnloop/context/compaction.py +0 -0
- {turnloop-0.1.3 → turnloop-0.1.5}/turnloop/context/memory.py +0 -0
- {turnloop-0.1.3 → turnloop-0.1.5}/turnloop/core/__init__.py +0 -0
- {turnloop-0.1.3 → turnloop-0.1.5}/turnloop/core/events.py +0 -0
- {turnloop-0.1.3 → turnloop-0.1.5}/turnloop/core/ids.py +0 -0
- {turnloop-0.1.3 → turnloop-0.1.5}/turnloop/core/messages.py +0 -0
- {turnloop-0.1.3 → turnloop-0.1.5}/turnloop/core/tokens.py +0 -0
- {turnloop-0.1.3 → turnloop-0.1.5}/turnloop/diagnostics.py +0 -0
- {turnloop-0.1.3 → turnloop-0.1.5}/turnloop/errors.py +0 -0
- {turnloop-0.1.3 → turnloop-0.1.5}/turnloop/experiments/__init__.py +0 -0
- {turnloop-0.1.3 → turnloop-0.1.5}/turnloop/experiments/configs/bakeoff.yaml +0 -0
- {turnloop-0.1.3 → turnloop-0.1.5}/turnloop/experiments/configs/ceiling-check.yaml +0 -0
- {turnloop-0.1.3 → turnloop-0.1.5}/turnloop/experiments/configs/context-glm.yaml +0 -0
- {turnloop-0.1.3 → turnloop-0.1.5}/turnloop/experiments/configs/context.yaml +0 -0
- {turnloop-0.1.3 → turnloop-0.1.5}/turnloop/experiments/configs/glm-selfhosted.yaml +0 -0
- {turnloop-0.1.3 → turnloop-0.1.5}/turnloop/experiments/configs/groq-free.yaml +0 -0
- {turnloop-0.1.3 → turnloop-0.1.5}/turnloop/experiments/configs/hard-calibration.yaml +0 -0
- {turnloop-0.1.3 → turnloop-0.1.5}/turnloop/experiments/configs/hard-glm.yaml +0 -0
- {turnloop-0.1.3 → turnloop-0.1.5}/turnloop/experiments/configs/loop.yaml +0 -0
- {turnloop-0.1.3 → turnloop-0.1.5}/turnloop/experiments/configs/nvidia-smoke.yaml +0 -0
- {turnloop-0.1.3 → turnloop-0.1.5}/turnloop/experiments/configs/reliability.yaml +0 -0
- {turnloop-0.1.3 → turnloop-0.1.5}/turnloop/experiments/configs/rewrite-calibration.yaml +0 -0
- {turnloop-0.1.3 → turnloop-0.1.5}/turnloop/experiments/configs/smoke.yaml +0 -0
- {turnloop-0.1.3 → turnloop-0.1.5}/turnloop/experiments/graders.py +0 -0
- {turnloop-0.1.3 → turnloop-0.1.5}/turnloop/experiments/report.py +0 -0
- {turnloop-0.1.3 → turnloop-0.1.5}/turnloop/experiments/runner.py +0 -0
- {turnloop-0.1.3 → turnloop-0.1.5}/turnloop/experiments/suite/default.yaml +0 -0
- {turnloop-0.1.3 → turnloop-0.1.5}/turnloop/experiments/suite/fixtures/bulky/module_1.py +0 -0
- {turnloop-0.1.3 → turnloop-0.1.5}/turnloop/experiments/suite/fixtures/bulky/module_2.py +0 -0
- {turnloop-0.1.3 → turnloop-0.1.5}/turnloop/experiments/suite/fixtures/bulky/module_3.py +0 -0
- {turnloop-0.1.3 → turnloop-0.1.5}/turnloop/experiments/suite/fixtures/bulky/module_4.py +0 -0
- {turnloop-0.1.3 → turnloop-0.1.5}/turnloop/experiments/suite/fixtures/bulky/module_5.py +0 -0
- {turnloop-0.1.3 → turnloop-0.1.5}/turnloop/experiments/suite/fixtures/bulky/module_6.py +0 -0
- {turnloop-0.1.3 → turnloop-0.1.5}/turnloop/experiments/suite/fixtures/bulky/module_7.py +0 -0
- {turnloop-0.1.3 → turnloop-0.1.5}/turnloop/experiments/suite/fixtures/bulky/module_8.py +0 -0
- {turnloop-0.1.3 → turnloop-0.1.5}/turnloop/experiments/suite/fixtures/circular_import/models.py +0 -0
- {turnloop-0.1.3 → turnloop-0.1.5}/turnloop/experiments/suite/fixtures/circular_import/services.py +0 -0
- {turnloop-0.1.3 → turnloop-0.1.5}/turnloop/experiments/suite/fixtures/circular_import/test_cancel.py +0 -0
- {turnloop-0.1.3 → turnloop-0.1.5}/turnloop/experiments/suite/fixtures/cli_flag/app.py +0 -0
- {turnloop-0.1.3 → turnloop-0.1.5}/turnloop/experiments/suite/fixtures/cross_file_contract/decode.py +0 -0
- {turnloop-0.1.3 → turnloop-0.1.5}/turnloop/experiments/suite/fixtures/cross_file_contract/encode.py +0 -0
- {turnloop-0.1.3 → turnloop-0.1.5}/turnloop/experiments/suite/fixtures/cross_file_contract/test_roundtrip.py +0 -0
- {turnloop-0.1.3 → turnloop-0.1.5}/turnloop/experiments/suite/fixtures/cross_file_contract/test_wire_format.py +0 -0
- {turnloop-0.1.3 → turnloop-0.1.5}/turnloop/experiments/suite/fixtures/empty/.keep +0 -0
- {turnloop-0.1.3 → turnloop-0.1.5}/turnloop/experiments/suite/fixtures/failing_test/calc.py +0 -0
- {turnloop-0.1.3 → turnloop-0.1.5}/turnloop/experiments/suite/fixtures/failing_test/test_calc.py +0 -0
- {turnloop-0.1.3 → turnloop-0.1.5}/turnloop/experiments/suite/fixtures/generate_bulky.py +0 -0
- {turnloop-0.1.3 → turnloop-0.1.5}/turnloop/experiments/suite/fixtures/grep_edit/ingest.py +0 -0
- {turnloop-0.1.3 → turnloop-0.1.5}/turnloop/experiments/suite/fixtures/grep_edit/load.py +0 -0
- {turnloop-0.1.3 → turnloop-0.1.5}/turnloop/experiments/suite/fixtures/grep_edit/transform.py +0 -0
- {turnloop-0.1.3 → turnloop-0.1.5}/turnloop/experiments/suite/fixtures/misleading_traceback/checkout.py +0 -0
- {turnloop-0.1.3 → turnloop-0.1.5}/turnloop/experiments/suite/fixtures/misleading_traceback/invoice.py +0 -0
- {turnloop-0.1.3 → turnloop-0.1.5}/turnloop/experiments/suite/fixtures/misleading_traceback/pricing.py +0 -0
- {turnloop-0.1.3 → turnloop-0.1.5}/turnloop/experiments/suite/fixtures/misleading_traceback/test_checkout.py +0 -0
- {turnloop-0.1.3 → turnloop-0.1.5}/turnloop/experiments/suite/fixtures/misleading_traceback/test_invoice.py +0 -0
- {turnloop-0.1.3 → turnloop-0.1.5}/turnloop/experiments/suite/fixtures/needle/inventory.py +0 -0
- {turnloop-0.1.3 → turnloop-0.1.5}/turnloop/experiments/suite/fixtures/needle/pricing.py +0 -0
- {turnloop-0.1.3 → turnloop-0.1.5}/turnloop/experiments/suite/fixtures/needle/shipping.py +0 -0
- {turnloop-0.1.3 → turnloop-0.1.5}/turnloop/experiments/suite/fixtures/regression_trap/test_validators.py +0 -0
- {turnloop-0.1.3 → turnloop-0.1.5}/turnloop/experiments/suite/fixtures/regression_trap/validators.py +0 -0
- {turnloop-0.1.3 → turnloop-0.1.5}/turnloop/experiments/suite/fixtures/rename/billing.py +0 -0
- {turnloop-0.1.3 → turnloop-0.1.5}/turnloop/experiments/suite/fixtures/rename/report.py +0 -0
- {turnloop-0.1.3 → turnloop-0.1.5}/turnloop/experiments/suite/fixtures/rename/test_billing.py +0 -0
- {turnloop-0.1.3 → turnloop-0.1.5}/turnloop/experiments/suite/fixtures/spec/parser.py +0 -0
- {turnloop-0.1.3 → turnloop-0.1.5}/turnloop/experiments/suite/fixtures/spec/test_parser.py +0 -0
- {turnloop-0.1.3 → turnloop-0.1.5}/turnloop/experiments/suite/fixtures/subtle_spec_edge/leaderboard.py +0 -0
- {turnloop-0.1.3 → turnloop-0.1.5}/turnloop/experiments/suite/fixtures/subtle_spec_edge/test_leaderboard.py +0 -0
- {turnloop-0.1.3 → turnloop-0.1.5}/turnloop/experiments/suite/fixtures/util/util.py +0 -0
- {turnloop-0.1.3 → turnloop-0.1.5}/turnloop/hooks/__init__.py +0 -0
- {turnloop-0.1.3 → turnloop-0.1.5}/turnloop/hooks/runner.py +0 -0
- {turnloop-0.1.3 → turnloop-0.1.5}/turnloop/mcp/__init__.py +0 -0
- {turnloop-0.1.3 → turnloop-0.1.5}/turnloop/mcp/adapter.py +0 -0
- {turnloop-0.1.3 → turnloop-0.1.5}/turnloop/mcp/client.py +0 -0
- {turnloop-0.1.3 → turnloop-0.1.5}/turnloop/permissions/__init__.py +0 -0
- {turnloop-0.1.3 → turnloop-0.1.5}/turnloop/permissions/engine.py +0 -0
- {turnloop-0.1.3 → turnloop-0.1.5}/turnloop/permissions/rules.py +0 -0
- {turnloop-0.1.3 → turnloop-0.1.5}/turnloop/providers/__init__.py +0 -0
- {turnloop-0.1.3 → turnloop-0.1.5}/turnloop/providers/anthropic.py +0 -0
- {turnloop-0.1.3 → turnloop-0.1.5}/turnloop/providers/base.py +0 -0
- {turnloop-0.1.3 → turnloop-0.1.5}/turnloop/providers/gemini.py +0 -0
- {turnloop-0.1.3 → turnloop-0.1.5}/turnloop/providers/mock.py +0 -0
- {turnloop-0.1.3 → turnloop-0.1.5}/turnloop/providers/openai_compat.py +0 -0
- {turnloop-0.1.3 → turnloop-0.1.5}/turnloop/providers/registry.py +0 -0
- {turnloop-0.1.3 → turnloop-0.1.5}/turnloop/providers/sse.py +0 -0
- {turnloop-0.1.3 → turnloop-0.1.5}/turnloop/sessions/__init__.py +0 -0
- {turnloop-0.1.3 → turnloop-0.1.5}/turnloop/sessions/models.py +0 -0
- {turnloop-0.1.3 → turnloop-0.1.5}/turnloop/sessions/store.py +0 -0
- {turnloop-0.1.3 → turnloop-0.1.5}/turnloop/tools/__init__.py +0 -0
- {turnloop-0.1.3 → turnloop-0.1.5}/turnloop/tools/ask.py +0 -0
- {turnloop-0.1.3 → turnloop-0.1.5}/turnloop/tools/base.py +0 -0
- {turnloop-0.1.3 → turnloop-0.1.5}/turnloop/tools/bash.py +0 -0
- {turnloop-0.1.3 → turnloop-0.1.5}/turnloop/tools/builtin.py +0 -0
- {turnloop-0.1.3 → turnloop-0.1.5}/turnloop/tools/edit.py +0 -0
- {turnloop-0.1.3 → turnloop-0.1.5}/turnloop/tools/glob.py +0 -0
- {turnloop-0.1.3 → turnloop-0.1.5}/turnloop/tools/grep.py +0 -0
- {turnloop-0.1.3 → turnloop-0.1.5}/turnloop/tools/read.py +0 -0
- {turnloop-0.1.3 → turnloop-0.1.5}/turnloop/tools/runner.py +0 -0
- {turnloop-0.1.3 → turnloop-0.1.5}/turnloop/tools/shell.py +0 -0
- {turnloop-0.1.3 → turnloop-0.1.5}/turnloop/tools/skill.py +0 -0
- {turnloop-0.1.3 → turnloop-0.1.5}/turnloop/tools/task.py +0 -0
- {turnloop-0.1.3 → turnloop-0.1.5}/turnloop/tools/textio.py +0 -0
- {turnloop-0.1.3 → turnloop-0.1.5}/turnloop/tools/todo.py +0 -0
- {turnloop-0.1.3 → turnloop-0.1.5}/turnloop/tools/webfetch.py +0 -0
- {turnloop-0.1.3 → turnloop-0.1.5}/turnloop/tools/websearch.py +0 -0
- {turnloop-0.1.3 → turnloop-0.1.5}/turnloop/tools/write.py +0 -0
- {turnloop-0.1.3 → turnloop-0.1.5}/turnloop/tui/__init__.py +0 -0
- {turnloop-0.1.3 → turnloop-0.1.5}/turnloop/tui/app.tcss +0 -0
- {turnloop-0.1.3 → turnloop-0.1.5}/turnloop/tui/bridge.py +0 -0
- {turnloop-0.1.3 → turnloop-0.1.5}/turnloop/tui/widgets/__init__.py +0 -0
- {turnloop-0.1.3 → turnloop-0.1.5}/turnloop/tui/widgets/permission.py +0 -0
- {turnloop-0.1.3 → turnloop-0.1.5}/turnloop/tui/widgets/status.py +0 -0
- {turnloop-0.1.3 → turnloop-0.1.5}/turnloop/tui/widgets/transcript.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: turnloop
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.5
|
|
4
4
|
Summary: A local-first agentic coding harness: streaming tool loop, permission gating, context compaction, subagents, and any LLM provider.
|
|
5
5
|
Project-URL: Homepage, https://github.com/Pranesh-2005/turnloop
|
|
6
6
|
Author: Pranesh
|
|
@@ -49,7 +49,7 @@ tl experiment run smoke # a measurement run, offline and free
|
|
|
49
49
|
|
|
50
50
|
Four runtime dependencies: `textual`, `pydantic`, `httpx`, `pyyaml`. No vendor
|
|
51
51
|
SDKs, no agent framework — the loop is the point, so the loop is written here.
|
|
52
|
-
~18,000 lines of Python,
|
|
52
|
+
~18,000 lines of Python, 290 tests, no network in the default test run.
|
|
53
53
|
|
|
54
54
|
---
|
|
55
55
|
|
|
@@ -117,7 +117,7 @@ point.
|
|
|
117
117
|
Verify:
|
|
118
118
|
|
|
119
119
|
```bash
|
|
120
|
-
tl --version # turnloop 0.1.
|
|
120
|
+
tl --version # turnloop 0.1.5
|
|
121
121
|
tl doctor # every provider, key presence, shell, context budget
|
|
122
122
|
```
|
|
123
123
|
|
|
@@ -143,8 +143,14 @@ tl --resume # resume the most recent session
|
|
|
143
143
|
tl --resume <session-id> # resume a specific one
|
|
144
144
|
tl sessions # list recorded sessions
|
|
145
145
|
tl config # merged config + which layer each value came from
|
|
146
|
+
tl config --edit # interactive settings editor
|
|
147
|
+
tl mcp # add, edit, enable/disable, remove MCP servers
|
|
146
148
|
```
|
|
147
149
|
|
|
150
|
+
`config --edit` and `mcp` are deliberately the only paths that write a settings file,
|
|
151
|
+
and neither is reachable by the model — see
|
|
152
|
+
[Editing configuration](#editing-configuration).
|
|
153
|
+
|
|
148
154
|
### Flags
|
|
149
155
|
|
|
150
156
|
| flag | meaning |
|
|
@@ -195,9 +201,10 @@ focus and would otherwise swallow them.
|
|
|
195
201
|
/compact summarize conversation now /memory discovered memory files
|
|
196
202
|
/cost token and cost accounting /tools available tools
|
|
197
203
|
/context what is filling the window /mcp MCP server status
|
|
198
|
-
/model show or change model /
|
|
199
|
-
/provider switch provider /
|
|
200
|
-
/export transcript to markdown /
|
|
204
|
+
/model show or change model /mcp add add an MCP server
|
|
205
|
+
/provider switch provider /config settings editor
|
|
206
|
+
/export transcript to markdown /hooks configured hooks
|
|
207
|
+
/sessions recorded sessions /doctor diagnostics
|
|
201
208
|
/quit exit
|
|
202
209
|
```
|
|
203
210
|
|
|
@@ -336,7 +343,18 @@ every segment is allowed.** Without that, `Bash(git *)` grants
|
|
|
336
343
|
scanner that respects quoting — `shlex` discards operators, so the `rm` comes back
|
|
337
344
|
looking like an argument to `git`. 48 tests cover this file alone.
|
|
338
345
|
|
|
339
|
-
|
|
346
|
+
Twelve deny rules ship by default, including `Read(**/.env)` and — since 0.1.4 —
|
|
347
|
+
`Write(**/.turnloop/settings*.json)` and `Edit(**/.turnloop/settings*.json)`. Those two
|
|
348
|
+
close a self-escalation path: settings files set `permission_mode` and
|
|
349
|
+
`permissions.allow`, so a model that could write one could grant itself `bypass` on the
|
|
350
|
+
next launch. Verified against the real engine *in bypass mode*, which is the case that
|
|
351
|
+
matters — deny beats bypass, so the block holds even there.
|
|
352
|
+
|
|
353
|
+
It is a guardrail, not a sandbox, and the boundary is worth being precise about. `Write`
|
|
354
|
+
and `Edit` are blocked; **`Bash` is not**, so a shell redirect reaches the same file.
|
|
355
|
+
Closing that properly needs a path guard below the tool layer rather than more patterns.
|
|
356
|
+
In `default` mode Bash asks a human first, so this only bites under `bypass` — which is
|
|
357
|
+
already the mode that means "I accept the consequences."
|
|
340
358
|
|
|
341
359
|
### Context compaction
|
|
342
360
|
|
|
@@ -851,11 +869,34 @@ and through session JSONL, so a `--resume`d conversation does not hit the same w
|
|
|
851
869
|
check, and anyone who pointed 0.1.2 at Gemini got one working tool call and then a
|
|
852
870
|
400. Fixed in 0.1.3.
|
|
853
871
|
|
|
854
|
-
|
|
872
|
+
### `NoActiveWorker`: 283 green tests and a crash on the first keystroke
|
|
873
|
+
|
|
874
|
+
The config UI shipped with tests covering `/config` — they asserted that dispatching it
|
|
875
|
+
returned `open_screen="config"`. All 283 passed. Typing `/config` in the actual TUI
|
|
876
|
+
raised `NoActiveWorker: push_screen must be run from a worker`.
|
|
877
|
+
|
|
878
|
+
Textual's `push_screen_wait` blocks the calling coroutine until the screen is dismissed,
|
|
879
|
+
so it must run inside a worker; called from the message pump it would deadlock the pump
|
|
880
|
+
it is waiting on, and Textual refuses rather than hanging. `_handle_command` runs on the
|
|
881
|
+
pump. So does Textual's action dispatch, which meant the same defect existed at four
|
|
882
|
+
call sites, not the one in the traceback — including the provider form reachable from
|
|
883
|
+
`tl config --edit`, a path the crash report never touched.
|
|
884
|
+
|
|
885
|
+
The correct pattern was eleven lines away in the same file: `_permission_worker` is
|
|
886
|
+
`@work`-decorated, which is exactly why *its* `push_screen_wait` works.
|
|
887
|
+
|
|
888
|
+
Two things this cost, both worth stating. The tests verified the layer *below* the bug —
|
|
889
|
+
that the command produced the right instruction, never that the app could carry it out.
|
|
890
|
+
And **0.1.4 shipped this way**, so `pip install turnloop==0.1.4` crashes on `/config`.
|
|
891
|
+
Fixed in 0.1.5. The tests now drive a real app through Textual's `run_test()` pilot and
|
|
892
|
+
fail if the decorators are removed.
|
|
893
|
+
|
|
894
|
+
**The lesson, five times over in one project:** an 84-run sweep failing while printing
|
|
855
895
|
a tidy report of zeros; a tool returning empty with `ok: true`; a sweep sitting at
|
|
856
896
|
10/18 doing nothing for 25 minutes; an adapter passing its live test and failing on
|
|
857
|
-
the turn the test never made
|
|
858
|
-
|
|
897
|
+
the turn the test never made; a feature with green tests that crashed on its first
|
|
898
|
+
keystroke. Each was caught only by looking past the summary line — and the last two
|
|
899
|
+
only by using the thing instead of testing it.
|
|
859
900
|
|
|
860
901
|
---
|
|
861
902
|
|
|
@@ -889,7 +930,7 @@ one-line edit in a CRLF checkout does not produce a whole-file diff.
|
|
|
889
930
|
## Testing
|
|
890
931
|
|
|
891
932
|
```bash
|
|
892
|
-
pytest #
|
|
933
|
+
pytest # 290 tests, no network
|
|
893
934
|
ruff check turnloop
|
|
894
935
|
mypy turnloop
|
|
895
936
|
```
|
|
@@ -901,12 +942,14 @@ mypy turnloop
|
|
|
901
942
|
| `test_providers.py` | 35 | adapters vs recorded `.sse`, image blocks, thought signatures |
|
|
902
943
|
| `test_tools_files.py` | 28 | Read/Write/Edit/Glob/Grep, encodings, newlines, image detection |
|
|
903
944
|
| `test_loop.py` | 20 | streaming, truncation, iteration cap |
|
|
904
|
-
| `test_hooks_mcp_commands.py` |
|
|
945
|
+
| `test_hooks_mcp_commands.py` | 23 | lifecycle hooks, MCP client, slash commands |
|
|
905
946
|
| `test_config.py` | 21 | layering, env overrides, presets, narrow-console guard |
|
|
906
947
|
| `test_compaction.py` | 16 | three tiers, tool_use/tool_result invariant |
|
|
907
948
|
| `test_bash.py` | 14 | shell selection, process-tree kill |
|
|
949
|
+
| `test_configio.py` | 15 | minimal-diff writes, atomic save, secret-bearing MCP targets, the settings deny rules |
|
|
950
|
+
| `test_config_screen.py` | 10 | provider form validation, caps derivation, env indicator leaks |
|
|
908
951
|
| `test_websearch.py` | 9 | HTML parsing, backend selection, truncation, failure modes |
|
|
909
|
-
| `test_tui.py` |
|
|
952
|
+
| `test_tui.py` | 15 | Textual snapshots, `/config` and `/mcp add` driven through a real app |
|
|
910
953
|
|
|
911
954
|
`conftest.py` monkeypatches httpx's transport to raise unless a test is marked
|
|
912
955
|
`live` — **network is off at the transport layer**, so a forgotten real call fails
|
|
@@ -957,6 +1000,87 @@ Shell expansion inside a command template (`` !`cmd` ``) goes through the permis
|
|
|
957
1000
|
engine like any other Bash call. A markdown file in a repository is not trusted input
|
|
958
1001
|
just because it is on disk.
|
|
959
1002
|
|
|
1003
|
+
### Editing configuration
|
|
1004
|
+
|
|
1005
|
+
`tl config --edit` edits the provider table, permission mode, tool verbosity, iteration
|
|
1006
|
+
cap, search backend, memory, and the three rule lists. `tl mcp` manages MCP servers. The
|
|
1007
|
+
same screens open inside a running session as `/config` and `/mcp add`.
|
|
1008
|
+
|
|
1009
|
+
**Adding a provider** is a form for `kind`, `model`, `base_url`, and `api_key_env`, with
|
|
1010
|
+
a live ✓/✗ showing whether that environment variable is currently set. Capabilities are
|
|
1011
|
+
re-derived from `preset_for(model)` on every save, mirroring what `load_settings` does
|
|
1012
|
+
for `--model` — without that, a provider added through the UI keeps the previous model's
|
|
1013
|
+
context window and pricing, and `doctor`'s context-budget line quietly lies. An
|
|
1014
|
+
`openai_compat` provider without a `base_url` is refused by the form, because
|
|
1015
|
+
`load_settings` would otherwise raise on the *next* launch, turning a typo into a CLI
|
|
1016
|
+
that will not start.
|
|
1017
|
+
|
|
1018
|
+
Four further properties are structural rather than cosmetic:
|
|
1019
|
+
|
|
1020
|
+
**Only the diff is written.** Neither screen dumps the settings model. It writes the
|
|
1021
|
+
difference against the packaged defaults, merged into whatever the file already holds.
|
|
1022
|
+
Round-tripping the full model would freeze today's deny list and provider table into
|
|
1023
|
+
your file permanently, so a future security default could never reach you.
|
|
1024
|
+
|
|
1025
|
+
**Writes are atomic and validated.** The edited settings round-trip through
|
|
1026
|
+
`Settings.model_validate` before anything touches disk, then land via a temp file and
|
|
1027
|
+
`os.replace`. A malformed settings file makes `load_settings` raise and the CLI exit 2 —
|
|
1028
|
+
failing closed is right, but only if we never cause it ourselves.
|
|
1029
|
+
|
|
1030
|
+
**Secrets are named, never stored.** Provider credentials are edited as the env var
|
|
1031
|
+
*name*, and the written config has an `api_key_env` key and no `api_key` value at all —
|
|
1032
|
+
a config UI with a "paste your key here" box writes secrets into a JSON file that gets
|
|
1033
|
+
committed, which is how keys leak. The ✓/✗ indicator reports the name and a boolean,
|
|
1034
|
+
never the value, a prefix, or a length. MCP `env` values are literal by protocol necessity, so a server carrying any is
|
|
1035
|
+
forced to `settings.local.json` and refused a shared file, and the screen creates
|
|
1036
|
+
`.turnloop/.gitignore` before writing — otherwise a secret-bearing file can be committed
|
|
1037
|
+
before the first session ever runs.
|
|
1038
|
+
|
|
1039
|
+
**No agent-reachable route writes settings.** No tool and no hook mutates a settings
|
|
1040
|
+
file — which is what makes the deny rules above meaningful instead of decorative.
|
|
1041
|
+
`/config` and `/mcp add` are slash commands, and that is safe for a specific reason worth
|
|
1042
|
+
stating: `dispatch_command` has exactly one caller, reached from the prompt Input's
|
|
1043
|
+
`on_input_submitted`. The model emits tool calls, not typed input, so it cannot reach
|
|
1044
|
+
that path. The property lives in the dispatch path rather than in the commands, so a
|
|
1045
|
+
future refactor that routes model output through `dispatch_command` would break it
|
|
1046
|
+
silently — there is a comment there saying so.
|
|
1047
|
+
|
|
1048
|
+
**In-session saves reload only what is safe to reload.** `permission_mode`,
|
|
1049
|
+
`permissions`, `max_iterations` and `tool_verbosity` are read from the live `Settings`
|
|
1050
|
+
and `PermissionEngine`, so they take effect immediately. `provider`, `providers`,
|
|
1051
|
+
`search` and `include_memory` are baked into objects built once at agent construction,
|
|
1052
|
+
so the confirmation names them as needing a restart. Reporting "saved" while the old
|
|
1053
|
+
permission mode is still being enforced would be worse than saying nothing.
|
|
1054
|
+
|
|
1055
|
+
One honest gap: removing a server deletes it from the one file you chose. `deep_merge`
|
|
1056
|
+
has no tombstone, so a server also defined in a higher layer stays effective. The screen
|
|
1057
|
+
says so rather than pretending otherwise.
|
|
1058
|
+
|
|
1059
|
+
### MCP servers are a trust decision
|
|
1060
|
+
|
|
1061
|
+
Adding an MCP server is the same class of decision as `npm install`, and turnloop cannot
|
|
1062
|
+
check it for you. Two specific reasons, both inherent to the protocol rather than to this
|
|
1063
|
+
implementation:
|
|
1064
|
+
|
|
1065
|
+
**A stdio server is a program you agreed to run.** `command` and `args` are spawned as a
|
|
1066
|
+
child process at every launch, with your full user privileges and any `env` you set. The
|
|
1067
|
+
add form states this and requires an explicit confirmation, because a text field labelled
|
|
1068
|
+
"command" does not otherwise read like "execute this forever."
|
|
1069
|
+
|
|
1070
|
+
**Tool descriptions are untrusted text that enters the model's context.** A server
|
|
1071
|
+
advertises its own names and descriptions, and those go into the system prompt verbatim.
|
|
1072
|
+
A hostile or compromised server can put instructions there. Nothing in the protocol
|
|
1073
|
+
authenticates that text.
|
|
1074
|
+
|
|
1075
|
+
What turnloop does about it, which is containment rather than prevention: MCP tools are
|
|
1076
|
+
`read_only = False` unconditionally (`mcp/adapter.py`), because the protocol has no
|
|
1077
|
+
read-only annotation worth trusting — so they always prompt in `default` mode and are
|
|
1078
|
+
refused outright in `plan` mode. A dead or misbehaving server degrades to "that tool is
|
|
1079
|
+
unavailable" and never blocks the loop. Connections are lazy and every call is bounded.
|
|
1080
|
+
|
|
1081
|
+
None of that helps if you install a server that does exactly what it says. Read what you
|
|
1082
|
+
add.
|
|
1083
|
+
|
|
960
1084
|
---
|
|
961
1085
|
|
|
962
1086
|
## Limitations
|
|
@@ -20,7 +20,7 @@ tl experiment run smoke # a measurement run, offline and free
|
|
|
20
20
|
|
|
21
21
|
Four runtime dependencies: `textual`, `pydantic`, `httpx`, `pyyaml`. No vendor
|
|
22
22
|
SDKs, no agent framework — the loop is the point, so the loop is written here.
|
|
23
|
-
~18,000 lines of Python,
|
|
23
|
+
~18,000 lines of Python, 290 tests, no network in the default test run.
|
|
24
24
|
|
|
25
25
|
---
|
|
26
26
|
|
|
@@ -88,7 +88,7 @@ point.
|
|
|
88
88
|
Verify:
|
|
89
89
|
|
|
90
90
|
```bash
|
|
91
|
-
tl --version # turnloop 0.1.
|
|
91
|
+
tl --version # turnloop 0.1.5
|
|
92
92
|
tl doctor # every provider, key presence, shell, context budget
|
|
93
93
|
```
|
|
94
94
|
|
|
@@ -114,8 +114,14 @@ tl --resume # resume the most recent session
|
|
|
114
114
|
tl --resume <session-id> # resume a specific one
|
|
115
115
|
tl sessions # list recorded sessions
|
|
116
116
|
tl config # merged config + which layer each value came from
|
|
117
|
+
tl config --edit # interactive settings editor
|
|
118
|
+
tl mcp # add, edit, enable/disable, remove MCP servers
|
|
117
119
|
```
|
|
118
120
|
|
|
121
|
+
`config --edit` and `mcp` are deliberately the only paths that write a settings file,
|
|
122
|
+
and neither is reachable by the model — see
|
|
123
|
+
[Editing configuration](#editing-configuration).
|
|
124
|
+
|
|
119
125
|
### Flags
|
|
120
126
|
|
|
121
127
|
| flag | meaning |
|
|
@@ -166,9 +172,10 @@ focus and would otherwise swallow them.
|
|
|
166
172
|
/compact summarize conversation now /memory discovered memory files
|
|
167
173
|
/cost token and cost accounting /tools available tools
|
|
168
174
|
/context what is filling the window /mcp MCP server status
|
|
169
|
-
/model show or change model /
|
|
170
|
-
/provider switch provider /
|
|
171
|
-
/export transcript to markdown /
|
|
175
|
+
/model show or change model /mcp add add an MCP server
|
|
176
|
+
/provider switch provider /config settings editor
|
|
177
|
+
/export transcript to markdown /hooks configured hooks
|
|
178
|
+
/sessions recorded sessions /doctor diagnostics
|
|
172
179
|
/quit exit
|
|
173
180
|
```
|
|
174
181
|
|
|
@@ -307,7 +314,18 @@ every segment is allowed.** Without that, `Bash(git *)` grants
|
|
|
307
314
|
scanner that respects quoting — `shlex` discards operators, so the `rm` comes back
|
|
308
315
|
looking like an argument to `git`. 48 tests cover this file alone.
|
|
309
316
|
|
|
310
|
-
|
|
317
|
+
Twelve deny rules ship by default, including `Read(**/.env)` and — since 0.1.4 —
|
|
318
|
+
`Write(**/.turnloop/settings*.json)` and `Edit(**/.turnloop/settings*.json)`. Those two
|
|
319
|
+
close a self-escalation path: settings files set `permission_mode` and
|
|
320
|
+
`permissions.allow`, so a model that could write one could grant itself `bypass` on the
|
|
321
|
+
next launch. Verified against the real engine *in bypass mode*, which is the case that
|
|
322
|
+
matters — deny beats bypass, so the block holds even there.
|
|
323
|
+
|
|
324
|
+
It is a guardrail, not a sandbox, and the boundary is worth being precise about. `Write`
|
|
325
|
+
and `Edit` are blocked; **`Bash` is not**, so a shell redirect reaches the same file.
|
|
326
|
+
Closing that properly needs a path guard below the tool layer rather than more patterns.
|
|
327
|
+
In `default` mode Bash asks a human first, so this only bites under `bypass` — which is
|
|
328
|
+
already the mode that means "I accept the consequences."
|
|
311
329
|
|
|
312
330
|
### Context compaction
|
|
313
331
|
|
|
@@ -822,11 +840,34 @@ and through session JSONL, so a `--resume`d conversation does not hit the same w
|
|
|
822
840
|
check, and anyone who pointed 0.1.2 at Gemini got one working tool call and then a
|
|
823
841
|
400. Fixed in 0.1.3.
|
|
824
842
|
|
|
825
|
-
|
|
843
|
+
### `NoActiveWorker`: 283 green tests and a crash on the first keystroke
|
|
844
|
+
|
|
845
|
+
The config UI shipped with tests covering `/config` — they asserted that dispatching it
|
|
846
|
+
returned `open_screen="config"`. All 283 passed. Typing `/config` in the actual TUI
|
|
847
|
+
raised `NoActiveWorker: push_screen must be run from a worker`.
|
|
848
|
+
|
|
849
|
+
Textual's `push_screen_wait` blocks the calling coroutine until the screen is dismissed,
|
|
850
|
+
so it must run inside a worker; called from the message pump it would deadlock the pump
|
|
851
|
+
it is waiting on, and Textual refuses rather than hanging. `_handle_command` runs on the
|
|
852
|
+
pump. So does Textual's action dispatch, which meant the same defect existed at four
|
|
853
|
+
call sites, not the one in the traceback — including the provider form reachable from
|
|
854
|
+
`tl config --edit`, a path the crash report never touched.
|
|
855
|
+
|
|
856
|
+
The correct pattern was eleven lines away in the same file: `_permission_worker` is
|
|
857
|
+
`@work`-decorated, which is exactly why *its* `push_screen_wait` works.
|
|
858
|
+
|
|
859
|
+
Two things this cost, both worth stating. The tests verified the layer *below* the bug —
|
|
860
|
+
that the command produced the right instruction, never that the app could carry it out.
|
|
861
|
+
And **0.1.4 shipped this way**, so `pip install turnloop==0.1.4` crashes on `/config`.
|
|
862
|
+
Fixed in 0.1.5. The tests now drive a real app through Textual's `run_test()` pilot and
|
|
863
|
+
fail if the decorators are removed.
|
|
864
|
+
|
|
865
|
+
**The lesson, five times over in one project:** an 84-run sweep failing while printing
|
|
826
866
|
a tidy report of zeros; a tool returning empty with `ok: true`; a sweep sitting at
|
|
827
867
|
10/18 doing nothing for 25 minutes; an adapter passing its live test and failing on
|
|
828
|
-
the turn the test never made
|
|
829
|
-
|
|
868
|
+
the turn the test never made; a feature with green tests that crashed on its first
|
|
869
|
+
keystroke. Each was caught only by looking past the summary line — and the last two
|
|
870
|
+
only by using the thing instead of testing it.
|
|
830
871
|
|
|
831
872
|
---
|
|
832
873
|
|
|
@@ -860,7 +901,7 @@ one-line edit in a CRLF checkout does not produce a whole-file diff.
|
|
|
860
901
|
## Testing
|
|
861
902
|
|
|
862
903
|
```bash
|
|
863
|
-
pytest #
|
|
904
|
+
pytest # 290 tests, no network
|
|
864
905
|
ruff check turnloop
|
|
865
906
|
mypy turnloop
|
|
866
907
|
```
|
|
@@ -872,12 +913,14 @@ mypy turnloop
|
|
|
872
913
|
| `test_providers.py` | 35 | adapters vs recorded `.sse`, image blocks, thought signatures |
|
|
873
914
|
| `test_tools_files.py` | 28 | Read/Write/Edit/Glob/Grep, encodings, newlines, image detection |
|
|
874
915
|
| `test_loop.py` | 20 | streaming, truncation, iteration cap |
|
|
875
|
-
| `test_hooks_mcp_commands.py` |
|
|
916
|
+
| `test_hooks_mcp_commands.py` | 23 | lifecycle hooks, MCP client, slash commands |
|
|
876
917
|
| `test_config.py` | 21 | layering, env overrides, presets, narrow-console guard |
|
|
877
918
|
| `test_compaction.py` | 16 | three tiers, tool_use/tool_result invariant |
|
|
878
919
|
| `test_bash.py` | 14 | shell selection, process-tree kill |
|
|
920
|
+
| `test_configio.py` | 15 | minimal-diff writes, atomic save, secret-bearing MCP targets, the settings deny rules |
|
|
921
|
+
| `test_config_screen.py` | 10 | provider form validation, caps derivation, env indicator leaks |
|
|
879
922
|
| `test_websearch.py` | 9 | HTML parsing, backend selection, truncation, failure modes |
|
|
880
|
-
| `test_tui.py` |
|
|
923
|
+
| `test_tui.py` | 15 | Textual snapshots, `/config` and `/mcp add` driven through a real app |
|
|
881
924
|
|
|
882
925
|
`conftest.py` monkeypatches httpx's transport to raise unless a test is marked
|
|
883
926
|
`live` — **network is off at the transport layer**, so a forgotten real call fails
|
|
@@ -928,6 +971,87 @@ Shell expansion inside a command template (`` !`cmd` ``) goes through the permis
|
|
|
928
971
|
engine like any other Bash call. A markdown file in a repository is not trusted input
|
|
929
972
|
just because it is on disk.
|
|
930
973
|
|
|
974
|
+
### Editing configuration
|
|
975
|
+
|
|
976
|
+
`tl config --edit` edits the provider table, permission mode, tool verbosity, iteration
|
|
977
|
+
cap, search backend, memory, and the three rule lists. `tl mcp` manages MCP servers. The
|
|
978
|
+
same screens open inside a running session as `/config` and `/mcp add`.
|
|
979
|
+
|
|
980
|
+
**Adding a provider** is a form for `kind`, `model`, `base_url`, and `api_key_env`, with
|
|
981
|
+
a live ✓/✗ showing whether that environment variable is currently set. Capabilities are
|
|
982
|
+
re-derived from `preset_for(model)` on every save, mirroring what `load_settings` does
|
|
983
|
+
for `--model` — without that, a provider added through the UI keeps the previous model's
|
|
984
|
+
context window and pricing, and `doctor`'s context-budget line quietly lies. An
|
|
985
|
+
`openai_compat` provider without a `base_url` is refused by the form, because
|
|
986
|
+
`load_settings` would otherwise raise on the *next* launch, turning a typo into a CLI
|
|
987
|
+
that will not start.
|
|
988
|
+
|
|
989
|
+
Four further properties are structural rather than cosmetic:
|
|
990
|
+
|
|
991
|
+
**Only the diff is written.** Neither screen dumps the settings model. It writes the
|
|
992
|
+
difference against the packaged defaults, merged into whatever the file already holds.
|
|
993
|
+
Round-tripping the full model would freeze today's deny list and provider table into
|
|
994
|
+
your file permanently, so a future security default could never reach you.
|
|
995
|
+
|
|
996
|
+
**Writes are atomic and validated.** The edited settings round-trip through
|
|
997
|
+
`Settings.model_validate` before anything touches disk, then land via a temp file and
|
|
998
|
+
`os.replace`. A malformed settings file makes `load_settings` raise and the CLI exit 2 —
|
|
999
|
+
failing closed is right, but only if we never cause it ourselves.
|
|
1000
|
+
|
|
1001
|
+
**Secrets are named, never stored.** Provider credentials are edited as the env var
|
|
1002
|
+
*name*, and the written config has an `api_key_env` key and no `api_key` value at all —
|
|
1003
|
+
a config UI with a "paste your key here" box writes secrets into a JSON file that gets
|
|
1004
|
+
committed, which is how keys leak. The ✓/✗ indicator reports the name and a boolean,
|
|
1005
|
+
never the value, a prefix, or a length. MCP `env` values are literal by protocol necessity, so a server carrying any is
|
|
1006
|
+
forced to `settings.local.json` and refused a shared file, and the screen creates
|
|
1007
|
+
`.turnloop/.gitignore` before writing — otherwise a secret-bearing file can be committed
|
|
1008
|
+
before the first session ever runs.
|
|
1009
|
+
|
|
1010
|
+
**No agent-reachable route writes settings.** No tool and no hook mutates a settings
|
|
1011
|
+
file — which is what makes the deny rules above meaningful instead of decorative.
|
|
1012
|
+
`/config` and `/mcp add` are slash commands, and that is safe for a specific reason worth
|
|
1013
|
+
stating: `dispatch_command` has exactly one caller, reached from the prompt Input's
|
|
1014
|
+
`on_input_submitted`. The model emits tool calls, not typed input, so it cannot reach
|
|
1015
|
+
that path. The property lives in the dispatch path rather than in the commands, so a
|
|
1016
|
+
future refactor that routes model output through `dispatch_command` would break it
|
|
1017
|
+
silently — there is a comment there saying so.
|
|
1018
|
+
|
|
1019
|
+
**In-session saves reload only what is safe to reload.** `permission_mode`,
|
|
1020
|
+
`permissions`, `max_iterations` and `tool_verbosity` are read from the live `Settings`
|
|
1021
|
+
and `PermissionEngine`, so they take effect immediately. `provider`, `providers`,
|
|
1022
|
+
`search` and `include_memory` are baked into objects built once at agent construction,
|
|
1023
|
+
so the confirmation names them as needing a restart. Reporting "saved" while the old
|
|
1024
|
+
permission mode is still being enforced would be worse than saying nothing.
|
|
1025
|
+
|
|
1026
|
+
One honest gap: removing a server deletes it from the one file you chose. `deep_merge`
|
|
1027
|
+
has no tombstone, so a server also defined in a higher layer stays effective. The screen
|
|
1028
|
+
says so rather than pretending otherwise.
|
|
1029
|
+
|
|
1030
|
+
### MCP servers are a trust decision
|
|
1031
|
+
|
|
1032
|
+
Adding an MCP server is the same class of decision as `npm install`, and turnloop cannot
|
|
1033
|
+
check it for you. Two specific reasons, both inherent to the protocol rather than to this
|
|
1034
|
+
implementation:
|
|
1035
|
+
|
|
1036
|
+
**A stdio server is a program you agreed to run.** `command` and `args` are spawned as a
|
|
1037
|
+
child process at every launch, with your full user privileges and any `env` you set. The
|
|
1038
|
+
add form states this and requires an explicit confirmation, because a text field labelled
|
|
1039
|
+
"command" does not otherwise read like "execute this forever."
|
|
1040
|
+
|
|
1041
|
+
**Tool descriptions are untrusted text that enters the model's context.** A server
|
|
1042
|
+
advertises its own names and descriptions, and those go into the system prompt verbatim.
|
|
1043
|
+
A hostile or compromised server can put instructions there. Nothing in the protocol
|
|
1044
|
+
authenticates that text.
|
|
1045
|
+
|
|
1046
|
+
What turnloop does about it, which is containment rather than prevention: MCP tools are
|
|
1047
|
+
`read_only = False` unconditionally (`mcp/adapter.py`), because the protocol has no
|
|
1048
|
+
read-only annotation worth trusting — so they always prompt in `default` mode and are
|
|
1049
|
+
refused outright in `plan` mode. A dead or misbehaving server degrades to "that tool is
|
|
1050
|
+
unavailable" and never blocks the loop. Connections are lazy and every call is bounded.
|
|
1051
|
+
|
|
1052
|
+
None of that helps if you install a server that does exactly what it says. Read what you
|
|
1053
|
+
add.
|
|
1054
|
+
|
|
931
1055
|
---
|
|
932
1056
|
|
|
933
1057
|
## Limitations
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "turnloop"
|
|
7
|
-
version = "0.1.
|
|
7
|
+
version = "0.1.5"
|
|
8
8
|
description = "A local-first agentic coding harness: streaming tool loop, permission gating, context compaction, subagents, and any LLM provider."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.11"
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""Provider-editor pure logic (config_screen.py), tested without spinning up Textual.
|
|
2
|
+
|
|
3
|
+
`_save()` on `ProviderFormModal` is a thin wrapper around these functions —
|
|
4
|
+
testing them directly is cheaper than driving a pilot and covers exactly the
|
|
5
|
+
validation and caps-derivation rules that matter.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import pytest
|
|
11
|
+
|
|
12
|
+
from turnloop.config import load_settings
|
|
13
|
+
from turnloop.errors import ConfigError
|
|
14
|
+
from turnloop.providers.pricing import preset_for
|
|
15
|
+
from turnloop.tui.config_screen import (
|
|
16
|
+
build_provider_dict,
|
|
17
|
+
env_status_text,
|
|
18
|
+
validate_provider_fields,
|
|
19
|
+
validate_provider_name,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def test_env_status_reports_set_and_unset_without_leaking_the_value(monkeypatch):
|
|
24
|
+
monkeypatch.setenv("TL_TEST_PRESENT_KEY", "super-secret-value")
|
|
25
|
+
monkeypatch.delenv("TL_TEST_ABSENT_KEY", raising=False)
|
|
26
|
+
|
|
27
|
+
present = env_status_text("TL_TEST_PRESENT_KEY")
|
|
28
|
+
absent = env_status_text("TL_TEST_ABSENT_KEY")
|
|
29
|
+
|
|
30
|
+
assert "set" in present and "not set" not in present
|
|
31
|
+
assert "not set" in absent
|
|
32
|
+
assert "super-secret-value" not in present
|
|
33
|
+
assert "super-secret-value" not in repr((present, absent))
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def test_env_status_blank_name_is_blank():
|
|
37
|
+
assert env_status_text("") == ""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def test_validate_provider_name_rejects_blank_and_non_identifier():
|
|
41
|
+
assert validate_provider_name("", set(), is_new=True) is not None
|
|
42
|
+
assert validate_provider_name("has spaces", set(), is_new=True) is not None
|
|
43
|
+
assert validate_provider_name("fine_name", set(), is_new=True) is None
|
|
44
|
+
assert validate_provider_name("kebab-ok", set(), is_new=True) is None
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def test_validate_provider_name_refuses_a_collision_only_when_new():
|
|
48
|
+
assert validate_provider_name("nvidia", {"nvidia"}, is_new=True) is not None
|
|
49
|
+
assert validate_provider_name("nvidia", {"nvidia"}, is_new=False) is None
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def test_openai_compat_without_base_url_is_refused_by_the_form():
|
|
53
|
+
"""The form check exists precisely because `load_settings` raises for the same
|
|
54
|
+
condition — this documents that the two are testing the same failure."""
|
|
55
|
+
assert validate_provider_fields("openai_compat", "some-model", None, None) is not None
|
|
56
|
+
assert validate_provider_fields("openai_compat", "some-model", "https://x", None) is None
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def test_openai_compat_without_base_url_also_fails_at_load_settings(tmp_path):
|
|
60
|
+
"""The other half of the pair above: confirms the form isn't guarding against
|
|
61
|
+
a condition load_settings would have tolerated anyway."""
|
|
62
|
+
(tmp_path / ".turnloop").mkdir()
|
|
63
|
+
(tmp_path / ".turnloop" / "settings.local.json").write_text(
|
|
64
|
+
'{"providers": {"broken": {"kind": "openai_compat", "model": "x"}}}',
|
|
65
|
+
encoding="utf-8",
|
|
66
|
+
)
|
|
67
|
+
with pytest.raises(ConfigError, match="base_url"):
|
|
68
|
+
load_settings(tmp_path)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def test_validate_provider_fields_requires_a_model():
|
|
72
|
+
assert validate_provider_fields("mock", "", None, None) is not None
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def test_validate_provider_fields_rejects_a_bad_api_key_env_name():
|
|
76
|
+
assert validate_provider_fields("mock", "m", None, "not a valid name") is not None
|
|
77
|
+
assert validate_provider_fields("mock", "m", None, "GOOD_NAME") is None
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def test_build_provider_dict_derives_caps_from_the_new_model_not_the_old_one():
|
|
81
|
+
"""The whole point of gap 1's caps requirement: a provider that switches
|
|
82
|
+
models must not keep the previous model's context window and pricing."""
|
|
83
|
+
old = {
|
|
84
|
+
"kind": "openai_compat", "model": "gpt-4o-mini", "base_url": "https://old",
|
|
85
|
+
"caps": preset_for("gpt-4o-mini").model_dump(mode="json"),
|
|
86
|
+
}
|
|
87
|
+
updated = build_provider_dict(
|
|
88
|
+
old, kind="anthropic", model="claude-opus-4-5", base_url=None,
|
|
89
|
+
api_key_env="ANTHROPIC_API_KEY", tool_verbosity=None,
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
assert updated["caps"] == preset_for("claude-opus-4-5").model_dump(mode="json")
|
|
93
|
+
assert updated["caps"] != old["caps"]
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def test_build_provider_dict_falls_back_sanely_for_an_unrecognized_model():
|
|
97
|
+
caps = preset_for("some-model-nobody-has-heard-of")
|
|
98
|
+
assert caps.max_context == 128_000
|
|
99
|
+
assert caps.max_output == 8_192
|
|
100
|
+
assert caps.max_context > 0 # never a silent zero-context provider
|