codex-telegram 0.6.0__tar.gz → 0.6.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (101) hide show
  1. {codex_telegram-0.6.0 → codex_telegram-0.6.1}/PKG-INFO +12 -1
  2. {codex_telegram-0.6.0 → codex_telegram-0.6.1}/README.md +11 -0
  3. {codex_telegram-0.6.0 → codex_telegram-0.6.1}/WEB.md +4 -1
  4. {codex_telegram-0.6.0 → codex_telegram-0.6.1}/codex_telegram/__init__.py +1 -1
  5. {codex_telegram-0.6.0 → codex_telegram-0.6.1}/codex_telegram.egg-info/PKG-INFO +12 -1
  6. {codex_telegram-0.6.0 → codex_telegram-0.6.1}/codex_telegram.egg-info/SOURCES.txt +4 -2
  7. codex_telegram-0.6.1/tests/test_project_config.py +42 -0
  8. codex_telegram-0.6.1/tests/test_project_routes.py +59 -0
  9. {codex_telegram-0.6.0 → codex_telegram-0.6.1}/tests/test_web_server_local_command.py +44 -0
  10. {codex_telegram-0.6.0 → codex_telegram-0.6.1}/utils/config.py +65 -2
  11. {codex_telegram-0.6.0 → codex_telegram-0.6.1}/web/routes.py +49 -12
  12. codex_telegram-0.6.0/web/static/dist/assets/index-wjzNlOuf.js → codex_telegram-0.6.1/web/static/dist/assets/index-0u8Hjl9v.js +27 -27
  13. codex_telegram-0.6.1/web/static/dist/assets/index-Bh3KHDwf.css +1 -0
  14. {codex_telegram-0.6.0 → codex_telegram-0.6.1}/web/static/dist/index.html +2 -2
  15. codex_telegram-0.6.0/web/static/dist/assets/index-Cb6qZVCI.css +0 -1
  16. {codex_telegram-0.6.0 → codex_telegram-0.6.1}/LICENSE +0 -0
  17. {codex_telegram-0.6.0 → codex_telegram-0.6.1}/MANIFEST.in +0 -0
  18. {codex_telegram-0.6.0 → codex_telegram-0.6.1}/TELEGRAM.md +0 -0
  19. {codex_telegram-0.6.0 → codex_telegram-0.6.1}/app_runtime/__init__.py +0 -0
  20. {codex_telegram-0.6.0 → codex_telegram-0.6.1}/app_runtime/bootstrap.py +0 -0
  21. {codex_telegram-0.6.0 → codex_telegram-0.6.1}/app_runtime/telegram_app.py +0 -0
  22. {codex_telegram-0.6.0 → codex_telegram-0.6.1}/app_runtime/web_server.py +0 -0
  23. {codex_telegram-0.6.0 → codex_telegram-0.6.1}/bot/__init__.py +0 -0
  24. {codex_telegram-0.6.0 → codex_telegram-0.6.1}/bot/callbacks.py +0 -0
  25. {codex_telegram-0.6.0 → codex_telegram-0.6.1}/bot/features_ui.py +0 -0
  26. {codex_telegram-0.6.0 → codex_telegram-0.6.1}/bot/handlers.py +0 -0
  27. {codex_telegram-0.6.0 → codex_telegram-0.6.1}/bot/keyboard.py +0 -0
  28. {codex_telegram-0.6.0 → codex_telegram-0.6.1}/bot/projects_ui.py +0 -0
  29. {codex_telegram-0.6.0 → codex_telegram-0.6.1}/bot/skills_ui.py +0 -0
  30. {codex_telegram-0.6.0 → codex_telegram-0.6.1}/bot/thread_ui.py +0 -0
  31. {codex_telegram-0.6.0 → codex_telegram-0.6.1}/codex/__init__.py +0 -0
  32. {codex_telegram-0.6.0 → codex_telegram-0.6.1}/codex/approval_flow.py +0 -0
  33. {codex_telegram-0.6.0 → codex_telegram-0.6.1}/codex/approval_guardian.py +0 -0
  34. {codex_telegram-0.6.0 → codex_telegram-0.6.1}/codex/client.py +0 -0
  35. {codex_telegram-0.6.0 → codex_telegram-0.6.1}/codex/client_pool.py +0 -0
  36. {codex_telegram-0.6.0 → codex_telegram-0.6.1}/codex/collaboration_mode.py +0 -0
  37. {codex_telegram-0.6.0 → codex_telegram-0.6.1}/codex/command_router/__init__.py +0 -0
  38. {codex_telegram-0.6.0 → codex_telegram-0.6.1}/codex/command_router/common.py +0 -0
  39. {codex_telegram-0.6.0 → codex_telegram-0.6.1}/codex/command_router/context.py +0 -0
  40. {codex_telegram-0.6.0 → codex_telegram-0.6.1}/codex/command_router/contracts.py +0 -0
  41. {codex_telegram-0.6.0 → codex_telegram-0.6.1}/codex/command_router/core.py +0 -0
  42. {codex_telegram-0.6.0 → codex_telegram-0.6.1}/codex/command_router/projects.py +0 -0
  43. {codex_telegram-0.6.0 → codex_telegram-0.6.1}/codex/command_router/review.py +0 -0
  44. {codex_telegram-0.6.0 → codex_telegram-0.6.1}/codex/command_router/system.py +0 -0
  45. {codex_telegram-0.6.0 → codex_telegram-0.6.1}/codex/command_router/threads.py +0 -0
  46. {codex_telegram-0.6.0 → codex_telegram-0.6.1}/codex/commands.py +0 -0
  47. {codex_telegram-0.6.0 → codex_telegram-0.6.1}/codex/event_forwarding.py +0 -0
  48. {codex_telegram-0.6.0 → codex_telegram-0.6.1}/codex/events.py +0 -0
  49. {codex_telegram-0.6.0 → codex_telegram-0.6.1}/codex/protocol.py +0 -0
  50. {codex_telegram-0.6.0 → codex_telegram-0.6.1}/codex_telegram/cli.py +0 -0
  51. {codex_telegram-0.6.0 → codex_telegram-0.6.1}/codex_telegram.egg-info/dependency_links.txt +0 -0
  52. {codex_telegram-0.6.0 → codex_telegram-0.6.1}/codex_telegram.egg-info/entry_points.txt +0 -0
  53. {codex_telegram-0.6.0 → codex_telegram-0.6.1}/codex_telegram.egg-info/requires.txt +0 -0
  54. {codex_telegram-0.6.0 → codex_telegram-0.6.1}/codex_telegram.egg-info/top_level.txt +0 -0
  55. {codex_telegram-0.6.0 → codex_telegram-0.6.1}/conf.toml.example +0 -0
  56. {codex_telegram-0.6.0 → codex_telegram-0.6.1}/main.py +0 -0
  57. {codex_telegram-0.6.0 → codex_telegram-0.6.1}/models/__init__.py +0 -0
  58. {codex_telegram-0.6.0 → codex_telegram-0.6.1}/models/state.py +0 -0
  59. {codex_telegram-0.6.0 → codex_telegram-0.6.1}/models/telegram_bridge.py +0 -0
  60. {codex_telegram-0.6.0 → codex_telegram-0.6.1}/models/thread.py +0 -0
  61. {codex_telegram-0.6.0 → codex_telegram-0.6.1}/models/user.py +0 -0
  62. {codex_telegram-0.6.0 → codex_telegram-0.6.1}/pyproject.toml +0 -0
  63. {codex_telegram-0.6.0 → codex_telegram-0.6.1}/setup.cfg +0 -0
  64. {codex_telegram-0.6.0 → codex_telegram-0.6.1}/tests/test_approval_guardian.py +0 -0
  65. {codex_telegram-0.6.0 → codex_telegram-0.6.1}/tests/test_approval_policy.py +0 -0
  66. {codex_telegram-0.6.0 → codex_telegram-0.6.1}/tests/test_bootstrap_update_check.py +0 -0
  67. {codex_telegram-0.6.0 → codex_telegram-0.6.1}/tests/test_callbacks_results.py +0 -0
  68. {codex_telegram-0.6.0 → codex_telegram-0.6.1}/tests/test_cli_entrypoint.py +0 -0
  69. {codex_telegram-0.6.0 → codex_telegram-0.6.1}/tests/test_codex_client.py +0 -0
  70. {codex_telegram-0.6.0 → codex_telegram-0.6.1}/tests/test_codex_client_pool.py +0 -0
  71. {codex_telegram-0.6.0 → codex_telegram-0.6.1}/tests/test_command_router.py +0 -0
  72. {codex_telegram-0.6.0 → codex_telegram-0.6.1}/tests/test_guardian_config.py +0 -0
  73. {codex_telegram-0.6.0 → codex_telegram-0.6.1}/tests/test_handlers_results.py +0 -0
  74. {codex_telegram-0.6.0 → codex_telegram-0.6.1}/tests/test_keyboard.py +0 -0
  75. {codex_telegram-0.6.0 → codex_telegram-0.6.1}/tests/test_main_approval_flow.py +0 -0
  76. {codex_telegram-0.6.0 → codex_telegram-0.6.1}/tests/test_normalize.py +0 -0
  77. {codex_telegram-0.6.0 → codex_telegram-0.6.1}/tests/test_pypi_check.py +0 -0
  78. {codex_telegram-0.6.0 → codex_telegram-0.6.1}/tests/test_single_instance.py +0 -0
  79. {codex_telegram-0.6.0 → codex_telegram-0.6.1}/tests/test_telegram_bot_config.py +0 -0
  80. {codex_telegram-0.6.0 → codex_telegram-0.6.1}/tests/test_thread_history.py +0 -0
  81. {codex_telegram-0.6.0 → codex_telegram-0.6.1}/tests/test_web_runtime.py +0 -0
  82. {codex_telegram-0.6.0 → codex_telegram-0.6.1}/utils/__init__.py +0 -0
  83. {codex_telegram-0.6.0 → codex_telegram-0.6.1}/utils/approval_policy.py +0 -0
  84. {codex_telegram-0.6.0 → codex_telegram-0.6.1}/utils/local_command.py +0 -0
  85. {codex_telegram-0.6.0 → codex_telegram-0.6.1}/utils/logger.py +0 -0
  86. {codex_telegram-0.6.0 → codex_telegram-0.6.1}/utils/normalize.py +0 -0
  87. {codex_telegram-0.6.0 → codex_telegram-0.6.1}/utils/pypi_check.py +0 -0
  88. {codex_telegram-0.6.0 → codex_telegram-0.6.1}/utils/single_instance.py +0 -0
  89. {codex_telegram-0.6.0 → codex_telegram-0.6.1}/utils/web_config.py +0 -0
  90. {codex_telegram-0.6.0 → codex_telegram-0.6.1}/web/__init__.py +0 -0
  91. {codex_telegram-0.6.0 → codex_telegram-0.6.1}/web/dependencies.py +0 -0
  92. {codex_telegram-0.6.0 → codex_telegram-0.6.1}/web/runtime.py +0 -0
  93. {codex_telegram-0.6.0 → codex_telegram-0.6.1}/web/server.py +0 -0
  94. {codex_telegram-0.6.0 → codex_telegram-0.6.1}/web/static/dist/assets/codex-telegram-icon-black.svg +0 -0
  95. {codex_telegram-0.6.0 → codex_telegram-0.6.1}/web/static/dist/assets/codex-telegram-icon-ivory.svg +0 -0
  96. {codex_telegram-0.6.0 → codex_telegram-0.6.1}/web/static/dist/assets/codex-telegram-login-image.png +0 -0
  97. {codex_telegram-0.6.0 → codex_telegram-0.6.1}/web/static/dist/favicon.ico +0 -0
  98. {codex_telegram-0.6.0 → codex_telegram-0.6.1}/web/telegram_sync.py +0 -0
  99. {codex_telegram-0.6.0 → codex_telegram-0.6.1}/web/telegram_thread_selection.py +0 -0
  100. {codex_telegram-0.6.0 → codex_telegram-0.6.1}/web/thread_history.py +0 -0
  101. {codex_telegram-0.6.0 → codex_telegram-0.6.1}/web/workspace.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: codex-telegram
3
- Version: 0.6.0
3
+ Version: 0.6.1
4
4
  Summary: Bridge for controlling the Codex App Server from Telegram and Web UI
5
5
  Author: heungtae
6
6
  License: Apache License
@@ -382,6 +382,17 @@ cp conf.toml.example ~/.config/codex-telegram/conf.toml
382
382
  ./run_build_and_main.sh
383
383
  ```
384
384
 
385
+ ## Publishing a Release
386
+
387
+ 1. Update `codex_telegram/__init__.py` (`__version__`), validate the changes, and push the commits.
388
+ 2. In GitHub Releases, create a draft with a matching tag (for example, `v0.6.0` for version `0.6.0`) targeting the intended commit. The commit must contain `.github/workflows/pypi-publish.yml` with the release trigger.
389
+ 3. Write the release notes and publish the Release. Drafts and tag pushes alone do not deploy.
390
+ 4. Wait for **Publish release to PyPI** to succeed: it builds the Web UI, builds/checks the wheel and sdist (including UI assets), publishes to PyPI, then attaches the same distributions and `SHA256SUMS` to the Release.
391
+
392
+ Configure `PYPI_API_TOKEN` as a repository secret or a secret on the `pypi` environment before publishing. Existing environment protection rules still apply; remove required reviewers from that environment if fully automatic publishing is desired. Published prereleases also trigger this workflow, so use a matching Python prerelease version such as `0.6.1rc1` / `v0.6.1rc1`.
393
+
394
+ A published GitHub Release alone does not mean PyPI deployment succeeded; check the workflow result. If attachment fails after PyPI publishing succeeds, rerun only failed jobs to attach the retained files without republishing. For code changes after a successful PyPI upload, use a new version and Release.
395
+
385
396
  ## Documentation
386
397
 
387
398
  | File | Description |
@@ -157,6 +157,17 @@ cp conf.toml.example ~/.config/codex-telegram/conf.toml
157
157
  ./run_build_and_main.sh
158
158
  ```
159
159
 
160
+ ## Publishing a Release
161
+
162
+ 1. Update `codex_telegram/__init__.py` (`__version__`), validate the changes, and push the commits.
163
+ 2. In GitHub Releases, create a draft with a matching tag (for example, `v0.6.0` for version `0.6.0`) targeting the intended commit. The commit must contain `.github/workflows/pypi-publish.yml` with the release trigger.
164
+ 3. Write the release notes and publish the Release. Drafts and tag pushes alone do not deploy.
165
+ 4. Wait for **Publish release to PyPI** to succeed: it builds the Web UI, builds/checks the wheel and sdist (including UI assets), publishes to PyPI, then attaches the same distributions and `SHA256SUMS` to the Release.
166
+
167
+ Configure `PYPI_API_TOKEN` as a repository secret or a secret on the `pypi` environment before publishing. Existing environment protection rules still apply; remove required reviewers from that environment if fully automatic publishing is desired. Published prereleases also trigger this workflow, so use a matching Python prerelease version such as `0.6.1rc1` / `v0.6.1rc1`.
168
+
169
+ A published GitHub Release alone does not mean PyPI deployment succeeded; check the workflow result. If attachment fails after PyPI publishing succeeds, rerun only failed jobs to attach the retained files without republishing. For code changes after a successful PyPI upload, use a new version and Release.
170
+
160
171
  ## Documentation
161
172
 
162
173
  | File | Description |
@@ -20,6 +20,7 @@ A ChatGPT-style web interface for interacting with Codex App Server. Provides re
20
20
  - **Thread tabs**: Multiple conversations per project, each with independent chat state
21
21
  - **Thread states**: Visual indicators for idle, running, completed, failed, or cancelled
22
22
  - **Unread notifications**: Badge indicators for new completions
23
+ - **Project management**: Use the project menu → Modify Project to edit its name/path. The key remains fixed to preserve chat associations. Add/Modify Project includes a Default project checkbox. The default project is marked D and its Remove menu is disabled; choose another default before removing it. Changing the default updates the shared configuration without switching existing open chats.
23
24
  - **Persistent state**: Thread and project tabs preserved across sessions
24
25
 
25
26
  ### Workspace Browser
@@ -162,7 +163,9 @@ Guardian provides automated safety checks before approval decisions:
162
163
  | Method | Endpoint | Description |
163
164
  |--------|----------|-------------|
164
165
  | GET | `/api/projects` | List projects |
165
- | POST | `/api/projects` | Add project |
166
+ | POST | `/api/projects` | Add project (`default: true` optionally sets the default) |
167
+ | PUT | `/api/projects/{key}` | Modify name/path and optionally set default |
168
+ | DELETE | `/api/projects/{key}` | Remove a non-default project |
166
169
  | POST | `/api/projects/select` | Switch project |
167
170
  | POST | `/api/projects/open-thread` | Open thread in project |
168
171
  | POST | `/api/projects/open-explorer` | Open project folder in OS file explorer |
@@ -1,3 +1,3 @@
1
1
  """codex-telegram package."""
2
2
 
3
- __version__ = "0.6.0"
3
+ __version__ = "0.6.1"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: codex-telegram
3
- Version: 0.6.0
3
+ Version: 0.6.1
4
4
  Summary: Bridge for controlling the Codex App Server from Telegram and Web UI
5
5
  Author: heungtae
6
6
  License: Apache License
@@ -382,6 +382,17 @@ cp conf.toml.example ~/.config/codex-telegram/conf.toml
382
382
  ./run_build_and_main.sh
383
383
  ```
384
384
 
385
+ ## Publishing a Release
386
+
387
+ 1. Update `codex_telegram/__init__.py` (`__version__`), validate the changes, and push the commits.
388
+ 2. In GitHub Releases, create a draft with a matching tag (for example, `v0.6.0` for version `0.6.0`) targeting the intended commit. The commit must contain `.github/workflows/pypi-publish.yml` with the release trigger.
389
+ 3. Write the release notes and publish the Release. Drafts and tag pushes alone do not deploy.
390
+ 4. Wait for **Publish release to PyPI** to succeed: it builds the Web UI, builds/checks the wheel and sdist (including UI assets), publishes to PyPI, then attaches the same distributions and `SHA256SUMS` to the Release.
391
+
392
+ Configure `PYPI_API_TOKEN` as a repository secret or a secret on the `pypi` environment before publishing. Existing environment protection rules still apply; remove required reviewers from that environment if fully automatic publishing is desired. Published prereleases also trigger this workflow, so use a matching Python prerelease version such as `0.6.1rc1` / `v0.6.1rc1`.
393
+
394
+ A published GitHub Release alone does not mean PyPI deployment succeeded; check the workflow result. If attachment fails after PyPI publishing succeeds, rerun only failed jobs to attach the retained files without republishing. For code changes after a successful PyPI upload, use a new version and Release.
395
+
385
396
  ## Documentation
386
397
 
387
398
  | File | Description |
@@ -63,6 +63,8 @@ tests/test_handlers_results.py
63
63
  tests/test_keyboard.py
64
64
  tests/test_main_approval_flow.py
65
65
  tests/test_normalize.py
66
+ tests/test_project_config.py
67
+ tests/test_project_routes.py
66
68
  tests/test_pypi_check.py
67
69
  tests/test_single_instance.py
68
70
  tests/test_telegram_bot_config.py
@@ -92,5 +94,5 @@ web/static/dist/index.html
92
94
  web/static/dist/assets/codex-telegram-icon-black.svg
93
95
  web/static/dist/assets/codex-telegram-icon-ivory.svg
94
96
  web/static/dist/assets/codex-telegram-login-image.png
95
- web/static/dist/assets/index-Cb6qZVCI.css
96
- web/static/dist/assets/index-wjzNlOuf.js
97
+ web/static/dist/assets/index-0u8Hjl9v.js
98
+ web/static/dist/assets/index-Bh3KHDwf.css
@@ -0,0 +1,42 @@
1
+ import tomllib
2
+ from unittest.mock import patch
3
+
4
+ import pytest
5
+
6
+ from utils.config import remove_project_profile, save_project_profile, update_project_profile
7
+
8
+
9
+ @pytest.fixture
10
+ def project_config(tmp_path):
11
+ path = tmp_path / "conf.toml"
12
+ path.write_text('project = "alpha"\n\n[projects.alpha]\nname = "Alpha"\npath = "/alpha"\nextra = true\n\n[web]\nenabled = true\n')
13
+ with patch("utils.config._get_config_path", return_value=path):
14
+ yield path
15
+
16
+
17
+ def test_modify_and_change_default_preserves_other_settings(project_config):
18
+ save_project_profile("beta", "Beta", "/beta")
19
+ update_project_profile("beta", 'New "Beta"', r"/new\beta", make_default=True)
20
+ parsed = tomllib.loads(project_config.read_text())
21
+ assert parsed["project"] == "beta"
22
+ assert parsed["projects"]["beta"] == {"name": 'New "Beta"', "path": r"/new\beta"}
23
+ assert parsed["projects"]["alpha"]["extra"] is True
24
+ assert parsed["web"]["enabled"] is True
25
+ with pytest.raises(ValueError, match="default project"):
26
+ remove_project_profile("beta")
27
+ remove_project_profile("alpha")
28
+ assert "alpha" not in tomllib.loads(project_config.read_text())["projects"]
29
+
30
+
31
+ def test_add_default_and_modify_without_unsetting_default(project_config):
32
+ save_project_profile("beta", "Beta", "/beta", make_default=True)
33
+ update_project_profile("beta", "Changed", "/changed")
34
+ assert tomllib.loads(project_config.read_text())["project"] == "beta"
35
+
36
+
37
+ @pytest.mark.parametrize("key,name,path", [("missing", "Name", "/path"), ("alpha", " ", "/path"), ("alpha", "Name", " ")])
38
+ def test_invalid_modify_does_not_write(project_config, key, name, path):
39
+ original = project_config.read_text()
40
+ with pytest.raises(ValueError):
41
+ update_project_profile(key, name, path, make_default=True)
42
+ assert project_config.read_text() == original
@@ -0,0 +1,59 @@
1
+ import asyncio
2
+ import unittest
3
+ from types import SimpleNamespace
4
+ from unittest.mock import AsyncMock, patch
5
+
6
+ from fastapi import FastAPI, HTTPException
7
+
8
+ from web.routes import register_project_routes
9
+
10
+
11
+ class ProjectRouteTests(unittest.TestCase):
12
+ def setUp(self):
13
+ app = FastAPI()
14
+ register_project_routes(app)
15
+ self.modify = next(route.endpoint for route in app.routes if "PUT" in getattr(route, "methods", set()))
16
+ self.add = next(route.endpoint for route in app.routes if getattr(route, "path", "") == "/api/projects" and "POST" in route.methods)
17
+
18
+ def test_modify_saves_default_and_reloads(self):
19
+ with (
20
+ patch("web.routes.session_from_request", new=AsyncMock(return_value=SimpleNamespace(user_id=1))),
21
+ patch("web.routes.update_project_profile") as save,
22
+ patch("web.routes.reload") as reload_config,
23
+ patch("web.routes.route_command", new=AsyncMock(return_value={"ok": True})),
24
+ ):
25
+ result = asyncio.run(self.modify("alpha", {"name": "Alpha", "path": "/alpha", "default": True}, object()))
26
+ save.assert_called_once_with("alpha", "Alpha", "/alpha", make_default=True)
27
+ reload_config.assert_called_once()
28
+ self.assertTrue(result["ok"])
29
+
30
+ def test_add_can_set_default(self):
31
+ with (
32
+ patch("web.routes.session_from_request", new=AsyncMock(return_value=SimpleNamespace(user_id=1))),
33
+ patch("web.routes.save_project_profile") as save,
34
+ patch("web.routes.reload"),
35
+ patch("web.routes.route_command", new=AsyncMock(return_value={})),
36
+ ):
37
+ asyncio.run(self.add({"key": "alpha", "name": "Alpha", "path": "/alpha", "default": True}, object()))
38
+ save.assert_called_once_with("alpha", "Alpha", "/alpha", make_default=True)
39
+
40
+ def test_modify_reports_invalid_project(self):
41
+ with (
42
+ patch("web.routes.session_from_request", new=AsyncMock(return_value=SimpleNamespace(user_id=1))),
43
+ patch("web.routes.update_project_profile", side_effect=ValueError("not found")),
44
+ patch("web.routes.reload") as reload_config,
45
+ ):
46
+ with self.assertRaises(HTTPException) as raised:
47
+ asyncio.run(self.modify("missing", {"name": "Name", "path": "/path"}, object()))
48
+ self.assertEqual(400, raised.exception.status_code)
49
+ reload_config.assert_not_called()
50
+
51
+ def test_modify_requires_authentication_before_writing(self):
52
+ with (
53
+ patch("web.routes.session_from_request", new=AsyncMock(side_effect=HTTPException(401))),
54
+ patch("web.routes.update_project_profile") as save,
55
+ ):
56
+ with self.assertRaises(HTTPException) as raised:
57
+ asyncio.run(self.modify("alpha", {"name": "Name", "path": "/path"}, object()))
58
+ self.assertEqual(401, raised.exception.status_code)
59
+ save.assert_not_called()
@@ -1274,6 +1274,50 @@ path_glob_any = ["helm/**"]
1274
1274
  body["items"],
1275
1275
  )
1276
1276
 
1277
+ def test_thread_summaries_paginates_before_project_limit_and_offset(self):
1278
+ app = create_web_app()
1279
+ endpoint = next(route.endpoint for route in app.routes
1280
+ if getattr(route, "path", None) == "/api/threads/summaries")
1281
+ request = SimpleNamespace(cookies={COOKIE_NAME: self.session.token})
1282
+ user_manager.get(self.session.user_id).selected_project_key = "default"
1283
+ for tid in ("t-1", "t-2", "t-3"):
1284
+ user_manager.bind_thread_project(tid, "default")
1285
+ user_manager.bind_thread_project("other", "other")
1286
+ for offset, expected in ((0, ["t-1", "t-2"]), (1, ["t-2", "t-3"])):
1287
+ with self.subTest(offset=offset):
1288
+ state.codex_client.call = AsyncMock(side_effect=[
1289
+ {"data": [{"id": "other"}], "nextCursor": "page-2"},
1290
+ {"data": [{"id": "t-1"}], "nextCursor": "page-3"},
1291
+ {"data": [{"id": "t-1"}, {"id": "t-2"}, {"id": "t-3"}],
1292
+ "nextCursor": "unused"},
1293
+ {"thread": {}}, {"thread": {}},
1294
+ ])
1295
+ body = asyncio.run(endpoint(request, limit=2, offset=offset))
1296
+ self.assertEqual(expected, [item["id"] for item in body["items"]])
1297
+ list_calls = [c for c in state.codex_client.call.await_args_list
1298
+ if c.args[0] == "thread/list"]
1299
+ self.assertEqual(3, len(list_calls))
1300
+ self.assertNotIn("cursor", list_calls[0].args[1])
1301
+ self.assertEqual("page-2", list_calls[1].args[1]["cursor"])
1302
+ self.assertEqual("page-3", list_calls[2].args[1]["cursor"])
1303
+
1304
+ def test_thread_summaries_stops_on_exhaustion_or_repeated_cursor(self):
1305
+ app = create_web_app()
1306
+ endpoint = next(route.endpoint for route in app.routes
1307
+ if getattr(route, "path", None) == "/api/threads/summaries")
1308
+ request = SimpleNamespace(cookies={COOKIE_NAME: self.session.token})
1309
+ user_manager.get(self.session.user_id).selected_project_key = "default"
1310
+ user_manager.bind_thread_project("other", "other")
1311
+ for page in ({"data": [{"id": "other"}]},
1312
+ {"data": None, "nextCursor": "repeat"}):
1313
+ with self.subTest(page=page):
1314
+ state.codex_client.call = AsyncMock(return_value=page)
1315
+ body = asyncio.run(endpoint(request, limit=2, archived=True))
1316
+ self.assertEqual([], body["items"])
1317
+ self.assertEqual(2 if page.get("nextCursor") else 1,
1318
+ state.codex_client.call.await_count)
1319
+ self.assertTrue(state.codex_client.call.await_args.args[1]["archived"])
1320
+
1277
1321
  def test_thread_summaries_use_user_request_excerpt_as_title(self):
1278
1322
  app = create_web_app()
1279
1323
  endpoint = next(
@@ -682,7 +682,9 @@ def save_guardian_settings(
682
682
  return get_guardian_settings()
683
683
 
684
684
 
685
- def save_project_profile(key: str, name: str, path: str) -> None:
685
+ def save_project_profile(
686
+ key: str, name: str, path: str, *, make_default: bool = False
687
+ ) -> None:
686
688
  if not re.fullmatch(r"[A-Za-z0-9_-]+", key):
687
689
  raise ValueError("Invalid project key. Use letters, numbers, '_' or '-'.")
688
690
  if not name.strip():
@@ -711,6 +713,64 @@ def save_project_profile(key: str, name: str, path: str) -> None:
711
713
  f'path = "{_escape_toml_string(path)}"\n'
712
714
  )
713
715
 
716
+ if make_default:
717
+ updated = _with_default_project(updated, key)
718
+ tomllib.loads(updated)
719
+ config_path.write_text(updated, encoding="utf-8")
720
+
721
+
722
+ def _with_default_project(raw: str, key: str) -> str:
723
+ lines = raw.splitlines(keepends=True)
724
+ for index, line in enumerate(lines):
725
+ if line.lstrip().startswith("["):
726
+ break
727
+ if re.match(r"^\s*project\s*=", line):
728
+ lines[index] = f'project = "{_escape_toml_string(key)}"\n'
729
+ return "".join(lines)
730
+ return f'project = "{_escape_toml_string(key)}"\n' + raw
731
+
732
+
733
+ def update_project_profile(
734
+ key: str, name: str, path: str, *, make_default: bool = False
735
+ ) -> None:
736
+ if not name.strip() or not path.strip():
737
+ raise ValueError("Project name and path must not be empty.")
738
+ _ensure_config_exists()
739
+ config_path = _get_config_path()
740
+ raw = config_path.read_text(encoding="utf-8")
741
+ parsed = tomllib.loads(raw)
742
+ if key not in parsed.get("projects", {}):
743
+ raise ValueError(f"Project key '{key}' not found.")
744
+ lines = raw.splitlines(keepends=True)
745
+ start = None
746
+ end = len(lines)
747
+ for index, line in enumerate(lines):
748
+ if not line.lstrip().startswith("["):
749
+ continue
750
+ if start is not None:
751
+ end = index
752
+ break
753
+ try:
754
+ header = tomllib.loads(line.strip())
755
+ except tomllib.TOMLDecodeError:
756
+ continue
757
+ if header.get("projects", {}).get(key) == {}:
758
+ start = index
759
+ if start is None:
760
+ raise ValueError("Project table could not be updated.")
761
+ body = "".join(lines[start + 1:end])
762
+ for field, value in (("name", name), ("path", path)):
763
+ pattern = rf"(?m)^\s*{field}\s*=.*$"
764
+ replacement = f'{field} = "{_escape_toml_string(value)}"'
765
+ if re.search(pattern, body):
766
+ body = re.sub(pattern, lambda match: replacement, body)
767
+ else:
768
+ body = body.rstrip() + "\n" + replacement + "\n"
769
+ updated = "".join(lines[:start + 1]) + body + "".join(lines[end:])
770
+ tomllib.loads(updated)
771
+ if make_default:
772
+ updated = _with_default_project(updated, key)
773
+ tomllib.loads(updated)
714
774
  config_path.write_text(updated, encoding="utf-8")
715
775
 
716
776
 
@@ -725,7 +785,10 @@ def remove_project_profile(key: str) -> None:
725
785
  raise ValueError(f"Project key '{key}' not found.")
726
786
 
727
787
  if parsed.get("project") == key:
728
- raise ValueError(f"Cannot remove the active project '{key}'. Switch to another project first.")
788
+ raise ValueError(
789
+ f"Cannot remove the default project '{key}'. "
790
+ "Set another default project first."
791
+ )
729
792
 
730
793
  lines = raw.splitlines(keepends=True)
731
794
  result: list[str] = []
@@ -16,7 +16,14 @@ from codex_telegram import __version__
16
16
  from codex.collaboration_mode import codex_mode_name
17
17
  from models import state
18
18
  from models.user import user_manager
19
- from utils.config import get, get_web_password, reload, remove_project_profile, save_project_profile
19
+ from utils.config import (
20
+ get,
21
+ get_web_password,
22
+ reload,
23
+ remove_project_profile,
24
+ save_project_profile,
25
+ update_project_profile,
26
+ )
20
27
  from utils.local_command import resolve_command_cwd
21
28
  from utils.normalize import clamp_int, parse_bool
22
29
  from web.dependencies import (
@@ -322,10 +329,6 @@ def register_thread_routes(app: FastAPI) -> None:
322
329
  params: dict[str, Any] = {"limit": min(100, safe_limit + safe_offset)}
323
330
  if archived:
324
331
  params["archived"] = True
325
- result = await state.codex_client.call("thread/list", params)
326
- rows = result.get("data", [])
327
- if not isinstance(rows, list):
328
- rows = []
329
332
  state_user = user_manager.get(session.user_id)
330
333
  current_profile_key = project_key.strip()
331
334
  if not current_profile_key:
@@ -334,12 +337,30 @@ def register_thread_routes(app: FastAPI) -> None:
334
337
  default_key = get("project")
335
338
  if isinstance(default_key, str) and default_key.strip():
336
339
  current_profile_key = default_key.strip()
337
- if current_profile_key:
338
- rows = [
339
- thread
340
- for thread in rows
341
- if isinstance(thread, dict) and thread_profile_key(thread, current_profile_key) == current_profile_key
342
- ]
340
+ # Apply the limit to matching threads, rather than the global first page.
341
+ rows: list[dict[str, Any]] = []
342
+ seen_ids: set[str] = set()
343
+ seen_cursors: set[str] = set()
344
+ while len(rows) < safe_offset + safe_limit:
345
+ result = await state.codex_client.call("thread/list", dict(params))
346
+ page = result.get("data", [])
347
+ if not isinstance(page, list):
348
+ page = []
349
+ for thread in page:
350
+ if not isinstance(thread, dict):
351
+ continue
352
+ tid = thread.get("id")
353
+ if not isinstance(tid, str) or not tid or tid in seen_ids:
354
+ continue
355
+ seen_ids.add(tid)
356
+ if current_profile_key and thread_profile_key(thread, current_profile_key) != current_profile_key:
357
+ continue
358
+ rows.append(thread)
359
+ cursor = result.get("nextCursor")
360
+ if not isinstance(cursor, str) or not cursor or cursor in seen_cursors:
361
+ break
362
+ seen_cursors.add(cursor)
363
+ params["cursor"] = cursor
343
364
  if safe_offset:
344
365
  rows = rows[safe_offset:]
345
366
  rows = rows[:safe_limit]
@@ -692,7 +713,23 @@ def register_project_routes(app: FastAPI) -> None:
692
713
  name = _required_str(payload, "name")
693
714
  path = _required_str(payload, "path")
694
715
  try:
695
- save_project_profile(key, name, path)
716
+ save_project_profile(key, name, path, make_default=payload.get("default") is True)
717
+ reload()
718
+ except ValueError as exc:
719
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
720
+ return await route_command("/projects", ["--list"], session.user_id)
721
+
722
+ @app.put("/api/projects/{project_key}")
723
+ async def modify_project(
724
+ project_key: str, payload: dict[str, Any], request: Request
725
+ ) -> dict[str, Any]:
726
+ session = await session_from_request(request)
727
+ name = _required_str(payload, "name")
728
+ path = _required_str(payload, "path")
729
+ try:
730
+ update_project_profile(
731
+ project_key, name, path, make_default=payload.get("default") is True
732
+ )
696
733
  reload()
697
734
  except ValueError as exc:
698
735
  raise HTTPException(status_code=400, detail=str(exc)) from exc