scootcli 0.14.1__tar.gz → 0.14.2__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.
- {scootcli-0.14.1/src/scootcli.egg-info → scootcli-0.14.2}/PKG-INFO +1 -1
- {scootcli-0.14.1 → scootcli-0.14.2}/src/scootcli/__init__.py +1 -1
- {scootcli-0.14.1 → scootcli-0.14.2}/src/scootcli/providers/base.py +35 -0
- {scootcli-0.14.1 → scootcli-0.14.2}/src/scootcli/providers/openai_chat.py +8 -2
- {scootcli-0.14.1 → scootcli-0.14.2}/src/scootcli/providers/openai_responses.py +4 -3
- {scootcli-0.14.1 → scootcli-0.14.2/src/scootcli.egg-info}/PKG-INFO +1 -1
- {scootcli-0.14.1 → scootcli-0.14.2}/tests/test_providers.py +49 -0
- {scootcli-0.14.1 → scootcli-0.14.2}/LICENSE +0 -0
- {scootcli-0.14.1 → scootcli-0.14.2}/README.md +0 -0
- {scootcli-0.14.1 → scootcli-0.14.2}/pyproject.toml +0 -0
- {scootcli-0.14.1 → scootcli-0.14.2}/setup.cfg +0 -0
- {scootcli-0.14.1 → scootcli-0.14.2}/src/scootcli/__main__.py +0 -0
- {scootcli-0.14.1 → scootcli-0.14.2}/src/scootcli/activity.py +0 -0
- {scootcli-0.14.1 → scootcli-0.14.2}/src/scootcli/agent.py +0 -0
- {scootcli-0.14.1 → scootcli-0.14.2}/src/scootcli/approvals.py +0 -0
- {scootcli-0.14.1 → scootcli-0.14.2}/src/scootcli/auth.py +0 -0
- {scootcli-0.14.1 → scootcli-0.14.2}/src/scootcli/cli.py +0 -0
- {scootcli-0.14.1 → scootcli-0.14.2}/src/scootcli/clipboard.py +0 -0
- {scootcli-0.14.1 → scootcli-0.14.2}/src/scootcli/commands/__init__.py +0 -0
- {scootcli-0.14.1 → scootcli-0.14.2}/src/scootcli/commands/approve.py +0 -0
- {scootcli-0.14.1 → scootcli-0.14.2}/src/scootcli/commands/auth.py +0 -0
- {scootcli-0.14.1 → scootcli-0.14.2}/src/scootcli/commands/base.py +0 -0
- {scootcli-0.14.1 → scootcli-0.14.2}/src/scootcli/commands/compact.py +0 -0
- {scootcli-0.14.1 → scootcli-0.14.2}/src/scootcli/commands/copy.py +0 -0
- {scootcli-0.14.1 → scootcli-0.14.2}/src/scootcli/commands/exit.py +0 -0
- {scootcli-0.14.1 → scootcli-0.14.2}/src/scootcli/commands/forget.py +0 -0
- {scootcli-0.14.1 → scootcli-0.14.2}/src/scootcli/commands/help.py +0 -0
- {scootcli-0.14.1 → scootcli-0.14.2}/src/scootcli/commands/hooks.py +0 -0
- {scootcli-0.14.1 → scootcli-0.14.2}/src/scootcli/commands/init.py +0 -0
- {scootcli-0.14.1 → scootcli-0.14.2}/src/scootcli/commands/logo.py +0 -0
- {scootcli-0.14.1 → scootcli-0.14.2}/src/scootcli/commands/model.py +0 -0
- {scootcli-0.14.1 → scootcli-0.14.2}/src/scootcli/commands/panel.py +0 -0
- {scootcli-0.14.1 → scootcli-0.14.2}/src/scootcli/commands/reset.py +0 -0
- {scootcli-0.14.1 → scootcli-0.14.2}/src/scootcli/commands/resume.py +0 -0
- {scootcli-0.14.1 → scootcli-0.14.2}/src/scootcli/commands/route.py +0 -0
- {scootcli-0.14.1 → scootcli-0.14.2}/src/scootcli/commands/save.py +0 -0
- {scootcli-0.14.1 → scootcli-0.14.2}/src/scootcli/commands/scope.py +0 -0
- {scootcli-0.14.1 → scootcli-0.14.2}/src/scootcli/commands/sessions.py +0 -0
- {scootcli-0.14.1 → scootcli-0.14.2}/src/scootcli/commands/status.py +0 -0
- {scootcli-0.14.1 → scootcli-0.14.2}/src/scootcli/commands/verbosity.py +0 -0
- {scootcli-0.14.1 → scootcli-0.14.2}/src/scootcli/commands/worktree.py +0 -0
- {scootcli-0.14.1 → scootcli-0.14.2}/src/scootcli/commands/yolo.py +0 -0
- {scootcli-0.14.1 → scootcli-0.14.2}/src/scootcli/config.py +0 -0
- {scootcli-0.14.1 → scootcli-0.14.2}/src/scootcli/context.py +0 -0
- {scootcli-0.14.1 → scootcli-0.14.2}/src/scootcli/credentials.py +0 -0
- {scootcli-0.14.1 → scootcli-0.14.2}/src/scootcli/errors.py +0 -0
- {scootcli-0.14.1 → scootcli-0.14.2}/src/scootcli/headless.py +0 -0
- {scootcli-0.14.1 → scootcli-0.14.2}/src/scootcli/hooks.py +0 -0
- {scootcli-0.14.1 → scootcli-0.14.2}/src/scootcli/images.py +0 -0
- {scootcli-0.14.1 → scootcli-0.14.2}/src/scootcli/keys.py +0 -0
- {scootcli-0.14.1 → scootcli-0.14.2}/src/scootcli/lineeditor.py +0 -0
- {scootcli-0.14.1 → scootcli-0.14.2}/src/scootcli/logo.py +0 -0
- {scootcli-0.14.1 → scootcli-0.14.2}/src/scootcli/models.py +0 -0
- {scootcli-0.14.1 → scootcli-0.14.2}/src/scootcli/panel.py +0 -0
- {scootcli-0.14.1 → scootcli-0.14.2}/src/scootcli/preferences.py +0 -0
- {scootcli-0.14.1 → scootcli-0.14.2}/src/scootcli/presets.py +0 -0
- {scootcli-0.14.1 → scootcli-0.14.2}/src/scootcli/pricing.py +0 -0
- {scootcli-0.14.1 → scootcli-0.14.2}/src/scootcli/project.py +0 -0
- {scootcli-0.14.1 → scootcli-0.14.2}/src/scootcli/prompts.py +0 -0
- {scootcli-0.14.1 → scootcli-0.14.2}/src/scootcli/providers/__init__.py +0 -0
- {scootcli-0.14.1 → scootcli-0.14.2}/src/scootcli/providers/anthropic.py +0 -0
- {scootcli-0.14.1 → scootcli-0.14.2}/src/scootcli/providers/registry.py +0 -0
- {scootcli-0.14.1 → scootcli-0.14.2}/src/scootcli/providers/router.py +0 -0
- {scootcli-0.14.1 → scootcli-0.14.2}/src/scootcli/rendering.py +0 -0
- {scootcli-0.14.1 → scootcli-0.14.2}/src/scootcli/repl.py +0 -0
- {scootcli-0.14.1 → scootcli-0.14.2}/src/scootcli/resize.py +0 -0
- {scootcli-0.14.1 → scootcli-0.14.2}/src/scootcli/sessions.py +0 -0
- {scootcli-0.14.1 → scootcli-0.14.2}/src/scootcli/status.py +0 -0
- {scootcli-0.14.1 → scootcli-0.14.2}/src/scootcli/store.py +0 -0
- {scootcli-0.14.1 → scootcli-0.14.2}/src/scootcli/tools/__init__.py +0 -0
- {scootcli-0.14.1 → scootcli-0.14.2}/src/scootcli/tools/base.py +0 -0
- {scootcli-0.14.1 → scootcli-0.14.2}/src/scootcli/tools/edit_file.py +0 -0
- {scootcli-0.14.1 → scootcli-0.14.2}/src/scootcli/tools/list_dir.py +0 -0
- {scootcli-0.14.1 → scootcli-0.14.2}/src/scootcli/tools/open_editor.py +0 -0
- {scootcli-0.14.1 → scootcli-0.14.2}/src/scootcli/tools/read_file.py +0 -0
- {scootcli-0.14.1 → scootcli-0.14.2}/src/scootcli/tools/run_shell.py +0 -0
- {scootcli-0.14.1 → scootcli-0.14.2}/src/scootcli/tools/search.py +0 -0
- {scootcli-0.14.1 → scootcli-0.14.2}/src/scootcli/tools/update_plan.py +0 -0
- {scootcli-0.14.1 → scootcli-0.14.2}/src/scootcli/tools/write_file.py +0 -0
- {scootcli-0.14.1 → scootcli-0.14.2}/src/scootcli/transport.py +0 -0
- {scootcli-0.14.1 → scootcli-0.14.2}/src/scootcli/updates.py +0 -0
- {scootcli-0.14.1 → scootcli-0.14.2}/src/scootcli/vision.py +0 -0
- {scootcli-0.14.1 → scootcli-0.14.2}/src/scootcli/workspace.py +0 -0
- {scootcli-0.14.1 → scootcli-0.14.2}/src/scootcli/worktree.py +0 -0
- {scootcli-0.14.1 → scootcli-0.14.2}/src/scootcli.egg-info/SOURCES.txt +0 -0
- {scootcli-0.14.1 → scootcli-0.14.2}/src/scootcli.egg-info/dependency_links.txt +0 -0
- {scootcli-0.14.1 → scootcli-0.14.2}/src/scootcli.egg-info/entry_points.txt +0 -0
- {scootcli-0.14.1 → scootcli-0.14.2}/src/scootcli.egg-info/top_level.txt +0 -0
- {scootcli-0.14.1 → scootcli-0.14.2}/tests/test_agent.py +0 -0
- {scootcli-0.14.1 → scootcli-0.14.2}/tests/test_anthropic.py +0 -0
- {scootcli-0.14.1 → scootcli-0.14.2}/tests/test_approvals.py +0 -0
- {scootcli-0.14.1 → scootcli-0.14.2}/tests/test_auth.py +0 -0
- {scootcli-0.14.1 → scootcli-0.14.2}/tests/test_clipboard.py +0 -0
- {scootcli-0.14.1 → scootcli-0.14.2}/tests/test_config.py +0 -0
- {scootcli-0.14.1 → scootcli-0.14.2}/tests/test_headless.py +0 -0
- {scootcli-0.14.1 → scootcli-0.14.2}/tests/test_hooks.py +0 -0
- {scootcli-0.14.1 → scootcli-0.14.2}/tests/test_images.py +0 -0
- {scootcli-0.14.1 → scootcli-0.14.2}/tests/test_lineeditor.py +0 -0
- {scootcli-0.14.1 → scootcli-0.14.2}/tests/test_logo.py +0 -0
- {scootcli-0.14.1 → scootcli-0.14.2}/tests/test_models.py +0 -0
- {scootcli-0.14.1 → scootcli-0.14.2}/tests/test_panel.py +0 -0
- {scootcli-0.14.1 → scootcli-0.14.2}/tests/test_preferences.py +0 -0
- {scootcli-0.14.1 → scootcli-0.14.2}/tests/test_readiness.py +0 -0
- {scootcli-0.14.1 → scootcli-0.14.2}/tests/test_rendering.py +0 -0
- {scootcli-0.14.1 → scootcli-0.14.2}/tests/test_repl.py +0 -0
- {scootcli-0.14.1 → scootcli-0.14.2}/tests/test_resilience.py +0 -0
- {scootcli-0.14.1 → scootcli-0.14.2}/tests/test_resize.py +0 -0
- {scootcli-0.14.1 → scootcli-0.14.2}/tests/test_router.py +0 -0
- {scootcli-0.14.1 → scootcli-0.14.2}/tests/test_scope.py +0 -0
- {scootcli-0.14.1 → scootcli-0.14.2}/tests/test_sessions.py +0 -0
- {scootcli-0.14.1 → scootcli-0.14.2}/tests/test_small_060.py +0 -0
- {scootcli-0.14.1 → scootcli-0.14.2}/tests/test_smoke.py +0 -0
- {scootcli-0.14.1 → scootcli-0.14.2}/tests/test_store.py +0 -0
- {scootcli-0.14.1 → scootcli-0.14.2}/tests/test_streaming.py +0 -0
- {scootcli-0.14.1 → scootcli-0.14.2}/tests/test_tools.py +0 -0
- {scootcli-0.14.1 → scootcli-0.14.2}/tests/test_transport_native.py +0 -0
- {scootcli-0.14.1 → scootcli-0.14.2}/tests/test_vision.py +0 -0
- {scootcli-0.14.1 → scootcli-0.14.2}/tests/test_workspace.py +0 -0
- {scootcli-0.14.1 → scootcli-0.14.2}/tests/test_worktree.py +0 -0
|
@@ -363,6 +363,41 @@ class BaseProvider:
|
|
|
363
363
|
time.sleep(0.05)
|
|
364
364
|
|
|
365
365
|
|
|
366
|
+
def valid_tool_arguments(raw) -> str:
|
|
367
|
+
"""A tool call's ``arguments`` as a JSON *object* string, safe to replay to a provider that
|
|
368
|
+
validates them. A missing, empty, or malformed value (a stream truncated mid-JSON on a length turn)
|
|
369
|
+
becomes ``"{}"``, so one bad tool call cannot poison the session on every later request. The call
|
|
370
|
+
is never dropped, so it still pairs with its matching ``tool`` result and history stays valid.
|
|
371
|
+
"""
|
|
372
|
+
if isinstance(raw, dict):
|
|
373
|
+
try:
|
|
374
|
+
return json.dumps(raw)
|
|
375
|
+
except (TypeError, ValueError):
|
|
376
|
+
return "{}"
|
|
377
|
+
if not raw or not isinstance(raw, str):
|
|
378
|
+
return "{}"
|
|
379
|
+
try:
|
|
380
|
+
parsed = json.loads(raw)
|
|
381
|
+
except (json.JSONDecodeError, ValueError):
|
|
382
|
+
return "{}"
|
|
383
|
+
return raw if isinstance(parsed, dict) else "{}"
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def sanitized_tool_calls(calls) -> list:
|
|
387
|
+
"""Copy ``calls`` with each ``function.arguments`` replaced by a valid JSON-object string."""
|
|
388
|
+
out = []
|
|
389
|
+
for c in calls or []:
|
|
390
|
+
if not isinstance(c, dict):
|
|
391
|
+
out.append(c)
|
|
392
|
+
continue
|
|
393
|
+
c = dict(c)
|
|
394
|
+
fn = dict(c.get("function") or {})
|
|
395
|
+
fn["arguments"] = valid_tool_arguments(fn.get("arguments"))
|
|
396
|
+
c["function"] = fn
|
|
397
|
+
out.append(c)
|
|
398
|
+
return out
|
|
399
|
+
|
|
400
|
+
|
|
366
401
|
def iter_sse_json(lines: Iterator[str], status_holder: dict, leftovers: List[str]) -> Iterator[dict]:
|
|
367
402
|
"""Walk raw SSE lines: yield each ``data:`` JSON object, record the status sentinel, collect
|
|
368
403
|
anything that is not SSE (an error body) into ``leftovers``."""
|
|
@@ -18,17 +18,23 @@ from .base import (
|
|
|
18
18
|
finish_stream,
|
|
19
19
|
iter_sse_json,
|
|
20
20
|
normalize_usage,
|
|
21
|
+
sanitized_tool_calls,
|
|
21
22
|
)
|
|
22
23
|
|
|
23
24
|
_PRIVATE_KEYS = ("provider_items",)
|
|
24
25
|
|
|
25
26
|
|
|
26
27
|
def strip_private(messages: List[dict]) -> List[dict]:
|
|
27
|
-
"""
|
|
28
|
+
"""Prepare messages for the wire: drop scoot-private keys the API would reject, and rewrite any
|
|
29
|
+
tool-call arguments that are not valid JSON objects (a truncated length-turn leaves a malformed
|
|
30
|
+
string that a validating provider would reject on every later request). Copies, never mutates."""
|
|
28
31
|
out = []
|
|
29
32
|
for m in messages:
|
|
30
|
-
|
|
33
|
+
calls = m.get("tool_calls")
|
|
34
|
+
if any(k in m for k in _PRIVATE_KEYS) or calls:
|
|
31
35
|
m = {k: v for k, v in m.items() if k not in _PRIVATE_KEYS}
|
|
36
|
+
if calls:
|
|
37
|
+
m["tool_calls"] = sanitized_tool_calls(calls)
|
|
32
38
|
out.append(m)
|
|
33
39
|
return out
|
|
34
40
|
|
|
@@ -31,6 +31,7 @@ from .base import (
|
|
|
31
31
|
iter_sse_json,
|
|
32
32
|
normalize_usage,
|
|
33
33
|
raise_for_status,
|
|
34
|
+
valid_tool_arguments,
|
|
34
35
|
)
|
|
35
36
|
|
|
36
37
|
# Models that accept the ``reasoning`` parameter (others reject it with a 400).
|
|
@@ -68,9 +69,9 @@ def _content_parts(content, role: str) -> object:
|
|
|
68
69
|
|
|
69
70
|
|
|
70
71
|
def _arguments(raw) -> str:
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
return
|
|
72
|
+
# A valid JSON-object string, so a truncated/malformed tool call replayed to the Responses API
|
|
73
|
+
# cannot get the session stuck (mirrors the Chat wire and Anthropic's dict fallback).
|
|
74
|
+
return valid_tool_arguments(raw)
|
|
74
75
|
|
|
75
76
|
|
|
76
77
|
def translate_messages(messages: List[dict], qualified_model: str) -> Tuple[str, List[dict]]:
|
|
@@ -256,3 +256,52 @@ def test_cli_models_caps_per_provider_unless_filtered(capsys):
|
|
|
256
256
|
_cmd_models(_Pool(), as_json=False, provider="openai")
|
|
257
257
|
out = capsys.readouterr().out
|
|
258
258
|
assert "more ·" not in out # a single --provider shows all
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
# ── outbound tool-argument sanitizing: a malformed/truncated call cannot stick a session ──
|
|
262
|
+
def test_valid_tool_arguments_normalizes_to_a_json_object():
|
|
263
|
+
import json as _json
|
|
264
|
+
from scootcli.providers.base import valid_tool_arguments
|
|
265
|
+
|
|
266
|
+
assert valid_tool_arguments('{"path": "a.txt"}') == '{"path": "a.txt"}' # valid: unchanged
|
|
267
|
+
assert valid_tool_arguments("") == "{}" # empty
|
|
268
|
+
assert valid_tool_arguments(None) == "{}"
|
|
269
|
+
assert valid_tool_arguments('{"path": "a') == "{}" # truncated mid-JSON
|
|
270
|
+
assert valid_tool_arguments("[1, 2]") == "{}" # JSON but not an object
|
|
271
|
+
assert _json.loads(valid_tool_arguments({"x": 1})) == {"x": 1} # dict -> json string
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def test_chat_wire_sanitizes_tool_args_and_keeps_pairing():
|
|
275
|
+
import json as _json
|
|
276
|
+
from scootcli.providers.openai_chat import strip_private
|
|
277
|
+
|
|
278
|
+
def call(cid, args):
|
|
279
|
+
return {"id": cid, "type": "function", "function": {"name": "write_file", "arguments": args}}
|
|
280
|
+
|
|
281
|
+
messages = [
|
|
282
|
+
{"role": "user", "content": "go"},
|
|
283
|
+
{"role": "assistant", "content": "", "tool_calls": [
|
|
284
|
+
call("c1", '{"ok": true}'), call("c2", ""), call("c3", '{"path": "x')]},
|
|
285
|
+
{"role": "tool", "tool_call_id": "c1", "content": "ok"},
|
|
286
|
+
{"role": "tool", "tool_call_id": "c2", "content": "ok"},
|
|
287
|
+
{"role": "tool", "tool_call_id": "c3", "content": "ok"},
|
|
288
|
+
]
|
|
289
|
+
out = strip_private(messages)
|
|
290
|
+
calls = out[1]["tool_calls"]
|
|
291
|
+
assert len(calls) == 3 # no call dropped
|
|
292
|
+
assert [c["id"] for c in calls] == ["c1", "c2", "c3"]
|
|
293
|
+
for c in calls:
|
|
294
|
+
_json.loads(c["function"]["arguments"]) # every one is now valid JSON
|
|
295
|
+
assert [m.get("tool_call_id") for m in out if m["role"] == "tool"] == ["c1", "c2", "c3"] # pairing kept
|
|
296
|
+
assert messages[1]["tool_calls"][1]["function"]["arguments"] == "" # original history untouched (copied)
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def test_responses_wire_sanitizes_reconstructed_tool_args():
|
|
300
|
+
import json as _json
|
|
301
|
+
from scootcli.providers.openai_responses import translate_messages
|
|
302
|
+
|
|
303
|
+
messages = [{"role": "assistant", "content": "", "tool_calls": [
|
|
304
|
+
{"id": "c1", "type": "function", "function": {"name": "read_file", "arguments": '{"path": "a'}}]}]
|
|
305
|
+
_instr, items = translate_messages(messages, "openai/gpt-5.3-codex")
|
|
306
|
+
fc = [it for it in items if it.get("type") == "function_call"]
|
|
307
|
+
assert len(fc) == 1 and _json.loads(fc[0]["arguments"]) == {} # truncated args replayed as {}
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|