kiwi-code 0.0.41__tar.gz → 0.0.43__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.
- {kiwi_code-0.0.41 → kiwi_code-0.0.43}/PKG-INFO +1 -1
- {kiwi_code-0.0.41 → kiwi_code-0.0.43}/pyproject.toml +1 -1
- {kiwi_code-0.0.41 → kiwi_code-0.0.43}/src/kiwi_tui/main.py +54 -4
- {kiwi_code-0.0.41 → kiwi_code-0.0.43}/src/kiwi_tui/screens/dashboard.py +240 -62
- {kiwi_code-0.0.41 → kiwi_code-0.0.43}/tests/test_tui_headless.py +253 -27
- {kiwi_code-0.0.41 → kiwi_code-0.0.43}/uv.lock +1 -1
- {kiwi_code-0.0.41 → kiwi_code-0.0.43}/.github/workflows/publish.yml +0 -0
- {kiwi_code-0.0.41 → kiwi_code-0.0.43}/.github/workflows/test.yml +0 -0
- {kiwi_code-0.0.41 → kiwi_code-0.0.43}/.gitignore +0 -0
- {kiwi_code-0.0.41 → kiwi_code-0.0.43}/.python-version +0 -0
- {kiwi_code-0.0.41 → kiwi_code-0.0.43}/CLAUDE.md +0 -0
- {kiwi_code-0.0.41 → kiwi_code-0.0.43}/Makefile +0 -0
- {kiwi_code-0.0.41 → kiwi_code-0.0.43}/README.md +0 -0
- {kiwi_code-0.0.41 → kiwi_code-0.0.43}/src/kiwi_cli/__init__.py +0 -0
- {kiwi_code-0.0.41 → kiwi_code-0.0.43}/src/kiwi_cli/auth.py +0 -0
- {kiwi_code-0.0.41 → kiwi_code-0.0.43}/src/kiwi_cli/cli.py +0 -0
- {kiwi_code-0.0.41 → kiwi_code-0.0.43}/src/kiwi_cli/client.py +0 -0
- {kiwi_code-0.0.41 → kiwi_code-0.0.43}/src/kiwi_cli/commands.py +0 -0
- {kiwi_code-0.0.41 → kiwi_code-0.0.43}/src/kiwi_cli/logger.py +0 -0
- {kiwi_code-0.0.41 → kiwi_code-0.0.43}/src/kiwi_cli/models.py +0 -0
- {kiwi_code-0.0.41 → kiwi_code-0.0.43}/src/kiwi_cli/runtime_manager.py +0 -0
- {kiwi_code-0.0.41 → kiwi_code-0.0.43}/src/kiwi_cli/server.py +0 -0
- {kiwi_code-0.0.41 → kiwi_code-0.0.43}/src/kiwi_cli/terminal_mode.py +0 -0
- {kiwi_code-0.0.41 → kiwi_code-0.0.43}/src/kiwi_runtime/__init__.py +0 -0
- {kiwi_code-0.0.41 → kiwi_code-0.0.43}/src/kiwi_runtime/__main__.py +0 -0
- {kiwi_code-0.0.41 → kiwi_code-0.0.43}/src/kiwi_runtime/main.py +0 -0
- {kiwi_code-0.0.41 → kiwi_code-0.0.43}/src/kiwi_runtime/snake_game/.gitignore +0 -0
- {kiwi_code-0.0.41 → kiwi_code-0.0.43}/src/kiwi_runtime/snake_game/requirements.txt +0 -0
- {kiwi_code-0.0.41 → kiwi_code-0.0.43}/src/kiwi_tui/__init__.py +0 -0
- {kiwi_code-0.0.41 → kiwi_code-0.0.43}/src/kiwi_tui/inline_file_picker.py +0 -0
- {kiwi_code-0.0.41 → kiwi_code-0.0.43}/src/kiwi_tui/random_words.py +0 -0
- {kiwi_code-0.0.41 → kiwi_code-0.0.43}/src/kiwi_tui/runtime_agent.py +0 -0
- {kiwi_code-0.0.41 → kiwi_code-0.0.43}/src/kiwi_tui/screens/__init__.py +0 -0
- {kiwi_code-0.0.41 → kiwi_code-0.0.43}/src/kiwi_tui/screens/attach_content.py +0 -0
- {kiwi_code-0.0.41 → kiwi_code-0.0.43}/src/kiwi_tui/screens/command_result.py +0 -0
- {kiwi_code-0.0.41 → kiwi_code-0.0.43}/src/kiwi_tui/screens/file_browser.py +0 -0
- {kiwi_code-0.0.41 → kiwi_code-0.0.43}/src/kiwi_tui/screens/help.py +0 -0
- {kiwi_code-0.0.41 → kiwi_code-0.0.43}/src/kiwi_tui/screens/id_picker.py +0 -0
- {kiwi_code-0.0.41 → kiwi_code-0.0.43}/src/kiwi_tui/screens/login.py +0 -0
- {kiwi_code-0.0.41 → kiwi_code-0.0.43}/src/kiwi_tui/screens/runtime_cleanup.py +0 -0
- {kiwi_code-0.0.41 → kiwi_code-0.0.43}/src/kiwi_tui/screens/runtime_logs.py +0 -0
- {kiwi_code-0.0.41 → kiwi_code-0.0.43}/src/kiwi_tui/screens/slash_picker.py +0 -0
- {kiwi_code-0.0.41 → kiwi_code-0.0.43}/src/kiwi_tui/slash_commands.py +0 -0
- {kiwi_code-0.0.41 → kiwi_code-0.0.43}/src/kiwi_tui/status_words.py +0 -0
- {kiwi_code-0.0.41 → kiwi_code-0.0.43}/src/kiwi_tui/widgets.py +0 -0
- {kiwi_code-0.0.41 → kiwi_code-0.0.43}/test_hello.py +0 -0
- {kiwi_code-0.0.41 → kiwi_code-0.0.43}/tests/__init__.py +0 -0
- {kiwi_code-0.0.41 → kiwi_code-0.0.43}/tests/conftest.py +0 -0
- {kiwi_code-0.0.41 → kiwi_code-0.0.43}/tests/test_cli_help.py +0 -0
- {kiwi_code-0.0.41 → kiwi_code-0.0.43}/tests/test_imports.py +0 -0
- {kiwi_code-0.0.41 → kiwi_code-0.0.43}/tests/test_reexec_kiwi.py +0 -0
- {kiwi_code-0.0.41 → kiwi_code-0.0.43}/tests/test_runtime_log_trimming.py +0 -0
- {kiwi_code-0.0.41 → kiwi_code-0.0.43}/tests/test_slash_commands.py +0 -0
- {kiwi_code-0.0.41 → kiwi_code-0.0.43}/tests/test_terminal_mode.py +0 -0
- {kiwi_code-0.0.41 → kiwi_code-0.0.43}/tests/test_tokens.py +0 -0
- {kiwi_code-0.0.41 → kiwi_code-0.0.43}/tests/test_tui_interactive_runtime.py +0 -0
- {kiwi_code-0.0.41 → kiwi_code-0.0.43}/tests/test_tui_palette.py +0 -0
|
@@ -610,11 +610,12 @@ class AutobotsTUI(App):
|
|
|
610
610
|
# runtime_manager.save_refresh_token(tokens.refresh_token)
|
|
611
611
|
|
|
612
612
|
def _refresh_token_if_needed(self, force: bool = False) -> bool:
|
|
613
|
-
"""Synchronize auth state from disk and refresh
|
|
613
|
+
"""Synchronize auth state from disk and refresh when needed.
|
|
614
614
|
|
|
615
615
|
Args:
|
|
616
|
-
force: If True,
|
|
617
|
-
the
|
|
616
|
+
force: If True, attempt a refresh-token exchange immediately even if
|
|
617
|
+
the current access token does not appear expired on disk. This is
|
|
618
|
+
used after an HTTP 401/403 from the server.
|
|
618
619
|
"""
|
|
619
620
|
with self.token_manager.file_lock():
|
|
620
621
|
tokens = self.token_manager.load_tokens()
|
|
@@ -622,7 +623,7 @@ class AutobotsTUI(App):
|
|
|
622
623
|
logger.debug("No tokens to refresh")
|
|
623
624
|
return False
|
|
624
625
|
|
|
625
|
-
if not tokens.is_expired():
|
|
626
|
+
if not force and not tokens.is_expired():
|
|
626
627
|
logger.debug("Token on disk is valid; synchronizing in-memory auth state")
|
|
627
628
|
if getattr(tokens, "access_token", None):
|
|
628
629
|
self.autobots_client.update_token(tokens.access_token)
|
|
@@ -683,6 +684,55 @@ class AutobotsTUI(App):
|
|
|
683
684
|
except Exception:
|
|
684
685
|
pass
|
|
685
686
|
|
|
687
|
+
def _handle_auth_expired(self, reason: str | None = None) -> None:
|
|
688
|
+
"""Clear auth state and return the user to the login screen.
|
|
689
|
+
|
|
690
|
+
Used when the server rejects the current session (401/403) and a forced
|
|
691
|
+
token refresh cannot recover it.
|
|
692
|
+
"""
|
|
693
|
+
logger.warning(f"Authentication expired; redirecting to login. Reason: {reason or 'unknown'}")
|
|
694
|
+
|
|
695
|
+
try:
|
|
696
|
+
screen = self.screen
|
|
697
|
+
if hasattr(screen, "reset_for_auth_expired"):
|
|
698
|
+
screen.reset_for_auth_expired()
|
|
699
|
+
except Exception:
|
|
700
|
+
pass
|
|
701
|
+
|
|
702
|
+
timer = getattr(self, "_token_refresh_timer", None)
|
|
703
|
+
if timer is not None:
|
|
704
|
+
try:
|
|
705
|
+
timer.stop()
|
|
706
|
+
except Exception:
|
|
707
|
+
pass
|
|
708
|
+
self._token_refresh_timer = None
|
|
709
|
+
|
|
710
|
+
self.pending_runtime_id = None
|
|
711
|
+
self.token_manager.clear_tokens()
|
|
712
|
+
self._kiwi_token = None
|
|
713
|
+
|
|
714
|
+
self.autobots_client = AutobotsClientWrapper(
|
|
715
|
+
base_url=self.config.backend_url,
|
|
716
|
+
api_key=self.config.api_key,
|
|
717
|
+
)
|
|
718
|
+
|
|
719
|
+
try:
|
|
720
|
+
already_on_login = type(self.screen).__name__ == "LoginScreen"
|
|
721
|
+
except Exception:
|
|
722
|
+
already_on_login = False
|
|
723
|
+
|
|
724
|
+
if not already_on_login:
|
|
725
|
+
try:
|
|
726
|
+
self.switch_screen("login")
|
|
727
|
+
except IndexError:
|
|
728
|
+
self.push_screen("login")
|
|
729
|
+
|
|
730
|
+
self.notify(
|
|
731
|
+
reason or "Your session expired. Please log in again.",
|
|
732
|
+
severity="error",
|
|
733
|
+
title="Authentication required",
|
|
734
|
+
)
|
|
735
|
+
|
|
686
736
|
def action_logout(self) -> None:
|
|
687
737
|
"""Logout user and return to login screen."""
|
|
688
738
|
logger.info("User logout requested")
|
|
@@ -138,6 +138,7 @@ class DashboardScreen(Screen):
|
|
|
138
138
|
"69c2180355a89324a9926bc6",
|
|
139
139
|
"6a0625b8ab5f80ba8ac5d012",
|
|
140
140
|
"69e4be0d70c2d7a89197e66e",
|
|
141
|
+
"6a1c0773c519da8a0fa9b8a4",
|
|
141
142
|
]
|
|
142
143
|
|
|
143
144
|
BINDINGS = [
|
|
@@ -600,6 +601,27 @@ class DashboardScreen(Screen):
|
|
|
600
601
|
action_w.update(f"Action: {action_label}")
|
|
601
602
|
run_w.update(f"Run: {run_id}")
|
|
602
603
|
|
|
604
|
+
|
|
605
|
+
def _refresh_auth_or_redirect(self, code: int | None, *, context: str) -> bool:
|
|
606
|
+
"""Try one forced auth refresh after a 401/403.
|
|
607
|
+
|
|
608
|
+
Returns True only when a forced refresh succeeded and the caller should
|
|
609
|
+
retry the original request once. If refresh itself fails, auth is already
|
|
610
|
+
unrecoverable for this request and the user is redirected to login.
|
|
611
|
+
"""
|
|
612
|
+
if code not in (401, 403):
|
|
613
|
+
return False
|
|
614
|
+
|
|
615
|
+
try:
|
|
616
|
+
refreshed = bool(self.app._refresh_token_if_needed(force=True))
|
|
617
|
+
except Exception:
|
|
618
|
+
refreshed = False
|
|
619
|
+
|
|
620
|
+
if not refreshed:
|
|
621
|
+
self.app._handle_auth_expired(
|
|
622
|
+
f"Authentication expired while {context} (HTTP {code}). Please log in again."
|
|
623
|
+
)
|
|
624
|
+
return refreshed
|
|
603
625
|
async def _refresh_current_action_name_async(self) -> None:
|
|
604
626
|
"""Best-effort fetch of the current action name for the status bar."""
|
|
605
627
|
from autobots_client.api.actions import get_action_v1_actions_id_get
|
|
@@ -614,13 +636,18 @@ class DashboardScreen(Screen):
|
|
|
614
636
|
|
|
615
637
|
resp = await asyncio.to_thread(lambda: _fetch(api_client))
|
|
616
638
|
if resp.status_code in (401, 403):
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
639
|
+
refreshed = self._refresh_auth_or_redirect(
|
|
640
|
+
resp.status_code,
|
|
641
|
+
context="refreshing the current action name",
|
|
642
|
+
)
|
|
621
643
|
if refreshed:
|
|
622
644
|
api_client = getattr(getattr(self.app, "autobots_client", None), "client", api_client)
|
|
623
645
|
resp = await asyncio.to_thread(lambda: _fetch(api_client))
|
|
646
|
+
if resp.status_code in (401, 403):
|
|
647
|
+
self.app._handle_auth_expired(
|
|
648
|
+
f"Authentication expired while refreshing the current action name (HTTP {resp.status_code}). Please log in again."
|
|
649
|
+
)
|
|
650
|
+
return
|
|
624
651
|
|
|
625
652
|
if requested_action_id != self.current_action_id:
|
|
626
653
|
return
|
|
@@ -1261,14 +1288,27 @@ class DashboardScreen(Screen):
|
|
|
1261
1288
|
except Exception:
|
|
1262
1289
|
code = None
|
|
1263
1290
|
if code in (401, 403):
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1291
|
+
refreshed = self._refresh_auth_or_redirect(
|
|
1292
|
+
code,
|
|
1293
|
+
context="loading conversation history",
|
|
1294
|
+
)
|
|
1268
1295
|
if refreshed:
|
|
1269
1296
|
success, result, msg = await asyncio.to_thread(
|
|
1270
1297
|
lambda: self.app.autobots_client.get_action_result(run_id)
|
|
1271
1298
|
)
|
|
1299
|
+
if (not success) and msg:
|
|
1300
|
+
retry_code = None
|
|
1301
|
+
try:
|
|
1302
|
+
m = re.search(r"(?:HTTP|status)\s*(\d{3})", str(msg))
|
|
1303
|
+
if m:
|
|
1304
|
+
retry_code = int(m.group(1))
|
|
1305
|
+
except Exception:
|
|
1306
|
+
retry_code = None
|
|
1307
|
+
if retry_code in (401, 403):
|
|
1308
|
+
self.app._handle_auth_expired(
|
|
1309
|
+
f"Authentication expired while loading conversation history (HTTP {retry_code}). Please log in again."
|
|
1310
|
+
)
|
|
1311
|
+
return
|
|
1272
1312
|
if not success or not result:
|
|
1273
1313
|
return
|
|
1274
1314
|
self._update_action_context_from_action_result(result)
|
|
@@ -1309,10 +1349,10 @@ class DashboardScreen(Screen):
|
|
|
1309
1349
|
for action_id in self.AUTOCODE_ACTION_IDS
|
|
1310
1350
|
]
|
|
1311
1351
|
if any(resp.status_code in (401, 403) for _, resp in responses):
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1352
|
+
refreshed = self._refresh_auth_or_redirect(
|
|
1353
|
+
401,
|
|
1354
|
+
context="loading the available AutoCode actions",
|
|
1355
|
+
)
|
|
1316
1356
|
if refreshed:
|
|
1317
1357
|
api_client = getattr(getattr(self.app, "autobots_client", None), "client", api_client)
|
|
1318
1358
|
|
|
@@ -1325,6 +1365,11 @@ class DashboardScreen(Screen):
|
|
|
1325
1365
|
(action_id, await _fetch_action_refreshed(action_id))
|
|
1326
1366
|
for action_id in self.AUTOCODE_ACTION_IDS
|
|
1327
1367
|
]
|
|
1368
|
+
if any(resp.status_code in (401, 403) for _, resp in responses):
|
|
1369
|
+
self.app._handle_auth_expired(
|
|
1370
|
+
"Authentication expired while loading the available AutoCode actions. Please log in again."
|
|
1371
|
+
)
|
|
1372
|
+
return
|
|
1328
1373
|
|
|
1329
1374
|
rows: list[tuple[str, list[str]]] = []
|
|
1330
1375
|
unavailable: list[str] = []
|
|
@@ -1377,10 +1422,10 @@ class DashboardScreen(Screen):
|
|
|
1377
1422
|
)
|
|
1378
1423
|
if resp.status_code in (401, 403):
|
|
1379
1424
|
# Reactive auth guard: force refresh and retry once.
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1425
|
+
refreshed = self._refresh_auth_or_redirect(
|
|
1426
|
+
resp.status_code,
|
|
1427
|
+
context="listing actions",
|
|
1428
|
+
)
|
|
1384
1429
|
if refreshed:
|
|
1385
1430
|
api_client = getattr(getattr(self.app, "autobots_client", None), "client", api_client)
|
|
1386
1431
|
resp = await asyncio.to_thread(
|
|
@@ -1391,6 +1436,11 @@ class DashboardScreen(Screen):
|
|
|
1391
1436
|
offset=offset,
|
|
1392
1437
|
)
|
|
1393
1438
|
)
|
|
1439
|
+
if resp.status_code in (401, 403):
|
|
1440
|
+
self.app._handle_auth_expired(
|
|
1441
|
+
f"Authentication expired while listing actions (HTTP {resp.status_code}). Please log in again."
|
|
1442
|
+
)
|
|
1443
|
+
return
|
|
1394
1444
|
if resp.status_code != 200 or not resp.parsed:
|
|
1395
1445
|
self._show_command_result(command, f"Error: HTTP {resp.status_code}", is_error=True)
|
|
1396
1446
|
return
|
|
@@ -1446,10 +1496,10 @@ class DashboardScreen(Screen):
|
|
|
1446
1496
|
)
|
|
1447
1497
|
)
|
|
1448
1498
|
if resp.status_code in (401, 403):
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1499
|
+
refreshed = self._refresh_auth_or_redirect(
|
|
1500
|
+
resp.status_code,
|
|
1501
|
+
context="listing runs",
|
|
1502
|
+
)
|
|
1453
1503
|
if refreshed:
|
|
1454
1504
|
api_client = getattr(getattr(self.app, "autobots_client", None), "client", api_client)
|
|
1455
1505
|
resp = await asyncio.to_thread(
|
|
@@ -1462,6 +1512,11 @@ class DashboardScreen(Screen):
|
|
|
1462
1512
|
offset=int(opts.get("offset", "0")),
|
|
1463
1513
|
)
|
|
1464
1514
|
)
|
|
1515
|
+
if resp.status_code in (401, 403):
|
|
1516
|
+
self.app._handle_auth_expired(
|
|
1517
|
+
f"Authentication expired while listing runs (HTTP {resp.status_code}). Please log in again."
|
|
1518
|
+
)
|
|
1519
|
+
return
|
|
1465
1520
|
if resp.status_code != 200 or not resp.parsed:
|
|
1466
1521
|
self._show_command_result(command, f"Error: HTTP {resp.status_code}", is_error=True)
|
|
1467
1522
|
return
|
|
@@ -1504,10 +1559,10 @@ class DashboardScreen(Screen):
|
|
|
1504
1559
|
)
|
|
1505
1560
|
)
|
|
1506
1561
|
if resp.status_code in (401, 403):
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1562
|
+
refreshed = self._refresh_auth_or_redirect(
|
|
1563
|
+
resp.status_code,
|
|
1564
|
+
context="listing graphs",
|
|
1565
|
+
)
|
|
1511
1566
|
if refreshed:
|
|
1512
1567
|
api_client = getattr(getattr(self.app, "autobots_client", None), "client", api_client)
|
|
1513
1568
|
resp = await asyncio.to_thread(
|
|
@@ -1518,6 +1573,11 @@ class DashboardScreen(Screen):
|
|
|
1518
1573
|
offset=offset,
|
|
1519
1574
|
)
|
|
1520
1575
|
)
|
|
1576
|
+
if resp.status_code in (401, 403):
|
|
1577
|
+
self.app._handle_auth_expired(
|
|
1578
|
+
f"Authentication expired while listing graphs (HTTP {resp.status_code}). Please log in again."
|
|
1579
|
+
)
|
|
1580
|
+
return
|
|
1521
1581
|
if resp.status_code != 200 or not resp.parsed:
|
|
1522
1582
|
self._show_command_result(command, f"Error: HTTP {resp.status_code}", is_error=True)
|
|
1523
1583
|
return
|
|
@@ -1573,10 +1633,10 @@ class DashboardScreen(Screen):
|
|
|
1573
1633
|
)
|
|
1574
1634
|
)
|
|
1575
1635
|
if resp.status_code in (401, 403):
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1636
|
+
refreshed = self._refresh_auth_or_redirect(
|
|
1637
|
+
resp.status_code,
|
|
1638
|
+
context="listing graph runs",
|
|
1639
|
+
)
|
|
1580
1640
|
if refreshed:
|
|
1581
1641
|
api_client = getattr(getattr(self.app, "autobots_client", None), "client", api_client)
|
|
1582
1642
|
resp = await asyncio.to_thread(
|
|
@@ -1589,6 +1649,11 @@ class DashboardScreen(Screen):
|
|
|
1589
1649
|
offset=int(opts.get("offset", "0")),
|
|
1590
1650
|
)
|
|
1591
1651
|
)
|
|
1652
|
+
if resp.status_code in (401, 403):
|
|
1653
|
+
self.app._handle_auth_expired(
|
|
1654
|
+
f"Authentication expired while listing graph runs (HTTP {resp.status_code}). Please log in again."
|
|
1655
|
+
)
|
|
1656
|
+
return
|
|
1592
1657
|
if resp.status_code != 200 or not resp.parsed:
|
|
1593
1658
|
self._show_command_result(command, f"Error: HTTP {resp.status_code}", is_error=True)
|
|
1594
1659
|
return
|
|
@@ -1678,16 +1743,21 @@ class DashboardScreen(Screen):
|
|
|
1678
1743
|
lines = await asyncio.to_thread(lambda: fn(api_client))
|
|
1679
1744
|
code = self._http_status_from_lines(lines)
|
|
1680
1745
|
if code in (401, 403):
|
|
1681
|
-
refreshed =
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
refreshed = False
|
|
1746
|
+
refreshed = self._refresh_auth_or_redirect(
|
|
1747
|
+
code,
|
|
1748
|
+
context=f"running {title}",
|
|
1749
|
+
)
|
|
1686
1750
|
if refreshed:
|
|
1687
1751
|
api_client = self._get_api_client_for_command(title)
|
|
1688
1752
|
if not api_client:
|
|
1689
1753
|
return lines
|
|
1690
1754
|
lines = await asyncio.to_thread(lambda: fn(api_client))
|
|
1755
|
+
retry_code = self._http_status_from_lines(lines)
|
|
1756
|
+
if retry_code in (401, 403):
|
|
1757
|
+
self.app._handle_auth_expired(
|
|
1758
|
+
f"Authentication expired while running {title} (HTTP {retry_code}). Please log in again."
|
|
1759
|
+
)
|
|
1760
|
+
return lines
|
|
1691
1761
|
return lines
|
|
1692
1762
|
|
|
1693
1763
|
def _parse_command_opts(self, command: str, skip: int = 2) -> dict[str, str]:
|
|
@@ -2282,6 +2352,50 @@ class DashboardScreen(Screen):
|
|
|
2282
2352
|
pass
|
|
2283
2353
|
messages.scroll_end(animate=False)
|
|
2284
2354
|
|
|
2355
|
+
|
|
2356
|
+
def reset_for_auth_expired(self) -> None:
|
|
2357
|
+
"""Clear transient dashboard state before redirecting to login."""
|
|
2358
|
+
try:
|
|
2359
|
+
for worker in self.workers:
|
|
2360
|
+
if not worker.is_finished:
|
|
2361
|
+
worker.cancel()
|
|
2362
|
+
except Exception:
|
|
2363
|
+
pass
|
|
2364
|
+
|
|
2365
|
+
try:
|
|
2366
|
+
self._set_streaming(False)
|
|
2367
|
+
except Exception:
|
|
2368
|
+
pass
|
|
2369
|
+
|
|
2370
|
+
try:
|
|
2371
|
+
w = getattr(self, "_streaming_widget_ref", None)
|
|
2372
|
+
if w:
|
|
2373
|
+
w.remove()
|
|
2374
|
+
except Exception:
|
|
2375
|
+
pass
|
|
2376
|
+
self._streaming_widget_ref = None
|
|
2377
|
+
|
|
2378
|
+
self.current_run_id = None
|
|
2379
|
+
self.current_run_kind = None
|
|
2380
|
+
self._pending_urls.clear()
|
|
2381
|
+
self._upload_status_text = ""
|
|
2382
|
+
try:
|
|
2383
|
+
self._update_pending_files_bar()
|
|
2384
|
+
except Exception:
|
|
2385
|
+
pass
|
|
2386
|
+
try:
|
|
2387
|
+
self._update_run_status_bar()
|
|
2388
|
+
except Exception:
|
|
2389
|
+
pass
|
|
2390
|
+
|
|
2391
|
+
try:
|
|
2392
|
+
chat_input = self.query_one("#chat-input", ChatInput)
|
|
2393
|
+
chat_input.value = ""
|
|
2394
|
+
chat_input.focus()
|
|
2395
|
+
except Exception:
|
|
2396
|
+
pass
|
|
2397
|
+
|
|
2398
|
+
self._clear_chat_messages()
|
|
2285
2399
|
def add_message(self, text: str, msg_type: str = "assistant") -> None:
|
|
2286
2400
|
"""Add a message to the chat."""
|
|
2287
2401
|
messages = self.query_one("#messages", VerticalScroll)
|
|
@@ -2581,10 +2695,21 @@ class DashboardScreen(Screen):
|
|
|
2581
2695
|
refreshed = bool(await asyncio.to_thread(self.app._refresh_token_if_needed, True))
|
|
2582
2696
|
except Exception:
|
|
2583
2697
|
refreshed = False
|
|
2584
|
-
if refreshed:
|
|
2585
|
-
|
|
2586
|
-
|
|
2698
|
+
if not refreshed:
|
|
2699
|
+
self.app._handle_auth_expired(
|
|
2700
|
+
"Authentication expired while uploading files. Please log in again."
|
|
2701
|
+
)
|
|
2702
|
+
self._set_upload_status("")
|
|
2703
|
+
return
|
|
2704
|
+
success, urls, message = await asyncio.to_thread(
|
|
2705
|
+
lambda: self.app.autobots_client.upload_files(file_paths)
|
|
2706
|
+
)
|
|
2707
|
+
if (not success) and ("HTTP 401" in message or "HTTP 403" in message):
|
|
2708
|
+
self.app._handle_auth_expired(
|
|
2709
|
+
"Authentication expired while uploading files. Please log in again."
|
|
2587
2710
|
)
|
|
2711
|
+
self._set_upload_status("")
|
|
2712
|
+
return
|
|
2588
2713
|
|
|
2589
2714
|
if success:
|
|
2590
2715
|
self._pending_urls.extend(urls)
|
|
@@ -2779,25 +2904,58 @@ class DashboardScreen(Screen):
|
|
|
2779
2904
|
except Exception:
|
|
2780
2905
|
code = None
|
|
2781
2906
|
|
|
2907
|
+
retried_after_auth = False
|
|
2782
2908
|
if code in (401, 403):
|
|
2909
|
+
refreshed = self._refresh_auth_or_redirect(
|
|
2910
|
+
code,
|
|
2911
|
+
context="starting an action",
|
|
2912
|
+
)
|
|
2913
|
+
if not refreshed:
|
|
2914
|
+
try:
|
|
2915
|
+
w = getattr(self, "_streaming_widget_ref", None)
|
|
2916
|
+
if w:
|
|
2917
|
+
w.remove()
|
|
2918
|
+
except Exception:
|
|
2919
|
+
pass
|
|
2920
|
+
self._streaming_widget_ref = None
|
|
2921
|
+
self._set_streaming(False)
|
|
2922
|
+
return
|
|
2923
|
+
retried_after_auth = True
|
|
2783
2924
|
try:
|
|
2784
|
-
|
|
2925
|
+
success, run_id, message = await loop.run_in_executor(
|
|
2926
|
+
None,
|
|
2927
|
+
lambda: client.run_action_async(
|
|
2928
|
+
self.current_action_id,
|
|
2929
|
+
user_input,
|
|
2930
|
+
action_result_id=self.current_run_id,
|
|
2931
|
+
urls=urls if urls else None,
|
|
2932
|
+
metadata=self._metadata if self._metadata else None,
|
|
2933
|
+
),
|
|
2934
|
+
)
|
|
2785
2935
|
except Exception:
|
|
2786
|
-
|
|
2787
|
-
|
|
2936
|
+
pass
|
|
2937
|
+
|
|
2938
|
+
if retried_after_auth and not success:
|
|
2939
|
+
retry_code = None
|
|
2940
|
+
try:
|
|
2941
|
+
m = re.search(r"(?:HTTP|status)\s*(\d{3})", str(message or ""))
|
|
2942
|
+
if m:
|
|
2943
|
+
retry_code = int(m.group(1))
|
|
2944
|
+
except Exception:
|
|
2945
|
+
retry_code = None
|
|
2946
|
+
if retry_code in (401, 403):
|
|
2947
|
+
self.app._handle_auth_expired(
|
|
2948
|
+
f"Authentication expired while starting an action (HTTP {retry_code}). Please log in again."
|
|
2949
|
+
)
|
|
2788
2950
|
try:
|
|
2789
|
-
|
|
2790
|
-
|
|
2791
|
-
|
|
2792
|
-
self.current_action_id,
|
|
2793
|
-
user_input,
|
|
2794
|
-
action_result_id=self.current_run_id,
|
|
2795
|
-
urls=urls if urls else None,
|
|
2796
|
-
metadata=self._metadata if self._metadata else None,
|
|
2797
|
-
),
|
|
2798
|
-
)
|
|
2951
|
+
w = getattr(self, "_streaming_widget_ref", None)
|
|
2952
|
+
if w:
|
|
2953
|
+
w.remove()
|
|
2799
2954
|
except Exception:
|
|
2800
2955
|
pass
|
|
2956
|
+
self._streaming_widget_ref = None
|
|
2957
|
+
self._set_streaming(False)
|
|
2958
|
+
return
|
|
2801
2959
|
|
|
2802
2960
|
if not success:
|
|
2803
2961
|
self.add_message(f"Error starting action: {message}", "error")
|
|
@@ -2944,25 +3102,32 @@ class DashboardScreen(Screen):
|
|
|
2944
3102
|
code = None
|
|
2945
3103
|
|
|
2946
3104
|
if code in (401, 403):
|
|
2947
|
-
|
|
2948
|
-
|
|
2949
|
-
|
|
2950
|
-
|
|
3105
|
+
refreshed = self._refresh_auth_or_redirect(
|
|
3106
|
+
code,
|
|
3107
|
+
context="fetching the final action result",
|
|
3108
|
+
)
|
|
2951
3109
|
if refreshed:
|
|
2952
3110
|
success, final_result, message = await asyncio.to_thread(
|
|
2953
3111
|
client.get_action_result, run_id
|
|
2954
3112
|
)
|
|
2955
|
-
|
|
2956
|
-
|
|
3113
|
+
if not success:
|
|
3114
|
+
retry_code = None
|
|
3115
|
+
try:
|
|
3116
|
+
m = re.search(r"(?:HTTP|status)\s*(\d{3})", str(message or ""))
|
|
3117
|
+
if m:
|
|
3118
|
+
retry_code = int(m.group(1))
|
|
3119
|
+
except Exception:
|
|
3120
|
+
retry_code = None
|
|
3121
|
+
if retry_code in (401, 403):
|
|
3122
|
+
auth_failed_code = retry_code
|
|
2957
3123
|
except Exception as e:
|
|
2958
3124
|
logger.warning(f"Poll error: {e}")
|
|
2959
3125
|
return False
|
|
2960
3126
|
|
|
2961
3127
|
|
|
2962
3128
|
if auth_failed_code in (401, 403):
|
|
2963
|
-
self.
|
|
2964
|
-
f"Authentication
|
|
2965
|
-
"error",
|
|
3129
|
+
self.app._handle_auth_expired(
|
|
3130
|
+
f"Authentication expired while fetching the result (HTTP {auth_failed_code}). Please log in again."
|
|
2966
3131
|
)
|
|
2967
3132
|
got_final_result = True
|
|
2968
3133
|
# Stop spinner/blink immediately when we have a terminal state.
|
|
@@ -3271,12 +3436,25 @@ class DashboardScreen(Screen):
|
|
|
3271
3436
|
except Exception:
|
|
3272
3437
|
code = None
|
|
3273
3438
|
if code in (401, 403):
|
|
3274
|
-
|
|
3275
|
-
|
|
3276
|
-
|
|
3277
|
-
|
|
3439
|
+
refreshed = self._refresh_auth_or_redirect(
|
|
3440
|
+
code,
|
|
3441
|
+
context="refreshing displayed conversation history",
|
|
3442
|
+
)
|
|
3278
3443
|
if refreshed:
|
|
3279
3444
|
success, result, msg = self.app.autobots_client.get_action_result(run_id)
|
|
3445
|
+
if (not success) and msg:
|
|
3446
|
+
retry_code = None
|
|
3447
|
+
try:
|
|
3448
|
+
m = re.search(r"(?:HTTP|status)\s*(\d{3})", str(msg))
|
|
3449
|
+
if m:
|
|
3450
|
+
retry_code = int(m.group(1))
|
|
3451
|
+
except Exception:
|
|
3452
|
+
retry_code = None
|
|
3453
|
+
if retry_code in (401, 403):
|
|
3454
|
+
self.app._handle_auth_expired(
|
|
3455
|
+
f"Authentication expired while refreshing displayed conversation history (HTTP {retry_code}). Please log in again."
|
|
3456
|
+
)
|
|
3457
|
+
return
|
|
3280
3458
|
if not success or not result:
|
|
3281
3459
|
return
|
|
3282
3460
|
self._render_conversation_history_from_action_result(result)
|
|
@@ -701,7 +701,7 @@ async def test_tui_continue_run_updates_action_name_from_run(
|
|
|
701
701
|
async def test_tui_autocode_select_shows_only_special_action_names(
|
|
702
702
|
isolated_home: Path, monkeypatch: pytest.MonkeyPatch
|
|
703
703
|
) -> None:
|
|
704
|
-
"""/autocode-select should open the picker with only the
|
|
704
|
+
"""/autocode-select should open the picker with only the 4 special actions."""
|
|
705
705
|
tokens_path = isolated_home / ".kiwi" / "tokens.json"
|
|
706
706
|
tokens_path.parent.mkdir(parents=True, exist_ok=True)
|
|
707
707
|
tokens_path.write_text(
|
|
@@ -720,6 +720,7 @@ async def test_tui_autocode_select_shows_only_special_action_names(
|
|
|
720
720
|
"69c2180355a89324a9926bc6": "AutoCode Alpha",
|
|
721
721
|
"6a0625b8ab5f80ba8ac5d012": "AutoCode Beta",
|
|
722
722
|
"69e4be0d70c2d7a89197e66e": "AutoCode Gamma",
|
|
723
|
+
"6a1c0773c519da8a0fa9b8a4": "AutoCode Delta",
|
|
723
724
|
}
|
|
724
725
|
|
|
725
726
|
from kiwi_tui.screens.dashboard import DashboardScreen
|
|
@@ -753,10 +754,11 @@ async def test_tui_autocode_select_shows_only_special_action_names(
|
|
|
753
754
|
|
|
754
755
|
assert type(app.screen).__name__ == "IdPickerScreen"
|
|
755
756
|
table = app.screen.query_one("#idpicker-table", DataTable)
|
|
756
|
-
assert table.row_count ==
|
|
757
|
+
assert table.row_count == 4
|
|
757
758
|
assert list(table.get_row_at(0)) == ["1", "AutoCode Alpha"]
|
|
758
759
|
assert list(table.get_row_at(1)) == ["2", "AutoCode Beta"]
|
|
759
760
|
assert list(table.get_row_at(2)) == ["3", "AutoCode Gamma"]
|
|
761
|
+
assert list(table.get_row_at(3)) == ["4", "AutoCode Delta"]
|
|
760
762
|
|
|
761
763
|
|
|
762
764
|
|
|
@@ -853,8 +855,6 @@ async def test_tui_streaming_requests_autofollow_when_viewport_is_near_bottom(
|
|
|
853
855
|
)
|
|
854
856
|
await pilot.pause()
|
|
855
857
|
assert calls == [{"after_refresh": True}]
|
|
856
|
-
|
|
857
|
-
|
|
858
858
|
@pytest.mark.asyncio
|
|
859
859
|
async def test_tui_streaming_does_not_force_scroll_when_user_reads_older_messages(
|
|
860
860
|
isolated_home: Path,
|
|
@@ -881,45 +881,271 @@ async def test_tui_streaming_does_not_force_scroll_when_user_reads_older_message
|
|
|
881
881
|
await pilot.pause()
|
|
882
882
|
assert type(app.screen).__name__ == "DashboardScreen"
|
|
883
883
|
screen = app.screen
|
|
884
|
-
messages = screen.query_one("#messages")
|
|
885
884
|
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
885
|
+
calls: list[dict] = []
|
|
886
|
+
monkeypatch = pytest.MonkeyPatch()
|
|
887
|
+
monkeypatch.setattr(screen, "_messages_is_near_bottom", lambda *args, **kwargs: False)
|
|
888
|
+
monkeypatch.setattr(
|
|
889
|
+
screen,
|
|
890
|
+
"_scroll_messages_to_end",
|
|
891
|
+
lambda *args, **kwargs: calls.append(dict(kwargs)),
|
|
892
|
+
)
|
|
893
|
+
try:
|
|
894
|
+
widget = screen.update_streaming_message({"blocks": [{"text": "stream start"}]})
|
|
895
|
+
await pilot.pause()
|
|
896
|
+
assert widget is not None
|
|
897
|
+
assert calls == []
|
|
898
|
+
|
|
899
|
+
screen.update_streaming_message(
|
|
900
|
+
{"blocks": [{"text": "\n".join(f"stream line {i}" for i in range(60))}]},
|
|
901
|
+
widget,
|
|
890
902
|
)
|
|
903
|
+
await pilot.pause()
|
|
904
|
+
assert calls == []
|
|
905
|
+
finally:
|
|
906
|
+
monkeypatch.undo()
|
|
907
|
+
|
|
908
|
+
@pytest.mark.asyncio
|
|
909
|
+
async def test_tui_quits_cleanly(isolated_home: Path) -> None:
|
|
910
|
+
from kiwi_tui.main import AutobotsTUI
|
|
911
|
+
|
|
912
|
+
app = AutobotsTUI()
|
|
913
|
+
async with app.run_test() as pilot:
|
|
891
914
|
await pilot.pause()
|
|
892
|
-
|
|
915
|
+
await pilot.press("ctrl+c")
|
|
916
|
+
await pilot.pause()
|
|
917
|
+
# Reaching here without an exception is the assertion.
|
|
893
918
|
|
|
894
|
-
|
|
919
|
+
|
|
920
|
+
@pytest.mark.asyncio
|
|
921
|
+
async def test_tui_redirects_to_login_when_auth_expires(isolated_home: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
922
|
+
tokens_path = isolated_home / ".kiwi" / "tokens.json"
|
|
923
|
+
tokens_path.parent.mkdir(parents=True, exist_ok=True)
|
|
924
|
+
tokens_path.write_text(
|
|
925
|
+
json.dumps(
|
|
926
|
+
{
|
|
927
|
+
"access_token": "test-access-token",
|
|
928
|
+
"refresh_token": "test-refresh-token",
|
|
929
|
+
"token_type": "Bearer",
|
|
930
|
+
"expires_at": None,
|
|
931
|
+
}
|
|
932
|
+
),
|
|
933
|
+
encoding="utf-8",
|
|
934
|
+
)
|
|
935
|
+
|
|
936
|
+
from autobots_client.api.actions import get_action_v1_actions_id_get
|
|
937
|
+
monkeypatch.setattr(
|
|
938
|
+
get_action_v1_actions_id_get,
|
|
939
|
+
"sync_detailed",
|
|
940
|
+
lambda *, id, client: SimpleNamespace(
|
|
941
|
+
status_code=200,
|
|
942
|
+
parsed=SimpleNamespace(field_id=id, name="AutoCode Default"),
|
|
943
|
+
),
|
|
944
|
+
)
|
|
945
|
+
|
|
946
|
+
from kiwi_tui.main import AutobotsTUI
|
|
947
|
+
|
|
948
|
+
app = AutobotsTUI()
|
|
949
|
+
async with app.run_test() as pilot:
|
|
895
950
|
await pilot.pause()
|
|
896
|
-
assert
|
|
951
|
+
assert type(app.screen).__name__ == "DashboardScreen"
|
|
897
952
|
|
|
898
|
-
|
|
899
|
-
messages.scroll_to(y=target_y, animate=False, immediate=True)
|
|
953
|
+
app._handle_auth_expired("Session expired during test.")
|
|
900
954
|
await pilot.pause()
|
|
901
|
-
y_before = int(messages.scroll_offset.y)
|
|
902
|
-
target_before = int(messages.scroll_target_y)
|
|
903
|
-
assert y_before == target_before
|
|
904
|
-
assert y_before < int(messages.max_scroll_y)
|
|
905
955
|
|
|
906
|
-
screen.
|
|
907
|
-
|
|
908
|
-
|
|
956
|
+
assert type(app.screen).__name__ == "LoginScreen"
|
|
957
|
+
assert not tokens_path.exists()
|
|
958
|
+
|
|
959
|
+
|
|
960
|
+
@pytest.mark.asyncio
|
|
961
|
+
async def test_tui_run_action_403_redirects_to_login_when_refresh_cannot_run(
|
|
962
|
+
isolated_home: Path, monkeypatch: pytest.MonkeyPatch
|
|
963
|
+
) -> None:
|
|
964
|
+
tokens_path = isolated_home / ".kiwi" / "tokens.json"
|
|
965
|
+
tokens_path.parent.mkdir(parents=True, exist_ok=True)
|
|
966
|
+
tokens_path.write_text(
|
|
967
|
+
json.dumps(
|
|
968
|
+
{
|
|
969
|
+
"access_token": "test-access-token",
|
|
970
|
+
"refresh_token": "test-refresh-token",
|
|
971
|
+
"token_type": "Bearer",
|
|
972
|
+
"expires_at": None,
|
|
973
|
+
}
|
|
974
|
+
),
|
|
975
|
+
encoding="utf-8",
|
|
976
|
+
)
|
|
977
|
+
|
|
978
|
+
from autobots_client.api.actions import get_action_v1_actions_id_get
|
|
979
|
+
monkeypatch.setattr(
|
|
980
|
+
get_action_v1_actions_id_get,
|
|
981
|
+
"sync_detailed",
|
|
982
|
+
lambda *, id, client: SimpleNamespace(
|
|
983
|
+
status_code=200,
|
|
984
|
+
parsed=SimpleNamespace(field_id=id, name="AutoCode Default"),
|
|
985
|
+
),
|
|
986
|
+
)
|
|
987
|
+
|
|
988
|
+
from kiwi_tui.main import AutobotsTUI
|
|
989
|
+
|
|
990
|
+
app = AutobotsTUI()
|
|
991
|
+
async with app.run_test() as pilot:
|
|
992
|
+
await pilot.pause()
|
|
993
|
+
assert type(app.screen).__name__ == "DashboardScreen"
|
|
994
|
+
screen = app.screen
|
|
995
|
+
|
|
996
|
+
monkeypatch.setattr(app, "_refresh_token_if_needed", lambda force=False: False)
|
|
997
|
+
monkeypatch.setattr(
|
|
998
|
+
app.autobots_client,
|
|
999
|
+
"run_action_async",
|
|
1000
|
+
lambda *args, **kwargs: (False, None, "Failed to start action: status 403"),
|
|
909
1001
|
)
|
|
1002
|
+
|
|
1003
|
+
screen.run_action_with_polling("hello")
|
|
1004
|
+
await pilot.pause(0.3)
|
|
1005
|
+
await pilot.pause(0.3)
|
|
1006
|
+
|
|
1007
|
+
assert type(app.screen).__name__ == "LoginScreen"
|
|
1008
|
+
assert not tokens_path.exists()
|
|
1009
|
+
|
|
1010
|
+
|
|
1011
|
+
@pytest.mark.asyncio
|
|
1012
|
+
async def test_tui_run_action_403_redirects_to_login_on_second_failure(
|
|
1013
|
+
isolated_home: Path, monkeypatch: pytest.MonkeyPatch
|
|
1014
|
+
) -> None:
|
|
1015
|
+
tokens_path = isolated_home / ".kiwi" / "tokens.json"
|
|
1016
|
+
tokens_path.parent.mkdir(parents=True, exist_ok=True)
|
|
1017
|
+
tokens_path.write_text(
|
|
1018
|
+
json.dumps(
|
|
1019
|
+
{
|
|
1020
|
+
"access_token": "test-access-token",
|
|
1021
|
+
"refresh_token": "test-refresh-token",
|
|
1022
|
+
"token_type": "Bearer",
|
|
1023
|
+
"expires_at": None,
|
|
1024
|
+
}
|
|
1025
|
+
),
|
|
1026
|
+
encoding="utf-8",
|
|
1027
|
+
)
|
|
1028
|
+
|
|
1029
|
+
from autobots_client.api.actions import get_action_v1_actions_id_get
|
|
1030
|
+
monkeypatch.setattr(
|
|
1031
|
+
get_action_v1_actions_id_get,
|
|
1032
|
+
"sync_detailed",
|
|
1033
|
+
lambda *, id, client: SimpleNamespace(
|
|
1034
|
+
status_code=200,
|
|
1035
|
+
parsed=SimpleNamespace(field_id=id, name="AutoCode Default"),
|
|
1036
|
+
),
|
|
1037
|
+
)
|
|
1038
|
+
|
|
1039
|
+
from kiwi_tui.main import AutobotsTUI
|
|
1040
|
+
|
|
1041
|
+
app = AutobotsTUI()
|
|
1042
|
+
async with app.run_test() as pilot:
|
|
910
1043
|
await pilot.pause()
|
|
1044
|
+
assert type(app.screen).__name__ == "DashboardScreen"
|
|
1045
|
+
screen = app.screen
|
|
1046
|
+
|
|
1047
|
+
monkeypatch.setattr(app, "_refresh_token_if_needed", lambda force=False: True)
|
|
1048
|
+
monkeypatch.setattr(
|
|
1049
|
+
app.autobots_client,
|
|
1050
|
+
"run_action_async",
|
|
1051
|
+
lambda *args, **kwargs: (False, None, "Failed to start action: status 403"),
|
|
1052
|
+
)
|
|
1053
|
+
|
|
1054
|
+
screen.run_action_with_polling("hello")
|
|
1055
|
+
await pilot.pause(0.3)
|
|
1056
|
+
await pilot.pause(0.3)
|
|
1057
|
+
|
|
1058
|
+
assert type(app.screen).__name__ == "LoginScreen"
|
|
1059
|
+
assert not tokens_path.exists()
|
|
1060
|
+
|
|
1061
|
+
|
|
1062
|
+
def test_tui_force_refresh_uses_refresh_token_even_when_access_token_not_expired(
|
|
1063
|
+
isolated_home: Path, monkeypatch: pytest.MonkeyPatch
|
|
1064
|
+
) -> None:
|
|
1065
|
+
tokens_path = isolated_home / ".kiwi" / "tokens.json"
|
|
1066
|
+
tokens_path.parent.mkdir(parents=True, exist_ok=True)
|
|
1067
|
+
tokens_path.write_text(
|
|
1068
|
+
json.dumps(
|
|
1069
|
+
{
|
|
1070
|
+
"access_token": "test-access-token",
|
|
1071
|
+
"refresh_token": "test-refresh-token",
|
|
1072
|
+
"token_type": "Bearer",
|
|
1073
|
+
"expires_at": "2999-01-01T00:00:00",
|
|
1074
|
+
}
|
|
1075
|
+
),
|
|
1076
|
+
encoding="utf-8",
|
|
1077
|
+
)
|
|
1078
|
+
|
|
1079
|
+
from kiwi_cli.models import AuthTokens
|
|
1080
|
+
from kiwi_tui.main import AutobotsTUI
|
|
1081
|
+
|
|
1082
|
+
app = AutobotsTUI()
|
|
1083
|
+
|
|
1084
|
+
calls: list[str] = []
|
|
1085
|
+
|
|
1086
|
+
def _fake_refresh(refresh_token: str):
|
|
1087
|
+
calls.append(refresh_token)
|
|
1088
|
+
return True, AuthTokens(
|
|
1089
|
+
access_token="new-access-token",
|
|
1090
|
+
refresh_token="new-refresh-token",
|
|
1091
|
+
token_type="Bearer",
|
|
1092
|
+
expires_at=None,
|
|
1093
|
+
), "ok"
|
|
1094
|
+
|
|
1095
|
+
monkeypatch.setattr(app.autobots_client, "refresh_token", _fake_refresh)
|
|
1096
|
+
|
|
1097
|
+
assert app._refresh_token_if_needed(force=True) is True
|
|
1098
|
+
assert calls == ["test-refresh-token"]
|
|
911
1099
|
|
|
912
|
-
assert int(messages.scroll_offset.y) == y_before
|
|
913
|
-
assert int(messages.scroll_target_y) == target_before
|
|
914
|
-
assert int(messages.scroll_offset.y) < int(messages.max_scroll_y)
|
|
915
1100
|
|
|
916
1101
|
@pytest.mark.asyncio
|
|
917
|
-
async def
|
|
1102
|
+
async def test_tui_auth_expired_clears_dashboard_draft_and_messages(
|
|
1103
|
+
isolated_home: Path, monkeypatch: pytest.MonkeyPatch
|
|
1104
|
+
) -> None:
|
|
1105
|
+
tokens_path = isolated_home / ".kiwi" / "tokens.json"
|
|
1106
|
+
tokens_path.parent.mkdir(parents=True, exist_ok=True)
|
|
1107
|
+
tokens_path.write_text(
|
|
1108
|
+
json.dumps(
|
|
1109
|
+
{
|
|
1110
|
+
"access_token": "test-access-token",
|
|
1111
|
+
"refresh_token": "test-refresh-token",
|
|
1112
|
+
"token_type": "Bearer",
|
|
1113
|
+
"expires_at": None,
|
|
1114
|
+
}
|
|
1115
|
+
),
|
|
1116
|
+
encoding="utf-8",
|
|
1117
|
+
)
|
|
1118
|
+
|
|
1119
|
+
from autobots_client.api.actions import get_action_v1_actions_id_get
|
|
1120
|
+
monkeypatch.setattr(
|
|
1121
|
+
get_action_v1_actions_id_get,
|
|
1122
|
+
"sync_detailed",
|
|
1123
|
+
lambda *, id, client: SimpleNamespace(
|
|
1124
|
+
status_code=200,
|
|
1125
|
+
parsed=SimpleNamespace(field_id=id, name="AutoCode Default"),
|
|
1126
|
+
),
|
|
1127
|
+
)
|
|
1128
|
+
|
|
1129
|
+
from textual.containers import VerticalScroll
|
|
918
1130
|
from kiwi_tui.main import AutobotsTUI
|
|
1131
|
+
from kiwi_tui.widgets import ChatInput
|
|
919
1132
|
|
|
920
1133
|
app = AutobotsTUI()
|
|
921
1134
|
async with app.run_test() as pilot:
|
|
922
1135
|
await pilot.pause()
|
|
923
|
-
|
|
1136
|
+
assert type(app.screen).__name__ == "DashboardScreen"
|
|
1137
|
+
dashboard = app.screen
|
|
1138
|
+
chat_input = dashboard.query_one("#chat-input", ChatInput)
|
|
1139
|
+
messages = dashboard.query_one("#messages", VerticalScroll)
|
|
1140
|
+
|
|
1141
|
+
chat_input.value = "hi"
|
|
1142
|
+
dashboard.add_message("hi", "user")
|
|
924
1143
|
await pilot.pause()
|
|
925
|
-
|
|
1144
|
+
assert len(list(messages.children)) > 0
|
|
1145
|
+
|
|
1146
|
+
app._handle_auth_expired("Session expired during test.")
|
|
1147
|
+
await pilot.pause()
|
|
1148
|
+
|
|
1149
|
+
assert chat_input.value == ""
|
|
1150
|
+
assert len(list(messages.children)) == 0
|
|
1151
|
+
assert type(app.screen).__name__ == "LoginScreen"
|
|
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
|