agentbridge-cli 0.1.14__tar.gz → 0.1.16__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 (111) hide show
  1. {agentbridge_cli-0.1.14 → agentbridge_cli-0.1.16}/PKG-INFO +2 -1
  2. {agentbridge_cli-0.1.14 → agentbridge_cli-0.1.16}/README.md +1 -0
  3. agentbridge_cli-0.1.16/agentbridge/_build_info.py +1 -0
  4. {agentbridge_cli-0.1.14 → agentbridge_cli-0.1.16}/agentbridge/dashboard.py +12 -7
  5. {agentbridge_cli-0.1.14 → agentbridge_cli-0.1.16}/agentbridge/server.py +43 -70
  6. {agentbridge_cli-0.1.14 → agentbridge_cli-0.1.16}/agentbridge/templates/dashboard/chat.html +138 -40
  7. {agentbridge_cli-0.1.14 → agentbridge_cli-0.1.16}/agentbridge/templates/dashboard/page.html +75 -19
  8. {agentbridge_cli-0.1.14 → agentbridge_cli-0.1.16}/agentbridge/templates/dashboard/requests.html +12 -11
  9. {agentbridge_cli-0.1.14 → agentbridge_cli-0.1.16}/macos/Sources/AgentBridgeMenuBar/MenuBarStatusPresenter.swift +2 -17
  10. {agentbridge_cli-0.1.14 → agentbridge_cli-0.1.16}/macos/Tests/AgentBridgeMenuBarTests/StatusModelsTests.swift +20 -1
  11. {agentbridge_cli-0.1.14 → agentbridge_cli-0.1.16}/pyproject.toml +1 -1
  12. {agentbridge_cli-0.1.14 → agentbridge_cli-0.1.16}/tests/test_dashboard.py +134 -0
  13. {agentbridge_cli-0.1.14 → agentbridge_cli-0.1.16}/tests/test_server.py +21 -0
  14. {agentbridge_cli-0.1.14 → agentbridge_cli-0.1.16}/uv.lock +1 -1
  15. agentbridge_cli-0.1.14/agentbridge/_build_info.py +0 -1
  16. {agentbridge_cli-0.1.14 → agentbridge_cli-0.1.16}/.codex/config.toml +0 -0
  17. {agentbridge_cli-0.1.14 → agentbridge_cli-0.1.16}/.codex/skills/build-release/SKILL.md +0 -0
  18. {agentbridge_cli-0.1.14 → agentbridge_cli-0.1.16}/.codex/skills/build-release/agents/openai.yaml +0 -0
  19. {agentbridge_cli-0.1.14 → agentbridge_cli-0.1.16}/.codex/skills/build-release/scripts/release_build.py +0 -0
  20. {agentbridge_cli-0.1.14 → agentbridge_cli-0.1.16}/.env.example +0 -0
  21. {agentbridge_cli-0.1.14 → agentbridge_cli-0.1.16}/.github/dependabot.yml +0 -0
  22. {agentbridge_cli-0.1.14 → agentbridge_cli-0.1.16}/.github/workflows/ci.yml +0 -0
  23. {agentbridge_cli-0.1.14 → agentbridge_cli-0.1.16}/.github/workflows/dependency-review.yml +0 -0
  24. {agentbridge_cli-0.1.14 → agentbridge_cli-0.1.16}/.github/workflows/release.yml +0 -0
  25. {agentbridge_cli-0.1.14 → agentbridge_cli-0.1.16}/.gitignore +0 -0
  26. {agentbridge_cli-0.1.14 → agentbridge_cli-0.1.16}/AGENTS.md +0 -0
  27. {agentbridge_cli-0.1.14 → agentbridge_cli-0.1.16}/CLAUDE.md +0 -0
  28. {agentbridge_cli-0.1.14 → agentbridge_cli-0.1.16}/LICENSE +0 -0
  29. {agentbridge_cli-0.1.14 → agentbridge_cli-0.1.16}/Makefile +0 -0
  30. {agentbridge_cli-0.1.14 → agentbridge_cli-0.1.16}/TODO.txt +0 -0
  31. {agentbridge_cli-0.1.14 → agentbridge_cli-0.1.16}/agentbridge/__init__.py +0 -0
  32. {agentbridge_cli-0.1.14 → agentbridge_cli-0.1.16}/agentbridge/config.py +0 -0
  33. {agentbridge_cli-0.1.14 → agentbridge_cli-0.1.16}/agentbridge/models.py +0 -0
  34. {agentbridge_cli-0.1.14 → agentbridge_cli-0.1.16}/agentbridge/pool.py +0 -0
  35. {agentbridge_cli-0.1.14 → agentbridge_cli-0.1.16}/agentbridge/static/brand/android-chrome-192.png +0 -0
  36. {agentbridge_cli-0.1.14 → agentbridge_cli-0.1.16}/agentbridge/static/brand/android-chrome-512.png +0 -0
  37. {agentbridge_cli-0.1.14 → agentbridge_cli-0.1.16}/agentbridge/static/brand/apple-touch-icon.png +0 -0
  38. {agentbridge_cli-0.1.14 → agentbridge_cli-0.1.16}/agentbridge/static/brand/favicon-16.png +0 -0
  39. {agentbridge_cli-0.1.14 → agentbridge_cli-0.1.16}/agentbridge/static/brand/favicon-32.png +0 -0
  40. {agentbridge_cli-0.1.14 → agentbridge_cli-0.1.16}/agentbridge/static/brand/favicon-48.png +0 -0
  41. {agentbridge_cli-0.1.14 → agentbridge_cli-0.1.16}/agentbridge/static/brand/favicon.ico +0 -0
  42. {agentbridge_cli-0.1.14 → agentbridge_cli-0.1.16}/agentbridge/static/brand/favicon.svg +0 -0
  43. {agentbridge_cli-0.1.14 → agentbridge_cli-0.1.16}/agentbridge/static/brand/site.webmanifest +0 -0
  44. {agentbridge_cli-0.1.14 → agentbridge_cli-0.1.16}/agentbridge/templates/dashboard/base.html +0 -0
  45. {agentbridge_cli-0.1.14 → agentbridge_cli-0.1.16}/agentbridge/templates/dashboard/detail.html +0 -0
  46. {agentbridge_cli-0.1.14 → agentbridge_cli-0.1.16}/agentbridge/templates/dashboard/pool.html +0 -0
  47. {agentbridge_cli-0.1.14 → agentbridge_cli-0.1.16}/architecture.png +0 -0
  48. {agentbridge_cli-0.1.14 → agentbridge_cli-0.1.16}/design-qa/agentbridge-popover-final.png +0 -0
  49. {agentbridge_cli-0.1.14 → agentbridge_cli-0.1.16}/design-qa/agentbridge-popover-normalized.png +0 -0
  50. {agentbridge_cli-0.1.14 → agentbridge_cli-0.1.16}/design-qa/agentbridge-popover-pass-1.jpeg +0 -0
  51. {agentbridge_cli-0.1.14 → agentbridge_cli-0.1.16}/design-qa/implementation-header-activity.png +0 -0
  52. {agentbridge_cli-0.1.14 → agentbridge_cli-0.1.16}/design-qa/reference-header-activity.png +0 -0
  53. {agentbridge_cli-0.1.14 → agentbridge_cli-0.1.16}/design-qa/reference-inactive-menu-icons.png +0 -0
  54. {agentbridge_cli-0.1.14 → agentbridge_cli-0.1.16}/design-qa/reference-inactive-telegram-badge-normalized.png +0 -0
  55. {agentbridge_cli-0.1.14 → agentbridge_cli-0.1.16}/design-qa/reference-inactive-telegram-badge.png +0 -0
  56. {agentbridge_cli-0.1.14 → agentbridge_cli-0.1.16}/design-qa/reference-low-contrast-arrows-normalized.png +0 -0
  57. {agentbridge_cli-0.1.14 → agentbridge_cli-0.1.16}/design-qa/reference-low-contrast-arrows.png +0 -0
  58. {agentbridge_cli-0.1.14 → agentbridge_cli-0.1.16}/design-qa/reference-panel.png +0 -0
  59. {agentbridge_cli-0.1.14 → agentbridge_cli-0.1.16}/design-qa/reference-telegram-badge-normalized.png +0 -0
  60. {agentbridge_cli-0.1.14 → agentbridge_cli-0.1.16}/design-qa/reference-telegram-badge.png +0 -0
  61. {agentbridge_cli-0.1.14 → agentbridge_cli-0.1.16}/design-qa/status-item-active-workers.png +0 -0
  62. {agentbridge_cli-0.1.14 → agentbridge_cli-0.1.16}/design-qa/status-item-arrow-contrast-comparison.png +0 -0
  63. {agentbridge_cli-0.1.14 → agentbridge_cli-0.1.16}/design-qa/status-item-badge-comparison.png +0 -0
  64. {agentbridge_cli-0.1.14 → agentbridge_cli-0.1.16}/design-qa/status-item-inactive-comparison.png +0 -0
  65. {agentbridge_cli-0.1.14 → agentbridge_cli-0.1.16}/design-qa/status-item-no-workers.png +0 -0
  66. {agentbridge_cli-0.1.14 → agentbridge_cli-0.1.16}/design-qa.md +0 -0
  67. {agentbridge_cli-0.1.14 → agentbridge_cli-0.1.16}/docs/plans/2026-02-14-dashboard-design.md +0 -0
  68. {agentbridge_cli-0.1.14 → agentbridge_cli-0.1.16}/docs/plans/2026-02-14-dashboard-implementation.md +0 -0
  69. {agentbridge_cli-0.1.14 → agentbridge_cli-0.1.16}/hatch_build.py +0 -0
  70. {agentbridge_cli-0.1.14 → agentbridge_cli-0.1.16}/image-assets/apple-store/app-store-icon-1024.png +0 -0
  71. {agentbridge_cli-0.1.14 → agentbridge_cli-0.1.16}/image-assets/icon/icon-1024.png +0 -0
  72. {agentbridge_cli-0.1.14 → agentbridge_cli-0.1.16}/image-assets/logo/logo-1024.png +0 -0
  73. {agentbridge_cli-0.1.14 → agentbridge_cli-0.1.16}/image-assets/manifest.json +0 -0
  74. {agentbridge_cli-0.1.14 → agentbridge_cli-0.1.16}/image-assets/sources/icon-source-keyed.png +0 -0
  75. {agentbridge_cli-0.1.14 → agentbridge_cli-0.1.16}/image-assets/sources/icon-source.png +0 -0
  76. {agentbridge_cli-0.1.14 → agentbridge_cli-0.1.16}/image-assets/sources/logo-source-keyed.png +0 -0
  77. {agentbridge_cli-0.1.14 → agentbridge_cli-0.1.16}/image-assets/sources/logo-source.png +0 -0
  78. {agentbridge_cli-0.1.14 → agentbridge_cli-0.1.16}/image-assets/sources/social-background-source.png +0 -0
  79. {agentbridge_cli-0.1.14 → agentbridge_cli-0.1.16}/image-assets/sources/social-source.png +0 -0
  80. {agentbridge_cli-0.1.14 → agentbridge_cli-0.1.16}/image-assets/web-seo/android-chrome-192.png +0 -0
  81. {agentbridge_cli-0.1.14 → agentbridge_cli-0.1.16}/image-assets/web-seo/android-chrome-512.png +0 -0
  82. {agentbridge_cli-0.1.14 → agentbridge_cli-0.1.16}/image-assets/web-seo/apple-touch-icon.png +0 -0
  83. {agentbridge_cli-0.1.14 → agentbridge_cli-0.1.16}/image-assets/web-seo/favicon/favicon-16.png +0 -0
  84. {agentbridge_cli-0.1.14 → agentbridge_cli-0.1.16}/image-assets/web-seo/favicon/favicon-32.png +0 -0
  85. {agentbridge_cli-0.1.14 → agentbridge_cli-0.1.16}/image-assets/web-seo/favicon/favicon-48.png +0 -0
  86. {agentbridge_cli-0.1.14 → agentbridge_cli-0.1.16}/image-assets/web-seo/favicon/favicon.ico +0 -0
  87. {agentbridge_cli-0.1.14 → agentbridge_cli-0.1.16}/image-assets/web-seo/favicon/favicon.svg +0 -0
  88. {agentbridge_cli-0.1.14 → agentbridge_cli-0.1.16}/image-assets/web-seo/og-image-1200x630.png +0 -0
  89. {agentbridge_cli-0.1.14 → agentbridge_cli-0.1.16}/image-assets/web-seo/site.webmanifest +0 -0
  90. {agentbridge_cli-0.1.14 → agentbridge_cli-0.1.16}/logo.png +0 -0
  91. {agentbridge_cli-0.1.14 → agentbridge_cli-0.1.16}/macos/Package.swift +0 -0
  92. {agentbridge_cli-0.1.14 → agentbridge_cli-0.1.16}/macos/README.md +0 -0
  93. {agentbridge_cli-0.1.14 → agentbridge_cli-0.1.16}/macos/Sources/AgentBridgeMenuBar/AgentBridgeMenuBarApp.swift +0 -0
  94. {agentbridge_cli-0.1.14 → agentbridge_cli-0.1.16}/macos/Sources/AgentBridgeMenuBar/BrandMark.swift +0 -0
  95. {agentbridge_cli-0.1.14 → agentbridge_cli-0.1.16}/macos/Sources/AgentBridgeMenuBar/HealthClient.swift +0 -0
  96. {agentbridge_cli-0.1.14 → agentbridge_cli-0.1.16}/macos/Sources/AgentBridgeMenuBar/ServerController.swift +0 -0
  97. {agentbridge_cli-0.1.14 → agentbridge_cli-0.1.16}/macos/Sources/AgentBridgeMenuBar/ServerLauncher.swift +0 -0
  98. {agentbridge_cli-0.1.14 → agentbridge_cli-0.1.16}/macos/Sources/AgentBridgeMenuBar/SettingsView.swift +0 -0
  99. {agentbridge_cli-0.1.14 → agentbridge_cli-0.1.16}/macos/Sources/AgentBridgeMenuBar/StatusModels.swift +0 -0
  100. {agentbridge_cli-0.1.14 → agentbridge_cli-0.1.16}/macos/Sources/AgentBridgeMenuBar/StatusPopover.swift +0 -0
  101. {agentbridge_cli-0.1.14 → agentbridge_cli-0.1.16}/scripts/build_macos_app.sh +0 -0
  102. {agentbridge_cli-0.1.14 → agentbridge_cli-0.1.16}/scripts/dashboard_load_test.py +0 -0
  103. {agentbridge_cli-0.1.14 → agentbridge_cli-0.1.16}/scripts/verify_macos_app.sh +0 -0
  104. {agentbridge_cli-0.1.14 → agentbridge_cli-0.1.16}/tests/fixtures/ocr_test_document.png +0 -0
  105. {agentbridge_cli-0.1.14 → agentbridge_cli-0.1.16}/tests/test_config.py +0 -0
  106. {agentbridge_cli-0.1.14 → agentbridge_cli-0.1.16}/tests/test_dependency_security.py +0 -0
  107. {agentbridge_cli-0.1.14 → agentbridge_cli-0.1.16}/tests/test_image_utils.py +0 -0
  108. {agentbridge_cli-0.1.14 → agentbridge_cli-0.1.16}/tests/test_model_mapping.py +0 -0
  109. {agentbridge_cli-0.1.14 → agentbridge_cli-0.1.16}/tests/test_models.py +0 -0
  110. {agentbridge_cli-0.1.14 → agentbridge_cli-0.1.16}/tests/test_pool.py +0 -0
  111. {agentbridge_cli-0.1.14 → agentbridge_cli-0.1.16}/tests/test_session_logger.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: agentbridge-cli
3
- Version: 0.1.14
3
+ Version: 0.1.16
4
4
  Summary: Bridge OpenAI tools to Claude Code SDK, Codex CLI, and OpenRouter
5
5
  Project-URL: Homepage, https://github.com/tsilva/agentbridge
6
6
  Project-URL: Repository, https://github.com/tsilva/agentbridge
@@ -177,6 +177,7 @@ uv build # build wheel and source d
177
177
  - Claude clients are created lazily, reused by model, and capped by the worker count. Claude sessions do not load filesystem settings and run with built-in tools disabled.
178
178
  - Codex runs one ephemeral `codex exec` process per request in a temporary directory with read-only sandboxing, no approvals, and project rules ignored. Multimodal structured-output calls also ignore user config and disable execution and image-generation tools. Native image calls use the same strict profile, keep execution disabled, and enable the image-generation capability needed for the edit.
179
179
  - Claude and Codex function calls are represented through prompted JSON; OpenRouter tool calls pass through its SDK. Session logs and extracted image or PDF attachments are saved under `~/.config/agentbridge/logs/sessions` by default.
180
+ - Streaming provider failures emit an OpenAI-shaped `error` object as an SSE `data` event before `[DONE]`; they are not returned as assistant message text.
180
181
  - Set `store: false` on chat requests to suppress session-log artifacts. The native image route requires `store: false`, accepts data URLs only, validates both rasters, locates the result from the structured Codex thread ID, and removes that thread's generated-image directory after the request.
181
182
 
182
183
  ## Publishing
@@ -145,6 +145,7 @@ uv build # build wheel and source d
145
145
  - Claude clients are created lazily, reused by model, and capped by the worker count. Claude sessions do not load filesystem settings and run with built-in tools disabled.
146
146
  - Codex runs one ephemeral `codex exec` process per request in a temporary directory with read-only sandboxing, no approvals, and project rules ignored. Multimodal structured-output calls also ignore user config and disable execution and image-generation tools. Native image calls use the same strict profile, keep execution disabled, and enable the image-generation capability needed for the edit.
147
147
  - Claude and Codex function calls are represented through prompted JSON; OpenRouter tool calls pass through its SDK. Session logs and extracted image or PDF attachments are saved under `~/.config/agentbridge/logs/sessions` by default.
148
+ - Streaming provider failures emit an OpenAI-shaped `error` object as an SSE `data` event before `[DONE]`; they are not returned as assistant message text.
148
149
  - Set `store: false` on chat requests to suppress session-log artifacts. The native image route requires `store: false`, accepts data URLs only, validates both rasters, locates the result from the structured Codex thread ID, and removes that thread's generated-image directory after the request.
149
150
 
150
151
  ## Publishing
@@ -0,0 +1 @@
1
+ GIT_HASH = "4d5ec6b"
@@ -225,6 +225,8 @@ def _parse_log_file(path: Path) -> dict | None:
225
225
 
226
226
  def _get_recent_logs(limit: int = 20) -> list[dict]:
227
227
  """Read recent session log files, newest first."""
228
+ if limit <= 0:
229
+ return []
228
230
  log_dir = session_log_dir()
229
231
  if not log_dir.exists():
230
232
  return []
@@ -242,10 +244,12 @@ def _get_recent_logs(limit: int = 20) -> list[dict]:
242
244
  )
243
245
 
244
246
  results = []
245
- for path in log_files[:limit]:
247
+ for path in log_files:
246
248
  parsed = _parse_log_file(path)
247
249
  if parsed is not None:
248
250
  results.append(parsed)
251
+ if len(results) >= limit:
252
+ break
249
253
 
250
254
  return results
251
255
 
@@ -366,10 +370,11 @@ def create_dashboard_router(
366
370
  if log.get("duration_ms") is None:
367
371
  log["duration_ms"] = timing.get("duration_ms", 0)
368
372
  if log.get("input_tokens") is None:
369
- usage_data = log.get("usage")
370
- if usage_data:
371
- log["input_tokens"] = usage_data.get("input_tokens")
372
- log["output_tokens"] = usage_data.get("output_tokens")
373
+ usage_data = (
374
+ log.get("usage") if isinstance(log.get("usage"), dict) else {}
375
+ )
376
+ log["input_tokens"] = usage_data.get("input_tokens")
377
+ log["output_tokens"] = usage_data.get("output_tokens")
373
378
 
374
379
  merged = active + completed
375
380
  return merged[:limit]
@@ -441,8 +446,8 @@ def create_dashboard_router(
441
446
  if parsed is None:
442
447
  raise HTTPException(status_code=404, detail="Request not found")
443
448
 
444
- timing = parsed.get("timing", {})
445
- usage = parsed.get("usage", {})
449
+ timing = parsed.get("timing") if isinstance(parsed.get("timing"), dict) else {}
450
+ usage = parsed.get("usage") if isinstance(parsed.get("usage"), dict) else {}
446
451
  return templates.TemplateResponse(
447
452
  request,
448
453
  "detail.html",
@@ -686,6 +686,23 @@ OPENROUTER_TIMEOUT = int(
686
686
  )
687
687
 
688
688
 
689
+ def _stream_error_event(
690
+ message: str,
691
+ *,
692
+ error_type: str = "server_error",
693
+ code: str | None = None,
694
+ ) -> str:
695
+ """Return a machine-readable OpenAI-shaped error in an SSE data event."""
696
+ payload = ErrorResponse(
697
+ error=ErrorDetail(
698
+ message=message,
699
+ type=error_type,
700
+ code=code,
701
+ )
702
+ )
703
+ return f"data: {payload.model_dump_json()}\n\n"
704
+
705
+
689
706
  class BridgeHTTPException(HTTPException):
690
707
  """HTTPException with request_id for error tracing."""
691
708
 
@@ -1863,18 +1880,13 @@ async def stream_openrouter_api(
1863
1880
  )
1864
1881
  dashboard_state.request_errored(request_id, f"{type(e).__name__}: {error}")
1865
1882
  _dashboard_handled = True
1866
- error_chunk = ChatCompletionChunk(
1867
- id=request_id,
1868
- created=created,
1869
- model=request.model,
1870
- choices=[
1871
- StreamChoice(
1872
- delta=DeltaMessage(content="\n\n[Error: OpenRouter request failed.]"),
1873
- finish_reason=None,
1874
- )
1875
- ],
1883
+ yield _stream_error_event(
1884
+ "OpenRouter request timed out."
1885
+ if isinstance(e, asyncio.TimeoutError)
1886
+ else "OpenRouter request failed.",
1887
+ error_type="timeout_error" if isinstance(e, asyncio.TimeoutError) else "server_error",
1888
+ code="timeout_error" if isinstance(e, asyncio.TimeoutError) else "provider_error",
1876
1889
  )
1877
- yield f"data: {error_chunk.model_dump_json()}\n\n"
1878
1890
  yield "data: [DONE]\n\n"
1879
1891
  finally:
1880
1892
  if not _dashboard_handled:
@@ -2186,23 +2198,11 @@ async def stream_claude_sdk(
2186
2198
  )
2187
2199
  dashboard_state.request_errored(request_id, f"Timeout after {CLAUDE_TIMEOUT}s")
2188
2200
  _dashboard_handled = True
2189
- error_chunk = ChatCompletionChunk(
2190
- id=request_id,
2191
- created=created,
2192
- model=model,
2193
- choices=[
2194
- StreamChoice(
2195
- delta=DeltaMessage(
2196
- content=(
2197
- "\n\n[Error: Request timed out. Increase CLAUDE_TIMEOUT "
2198
- "env var for longer requests.]"
2199
- )
2200
- ),
2201
- finish_reason=None,
2202
- )
2203
- ],
2201
+ yield _stream_error_event(
2202
+ "Request timed out. Increase CLAUDE_TIMEOUT env var for longer requests.",
2203
+ error_type="timeout_error",
2204
+ code="timeout_error",
2204
2205
  )
2205
- yield f"data: {error_chunk.model_dump_json()}\n\n"
2206
2206
  yield "data: [DONE]\n\n"
2207
2207
  return
2208
2208
  except HTTPException as he:
@@ -2213,16 +2213,11 @@ async def stream_claude_sdk(
2213
2213
  )
2214
2214
  dashboard_state.request_errored(request_id, f"HTTP {he.status_code}: {he.detail}")
2215
2215
  _dashboard_handled = True
2216
- error_chunk = ChatCompletionChunk(
2217
- id=request_id,
2218
- created=created,
2219
- model=model,
2220
- choices=[StreamChoice(
2221
- delta=DeltaMessage(content=f"\n\n[Error: {he.detail}]"),
2222
- finish_reason=None,
2223
- )],
2216
+ yield _stream_error_event(
2217
+ str(he.detail),
2218
+ error_type="invalid_request_error" if he.status_code < 500 else "server_error",
2219
+ code="invalid_request_error" if he.status_code < 500 else "server_error",
2224
2220
  )
2225
- yield f"data: {error_chunk.model_dump_json()}\n\n"
2226
2221
  yield "data: [DONE]\n\n"
2227
2222
  return
2228
2223
  except Exception as e:
@@ -2237,16 +2232,11 @@ async def stream_claude_sdk(
2237
2232
  )
2238
2233
  dashboard_state.request_errored(request_id, f"{type(e).__name__}: {e}")
2239
2234
  _dashboard_handled = True
2240
- error_chunk = ChatCompletionChunk(
2241
- id=request_id,
2242
- created=created,
2243
- model=model,
2244
- choices=[StreamChoice(
2245
- delta=DeltaMessage(content="\n\n[Error: An internal error occurred.]"),
2246
- finish_reason=None,
2247
- )],
2235
+ yield _stream_error_event(
2236
+ "An internal error occurred.",
2237
+ error_type="server_error",
2238
+ code="server_error",
2248
2239
  )
2249
- yield f"data: {error_chunk.model_dump_json()}\n\n"
2250
2240
  yield "data: [DONE]\n\n"
2251
2241
  return
2252
2242
  finally:
@@ -2482,23 +2472,11 @@ async def stream_codex_cli(
2482
2472
  )
2483
2473
  dashboard_state.request_errored(request_id, f"Timeout after {CODEX_TIMEOUT}s")
2484
2474
  _dashboard_handled = True
2485
- error_chunk = ChatCompletionChunk(
2486
- id=request_id,
2487
- created=created,
2488
- model=model,
2489
- choices=[
2490
- StreamChoice(
2491
- delta=DeltaMessage(
2492
- content=(
2493
- "\n\n[Error: Request timed out. Increase CODEX_TIMEOUT "
2494
- "env var for longer requests.]"
2495
- )
2496
- ),
2497
- finish_reason=None,
2498
- )
2499
- ],
2475
+ yield _stream_error_event(
2476
+ "Request timed out. Increase CODEX_TIMEOUT env var for longer requests.",
2477
+ error_type="timeout_error",
2478
+ code="timeout_error",
2500
2479
  )
2501
- yield f"data: {error_chunk.model_dump_json()}\n\n"
2502
2480
  yield "data: [DONE]\n\n"
2503
2481
  return
2504
2482
  except Exception as e:
@@ -2514,16 +2492,11 @@ async def stream_codex_cli(
2514
2492
  )
2515
2493
  dashboard_state.request_errored(request_id, f"{type(e).__name__}: {e}")
2516
2494
  _dashboard_handled = True
2517
- error_chunk = ChatCompletionChunk(
2518
- id=request_id,
2519
- created=created,
2520
- model=model,
2521
- choices=[StreamChoice(
2522
- delta=DeltaMessage(content="\n\n[Error: An internal error occurred.]"),
2523
- finish_reason=None,
2524
- )],
2495
+ yield _stream_error_event(
2496
+ "An internal error occurred.",
2497
+ error_type="server_error",
2498
+ code="server_error",
2525
2499
  )
2526
- yield f"data: {error_chunk.model_dump_json()}\n\n"
2527
2500
  yield "data: [DONE]\n\n"
2528
2501
  return
2529
2502
  finally:
@@ -642,14 +642,14 @@
642
642
  </svg>
643
643
  </button>
644
644
  <textarea id="prompt" placeholder="Send a test message..." rows="1"></textarea>
645
- <button id="send" class="primary" type="button" aria-label="Send message" title="Send message">
645
+ <button id="send" class="primary" type="button" disabled aria-label="Send message" title="Send message">
646
646
  <svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
647
647
  <path d="m5 12 7-7 7 7"></path>
648
648
  <path d="M12 19V5"></path>
649
649
  </svg>
650
650
  </button>
651
651
  </div>
652
- <input id="file-input" class="hidden-input" type="file" multiple accept="image/*,application/pdf,text/plain,.txt">
652
+ <input id="file-input" class="hidden-input" type="file" tabindex="-1" aria-hidden="true" multiple accept="image/*,application/pdf,text/plain,.txt">
653
653
  </section>
654
654
  </main>
655
655
  {% endblock %}
@@ -670,6 +670,7 @@
670
670
  var attachedFiles = [];
671
671
  var conversationMessages = [];
672
672
  var renderedMessages = [];
673
+ var isSending = false;
673
674
  var chatStateKey = "agentbridge.chat.state.v1";
674
675
 
675
676
  function endpoint(path) {
@@ -690,6 +691,10 @@
690
691
  promptEl.style.overflowY = promptEl.scrollHeight > maxHeight ? "auto" : "hidden";
691
692
  }
692
693
 
694
+ function updateSendAvailability() {
695
+ sendEl.disabled = isSending || (!promptEl.value.trim() && !attachedFiles.length);
696
+ }
697
+
693
698
  function clearEmptyState() {
694
699
  var empty = messagesEl.querySelector(".empty");
695
700
  if (empty) empty.remove();
@@ -839,9 +844,26 @@
839
844
  target.innerHTML = '<span class="typing-indicator" aria-hidden="true"><span></span><span></span><span></span></span>';
840
845
  }
841
846
 
847
+ function removeRenderedMessage(article) {
848
+ if (!article) return;
849
+ if (article.dataset.chatIndex === undefined) {
850
+ article.remove();
851
+ return;
852
+ }
853
+ var index = Number(article.dataset.chatIndex);
854
+ if (Number.isInteger(index) && index >= 0 && index < renderedMessages.length) {
855
+ renderedMessages.splice(index, 1);
856
+ }
857
+ article.remove();
858
+ messagesEl.querySelectorAll(".message[data-chat-index]").forEach(function(message, nextIndex) {
859
+ message.dataset.chatIndex = String(nextIndex);
860
+ });
861
+ saveChatState();
862
+ }
863
+
842
864
  function removeMessageBody(target) {
843
865
  var article = target && target.closest ? target.closest(".message") : null;
844
- if (article) article.remove();
866
+ removeRenderedMessage(article);
845
867
  }
846
868
 
847
869
  function messageFileNames(files) {
@@ -993,6 +1015,25 @@
993
1015
  if (hasSavedModel) modelEl.value = state.model;
994
1016
  }
995
1017
  state.renderedMessages.forEach(function(message) {
1018
+ if (message.role === "error") {
1019
+ var errorArticle = appendError(
1020
+ message.status || 0,
1021
+ message.statusText || "Request failed",
1022
+ message.body || null,
1023
+ message.requestId || null,
1024
+ null,
1025
+ { persist: false }
1026
+ );
1027
+ renderedMessages.push({
1028
+ role: "error",
1029
+ status: message.status || 0,
1030
+ statusText: message.statusText || "Request failed",
1031
+ body: message.body || null,
1032
+ requestId: message.requestId || null
1033
+ });
1034
+ errorArticle.dataset.chatIndex = String(renderedMessages.length - 1);
1035
+ return;
1036
+ }
996
1037
  var body = appendMessage(
997
1038
  message.role,
998
1039
  message.text || "",
@@ -1032,7 +1073,7 @@
1032
1073
  function summarizeError(status, statusText, body, requestId) {
1033
1074
  var err = body && body.error ? body.error : null;
1034
1075
  var title = "Server error";
1035
- var summary = status + (statusText ? " " + statusText : "");
1076
+ var summary = status ? String(status) + (statusText ? " " + statusText : "") : (statusText || "Request failed");
1036
1077
  if (err && err.message) summary = summary + ": " + err.message;
1037
1078
  return {
1038
1079
  title: title,
@@ -1046,7 +1087,8 @@
1046
1087
  };
1047
1088
  }
1048
1089
 
1049
- function appendError(status, statusText, body, requestId) {
1090
+ function appendError(status, statusText, body, requestId, retryAction, options) {
1091
+ options = options || {};
1050
1092
  clearEmptyState();
1051
1093
  var info = summarizeError(status, statusText, body, requestId);
1052
1094
  var article = document.createElement("article");
@@ -1073,12 +1115,6 @@
1073
1115
  var actions = document.createElement("div");
1074
1116
  actions.className = "error-actions";
1075
1117
 
1076
- var retry = document.createElement("button");
1077
- retry.className = "secondary";
1078
- retry.type = "button";
1079
- retry.textContent = "Retry";
1080
- retry.addEventListener("click", sendMessage);
1081
-
1082
1118
  var copy = document.createElement("button");
1083
1119
  copy.className = "secondary";
1084
1120
  copy.type = "button";
@@ -1087,16 +1123,38 @@
1087
1123
  navigator.clipboard.writeText(compactJson(info.details));
1088
1124
  });
1089
1125
 
1090
- actions.appendChild(retry);
1126
+ if (retryAction) {
1127
+ var retry = document.createElement("button");
1128
+ retry.className = "secondary";
1129
+ retry.type = "button";
1130
+ retry.textContent = "Retry";
1131
+ retry.addEventListener("click", function() {
1132
+ removeRenderedMessage(article);
1133
+ retryAction();
1134
+ });
1135
+ actions.appendChild(retry);
1136
+ }
1091
1137
  actions.appendChild(copy);
1092
1138
 
1093
1139
  article.appendChild(title);
1094
1140
  article.appendChild(summary);
1095
1141
  article.appendChild(details);
1096
1142
  article.appendChild(actions);
1143
+ if (options.persist !== false) {
1144
+ article.dataset.chatIndex = String(renderedMessages.length);
1145
+ renderedMessages.push({
1146
+ role: "error",
1147
+ status: status,
1148
+ statusText: statusText || null,
1149
+ body: body || null,
1150
+ requestId: requestId || null
1151
+ });
1152
+ }
1097
1153
  messagesEl.appendChild(article);
1098
1154
  messagesEl.scrollTop = messagesEl.scrollHeight;
1099
1155
  setStatus("error", "Error");
1156
+ if (options.persist !== false) saveChatState();
1157
+ return article;
1100
1158
  }
1101
1159
 
1102
1160
  function filePill(fileName, removable, index) {
@@ -1111,8 +1169,9 @@
1111
1169
  var remove = document.createElement("button");
1112
1170
  remove.className = "remove-file";
1113
1171
  remove.type = "button";
1114
- remove.textContent = "x";
1172
+ remove.textContent = "×";
1115
1173
  remove.title = "Remove file";
1174
+ remove.setAttribute("aria-label", "Remove " + fileName);
1116
1175
  remove.addEventListener("click", function() {
1117
1176
  attachedFiles.splice(index, 1);
1118
1177
  renderAttachedFiles();
@@ -1127,6 +1186,7 @@
1127
1186
  attachedFiles.forEach(function(file, index) {
1128
1187
  attachedFilesEl.appendChild(filePill(file.name, true, index));
1129
1188
  });
1189
+ updateSendAvailability();
1130
1190
  }
1131
1191
 
1132
1192
  function isTextFile(file) {
@@ -1237,12 +1297,19 @@
1237
1297
  if (data === "[DONE]") return;
1238
1298
  try {
1239
1299
  var chunk = JSON.parse(data);
1300
+ if (chunk.error) {
1301
+ var error = new Error(chunk.error.message || "Streaming request failed");
1302
+ error.type = "stream_error";
1303
+ error.details = chunk;
1304
+ throw error;
1305
+ }
1240
1306
  var choice = chunk.choices && chunk.choices[0];
1241
1307
  var delta = choice && choice.delta && choice.delta.content;
1242
1308
  if (delta) {
1243
1309
  appendAssistantDelta(target, delta);
1244
1310
  }
1245
1311
  } catch (e) {
1312
+ if (e.type === "stream_error") throw e;
1246
1313
  appendAssistantDelta(target, data);
1247
1314
  }
1248
1315
  });
@@ -1251,31 +1318,13 @@
1251
1318
  return target.dataset.rawMarkdown || "";
1252
1319
  }
1253
1320
 
1254
- async function sendMessage() {
1255
- var text = promptEl.value.trim();
1256
- if (!text && !attachedFiles.length) return;
1257
- var assistantTarget = null;
1258
-
1259
- sendEl.disabled = true;
1321
+ async function submitPreparedMessage(payload, userMessage, userTarget) {
1322
+ isSending = true;
1323
+ updateSendAvailability();
1260
1324
  setStatus(null, "Sending");
1325
+ var assistantTarget = appendMessage("assistant", "", null, { model: payload.model, loading: true });
1261
1326
 
1262
1327
  try {
1263
- var content = await buildUserContent(text);
1264
- var filesForMessage = attachedFiles.slice();
1265
- var userMessage = { role: "user", content: content };
1266
- var payload = {
1267
- model: modelEl.value,
1268
- messages: conversationMessages.concat([userMessage]),
1269
- stream: true
1270
- };
1271
-
1272
- var userTarget = appendMessage("user", displayTextForMessage(text, filesForMessage), filesForMessage);
1273
- assistantTarget = appendMessage("assistant", "", null, { model: payload.model, loading: true });
1274
- promptEl.value = "";
1275
- autosizePrompt();
1276
- attachedFiles = [];
1277
- renderAttachedFiles();
1278
-
1279
1328
  var response = await fetch(endpoint("/chat/completions"), {
1280
1329
  method: "POST",
1281
1330
  headers: { "Content-Type": "application/json" },
@@ -1287,7 +1336,13 @@
1287
1336
 
1288
1337
  if (!response.ok) {
1289
1338
  removeMessageBody(assistantTarget);
1290
- appendError(response.status, response.statusText, await parseResponseBody(response), requestId);
1339
+ appendError(
1340
+ response.status,
1341
+ response.statusText,
1342
+ await parseResponseBody(response),
1343
+ requestId,
1344
+ function() { submitPreparedMessage(payload, userMessage, userTarget); }
1345
+ );
1291
1346
  return;
1292
1347
  }
1293
1348
 
@@ -1299,15 +1354,54 @@
1299
1354
  saveChatState();
1300
1355
  } catch (error) {
1301
1356
  removeMessageBody(assistantTarget);
1302
- appendError(0, "Request failed", {
1357
+ appendError(0, error.type === "stream_error" ? "Stream failed" : "Request failed", error.details || {
1303
1358
  error: {
1304
1359
  message: error && error.message ? error.message : String(error),
1305
1360
  type: "network_error",
1306
1361
  code: "request_failed"
1307
1362
  }
1308
- }, null);
1363
+ }, null, function() { submitPreparedMessage(payload, userMessage, userTarget); });
1309
1364
  } finally {
1310
- sendEl.disabled = false;
1365
+ isSending = false;
1366
+ updateSendAvailability();
1367
+ }
1368
+ }
1369
+
1370
+ async function sendMessage() {
1371
+ var text = promptEl.value.trim();
1372
+ if (!text && !attachedFiles.length) return;
1373
+
1374
+ isSending = true;
1375
+ updateSendAvailability();
1376
+ setStatus(null, "Sending");
1377
+
1378
+ try {
1379
+ var content = await buildUserContent(text);
1380
+ var filesForMessage = attachedFiles.slice();
1381
+ var userMessage = { role: "user", content: content };
1382
+ var payload = {
1383
+ model: modelEl.value,
1384
+ messages: conversationMessages.concat([userMessage]),
1385
+ stream: true
1386
+ };
1387
+ var userTarget = appendMessage("user", displayTextForMessage(text, filesForMessage), filesForMessage);
1388
+
1389
+ promptEl.value = "";
1390
+ autosizePrompt();
1391
+ attachedFiles = [];
1392
+ renderAttachedFiles();
1393
+
1394
+ await submitPreparedMessage(payload, userMessage, userTarget);
1395
+ } catch (error) {
1396
+ appendError(0, "Request failed", {
1397
+ error: {
1398
+ message: error && error.message ? error.message : String(error),
1399
+ type: "request_preparation_error",
1400
+ code: "request_preparation_failed"
1401
+ }
1402
+ }, null, sendMessage);
1403
+ isSending = false;
1404
+ updateSendAvailability();
1311
1405
  }
1312
1406
  }
1313
1407
 
@@ -1329,7 +1423,10 @@
1329
1423
  });
1330
1424
  sendEl.addEventListener("click", sendMessage);
1331
1425
  modelEl.addEventListener("change", saveChatState);
1332
- promptEl.addEventListener("input", autosizePrompt);
1426
+ promptEl.addEventListener("input", function() {
1427
+ autosizePrompt();
1428
+ updateSendAvailability();
1429
+ });
1333
1430
  promptEl.addEventListener("keydown", function(e) {
1334
1431
  if (e.key !== "Enter" || e.isComposing) return;
1335
1432
  if (e.altKey) return;
@@ -1356,6 +1453,7 @@
1356
1453
  restoreChatState();
1357
1454
  checkHealth();
1358
1455
  autosizePrompt();
1456
+ updateSendAvailability();
1359
1457
  })();
1360
1458
  </script>
1361
1459
  {% endblock %}